Undefiniertes Verhalten und Wahrheit nicht definiert

Der Begriff "unbestimmtes Verhalten" in der Sprache C und C ++ bezeichnet eine Situation, in der wörtlich "was einfach nicht passiert". In der Vergangenheit wurden Fälle, in denen sich die vorherigen C-Compiler (und die darauf befindlichen Architekturen) inkompatibel verhielten, auf unbestimmtes Verhalten zurückgeführt, und das Komitee für die Entwicklung des Standards entschied in seiner grenzenlosen Weisheit, nichts darüber zu entscheiden (d. H. Keine Präferenz zu geben) eine der konkurrierenden Implementierungen). Unbestimmtes Verhalten wurde auch als mögliche Situationen bezeichnet, in denen der normalerweise so erschöpfende Standard kein spezifisches Verhalten vorschrieb. Dieser Begriff hat eine dritte Bedeutung, die in unserer Zeit immer relevanter wird: unbestimmtes Verhalten - dies ist die Möglichkeit zur Optimierung. Und Entwickler in C und C ++ lieben Optimierungen. Compiler müssen unbedingt alle Anstrengungen unternehmen, um den Code zu beschleunigen.

Dieser Artikel wurde erstmals auf der Cryptography Services-Website veröffentlicht. Die Übersetzung wird mit Genehmigung des Autors Thomas Pornin veröffentlicht.

Hier ist ein klassisches Beispiel:

void foo(double *src, int *dst) { int i; for (i = 0; i < 4; i ++) { dst[i] = (int)src[i]; } } 

Wir werden diesen GCC-Code auf einer 64-Bit-x86-Plattform für Linux kompilieren (ich arbeite an der neuesten Version von Ubuntu 18.04, Version GCC - 7.3.0). Wir aktivieren die vollständige Optimierung und sehen uns dann die Assembler-Liste an, für die wir die Schlüssel "-W -Wall -O9 -S " verwenden (das Argument " -O9 " legt die maximale Stufe der GCC-Optimierung fest, die in der Praxis " -O3 " entspricht, obwohl dies in einigen Gabeln der Fall ist GCC definiert und höhere Ebenen). Wir erhalten folgendes Ergebnis:

  .file "zap.c" .text .p2align 4,,15 .globl foo .type foo, @function foo: .LFB0: .cfi_startproc movupd (%rdi), %xmm0 movupd 16(%rdi), %xmm1 cvttpd2dq %xmm0, %xmm0 cvttpd2dq %xmm1, %xmm1 punpcklqdq %xmm1, %xmm0 movups %xmm0, (%rsi) ret .cfi_endproc .LFE0: .size foo, .-foo .ident "GCC: (Ubuntu 7.3.0-27ubuntu1~18.04) 7.3.0" .section .note.GNU-stack,"",@progbits 

Jeder der ersten beiden movupd- Befehle verschiebt zwei Doppelwerte in das 128-Bit-SSE2-Register ( double hat eine Größe von 64 Bit, sodass das SSE2-Register zwei Doppelwerte speichern kann). Mit anderen Worten, vier Anfangswerte werden zuerst gelesen und erst dann in int umgewandelt (Operation cvttpd2dq ). Die Operation punpcklqdq verschiebt die vier empfangenen 32-Bit-Ganzzahlen in ein SSE2-Register (% xmm0 ), dessen Inhalt dann in den RAM geschrieben wird ( movups ). Und jetzt die Hauptsache: Unser C-Programm erfordert formal, dass der Zugriff auf den Speicher in der folgenden Reihenfolge erfolgt:

  • Lesen Sie den ersten Doppelwert aus src [0] .
  • Schreiben Sie den ersten Wert vom Typ int in dst [0] .
  • Lesen Sie den zweiten Doppelwert aus src [1] .
  • Schreiben Sie den zweiten Wert vom Typ int in dst [1] .
  • Lesen Sie den dritten Doppelwert aus src [2] .
  • Schreiben Sie den dritten Wert vom Typ int in dst [2] .
  • Lesen Sie den vierten Doppelwert aus src [3] .
  • Schreiben Sie den vierten Wert vom Typ int in dst [3] .

