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.
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
}
}3.14159
6.0
16.0Una 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:
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.
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
}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:
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:
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:
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 campocents), 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:
Cash: 12.50
Card ending 4242: 30.00
Voucher SUMMER: 5.00
Total: 47.50Mostra una soluzione (prima prova da solo!)
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
casericonosce 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 usacase. - Scegli i tipi in modo che gli stati senza senso non si possano scrivere:
Guest | LoggedIn(name)invece dilogged_in: Boolpiù un nome.
Nella prossima lezione rendiamo i nostri tipi generici, come List(a), e costruiamo un tipo per i valori che potrebbero mancare.