Lezione 4 di 8 · 20 min di lettura

Decidere con case

In Gleam non c'è if. C'è qualcosa di meglio, case, e un compilatore che si accorge se hai dimenticato un caso.

Niente if

Un programma che fa sempre la stessa cosa serve a poco: prima o poi deve scegliere. Se il voto è sufficiente, “promosso”, altrimenti “bocciato”. Se la password è giusta, entra. In quasi tutti i linguaggi per questo c’è if. In Gleam no: non esiste if, non esiste switch, non esiste else. Esiste una sola costruzione per decidere, e si chiama case.

Sembra una limitazione. Alla fine della lezione vedrai che è il contrario.

La prima scelta

src/medals.gleam
import gleam/io

pub fn main() -> Nil {
  io.println(medal(1))
  io.println(medal(3))
  io.println(medal(7))
}

fn medal(position: Int) -> String {
  case position {
    1 -> "Gold"
    2 -> "Silver"
    3 -> "Bronze"
    _ -> "No medal"
  }
}
output
Gold
Bronze
No medal

Leggiamola:

  • case position { ... } dice: “guarda il valore di position”. Quel valore si chiama il soggetto del case.
  • Dentro ci sono le clausole, una per riga, nella forma pattern -> risultato. Il pattern (modello) descrive una forma che il soggetto potrebbe avere; il risultato è cosa fare se ce l’ha.
  • Le clausole vengono provate dall’alto in basso, e vince la prima che corrisponde. Le altre vengono ignorate.
  • Il trattino basso _ è il pattern che corrisponde a qualsiasi valore: è il “in tutti gli altri casi”.

Con medal(7) il 7 non è né 1, né 2, né 3, e finisce nel _.

Questo modo di decidere, confrontando un valore con una serie di forme, si chiama pattern matching. Per ora le forme sono semplici numeri; nelle prossime lezioni diventeranno molto più interessanti.

case è un’espressione

case non “esegue un ramo”: vale qualcosa, il risultato della clausola scelta. Per questo medal può restituirlo direttamente, e per questo lo puoi mettere in un let:

gleam
  let label = case position {
    1 -> "Gold"
    _ -> "Other"
  }

La conseguenza è che tutte le clausole devono produrre un valore dello stesso tipo. Se una restituisce una String e un’altra un Int, che tipo avrebbe label? Il compilatore non te lo lascia fare:

src/medals.gleam
import gleam/io

pub fn main() -> Nil {
  io.println(medal(1))
}

fn medal(position: Int) -> String {
  case position {
    1 -> "Gold"
    _ -> 0
  }
}
output
error: Type mismatch
   ┌─ /home/ada/learn-gleam/exercises/src/medals.gleam:10:5
   │
10 │     _ -> 0
   │     ^^^^^^

This case clause was found to return a different type than the previous
one, but all case clauses must return the same type.

Expected type:

    String

Found type:

    Int

Il compilatore conta i casi per te

Ecco la vera forza di case. Prova a togliere la clausola _ da medal:

src/medals.gleam
import gleam/io

pub fn main() -> Nil {
  io.println(medal(1))
}

fn medal(position: Int) -> String {
  case position {
    1 -> "Gold"
    2 -> "Silver"
    3 -> "Bronze"
  }
}
output
error: Inexhaustive patterns
   ┌─ /home/ada/learn-gleam/exercises/src/medals.gleam:8:3
   │
 8 │ ╭   case position {
 9 │ │     1 -> "Gold"
10 │ │     2 -> "Silver"
11 │ │     3 -> "Bronze"
12 │ │   }
   │ ╰───^

This case expression does not have a pattern for all possible values. If it
is run on one of the values without a pattern then it will crash.

The missing patterns are:

    _

Il programma non compila. Il compilatore ha capito che position è un Int, che gli Int sono molti più di tre, e che per 4 (o 7, o -1) il case non saprebbe cosa fare. Ti elenca anche cosa manca: _, cioè “tutti gli altri numeri”. Questo controllo si chiama verifica di esaustività, ed è uno dei motivi per cui i programmi Gleam sono così affidabili: un caso dimenticato non diventa un bug scoperto dopo mesi, diventa un errore di compilazione.

Il controllo funziona anche al contrario. Se scrivi una clausola che non potrà mai essere raggiunta, perché una precedente cattura già tutti i suoi valori, ricevi un avviso:

src/medals.gleam
import gleam/io

pub fn main() -> Nil {
  io.println(medal(1))
}

fn medal(position: Int) -> String {
  case position {
    _ -> "No medal"
    1 -> "Gold"
  }
}
output
warning: Unreachable pattern
   ┌─ /home/ada/learn-gleam/exercises/src/medals.gleam:10:5
   │
10 │     1 -> "Gold"
   │     ^

This pattern cannot be reached as a previous pattern matches the same
values.

Hint: It can be safely removed.

No medal

Il _ in cima vince sempre, quindi l’oro non arriva mai. La regola pratica: i casi particolari in alto, quelli generali in basso.

Quiz

Cosa restituisce medal(2) se le clausole sono, in ordine: 2 -> "Silver", _ -> "No medal", 2 -> "Second"?

Dare un nome al valore

Al posto di _ puoi scrivere un nome. Anche lui corrisponde a qualsiasi valore, ma in più lo lega a quel nome, che puoi usare nel risultato della clausola:

src/positions.gleam
import gleam/int
import gleam/io

pub fn main() -> Nil {
  io.println(position_text(1))
  io.println(position_text(12))
}