Alle diese Anforderungen sind jedoch nur im Kontext einer abstrakten Maschine sinnvoll, die der C-Standard definiert. Das Verfahren auf einer realen Maschine kann variieren. Dem Compiler steht es frei, Operationen neu anzuordnen oder zu ändern, sofern ihr Ergebnis nicht der Semantik der abstrakten Maschine widerspricht (die sogenannte Als-ob- Regel lautet „als ob“). In unserem Beispiel ist die Reihenfolge der Aktionen einfach anders:

  • Lesen Sie den ersten Doppelwert aus src [0] .
  • Lesen Sie den zweiten Doppelwert aus src [1] .
  • Lesen Sie den dritten Doppelwert aus src [2] .
  • Lesen Sie den vierten Doppelwert aus src [3] .
  • Schreiben Sie den ersten Wert vom Typ int in dst [0] .
  • Schreiben Sie den zweiten Wert vom Typ int in dst [1] .
  • Schreiben Sie den dritten Wert vom Typ int in dst [2] .
  • Schreiben Sie den vierten Wert vom Typ int in dst [3] .

Dies ist die C-Sprache: Alle Speicherinhalte sind letztendlich Bytes (d. H. Slots mit Werten vom Typ vorzeichenloses Zeichen, in der Praxis jedoch Gruppen von acht Bits), und beliebige Zeigeroperationen sind zulässig. Insbesondere können die Zeiger src und dst verwendet werden, um beim Aufruf auf überlappende Teile des Speichers zuzugreifen (diese Situation wird als "Aliasing" bezeichnet). Daher kann die Lese- und Schreibreihenfolge wichtig sein, wenn Bytes geschrieben und dann erneut gelesen werden. Damit das tatsächliche Verhalten des Programms der durch den C-Standard definierten Zusammenfassung entspricht, müsste der Compiler zwischen Lese- und Schreiboperationen wechseln und bei jeder Iteration einen vollständigen Zyklus von Speicherzugriffen bereitstellen. Der resultierende Code wäre größer und würde viel langsamer arbeiten. Für C-Entwickler wäre dies ein Leid.

Hier kommt glücklicherweise unbestimmtes Verhalten zur Rettung. Standard C besagt, dass auf Werte nicht über Zeiger zugegriffen werden kann, deren Typ nicht den aktuellen Typen dieser Werte entspricht. Einfach ausgedrückt, wenn der Wert in dst [0] geschrieben wird , wobei dst ein int- Zeiger ist, können die entsprechenden Bytes nicht über src [1] gelesen werden, wobei src ein doppelter Zeiger ist, da wir in diesem Fall versuchen würden, darauf zuzugreifen Wert, der jetzt vom Typ int ist , unter Verwendung eines Zeigers eines inkompatiblen Typs. In diesem Fall würde ein undefiniertes Verhalten auftreten. Dies ist in Abschnitt 7.5 Absatz 7 der Norm ISO 9899: 1999 („C99“) angegeben (in der neuen Ausgabe 9899: 2018 oder „C17“ hat sich der Wortlaut nicht geändert). Diese Anforderung wird als strikte Aliasing-Regel bezeichnet. Infolgedessen kann der C-Compiler davon ausgehen, dass Speicherzugriffsvorgänge, die aufgrund eines Verstoßes gegen die strikte Aliasing-Regel zu undefiniertem Verhalten führen, nicht auftreten. Somit kann der Compiler die Lese- und Schreibvorgänge in beliebiger Reihenfolge neu anordnen, da sie nicht auf überlappende Teile des Speichers zugreifen sollten. Darum geht es bei der Codeoptimierung.

Kurz gesagt bedeutet undefiniertes Verhalten Folgendes: Der Compiler kann davon ausgehen, dass es kein undefiniertes Verhalten gibt, und auf der Grundlage dieser Annahme Code generieren. Im Fall der strengen Aliasing-Regel - vorausgesetzt, Aliasing findet statt - ermöglicht das unbestimmte Verhalten wichtige Optimierungen, die ansonsten schwierig zu implementieren wären. Im Allgemeinen weist jede Anweisung in den vom Compiler verwendeten Codegenerierungsprozeduren Abhängigkeiten auf, die den Operationsplanungsalgorithmus einschränken: Eine Anweisung kann nicht vor den Anweisungen ausgeführt werden, von denen sie abhängt, oder nach den Anweisungen, die davon abhängen. In unserem Beispiel beseitigt undefiniertes Verhalten die Abhängigkeiten zwischen Schreiboperationen in dst [] und "nachfolgenden" Leseoperationen von src [] : Eine solche Abhängigkeit kann nur in Fällen bestehen, in denen beim Zugriff auf den Speicher undefiniertes Verhalten auftritt. In ähnlicher Weise ermöglicht das Konzept des undefinierten Verhaltens dem Compiler, einfach Code zu löschen, der nicht ausgeführt werden kann, ohne einen Zustand undefinierten Verhaltens einzugeben.

