Startseite / Artikel

Was Enums in C, Java, Rust, Go und TypeScript über das Typendesign aussagen

Vergleichen Sie, wie sieben Sprachen Enums definieren – von benannten Ganzzahlen über markierte Unionen bis hin zu Laufzeitobjekten – und erfahren Sie, was das für die Modellierung in TypeScript bedeutet.

Verlag: REACTAPP.TOP17 Min. Lesezeit3847 Wörter

Quellen: Enums Were Never Just Constants

Fast jede gängige Programmiersprache verfügt über etwas, das als Enum bezeichnet wird, und fast jede von ihnen verwendet diesen Begriff in einer anderen Bedeutung. In einer Sprache handelt es sich dabei um eine einfache Kennzeichnung für eine Ganzzahl, in einer anderen um eine Klasse mit Methoden, in einer dritten um eine Vereinigung von Daten unterschiedlicher Struktur – und in TypeScript ist es eines der wenigen konstruktiven Elemente auf Typebene, die das reine JavaScript übertreffen. Ein Vergleich von C, C++, Java, Rust, Go, TypeScript und Odin zeigt diese Unterschiede konkret und vermittelt Ihnen ein präziseres Vokabular, um zu entscheiden, welches Konstrukt in Ihrem eigenen TypeScript-Code verwendet werden sollte, wenn ein Wert nur eines von wenigen Werten sein kann.

Ein Begriff, mehrere unterschiedliche Probleme

Das klassische Bild ist einfach: Man listet einige Namen auf, wobei jeder Name für einen der zulässigen Werte steht:

Red
Green
Blue

Das scheint ein universelles Konzept zu sein, doch sobald man Implementierungen vergleicht, zeigt sich die Unterschiede:

  • C behandelt eine Enum im Wesentlichen als benannte Gruppe von Ganzzahlkonstanten.
  • C++ behält dieses Modell bei, fügt aber enum class hinzu, was Scoping und strengere Typisierung ermöglicht.
  • Java macht jede Enum-Konstante zu einem echten Objekt einer Enum-Klasse, ausgestattet mit Feldern, Konstruktoren, Methoden und sogar Klassenkörpern pro Konstante.
  • Rust erlaubt es einem einzigen Enum-Typ, Varianten zu enthalten, die völlig unterschiedliche Daten tragen.
  • Go verfügt über kein enum-Schlüsselwort; stattdessen werden Konstanten sowie der Identifikator iota angeboten.
  • TypeScript-Enums werden in JavaScript-Objekte kompiliert, die zur Laufzeit existieren.
  • Odin hält Enums absichtlich einfach und überlässt ähnliche Aufgaben anderen Funktionen, wie Bitmengen und mit Enum-Indizes versehenen Arrays.

Der Grund dafür, dass es keine einheitliche Definition gibt, ist, dass „enum“ eigentlich eine Ansammlung von Problemen ist, die zufällig denselben Schlüsselwort teilen. Ein Sprachentwerfer muss entscheiden, welche der folgenden Funktionen das Konstrukt übernehmen soll:

  • der Festlegung von Namen für eine feste Menge an Werten
  • der Einführung eines eigenen Typs anstelle der Wiederverwendung eines bestehenden
  • der Verknüpfung von Daten mit einzelnen Werten
  • der Darstellung mehrerer möglicher Zustände eines Wertes
  • der Erlaubnis für den Compiler, nachzuweisen, dass alle Fälle berücksichtigt wurden
  • der kompakten Darstellung von Kombinationen von Flags
  • der Entscheidung darüber, was – falls überhaupt etwas – im laufenden Programm erhalten bleibt

Jede Sprache wählt einen anderen Teilmenge aus, und diese Entscheidungen verraten viel über ihre Prioritäten.

C: lesbare Namen für Ganzzahlen

Die traditionelle C-Enum ist eine Erleichterung bei der Benennung von Ganzzahlkonstanten. Ein Statustyp kann wie folgt deklariert werden:

enum Status {
    STATUS_OK,
    STATUS_ERROR,
    STATUS_PENDING
};

Im Grunde entsprechen die drei Namen einfach aufeinanderfolgenden Ganzzahlen, die bei null beginnen:

0
1
2

Das mag trivial klingen, löst aber ein echtes Lesbarkeitsproblem. Stellen Sie sich einen Vergleich mit einer einfachen Zahl vor, die in der Konfigurationslogik versteckt ist:

if (status == 2) {
    ...
}

In dieser Zeile wird einem Prüfer nicht mitgeteilt, was 2 darstellt. Derselbe Check, der mit einer benannten Konstante durchgeführt wird, dokumentiert sich selbst:

if (status == STATUS_PENDING) {
    ...
}

Das ist die älteste Begründung für Enums: Sie ersetzen „Zauberzahlen“ durch Namen, die eine Bedeutung vermitteln. Zudem passen sie gut zu switch, wobei jeder Fall wie eine Aussage über den Anwendungsbereich und nicht über Arithmetik erscheint:

switch (status) {
    case STATUS_OK:
        ...
        break;

Die verbleibenden Fälle folgen demselben Muster:

    case STATUS_ERROR:
        ...
        break;    case STATUS_PENDING:
        ...
        break;
}

Weil es sich um gewöhnliche, vorhersehbare Ganzzahlen handelt, eignen sich C-Enums hervorragend für Code auf niedrigem Niveau: Sie entsprechen ohne Überraschungen Hardware-Registern, Protokollfeldern und Dateiformaten.

Der Kompromiss besteht in einer schwachen Durchsetzung dieser Regeln. C behandelt Enums nicht wie eine geschlossene Menge, wie es Rust tut. Enumerationskonstanten verhalten sich wie Ganzzahlen, einer Enum-Variable kann in der Regel jeder Ganzzahl zugewiesen werden, ohne dass Probleme auftreten, und der Compiler ist nicht verpflichtet, fehlende case-Werte anzugeben. Viele Compiler liefern Warnungen für unverarbeitete Enum-Werte in einem switch, doch das ist eher eine Hilfestellung der Tools als eine Sprachgarantie. C23 fügte zwar eine Syntax zur Deklaration eines festen zugrundeliegenden Typs hinzu, was eine Lücke schließt, die C++ bereits Jahre zuvor geschlossen hatte.

Die Philosophie von C lässt sich wie folgt zusammenfassen:

Geben Sie Ganzzahlen bessere Namen und halten Sie die um sie herum erforderlichen Strukturen auf ein Minimum.

Bei der Systemprogrammierung ist genau dieser Minimalismus oft genau das, was man braucht.

C++: dieselbe Idee mit strengeren Grenzen

C++ übernahm die C-ähnlichen Enums und führte später eine strengere Form ein, nämlich die gebundene Enumeration:

enum class Status {
    Ok,
    Error,
    Pending
};

Die Änderung ist nicht nur syntaktischer Zucker. Enumeratoren einer gebundenen Enumeration müssen mit dem Typnamen qualifiziert werden:

Status::Ok

anstatt als einfacher Identifikator im umgebenden Scope aufzutreten:

Ok

Ebenso wichtig ist, dass eine gebundene Enumeration nicht implizit in eine Ganzzahl umgewandelt wird. Man kann versehentlich keinen Status mit einem unverwandten Color vergleichen oder ihn dort einsetzen, wo ein int erwartet wird, ohne eine explizite Umwandlung – was eine ganze Klasse von stillen Fehlern vermeidet.

C++ ermöglicht es außerdem, den Speichertyp explizit auszuwählen:

enum class Status : uint8_t {
    Ok,
    Error,
    Pending
};

Das ist wichtig, wenn die Layout-Struktur Teil des Vertrags ist – beispielsweise in Netzwerkpaketen oder Strukturen, die mit C-Code geteilt werden. Die Sprache unterscheidet zwischen ungebundenen Enums und gebundenen Enums, die mit enum class oder enum struct deklariert werden, wobei beide Formen einen festgelegten zugrundeliegenden Typ zulassen.

