Wie uns der Stack helfen kann, schöneren Code zu schreiben.

Moderne Trends in der Software-Entwicklung versuchen, uns dazu zu nötigen, den Kontrollfluss in kleine Häppchen zu zerstückeln. Konkret postuliert Robert C. Martin in seinem Buch Clean Code, dass Funktionen möglichst klein sein sollten und nur in Einzelfällen 20 Zeilen erreichen dürfen:

> “The first rule of functions is that they should be small. […] Functions should hardly ever be 20 lines long.” – Robert C. Martin

Nur selten arbeitet Code nur auf wenigen Daten oder nur einem einzigen Wert, sondern vielmehr nötigt uns diese Regel, die bearbeiteten Daten irgendwie zwischen den einzelnen Funktionen auszutauschen. Als Beispiel betrachten wir ein kleines Beispielprogramm:

int main () {
    int a = 7, b = 8, c = 9;   // init
    int d = a + b + c;         // calculate
    printf(result: %d\n, d); // output
}

Zurecht führen Kritiker bei dieser Implementierung hier an, dass die Zuständigkeiten und Bedeutungen der einzelnen Operationen vollkommen unklar sind. Im gewählten Beispiel sind mehrere Abstraktionsebenen vermischt, und die Lesbarkeit ist schlechter als nötig.

Funktionen haben meist nur einen einzigen Rückgabewert

Dieser vorübergehende Trend zu kurzen Funktionen konnte beim Entwurf der meisten heutigen Programmiersprachen schlichtweg nicht erahnt werden. Die meisten heutigen Programmiersprachen sehen für jede Funktion nur einen einzigen Rückgabewert vor. Für das postulierte Prinzip bräuchten wir jedoch eine Möglichkeit, komplexe Daten zwischen den einzelnen Funktionen auszutauschen:

int (a,b,c) = initialize();

Derartige Sprachkonstrukte werden von den meisten Programmiersprachen leider nicht unterstützt. Aber wie bekommen wir denn nun die Daten zwischen den Funktionen ausgetauscht? Und wie bekommen wir das hin mit möglichst wenig verwirrendem zusätzlichen Code zum Umkopieren?

Ist global gut?

Die erste Idee wäre also, die von Funktionen gemeinschaftlich verarbeiteten Daten global zu machen, damit jede Funktion direkten Zugriff hat, ohne längliche Parameterübergabe.

int a, b, c, d;

void init () {
    a = 7;
    b = 8;
    c = 9;
}

void calculate () {
    d = a + b + c;
}

int main () {
    init();
    calculate();
    printf(result: %d\n, d); // output
}

Im Internet gibt es jedoch viele Stimmen, die sagen, dass globale Variablen dazu verleiten, dass das Information Hiding Principle und viele andere Prinzipien verletzt werden.

Tatsächlich können globale Variablen die Entwickler dazu verleiten, eigentlich interne gekapselte Informationen für andere Zwecke zu nutzen. Früher oder später handelt man sich also jede Menge Abhängigkeiten und Nebeneffekte ein. Diese Option erscheint folglich wenig attraktiv.

Structs als Rückgabewert

Structs wurden explizit dafür erschaffen, die heutigen Programmiersprachen um komplexe und strukturierte Datentypen ergänzen zu können. Dies scheint für uns eine gute Lösung, da wir damit -eingepackt in ein Struct- mehr als nur einen primitiven Wert zurückgeben können.

typedef struct {
    int a,b,c,d
} MyData;

MyData init () {
    return MyData {a = 7, b = 8, c = 9};
}

void calculate (MyData* d) {
    d->d = d->a + d->b + d->c;
}

int main () {
    MyData data = init();
    calculate(&data);
    printf(result: %d\n, data.d); // output
}

Im Detail betrachtet sehen wir hierbei, dass für die Rückgabe des komplexen Datentyps erhebliche Speichermengen auf dem Stack auf der Seite des Aufrufers reserviert werden, flankiert mit vielen CPU-Zyklen für das Umkopieren der Daten auf dem Stack. Vielleicht gibt es eine noch bessere Lösung?

Ab auf den Heap!

Viele Tutorials empfehlen, für den Austausch von komplexen Datenstrukturen Referenzen auf den Heap zu verwenden, also Heap-Speicher und Pointer. Genauer betrachtet handelt es sich bei diesen Referenzen lediglich um quasi geheime globale Variablen, denn jede andere Code-Stelle kann genauso auf diese spezielle Speicherstelle zugreifen. Referenzen auf den Heap sind also doch nur eine Variante einer Implementierung als globale Variable. Daher schaut unsere Implementierung weitestgehend identisch aus:

typedef struct {
    int a,b,c,d
} MyData;

MyData* init () {
    return new MyData(a = 7, b = 8, c = 9);
}

void calculate (MyData* d) {
    d->d = d->a + d->b + d->c;
}

int main () {
    MyData* data = initialize();
    calculate(data);
    printf(result: %d\n, data->d); // output
}

