Menu

Capability World w Zero: jawne I/O bez zmiennych globalnych

Zero nie ma globalnego stdout, wszechobecnego systemu plików ani niejawnej sieci. Wszystko, co dotyka świata zewnętrznego, przechodzi przez capability World przekazywaną do main. Oto dlaczego i jak to działa.

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

Idea w jednym zdaniu

Programy w Zero wykonują I/O, bo dostały na to pozwolenie, a nie dlatego, że sięgają po wszechobecną zmienną globalną.

To pozwolenie jest wartością typu World. Środowisko uruchomieniowe tworzy ją przed wywołaniem main, a twój program przekazuje ją (lub jej części) wszędzie tam, gdzie musi komunikować się ze światem zewnętrznym.

Dlaczego bez zmiennych globalnych?

Większość języków pozwala dowolnej funkcji w dowolnym miejscu pisać do stdout albo otwierać plik. JavaScript ma console.log. Python ma print. C ma printf. Wygoda jest prawdziwa, ale koszt też: z sygnatury funkcji nie da się stwierdzić, czy może ona wykonywać I/O. Aby to wiedzieć, trzeba przeczytać jej ciało, i to rekurencyjnie.

Zero przyjmuje inne stanowisko. Nie ma globalnego print. Nie ma wszechobecnego os.Stdout. Jeśli twoja funkcja wykonuje I/O, ten fakt musi pojawić się w jej sygnaturze, bo jedynym sposobem na I/O jest otrzymanie capability.

Korzyści widać w trzech miejscach:

  1. Sygnatura mówi, co funkcja może zrobić. Funkcja, która nie wspomina o World, nie może pisać do stdout, otwierać gniazda ani czytać pliku. System typów czyni to twardą gwarancją.
  2. Testowanie czystego kodu jest banalne. Czyste funkcje nie potrzebują zaślepek print ani atrap systemu plików, bo w ogóle nie mają do nich dostępu.
  3. Agenci mogą rozumować lokalnie. Agent AI generujący lub naprawiający kod Zero może wiedzieć, bez czytania całej bazy kodu, czy oglądana funkcja ma efekty.

Kanoniczne użycie

Podstawowy kształt znasz już z hello-world:

Dzieją się tu trzy rzeczy:

  • main deklaruje parametr jako world: World. Środowisko uruchomieniowe przekazuje programowi wartość World i wiąże ją w tym miejscu.
  • world.out to strumień standardowego wyjścia, udostępniony jako pole capability World.
  • world.out.write(...) zapisuje tekst. Zwraca wartość zawodną (zapis może się nie udać), którą check przekazuje dalej.

Parametr możesz też nazwać inaczej, na przykład w: World albo io: World, ale world to konwencja i warto się jej trzymać dla spójności z resztą ekosystemu Zero.

Co znajduje się w World

Capability World udostępnia powierzchnie, których program może potrzebować. Dokładny kształt zależy od tego, co obsługuje środowisko uruchomieniowe, ale możesz spodziewać się pozycji takich jak:

  • world.out: standardowe wyjście.
  • world.err: standardowe wyjście błędów.
  • world.in: standardowe wejście.
  • Sposób na otwieranie plików, odczyt zmiennych środowiskowych i połączenia sieciowe.

Aktualną, wiążącą listę pól znajdziesz w dokumentacji biblioteki standardowej Zero. Niektóre powierzchnie (sieć, system plików) mogą być dostępne przez węższe typy capability osiągalne przez World, a nie na najwyższym poziomie.

Przekazywanie capabilities przez kod

Haczyk, czyli cena jawnych efektów, polega na tym, że każda funkcja wykonująca I/O musi otrzymać capability. Nie da się po nią sięgnąć niejawnie.

fun log(world: World, message: String) -> Void raises {
    check world.out.write(message)
}

pub fun main(world: World) -> Void raises {
    log(world, "starting\n")
    log(world, "done\n")
}

log przyjmuje world, żeby móc przez nie pisać. Gdyby log nie przyjmowało world, jego ciało nie mogłoby wywołać world.out.write, bo takie wiązanie by nie istniało.

To więcej przekazywania parametrów niż w języku z wszechobecnym I/O. W zamian cały graf wywołań main widać z samych sygnatur:

  • main przyjmuje world, więc może wykonywać I/O.
  • log przyjmuje world, więc może wykonywać I/O.
  • Żadna funkcja bez world w sygnaturze tego nie może.

Węższe capabilities

Przekazywanie całego World każdej funkcji to toporne podejście, trochę jak rozdawanie uprawnień roota. Wzorzec, do którego zachęca Zero, to przyjmowanie tylko tego wycinka World, który jest naprawdę potrzebny:

fun log(out: Stream, message: String) -> Void raises {
    check out.write(message)
}

pub fun main(world: World) -> Void raises {
    log(world.out, "starting\n")
    log(world.out, "done\n")
}

