In questa guida dedicata alla sintassi nix linguaggio analizzeremo passo dopo passo come muovere i primi passi con la REPL e comprendere i pilastri della programmazione funzionale.
Abbiamo già parlato della nostra infrastruttura in alcuni post, e abbiamo già citato come Nix sia un componente molto utile per gestire configurazioni di sistema (o della propria home directory) in modo dichiarativo.
In questo post, che si rivolge a chi non ha esperienza con il linguaggio Nix, ma vorrebbe impararne le basi; oppure a chi ha poca esperienza con Nix e volesse approfondire, o chi non ha esperienza con la programmazione funzionale in generale, che non è il semplice uso di funzioni all’interno di un programma.
Lo scopo non è essere esaustivi, ma di introdurre i concetti base della programmazione funzionale e dichiarativa, illustrando alcuni elementi della sintassi del linguaggio Nix, dei tool e procedere quindi a costruire alcuni semplici programmi.
Consigliamo caldamente a chi volesse seguire questa guida di procurarsi un computer, installare Nix e seguire gli esempi di codice scrivendoli nel terminale, sperimentando e provando cose nuove man mano che si avanza nella lettura. Provate a capire le differenze tra Nix e i linguaggi che vi sono più familiari per costruire un’intuizione più solida di come funziona il linguaggio.
Linguaggi dichiarativi? Linguaggi funzionali?
Nix è spesso ritenuto un ottimo modo per esprimere in modo dichiarativo la configurazione di un sistema. Ma cosa vuol dire, “dichiarativo”?
Nell’ambito dell’informatica, con “programmazione dichiarativa” si intende un modo di esprimere un programma senza dover definire esplicitamente i comandi da eseguire, bensì lo stato desiderato.
Per fare un esempio non legato alla programmazione, se volessi esprimere come fare una torta di carote, il modo “classico” (imperativo) sarebbe qualcosa simile a:
1. prendi le uova, rompile e separa gli albumi dai tuorli,
2. aggiungi la farina e lo zucchero ai tuorli,
3. mescola finché non ottieni una crema uniforme,
4. aggiungi le carote tritate,
5. mescola fino ad avere un composto omogeneo,
6. monta gli albumi a neve,
7. aggiungi al composto senza smontarli, mescolando dal basso in alto,
8. inforna a 180 gradi per 35 minuti
mentre, con un approccio dichiarativo, potrei dire:
una torta di carote è fatta cuocendo l'impasto in forno, l'impasto si fa con albumi montati e composto di farina, tuorli, zucchero e carote tritate.
La differenza sta nel fatto che nell’approccio dichiarativo si definisce come gli oggetti sono, anziché dire come vengono costruiti: non indichiamo quanto deve cuocere la torta, non indichiamo come unire gli albumi al composto, etc.
Questo non toglie che, in fondo, vi sia una esecuzione fatta più o meno allo stesso modo (una mano dovrà prendere delle uova e separare albumi e tuorli, la cottura avverrà in forno a 180 gradi circa, etc), ma la differenza sta nel modo in cui si esprime la ricetta ed eventualmente a quanto il contesto ci permette di fare assunzioni.
Chiaramente esistono molti modi diversi per programmare un sistema: linguaggi diversi, stili diversi, paradigmi diversi. Due famosi paradigmi che ricadono nella categoria “dichiarativa” sono quello funzionale e quello logico. Entrambi vengono spesso considerati approcci dichiarativi alla programmazione. Lisp e Prolog sono due linguaggi di programmazione particolarmente famosi per rappresentare programmi con approcci funzionali e logici. Ma ciò non toglie che i linguaggi possano adottare stili diversi: anche in javascript, che non è un “linguaggio funzionale”, si può adottare uno “stile funzionale”.
Ora, mangiamo la torta di carote e continuiamo con Nix.
Nix, è un “linguaggio funzionale” in quanto agevola la scrittura di programmi (e configurazioni!) con uno stile funzionale. Ovvero: il programma (o la configurazione) è definito mediante una sequenza di applicazione di funzioni “pure”, ovvero che non hanno side-effects (effetti collaterali). Questo implica che due valutazioni della stessa funzione permettono di ottenere gli stessi output a partire dagli stessi input.
Ad esempio, la funzione time(), che ritorna l’ora corrente sottoforma di stringa, non è pura se ogni volta che la chiamiamo ci ritorna l’ora attuale, mentre se ritornasse un’ora fissata (e.g. 12:15:59), sarebbe forse poco utile, ma sarebbe pura.
Ci sono ottimi motivi per cui un linguaggio funzionale dovrebbe essere “puro”. Uno dei quali, in particolare per Nix, è la riproducibilità: se la valutazione di un programma porta sempre allo stesso output dato lo stesso input, significa che possiamo sempre riprodurre il risultato e che possiamo sostituire il valore ottenuto dall’esecuzione del programma con il valore stesso, proprio come scrivere time() nel nostro esempio precedente può essere sostituito con 12:15:59.
Questa proprietà, per cui una parte di un programma si può sempre sostituire con il valore che produce senza modificarne il risultato finale, si chiama trasparenza referenziale, ed è una proprietà quasi sempre desiderata e mantenuta nei linguaggi funzionali.
Ci sono ovviamente delle situazioni (come time() sopra), in cui invece noi vogliamo avere delle funzioni non pure, ma questo porta ad una serie di svantaggi, per cui la comunità della programmazione funzionale si è adoperata in vari modi per isolare il più possibile queste situazioni. Ma non parleremo di questo, qui, perché in Nix, il tempo non cambia e, come vedremo, ci sono ottimi motivi perché sia così.
Programma o configurazione?
Nella parte precedente abbiamo usato i termini “programma” e “configurazione” quasi in modo intercambiabile. Ma è davvero così? Cos’è un programma? Cos’è una configurazione? Rispondere a queste domande non è così semplice come potrebbe sembrare.
Una interpretazione molto comune è che un programma è una definizione di qualcosa che deve essere eseguito per trasformare dei dati di input in dati di output, mentre una configurazione è un dato e in quanto tale non deve essere eseguito (al più deve essere letto da un certo formato).
Un’altra interpretazione comune è che una configurazione è qualcosa che serve ad un programma per funzionare e ne determina il comportamento.
Eppure, il codice sorgente di programma è una serie di stringhe, tipicamente definito in un linguaggio formale, e noi usiamo software, come editor di testi, per scrivere questi dati. Il codice sorgente è quindi un dato!
E, tipicamente, il codice sorgente è un dato che viene fornito ad un altro programma, un compilatore o un interprete, per poter funzionare e determinarne il comportamento (e.g. la creazione di un eseguibile o la manipolazione di pixel sullo schermo). È chiaro quindi come la differenza non sia così netta: il codice sorgente può diventare una configurazione a sua volta, a seconda del livello a cui ci poniamo.
Non cercheremo quindi di rispondere a quelle domande: per alcune persone non vi è differenza, per altre sì, in base alla prassi, alla semantica del contesto o alla pragmatica della comunicazione in corso.
Quello che è importante per noi, è che in Nix, una configurazione è un programma: non è una semplice dichiarazione di dati che assolve al suo scopo finale direttamente, ma deve essere valutata da un interprete prima di produrre il dato finale.
Nix: linguaggio e package manager
Nix, infatti, è un duo di due oggetti diversi: un linguaggio di programmazione funzionale e un package manager: il suo scopo è fornire al computer le istruzioni per poter prendere codice sorgente di altre applicazioni (un “pacchetto”) ed eseguirlo, eventualmente dopo averlo compilato (averne fatto una “build”).
Qui confluiscono alcuni elementi che abbiamo descritto in precedenza:
- i passi che indicano come fare la build di un pacchetto sono sostanzialmente imperativi: prima bisogna compilare i sorgenti, poi fare il link;
- la build di un pacchetto dipende dallo stato del computer: se un codice sorgente usa la data del sistema per produrre il file eseguibile, allora due build diverse producono due eseguibili diversi;
Quindi, è evidente che se volessimo avere delle build riproducibili (cosa auspicabile per poter tracciare bug, sviluppare in team, etc), dovremmo isolare la compilazione di un programma dall’ambiente in cui viene fatto.
Da qui nasce la necessità di avere un linguaggio puro e riproducibile per fare la build di pacchetti. Se un programma contenesse un codice tipo:
#ifdef BUILD_YEAR_2025
printf("Computing statistics\n");
// etc
#else
printf("License expired!\n");
exit(0);
#endif
è evidente che due compilazioni del codice fatte in due momenti di tempo diversi potrebbero avere effetti molto diversi.
Nix, quindi, nasce come accoppiata di un gestore di pacchetti e un linguaggio funzionale puro che cerca di isolare completamente il processo di build di un pacchetto per produrre degli artefatti completamente riproducibili. E, infatti, Nix esegue le build in un ambiente isolato (sandbox) in cui la data non cambia mai.
Come avviene quindi, in Nix, la compilazione di un programma? Tipicamente si scrive una configurazione mediante il linguaggio Nix. Questa configurazione può essere eseguita ed interpretata per produrre un oggetto finale “statico” che determina poi una derivazione, ovvero una definizione riproducibile di come fare la buil di un certo pacchetto. Il package manager di Nix, quindi, prende questa derivazione e segue le sue indicazioni per produrre un artefatto: la build di quel pacchetto.
In questo articolo, noi ci fermeremo a livello di linguaggio Nix: non ci interesserà effettuare la build di un pacchetto o creare derivazioni, ma semplicemente scrivere un programma (o configurazione) con Nix che prende dei dati e li trasforma in qualche modo.
Le basi della sintassi
Entriamo nel vivo dell’azione esplorando i comandi principali per la manipolazione delle liste in Nix REPL e la gestione delle stringhe.
Partiamo quindi con la pratica: dopo aver installato Nix, possiamo lanciare una REPL (Read-Evaluate-Print Loop), ovvero un programma che attende il nostro input, lo valuta, stampa il risultato e torna ad aspettare il nostro input:
$ nix repl
Nix 2.31.2
Type :? for help.
nix-repl>
Qui nix-repl> indica il prompt del programma, che attende il nostro input. Iniziamo a vedere davvero come Nix si comporta con il data e ora:
nix-repl> builtins.currentTime
1781171288
questa funzione restituisce un timestamp in secondi, e se ispezioniamo a quando corrisponde vedremo che corrisponde col momento in cui è stata avviata la repl, ad esempio possiamo usare python per fare una verifica (in un altro terminale, senza uscire dalla repl):
Python 3.13.12 (main, Feb 3 2026, 17:53:27) [GCC 14.3.0] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> from datetime import datetime
>>> datetime.fromtimestamp(1781171288)
datetime.datetime(2026, 6, 11, 11, 48, 8)
Ok, il timestamp 1781171288 corrisponde al 2026-06-11 alle 11:48:08. Ignoriamo la timezone al momento.
Proviamo nuovamente a chiedere l’ora a Nix:
nix-repl> builtins.currentTime
1781171288
(dopo qualche secondo...)
nix-repl> builtins.currentTime
1781171288
ecco, vediamo che il risultato non cambia. Questo perché Nix è puro e vuole isolarci dai side-effect, ovvero dai cambiamenti all’ambiente che non abbiamo apportato noi stessi. Se volessimo avere un ambiente 100% riproducibile, in cui ad esempio data e ora siano sempre costanti, basterebbe avviare l’interprete Nix specificando e fissando tutte queste variabili “di confine” (cosa che Nix fa quando deve costruire i pacchetti in modo riproducibile, infatti quando si fa build di un pacchetto tramite Nix la data è fissata al primo gennaio 1970), anche se nella REPL questo vincolo non è forzato.
Ovviamente la REPL stessa ha dei side effect (leggere e stampare stringhe), ma questi non dovrebbero avere un impatto sui comandi che eseguono.
Vediamo ora come stampare una stringa. Nix supporta stringhe con doppi-apici e doppi singoli-apici, a seconda di cosa ci serve:
nix-repl> "Hello, World!"
"Hello, World!"
nix-repl> ''Hello, World!''
"Hello, World!"
nix-repl> "Hello,
> World!"
"Hello,\nWorld!"
nix-repl> ''Hello,
> World!''
"Hello,\nWorld!"
Di solito vengono usati i doppi singoli-apici quando vogliamo usare i doppi-apici all’interno della stringa:
nix-repl> ''Hello, "World!"''
"Hello, \"World!\""
nix-repl> '''Hello', "World!"''
"'Hello', \"World!\""
cosa molto utile quando si definiscono dei pacchetti, dato che capita spesso di dover usare `”` all’interno di script e procedure.
Come facciamo l’escape, se avessimo bisogno sia di `”` che `”` nella stringa? L’escape si fa mettendo un ulteriore `’` davanti a `”`:
nix-repl> ''Hello, '''World!'''''
"Hello, ''World!''"
mentre l’escape di `”` avviene con `\”`.
Nix supporta anche “l’interpolazione” di stringhe, ovvero l’espansione di una variabile dentro una stringa. Questo si fa usando la sintassi ${expr}, ad esempio:
nix-repl> foo = "World"
nix-repl> "Hello, ${foo}!"
"Hello, World!"
Qui abbiamo anche visto che nella REPL è possibile dichiarare delle variabili. In uno script Nix la sintassi è leggermente diversa. Ne approfittiamo per mostrare come, anziché usare una REPL, si potrebbe usare un editor per scrivere un file sorgente e poi valutarlo.
Scriviamo nel file hello_world.nix il seguente contenuto:
let
who = "World";
in
"Hello, ${who}!"
e poi valutiamolo:
$ nix eval --file hello_world.nix "Hello, World!"
Visto che stiamo parlando di file .nix, è utile anche citare che è possibile usare import per importare un file, cosa che potrebbe tornare utile sperimentando con la repl:
nix-repl> import hello_world.nix
"Hello, World!"
Perfetto, abbiamo visto come dichiarare una variabile in Nix, tramite let ... in ... e come valuare un file.
A questo punto è opportuno introdurre un concetto importante, tipico di molti linguaggi funzionali, ma che le persone che vengono da linguaggi imperativi spesso non conoscono: tutti gli elementi del linguaggio Nix sono espressioni.
Statements ed Espressioni
In linguaggi come C, C++, Java e JavaScript, tipicamente vedremo strutture come queste:
```
if (true) {
printf("Hello, World!");
} else {
printf("Hi, World!");
}
oppure, usando operatori ternari, costruzioni simili alla seguente:
printf(true ? "Hello, World!" : "Hi, World!");
qual è la differenza sostanziale? Che mentre if ... else ... è una affermazione (statement), l’operatore ternario è un’espressione (expression). In altre parole, un’espressione assume sempre un valore, mentre uno statement no. In quei linguaggi non è possibile infatti fare:
printf(if (true) { "Hello, World!" } else { "Hi, World!" });
Questo perchè possiamo scrivere alcuni “blocchi” di codice che non hanno un valore associato, quindi non possiamo usarli in posti dove serve un valore (ad esempio, una chiamata a funzione o una somma).
Invece, in molti linguaggi funzionali quasi tutto è un’espressione, ovvero assume un valore, e i blocchi di codice hanno un valore associato.
In Nix, scrivere
if true then "Hello, World!" else "Hi, World!"
equivale esattamente a scrivere "Hello, World!":
nix-repl> if true then "Hello, World!" else "Hi, World!"
"Hello, World!"
quindi possiamo fare cose come questa:
nix-repl> "Hello, " + (if true then "World!" else "World!")
"Hello, World!"
Dove l’intera espressione dentro parentesi assume un valore. In Nix non esiste, in fatti, un modo per scrivere if senza else: se fosse possibile, allora dovremmo accettare che il programma potrebbe non avere un valore:
nix-repl> "Hello, " + (if true then "World!")
error: syntax error, unexpected ')', expecting ELSE
at «string»:1:35:
1| "Hello, " + (if true then "World!")
| ^
Invece, Nix richiede che le espressioni assumino sempre un valore. Quindi, ad esempio, che gli if abbiano sempre un else ad accompagnarli.
Questa cosa potrebbe sembrare assurda a chi non è avvezzo a questo stile di programmazione, ma non lo è.
Difatti, in Nix e in altri linguaggi, l’intero programma diventa un’espressione: dato che i blocchi sono espressioni e hanno quindi un valore, l’esecuzione del programma produce un valore, non esiste quindi modo per non produrre un valore (se non avere un errore a run-time). Da un certo punto di vista, non è solo logico, ma anche liberatorio: tutti gli elementi del linguaggio ricadono così in una sola categoria (espressioni) ed è più facile combinarli.
Tornando all’esempio di prima, dove abbiamo usato il costrutto let ... in ...; anche in questo caso l’intero blocco è un’espressione ed assume un valore: let ci permette di creare delle associazioni tra un valore e un identificatore (“bindings”, ovvero creare delle variabili) che sono valide ed utilizzabili all’interno del corpo.
Benché di dubbia utilità, questo codice è perfettamente valido (le parentesi tonde possono essere rimosse: sono state messe per rendere chiaro quali sono i blocchi):
# L'intera espressione ha valore "FooBar"
(
let
foo = (
let
f = "F";
o = "o";
in
( f + o + o ) # "F" + "o" + "o"
);
bar = "Bar";
in
( foo + bar ) # "Foo" + "Bar"
)
Quindi, anche let ... in ... assume un valore. E abbiamo visto anche che # permette di creare commenti in-line. Il costrutto let ... in ... non è così diverso dal creare uno scope in un blocco C++ o Rust, in cui possiamo definire delle variabili che valgono solo per quello scope.
Al contrario di C++, però, Rust è simile a Nix nel fatto che il blocco può assumere un valore, proprio come in Nix e in molti altri linguaggi funzionali.
Le funzioni nel linguaggio Nix
Capire come strutturare funzioni e variabili in Nix è il passo fondamentale per scrivere configurazioni pulite, modulari e prive di effetti collaterali.
Qualche lettore potrebbe notare che, nonostante si stia parlando di un linguaggio funzionale, non abbiamo ancora visto alcuna funzione.
E invece, una l’abbiamo vista: import.
Al contrario della famosa sintassi usata nei linguaggi C-like (C, C++, Java, JavaScript, C#, Rust, etc), in cui si usano le parentesi per denotare l’applicazione (chiamata) di una funzione, in Nix (e una serie di linguaggi funzionali, tipicamente quelli della famiglia ML-like) non si usano le parentesi, ma è sufficiente far seguire alla funzione i suoi parametri, separati da spazi.
In questo caso, la funzione import richiede un solo parametro, un percorso (che è un tipo di dato nativo nel linguaggio Nix):
nix-repl> import /path/to/file.nix
Come altro esempio, se avessimo avuto una funzione double che raddoppia il suo parametro, avremmo potuto invocarla così:
nix-repl> double 21
42
Prima di continuare, vale la pena spiegare perché Nix è un linguaggio “funzionale”. D’altronde, le funzioni sono un costrutto molto usato nella maggior parte dei linguaggi di programmazione (sebbene non in tutti). Cosa rende i linguaggi “funzionali” diversi da altri linguaggi? C, C++ e JavaScript, ad esempio, non sono linguaggi funzionali propriamente detti.
La purezza, cioé l’assenza di side-effect, è un aspetto di cui abbiamo già parlato. In particolare, le funzioni dei linguaggi funzionali sono solitamente prive di side-effect, ma non è una regola universale.
Un altro aspetto importante della programmazione funzionale è che le funzioni sono un tipo di dato primitivo. Ovvero, potete crearle e manipolarle quasi alla stregua di altri tipi primitivi del linugaggio, come numeri o stringhe. Potete infatti assegnare delle funzioni a delle variabili, passarle come parametri o ritornarle come valori da una funzione.
Purtroppo non è sempre immediato fare operazioni come “stabilire l’uguaglianza di due funzioni”, o “concatenare le funzioni”, ma questo dipende dal linguaggio e dalle sue proprietà. In Nix, ad esempio, la comparazione di due funzioni diverse ritorna sempre false, anche se fanno la stessa cosa.
Ipotizzando di avere una funzione double e una funzione timesTwo, entrambe che raddoppiano l’argomento:
nix-repl> double == timesTwo
false
Ma questo vale anche per la stessa funzione:
nix-repl> import == import
false
Ma il punto cruciale di un linguaggio di programmazione funzionale è che i programmi sono fatti applicando e componendo funzioni.
Solitamente, e in Nix è sicuramente vero, l’applicazione (invocazione, chiamata) di una funzione è un’espressione, ovvero, la funzione deve ritornare un valore.
Questo dovrebbe chiudere il cerchio: in Nix le espressioni si compongono, i blocchi sono espressioni, l’applicazione di una funzione ad un valore è un’espressione, quindi tutti questi fattori partecipano a definire un programma come un’espressione che si ottiene applicando delle funzioni a dei dati.
Come si definiscono, quindi, le funzioni in Nix? Così:
parametro: espressione
Ad esempio, la funzione double di prima, si definisce così:
nix-repl> double = x: x + x
nix-repl> double 21
42
nix-repl> double == double # le funzioni non si possono comparare
false
Ho usato il singolare apposta: in Nix, le funzioni possono avere esattamente un solo argomento. Non zero. Non due.
Come è possibile allora a lavorare su più valori? Esistono almeno tre modi:
- sfruttare il fatto che le funzioni possono accedere ai valori che erano disponibile al momento della loro definizione, ovvero effettuare una chiusura (closure);
- ritornare un’altra funzione che fa parte del lavoro (currying);
- passare un valore composto (ad esempio una lista) come parametro della funzione.
Vediamo tutte e 3 queste possibilità.
Chiusure
In generale e in modo poco rigoroso, si parla di “chiusura” quando una funzione è in grado di “catturare” dei valori che sono stati definiti all’infuori di essa e usarli nella propria definizione.
Esistono declinazioni e definizioni diverse di cosa questo significhi, ma non ci interessa essere rigorosi al momento.
Possiamo però vedere un esempio esplicativo in Nix:
nix-repl> foo = 21
nix-repl> bar = x: x * foo
nix-repl> bar 2
42
qui bar ha potuto accedere a foo perché era disponibile al momento della definizione della funzione. Si noti come il valore di foo “catturato” dalla funzione ora non possa essere cambiato:
nix-repl> foo = 123
nix-repl> bar 2 # il valore di foo all'interno di bar è ancora 21
42
Qualcuno potrebbe obiettare e far presente che questo non risolve davvero il problema: se dovessimo definire una funzione ogni volta che avessimo bisogno di passare più di un parametro, allora avrebbe poco senso il concetto di funzione.
È vero. Ma questa tecnica ci apre la strada per un’altra via: il currying.
Currying
nix-repl> sum = x: y: x + y
nix-repl> sum 20 22
42
Ecco fatto. La funzione sum ora sembra prendere 2 valori, ma non è così: la funzione sum ha un solo parametro, x, e ritorna una funzione (anonima) che somma il valore catturato x con il parametri y. Quindi stiamo sfruttando le chiusure che abbiamo visto in precedenza per poter fingere di avere una funzione con più argomenti.
Se scriviamo solo sum 20, otteniamo quindi una funzione:
nix-repl> sum 20
«lambda @ «string»:1:5»
lambda è il nome con cui, nella programmazione funzionale, ci si riferisce alle funzioni. sum stessa è una “lambda”:
nix-repl> sum
«lambda @ «string»:1:2»
Quindi: sum è una funzione, ma anche il risultato della sua applicazione sum 20 è una funzione. Potremmo scrivere la stessa cosa in modi diversi:
nix-repl> sum 20 22
42
nix-repl> (sum 20) 22
42
nix-repl> (((sum) 20) 22)
42
nix-repl> let sum-twenty = sum 20; in sum-twenty 22
42
Nei primi 3 casi abbiamo solo esplicitato i valori e le applicazioni delle funzioni, mentre nell’ultimo caso abbiamo dato un nome alla funzione che altrimenti sarebbe rimasta anonima.
Il lettore attento avrà visto che non esiste una sintassi specifica per dare un nome alle funzioni di Nix: tutte le funzioni di Nix nascono “anonime”: nella sintassi che ho riportato prima parametro: espressione, non c’è un “nome funzione”. Questa funzione infatti nasce anonima, e siamo noi a decidere se vogliamo darle un nome.
Ad esempio, è perfettamente lecito non assegnare un nome alle funzioni e fare qualcosa tipo questo:
nix-repl> (x: 20 + x) 22
42
A questo punto, dovrebbe esservi abbastanza chiaro cos’è successo prima quando abbiamo usato import: esso non è un costrutto del linguaggio, come, diciamo, import in Python, ma è una funzione che carica un file e lo valuta, ritornando il valore dell’espressione in quel file:
# nel file test.nix
"Hello, World!"
nella stessa directory del file test.nix invochiamo nix repl:
nix-repl> import ./test.nix # attenzione al ./ davanti al nome del file
"Hello, World!"
Non è raro, in Nix, avere dei file che contengono una funzione e usare import per importare la funzione e valutarla sui parametri desiderati:
# nel file concatena.nix
s1: s2: s1 + s2
nix-repl> import ./concatena.nix "Foo" "Bar"
"FooBar"
nix-repl> (import ./concatena.nix) "Foo" "Bar"
"FooBar"
nix-repl> ((import ./concatena.nix) "Foo") "Bar"
"FooBar"
La funzione import ha importato il file concatena.nix, ritornando la funzione in esso contenuta; tale funzione è stata quindi invocata col parametro "Foo", ritornando una funzione anonima che è stata invocata con il parametro "Bar";, producendo quindi la stringa composta "FooBar".
Questo ci porta all’ultimo pattern: l’uso di dati più complessi come unico parametro della funzione, che apre anche un nuovo capitolo.
Tipi di dati composti: attrset
In Nix, non è affatto raro trovare dei file che assomigliano al seguente:
# somefile.nix
{ first, second, third }: {
foo = first + second;
bar = someFunction third;
}
Come si interpreta questo codice? La presenza di : dovrebbe far intuire che questo programma non è nient’altro che una funzione. Questa funzione prende un parametro { first, second, third } e ritorna un oggetto di qualche tipo { foo = ...; bar = ...; }.
Questi oggetti sono molto importanti in Nix e ricordano vagamente oggetti simili in altri linguaggi: strutture in C e C++, oggetti in JavaScript o anche oggetti JSON.
In Nix, questi oggetti hanno un nome diverso e un po’ altisonante: attribute set, abbreviato in attrset. Sono degli insiemi di attributi, gli attributi assumono sempre un valore ed, essendo degli insiemi, gli attributi sono unici e non si possono ripetere. L’ordine degli attributi è irrilevante. In generale, un attrset si definisce così (si notino = e ;):
{
attr-name = value;
attr-name2 = value;
# etc
}
I nomi degli attributi possono essere stringhe qualunque. Tipicamente si usano nomi separatiDaMaiuscole, oppure separati_da_underscore, oppure separati-da-dash, ma possono essere anche altre stringhe, se messi tra apici:
nix-repl> {
> name = "Alex";
> "date of birth" = "1970-01-01";
> current-country = "Italy";
> petName = "Axel";
> }
{
current-country = "Italy";
"date of birth" = "1970-01-01";
name = "Alex";
petName = "Axel";
}
Questo dovrebbe rendere chiaro il corpo della funzione che abbiamo visto in somefile.nix, l’argomento { first, second, third } è leggermente diverso. In questo caso, Nix supporta l’uso di “set patterns” come argomenti di una funzione. Ovvero, è possibile dare ad una funzione un argomento strutturato come un attrset, ma in cui ci sono solo i nomi: la funzione dovrà essere invocata con un attrset che ha precisamente quegli attributi.
Vediamo con un esempio questa sintassi. Vediamo come sia necessario specificare tutti, e solamente, gli argomenti indicati:
nix-repl> test = { foo, bar } : foo + bar # definiamo una funzione che prende un attrset con attributi foo e bar
nix-repl> test { foo = 1; bar = 2; } # possiamo invocarla passando un attrset
3
nix-repl> test { foo = 1; } # non si possono passare attrset incompleti
error:
… from call site
at «string»:1:1:
1| test { foo = 1 ; }
| ^
error: function 'anonymous lambda' called without required argument 'bar'
at «string»:1:2:
1| { foo, bar } : foo + bar
| ^
nix-repl> test { foo = 1; bar = 2; baz = 3; } # non si possono passare attrset con più attributi
error:
… from call site
at «string»:1:1:
1| test { foo = 1 ; bar = 2 ; baz = 3; }
| ^
error: function 'anonymous lambda' called with unexpected argument 'baz'
at «string»:1:2:
1| { foo, bar } : foo + bar
| ^
Did you mean bar?
Come si vede, Nix è severo per quanto riguarda il numero di parametri necessari. (L’osservatore attento avrà anche notato che non è specificato il tipo di dati, infatti Nix non è un linguaggio staticamente tipato.)
Però abbiamo due alternative relativamente al numero di argomenti:
- è possibile dare un valore di default ad un parametro, nel caso non fosse specificato, attraverso la sintassi
parametro ? valore; - è possibile ignorare i parametri aggiuntivi, nel caso venissero forniti, attraverso la sintassi
....
Ecco un esempio:
nix-repl> concat = { first, second ? "", ... }: first + second
nix-repl> concat { first = "foo"; }
"foo"
nix-repl> concat { first = "foo"; second = "bar"; }
"foobar"
nix-repl> concat { first = "foo"; second = "bar"; third = "baz"; }
"foobar"
Ecco quindi spiegato l’esempio precedente: il contenuto di somefile.nix è una funzione che prende esattamente 3 argomenti, first, second e third e ritorna un attrset con due attributi, foo e bar.
Tipi di dati composti: liste
Ci manca ora vedere un altro tipo di dato molto importante: le liste.
Le liste in Nix si definiscono tra parentesi quadre e senza separatori tra gli elementi. Gli elementi non devono avere tutti lo stesso tipo, possiamo quindi mischiare interi, stringhe, attrset e liste senza problemi:
```
nix-repl> [ 1 2 3 4 ]
[
1
2
3
4
]
nix-repl> [ "one" "two" 3 ]
[
"one"
"two"
3
]
nix-repl> [ "one" [ "two" 2 ] { three = 3; } ]
[
"one"
[ ... ]
{ ... }
]
```
Nell’ultimo esempio, la REPL di Nix non ci fa vedere il contenuto dei dati dalla radice alle foglie. Questo non è un difetto, ma è una particolarità del linguaggio Nix che è un linguaggio “pigro” (lazy). Ovvero, evita di valutare un’espressione se può evitare di farlo.
Questa particolarità ha pregi e difetti, come tutte le scelte. Un pregio è, ad esempio, che possiamo lavorare con attrset molto, molto grandi (con decine di migliaia di elementi) in modo molto rapido; un difetto è, ad esempio, che non vediamo tutto il valore nella repl.
Per poterlo vedere, nella repl, possiamo usare il comando :p o :print, seguito dall’espressione:
nix-repl> :p [ "one" ["two" 2] { three = 3; } ]
[
"one"
[
"two"
2
]
{ three = 3; }
]
Si provi a digitare :? nella repl per vedere altri comandi utili.
Tornando alle liste, qualche lettore arguto potrebbe chiedersi: ma se l’applicazione di una funzione è fatta facendo seguire un argomento ad una funzione (con uno spazio in mezzo), e la sequenza di valori di una lista è separata da spazi, cosa succede se volessimo mettere una funzione in una lista? La risposta è che dobbiamo usare le parentesi tonde:
nix-repl> double = x: x + x
nix-repl> [ double double 21 (double 21) ] # ovvero: [(double) (double) (21) (double 21)]
[
«lambda @ «string»:1:2» # double
«lambda @ «string»:1:2» # double
21 # 21
42 # double 21
]
Una peculiarità del linguaggio, che emerge talvola lavorando con le liste, è come Nix gestisce i numeri negativi: essi esistono, ma sono in realtà calcolati sottraendo un numero da zero. Questo porta ad alcuni comportamenti bizzarri come il seguente:
nix-repl> -42 # sembra Ok...
-42
nix-repl> [ -42 ] # dentro una lista, qualcosa rivela che c'è un problema!
error: syntax error, unexpected '-'
at «string»:1:3:
1| [ -42 ]
| ^
nix-repl> [ 10-42 ] # sottrarre numeri dentro una lista, non si può fare!
error: syntax error, unexpected '-'
at «string»:1:5:
1| [ 10-42 ]
| ^
nix-repl> [ (-42) ] # dobbiamo mettere delle parentesi
[ -42 ]
A onor del vero, anche i numeri positivi non possono iniziare con un segno +, che è riservato alle addizioni:
nix-repl> 12 # numero positivo
12
nix-repl> +12 # niente segno positivo
error: syntax error, unexpected '+'
at «string»:1:1:
1| +12
| ^
nix-repl> (+12) # nemmeno tra parentesi!
error: syntax error, unexpected '+'
at «string»:1:2:
1| (+12)
| ^
nix-repl> [ +12 ] # nemmeno nelle liste!
error: syntax error, unexpected '+'
at «string»:1:3:
1| [ +12 ]
| ^
nix-repl> [ (+12) ]
error: syntax error, unexpected '+'
at «string»:1:4:
1| [ (+12) ]
| ^
Questa deviazione da altri linguaggi, e l’asimmetria tra numeri positivi e negativi, può essere sorprendente e talvolta serve prestare attenzione.
Cicli e ricorsione
Abbiamo ora molti elementi utili per lavorare su cose più serie: sappiamo che Nix è un linguaggio funzionale, in cui ci sono numeri interi (spoiler: ci sono anche i float), stringhe, funzioni, liste e attrset. Abbiamo visto la sintassi per definire una funzione, per creare dei bindings usando let, la possibilità di creare chiusure di funzioni, currying e di usare un attrset con pattern matching per passare più argomenti alla volta.
Abbiamo anche visto il costrutto if ... then ..., e chi verrà da altri linguaggi C-like, potrebbe quindi chiedersi: come faccio un ciclo? Come processo tutti gli elementi di un attrset o di una lista?
Ebbene, in Nix non ci sono costruitti per fare cicli, come for e while. Anzi, in diversi linguaggi funzionali non esistono costrutti simili.
Questo compito è delegato alle funzioni, attraverso la ricorsione: una funzione che invoca se stessa ci fornisce un elemento tramite il quale possiamo eseguire lo stesso codice più volte.
Vediamo quindi un esempio di ricorsione in Nix, implementiamo una funzione che calcola la somma 1 + 2 + 3 + ... + n, per un parametro n. La scriviamo in un file, triangular.nix:
# triangular.nix
let
triangular = n:
if n > 0
then n + triangular (n - 1)
else 0;
in
triangular
(Si noti, innanzi tutto, come abbiamo dovuto dare un nome alla funzione usando un let, altrimenti non avremmo potuto chiamarla dentro se stessa.)
e poi la importiamo:
nix-repl> tri = import ./triangular.nix
nix-repl> tri 4
10
nix-repl> tri 5
15
nix-repl> tri (-1)
0
Se volessimo capire cosa fa questa funzione, potremmo semplicemente espandere l’espressione:
= triangular 4
# ora "espandiamo" la funzione, applicando tutti i parametri
# espandiamo triangular 4
= if 4 > 0 then 4 + triangular 3 else 0
# espandiamo triangular 3
= if 4 > 0 then 4 + (if 3 > 0 then 3 + triangular 2 else 0) else 0
# espandiamo triangular 2
= if 4 > 0 then 4 + (if 3 > 0 then 3 + (if 2 > 0 then 2 + triangular 1 else 0) else 0) else 0
# espandiamo triangular 1
= if 4 > 0 then 4 + (if 3 > 0 then 3 + (if 2 > 0 then 2 + (if 1 > 0 then 1 + triangular 0 else 0) else 0) else 0) else 0
# espandiamo triangular 0
= if 4 > 0 then 4 + (if 3 > 0 then 3 + (if 2 > 0 then 2 + (if 1 > 0 then 1 + (if 0 > 0 then 0 + triangular (-1) else 0) else 0) else 0) else 0) else 0
# ora "comprimiamo" la funzione, calcolando i valori
# calcoliamo 0 > 0 -> false
= if 4 > 0 then 4 + (if 3 > 0 then 3 + (if 2 > 0 then 2 + (if 1 > 0 then 1 + (if false then 0 + triangular (-1) else 0) else 0) else 0) else 0) else 0
# calcoliamo if false then ... else 0 -> 0
= if 4 > 0 then 4 + (if 3 > 0 then 3 + (if 2 > 0 then 2 + (if 1 > 0 then 1 + (0) else 0) else 0) else 0) else 0
# calcoliamo 1 > 0 -> true
= if 4 > 0 then 4 + (if 3 > 0 then 3 + (if 2 > 0 then 2 + (if true then 1 + (0) else 0) else 0) else 0) else 0
# calcoliamo if true then 1 + (0) else ... -> 1
= if 4 > 0 then 4 + (if 3 > 0 then 3 + (if 2 > 0 then 2 + (1) else 0) else 0) else 0
# calcoliamo 2 > 0 -> true
= if 4 > 0 then 4 + (if 3 > 0 then 3 + (if true then 2 + (1) else 0) else 0) else 0
# calcoliamo if true then 2 + (1) else ... -> 3
= if 4 > 0 then 4 + (if 3 > 0 then 3 + (3) else 0) else 0
# calcoliamo 3 > 0 -> true
= if 4 > 0 then 4 + (if true then 3 + (3) else 0) else 0
# calcoliamo if true then 3 + 3 else ... -> 6
= if 4 > 0 then 4 + (6) else 0
# calcoliamo 4 > 0 -> true
= if true then 4 + (6) else 0
# calcoliamo if true then 4 + 6 else ... -> 10
= 10
E questo è, con ragionevole approssimazione, quello che accade quando viene valutata la funzione in modo ricorsivo. Questo processo è usato in molti linguaggi funzionali ed è un sistema così comune ed usato che gli interpreti e i compilatori di linguaggi funzionali sono altamente ottimizzati per eseguire questo tipo di operazioni.
Tipicamente, però, non è necessario scrivere delle funzioni ricorsive.
Questo perché è molto comune avere alcune funzioni “primitive”, come map, filter e fold (o reduce), che ci permettono di lavorare con sequenze di dati. Vediamole brevemente.
map è molto comune in molti linguaggi: essa ci permette di prendere una sequenza e trasformare ogni valore della sequenza mediante una funzione, “mappando” i valori della sequenza di partenza con quelli risultati dall’applicazione della funzione. In Nix, nelle funzioni fornite col linguaggio (built-ins), è fornita map f list che prende una funzione f e una lista e applica f su ogni elemento della lista.
Ad esempio:
nix-repl> double = x: x + x
nix-repl> map double [1 2 3]
[
2
4
6
]
Un’altra funzione molto utile è filter f list, che filtra gli elementi di una lista in base al risultato di una funzione: se la funzione f ritorna true per un certo elemento della lista, allora quell’elemento non viene scartato.
Ad esempio:
nix-repl> is-negative = x: x < 0
nix-repl> builtins.filter is-negative [1 (-1) 2 (-2) 3 (-3)]
[
-1
-2
-3
]
L’ultima funzione importante è fold, che viene usata per ridurre una sequenza ad un singolo valore. Dato che esistono vari modi per ridurre una sequenza in un singolo valore, esistono varie versioni di questa funzione.
In builtins, Nix ci mette a disposizione foldl' op nul list, che usa una funzione op (binaria, ovvero che prende 2 argomenti) per comprimere la lista in un singolo valore, partendo dall’elemento di sinistra (l’inizio) della lista. I 3 argomenti, in particolare, sono i seguenti:
op, una funzione che prende 2 argomenti, un accumulatore e il valore corrente, e produce un nuovo valore che sarà usato come accumulatore per il prossimo elemento della lista;nulil valore iniziale dell’accumulatore, assegnato prima della prima invocazione diop;- la lista su cui iterare.
Se dovessimo dirlo in pseudo-codice (Python), foldl sarebbe più o meno così:
def foldl(op, nul, list):
# inizializza l'accumulatore con nul
acc = nul
# scorri tutta la lista, da sinistra a destra
for elem in list:
# applica la funzione passando l'accumulatore e l'elemento
# poi aggiorna l'accumulatore col risultato
acc = op(acc, elem)
# ritorna il valore accumulato
return acc
Vediamo un esempio pratico:
nix-repl> sum = acc: elem: acc + elem
nix-repl> builtins.foldl' sum 0 [ 1 2 3 4 ]
10
Quello che avviene è sostanzialmente questo:
foldl' sum 0 [ 1 2 3 4 ] =
# acc = 0
= foldl' sum 1 [ 2 3 4 ]
# acc = sum 0 1 = 1
= foldl' sum 3 [ 3 4 ]
# acc = sum acc 2 = sum 1 2 = 3
= foldl' sum 6 [ 4 ]
# acc = sum acc 3 = sum 3 3 = 6
= foldl' sum 10 [ ]
# acc = sum acc 4 = sum 6 4 = 10
= 10
map, filter e foldl' ci permettono di calcolare sostanzialmente ciò che vogliamo a partire da delle liste. Le liste possono essere create scrivendole letteralmente, componendole a partire da altre liste usando l’operatore ++ o usando funzioni come builtins.genList generator length:
nix-repl> [1 2 3 4]
[ 1 2 3 4 ]
nix-repl> [1 2] ++ [3 4]
[ 1 2 3 4 ]
nix-repl> builtins.genList (x: x+1) 4
[ 1 2 3 4 ]
Operazioni su attrset
Abbiamo parlato di map, filter e foldl' su liste, ma come facciamo a processare attrset?
Ebbene, non solo Nix mette a disposizione delle funzioni di utilità per lavorare in modo simile su attrset, come ad esempio builtins.mapAttrs, ma anche due primitive molto più semplici che ci permettono di passare da lista ad attrset e vice-versa:
nix-repl> builtins.listToAttrs [ { name = "Foo"; value = "Bar"; } { name = "Baz"; value = 123; } ]
{
Baz = 123;
Foo = "Bar";
}
nix-repl> builtins.attrNames { Foo = "Bar"; Baz = 123; }
[
"Baz"
"Foo"
]
nix-repl> builtins.attrValues { Foo = "Bar"; Baz = 123; }
[
123
"Bar"
]
Gli attributi sono ritornati in ordine alfabetico, mentre i valori sono ritornati nell’ordine corrispondente ai loro attributi.
Conclusione e riferimenti
E con questo, abbiamo concluso il nostro tour di Nix come linguaggio funzionale!
Ci sono altri aspetti importanti legati al linguaggio Nix che abbiamo trascurato (e.g. non abbiamo approfondito le implicazioni del fatto che Nix è un linguaggio “lazy”), non abbiamo analizzato molte delle funzioni utili (ma rimandiamo a [noogle] per cercare le funzioni a disposizione) che rendono sicuramente la vita più semplice e non abbiamo discusso di tutti gli aspetti legati alla gestione di pacchetti.
L’obiettivo era tuttavia di introdurre i concetti principali di programmazione funzionale tramite un linguaggio semplice e limitato, utile per un caso d’uso molto specifico e di immediata utilità.
Ora non serve altro che fare pratica. Il lettore interessato può provare ad implementare funzioni di uso comune o semplici esercizi, ecco alcune opzioni:
- la funzione
enumerate()di Python, che data una lista, ritorna una lista di coppie indice-valore, e.g.enumerate(["foo", "bar"]) == [(0, "foo"), (1, "bar")]; - la funzione
zipdi Python, che date due liste, ritorna una lista che mette assieme i valori della prima e della seconda, e.g.zip([1, 2, 3], ["a", "b", "c"]) == [(1, "a"), (2, "b"), (3, "c")]; - calcolo del fattoriale in modo ricorsivo;
- calcolo della sequenza di fibonacci in modo ricorsivo;
- il classico gioco fizz-buzz per N parametrico.
Per chi si volesse cimentare con il linguaggio Nix, ricordando che comunque non è stato progettato come linguaggio general-purpose, ma come linguaggio per un package manager, consigliamo:
- la documentazione dei builtins;
- la documentazione di riferimento sul linguaggio Nix;
- le stranezze del linguaggio Nix sulla wiki ufficiale di NixOS;
- guida al linguaggio Nix sulla documentazione ugguale di Nix
- per chi volesse cimentarsi con l’uso di Nix per la gestione dei pacchetti e delle configurazioni, le nix pills sono un’ottima risorsa.
