Dienstag, 3. November 2009

Globale und lokale Variablen

Es ist ein alter Programmiergrundsatz, dass man globale Daten möglichst vermeiden und stattdessen besser mit parametrisierten Codeblöcken (Methoden, Funktionen, ...) und lokalen Variablen arbeiten sollte. Um die Vor- und Nachteile der verschiedenen Gültigkeitsbereiche von Variablen zu verstehen, ist auch ein Blick auf die Grundlagen ihrer Implementierung sinnvoll.

Wenn man in ABAP von globalen Daten spricht, meint man nicht - wie sonst auch üblich - applikationsweit sichtbare Daten, sondern Variablen, die für eine Codeeinheit, also eine Klasse, ein Programm oder eine Funktionsgruppe, deklariert, verwendbar und gültig sind. Der Zugriff ist also - ausser bei öffentlichen Klassenattributen - modular: auf die Prozeduren der jeweilige Codeeinheit eingeschränkt. Nur im klassischen Monolithen, bei dem sämtlicher Code in einen einzigen Report gepackt wird, fallen die beiden Begriffe zusammen: Programmglobale Daten sind dann auch applikationsweit global.[1]

Diesen Unterschied zwischen applikationsweit gültigen Daten und globalen Daten muss man im Auge behalten, wenn man in der Literatur Diskussionen über globale Daten verfolgt. In der C/C++-Literatur sind mit globalen Daten oft die applikationsweit gültigen Daten gemeint. Diese sind besonders problematisch. Viele Probleme haben sie jedoch mit den pro Codeeinheit definierten Daten gemeinsam. Denn auch wenn der Zugriff eingeschränkt ist, bleibt der für globale Variablen reservierte Speicherbereich für die gesamte Lebensdauer der Codeeinheit erhalten. Für Programme, Funktionsgruppen und statische Klassenattribute bedeutet das: für die gesamte Applikation. Nur Instanzattribute sind meist kurzlebiger - wenn keine Referenz auf die Instanz mehr da ist, wird die Instanz mitsamt ihren Attributen abgebaut.

Die Hauptschwierigkeit mit globalen Daten ist das sogenannte "Reentrance"-Problem: Wenn eine Prozedur in einer Codeeinheit aufgerufen wird, werden globale Variablen in der Regel verändert (sonst hätte man sie sinnvoller nicht als Variablen, sondern als Konstanten deklariert). Bei einem späteren Aufruf hängt der Wert der Variable daher von der Aufrufgeschichte der Prozedur ab, er ist aus Sicht dieses späteren Aufrufs unbestimmt. Das macht den Code schwerer nachzuvollziehen. Reentranter Code dagegen macht bei jedem Aufruf exakt das Gleiche, er generiert keine Seiteneffekte.

In Programmiersprachen wie Java oder C++, in denen dieselbe Codestrecke von verschiedenen Threads durchlaufen werden kann, stellt Reentrance ein notwendiges Kriterium für nebenläufige Programmierung dar. In ABAP gibt es zwar Shared Objects und den "parallelisierten" Aufruf eines Funktionsbausteins in mehreren Prozessen mit dem Zusatz starting new task - ein Reentrance-Problem hat man aber mit diesen Konzepten nicht:

  • Shared Objects müssen gegen Änderungen gesperrt werden, wenn man ihre globalen Daten ändern will - der naive Versuch, innerhalb einer Methode globale Daten zu ändern, führt zu einem Programmabbruch, wenn man sich im normalen Lesemodus des Objekts befindet. Während des Änderns greifen andere Konsumenten auf die frühere, konsistente Version des Objekts zurück. Jeder Prozess arbeitet daher zu jeder Zeit mit einem konsisten Datenobjekt.

  • Der call function mit dem Zusatz starting new task ergibt überhaupt keine Nebenläufigkeit, da der Funktionsbaustein für jeden Aufruf in einem neuen, blitzblanken Modus ausgeführt wird, in dem alle Codeeinheiten wieder im initialen Zustand vorliegen.


