Lezione 5 di 6 · 16 min di lettura

Tipi generici

Tipi con una variabile di tipo dentro, come List(a). Un tipo per i valori che potrebbero mancare, Option dalla libreria standard, e gli alias di tipo.

Il primo elemento, se c’è

Nella lezione 3.2 abbiamo scritto first_or: il primo elemento di una lista, oppure un valore di riserva. Il valore di riserva però è una scelta di chi chiama, e a volte non ce n’è uno sensato. Qual è il “primo elemento di riserva” di una lista di persone?

Sarebbe meglio che la funzione rispondesse onestamente: “ecco il primo elemento”, oppure “non c’è”. Due possibilità, una con un dato e una senza: è esattamente un tipo con varianti.

gleam
pub type MaybeInt {
  JustInt(Int)
  NothingInt
}

Funziona per le liste di Int. Ma per le liste di String servirebbe MaybeString, poi MaybePerson… Lo stesso problema della funzione twice della lezione 2.3, e la stessa soluzione: una variabile di tipo.

Un tipo con un parametro

src/optional.gleam
import gleam/io

pub type Maybe(a) {
  Just(a)
  Nothing
}

pub fn main() -> Nil {
  echo first([3, 4])
  echo first(["a", "b"])
  echo first([])
  echo with_default(first([]), 0)
  io.println("Done")
}

fn first(items: List(a)) -> Maybe(a) {
  case items {
    [] -> Nothing
    [x, ..] -> Just(x)
  }
}

fn with_default(maybe: Maybe(a), default: a) -> a {
  case maybe {
    Just(value) -> value
    Nothing -> default
  }
}
output
src/optional.gleam:9
Just(3)
src/optional.gleam:10
Just("a")
src/optional.gleam:11
Nothing
src/optional.gleam:12
0
Done

Maybe(a) è un tipo generico: a sta per un tipo qualsiasi, deciso da come lo usi. first([3, 4]) ha tipo Maybe(Int), first(["a", "b"]) ha tipo Maybe(String). La variante Just(a) contiene un valore di quel tipo; Nothing non contiene niente.

Nota anche che il campo di Just non ha un’etichetta: si può fare, e con un campo solo e ovvio si fa spesso. Il valore si prende con un pattern, Just(value).

(Attenzione al nome del file: il modulo si chiama optional e non maybe, perché maybe è una parola riservata di Erlang, e un modulo con quel nome fa fallire l’avvio del programma.)

Perché non restituire un valore speciale?

In molti linguaggi, quando qualcosa manca si restituisce un valore “speciale”: null, None, undefined, oppure -1 per un indice che non esiste. Il problema è che quel valore ha lo stesso tipo di quelli buoni, o si infila ovunque: niente ti obbliga a controllarlo, e prima o poi qualcuno lo usa come se fosse un valore vero.

Con Maybe(Int) questo non può succedere. Un Maybe(Int) non è un Int: non puoi sommarlo, non puoi passarlo a int.to_string. Per arrivare al numero devi passare da un case (o da una funzione come with_default), e il case ti obbliga a dire cosa fare quando il numero non c’è.

Option, dalla libreria standard

Un tipo per “c’è o non c’è” è così utile che la libreria standard ce l’ha già: si chiama Option(a), nel modulo gleam/option, con le varianti Some(a) e None. È identico al nostro Maybe, solo con altri nomi.

Per usare Some e None senza scrivere ogni volta option.Some, si importano per nome:

gleam
import gleam/option.{type Option, None, Some}

Le graffe dopo il nome del modulo elencano le cose da importare direttamente: il tipo Option (preceduto da type) e le varianti None e Some. Il resto del modulo resta disponibile con il prefisso: option.unwrap(maybe, 0) è il nostro with_default, e option.map(Some(3), fn(x) { x * 2 }) applica una funzione al valore, se c’è, e vale Some(6).

Quiz

Con import gleam/option.{type Option, None, Some}, quale di queste righe non compila?

Alias di tipo

Un’ultima cosa sui tipi, piccola ma comoda. Nel corso abbiamo usato spesso Int per i centesimi: fn format_cents(cents: Int) -> String. Con un alias puoi dare un nome più parlante a un tipo esistente:

gleam
pub type Cents =
  Int

(Il formattatore lo scrive su due righe, così.) Ora fn format_cents(cents: Cents) -> String si legge meglio. Ma attenzione: un alias è solo un altro nome, non un tipo nuovo. Cents e Int sono intercambiabili, e il compilatore non ti impedirà di passare un’età dove servono dei centesimi. Per un tipo davvero separato serve un tipo personalizzato, come Cents(Int), con la sua variante.

Esercizio · sul tuo computer

La rubrica

Nel progetto exercises crea src/phonebook.gleam, con il tipo Person(name: String, age: Int) e la lista:

gleam
  let people = [Person("Ada", 36), Person("Grace", 85)]

Scrivi:

  • find_age(people: List(Person), name: String) -> Option(Int), ricorsiva: l’età della prima persona con quel nome, se c’è (usa una guardia sul nome);
  • describe_age(people: List(Person), name: String) -> String, che usa find_age e un case su Some e None.

In main stampa describe_age per "Grace" e per "Linus":

output
Grace is 85
Linus: not found
Mostra una soluzione (prima prova da solo!)
src/phonebook.gleam
import gleam/int
import gleam/io
import gleam/option.{type Option, None, Some}

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

pub fn main() -> Nil {
  let people = [Person("Ada", 36), Person("Grace", 85)]
  io.println(describe_age(people, "Grace"))
  io.println(describe_age(people, "Linus"))
}

fn find_age(people: List(Person), name: String) -> Option(Int) {
  case people {
    [] -> None
    [person, ..] if person.name == name -> Some(person.age)
    [_, ..rest] -> find_age(rest, name)
  }
}

fn describe_age(people: List(Person), name: String) -> String {
  case find_age(people, name) {
    Some(age) -> name <> " is " <> int.to_string(age)
    None -> name <> ": not found"
  }
}

Nella guardia person.name == name c’è un accessore: si può, perché leggere un campo non è una chiamata di funzione. E find_age è in coda: quando il primo non è la persona cercata, l’ultima cosa che fa è richiamarsi sul resto.

Ricapitolando

  • Un tipo può avere un parametro: pub type Maybe(a) { Just(a) Nothing }. Maybe(Int), Maybe(String)… sono tutti casi dello stesso tipo generico.
  • List(a) e le tuple sono tipi generici già pronti.
  • In Gleam non esiste null: un valore che può mancare ha un tipo che lo dice, e va aperto con un case.
  • La libreria standard ha Option(a), con Some(a) e None, nel modulo gleam/option.
  • import gleam/option.{type Option, None, Some} importa tipo e varianti per nome.
  • option.unwrap e option.map lavorano sugli Option senza case.
  • pub type Cents = Int è un alias: un nome in più, non un tipo nuovo.

Nella prossima lezione, la sfida del modulo: un piccolo robot fatto di tipi, varianti e record.