Menu

La capability World in Zero: I/O esplicito senza variabili globali

Zero non ha uno stdout globale, né un filesystem sempre disponibile, né una rete implicita. Tutto ciò che tocca il mondo esterno passa da una capability World consegnata a main. Ecco perché e come.

Questa pagina include editor eseguibili: modifica, esegui e vedi subito l'output.

L'idea in una frase

I programmi Zero fanno I/O perché hanno ricevuto il permesso di farlo, non perché attingono a una variabile globale sempre disponibile.

Quel permesso è un valore di tipo World. Il runtime ne costruisce uno prima di chiamare main, e il tuo programma lo fa passare (intero o in parti) ovunque debba interagire con il mondo esterno.

Perché niente variabili globali?

La maggior parte dei linguaggi permette a qualsiasi funzione, ovunque, di scrivere sullo stdout o aprire un file. JavaScript ha console.log. Python ha print. C ha printf. La comodità è reale, ma lo è anche il costo: dalla firma di una funzione non puoi capire se potrebbe fare I/O. Per saperlo devi leggerne il corpo, ricorsivamente.

Zero prende una posizione diversa. Non esiste un print globale. Non esiste un os.Stdout sempre disponibile. Se la tua funzione fa I/O, questo fatto deve comparire nella sua firma, perché l'unico modo per fare I/O è aver ricevuto una capability.

I vantaggi si vedono in tre punti:

  1. Leggere una firma ti dice cosa una funzione può fare. Una funzione che non menziona World non può scrivere sullo stdout, non può aprire un socket, non può leggere un file. Il sistema dei tipi lo rende una garanzia rigida.
  2. Testare il codice puro è banale. Le funzioni pure non hanno bisogno di print finti o di filesystem simulati: non vi hanno accesso fin dall'inizio.
  3. Gli agenti possono ragionare localmente. Un agente AI che genera o corregge codice Zero può sapere, senza leggere l'intero codebase, se la funzione che sta guardando ha effetti.

L'uso classico

Hai già visto la forma di base in hello-world:

Succedono tre cose:

  • main dichiara il suo parametro come world: World. Il runtime consegna al programma un valore World e lo associa qui.
  • world.out è lo stream di output standard, esposto come campo della capability World.
  • world.out.write(...) scrive una stringa. Restituisce un valore che può fallire (la scrittura potrebbe non riuscire), che check propaga.

Puoi anche rinominare il parametro, per esempio w: World o io: World, ma world è la convenzione e conviene mantenerla per coerenza con il resto dell'ecosistema Zero.

Cosa c'è dentro World

La capability World espone le interfacce di cui un programma potrebbe avere bisogno. La forma esatta dipende da ciò che supporta il runtime, ma puoi aspettarti voci per:

  • world.out: output standard.
  • world.err: errore standard.
  • world.in: input standard.
  • Un modo per aprire file, leggere variabili d'ambiente e connettersi in rete.

Consulta la documentazione attuale della libreria standard di Zero per l'elenco ufficiale dei campi. Alcune interfacce (rete, filesystem) potrebbero trovarsi dietro tipi di capability più ristretti accessibili tramite World invece che al livello principale.

Far passare le capability nel codice

Il rovescio della medaglia, cioè il prezzo degli effetti espliciti, è che ogni funzione che deve fare I/O deve ricevere la capability. Non puoi recuperarla implicitamente.

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 riceve world per poterci scrivere attraverso. Se log non ricevesse world, il corpo non potrebbe chiamare world.out.write: il binding non esisterebbe.

Significa passare più parametri rispetto a un linguaggio con I/O implicito. In cambio, l'intero grafo delle chiamate di main è ora visibile dalle sole firme:

  • main riceve world, quindi potrebbe fare I/O.
  • log riceve world, quindi potrebbe fare I/O.
  • Qualsiasi funzione senza world nella firma non può farlo.

Capability più ristrette

Passare l'intero World a ogni funzione è un approccio grossolano: è come distribuire privilegi di root. Lo schema che Zero incoraggia è ricevere solo la parte di World che ti serve davvero:

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")
}

Ora log ha accesso solo a uno Stream (lo stesso tipo esposto da world.out). Può scriverci attraverso, ma non può aprire un file o leggere dalla rete. È il chiamante a scegliere cosa può fare log.

