Lezione 7 di 8 · 18 min di lettura

Ricorsione in coda

Una ricorsione che non consuma memoria, per quanto a lungo giri. L'accumulatore, la chiamata in coda e la funzione pubblica che nasconde i dettagli.

Il conto della memoria

Nella lezione precedente abbiamo visto che ogni chiamata ricorsiva in sospeso occupa un posto nello stack. Con sum_to(3) non è un problema. Ma proviamo con numeri più grandi:

gleam
fn sum_to(n: Int) -> Int {
  case n {
    0 -> 0
    _ -> n + sum_to(n - 1)
  }
}

sum_to(10_000_000) risponde correttamente, 50000005000000. Ma prima di arrivare al caso base accumula dieci milioni di somme in sospeso (“dopo, aggiungi 10000000”, “dopo, aggiungi 9999999”…), e ognuna occupa memoria. Sul computer su cui è stato scritto questo corso, il programma è arrivato a occupare più di 1,5 GB di memoria per sommare dei numeri.

Il colpevole è quel n + ...: dopo che sum_to(n - 1) ha risposto, c’è ancora del lavoro da fare, l’addizione. Per questo la chiamata deve lasciare un promemoria.

Il trucco: portarsi dietro il risultato

E se il lavoro lo facessimo prima di chiamarci, invece che dopo? Basta aggiungere un parametro che si porta dietro il risultato parziale, un accumulatore:

src/sum_loop.gleam
import gleam/int
import gleam/io

pub fn main() -> Nil {
  io.println(int.to_string(sum_loop(3, 0)))
  io.println(int.to_string(sum_loop(10_000_000, 0)))
}

fn sum_loop(n: Int, total: Int) -> Int {
  case n {
    0 -> total
    _ -> sum_loop(n - 1, total + n)
  }
}
output
6
50000005000000

Srotoliamo sum_loop(3, 0):

output
sum_loop(3, 0)
= sum_loop(2, 3)
= sum_loop(1, 5)
= sum_loop(0, 6)
= 6

Confrontalo con lo srotolamento di sum_to(3) della lezione precedente: lì le somme si accumulavano in sospeso, sempre più a destra, e si completavano solo alla fine. Qui ogni riga è completa: la somma è già fatta e viaggia nel secondo argomento. Quando si arriva a n = 0, il risultato è già pronto in total, e basta restituirlo.

La chiamata in coda

La differenza chiave è questa: in sum_loop, la chiamata a se stessa è l’ultima cosa che la funzione fa. Non c’è niente da fare dopo, nessuna addizione in sospeso. Una chiamata così si dice in coda (tail call).

E quando una chiamata è in coda, non serve ricordarsi niente: il posto nello stack della chiamata attuale può essere riusato per la successiva. Gleam lo fa automaticamente, e si chiama ottimizzazione delle chiamate in coda. Risultato: sum_loop(10_000_000, 0) ha usato meno di 100 MB, cioè più o meno quello che serve alla BEAM per partire. E sarebbe lo stesso con cento milioni, o un miliardo.

Dettagli nerd Come fa una chiamata in coda a non occupare memoria?

Pensa allo stack della lezione precedente: ogni chiamata aggiunge un promemoria con i suoi valori e il punto da cui riprendere. Ma se dopo la chiamata non c’è niente da fare, il promemoria della funzione attuale non serve più a nessuno. Allora la macchina, invece di aggiungerne uno nuovo in cima, sovrascrive quello attuale con i nuovi argomenti e salta di nuovo all’inizio della funzione. La pila resta alta un piano solo, qualunque sia il numero di giri.

Si vede bene compilando per JavaScript (gleam build --target javascript, che genera il codice senza eseguirlo). Ecco cosa diventa sum_loop:

output
function sum_loop(loop$n, loop$total) {
  while (true) {
    let n = loop$n;
    let total = loop$total;
    if (n === 0) {
      return total;
    } else {
      loop$n = n - 1;
      loop$total = total + n;
    }
  }
}

Un ciclo while! Il compilatore ha trasformato la tua ricorsione in coda nel ciclo che avresti scritto in JavaScript. sum_to, invece, resta una funzione che chiama se stessa, e con numeri grandi JavaScript la fermerebbe con un errore di stack esaurito. Sulla BEAM le chiamate in coda sono una caratteristica della macchina stessa: Erlang non ha cicli, e ha sempre ripetuto le cose così.

Riconoscere una chiamata in coda

La domanda da farsi è sempre: dopo che la chiamata ha risposto, resta qualcosa da fare?

RamoIn coda?Perché
_ -> sum_loop(n - 1, total + n)sìla chiamata è l’ultima cosa; la somma si fa prima, negli argomenti
_ -> n + sum_to(n - 1)nodopo la chiamata resta l’addizione
_ -> n * factorial(n - 1)nodopo la chiamata resta la moltiplicazione
_ -> { io.println("x") countdown(n - 1) }sìla stampa viene prima, la chiamata è l’ultima espressione del blocco
_ -> int.to_string(count(n - 1))noil risultato va ancora convertito in testo

Il countdown e il play della lezione precedente, quindi, erano già in coda senza che lo sapessi.

Quiz

Quale di questi rami contiene una chiamata in coda?

Nascondere l’accumulatore

sum_loop(3, 0) funziona, ma costringe chi la chiama a sapere un dettaglio interno: che il secondo argomento deve partire da 0. Se qualcuno scrivesse sum_loop(3, 1), otterrebbe un risultato sbagliato. La soluzione abituale è una coppia di funzioni:

