Wie Programme funktionieren (1a): Wert und Referenz, Seiteneffekte, Stack und Heap

Track Konzepte · Schicht 1: Sprachen und Laufzeit · ca. 50 Min.

Worum es geht

Eine Funktion bereinige(nutzer) soll Tags kleinschreiben. Danach zeigt der Audit-Report plötzlich auch die kleingeschriebenen Tags der Originaldaten, obwohl niemand sie ändern wollte. Das ist kein exotischer Fehler, sondern der häufigste Fehler in Datenpipelines: Aliasing (aliasing, zwei Namen zeigen auf dasselbe Objekt) kombiniert mit einem Seiteneffekt (side effect, die Funktion ändert etwas außerhalb ihres Rückgabewerts).

Diese Lektion klärt das Fundament dahinter. Es ist sprachunabhängig: Du kennst es aus JS/TS, und es gilt genauso in Python, Java, Go oder Rust.

Am Ende von Teil a kannst du:

  • erklären, warum eine Funktion ein Objekt verändert, das sie nur lesen sollte, und das reparieren (Übung 1),
  • den Unterschied zwischen Stack (Aufruf-Stapel) und Heap (Halde) erklären und Rekursionstiefe und Objektlebensdauer vorhersagen (Übung 2),
  • eine unreine Funktion rein machen (Übung 3).

Teil b (Fehlerbehandlung und Ressourcen) behandelt Exception, Result-Typ, Fail Fast und Kontextmanager.

Nicht Thema: Scope und Closures (siehe Python 1), die Default-Falle (ebenfalls dort), Referenzen und Kopien in Python im Detail (siehe Python 3 und Python 13a). Hier steht die sprachunabhängige Sicht darauf.

Plane ehrlich 50 Minuten ein: etwa 20 Minuten Lesen (drei Themen, viel Code zum Mitlaufen), 28 Minuten Übungen (drei Stück).

Von JS/TS her gedacht

Beide Sprachen verhalten sich bei Parametern gleich. Das Ergebnis ist in beiden dasselbe, auch wenn die Sprachbeschreibung andere Wörter benutzt:

Frage JS/TS Python
Was bekommt eine Funktion übergeben? Kopie des Werts bei Zahlen, Strings, Booleans; Kopie der Referenz bei Objekten und Arrays immer eine Kopie der Referenz auf ein Objekt (bei Zahlen und Strings ist das Objekt unveränderlich, darum merkt man es nicht)
Parameter neu zuweisen wirkt nur in der Funktion wirkt nur in der Funktion
Objekt über den Parameter ändern wirkt außen wirkt außen
Flache Kopie {...obj}, [...arr] dict(d), d.copy(), l[:]
Tiefe Kopie structuredClone(obj) copy.deepcopy(obj)
Unveränderlich machen Object.freeze (nur flach) tuple, frozenset, @dataclass(frozen=True)

Das Verhalten in beiden Sprachen, in JavaScript mit Node ausgeführt:

function setzeNeu(l) { l = [9]; }
function haengeAn(l) { l.push(9); }
const a = [1];
setzeNeu(a); console.log(a);
haengeAn(a); console.log(a);

Ausgabe:

[ 1 ]
[ 1, 9 ]

Dasselbe in Python:

Ausgabe: [1] und [1, 9]. Die Funktion bekommt eine Kopie des Pfeils, nicht des Objekts. l = [9] biegt nur den Pfeil der Funktion um. l.append(9) folgt dem Pfeil und ändert das gemeinsame Objekt. Man nennt das Call by Sharing (Übergabe durch Teilen).

Konzept

Schritt 1: Wert, Referenz und Aliasing

Ein Wert (value) ist der Inhalt selbst. Eine Referenz (reference) ist ein Pfeil auf ein Objekt im Speicher. Wer eine Referenz kopiert, hat danach zwei Pfeile auf ein Objekt: Das ist Aliasing. Ändert eine Seite das Objekt, sieht die andere Seite die Änderung. Das ist praktisch (kein Kopieren großer Daten) und gefährlich (Änderungen an unerwarteter Stelle).

Die Pipeline aus der Einleitung, mit Python nachgebaut. Der Entwickler weiß, dass er nicht das Original ändern soll, und macht eine Kopie. Eine flache Kopie (shallow copy) kopiert nur die oberste Ebene, die inneren Listen bleiben dieselben Objekte:

Ausgabe: ['admin', 'de'] ['admin', 'de']. Beide Namen zeigen auf dieselbe tags-Liste. Dasselbe passiert in JS mit {...obj}:

const bestellung = { id: "B1", positionen: [{ artikel: "Stift", cent: 200 }] };
const flach = { ...bestellung };
flach.positionen[0].cent = 1;
console.log(bestellung.positionen[0].cent);
const tief = structuredClone(bestellung);
tief.positionen[0].cent = 999;
console.log(bestellung.positionen[0].cent, tief.positionen[0].cent);

Ausgabe: 1 (das Original wurde über die flache Kopie verändert) und danach 1 999 (die tiefe Kopie ist unabhängig).

Es gibt drei Wege aus dem Problem:

  1. Tief kopieren (deepcopy, structuredClone), dann gefahrlos ändern. Einfach, kostet Zeit und Speicher bei großen Daten.
  2. Neu aufbauen, statt zu ändern: Die Funktion liefert neue Objekte und fasst die Eingabe nur lesend an (nächster Schritt).
  3. Unveränderliche Datenstrukturen (immutable) verwenden, bei denen Ändern gar nicht möglich ist.

Schritt 2: Seiteneffekte, Reinheit, Immutability

Ein Seiteneffekt ist alles, was eine Funktion außer dem Zurückgeben ihres Ergebnisses tut: Eingabe ändern, globale Variable setzen, in eine Datei schreiben, loggen, die Uhr oder den Zufall lesen. Eine reine Funktion (pure function) hat keine Seiteneffekte und hängt nur von ihren Argumenten ab. Dann gilt referentielle Transparenz (referential transparency): Du kannst den Aufruf überall durch sein Ergebnis ersetzen, ohne dass sich das Programm ändert. Das macht Code leichter zu verstehen, zu testen (gleiche Eingabe, gleiches Ergebnis) und zu parallelisieren.

Die Reparatur der Pipeline, ohne Seiteneffekt: Die Funktion baut neue Objekte, auch für die verschachtelten Listen.

Ausgabe: ['admin', 'de'] ['Admin', 'DE']. Zur Immutability: Statt Disziplin (“ich ändere die Eingabe nicht”) lässt du die Sprache es verbieten. In Python mit einer eingefrorenen Dataclass und Tupeln:

Ausgabe: FrozenInstanceError, dann Nutzer(name='Ayse', tags=('Admin', 'DE')) Nutzer(name='Ayse', tags=('admin', 'de')). Statt zu ändern, erzeugst du eine neue Version (replace). Vorsicht: Unveränderlich ist nur die oberste Ebene. Ein Tupel mit einer Liste darin lässt sich in der Liste weiter ändern, und Object.freeze in JS friert ebenfalls nur flach ein. In JS ausgeführt: Nach eingefroren.a = 5 und eingefroren.inner.b = 7 steht a weiter auf 1, aber inner.b auf 7.

Unreine Funktionen reinmachen: Die versteckten Eingaben (Uhr, Zufall, globaler Zustand) werden zu Parametern. Das nennt man Dependency Injection im Kleinen. Ein Token-Check mit Uhr:

Ausgabe: False True False, dann 6 6, dann 2 3. Gleiche Eingabe gibt bei den reinen Funktionen immer das gleiche Ergebnis, bei mit_zustand nicht. Die Version mit time.time() im Funktionskörper könntest du nur testen, indem du die Uhr austrickst. Reine Funktionen testest du mit einer Zeile.

Dazu gehört ein Muster, das du als JS-Entwickler täglich nutzt: Higher-order Functions (Funktionen höherer Ordnung) nehmen Funktionen als Parameter oder liefern sie zurück (map, filter, sorted(key=...)). Sie sind der Grund, warum Verhalten injizierbar ist: Du gibst nicht nur Daten, sondern auch Verhalten hinein.

Die Praxisregel: Reinen Kern, unreine Hülle. Berechnungen als reine Funktionen schreiben, Lesen, Schreiben und Netzwerk am Rand halten. Nicht jede Funktion muss rein sein (ein Logger ist es nie), aber du solltest wissen, welche es nicht sind.

Schritt 3: Speicher: Stack, Heap und Lebensdauer

Ein Programm nutzt zwei Bereiche:

  • Stack (Aufruf-Stapel): Pro Funktionsaufruf liegt ein Stack Frame (Aufruf-Rahmen) oben auf dem Stapel mit den lokalen Variablen und der Rückkehradresse. Kehrt die Funktion zurück, verschwindet der Frame sofort. Schnell, aber klein: Die Tiefe ist begrenzt, und zu tiefe Rekursion löst einen Stack Overflow aus (in Python vorher eine RecursionError).
  • Heap (Halde): Hier liegen Objekte, die länger leben als ein Aufruf oder deren Größe nicht feststeht (Listen, Dicts, Instanzen). Größer, aber jemand muss entscheiden, wann ein Objekt freigegeben wird.

