Lezione 4 di 6 · 18 min di lettura

Varianti con dati

Più varianti, ognuna con i suoi campi. Forme geometriche, pagamenti e stati impossibili che diventano impossibili davvero.

Il meglio dei due mondi

Nella lezione 4.2 un tipo aveva più varianti, ma senza dati. Nella 4.3 le varianti avevano dati, ma ce n’era una sola. In realtà le due cose si combinano liberamente: un tipo può avere più varianti, ognuna con i suoi campi.

src/shapes.gleam
import gleam/float
import gleam/io

pub type Shape {
  Circle(radius: Float)
  Rectangle(width: Float, height: Float)
  Square(side: Float)
}

pub fn main() -> Nil {
  io.println(float.to_string(area(Circle(radius: 1.0))))
  io.println(float.to_string(area(Rectangle(width: 2.0, height: 3.0))))
  io.println(float.to_string(area(Square(side: 4.0))))
}

fn area(shape: Shape) -> Float {
  case shape {
    Circle(radius:) -> 3.14159 *. radius *. radius
    Rectangle(width:, height:) -> width *. height
    Square(side:) -> side *. side
  }
}
output
3.14159
6.0
16.0

Una Shape è o un cerchio con il suo raggio, o un rettangolo con base e altezza, o un quadrato con il suo lato. Ogni variante ha i campi che servono a lei, e solo quelli: un cerchio non ha un’altezza, e non è costretto a far finta di averne una.

Nel case, ogni clausola riconosce una variante e insieme ne prende i campi. È qui che il pattern matching mostra tutta la sua forza: decidere quale caso è, e smontarlo, avvengono nello stesso gesto.

Pattern sulle varianti

Tutto quello che sai sui pattern vale anche qui:

gleam
fn describe(shape: Shape) -> String {
  case shape {
    Rectangle(width: w, height: h) if w == h -> "a square in disguise"
    Rectangle(..) -> "a rectangle"
    Circle(..) | Square(..) -> "a circle or a square"
  }
}
  • guardie sui campi (if w == h);
  • Rectangle(..), “un rettangolo, non mi interessano i campi”;
  • alternative tra varianti diverse, con |.

Un’alternativa può anche legare una variabile, purché tutte le alternative la leghino con lo stesso nome e lo stesso tipo. Lo useremo nell’esercizio.

Quali campi si possono leggere?

Con più varianti, gli accessori hanno una regola in più. shape.radius ha senso solo se shape è un cerchio; ma in una funzione che riceve una Shape qualsiasi, il compilatore non sa quale variante arriverà. Per questo un accessore funziona su un tipo con più varianti solo se il campo esiste in tutte le varianti, con lo stesso nome, nella stessa posizione e con lo stesso tipo.

src/labels.gleam
import gleam/io

pub type Shape {
  Circle(name: String, radius: Float)
  Rectangle(name: String, width: Float, height: Float)
}

pub fn main() -> Nil {
  describe(Circle("wheel", 1.0))
  io.println("Done")
}

fn describe(shape: Shape) -> Nil {
  echo shape.name
  echo shape.width
  Nil
}
output
error: Unknown record field
   ┌─ /home/ada/learn-gleam/exercises/src/labels.gleam:15:14
   │
15 │   echo shape.width
   │              ^^^^^ Did you mean `name`?

The value being accessed has this type:

    Shape

It has these accessible fields:

    .name

Note: The field you are trying to access is not defined consistently across
all variants of this custom type. To fix this, ensure that all variants
include the field with the same name, position, and type.

shape.name va bene: tutte le forme hanno un nome, al primo posto, di tipo String. shape.width no: il cerchio non ce l’ha. Per leggere la larghezza devi prima scoprire, con un case, che la forma è un rettangolo.

Per lo stesso motivo non puoi smontare con un let un tipo che ha più varianti: let Circle(radius:) = shape non compila (Inexhaustive pattern), perché shape potrebbe essere un rettangolo. Il compilatore suggerisce let assert, che vedremo più avanti; per ora, con più varianti si usa case.

Quiz

Con pub type Pet { Dog(name: String, breed: String) Cat(name: String, lives: Int) }, quale accessore compila su un pet: Pet qualsiasi?

Rendere impossibili gli stati impossibili

Pensiamo a un utente di un sito, che può aver fatto l’accesso oppure no. Con un record solo, verrebbe spontaneo:

gleam
pub type User {
  User(logged_in: Bool, name: String)
}

