Lezione 3 di 6 · 18 min di lettura

use

La parola chiave che trasforma una scala di funzioni annidate in righe dritte. Come funziona davvero, use con result.try, e le uscite anticipate con bool.guard.

La scala, di nuovo

Alla fine della lezione precedente, parse_time era così:

gleam
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) })
    })
  })
}

Ogni result.try riceve una funzione anonima, e tutto il resto del lavoro sta dentro quella funzione. Tre passi, tre livelli di rientro. Con dieci passi, dieci livelli. Gleam ha una parola chiave fatta apposta per questa forma: use.

La stessa funzione, con use

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

Stessi passi, stesso comportamento, stessi errori. Ma ora si legge dall’alto in basso, come se ogni operazione “tirasse fuori” il valore dal suo Result: dividi il testo, prendi le ore, prendi i minuti, controlla. Se un passo fallisce, la funzione restituisce quell’errore e le righe sotto non vengono eseguite.

Nota che il valore a sinistra della freccia può essere un pattern, come #(hours_text, minutes_text): la coppia viene smontata direttamente.

Come funziona davvero

use sembra magia, ma è solo una riscrittura, fatta dal compilatore, di codice che sai già leggere. La regola è una:

Tutto quello che sta sotto una riga use x <- f(a), fino alla fine del blocco, diventa il corpo di una funzione anonima fn(x) { ... }, che viene passata come ultimo argomento di f.

Quindi questo:

gleam
  use hours <- result.try(parse_number(hours_text))
  hours * 60

è esattamente questo:

gleam
  result.try(parse_number(hours_text), fn(hours) {
    hours * 60
  })

Nient’altro. use non conosce i Result, non gestisce errori, non ha niente di speciale: funziona con qualsiasi funzione il cui ultimo argomento è una funzione. È result.try a decidere di non chiamare la funzione se c’è un errore; use si limita a farti scrivere quella funzione senza graffe e senza rientri.

Per convincersene, eccolo con una funzione che non ha niente a che fare con gli errori, list.each:

src/use_each.gleam
import gleam/io
import gleam/list

pub fn main() -> Nil {
  use name <- list.each(["Ada", "Grace"])
  io.println("Hello, " <> name)
}
output
Hello, Ada
Hello, Grace

list.each(lista, fn(name) { io.println(...) }), scritto con use.

Quiz

In cosa si trasforma use x <- f(1) seguito dalla riga x + 1?

Uscire prima: bool.guard

Un’altra forma comune: “se questa condizione è vera, restituisci subito questo valore, altrimenti continua”. Negli altri linguaggi è un return anticipato in un if. In Gleam c’è bool.guard, pensata per essere usata con use:

gleam
  use <- bool.guard(when: amount <= 0, return: Error(InvalidAmount))
  // from here on, amount is positive

bool.guard(when: condizione, return: valore, otherwise: funzione): se la condizione è vera restituisce valore, altrimenti chiama la funzione. Con use, “la funzione” è il resto del blocco. Nota che qui a sinistra della freccia non c’è niente: la funzione del resto non riceve argomenti.

Mettere tutto insieme

use con result.try e bool.guard si combinano bene per le validazioni: una serie di controlli, ognuno dei quali può fermare tutto con un errore preciso.

gleam
pub fn validate(
  name: String,
  age_text: String,
  email: String,
) -> Result(User, FormError) {
  let name = string.trim(name)
  use <- bool.guard(when: name == "", return: Error(EmptyName))
  use age <- result.try(parse_age(age_text))
  use <- bool.guard(
    when: !string.contains(email, "@"),
    return: Error(BadEmail(email)),
  )
  Ok(User(name:, age:, email:))
}

Una riga per controllo, nell’ordine in cui avvengono. E alla fine, arrivati fin lì, tutto è valido. (string.contains(testo, pezzo) dice se pezzo compare in testo; User(name:, age:, email:) usa la scorciatoia delle etichette anche nella costruzione del record.)

Esercizio · sul tuo computer

Il modulo di iscrizione

Nel progetto exercises crea src/signup.gleam con i tipi:

gleam
pub type User {
  User(name: String, age: Int, email: String)
}

pub type FormError {
  EmptyName
  BadAge(String)
  BadEmail(String)
}

Scrivi:

  • parse_age(text: String) -> Result(Int, FormError): un’età da 0 a 150, altrimenti BadAge con il testo;
  • validate(name: String, age_text: String, email: String) -> Result(User, FormError), con use, bool.guard e result.try, come nell’esempio della lezione (il nome senza spazi ai lati non deve essere vuoto, l’email deve contenere @);
  • describe(result: Result(User, FormError)) -> String, come nell’output.

In main prova validate con: "Ada", "36", "ada@example.com"; " ", "36", "x@y.z"; "Ada", "forty", "ada@example.com"; "Ada", "36", "ada.example.com".

output
Welcome, Ada (36)
Error: the name is empty
Error: not a valid age: forty
Error: not a valid email: ada.example.com
Mostra una soluzione (prima prova da solo!)
src/signup.gleam
import gleam/bool
import gleam/int
import gleam/io
import gleam/result
import gleam/string

pub type User {
  User(name: String, age: Int, email: String)
}

pub type FormError {
  EmptyName
  BadAge(String)
  BadEmail(String)
}

pub fn main() -> Nil {
  io.println(describe(validate("Ada", "36", "ada@example.com")))
  io.println(describe(validate("  ", "36", "x@y.z")))
  io.println(describe(validate("Ada", "forty", "ada@example.com")))
  io.println(describe(validate("Ada", "36", "ada.example.com")))
}

pub fn validate(
  name: String,
  age_text: String,
  email: String,
) -> Result(User, FormError) {
  let name = string.trim(name)
  use <- bool.guard(when: name == "", return: Error(EmptyName))
  use age <- result.try(parse_age(age_text))
  use <- bool.guard(
    when: !string.contains(email, "@"),
    return: Error(BadEmail(email)),
  )
  Ok(User(name:, age:, email:))
}

fn parse_age(text: String) -> Result(Int, FormError) {
  case int.parse(text) {
    Ok(age) if age >= 0 && age <= 150 -> Ok(age)
    _ -> Error(BadAge(text))
  }
}

fn describe(result: Result(User, FormError)) -> String {
  case result {
    Ok(user) ->
      "Welcome, " <> user.name <> " (" <> int.to_string(user.age) <> ")"
    Error(EmptyName) -> "Error: the name is empty"
    Error(BadAge(text)) -> "Error: not a valid age: " <> text
    Error(BadEmail(email)) -> "Error: not a valid email: " <> email
  }
}

In parse_age un pattern con guardia (Ok(age) if ...) e un _ finale coprono sia i testi che non sono numeri sia i numeri fuori intervallo, con lo stesso errore.

Ricapitolando

  • use x <- f(a) trasforma il resto del blocco in fn(x) { ... }, passata come ultimo argomento di f.
  • Non è specifica degli errori: funziona con qualsiasi funzione che riceve una funzione come ultimo argomento.
  • use value <- result.try(operazione) scrive dritta una catena di passi che possono fallire; il primo errore la interrompe.
  • A sinistra della freccia può esserci un pattern, o niente (use <- ...).
  • use <- bool.guard(when: condizione, return: valore) esce subito dalla funzione con valore.
  • use si prende tutto il resto del blocco: usalo con misura, soprattutto per result.try e bool.guard.

Nella prossima lezione vediamo l’altra faccia degli errori: quando è giusto fermare il programma di proposito.