Globale Variablen sind zwar bequem, weil man ihre Werte immer im Zugriff hat. Aber diese Bequemlichkeit hat einen hohen Preis: Aufgrund der verstreuten Verwendung in den verschiedensten Prozeduren erhöht jede globale Variable die Komplexität des Programms. Es ist viel einfacher, eine Prozedur zu refaktorisieren, die nicht mit globalen Daten arbeitet. Daher gibt es die folgende Regel:

Globale Variablen sollten nur dann eingeführt werden, wenn es keine Alternativen gibt.

Natürlich gibt es auch Fälle, in denen der Einsatz globaler Variablen sinnvoll ist. Beispielsweise braucht man globale Daten üblicherweise zum Halten von Sitzungsdaten, etwa um sich in einer Transaktion die Eingaben eines Benutzers zu merken: Mit jedem Dialogschritt der Transaktion wird die Kontrolle zunächst an den Benutzer, danach wieder an den Computer übergeben. Der Computer muss aber die zuvor getätigten Eingaben im Speicher aufbewahren. Lokale Variablen sind hierfür natürlich nicht möglich, da bei Übergabe der Kontrolle an den Benutzer alle Aufrufstacks abgebaut wurden.[4] Die nächsten Eingaben triggern dann über die PAI-Module neue Aufrufstacks. Aber in den Codeeinheiten, die hierbei durchlaufen werden, müssen frühere Eingaben vorliegen - das geht nur, wenn sie als globale (oder statische) Variablen ausgeprägt sind.

Aber auch wenn globale Variablen in einigen Situationen gerechtfertigt und sinnvoll sind, stellen die lokalen Variablen meist die bessere Alternative dar. Man nennt lokale Variablen auch "Variablen auf dem Stack". Warum eigentlich? "Der Stack" ist ein für Mikroprozessoren und virtuelle Maschinen übliches Speicherkonstrukt, mit dem sich das System bei Prozeduraufrufen die Rücksprungadressen merkt. Es gibt einen Stackzeiger auf die Adresse des aktuellen "Top auf Stack" sowie eingebaute push- und pop-Operationen, um Daten in den Stack zu kopieren und von ihm zu nehmen. Genau dieser Stack wird verwendet, um lokale Daten zu verwalten: Der für die lokalen Daten benötigte Speicherplatz wird einfach auf den aktuellen Stackzeiger addiert.[2] Dies geht sehr schnell und stellt auf tiefster Ebene sicher, dass lokale Daten wirklich nur innerhalb einer Prozedur verwendet werden können.

Ein einfaches Beispiel - eine Routine zum Addieren zweier Ganzzahlen soll verdeutlichen, wie das auf Maschinenebene vor sich geht. Der naheliegende Quelltext für eine solche Routine, in C etwa

int add(int x, int y) {
return x+y;
}

würde jedoch von jedem Optimierer sofort als inline-würdig erkannt, so dass der komplette Aufruf der Funktion wegoptimiert werden würde. Stattdessen würde der Prozessorbefehl add zum Addieren zweier Zahlen direkt in den Bytecode der Aufrufstelle hineingeneriert. Auch wenn wir dasselbe etwas umständlicher hinschreiben,

int add(int x, int y) {
int z;
z = x + y;
return z;
}
add(1,1);

hätte die Prozedur unter heutigen Compilern keine guten Überlebenschancen. Wenn man es trotzdem schafft - indem man beispielsweise die Optimierer-Einstellungen verändert und das Inlining verbietet [3], so erhält man in x86 Assemblercode etwa das folgende Resultat.

start: ; Beispielaufruf: Berechne 1 + 1
push 1 ; Parameter y und ...
push 1 ; ... Parameter x auf den Stack legen
call _add ; In EAX steht nach Ausführung der Wert 2
...
_add:
push ebp ; Bezugsregister EBP retten
mov ebp,esp ; Top of Stack merken
add esp,0FFFFFFFCh ; 4 Byte von ESP abziehen (für z)
mov eax,[ebp+8] ; Parameter x in EAX laden
add eax,[ebp+0Ch] ; Parameter y hinzuaddieren
mov [ebp-4],eax ; Ergebnis in lokaler Variable z speichern
leave ; macht implizit POP EBP, EBP -> ESP
ret 8 ; Rückgabewert ist immer EAX
; 8 Byte für Aufrufparameter bereinigen

