Lezione 1 di 6 · 20 min di lettura

Parlare con Erlang

Usare da Gleam le funzioni scritte in Erlang. L'attributo @external, le annotazioni di tipo obbligatorie, un compilatore che deve fidarsi di te, i tipi esterni, e un file Erlang tutto tuo.

Trent’anni di librerie

Gleam gira sulla BEAM, e sulla BEAM c’è tanto altro: la libreria standard di Erlang, con centinaia di moduli collaudati da decenni, e tutto l’ecosistema di Elixir. Ogni tanto serve proprio una di quelle funzioni, e nessun pacchetto Gleam la offre già pronta.

Per questi casi Gleam ha le funzioni esterne: funzioni scritte in un altro linguaggio, che da Gleam si chiamano come quelle normali. Anzi, le usi da tempo senza saperlo: process.self() del modulo precedente è una funzione esterna, che chiama erlang:self().

@external

Una funzione esterna si dichiara con l’attributo @external e senza corpo:

src/square_root.gleam
import gleam/float
import gleam/io
import gleam/string

@external(erlang, "math", "sqrt")
fn square_root(x: Float) -> Float

@external(erlang, "lists", "reverse")
fn reverse_list(items: List(a)) -> List(a)

pub fn main() -> Nil {
  io.println(float.to_string(square_root(2.0)))
  io.println(string.inspect(reverse_list([1, 2, 3])))
}
output
1.4142135623730951
[3, 2, 1]

L’attributo ha tre argomenti:

  1. il bersaglio: erlang (l’altro possibile è javascript);
  2. il modulo Erlang dove sta la funzione: "math";
  3. il nome della funzione: "sqrt".

Sotto c’è la firma della funzione Gleam, con il nome che preferisci (square_root, non sqrt) e i tipi. Da lì in poi square_root è una funzione come le altre: la chiami, la passi a list.map, la rendi pub se vuoi. Chiamarla non costa niente in più di una funzione scritta in Gleam: per la BEAM sono identiche.

Dettagli nerd Cos'è una FFI?

Il meccanismo che permette a un linguaggio di chiamare funzioni scritte in un altro si chiama FFI, Foreign Function Interface: interfaccia per le funzioni “straniere”. Quasi tutti i linguaggi ne hanno una: Python può chiamare codice C, Java ha la JNI, Rust può chiamare C e farsi chiamare da C.

Di solito è un confine faticoso: i due linguaggi rappresentano i dati in modo diverso, e bisogna convertirli ogni volta. Per Gleam ed Erlang il confine è quasi invisibile, perché Gleam diventa Erlang: un intero è un intero Erlang, una lista è una lista Erlang, una tupla è una tupla. Per questo nei progetti Gleam i file con le funzioni esterne si chiamano spesso qualcosa_ffi.erl.

I tipi sono obbligatori

In una funzione normale, il compilatore può dedurre i tipi guardando il corpo. Una funzione esterna non ha corpo, quindi i tipi vanno sempre scritti. Se ne manca uno:

output
error: Missing type annotation
  ┌─ /home/ada/learn-gleam/exercises/src/square_root.gleam:6:16
  │
6 │ fn square_root(x) -> Float
  │                ^

A parameter annotation is missing from this function.

Functions with external implementations must have type annotations
so we can tell what type of values they accept and return.

Il compilatore si fida di te

Ecco il punto delicato. Il compilatore controlla che ogni chiamata a square_root rispetti la firma che hai scritto. Ma non può controllare che la firma sia vera: non legge il codice Erlang, e non sa nemmeno se la funzione esiste. Si fida.

Proviamo con erlang:system_info(otp_release), che dice quale versione di Erlang sta girando. L’argomento è l’atomo otp_release: in Gleam lo otteniamo con una variante senza dati, che sulla BEAM diventa proprio un atomo (lezione 4.2). E il risultato sembra una stringa:

src/otp_version.gleam
import gleam/io

pub type Item {
  OtpRelease
}

@external(erlang, "erlang", "system_info")
fn system_info(item: Item) -> String

