Aujourd'hui, nous avons découvert la cause d'un méchant bug qui ne s'est produit que de manière intermittente 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
transmet une référence à la clé à A
, qui supprime l'entrée de mappage avant d'essayer de l'imprimer. (Dans notre cas, il n'a pas été imprimé, mais utilisé de manière plus compliquée). C'est bien sûr un comportement indéfini, puisque key
est une référence pendant 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 aient fait le bon choix:
A
n'a aucun moyen de savoir que key
fait référence à ce qu'il est sur le point de détruire.B
aurait pu faire une copie avant de la passer à A
, mais n'est-ce pas le travail de l'appelé de décider s'il faut prendre les paramètres par valeur ou par référence?Y a-t-il une règle que nous n'avons pas suivie?
A
n'a aucun moyen de savoir quekey
fait référence à ce qu'il est sur le point de détruire.
Bien que cela soit vrai, A
connaît les choses suivantes:
Son but est de détruire quelque chose.
Il prend un paramètre qui est du exactement le même type de la chose qu'il détruira.
Compte tenu de ces faits, il est possible pour A
de détruire son propre paramètre s'il prend le paramètre comme pointeur/référence. Ce n'est pas le seul endroit en C++ où de telles considérations doivent être abordées.
Cette situation est similaire à la façon dont la nature d'un opérateur d'affectation operator=
Signifie que vous devrez peut-être vous préoccuper de l'affectation automatique. C'est une possibilité car le type de this
et le type du paramètre de référence sont les mêmes.
Il convient de noter que cela n'est problématique que parce que A
a l'intention d'utiliser ultérieurement le paramètre key
après avoir supprimé l'entrée. Si ce n'était pas le cas, ce serait bien. Bien sûr, il devient alors facile que tout fonctionne parfaitement, puis quelqu'un change A
pour utiliser key
après qu'il a été potentiellement détruit.
Ce serait un bon endroit pour un commentaire.
Y a-t-il une règle que nous n'avons pas suivie?
En C++, vous ne pouvez pas fonctionner en supposant que si vous suivez aveuglément un ensemble de règles, votre code sera 100% sûr. Nous ne pouvons pas avoir de règles pour tout.
Considérez le point # 2 ci-dessus. A
aurait pu prendre un paramètre d'un type différent de la clé, mais l'objet lui-même pourrait être un sous-objet d'une clé dans la carte. En C++ 14, find
peut prendre un type différent du type de clé, tant qu'il existe une comparaison valide entre eux. Donc, si vous faites m.erase(m.find(key))
, vous pouvez détruire le paramètre même si le type du paramètre n'est pas le type de clé.
Ainsi, une règle comme "si le type de paramètre et le type de clé sont identiques, prenez-les par valeur" ne vous sauvera pas. Vous auriez besoin de plus d'informations que cela.
En fin de compte, vous devez faire attention à vos cas d'utilisation spécifiques et faire preuve de jugement, en vous appuyant sur l'expérience.
Je dirais que oui, il y a une règle assez simple que vous avez enfreinte qui vous aurait sauvé: le principe de la responsabilité unique.
À l'heure actuelle, A
reçoit un paramètre qu'il utilise à la fois pour supprimer un élément d'une carte, et faire un autre traitement (impression comme illustré ci-dessus, apparemment quelque chose d'autre dans le vrai code) ). La combinaison de ces responsabilités me semble être une grande partie de la source du problème.
Si nous avons une fonction qui juste supprime la valeur de la carte, et une autre qui juste fait le traitement d'une valeur de la carte, nous devons appeler chacune de code de niveau supérieur, nous nous retrouverions donc avec quelque chose comme ceci:
std::string &key = get_value_from_map();
destroy(key);
continue_to_use(key);
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 sont significatifs, ils sont presque certains de faire comprendre 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.
Y a-t-il une règle que nous n'avons pas suivie?
Oui, vous n'avez pas documenté la fonction .
Sans une description du contrat de passage de paramètres (en particulier la partie relative à la validité du paramètre - est-ce au début de l'appel de fonction ou tout au long), il est impossible de dire si l'erreur est dans la mise en œuvre (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 qui pourrait 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 passer une référence aux données à l'intérieur de la collection en cours de modification).
Par exemple, la norme C++ elle-même spécifie que:
Si un argument d'une fonction a une valeur non valide (telle qu'une valeur en dehors du domaine de la fonction ou un pointeur non valide pour son utilisation prévue), le comportement n'est pas défini.
mais il ne précise pas si cela s'applique uniquement à l'instant où l'appel est effectué, ou tout au long de l'exécution de la fonction. Cependant, dans de nombreux cas, il est clair que seul ce dernier est même possible - à savoir lorsque l'argument ne peut pas être maintenu valide en faisant une copie.
Il existe de nombreux cas concrets où cette distinction entre en jeu. Par exemple, en ajoutant un std::vector<T>
à lui-même
Y a-t-il une règle que nous n'avons pas suivie?
Oui, vous n'avez pas pu le tester correctement. Vous n'êtes pas seul et vous êtes au bon endroit pour apprendre :)
C++ a beaucoup de comportements indéfinis, les comportements indéfinis se manifestent de manière subtile et ennuyeuse.
Vous ne pouvez probablement jamais écrire du code C++ 100% sûr, mais vous pouvez certainement diminuer la probabilité d'introduire accidentellement un comportement indéfini dans votre base de code en utilisant un certain nombre d'outils.
Dans votre cas, je doute que (1) et (2) auraient beaucoup aidé, bien qu'en général je conseille de les utiliser. Pour l'instant concentrons-nous sur les deux autres.
Gcc et Clang disposent d'un -fsanitize
indique quel instrument les programmes que vous compilez pour vérifier une variété de problèmes. -fsanitize=undefined
par exemple, interceptera le dépassement/dépassement d'entier signé, le décalage d'une quantité trop élevée, etc ... Dans votre cas spécifique, -fsanitize=address
et -fsanitize=memory
aurait été susceptible de reprendre le problème ... à condition d'avoir un test appelant la fonction. Pour être complet, -fsanitize=thread
vaut la peine d'être utilisé si vous avez une base de code multithread. Si vous ne pouvez pas implémenter le binaire (par exemple, vous avez des bibliothèques tierces sans leur source), vous pouvez également utiliser valgrind
bien qu'il soit plus lent en général.
Les compilateurs récents proposent également une richesse des possibilités de durcissement . La principale différence avec les binaires instrumentés est que les contrôles de durcissement sont conçus pour avoir un faible impact sur les performances (<1%), ce qui les rend adaptés au code de production en général. Les plus connus sont les contrôles CFI (Control Flow Integrity) qui sont conçus pour déjouer les attaques par écrasement de pile et le détournement de pointeur virtuel, entre autres façons de renverser le flux de contrôle.
L'intérêt de (3) et (4) est de transformer une défaillance intermittente en une certaine défaillance : ils suivent tous les deux le principe fail fast. 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. L'utilisation de (4) en production peut faire la différence entre un bug ennuyeux et un exploit.
@note: ce post ajoute juste plus d'arguments en plus de réponse de Ben Voigt .
La question est: comment aurions-nous pu éviter ce bug en premier lieu? Il semble que les deux fonctions aient fait ce qu'il fallait:
- A n'a aucun moyen de savoir que la clé fait référence à la chose qu'elle est sur le point de détruire.
- B aurait pu faire une copie avant de la passer à A, mais n'est-ce pas le travail de l'appelé de décider s'il faut prendre les paramètres par valeur ou par référence?
Les deux fonctions ont fait le bon choix.
Le problème est dans le code client, qui n'a pas pris en compte les effets secondaires de l'appel A.
C++ n'a aucun moyen direct de spécifier les effets secondaires dans le langage.
Cela signifie que c'est à vous (et à votre équipe) de vous assurer que des éléments tels que les effets secondaires sont visibles dans le code (en tant que documentation) et maintenus avec le code (vous devriez probablement envisager de documenter les conditions préalables, les conditions postérieures et les invariants également pour des raisons de visibilité).
Changement de code:
class Foo {
map<string,string> m;
/// \sideeffect invalidates iterators
void A(const string& key) {
m.erase(key);
cout << "Erased: " << key; // oops
}
...
À partir de ce moment, 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.