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:
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’è:
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.
"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.
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 calledCon 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.
| Funzione | La funzione che le passi restituisce | Uso tipico |
|---|---|---|
result.map(r, f) | un valore normale | trasformare il valore se c’è |
result.try(r, f) | un altro Result | concatenare 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 è unOk, altrimentidefault.result.unwrap(int.parse("x"), 0)vale 0. Comodo quando c’è un valore di ripiego sensato.result.replace_error(r, e): se è unError, sostituisce l’errore cone. Serve a trasformare unError(Nil)generico in un errore del tuo tipo, che dice cosa è andato storto.result.map_error(r, f): comereplace_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:
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:
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"
}
}12:30 -> 750 minutes
7:05 -> 425 minutes
noon -> missing colon
12:3x -> not a number: 3x
25:00 -> out of rangeOgni 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): applicaf(che restituisce unResult) a ogni elemento. Se tutti vanno bene, ottieniOkcon la lista dei valori; al primo errore si ferma e restituisce quell’errore. “Tutto o niente.”list.filter_map(l, f): applicafa ogni elemento e tiene solo i valori degliOk, scartando in silenzio gli errori. “Prendi quello che si può.”
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, restituisceErrorcon 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):
Strict: Ok(12)
Strict: Error("x")
Lenient: 8per sum_strict("3, 4, 5"), sum_strict("3, x, 5") e sum_lenient("3, x, 5").
Mostra una soluzione (prima prova da solo!)
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 unOk; gli errori passano intatti.result.try(r, f)concatena un passo che restituisce a sua volta unResult; il primo errore ferma la catena.result.unwrap(r, default)tira fuori il valore, o un ripiego.result.replace_erroreresult.map_errortrasformano l’errore, per esempio daNila un tuo tipo.string.split_once(testo, on: ":")divide alla prima occorrenza e restituisce unResult.list.try_map: tutto o niente.list.filter_map: tiene solo i successi.result.allsu una lista diResult.
Nella prossima lezione, la parola chiave use: la stessa catena di result.try, ma scritta dritta, una riga sotto l’altra.