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

src/parsing.gleam
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")))
}
output
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ì:

gleam
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.

src/doubling.gleam
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
  }
}
output
42
Not a number: twenty

Niente 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:

src/ignored.gleam
import gleam/int
import gleam/io

pub fn main() -> Nil {
  int.parse("42")
  io.println("Done")
}
output
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 `_`.

Done

Un 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.

src/bank.gleam
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"
  }
}
output
New balance: 70
Not enough money: 150 missing
The amount must be positive

Guarda 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:

gleam
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":

output
Age: 36
Age: 42
Not a number: abc
Out of range: 250
Out of range: -1

Suggerimento: 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!)
src/age.gleam
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) o Error(errore).
  • int.parse(testo) restituisce Result(Int, Nil); string.trim toglie gli spazi prima.
  • Un Result non è il valore che contiene: si apre con un case che gestisce Ok ed Error.
  • Gleam non ha eccezioni: gli errori sono valori, scritti nel tipo della funzione.
  • Un Result ignorato dà l’avviso Unused result value; let _ = ... per ignorarlo apposta.
  • Per le tue funzioni, l’errore è di solito un tipo personalizzato con una variante per ogni problema.
  • Option se un valore può mancare normalmente, Result se qualcosa è andato storto.

Nella prossima lezione vediamo come concatenare più operazioni che possono fallire senza scrivere un case dentro l’altro.