====== Lyx – Generics & Traits ====== Generics und Traits sind das Typsystem-Fundament für wiederverwendbaren Code in Lyx. **Generics** erlauben es, Funktionen und Datenstrukturen einmal zu schreiben und für beliebig viele konkrete Typen zu verwenden. **Traits** definieren die Anforderungen, die ein Typ erfüllen muss, damit er als generischer Parameter zulässig ist. Der entscheidende Unterschied zu Interfaces in Java oder Go: Lyx löst Generics durch **Monomorphisierung** auf — der Compiler erzeugt für jede verwendete Typ-Kombination eine vollständig eigenständige, optimierte Version der Funktion. Es entsteht kein Laufzeit-Overhead, keine V-Table, kein Dispatch. Das Ergebnis ist identisch mit handgeschriebenem spezialisierten Code — und WCET-berechenbar. ---- ===== 1. Generische Funktionen ===== Der Typparameter wird in spitzen Klammern hinter dem Funktionsnamen deklariert und kann dann im Parametertyp und Rückgabetyp verwendet werden. fn Identity(value: T): T { return value; } fn Swap(var a: T, var b: T): void { let temp: T := a; a := b; b := temp; } fn main(): int64 { var x: int64 := 10; var y: int64 := 20; Swap(x, y); // x = 20, y = 10 var s: int64 := Identity(42); return 0; } Der Compiler leitet den Typparameter in vielen Fällen automatisch her — die explizite Angabe '''' ist dann optional: var result: int64 := Identity(42); // T wird zu int64 inferiert var pi: f64 := Identity(3.14); // T wird zu f64 inferiert ==== Mehrere Typparameter ==== Generische Funktionen können beliebig viele Typparameter haben: fn Pair(first: A, second: B): (A, B) { return (first, second); } fn MapApply(value: In, fn transform: fn(In): Out): Out { return transform(value); } fn main(): int64 { var p: (int64, f64) := Pair(1, 3.14); var doubled: int64 := MapApply(21, fn(x: int64): int64 { return x * 2; }); return 0; } ---- ===== 2. Traits — Schnittstellen für Typen ===== Ein Trait ist eine benannte Menge von Methodensignaturen. Er beschreibt, was ein Typ **kann**, ohne festzulegen, wie er es tut. Ein Typ erfüllt einen Trait, indem er alle geforderten Methoden implementiert. trait Printable { fn Print(): void; fn ToString(): int64; // Rückgabe: Zeiger auf String } Ein Trait wird mit ''impl TraitName for TypeName'' für einen bestimmten Typ implementiert: type Point = struct { x: f64; y: f64; }; impl Printable for Point { fn Print(): void { PrintStr("Point("); PrintF64(self.x); PrintStr(", "); PrintF64(self.y); PrintStr(")"); } fn ToString(): int64 { // Gibt einen formatierten String zurück return StrFormat("Point(%f, %f)", self.x, self.y); } } Und analog für einen anderen Typ — ohne den aufrufenden Code zu ändern: type Color = struct { r: int64; g: int64; b: int64; }; impl Printable for Color { fn Print(): void { PrintStr("rgb("); PrintInt(self.r); PrintStr(", "); PrintInt(self.g); PrintStr(", "); PrintInt(self.b); PrintStr(")"); } fn ToString(): int64 { return StrFormat("rgb(%d,%d,%d)", self.r, self.g, self.b); } } ---- ===== 3. Generic Constraints — Traits als Typbedingungen ===== Damit eine generische Funktion Methoden eines Typparameters aufrufen darf, muss der entsprechende Trait als Constraint angegeben werden. Ohne Constraint weiß der Compiler nicht, welche Operationen auf ''T'' zulässig sind. // T muss Printable implementieren fn PrintTwice(value: T): void { value.Print(); PrintStr("\n"); value.Print(); PrintStr("\n"); } fn main(): int64 { var p: Point := Point { x: 3.0, y: 4.0 }; var c: Color := Color { r: 255, g: 128, b: 0 }; PrintTwice(p); // OK: Point impl Printable PrintTwice(c); // OK: Color impl Printable // PrintTwice(42); // Compiler-Fehler: int64 impl nicht Printable return 0; } ==== Mehrere Constraints ==== Ein Typparameter kann mehrere Traits gleichzeitig fordern — verknüpft mit ''+'' (ähnlich wie in Rust mit ''+'' oder Java ''& ''): trait Comparable { fn LessThan(other: self): bool; fn Equals(other: self): bool; } trait Serializable { fn Serialize(): int64; // Zeiger auf Byte-Puffer fn SerializeLen(): int64; // Länge des Puffers } // T muss sowohl Comparable als auch Serializable sein fn SortAndStore(items: T[], n: int64): void { // Sortieren (Comparable erlaubt LessThan) // ... Sortier-Logik ... // Speichern (Serializable erlaubt Serialize) var i: int64 := 0; while (i < n) limit(65536) { var buf: int64 := items[i].Serialize(); var len: int64 := items[i].SerializeLen(); WriteToStorage(buf, len); i := i + 1; } } ---- ===== 4. Standardimplementierungen in Traits ===== Traits können Methoden mit einer Default-Implementierung versehen. Typen, die den Trait implementieren, erben diese Implementierung automatisch — können sie aber überschreiben. trait Describable { fn Name(): int64; // Abstrakt — muss implementiert werden // Default-Implementierung — optional überschreibbar fn Describe(): void { PrintStr("Ich bin: "); PrintStr(self.Name()); PrintStr("\n"); } } type Sensor = struct { id: int64; }; impl Describable for Sensor { fn Name(): int64 { return "Drucksensor"; // Pflicht-Implementierung } // Describe() wird von der Default-Implementierung geerbt } type Actuator = struct { id: int64; }; impl Describable for Actuator { fn Name(): int64 { return "Steuerventil"; } // Describe() wird überschrieben fn Describe(): void { PrintStr("[Aktor] "); PrintStr(self.Name()); PrintStr(" (ID="); PrintInt(self.id); PrintStr(")\n"); } } ---- ===== 5. Generische Structs und Klassen ===== Nicht nur Funktionen, sondern auch Datenstrukturen können generisch sein. ==== Generischer Struct ==== type Optional = struct { value: T; has_value: bool; }; fn OptionalOf(v: T): Optional { return Optional { value: v, has_value: true }; } fn OptionalEmpty(): Optional { return Optional { has_value: false }; } fn OptionalGet(opt: Optional): T { if (!opt.has_value) { panic("Optional ist leer"); } return opt.value; } fn main(): int64 { var maybe_int: Optional := OptionalOf(42); var maybe_none: Optional := OptionalEmpty(); if (maybe_int.has_value) { PrintInt(OptionalGet(maybe_int)); PrintStr("\n"); } return 0; } ==== Generischer Stack ==== con STACK_MAX: int64 := 256; type Stack = struct { data: T[256]; top: int64; }; fn StackNew(): Stack { return Stack { top: 0 }; } fn StackPush(var s: Stack, value: T): bool { if (s.top >= STACK_MAX) { return false; } s.data[s.top] := value; s.top := s.top + 1; return true; } fn StackPop(var s: Stack): T { if (s.top <= 0) { panic("Stack underflow"); } s.top := s.top - 1; return s.data[s.top]; } fn StackIsEmpty(s: Stack): bool { return s.top == 0; } fn main(): int64 { var int_stack: Stack := StackNew(); StackPush(int_stack, 10); StackPush(int_stack, 20); StackPush(int_stack, 30); while (!StackIsEmpty(int_stack)) limit(STACK_MAX) { PrintInt(StackPop(int_stack)); PrintStr("\n"); } // Ausgabe: 30, 20, 10 return 0; } ---- ===== 6. Traits für Hardware-Abstraktion ===== Ein zentrales Einsatzfeld für Traits in Lyx ist die Hardware-Abstraktion: Treiberschnittstellen werden als Trait definiert, der Applikationscode ist generisch dagegen geschrieben. Für jede Zielplattform gibt es eine eigene Implementierung — der Anwendungscode ändert sich nicht. // ── Schnittstelle (für jede UART-Hardware gleich) ────────────────────────── trait UART { fn Init(baudrate: int64): bool; fn SendByte(b: uint8): void; fn RecvByte(): uint8; fn IsReady(): bool; } // ── Implementierung für ESP32 ─────────────────────────────────────────────── type ESP32UART = struct { base_addr: int64; }; impl UART for ESP32UART { fn Init(baudrate: int64): bool { // ESP32-spezifische Register setzen return true; } fn SendByte(b: uint8): void { /* UART0_FIFO schreiben */ } fn RecvByte(): uint8 { return 0u8; /* UART0_FIFO lesen */ } fn IsReady(): bool { return true; } } // ── Implementierung für RISC-V ───────────────────────────────────────────── type RiscVUART = struct { base_addr: int64; }; impl UART for RiscVUART { fn Init(baudrate: int64): bool { // SiFive UART-Register konfigurieren return true; } fn SendByte(b: uint8): void { /* txdata schreiben */ } fn RecvByte(): uint8 { return 0u8; /* rxdata lesen */ } fn IsReady(): bool { return true; } } // ── Applikationscode — hardware-unabhängig ───────────────────────────────── fn SendString(var uart: U, msg: int64): void { var p: int64 := msg; while ((p as uint8) != 0u8) limit(65536) { uart.SendByte(p as uint8); p := p + 1; } } fn main(): int64 { var esp_uart: ESP32UART := ESP32UART { base_addr: 0x3FF40000 }; esp_uart.Init(115200); SendString(esp_uart, "Hallo ESP32!\n"); var rv_uart: RiscVUART := RiscVUART { base_addr: 0x10013000 }; rv_uart.Init(9600); SendString(rv_uart, "Hallo RISC-V!\n"); return 0; } Der Compiler monomorphisiert ''SendString'' zweimal — einmal für ''ESP32UART'', einmal für ''RiscVUART''. Der generierte Code ist identisch mit dem einer manuell spezialisierten Version. ---- ===== 7. Trait-Vererbung ===== Ein Trait kann einen anderen Trait als Voraussetzung deklarieren. Wer den abgeleiteten Trait implementiert, muss auch den Basis-Trait implementieren. trait Readable { fn Read(buf: int64, len: int64): int64; } trait Writable { fn Write(buf: int64, len: int64): int64; } // ReadWrite setzt Readable und Writable voraus trait ReadWrite : Readable + Writable { fn Flush(): void; fn Seek(offset: int64): void; } type FileHandle = struct { fd: int64; }; impl Readable for FileHandle { fn Read(buf: int64, len: int64): int64 { return SysRead(self.fd, buf, len); } } impl Writable for FileHandle { fn Write(buf: int64, len: int64): int64 { return SysWrite(self.fd, buf, len); } } impl ReadWrite for FileHandle { fn Flush(): void { SysFsync(self.fd); } fn Seek(offset: int64): void { SysLseek(self.fd, offset, 0); } } // Diese Funktion akzeptiert jeden Typ mit vollem ReadWrite-Trait fn CopyData(src: S, dst: D, len: int64): void { var buf: uint8[4096]; var remaining: int64 := len; while (remaining > 0) limit(65536) { var chunk: int64 := remaining > 4096 ? 4096 : remaining; var n: int64 := src.Read(buf as int64, chunk); dst.Write(buf as int64, n); remaining := remaining - n; } } ---- ===== 8. Monomorphisierung und Codegröße ===== Monomorphisierung ist die Strategie, durch die Lyx Generics auflöst. Der Compiler erzeugt für jede eindeutige Typ-Kombination eine eigene, spezialisierte Funktion — kein Dispatch, kein Overhead. fn Max(a: T, b: T): T { if (a.LessThan(b)) { return b; } return a; } // Verwendung mit verschiedenen Typen: var a: int64 := Max(3, 7); // → Max_int64(3, 7) var b: f64 := Max(1.5, 2.3); // → Max_f64(1.5, 2.3) var c: int64 := Max(10, 5); // → Max_int64(10, 5) — selbe Instanz wie oben Der Compiler erzeugt intern: ^ Aufruf ^ Erzeugte Funktion ^ Overhead ^ | ''Max(3, 7)'' | ''Max_int64'' | Keiner — direkter CPU-Vergleich | | ''Max(1.5, 2.3)'' | ''Max_f64'' | Keiner — direkter FPU-Vergleich | | ''Max(3, 7)'' erneut | Dieselbe ''Max_int64'' | Keine zweite Kopie | **Binärgröße:** Jede neue Typ-Kombination fügt eine Kopie hinzu. Bei eingebetteten Systemen mit wenig Flash-Speicher sollte die Anzahl der Typ-Instanzen bewusst gehalten werden. Der Compiler gibt mit ''--symbol-sizes'' eine Übersicht der Größe jeder monomorphisierten Funktion aus. lyxc src/main.lyx --symbol-sizes | grep "Max_" # Max_int64 28 bytes # Max_f64 32 bytes ---- ===== 9. Statischer vs. Dynamischer Dispatch ===== Lyx bevorzugt statischen Dispatch (Monomorphisierung) als Standard. Dynamischer Dispatch über eine V-Table ist ab v0.9.5 als explizite Option verfügbar — sinnvoll, wenn die Menge an Typ-Instanzen die Binärgröße dominiert. ^ Eigenschaft ^ Statisch (Standard) ^ Dynamisch (''dyn'') ^ | Mechanismus | Monomorphisierung | Virtual Table (V-Table) | | Performance | Maximal — Inlining möglich | Indirekter Sprung, kein Inlining | | Binärgröße | Wächst pro Typ-Instanz | Konstant | | WCET-Analyse | Exakt berechenbar | Schwierig — Sprungziel variabel | | Zertifizierung (DO-178C) | DAL-A geeignet | DAL-C/D empfohlen | | Einsatz | Standard, Safety-Code | Plugins, erweiterbare Systeme | // Statisch — Compiler kennt den Typ zur Compile-Zeit fn DrawStatic(shape: T): void { shape.Draw(); } // Dynamisch — Typ erst zur Laufzeit bekannt (dyn-Keyword) fn DrawDynamic(shape: dyn Drawable): void { shape.Draw(); } Im Safety-Umfeld (DAL-A, DAL-B) gilt: **Immer statischen Dispatch verwenden.** Dynamischer Dispatch erzeugt indirekte Sprünge, die in der WCET-Analyse nicht exakt gebunden werden können. ---- ===== 10. Generics im Safety-Umfeld ===== Generics und Traits funktionieren vollständig mit den Safety-Pragmas zusammen — Constraints werden vor der Monomorphisierung geprüft, die erzeugte Funktion ist gewöhnlicher nativer Code. trait SensorReader { fn Read(): int64; fn IsValid(): bool; } @dal(B) @flight_crit @stack_limit(256) @wcet(100) fn ReadValidSensor(var sensor: S): int64 { if (!sensor.IsValid()) { return -1; } return sensor.Read(); } ''@wcet'', ''@stack_limit'' und ''@flight_crit'' gelten für die erzeugte monomorphisierte Funktion — der Compiler prüft das Budget für jede Typ-Instanz separat. Wird der Stack-Limit für ''ReadValidSensor'' überschritten, entsteht ein Compiler-Fehler mit dem konkreten Typ im Fehlertext. **Best Practices im Safety-Umfeld:** ^ Situation ^ Empfehlung ^ | Treiber-Abstraktion | Traits für Hardware-Interfaces (UART, SPI, I2C) — statisch auflösen | | Wiederverwendbare Algorithmen | Generisch + Constraints — kein manuelles Copy-Paste | | WCET-Budget | ''@wcet'' pro monomorphisierter Instanz prüfen lassen | | Tiefe Generic-Hierarchien | Vermeiden — erhöht Compiler-Nachweisaufwand bei Zertifizierung | | Dynamischer Dispatch | Nur DAL-C/D, nie in Regelpfaden | | Constraints | Immer explizit angeben — Fehlermeldungen entstehen am Aufrufort | ---- ===== 11. Vollständiges Beispiel: Generischer Ringpuffer ===== Ein Ringpuffer (Circular Buffer) ist eine klassische Datenstruktur für Embedded-Systeme und Echtzeit-Kommunikation. Die generische Version funktioniert für beliebige Elementtypen. unit RingBuffer; con RING_CAPACITY: int64 := 64; type RingBuffer = struct { data: T[64]; head: int64; // Nächster Schreibindex tail: int64; // Nächster Leseindex count: int64; // Aktuell enthaltene Elemente }; fn RingNew(): RingBuffer { return RingBuffer { head: 0, tail: 0, count: 0 }; } fn RingIsEmpty(r: RingBuffer): bool { return r.count == 0; } fn RingIsFull(r: RingBuffer): bool { return r.count >= RING_CAPACITY; } fn RingPush(var r: RingBuffer, value: T): bool { if (RingIsFull(r)) { return false; } r.data[r.head] := value; r.head := (r.head + 1) % RING_CAPACITY; r.count := r.count + 1; return true; } fn RingPop(var r: RingBuffer): (T, bool) { if (RingIsEmpty(r)) { return (r.data[0], false); } var value: T := r.data[r.tail]; r.tail := (r.tail + 1) % RING_CAPACITY; r.count := r.count - 1; return (value, true); } fn RingLen(r: RingBuffer): int64 { return r.count; } // ── Verwendung ──────────────────────────────────────────────────────────────── fn main(): int64 { // Ringpuffer für Sensor-Messwerte var sensor_buf: RingBuffer := RingNew(); RingPush(sensor_buf, 101.3); RingPush(sensor_buf, 101.5); RingPush(sensor_buf, 100.9); while (!RingIsEmpty(sensor_buf)) limit(RING_CAPACITY) { var (val, ok): (f64, bool) := RingPop(sensor_buf); if (ok) { PrintF64(val); PrintStr(" hPa\n"); } } // Derselbe Puffer-Code für Kommandos (int64) var cmd_buf: RingBuffer := RingNew(); RingPush(cmd_buf, 0x01); RingPush(cmd_buf, 0x02); PrintStr("Kommandos im Puffer: "); PrintInt(RingLen(cmd_buf)); PrintStr("\n"); return 0; }