Menu

Raises e check in Zero: errori espliciti senza eccezioni

Le funzioni di Zero dichiarano i loro modi di fallire con raises e chi le chiama ne prende atto con check. Ecco come funziona il sistema, perché non esistono eccezioni silenziose e come interagisce con la capability World.

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

Il quadro generale

In Zero il fallimento non è un flusso di controllo separato e parallelo. Fa parte del normale flusso di controllo, è dichiarato nella firma di una funzione e riconosciuto in ogni punto di chiamata. Due elementi lo rendono possibile:

  • raises nella firma della funzione: "questa funzione può fallire".
  • check nel punto di chiamata: "se questo fallisce, fai fallire la mia funzione con lo stesso errore".

Questa combinazione basta per esprimere ciò per cui la maggior parte dei linguaggi ricorre a try/catch o ai tipi Result.

Dichiarare una funzione che può fallire

Aggiungi raises dopo il tipo di ritorno:

fun validate(ok: Bool) -> i32 raises { InvalidInput } {
    if ok == false {
        raise InvalidInput
    }
    return 42
}

Due modi di leggere la firma:

  • "validate restituisce un i32 oppure solleva InvalidInput."
  • "L'insieme dei risultati possibili è { i32, InvalidInput }."

Entrambe le letture sono corrette. Il compilatore tiene traccia di tutte e due le possibilità e pretende che chi chiama faccia qualcosa di esplicito per ciascuna.

Puoi anche scrivere una clausola raises semplice:

pub fun main(world: World) -> Void raises {
    check world.out.write("ciao\n")
}

raises (senza elenco di errori) significa "questa funzione può fallire con qualsiasi errore". Su main è la forma convenzionale: il programma può terminare con uno stato diverso da zero se qualcosa va storto, e il runtime si occupa di far emergere l'errore.

Per le funzioni più in profondità nello stack di chiamate, preferisci la forma esplicita raises { ErrorA, ErrorB }, così i modi di fallire sono documentati a ogni livello.

Sollevare un errore

All'interno di una funzione che può fallire, raise fa uscire dalla funzione con l'errore indicato:

fun validate(ok: Bool) -> i32 raises { InvalidInput } {
    if ok == false {
        raise InvalidInput
    }
    return 42
}

raise InvalidInput produce un errore InvalidInput in quella riga. La funzione non prosegue oltre quel punto: il controllo torna al chiamante, che vede l'errore invece di un i32. La clausola raises elenca gli unici tipi di errore che questa funzione può sollevare; sollevare qualcosa che non è nell'elenco è un errore di compilazione.

Propagare un errore con check

Chi chiama una funzione che può fallire deve prendere atto del possibile fallimento. Il modo più comune è check:

fun run() -> Void raises { InvalidInput } {
    check validate(true)
}

check validate(true) fa due cose:

  1. Chiama validate(true).
  2. Se validate ha sollevato un errore, lo propaga verso l'alto: run solleva lo stesso errore verso il suo chiamante.

Perché la propagazione sia permessa, run deve dichiarare nella propria clausola raises di poter sollevare InvalidInput (o qualcosa di compatibile). Il compilatore lo verifica. Se la firma di run dicesse raises { OtherError }, la propagazione non compilerebbe perché InvalidInput non è nell'insieme.

Un esempio completo tratto dagli esempi ufficiali del linguaggio: clicca su Esegui per vedere la propagazione andare a buon fine:

Il tipo di errore viaggia con la firma della funzione lungo tutto lo stack di chiamate. Il raises semplice di main accetta qualsiasi cosa run possa sollevare, quindi la propagazione arriva a destinazione senza problemi.

Perché non try/catch?

La disciplina di design dietro raises/check è che il fallimento non è mai invisibile in un punto di chiamata. In un linguaggio con try/catch, un'eccezione può attraversare in silenzio una funzione che non sa nemmeno di esserne coinvolta: la funzione sembra pura, ma una chiamata profonda nel suo corpo lancia un'eccezione che risale attraverso di essa.

È comodo per chi scrive il codice che lancia l'eccezione. Costa caro a tutti gli altri:

  • Chi legge (persona o agente) non può capire dalla firma se una funzione partecipa ai percorsi di errore.
  • Il refactoring diventa rischioso: spostare una chiamata da una funzione all'altra può cambiare quali eccezioni sono raggiungibili.
  • Il codice di recupero vive lontano dal punto che sa cosa fare.

Zero paga il costo in anticipo, con annotazioni su ogni funzione che può fallire e check su ogni chiamata che può fallire, per ottenere la proprietà "i modi di fallire a cui partecipa una funzione sono visibili dalla sua firma". È una proprietà su cui possono contare sia le persone sia gli agenti.

Perché non semplicemente Result<T, E>?

Potresti esprimere la stessa cosa con una choice: un tipo Result<T, E> con le varianti ok ed err. Zero ti offre anche questo schema; è uno strumento utile quando il fallimento è un dato che vuoi ispezionare, conservare o passare in giro.