In Python und JS liegen alle Objekte auf dem Heap. Auf dem Stack liegen nur die Namen, also die Pfeile. Das erklärt das Verhalten aus Schritt 1: Ein Funktionsaufruf kopiert den Pfeil auf den Stack, das Objekt bleibt auf dem Heap.

Du kannst die Stack-Frames in Python zählen. tiefe() läuft die Kette der Aufrufer zurück:

Ausgabe: 1 4. Bei ebene(3, ...) sind vier ebene-Frames gleichzeitig offen. Eine Rekursion der Tiefe n hält n Frames offen. Python begrenzt das mit einem Rekursionslimit (CPython-Standard 1000, lokal und in Pyodide 0.28.1 geprüft). Wir setzen es hier eng relativ zur aktuellen Tiefe, damit das Ergebnis überall gleich ist:

Ausgabe: RecursionError | maximum recursion depth exceeded. Der Ausweg bei tiefen Problemen ist eine Schleife (kein Frame pro Durchlauf) oder ein eigener Stapel (eine Liste auf dem Heap).

Lebensdauer auf dem Heap: Wann stirbt ein Objekt? Es gibt drei Strategien:

Strategie Wer gibt frei? Beispiele Preis
Garbage Collection (Speicherbereinigung) die Laufzeit, automatisch JS (Tracing GC), Python (Referenzzählung plus Zyklenerkennung), Java, Go Pausen oder Zusatzaufwand, Zeitpunkt nicht exakt vorhersehbar
Manuelle Verwaltung du, mit malloc und free C Vergessenes Freigeben (Leak), doppeltes oder zu frühes Freigeben (Use after free)
Ownership (Besitz) der Compiler: ein Besitzer pro Wert, Freigabe am Ende seines Scopes, Regeln zur Compile-Zeit geprüft Rust (allgemeines Fachwissen, hier nicht ausgeführt) strengere Programmierregeln, dafür kein GC und keine Use-after-free-Fehler

CPython zählt pro Objekt, wie viele Referenzen darauf zeigen (Referenzzählung, reference counting). Fällt der Zähler auf 0, wird das Objekt sofort freigegeben. Mit weakref (eine Referenz, die das Objekt nicht am Leben hält) kannst du beobachten, ob ein Objekt noch lebt:

Ausgabe: 1, dann False, False, True. sys.getrefcount zählt sich selbst mit (der Parameter ist eine weitere Referenz). Lokal liefert getrefcount(x) zuerst 2 und nach y = x 3. Absolute Zahlen hängen von der Python-Version ab (seit 3.12 haben einige Objekte feste, riesige Zähler, allgemeines Fachwissen), darum vergleichen wir nur die Differenz. Die Reihenfolge der Ausgaben: Solange s oder t noch zeigt, lebt das Objekt (w() liefert es, is None ist False). Mit del t verschwindet der letzte Pfeil, der Zähler fällt auf 0, w() liefert None. Das ist kein Heap-Trick, sondern die Regel: Ein Objekt lebt, solange mindestens eine Referenz darauf zeigt, nicht so lange wie die Funktion läuft, die es erzeugt hat.

Das gilt auch für Referenzen, die du nicht sofort siehst. Eine Closure (eine innere Funktion, die Variablen der äußeren benutzt) trägt diese Variablen mit sich herum, auch nachdem die äußere Funktion zurückgekehrt ist. In JS kennst du das aus jedem Hook und jedem Event-Handler. Ein kleines Beispiel in Python:

import weakref

class Daten:
    pass

def baue():
    d = Daten()
    spur = weakref.ref(d)
    def lies():
        return d              # die innere Funktion hält d fest
    return lies, spur

lies, spur = baue()
print(spur() is None)         # False: baue() ist zurückgekehrt, aber lies() hält d
del lies
print(spur() is None)         # True: jetzt zeigt nichts mehr auf d

Ausgabe: False, dann True. Das ist die Denkweise für Frage 4 und 5 in Übung 2.

Referenzzählung hat eine Lücke: Zyklen. Zeigen zwei Objekte aufeinander, ist ihr Zähler nie 0. Darum hat CPython zusätzlich einen Zyklenerkenner (gc):

