Lezione 3 di 6 · 18 min di lettura

Record

Varianti con dei dati dentro, e campi con un nome. Costruire un record, leggerne i campi, smontarlo con i pattern e crearne una copia aggiornata.

Una persona non è una tupla

Alla fine della lezione sulle tuple abbiamo lasciato un problema: #("Ada", 36, False) non dice cosa sia quel False. E se da qualche parte scrivessi #(36, "Ada", False), sarebbe un’altra cosa ancora, di un altro tipo. Per un gruppo di valori con un significato preciso, Gleam permette di dare un nome al gruppo e a ciascun valore.

Nella lezione precedente le varianti erano solo nomi. Ma una variante può anche contenere dei dati:

src/people.gleam
import gleam/io

pub type Person {
  Person(name: String, age: Int, needs_glasses: Bool)
}

pub fn main() -> Nil {
  let ada = Person("Ada", 36, False)
  let grace = Person(name: "Grace", age: 85, needs_glasses: True)
  echo ada
  echo grace.name
  echo grace.age
  io.println("Done")
}
output
src/people.gleam:10
Person("Ada", 36, False)
src/people.gleam:11
"Grace"
src/people.gleam:12
85
Done

Person è un tipo con una sola variante, che si chiama anch’essa Person (è l’abitudine quando c’è una variante sola, ma non è obbligatorio). La variante ha tre campi, ciascuno con un’etichetta e un tipo: name, age, needs_glasses. Un valore costruito così si chiama record.

Per costruirlo, la variante si usa come una funzione: Person("Ada", 36, False). E come per le funzioni, le etichette si possono scrivere nella chiamata, in qualsiasi ordine: Person(name: "Grace", age: 85, needs_glasses: True) si legge molto meglio, e di solito si preferisce questa forma. Se sbagli un tipo o dimentichi un campo, il compilatore ti ferma come con qualsiasi chiamata.

Leggere i campi

grace.name legge il campo name di grace: si chiama accessore. Se sbagli il nome del campo, il compilatore elenca quelli che esistono:

output
error: Unknown record field
   ┌─ /home/ada/learn-gleam/exercises/src/people.gleam:11:14
   │
11 │   echo grace.nmae
   │              ^^^^ Did you mean `name`?

The value being accessed has this type:

    Person

It has these accessible fields:

    .age
    .name
    .needs_glasses

Con gli accessori, i record si combinano benissimo con le funzioni sulle liste:

gleam
  people
  |> list.filter(fn(person) { person.age >= 18 })
  |> list.map(fn(person) { person.name })

“Delle persone, tieni i maggiorenni, e prendine i nomi.” Senza nomi di campo, con le tuple, sarebbe stato person.1 >= 18 e person.0: funziona, ma bisogna ricordarsi cosa c’è in ogni posizione.

Smontare un record

Come le tuple, un record si smonta con un pattern, che ha la stessa forma del costruttore:

gleam
  let Person(name: n, age: a, needs_glasses: _) = ada

Ora n vale "Ada" e a vale 36. Due scorciatoie rendono questi pattern molto più comodi:

  • .. alla fine significa “gli altri campi non mi interessano”: Person(name: n, ..);
  • se vuoi chiamare la variabile come il campo, basta l’etichetta con i due punti, come la scorciatoia della lezione 2.2: Person(name:, age:, ..) crea le variabili name e age.
gleam
  let Person(name:, age:, ..) = ada

Il let funziona perché Person ha una sola variante: il pattern corrisponde sempre. I pattern sui record servono soprattutto dentro case, dove si combinano con valori esatti e guardie:

src/greetings.gleam
import gleam/io

pub type Person {
  Person(name: String, age: Int, needs_glasses: Bool)
}

pub fn main() -> Nil {
  io.println(greeting(Person(name: "Tim", age: 12, needs_glasses: False)))
  io.println(greeting(Person(name: "Grace", age: 85, needs_glasses: True)))
  io.println(greeting(Person(name: "Ada", age: 36, needs_glasses: False)))
}

fn greeting(person: Person) -> String {
  case person {
    Person(name:, age:, ..) if age < 18 -> "Hi " <> name <> "!"
    Person(name:, needs_glasses: True, ..) ->
      "Good day, " <> name <> " (large print)"
    Person(name:, ..) -> "Good day, " <> name
  }
}
output
Hi Tim!
Good day, Grace (large print)
Good day, Ada

Person(name:, needs_glasses: True, ..) corrisponde solo alle persone con gli occhiali, e intanto prende il nome.

Quiz

Con let p = Person(name: "Ada", age: 36, needs_glasses: False), quale riga non compila?

Aggiornare, cioè copiare con modifiche

