Lezione 3 di 8 · 18 min di lettura

Funzioni come valori

Una funzione si può mettere in una variabile, passare a un'altra funzione, creare al volo e perfino restituire. E il trattino basso che ne costruisce una in un colpo.

Una funzione è un valore

Fin qui abbiamo trattato le funzioni come “comandi”: le definisci, le chiami, finito. Ma in Gleam una funzione è un valore, esattamente come 42 o "Ada". La puoi mettere in una variabile, e poi chiamare la variabile:

src/values.gleam
import gleam/io

pub fn main() -> Nil {
  let f = double
  echo f(21)
  echo double
  io.println("Done")
}

fn double(x: Int) -> Int {
  x * 2
}
output
src/values.gleam:5
42
src/values.gleam:6
//fn(a) { ... }
Done

Nota la differenza tra double e double(21). Con le parentesi chiami la funzione e ottieni il suo risultato; senza parentesi parli della funzione stessa, come oggetto. echo double non sa bene come mostrarla, e stampa //fn(a) { ... }: “qui c’è una funzione con un argomento”.

Anche le funzioni hanno un tipo

Se una funzione è un valore, deve avere un tipo. Il tipo di double si scrive:

gleam
fn(Int) -> Int

“Una funzione che riceve un Int e restituisce un Int.” È la riga di definizione senza i nomi. greet della lezione 1 ha tipo fn(String) -> String, area ha tipo fn(Int, Int) -> Int, e main ha tipo fn() -> Nil.

Con questo tipo possiamo scrivere una funzione che riceve un’altra funzione come argomento:

src/twice.gleam
import gleam/io

pub fn main() -> Nil {
  echo twice(5, double)
  echo twice(5, add_three)
  io.println("Done")
}

fn twice(value: Int, f: fn(Int) -> Int) -> Int {
  f(f(value))
}

fn double(x: Int) -> Int {
  x * 2
}

fn add_three(x: Int) -> Int {
  x + 3
}
output
src/twice.gleam:4
20
src/twice.gleam:5
11
Done

twice non sa cosa farà f: sa solo che riceve un Int e restituisce un Int, e la applica due volte. Con double, 5 diventa 10 e poi 20; con add_three, 8 e poi 11. Una funzione che riceve (o restituisce) altre funzioni si chiama funzione di ordine superiore, un nome altisonante per un’idea semplice: separare il “come si ripete” (lo decide twice) dal “cosa si fa” (lo decide chi la chiama).

Il compilatore controlla anche qui: se passi a twice una funzione del tipo sbagliato, per esempio string.uppercase, ti ferma.

src/twice.gleam
import gleam/io
import gleam/string

pub fn main() -> Nil {
  echo twice(5, string.uppercase)
  io.println("Done")
}

fn twice(value: Int, f: fn(Int) -> Int) -> Int {
  f(f(value))
}
output
error: Type mismatch
  ┌─ /home/ada/learn-gleam/exercises/src/twice.gleam:5:17
  │
5 │   echo twice(5, string.uppercase)
  │                 ^^^^^^^^^^^^^^^^

Expected type:

    fn(Int) -> Int

Found type:

    fn(String) -> String

Funzioni senza nome

Definire una funzione con un nome solo per passarla una volta a twice è un po’ eccessivo. Gleam permette di crearla al volo, nel punto in cui serve, con fn senza nome:

gleam
  twice(5, fn(x) { x * 10 })

fn(x) { x * 10 } è una funzione anonima: stessa forma di una funzione normale, ma senza nome e senza pub. Vale 500. Le annotazioni di tipo sono facoltative anche qui, e di solito non si scrivono: il compilatore sa già che twice vuole un fn(Int) -> Int, quindi x è un Int.

Una funzione anonima si può anche mettere in una variabile, che diventa di fatto una funzione locale:

gleam
  let exclaim = fn(text: String) -> String { text <> "!" }
  exclaim("Hi")

Funzioni che si ricordano

Una funzione anonima può usare le variabili che esistono nel punto in cui viene creata, e se le porta dietro. Guarda adder, una funzione che restituisce una funzione:

src/adders.gleam
import gleam/io

pub fn main() -> Nil {
  let add_ten = adder(10)
  let add_one = adder(1)
  echo add_ten(5)
  echo add_one(5)
  io.println("Done")
}

fn adder(amount: Int) -> fn(Int) -> Int {
  fn(x) { x + amount }
}
output
src/adders.gleam:6
15
src/adders.gleam:7
6
Done

Il tipo di ritorno di adder è fn(Int) -> Int: restituisce una funzione. Quando chiami adder(10), la funzione anonima viene creata in un momento in cui amount vale 10, e se lo ricorda anche dopo che adder ha finito. add_one è un’altra funzione, creata con amount uguale a 1. Una funzione che si porta dietro le variabili del posto in cui è nata si chiama closure (chiusura).

Dettagli nerd Come fa una funzione a ricordarsi una variabile?

Il codice di fn(x) { x + amount } esiste una volta sola, dentro il programma compilato. Ma quando la BEAM la crea come valore, non salva solo “dov’è il codice”: costruisce in memoria una piccola struttura con un riferimento al codice più una copia dei valori che quel codice usa da fuori, qui amount. add_ten e add_one sono due di queste strutture: stesso codice, amount diverso.

Siccome in Gleam i valori non cambiano mai, la copia non può “andare fuori sincrono” con l’originale. In linguaggi dove le variabili si modificano, le closure sono una fonte classica di sorprese; qui no.