Das bemerkenswerte Muster ist, dass C++ nicht neu definiert, was ein Enum ist. Stattdessen bietet es Kontrolle über drei Aspekte davon:

  • wie die Namen gebunden sind
  • inwieweit der Typ streng von Ganzzahlen getrennt wird
  • wie der Wert im Speicher dargestellt wird

In seinem Kern handelt es sich weiterhin um eine Liste von benannten Werten; der Programmierer kann lediglich klarere Grenzen dafür setzen.

Java: Enums als Teil des Objektmodells

Java entfernt sich stark von Ganzzahlen. Die Deklaration sieht vertraut aus:

enum Status {
    OK,
    ERROR,
    PENDING
}

Doch OK, ERROR und PENDING sind keine numerischen Aliase. Laut der Java Language Specification ist eine Enum eine spezielle, eingeschränkte Form einer Klasse, deren Instanzen alle im Voraus namentlich deklariert werden. Jede Konstante ist ein Objekt, das einmal bei der Initialisierung der Klasse erstellt wird, und da es sich um ein Objekt handelt, kann es Zustand und Verhalten speichern.

Konstanten, die Daten tragen

Ein Konstruktorargument kann jedem Wert eine Zahl zuweisen. Hier speichert jeder Planet seine Masse:

enum Planet {
    EARTH(5.97),
    MARS(0.642);

Das Feld sowie der Konstruktor, der es initialisiert, vervollständigen die Deklaration:

    final double mass;    Planet(double mass) {
        this.mass = mass;
    }
}

Jede Konstante verfügt nun über eigene Daten, und Code, der die Masse eines Planeten benötigt, liest direkt ein Feld anstelle dessen, eine separate Abfristabelle zu konsultieren, die aus dem Takt geraten könnte.

Konstanten, die Verhalten tragen

Enums können auch Methoden deklarieren, und jede Konstante kann ihre eigene Implementierung bereitstellen. Der folgende Operationstyp überschreibt eine abstrakte apply-Methode pro Konstante:

enum Operation {
    ADD {
        @Override
        int apply(int a, int b) {
            return a + b;
        }
    },

Die zweite Konstante sowie die abstrakte Deklaration sehen wie folgt aus:

    MULTIPLY {
        @Override
        int apply(int a, int b) {
            return a * b;
        }
    };    abstract int apply(int a, int b);
}

Zu diesem Zeitpunkt ist die Enum offensichtlich eine Klasse – und das ist beabsichtigt. Java integriert Enums in sein objektorientiertes Modell, sodass eine Konstante Felder, Konstruktorargumente, gemeinsame Methoden sowie konstantenspezifische Überschreibungen haben kann. Eine Konstante mit eigenem Klassenkörper wird als anonyme Unterklasse der Enum-Klasse kompiliert, und in neueren Java-Versionen wird eine solche Enum-Klasse als implizit abgeschlossen beschrieben, da die Menge der Unterklassen durch die Deklaration festgelegt wird.

Javas Haltung ist somit im Großen und Ganzen folgende:

Falls ein Wert aus einer festen Menge stammt, warum sollte er auf eine Ganzzahl beschränkt werden, wenn es sich um ein Objekt mit eigenem Verhalten handeln könnte?

Deshalb eignen sich Java-Enums hervorragend für Zustandsmaschinen, Parser, Operatortabellen mit Prioritäten sowie strategiebasiertes Dispatching, bei dem jede Option weiß, wie sie ihre eigene Arbeit ausführen soll.

Wenn die Struktur der Daten von der Variante abhängt

Java-Enums sind dann besonders nützlich, wenn jede Konstante dieselbe Art von vordefinierter Information enthält. Ein anderes Problem tritt auf, wenn jede Alternative eine andere Struktur benötigt.

Betrachten wir Belege in einem Chat-Protokoll:

TextMessage
ImageMessage
VideoMessage