I nomi esatti dei tipi che vedrai nel codice Zero reale (Stream, Writer, porzioni di capability) seguiranno il vocabolario della libreria standard nella tua versione della toolchain. Lo schema, cioè passare il minimo e non il massimo, è universale.

Funzioni pure

Le funzioni che non hanno bisogno di World non dovrebbero riceverlo. In parte è una preferenza di stile e in parte è imposto dal sistema dei tipi: non c'è modo di fare I/O senza una capability.

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

sum è pura rispetto al mondo esterno. Chi guarda la firma sa con certezza che questa funzione non stamperà nulla, non aprirà un file, non contatterà un server. È una proprietà su cui un analizzatore statico (o un agente) può contare senza leggere il corpo.

Capability e raises

Quasi ogni operazione su una capability può fallire. world.out.write può fallire perché lo stream è chiuso. L'apertura di un file può fallire perché il file non esiste. L'interfaccia delle capability va di pari passo con raises e check: le operazioni che possono fallire dichiarano nella firma i loro modi di fallire, e chi le chiama ne prende atto con check.

Questa combinazione è il cuore della gestione degli effetti in Zero:

  • Cosa può succedere → raises { ... }.
  • Attraverso cosa → World (o una sua parte).
  • Dove → ovunque siano visibili la capability e raises.

Sono informazioni sufficienti per ragionare con precisione sugli effetti di una funzione a partire dalla sola firma.

Una nota sui test

L'I/O basato su capability rende i test semplici per costruzione. Vuoi catturare l'output di una funzione sotto test? Passale una capability out finta che registra cosa le è stato chiesto di scrivere. Vuoi testare una funzione che dovrebbe essere pura? Non passarle nessuna capability: la sua firma di tipo non le permetterà di toccare il mondo esterno.

La libreria standard può offrire strumenti di test che costruiscono capability finte o in memoria proprio a questo scopo. L'API esatta evolverà insieme al linguaggio; il principio (le capability sono valori che puoi sostituire) è la leva.

Prossimo passo: raises e check

World è metà della storia degli effetti: le interfacce che una funzione può toccare. L'altra metà è il fallimento: quando qualcosa va storto, come si propaga? Lo vediamo nella prossima pagina, Raises e check.

Domande frequenti

Cos'è World in Zero?

World è l'oggetto capability fornito dal runtime che dà a un programma Zero accesso al mondo esterno: stdout, stdin, file, rete, variabili d'ambiente e così via. Il runtime costruisce un valore World e lo passa a main. Le funzioni che devono fare I/O devono ricevere il World (o una sua parte più ristretta): non esiste una scappatoia globale.

Perché main riceve un parametro World?

Zero non ha variabili globali sempre disponibili. Non esiste un equivalente di printf, console.log o os.Stdout che qualsiasi funzione possa chiamare senza permesso. Il runtime consegna a main una capability World, e main (con ogni funzione che chiama) può fare I/O solo tramite quel valore. Così ogni effetto è visibile nella firma di una funzione.

In cosa l'I/O basato su capability è diverso dal normale I/O?

Nella maggior parte dei linguaggi l'I/O è implicito: qualsiasi funzione può scrivere sullo stdout o leggere dal filesystem in qualsiasi momento. L'I/O basato su capability trasforma il permesso di fare I/O in un valore: per usarlo devi ricevere un World (o una sua parte). Le funzioni di puro calcolo non ricevono World e quindi non possono letteralmente fare I/O, cosa che il sistema dei tipi garantisce.

Posso ottenere World implicitamente in un punto profondo dello stack di chiamate?

No, per scelta di design. Se una funzione di supporto in profondità nello stack di chiamate deve scrivere sullo stdout, devi passarle esplicitamente il World o una capability più ristretta. Significa passare più parametri rispetto a un linguaggio con I/O implicito, ma è il prezzo per poter leggere la firma di una funzione e sapere se potrebbe fare I/O.

Cosa fa world.out.write?

world.out.write("testo\n") scrive la stringa indicata sullo stream di output standard del programma tramite la capability fornita dal runtime. Restituisce un valore che può fallire, perché la scrittura potrebbe non riuscire, quindi avvolgi la chiamata con check per propagare l'errore lungo lo stack di chiamate.

Illustrazione dei linguaggi di programmazione di Coddy

Impara a programmare con Coddy

INIZIA