src/sum.gleam
import gleam/int
import gleam/io

pub fn main() -> Nil {
  io.println(int.to_string(sum_to(100)))
}

/// The sum of the numbers from 1 to n.
pub fn sum_to(n: Int) -> Int {
  sum_to_loop(n, 0)
}

fn sum_to_loop(n: Int, total: Int) -> Int {
  case n {
    0 -> total
    _ -> sum_to_loop(n - 1, total + n)
  }
}
output
5050

sum_to è la funzione pubblica, con l’interfaccia pulita di prima: un numero dentro, la somma fuori. Il suo unico compito è chiamare sum_to_loop con il valore iniziale giusto dell’accumulatore. sum_to_loop è privata e fa il lavoro vero. Il suffisso _loop è una convenzione diffusa: lo usa anche la libreria standard, dove per esempio string.repeat è implementata con una repeat_loop privata.

Accumulare altro che somme

L’accumulatore può essere di qualsiasi tipo, e l’operazione qualsiasi. Il fattoriale in coda moltiplica, e parte da 1:

gleam
pub fn factorial(n: Int) -> Int {
  factorial_loop(n, 1)
}

fn factorial_loop(n: Int, acc: Int) -> Int {
  case n {
    n if n <= 1 -> acc
    _ -> factorial_loop(n - 1, acc * n)
  }
}

E un accumulatore può anche costruire un risultato cifra per cifra. Questa funzione rovescia le cifre di un numero: a ogni passo stacca l’ultima cifra di n con % 10 e la attacca in fondo all’accumulatore, che si sposta di una posizione con * 10.

src/reverse.gleam
import gleam/int
import gleam/io

pub fn main() -> Nil {
  io.println(int.to_string(reverse_digits(1234)))
  io.println(int.to_string(reverse_digits(907)))
}

pub fn reverse_digits(n: Int) -> Int {
  reverse_loop(n, 0)
}

fn reverse_loop(n: Int, acc: Int) -> Int {
  case n {
    0 -> acc
    _ -> reverse_loop(n / 10, acc * 10 + n % 10)
  }
}
output
4321
709

Srotolato: reverse_loop(1234, 0), poi (123, 4), (12, 43), (1, 432), (0, 4321). L’accumulatore qui fa da “variabile che cambia a ogni giro” dei cicli degli altri linguaggi: la differenza è che non cambia niente, ogni giro ne riceve una nuova.

Esercizio · sul tuo computer

La congettura di Collatz

Prendi un numero intero positivo. Se è pari, dividilo per 2; se è dispari, moltiplicalo per 3 e aggiungi 1. Ripeti. Partendo da 6: 6, 3, 10, 5, 16, 8, 4, 2, 1. Ci sono voluti 8 passi per arrivare a 1. La congettura di Collatz dice che, da qualsiasi numero si parta, prima o poi si arriva a 1. Nessuno è mai riuscito a dimostrarlo, ma nessuno ha mai trovato un numero per cui non succeda.

Nel progetto exercises crea src/collatz.gleam con:

  • una funzione pubblica steps(n: Int) -> Int che restituisce quanti passi servono per arrivare da n a 1;
  • una funzione privata steps_loop con un accumulatore che conta i passi, e una chiamata in coda.

In main stampa il risultato per 6, 7 e 27. L’output deve essere:

output
6 takes 8 steps
7 takes 16 steps
27 takes 111 steps

Suggerimento: il caso base è n uguale a 1. Per distinguere pari e dispari, case n % 2.

Mostra una soluzione (prima prova da solo!)
src/collatz.gleam
import gleam/int
import gleam/io

pub fn main() -> Nil {
  io.println(report(6))
  io.println(report(7))
  io.println(report(27))
}

fn report(n: Int) -> String {
  int.to_string(n) <> " takes " <> int.to_string(steps(n)) <> " steps"
}

pub fn steps(n: Int) -> Int {
  steps_loop(n, 0)
}

fn steps_loop(n: Int, count: Int) -> Int {
  case n, n % 2 {
    1, _ -> count
    _, 0 -> steps_loop(n / 2, count + 1)
    _, _ -> steps_loop(3 * n + 1, count + 1)
  }
}

Un case su due soggetti distingue le tre situazioni: arrivati a 1, pari, dispari. Le due chiamate ricorsive sono entrambe in coda: il + 1 avviene negli argomenti, prima della chiamata. (Partendo da 27 la sequenza sale fino a 9232 prima di ridiscendere: prova a stampare n a ogni passo con un echo.)

Ricapitolando

  • Una ricorsione “normale” lascia del lavoro in sospeso a ogni chiamata, e con molti giri occupa molta memoria.
  • Un accumulatore è un parametro che porta avanti il risultato parziale: il lavoro si fa prima di chiamarsi, negli argomenti.
  • Una chiamata è in coda quando è l’ultima cosa che la funzione fa. Gleam la ottimizza: lo stack non cresce, qualunque sia il numero di giri.
  • La domanda da farsi: dopo la chiamata, resta qualcosa da fare? Se sì, non è in coda.
  • Si nasconde l’accumulatore con una funzione pubblica che chiama una privata nome_loop con il valore iniziale.
  • Per poche ripetizioni la forma normale va bene; per tante, o per un numero sconosciuto, meglio la coda.

Nella prossima lezione tiriamo le somme del modulo, con una sfida che usa tutto: funzioni, case, guardie e ricorsione.