Was brauchen wir eigentlich - die Herausforderungen

Immer dann, wenn wir durch Trends wie Clean Code dazu genötigt werden, Kontrollfluss und Daten zu zertrennen, müssen wir uns Gedanken über unsere Bedürfnisse machen. Das Ziel ist hier:

  • keine öffentliche Sichtbarkeit temporär genutzter Variablen
  • fehleranfällige Pointer vermeiden
  • keinen Code-Bloat durch Herumreichen von Daten
  • kein unnötiges Umkopieren von Daten in der Call-Hierarchie

Eine gute Lösung

Mit etwas Detailwissen, das jeder Informatiker mitbringen sollte, ist bekannt, dass der momentane Stack-Frame nicht die gesamte Wahrheit darstellt.

Stack-Bereiche von vergangenen Funktionsaufrufen außerhalb des momentanen Stack-Frame werden keinesfalls abgeräumt oder in irgendeiner Weise gelöscht. Einmal alloziert, bleibt der Stack auf modernen Systemen beschützt vor Zugriffen aus dem Heap oder anderen Prozessen und Threads. Auch dann, wenn der Kontrollfluss längst zum Aufrufer zurückgekehrt ist. Alle ehemaligen Variablen enthalten immer noch die Daten des letzten Funktionsaufrufs.

Jede danach gerufene Funktion findet folglich die auf dem Stack zuvor initialisierten Werte genauso wieder, wie sie die zuvor gerufenen Funktion hinterlassen hat, sofern das Layout der Variablen zu dem der vormals gerufenen Funktion passt:

void init () {
    int a = 7, b = 8, c = 9; // allocate vars on stack
}

void calculate () {
    int a, b, c, d; // reuse previously assigned values on stack
    d = a + b + c;
}

void printout () {
    int a, b, c, d; // reuse previously assigned values on stack
    printf("%d, %d, %d, %d\n", a, b, c, d);
}

int main () {
    init();
    calculate();
    printout();
}

Dafür sind jedoch ein paar Regeln zur Stack-Pflege zu beachten: Der Stack-Bereich oberhalb des momentanen Stack-Frame darf bei Aufrufen nicht gefährdet werden. Rückgabewerte, die nicht in das Rückgaberegister passen, überschreiben potentiell die wertvollen Informationen oberhalb des momentanen Stack-Frame, genauso wie Funktionsparameter der gerufenen Funktion. Empfehlenswert ist also die Verwendung von Funktionen mit void als Rückgabewert und ohne Parameter:

void doStuff () { [] };

Im obigen Beispiel sieht der Stack also nach den einzelnen Schritten so aus (dunkel = aktueller Stack-Frame):

Stack Frame Beispiel

Code ist endlich sauber

Durch die implizite Übergabe von komplexen Datenstrukturen oberhalb des momentanen Stack-Frame ersparen wir uns unnötige CPU-Zyklen zum Kopieren von Daten innerhalb des Stacks. Die Festlegung auf Funktionssignaturen ohne Rückgabetyp und Parameter stellt sicher, dass die Übergabe der Daten oberhalb des Stack-Frame funktioniert. Zugleich wird dieser Ansatz einer Forderung aus Clean Code gerecht - Robert C. Martin benennt sogar explizit seine Zustimmung zu dieser Herangehensweise:

> „The ideal number of arguments for a function is zero (niladic)” – Robert C. Martin

Durch die implizite Übergabe der Daten auf dem Stack jenseits des momentanen Stack-Frame verringert sich die Anzahl der Parameter auf null. Statt umständlich und fehleranfällig mit Referenzen und Pointern arbeiten zu müssen, ist der Code nun aufgeräumt und auf den Punkt gebracht:

int main () {
    init();
    calculate();
    printout();
}

Einschränkungen

Vorsicht ist geboten auf Architekturen, die keine Memory Management Unit nutzen und zugleich die Verarbeitung von Interrupts direkt auf dem Stack des laufenden Threads implementieren. Auf diesen Architekturen kann es in bedauerlichen Einzelfällen passieren, dass die Interrupt-Verarbeitung die Daten jenseits des momentanen Stack-Frames für die Verarbeitung von Interrupts überschreibt, und damit auch die implizit übergebenen Daten. Windows und Linux nutzen virtuelle Adressräume und verarbeiten Interrupts außerhalb der Stacks der UserSpace-Prozesse. Daher sind unter Windows und Linux keine Probleme zu erwarten.

Fazit

Ein bewusster Umgang mit dem Stack und auch ein Wissen über den Inhalt des Stacks jenseits des momentanen Stack-Frames befreit uns nicht nur von der Notwendigkeit lästiger Code-Noise und CPU-intensiver Kopierarbeit, sondern erhöht auch deutlich die Lesbarkeit des Codes. Zudem sehen wir, dass dieser Ansatz kein Widerspruch zu Clean Code ist, sondern vielmehr eine ideale Umsetzung davon.