Funktion macht versehentlich Referenzparameter ungültig - was ist falsch gelaufen?

54

Heute haben wir die Ursache eines bösen Fehlers herausgefunden, der nur zeitweise auf bestimmten Plattformen auftrat. In Kürze sah unser Code so aus:

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);
    }
  }
}

Das Problem könnte in diesem vereinfachten Fall offensichtlich sein: BÜbergibt einen Verweis auf den Schlüssel an A, der den Karteneintrag entfernt, bevor versucht wird, ihn zu drucken. (In unserem Fall wurde es nicht gedruckt, sondern komplizierter verwendet.) Dies ist natürlich undefiniertes Verhalten, da keyes sich nach dem Aufruf von um eine baumelnde Referenz handelt erase.

Dies zu beheben war trivial - wir haben nur den Parametertyp von const string&in geändert string. Die Frage ist: Wie hätten wir diesen Fehler überhaupt vermeiden können? Anscheinend haben beide Funktionen das Richtige getan:

  • Ahat keine Ahnung, was keysich auf das bezieht, was zerstört werden soll.
  • BSie hätten eine Kopie Aerstellen können, bevor Sie an sie weitergeleitet wurden , aber ist es nicht die Aufgabe des Anrufers, zu entscheiden, ob Parameter nach Wert oder nach Referenz verwendet werden sollen?

Gibt es eine Regel, die wir nicht befolgt haben?

Nikolai
quelle

Antworten:

35

Ahat keine Ahnung, was keysich auf das bezieht, was zerstört werden soll.

Während dies wahr ist, Aweiß die folgenden Dinge:

  1. Ihr Zweck ist es , etwas zu zerstören .

  2. Es wird ein Parameter verwendet, der genau dem Typ des zu zerstörenden Objekts entspricht.

In Anbetracht dieser Tatsachen ist es möglich , für Aeinen eigenen Parameter zu zerstören , wenn es um den Parameter als Zeiger / Referenz nimmt. Dies ist nicht der einzige Ort in C ++, an dem solche Überlegungen angesprochen werden müssen.

Diese Situation ähnelt der Art eines operator=Zuweisungsoperators, weshalb Sie möglicherweise Bedenken hinsichtlich der Selbstzuweisung haben müssen. Dies ist möglich, da Typ thisund Typ des Referenzparameters identisch sind.

Es ist zu beachten, dass dies nur problematisch ist, weil Aspäter beabsichtigt wird, den keyParameter nach dem Entfernen des Eintrags zu verwenden. Wenn dies nicht der Fall wäre, wäre es in Ordnung. Dann wird es natürlich einfach, alles perfekt funktionieren zu lassen, und dann wechselt jemand Adie Verwendung, keynachdem es möglicherweise zerstört wurde.

Das wäre ein guter Ort für einen Kommentar.

Gibt es eine Regel, die wir nicht befolgt haben?

In C ++ können Sie nicht davon ausgehen, dass Ihr Code zu 100% sicher ist, wenn Sie blind einem Regelsatz folgen. Wir können nicht für alles Regeln haben .

Betrachten Sie Punkt 2 oben. Akönnte einen anderen Parameter als den Schlüssel haben, aber das Objekt selbst könnte ein Unterobjekt eines Schlüssels in der Karte sein. findKann in C ++ 14 einen anderen Typ als den Schlüsseltyp annehmen, sofern ein gültiger Vergleich zwischen ihnen besteht. Wenn Sie dies tun m.erase(m.find(key)), können Sie den Parameter zerstören, obwohl der Parametertyp nicht der Schlüsseltyp ist.

Eine Regel wie "Wenn der Parametertyp und der Schlüsseltyp identisch sind, nehmen Sie sie nach Wert" wird Sie nicht retten. Sie benötigen mehr Informationen als nur das.

Letztendlich müssen Sie auf Ihre spezifischen Anwendungsfälle achten und ein Urteilsvermögen ausüben, das auf Erfahrung beruht.

Nicol Bolas
quelle
10
Nun, Sie könnten die Regel "Niemals den veränderlichen Zustand teilen" oder "Niemals den veränderlichen Zustand teilen" haben, aber dann würden Sie
Schwierigkeiten haben
7
@Caleth Wenn Sie diese Regeln verwenden möchten, ist C ++ wahrscheinlich nicht die Sprache für Sie.
user253751
3
@Caleth Beschreibst du Rust?
Malcolm
1
"Wir können nicht für alles Regeln haben." Ja wir können. cstheory.stackexchange.com/q/4052
Ouroborus
23

