Diskussion:C++-Programmierung: Weitere Grundelemente: Die Rechte des Compilers

Letzter Kommentar: vor 17 Jahren von Ce2 in Abschnitt Text Alternativvorschlag

Begonnener Text von Prog 05:18, 23. Aug. 2007 (CEST)Beantworten


Dieser Titel ist mehrdeutig und doch sind beide Aussagen treffend. Es geht um das, was der Compiler darf (Rechte im Sinne von "Recht und Ordnung") und darum, was Ihnen passieren kann wenn Sie nicht richtig aufpassen (Rechte im Sinne von "Ihnen eine Überbraten"). Wie schon im Kapitel Rechnen (lassen) erwähnt, können Ihnen manchmal seltsame Dinge passieren, wenn Sie den Wert einer Variable unvorsichtig ändern.

Sehen Sie sich folgendes Beispiel an:

C++-Quelltext:  

zahl = 400;
zahl = ++zahl - zahl--;

Sieht man sich den Ausdruck an, könnte man schnell zu der Annahme kommen, dass zahl nach Auswertung des zweiten Ausdrucks den Wert 0 hat. Die Auswertungsreihenfolge währe auf den ersten Blick:

Im folgenden handelt es sich um Pseudocode:
Quelltext:  

++zahl;    // zahl == 401, Rückgabe == 401
zahl--;    // zahl == 400, Rückgabe == 401
401 - 401; // zahl == 400, Rückgabe == 0
zahl = 0;  // zahl == 0,   Rückgabe == 0

Das sieht vielleicht etwas verwirrend aus. Hier wird davon Ausgegangen das die Operatoren entsprechend Ihrem Vorrang und Ihrer Auswertungsrichtung ausgewertet werden. Der Zuweisungsoperator hat den niedrigsten Vorrang und wird von rechts nach links ausgewertet. Eine Subtraktion wird von links nach rechts ausgewertet und hat einen geringeren Vorrang als der Inkrement- und der Dekrementoperator. Graphisch sieht das dann so aus:

     =
  2/   \1
zahl    -
     1/   \2
  ++zahl zahl--

Leider ist dies eine Fehlannahme, der Compiler ist völlig frei in seiner Entscheidung, welchen der Teilausdrücke er zuerst auswertet. Natürlich müssen die Ausdrücke der Zweige vor dem jeweiligen Knoten ausgewertet werden. Besser gesagt muss der Wert von dort geholt werden können. In Abhängigkeit von Ihrem Compiler könnte zahl am Ende des Ausdrucks einen der Werte -1, 0, 1, 399, 400 oder 401 haben.

Der Compiler darf selbst entscheiden, wann er einen geänderten Wert innerhalb einer Sequenz wirklich schreibt. Als Sequenz bezeichnet man einen durch Semikolon (;) abgeschlossenen Befehl. Wenn Sie nun innerhalb einer Sequenz, den gleichen Wert 2 mal oder noch öfter (im obigen Beispiel 3 mal) ändern, entscheidet der Compiler welcher Wert zum Schluss wirklich in der Variable steht. Und es kommt noch schlimmer. Da der Compiler entscheidet wann ein Wert geschrieben wird, können Sie sich nicht einmal sicher sein, ob Sie später den neuen oder den alten Wert lesen. Folgendes erzeugt zum Beispiel bei vielen Compilern eine Überraschende Ausgabe:

C++-Quelltext:  

zahl = 1;
cout << zahl++ << ", " << zahl++;

Wahrscheinlich erwarten Sie, wie die meisten Menschen, die Ausgabe 1, 2, tatsächlich lautet die Ausgabe vieler Compiler aber 2, 1. Auch das kann wieder Graphisch dargestellt werden:

          <<
         /  \
       <<  zahl++
      /  \
    <<  ", "
   /  \
cout zahl++

Die Zahlen an den Zweigen wurden dieses mal bewusst weggelassen, da Sie für den Compiler nichts bedeuten. Er kann die Zweige in beliebiger Reihenfolge ausführen. Der Compiler muss nur darauf achten das jeder Zweig vollständig ausgewertet ist, bevor der jeweilige Knotenoperator ausgewertet wird. Leider stimmt das nicht ganz, denn wie schon gesagt, es liegt in der Verantwortung des Compilers zu entscheiden, zu welchem Zeitpunkt der Sequenz, ein Wert in eine Variable geschrieben wird.

