Ho scritto un linguaggio di programmazione. Ecco come puoi farlo anche tu.

Negli ultimi 6 mesi, ho lavorato su un linguaggio di programmazione chiamato Pinecone. Non lo definirei ancora maturo, ma ha già abbastanza funzioni che funzionano per essere utilizzabili, come:

  • variabili
  • funzioni
  • strutture definite dall'utente

Se ti interessa, controlla la landing page di Pinecone o il suo repository GitHub.

Non sono un esperto. Quando ho iniziato questo progetto, non avevo idea di cosa stavo facendo, e ancora non lo so. Ho seguito zero lezioni sulla creazione della lingua, ne ho letto solo un po 'online e non ho seguito molti dei consigli che mi sono stati dati.

Eppure, ho ancora fatto una lingua completamente nuova. E funziona Quindi devo fare qualcosa di giusto.

In questo post, mi tufferò sotto il cofano e ti mostrerò la pipeline che Pinecone (e altri linguaggi di programmazione) usano per trasformare il codice sorgente in magia.

Toccherò anche alcuni dei compromessi che ho avuto e il motivo per cui ho preso le decisioni che ho preso.

Questo non è affatto un tutorial completo sulla scrittura di un linguaggio di programmazione, ma è un buon punto di partenza se sei curioso di sviluppare il linguaggio.

Iniziare

"Non ho assolutamente idea di dove possa iniziare" è qualcosa che sento molto quando dico ad altri sviluppatori che sto scrivendo una lingua. Nel caso in cui questa sia la tua reazione, ora esaminerò alcune decisioni iniziali che verranno prese e le misure che verranno prese all'avvio di una nuova lingua.

Compilato vs interpretato

Esistono due tipi principali di lingue: compilate e interpretate:

  • Un compilatore scopre tutto ciò che farà un programma, lo trasforma in "codice macchina" (un formato che il computer può eseguire molto velocemente), quindi lo salva da eseguire in seguito.
  • Un interprete passa attraverso il codice sorgente riga per riga, scoprendo cosa sta facendo mentre procede.

Tecnicamente qualsiasi lingua potrebbe essere compilata o interpretata, ma l'una o l'altra di solito ha più senso per una lingua specifica. In generale, l'interpretazione tende ad essere più flessibile, mentre la compilazione tende ad avere prestazioni più elevate. Ma questo sta solo graffiando la superficie di un argomento molto complesso.

Apprezzo molto le prestazioni e ho visto la mancanza di linguaggi di programmazione che sono sia ad alte prestazioni che orientati alla semplicità, quindi sono andato con compilato per Pinecone.

Questa è stata una decisione importante da prendere all'inizio, perché molte decisioni di progettazione del linguaggio ne sono influenzate (ad esempio, la tipizzazione statica è un grande vantaggio per i linguaggi compilati, ma non tanto per quelli interpretati).

Nonostante il fatto che Pinecone sia stato progettato pensando alla compilazione, ha un interprete completamente funzionale che è stato l'unico modo per eseguirlo per un po '. Ci sono diverse ragioni per questo, che spiegherò più avanti.

Scegliere una lingua

So che è un po 'meta, ma un linguaggio di programmazione è esso stesso un programma e quindi è necessario scriverlo in una lingua. Ho scelto C ++ per le sue prestazioni e il suo ampio set di funzionalità. Inoltre, mi piace davvero lavorare in C ++.

Se stai scrivendo un linguaggio interpretato, ha molto senso scriverlo in un linguaggio compilato (come C, C ++ o Swift) perché le prestazioni perse nella lingua del tuo interprete e dell'interprete che sta interpretando il tuo interprete aumenteranno.

Se hai intenzione di compilare, un linguaggio più lento (come Python o JavaScript) è più accettabile. Il tempo di compilazione può essere negativo, ma a mio avviso non è un grosso problema quanto un brutto tempo di esecuzione.

Design di alto livello

Un linguaggio di programmazione è generalmente strutturato come una pipeline. Cioè, ha diverse fasi. Ogni fase ha i dati formattati in un modo specifico e ben definito. Ha anche funzioni per trasformare i dati da ogni fase alla successiva.

Il primo stadio è una stringa contenente l'intero file sorgente di input. La fase finale è qualcosa che può essere eseguito. Tutto ciò diventerà chiaro mentre attraversiamo la pipeline di Pinecone passo dopo passo.

Lexing

Il primo passo nella maggior parte dei linguaggi di programmazione è il lessismo o tokenizzazione. "Lex" è l'abbreviazione di analisi lessicale, una parola molto elaborata per dividere un mucchio di testo in token. La parola "tokenizer" ha molto più senso, ma "lexer" è molto divertente dire che lo uso comunque.

gettoni

Un token è una piccola unità di una lingua. Un token può essere una variabile o un nome di funzione (AKA un identificatore), un operatore o un numero.

Compito della Lexer

Il lexer dovrebbe prendere una stringa contenente un intero file di codice sorgente e sputare un elenco contenente ogni token.

