Co mówią typy enum w C, Java, Rust, Go i TypeScript o projektowaniu typów
Porównaj, w jaki sposób siedem języków definiuje typy enum – od nazwanych liczb całkowitych po związki oznaczone tagami oraz obiekty w czasie wykonywania – i dowiedz się, co to oznacza dla modelowania w TypeScript.
Źródła: Enums Were Never Just Constants
Prawie każdy język popularny posiada coś, co nazywa się enum, a prawie każdy z nich rozumie to po swojemu. W jednym języku jest to prosta etykieta nad liczbą całkowitą, w innym – klasa z metodami, w trzecim – zbiór danych o różnej strukturze, a w TypeScript jest to jedna z nielicznych konstrukcji na poziomie typów, które wyprzedzają standardowy JavaScript. Porównanie języków C, C++, Java, Rust, Go, TypeScript i Odin pokazuje te różnice w praktyce i daje lepszą wiedzę, jak decydować, którą konstrukcję należy użyć w własnym kodzie TypeScript, gdy wartość może być tylko jedną z kilku możliwych.
Jedno słowo, kilka oddzielnych problemów
Klasyczny obraz jest prosty. Wymienia się kilka nazw, a każda z nich reprezentuje jedną z dozwolonych wartości:
Red
Green
Blue
To wygląda na uniwersalną koncepcję, jednak gdy porównuje się implementacje, rozdziela się ona:
- C traktuje enum głównie jako nazwany zbiór stałych całkowitoliczbowych.
- C++ zachowuje ten model, ale dodaje
enum class, który wprowadza zakresy dostępu oraz bardziej ścisłe typowanie. - Java sprawia, że każda stała enum staje się rzeczywistym obiektem klasy enum, zawierającym pola, konstruktory, metody, a nawet ciała klas specyficzne dla danej stałej.
- Rust pozwala pojedynczemu typowi enum przechowywać warianty, które zawierają zupełnie różne dane.
- Go nie posiada słowa kluczowego
enum; zamiast tego oferuje stałe oraz identyfikatoriota. - Enum w TypeScript są kompilowane do obiektów JavaScript, które istnieją w czasie wykonywania.
- Odin celowo utrzymuje enum w prostocie i przekazuje powiązane zadania innym funkcjonalnościom, takim jak zbiory bitowe i tablice indeksowane enum.
Powodem braku jednej, spójnej definicji jest to, że „enum” to w rzeczywistości zbiór problemów, które przypadkowo dzielą tę samą słowo kluczowe. Projektant języka musi zdecydować, jakie z poniższych funkcji powinna pełnić ta struktura:
- przypisywanie nazw ustalonej grupie wartości
- wprowadzanie odrębnego typu zamiast ponownego wykorzystywania istniejącego
- przypinanie danych do poszczególnych wartości
- reprezentowanie kilku możliwych stanów wartości
- umożliwienie kompilatorowi udowodnienia, że każdy przypadek został obsłużony
- kompaktowe reprezentowanie kombinacji flag
- decydowanie o tym, co – jeśli w ogóle cokolwiek – przejdzie do działającego programu
Każdy język wybiera inny podzbiór tych funkcji, a te wybory wiele mówią o jego priorytetach.
C: czytelne nazwy dla liczb całkowitych
Tradycyjne enum w C służy do wygodnego nadawania nazw stałym liczbowym. Typ stanu może być zadeklarowany w ten sposób:
enum Status {
STATUS_OK,
STATUS_ERROR,
STATUS_PENDING
};
W rzeczywistości te trzy nazwy odpowiadają po prostu kolejnym liczbom całkowitym zaczynającym się od zera:
0
1
2
To może brzmieć banalnie, ale rozwiązuje prawdziwy problem czytelności. Wyobraźmy sobie porównanie z prostą liczbą ukrytą w logice konfiguracji:
if (status == 2) {
...
}
W tym wierszu nic nie informuje recenzenta, co oznacza 2. Ten sam test napisany z użyciem nazwanej stałej sam się dokumentuje:
if (status == STATUS_PENDING) {
...
}
To jest najstarsze uzasadnienie dla enum: zastępują one liczby magiczne nazwami, które przekazują intencję. Naturalnie łączą się również z instrukcją switch, gdzie każdy przypadek jest interpretowany jako stwierdzenie dotyczące danej dziedziny, a nie arytmetyki:
switch (status) {
case STATUS_OK:
...
break;
Pozostałe przypadki follow ten sam wzorzec:
case STATUS_ERROR:
...
break; case STATUS_PENDING:
...
break;
}
Ponieważ wartości to zwykłe, przewidywalne liczby całkowite, enum w języku C doskonale pasuje do kodu niskopoziomowego: odpowiada rejestrom sprzętowym, polom protokołów oraz formatom plików bez żadnych niespodzianek.
Kosztem jest słaba kontrola implementacji. Język C nie traktuje enum jako zamkniętego zbioru w taki sposób, jak to robi Rust. Stałe z typu enumeration zachowują się jak liczby całkowite, zmienna typu enum może być zazwyczaj przypisana dowolnej liczbie całkowitej bez żadnych problemów, a kompilator nie jest zobowiązany do sygnalizowania braku odpowiedniego case. Wiele kompilatorów wyświetla ostrzeżenia dotyczące nierozpatrzonych wartości enum w strukturze switch, ale jest to raczej uprzejmość narzędzi, a nie gwarancja języka. W standardzie C23 dodano składnię do deklarowania stałego typu podstawowego, co zmniejsza luki, które C++ zamknął już wiele lat temu.
Filozofię języka C można podsumować następująco:
Daj liczbom całkowitym lepsze nazwy i zminimalizuj mechanizmy towarzyszące im.
W programowaniu systemowym taki minimalizm jest często dokładnie tym, czego się potrzebuje.
C++: ta sama idea z ostrzejszymi ograniczeniami
C++ odziedziczył enumy w stylu C, a później wprowadził bardziej rygorystyczną formę – enumerację zakresową:
enum class Status {
Ok,
Error,
Pending
};
Zmiana ta to nie tylko „cukier składniowy”. Enumeratory enumeracji zakresowej muszą być określone nazwą typu:
Status::Ok
Zamiast pojawiać się jako zwykły identyfikator w otaczającym zakresie:
Ok
Równie ważne jest to, że enumeracja zakresowa nie przekształca się domyślnie w liczbę całkowitą. Nie można przypadkowo porównywać Status z niepowiązanym Color ani przekazać jej w miejscu, gdzie oczekiwany jest int, bez wyraźnego przekształcenia, co eliminuje całą klasę ukrytych pomyłek.
C++ pozwala również wyraźnie wybrać typ przechowywania:
enum class Status : uint8_t {
Ok,
Error,
Pending
};
To ma znaczenie wtedy, gdy układ jest częścią umowy, na przykład w pakietach sieciowych lub strukturach udostępnianych z kodem C. Język odróżnia enumy bez zakresu od tych z zakresem, deklarowanych za pomocą enum class lub enum struct, przy czym obie formy akceptują określony typ podstawowy.
Warto zauważyć, że C++ nie przedefiniowuje tego, czym jest enum. Dodaje kontrolę nad trzema aspektami związanymi z nim:
- sposobem zakreślania nazw
- stopniem oddzielenia typu od liczb całkowitych
- sposobem reprezentacji wartości w pamięci
W swojej istocie nadal jest to lista nazwanych wartości; programista po prostu może wyznaczyć bardziej precyzyjne granice dla tej listy.
Java: enumy jako część modelu obiektowego
Java oddala się znacznie bardziej od liczb całkowitych. Deklaracja wygląda znajomo:
enum Status {
OK,
ERROR,
PENDING
}
Jednak OK, ERROR i PENDING nie są aliasami liczbowymi. Zgodnie z Specyfikacją Języka Java, enum to specjalna, ograniczona forma klasy, której instancje są wszystkie z góry deklarowane pod nazwą. Każda stała to obiekt, utworzony raz przy inicjalizacji klasy, a ponieważ jest to obiekt, może przechowywać stan i zachowanie.
Stałe, które przechowują dane
Argument konstruktora może przypisać wartość do każdej stałej. Tutaj każda planeta rejestruje swoją masę:
enum Planet {
EARTH(5.97),
MARS(0.642);
Pole oraz konstruktor, który je wypełnia, uzupełniają deklarację:
final double mass; Planet(double mass) {
this.mass = mass;
}
}
Każda stała teraz ma przy sobie własne dane, a kod potrzebujący masy planety odczytuje pole zamiast korzystać z oddzielnej tabeli wyszukiwania, która mogłaby stracić synchronizację.
Stałe, które przechowują zachowanie
Enumy mogą również deklarować metody, a każda stała może zawierać własną implementację. Poniższy typ operacji nadpisuje abstraktną metodę apply dla danej stałej:
enum Operation {
ADD {
@Override
int apply(int a, int b) {
return a + b;
}
},
Druga stała oraz abstrakcyjna deklaracja wyglądają w ten sposób:
MULTIPLY {
@Override
int apply(int a, int b) {
return a * b;
}
}; abstract int apply(int a, int b);
}
W tym momencie enum jest wyraźnie klasą, i to jest zamierzone. Java włącza enumy do swojego modelu obiektowo-zorientowanego, dzięki czemu stała może mieć pola, argumenty konstruktora, wspólne metody oraz nadpisy specyficzne dla danej stałej. Stała z własnym ciałem klasy jest kompilowana jako anonimowa podklasa typu enum, a w nowszych wersjach Javy specyfikacja określa taką klasę enum jako domyślnie zamkniętą, ponieważ zbiór podklas jest ustalony przez deklarację.
Zatem stanowisko Javy jest mniej więcej następujące:
Jeśli wartość pochodzi z ustalonej grupy, dlaczego ograniczać ją do liczby całkowitej, skoro może to być obiekt z własnym zachowaniem?
Dlatego enumy w Javie doskonale nadają się do maszyn stanowych, parserów, tabel operatorów z hierarchią priorytetów oraz mechanizmów dyspozycji w stylu strategii, gdzie każda opcja wie, jak wykonać swoją pracę.
Gdy struktura danych zależy od wariantu
Enumy w Javie sprawdzają się najlepiej, gdy każda stała zawiera ten sam rodzaj z góry określonych informacji. Inny problem pojawia się, gdy każda alternatywa wymaga innej struktury.
Rozważmy wiadomości w protokole czatu:
TextMessage
ImageMessage
VideoMessage
Wiadomość tekstowa zawiera ciąg znaków, wiadomość z obrazem zawiera bajty obrazu oraz metadane, a wiadomość wideo znowu zawiera coś innego. To, co się tu zmienia, to nie wartość pola, lecz sama struktura danych. Enum w stylu Java, w którym każda stała dzieli się tym samym zestawem pól, nie nadaje się do tego celu. To właśnie jest problem, dla którego zaprojektowano enums w Rust.
Rust: enums jako związki o tagach
W Rust każda odmiana enum może mieć swój własny treść:
enum Message {
Quit,
Move { x: i32, y: i32 },
Write(String),
ChangeColor(u8, u8, u8),
}
Quit nie przenosi żadnych danych, Move ma nazwane pola, Write zawiera String, a ChangeColor przechowuje trzy bajty. Tworzenie wartości polega na wyborze odmiany i podaniu jej danych:
let message = Message::Move { x: 10, y: 20 };
Inna odmiana tego samego typu wygląda w ten sposób:
let message = Message::Write(String::from("Hello"));
Obie wyrażenia tworzą obiekt typu Message, jednak informacje w nich zawarte mają zupełnie inny kształt. Taka struktura jest w teorii typów nazywana typem sumarycznym, a przez programistów zajmujących się systemami – złączem o tagach: tag rejestruje, która wariant jest aktywna, natomiast treść przenoszona zawiera dane tej warianty. Typ ten opisuje zamknięty zbiór alternatyw:
Message
├── Quit
├── Move { x, y }
├── Write(String)
└── ChangeColor(r, g, b)
Pełne dopasowanie
Kompilator zna każdą wariantę, a instrukcja match wykorzystuje tę wiedzę. Pierwszy przypadek obsługuje wartość Quit:
match message {
Message::Quit => {
println!("Quit");
}
Pozostałe przypadeki dekonstruują treść przenoszoną każdej z wariantów i przypisują jej pola do lokalnych nazw:
Message::Move { x, y } => {
println!("Move to {}, {}", x, y);
} Message::Write(text) => {
println!("{}", text);
} Message::ChangeColor(r, g, b) => {
println!("RGB: {}, {}, {}", r, g, b);
}
}
Jeśli usuniesz jeden z tych wariantów, program przestaje się kompilować – Rust odrzuca niepełny match. Dokumentacja Rust przedstawia enumy i dopasowywanie wzorów właśnie z tego powodu. Dodanie nowego wariantu później zamienia każdy niekompletny match w bazie kodu na błąd kompilacji, który wskazuje miejsca wymagające aktualizacji.
To jest kluczowa różnica w porównaniu z enumem w C. Enum w C odpowiada na pytanie:
Której nazwanej stałej odpowiada ta liczba?
Enum w Rust odpowiada na pytanie:
Który wariant jest obecnie aktywny i jaki jest jego ładunek?
Druga kwestia obejmuje znacznie szerszy zakres możliwych projektów.
Czynienie nieważnych stanów nieprzedstawialnymi
Praktyczne korzyści są najłatwiej dostrzegalne poprzez konkretne wyniki. Operacja, która może się powieść lub nie powieść, może być zmodelowana jako typ z dwoma wariantami:
Result
├── Success(value)
└── Error(error)
Wartość, która może być obecna, a może też jej nie być, jest traktowana w ten sam sposób:
Option
├── Some(value)
└── None
Standardowa biblioteka Rusta dostarcza oba te typy jako Result i Option, a są to zwykłe enumy. Porównaj to z podejściem opartym na luźnych polach, powszechnym w wielu bazach kodu:
value = null
hasValue = false
error = ...
Przy osobnych polach nic nie stoi na przeszkodzie temu, by hasValue przyjmowało wartość true, podczas gdy value jest null, lub aby błąd i wartość zostały ustawione jednocześnie. Każdy użytkownik musi pamiętać o tych niepisanych zasadach. W przypadku typu sumarycznego takie kombinacje w ogóle nie mogą powstać, ponieważ jedynym sposobem na utworzenie wartości jest użycie jednego z deklarowanych wariantów.
To oddaje jeden z najważniejszych celów projektowania typów enum:
Typ enum może bezpośrednio w systemie typów kodować zasady dotyczące struktury danych.
Gdy zasady te znajdują się w typie, kompilator może wykryć brakujące przypadki jeszcze przed uruchomieniem programu. Programiści używający TypeScript mają coś bardzo podobnego dzięki zjednoczeniom dyskryminowanym, gdzie wspólne pole literówkowe takie jak kind pełni rolę tagu, a sprawdzenie typu never zapewnia kompletność.
Go: w ogóle nie ma słowa kluczowego enum
Po Rust podejście w Go może wydawać się krokiem wstecz w czasie. Nie istnieje słowo kluczowe enum. Idiomem jest tu blok stałych generowanych za pomocą iota:
const (
StatusOK = iota
StatusError
StatusPending
)
Wewnątrz deklaracji stałej iota przyjmuje wartość indeksu bieżącej specyfikacji stałej, zaczynając od zera, i może występować w wyrażeniach. Gdy w bloku pominięte zostanie wyrażenie, Go powtarza poprzednie, więc następuje:
const (
A = iota
B
C
)
wynik:
A = 0
B = 1
C = 2
Ponieważ domyślne powtórzenie kopiuje całe wyrażenie, iota może służyć do czegoś więcej niż tylko liczenia. Przesunięcie o jego wartość generuje potęgi dwójkowe:
const (
Read = 1 << iota
Write
Execute
)
Wynikowe wartości to:
Read = 1
Write = 2
Execute = 4
Różne bity sprawiają, że te stałe nadają się jako naturalne flagi: Read | Write łączy uprawnienia, a operacja AND bitowa sprawdza jedno z nich. Można również wprowadzić nazwany typ, aby stałe nie były zwykłymi liczbami całkowitymi:
type Permission uint8
a następnie zadeklarować stałe przy użyciu tego typu:
const (
Read Permission = 1 << iota
Write
Execute
)
Tylko Read oznacza Permission, ale powtarzający się wyrażenie przenosi typ, więc Write i Execute również są wartościami Permission. Specyfikacja Go rozróżnia stałe typowane od nietypowanych, a nazwany typ zapewnia pewną ochronę przed mieszaniem niespowiązanych wartości. Mimo to nie tworzy on takiej tożsamości typu enum, jaką znajdujemy w językach z dedykowanym konstruktem.
To jest charakterystyczne dla Go: zamiast dodawać nową funkcjonalność, oferuje elastyczny system stałych i pozostawia abstrakcję wyższego poziomu programiście. Kosztem jest to, że język nigdy formalnie nie łączy
these constants
z
this closed set of variants
w sposób podobny do Rusta lub Javy. Każda wartość typu podstawowego może zostać przekształcona na Permission, a kompilator nie sprawdza, czy switch obejmuje wszystkie możliwe wartości. Zespoły, które chcą takiej gwarancji, zazwyczaj dodają narzędzie do analizy kodu lub generator kodu.
TypeScript: enum istniejący w czasie wykonywania
TypeScript to przede wszystkim warstwa statycznych typów nad JavaScriptem. Alias typu taki jak ten:
type User = {
name: string;
};
nie pozostawia żadnego śladu w wygenerowanym JavaScriptie; jest usuwany podczas kompilacji. Enums łamią tę zasadę:
enum Direction {
Up,
Down,
Left,
Right
}
Zwykły enum w TypeScriptie kompiluje się do rzeczywistego obiektu JavaScripta, a podręcznik TypeScriptu wyraźnie opisuje to zachowanie w czasie wykonywania. W przypadku enum liczbowych generowany obiekt zawiera zarówno mapowanie w kierunku imienia na liczbę, jak i odwrotne, dzięki czemu można przejść od imienia do liczby i z powrotem.
Direction.Up -> 0
Direction[0] -> "Up"
To zaskakuje programistów, którzy zakładają, że wszystko, co dodaje TypeScript, dotyczy wyłącznie czasu kompilacji. Ma to również praktyczne konsekwencje: enum dodaje kod do Twojego pliku bundle, a iterowanie nad numerycznym enum za pomocą Object.keys zwraca klucze numeryczne jako łańcuchy znaków obok nazw.
Enum typu string przypisują wyraźne wartości typu string:
enum Direction {
Up = "UP",
Down = "DOWN",
Left = "LEFT",
Right = "RIGHT"
}
Nie posiadają odwrotnych mapowań, a ich wartości są znacznie łatwiejsze do odczytania w logach, danych przesyłanych przez sieć oraz wynikach debuggera, ponieważ "UP" jest sam w sobie zrozumiały, podczas gdy 0 nie. TypeScript obsługuje również członki obliczane oraz const enum, który jest wstawiany bezpośrednio w miejscu użycia i usuwany z wyjścia. Należy pamiętać, że const enum źle współpracuje z narzędziami, które kompilują jeden plik po drugim, dlatego wiele projektów unika go.
Alternatywa: object-plus-as-const
Dokumentacja TypeScript pokazuje również lżejszy wzorzec skonstruowany z zwykłego obiektu oraz stwierdzenia as const:
const Direction = {
Up: 0,
Down: 1,
Left: 2,
Right: 3,
} as const;
To jest zwykły JavaScript, który rozumie każde narzędzie, natomiast as const zapewnia TypeScript literalne typy dla każdej właściwości. Typ unii odpowiadający można uzyskać za pomocą (typeof Direction)[keyof typeof Direction]. Wzorzec ten stał się jeszcze bardziej istotny, gdy środowiska wykonywania nabrały możliwości bezpośredniego usuwania typów: taki podejście usuwa jedynie adnotacje, a enumy, które wymagają rzeczywistej generacji kodu, do niego nie pasują. Sprawdź aktualną dokumentację swojego środowiska wykonywania i kompilatora w celu poznania dokładnych zasad; artykuł na temat tego, co faktycznie robi wbudowana obsługa TypeScript w Node.js omawia ten aspekt szczegółowo.
Kluczowym punktem jest to, że projektowanie enum w TypeScript jest kształtowane przez jego środowisko hostujące. Java mogłoby zdefiniować enum jako funkcję pierwszej klasy w czasie wykonywania, tworząc ją od zera. TypeScript musi istnieć obok JavaScripta, który nie posiada własnych enum. Jego wersja tego pytania brzmi:
W co powinna się przekształcić funkcja na poziomie typów, gdy podstawowym środowiskiem jest JavaScript?
Nie ma jednoznacznej odpowiedzi, dlatego TypeScript ostatecznie posiada kilka nakładających się opcji.
Odin: proste enum w połączeniu z ortogonalnymi narzędziami
Odin wybiera zupełnie inną drogę. Jego enumy pozostają bliskie klasycznej koncepcji nazwanych zbiorów wartości, a zamiast przekształcać je w hierarchię obiektów lub algebraiczny typ danych, język dostarcza oddzielnych mechanizmów do rozwiązywania pokrewnych problemów: odrębne typy, tablice zliczane, które są indeksowane za pomocą enumu, unie oraz zestawy bitów, których typ elementu może być enumem.
Zwykły enum opisuje kierunki kompasu:
Direction :: enum {
North,
East,
South,
West,
}
Zestaw bitów oparty na tym enumie reprezentuje z kolei dowolną kombinację kierunków:
Direction_Set :: bit_set[Direction]
Zasada projektowania polega na podziale zadań:
- stały zbiór wartości to enum
- zbiór tych wartości to zestaw bitów
- dane powiązane z każdą wartością znajdują się w tablicy indeksowanej za pomocą enumu
- alternatywy o różnych strukturach danych to unie
Ponieważ każde narzędzie pełni jedną funkcję, można je rozpatrywać osobno, a żadne z nich nie musi dźwigać ciężaru pozostałych. Porównaj to z językiem Go, gdzie flagi tworzone są ręcznie za pomocą operacji przesunięcia, lub z Rustem, gdzie sam enum obejmuje zarówno proste listy, jak i związki oznaczone tagami.
Żaden język nie rozwiązał problemu enum w sposób idealny
Kuszące jest sklasyfikowanie tych rozwiązań, ale odpowiadają one na różne pytania. Krótkie porównanie:
- C: nazwane stałe całkowite, optymalizowane pod kątem prostoty, czytelności i kompatybilności na niskim poziomie.
- C++: ta sama baza z opcjonalnym zakresem dostępu, bardziej ścisłym typowaniem oraz wyraźnym typem podstawowym.
- Java: stała liczba instancji klasy enum, mogąca zawierać pola, konstruktory, metody oraz zachowanie specyficzne dla każdej stałej.
iota generują wartości sekwencyjne lub obliczone.Żadna z tych technik nie jest ulepszoną wersją poprzedniej. Każda odzwierciedla to, za co jej twórcy chcieli, aby była odpowiedzialna dana funkcja.
Co o języku mówi projektowanie enum
Enumy są na tyle proste, że można je opanować w ciągu popołudnia, a mimo to ukazują filozofię języka, który je otacza. Można je postrzegać jako pytanie, które zdaje się stawiać każdy język:
- C: jak można to efektywnie przedstawić za pomocą prostych prymitywów?
- C++: jak zachować kontrolę na poziomie sprzętu i jednocześnie dodać bezpieczniejsze abstrakcje?
- Java: a co, gdyby każdy element zamkniętego zbioru był pełnym obiektem z własnymi metodami?
- Rust: czy jeden typ może opisać wszystkie dopuszczalne stany wraz z zawartością każdego z nich?
- Go: czy w ogóle potrzebna jest dedykowana funkcjonalność?
- TypeScript: czym powinna być funkcjonalność kompilowana, gdy musi działać jako zwykły JavaScript?
- Odin: czy podstawowa struktura może pozostać prosta, podczas gdy ortogonalne funkcjonalności zajmują się powiązanymi potrzebami?
Ponieważ języki optymalizują się pod różne cele, enumy nigdy nie sprowadziły się do jednej koncepcji.
Enumy to decyzje projektowe, a nie składnia
Latwo jest postrzegać enum jako element składni:
enum {
A,
B,
C
}
Składnia to najmniej interesująca część. Prawdziwe pytanie brzmi: co chcesz, aby system typów zagwarantował dotyczące wartości, które wymieniłeś?
A, B, C
Rozważanie tego pytania oznacza odpowiedź na kilka mniejszych pytań:
- Czy są to jedynie nazwy dla liczb?
- Czy każda wartość wymaga powiązanych danych?
- Czy każda wariantowa wartość potrzebuje własnego zachowania?
- Czy kompilator powinien wiedzieć, że to jedyne ważne stany, i zapewniać ich w pełni obsługę?
- Czy potrzebujesz kompaktowych flag bitowych?
- Czy wartości muszą istnieć w czasie wykonywania, do iteracji lub walidacji?
- Jak powinny być serializowane w transmisji lub przechowywane?
Różne odpowiedzi prowadzą do różnych rozwiązań projektowych, a ich konsekwencje wykraczają daleko poza syntaksę: układ pamięci, bezpieczeństwo typów, to, co może przeanalizować kompilator, koszty działania w czasie rzeczywistym, dopasowywanie wzorców, struktura API, format serializacji oraz w końcu sposób, w jaki zespół postrzega swoje dane.
Czasami odpowiedni enum to wcale nie enum
To ma bezpośrednie konsekwencje dla codziennego używania TypeScript. Język oferuje enum, ponieważ istnieje określony problem, ale to nie oznacza, że każdy stały zbiór wartości musi stać się enum. W zależności od sytuacji:
- union literów tekstowych jest często jaśniejszy i nie generuje żadnych kosztów w czasie rzeczywistym
- zwykły obiekt z
as constumożliwia dostęp do wartości i typów w czasie rzeczywistym bez specjalnej kompilacji - dedykowana klasa ma sens, gdy każda opcja wiąże się z istotnym zachowaniem
- zbiór bitów lub liczbę flag jest odpowiednim narzędziem do reprezentacji kombinacji
Dobre projektowanie typów zaczyna się od danych i ich niezmienników, a nie od jakiegokolwiek słowa kluczowego dostarczanego przez język.
Główne wnioski
- "Enum" łączy kilka niezależnych aspektów: nazewnictwo, odrębne typowanie, dane dołączone, kształty wariantów, kompletność, flagi oraz obecność w czasie wykonywania.
- C i C++ koncentrują się na liczbach całkowitych i ich reprezentacji; Java koncentruje się na obiektach; Rust koncentruje się na typach sumowych z weryfikacją dopasowania przez kompilatora.
- Go i Odin pokazują dwa przeciwne sposoby uniknięcia uniwersalnego enum: stworzenie go samodzielnie z stałych lub podzielenie tego zadania między specjalistyczne funkcje.
- Enumy w TypeScript są rzeczywistymi obiektami w czasie wykonywania, co wpływa na rozmiar plików, możliwości iteracji oraz kompatybilność z narzędziami do usuwania informacji typowych; łączenia literów i obiekty
as constsą często prostszym wyborem. - Zanim sięgniesz po enum, zdecyduj, jakie gwarancje potrzebujesz. Wybrana struktura powinna wynikać z tych gwarancji, a nie odwrotnie.