Anders sieht es aus, wenn ein Wert innerhalb eines Funktionsaufrufes geändert wird. Wenn eine Funktion aufgerufen wird gilt dies als eigenständige Sequenz, (die natürlich auch wieder eigenständige Sequenzen enthalten muss,) so das der Compiler keine Wahl hat. Er muss den Wert schreiben bevor er aus der Funktion zurückkehrt. Betrachten Sie folgendes Beispiel:

C++-Quelltext:  

int InkrementPrefix(int zahl){
    return ++zahl;   // Sequenz innerhalb der Funktion
}

int DekrementPosix(int zahl){
    int tmp = zahl;  // Sequenz innerhalb der Funktion
    ++zahl;          // Sequenz innerhalb der Funktion
    return tmp;      // Sequenz innerhalb der Funktion
}

int main(){
    int = 400;                                           // Sequenz in main()
    zahl = InkrementPrefix(zahl) - DekrementPosix(zahl); // Sequenz in main()
}

Mit der bitte um Überprüfung des Inhalts --Prog 05:18, 23. Aug. 2007 (CEST)Beantworten

Andere Herangehensweise

Bearbeiten

Ich habe beim Lesen dieser Seite das Gefühl, dass du das Pferd von hinten aufzäumst. Es wäre für die Verständlichkeit des Themas wirklich leichter, man würde von vorne anfangen, eigentlich so, wie es der C++-Standard auch macht: Man fängt damit an, was ein C++-Programm eigentlich ist, was es macht. Dann kommt man auf den Begriff der "observable behavior"(*) (deutsch: beobachtbares Verhalten). Dann kann man erklären, was "sequence points"(*) sind (nämlich die Stellen, an denen das beobachtbare Verhalten definiert ist). Nicht nur Semiolons (Semikola, Semikolonten?) sind Sequence points, && und || (sofern beide Operanden Basisdatentypen sind) sind es ebenso und noch ein paar mehr.

Ich weiß, diese abstrakten Dinge sind für Anfänger vielleicht etwas mühsam zu verstehen. Aber wenn man diese Grundlagen erstmal begriffen hat, dürfte der Rest dieses Kapitels fast schon von alleine klarwerden, denn dann hat die Leserin verstanden, dass jeglicher Code, der Annahmen darüber macht, was zwischen zwei Sequence points passiert, kaputt ist, da er auf undefiniertem Verhalten (undefined behavior) beruht.

Da Beispiele die Verständlichkeit natürlich durchaus erhöhen, kann man dann ein paar Beispiele anführen, wo derartiges offensichtlich oder weniger offensichtlich passiert.

Kurzum: Statt der Leserin einzubläuen: "Das und das ist verboten!", ist es didaktisch klüger, ihr klarzumachen, warum das und das zu undefiniertem Verhalten führt. Auch wenn man dafür mit den Erklärungen etwas weiter ausholen muss.

Verstehst du, worauf ich hinaus will? --RokerHRO 12:10, 23. Aug. 2007 (CEST)Beantworten


(*): Ich würde die englischen Originalbegriffe durchaus auch im Text erwähnen, da sie es der Leserin leichter machen, diese in der Originalliteratur nachzuschlagen, als wenn sie die deutschen Übersetzungen ins Englische rückübersetzen müsste.

Danke fürs lesen, leider ist dir genau das aufgefallen was ich befürchtet hatte. Um dieses Thema anständig zu beschreiben, muss man sich damit gut auskennen. Leider erfülle ich diese Voraussetzungen nicht, da ich keine Literatur dazu habe... Den C++-Standard hatte ich mal irgendwann im PDF-Format, aber ich habe keinen Schimmer wo diese Datei geblieben ist. Was die englischen Originalbegriffe angeht, würde ich diese gern hinter den deutschen in Klammern sehen. Ich beherrsche die englische Sprache aber nicht gut, die Begriffe können gern von dir oder anders wem nachgetragen werden.
Es würde mich freuen wenn du mal ein Kapitel dazu schreiben könntest, da ich es erst vollständig begriffen haben muss, bevor ich etwas dazu sagen kann und da würde mir ein Kapitel von dir sehr weiterhelfen. --Prog 18:13, 23. Aug. 2007 (CEST)Beantworten

Text Alternativvorschlag

Bearbeiten

Hier mein Alternativvorschlag für den Text. Bitte verreißen :-) Den ersten Absatz habe ich einfach von Prog übernommen und etwas erweitert. --Ce 00:39, 23. Sep. 2007 (CEST)Beantworten

