Sequenz-Punkte, Nebeneffekte

Wenn innerhalb eines Ausdruckes ein Operand mehrfach auftritt und mindestens einmal schreibend auf diesen Operanden zugegriffen wird, so kann dies in seltenen Fällen zu Unbestimmtheiten führen, welche das Ergebnis eines Ausdruckes unvorhersehbar machen. In diesem Zusammenhang wird ein schreibender Zugriff auch als Nebeneffekt bezeichnet. Je nachdem, wie der Ausdruck geschrieben, mit welchem Compiler er übersetzt und in welcher Umgebung er ausgeführt wird, kann ein Nebeneffekt zu anderen Resultaten führen. Durch die Festlegung von sogenannten Sequenz-Punkten wird in den Sprachen C und C++ festgelegt, wann alle Nebeneffekte abgearbeitet sein müssen.

Details

Zwar ist in den Sprachen C und C++ durch die Abarbeitungsrichtung und Rangfolge von Operatoren genau spezifiziert, wann welcher Operator eines Ausdruckes abgearbeitet werden soll. Es ist jedoch nicht festgelegt, wann die Operanden des Ausdruckes ausgewertet, sprich in rvalues umgewandelt werden sollen. Wenn in einem Ausdruck ein Operand mehrfach auftritt und sich der zugrundeliegende lvalue des Operanden irgendwann während der Abarbeitung des Ausdruckes verändert, kann diese Änderung als Nebeneffekt die verbleibenden Auswertungen des Operanden beeinflussen. Je nachdem, ob der vom Compiler erstellte Maschinencode den Operanden vor oder nach einer solchen Änderung anspricht (lesend oder schreibend), kann es sein, dass unterschiedliche Resultate hervorgehen.

In C und C++ wurden aus diesem Grund für einige wichtige Fälle sogenannte Sequenzpunkte definiert. Bei einem Sequenzpunkt kann davon ausgegangen werden, dass sämtliche Schreibzugriffe des vorangegangenen Ausdrucks abgearbeitet und somit abgeschlossen sind, sowie dass noch keine neuen Schreibzugriffe des folgenden Ausdrucks ausgeführt wurden. Zwischen Sequenzpunkten können jedoch nach wie vor Nebeneffekte zu Problemen führen.

Folgendes sind die Sequenzpunkte in C und C++:

Diese Punkte decken die wichtigsten Sequenzpunkte ab. Für Details über den präzisen Zeitpunkt der Sequentialisierung sowie zusätzliche Angaben über die Unterschiede in C und C++ wird auf andere Quellen verwiesen.

Nebeneffekte

Von einem Nebeneffekt wird in der Programmierung grundsätzlich dann gesprochen, wenn nebst der Auswertung eines lvalues selbiger auch verändert wird. Der Autor empfielt jedoch, diesen Begriff wenn immer möglich zu vermeiden. Auf ManderC wird auf diesen Begriff fast gänzlich verzichtet.

Gemäss der Definition des Begriffes Nebeneffekt treten somit bei Zuweisungs-Operatoren grundsätzlich immer Nebeneffekte auf. Nach allgemeinem Bewusstsein wird dies jedoch nicht als Nebeneffekt bezeichnet, da dies ja der gewünschte Effekt des entsprechende Operators ist. Genauso debattierbar ist der Begriff bei den Inkrement- und Dekrement-Operatoren, welche semantisch betrachtet als Haupt-Effekt die Erhöhung beziehungsweise Verminderung des lvalues um eine Einheit bewirken. Laut der Begriffsdefinition ist dies jedoch der Nebeneffekt. Der Autor verwendet somit diesen Begriff nicht.

Viel illustrativer ist das Prinzip des Nebeneffektes bei einem Funktionsaufruf zu beobachten:









5
6
7
#include <stdio.h>

void printvalue(int* variable){
  printf("%d\n", (*variable)++);
}

int main(){
  int x = 5;
  printvalue(&x);
  printvalue(&x);
  printf("%d\n", x);
  return 0;
}

Die Funktion printvalue dient hierbei als Nebeneffekt-Quelle, wo die übergebene Variable gleichzeitig gelesen, wie auch geschrieben wird. Wenn diese Funktion aufgerufen wird, wird die übergebene Variable verändert. Solange ein solcher Nebeneffekt nicht offensichtlich ist, kann dies möglicherweise einige Stunden Fehlersuche nach sich ziehen.

Ungewollte Nebeneffekte sind üble Fehlerquellen. Sie passieren hauptsächlich bei den Inkrement- und Dekrement-Operatoren oder bei Funktionen, welche Pointer (*) oder Referenzen (&) annehmen. Bei den Funktionen können die Nebeneffekte vermindert werden, indem durchgehend const-safe programmiert wird. Bei den Inkrement- und Dekrement-Operatoren kann der Autor keinen anderen Tipp geben, als genau aufzupassen, dass die Operatoren da gebraucht werden, wo sie gebraucht werden.