Le fasi future della pipeline non faranno riferimento al codice sorgente originale, quindi il lexer deve produrre tutte le informazioni necessarie. La ragione di questo formato di pipeline relativamente rigoroso è che il lexer può svolgere attività come rimuovere commenti o rilevare se qualcosa è un numero o identificatore. Vuoi mantenere la logica bloccata all'interno del lexer, entrambi in modo da non dover pensare a queste regole quando scrivi il resto della lingua, e quindi puoi cambiare questo tipo di sintassi in un unico posto.

Flettere

Il giorno in cui ho iniziato la lingua, la prima cosa che ho scritto è stato un semplice lexer. Poco dopo, ho iniziato a conoscere strumenti che presumibilmente avrebbero reso il lessing più semplice e meno buggy.

Lo strumento predominante è Flex, un programma che genera i lexer. Gli dai un file che ha una sintassi speciale per descrivere la grammatica della lingua. Da ciò genera un programma C che allenta una stringa e produce l'output desiderato.

La mia decisione

Ho scelto di mantenere il lexer che ho scritto per il momento. Alla fine, non ho visto benefici significativi dell'uso di Flex, almeno non abbastanza per giustificare l'aggiunta di una dipendenza e complicare il processo di compilazione.

Il mio lexer è lungo solo poche centinaia di righe e raramente mi dà problemi. Il rolling del mio lexer mi offre anche una maggiore flessibilità, come la possibilità di aggiungere un operatore alla lingua senza modificare più file.

parsing

Il secondo stadio della pipeline è il parser. Il parser trasforma un elenco di token in un albero di nodi. Un albero utilizzato per archiviare questo tipo di dati è noto come albero di sintassi astratto o AST. Almeno in Pinecone, l'AST non ha informazioni sui tipi o sugli identificatori quali. È semplicemente token strutturati.

Doveri parsimoniosi

Il parser aggiunge struttura all'elenco ordinato di token che il lexer produce. Per interrompere le ambiguità, il parser deve tenere conto della parentesi e dell'ordine delle operazioni. Semplicemente analizzare gli operatori non è tremendamente difficile, ma man mano che vengono aggiunti più costrutti linguistici, l'analisi può diventare molto complessa.

Bisonte

Ancora una volta, c'è stata una decisione da prendere coinvolgendo una biblioteca di terze parti. La libreria di analisi predominante è Bison. Bison funziona in modo molto simile a Flex. Scrivi un file in un formato personalizzato che memorizza le informazioni grammaticali, quindi Bison lo utilizza per generare un programma C che eseguirà l'analisi. Non ho scelto di usare Bison.

Perché l'abitudine è migliore

Con il lexer, la decisione di usare il mio codice era abbastanza ovvia. Un lexer è un programma così banale che non scrivere il mio è sembrato quasi sciocco come non scrivere il mio "pad sinistro".

Con il parser, è una questione diversa. Il mio parser Pigna è attualmente lungo 750 righe e ne ho scritte tre perché le prime due erano spazzatura.

Inizialmente ho preso la mia decisione per una serie di motivi e, sebbene non sia andata completamente bene, la maggior parte di essi è vera. I principali sono i seguenti:

  • Riduci al minimo il cambio di contesto nel flusso di lavoro: il cambio di contesto tra C ++ e Pinecone è abbastanza grave senza gettare nella grammatica di Bison
  • Semplifica la compilazione: ogni volta che cambia la grammatica Bison deve essere eseguito prima della compilazione. Questo può essere automatizzato ma diventa una seccatura quando si passa da un sistema di build all'altro.
  • Mi piace costruire cose interessanti: non ho creato Pinecone perché pensavo che sarebbe stato facile, quindi perché dovrei delegare un ruolo centrale quando potrei farlo da solo? Un parser personalizzato potrebbe non essere banale, ma è completamente fattibile.

All'inizio non ero del tutto sicuro se stavo percorrendo un percorso praticabile, ma mi è stata data fiducia da ciò che Walter Bright (uno sviluppatore di una prima versione di C ++ e il creatore del linguaggio D) aveva da dire sul argomento:

"Un po 'più controverso, non mi preoccuperei di perdere tempo con i generatori di lexer o parser e altri cosiddetti" compilatori di compilatori ". Sono una perdita di tempo. Scrivere un lexer e un parser è una piccola percentuale del lavoro di scrittura di un compilatore. L'uso di un generatore richiederà circa il tempo necessario per scriverne uno a mano e ti sposerà con il generatore (che conta quando si esegue il porting del compilatore su una nuova piattaforma). E i generatori hanno anche la sfortunata reputazione di emettere messaggi di errore scadenti. "

Albero d'azione

Ora abbiamo lasciato l'area dei termini comuni e universali, o almeno non so più quali siano i termini. Secondo la mia comprensione, ciò che chiamo "albero d'azione" è molto simile alla IR (rappresentazione intermedia) di LLVM.

Esiste una differenza sottile ma molto significativa tra l'albero di azione e l'albero di sintassi astratto. Mi ci è voluto un po 'di tempo per capire che dovrebbe esserci anche una differenza tra loro (che ha contribuito alla necessità di riscrivere il parser).

Albero delle azioni vs AST

