Il titolo di questo articolo riflette una domanda che sento continuamente nei forum o nelle e-mail che ricevo.
Credo che tutti gli sviluppatori curiosi se la siano posta almeno una volta. È normale essere affascinati da come funzionano i linguaggi di programmazione. Sfortunatamente, la maggior parte delle risposte che leggiamo sono molto accademiche o teoriche. Alcune altre contengono troppi dettagli di implementazione. Dopo averle lette ci chiediamo ancora come funzionano le cose in pratica.
Quindi risponderemo. Sì, vedremo qual è il processo per creare il proprio linguaggio completo con un compilatore per esso e cosa no.
La panoramica
La maggior parte delle persone che vogliono imparare come “creare un linguaggio di programmazione” stanno effettivamente cercando informazioni su come costruire un compilatore. Vogliono capire la meccanica che permette di eseguire un nuovo linguaggio di programmazione.
Un compilatore è un pezzo fondamentale del puzzle, ma fare un nuovo linguaggio di programmazione richiede più di questo:
1) Un linguaggio deve essere progettato: il creatore del linguaggio deve prendere alcune decisioni fondamentali sui paradigmi da usare e sulla sintassi del linguaggio
2) Deve essere creato un compilatore
3) Deve essere implementata una libreria standard
4) Devono essere forniti strumenti di supporto come editor e sistemi di compilazione
Vediamo più in dettaglio cosa comporta ognuno di questi punti.
Progettare un linguaggio di programmazione
Se vuoi solo scrivere il tuo compilatore per imparare come funzionano queste cose, puoi saltare questa fase. Potete semplicemente prendere un sottoinsieme di un linguaggio esistente o inventare una semplice variazione di esso e iniziare. Tuttavia, se hai intenzione di creare il tuo linguaggio di programmazione, dovrai pensarci un po’ su.
Penso che la progettazione di un linguaggio di programmazione sia divisa in due fasi:
- La fase del quadro generale
- La fase del perfezionamento
Nella prima fase rispondiamo alle domande fondamentali sul nostro linguaggio.
- Quale paradigma di esecuzione vogliamo usare? Sarà imperativo o funzionale? O forse basato su macchine a stati o regole di business?
- Vogliamo una tipizzazione statica o dinamica?
- Con quale tipo di programmi questo linguaggio sarà migliore? Sarà usato per piccoli script o per grandi sistemi?
- Cosa conta di più per noi: prestazioni? Leggibilità?
- Vogliamo che sia simile a un linguaggio di programmazione esistente? Sarà rivolto agli sviluppatori C o facile da imparare per chi viene da Python?
- Vogliamo che funzioni su una piattaforma specifica (JVM, CLR)?
- Quale tipo di capacità di metaprogrammazione vogliamo supportare, se esiste? Macro? Modelli? Riflessione?
Nella seconda fase continueremo ad evolvere il linguaggio mentre lo usiamo. Ci imbatteremo in problemi, in cose che sono molto difficili o impossibili da esprimere nel nostro linguaggio e finiremo per evolverlo. La seconda fase potrebbe non essere così affascinante come la prima, ma è la fase in cui continuiamo a mettere a punto il nostro linguaggio per renderlo utilizzabile nella pratica, quindi non dovremmo sottovalutarla.
Costruire un compilatore
Costruire un compilatore è il passo più emozionante nella creazione di un linguaggio di programmazione. Una volta che abbiamo un compilatore possiamo effettivamente dare vita al nostro linguaggio. Un compilatore ci permette di iniziare a giocare con il linguaggio, usarlo e identificare ciò che ci manca nel progetto iniziale. Permette di vedere i primi risultati. È difficile battere la gioia di eseguire il primo programma scritto nel nostro nuovo linguaggio di programmazione, non importa quanto semplice possa essere quel programma.
Ma come costruiamo un compilatore?
Come ogni cosa complessa, lo facciamo per gradi:
- Costruiamo un parser: il parser è la parte del nostro compilatore che prende il testo dei nostri programmi e capisce quali comandi esprimono. Riconosce le espressioni, le dichiarazioni, le classi e crea strutture dati interne per rappresentarle. Il resto del parser lavorerà con queste strutture di dati, non con il testo originale
- (opzionale) Traduciamo l’albero di parsing in un Abstract Syntax Tree. Di solito le strutture di dati prodotte dal parser sono un po’ di basso livello perché contengono molti dettagli che non sono cruciali per il nostro compilatore. Per questo motivo vogliamo spesso riorganizzare le strutture dati in qualcosa di livello leggermente più alto
- Risolviamo i simboli. Nel codice scriviamo cose come
a + 1
. Il nostro compilatore deve capire a cosa si riferiscea
. È un campo? È una variabile? È un parametro di un metodo? Esaminiamo il codice per rispondere a questo - Convalidiamo l’albero. Dobbiamo controllare che il programmatore non abbia commesso errori. Sta cercando di sommare un booleano e un int? O di accedere ad un campo inesistente? Dobbiamo produrre messaggi di errore appropriati
- Generiamo il codice macchina. A questo punto traduciamo il codice in qualcosa che la macchina può eseguire. Potrebbe essere codice macchina corretto o bytecode per qualche macchina virtuale
- (opzionale) Eseguiamo il collegamento. In alcuni casi abbiamo bisogno di combinare il codice macchina prodotto per i nostri programmi con il codice delle librerie statiche che vogliamo includere, al fine di generare un unico eseguibile
Abbiamo sempre bisogno di un compilatore? No. Possiamo sostituirlo con altri mezzi per eseguire il codice:
- Possiamo scrivere un interprete: un interprete è sostanzialmente un programma che fa i passi 1-4 di un compilatore e poi esegue direttamente ciò che è specificato dall’Abstract Syntax Tree
- Possiamo scrivere un transpiler: un transpiler farà ciò che è specificato nei passi 1-4 e poi produrrà del codice in qualche linguaggio per il quale abbiamo già un compilatore (per esempio C++ o Java)
Queste due alternative sono perfettamente valide e spesso ha senso scegliere una di queste due perché lo sforzo richiesto è tipicamente minore.
Abbiamo scritto un articolo che spiega come scrivere un transpiler. Dategli un’occhiata se volete vedere un esempio pratico, con del codice.
In questo articolo spieghiamo più in dettaglio la differenza tra un compilatore e un interprete.
Una libreria standard per il tuo linguaggio di programmazione
Ogni linguaggio di programmazione ha bisogno di fare alcune cose:
- Stampare sullo schermo
- Accedere al filesystem
- Utilizzare connessioni di rete
- Creare GUI
Sono le funzionalità di base per interagire con il resto del sistema. Senza di esse un linguaggio è fondamentalmente inutile. Come facciamo a fornire queste funzionalità? Creando una libreria standard. Questo sarà un insieme di funzioni o classi che possono essere chiamate nei programmi scritti nel nostro linguaggio di programmazione ma che saranno scritti in qualche altro linguaggio. Per esempio, molti linguaggi hanno librerie standard scritte almeno parzialmente in C.
Una libreria standard può poi contenere molto di più. Per esempio classi per rappresentare le collezioni principali come liste e mappe, o per elaborare formati comuni come JSON o XML. Spesso conterrà funzionalità avanzate per elaborare stringhe ed espressioni regolari.
In altre parole, scrivere una libreria standard è un sacco di lavoro. Non è affascinante, non è concettualmente interessante come scrivere un compilatore, ma è comunque un componente fondamentale per rendere fattibile un linguaggio di programmazione.
Ci sono modi per evitare questo requisito. Uno è quello di far funzionare il linguaggio su qualche piattaforma e rendere possibile il riutilizzo della libreria standard di un altro linguaggio. Per esempio, tutti i linguaggi che girano sulla JVM possono semplicemente riutilizzare la libreria standard di Java.
Strumenti di supporto per un nuovo linguaggio di programmazione
Per rendere un linguaggio utilizzabile in pratica abbiamo spesso bisogno di scrivere alcuni strumenti di supporto.
Il più ovvio è un editor. Un editor specializzato con evidenziazione della sintassi, controllo degli errori in linea e completamento automatico è oggi un must per rendere produttivo qualsiasi sviluppatore.
Ma oggi gli sviluppatori sono viziati e si aspettano ogni sorta di altri strumenti di supporto. Per esempio, un debugger potrebbe essere davvero utile per affrontare un brutto bug. Oppure un sistema di compilazione simile a maven o gradle potrebbe essere qualcosa che gli utenti chiederanno in seguito.
All’inizio un editor potrebbe essere sufficiente, ma man mano che la vostra base di utenti cresce anche la complessità dei progetti crescerà e saranno necessari più strumenti di supporto. Speriamo che a quel punto ci sia una comunità disposta ad aiutare a costruirli.
Sommario
Creare un linguaggio di programmazione è un processo che sembra misterioso a molti sviluppatori. In questo articolo abbiamo cercato di mostrare che è solo un processo. È affascinante e non facile, ma può essere fatto.
Potreste voler costruire un linguaggio di programmazione per una varietà di ragioni. Una buona ragione è per divertimento, un’altra è per imparare come funzionano i compilatori. Il vostro linguaggio potrebbe finire per essere molto utile o no, a seconda di molti fattori. Comunque se ti diverti e/o impari mentre lo costruisci allora vale la pena investire del tempo su questo.
E naturalmente potrai vantarti con i tuoi colleghi sviluppatori.
Se vuoi saperne di più sulla creazione di un linguaggio dai un’occhiata alle altre risorse che abbiamo creato: imparare a costruire linguaggi.
Potresti anche essere interessato ad alcuni dei nostri articoli: