Führt die Initialisierung von Variablen nicht dazu, dass wichtige Fehler ausgeblendet werden?

35

Die C ++ Core Guidelines haben die Regel ES.20: Ein Objekt immer initialisieren .

Vermeiden Sie zuvor festgelegte Fehler und das damit verbundene undefinierte Verhalten. Vermeiden Sie Probleme mit dem Verständnis komplexer Initialisierungen. Vereinfachen Sie das Refactoring.

Aber diese Regel hilft nicht, Fehler zu finden, sondern verbirgt sie nur.
Angenommen, ein Programm hat einen Ausführungspfad, in dem es eine nicht initialisierte Variable verwendet. Es ist ein Fehler. Abgesehen von undefiniertem Verhalten bedeutet dies auch, dass ein Fehler aufgetreten ist und das Programm möglicherweise nicht die Produktanforderungen erfüllt. Wenn es in der Produktion eingesetzt wird, kann es zu einem Geldverlust oder noch schlimmer kommen.

Wie überprüfen wir Fehler? Wir schreiben Tests. Tests decken jedoch nicht 100% der Ausführungspfade ab, und Tests decken niemals 100% der Programmeingaben ab. Darüber hinaus deckt sogar ein Test einen fehlerhaften Ausführungspfad ab - er kann immer noch bestehen. Es ist undefiniertes Verhalten, eine nicht initialisierte Variable kann einen etwas gültigen Wert haben.

Aber zusätzlich zu unseren Tests haben wir die Compiler, die so etwas wie 0xCDCDCDCD in nicht initialisierte Variablen schreiben können. Dies verbessert die Erkennungsrate der Tests geringfügig.
Noch besser - es gibt Tools wie Address Sanitizer, die alle Lesevorgänge von nicht initialisierten Speicherbytes erfassen.

Und schließlich gibt es statische Analysatoren, die das Programm anzeigen und feststellen können, dass sich auf diesem Ausführungspfad ein Read-Before-Set befindet.

Wir haben also viele leistungsfähige Werkzeuge, aber wenn wir die Variable initialisieren, finden Desinfektionsmittel nichts .

int bytes_read = 0;
my_read(buffer, &bytes_read); // err_t my_read(buffer_t, int*);
// bytes_read is not changed on read error.
// It's a bug of "my_read", but detection is suppressed by initialization.
buffer.shrink(bytes_read); // Uninitialized bytes_read could be detected here.

// Another bug: use empty buffer after read error.
use(buffer);

Es gibt eine andere Regel: Wenn die Programmausführung auf einen Fehler stößt, sollte das Programm so schnell wie möglich abstürzen. Sie müssen es nicht am Leben halten, nur abstürzen, ein Absturzabbild schreiben und es den Ingenieuren zur Untersuchung geben.
Das Initialisieren von Variablen bewirkt unnötigerweise das Gegenteil - das Programm wird am Leben gehalten, wenn es sonst bereits einen Segmentierungsfehler bekommen würde.

Abyx
quelle
10
Obwohl ich denke, dass dies eine gute Frage ist, verstehe ich Ihr Beispiel nicht. Wenn ein Lesefehler auftritt und bytes_readnicht geändert wird (also Null bleibt), warum soll dies ein Fehler sein? Das Programm könnte immer noch auf vernünftige Weise fortgesetzt werden, solange es nicht implizit bytes_read!=0danach erwartet . Es ist also in Ordnung, dass Desinfektionsmittel sich nicht beschweren. Auf der anderen Seite, wenn bytes_readvorher nicht initialisiert wird, wird das Programm nicht in der Lage sein , in einer vernünftigen Art und Weise fortzusetzen, die Initialisierung so nicht bytes_readwirklich stellt einen Fehler, der vorher nicht da war.
Doc Brown
2
@Abyx: Auch wenn es sich um eine Drittpartei handelt, ist es fehlerhaft, wenn es sich nicht um einen Puffer handelt, der damit beginnt \0. Wenn dokumentiert ist, dass dies nicht der Fall ist, ist Ihr Anrufcode fehlerhaft. Wenn Sie Ihren Anrufcode korrigieren, um ihn bytes_read==0vor dem Anruf zu überprüfen , kehren Sie zu Ihrem Ausgangspunkt zurück: Ihr Code ist fehlerhaft, wenn Sie ihn nicht initialisieren bytes_read, und in diesem Fall sicher. ( Normalerweise sollen Funktionen ihre Out-Parameter auch im Fehlerfall ausfüllen : nicht wirklich. Sehr oft werden die Ausgänge entweder alleine oder undefiniert gelassen.)
Mat
1
Gibt es einen Grund, warum dieser Code das err_tzurückgegebene von ignoriert my_read()? Wenn es irgendwo im Beispiel einen Fehler gibt, ist es das.
Blrfl
1
Es ist ganz einfach: Initialisieren Sie Variablen nur, wenn sie von Bedeutung sind. Wenn nicht, dann nicht. Ich kann jedoch zustimmen, dass es schlecht ist, "Dummy" -Daten zu verwenden, um dies zu tun, da sie Fehler verbergen.
Pieter B
1
"Es gibt eine andere Regel: Wenn die Programmausführung auf einen Fehler stößt, sollte das Programm so schnell wie möglich abstürzen. Sie müssen es nicht am Leben halten, nur abstürzen, einen Absturzdump schreiben und den Ingenieuren zur Untersuchung übergeben." Steuerungssoftware. Viel Glück bei der Bergung der Bruchkippe aus dem Flugzeugwrack.
Giorgio

Antworten:

44

Ihre Argumentation ist in mehreren Punkten falsch:

  1. Segmentierungsfehler sind bei weitem nicht sicher. Die Verwendung einer nicht initialisierten Variablen führt zu undefiniertem Verhalten . Segmentierungsfehler sind eine Möglichkeit, wie sich ein solches Verhalten manifestieren kann, aber es ist genauso wahrscheinlich, dass es normal zu laufen scheint.
  2. Compiler füllen niemals den nicht initialisierten Speicher mit einem definierten Muster (wie 0xCD). Einige Debugger helfen Ihnen dabei, Stellen zu finden, an denen nicht initialisierte Variablen verwendet werden. Wenn Sie ein solches Programm außerhalb eines Debuggers ausführen, enthält die Variable völlig zufälligen Müll. Es ist ebenso wahrscheinlich, dass ein Zähler wie der bytes_readden Wert hat, 10als dass er den Wert hat 0xcdcdcdcd.
  3. Selbst wenn Sie in einem Debugger arbeiten, der den nicht initialisierten Speicher auf ein festes Muster setzt, geschieht dies nur beim Start. Dies bedeutet, dass dieser Mechanismus nur für statische (und möglicherweise Heap-zugewiesene) Variablen zuverlässig funktioniert. Bei automatischen Variablen, die auf dem Stack zugewiesen werden oder nur in einem Register gespeichert werden, ist die Wahrscheinlichkeit groß, dass die Variable an einem zuvor verwendeten Speicherort gespeichert wird, sodass das Muster des Kontrollspeichers bereits überschrieben wurde.

Die Idee hinter der Anleitung, Variablen immer zu initialisieren, besteht darin, diese beiden Situationen zu ermöglichen

  1. Die Variable enthält von Anfang an einen nützlichen Wert. Wenn Sie dies mit der Anweisung kombinieren, eine Variable nur dann zu deklarieren, wenn Sie sie benötigen, können Sie verhindern, dass zukünftige Wartungsprogrammierer in die Falle geraten, eine Variable zwischen ihrer Deklaration und der ersten Zuweisung zu verwenden, bei der die Variable vorhanden, aber nicht initialisiert wäre.

  2. Die Variable enthält einen definierten Wert, den Sie später testen können, um festzustellen, ob eine Funktion wie my_readdie den Wert aktualisiert hat. Ohne Initialisierung können Sie nicht feststellen, ob bytes_readtatsächlich ein gültiger Wert vorliegt, da Sie nicht wissen, mit welchem ​​Wert er begonnen hat.

Bart van Ingen Schenau
quelle
8
1) Es geht nur um Wahrscheinlichkeiten wie 1% gegen 99%. 2 und 3) VC ++ generiert diesen Initialisierungscode auch für lokale Variablen. 3) statische (globale) Variablen werden immer mit 0 initialisiert.
Abyx
5
@Abyx: 1) Nach meiner Erfahrung beträgt die Wahrscheinlichkeit ~ 80% "kein sofort offensichtlicher Verhaltensunterschied", 10% "macht das Falsche", 10% "segfault". Zu (2) und (3): VC ++ führt dies nur in Debug-Builds aus. Sich darauf zu verlassen, ist eine schrecklich schlechte Idee, da sie Release-Builds selektiv bricht und in vielen Tests nicht auftaucht.
Christian Aichinger
8
Ich denke, die "Idee hinter der Anleitung" ist der wichtigste Teil dieser Antwort. Die Anleitung fordert Sie keinesfalls dazu auf, jede Variablendeklaration mit zu befolgen = 0;. Der Zweck des Hinweises besteht darin, die Variable an der Stelle zu deklarieren, an der Sie einen nützlichen Wert dafür haben, und diesen Wert sofort zuzuweisen. Dies wird in den unmittelbar folgenden Regeln ES21 und ES22 explizit verdeutlicht. Diese drei sollten alle als zusammenarbeitend verstanden werden; nicht als einzelne unabhängige Regeln.
GrandOpener
1
@GrandOpener Genau. Wenn an dem Punkt, an dem die Variable deklariert wird, kein sinnvoller Wert zuzuweisen ist, ist der Gültigkeitsbereich der Variablen wahrscheinlich falsch.
Kevin Krumwiede
5
"Compiler füllen nie" sollte das nicht immer sein ?
CodesInChaos
25

Sie haben geschrieben, "diese Regel hilft nicht, Fehler zu finden, sie versteckt sie nur" - nun, das Ziel der Regel ist nicht, Fehler zu finden, sondern sie zu vermeiden . Und wenn ein Fehler vermieden wird, ist nichts verborgen.

Lassen Sie uns das Problem anhand Ihres Beispiels erläutern: Nehmen wir an, die my_readFunktion verfügt bytes_readunter allen Umständen über den schriftlichen Initialisierungsvertrag , tut dies jedoch nicht im Fehlerfall, so dass sie zumindest für diesen Fall fehlerhaft ist. Sie möchten diesen Fehler in der Laufzeitumgebung anzeigen, indem Sie den bytes_readParameter nicht zuerst initialisieren . Solange Sie sicher wissen, dass ein Adressbereinigungsprogramm vorhanden ist, ist dies in der Tat eine Möglichkeit, einen solchen Fehler zu erkennen. Um den Fehler zu beheben, muss die my_readFunktion intern geändert werden.

Es gibt aber eine andere Sichtweise, die mindestens gleichermaßen gilt: Das fehlerhafte Verhalten ergibt sich nur aus der Kombination von nicht bytes_readvorher initialisieren undmy_read danach aufrufen (wobei die Erwartung danach bytes_readinitialisiert wird). Dies ist eine Situation, die in Komponenten der realen Welt häufig vorkommt, wenn die geschriebene Spezifikation für eine Funktion wie my_readnicht 100% klar oder sogar falsch ist, was das Verhalten im Fehlerfall angeht. Solange bytes_readdas Programm jedoch vor dem Aufruf auf Null initialisiert wird, verhält es sich genauso, als ob die Initialisierung intern durchgeführt worden wäre my_read, und verhält sich daher korrekt. In dieser Kombination gibt es keinen Fehler im Programm.

Daraus folgt meine Empfehlung: Verwenden Sie den nicht initialisierenden Ansatz nur, wenn

  • Sie möchten testen, ob eine Funktion oder ein Codebaustein einen bestimmten Parameter initialisiert
  • Sie sind zu 100% sicher, dass die betreffende Funktion einen Vertrag hat, bei dem es definitiv falsch ist, diesem Parameter keinen Wert zuzuweisen
  • Sie sind sich zu 100% sicher, dass die Umwelt dies auffangen kann

Dies sind Bedingungen, die Sie normalerweise in Testcode für eine bestimmte Tooling-Umgebung festlegen können.

Im Produktionscode ist es jedoch besser, eine solche Variable immer im Voraus zu initialisieren. Dies ist der defensivere Ansatz, der Fehler verhindert, wenn der Vertrag unvollständig oder falsch ist oder wenn das Adressbereinigungsprogramm oder ähnliche Sicherheitsmaßnahmen nicht aktiviert sind. Und die "Absturz-früh" -Regel gilt, wie Sie richtig geschrieben haben, wenn die Programmausführung auf einen Fehler stößt. Wenn jedoch eine Variable im Voraus initialisiert wird, bedeutet dies, dass nichts falsch ist, und die weitere Ausführung nicht angehalten werden muss.

Doc Brown
quelle
4
Genau das habe ich mir gedacht, als ich es gelesen habe. Es geht nicht darum, Dinge unter den Teppich zu kehren, es geht darum, sie in den Mülleimer zu kehren!
Corsika
22

Initialisieren Sie immer Ihre Variablen

Der Unterschied zwischen den betrachteten Situationen besteht darin, dass der Fall ohne Initialisierung zu undefiniertem Verhalten führt , während der Fall, in dem Sie sich die Zeit für die Initialisierung genommen haben, einen genau definierten und deterministischen Fehler verursacht. Ich kann nicht betonen, wie sehr diese beiden Fälle voneinander abweichen.

Stellen Sie sich ein hypothetisches Beispiel vor, das einem hypothetischen Mitarbeiter in einem hypothetischen Simulationsprogramm passiert sein könnte. Dieses hypothetische Team versuchte hypothetisch, eine deterministische Simulation durchzuführen, um zu demonstrieren, dass das Produkt, das sie hypothetisch verkauften, den Anforderungen entsprach.

Okay, ich werde mit dem Wort Spritzen aufhören. Ich denke du verstehst den Punkt ;-)

In dieser Simulation gab es Hunderte von nicht initialisierten Variablen. Ein Entwickler hat valgrind für die Simulation ausgeführt und festgestellt, dass mehrere Fehler beim Verzweigen auf nicht initialisierten Wert aufgetreten sind. "Hmm, das sieht so aus, als könnte es zu Nicht-Determinismus kommen, was es schwierig macht, Testläufe zu wiederholen, wenn wir es am dringendsten brauchen." Der Entwickler wandte sich an das Management, aber das Management hatte einen sehr engen Zeitplan und konnte keine Ressourcen sparen, um dieses Problem zu beheben. "Am Ende initialisieren wir alle unsere Variablen, bevor wir sie verwenden. Wir haben gute Codierungspraktiken."

Ein paar Monate vor der endgültigen Auslieferung, wenn sich die Simulation im Churn-Modus befindet und das gesamte Team sprintet, um alle versprochenen Aufgaben des Managements mit einem Budget zu erledigen, das wie jedes jemals finanzierte Projekt zu klein war. Jemand bemerkte, dass sie ein wesentliches Merkmal nicht testen konnten, weil sich der deterministische Sim aus irgendeinem Grund nicht deterministisch zum Debuggen verhielt.

Möglicherweise wurde das gesamte Team angehalten und verbrachte den größten Teil von 2 Monaten damit, die gesamte Codebasis der Simulation zu kämmen, um nicht initialisierte Wertefehler zu beheben, anstatt Funktionen zu implementieren und zu testen. Unnötig zu erwähnen, dass der Mitarbeiter das "Ich habe es Ihnen gesagt" übersprungen hat und sofort anderen Entwicklern geholfen hat, zu verstehen, was nicht initialisierte Werte sind. Seltsamerweise wurden die Codierungsstandards kurz nach diesem Vorfall geändert, um die Entwickler zu ermutigen, ihre Variablen immer zu initialisieren.

Und das ist der Warnschuss. Dies ist die Kugel, die über Ihre Nase streifte. Das eigentliche Problem ist weit, weit, weit, weit heimtückischer, als Sie sich vorstellen.

Die Verwendung eines nicht initialisierten Werts ist "undefiniertes Verhalten" (mit Ausnahme einiger Eckfälle wie char). Undefiniertes Verhalten (oder kurz UB) ist so wahnsinnig schlecht für Sie, dass Sie niemals glauben sollten, es sei besser als die Alternative. Manchmal können Sie feststellen, dass Ihr bestimmter Compiler das UB definiert und dann sicher verwendet werden kann. Andernfalls ist undefiniertes Verhalten "ein Verhalten, wie es sich der Compiler anfühlt". Es kann etwas bewirken, das Sie als "normal" bezeichnen, wie einen nicht angegebenen Wert. Es kann ungültige Opcodes ausgeben, die möglicherweise dazu führen, dass Ihr Programm sich selbst beschädigt. Möglicherweise wird beim Kompilieren eine Warnung ausgelöst, oder der Compiler betrachtet sie sogar als Fehler.

