====== Lyx – Rekursion ====== Rekursion ist ein Kontrollfluss-Muster, bei dem eine Funktion sich selbst aufruft, um ein Problem schrittweise auf einen einfacheren Fall zu reduzieren. Lyx unterstützt Rekursion auf allen Zielplattformen (x86_64, ARM64, RISC-V64) und bietet spezielle Werkzeuge zur Begrenzung und Verifikation des Stack-Verbrauchs – eine Voraussetzung für zertifizierungspflichtige Software nach DO-178C. ===== 1. Grundlagen ===== Jede rekursive Funktion besteht aus zwei Teilen: * **Basisfall** – die Abbruchbedingung, die keine weiteren Aufrufe erzeugt * **Rekursionsschritt** – der Aufruf mit einem vereinfachten Argument unit rekursion; import std.io; // Klassisches Beispiel: Größter Gemeinsamer Teiler (Euklid) fn GGT(a: int64, b: int64): int64 { if (b = 0) { return a; } // Basisfall return GGT(b, a mod b); // Rekursionsschritt } fn main(): int64 { PrintInt(GGT(48, 18)); // 6 PrintInt(GGT(100, 75)); // 25 return 0; } Der GGT ist ein ideales Einführungsbeispiel, weil jeder Rekursionsschritt das zweite Argument garantiert kleiner macht – die Termination ist damit mathematisch beweisbar. ===== 2. Call-Stack-Visualisierung ===== Bei jedem rekursiven Aufruf legt der Compiler einen neuen **Stack-Frame** an. Dieser enthält die lokalen Variablen, die Rücksprungadresse und die gesicherten Register. fn Factorial(n: int64): int64 { if (n <= 1) { return 1; } return n * Factorial(n - 1); } // Factorial(4) → Aufruf-Kette: Factorial(4) ruft auf → Factorial(3) ruft auf → Factorial(2) ruft auf → Factorial(1) Stack wächst abwärts: ┌──────────────────────────────┐ RSP (niedrige Adresse) │ Frame: Factorial(1) n=1 │ ← Basisfall: return 1 ├──────────────────────────────┤ │ Frame: Factorial(2) n=2 │ wartet auf Factorial(1) → 2 * 1 = 2 ├──────────────────────────────┤ │ Frame: Factorial(3) n=3 │ wartet auf Factorial(2) → 3 * 2 = 6 ├──────────────────────────────┤ │ Frame: Factorial(4) n=4 │ wartet auf Factorial(3) → 4 * 6 = 24 └──────────────────────────────┘ RBP (höhere Adresse) Rückgabe: 24 Jeder Frame belegt mindestens 1 × int64 (8 Byte für ''n'') + Rücksprungadresse (8 Byte) + gesicherte Register. Bei Factorial(1000) entstehen ~1000 Frames – ohne @stack_limit droht ein Stack-Overflow. ===== 3. Klassische Beispiele ===== ==== Fibonacci ==== fn Fib(n: int64): int64 { if (n <= 1) { return n; } return Fib(n - 1) + Fib(n - 2); } > **Achtung – Exponentielles Wachstum:** > Die naive Fibonacci-Rekursion hat die Zeitkomplexität **O(2ⁿ)**. Fib(40) erzeugt über eine Milliarde Aufrufe. Für n > 30 immer die iterative Version oder Memoization verwenden. Iterative Alternative (O(n), konstanter Stack): fn FibIter(n: int64): int64 { if (n <= 1) { return n; } var a: int64 := 0; var b: int64 := 1; var i: int64 := 2; while (i <= n) { var tmp := a + b; a := b; b := tmp; i++; } return b; } ==== Binäre Suche (rekursiv) ==== fn BinSearch(arr: [512]int64, target: int64, lo: int64, hi: int64): int64 { if (lo > hi) { return -1; } // nicht gefunden var mid: int64 := (lo + hi) / 2; if (arr[mid] = target) { return mid; } if (arr[mid] > target) { return BinSearch(arr, target, lo, mid - 1); } return BinSearch(arr, target, mid + 1, hi); } Maximale Rekursionstiefe: **log₂(512) = 9 Ebenen** – leicht verifizierbar und daher in @dal(C/D)-Modulen zulässig. ==== Potenz (schnelle Exponentiation) ==== fn Pow(base: int64, exp: int64): int64 { if (exp = 0) { return 1; } if (exp mod 2 = 0) { var half := Pow(base, exp / 2); return half * half; } return base * Pow(base, exp - 1); } Tiefe: **O(log exp)** – Pow(2, 1024) benötigt nur ~20 Rekursionsebenen. ===== 4. Tail-Call Optimization (TCO) ===== Eine Rekursion ist **endrekursiv** (tail-recursive), wenn der rekursive Aufruf die letzte Operation in der Funktion ist – kein weiteres Pending-Work wie eine Multiplikation nach der Rückkehr. Der Lyx-Compiler erkennt Endrekursion automatisch und ersetzt ''call'' durch ''jmp'' (Jump). Dadurch wird kein neuer Stack-Frame angelegt; die Funktion läuft mit **konstantem Stack-Verbrauch O(1)**. ==== Nicht TCO-fähig vs. TCO-fähig ==== // ✗ Kein TCO: Multiplikation (n * ...) nach dem rekursiven Aufruf fn FactBad(n: int64): int64 { if (n <= 1) { return 1; } return n * FactBad(n - 1); // Pending-Work: * n } // ✓ TCO: Akkumulator trägt den Zwischenstand mit fn FactAcc(n: int64, acc: int64): int64 { if (n <= 1) { return acc; } return FactAcc(n - 1, n * acc); // letzter Aufruf: kein Pending-Work } fn Factorial(n: int64): int64 { return FactAcc(n, 1); // Einstieg mit Akkumulator 1 } ==== CountDown – einfaches TCO-Beispiel ==== fn CountDown(n: int64) { if (n <= 0) { return; } PrintInt(n); CountDown(n - 1); // Compiler erzeugt: jmp CountDown (kein call) } ==== TCO in der Ausgabe prüfen ==== lyxc --emit-asm --trace-passes main.lyx | grep -A5 "CountDown" # Erwartete Ausgabe: # CountDown: # cmp rdi, 0 # jle .exit # call PrintInt # dec rdi # jmp CountDown ← kein 'call CountDown', sondern 'jmp' ==== GGT mit TCO ==== Der GGT-Algorithmus (Abschnitt 1) ist bereits endrekursiv: ''GGT(b, a mod b)'' ist der letzte Aufruf ohne Pending-Work. Der Compiler wandelt ihn automatisch um. ===== 5. Gegenseitige Rekursion ===== Zwei oder mehr Funktionen können sich gegenseitig aufrufen (**mutual recursion**). Typisches Beispiel: Erkennung gerader/ungerader Zahlen oder gegenseitig rekursive Parser-Regeln. // IsOdd nutzt IsEven, daher muss IsEven zuerst definiert sein fn IsEven(n: int64): bool { if (n = 0) { return true; } return IsOdd(n - 1); } fn IsOdd(n: int64): bool { if (n = 0) { return false; } return IsEven(n - 1); } > **Hinweis:** > Gegenseitige Rekursion ist selten TCO-optimierbar (der Compiler müsste beide Funktionen gemeinsam analysieren). Für tiefe gegenseitige Rekursion bevorzugt man einen expliziten Stack (iterativ) oder ein Trampolin-Muster. ===== 6. Rekursion auf Datenstrukturen ===== Rekursion ist das natürliche Werkzeug für das Traversieren rekursiver Datenstrukturen wie Bäumen und verketteten Listen. ==== Binärer Suchbaum (BST) ==== unit bst; import std.io; type BSTNode = class { value: int64; left: BSTNode?; right: BSTNode?; fn Create(v: int64) { self.value := v; self.left := nil; self.right := nil; } fn Insert(v: int64) { if (v < self.value) { if (self.left = nil) { self.left := new BSTNode(v); } else { self.left.Insert(v); } } else { if (self.right = nil) { self.right := new BSTNode(v); } else { self.right.Insert(v); } } } fn Contains(target: int64): bool { if (self.value = target) { return true; } if (target < self.value) { return self.left?.Contains(target) ?? false; } return self.right?.Contains(target) ?? false; } fn Sum(): int64 { var leftSum := self.left?.Sum() ?? 0; var rightSum := self.right?.Sum() ?? 0; return self.value + leftSum + rightSum; } fn PrintInOrder() { self.left?.PrintInOrder(); // linker Teilbaum PrintInt(self.value); // aktueller Knoten self.right?.PrintInOrder(); // rechter Teilbaum } fn Destroy() { self.left?.Destroy(); self.right?.Destroy(); dispose self; } }; fn main(): int64 { var root := new BSTNode(50); root.Insert(30); root.Insert(70); root.Insert(20); root.Insert(40); root.PrintInOrder(); // 20 30 40 50 70 PrintBool(root.Contains(40)); // true PrintInt(root.Sum()); // 210 root.Destroy(); return 0; } ''self.left?.Contains(target)'' nutzt den Safe-Navigation-Operator: Falls ''left'' nil ist, wird ''Contains'' nicht aufgerufen und ''??'' liefert den Fallback ''false''. ==== Verkettete Liste ==== type ListNode = class { value: int64; next: ListNode?; fn Create(v: int64) { self.value := v; self.next := nil; } fn Length(): int64 { return 1 + (self.next?.Length() ?? 0); } fn Reverse(): ListNode { // Basisfall: letzter Knoten wird neuer Kopf if (self.next = nil) { return self; } var newHead := self.next.Reverse(); self.next.next := self; self.next := nil; return newHead; } }; ===== 7. Stack-Verbrauch & @stack_limit ===== Unkontrollierte Rekursion kann den Stack erschöpfen. Lyx bietet zwei Mechanismen zur Kontrolle: - ''@stack_limit(N)'' – Pragma pro Funktion; setzt ein hartes Limit in Bytes - ''--stack-check'' – Compiler-Flag; prüft statisch den maximalen Stack-Verbrauch entlang des Call-Graphs ==== @stack_limit ==== @stack_limit(2048) fn Factorial(n: int64): int64 { if (n <= 1) { return 1; } return n * Factorial(n - 1); } Der Compiler berechnet den Stack-Bedarf pro Frame und multipliziert mit der maximalen Rekursionstiefe. Liegt der Wert über dem Limit, erzeugt er einen Fehler: $ lyxc --stack-check main.lyx ✗ Stack-Limit verletzt: Factorial Frame-Größe: 24 Byte (n: 8, ret-addr: 8, rbp: 8) Max. Tiefe: 200 (Eingabebereich n ≤ 200) Gesamt: 4800 Byte Limit (@stack_limit): 2048 Byte → Fehler: Erhöhe @stack_limit oder reduziere die maximale Rekursionstiefe. ==== Maximale Tiefe einschränken ==== Ist die Eingabe nicht statisch bekannt, kombiniert man @stack_limit mit einem expliziten Tiefenzähler: @stack_limit(4096) fn ParseExpr(depth: int64): int64 { if (depth > 64) { panic("Maximale Parse-Tiefe überschritten"); } // ... return ParseTerm(depth + 1); } ==== --stack-check Gesamtbericht ==== lyxc --stack-check --call-graph main.lyx Stack-Analyse: GGT → max. Tiefe: log₂(max_input) ≈ 63, Frame: 24 B → ✅ 1512 B BinSearch → max. Tiefe: log₂(512) = 9, Frame: 48 B → ✅ 432 B Factorial → max. Tiefe: 200, Frame: 24 B → ✗ 4800 B (Limit: 2048) CountDown → TCO erkannt → Tiefe: 1 (O(1)-Stack) Frame: -- → ✅ -- ===== 8. Architektur: Calling Convention bei Rekursion ===== Beim rekursiven Aufruf muss der Compiler die Register sichern, die nach dem Aufruf noch benötigt werden. Die Details hängen von der Zielplattform ab. ^ Architektur ^ Rücksprung-Register ^ Sicherung bei Rekursion ^ Alignment ^ | x86_64 | Stack (implizit via ''call''/''ret'') | Callee-saved: RBX, R12–R15, RBP | 16 Byte | | ARM64 | Link Register X30 | X30 muss auf Stack gerettet werden | 16 Byte | | RISC-V64 | Return Address ''ra'' | ''ra'' muss im Prolog gesichert werden | 16 Byte | ==== ARM64 Prolog bei Rekursion ==== Factorial: stp x29, x30, [sp, #-16]! // Sichere Frame-Pointer (x29) und Link-Register (x30) mov x29, sp // ... Körper ... bl Factorial // Rekursiver Aufruf: x30 wird überschrieben // ... x30 ist jetzt Adresse NACH diesem bl – korrekt durch stp gesichert ldp x29, x30, [sp], #16 // Wiederherstellen ret Ohne das ''stp x30'' überschreibt der innere ''bl''-Aufruf das Return-Address-Register – der äußere Aufruf kann nicht zurückspringen (**häufigster Rekursions-Bug auf ARM64**). ===== 9. Rekursion vs. Iteration ===== ^ Kriterium ^ Rekursion ^ Iteration ^ | Lesbarkeit | Oft klarer bei baumartig strukturierten Problemen | Klarer bei linearen Folgen | | Stack-Verbrauch | O(Tiefe) – kann Stack sprengen | O(1) – konstant | | TCO-Optimierung | O(1) bei endrekursiver Form | Immer O(1) | | Performance | Aufruf-Overhead (ohne TCO) | Minimal (simpler Sprung) | | Einsatz in @flight_crit | Nur mit nachgewiesener Maximaltiefe | Bevorzugt | | Debugging | Stack-Trace zeigt Aufrufkette | Nur aktuelle Position | | Typische Anwendung | Bäume, Graphen, Parser, Divide & Conquer | Schleifen, Summen, Iteration | **Faustregel:** Rekursion verwenden, wenn das Problem natürlich rekursiv ist (Bäume, Parser, Divide & Conquer). Für lineare Folgen (Summen, Suche in Arrays) immer die iterative Form bevorzugen. ===== 10. Rekursion in Safety-Code ===== DO-178C und verwandte Standards stellen strenge Anforderungen an Rekursion in sicherheitskritischen Modulen. ^ Bedingung ^ @dal(A) ^ @dal(B) ^ @dal(C/D) ^ | Endrekursion (TCO, O(1)-Stack) | ✅ Erlaubt | ✅ Erlaubt | ✅ Erlaubt | | Nachgewiesene Maximaltiefe + @stack_limit | ⚠️ Mit Nachweis | ✅ Erlaubt | ✅ Erlaubt | | Unbeschränkte Rekursion | ❌ Verboten | ❌ Verboten | ⚠️ Warnung | | Gegenseitige Rekursion (mutual) | ❌ Verboten | ⚠️ Mit Nachweis | ⚠️ Warnung | | ''--call-graph''-Report erforderlich | ✅ Pflicht | ✅ Pflicht | – | ==== Empfohlenes Muster für @dal(B) ==== @dal(B) @flight_crit @stack_limit(512) unit parser; // Maximale Grammatik-Tiefe ist durch das Protokoll auf 8 begrenzt. // Nachweis: Dokument REQ-PARSE-MAX-DEPTH (rev 3) fn ParsePacket(data: ^uint8, depth: int64): int64 { if (depth > 8) { return -1; // Fehler statt panic – erlaubt in @flight_crit } var type_byte: uint8 := data^; if (type_byte = 0x01u8) { return ParseHeader(data, depth + 1); } if (type_byte = 0x02u8) { return ParsePayload(data, depth + 1); } return 0; } Der explizite ''depth''-Parameter macht die maximale Rekursionstiefe für den Compiler und den Verifizierer **sichtbar und prüfbar**. ===== 11. Vollständiges Beispiel: Merge-Sort ===== Merge-Sort ist ein klassischer Divide-&-Conquer-Algorithmus mit messbarer Rekursionstiefe O(log n). unit mergesort; import std.io; fn Merge(arr: [1024]int64, lo: int64, mid: int64, hi: int64) { var left: [512]int64; var right: [512]int64; var lLen := mid - lo + 1; var rLen := hi - mid; var i: int64 := 0; var j: int64 := 0; while (i < lLen) { left[i] := arr[lo + i]; i++; } while (j < rLen) { right[j] := arr[mid + 1 + j]; j++; } i := 0; j := 0; var k := lo; while (i < lLen & j < rLen) { if (left[i] <= right[j]) { arr[k] := left[i]; i++; } else { arr[k] := right[j]; j++; } k++; } while (i < lLen) { arr[k] := left[i]; i++; k++; } while (j < rLen) { arr[k] := right[j]; j++; k++; } } @stack_limit(8192) fn MergeSort(arr: [1024]int64, lo: int64, hi: int64) { if (lo >= hi) { return; } // Basisfall: ein Element ist bereits sortiert var mid := (lo + hi) / 2; MergeSort(arr, lo, mid); // linke Hälfte MergeSort(arr, mid + 1, hi); // rechte Hälfte Merge(arr, lo, mid, hi); // zusammenführen } fn main(): int64 { var data: [8]int64; data[0] := 38; data[1] := 27; data[2] := 43; data[3] := 3; data[4] := 9; data[5] := 82; data[6] := 10; data[7] := 1; MergeSort(data, 0, 7); var i: int64 := 0; while (i < 8) { PrintInt(data[i]); i++; } // Ausgabe: 1 3 9 10 27 38 43 82 return 0; } Für n=1024: maximale Rekursionstiefe = log₂(1024) = **10 Ebenen** – gut kalkulierbar für @stack_limit. ===== 12. Debugging & häufige Fehler ===== ==== Stack-Overflow diagnostizieren ==== $ lyxc --stack-check --call-graph main.lyx # Falls ein Overflow möglich ist: ✗ Rekursionstiefe für 'FibBad' nicht beschränkbar. Keine Terminationsgarantie nachweisbar (n kann beliebig groß sein). → Füge @stack_limit hinzu oder wandle in iterative Form um. ==== Häufige Fehler ==== ^ Fehler ^ Ursache ^ Lösung ^ | Stack-Overflow (Segfault) | Basisfall fehlt oder wird nie erreicht | Abbruchbedingung prüfen; Eingabe validieren | | Falsche Ergebnisse auf ARM64 | Link-Register X30 nicht gesichert | Compiler-Bug: fehlendes ''stp x29, x30'' im Prolog | | Stack-Overflow auf ARM64 bei 16-Byte-Grenze | Ungerader Stack vor ''bl'' | Compiler: RSP-Alignment-Prüfung im Prolog aktivieren | | Unresolved Jump Patches | Emitter erkennt Eigenaufruf nicht als intern | ''--trace-passes'' prüfen; Symbol-Tabelle vollständig? | | Exponentiell langsam | Naive Rekursion ohne Memoization | Iterative Form oder Akkumulator-Pattern verwenden | | Gegenseitige Rekursion – TCO fehlt | Compiler analysiert nur einzelne Funktionen | Trampolin-Pattern oder iterativen Dispatcher verwenden | ==== Stack-Trace im Debug-Build ==== lyxc --debug --gl main.lyx && ./main # Bei Stack-Overflow: Runtime-Fehler: Stack-Overflow in 'Factorial' Stack-Trace: Factorial(n=1000) at main.lyx:5 Factorial(n=999) at main.lyx:5 Factorial(n=998) at main.lyx:5 ... (997 weitere Frames) **Weiterführende Seiten:** * [[lyx_-_programmiersprache:memory-management|Memory Management – Stack, Heap, @stack_limit]] * [[lyx_-_programmiersprache:schleifen|Schleifen – while, for, repeat-until]] * [[lyx_-_programmiersprache:pattern-matching|Pattern Matching – match als Alternative zur Rekursion]] * [[lyx_-_programmiersprache:oop|OOP – Rekursive Klassen und Datenstrukturen]] * [[lyx_-_programmiersprache:do-178c|DO-178C – Stack-Nachweis und Call-Graph-Verifikation]]