Lezione 2 di 6 · 20 min di lettura

Il modulo result

Trasformare un Result senza aprirlo, concatenare operazioni che possono fallire, convertire gli errori. E cosa fare con una lista di Result.

Il problema dei case annidati

Supponiamo di dover leggere un orario scritto come "12:30" e trasformarlo in minuti dalla mezzanotte. I passi sono tre, e ognuno può fallire: dividere il testo sui due punti, convertire le ore, convertire i minuti. Con quello che sappiamo, ogni passo è un case, e ogni case sta dentro il ramo Ok del precedente:

gleam
fn minutes(text: String) -> Result(Int, Nil) {
  case string.split_once(text, on: ":") {
    Error(Nil) -> Error(Nil)
    Ok(#(hours_text, minutes_text)) ->
      case int.parse(hours_text) {
        Error(Nil) -> Error(Nil)
        Ok(hours) ->
          case int.parse(minutes_text) {
            Error(Nil) -> Error(Nil)
            Ok(minutes) -> Ok(hours * 60 + minutes)
          }
      }
  }
}

(string.split_once(text, on: ":") taglia il testo alla prima occorrenza del separatore, e restituisce la coppia dei due pezzi, oppure Error(Nil) se il separatore non c’è.)

Funziona, ma guarda la forma: la parte interessante, hours * 60 + minutes, sta in fondo a una scala che scende verso destra, e metà delle righe dicono la stessa cosa: “se è un errore, restituisci l’errore”. Il modulo gleam/result esiste per non dover scrivere quelle righe.

result.map: trasformare il valore dentro

result.map(r, f) applica f al valore se r è un Ok, e lascia passare l’errore così com’è:

gleam
  result.map(Ok(2), fn(x) { x * 10 })        // Ok(20)
  result.map(Error("bad"), fn(x) { x * 10 }) // Error("bad")

È lo stesso map delle liste, applicato a una “scatola” che contiene zero o un valore. Il vantaggio: puoi trasformare il risultato di un’operazione che può fallire senza aprirlo con un case, e l’eventuale errore viaggia intatto fino in fondo.

gleam
  "21"
  |> int.parse
  |> result.map(fn(n) { n * 2 })

vale Ok(42); con "ventuno" varrebbe Error(Nil).

result.try: un’altra operazione che può fallire

E se la trasformazione stessa può fallire? Con result.map otterresti un Result dentro un Result. Per questo c’è result.try(r, f): se r è un Ok, chiama f con il valore, e f restituisce un nuovo Result; se r è un Error, si ferma subito e restituisce quell’errore.

gleam
  Ok("5") |> result.try(int.parse)     // Ok(5)
  Ok("x") |> result.try(int.parse)     // Error(Nil)
  Error(Nil) |> result.try(int.parse)  // Error(Nil): int.parse is never called

Con try si mette in fila una catena di passi che possono fallire: il primo errore interrompe la catena, e arriva in fondo così com’è. È il “se è un errore, restituisci l’errore” dei case annidati, scritto una volta sola.

FunzioneLa funzione che le passi restituisceUso tipico
result.map(r, f)un valore normaletrasformare il valore se c’è
result.try(r, f)un altro Resultconcatenare un passo che può fallire

Quiz

Quanto vale Ok("7") |> result.try(int.parse) |> result.map(fn(n) { n + 1 })?

Uscire dal Result, e cambiare l’errore

Altre tre funzioni che userai spesso:

  • result.unwrap(r, default): il valore se è un Ok, altrimenti default. result.unwrap(int.parse("x"), 0) vale 0. Comodo quando c’è un valore di ripiego sensato.
  • result.replace_error(r, e): se è un Error, sostituisce l’errore con e. Serve a trasformare un Error(Nil) generico in un errore del tuo tipo, che dice cosa è andato storto.
  • result.map_error(r, f): come replace_error, ma calcola il nuovo errore dal vecchio con una funzione.

replace_error è la chiave per usare le funzioni della libreria (che spesso falliscono con Nil) dentro funzioni che hanno un tipo di errore preciso:

gleam
pub type TimeError {
  MissingColon
  NotANumber(String)
  OutOfRange
}

fn parse_number(text: String) -> Result(Int, TimeError) {
  int.parse(text) |> result.replace_error(NotANumber(text))
}

L’orario, senza scala

Rimettiamo insieme l’esempio dell’inizio, con un tipo di errore vero e le funzioni di result:

src/time_parse.gleam
import gleam/int
import gleam/io
import gleam/list
import gleam/result
import gleam/string

pub type TimeError {
  MissingColon
  NotANumber(String)
  OutOfRange
}

pub fn main() -> Nil {
  ["12:30", "7:05", "noon", "12:3x", "25:00"]
  |> list.each(fn(text) {
    io.println(text <> " -> " <> describe(parse_time(text)))
  })
}

fn parse_time(text: String) -> Result(Int, TimeError) {
  string.split_once(text, on: ":")
  |> result.replace_error(MissingColon)
  |> result.try(fn(parts) {
    let #(hours_text, minutes_text) = parts
    parse_number(hours_text)
    |> result.try(fn(hours) {
      parse_number(minutes_text)
      |> result.try(fn(minutes) { check_range(hours, minutes) })
    })
  })
}

fn parse_number(text: String) -> Result(Int, TimeError) {
  int.parse(text) |> result.replace_error(NotANumber(text))
}

fn check_range(hours: Int, minutes: Int) -> Result(Int, TimeError) {
  case hours < 24 && minutes < 60 {
    True -> Ok(hours * 60 + minutes)
    False -> Error(OutOfRange)
  }
}

fn describe(result: Result(Int, TimeError)) -> String {
  case result {
    Ok(minutes) -> int.to_string(minutes) <> " minutes"
    Error(MissingColon) -> "missing colon"
    Error(NotANumber(text)) -> "not a number: " <> text
    Error(OutOfRange) -> "out of range"
  }
}
output
12:30 -> 750 minutes
7:05 -> 425 minutes
noon -> missing colon
12:3x -> not a number: 3x
25:00 -> out of range

Ogni fallimento produce l’errore giusto, e nessun ramo “se è un errore, restituisci l’errore” è scritto a mano. Ma sii onesto: parse_time si legge meglio della versione con i case? Le righe Error(Nil) -> Error(Nil) sono sparite, però le funzioni anonime annidate formano ancora una scala. Serve un’ultima idea, ed è l’argomento della prossima lezione.

Liste di Result

Capita spesso di avere una lista di cose da convertire, ognuna delle quali può fallire. La libreria offre due strategie, e la scelta dipende da cosa vuoi che succeda con gli elementi sbagliati:

  • list.try_map(l, f): applica f (che restituisce un Result) a ogni elemento. Se tutti vanno bene, ottieni Ok con la lista dei valori; al primo errore si ferma e restituisce quell’errore. “Tutto o niente.”
  • list.filter_map(l, f): applica f a ogni elemento e tiene solo i valori degli Ok, scartando in silenzio gli errori. “Prendi quello che si può.”
gleam
  list.try_map(["1", "2", "3"], int.parse)     // Ok([1, 2, 3])
  list.try_map(["1", "x", "3"], int.parse)     // Error(Nil)
  list.filter_map(["1", "x", "3"], int.parse)  // [1, 3]

(E se hai già una lista di Result, result.all fa la stessa cosa di try_map: Ok con tutti i valori, oppure il primo errore.)

Esercizio · sul tuo computer

La somma dei numeri

Nel progetto exercises crea src/sums.gleam con due funzioni che sommano i numeri di un testo separato da virgole, come "3, 4, 5":

  • sum_strict(text: String) -> Result(Int, String): se un pezzo non è un numero, restituisce Error con quel pezzo (già senza spazi);
  • sum_lenient(text: String) -> Int: ignora i pezzi che non sono numeri.

Entrambe: dividi con string.split(on: ","), togli gli spazi con list.map(string.trim), poi converti. Per sum_strict usa list.try_map, result.replace_error e result.map; per sum_lenient, list.filter_map.

In main stampa (con string.inspect per i Result):

output
Strict: Ok(12)
Strict: Error("x")
Lenient: 8

per sum_strict("3, 4, 5"), sum_strict("3, x, 5") e sum_lenient("3, x, 5").

Mostra una soluzione (prima prova da solo!)
src/sums.gleam
import gleam/int
import gleam/io
import gleam/list
import gleam/result
import gleam/string

pub fn main() -> Nil {
  io.println("Strict: " <> string.inspect(sum_strict("3, 4, 5")))
  io.println("Strict: " <> string.inspect(sum_strict("3, x, 5")))
  io.println("Lenient: " <> int.to_string(sum_lenient("3, x, 5")))
}

fn sum_strict(text: String) -> Result(Int, String) {
  text
  |> string.split(on: ",")
  |> list.map(string.trim)
  |> list.try_map(fn(item) { int.parse(item) |> result.replace_error(item) })
  |> result.map(int.sum)
}

fn sum_lenient(text: String) -> Int {
  text
  |> string.split(on: ",")
  |> list.map(string.trim)
  |> list.filter_map(int.parse)
  |> int.sum
}

In sum_strict la funzione passata a try_map converte un pezzo e, se fallisce, mette il pezzo stesso nell’errore. result.map(int.sum) somma la lista solo se tutto è andato bene. Due tubi quasi identici, due comportamenti opposti davanti ai dati sbagliati.

Ricapitolando

  • result.map(r, f) trasforma il valore di un Ok; gli errori passano intatti.
  • result.try(r, f) concatena un passo che restituisce a sua volta un Result; il primo errore ferma la catena.
  • result.unwrap(r, default) tira fuori il valore, o un ripiego.
  • result.replace_error e result.map_error trasformano l’errore, per esempio da Nil a un tuo tipo.
  • string.split_once(testo, on: ":") divide alla prima occorrenza e restituisce un Result.
  • list.try_map: tutto o niente. list.filter_map: tiene solo i successi. result.all su una lista di Result.

Nella prossima lezione, la parola chiave use: la stessa catena di result.try, ma scritta dritta, una riga sotto l’altra.