Oder es kann überhaupt nichts tun

Mein Kanarienvogel in der Kohlenmine für UB ist ein Fall von einer SQL-Engine, über die ich gelesen habe. Verzeihen Sie, dass ich den Artikel nicht gefunden habe. Es gab ein Pufferüberlaufproblem in der SQL-Engine, als Sie einer Funktion eine größere Puffergröße übergeben haben, jedoch nur in einer bestimmten Debian-Version. Der Fehler wurde pflichtgemäß protokolliert und untersucht. Der lustige Teil war: Der Pufferüberlauf wurde überprüft . Es gab Code, um den Pufferüberlauf an Ort und Stelle zu behandeln. Es sah ungefähr so ​​aus:

// move the pointers properly to copy data into a ring buffer.
char* putIntoRingBuffer(char* begin, char* end, char* get, char*put, char* newData, unsigned int dataLength)
{
    // If dataLength is very large, we might overflow the pointer
    // arithmetic, and end up with some very small pointer number,
    // causing us to fail to realize we were trying to write past the
    // end.  Check this before we continue
    if (put + dataLength < put)
    {
        RaiseError("Buffer overflow risk detected");
        return 0;
    }
    ...
    // typical ring-buffer pointer manipulation followed...
}

Ich habe meiner Wiedergabe weitere Kommentare hinzugefügt, aber die Idee ist dieselbe. Wenn ein put + dataLengthZeilenumbruch erfolgt, ist er kleiner als der putZeiger (bei ihnen wurde die Kompilierungszeit überprüft, um sicherzustellen, dass das vorzeichenlose int für Neugierige die Größe eines Zeigers hatte). In diesem Fall wissen wir, dass die Standard-Ringpuffer-Algorithmen durch diesen Überlauf verwirrt werden können, und geben daher 0 zurück. Oder doch?

Wie sich herausstellt, ist der Überlauf auf Zeigern in C ++ undefiniert. Da die meisten Compiler Zeiger als Ganzzahlen behandeln, ergibt sich ein typisches Überlaufverhalten von Ganzzahlen, das genau das ist, was wir wollen. Dies ist jedoch undefiniertes Verhalten, dh der Compiler kann alles tun, was er will.

Im Falle dieses Fehlers entschied sich Debian zufällig für die Verwendung einer neuen Version von gcc, auf die keine der anderen wichtigen Linux-Varianten in ihren Produktionsversionen aktualisiert worden war. Diese neue Version von gcc hatte einen aggressiveren Dead-Code-Optimierer. Der Compiler erkannte das undefinierte Verhalten und entschied, dass das Ergebnis der ifAnweisung "Was auch immer die Codeoptimierung am besten macht" ist, was eine absolut legale Übersetzung von UB ist. Dementsprechend wurde die Annahme getroffen, dass die Anweisung niemals die Pufferüberlaufprüfung auslösen und optimieren würde , da ptr+dataLengthsie ptrohne einen UB-Zeigerüberlauf ifniemals niedriger sein kann .

Die Verwendung von "sane" UB verursachte tatsächlich bei einem großen SQL-Produkt einen Buffer Overrun-Exploit , bei dem Code geschrieben wurde, um dies zu vermeiden!

Verlassen Sie sich niemals auf undefiniertes Verhalten. Je.

Cort Ammon
quelle
Für eine sehr amüsante Lektüre zu Undefined Behaviour ist software.intel.com/en-us/blogs/2013/01/06/… ein erstaunlich gut geschriebener Beitrag darüber, wie schlecht es gehen kann. In diesem speziellen Beitrag geht es jedoch um atomare Operationen, die für die meisten sehr verwirrend sind. Ich rate daher davon ab, sie als Grundierung für UB zu empfehlen und zu erläutern, wie es schief gehen kann.
Cort Ammon
1
Ich wünschte, C hätte intrinsische Eigenschaften, um einen Wert oder ein Array von ihnen auf nicht initialisierte, nicht abfangende, unbestimmte Werte oder nicht spezifizierte Werte zu setzen oder unangenehme Werte in weniger unangenehme Werte (nicht abfangende, unbestimmte oder nicht spezifizierte) umzuwandeln, während definierte Werte in Ruhe gelassen werden. Compiler könnten solche Direktiven verwenden, um nützliche Optimierungen zu unterstützen, und Programmierer könnten sie verwenden, um zu vermeiden, dass nutzloser Code geschrieben werden muss, während "Optimierungen" blockiert werden, wenn Dinge wie Techniken mit geringer Matrix verwendet werden.
Supercat
@supercat Es wäre eine nette Funktion, vorausgesetzt, Sie zielen auf Plattformen ab, auf denen dies eine gültige Lösung ist. Eines der Beispiele für bekannte Probleme ist die Fähigkeit, Speichermuster zu erzeugen, die nicht nur für den Speichertyp ungültig sind, sondern mit herkömmlichen Mitteln unmöglich zu erreichen sind. boolDies ist ein hervorragendes Beispiel für offensichtliche Probleme, die jedoch an anderer Stelle auftreten, sofern Sie nicht davon ausgehen, dass Sie auf einer sehr hilfreichen Plattform wie x86 oder ARM oder MIPS arbeiten, bei der all diese Probleme zum Opcode-Zeitpunkt behoben werden.
Cort Ammon
Betrachten Sie den Fall, in dem ein Optimierer nachweisen kann , dass ein für a verwendeter Wert switchaufgrund der Größe der Ganzzahlarithmetik kleiner als 8 ist, damit er schnelle Anweisungen verwenden kann, bei denen davon ausgegangen wird, dass kein "großer" Wert eingeht Ein nicht angegebener Wert (der nach den Regeln des Compilers niemals erstellt werden könnte) wird angezeigt und führt zu einem unerwarteten Ergebnis. Dann springt plötzlich ein großer Sprung vom Ende einer Sprungtabelle. Wenn Sie hier nicht angegebene Ergebnisse zulassen, muss jede switch-Anweisung im Programm über zusätzliche Traps verfügen, um diese Fälle zu unterstützen, die "niemals auftreten" können.
Cort Ammon
Wenn die Intrinsics standardisiert wären, könnten Compiler aufgefordert werden, alles zu tun, um die Semantik zu würdigen. Wenn z. B. einige Codepfade eine Variable setzen und andere nicht, und ein Intrinsic dann sagt "In nicht spezifizierten Wert konvertieren, wenn nicht initialisiert oder unbestimmt, sonst nichts", müsste ein Compiler für Plattformen mit "Nicht-Wert" -Registern Fügen Sie Code ein, um die Variable entweder zu initialisieren, bevor ein Codepfad vorhanden ist, oder auf einem Codepfad, bei dem die Initialisierung ansonsten fehlschlagen würde. Die dafür erforderliche semantische Analyse ist jedoch recht einfach.
Supercat
5