Eine Textnachricht enthält einen String, eine Bildnachricht enthält Bildbytes sowie Metadaten, und eine Videonachricht enthält wieder etwas anderes. Was hier variiert, ist nicht der Wert eines Feldes, sondern die Struktur der Daten selbst. Eine Enum im Java-Stil, bei der jede Konstante dieselben Felder teilt, eignet sich hier nicht gut. Genau dafür sind die Enums in Rust konzipiert.

Rust: Enums als markierte Unionen

In Rust können die Varianten einer Enum jeweils ihren eigenen Inhalt haben:

enum Message {
    Quit,
    Move { x: i32, y: i32 },
    Write(String),
    ChangeColor(u8, u8, u8),
}

Quit enthält nichts, Move hat benannte Felder, Write umschließt einen String, und ChangeColor enthält drei Bytes. Um einen Wert zu erstellen, wird eine Variante ausgewählt und deren Daten bereitgestellt:

let message = Message::Move { x: 10, y: 20 };

Eine andere Variante desselben Typs sieht so aus:

let message = Message::Write(String::from("Hello"));

Sowohl Ausdrücke erzeugen ein Message, doch die darin enthaltenen Informationen haben eine völlig unterschiedliche Struktur. Diese Konstruktion wird in der Typentheorie als Summotyp bezeichnet, während Systemprogrammierer sie oft als geführte Union bezeichnen: Ein Tag gibt an, welche Variante aktiv ist, und der Payload enthält die Daten dieser Variante. Der Typ beschreibt eine abgeschlossene Menge von Alternativen:

Message
 ├── Quit
 ├── Move { x, y }
 ├── Write(String)
 └── ChangeColor(r, g, b)

Ausführliche Abgleichung

Der Compiler kennt jede Variante, und match nutzt dieses Wissen. Der erste Arm handelt den Fall Quit:

match message {
    Message::Quit => {
        println!("Quit");
    }

Die verbleibenden Arme destrukturieren den Payload jeder Variante und binden ihre Felder an lokale Namen:

    Message::Move { x, y } => {
        println!("Move to {}, {}", x, y);
    }    Message::Write(text) => {
        println!("{}", text);
    }    Message::ChangeColor(r, g, b) => {
        println!("RGB: {}, {}, {}", r, g, b);
    }
}

Falls man einen dieser „Arme“ löscht, lässt sich das Programm nicht mehr kompilieren; Rust lehnt einen unvollständigen match-Ausdruck ab. Aus genau diesem Grund stellt die Rust-Dokumentation Enums und Musterabgleich gemeinsam vor. Das Hinzufügen einer neuen Variante verwandelt später jeden unvollständigen match-Ausdruck im Codebasis in einen Kompilierfehler, der auf die Stellen hinweist, die aktualisiert werden müssen.

Das ist der wesentliche Unterschied zu einem C-Enum. Ein C-Enum beantwortet:

Zu welcher benannten Konstante entspricht diese Zahl?

Ein Rust-Enum beantwortet:

Welche Variante ist derzeit aktiv und welcher Inhalt gehört dazu?

Die zweite Frage umfasst ein weitaus größeres Spektrum an Designs.

Unerlaubte Zustände undarstellbar machen

Die praktischen Vorteile sind am leichtesten anhand von Ergebnissen erkennbar. Eine Operation, die erfolgreich oder fehlschlagen kann, lässt sich als Typ mit zwei Varianten modellieren:

Result
 ├── Success(value)
 └── Error(error)

Ein Wert, der vorhanden sein kann oder auch nicht, wird auf dieselbe Weise behandelt:

Option
 ├── Some(value)
 └── None

Rusts Standardbibliothek stellt beide Formen als Result und Option bereit, und es handelt sich dabei um gewöhnliche Enums. Vergleichen Sie das mit dem Ansatz der freien Felder, der in vielen Codebasen üblich ist:

value = null
hasValue = false
error = ...