Dieser Titel ist mehrdeutig und doch sind beide Aussagen treffend. Es geht um das, was der Compiler darf (Rechte im Sinne von "Recht und Ordnung") und darum, was Ihnen passieren kann wenn Sie nicht richtig aufpassen (Rechte im Sinne von "Ihnen eine Überbraten"). Wie schon im Kapitel Rechnen (lassen) erwähnt, können Ihnen manchmal seltsame Dinge passieren, wenn Sie den Wert einer Variable unvorsichtig ändern. In diesem Kapitel geht es darum, warum dies so ist, welche Rechte der Compiler hat, und welche Regeln man einhalten muss, um nicht von der Rechten des Compilers getroffen zu werden.

Der C++-Compiler dient bekanntlich dazu, um aus dem Quellcode ein ausführbares Programm zu machen. Daraus ergibt sich die Frage, was das erzeugte Programm denn eigentlich tun soll.

Auf den ersten Blick erscheint die Antwort einfach: Natürlich genau das, was man im Quelltext geschrieben hat! Aber bei einer genaueren Betrachtung zeigt sich, dass die Sache doch nicht so einfach ist. Betrachten Sie beispielsweise die folgende Anweisung:

b=a*a;

Streng interpretiert bedeutet diese Anweisung: "Hole den Wert von a, dann hole nochmal den Wert von a, dann multipliziere die beiden gelesenen Werte und schreibe das Ergebnis in b." Eine derartige Interpretation würde aber zu unnötig langsamem Code führen: Der Wert von a ändert sich ja nicht zwischen den beiden Lese-Operationen. Deshalb ist es sinnvoll, dem Compiler zu erlauben, a nur einmal zu lesen, dann den gelesenen Wert mit sich selbst zu multiplizieren, und das Ergebnis an b zuzuweisen.

Nicht immer sind Optimierungsmöglichkeiten so offensichtlich. Beispielsweise könnte man erwarten, dass bei einem Funktionsaufruf wie f(g(),h()) die Funktion g zuerst aufgerufen wird. Jedoch ist es auf vielen (aber nicht unbedingt auf allen) Plattformen günstiger, zuerst h aufzurufen. Da in den allermeisten Fällen die Reihenfolge der Funktionsaufrufe nicht wesentlich ist, ist es also sinnvoll, dem Compiler zu überlassen, welche Reihenfolge er wählt – ein guter Compiler wird die effizienteste Reihenfolge benutzen. Dies bedeutet aber im Umkehrschluss, dass Code, der einen solchen verschachtelten Funktionsaufruf verwendet, nicht mehr eine bestimmte Reihenfolge für die Funktionsaufrufe annehmen kann. Code, der sich auf eine bestimmte Reihenfolge verlässt, riskiert also schwer zu findende Programmfehler, sobald er auf einem anderen Compiler (oder sogar demselben Compiler mit anderen Optimierungseinstellungen) übersetzt wird.

Wie die obigen Beispiele zeigen, bedeuten Freiheiten für den Compiler einerseits die Möglichkeit, effizienteren Code zu erhalten, andererseits bedeuten sie, dass bestimmte Programme kein eindeutig definiertes Verhalten mehr zeigen. Da die Entwickler der Sprache C++ sehr auf die Möglichkeit effizienten Codes geachtet haben, gibt es für den Compiler einige Freiheiten, die mit entsprechenden Einschränkungen für Code einhergehen.

Für die Nichtbeachtung der entsprechenden Regeln sieht der C++-Standard zwei verschiedene "Strafen" vor:

  • Unspezifiziertes Verhalten (unspecified behaviour): Ein bestimmter Aspekt des Verhaltens ist nicht festgelegt. Es gibt also mehrere gültige Programme, die aus dem Quellcode generiert werden dürfen. Solches Verhalten sollte in der Regel vermieden werden, ist aber kein Problem, wenn alle möglichen Programme akzeptables Verhalten zeigen (ist beispielsweise die Reihenfolge zweier Funktionsaufrufe nicht spezifiziert, dann ist das nur dann ein Problem, wenn eine der möglichen Reihenfolgen zu fehlerhaftem Verhalten führt; wenn z.B. beide Funktionen eine Ausgabe machen, aber nicht wesentlich ist, welche der beiden Ausgaben zuerst erfolgt, dann ist das Programm dennoch korrekt).
  • Undefiniertes Verhalten (undefined behaviour): Hier darf der Compiler alles. Undefiniertes Verhalten ist also in jedem Fall zu vermeiden.

Die Ausführungs-Reihenfolge

Bearbeiten