Teraz log dostaje dostęp tylko do Stream (tego samego typu, który udostępnia world.out). Może przez niego pisać, ale nie może otworzyć pliku ani czytać z sieci. To kod wywołujący zdecydował, co log wolno robić.

Dokładne nazwy typów w prawdziwym kodzie Zero (Stream, Writer, wycinki capabilities) będą odpowiadać słownictwu biblioteki standardowej w twojej wersji toolchaina. Sam wzorzec, czyli przekazuj minimum, a nie maksimum, jest uniwersalny.

Czyste funkcje

Funkcje, które nie potrzebują World, nie powinny go przyjmować. To po części kwestia stylu, a po części reguła wymuszana przez system typów: bez capability nie da się wykonać I/O.

fun sum(point: Point) -> i32 {
    return point.x + point.y
}

sum jest czysta względem świata zewnętrznego. Ktoś, kto patrzy na sygnaturę, wie na pewno, że ta funkcja niczego nie wypisze, nie otworzy pliku i nie połączy się z serwerem. Na takiej właściwości może polegać analizator statyczny (albo agent) bez czytania ciała funkcji.

Capabilities a raises

Niemal każda operacja na capability jest zawodna. world.out.write może się nie udać, bo strumień jest zamknięty. Otwarcie pliku może się nie udać, bo plik nie istnieje. API capabilities idzie w parze z raises i check: zawodne operacje deklarują w sygnaturach, jak mogą zawieść, a kod wywołujący potwierdza to przez check.

To połączenie jest sercem podejścia Zero do efektów:

  • Co może się stać → raises { ... }.
  • Przez co → World (lub jego wycinek).
  • Gdzie → wszędzie tam, gdzie widać capability i raises.

To wystarczy, by precyzyjnie wnioskować o efektach funkcji na podstawie samej sygnatury.

Kilka słów o testowaniu

I/O oparte na capabilities z natury ułatwia testowanie. Chcesz przechwycić wyjście testowanej funkcji? Przekaż jej fałszywą capability out, która zapisuje, co miała wypisać. Chcesz przetestować funkcję, która ma być czysta? Nie przekazuj jej żadnej capability: jej sygnatura typów nie pozwoli jej dotknąć świata zewnętrznego.

Biblioteka standardowa może dostarczać narzędzia testowe, które budują fałszywe lub działające w pamięci capabilities właśnie w tym celu. Dokładne API będzie ewoluować razem z językiem; dźwignią jest zasada, że capabilities to wartości, które można podmieniać.

Dalej: Raises i Check

World to połowa historii o efektach: powierzchnie, których funkcja może dotknąć. Druga połowa to błędy: gdy coś pójdzie nie tak, jak błąd się propaguje? O tym w następnym artykule, Raises i Check.

Najczęściej zadawane pytania

Czym jest World w Zero?

World to obiekt capability dostarczany przez środowisko uruchomieniowe, który daje programowi Zero dostęp do świata zewnętrznego: stdout, stdin, plików, sieci, zmiennych środowiskowych i tak dalej. Środowisko uruchomieniowe tworzy wartość World i przekazuje ją do main. Funkcje, które mają wykonywać I/O, muszą dostać World (lub jego węższy wycinek): nie ma globalnej furtki.

Dlaczego main przyjmuje parametr World?

Zero nie ma wszechobecnych zmiennych globalnych. Nie ma odpowiednika printf, console.log ani os.Stdout, który dowolna funkcja mogłaby wywołać bez pozwolenia. Środowisko uruchomieniowe przekazuje main capability World, a main (i każda wywoływana przez nie funkcja) może wykonywać I/O tylko przez tę wartość. Dzięki temu każdy efekt jest widoczny w sygnaturze funkcji.

Czym I/O oparte na capabilities różni się od zwykłego I/O?

W większości języków I/O jest niejawne: każda funkcja w dowolnej chwili może pisać do stdout albo czytać z systemu plików. I/O oparte na capabilities zamienia pozwolenie na I/O w wartość: aby z niego skorzystać, trzeba otrzymać World (lub jego wycinek). Czysto obliczeniowe funkcje nie przyjmują World, więc dosłownie nie mogą wykonywać I/O, co wymusza system typów.

Czy mogę niejawnie dostać World gdzieś głęboko w stosie wywołań?

Nie, i to celowo. Jeśli funkcja pomocnicza głęboko w stosie wywołań ma pisać do stdout, musisz jej jawnie przekazać World albo węższą capability. To więcej przekazywania parametrów niż w języku z wszechobecnym I/O, ale taka jest cena możliwości odczytania z sygnatury, czy funkcja może wykonywać I/O.

Co robi world.out.write?

world.out.write("text\n") zapisuje podany tekst do strumienia standardowego wyjścia programu przez capability dostarczoną przez środowisko uruchomieniowe. Zwraca wartość zawodną (zapis może się nie udać), więc opakowujesz wywołanie w check, aby przekazać błąd w górę stosu wywołań.

Ilustracja języków programowania w Coddy

Ucz się programowania z Coddy

ZACZNIJ