Heroes / Guida / capitolo 14

Il confine con il C

Dichiarare e usare funzioni, tipi e costanti delle librerie C.

Heroes usa le librerie C per funzionalità come socket, database, grafica e funzioni matematiche. Questo capitolo spiega come dichiararle e quali controlli esegue il compilatore.

Un gruppo nomina il suo header

extern "math.h" link "m"
    function sqrt(x: f64) -> f64
    function pow(base: f64, exponent: f64) -> f64
    function floor(x: f64) -> f64
examples/gallery/08-ffi.hero, righe 12–15

Dopo queste dichiarazioni puoi chiamare le tre funzioni C. L’header è il file che descrive funzioni e tipi della libreria. Clang lo legge e controlla le dichiarazioni del gruppo. Questo collegamento tra Heroes e C si chiama binding.

Quali errori rileva il controllo

Se scrivi male il nome di una funzione, il compilatore segnala l’errore nella dichiarazione e mostra la risposta di clang:

$ heroes build ffi.hero
error[ffi_unknown_name]: `math.h` declares no `flooor` — clang read the header and could not find it
  at ffi.hero:15:5
     |
  15 |     function flooor(x: f64) -> f64
     |     ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  note: an `extern` names what the header already has (§4.19): check the spelling, or name the header that does declare `flooor`
  fix (guess): the header declares `floor`

Il suggerimento nell’ultima riga viene da clang, che ha consultato l’header della libreria.

Sbaglia un tipo e la risposta arriva dallo stesso posto:

$ heroes build ffi.hero
error[ffi_return_type]: `puts` does not return `cstr` — that is what `stdio.h` says, and clang read it
  at ffi.hero:22:5
     |
  22 |     function puts(s: cstr) -> cstr
     |     ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  note: an `extern` is checked against the real header (§4.19): correct the result type, or name the header that declares this `puts`

Verificare le dichiarazioni sull’header permette di rilevare errori che altrimenti potrebbero causare problemi durante l’esecuzione.

Il confine si vede nei tipi

# `cstr` is a C string and `ptr` an opaque pointer. Neither is a `str`:
# the boundary is visible in the type. A second group, because this one
# comes from a different header — and on macOS neither needs `link`, since
# libm and libc are already there.
extern "stdio.h"
    function puts(s: cstr) -> i64
examples/gallery/08-ffi.hero, righe 17–22

cstr rappresenta una stringa C e ptr un puntatore opaco, un indirizzo di cui Heroes non legge direttamente il contenuto. Sono tipi diversi da str e le conversioni sono esplicite. s.cstr() rende una stringa Heroes leggibile dal C per la durata della chiamata. c.validated() controlla e copia il testo C, restituendo uno str?: un valore oppure un errore se il puntatore è nullo o il testo non è UTF-8 valido. Passare un cstr nullo a una funzione C ferma il programma con un messaggio d’errore.

I tipi distinguono i dati provenienti dal C e le conversioni mostrano dove vengono trasformati in valori Heroes.

Una stringa prestata con .cstr() può stare solo come argomento della chiamata che la legge, e solo dove la dichiarazione dice che quella chiamata non ne tiene niente: function strlen(s: cstr lent) -> u64. Senza quella parola un parametro C si assume che tenga quel che riceve, e il prestito viene rifiutato, perché byte tenuti oltre la chiamata vengono letti dopo che il programma li ha cambiati o liberati. La parola è un’affermazione sul C che qui nessuno può controllare, quindi il compilatore non la scrive mai al posto tuo. Tenuto altrove, il puntatore sopravviverebbe ai byte a cui punta, e il compilatore rifiuta quella forma prima che qualcosa giri. Quando una libreria tiene il testo oltre la chiamata, il programma lo dice e resta padrone dei byte. x: cstr @= s.lease() consegna al C una copia dei byte in lease, cioè in prestito finché lo dici tu: la copia vive fino a end_lease(@x), che la libera. Un lease che nessuno chiude ferma il programma quando main termina, dicendo quanti ne restano aperti. Un programma che esce con exit() non viene conteggiato lì, e nella build Linux il controllo di memoria segnala quei byte come persi. Nell’esempio, keep_label è una funzione C che tiene il puntatore e kept_label_length lo legge dopo:

    while at < 3
        label: cstr @= f"row-{at}-payload".lease()
        keep_label(s: label)
        print(f"C still reads {kept_label_length()} bytes")
        end_lease(@label)
        at @ at + 1
examples/gallery/13-lease.hero, righe 22–27

La copia viene presa quando il lease comincia, quindi le modifiche successive alla stringa non arrivano al C. E se la libreria legge ancora il testo dopo end_lease, nessun compilatore può vederlo: l’header non dice per quanto il C tiene un puntatore, quindi quell’errore resta tuo, e a trovarlo è il controllo di memoria con cui gira il ramo Linux, il sanitizer.