Ma che nome ha un utente che non ha fatto l’accesso? Una stringa vuota? E cosa succede se da qualche parte qualcuno crea User(logged_in: False, name: "Ada")? È uno stato che non ha senso, ma il tipo lo permette, e prima o poi qualche parte del programma ci inciamperà. Con le varianti, invece:

gleam
pub type User {
  Guest
  LoggedIn(name: String)
}

Un ospite non ha un nome, e non c’è modo di dargliene uno. Chi ha fatto l’accesso ha sempre un nome. Ogni funzione che riceve un User deve dire, con un case, cosa fare in entrambi i casi, e in ognuno ha a disposizione esattamente i dati che esistono.

È uno dei principi più utili per progettare programmi in Gleam (e in molti linguaggi con tipi simili): scegli i tipi in modo che i valori senza senso non si possano nemmeno scrivere. Ogni bug che il tipo rende impossibile è un bug che non dovrai mai cercare.

Dettagli nerd E in memoria?

Come abbiamo visto nella lezione precedente, ogni variante con dati diventa sulla BEAM una tupla che comincia con l’atomo del suo nome: Circle(1.0) è {circle, 1.0}, Rectangle(2.0, 3.0) è {rectangle, 2.0, 3.0}. Un case su una Shape guarda il primo elemento per capire quale variante è, poi prende gli altri. Le varianti senza dati restano atomi da soli. Niente di più: il tipo Shape esiste per il compilatore, che controlla tutto prima, mentre la macchina lavora solo con atomi e tuple.

Esercizio · sul tuo computer

Pagamenti misti

Nel progetto exercises crea src/payments.gleam. Un negozio accetta tre tipi di pagamento:

gleam
pub type Payment {
  Cash(cents: Int)
  Card(cents: Int, last_digits: String)
  Voucher(code: String)
}

Un buono vale sempre 5 euro: mettilo in una costante const voucher_value = 500. Scrivi:

  • amount(payment: Payment) -> Int, l’importo in centesimi. Usa due clausole: una sola, con un pattern alternativo, per contanti e carta (che hanno entrambi il campo cents), e una per il buono;
  • describe(payment: Payment) -> String, come nell’output qui sotto.

In main, con la lista [Cash(1250), Card(3000, last_digits: "4242"), Voucher("SUMMER")], stampa una riga per pagamento e il totale:

output
Cash: 12.50
Card ending 4242: 30.00
Voucher SUMMER: 5.00
Total: 47.50
Mostra una soluzione (prima prova da solo!)
src/payments.gleam
import gleam/int
import gleam/io
import gleam/list
import gleam/string

pub type Payment {
  Cash(cents: Int)
  Card(cents: Int, last_digits: String)
  Voucher(code: String)
}

const voucher_value = 500

pub fn main() -> Nil {
  let payments = [
    Cash(1250),
    Card(3000, last_digits: "4242"),
    Voucher("SUMMER"),
  ]
  payments |> list.each(fn(payment) { io.println(describe(payment)) })
  let total = payments |> list.map(amount) |> int.sum
  io.println("Total: " <> format_cents(total))
}

fn amount(payment: Payment) -> Int {
  case payment {
    Cash(cents:) | Card(cents:, ..) -> cents
    Voucher(..) -> voucher_value
  }
}

fn describe(payment: Payment) -> String {
  case payment {
    Cash(cents:) -> "Cash: " <> format_cents(cents)
    Card(cents:, last_digits:) ->
      "Card ending " <> last_digits <> ": " <> format_cents(cents)
    Voucher(code:) -> "Voucher " <> code <> ": " <> format_cents(voucher_value)
  }
}

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

Cash(cents:) | Card(cents:, ..) funziona perché entrambe le alternative legano una variabile cents di tipo Int. Nota anche che payment.cents non si potrebbe scrivere: il buono non ha quel campo.

Ricapitolando

  • Un tipo può avere più varianti, ognuna con i propri campi: Circle(radius: Float), Rectangle(width: Float, height: Float).
  • Un case riconosce la variante e ne prende i campi nello stesso momento.
  • Nei pattern: Rectangle(..), guardie sui campi, alternative tra varianti (Circle(..) | Square(..)), anche con variabili comuni.
  • Un accessore funziona solo se il campo esiste in tutte le varianti, uguale per nome, posizione e tipo.
  • Con più varianti non si smonta con let: si usa case.
  • Scegli i tipi in modo che gli stati senza senso non si possano scrivere: Guest | LoggedIn(name) invece di logged_in: Bool più un nome.

Nella prossima lezione rendiamo i nostri tipi generici, come List(a), e costruiamo un tipo per i valori che potrebbero mancare.