All dies ist natürlich gut, aber ein solches Verhalten wird vom Compiler manchmal als tückischer Verrat empfunden. Sie können oft den Satz hören: "Der Compiler verwendet das Konzept des unbestimmten Verhaltens als Entschuldigung, um meinen Code zu brechen." Angenommen, jemand schreibt ein Programm, das ganze Zahlen addiert und einen Überlauf befürchtet - denken Sie an den Fall von Bitcoin . Er kann so denken: Um ganze Zahlen darzustellen, verwendet der Prozessor zusätzlichen Code. Wenn also ein Überlauf auftritt, geschieht dies, weil das Ergebnis auf die Größe des Typs abgeschnitten wird, d. H. 32 Bit Dies bedeutet, dass das Ergebnis des Überlaufs vorhergesagt und mit einem Test überprüft werden kann.

Unser bedingter Entwickler wird dies schreiben:

 #include <stdio.h> #include <stdlib.h> int add(int x, int y, int *z) { int r = x + y; if (x > 0 && y > 0 && r < x) { return 0; } if (x < 0 && y < 0 && r > x) { return 0; } *z = r; return 1; } int main(int argc, char *argv[]) { int x, y, z; if (argc != 3) { return EXIT_FAILURE; } x = atoi(argv[1]); y = atoi(argv[2]); if (add(x, y, &z)) { printf("%d\n", z); } else { printf("overflow!\n"); } return 0; } 

Versuchen wir nun, diesen Code mit GCC zu kompilieren:

 $ gcc -W -Wall -O9 testadd.c $ ./a.out 17 42 59 $ ./a.out 2000000000 1500000000 overflow! 

Ok, es scheint zu funktionieren. Versuchen Sie jetzt einen anderen Compiler, zum Beispiel Clang (ich habe Version 6.0.0):

 $ clang -W -Wall -O3 testadd.c $ ./a.out 17 42 59 $ ./a.out 2000000000 1500000000 -794967296 

Was?

Es stellt sich heraus, dass wir das Gebiet des undefinierten Verhaltens betreten, wenn eine Operation mit vorzeichenbehafteten Ganzzahltypen zu einem Ergebnis führt, das nicht durch den Zieltyp dargestellt werden kann. Der Compiler kann jedoch davon ausgehen, dass dies nicht der Fall ist. Insbesondere bei der Optimierung des Ausdrucks x> 0 && y> 0 && r <x kommt der Compiler zu dem Schluss, dass die dritte Prüfung nicht wahr sein kann, da die Werte von x und y streng positiv sind (die Summe zweier Werte kann nicht kleiner sein als einer von ihnen). und Sie können diesen gesamten Vorgang überspringen. Mit anderen Worten, da Überlaufen ein undefiniertes Verhalten ist, kann es aus Sicht des Compilers nicht passieren, und alle Anweisungen, die von diesem Status abhängen, können gelöscht werden. Der Mechanismus zum Erkennen von undefiniertem Verhalten ist einfach verschwunden.

Der Standard hat niemals die Annahme vorgeschrieben, dass "vorzeichenbehaftete Semantik" (die tatsächlich in Prozessoroperationen verwendet wird) in Berechnungen mit vorzeichenbehafteten Typen verwendet wird; Dies geschah eher aus Tradition - selbst in jenen Tagen, als Compiler nicht klug genug waren, um den Code zu optimieren und sich auf eine Reihe von Werten zu konzentrieren. Sie können Clang und GCC zwingen, die Wrapping-Semantik auf signierte Typen anzuwenden, indem Sie das spezielle Flag -fwrapv verwenden (in Microsoft Visual C können Sie -d2UndefIntOverflow- verwenden, wie hier beschrieben). Dieser Ansatz ist jedoch unzuverlässig. Das Flag kann verschwinden, wenn der Code in ein anderes Projekt oder in eine andere Architektur übertragen wird.

Nur wenige Menschen wissen, dass Überläufe von Zeichentypen undefiniertes Verhalten beinhalten. Dies ist in Abschnitt 6.5 Absatz 5 der Normen C99 und C17 angegeben:

Wenn beim Auswerten eines Ausdrucks eine Ausnahme auftritt (d. H. Wenn das Ergebnis nicht mathematisch definiert ist oder außerhalb des Bereichs gültiger Werte eines bestimmten Typs liegt), ist das Verhalten undefiniert.

Für vorzeichenlose Typen ist jedoch eine modulare Semantik garantiert. In Abschnitt 6.2.5 Absatz 9 heißt es:

Bei Berechnungen mit vorzeichenlosen Operanden tritt niemals ein Überlauf auf, da ein Ergebnis, das nicht durch den resultierenden vorzeichenlosen Ganzzahltyp dargestellt werden kann, modulo abgeschnitten wird und eine Zahl ist, die um eins höher ist als der durch den resultierenden Typ dargestellte Maximalwert.

Ein weiteres Beispiel für undefiniertes Verhalten bei Operationen mit vorzeichenbehafteten Typen ist die Divisionsoperation. Wie jeder weiß, ist das Ergebnis der Division durch Null nicht mathematisch bestimmt, daher beinhaltet diese Operation gemäß dem Standard ein undefiniertes Verhalten. Wenn der Teiler in der idiv- Operation auf dem x86-Prozessor Null ist, wird eine Prozessorausnahme ausgelöst. Prozessorausnahmen werden wie Interrupt-Anforderungen vom Betriebssystem behandelt. Auf Unix-ähnlichen Systemen wie Linux wird die durch die idiv- Operation ausgelöste Prozessorausnahme in ein SIGFPE- Signal übersetzt, das an den Prozess gesendet wird, und endet mit dem Standardhandler (wundern Sie sich nicht, dass "FPE" für "Gleitkomma-Ausnahme" steht (Ausnahme in) Gleitkommaoperationen), während idiv mit ganzen Zahlen arbeitet). Es gibt jedoch eine andere Situation, die zu undefiniertem Verhalten führt. Betrachten Sie den folgenden Code:

 #include <stdio.h> #include <stdlib.h> int main(int argc, char *argv[]) { int x, y; if (argc != 3) { return EXIT_FAILURE; } x = atoi(argv[1]); y = atoi(argv[2]); printf("%d\n", x / y); return 0; }  : $ gcc -W -Wall -O testdiv.c $ ./a.out 42 17 2 $ ./a.out -2147483648 -1 zsh: floating point exception (core dumped) ./a.out -2147483648 -1 

Und die Wahrheit ist: Auf diesem Computer (das gleiche x86 für Linux) repräsentiert der int- Typ einen Wertebereich von -2.147.483.648 bis +2.147.483.647. Wenn Sie -2.147.483.648 durch -1 teilen, sollten Sie +2.147.483.648 erhalten Diese Zahl liegt jedoch nicht im Bereich der int- Werte. Daher ist das Verhalten nicht definiert. Alles kann passieren. In diesem Fall wird der Prozess zwangsweise beendet. Auf einem anderen System, insbesondere bei einem kleinen Prozessor ohne Teilungsoperation, kann das Ergebnis variieren. In solchen Architekturen wird die Unterteilung programmgesteuert durchgeführt - mit Hilfe der Prozedur, die normalerweise vom Compiler bereitgestellt wird, und jetzt kann sie mit unbestimmtem Verhalten tun, was sie will, denn genau das ist es.

Ich stelle fest, dass SIGFPE unter den gleichen Bedingungen und mit Hilfe des Modulo-Operators ( % ) erhalten werden kann. Und in der Tat: Darunter liegt dieselbe idiv- Operation, die sowohl den Quotienten als auch den Rest berechnet, sodass dieselbe Prozessorausnahme ausgelöst wird. Interessanterweise besagt der C99-Standard, dass der Ausdruck INT_MIN% -1 nicht zu undefiniertem Verhalten führen kann, da das Ergebnis mathematisch definiert ist (Null) und eindeutig in den Wertebereich des Zieltyps fällt. In Version C17 wurde der Text von Absatz 6 von Abschnitt 6.5.5 geändert, und jetzt wird auch dieser Fall berücksichtigt, wodurch der Standard der tatsächlichen Situation auf gängigen Hardwareplattformen näher kommt.

