Aujourd'hui, nous avons découvert la cause d'un vilain bug qui ne s'est produit que par intermittence sur certaines plates-formes. En résumé, notre code ressemblait à ceci:
class Foo {
map<string,string> m;
void A(const string& key) {
m.erase(key);
cout << "Erased: " << key; // oops
}
void B() {
while (!m.empty()) {
auto toDelete = m.begin();
A(toDelete->first);
}
}
}
Le problème peut sembler évident dans ce cas simplifié: B
passe une référence à la clé à A
, qui supprime l’entrée de la carte avant de tenter de l’imprimer. (Dans notre cas, il n'a pas été imprimé, mais utilisé d'une manière plus compliquée.) Il s'agit bien sûr d'un comportement indéfini, car il key
s'agit d'une référence suspendue après l'appel à erase
.
La résolution de ce problème était triviale - nous venons de changer le type de paramètre de const string&
à string
. La question est: comment aurions-nous pu éviter ce bug en premier lieu? Il semble que les deux fonctions ont fait le bon choix:
A
n'a aucun moyen de savoir quikey
fait référence à la chose qu'il est sur le point de détruire.B
aurait pu en faire une copie avant de la transmettre àA
, mais l’appelé n’est-il pas de décider des paramètres à prendre par valeur ou par référence?
Y a-t-il une règle que nous n'avons pas suivie?
Je dirais que oui, vous avez enfreint une règle assez simple qui vous aurait sauvé: le principe de la responsabilité unique.
À l'heure actuelle,
A
un paramètre est utilisé pour supprimer un élément d'une carte et en effectuer un autre (traitement comme indiqué ci-dessus, apparemment quelque chose d'autre dans le code réel). Combiner ces responsabilités me semble être en grande partie la source du problème.Si nous avons une fonction qui vient supprime la valeur de la carte, et un autre qui vient ne le traitement d'une valeur de la carte, nous aurions dû appeler chacun à partir du code de niveau supérieur, donc nous finirions avec quelque chose comme ça :
Certes, les noms que j'ai utilisés rendent sans aucun doute le problème plus évident que les vrais noms, mais si les noms ont un sens, ils sont presque sûrs de préciser que nous essayons de continuer à utiliser la référence après été invalidé. Le simple changement de contexte rend le problème beaucoup plus évident.
la source
A
effectivement recherchékey
dans deux cartes différentes et, si trouvé, supprimé les entrées plus un nettoyage supplémentaire. Donc, il n'est pas clair que notreA
PÉR violé. Je me demande si je devrais mettre à jour la question à ce stade.m.erase(key)
a la première responsabilité etcout << "Erased: " << key
la deuxième responsabilité, de sorte que la structure du code présenté dans cette réponse n'est en réalité pas différente de la structure du code de l'exemple, Dans le monde réel, le problème était négligé. Le principe de responsabilité unique ne garantit en rien, ni même ne le rend plus probable, que des séquences contradictoires d'actions uniques apparaîtront à proximité dans le code du monde réel.Oui, vous avez échoué à documenter la fonction .
Sans une description du contrat de passage de paramètre (en particulier la partie relative à la validité du paramètre - que ce soit au début de l'appel de fonction ou pendant toute la durée), il est impossible de savoir si l'erreur est dans l'implémentation (si le contrat d'appel est que le paramètre est valide au début de l'appel, la fonction doit en faire une copie avant d'effectuer toute action susceptible d'invalider le paramètre) ou dans l'appelant (si le contrat d'appel stipule que le paramètre doit rester valide tout au long de l'appel, l'appelant ne peut pas transmettre une référence aux données de la collection en cours de modification).
Par exemple, le standard C ++ lui-même spécifie que:
mais il ne spécifie pas si cela s'applique uniquement à l'instant où l'appel est effectué ou pendant l'exécution de la fonction. Cependant, dans de nombreux cas, il est clair que seule cette dernière solution est possible - à savoir lorsque l'argument ne peut être maintenu en faisant une copie.
Il existe de nombreux cas réels où cette distinction entre en jeu. Par exemple, ajouter un
std::vector<T>
à lui-mêmela source
Oui, vous avez échoué à le tester correctement. Vous n'êtes pas seul et vous êtes au bon endroit pour apprendre :)
C ++ a beaucoup de comportement indéfini, comportement indéfini se manifeste de manière subtile et ennuyeuse.
Vous ne pourrez probablement jamais écrire du code C ++ sûr à 100%, mais vous pouvez certainement diminuer la probabilité d'introduire accidentellement Undefined Behavior dans votre base de code en utilisant un certain nombre d'outils.
Dans votre cas, je doute que (1) et (2) auraient beaucoup aidé, même si, en général, je conseille de les utiliser. Pour l'instant, concentrons-nous sur les deux autres.
Gcc et Clang ont tous les deux un
-fsanitize
indicateur indiquant l’instrument utilisé par les programmes que vous compilez pour vérifier divers problèmes.-fsanitize=undefined
par exemple attraperez entier signé soupassement / débordement, décalage d'une quantité trop élevée, etc ... Dans votre cas spécifique,-fsanitize=address
et-fsanitize=memory
aurait été susceptible de revenir sur la question ... à condition que vous avez un test d' appeler la fonction. Pour être complet, cela-fsanitize=thread
vaut la peine d’être utilisé si vous avez une base de code multi-threadée. Si vous ne pouvez pas implémenter le binaire (par exemple, vous avez des bibliothèques tierces sans leur source), vous pouvez également utiliservalgrind
ce fichier, même s'il est généralement plus lent.Les compilateurs récents présentent également des possibilités de renforcement de la richesse . La principale différence avec les fichiers binaires instrumentés est que les contrôles de durcissement sont conçus pour avoir un impact faible sur les performances (<1%), ce qui les rend adaptés au code de production en général. Les contrôles les plus connus sont les contrôles CFI (Control Flow Integrity), conçus pour déjouer les attaques par superposition de piles et le détournement de pointeurs virtuels, entre autres moyens de subvertir le flux de contrôle.
Les points (3) et (4) ont pour objectif de transformer une défaillance intermittente en une défaillance : ils respectent tous deux le principe de défaillance rapide . Cela signifie que:
La combinaison de (3) avec une bonne couverture de test devrait permettre de détecter la plupart des problèmes avant qu’ils n’atteignent la production. Utiliser (4) en production peut faire la différence entre un bug ennuyeux et un exploit.
la source
@ note: cet article ajoute simplement plus d'arguments à la réponse de Ben Voigt .
Les deux fonctions ont fait la bonne chose.
Le problème se situe dans le code client, ce qui n’a pas pris en compte les effets secondaires de l’appel A.
C ++ n'a pas de moyen direct de spécifier des effets secondaires dans le langage.
Cela signifie que vous (et votre équipe) devez vous assurer que des éléments tels que les effets secondaires sont visibles dans le code (en tant que documentation) et gérés avec le code (vous devriez probablement envisager de documenter les conditions préalables, les conditions préalables et les invariants également pour des raisons de visibilité).
Changement de code:
À partir de là, vous avez quelque chose au-dessus de l'API qui vous indique que vous devriez avoir un test unitaire pour cela; Il vous indique également comment utiliser (et ne pas utiliser) l'API.
la source
Il n'y a qu'un seul moyen d'éviter les bugs: arrêter d'écrire du code. Tout le reste a échoué d'une certaine manière.
Cependant, tester le code à différents niveaux (tests unitaires, tests fonctionnels, tests d'intégration, tests d'acceptation, etc.) améliorera non seulement la qualité du code, mais réduira également le nombre de bogues.
la source