Mit separaten Feldern kann nichts verhindern, dass hasValue auf true gesetzt wird, während value null ist, oder dass gleichzeitig ein Fehler und ein Wert gesetzt werden. Jeder Nutzer muss sich die ungeschriebenen Regeln merken. Mit einem Summityp können solche Kombinationen überhaupt nicht erstellt werden, denn die einzige Möglichkeit, einen Wert zu erzeugen, besteht darin, eine der deklarierten Varianten zu verwenden.

Das fasst einen der tiefsten Zwecke des Enum-Designs zusammen:

Eine Enum kann Regeln bezüglich der Struktur Ihrer Daten direkt im Typsystem kodieren.

Sobald die Regeln im Typ enthalten sind, kann der Compiler fehlende Fälle bereits vor dem Ausführen erkennen. TypeScript-Entwickler erhalten ein ähnliches Konzept durch diskriminierte Unionen, bei denen ein gemeinsames Literalfeld wie kind die Rolle des Tags übernimmt und eine Kombination aus Einschränkungen sowie einer never-Prüfung die Vollständigkeit gewährleistet.

Go: Es gibt überhaupt kein Enum-Schlüsselwort

Nach Rust kann der Ansatz in Go wie ein Rückschritt erscheinen. Es gibt kein enum-Schlüsselwort. Der übliche Ansatz besteht aus einem Block von Konstanten, die durch iota generiert werden:

const (
    StatusOK = iota
    StatusError
    StatusPending
)

In einer Konstantendeklaration ergibt iota den Index der aktuellen Konstantenspezifikation, beginnend bei null, und kann in Ausdrücken verwendet werden. Wenn eine Zeile im Block ihren Ausdruck weglässt, wiederholt Go den vorherigen, sodass Folgendes gilt:

const (
    A = iota
    B
    C
)

ergibt:

A = 0
B = 1
C = 2

Weil die implizite Wiederholung den gesamten Ausdruck kopiert, kann iota mehr tun als nur zählen. Durch Verschieben damit entstehen Potenzen von zwei:

const (
    Read  = 1 << iota
    Write
    Execute
)

Die resultierenden Werte sind:

Read    = 1
Write   = 2
Execute = 4

Unterschiedliche Bits machen diese Konstanten zu natürlichen Flags: Read | Write kombiniert Berechtigungen, und ein bitweises UND prüft auf eine davon. Man kann auch einen benannten Typ einführen, damit die Konstanten nicht nur freie Zahlen sind:

type Permission uint8

und dann die Konstanten mit diesem Typ deklarieren:

const (
    Read Permission = 1 << iota
    Write
    Execute
)

Nur Read beschreibt ausdrücklich Permission, doch da der Ausdruck wiederholt wird, wird auch das Typinformation mit übertragen, wodurch Write und Execute ebenfalls Permission-Werte sind. Die Go-Spezifikation unterscheidet zwischen typisierten und untypisierten Konstanten, und ein benannter Typ bietet einen gewissen Schutz dagegen, unzusammenhängende Werte zu vermischen. Dennoch entsteht nicht die Art von Enum-Identität, wie sie in Sprachen mit einer speziellen Konstruktion vorhanden ist.

Das ist typisch für Go: Anstatt eine neue Funktion hinzuzufügen, bietet es ein flexibles Konstantensystem und überlässt die höheren Abstraktionsebenen dem Programmierer. Der Nachteil ist, dass die Sprache dies nie formal verbindet.

these constants

mit

this closed set of variants

genau wie es Rust oder Java tun. Jeder Wert des zugrundeliegenden Typs kann in eine Permission umgewandelt werden, und der Compiler überprüft nicht, ob ein switch über diese Konstanten ausführlich ist. Teams, die diese Garantie wünschen, fügen in der Regel einen Linter oder einen Codegenerator hinzu.

TypeScript: eine Enum, die zur Laufzeit existiert

TypeScript ist im Wesentlichen eine statische Typschicht über JavaScript. Ein Typalias wie dieser:

type User = {
    name: string;
};