Ciò che raises/check aggiunge è una convenzione a livello di sintassi per il caso comune: "se questo fallisce, fai fallire la mia funzione allo stesso modo". Senza di essa, ogni chiamata sarebbe avvolta in un match che quasi sempre reimpacchetta l'errore nel Result del chiamante. check è la scorciatoia per questo, con il compilatore che garantisce che la propagazione sia ben tipizzata.

Quindi:

  • Result<T, E> (una choice): quando vuoi ispezionare o trasportare il fallimento come valore.
  • raises + check: quando vuoi solo propagare il fallimento lungo lo stack di chiamate.

Sono disponibili entrambi; coprono esigenze ergonomiche diverse.

Più tipi di errore

Una funzione può sollevare più di un tipo di errore:

fun parse(input: String) -> i32 raises { Empty, Malformed } {
    if std.mem.len(input) == 0 {
        raise Empty
    }
    // ... logica di parsing ...
    raise Malformed
}

Chi chiama può:

  • Propagare con check parse(input) se la sua firma elenca sia Empty sia Malformed (o un insieme più ampio).
  • Gestire uno o entrambi gli errori esplicitamente con match o con i costrutti in stile try che il linguaggio offre a questo scopo.

La sintassi esatta per la gestione dettagliata (fare match su tipi di errore specifici e propagare gli altri) è una delle parti che potrebbero cambiare nello Zero pre-1.0. Il contratto, cioè che ogni tipo di errore che una funzione può sollevare compare nella sua firma, è la parte stabile.

Un modello mentale

Il sistema degli errori di Zero è una versione rigorosamente onesta di ciò che ha ogni linguaggio imperativo:

ConcettoTry/catchZero
Segnare una funzione come fallibileniente (implicito)raises { ... }
Sollevare un errorethrow eraise E
Propagare al chiamanterisale in modo invisibilecheck call(...)
Gestire localmentetry { ... } catch(e) { ... }match su un Result restituito, o una forma di gestione tipizzata

La differenza di comportamento è piccola. La differenza nelle annotazioni è grande, e di proposito. Gli effetti sono espliciti. I fallimenti sono effetti.

Note di stile

  • Usa check senza risparmio. È la scelta predefinita giusta quando a questo livello non hai niente di specifico da fare.
  • Evita il raises semplice sulle funzioni di supporto interne. Più l'insieme degli errori è ristretto, più la firma è utile.
  • Abbina le operazioni che possono fallire alle capability di cui hanno bisogno. Una funzione che usa World per scrivere sullo stdout vuole quasi sempre raises, perché le scritture possono fallire.

Prossimo passo: diagnostica JSON

raises/check lavora insieme alla diagnostica del compilatore di Zero: quando scrivi check su una funzione che non dichiara gli errori giusti, il compilatore ti dice esattamente cosa non va in forma strutturata. La prossima pagina tratta la diagnostica JSON, il flusso leggibile dalle macchine che un agente usa per correggere il codice.

Domande frequenti

Cosa significa raises in Zero?

raises nella firma di una funzione dichiara che la funzione può fallire. Un raises semplice ammette qualsiasi tipo di errore. Una forma specifica come raises { InvalidInput } limita la funzione a fallire solo con i tipi di errore elencati. Chi la chiama deve prendere atto della possibilità di fallimento, con check o con un'altra forma esplicita di gestione.

Cosa fa l'operatore check?

check expr valuta expr e, se produce un errore, lo propaga a chi ha chiamato la funzione corrente. Pensalo come 'esegui questo e, se fallisce, fai fallire il mio chiamante con lo stesso errore'. Perché la clausola raises del chiamante permetta la propagazione, il chiamante deve dichiarare a sua volta di poter sollevare un errore compatibile.

Come si solleva un errore in Zero?

Usa raise ErrorName all'interno di una funzione la cui clausola raises include quell'errore. Esempio: if ok == false { raise InvalidInput }. La funzione termina in quel punto; l'errore diventa il risultato della funzione, che il chiamante può gestire con check.

Perché Zero non usa try/catch?

Try/catch permette alle eccezioni di attraversare in silenzio funzioni che non ne sanno nulla. Il design di Zero prevede che la firma di ogni funzione dichiari i modi di fallire a cui partecipa. Non c'è flusso di controllo nascosto: se una funzione può fallire, la sua firma lo dice, e ogni chiamante deve prenderne atto esplicitamente con check.

In Zero una funzione può sollevare più tipi di errore?

Sì: elencali nella clausola raises { ... }, separati da virgole (o seguendo la sintassi attuale di Zero per gli insiemi di errori). L'insieme degli errori possibili fa parte del contratto della funzione, proprio come i tipi dei parametri e del valore di ritorno. Chi chiama può fare pattern matching sull'errore sollevato o semplicemente propagarlo con check.

Illustrazione dei linguaggi di programmazione di Coddy

Impara a programmare con Coddy

INIZIA