C’è una cosa che una dichiarazione non può dire affatto: che la funzione C libera quel che riceve. Un lease è memoria del programma, e solo end_lease può liberarla, quindi una libreria che la libera ammazza il programma sul posto. Nessuna parola aiuterebbe, perché la libreria può liberare quei byte più tardi, da una callback, o da una chiamata che non prende nessun puntatore, e una parola si scrive su un parametro. Quel che il programma fa invece è dire quel che ha visto: quando muore così stampa quanti lease erano vivi e in quale funzione, e lascia al lettore il controllo se sia stato il C a liberarne uno. Quando è la libreria a dover possedere la memoria, dagliene una sua: alloca con l’allocatore della libreria e passale la funzione che la smaltisce. L’esempio intero, con il suo header →

Un ptr nudo resta un indirizzo e niente più: Heroes non può sapere se quello che restituisci a una libreria è quello giusto, né come la libreria lo usa. Quando la libreria ha un nome per il suo puntatore, il gruppo può usare quel nome e il compilatore controlla di più: sono gli handle, più avanti in questo capitolo. I controlli sui tipi non eliminano i rischi del codice C.

Un altro controllo avviene durante l’esecuzione. Se una libreria C richiama una funzione Heroes da un thread che ha creato da sé, l’esecuzione si ferma e il messaggio nomina la funzione. Ogni thread Heroes gestisce memoria propria; il thread esterno non è inizializzato per farlo. La libreria deve richiamare la funzione dal thread della chiamata originale, oppure il thread va avviato da Heroes.

Come si usano

# The hypotenuse, in the language's own arithmetic plus one C call.
#
# Both sides are `f64`, so §4.9 makes the names mandatory at the call site. C's
# numeric APIs are where same-typed parameters cluster — `pow(base:, exponent:)`
# is the same shape — so this is where the rule is felt most and where argument
# inversion would be silent without it.
function hypotenuse(a: f64, b: f64) -> f64
    return sqrt(a * a + b * b)
examples/gallery/08-ffi.hero, righe 24–31

Una funzione C si chiama come una funzione Heroes. Si applica anche la regola delle etichette del capitolo 3: in pow(base:, exponent:) i due argomenti hanno lo stesso tipo, quindi i loro nomi sono obbligatori.

# No implicit conversions: an `i64` becomes an `f64` because you said so.
function diagonal_of(width: i64, height: i64) -> f64
    return hypotenuse(a: to_f64(width), b: to_f64(height))
examples/gallery/08-ffi.hero, righe 33–35
# `pow` is C's, `floor` is C's, and the rounding is yours.
function rounded_power(base: f64, exponent: f64) -> i64
    return to_i64(floor(pow(base: base, exponent: exponent))).must()
examples/gallery/08-ffi.hero, righe 37–39

Le conversioni numeriche sono esplicite. to_f64 restituisce direttamente un valore. to_i64 può fallire se il valore non è rappresentabile come intero e restituisce quindi un i64?. Qui .must() estrae il risultato o ferma il programma in caso di errore.

Trovare librerie e dipendenze

link "m" indica la libreria da collegare al programma. Se servono anche percorsi o altre dipendenze, puoi indicare un package, un pacchetto di cui il sistema conosce le impostazioni:

extern "raylib.h" package "raylib"

Questa dichiarazione chiede al sistema i percorsi degli header, le librerie e le altre dipendenze necessarie, come i framework su macOS o -lGL -lX11 su Linux. La sintassi Heroes resta uguale sulle diverse piattaforme. Se il pacchetto richiede opzioni non supportate, il compilatore le segnala e interrompe la compilazione.

Un gruppo può anche dichiarare una constant, il cui valore arriva dall’header e non dal tuo file, e un record che ricalca una struct C campo per campo. Tutti e due vengono controllati allo stesso modo, da clang, sulla cosa vera.

Puoi passare una tua funzione a una libreria C perché la richiami: è una callback. Heroes ammette callback come parametri, ma non come risultati di funzioni C. Clang ne verifica i tipi sull’header. Alcune callback, come quella di qsort, richiedono controlli sui puntatori non ancora supportati; il compilatore segnala il limite sulla dichiarazione.

Con owned puoi indicare la funzione C che libera una stringa allocata dalla libreria, ricevuta come risultato o tramite un parametro @. Il compilatore usa quella funzione per liberare la stringa e restituisce il testo come valore Heroes.

Gli handle, e le tre parole che dicono di chi sono