Ich arbeite hauptsächlich in einer funktionalen Programmiersprache, in der Sie keine Variablen neu zuweisen dürfen. Je. Damit ist diese Klasse von Fehlern vollständig beseitigt. Dies schien zunächst eine enorme Einschränkung zu sein, zwingt Sie jedoch dazu, Ihren Code so zu strukturieren, dass er mit der Reihenfolge übereinstimmt, in der Sie neue Daten lernen. Dies vereinfacht Ihren Code und erleichtert die Wartung.

Diese Gewohnheiten können auch auf imperative Sprachen übertragen werden. Es ist fast immer möglich, den Code umzugestalten, um zu vermeiden, dass eine Variable mit einem Dummy-Wert initialisiert wird. Das ist es, wozu Sie diese Richtlinien auffordern. Sie möchten, dass Sie etwas Sinnvolles einfügen, nicht etwas, das nur automatisierte Werkzeuge glücklich macht.

Ihr Beispiel mit einer C-API ist etwas kniffliger. In diesen Fällen , wenn ich verwende die Funktion werde ich auf Null initialisieren die Compiler , um zu verhindern beschweren, aber eine Zeit in den my_readUnit - Tests, werde ich auf etwas anderes um sicherzustellen , dass der Fehler richtig funktioniert initialisieren. Sie müssen nicht bei jeder Verwendung alle möglichen Fehlerbedingungen testen.

Karl Bielefeldt
quelle
5

Nein, es versteckt keine Bugs. Stattdessen wird das Verhalten so determiniert, dass ein Entwickler einen Fehler reproduzieren kann, wenn ein Benutzer auf einen Fehler stößt.


quelle
1
Und das Initialisieren mit -1 kann tatsächlich sinnvoll sein. Wobei "int bytes_read = 0" schlecht ist, weil Sie tatsächlich 0 Bytes lesen können. Durch Initialisieren mit -1 wird deutlich, dass kein Versuch, Bytes zu lesen, erfolgreich war, und Sie können dies testen.
Pieter B
4

TL; DR: Es gibt zwei Möglichkeiten, dieses Programm zu korrigieren, Ihre Variablen zu initialisieren und zu beten. Nur eine liefert konsistente Ergebnisse.


Bevor ich Ihre Frage beantworten kann, muss ich zunächst erklären, was undefiniertes Verhalten bedeutet. Eigentlich lasse ich einen Compilerautor den Großteil der Arbeit machen:

Wenn Sie diese Artikel nicht lesen möchten, ist ein TL; DR:

Undefiniertes Verhalten ist ein Gesellschaftsvertrag zwischen dem Entwickler und dem Compiler. Der Compiler geht mit blindem Glauben davon aus, dass sich sein Benutzer niemals auf undefiniertes Verhalten verlassen wird.

Der Archetyp der "Dämonen, die aus der Nase fliegen" hat die Implikationen dieser Tatsache leider überhaupt nicht vermittelt. Es sollte zwar beweisen, dass alles passieren konnte, aber es war so unglaublich, dass es größtenteils abgeschüttelt wurde.

Die Wahrheit ist jedoch, dass Undefiniertes Verhalten die Kompilierung selbst beeinflusst, lange bevor Sie versuchen, das Programm zu verwenden (instrumentiert oder nicht, innerhalb eines Debuggers oder nicht) und sein Verhalten vollständig ändern kann.

Ich finde das Beispiel in Teil 2 oben auffällig:

void contains_null_check(int *P) {
  int dead = *P;
  if (P == 0)
    return;
  *P = 4;
}

verwandelt sich in:

void contains_null_check(int *P) {
  *P = 4;
}

denn es ist offensichtlich, dass Pdies nicht möglich ist, 0da es vor der Überprüfung dereferenziert wird.


Wie trifft dies auf Ihr Beispiel zu?

int bytes_read = 0;
my_read(buffer, &bytes_read); // err_t my_read(buffer_t, int*);
// bytes_read is not changed on read error.
// It's a bug of "my_read", but detection is suppressed by initialization.
buffer.shrink(bytes_read); // Uninitialized bytes_read could be detected here.