Quiz

Con adder e twice di questa lezione, quanto vale twice(1, adder(5))?

La cattura: il trattino basso

Molto spesso la funzione anonima che ti serve non fa altro che chiamare un’altra funzione, con un argomento che manca:

gleam
  fn(x) { add_vat(x, rate: 22) }

Per questo caso Gleam ha una scorciatoia, la cattura (function capture): scrivi la chiamata normale e metti un trattino basso _ al posto dell’argomento mancante.

gleam
  add_vat(_, rate: 22)

Le due righe sono identiche: una funzione che riceve un valore e lo mette al posto del _. Il trattino basso può stare in qualsiasi posizione, e ce n’è sempre uno solo.

La cattura risolve anche un problema del tubo. |> mette il valore sempre al primo posto; se ti serve altrove, cattura:

src/capture.gleam
import gleam/io
import gleam/string

pub fn main() -> Nil {
  let suffix = "!"
  "Hello"
  |> string.append(suffix)
  |> io.println

  let prefix = "> "
  "Hello"
  |> string.append(prefix, _)
  |> io.println
}
output
Hello!
> Hello

string.append(a, b) attacca b in fondo ad a. Nel primo tubo "Hello" entra come primo argomento, e il punto esclamativo finisce dopo. Nel secondo, grazie al _, "Hello" entra come secondo argomento, e il prefisso finisce davanti.

Funzioni che vanno bene per tutto

La nostra twice funziona solo con gli Int. Ma “applica due volte una funzione” ha senso per qualsiasi tipo. Gleam permette di scrivere un tipo “qualsiasi” con un nome minuscolo, una variabile di tipo:

src/generic.gleam
import gleam/io

pub fn main() -> Nil {
  echo twice(5, fn(x) { x * 10 })
  echo twice("Hi", fn(text) { text <> "!" })
  io.println("Done")
}

fn twice(value: a, f: fn(a) -> a) -> a {
  f(f(value))
}
output
src/generic.gleam:4
500
src/generic.gleam:5
"Hi!!"
Done

a sta per “un tipo qualsiasi, ma lo stesso ovunque compaia”. Nella prima chiamata a diventa Int, nella seconda String. Non è un modo per spegnere il controllo dei tipi: se provi twice(10, fn(x) { x <> "!" }), il valore è un Int ma la funzione restituisce una String, le due a non possono essere lo stesso tipo, e il compilatore ti ferma. Queste funzioni si chiamano generiche; le ritroverai spesso nella libreria standard, e le useremo molto quando arriveranno le liste.

Esercizio · sul tuo computer

Le promozioni

Nel progetto exercises crea src/promos.gleam. Un negozio applica promozioni diverse ai prezzi, e ogni promozione è una funzione fn(Int) -> Int che riceve un prezzo in centesimi e restituisce il prezzo scontato. Scrivi:

  • checkout(cents: Int, promo: fn(Int) -> Int) -> String, che applica la promozione al prezzo e restituisce "To pay: " seguito dall’importo formattato (riusa format_cents);
  • no_promo(cents: Int) -> Int, che restituisce il prezzo così com’è;
  • percent_off(cents: Int, percent: Int) -> Int, che toglie una percentuale.

In main stampa il checkout di un prezzo di 4990 centesimi tre volte: con no_promo, con una funzione anonima che toglie 5 euro fissi, e con una cattura di percent_off al 10%. L’output deve essere:

output
To pay: 49.90
To pay: 44.90
To pay: 44.91
Mostra una soluzione (prima prova da solo!)
src/promos.gleam
import gleam/int
import gleam/io
import gleam/string

pub fn main() -> Nil {
  io.println(checkout(4990, no_promo))
  io.println(checkout(4990, fn(cents) { cents - 500 }))
  io.println(checkout(4990, percent_off(_, 10)))
}

fn checkout(cents: Int, promo: fn(Int) -> Int) -> String {
  "To pay: " <> format_cents(promo(cents))
}

fn no_promo(cents: Int) -> Int {
  cents
}

fn percent_off(cents: Int, percent: Int) -> Int {
  cents - cents * percent / 100
}

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

Tre modi di scrivere un fn(Int) -> Int: una funzione con nome, una anonima, una cattura. Per checkout sono tutte uguali. Il 10% di 4990 è 499, quindi l’ultima riga è 44,91.

Ricapitolando

  • Una funzione è un valore: double è la funzione, double(21) è il suo risultato.
  • Il tipo di una funzione si scrive fn(Int, String) -> Bool: argomenti e risultato, senza nomi.
  • Una funzione può ricevere funzioni come argomenti e restituirne: è una funzione di ordine superiore.
  • fn(x) { x + 1 } è una funzione anonima, da creare al volo dove serve.
  • Una funzione anonima si ricorda le variabili del posto in cui è nata: è una closure.
  • La cattura f(_, 22) è la scorciatoia per fn(x) { f(x, 22) }; con il tubo, porta il valore in una posizione diversa dalla prima.
  • Un nome di tipo minuscolo (a) è una variabile di tipo: la funzione diventa generica, ma ogni a è sempre lo stesso tipo.

Adesso sappiamo scrivere funzioni di ogni forma, ma vanno ancora sempre dritte, dalla prima riga all’ultima. Nella prossima lezione impareremo a far prendere decisioni al programma.