Une fonction invalide par inadvertance le paramètre de référence - qu'est-ce qui ne va pas?

54

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é: Bpasse 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 keys'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:

  • An'a aucun moyen de savoir qui keyfait référence à la chose qu'il est sur le point de détruire.
  • Baurait 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?

Nikolai
la source

Réponses:

35

An'a aucun moyen de savoir qui keyfait référence à la chose qu'il est sur le point de détruire.

Bien que cela soit vrai, Asachez les choses suivantes:

  1. Son but est de détruire quelque chose .

  2. Il faut un paramètre qui est du même type que ce qu’il va détruire.

Compte tenu de ces faits, il est possible de Adétruire son propre paramètre si elle prend le paramètre comme un 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 operator=opérateur d'affectation signifie que vous devez peut-être vous préoccuper de l'auto-affectation. C'est une possibilité car le type thiset le type du paramètre de référence sont les mêmes.

Il convient de noter que cela ne fait que poser problème, car il est prévu Ad’utiliser le keyparamètre après avoir supprimé l’entrée. Si ce n'était pas le cas, alors ça irait. Bien sûr, il devient alors facile de tout faire fonctionner parfaitement, puis quelqu'un change Ad'utilisation keyaprès avoir é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 partir du principe que si vous suivez aveuglément un ensemble de règles, votre code sera sûr à 100%. Nous ne pouvons pas avoir de règles pour tout .

Considérons le point 2 ci-dessus. Aaurait 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é de la carte. En C ++ 14, findpeut prendre un type différent du type de clé, tant qu'il existe une comparaison valide entre eux. Ainsi, si vous le 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 du type "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 basant sur votre expérience.

Nicol Bolas
la source
10
Eh bien, vous pourriez avoir la règle "ne jamais partager un état mutable" ou son double "ne jamais modifier un état partagé", mais vous auriez alors du mal à écrire un c ++ identifiable
Caleth
7
@ Caleth Si vous souhaitez utiliser ces règles, C ++ n'est probablement pas le langage pour vous.
user253751
3
@ Caleth Décrivez-vous la rouille?
Malcolm
1
"Nous ne pouvons pas avoir de règles pour tout." Oui nous pouvons. cstheory.stackexchange.com/q/4052
Ouroborus
23

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, Aun 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 :

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 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.

Jerry Coffin
la source
3
C'est une observation valable, elle ne s'applique que très étroitement à la présente affaire. Il existe de nombreux exemples de cas où la SRP est respectée et des problèmes de fonction susceptibles d'invalider son propre paramètre.
Ben Voigt
5
@ BenVoigt: Le fait d'invalider son paramètre ne pose cependant pas de problème. Le fait de continuer à utiliser le paramètre après son invalidation entraîne des problèmes. Mais finalement, vous avez raison: bien que cela l'ait sauvé dans ce cas, il existe sans aucun doute des cas où cela est insuffisant.
Jerry Coffin
3
Lorsque vous écrivez un exemple simplifié, vous devez omettre certains détails et il s'avère parfois qu'un de ces détails était important. Dans notre cas, Aeffectivement recherché keydans deux cartes différentes et, si trouvé, supprimé les entrées plus un nettoyage supplémentaire. Donc, il n'est pas clair que notre APÉR violé. Je me demande si je devrais mettre à jour la question à ce stade.
Nikolai
2
Pour développer le point de @BenVoigt: dans l'exemple de Nicolai, m.erase(key)a la première responsabilité et cout << "Erased: " << keyla 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.
Sdenham
10

Y a-t-il une règle que nous n'avons pas suivie?

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:

Si un argument pour une fonction a une valeur non valide (telle qu'une valeur hors du domaine de la fonction ou un pointeur invalide pour son utilisation prévue), le comportement est indéfini.

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ême

Ben Voigt
la source
"il n’est pas possible de spécifier si cela s’applique uniquement à l’instant où l’appel est effectué ou tout au long de l’exécution de la fonction." En pratique, les compilateurs font pratiquement tout ce qu'ils veulent dans la fonction une fois que UB est appelé. Cela peut conduire à un comportement vraiment étrange si le programmeur n'attrape pas l'UB.
@snowman est intéressant, mais la réorganisation de UB n'a aucun rapport avec ce dont je discute dans cette réponse, à savoir la responsabilité de garantir la validité (pour que UB ne se produise jamais).
Ben Voigt
ce qui est exactement ce que je veux dire: la personne qui écrit le code doit être responsable d'éviter UB, afin d'éviter tout un trou de lapin rempli de problèmes.
@Snowman: Il n'y a pas "une personne" qui écrit tout le code d'un projet. C'est une des raisons pour lesquelles la documentation d'interface est si importante. Une autre est que des interfaces bien définies réduisent la quantité de code à raisonner simultanément - pour tout projet non trivial, il est tout simplement impossible que quelqu'un soit "responsable" de penser à l'exactitude de chaque énoncé.
Ben Voigt
Je n'ai jamais dit qu'une personne écrit tout le code. À un moment donné, un programmeur peut être en train de regarder une fonction ou d'écrire du code. Tout ce que j'essaie de dire, c'est que quiconque examine le code doit faire attention, car en pratique, UB est contagieux et se propage à partir d'une ligne de code sur des champs d'application plus larges une fois que le compilateur est impliqué. Cela nous ramène à ce que vous avez dit à propos de la violation du contrat d’une fonction: je suis d’accord avec vous, mais je dirais que cela peut devenir un problème encore plus important.
2

Y a-t-il une règle que nous n'avons pas suivie?

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.

  1. Avertissements du compilateur
  2. Analyse statique (version étendue des avertissements)
  3. Tests binaires instrumentés
  4. Binaires de production durcis

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 -fsanitizeindicateur indiquant l’instrument utilisé par les programmes que vous compilez pour vérifier divers problèmes. -fsanitize=undefinedpar exemple attraperez entier signé soupassement / débordement, décalage d'une quantité trop élevée, etc ... Dans votre cas spécifique, -fsanitize=addresset -fsanitize=memoryaurait été susceptible de revenir sur la question ... à condition que vous avez un test d' appeler la fonction. Pour être complet, cela -fsanitize=threadvaut 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 utiliser valgrindce 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:

  • il échoue toujours lorsque vous marchez sur la mine
  • il échoue immédiatement , vous indiquant l’erreur plutôt que de corrompre la mémoire de manière aléatoire, etc.

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.

Matthieu M.
la source
0

@ note: cet article ajoute simplement plus d'arguments à la réponse de Ben Voigt .

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 que cette clé fait référence à la chose qu'elle est sur le point de détruire.
  • B aurait pu en faire une copie avant de la transmettre à A, mais ce n'est pas à l'appelé de décider s'il faut prendre les paramètres par valeur ou par référence?

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:

class Foo {
  map<string,string> m;

  /// \sideeffect invalidates iterators
  void A(const string& key) {
    m.erase(key);
    cout << "Erased: " << key; // oops
  }
  ...

À 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.

utnapistim
la source
-4

comment aurions-nous pu éviter ce bug en premier lieu?

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.

BЈовић
la source
1
C'est un non-sens complet. Il n'y a pas qu'un seul moyen d'éviter les bugs. Bien qu'il soit trivial de dire que le seul moyen d' éviter complètement l'existence de bogues est de ne jamais écrire de code, il est également vrai (et bien plus utile) qu'il existe diverses procédures de génie logiciel que vous pouvez suivre, à la fois lors de l'écriture initiale du code et lors du test, cela peut réduire considérablement la présence de bugs. Tout le monde est au courant de la phase de test, mais l’impact le plus important peut souvent être obtenu au moindre coût en suivant des pratiques de conception responsables et des idiomes tout en écrivant le code.
Cody Gray