Ich würde sagen, ja, es gibt eine ziemlich einfache Regel, die Sie gebrochen haben, die Sie gerettet hätte: das Prinzip der alleinigen Verantwortung.

Im Moment Awird ein Parameter übergeben, mit dem sowohl ein Element aus einer Karte entfernt als auch eine andere Verarbeitung durchgeführt wird (Drucken wie oben gezeigt, anscheinend etwas anderes im realen Code). Die Kombination dieser Verantwortlichkeiten scheint mir die Ursache des Problems zu sein.

Wenn wir eine Funktion haben, die nur den Wert aus der Karte löscht, und eine andere, die nur einen Wert aus der Karte verarbeitet, müssen wir jeden Code einer höheren Ebene aufrufen, sodass wir am Ende so etwas wie diesen haben :

std::string &key = get_value_from_map();
destroy(key);
continue_to_use(key);

Zugegeben, die Namen, die ich verwendet habe, machen das Problem zweifellos offensichtlicher als die echten Namen, aber wenn die Namen überhaupt bedeutungsvoll sind, werden sie mit ziemlicher Sicherheit klarstellen, dass wir versuchen, die Referenz auch nachträglich zu verwenden wurde für ungültig erklärt. Die einfache Änderung des Kontexts macht das Problem viel offensichtlicher.

Jerry Sarg
quelle
3
Nun, das ist eine gültige Beobachtung, sie trifft nur sehr eng auf diesen Fall zu. Es gibt viele Beispiele, bei denen die SRP respektiert wird, und es gibt immer noch Probleme, bei denen die Funktion möglicherweise ihren eigenen Parameter ungültig macht.
Ben Voigt
5
@BenVoigt: Das Ungültigmachen des Parameters ist jedoch kein Problem. Der Parameter wird weiterhin verwendet, nachdem er ungültig ist, was zu Problemen führt. Aber letztendlich ja, Sie haben Recht: Während es ihn in diesem Fall gerettet hätte, gibt es zweifellos Fälle, in denen es nicht ausreicht.
Jerry Coffin
3
Wenn Sie ein vereinfachtes Beispiel schreiben, müssen Sie einige Details weglassen, und manchmal stellt sich heraus, dass eines dieser Details wichtig war. In unserem Fall Atatsächlich keyin zwei verschiedenen Karten gesucht und, falls gefunden, die Einträge plus einige zusätzliche Aufräumarbeiten entfernt. Es ist also nicht klar, dass unser ASRP verletzt wurde. Ich frage mich, ob ich die Frage an dieser Stelle aktualisieren sollte.
Nikolai
2
Um den Punkt von @BenVoigt zu erweitern: Im Beispiel von Nicolai m.erase(key)hat er die erste Verantwortung und cout << "Erased: " << keydie zweite Verantwortung, sodass sich die Struktur des in dieser Antwort gezeigten Codes tatsächlich nicht von der Struktur des Codes im Beispiel unterscheidet In der realen Welt wurde das Problem übersehen. Das Prinzip der Einzelverantwortung trägt nicht dazu bei, dass widersprüchliche Folgen von Einzelaktionen im Code der realen Welt in der Nähe auftauchen oder sogar wahrscheinlicher werden.
Sdenham
10

Gibt es eine Regel, die wir nicht befolgt haben?

Ja, Sie haben die Funktion nicht dokumentiert .

Ohne eine Beschreibung des Parameterübergabevertrags (insbesondere des Teils, der sich auf die Gültigkeit des Parameters bezieht - zu Beginn des Funktionsaufrufs oder durchgehend) ist es unmöglich zu sagen, ob der Fehler in der Implementierung liegt (wenn der Aufrufvertrag vorliegt) Wenn der Parameter gültig ist, wenn der Aufruf beginnt, muss die Funktion eine Kopie erstellen, bevor eine Aktion ausgeführt wird, die den Parameter ungültig machen kann. Dies ist jedoch nicht möglich, wenn der Aufrufvertrag vorsieht, dass der Parameter während des gesamten Aufrufs gültig bleiben muss einen Verweis auf Daten in der zu ändernden Sammlung übergeben).

Beispielsweise gibt der C ++ - Standard selbst Folgendes an:

Wenn ein Argument für eine Funktion einen ungültigen Wert aufweist (z. B. einen Wert außerhalb der Domäne der Funktion oder einen Zeiger, der für die beabsichtigte Verwendung ungültig ist), ist das Verhalten undefiniert.

