Unioni etichettate, alla maniera di Zero
choice dichiara un tipo il cui valore è una tra diverse varianti con nome, ognuna con il proprio payload:
choice Result {
ok: i32,
err: String,
}
Un valore Result è o un ok che contiene un i32 o un err che contiene una String. Mai entrambi, mai nessuno dei due. Il sistema dei tipi lo garantisce e match rende comodo agire di conseguenza.
È la stessa idea che altri linguaggi chiamano "tagged union", "tipo somma", "unione discriminata" o "tipo di dato algebrico". Zero la chiama choice e mantiene piccola la grammatica.
Dichiarare una choice
choice Name {
variantA: PayloadTypeA,
variantB: PayloadTypeB,
}
Ogni riga elenca una variante. Il nome lo scegli tu; il tipo dopo i due punti è il payload che quella variante contiene. Una variante che non ha bisogno di payload usa Void:
choice Token {
word: String,
number: i32,
eof: Void,
}
Token.eof è una variante senza un payload utile (il suo tipo di payload è Void): comoda per i casi che fanno da terminatore.
Costruire un valore choice
Costruisci un valore indicando il tipo, poi la variante, poi passando il payload:
let success = Result.ok(42)
let failure = Result.err("convalida non riuscita")
Il tipo del payload deve corrispondere a quello dichiarato nella variante. Result.ok("hello") sarebbe un errore di compilazione, perché ok si aspetta un i32.
Anche qui funziona l'inferenza dei tipi. Se il lato destro determina completamente il tipo, puoi scrivere let success = Result.ok(42) e il tipo del binding sarà Result. Annotare esplicitamente il tipo va benissimo quando vuoi documentarlo nel punto del binding:
let success: Result = Result.ok(42)
Fare match su una choice
match è il modo per leggere un valore choice. La struttura:
match value {
.variantA => binding { /* corpo quando value è variantA, con il payload in `binding` */ }
.variantB => binding { /* corpo quando value è variantB */ }
}
Un esempio completo tratto dal repository ufficiale di Zero: clicca su Esegui per vedere scattare il ramo .ok:
Leggi il match alla lettera: "a seconda della variante contenuta in result, esegui il ramo corrispondente e associa il payload al nome scelto". Nel ramo .ok, value è il payload i32. Nel ramo .err, message è il payload String. Ogni ramo è uno scope separato; il binding è visibile solo all'interno del suo corpo.
Esaustività
Questo è il grande vantaggio di match rispetto alle catene if/else if: il compilatore verifica che ogni variante abbia un ramo. Se dimentichi il caso .err, non ottieni una caduta a runtime in un ramo predefinito, ma un errore di compilazione:
{
"code": "MAT001",
"message": "match is not exhaustive: missing variant 'err'",
"line": 9
}
(Il codice di errore è indicativo; il principio è il contratto.)
Aggiungi una nuova variante alla choice, per esempio Result.timeout: Void, e ogni match su Result nel codebase diventa un errore di compilazione finché non gestisci il nuovo caso. È una caratteristica, non una seccatura: il compilatore ti indica esattamente dove il nuovo caso richiede attenzione.
Quando il payload non ti serve
Se il payload di una variante è Void o semplicemente in quel ramo non ti interessa, puoi ignorare il binding, ma devi comunque scrivere il ramo per rispettare l'esaustività:
match token {
.word => w { /* usa w */ }
.number => n { /* usa n */ }
.eof => _ { /* niente da associare */ }
}
La sintassi esatta per "ignora il payload" potrebbe cambiare nello Zero pre-1.0 (potresti vedere _ oppure il binding omesso del tutto). Il punto concettuale, cioè che ogni variante ha un ramo con o senza payload, è la parte stabile.
Schemi comuni
Un tipo di errore in stile Result
È proprio l'esempio usato dal repository ufficiale:
choice Result {
ok: i32,
err: String,
}
Le funzioni che possono riuscire con un valore o fallire con un messaggio restituiscono un Result. Chi le chiama usa il pattern matching per estrarre il valore o il messaggio. Il sistema raises/check di Zero gestisce la propagazione per le operazioni che possono fallire; Result è utile quando vuoi conservare un valore di successo o fallimento come dato.
Un token di un parser
choice Token {
word: String,
number: i32,
eof: Void,
}
Un tokenizer produce un flusso di Token. Ogni consumatore fa match sulla variante per decidere cosa fare: stampare la parola, sommare il numero, uscire con eof.
Una macchina a stati
choice State {
waiting: Void,
processing: i32,
done: String,
}
processing contiene l'ID dell'attività corrente; done contiene il risultato finale. Ogni transizione è un nuovo valore State: niente campi mutabili sparsi in una shape.
Choice e generics
choice può essere generica proprio come shape:
choice Maybe<T> {
some: T,
none: Void,
}
Maybe<i32> è "un intero opzionale". Maybe<String> è "una stringa opzionale". Lo stesso schema compare nella libreria standard di Zero ed è molto più adatto di un valore sentinella null: una volta che fai match sul tipo, non c'è modo di dimenticare il caso .none.
Quando usare choice, shape o enum
Un breve riepilogo da shape ed enum:
- Shape: un record con più campi, tutti presenti insieme.
- Enum: una tra N etichette, senza dati aggiuntivi.
- Choice: una tra N varianti, ognuna con un payload.
La maggior parte dei modelli di dati in un programma reale è una combinazione di questi tre. La chiarezza che deriva dal chiedersi "è un e, un o, oppure un o con dati?" è uno dei vantaggi sottovalutati di lavorare con un linguaggio piccolo.
Prossimo passo: la capability World
choice e match coprono il lato dei dati di Zero. Il prossimo capitolo parla degli effetti, cioè di come i programmi Zero interagiscono con il mondo esterno. Si parte dalla capability World, l'oggetto che regola ogni operazione di I/O.
Domande frequenti
Cos'è una choice in Zero?
Una choice è il tipo unione etichettata di Zero: un valore che è una tra diverse varianti con nome, ognuna con il proprio tipo di payload. Esempio: choice Result { ok: i32, err: String }. Un valore Result è o un ok che contiene un i32 o un err che contiene una String. Lo costruisci con Result.ok(42) o Result.err("bad").
Come funziona match in Zero?
match value { .variantA => binding { ...body } .variantB => binding { ...body } } sceglie un ramo in base alla variante contenuta in value. Ogni ramo riconosce una variante, dà un nome al binding del payload ed esegue il suo corpo. Il compilatore verifica che tu abbia coperto ogni variante: l'esaustività è il vantaggio principale rispetto a if/else if.
Come si costruisce un valore choice?
Lo costruisci indicando il tipo e la variante e passando il payload: let r: Result = Result.ok(42) o let r = Result.err("convalida non riuscita"). Il tipo del payload deve corrispondere a quello dichiarato nella variante: passare il tipo sbagliato è un errore di compilazione.
Qual è la differenza tra choice ed enum?
Le varianti di un enum sono solo etichette senza payload. Le varianti di una choice portano ciascuna un valore di un tipo dichiarato. Se devi associare dei dati a uno dei casi (un messaggio di errore, un risultato riuscito, un token analizzato), usa choice. Se i casi sono semplici etichette, usa enum.
Perché con le choice si preferisce match a if-else?
match è esaustivo per costruzione: il compilatore controlla che ogni variante sia gestita, quindi se in seguito aggiungi una nuova variante sei costretto ad aggiornare ogni punto che fa una scelta in base a quel tipo. Una catena if/else if va avanti in silenzio, nascondendo il caso mancante finché non si presenta come bug in produzione.