Założenia
Błąd w Zero nie jest osobnym, równoległym przepływem sterowania. To część zwykłego przepływu, zadeklarowana w sygnaturze funkcji i potwierdzana w każdym miejscu wywołania. Działa to dzięki dwóm elementom:
raisesw sygnaturze funkcji: "ta funkcja może się nie powieść".checkw miejscu wywołania: "jeśli to się nie uda, zakończ moją funkcję tym samym błędem".
To połączenie wystarcza, by wyrazić to, do czego większość języków używa try/catch albo typów Result.
Deklarowanie zawodnej funkcji
Dodaj raises po typie zwracanym:
fun validate(ok: Bool) -> i32 raises { InvalidInput } {
if ok == false {
raise InvalidInput
}
return 42
}
Sygnaturę można odczytać na dwa sposoby:
- "
validatezwracai32albo zgłaszaInvalidInput". - "Zbiór możliwych wyników to {
i32,InvalidInput}".
Oba odczytania są poprawne. Kompilator śledzi obie możliwości i wymaga, by kod wywołujący jawnie obsłużył każdą z nich.
Możesz też napisać samą klauzulę raises:
pub fun main(world: World) -> Void raises {
check world.out.write("cześć\n")
}
raises (bez listy błędów) oznacza: "ta funkcja może zakończyć się dowolnym błędem". Przy main to zwyczajowa forma: program może zakończyć się z niezerowym kodem, jeśli coś pójdzie nie tak, a środowisko uruchomieniowe zajmie się pokazaniem błędu.
W funkcjach głębiej w stosie wywołań lepiej używać jawnej formy raises { ErrorA, ErrorB }, aby możliwe błędy były udokumentowane na każdym poziomie.
Zgłaszanie błędu
Wewnątrz zawodnej funkcji raise kończy funkcję podanym błędem:
fun validate(ok: Bool) -> i32 raises { InvalidInput } {
if ok == false {
raise InvalidInput
}
return 42
}
raise InvalidInput tworzy błąd InvalidInput w tej linii. Funkcja nie wykonuje się dalej: sterowanie wraca do wywołującego, który zamiast i32 widzi błąd. Klauzula raises wymienia jedyne typy błędów, które ta funkcja może zgłosić; zgłoszenie czegoś spoza listy to błąd kompilacji.
Propagowanie błędu przez check
Kod wywołujący zawodną funkcję musi potwierdzić możliwą porażkę. Najczęstszą formą takiego potwierdzenia jest check:
fun run() -> Void raises { InvalidInput } {
check validate(true)
}
check validate(true) robi dwie rzeczy:
- Wywołuje
validate(true). - Jeśli
validatezgłosiło błąd, przekazuje go wyżej:runzgłasza ten sam błąd swojemu wywołującemu.
Aby taka propagacja była dozwolona, run musi w swojej klauzuli raises deklarować, że może zgłosić InvalidInput (lub coś zgodnego). Kompilator to sprawdza. Gdyby sygnatura run mówiła raises { OtherError }, propagacja by się nie skompilowała, bo InvalidInput nie należy do tego zbioru.
Kompletny przykład z oficjalnych próbek języka. Kliknij Run, aby zobaczyć udaną propagację:
Typ błędu wędruje razem z sygnaturą funkcji przez cały stos wywołań. Samo raises przy main przyjmuje wszystko, co może zgłosić run, więc propagacja kończy się bezpiecznie.
Dlaczego nie try/catch?
Dyscyplina stojąca za raises/check polega na tym, że błąd nigdy nie jest niewidoczny w miejscu wywołania. W języku z try/catch wyjątek może po cichu przejść przez funkcję, która nawet nie wie, że może być w to zaangażowana: funkcja wygląda na czystą, ale głęboko w jej ciele coś rzuca wyjątek, który ją przewija.
To wygodne dla autora kodu rzucającego wyjątek. Dla wszystkich pozostałych to koszt:
- Czytelnicy (i agenci) nie odczytają z sygnatury, czy funkcja uczestniczy w ścieżkach błędów.
- Refaktoryzacja staje się nerwowa: przeniesienie wywołania między funkcjami może zmienić, które wyjątki są osiągalne.
- Kod obsługi błędów żyje daleko od miejsca, które wie, co zrobić.
Zero płaci ten koszt z góry (adnotacje przy każdej zawodnej funkcji i check przy każdym zawodnym wywołaniu), aby zyskać właściwość: "błędy, w których funkcja uczestniczy, widać z jej sygnatury". Na tej właściwości mogą polegać zarówno ludzie, jak i agenci.
Dlaczego nie po prostu Result<T, E>?
To samo można wyrazić przez choice, czyli typ Result<T, E> z wariantami ok i err. Zero też daje ten wzorzec; przydaje się, gdy błąd jest danymi, które chcesz sprawdzić, zapisać lub przekazać dalej.
raises/check dodaje konwencję na poziomie składni dla typowego przypadku: "jeśli to się nie uda, zakończ moją funkcję w ten sam sposób". Bez niej każde wywołanie trzeba by opakować w match, który niemal zawsze przepakowuje błąd we własny Result wywołującego. check jest skrótem do tego, a kompilator pilnuje, by propagacja była poprawna typowo.
Czyli:
Result<T, E>(choice): gdy chcesz sprawdzać błąd albo przenosić go jako wartość.raises+check: gdy chcesz po prostu przekazać błąd w górę stosu wywołań.
Dostępne są oba; odpowiadają na różne potrzeby ergonomiczne.
Wiele typów błędów
Funkcja może zgłaszać więcej niż jeden rodzaj błędu:
fun parse(input: String) -> i32 raises { Empty, Malformed } {
if std.mem.len(input) == 0 {
raise Empty
}
// ... logika parsowania ...
raise Malformed
}
Kod wywołujący może:
- Przekazać błąd dalej przez
check parse(input), jeśli jego własna sygnatura wymienia zarównoEmpty, jak iMalformed(lub szerszy zbiór). - Obsłużyć jeden lub oba błędy jawnie przez
matchalbo konstrukcje w stylutry, które język udostępnia w tym celu.
Dokładna składnia szczegółowej obsługi (dopasowanie konkretnych typów błędów i przekazywanie pozostałych) to jedna z powierzchni, które mogą się jeszcze zmienić w Zero przed wersją 1.0. Stała jest umowa: każdy typ błędu, który funkcja może zgłosić, znajduje się w jej sygnaturze.
Model myślowy
System błędów Zero to rygorystycznie uczciwa wersja tego, co ma każdy język imperatywny:
| Pojęcie | Try/Catch | Zero |
|---|---|---|
| Oznaczenie funkcji jako zawodnej | nic (niejawnie) | raises { ... } |
| Zgłoszenie błędu | throw e | raise E |
| Przekazanie do wywołującego | wypływa niewidocznie | check call(...) |
| Obsługa lokalna | try { ... } catch(e) { ... } | match na zwróconym Result albo typowana forma obsługi |
Różnica w działaniu jest niewielka. Różnica w adnotacjach jest duża, i to celowo. Efekty są jawne. Błędy to efekty.
Uwagi o stylu
- Używaj
checkbez oporów. To właściwy wybór domyślny, gdy na danym poziomie nie masz nic konkretnego do zrobienia. - Unikaj samego
raisesw wewnętrznych funkcjach pomocniczych. Im węższy zbiór błędów, tym bardziej użyteczna sygnatura. - Łącz zawodne operacje z capabilities, których potrzebują. Funkcja używająca
Worlddo pisania na stdout prawie zawsze potrzebujeraises, bo zapis może się nie udać.
Dalej: diagnostyka w JSON
raises/check współpracuje z diagnostyką kompilatora Zero: gdy użyjesz check z funkcją, która nie deklaruje właściwych błędów, kompilator w ustrukturyzowanej formie powie ci dokładnie, co jest nie tak. Następny artykuł omawia diagnostykę w JSON, czyli czytelny maszynowo strumień, który agent czyta, aby naprawić kod.
Najczęściej zadawane pytania
Co oznacza raises w Zero?
raises w Zero?raises w sygnaturze funkcji deklaruje, że funkcja może się nie powieść. Samo raises dopuszcza dowolny typ błędu. Konkretna forma, taka jak raises { InvalidInput }, ogranicza funkcję do błędów wymienionych na liście. Kod wywołujący musi potwierdzić możliwość porażki: przez check albo inną jawną formę obsługi.
Co robi operator check?
check?check expr oblicza expr i jeśli wynikiem jest błąd, przekazuje go do kodu wywołującego bieżącą funkcję. Można o tym myśleć tak: 'wykonaj to, a jeśli się nie uda, zakończ moją funkcję tym samym błędem'. Aby klauzula raises wywołującego dopuściła taką propagację, sam wywołujący musi deklarować, że może zgłosić zgodny błąd.
Jak zgłosić błąd w Zero?
Użyj raise ErrorName wewnątrz funkcji, której klauzula raises zawiera ten błąd. Przykład: if ok == false { raise InvalidInput }. Funkcja kończy się w tym miejscu, a błąd staje się jej wynikiem, który kod wywołujący może obsłużyć przez check.
Dlaczego Zero nie używa try/catch?
Try/catch pozwala wyjątkom po cichu przechodzić przez funkcje, które nic o nich nie wiedzą. Założeniem Zero jest to, że sygnatura każdej funkcji musi uwzględniać błędy, w których ta funkcja uczestniczy. Nie ma ukrytego przepływu sterowania: jeśli funkcja może się nie powieść, mówi o tym jej sygnatura, a każdy wywołujący musi to jawnie potwierdzić przez check.
Czy funkcja w Zero może zgłaszać kilka typów błędów?
Tak: wymień je w klauzuli raises { ... }, oddzielając przecinkami (albo zgodnie z aktualną składnią zbiorów błędów w Zero). Zbiór możliwych błędów jest częścią kontraktu funkcji, tak jak typy parametrów i typ zwracany. Kod wywołujący może dopasować zgłoszony błąd wzorcem albo po prostu przekazać go dalej przez check.