Lezione 5 di 8 · 18 min di lettura

Pattern e guardie

Pattern che smontano le stringhe, case su più valori insieme e guardie per le condizioni che un pattern non sa esprimere. Con un classico dei colloqui di lavoro, FizzBuzz.

Pattern che guardano dentro le stringhe

Nella lezione precedente i pattern erano valori esatti: 1, "yes". Ma un pattern può anche descrivere una forma e prenderne i pezzi. Con le stringhe si può dire “comincia con questo testo, e il resto chiamalo così”:

src/commands.gleam
import gleam/io

pub fn main() -> Nil {
  io.println(reply("/help"))
  io.println(reply("/say Hello, Joe"))
  io.println(reply("/dance"))
}

fn reply(command: String) -> String {
  case command {
    "/help" -> "Commands: /help, /say <text>"
    "/say " <> text -> "You said: " <> text
    _ -> "Unknown command"
  }
}
output
Commands: /help, /say <text>
You said: Hello, Joe
Unknown command

Il pattern "/say " <> text corrisponde a ogni stringa che comincia con "/say ", e lega quello che resta al nome text. È lo stesso <> che usi per attaccare le stringhe, usato al contrario: invece di costruire, smonta.

C’è una regola: a sinistra di <> ci deve essere un testo scritto per intero (un letterale). Il pattern word <> "!", “qualsiasi cosa che finisce con il punto esclamativo”, non è permesso, e il compilatore lo spiega con un This must be a string literal: senza sapere quanto è lungo il pezzo iniziale, non saprebbe dove tagliare. Per controllare come finisce una stringa c’è la funzione string.ends_with.

Quiz

Quale stringa corrisponde al pattern "Hello, " <> name, e cosa vale name?

Più soggetti insieme

Un case può guardare più valori contemporaneamente: li separi con una virgola dopo case, e ogni clausola ha un pattern per ciascuno, nello stesso ordine.

È il momento di un classico. FizzBuzz è un piccolo problema famoso perché lo si usa nei colloqui di lavoro: per ogni numero, se è divisibile per 3 scrivi “Fizz”, se è divisibile per 5 “Buzz”, se lo è per entrambi “FizzBuzz”, altrimenti il numero stesso. Con due soggetti, i resti delle due divisioni, è quasi una tabella:

src/fizzbuzz.gleam
import gleam/int
import gleam/io

pub fn main() -> Nil {
  io.println(fizzbuzz(15))
  io.println(fizzbuzz(9))
  io.println(fizzbuzz(10))
  io.println(fizzbuzz(7))
}

fn fizzbuzz(n: Int) -> String {
  case n % 3, n % 5 {
    0, 0 -> "FizzBuzz"
    0, _ -> "Fizz"
    _, 0 -> "Buzz"
    _, _ -> int.to_string(n)
  }
}
output
FizzBuzz
Fizz
Buzz
7

Leggi le clausole come righe di una tabella: “resto 0 e resto 0”, “resto 0 e qualsiasi cosa”, “qualsiasi cosa e resto 0”, “tutto il resto”. L’ordine conta: 15 corrisponderebbe anche a 0, _, ma la clausola 0, 0 sta sopra e vince lei. Se provassi a scambiarle, il compilatore ti segnalerebbe che 0, 0 non è più raggiungibile.

Le guardie

Alcune condizioni non si possono scrivere come pattern. “Un numero negativo” non è un valore preciso, è una condizione. Per questi casi una clausola può avere una guardia: if seguito da una condizione, dopo il pattern. La clausola vince solo se il pattern corrisponde e la condizione è vera.

src/grades.gleam
import gleam/io

pub fn main() -> Nil {
  io.println(grade(95))
  io.println(grade(80))
  io.println(grade(64))
  io.println(grade(-3))
}

fn grade(score: Int) -> String {
  case score {
    s if s < 0 || s > 100 -> "invalid score"
    s if s >= 90 -> "A"
    s if s >= 75 -> "B"
    s if s >= 60 -> "C"
    _ -> "F"
  }
}
output
A
B
C
invalid score

Sì, qui compare la parola if, ma non è l’if degli altri linguaggi: esiste solo dentro un case, come condizione aggiuntiva di una clausola.

Nota come l’ordine delle clausole semplifichi le condizioni: quando arrivi a s >= 75 sai già che s è minore di 90, altrimenti avrebbe vinto la clausola prima. Non serve scrivere s >= 75 && s < 90.

Nelle guardie puoi usare confronti, operatori aritmetici e logici, e qualsiasi variabile visibile in quel punto, non solo quelle del pattern:

gleam
fn size(n: Int, limit: Int) -> String {
  case n {
    x if x > limit -> "too big"
    x if x * 2 > limit -> "medium"
    _ -> "small"
  }
}