Es kann jedoch nicht angegeben werden, ob dies nur zum Zeitpunkt des Aufrufs oder während der Ausführung der Funktion zutrifft. In vielen Fällen ist jedoch klar, dass nur letzteres überhaupt möglich ist - und zwar dann, wenn das Argument nicht durch Anfertigung einer Kopie gültig gehalten werden kann.

Es gibt einige Fälle, in denen diese Unterscheidung zum Tragen kommt. Zum Beispiel, indem Sie a std::vector<T>an sich selbst anhängen

Ben Voigt
quelle
msgstr "es kann nicht angegeben werden, ob dies nur zum Zeitpunkt des Aufrufs oder während der Ausführung der Funktion zutrifft." In der Praxis tun Compiler während der gesamten Funktion so ziemlich alles, was sie wollen, sobald UB aufgerufen wird. Dies kann zu merkwürdigem Verhalten führen, wenn der Programmierer die UB nicht abfängt.
@snowman Obwohl es interessant ist, hat die UB-Neuordnung nichts mit dem zu tun, was ich in dieser Antwort diskutiere. Dies ist die Verantwortung für die Gewährleistung der Gültigkeit (damit UB nie passiert).
Ben Voigt
Das ist genau mein Punkt: Die Person, die den Code schreibt, muss dafür verantwortlich sein, UB zu vermeiden, um ein ganzes Kaninchenloch voller Probleme zu vermeiden.
@Snowman: Es gibt keine "einzige Person", die den gesamten Code in ein Projekt schreibt. Dies ist einer der Gründe, warum die Dokumentation der Benutzeroberfläche so wichtig ist. Eine andere ist, dass gut definierte Schnittstellen die Menge an Code reduzieren, über die gleichzeitig nachgedacht werden muss - für ein nicht triviales Projekt ist es einfach nicht möglich, dass jemand dafür verantwortlich ist, über die Richtigkeit jeder Aussage nachzudenken.
Ben Voigt
Ich habe nie gesagt, dass eine Person den gesamten Code schreibt. Zu einem bestimmten Zeitpunkt betrachtet ein Programmierer möglicherweise eine Funktion oder schreibt Code. Ich versuche nur zu sagen, dass jeder, der sich den Code ansieht, vorsichtig sein muss, da UB in der Praxis infektiös ist und sich von einer Codezeile auf weitere Bereiche erstreckt, sobald der Compiler beteiligt ist. Dies geht auf Ihren Punkt zurück, bei dem es um die Verletzung des Vertrags einer Funktion geht: Ich stimme Ihnen zu, stelle jedoch fest, dass dies zu einem noch größeren Problem werden kann.
2

Gibt es eine Regel, die wir nicht befolgt haben?

Ja, Sie haben es nicht richtig getestet. Du bist nicht allein und bist am richtigen Ort, um zu lernen :)


C ++ hat eine Menge undefiniertes Verhalten, undefiniertes Verhalten manifestiert sich auf subtile und ärgerliche Weise.

Sie können wahrscheinlich nie 100% sicheren C ++ - Code schreiben, aber Sie können die Wahrscheinlichkeit einer versehentlichen Einführung von undefiniertem Verhalten in Ihre Codebasis durch den Einsatz einer Reihe von Tools verringern.

  1. Compiler-Warnungen
  2. Statische Analyse (erweiterte Version der Warnungen)
  3. Instrumentierte Test-Binärdateien
  4. Gehärtete Produktions-Binärdateien

Ich bezweifle in Ihrem Fall, dass (1) und (2) viel geholfen hätten, aber ich rate im Allgemeinen, sie zu verwenden. Konzentrieren wir uns zunächst auf die beiden anderen.

Sowohl gcc als auch Clang verfügen über ein -fsanitizeFlag, mit dem die Programme, die Sie kompilieren, auf eine Vielzahl von Problemen hin überprüft werden. -fsanitize=undefinedzum Beispiel wird fangen integer Unterlauf / Überlauf unterzeichnet, durch eine zu hohe Menge Verschiebung, etc ... In Ihrem speziellen Fall -fsanitize=addressund -fsanitize=memorywürde auf die Frage wahrscheinlich zu holen ... haben vorausgesetzt , Sie Aufruf der Funktion einen Test haben. Der Vollständigkeit -fsanitize=threadhalber lohnt es sich, wenn Sie eine Multithread-Codebasis haben. Wenn Sie die Binärdatei nicht implementieren können (z. B. Bibliotheken von Drittanbietern ohne deren Quelle), können Sie sie auch verwenden, valgrindobwohl sie im Allgemeinen langsamer ist.