lässt keine Spuren im erzeugten JavaScript zurück; er wird während der Kompilierung gelöscht. Enums brechen diese Regel:

enum Direction {
    Up,
    Down,
    Left,
    Right
}

Eine normale TypeScript-Enum wird zu einem echten JavaScript-Objekt kompiliert, und das TypeScript-Dokumentationssystem beschreibt dieses Laufzeitverhalten ausdrücklich. Bei numerischen Enums enthält das generierte Objekt sowohl Vorwärts- als auch Rückwärtsabbildungen, sodass man vom Namen zum Zahlwert und vom Zahlwert zurück zum Namen gelangen kann:

Direction.Up    -> 0
Direction[0]    -> "Up"

Das überrascht Entwickler, die annehmen, alles, was TypeScript hinzufügt, sei nur zur Kompilierzeit relevant. Es hat auch praktische Folgen: Die Enum fügt Code zum Bundle hinzu, und das Durchlaufen einer numerischen Enum mit Object.keys gibt die numerischen Schlüssel zusammen mit den Namen als Strings zurück.

String-Enums weisen explizite String-Werte zu:

enum Direction {
    Up = "UP",
    Down = "DOWN",
    Left = "LEFT",
    Right = "RIGHT"
}

Sie verfügen nicht über umgekehrte Zuordnungen, und ihre Werte sind in Logs, Netzwerkdatenpaketen sowie Debugger-Ausgaben viel leichter lesbar, da „UP“ von selbst verständlich ist, während 0 das nicht tut. TypeScript unterstützt außerdem berechnete Member sowie const enum, welches an den Stellen, an denen es verwendet wird, inlining erfolgt und aus dem Ausgabeergebnis entfernt wird. Beachten Sie, dass const enum schlecht mit Tools zusammenarbeitet, die Dateien nacheinander kompilieren – deshalb vermeiden viele Projekte es.

Die Alternative „Object-plus-as-const“

Die TypeScript-Dokumentation zeigt außerdem ein einfacheres Muster, das aus einem gewöhnlichen Objekt und einer as const-Aussage besteht:

const Direction = {
    Up: 0,
    Down: 1,
    Left: 2,
    Right: 3,
} as const;

Das ist gewöhnliches JavaScript, das jede Tool versteht, während as const TypeScript-Literaltypen für jede Eigenschaft bereitstellt. Ein entsprechender Union-Typ kann mit (typeof Direction)[keyof typeof Direction] abgeleitet werden. Dieses Muster ist außerdem relevanter geworden, da Laufzeiten in der Lage sind, Typen direkt zu entfernen: Dieser Ansatz entfernt nur Anmerkungen, und Enumerationen, die eine echte Codegenerierung erfordern, passen nicht dazu. Prüfen Sie die aktuelle Dokumentation Ihres Laufzeitumfelds und Compilers nach den genauen Regeln; der Artikel zu was die native TypeScript-Unterstützung von Node.js tatsächlich leistet behandelt diesen Aspekt ausführlich.

Der Kernpunkt ist, dass das Enum-Design von TypeScript durch seine Host-Sprache geprägt wird. Java könnte Enums als erstklassige Laufzeitfunktion von Grund auf definieren. TypeScript muss hingegen neben JavaScript existieren, das selbst keine Enums besitzt. Die entsprechende Frage lautet daher:

Was sollte eine typbezogene Funktion werden, wenn die zugrundeliegende Laufzeitumgebung JavaScript ist?

Es gibt keine klare Antwort, weshalb TypeScript letztendlich mehrere überschneidende Optionen verwendet.

Odin: ein einfaches Enum zusammen mit orthogonalen Werkzeugen

Odin wählt einen anderen Ansatz. Seine Enums bleiben der klassischen Vorstellung einer benannten Wertesammlung treu, und anstelle, die Enum in eine Objekthierarchie oder einen algebraischen Datentyp zu verwandeln, bietet die Sprache separate Funktionen für ähnliche Probleme: unterschiedliche Typen, durch eine Enum indizierte numerische Arrays, Unionen sowie Bitmengen, deren Elementtyp eine Enum sein kann.