Der vom Prozessor verwendete Stackzeiger heisst in der x86-Prozessorfamilie ESP. Das Hilfsregister EBP wird während einer Prozedur als Bezugsrahmen verwendet, es enthält den Inhalt des Stacks direkt nach Einsprung in die Routine.

Die obige Codestrecke zeigt zunächst den Beispielaufruf im Hauptprogramm: Es soll Eins und Eins addiert werden. Diese beiden Einsen müssen zuerst auf den Stack gelegt werden. Dann wird die Routine zum Addieren aufgerufen. Der call-Befehl legt dann intern die Rücksprungadresse des Befehlszeigers auf den Stapel und setzt den Befehlszeiger auf den Beginn der Addierroutine. Dort wird das EBP auf den Stack gerettet und anschliessend mit dem aktuellen Wert von ESP überschrieben.

Die nächste Anweisung ist nun interessant: Es werden einfach 4 Byte vom Stackzeiger abgezogen. Da der Stack mit push im Adressraum absteigt, bedeutet das: Diese vier Bytes sind durch diese simple Subtraktion reserviert. So werden lokale Variablen in Assembler behandelt, denn diese Subtraktion ist die Übersetzung der C-Anweisung

int z;

Weiter unter folgt der Zugriff auf die lokale Variable:

mov [ebp-4],eax ; Ergebnis in lokaler Variable z speichern

Die Variable wird also mit indirekter Adressierung über ihren, dem Assembler bekannten Offset angesprochen.

Genau derselbe Stapel, der bei Unterprogrammaufrufen zum Merken der Rücksprungadressen dient, wird also auch für die Verwaltung von Prozedurparametern und von lokalen Variablen verwendet. Damit ist sichergestellt, dass diese Variablen in der aktuellen Aufrufebene, aber nicht in höheren Ebenen verfügbar sind, denn beim Verlassen der Routine wird der Stackzeiger ja mitsamt den Übergabeparametern abgebaut.

Was passiert nun bei einem Unterprogrammaufruf im Unterprogramm? Auf den Stack kommen weitere Parameter, lokale Variablen und die Rücksprungadresse. Die Daten des rufenden Unterprogramms sind aber alle auch noch auf dem Stack und daher im Prinzip verfügbar. Wenn das aufrufende Unterprogramm dem aufgerufenen Unterprogramm die Adressen seiner lokalen Daten und Aufrufparameter mitteilen würde, könnte dieses auch darauf zugreifen. Das wäre eine Parameterübergabe nach dem Muster "call by reference", wie es in ABAP für alle Prozeduraufrufe der Default ist.

Parameter und lokale Variablen auf dem Stack

Dieses Beispiel zeigt, dass die Redeweise, lokale Variablen und Prozedurparameter "leben auf dem Stack" wörtlich zu nehmen ist. Die Bindung an den Aufrufstack hat viele Vorteile, die die lokalen Variablen vor Alternativen wie statischen oder globalen Variablen auszeichnen. Beispielsweise sind lokale Variablen automatisch "thread-safe": Denn der Aufrufstack ist so spezifisch für einen Thread, dass er sich niemals mit anderen Threads teilen lässt. Ausserdem gibt es niemals ein Reentrance-Problem: Der Inhalt lokaler Variablen hängt niemals von der Aufrufhistorie ab. Wenn die lokalen Variablen wie in ABAP beim Eintritt in die Prozedur implizit initialisiert werden, kann ihr Wert nur durch den Code der Prozedur selbst verändert werden, ist also zu jedem Zeitpunkt klar nachvollziehbar.

[1] Für Funktionsgruppen hat sich in der ABAP-Welt die Sprechweise eingebürgert, ihre globalen Daten machen das "globale Gedächtnis" der Funktionsgruppe aus.
[2]Im x86 Assembler wird subtrahiert, da der Stack "von oben nach unten" wächst, eine push-Operation also den Stackzeiger erniedrigt. Aber das sind Implementierungsdetails.
[3]oder, wie ich es gemacht habe, die Routine gleich in Assembler codiert.
[4] Ausnahme bilden hier die mit call screen aufgerufenen Dialoge, die in einer normalen Transaktion aber die Ausnahme darstellen.

Keine Kommentare :