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 key
es 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:
A
hat keine Ahnung, waskey
sich auf das bezieht, was zerstört werden soll.B
Sie hätten eine KopieA
erstellen 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?
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
A
wird 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 :
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.
quelle
A
tatsächlichkey
in zwei verschiedenen Karten gesucht und, falls gefunden, die Einträge plus einige zusätzliche Aufräumarbeiten entfernt. Es ist also nicht klar, dass unserA
SRP verletzt wurde. Ich frage mich, ob ich die Frage an dieser Stelle aktualisieren sollte.m.erase(key)
hat er die erste Verantwortung undcout << "Erased: " << key
die 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.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:
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ängenquelle
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.
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
-fsanitize
Flag, mit dem die Programme, die Sie kompilieren, auf eine Vielzahl von Problemen hin überprüft werden.-fsanitize=undefined
zum Beispiel wird fangen integer Unterlauf / Überlauf unterzeichnet, durch eine zu hohe Menge Verschiebung, etc ... In Ihrem speziellen Fall-fsanitize=address
und-fsanitize=memory
würde auf die Frage wahrscheinlich zu holen ... haben vorausgesetzt , Sie Aufruf der Funktion einen Test haben. Der Vollständigkeit-fsanitize=thread
halber 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,valgrind
obwohl 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:
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.
quelle
@note: In diesem Beitrag werden nur zusätzliche Argumente zu Ben Voigts Antwort hinzugefügt .
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:
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).
quelle
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.
quelle