Lezione 4 di 5 · 20 min di lettura
Tipi opachi
Un modulo che nasconde come sono fatti i suoi dati, e li lascia costruire solo nel modo giusto. pub opaque type, i costruttori intelligenti, e un progetto con più moduli che si parlano.
Una percentuale di 250
Supponiamo di voler rappresentare uno sconto in percentuale. Un record semplice:
pub type Percent {
Percent(value: Int)
}Il problema è che niente impedisce di scrivere Percent(250), o Percent(-40). Ogni funzione che riceve un Percent dovrebbe controllare di nuovo che il valore sia tra 0 e 100, e prima o poi qualcuno se ne dimentica. Nel modulo 4 abbiamo imparato a scegliere i tipi in modo che i valori sbagliati non si possano scrivere; ma qui l’intervallo 0-100 non si può esprimere con le varianti.
La soluzione è un’altra: fare in modo che l’unico modo di ottenere un Percent sia passare da una funzione che controlla. Per farlo servono due ingredienti: un modulo a parte, e la parola opaque.
Due moduli che si parlano
Finora ogni esercizio era un modulo solo. Ma un progetto Gleam può avere tutti i moduli che vuoi, e ognuno può importare gli altri, esattamente come importi gleam/list. Il nome del modulo è il percorso del file dentro src/, senza estensione: src/percent.gleam si importa con import percent.
Nel progetto exercises, crea src/percent.gleam:
/// A percentage, always between 0 and 100.
pub opaque type Percent {
Percent(value: Int)
}
/// Builds a percentage, if `value` is between 0 and 100.
pub fn new(value: Int) -> Result(Percent, Nil) {
case value >= 0 && value <= 100 {
True -> Ok(Percent(value))
False -> Error(Nil)
}
}
pub fn to_int(percent: Percent) -> Int {
percent.value
}
/// The given percentage of an amount in cents.
pub fn of(percent: Percent, amount: Int) -> Int {
amount * percent.value / 100
}e src/percent_demo.gleam, che lo usa:
import gleam/int
import gleam/io
import gleam/string
import percent
pub fn main() -> Nil {
let assert Ok(discount) = percent.new(20)
io.println(int.to_string(percent.to_int(discount)) <> "%")
io.println(int.to_string(percent.of(discount, 4990)))
io.println(string.inspect(percent.new(250)))
}Lancia gleam run -m percent_demo:
20%
998
Error(Nil)Le funzioni pubbliche di percent si chiamano con il prefisso del modulo, percent.new, come quelle della libreria standard. (E i due moduli possono stare in sottocartelle: src/shop/percent.gleam si importerebbe con import shop/percent, e si userebbe sempre come percent.new.)
pub opaque type
Guarda la definizione del tipo: pub opaque type Percent. Un tipo opaco è pubblico, quindi gli altri moduli possono usarlo nelle firme, nei parametri, nelle liste; ma i suoi costruttori sono privati. Da fuori del modulo percent non si può:
- costruire un
Percentdirettamente; - leggerne i campi con l’accessore;
- smontarlo con un pattern.
Prova a costruirne uno a mano da percent_demo.gleam:
import gleam/io
import percent
pub fn main() -> Nil {
let too_much = percent.Percent(250)
echo too_much
io.println("Done")
}warning: Unused imported module
┌─ /home/ada/learn-gleam/exercises/src/percent_demo.gleam:2:1
│
2 │ import percent
│ ^^^^^^^^^^^^^^ This imported module is never used
Hint: You can safely remove it.
error: Unknown module value
┌─ /home/ada/learn-gleam/exercises/src/percent_demo.gleam:5:26
│
5 │ let too_much = percent.Percent(250)
│ ^^^^^^^
percent.Percent is a type constructor, it cannot be used as a value(L’avviso sull’import inutilizzato arriva perché l’unico uso del modulo è proprio quello sbagliato.) E se provi a leggere il campo di un Percent valido, con discount.value, ricevi Unknown record field, con la spiegazione It does not have any fields: da fuori, un Percent non ha campi visibili.
L’unico modo di ottenere un Percent è percent.new, che controlla l’intervallo e restituisce un Result. Quindi ogni Percent che esiste nel programma, ovunque si trovi, è per forza tra 0 e 100. Le funzioni che lo ricevono, come percent.of, non devono controllare niente. Questa garanzia si chiama invariante, e il tipo opaco è il modo di proteggerla.
Senza la parola opaque, il programma qui sopra compilerebbe, e echo stamperebbe tranquillamente Percent(250).
La libreria standard è piena di tipi opachi
Dict e Set sono tipi opachi. Ecco perché non puoi fare un pattern match su un dizionario, né leggere i “campi” di un insieme: puoi solo usare le funzioni dei loro moduli. E dentro, come abbiamo visto con echo nella lezione precedente, un Set è un dizionario; ma è un dettaglio che il modulo gleam/set può cambiare quando vuole, senza rompere il codice di nessuno, perché nessuno ci può dipendere.
È il secondo grande vantaggio dei tipi opachi, oltre agli invarianti: separano cosa fa un modulo (le sue funzioni pubbliche) da come lo fa (la forma dei dati). Chi usa il modulo vede solo il primo.
Quiz
Da un altro modulo, cosa si può fare con un valore di un tipo pub opaque type Percent?
Importare il tipo per nome
Per scrivere il tipo nelle firme di un altro modulo, lo importi per nome, come Option nella lezione 4.5:
import percent.{type Percent}
fn discounted(price: Int, discount: Percent) -> Int {
price - percent.of(discount, price)
}Lo stesso vale per i tipi non opachi e i loro costruttori: import account.{type Account, InvalidAmount} importa il tipo Account e la variante InvalidAmount, da usare senza prefisso.
Esercizio · sul tuo computer
Il conto in banca
Nel progetto exercises crea due moduli.
src/account.gleam definisce un conto il cui saldo non può mai essere negativo:
- un tipo opaco
Accountcon i campiowner: Stringebalance: Int(in centesimi), e un tipo pubblico (non opaco)AccountErrorcon le variantiInvalidAmounteInsufficientFunds; open(owner: String) -> Account, un conto nuovo con saldo 0;ownerebalance, per leggere i due campi da fuori;depositewithdraw, che ricevono un conto e un importo e restituisconoResult(Account, AccountError): l’importo deve essere positivo, e non si può prelevare più del saldo. Usa la sintassi di aggiornamentoAccount(..account, balance: ...).
src/account_demo.gleam prova tre scenari: Ada apre un conto, versa 100 euro, preleva una somma, poi preleva altri 20 euro. La somma del primo prelievo è 30 euro nel primo scenario, 500 nel secondo, -5 nel terzo. Concatena le operazioni con use e result.try, e stampa il saldo finale o l’errore:
Ada: 50.00
Error: not enough money
Error: the amount must be positivePoi prova, da account_demo.gleam, a scrivere account.Account("Ada", 1_000_000): il compilatore non te lo lascerà fare.
Mostra una soluzione (prima prova da solo!)
/// A bank account whose balance can never go below zero.
pub opaque type Account {
Account(owner: String, balance: Int)
}
pub type AccountError {
InvalidAmount
InsufficientFunds
}
/// A new, empty account.
pub fn open(owner: String) -> Account {
Account(owner:, balance: 0)
}
pub fn owner(account: Account) -> String {
account.owner
}
pub fn balance(account: Account) -> Int {
account.balance
}
pub fn deposit(account: Account, amount: Int) -> Result(Account, AccountError) {
case amount > 0 {
True -> Ok(Account(..account, balance: account.balance + amount))
False -> Error(InvalidAmount)
}
}
pub fn withdraw(
account: Account,
amount: Int,
) -> Result(Account, AccountError) {
case amount {
a if a <= 0 -> Error(InvalidAmount)
a if a > account.balance -> Error(InsufficientFunds)
a -> Ok(Account(..account, balance: account.balance - a))
}
}import account.{type Account, type AccountError}
import gleam/int
import gleam/io
import gleam/result
import gleam/string
pub fn main() -> Nil {
io.println(report(scenario(withdrawal: 30)))
io.println(report(scenario(withdrawal: 500)))
io.println(report(scenario(withdrawal: -5)))
}
fn scenario(withdrawal withdrawal: Int) -> Result(Account, AccountError) {
let ada = account.open("Ada")
use ada <- result.try(account.deposit(ada, 10_000))
use ada <- result.try(account.withdraw(ada, withdrawal * 100))
account.withdraw(ada, 2000)
}
fn report(result: Result(Account, AccountError)) -> String {
case result {
Ok(acc) -> account.owner(acc) <> ": " <> format_cents(account.balance(acc))
Error(account.InvalidAmount) -> "Error: the amount must be positive"
Error(account.InsufficientFunds) -> "Error: not enough money"
}
}
fn format_cents(cents: Int) -> String {
int.to_string(cents / 100)
<> "."
<> { cents % 100 |> int.to_string |> string.pad_start(2, "0") }
}Nota come i pattern di report usano le varianti dell’errore con il prefisso del modulo, account.InvalidAmount: AccountError non è opaco, quindi le sue varianti si possono usare anche da fuori. Nessun codice fuori da account.gleam può creare un conto con un saldo negativo: è impossibile, non solo “vietato”.
Ricapitolando
- Un progetto può avere molti moduli:
src/percent.gleamsi importa conimport percent,src/shop/percent.gleamconimport shop/percent. pub opaque typerende pubblico il tipo ma privati i costruttori: da fuori non si costruisce, non si leggono i campi, non si fa pattern matching.- Un costruttore intelligente (di solito
new) è l’unica porta d’ingresso, e controlla che il valore sia valido. - Così ogni valore del tipo rispetta un invariante, e le altre funzioni non devono ricontrollarlo.
DicteSetsono opachi: si usano solo attraverso le funzioni dei loro moduli.import percent.{type Percent}importa il tipo per nome; le varianti di un tipo non opaco si importano allo stesso modo.
Nella prossima lezione, la sfida del modulo: un magazzino fatto di un dizionario, protetto da un tipo opaco.