pub fn main() -> Nil {
  io.println("Running on Erlang/OTP " <> system_info(OtpRelease))
}
output
runtime error: Erlang error

An error occurred outside of Gleam.

erlang:error(Badarg)

stacktrace:
  otp_version.main src/otp_version.gleam:13

Compila senza un avviso, e poi si schianta. system_info non restituisce una stringa Gleam: restituisce una charlist, un altro modo di rappresentare il testo in Erlang. Il compilatore ci ha creduto, e il problema è emerso solo quando <> ha provato a concatenare due cose incompatibili: badarg, “argomento sbagliato”.

La soluzione è dire la verità. Il pacchetto gleam_erlang ha il tipo giusto, Charlist, e una funzione per convertirlo:

src/otp_version.gleam
import gleam/erlang/charlist.{type Charlist}
import gleam/io

pub type Item {
  OtpRelease
}

@external(erlang, "erlang", "system_info")
fn system_info(item: Item) -> Charlist

pub fn main() -> Nil {
  let release = charlist.to_string(system_info(OtpRelease))
  io.println("Running on Erlang/OTP " <> release)
}
output
Running on Erlang/OTP 27

(Il tuo numero può essere diverso: è la versione di Erlang installata sul tuo computer.)

Dettagli nerd Perché Erlang ha due tipi di stringhe?

Quando Erlang è nato, negli anni ‘80, il testo si rappresentava come una lista di numeri, uno per carattere: "ciao" era [99, 105, 97, 111]. È la charlist. Comoda da smontare con il pattern matching, ma costosa: ogni carattere è un elemento di una lista collegata, e occupa parecchi byte.

Più tardi Erlang ha aggiunto i binari: sequenze compatte di byte, dove una stringa in UTF-8 occupa più o meno un byte per carattere. Le stringhe di Gleam (e quelle di Elixir) sono binari. Molte funzioni della libreria di Erlang, però, sono più vecchie, e parlano ancora in charlist. Quando leggi la loro documentazione, string() vuol dire charlist, e binary() vuol dire una stringa come quelle di Gleam.

Quiz

Dichiari @external(erlang, "math", "sqrt") fn square_root(x: Int) -> String. Cosa succede?

I tipi esterni

A volte una funzione Erlang restituisce un valore che in Gleam non ha una forma: un riferimento, un handle a un file aperto, una connessione. Per questi casi si dichiara un tipo esterno: un tipo senza varianti.

gleam
pub type UniqueId

@external(erlang, "erlang", "make_ref")
pub fn new_id() -> UniqueId

Gleam sa solo che UniqueId esiste: non può costruirne uno né guardarci dentro, e l’unico modo di averne uno è new_id(). Funziona come un tipo opaco, ed è così che gleam_erlang definisce Pid: pub type Pid, e basta.

Dare a ogni valore esterno il suo tipo, invece di un tipo generico, è un consiglio della guida ufficiale: se un handle di un file e un handle di una connessione hanno tipi diversi, il compilatore non ti lascerà mai confonderli.

Un file Erlang tutto tuo

Le funzioni esterne possono stare anche in un file Erlang del tuo progetto. Metti un file .erl dentro src/, accanto ai moduli Gleam, e Gleam lo compila insieme a loro:

src/dice_ffi.erl
-module(dice_ffi).
-export([roll/1]).

roll(Sides) ->
    rand:uniform(Sides).

In Erlang, -module dà il nome al modulo (deve essere uguale al nome del file), -export elenca le funzioni visibili da fuori con il numero di argomenti, e rand:uniform(N) è un intero a caso tra 1 e N. Da Gleam:

src/dice.gleam
import gleam/int
import gleam/io

@external(erlang, "dice_ffi", "roll")
fn roll(sides: Int) -> Int

pub fn main() -> Nil {
  io.println("You rolled " <> int.to_string(roll(6)))
}

Ogni volta che lo lanci, un numero diverso: You rolled 4, You rolled 1…