Wenn ein Operator mit Nebeneffekt verwendet wird, hat auch der gesamte Ausdruck, in dem der Operator eingebettet wird, einen Nebeneffekt. Dies kann insbesondere bei der Benutzung von Makros mit Parametern zu schwer auffindbaren Fehlern führen.

Ungelöste Probleme

Das Auftreten von unerwünschten Nebeneffekten wird durch Sequenzpunkte stark vermindert, jedoch nicht gänzlich vermieden. Beispielsweise ist die Reihenfolge der Auswertung bei den allermeisten Operatoren (ausser die oben genannten) NICHT definiert. Ebenfalls NICHT definiert ist die Reihenfolge der Auswertung der Argumente bei einem Funktionsaufruf. Es ist hierbei zu beachten, dass bei der Trennung der Argumente mittels Komma , es sich NICHT um den Sequenz-Operator handelt. Beispielsweise ist das Resultat des folgenden, einfachen Codes nicht definiert:

5, 6?
6, 5?
5, 5?
int i = 5;
printf("%i, %i\n", i++, i++);

Manche Compiler geben mittlerweile bei einer solchen unbestimmten Sequentialisierung eine Warnung aus.

Die bekanntesten Vertreter von Nebeneffekt-Quellen sind die Inkrement- und Dekrement-Operatoren. Beispielsweise ist es im folgenden Code nicht möglich, den schlussendlichen Wert von x vorauszusagen:






10? 11?
#include <stdio.h>

int main(){
  int x = 10;
  x = x++;
  printf("%d\n", x);
  return 0;
}

In diesem Beispiel ist es nicht klar, ob die Zuweisung vor oder nach Ausführung des Inkrements geschehen soll. Entweder wird zuerst das Inkrement ausgeführt und danach der ursprüngliche Wert (was dem Rückgabewert der Operation x++ entspricht) wieder zugewiesen, oder aber der ursprüngliche Wert wird zuerst zugewiesen und danach die Variable inkrementiert.

Abschliessend sei bemerkt, dass Probleme mit Nebeneffekten in der alltäglichen Programmierung eher selten eine Rolle spielen. Sie gehören zu den obskuren Spezialfällen, welche normalerweise nur bei äusserst extensivem Gebrauch von komplexen Ausdrücken und Makros auftreten. Durch die Sequenzpunkte werden bereits viele Problemfälle vermieden. Weitaus mehr Probleme können im Programmieralltag bei falschen oder vergessenen Klammerungen sowie unbeabsichtigten arithmetischen Umwandlungen auftreten.

Verwandte Probleme

Bei Funktionsaufrufen werden Arrays mittels Pointer als Argumente übergeben, wodurch die tatsächliche Grösse der Arrays dem Compiler innerhalb der Funktion nicht bekannt ist. Mittels dem restrict-Qualifikator kann dem Compiler mitgeteilt werden, dass sich zwei adressierte Bereiche im Speicher nicht überlappen und somit bei gleichzeitigem lesenden und schreibenden Zugriff keine Probleme auftreten können. Der Vorteil hiervon ist jedoch weniger eine definierte Sequentialisierung denn mehr eine verbesserte Optimierung des Maschinencodes.

Sequenzpunkte grundsätzlich dienen dazu, Lese- und Schreibzugriffe zu sequentialisieren, also für die obengenannten Situationen eine garantierte Auswertungsreihenfolge festzulegen. Solange ein Programm als ein komplett unabhängiger Prozess betrachtet werden kann, ist die Sequentialisierung schlussendlich durch das Compilat festgelegt und sämtliche Zugriffe auf einen Wert sind dadurch synchronisiert. Ist das Programm jedoch nicht unabhängig, so kann es sein, dass sich Werte von Operanden zu einem Zeitpunkt verändern, der nicht vorausgesehen werden kann. Ein solcher Zugriff wird als asynchron bezeichnet.

Asynchrone Zugriffe können auftreten, wenn beispielsweise das Betriebssystem einen Interrupt ausführt, welcher einen bestimmten Wert ändert, beispielsweise, wenn ein Peripheriegerät neue Daten an das Programm sendet. Für diesen Fall gibt es in den Sprachen C und C++ den volatile-Qualifikator, welcher bewirkt, dass der Wert einer Variablen niemals zwischengespeichert, sondern stets neu ausgewertet wird. Hierbei kann es natürlich vorkommen, dass sich der Wert einer Variablen während der Ausführung eines kompizierten Ausdrucks verändert und somit das Resultat nicht vorhersehbar ist. Heutzutage werden für solche System- oder Peripherie-Interrupts Funktionen vom Betriebssystem angeboten, welche die asynchronen Zugriffe steuern und so einen geregelten Ablauf der Zugriffe sicherstellen.

Bei der Ausführung von mehreren parallelen Prozessen (oder Threads) kann es sein, dass ein Wert von einem Prozess geändert wird, währenddessen sich ein anderer Prozess soeben in der Abarbeitung eines Ausdruckes befindet, welcher auf diesen Wert mehrfach (lesend oder schreibend) zugreift. Auch diese Zugriffe passieren asynchron und das Resultat ist nicht vorhersehbar.