Le guardie hanno due limiti

Il primo: in una guardia non si possono chiamare funzioni. Se provi a scrivere x if int.is_even(x) -> ..., il compilatore risponde Functions cannot be called in clause guards. Le guardie devono essere semplici e veloci da valutare; se ti serve il risultato di una funzione, calcolalo prima in un let e usa quella variabile (o fanne il soggetto del case).

Il secondo: il compilatore non ragiona sulle guardie quando controlla l’esaustività. A te è evidente che un numero è per forza negativo oppure maggiore o uguale a zero; a lui no:

src/sign.gleam
import gleam/io

pub fn main() -> Nil {
  io.println(sign(3))
}

fn sign(n: Int) -> String {
  case n {
    x if x < 0 -> "negative"
    x if x >= 0 -> "positive"
  }
}
output
error: Inexhaustive patterns
   ┌─ /home/ada/learn-gleam/exercises/src/sign.gleam:8:3
   │
 8 │ ╭   case n {
 9 │ │     x if x < 0 -> "negative"
10 │ │     x if x >= 0 -> "positive"
11 │ │   }
   │ ╰───^

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:

    _

La soluzione è che l’ultima clausola non abbia guardia: _ -> "positive". È anche più chiaro, perché dice apertamente “tutto il resto”.

Quiz

Quale clausola non compila?

Tutto insieme

Pattern su più soggetti e guardie si combinano: la guardia viene scritta una volta sola, dopo tutti i pattern della clausola, e può usare tutte le variabili che i pattern hanno legato.

gleam
  case age, has_coupon {
    a, _ if a < 6 -> "free"
    _, True -> "discounted"
    _, False -> "full price"
  }

Sotto i sei anni si entra gratis, coupon o no. Altrimenti decide il coupon. Tre clausole, e tutti i casi possibili sono coperti.

Esercizio · sul tuo computer

Il biglietto del museo

Nel progetto exercises crea src/museum.gleam. Il biglietto intero costa 10 euro. I bambini sotto i 6 anni entrano gratis; chi ha 65 anni o più paga 5 euro; gli studenti pagano 7 euro. Se una persona rientra in più categorie, vale la prima di questo elenco (quindi un anziano studente paga 5 euro).

Scrivi:

  • price(age: Int, student: Bool) -> Int, il prezzo in centesimi, con un case su due soggetti e le guardie;
  • ticket(age: Int, student: Bool) -> String, che restituisce "free" se il prezzo è 0, altrimenti l’importo formattato con format_cents. Usa un case sul prezzo.

In main stampa i biglietti per: un bambino di 4 anni, uno studente di 20, un non studente di 70, uno studente di 70 e un non studente di 35:

output
Child: free
Student: 7.00
Senior: 5.00
Senior student: 5.00
Adult: 10.00
Mostra una soluzione (prima prova da solo!)
src/museum.gleam
import gleam/int
import gleam/io
import gleam/string

pub fn main() -> Nil {
  io.println("Child: " <> ticket(4, False))
  io.println("Student: " <> ticket(20, True))
  io.println("Senior: " <> ticket(70, False))
  io.println("Senior student: " <> ticket(70, True))
  io.println("Adult: " <> ticket(35, False))
}

fn price(age: Int, student: Bool) -> Int {
  case age, student {
    a, _ if a < 6 -> 0
    a, _ if a >= 65 -> 500
    _, True -> 700
    _, False -> 1000
  }
}

fn ticket(age: Int, student: Bool) -> String {
  case price(age, student) {
    0 -> "free"
    cents -> format_cents(cents)
  }
}

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

L’ordine delle clausole traduce l’ordine dell’elenco: l’età viene controllata prima dello status di studente. Nota anche il soggetto di ticket: è una chiamata di funzione. Il soggetto di un case può essere qualsiasi espressione; sono solo le guardie a non poter chiamare funzioni.

Ricapitolando

  • "/say " <> text corrisponde alle stringhe che cominciano con "/say " e lega il resto a text. A sinistra di <> ci vuole un testo letterale.
  • case a, b { 0, 0 -> ... } guarda più soggetti insieme: un pattern per ciascuno, separati da virgole.
  • Una guardia pattern if condizione -> ... aggiunge una condizione alla clausola.
  • Nelle guardie: confronti, aritmetica, logica e variabili, ma niente chiamate di funzione.
  • Il compilatore non ragiona sulle guardie: l’ultima clausola di solito è senza guardia.
  • L’ordine delle clausole semplifica le condizioni: quelle sopra hanno già escluso dei casi.

Adesso il programma sa scegliere. Nella prossima lezione impara a ripetere, senza for e senza while.