E se sbagli il nome della funzione, scrivendo "rol" invece di "roll"? Di nuovo, il compilatore si fida, e l’errore arriva solo a tempo di esecuzione:

src/dice.gleam
import gleam/int
import gleam/io

@external(erlang, "dice_ffi", "rol")
fn roll(sides: Int) -> Int

pub fn main() -> Nil {
  io.println("You rolled " <> int.to_string(roll(6)))
}
output
runtime error: Erlang error

A function was called but it did not exist.

stacktrace:
  dice_ffi.rol unknown source
  dice.main src/dice.gleam:10

Più bersagli, e il piano B

Una funzione può avere un @external per ogni bersaglio, uno sotto l’altro:

gleam
@external(erlang, "lists", "reverse")
@external(javascript, "./my_ffi.mjs", "reverse_list")
pub fn reverse_list(items: List(a)) -> List(a)

E può avere anche un corpo in Gleam: in quel caso l’@external si usa sui bersagli per cui c’è, e il corpo su tutti gli altri. È utile quando una funzione si può scrivere in Gleam, ma Erlang ne ha una versione più veloce.

Un consiglio dalla guida ufficiale: usa le funzioni esterne con parsimonia. Il codice che non è Gleam il compilatore non lo controlla, e ogni @external è un posto dove i tipi potrebbero mentire. Prima di scriverne una, cerca su packages.gleam.run: spesso qualcuno ha già fatto il lavoro, e l’ha provato.

Esercizio · sul tuo computer

Quattro funzioni da Erlang

Crea src/from_erlang.gleam e dichiara quattro funzioni esterne:

  • pi() -> Float, da math:pi/0;
  • power(base: Float, exponent: Float) -> Float, da math:pow/2;
  • sequence(from: Int, to: Int) -> List(Int), da lists:seq/2, che restituisce gli interi da from a to compresi;
  • uppercase(text: String) -> String, da string:uppercase/1 (che accetta anche i binari, quindi le stringhe di Gleam).

In main stampa pi(), power(2.0, 10.0), sequence(1, 5) e uppercase("Hello, Joe!"):

output
3.141592653589793
1024.0
[1, 2, 3, 4, 5]
HELLO, JOE!
Mostra una soluzione (prima prova da solo!)
src/from_erlang.gleam
import gleam/float
import gleam/io
import gleam/string

@external(erlang, "math", "pi")
fn pi() -> Float

@external(erlang, "math", "pow")
fn power(base: Float, exponent: Float) -> Float

@external(erlang, "lists", "seq")
fn sequence(from: Int, to: Int) -> List(Int)

@external(erlang, "string", "uppercase")
fn uppercase(text: String) -> String

pub fn main() -> Nil {
  io.println(float.to_string(pi()))
  io.println(float.to_string(power(2.0, 10.0)))
  io.println(string.inspect(sequence(1, 5)))
  io.println(uppercase("Hello, Joe!"))
}

Il /1 e il /2 nella documentazione di Erlang sono il numero di argomenti: in Erlang lists:seq/2 e lists:seq/3 sono due funzioni diverse, e l’@external sceglie quella con tanti argomenti quanti ne ha la firma Gleam.

Ricapitolando

  • @external(erlang, "modulo", "funzione") sopra una funzione senza corpo la collega a una funzione Erlang.
  • I tipi dei parametri e del risultato sono obbligatori.
  • Il compilatore si fida della firma: se è sbagliata, o se la funzione non esiste, l’errore arriva quando il programma gira.
  • Molte funzioni Erlang usano le charlist, non le stringhe di Gleam: gleam/erlang/charlist le converte.
  • Un tipo esterno (pub type UniqueId, senza varianti) rappresenta un valore che Gleam non può guardare dentro.
  • I file .erl in src/ vengono compilati insieme al progetto.
  • Una funzione può avere più @external (uno per bersaglio) e un corpo Gleam di riserva. Usale con parsimonia.

Nella prossima lezione vedremo il modo sicuro di ricevere dati dal mondo esterno: non fidarsi, e controllare.