Ausgabe: False, dann True True. Nach del leben beide noch, bis gc.collect() den Zyklus aufräumt. Der Aufräumzeitpunkt ist in der Praxis darum nicht verlässlich genug, um darauf Ressourcen (Dateien, Verbindungen) zu stützen. Dazu mehr in Schritt 5.

Falle

  1. Flache Kopie für tief gehalten. {...obj}, dict(d) und l[:] kopieren nur die oberste Ebene. Wer verschachtelt ändert, ändert das Original (Übung 1).
  2. Parameter verändern, “weil es ja nur eine Kopie ist”. Die Funktion bekommt eine Kopie des Pfeils, nicht des Objekts. append, sort und Zuweisung auf Einträge sind sichtbare Seiteneffekte (Übungen 1 und 3).
  3. Rekursion statt Schleife bei unbekannter Tiefe. Stack Frames sind begrenzt (Übung 2).
  4. Annehmen, ein Objekt lebe bis zum Ende der Funktion. Es lebt, solange eine Referenz existiert, und sei es nur in einer Closure oder einer Liste (Übung 2).
  5. Versteckte Eingaben. Uhr, Zufall und globaler Zustand im Funktionskörper machen Tests unzuverlässig (Übung 3).

Übungen

Übung 1: Pipeline verändert ihre Eingabe (ca. 10 Min.)

Ein Shop gibt Rabatt auf Bestellungen. Die Funktion rabatt_anwenden(bestellungen, prozent) ändert in der Eingabe die Preise (in Cent) und liefert dieselbe Struktur zurück. Dadurch zeigt der Report, der später die Originalpreise lesen will, plötzlich die rabattierten Preise. Das ist das Aliasing aus Schritt 1.

Repariere die Funktion: Sie darf die Eingabe nicht verändern und liefert neue Bestellungen mit den rabattierten Preisen, gerechnet mit ganzzahliger Division: cent * (100 - prozent) // 100. Jede Bestellung ist ein Dict mit "id" und "positionen", jede Position ein Dict mit "artikel" und "cent". Eine Bestellung kann mehrere Positionen haben.

Der Check ruft die Funktion zweimal auf, prüft, dass die Eingabe danach unverändert ist, und dass sich auch das Ergebnis nachträglich ändern lässt, ohne die Eingabe zu berühren. Die letzte Zeile gibt die Funktion zurück.

Zähle die Ebenen der Daten: Liste, Dict, Liste, Dict. Auf welcher Ebene muss ein neues Objekt entstehen, damit nichts mehr mit der Eingabe geteilt wird? Welche der drei Auswege aus Schritt 1 passen hier, und wie viele davon brauchen Kopier-Befehle?

