1 Punkte von GN⁺ 2024-05-10 | 1 Kommentare | Auf WhatsApp teilen
  • 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-/plattform­spezifische 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

 
GN⁺ 2024-05-10
Hacker-News-Kommentare
  • Jedes Mal, wenn ich eine imperative Sprache verwende, vermisse ich algebraische Datentypen fast immer.
    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 algebraische Datentypen und Pattern Matching nie benutzt hat, versteht nicht, was daran so großartig ist, und selbst wer damit vertraut ist, weiß es erst wirklich zu schätzen, wenn er in einer Sprache ohne diese Dinge arbeiten muss.
      Wer es gerade erst entdeckt hat, hört nicht auf, es als beste Erfindung seit geschnittenem Brot zu preisen :)
    • Mit sealed interfaces in Java 21 ist Pattern Matching möglich.
    • Kotlin ist mit der JVM kompatibel und hat algebraische Datentypen.
      Für Java gibt es https://github.com/functionaljava/functionaljava; die Unterstützung ist eingestellt, aber es ist stabil.
    • Algebraische Datentypen und Pattern Matching funktionieren tatsächlich auch in imperativen Sprachen sehr gut. Rust ist ein Beispiel dafür.
  • Wenn ich ein Produkt noch einmal von Grund auf implementieren würde, wären discriminated unions mit vom Compiler erzwungenem vollständigem Pattern Matching eine zwingende Voraussetzung.
    Sie sind so mächtig, dass man nicht darauf verzichten kann.
    • Was ich mir für Dart am meisten wünsche, sind Union Types; leider sieht es nicht so aus, als würden sie in absehbarer Zeit hinzugefügt.
      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.
    • Welche Sprachen passen derzeit zu dieser Beschreibung? Ich bin mir nicht ganz sicher, was genau gemeint ist, aber Beispiele aus Sprachen, die das unterstützen, würden mir vermutlich helfen, es besser zu verstehen.
  • Das sieht definitiv besser aus und wird wahrscheinlich besser funktionieren als mein früherer Versuch [1], aber es hat achtmal so viel Code und hängt von der hervorragenden, aber etwas einschüchternden Metalang9-Makro-Toolchain ab.
    Wenn man sehen möchte, wie algebraische Datentypen intern funktionieren, halte ich libsum für einen guten Einstieg.
    [1] https://github.com/naasking/libsum
    • Da ich sehe, dass ich diesem Repository einen Stern gegeben habe, habe ich es mir beim Entwurf von Datatype99 wohl angesehen :)
  • Das ist das Werk eines Zauberers.
    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.
    • Der Autor ist gerade einmal 19 Jahre alt. Plötzlich fühle ich mich dumm.
    • metalang99 vom selben Autor könnte ebenfalls interessant sein.
    • x-macros, also diese Art der Makroverwendung, sind ziemlich ausgefeilt.
      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.
    • Algebraische Datentypen sind im Großen und Ganzen String-Substitution über generischen structs und unions, versehen mit einem Union-Tag.
      Für Makroverwendung ist das nicht besonders kompliziert.
  • Auf Wikipedia gibt es dazu einen interessanten Abschnitt: Unions werden als „Klassenhierarchien in der objektorientierten Programmierung“ implementiert.
    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.
  • Es gibt auch https://melt.cs.umn.edu/. Dort gibt es eine Erweiterung, die C Templates und algebraische Datentypen hinzufügt.
    https://github.com/melt-umn/ableC-template-algebraic-data-ty...
  • „Verwende innerhalb der an of und ifLet übergebenen Anweisungen kein top-level break/continue, sondern stattdessen goto label“ wirkt wie ein ziemlich großer Footgun-Faktor.
    Abgesehen davon ist es aber sehr cool.
    • Dass man goto verwendet, ist an sich kein Problem, aber man muss wissen, dass man in solchen Blöcken break/continue nicht verwenden darf.
      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.
    • Das Gute am Makrobereich von Rust ist, dass es nicht nur möglich ist, Code zu schreiben, der solche Bedingungen prüft, sondern dass es auch tatsächlich machbar und vorstellbar ist und man dabei Hilfe von leicht installierbaren Open-Source-Bibliotheken bekommt.
      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.

  • goto wird 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.
  • Angenommen, man muss ein C-Programm schreiben und braucht wirklich vollständiges Pattern Matching für Union-Tags. Was Datatype99 bietet, ist „kurz gesagt syntaktischer Zucker über Tagged Unions“.
    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 else von comptime werden alle Zweige einer switch-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 jeder switch-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.
  • Bekommt man mit struct + union + enum nicht 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::variant scheint 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 verschachtelte switch-Anweisungen sind auch nicht so unordentlich.
    • Stimmt. Allein mit Konventionen und Disziplin kann man viele Vorteile der Objektorientierung bekommen, aber dafür muss man zum Beispiel vtables selbst handhaben und daher häufig in Implementierungsdetails hinabsteigen.
      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.
    • Den Modellierungsaspekt kann man nachahmen. Aber das ist nicht einmal die Hälfte der Vorteile algebraischer Datentypen.
      Pattern Matching ist der große Vorteil bei der Nutzbarkeit.
    • Beim Umgang mit Werten eines Sum Types ist es absolut wichtig, sicherzustellen, dass man alle Alternativen geprüft hat.
      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.
    • Wenn es wirklich nötig ist, ist C++ für den Großteil des Codes im Allgemeinen in Ordnung, aber std::variant mag ich nicht besonders.
      Die Art, mit std::visit alle 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.
  • Dass das jemand implementiert hat, ist wirklich beeindruckend. Respekt.