Molte librerie C ti consegnano un handle: un puntatore a qualcosa che la libreria tiene per sé, una connessione a un database, una query preparata, e che prima o poi dovrai restituirle. Fin qui sarebbe un ptr, un indirizzo di cui non sai niente. Dentro un gruppo puoi invece dargli il nome che la libreria stessa usa: un record senza campi, con tag e il nome del tipo C, è il puntatore a quel tipo. Clang controlla il nome sull’header, un CDb non passa dove serve un CStmt, e nullptr è il suo valore vuoto.

    record CDb tag sqlite3
    record CStmt tag sqlite3_stmt
examples/ledger/db/sqlite.hero, righe 64–65

Poi ogni dichiarazione che tocca un handle dice che cosa fa, con una di tre parole. acquires e un nome: la chiamata te ne consegna uno, e quel nome è la funzione che lo riprende. consumes: la chiamata ne riprende uno e ne chiude la vita. borrows: la chiamata te ne mostra uno che la libreria continua a tenere, e tu non devi restituire niente. Nessuna delle tre viene indovinata dal compilatore, perché due funzioni con la stessa firma possono fare l’una la prima cosa e l’altra la terza: si scrive.

    function sqlite3_open(filename: cstr lent, @out: CDb acquires sqlite3_close) -> i64
    function sqlite3_close(db: CDb consumes) -> i64
examples/ledger/db/sqlite.hero, righe 83–84

Da quelle parole vengono due controlli. Il primo avviene prima che il programma giri. Un parametro scritto senza @ è in prestito: chi chiama tiene il valore e lo rilegge dopo. Passarlo a una chiamata che lo chiude lo lascerebbe con un indirizzo che il C ha già ripreso, quindi il compilatore rifiuta la riga, e la riparazione è scrivere @ sul parametro e sulla chiamata. Qui shut riceve la connessione in prestito e prova a chiuderla:

$ heroes check handles.hero
error[consumed_borrowed_handle]: `sqlite3_close` ends the life of what it is given, and `db` was borrowed — this function promised not to change it
  at handles.hero:8:27
    |
  8 |     _ = sqlite3_close(db: db)
    |                           ^^
  note: a parameter written without `@` is borrowed: the caller keeps the value and reads it again after the call, so a call that frees it leaves them holding an address C has taken back. Mark the parameter `@db` and the call site `@db` too, which says on both lines that the value does not survive
  note: this refuses the call, not the copy: a copy made BEFORE the call still holds the freed address, and nothing here sees that. design.md Part 8 wart 20 states that cost and names what would close it

Il secondo controllo avviene quando il programma finisce. Ogni handle preso con una chiamata marcata acquires va restituito con una marcata consumes. Quando main termina, il runtime, il codice che sta sotto il programma e lo tiene in piedi mentre gira, conta gli handle ancora aperti e, se ne trova, ferma il programma dicendo quanti sono. Questo programma apre una connessione e non la chiude mai:

$ heroes run forgetful.hero
opened, rc = 0
panic: 1 C handle(s) never given back — every call marked `acquires` owes one marked `consumes`, and this program is missing that many. The first is at 0x1031491f0

Lo stesso conteggio ferma un handle restituito due volte, che in C sarebbe liberare la stessa memoria due volte. E le tre parole si controllano a vicenda: se una funzione del gruppo dichiara consumes per un tipo di handle, ogni funzione che ne consegna uno deve dire con quale delle tre parole lo fa, e una che tace è rifiutata alla dichiarazione. Hanno senso solo su un handle, cioè su un record con tag: su un ptr nudo il compilatore non avrebbe dove leggerle, e le rifiuta.

Il programma

test "the C library does the maths"
    assert hypotenuse(a: 3.0, b: 4.0) == 5.0
    assert rounded_power(base: 2.0, exponent: 10.0) == 1024
examples/gallery/08-ffi.hero, righe 41–43
function main()
    print(
        "the 16:9 diagonal of a 1920-wide screen: ",
        diagonal_of(width: 1920, height: 1080)
    )
examples/gallery/08-ffi.hero, righe 45–49
$ heroes run examples/gallery/08-ffi.hero
the 16:9 diagonal of a 1920-wide screen: 2202.9071700822983

Due librerie C, quattro dichiarazioni, nessun wrapper e nessun generatore di binding. Nel repository, SQLite, curl, SDL e raylib sono collegati allo stesso modo: un gruppo, un header, e le dichiarazioni che servono a quel programma.

Le regole viste fin qui

Sette parole che possono aprire una riga di primo livello, regole esplicite per dati, errori e modifiche, le librerie C per le funzionalità esterne, e tre parole che dicono a chi appartiene un handle: questi sono gli elementi del linguaggio descritti fin qui.

La guida finisce qui. Torna all’elenco dei capitoli · perché il linguaggio è fatto così. ← Prima: Test e buchi

Esegui un programma tuo: bastano quattro comandi. Oppure prendi tutto il linguaggio in un file solo, e tutti i programmi già scritti.