====== 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;
}