In parole povere, l'albero delle azioni è l'AST con il contesto. Tale contesto è costituito da informazioni come il tipo restituito da una funzione o che due posizioni in cui viene utilizzata una variabile utilizzano effettivamente la stessa variabile. Poiché deve capire e ricordare tutto questo contesto, il codice che genera l'albero delle azioni richiede molte tabelle di ricerca dello spazio dei nomi e altre cose simili.

Esecuzione dell'albero delle azioni

Una volta che abbiamo l'albero delle azioni, eseguire il codice è facile. Ogni nodo di azione ha una funzione "Esegui" che accetta alcuni input, fa tutto ciò che l'azione dovrebbe (incluso possibilmente chiamando un'azione secondaria) e restituisce l'output dell'azione. Questo è l'interprete in azione.

Opzioni di compilazione

"Ma aspetta!" Ti sento dire, "non si suppone che Pinecone sia compilato?" Sì, lo è. Ma compilare è più difficile dell'interpretazione. Esistono alcuni approcci possibili.

Crea il mio compilatore

All'inizio mi è sembrata una buona idea. Adoro fare le cose da solo, e ho sempre desiderato una scusa per diventare bravo in assemblea.

Sfortunatamente, scrivere un compilatore portatile non è facile come scrivere un codice macchina per ogni elemento del linguaggio. A causa del numero di architetture e sistemi operativi, non è pratico per qualsiasi individuo scrivere un back-end del compilatore multipiattaforma.

Persino le squadre dietro Swift, Rust e Clang non vogliono preoccuparsene da sole, quindi usano tutte ...

LLVM

LLVM è una raccolta di strumenti di compilazione. È fondamentalmente una libreria che trasformerà la tua lingua in un binario eseguibile compilato. Mi è sembrata la scelta perfetta, quindi sono saltato dentro. Purtroppo non ho verificato quanto fosse profonda l'acqua e sono subito annegato.

LLVM, sebbene non sia un linguaggio di assemblaggio difficile, è una gigantesca libreria complessa difficile. Non è impossibile da usare e hanno buoni tutorial, ma mi sono reso conto che avrei dovuto fare un po 'di pratica prima di essere pronto a implementare completamente un compilatore Pinecone con esso.

Transpiling

Volevo una specie di Pinecone compilata e la volevo velocemente, quindi mi sono rivolto a un metodo che sapevo di poter fare il lavoro: traspilare.

Ho scritto un transpiler da Pinecone a C ++ e ho aggiunto la possibilità di compilare automaticamente l'origine di output con GCC. Questo funziona attualmente per quasi tutti i programmi Pinecone (anche se ci sono alcuni casi limite che lo rompono). Non è una soluzione particolarmente portatile o scalabile, ma per il momento funziona.

Futuro

Supponendo che continuo a sviluppare Pinecone, prima o poi otterrà il supporto per la compilazione di LLVM. Non ho alcun sospetto su quanto ci lavori, il transpiler non sarà mai completamente stabile e i vantaggi di LLVM sono numerosi. È solo questione di quando avrò il tempo di realizzare alcuni progetti di esempio in LLVM e prenderne il controllo.

Fino ad allora, l'interprete è ottimo per programmi banali e la traspilazione di C ++ funziona per la maggior parte delle cose che richiedono più prestazioni.

Conclusione

Spero di aver reso i linguaggi di programmazione un po 'meno misteriosi per te. Se vuoi crearne uno tu stesso, lo consiglio vivamente. Ci sono un sacco di dettagli di implementazione da capire, ma lo schema qui dovrebbe essere sufficiente per iniziare.

Ecco i miei consigli di alto livello per iniziare (ricorda, non so davvero cosa sto facendo, quindi prendilo con un po 'di sale):

  • In caso di dubbi, vai interpretato. Le lingue interpretate sono generalmente più facili da progettare, costruire e apprendere. Non ti scoraggio a scriverne uno compilato se sai che è quello che vuoi fare, ma se sei sul recinto, andrei interpretato.
  • Quando si tratta di lexer e parser, fai quello che vuoi. Ci sono argomenti validi a favore e contro la scrittura del tuo. Alla fine, se pensi al tuo progetto e realizzi tutto in modo sensato, non importa davvero.
  • Impara dalla pipeline con cui sono finito. Sono stati fatti molti tentativi ed errori nella progettazione della pipeline che ho ora. Ho tentato di eliminare le AST, le AST che si trasformano in alberi d'azione in atto e altre idee terribili. Questa pipeline funziona, quindi non cambiarla a meno che tu non abbia una buona idea.
  • Se non hai il tempo o la motivazione per implementare un linguaggio complesso per scopi generici, prova a implementare un linguaggio esoterico come Brainfuck. Questi interpreti possono avere una lunghezza di poche centinaia di righe.

Ho pochissimi rimpianti quando si tratta di sviluppo di Pinecone. Ho fatto una serie di scelte sbagliate lungo la strada, ma ho riscritto la maggior parte del codice interessato da tali errori.

In questo momento, Pinecone è in uno stato abbastanza buono da funzionare bene e può essere facilmente migliorato. Scrivere Pinecone è stata un'esperienza estremamente educativa e divertente per me, ed è solo all'inizio.