Unie z etykietami w stylu Zero
choice deklaruje typ, którego wartość jest jednym z kilku nazwanych wariantów, a każdy wariant niesie własne dane:
choice Result {
ok: i32,
err: String,
}
Wartość Result to albo ok z i32, albo err ze String. Nigdy oba naraz i nigdy żadne. System typów to gwarantuje, a match ułatwia działanie na tej podstawie.
To ta sama idea, którą inne języki nazywają "unią z etykietami", "typem sumowym", "unią rozróżnialną" albo "algebraicznym typem danych". Zero zapisuje ją jako choice i utrzymuje małą gramatykę.
Deklarowanie choice
choice Name {
variantA: PayloadTypeA,
variantB: PayloadTypeB,
}
Każda linia zawiera jeden wariant. Nazwę wybierasz ty; typ po dwukropku to dane, które niesie wariant. Wariant, który nie potrzebuje danych, używa Void:
choice Token {
word: String,
number: i32,
eof: Void,
}
Token.eof to wariant bez użytecznych danych (jego typ danych to Void), przydatny w przypadkach typu terminator.
Tworzenie wartości choice
Wartość buduje się, podając nazwę typu, potem wariant, a następnie przekazując dane:
let success = Result.ok(42)
let failure = Result.err("walidacja nie powiodła się")
Typ danych musi odpowiadać typowi zadeklarowanemu dla wariantu. Result.ok("hello") byłoby błędem kompilacji, bo ok oczekuje i32.
Tutaj też działa wnioskowanie typów. Jeśli prawa strona w pełni określa typ, możesz napisać let success = Result.ok(42), a wiązanie będzie typu Result. Jawna adnotacja typu też jest w porządku, gdy chcesz go udokumentować w miejscu wiązania:
let success: Result = Result.ok(42)
Dopasowanie choice
match to sposób na odczytanie wartości choice. Kształt:
match value {
.variantA => binding { /* ciało, gdy value to variantA, z danymi w `binding` */ }
.variantB => binding { /* ciało, gdy value to variantB */ }
}
Przykład z oficjalnego repozytorium Zero. Kliknij Run, aby zobaczyć, jak wykonuje się gałąź .ok:
Czytaj match dosłownie: "zależnie od tego, jaki wariant przechowuje result, wykonaj pasującą gałąź i zwiąż dane z wybraną nazwą". W gałęzi .ok value to dane typu i32. W gałęzi .err message to dane typu String. Każda gałąź to osobny zakres; wiązanie jest widoczne tylko w jej ciele.
Wyczerpującość
To największa przewaga match nad łańcuchami if/else if: kompilator sprawdza, czy każdy wariant ma swoją gałąź. Jeśli zapomnisz o przypadku .err, nie dostaniesz w czasie działania przejścia do domyślnej gałęzi, tylko błąd kompilacji:
{
"code": "MAT001",
"message": "match is not exhaustive: missing variant 'err'",
"line": 9
}
(Kod błędu jest poglądowy; umową jest zasada.)
Dodaj do choice nowy wariant, na przykład Result.timeout: Void, a każde match na Result w całej bazie kodu stanie się błędem kompilacji, dopóki nie obsłużysz nowego przypadku. To zaleta, a nie uciążliwość: kompilator wskazuje dokładnie, gdzie nowy przypadek wymaga uwagi.
Gdy dane nie są potrzebne
Jeśli dane wariantu są typu Void albo po prostu nie interesują cię w tej gałęzi, możesz zignorować wiązanie, ale gałąź i tak trzeba napisać, aby zachować wyczerpującość:
match token {
.word => w { /* użyj w */ }
.number => n { /* użyj n */ }
.eof => _ { /* nic do związania */ }
}
Dokładny zapis "zignoruj dane" może się jeszcze zmienić w Zero przed wersją 1.0 (możesz zobaczyć _ albo po prostu pominięte wiązanie). Stała jest idea: każdy wariant dostaje gałąź, z danymi czy bez.
Typowe wzorce
Typ błędu w stylu Result
To dokładnie przykład używany w oficjalnym repozytorium:
choice Result {
ok: i32,
err: String,
}
Funkcje, które mogą zakończyć się sukcesem z wartością albo porażką z komunikatem, zwracają Result. Kod wywołujący dopasowuje wzorzec, aby wydobyć wartość lub komunikat. System raises/check w Zero obsługuje propagację zawodnych operacji; Result przydaje się, gdy chcesz przechować wynik sukcesu lub porażki jako dane.
Token parsera
choice Token {
word: String,
number: i32,
eof: Void,
}
Tokenizer produkuje strumień wartości Token. Każdy odbiorca dopasowuje wariant, aby zdecydować, co zrobić: wypisać słowo, dodać liczbę, zakończyć na eof.
Maszyna stanów
choice State {
waiting: Void,
processing: i32,
done: String,
}
processing niesie identyfikator bieżącego zadania, a done końcowy wynik. Każde przejście to nowa wartość State, bez mutowalnych pól rozsianych po shape.
Choice i generyki
choice może być generyczne, tak jak shape:
choice Maybe<T> {
some: T,
none: Void,
}
Maybe<i32> to "opcjonalna liczba całkowita". Maybe<String> to "opcjonalny tekst". Ten sam wzorzec pojawia się w bibliotece standardowej Zero i sprawdza się znacznie lepiej niż wartość wartownicza null: gdy dopasowujesz typ przez match, nie da się zapomnieć o przypadku .none.
Kiedy choice, kiedy shape, a kiedy enum
Krótkie podsumowanie z artykułów o shape i enum:
- Shape: rekord z wieloma polami, wszystkimi obecnymi naraz.
- Enum: jedna z N etykiet, bez dodatkowych danych.
- Choice: jeden z N wariantów, każdy z własnymi danymi.
Większość modeli danych w prawdziwym programie to jakaś kombinacja tych trzech. Jasność, jaką daje zaczynanie od pytania "czy to i, lub, czy lub z danymi?", to jedna z niedocenianych zalet pracy w małym języku.
Dalej: capability World
choice i match zamykają stronę danych w Zero. Następny rozdział dotyczy efektów, czyli tego, jak programy w Zero komunikują się ze światem zewnętrznym. Zaczyna się od capability World, obiektu, który strzeże każdej operacji I/O.
Najczęściej zadawane pytania
Czym jest choice w Zero?
choice to typ unii z etykietami w Zero: wartość, która jest jednym z kilku nazwanych wariantów, a każdy wariant niesie dane własnego typu. Przykład: choice Result { ok: i32, err: String }. Wartość Result to albo ok z i32, albo err ze String. Tworzysz ją przez Result.ok(42) lub Result.err("bad").
Jak działa match w Zero?
match value { .variantA => binding { ...body } .variantB => binding { ...body } } rozgałęzia się według tego, jaki wariant przechowuje value. Każda gałąź dopasowuje wariant, nazywa wiązanie dla jego danych i wykonuje swoje ciało. Kompilator sprawdza, czy obsłużono każdy wariant: wyczerpującość to główna przewaga nad if/else if.
Jak utworzyć wartość choice?
Podaj nazwę typu i wariantu, a następnie przekaż dane: let r: Result = Result.ok(42) lub let r = Result.err("validation failed"). Typ danych musi odpowiadać typowi zadeklarowanemu dla wariantu: przekazanie złego typu to błąd kompilacji.
Czym różni się choice od enum?
Warianty enum to tylko etykiety bez danych. Każdy wariant choice niesie wartość zadeklarowanego typu. Jeśli do któregoś przypadku trzeba dołączyć dane (komunikat o błędzie, udany wynik, sparsowany token), użyj choice. Jeśli przypadki to czyste etykiety, użyj enum.
Dlaczego dla choice lepszy jest match niż if-else?
match jest z natury wyczerpujące: kompilator sprawdza, czy obsłużono każdy wariant, więc dodanie później nowego wariantu zmusza do aktualizacji każdego miejsca, które rozgałęzia się na tym typie. Łańcuch if/else if po cichu przepuszcza taki przypadek, ukrywając brak, dopóki nie wyjdzie jako błąd na produkcji.