Nun, Sie haben den allgemeinen Fehler gemacht, anzunehmen, dass Undefiniertes Verhalten einen Laufzeitfehler verursachen würde. Es darf nicht.

Stellen wir uns vor, die Definition von my_readlautet:

err_t my_read(buffer_t buffer, int* bytes_read) {
    err_t result = {};
    int blocks_read = 0;
    if (!(result = low_level_read(buffer, &blocks_read))) { return result; }
    *bytes_read = blocks_read * BLOCK_SIZE;
    return result;
}

und verfahren Sie wie von einem guten Compiler mit Inlining erwartet:

int bytes_read; // UNINITIALIZED

// start inlining my_read

err_t result = {};
int blocks_read = 0;
if (!(result = low_level_read(buffer, &blocks_read))) {
    // nothing
} else {
    bytes_read = blocks_reads * BLOCK_SIZE;
}

// end of inlining my_read

buffer.shrink(bytes_read);

Dann optimieren wir, wie von einem guten Compiler erwartet, nutzlose Zweige:

  1. Keine Variable sollte nicht initialisiert verwendet werden
  2. bytes_readwürde nicht initialisiert verwendet, wenn resultnicht0
  3. Der Entwickler verspricht, dass resultdas niemals sein wird 0!

So resultist es niemals 0:

int bytes_read; // UNINITIALIZED
err_t result = {};
int blocks_read = 0;
result = low_level_read(buffer, &blocks_read);

bytes_read = blocks_reads * BLOCK_SIZE;
buffer.shrink(bytes_read);

Oh, resultwird nie benutzt:

int bytes_read; // UNINITIALIZED
int blocks_read = 0;
low_level_read(buffer, &blocks_read);

bytes_read = blocks_reads * BLOCK_SIZE;
buffer.shrink(bytes_read);

Oh, wir können die Erklärung verschieben von bytes_read:

int blocks_read = 0;
low_level_read(buffer, &blocks_read);

int bytes_read = blocks_reads * BLOCK_SIZE;
buffer.shrink(bytes_read);

Und hier ist eine streng bestätigende Transformation des Originals, und kein Debugger fängt eine nicht initialisierte Variable ab, weil es keine gibt.

Ich war auf diesem Weg, das Problem zu verstehen, wenn das erwartete Verhalten und die Montage nicht übereinstimmen, ist wirklich kein Spaß.

Matthieu M.
quelle
Manchmal denke ich, dass die Compiler das Programm veranlassen sollten, die Quelldateien zu löschen, wenn sie einen UB-Pfad ausführen. Programmierer werden dann was UB Mittel , um ihre Anwender lernen ....
mattnz
1

Sehen wir uns Ihren Beispielcode genauer an:

int bytes_read = 0;
my_read(buffer, &bytes_read); // err_t my_read(buffer_t, int*);
// bytes_read is not changed on read error.
// It's a bug of "my_read", but detection is suppressed by initialization.
buffer.shrink(bytes_read); // Uninitialized bytes_read could be detected here.

// Another bug: use empty buffer after read error.
use(buffer);

Dies ist ein gutes Beispiel. Wenn wir einen solchen Fehler antizipieren, können wir die Zeile einfügen assert(bytes_read > 0);und diesen Fehler zur Laufzeit abfangen, was mit einer nicht initialisierten Variablen nicht möglich ist.

Aber nehmen wir an, wir tun es nicht und finden einen Fehler in der Funktion use(buffer). Wir laden das Programm in den Debugger, überprüfen die Rückverfolgung und stellen fest, dass es von diesem Code aufgerufen wurde. Also setzen wir einen Haltepunkt oben in dieses Snippet, führen es erneut aus und reproduzieren den Fehler. Wir versuchen es in einem Schritt zu fangen.

Wenn wir nicht initialisiert haben bytes_read, enthält es Müll. Es muss nicht jedes Mal den gleichen Müll enthalten. Wir treten an der Linie vorbei my_read(buffer, &bytes_read);. Wenn es ein anderer Wert als zuvor ist, können wir unseren Fehler möglicherweise überhaupt nicht reproduzieren! Möglicherweise funktioniert es beim nächsten Mal mit der gleichen Eingabe aus Versehen. Wenn es konstant null ist, erhalten wir ein konsistentes Verhalten.

Wir überprüfen den Wert, vielleicht sogar auf einem Backtrace im selben Lauf. Wenn es Null ist, können wir sehen, dass etwas nicht stimmt; bytes_readsollte bei Erfolg nicht Null sein. (Oder wenn es sein kann, möchten wir es vielleicht auf -1 initialisieren.) Wir können den Fehler wahrscheinlich hier abfangen. Wenn bytes_readein plausibler Wert jedoch falsch ist, können wir ihn auf einen Blick erkennen?

Dies gilt insbesondere für Zeiger: Ein NULL-Zeiger ist in einem Debugger immer offensichtlich, kann auf einfache Weise getestet werden und sollte auf moderner Hardware fehlerhaft sein, wenn wir versuchen, ihn zu dereferenzieren. Ein Garbage Pointer kann später zu nicht reproduzierbaren Speicherfehlern führen. Ein Debugging dieser Fehler ist nahezu unmöglich.

Davislor
quelle
1

