udok hat geschrieben: ↑09.09.2022, 15:47
Die Sache mit UB sehe ich pragmatischer, solange das getestet ist und nicht zufällig reinrutscht.
UB heisst ja nur, das der Standard dazu keine Meinung hat, und dem Compilerbauer die Freiheit lässt was Sinnvolles zu machen.
Nope, UB heißt eben genau dass du dich
nicht darauf verlassen kannst, dass das Programm in diesem Fall irgendwas Bestimmtes (oder gar Sinnvolles) tut. UB heißt nicht, dass der Compiler beim Kompilieren deines Codes sich denkt "aha, da macht es UB, überlegen wir mal, wie wir dieses UB heute implementieren wollen". Das ist interessanterweise genau das Gegenteil davon, was wirklich passiert. Sprachen wie C und C++ haben UB weil es Dinge gibt, die beim Kompilieren eines Programmes rein prinzipiell unmöglich oder zumindest impraktikabel zu detektieren sind. Beispiel: out-of-bounds Zugriff auf ein Array; der Compiler kann im Allgemeinen nicht wissen, ob ein bestimmer Array-Access out-of-bounds sein wird oder nicht, weil das im Allgemeinen von Kontrollfluss und Werten abhängt, die erst zur Laufzeit bestimmt sind.
Effektiv gibt es da nun zwei Möglichkeiten: a) du lässt den Compiler um jeden Array-Access Laufzeitchecks einbauen, die sicherstellen, dass kein Access jemals out of bounds geht oder b) du sagst dass out-of-bounds Access UB bedeutet. Viele "moderne" Sprachen gehen Weg a) und oft ist das gut genug weil Branch-Prediction etc. diese Checks auf modernen Desktop CPUs relativ billig machen. Das Problem mit a) ist, dass ein korrektes Programm, wo niemals out-of-bounds Accesses passieren, nun völlig unnötigen Overhead enthält. Selbst wenn dank Branch-Prediction etc. dieser Overhead in der Praxis oft sehr klein ausfällt, ist des dennoch unnötiger Overhead: Mehr Maschinencode der durch den Speicher und die CPU muss. Auch wenn es am Ende keine großen Auswirkungen auf die Laufzeit hat, das sind immer noch Speicher und Taktzyklen, die auch für tatsächlich nützliche Instruktionen hätten verwendet werden können. Und nicht alle Prozessoren haben derart komplexe Pipelines wie moderne Desktop CPUs. Auf GPUs beispielsweise wäre Overhead dieser Art absolut inakzeptabel.
Sprachen mit UB dagegen machen nicht nur keine Checks, sondern stellen die Sache effektiv komplett auf den Kopf. Nachdem es rein prinzipiell unmöglich ist, zu entscheiden, ob ein gegebenes Programm UB enthält oder nicht, drehen wir den Spieß doch einfach um und nutzen ihn dazu, besseren Code für
korrekte Programme zu generieren. Nachdem ein Programm
per Definition nicht davon abhängen kann, was im Falle von UB passiert, ist im Falle von UB jedes Verhalten gleich gut. Oder anders rum: Uns kann völlig egal sein, was der generierte Code im Falle von UB macht. UB ist effektiv alles, was uns nicht kümmert. UB sind effektiv alle Corner-Cases,
die wir nicht behandeln müssen. D.h. der Compiler kann also einfach Code generieren
unter der Annahme dass dein Programm niemals etwas tuen wird, das in UB resultieren würde, weil wenn es sowas täte, dann ist ja ganz egal was für Maschinencode dann läuft, weil du dich sowieso nicht drauf verlassen konntest. "UB ist alles, was nicht passieren kann" ist ein zentrales Axiom in modernen optimierenden Compilern. Sehen wir uns z.B. mal an, was Clang mit
-O3 aus
Code: Alles auswählen
int fun();
int test(int* x)
{
*x = 42;
if (!x)
return fun();
return *x;
}
macht:
Code: Alles auswählen
test(int*): # @test(int*)
mov dword ptr [rdi], 42
mov eax, 42
ret
Beachte, dass der generierte Maschinencode keine Branch mit Aufruf von
fun enthält. In dem Moment wo der Compiler sieht, dass du
*x = 42 machst, weiß der Compiler, dass
x niemals nicht auf ein valides Objekt zeigen kann. Insbesondere kann
x niemals ein Nullpointer sein, weil dann hättest du hier UB gemacht. Und UB kann nicht passieren. D.h. der einzige Weg, über den das
if jemals genommen und
fun jemals aufgerufen werden könnte, führt über UB, kann also nicht passieren, kann also alles wegoptimiert werden.
Genau so ein Bug war afaik übrigens mal für eine Sicherheitslücke im Linuxkernel verantwortlich. Der Compiler konnte einen Check ob der Benutzer über bestimmte Rechte verfügt einfach wegoptimieren, weil der Code zuvor was gemacht hat, was er nur hätte machen können, wenn die Dinge, die der spätere Check gechecked hat, Werte gehabt hätten, unter denen der Check niemals erfolgreich ausgehen konnte…
Oder dieser Code hier
resultiert (Clang mit
-O3) in
Man beachte, dass der generierte Maschinencode nicht einfach Müll returned, sondern einfach
gar nicht returned (keine
ret Instruction). Ja der Maschinencode besteht as
keiner einzigen Instruction, ein Aufruf dieser Funktion läuft also einfach weiter und führt was auch immer nach dieser Funktion zufällig so im Speicher rumliegt aus als wäre es Code. Wie kommt es dazu? Der Compiler sieht, dass der einzig mögliche Kontrollfluss durch diese Funktion in der
} endet, ohne dass ein
return Statement angetroffen wird. Das wäre UB. Nachdem kein möglicher Kontrollfluss durch diese Funktion nicht in UB endet, kann diese Funktion in einem korrekten Programm niemals aufgerufen werden (denn jeglicher Aufruf der Funktion wäre UB). UB kann nicht passieren. D.h. wir müssen für diese Funktion auch keinen Maschinencode emitten.
Fun Fact: kompiliert man das selbe Programm nicht als C++ sondern als C, bekommt man auf einmal eine ret Instruction, weil das Verlassen einer Funktion ohne return Statement in C nicht direkt UB ist (UB tritt in C erst ein, wenn der Aufrufer das Return-Value anschaut). Ich hab Situationen gesehen, wo der selbe Code kompiliert als C++ (vermutlich) aus diesem Grund besseren Maschinencode generiert als wenn man ihn (mit dem selben Compiler!) als C kompiliert. UB bedeutet für den Compiler Freiheit zur Optimierung.
Darin liegt imo der Schlüssel zum Verständis von UB. UB ist nicht etwas wo der Compiler bewusst frei entscheidet, was er in diesem Fall tut. Der Compiler kann im Allgemeinen nicht wissen wann und wo und ob in in einem gegebenen Programm UB auftritt oder nicht. Was der Compiler aber machen kann ist, sich anzuschauen in welchen Fällen im gegebenen Programm UB auftreten
würde und daraus dann Annahmen abzuleiten, über die das
mögliche Verhalten des Programmes eingegrenzt und somit dann effizienterer Code generiert werden kann.
Diese Idee, dass man wissen kann was bei bestimmten UB passiert, wenn man nur genug davon versteht, ist leider ein weit verbreiteter Irrtum. Ein wunderbares Beispiel für "a little bit of knowledge is a dangerous thing". Wer wirklich genug davon versteht, der versteht, dass man in der Praxis wirklich eben genau nicht wissen kann was passiert. Klar, optimierende Compiler sind auch nur Software und wenn man wirklich alle Variablen kennt und berücksichtigt, dann kann man rein theoretisch auch tatsächlich vorhersagen, was am Ende rauskommt. Das Problem ist nur, dass es so viele Variablen und komplexe Zusammenhänge gibt, dass das Gesamtsystem effektiv chaotisches Verhalten an den Tag legt. Jede auch noch so kleine Änderung an irgendeiner auch noch so unwesentlichen Stelle kann eine Kaskade an globalen Konsequenzen auslösen, die dann auf einmal Auswirkungen auf die Codegen in einem vermeintlich völlig unabhängigen Teil des Programms hat. Something Something Butterfly-Effect. Stell dir nur vor, du änderst irgendwo eine Funktion und auf einmal ist die nun kurz genug dass die beim Linken durch LTO über mehrere Module hinweg geinlined wird. Und die Funktion in die sie geinlined wurde ist nun auf einmal auch simpel genug dass die Heuristik sagt: inlinen. Und die Funktion in die diese Funktion nun auf einmal gelined wur… Und auf einmal hast du völlig anderen Maschinencode irgendwo an einer Stelle in deinem Programm, wo deine nun geinlinete Funktion über vier Ecken durch 2 verschiedene third-party Libraries hindurch indirekt aufgerufen wird. Und nachdem da auf einmal anderer Maschinencode rauskommt, beeinflusst das die Registerallokation. Und nun kommen 300 Zeilen weiter unten auf einmal auch andere Instruktionen raus, weil die Instruktionen die wir dort zuvor verwendet hatten konnten nur mit Registern arbeiten, die dort unten nun aber leider dirty sind oder in einer ungünstigen False-Dependency resultieren würden oder was auch immer. Und auf einmal ist die Art und Weise wie dein UB, das du da unten hattest, sich manifestiert eine völlig andere. Und alles ausgelöst durch eine scheinbar harmlose kleine Änderung an einer Stelle in deiner Codebase die nicht nur nichtmal in der selben Funktion, sondern die zehntausende Zeilen und viele Source Files und zwei Libraries entfernt von dem Punkt liegt, wo du das eigentliche UB gemacht hattest.
Und das Ganze funktioniert auch in die andere Richtung. Die Tatsache dass du da unten UB machst, kann indirekt plötzlich auch die Codegen aus deiner geinlineten Funktion, die selbst kein UB macht beeinflussen. Beispiel:
Code: Alles auswählen
extern int answer;
inline void fun()
{
answer = 42;
}
void test(int* x)
{
if (x)
return;
fun();
*x = 42;
}
erzeugt
Die funktion
fun selbst macht kein UB. Aber der Aufruf von
fun in
test kann nur über einen Pfad erreicht werden, der am Ende UB machen würde (
x kann dort nur ein Nullpointer sein, weil alles andere hätte vorher schon returned). Daher wird auch der ganze Effekt von
fun wegoptimiert, obwohl da noch gar kein UB passiert wäre. Der einzige Pfad der kein UB macht, ist der wo
fun nicht aufgerufen wird und somit wird auch nur für den Pfad Code generiert.
Das ist auch, wieso UB nicht nur eine Eigenschaft einer einzelnen Operation, sondern des gesamten Programmes an sich ist. Das Verhalten eines Programmes ist nicht nur undefiniert von dem bestimmten Punkt ab, wo UB gemacht wird. Das Verhalten eines Programmes das UB enthält ist
gänzlich undefiniert. Du kannst dich nicht nur auf nichts verlassen nachdem du UB gemacht hast, du kannst dich auch nicht auf irgendwas verlassen, was passiert, bevor du UB machst…
tl;dr: UB heißt nicht "der Compiler entscheidet was passiert". Das wäre implementation-defined oder unspecified Behavior. In diesen Fällen gibt es ein Behavior, du weißt nur nicht unbedingt, was das Behavior ist. UB dagegen bedeutet
die Abwesenheit von jeglichem Behavior. UB heißt effektiv "dieser Fall wird nicht behandelt". Was im Fall von UB passiert hängt nicht davon ab, wie der Compiler sich entscheidet, das UB zu implementieren, sondern davon, wie der Compiler sich entscheidet,
literally alles andere zu Implementieren.