Neuere Compiler bieten auch Möglichkeiten zum Härten des Reichtums . Der Hauptunterschied zu instrumentierten Binärdateien besteht darin, dass die Aushärtungsprüfungen so ausgelegt sind, dass sie die Leistung nur geringfügig beeinflussen (<1%), sodass sie im Allgemeinen für Produktionscode geeignet sind. Am bekanntesten sind CFI-Checks (Control Flow Integrity), mit denen unter anderem Stack-Smashing-Angriffe und Virtual Pointer Hi-Jacking verhindert werden, um den Kontrollfluss zu unterlaufen.

Der Punkt von sowohl (3) als auch (4) besteht darin, einen intermittierenden Fehler in einen bestimmten Fehler umzuwandeln : Beide folgen dem Fail-Fast- Prinzip. Das bedeutet, dass:

  • es scheitert immer, wenn Sie auf die Landmine treten
  • es schlägt sofort fehl und weist auf den Fehler hin, anstatt den Speicher nach dem Zufallsprinzip zu beschädigen.

Wenn Sie (3) mit einer guten Testabdeckung kombinieren, sollten die meisten Probleme behoben werden, bevor sie die Produktion erreichen. Die Verwendung von (4) in der Produktion kann den Unterschied zwischen einem nervigen Bug und einem Exploit ausmachen.

Matthieu M.
quelle
0

@note: In diesem Beitrag werden nur zusätzliche Argumente zu Ben Voigts Antwort hinzugefügt .

Die Frage ist: Wie hätten wir diesen Fehler überhaupt vermeiden können? Anscheinend haben beide Funktionen das Richtige getan:

  • A hat keine Möglichkeit zu wissen, dass sich der Schlüssel auf das bezieht, was zerstört werden soll.
  • B hätte eine Kopie anfertigen können, bevor sie an A übergeben wurde. Ist es jedoch nicht die Aufgabe des Angerufenen, zu entscheiden, ob Parameter nach Wert oder nach Referenz verwendet werden sollen?

Beide Funktionen haben das Richtige getan.

Das Problem liegt im Client-Code, der die Nebenwirkungen des Aufrufs von A nicht berücksichtigt hat .

C ++ hat keine direkte Möglichkeit, Nebenwirkungen in der Sprache anzugeben.

Dies bedeutet, dass Sie (und Ihr Team) dafür sorgen müssen, dass Dinge wie Nebenwirkungen im Code (als Dokumentation) sichtbar sind und mit dem Code gepflegt werden (Sie sollten wahrscheinlich in Betracht ziehen, Vorbedingungen, Nachbedingungen und Invarianten zu dokumentieren auch aus Sichtbarkeitsgründen).

Codeänderung:

class Foo {
  map<string,string> m;

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

Von diesem Punkt an haben Sie etwas über der API, das Ihnen sagt, dass Sie einen Komponententest dafür haben sollten. Außerdem erfahren Sie, wie Sie die API verwenden (und nicht verwenden).

utnapistim
quelle
-4

Wie hätten wir diesen Bug überhaupt vermeiden können?

Es gibt nur einen Weg, um Fehler zu vermeiden: Hören Sie auf, Code zu schreiben. Alles andere ist irgendwie gescheitert.

Das Testen von Code auf verschiedenen Ebenen (Komponententests, Funktionstests, Integrationstests, Abnahmetests usw.) verbessert jedoch nicht nur die Codequalität, sondern reduziert auch die Anzahl der Fehler.

BЈовић
quelle
1
Das ist völliger Unsinn. Es gibt nicht nur einen Weg, um Fehler zu vermeiden. Es ist trivial, dass die einzige Möglichkeit, Fehler vollständig zu vermeiden, darin besteht, niemals Code zu schreiben. Es ist jedoch auch wahr (und viel nützlicher), dass Sie verschiedene Softwareentwicklungsverfahren anwenden können, sowohl beim Schreiben von Code als auch beim Schreiben von Code Wenn Sie es testen, kann dies das Vorhandensein von Fehlern erheblich reduzieren. Jeder kennt die Testphase, aber die größte Auswirkung lässt sich oft zu den niedrigsten Kosten erzielen, indem verantwortungsbewusste Entwurfspraktiken und Redewendungen befolgt werden, während der Code zuerst geschrieben wird.
Cody Grey