Lezione 1 di 6 · 20 min di lettura
Result
Un'operazione che può fallire restituisce un valore che lo dice. Ok ed Error, i tipi di errore fatti su misura, e il compilatore che non ti lascia ignorarli.
Un numero che forse non è un numero
Finora tutti i dati dei nostri programmi li abbiamo scritti noi. Ma prima o poi un programma riceve dati dall’esterno: un testo digitato da qualcuno, una riga di un file, un messaggio dalla rete. E i dati esterni possono essere sbagliati.
Prendi il testo "42". Per farci dei conti bisogna trasformarlo in un Int, e la funzione che lo fa è int.parse. Ma cosa dovrebbe restituire int.parse("ciao")?
import gleam/int
import gleam/io
import gleam/string
pub fn main() -> Nil {
io.println(string.inspect(int.parse("42")))
io.println(string.inspect(int.parse("-7")))
io.println(string.inspect(int.parse("hello")))
io.println(string.inspect(int.parse(" 42")))
}Ok(42)
Ok(-7)
Error(Nil)
Error(Nil)int.parse non restituisce un Int, ma un Result: un tipo con due varianti, come quelli del modulo 4. Ok(42) significa “è andata bene, ecco il valore”; Error(Nil) significa “non è andata bene”. (Nota l’ultimo caso: anche uno spazio all’inizio basta a far fallire la conversione. Con i dati veri conviene passare prima da string.trim, che toglie gli spazi ai lati.)
Il tipo Result
Result è già nel linguaggio, ma se dovessimo scriverlo noi sarebbe così:
pub type Result(value, error) {
Ok(value)
Error(error)
}È un tipo generico con due parametri: il tipo del valore in caso di successo, e il tipo dell’errore in caso di fallimento. int.parse ha tipo fn(String) -> Result(Int, Nil): se va bene hai un Int, se va male hai Nil, cioè “nessuna informazione in più, solo che non ha funzionato”.
Come con Option nella lezione 4.5, un Result(Int, Nil) non è un Int. Non puoi sommarlo né stamparlo con int.to_string: per arrivare al numero devi aprirlo, e per aprirlo devi dire cosa fare in entrambi i casi.
import gleam/int
import gleam/io
pub fn main() -> Nil {
io.println(double_text("21"))
io.println(double_text("twenty"))
}
fn double_text(text: String) -> String {
case int.parse(text) {
Ok(n) -> int.to_string(n * 2)
Error(Nil) -> "Not a number: " <> text
}
}42
Not a number: twentyNiente eccezioni
In molti linguaggi, quando un’operazione fallisce, viene lanciata un’eccezione: l’esecuzione salta via dal punto in cui si trova, attraversa le funzioni, e si ferma nel primo blocco try/catch che la raccoglie, oppure fa terminare il programma. Il problema è che, guardando una funzione, non sai se può lanciare un’eccezione, né quale. Lo scopri quando succede.
Gleam non ha eccezioni. Un errore è un valore come un altro, restituito dalla funzione, e il suo tipo è scritto nella firma: se una funzione può fallire, restituisce un Result, e chiunque la usi lo vede e deve gestirlo. Non ci sono salti nascosti: il programma va sempre dall’alto in basso, come quelli che hai scritto finora.
Dettagli nerd Cos'è un'eccezione, per la macchina?
Quando un linguaggio lancia un’eccezione, la macchina deve “srotolare” lo stack delle chiamate (quello della lezione 2.6): toglie un promemoria dopo l’altro, abbandonando le funzioni a metà, finché non trova una funzione che ha dichiarato di voler raccogliere quell’eccezione. È un salto non locale: il flusso del programma non torna più indietro nel punto dell’errore.
Anche la BEAM ha questo meccanismo, e Erlang lo usa. Gleam però non lo espone nel linguaggio: le funzioni restituiscono sempre un valore, e un Result viaggia all’indietro lungo le chiamate come qualsiasi altro valore restituito. Il costo è lo stesso di restituire una tupla.
Quiz
Che tipo ha int.parse("7")?
Il compilatore non ti lascia ignorarli
Cosa succede se chiami una funzione che restituisce un Result e non guardi il risultato? Gleam te lo fa notare:
import gleam/int
import gleam/io
pub fn main() -> Nil {
int.parse("42")
io.println("Done")
}warning: Unused result value
┌─ /home/ada/learn-gleam/exercises/src/ignored.gleam:5:3
│
5 │ int.parse("42")
│ ^^^^^^^^^^^^^^^ The Result value created here is unused
Hint: If you are sure you don't need it you can assign it to `_`.
DoneUn Result scartato di solito è un errore dimenticato. Se davvero non ti interessa, lo dichiari con let _ = int.parse("42"), e l’avviso sparisce: hai detto chiaramente che lo ignori apposta.
I tuoi errori
Error(Nil) dice solo che qualcosa è andato storto. Per le tue funzioni puoi fare di meglio: l’errore può essere di qualsiasi tipo, e il tipo più utile è quasi sempre un tipo personalizzato, con una variante per ogni cosa che può andare storta.
import gleam/int
import gleam/io
pub type WithdrawError {
InvalidAmount
InsufficientFunds(missing: Int)
}
pub fn main() -> Nil {
io.println(describe(withdraw(100, 30)))
io.println(describe(withdraw(100, 250)))
io.println(describe(withdraw(100, -5)))
}
fn withdraw(balance: Int, amount: Int) -> Result(Int, WithdrawError) {
case amount {
a if a <= 0 -> Error(InvalidAmount)
a if a > balance -> Error(InsufficientFunds(missing: a - balance))
a -> Ok(balance - a)
}
}
fn describe(result: Result(Int, WithdrawError)) -> String {
case result {
Ok(balance) -> "New balance: " <> int.to_string(balance)
Error(InvalidAmount) -> "The amount must be positive"
Error(InsufficientFunds(missing:)) ->
"Not enough money: " <> int.to_string(missing) <> " missing"
}
}New balance: 70
Not enough money: 150 missing
The amount must be positiveGuarda la firma di withdraw: Result(Int, WithdrawError). Solo leggendola sai che la funzione può fallire, e in quali modi. E in describe i pattern entrano dentro l’errore (Error(InsufficientFunds(missing:))): il compilatore controlla che ogni tipo di errore sia gestito. Se domani aggiungi una variante AccountFrozen, ti porterà in tutti i punti da aggiornare.
Esercizio · sul tuo computer
L'età
Nel progetto exercises crea src/age.gleam con un tipo di errore:
pub type AgeError {
NotANumber(text: String)
OutOfRange(age: Int)
}Scrivi parse_age(text: String) -> Result(Int, AgeError): toglie gli spazi ai lati con string.trim, converte con int.parse, e accetta solo età da 0 a 150. Poi describe(text: String) -> String, che chiama parse_age e produce una riga come nell’output.
In main stampa describe di "36", " 42 ", "abc", "250" e "-1":
Age: 36
Age: 42
Not a number: abc
Out of range: 250
Out of range: -1Suggerimento: in parse_age, un case sul risultato di int.parse; nel ramo Ok, un secondo case (o delle guardie) per l’intervallo.
Mostra una soluzione (prima prova da solo!)
import gleam/int
import gleam/io
import gleam/list
import gleam/string
pub type AgeError {
NotANumber(text: String)
OutOfRange(age: Int)
}
pub fn main() -> Nil {
["36", " 42 ", "abc", "250", "-1"]
|> list.each(fn(text) { io.println(describe(text)) })
}
fn parse_age(text: String) -> Result(Int, AgeError) {
let text = string.trim(text)
case int.parse(text) {
Error(Nil) -> Error(NotANumber(text))
Ok(age) if age < 0 || age > 150 -> Error(OutOfRange(age))
Ok(age) -> Ok(age)
}
}
fn describe(text: String) -> String {
case parse_age(text) {
Ok(age) -> "Age: " <> int.to_string(age)
Error(NotANumber(text:)) -> "Not a number: " <> text
Error(OutOfRange(age:)) -> "Out of range: " <> int.to_string(age)
}
}Le guardie funzionano anche sui pattern dei Result: Ok(age) if age < 0 || age > 150. E in parse_age il nome text viene riusato con un nuovo let: da lì in poi è il testo senza spazi, ed è quello che finisce nell’errore.
Ricapitolando
- Un’operazione che può fallire restituisce un
Result(valore, errore):Ok(valore)oError(errore). int.parse(testo)restituisceResult(Int, Nil);string.trimtoglie gli spazi prima.- Un
Resultnon è il valore che contiene: si apre con uncaseche gestisceOkedError. - Gleam non ha eccezioni: gli errori sono valori, scritti nel tipo della funzione.
- Un
Resultignorato dà l’avvisoUnused result value;let _ = ...per ignorarlo apposta. - Per le tue funzioni, l’errore è di solito un tipo personalizzato con una variante per ogni problema.
Optionse un valore può mancare normalmente,Resultse qualcosa è andato storto.
Nella prossima lezione vediamo come concatenare più operazioni che possono fallire senza scrivere un case dentro l’altro.