Un record non si modifica, ma capita spesso di volerne uno quasi uguale: la stessa persona con un anno in più. Si potrebbe ricostruirlo campo per campo, ma con tanti campi è scomodo. La sintassi di aggiornamento fa proprio questo:

gleam
  let older = Person(..ada, age: 37)

“Una Person con tutti i campi di ada, tranne age, che vale 37.” older è Person("Ada", 37, False), e ada è rimasta Person("Ada", 36, False). I due puntini sono gli stessi di [x, ..rest]: “tutto il resto viene da qui”.

Anche == funziona con i record: due record sono uguali se sono la stessa variante e tutti i campi sono uguali. Person(..ada, age: 37) == Person("Ada", 37, False) è True.

Dettagli nerd Come sta in memoria un record?

Sulla BEAM un record diventa una tupla di Erlang il cui primo elemento è l’atomo con il nome della variante (l’atomo della lezione precedente): Person("Ada", 36, False) è {person, <<"Ada">>, 36, false}. Le etichette non ci sono: esistono solo nel tuo codice e per il compilatore, che traduce ada.age in “prendi il terzo elemento della tupla”. Per questo le etichette non costano niente in memoria, ed è anche per questo che echo stampa Person("Ada", 36, False) senza nomi di campo.

E l’aggiornamento? Crea una tupla nuova, copiando i riferimenti ai valori dei campi. I valori stessi, come la stringa "Ada", non vengono copiati: vengono condivisi, perché nessuno potrà mai cambiarli.

Esercizio · sul tuo computer

Lo scontrino con i record

Nel progetto exercises crea src/receipt_items.gleam. Definisci un tipo:

gleam
pub type Item {
  Item(name: String, price: Int, quantity: Int)
}

con il prezzo unitario in centesimi. Il carrello è:

gleam
  let cart = [
    Item(name: "Coffee", price: 120, quantity: 2),
    Item(name: "Croissant", price: 130, quantity: 3),
    Item(name: "Juice", price: 250, quantity: 1),
  ]

Scrivi subtotal(item: Item) -> Int (prezzo per quantità), riusa format_cents e la funzione receipt_line della lezione 2.1, e stampa lo scontrino: una riga per articolo ("Coffee x2" come etichetta), i trattini, e il totale calcolato con list.fold (o con list.map e int.sum).

output
Coffee x2 ......... 2.40
Croissant x3 ...... 3.90
Juice x1 .......... 2.50
------------------------
Total ............. 8.80
Mostra una soluzione (prima prova da solo!)
src/receipt_items.gleam
import gleam/int
import gleam/io
import gleam/list
import gleam/string

pub type Item {
  Item(name: String, price: Int, quantity: Int)
}

pub fn main() -> Nil {
  let cart = [
    Item(name: "Coffee", price: 120, quantity: 2),
    Item(name: "Croissant", price: 130, quantity: 3),
    Item(name: "Juice", price: 250, quantity: 1),
  ]

  cart
  |> list.each(fn(item) {
    let label = item.name <> " x" <> int.to_string(item.quantity)
    io.println(receipt_line(label, subtotal(item)))
  })

  let total = cart |> list.map(subtotal) |> int.sum
  io.println(string.repeat("-", 24))
  io.println(receipt_line("Total", total))
}

fn subtotal(item: Item) -> Int {
  item.price * item.quantity
}

fn format_cents(cents: Int) -> String {
  int.to_string(cents / 100)
  <> "."
  <> { cents % 100 |> int.to_string |> string.pad_start(2, "0") }
}

fn receipt_line(label: String, cents: Int) -> String {
  string.pad_end(label <> " ", 19, ".")
  <> string.pad_start(format_cents(cents), 5, " ")
}

list.map(subtotal) passa la funzione subtotal così com’è: riceve un Item e restituisce un Int, esattamente quello che serve. Confronta con lo scontrino del modulo 1: là c’erano due prodotti scritti a mano, qui c’è un carrello di dati, e aggiungere un articolo vuol dire aggiungere una riga alla lista.

Ricapitolando

  • Una variante può contenere dati: Person(name: String, age: Int). Un valore così è un record.
  • Con una sola variante, di solito si chiama come il tipo.
  • Si costruisce come una chiamata: Person("Ada", 36) o, meglio, Person(name: "Ada", age: 36).
  • record.campo legge un campo (accessore); un campo sbagliato non compila.
  • Si smonta con un pattern: Person(name:, age:, ..), con let se il tipo ha una sola variante, altrimenti in un case.
  • Person(..ada, age: 37) crea un record nuovo, uguale tranne i campi indicati.
  • I record si confrontano con ==, campo per campo.

Nella prossima lezione mettiamo insieme le due idee: un tipo con più varianti, ciascuna con i suoi dati.