Menu

Raises i Check w Zero: jawne błędy bez wyjątków

Funkcje w Zero deklarują możliwe błędy przez raises, a kod wywołujący potwierdza je przez check. Zobacz, jak działa ten system, dlaczego nie ma cichego rzucania wyjątków i jak współpracuje z capability World.

Na tej stronie są działające edytory: edytuj, uruchamiaj i od razu zobacz wynik.

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:

  • raises w sygnaturze funkcji: "ta funkcja może się nie powieść".
  • check w 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:

  • "validate zwraca i32 albo zgłasza InvalidInput".
  • "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:

  1. Wywołuje validate(true).
  2. Jeśli validate zgłosiło błąd, przekazuje go wyżej: run zgł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ówno Empty, jak i Malformed (lub szerszy zbiór).
  • Obsłużyć jeden lub oba błędy jawnie przez match albo konstrukcje w stylu try, 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ęcieTry/CatchZero
Oznaczenie funkcji jako zawodnejnic (niejawnie)raises { ... }
Zgłoszenie błęduthrow eraise E
Przekazanie do wywołującegowypływa niewidoczniecheck call(...)
Obsługa lokalnatry { ... } 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 check bez oporów. To właściwy wybór domyślny, gdy na danym poziomie nie masz nic konkretnego do zrobienia.
  • Unikaj samego raises w 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 World do pisania na stdout prawie zawsze potrzebuje raises, 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 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 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.

Ilustracja języków programowania w Coddy

Ucz się programowania z Coddy

ZACZNIJ