fn position_text(position: Int) -> String {
  case position {
    1 -> "Winner!"
    other -> "Arrived " <> int.to_string(other) <> "th"
  }
}
output
Winner!
Arrived 12th

other esiste solo dentro la sua clausola. (Qui avremmo potuto usare direttamente position, che ha lo stesso valore; il nome nel pattern diventerà indispensabile quando i pattern avranno più pezzi, tra poche lezioni.)

Il posto di if: case su un Bool

E per le decisioni sì/no? Un confronto come age >= 18 produce un Bool, e un Bool ha esattamente due valori: True e False. Basta farne il soggetto di un case:

src/ages.gleam
import gleam/io

pub fn main() -> Nil {
  io.println(describe(34))
  io.println(describe(12))
}

fn describe(age: Int) -> String {
  case age >= 18 {
    True -> "adult"
    False -> "minor"
  }
}
output
adult
minor

Questo è l’if di Gleam. Non è un trucco da principianti: è così che è scritta anche la libreria standard. Per esempio int.absolute_value, il valore assoluto, è:

gleam
pub fn absolute_value(x: Int) -> Int {
  case x >= 0 {
    True -> x
    False -> x * -1
  }
}

E la verifica di esaustività vale anche qui: se scrivi solo la clausola True, il compilatore ti ricorda che manca False (The missing patterns are: False). Non esiste un if senza else che “non fa niente”: ogni case deve dire cosa vale in ogni situazione.

Più righe in una clausola

Il risultato di una clausola è una espressione. Se ti servono più passaggi, usa un blocco: le graffe raggruppano più espressioni, e il blocco vale l’ultima.

gleam
  case age >= 18 {
    True -> {
      let years = age - 18
      "adult for " <> int.to_string(years) <> " years"
    }
    False -> "minor"
  }

Se però una clausola diventa lunga, di solito è il segnale che quel pezzo merita una funzione tutta sua.

Più valori, stessa risposta

Spesso più valori portano allo stesso risultato. Invece di ripetere la clausola, puoi elencare dei pattern alternativi separati da |:

src/weekend.gleam
import gleam/io

pub fn main() -> Nil {
  io.println(kind(6))
  io.println(kind(3))
  io.println(kind(9))
}

fn kind(day: Int) -> String {
  case day {
    6 | 7 -> "weekend"
    1 | 2 | 3 | 4 | 5 -> "workday"
    _ -> "not a day"
  }
}
output
weekend
workday
not a day

Si legge “6 oppure 7”. La clausola vince se il soggetto corrisponde ad almeno uno dei pattern.

I pattern possono anche essere stringhe, che corrispondono solo se sono esattamente uguali (maiuscole comprese):

gleam
  case answer {
    "yes" | "y" -> True
    _ -> False
  }

Quiz

Cosa restituisce case 0 { 0 | 1 -> "small" n -> "big" }?

Esercizio · sul tuo computer

I giorni della settimana

Nel progetto exercises crea src/weekdays.gleam con due funzioni:

  • day_name(day: Int) -> String: da 1 a 7 restituisce il nome del giorno in inglese (1 è "Monday", 7 è "Sunday"), per qualsiasi altro numero "Invalid day";
  • day_kind(day: Int) -> String: "weekend" per 6 e 7, "workday" da 1 a 5, "invalid" altrimenti. Usa i pattern alternativi.

Poi aggiungi una funzione describe(day: Int) -> String che mette insieme le due, e in main stampa describe di 1, 6, 7 e 0. L’output deve essere:

output
Monday: workday
Saturday: weekend
Sunday: weekend
Invalid day: invalid
Mostra una soluzione (prima prova da solo!)
src/weekdays.gleam
import gleam/io

pub fn main() -> Nil {
  io.println(describe(1))
  io.println(describe(6))
  io.println(describe(7))
  io.println(describe(0))
}

fn describe(day: Int) -> String {
  day_name(day) <> ": " <> day_kind(day)
}

fn day_name(day: Int) -> String {
  case day {
    1 -> "Monday"
    2 -> "Tuesday"
    3 -> "Wednesday"
    4 -> "Thursday"
    5 -> "Friday"
    6 -> "Saturday"
    7 -> "Sunday"
    _ -> "Invalid day"
  }
}

fn day_kind(day: Int) -> String {
  case day {
    6 | 7 -> "weekend"
    1 | 2 | 3 | 4 | 5 -> "workday"
    _ -> "invalid"
  }
}

Prova a togliere la clausola _ da una delle due funzioni: il compilatore ti mostrerà l’errore Inexhaustive patterns.

Ricapitolando

  • In Gleam non ci sono if, else né switch: si decide con case soggetto { pattern -> risultato }.
  • Le clausole si provano dall’alto in basso, e vince la prima che corrisponde.
  • _ corrisponde a tutto; un nome corrisponde a tutto e lega il valore a quel nome.
  • case è un’espressione: vale il risultato della clausola scelta, e tutte le clausole devono avere lo stesso tipo.
  • Il compilatore verifica l’esaustività (Inexhaustive patterns) e segnala le clausole irraggiungibili (Unreachable pattern).
  • Per le decisioni sì/no si fa case su un Bool: True -> ..., False -> ....
  • Più passaggi in una clausola: un blocco { ... }. Più valori con la stessa risposta: 6 | 7 -> ....

Nella prossima lezione i pattern diventano più furbi: pezzi di stringa, più soggetti insieme e condizioni aggiuntive con le guardie.