Wie bereits erwähnt, darf der Compiler die Auswertung von Funktionsargumenten in beliebiger Reihenfolge vornehmen. Bis auf wenige Ausnahmen, die weiter unten noch behandelt werden, gelten nur zwei Einschränkungen:

  1. Funktionen werden immer zusammenhängend ausgeführt; während eine Funktion ausgeführt wird, kann also kein Code außerhalb der Funktion ausgeführt werden (ausgenommen natürlich Code, der von der Funktion selbst aufgerufen wird). Im Ausdruck f()+g() ist also sichergestellt, dass während der Ausführung von f kein Code aus g (und auch keiner aus der aufrufenden Funktion) ausgeführt wird.
  2. Bevor eine Funktion aufgerufen wird, müssen alle ihre Argumente vollständig berechnet sein. In welcher Reihenfolge dies geschieht, ist jedoch in der Regel unspezifiziert. In der Tat müssen die Argumente nicht einmal zusammenhängend ausgeführt werden: Beispielsweise könnte in f(g()+h(), i()+j()) zuerst g(), dann j(), dann h() und schließlich i() ausgeführt werden; solange alle Funktionen (und die beiden Additionen) ausgeführt werden, bevor f aufgerufen wird, ist alles in Ordnung.

Nebeneffekte

Bearbeiten

Ein wesentlicher Punkt zum Verständnis der Regeln ist das Konzept der Nebeneffekte (side effects, meist fälschlich mit „Seiteneffekte“ übersetzt). Die meisten Anweisungen in C++ sind einfach Ausdrücke, denen ein Strichpunkt hinten angefügt wurde. Ein Ausdruck dient eigentlich zur Berechnung eines Ergebnisses (eine Ausnahme sind Ausdrücke vom Typ void, die explizit kein Ergebnis liefern), kann aber als Nebeneffekt den Wert einer oder mehrerer Variablen ändern. Paradebeispiel dafür ist der Postinkrement-Operator: Der Ausdruck i++ liefert als Ergebnis den (alten) Wert von i, und schreibt als Nebeneffekt den um 1 erhöhten Wert hinein. In der Tat können Variablen in C++ nur über Nebeneffekte geändert werden.

Die entscheidende Frage ist nun, wann diese Nebeneffekte wirksam werden. Wiederum erlaubt der C++-Standard dem Compiler hier große Freiheiten, um optimalen Code zu ermöglichen. Zu diesem Zweck gibt es das Konzept der Sequenzpunkte (sequence points). Generell gilt: Nebeneffekte können an einen beliebigen Zeitpunkt zwischen den beiden Sequenzpunkten auftreten, die den entsprechenden (Teil-)Ausdruck umschließen.

Sequenzpunkte gibt es am Ende jeder vollständigen Anweisung (also grob gesprochen bei jedem Strichpunkt), vor und nach jedem Funktionsaufruf (wenn also eine Funktion aufgerufen wird, dann sind alle Nebeneffekte in der Berechnung ihrer Argumente bereits erfolgt) und nach der Deklaration einer Variablen (bei int a=i++, b=i++; liegt also ein Sequenzpunkt zwischen den Deklarationen von a und b, sozusagen also im Komma). Zusätzlich gibt es noch ein paar weitere Sequenzpunkte, die weiter unten besprochen werden.

Um dem Optimierer möglichst große Freiheiten zu lassen, gelten nun in C++ zwei wichtige Regeln:

  1. Eine Variable darf zwischen zwei Sequenzpunkten nur einmal geschrieben werden.
  2. Eine Variable darf zwischen zwei Sequenzpunkten nicht sowohl gelesen als auch geschrieben werden, es sei denn, der gelesene Wert wird für die Berechnung des geschriebenen Wertes benötigt.

Wird eine dieser beiden Regeln verletzt, so ergibt dies undefiniertes Verhalten, der Compiler darf also im Prinzip beliebigen Code erzeugen.

Die Ausnahme in der zweiten Regel erlaubt Code wie a = a + 1 (rechts der Zuweisung wird a gelesen, um den neuen Wert zu schreiben), aber nicht a[i++] = b[i] (das Lesen von i für den Index von b wird nicht zur Berechnung des neuen Wertes von i verwendet).

Zu beachten ist, dass nicht alle solchen Zugriffe offensichtlich sind (wären sie es, könnte der Optimierer sie berücksichtigen, und man bräuchte die ganzen komplizierten Regeln nicht). Beispielsweise kann die scheinbar harmlose Zuweisung a = b++ undefiniertes Verhalten liefern, wenn z.B. b eine Referenz auf a ist.

Ausnahmen von der Regel: Spezielle Operatoren

Bearbeiten