Es gibt viele nicht offensichtliche Situationen, die auch zu undefiniertem Verhalten führen. Schauen Sie sich diesen Code an:

 #include <stdio.h> #include <stdlib.h> unsigned short mul(unsigned short x, unsigned short y) { return x * y; } int main(int argc, char *argv[]) { int x, y; if (argc != 3) { return EXIT_FAILURE; } x = atoi(argv[1]); y = atoi(argv[2]); printf("%d\n", mul(x, y)); return 0; } 

Denken Sie, dass ein Programm nach dem C-Standard gedruckt werden sollte, wenn wir die Faktoren 45.000 und 50.000 an die Funktion übergeben?

  • 18.048
  • 2.250.000.000
  • Gott schütze die Königin!

Die richtige Antwort ... ja, alles oben! Sie haben wahrscheinlich so argumentiert: Da der vorzeichenlose Short ein vorzeichenloser Typ ist, sollte er die Semantik des Wrapping von Modulo 65 536 unterstützen, da auf dem x86-Prozessor die Größe dieses Typs in der Regel genau 16 Bit beträgt (der Standard erlaubt jedoch auch eine größere Größe, aber In der Praxis ist dies immer noch ein 16-Bit-Typ. Da das Produkt mathematisch 2.250.000.000 ist, wird es modulo 65.536 abgeschnitten, was eine Antwort von 18.048 ergibt. Wenn wir jedoch so denken, vergessen wir die Erweiterung von Ganzzahltypen. Nach dem C-Standard (Abschnitt 6.3.1.1, Absatz 2) können die Operanden eines Typs sein, dessen Größe streng kleiner als die Größe von int ist , und die Werte dieses Typs können durch den Typ int ohne Verlust von Bits dargestellt werden (und wir haben nur diesen Fall: auf meinem x86 unter Linux hat eine int- Größe von 32 Bit und kann explizit Werte von 0 bis 65.535 speichern. Dann werden beide Operanden in int umgewandelt und die Operation wird bereits für die konvertierten Werte ausgeführt. Das heißt, das Produkt wird als Wert vom Typ int berechnet und erst nach Rückkehr von der Funktion auf einen vorzeichenlosen Kurzschluss zurückgesetzt (dh in diesem Moment tritt die Kürzung modulo 65 536 auf). Das Problem ist, dass das Ergebnis vor der inversen Transformation mathematisch 2,250 Millionen beträgt und dieser Wert den Bereich von int überschreitet, bei dem es sich um einen vorzeichenbehafteten Typ handelt. Als Ergebnis erhalten wir undefiniertes Verhalten. Danach kann alles passieren, einschließlich plötzlicher Anfälle von englischem Patriotismus.

In der Praxis beträgt das Ergebnis bei normalen Compilern jedoch 18.048, da es immer noch keine Optimierung gibt, die das unbestimmte Verhalten in diesem bestimmten Programm ausnutzen könnte (man könnte sich künstlichere Szenarien vorstellen, in denen es wirklich Probleme verursachen würde).

Zum Schluss noch ein Beispiel in C ++:

 #include <stdio.h> #include <stdlib.h> #include <string.h> #include <array> int main(int argc, char *argv[]) { std::array<char, 16> tmp; int i; if (argc < 2) { return EXIT_FAILURE; } memset(tmp.data(), 0, 16); if (strlen(argv[1]) < 16) { strcpy(tmp.data(), argv[1]); } for (i = 0; i < 17; i ++) { printf(" %02x", tmp[i]); } printf("\n"); } 

Dies ist nicht das typische "böse schreckliche strcpy () !" Für Sie. In der Tat wird hier die Funktion strcpy () nur ausgeführt, wenn die Größe der Quellzeichenfolge einschließlich des Terminals Null klein genug ist. Darüber hinaus werden die Elemente des Arrays explizit auf Null initialisiert, sodass alle Bytes im Array einen bestimmten Wert haben, unabhängig davon, ob eine große oder eine kleine Zeichenfolge an die Funktion übergeben wird. Gleichzeitig ist die Schleife am Ende falsch: Sie liest ein Byte mehr als es sollte.

Führen Sie den Code aus:

 $ g++ -W -Wall -O9 testvec.c $ ./a.out foo 66 6f 6f 00 00 00 00 00 00 00 00 00 00 00 00 00 10 58 ffffffca ff ffffac ffffffc0 55 00 00 00 ffffff80 71 34 ffffff99 07 ffffffba ff ffffea ffffffd0 ffffffe5 44 ffffff83 fffffffd 7f 00 00 00 00 00 00 00 00 00 00 10 58 ffffffca ffffffac ffffffc0 55 00 00 ffffff97 7b 12 1b ffffffa1 7f 00 00 02 00 00 00 00 00 00 00 ffffffd8 ffffffe5 44 ffffff83 fffffffd 7f 00 00 00 ffffff80 00 00 02 00 00 00 60 56 (...) 62 64 3d 30 30 zsh: segmentation fault (core dumped) ./a.out foo ++? 

Sie können naiv Einwände erheben: Nun, es liest ein zusätzliches Byte über die Grenzen des Arrays hinaus; Dies ist jedoch nicht so beängstigend, da dieses Byte auf dem Stapel noch vorhanden ist und dem Speicher zugeordnet ist. Das einzige Problem hierbei ist das zusätzliche siebzehnte Element mit einem unbekannten Wert. Der Zyklus druckt immer noch genau 17 Ganzzahlen (im Hexadezimalformat) und endet ohne Beschwerden.

Der Compiler hat diesbezüglich jedoch eine eigene Meinung. Er ist sich bewusst, dass die siebzehnte Lesung unbestimmtes Verhalten hervorruft. Nach seiner Logik ist jede nachfolgende Anweisung in der Schwebe: Es besteht keine Anforderung, dass nach unbestimmtem Verhalten überhaupt etwas existieren sollte (formal können sogar frühere Anweisungen angegriffen werden, da unbestimmtes Verhalten auch in die entgegengesetzte Richtung funktioniert). In unserem Fall ignoriert der Compiler einfach die Bedingungsprüfung in der Schleife und dreht sich für immer, oder besser gesagt, bis er außerhalb des für den Stapel zugewiesenen Speichers zu lesen beginnt. Danach funktioniert das SIGSEGV- Signal.

Es ist lustig, aber wenn GCC mit weniger aggressiven Einstellungen für Optimierungen startet, wird eine Warnung ausgegeben:

 $ g++ -W -Wall -O1 testvec.c testvec.c: In function 'int main(int, char**)': testvec.c:20:15: warning: iteration 16 invokes undefined behavior [-Waggressive-loop-optimizations] printf(" %02x", tmp[i]); ~~~~~~^~~~~~~~~~~~~~~~~ testvec.c:19:19: note: within this loop for (i = 0; i < 17; i ++) { ~~^~~~ 

Bei -O9 verschwindet diese Warnung irgendwie. Möglicherweise ist die Tatsache, dass der Compiler bei hohen Optimierungsstufen die Bereitstellung der Schleife aggressiver erzwingt. Es ist möglich (aber ungenau), dass dies ein GCC-Fehler ist (im Sinne eines Warnverlusts; daher widersprechen die Aktionen von GCC auf keinen Fall dem Standard, da in dieser Situation keine „Diagnose“ ausgestellt werden muss).

Fazit: Wenn Sie Code in C oder C ++ schreiben, seien Sie äußerst vorsichtig und vermeiden Sie Situationen, die zu undefiniertem Verhalten führen, auch wenn es so aussieht, als wäre es in Ordnung.

Ganzzahlige Typen ohne Vorzeichen sind ein guter Helfer bei arithmetischen Berechnungen, da ihnen eine modulare Semantik garantiert ist (es können jedoch weiterhin Probleme im Zusammenhang mit der Erweiterung von Ganzzahltypen auftreten). Eine andere Option - aus irgendeinem Grund unbeliebt - besteht darin, überhaupt nicht in C und C ++ zu schreiben. Aus mehreren Gründen ist diese Lösung nicht immer geeignet. Wenn Sie jedoch auswählen können, in welcher Sprache das Programm geschrieben werden soll, d. H. Wenn Sie gerade ein neues Projekt auf einer Plattform starten, die Go, Rust, Java oder andere Sprachen unterstützt, ist es möglicherweise rentabler, die Verwendung von C als "Standardsprache" abzulehnen. Die Auswahl der Tools, einschließlich einer Programmiersprache, ist immer ein Kompromiss. Fallstricke von C, insbesondere unbestimmtes Verhalten bei Vorgängen mit signierten Typen, führen zu zusätzlichen Kosten für die weitere Wartung des Codes, die häufig unterschätzt werden.

Source: https://habr.com/ru/post/de439502/


All Articles