def rabatt_anwenden(bestellungen, prozent):
    return [
        {**b, "positionen": [{**p, "cent": p["cent"] * (100 - prozent) // 100}
                             for p in b["positionen"]]}
        for b in bestellungen
    ]

rabatt_anwenden

Alternativ: import copy, dann neu = copy.deepcopy(bestellungen), die Schleife auf neu anwenden und neu zurückgeben. Beide Varianten erzeugen auch für die inneren Listen und Dicts neue Objekte. [dict(b) for b in bestellungen] allein reicht nicht, weil positionen dieselbe Liste bliebe.

Übung 2: Stack, Heap und Lebensdauer vorhersagen (ca. 10 Min.)

Lies den Code, ohne ihn auszuführen, und sage fünf Dinge vorher. laeuft(f, n) ruft f(n) auf, mit einem engen Rekursionslimit (höchstens 50 weitere Stack Frames über der aktuellen Tiefe), und liefert True, wenn es ohne RecursionError durchläuft.

import sys, weakref

def tiefe():                          # zählt die offenen Stack Frames
    n, f = 0, sys._getframe()
    while f:
        n, f = n + 1, f.f_back
    return n

def summe_rekursiv(n):
    return 0 if n == 0 else n + summe_rekursiv(n - 1)

def summe_schleife(n):
    gesamt = 0
    for i in range(1, n + 1):
        gesamt += i
    return gesamt

def laeuft(f, n):
    alt = sys.getrecursionlimit()
    sys.setrecursionlimit(tiefe() + 50)
    try:
        f(n)
        return True
    except RecursionError:
        return False
    finally:
        sys.setrecursionlimit(alt)

class Puffer:
    pass

spur = []

def baue():
    daten = Puffer()
    spur.append(weakref.ref(daten))
    def lies():
        return daten
    return lies

Gib ein Tupel aus fünf Wahrheitswerten zurück:

  1. laeuft(summe_rekursiv, 10)
  2. laeuft(summe_rekursiv, 100)
  3. laeuft(summe_schleife, 100_000)
  4. nach lese = baue(): ist spur[0]() is None?
  5. danach del lese: ist jetzt spur[0]() is None?

Der Check führt denselben Ablauf selbst aus und vergleicht.

Frage 1 bis 3: Wie viele Stack Frames sind gleichzeitig offen, und was zählt eine Schleife? Frage 4 und 5: Der Frame von baue ist nach der Rückkehr weg. Wer hält danach noch einen Pfeil auf das Objekt auf dem Heap, und wann fällt der Zähler auf 0?

antwort = (True, False, True, False, True)
antwort

summe_rekursiv(10) braucht 11 Frames, unter dem Limit. Bei 100 sind es 101 Frames, über dem Limit, also RecursionError. Die Schleife legt keine neuen Frames an. Nach baue() ist der Frame weg, aber die Closure lies hält das Puffer-Objekt auf dem Heap, es lebt (False). Nach del lese fehlt die letzte Referenz, der Zähler fällt auf 0, und das Objekt wird freigegeben (True).

Übung 3: Eine unreine Funktion reinmachen (ca. 8 Min.)

Ein Gutschein-Dienst prüft, ob ein Gutschein gerade einlösbar ist. Die Funktion liest die Uhrzeit aus einer globalen Variable UHR und zählt dabei auch noch Abfragen mit. Das sind eine versteckte Eingabe und ein Seiteneffekt (Schritt 2). Dadurch lässt sich die Funktion nur testen, indem man erst die globale Uhr umstellt.

Mach sie rein: Neue Signatur gutschein_gueltig(gutschein, jetzt). Ein Gutschein ist ein Dict mit "von", "bis" und "eingeloest" (ein bool). Er gilt, wenn von <= jetzt < bis ist und er noch nicht eingelöst wurde. Die Funktion liefert True oder False. Sie darf weder UHR lesen noch ändern, noch den Gutschein verändern, noch sonst etwas außerhalb von sich verändern (kein Zähler, keine Ausgabe). Die letzte Zeile gibt die Funktion zurück.

Suche in der Funktion jede Stelle, die etwas anderes als die Parameter benutzt. Was davon ist eine Eingabe, die zum Parameter werden kann, und was ist ein reiner Seiteneffekt, der wegfallen muss?

def gutschein_gueltig(gutschein, jetzt):
    return gutschein["von"] <= jetzt < gutschein["bis"] and not gutschein["eingeloest"]

gutschein_gueltig

Die Uhr wird zum Parameter jetzt, das Mitzählen entfällt. Wer die Abfragen zählen will, macht das in der unreinen Hülle um die Funktion herum, nicht im Kern.

Merksatz

Ein Objekt lebt auf dem Heap, solange eine Referenz darauf zeigt, und wer eine Referenz kopiert, teilt das Objekt. Darum ändern Funktionen ihre Eingabe nicht: reine Kerne, neue Objekte statt Mutation.

Prüfstein

  1. Warum verändert eine Funktion plötzlich ein Objekt, das sie nur lesen sollte? Nenne die zwei Gründe (Aliasing und Mutation) und drei Auswege.
  2. Was ist der Unterschied zwischen Stack und Heap? Wo liegen in Python die Objekte, wo die Namen, und was passiert bei zu tiefer Rekursion?

Weiter mit Teil b: Fehlerbehandlung und Ressourcen.


Quelle: quellen/konzeptuebersicht-software-grundlagen.md, Abschnitt “1. Wie Programme funktionieren” (Wert vs. Referenz, Speicher: Stack und Heap, Garbage Collection vs. manuelle Verwaltung vs. Ownership, Side Effects, Purity, Immutability; Prüfstein zu Wert und Referenz bzw. Stack und Heap). quellen/konzeptuebersicht-software-fortgeschritten.docx, Abschnitt “1. Sprachen, Typen und Laufzeit” (Funktionale Muster: Higher-order Functions, Referential Transparency).

Über die Quelle hinaus (allgemeines Fachwissen): die Erklärung von Call by Sharing, flacher und tiefer Kopie, die Beschreibung von Stack Frames und Heap, die Tabelle zu Garbage Collection, manueller Verwaltung und Ownership (Rust nicht ausgeführt), die Erklärung von Referenzzählung und Zyklenerkennung in CPython, die Aussage zum Rekursionslimit (Standardwert 1000, lokal und in Pyodide 0.28.1 geprüft). Alle Zahlen und Ausgaben in dieser Lektion stammen aus dem Ausführen des Codes mit Python 3 (lokal 3.13.9) bzw. Node (20).