Normalerweise haben Operatoren weder eine definierte Ausführungsreihenfolge noch einen Sequenzpunkt. Es gibt aber ein Ausnahmen. Zu beachten ist jedoch, dass diese Ausnahmen nur für die eingebauten Operatoren gilt; werden diese Operatoren überladen, so gelten für die selbstgeschriebenen Versionen stattdessen die Regeln für Funktionsaufrufe.

  • Der Komma-Operator garantiert, dass der linke vor dem rechten Ausdruck berechnet wird, und setzt außerdem einen Sequenzpunkt zwischen diese Ausdrücke. Beispielsweise ist der Ausdruck i++, i++ wohldefiniert (er erhöht i um zwei, liefert aber den um 1 erhöhten Wert zurück). Zu beachten ist dabei, dass das Komma zwischen Funktionsargumenten nicht den Kommaoperator beschreibt und keinen Sequenzpunkt definiert; f(i++, i++) ergibt undefiniertes Verhalten.
  • Die logischen Operatoren && und || garantieren ebenfalls, dass der linke Ausdruck zuerst ausgeführt wird. Der rechte Ausdruck wird jedoch nur dann ausgeführt, wenn der linke Ausdruck das Ergebnis noch nicht felstlegt (also bei &&, wenn der linke Ausdruck wahr ist, bei ||, wenn der linke Ausdruck falsch ist). Zwischen den Ausdrücken ist auch hier ein Sequenzpunkt.
  • Der Operator ?: wertet zunächst das linke Argument aus, um zu entscheiden, welches der beiden Argumente auf der rechten Seite des Fragezeichens ausgewertet werden sollen. Zwischen der Auswertung des linken Ausdrucks und des ausgeführten rechten Ausdrucks liegt wiederum ein Sequenzpunkt.

Was der Compiler sonst noch darf

Bearbeiten

Neben den oben angegebenen Regeln (und einigen weiteren, die noch nicht behandelte Konstrukte betreffen) hat der Compiler noch weitere Möglichkeiten zur Optimierung aufgrund der Als-ob-Regel (as-if rule). Die besagt, dass der Compiler das Programm beliebig umgestalten darf, solange dies nicht zur Änderung des beobachtbaren Verhaltens führt (ausgenommen Änderungen, die durch die oben genannten Regeln abgedeckt sind). Dies erlaubt beispielsweise normalerweise die eingangs erwähnte Optimierung von b=a*a, so dass a nur einmal gelesen wird, da das nochmalige Lesen keine beobachtbare Auswirkung hat. An dieser Stelle stellt sich natürlich die Frage, was in diesem Zusammenhang mit beobachtbarem Verhalten gemeint ist. So ist beispielsweise eine gute Optimierung durchaus beobachtbar, weil das Programm schneller läuft. Dennoch möchte man diese Art beobachtbaren Verhaltens natürlich nicht ausschließen, schließlich ist dieses ja der Sinn der Optimierung.

In C++ gilt als beobachtbares Verhalten jeder Aufruf einer Systemfunktion (also beispielsweise zur Textausgabe), sowie das Verändern von Variablen, die als volatile gekennzeichnet sind. Letzteres hat den Grund, dass manchmal das mehrfache Lesen einer Variablen durchaus einen Unterschied machen kann. Manche Speicheradressen sprechen nämlich in Wahrheit nicht den Speicher an, sondern spezielle Hardware (etwa die Grafikkarte), und da ist es durchaus möglich, dass das Lesen eine Aktion auslöst (beispielsweise das Bereitstellen des nächsten Wertes), und daher ein echter Unterschied zwischen einmaligem und zweimaligem Lesen besteht. In so einem Fall wäre es fatal, wenn der Compiler Lesezugriffe (oder auch Schreibzugriffe) wegoptimieren würde.

Hat man jedoch keinen solchen Spezialfall, dann kann man die Optimierung über die Als-ob-Regel beim Programmieren in der Regel ignorieren. Allerdings kann sie sich beim Debuggen eines Programms bemerkbar machen, indem beispielsweise eine Variable, die man sich im Debugger ansehen möchte, gar nicht existiert, weil der Compiler sie vollständig wegoptimieren konnte. Oder sie hat einen anderen Wert, als man nach dem Programm erwarten würde, weil der Compiler z.B. zwei Variablen in denselben Speicher gelegt hat. Zum Debuggen empfiehlt es sich daher, das Programm ohne oder nur mit geringer Optimierung zu übersetzen.

Zurück zur Seite „C++-Programmierung: Weitere Grundelemente: Die Rechte des Compilers“.