- Datatype99 ist eine makrobasierte Bibliothek, die in reinem C99 algebraische Datentypen, exhaustive Pattern Matching und Compile-Time-Introspection bereitstellt
- Ziel ist es, falsch typisierte Varianten, fehlendes Pattern Matching und ungültige Feldzugriffe bereits zur Kompilierzeit zu erkennen; benötigt wird nur ein standardkonformer C99-Compiler
- Intern wird
datatype zu einer Tagged Union und Value-Konstruktoren expandiert, match zu einer switch-Anweisung; das erzeugte Datenlayout folgt einer formalisierten Codegenerierungssemantik
- Die Installation erfolgt, indem
datatype99.h und die Abhängigkeit Metalang99 zum Include-Pfad hinzugefügt werden; für GCC/Clang werden Compiler-Optionen empfohlen, die die Ausgabe von Makro-Expansionsfehlern reduzieren
- Lässt sich in C-Codebasen mit
#include <datatype99.h> integrieren, funktioniert Berichten zufolge mit GCC, Clang, MSVC und TCC und unterstützt auch C++11 und neuer
Was Datatype99 bietet
- Datatype99 stellt sichere und intuitive algebraische Datentypen in C99 bereit
- Der Funktionsumfang umfasst auch exhaustives Pattern Matching und Compile-Time-Introspection
- Es funktioniert ohne externe Codegenerierungswerkzeuge, die Implementierung basiert auf reinem C99
- Hauptmerkmale
- Typsicherheit: Erkennt falsch typisierte Varianten, nicht-exhaustives Pattern Matching und ungültige Feldzugriffe zur Kompilierzeit
- Portabilität: Erfordert einen standardkonformen C99-Compiler und benötigt weder Standardbibliothek noch compiler-/plattformspezifische Features oder VLA
- Vorhersehbarkeit: Eine definierte Codegenerierungssemantik garantiert, dass das erzeugte Datenlayout immer gleich ist
- Verständliche Fehler: Die Bibliothek selbst erkennt bei fehlerhaftem Code einige Syntaxfehler
- Praxiseinsatz: Wird bei OpenIPC für die Entwicklung von Echtzeit-Streaming-Software für IP-Kameras verwendet, darunter eine RTSP-1.0-Implementierung und rund 50.000 Zeilen proprietären Codes
Installation und Build-Konfiguration
- Datatype99 besteht aus einer einzelnen Header-Datei
datatype99.h und der Abhängigkeit Metalang99
- Für die Verwendung im Projekt müssen
datatype99 und metalang99/include zu den Include-Verzeichnissen hinzugefügt werden
- Unter GCC wird
-ftrack-macro-expansion=0, unter Clang -fmacro-backtrace-limit=1 empfohlen, um unnötige Ausgaben von Makro-Expansionsfehlern zu reduzieren
- Bei Verwendung von CMake wird
FetchContent empfohlen
- Die Standarddatei
datatype99/CMakeLists.txt lädt Metalang99 v1.13.5 aus den GitHub Releases herunter
- Dieses Verhalten kann durch einen vorherigen Aufruf von
FetchContent_Declare überschrieben werden
- Header, die von Datatype99 abhängen, können als Precompiled Header angelegt werden, um die Kompilierzeit zu verkürzen, da sie nicht bei jedem Include neu kompiliert werden müssen
Verwendung: Tagged Unions sicherer einsetzen
- Datatype99 ist im Kern syntaktischer Zucker für Tagged Unions und bietet eine sicherere, kompaktere Form
- In gewöhnlichem C muss man zur Darstellung eines Binärbaums
tag-Enum und union direkt selbst schreiben
- In Datatype99 lässt sich dieselbe Struktur so deklarieren
datatype(
BinaryTree,
(Leaf, int),
(Node, BinaryTree *, int, BinaryTree *)
);
- Beim üblichen
switch-Ansatz kann der Compiler womöglich nicht warnen, wenn nach case Leaf: versehentlich auf tree->data.node zugegriffen wird
- Mit
match und of sind die variantenbezogenen Bindungen nur im jeweiligen Zweig sichtbar; bei unzulässigem Zugriff schlägt die Kompilierung fehl
int sum(const BinaryTree *tree) {
match(*tree) {
of(Leaf, x) return *x;
of(Node, lhs, x, rhs) return sum(*lhs) + * x + sum(*rhs);
}
return -1;
}
- Die von
of bereitgestellten Bindungen sind Variablen wie x, lhs, rhs; sie haben Zeigertypen, sodass Werte verändert werden können
- Zur Erzeugung von Varianten werden intern generierte Value-Konstruktoren verwendet
BinaryTree leaf5 = Leaf(5);
BinaryTree leaf7 = Leaf(7);
BinaryTree node = Node(&leaf5, 123, &leaf7);
Syntax und Generierungssemantik
- Datatype99 bietet Makro-Syntax wie
datatype, record, match, of, otherwise, MATCHES, ifLet usw.
- Es gibt sowohl Kurzformen der Makros als auch Versionen mit Suffix
- Beispiele:
match99, of99, derive99
- Um Namenskonflikte zu vermeiden, kann vor dem Include von
datatype99.h DATATYPE99_NO_ALIASES definiert werden
- In Bibliotheks-Headern wird die Verwendung der Makros mit Suffix empfohlen
datatype erzeugt die folgenden Elemente
- forward
typedef
- Strukturen für jede nicht-leere Variante
typedef für die Feldtypen der Varianten
typedef für den Sum Type
- Tagged Union mit
tag-Enum und union
inline static-Value-Konstruktoren für jede Variante
- Aufrufe der in
derive(...) angegebenen Deriver
- Selbst wenn alle Varianten leer sind, ist nach C-Standard mindestens ein Eintrag in der Union erforderlich; daher wird
char dummy; eingefügt
record ist ein struct mit definiertem Derivationsprozess und erzeugt ebenfalls char dummy;, wenn es keine Felder gibt
match vergleicht eine Sum-Type-Instanz der Reihe nach mit den Varianten und führt die Anweisungen des erfolgreichen Zweigs aus, bevor mit der nächsten Anweisung fortgefahren wird
- Vollständige
match- und ifLet-Ausdrücke expandieren jeweils zu genau einer C-Anweisung
MATCHES prüft als wahr/falsch, ob eine Sum-Type-Instanz einer bestimmten Variante entspricht
matches ist deprecated; empfohlen wird MATCHES
derive und Hilfsattribute
derive(...) dient dazu, globalen Code für einen Sum Type oder ein Record zu erzeugen
- Deriver für Sum Types werden als Metalang99-kompatible Makros aufgerufen; die Variantenliste wird als Tuple-Liste übergeben
- Deriver für Records werden ebenfalls als Metalang99-kompatible Makros aufgerufen; die Feldliste wird als Tuple-Liste der Form
(<type>, <field-name>) übergeben
- Ein derive-Hilfsattribut ist ein benanntes Argument, das an den Deriver übergeben wird
- Hilfsattribute verwenden die Form eines objektartigen Makros
#define <variant-name>_<namespace>_<attribute-name> attr(/* attribute value */)
- Bereitgestellte Makros zur Attributverarbeitung
DATATYPE99_attrIsPresent / DATATYPE99_ATTR_IS_PRESENT: Prüft, ob ein Attribut vorhanden ist
DATATYPE99_attrValue / DATATYPE99_ATTR_VALUE: Extrahiert den Wert eines vorhandenen Attributs
DATATYPE99_assertAttrIsPresent: Löst einen fatalen Fehler aus, wenn ein erforderliches Attribut fehlt
Zu beachtende Nutzungsmuster
- Innerhalb der an
of und ifLet übergebenen Anweisungen darf auf oberster Ebene kein break/continue verwendet werden
continue innerhalb interner for-/while-Schleifen ist in Ordnung
- Oberster Kontrollfluss sollte durch ein
goto-Label ersetzt werden
- Wenn ein Array als Variantenparameter angegeben werden soll, muss es in ein separates
struct verpackt werden
- Die von
of eingeführten Bindungen sind immer mutable; wenn der an match übergebene Wert const ist, darf er daher nicht verändert werden
- Um
datatype-Definitionen lesbar zu halten, kann in Clang-Format // clang-format off und // clang-format on verwendet werden
- derive-Hilfsattribute sollten nach der jeweiligen
datatype-Definition immer mit #undef aufgehoben werden, um Namespace-Verschmutzung zu vermeiden
- Wenn die Bedeutung von Variantenparametern nicht allein aus dem Kontext klar ist, können Typ-Aliase oder separate Strukturen mit aussagekräftigeren Namen verwendet werden
Fehler, IDE, Kompatibilität
- Einige Syntaxfehler werden von der Bibliothek selbst erkannt
- Nicht-Tuple-Formen wie
Bar(int)
- fehlendes Komma
- unzulässiges abschließendes Komma
- Andere Fehler zeigen sich in den normalen Compiler-Diagnosen
- nicht existierender Typname
- nicht-exhaustives
match
- zu viele Binder in
of
- falsch typisierte Variantenargumente
- Rückgabe ohne Dereferenzierung einer Pointer-Bindung
- Laut Erfahrung wirken fast 95 % der Fehlermeldungen sinnvoll
- Wenn ein Fehler nicht verständlich ist, kann mit
-E der generierte Code geprüft werden; da die Codegenerierungssemantik formal definiert ist, entsteht normalerweise kein unerwarteter Code
- VS Code aktiviert Vorschläge für generierte Typen automatisch, unterstützt aber kein Syntax-Highlighting für die Makro-Syntax
- Datatype99 soll mit GCC, Clang, MSVC und TCC funktionieren
- Auch C++11 und neuer werden unterstützt
Warum C als Ziel und welche Grenzen es gibt
- Bestehende, in reinem C geschriebene Software kann von den Vorteilen von Datatype99 profitieren
- In bestehende C-Codebasen lässt es sich allein mit
#include <datatype99.h> integrieren
- In manchen Umgebungen wird aus historischen Gründen weiterhin reines C beibehalten, etwa auf Embedded-Geräten, unter Linux und in anderen Betriebssystemen
- Das stabile ABI von C ist wichtig für Plugin-System-Projekte wie MetaCall
- C wird als ausgereifte Sprache mit vollständiger Spezifikation und vielen Bibliotheken dargestellt
- Wenn eine modernere oder höherstufige Sprache verwendet werden kann, wird empfohlen, diese statt altem C zu nutzen; für viele ist diese Wahl aber nicht möglich oder zu teuer
- Der Unterschied zwischen Datatype99 und Metalang99 liegt in ihrer Rolle
- Metalang99 ist eine funktionale Sprache für Metaprogrammierung
- Datatype99 ist eine in Metalang99 geschriebene Implementierung algebraischer Datentypen
1 Kommentare
Hacker-News-Kommentare
Beruflich muss ich Java nutzen, und ich bin inzwischen zu der Ansicht gekommen, dass Java nicht so schlecht ist, wie ich früher geschimpft habe, aber es gab Dutzende Momente, in denen ich mir dachte: „Wenn Java doch nur die discriminated unions aus F# hätte.“
Man kann sie mit verschiedenen Techniken nachbilden, und in vielen Fällen reichen enums allein aus, aber meist fehlt die Flexibilität und Knappheit echter algebraischer Datentypen. Vor allem fehlt das großartige Pattern Matching, das man aus funktionalen Sprachen bekommt.
Diese C-Erweiterung scheint das gewünschte Pattern Matching zu haben, sieht also ziemlich gut aus; ich sollte prüfen, ob ich sie in Arduino-Projekten verwenden kann.
Wer es gerade erst entdeckt hat, hört nicht auf, es als beste Erfindung seit geschnittenem Brot zu preisen :)
Für Java gibt es https://github.com/functionaljava/functionaljava; die Unterstützung ist eingestellt, aber es ist stabil.
Sie sind so mächtig, dass man nicht darauf verzichten kann.
Kürzlich kam Unterstützung für vom Compiler erzwungenes Pattern Matching auf sealed classes hinzu, also scheint man etwa auf halbem Weg zu sein.
Wenn man sehen möchte, wie algebraische Datentypen intern funktionieren, halte ich libsum für einen guten Einstieg.
[1] https://github.com/naasking/libsum
Ich kenne C seit fast 20 Jahren, hätte aber nie gedacht, dass das Makrosystem mächtig genug ist, um solche schwarze Magie zu ermöglichen.
Wirklich großartig.
Traditionell wurden sie genutzt, um typsichere Generics zu erstellen und darauf zuzugreifen oder um Boilerplate bei Definitionen von Hardware-Registern und Interrupts zu reduzieren.
Es fühlt sich ein wenig verflucht an, aber im Kern ist es tatsächlich sehr einfach und in C-basierten Projekten ein verlässliches Werkzeug, um kognitive Komplexität und Wiederholungscode zu reduzieren.
Für Makroverwendung ist das nicht besonders kompliziert.
https://en.wikipedia.org/wiki/Tagged_union#Class_hierarchies...
Es gibt auch einen längeren Blogbeitrag zum selben Thema; der Autor scheint den entsprechenden Wikipedia-Abschnitt aber noch nicht gesehen zu haben.
https://nandakumar.org/blog/2023/12/paradigms-in-disguise.ht...
Es ist lobenswert, dass der Entwickler von datatype99 die Probleme solcher Behelfslösungen direkt im README zeigt.
https://github.com/melt-umn/ableC-template-algebraic-data-ty...
Abgesehen davon ist es aber sehr cool.
Das erinnert mich an eine Immediate-Mode-UI, die ich früher einmal mit Makros gebaut habe. In meinem Fall war das kein Problem, aber in manchen Blöcken kann man „break“ verwenden.
In einem win_form-Block kann man zum Beispiel mit „break“ aussteigen, und „goto“ funktioniert ebenfalls. Ein win_command-Block hingegen fängt „break“ nicht ab; wenn man also innerhalb von win_command break oder goto verwendet, verlässt man den äußeren Block, der diesen win_command umgibt, vermutlich den win_form-Block. Das wird normalerweise etwa für einen „Cancel“-Button verwendet.
Es geht nicht nur darum, in ein paar Punkten etwas besser zu sein, sondern darum, im gesamten Ökosystem viele kleine Unannehmlichkeiten, die sich mit der Zeit angesammelt haben, glatt zu beseitigen. Der kumulative Effekt ist groß, ähnlich wie bei den Problemen, dass es in CPython-Umgebungen zu viele eigene lexikalische Typen gibt oder bei High-Performance-Computing-Bibliotheken.
Der Teil dieses Makros, der dieses Problem verursacht, wäre bei einem gut geschriebenen Rust-Makro beispielsweise von vornherein kein Problem gewesen. Es ist ein Nebenprodukt davon, dass kluge Leute versuchen, die Grenzen von C zu umgehen.
Allerdings ist dieses Makro selbst ursprünglich eine Portierung einer nativen Rust-Funktion, also hätte es in Rust von vornherein gar nicht geschrieben werden müssen; deshalb wird es in Community-Software ganz selbstverständlich genutzt.
gotowird nur dann zum Stolperstein, wenn man es verwendet, um von Funktion zu Funktion zu springen; tatsächlich zielte auch „goto considered harmful“ auf genau diese Praxis ab.Diese Praxis ist verschwunden, und ein goto, das heute innerhalb einer Funktion verwendet wird, ist ziemlich harmlos und entspricht praktisch
break/continue.Und nehmen wir an, man weiß auch schon, dass es Rust gibt, wird Rust aber aus den Gründen, die C-Programmierern alle bekannt sind, nicht verwenden.
Trotzdem sollte man zumindest Zig in Betracht ziehen. Vor zwei Tagen habe ich in Zig solchen Code geschrieben:
Mit
inline elsevoncomptimewerden alle Zweige einerswitch-Anweisung über einer Tagged Union erzeugt, und zu Union-Membern mit einem"l"-Feld wird ein Offset addiert. Wenn Typinformationen zur Compile-Zeit bekannt sind, kann man den Charakter der Zweige auf vielfältige Weise ändern, und von diesen Informationen gibt es ziemlich viele. Da alle Bedingungen zur Compile-Zeit entschieden werden, enthält jederswitch-Zweig nur die Logik, die zur Verarbeitung der jeweiligen Variante nötig ist.„Aber mein Programm ist schon C und ich brauche es nur in einer Datei“ – auch dann lohnt es sich, Zig auszuprobieren. Es könnte einem gefallen.
https://gist.github.com/unclechu/eb37cc81e80afbbb5e74990b62e...
struct+union+enumnicht die meisten Vorteile algebraischer Datentypen? Ich habe schon das Muster verwendet, eine Union mehrerer Typen zu haben und ein Enum dazuzupacken, das unterscheidet, welcher gewählt wurde.std::variantscheint sich bis zu einem gewissen Grad auch wie ein Sum Type zu verhalten.Das einzige Problem ist, dass man keine saubere
switch-Anweisung auf einen bestimmten Wert eines Feldes bauen kann, aber verschachtelteswitch-Anweisungen sind auch nicht so unordentlich.Das Problem dieses Ansatzes ist die große mentale Belastung, wirklich jedes Detail im Blick zu behalten. Weil das ermüdend ist, fängt man an, neue Funktionalität eher in bestehende Klassen hineinzuzwängen, statt neue Klassen zu erstellen.
Am Ende fühlt sich Abstraktion teuer an, und dadurch nutzt man weniger Abstraktion, obwohl man das Problem eleganter lösen könnte.
Kurz gesagt: Die Fähigkeit, „Darmok and Jalad at Tanagra“ sagen zu können, statt jedes Mal eine lange Geschichte zu erzählen, wenn man auf einen komplexen Gedanken verweist, ist transformativ.
Pattern Matching ist der große Vorteil bei der Nutzbarkeit.
Mit Makros ist das schwierig, aber nicht unmöglich. Im Grunde braucht man ein Makro, das den Matching-Kontext startet, eine Variable einführt, um alle geprüften Alternativen zu verfolgen, und den Kontext so aufbaut, dass am Ende des Matching-Kontexts geprüft wird, ob alle Alternativen abgedeckt wurden.
std::variantmag ich nicht besonders.Die Art, mit
std::visitalle Fälle erschöpfend zu matchen, wirkt hacky. Wenn das wirklich ein erstklassiges Sprachfeature wäre, wäre das ein großer Gewinn.Es könnte stärkere Auswirkungen auf alltäglichen Code haben als andere Features, an denen bisher gearbeitet wurde, etwa Coroutinen.