Das OP verlässt sich nicht auf undefiniertes Verhalten oder zumindest nicht genau. In der Tat ist es schlecht, sich auf undefiniertes Verhalten zu verlassen. Gleichzeitig ist das Verhalten eines Programms in einem unerwarteten Fall ebenfalls undefiniert, jedoch eine andere Art von undefiniert. Wenn Sie eine Variable auf Null, aber Sie haben nicht die Absicht , einen Ausführungspfad zu haben , dass Anwendungen , daß Erstnullabgleich wird sanely Ihr Programm verhalten , wenn Sie einen Fehler haben und tun haben einen solchen Weg? Du bist jetzt im Unkraut; Sie haben nicht geplant, diesen Wert zu verwenden, aber Sie verwenden ihn trotzdem. Möglicherweise ist es harmlos, oder das Programm stürzt ab, oder das Programm beschädigt unbemerkt Daten. Sie wissen es nicht.

Was das OP sagt ist, dass es Werkzeuge gibt, die Ihnen helfen, diesen Fehler zu finden, wenn Sie sie zulassen. Wenn Sie den Wert nicht initialisieren, ihn aber trotzdem verwenden, gibt es statische und dynamische Analysatoren, die Ihnen mitteilen, dass Sie einen Fehler haben. Ein statischer Analysator informiert Sie, bevor Sie mit dem Testen des Programms beginnen. Wenn Sie andererseits den Wert blind initialisieren, können die Analysatoren nicht erkennen, dass Sie nicht geplant haben, diesen Anfangswert zu verwenden, und Ihr Fehler bleibt somit unentdeckt. Wenn Sie Glück haben, ist es harmlos oder stürzt das Programm nur ab. Wenn Sie Pech haben, werden die Daten unbemerkt beschädigt.

Der einzige Ort, an dem ich mit dem OP nicht einverstanden bin, ist ganz am Ende, wo er sagt, "wenn es sonst schon einen Segmentierungsfehler bekommen würde". Tatsächlich liefert eine nicht initialisierte Variable keinen zuverlässigen Segmentierungsfehler. Stattdessen würde ich sagen, dass Sie statische Analysetools verwenden sollten, mit denen Sie nicht einmal versuchen können, das Programm auszuführen.

Jordan Brown
quelle
0

Eine Antwort auf Ihre Frage muss in die verschiedenen Arten von Variablen unterteilt werden, die in einem Programm angezeigt werden:


Lokale Variablen

Normalerweise sollte die Deklaration genau dort sein, wo die Variable zuerst ihren Wert erhält. Deklarieren Sie keine Variablen wie im alten Stil C:

//Bad: predeclared variables
int foo = 0;
double bar = 0.0;
long* baz = NULL;

bar = getBar();
foo = (int)bar;
baz = malloc(foo);


//Correct: declaration and initialization at the same place
double bar = getBar();
int foo = (int)bar;
long* baz = malloc(foo);

Damit entfallen 99% des Initialisierungsbedarfs, die Variablen haben ihren endgültigen Wert von Anfang an. Die wenigen Ausnahmen bestehen, wenn die Initialisierung von einer bestimmten Bedingung abhängt:

Base* ptr;
if(foo()) {
    ptr = new Derived1();
} else {
    ptr = new Derived2();
}

Ich halte es für eine gute Idee, diese Fälle so zu schreiben:

Base* ptr = nullptr;
if(foo()) {
    ptr = new Derived1();
} else {
    ptr = new Derived2();
}
assert(ptr);

Ich e. Setzen Sie explizit voraus, dass eine sinnvolle Initialisierung Ihrer Variablen durchgeführt wird.


Mitgliedsvariablen

Hier stimme ich dem zu, was die anderen Antwortenden gesagt haben: Diese sollten immer von den Konstruktoren / Initialisierer-Listen initialisiert werden. Andernfalls wird es Ihnen schwer fallen, die Konsistenz zwischen Ihren Mitgliedern zu gewährleisten. Wenn Sie eine Gruppe von Mitgliedern haben, die nicht in allen Fällen initialisiert werden müssen, müssen Sie Ihre Klasse umgestalten und diese Mitglieder zu einer abgeleiteten Klasse hinzufügen, wo sie immer benötigt werden.


Puffer

Hier bin ich mit den anderen Antworten nicht einverstanden. Wenn Leute religiös werden, um Variablen zu initialisieren, initialisieren sie häufig Puffer wie folgt:

char buffer[30];
memset(buffer, 0, sizeof(buffer));

char* buffer2 = calloc(30);

Ich halte das fast immer für schädlich: Der einzige Effekt dieser Initialisierungen ist, dass sie Werkzeuge wie valgrindmachtlos machen. Jeder Code, der mehr aus den initialisierten Puffern liest, als er sollte, ist sehr wahrscheinlich ein Fehler. Mit der Initialisierung kann dieser Fehler jedoch nicht von angezeigt werden valgrind. Verwenden Sie sie daher nur, wenn Sie wirklich darauf vertrauen, dass der Speicher mit Nullen gefüllt ist (und in diesem Fall schreiben Sie einen Kommentar, in dem Sie angeben, wofür Sie die Nullen benötigen).

Ich würde auch dringend empfehlen, Ihrem Build-System ein Ziel hinzuzufügen, auf dem die gesamte Testsuite valgrindoder ein ähnliches Tool ausgeführt wird, um Fehler und Speicherverluste vor der Initialisierung aufzudecken. Dies ist wertvoller als alle Vorinitialisierungen von Variablen. Dieses valgrindZiel sollte regelmäßig ausgeführt werden, vor allem bevor Code veröffentlicht wird.


Globale Variablen

Sie können keine globalen Variablen haben, die nicht initialisiert sind (zumindest in C / C ++ usw.). Stellen Sie daher sicher, dass diese Initialisierung Ihren Wünschen entspricht.