Eine einfache Enum beschreibt Kompassrichtungen:

Direction :: enum {
    North,
    East,
    South,
    West,
}

Ein Bitmengen über dieser Enum repräsentiert dann jede Kombination der Richtungen:

Direction_Set :: bit_set[Direction]

Das Gestaltungsprinzip ist die Arbeitsteilung:

  • eine feste Wertesammlung ist eine Enum
  • eine Sammlung dieser Werte ist ein Bitmengen
  • Daten, die mit jedem Wert verbunden sind, befinden sich in einem durch die Enum indizierten Array
  • Alternativen mit unterschiedlichen Datenaufbauten sind Unionen

Weil jedes Werkzeug eine bestimmte Aufgabe erledigt, kann man jedes für sich betrachten, und keines muss die Last aller anderen tragen. Vergleichen Sie das mit Go, wo Flags manuell mithilfe von Shift-Operationen erstellt werden, oder mit Rust, wo die Enum selbst sowohl einfache Listen als auch markierte Unionen abdeckt.

Keine einzige Sprache hat Enums richtig umgesetzt

Es ist verlockend, diese Designs zu bewerten, doch sie beantworten unterschiedliche Fragen. Eine kurze Vergleichstabelle:

  • C: benannte Ganzzahlkonstanten, optimiert für Einfachheit, Lesbarkeit und Kompatibilität auf niedriger Ebene.
  • C++: dieselbe Grundlage mit optionaler Scope-Steuerung, strengeren Typen und einem expliziten zugrundeliegenden Typ.
  • Java: eine feste Menge an Instanzen einer Enum-Klasse, die Felder, Konstruktoren, Methoden sowie konstantenspezifisches Verhalten enthalten kann.
  • Rust: eine geschlossene Menge von Varianten, von denen jede eigene Daten tragen kann, wobei das Compiler eine vollständige Musterübereinstimmung erzwingt.
  • Go: es gibt keinen speziellen Konstrukt; Konstanten und iota erzeugen sequentielle oder berechnete Werte.
  • TypeScript: Enums, die zur Laufzeit als JavaScript-Objekte existieren – im Gegensatz zu den meisten anderen TypeScript-Typmerkmalen – sowie lösbare Alternativen.
  • Odin: einfache Enums, die durch Bitmengen, enumerierte Arrays und Union-Typen für ähnliche Aufgaben ergänzt werden.
  • Absolut keines davon ist eine verbesserte Version des vorherigen. Jedes spiegelt wider, wofür die Entwickler das jeweilige Merkmal verantwortlich machen wollten.

    Was das Enum-Design über eine Sprache verrät

    Enums sind klein genug, um sie an einem Nachmittag zu erlernen, doch sie zeigen die Philosophie der zugrundeliegenden Sprache auf. Sie lassen sich als die Frage formulieren, die jede Sprache zu stellen scheint:

    • C: Wie kann dies effizient mit einfachen Primitiven dargestellt werden?
    • C++: Wie können wir eine nahe am Hardware-Niveau liegende Kontrolle beibehalten und gleichzeitig sicherere Abstraktionen hinzufügen?
    • Java: Was wäre, wenn jedes Mitglied einer geschlossenen Menge ein vollständiges Objekt mit eigenen Methoden wäre?
    • Rust: Kann eine einzige Typdefinition alle zulässigen Zustände samt deren Inhalt beschreiben?
    • Go: Ist überhaupt eine spezielle Funktion erforderlich?
    • TypeScript: Was sollte eine bei der Kompilierung verfügbare Funktion werden, wenn sie als reiner JavaScript-Code ausgeführt werden muss?
    • Odin: Kann die Kernkonstruktion einfach bleiben, während orthogonale Funktionen verwandte Anforderungen abdecken?

    Weil die Sprachen unterschiedliche Optimierungsziele verfolgen, hat sich die Konzeption von Enums niemals auf eine einzige Idee eingependelt.

    Enums sind Modellierungsentscheidungen, keine Syntax

    Es ist verlockend, eine Enum als Teil der Syntax zu betrachten:

    enum {
        A,
        B,
        C
    }
    

    Die Syntax ist der am wenigsten interessante Teil. Die eigentliche Frage lautet, was Sie vom Typensystem erwarten, was die von Ihnen aufgelisteten Werte betrifft:

    A, B, C
    

    Um diese Frage zu beantworten, müssen mehrere kleinere Fragen gestellt werden:

    • Sind das lediglich Namen für Zahlen?
    • Braucht jeder Wert zugehörige Daten?
    • Muss jede Variante ein eigenes Verhalten haben?
    • Soll der Compiler wissen, dass dies die einzigen gültigen Zustände sind, und eine vollständige Verarbeitung sicherstellen?
    • Brauchen Sie kompakte Bit-Flags?
    • Müssen die Werte zur Laufzeit vorhanden sein, um iterieren oder validieren zu können?
    • Wie sollten sie über das Netzwerk oder in die Speicherung serialisiert werden?

    Andere Antworten führen zu unterschiedlichen Designs, und die Folgen reichen weit über die Syntax hinaus: Speicheraufbau, Typsicherheit, was der Compiler analysieren kann, Laufzeitkosten, Mustererkennung, API-Struktur, Serialisierungsformat – und letztendlich auch, wie das Team über seine Daten denkt.

    Mannchmal ist die richtige Enum keine Enum

    Dies hat direkte Auswirkungen auf den täglichen Umgang mit TypeScript. Eine Sprache bietet eine Enum an, weil ein bestimmtes Problem besteht – doch das bedeutet nicht, dass jedes feste Wertesatz eine Enum werden muss. Je nach Situation gilt:

    • eine Vereinigung von String-Literalen ist oft klarer und kostet in der Laufzeit nichts
    • ein einfaches Objekt mit as const liefert Laufzeitwerte und -typen ohne spezielle Kompilierung
    • eine eigene Klasse macht Sinn, wenn jede Option erhebliches Verhalten mit sich bringt
    • ein Bit-Set oder Flags-Integer ist das richtige Werkzeug für Kombinationen
  • Eine diskriminierte Union ist genau das, was man benötigt, wenn Varianten unterschiedliche Daten enthalten.
  • Mannchmal reichen drei gut benannte Konstanten bereits aus, um die Struktur zu schaffen, die das Problem erfordert.
  • Gutes Typendesign beginnt mit den Daten und ihren Invarianten – nicht mit irgendeinem Schlüsselwort, das die Sprache zufällig bietet.

    Haupterkenntnisse

    • „Enum“ bündelt mehrere unabhängige Aspekte: Benennung, unterschiedliche Typisierung, zugehörige Daten, Variantenformen, Vollständigkeit, Flags sowie Präsenz zur Laufzeit.
    • C und C++ konzentrieren sich auf Ganzzahlen und deren Darstellung; Java konzentriert sich auf Objekte; Rust konzentriert sich auf Summitypen mit vom Compiler überprüfter Übereinstimmung.
    • Go und Odin zeigen zwei gegensätzliche Ansätze, um ein allgemeingültiges Enum zu vermeiden: Entweder baut man es selbst aus Konstanten auf oder verteilt die Aufgabe auf spezialisierte Funktionen.
    • TypeScript-Enums sind echte Laufzeitobjekte, was Auswirkungen auf die Paketgröße, die Iteration sowie die Kompatibilität mit Tools zur Entfernung von Typinformationen hat; literale Unionen und as const-Objekte sind oft die einfachere Wahl.
    • Bevor Sie zu einem Enum greifen, entscheiden Sie zunächst, welche Garantien Sie benötigen. Der von Ihnen gewählte Konstrukt sollte aus diesen Garantien abgeleitet sein – und nicht umgekehrt.