cmaster
quelle
Beachten Sie, dass Sie mit dem ternären Operator bedingte Initialisierungen schreiben können, z. B. Base& b = foo() ? new Derived1 : new Derived2;
Davislor
@Lorehead Das mag für die einfachen Fälle funktionieren, aber für die komplexeren nicht: Sie möchten dies nicht tun, wenn Sie drei oder mehr Fälle haben und Ihre Konstruktoren drei oder mehr Argumente verwenden, um die Lesbarkeit zu verbessern Gründe dafür. Dabei wird nicht einmal eine Berechnung in Betracht gezogen, die durchgeführt werden muss, wie beispielsweise die Suche nach einem Argument für einen Initialisierungszweig in einer Schleife.
cmaster
Für kompliziertere Fälle, könnten Sie den Code für die Initialisierung in einer Fabrik Funktion wickeln: Base &b = base_factory(which);. Dies ist am nützlichsten, wenn Sie den Code mehrmals aufrufen müssen oder wenn Sie das Ergebnis zu einer Konstanten machen können.
Davislor
@Lorehead Das ist wahr und sicherlich der richtige Weg, wenn die erforderliche Logik nicht einfach ist. Trotzdem glaube ich, dass es einen kleinen grauen Bereich dazwischen gibt, in dem die Initialisierung über ?:eine PITA ist und eine Factory-Funktion immer noch überfordert ist. Es gibt nur wenige Fälle, aber es gibt sie.
cmaster
-2

Ein anständiger C-, C ++ - oder Objective-C-Compiler mit den richtigen Compileroptionen teilt Ihnen beim Kompilieren mit, ob eine Variable verwendet wird, bevor ihr Wert festgelegt wird. Da die Verwendung des Werts einer nicht initialisierten Variablen in diesen Sprachen ein undefiniertes Verhalten ist, ist das Festlegen eines Werts vor der Verwendung kein Hinweis, keine Richtlinie oder bewährte Methode. ansonsten ist dein programm absolut kaputt. In anderen Sprachen, wie Java und Swift, wird der Compiler Ihnen niemals erlauben, eine Variable zu verwenden, bevor sie initialisiert wird.

Es gibt einen logischen Unterschied zwischen "Initialisieren" und "Wert setzen". Wenn ich den Umrechnungskurs zwischen Dollar und Euro finden möchte, schreibe ich "double rate = 0.0;" Dann ist für die Variable ein Wert festgelegt, der jedoch nicht initialisiert wurde. Die hier gespeicherte 0.0 hat überhaupt nichts mit dem korrekten Ergebnis zu tun. In dieser Situation hat der Compiler keine Chance, Ihnen mitzuteilen, wenn Sie aufgrund eines Fehlers nie die richtige Conversion-Rate speichern. Wenn Sie gerade "double rate" geschrieben haben; und nie eine sinnvolle Conversion-Rate gespeichert, würde der Compiler Ihnen sagen.

Also: Initialisieren Sie keine Variable, nur weil der Compiler angibt, dass sie verwendet wird, ohne initialisiert zu werden. Das verbirgt einen Bug. Das eigentliche Problem ist, dass Sie eine Variable verwenden, die Sie nicht verwenden sollten, oder dass Sie in einem Codepfad keinen Wert festgelegt haben. Beheben Sie das Problem, verstecken Sie es nicht.

Initialisieren Sie keine Variable, nur weil der Compiler Ihnen möglicherweise mitteilt, dass sie verwendet wird, ohne initialisiert zu werden. Wieder verstecken Sie Probleme.

Deklarieren Sie die zu verwendenden Variablen. Dies erhöht die Wahrscheinlichkeit, dass Sie es zum Zeitpunkt der Deklaration mit einem aussagekräftigen Wert initialisieren können.

Vermeiden Sie die Wiederverwendung von Variablen. Wenn Sie eine Variable wiederverwenden, wird sie höchstwahrscheinlich auf einen unbrauchbaren Wert initialisiert, wenn Sie sie für den zweiten Zweck verwenden.

Es wurde angemerkt, dass einige Compiler falsche Negative haben und dass das Überprüfen auf Initialisierung dem Problem des Anhaltens entspricht. Beides ist in der Praxis irrelevant. Wenn ein Compiler zehn Jahre nach der Meldung des Fehlers keine Verwendung einer nicht initialisierten Variablen findet, ist es an der Zeit, nach einem alternativen Compiler zu suchen. Java implementiert dies zweimal. Einmal im Compiler, einmal im Verifier, ohne Probleme. Der einfache Weg, um das Problem des Anhaltens zu umgehen, besteht nicht darin, dass eine Variable vor der Verwendung initialisiert wird, sondern dass sie vor der Verwendung auf eine Weise initialisiert wird, die durch einen einfachen und schnellen Algorithmus überprüft werden kann.

gnasher729
quelle
Das hört sich oberflächlich gut an, hängt aber zu sehr von der Genauigkeit von Warnungen mit nicht initialisierten Werten ab. Das perfekte Korrigieren dieser Variablen entspricht dem Problem des Anhaltens, und Compiler in der Produktion können und müssen mit falschen Negativen rechnen (dh sie diagnostizieren keine nicht initialisierte Variable, wenn sie diese haben sollten). siehe zum Beispiel GCC-Bug 18501 , der seit mehr als zehn Jahren nicht mehr behoben ist.
zwol
Was du über gcc sagst, ist nur gesagt. Der Rest ist irrelevant.
gnasher729
Es ist traurig über gcc, aber wenn Sie nicht verstehen, warum der Rest relevant ist, müssen Sie sich selbst erziehen.
zwol