De titel van dit artikel geeft een vraag weer die ik steeds weer hoor op forums of in e-mails die ik ontvang.
Ik denk dat alle nieuwsgierige ontwikkelaars deze vraag wel eens hebben gesteld. Het is normaal om gefascineerd te zijn door hoe programmeertalen werken. Helaas zijn de meeste antwoorden die we lezen erg academisch of theoretisch. Sommige andere bevatten te veel implementatiedetails. Na het lezen vragen we ons nog steeds af hoe dingen in de praktijk werken.
Dus we gaan het beantwoorden. Ja, we zullen zien wat het proces is om je eigen volledige taal te maken met een compiler ervoor en wat niet.
Het overzicht
De meeste mensen die willen leren hoe je “een programmeertaal maakt” zijn in feite op zoek naar informatie over hoe je een compiler bouwt. Zij willen de mechanica begrijpen die het mogelijk maakt een nieuwe programmeertaal uit te voeren.
Een compiler is een fundamenteel stukje van de puzzel, maar het maken van een nieuwe programmeertaal vereist meer dan dat:
1) Een taal moet ontworpen worden: de maker van de taal moet een aantal fundamentele beslissingen nemen over de te gebruiken paradigma’s en de syntaxis van de taal
2) Er moet een compiler worden gemaakt
3) Er moet een standaardbibliotheek worden geïmplementeerd
4) Er moet worden voorzien in ondersteunende gereedschappen zoals editors en build-systemen
Laten we eens in meer detail bekijken wat elk van deze punten inhoudt.
Ontwerpen van een programmeertaal
Als je alleen je eigen compiler wilt schrijven om te leren hoe deze dingen werken, kun je deze fase overslaan. U kunt gewoon een subset van een bestaande taal nemen of er een eenvoudige variatie op verzinnen en aan de slag gaan. Als je echter plannen hebt om je eigen programmeertaal te maken, zul je er goed over na moeten denken.
Ik zie het ontwerpen van een programmeertaal als twee fasen:
- De grote-beelden-fase
- De verfijningsfase
In de eerste fase beantwoorden we de fundamentele vragen over onze taal.
- Welk executie-paradigma willen we gebruiken? Wordt het imperatief of functioneel? Of misschien gebaseerd op toestandsmachines of bedrijfsregels?
- Willen we statische typing of dynamische typing?
- Wat voor soort programma’s zal deze taal het best tot zijn recht komen? Wordt het gebruikt voor kleine scripts of voor grote systemen?
- Wat is voor ons het belangrijkst: performance? Leesbaarheid?
- Willen we dat het lijkt op een bestaande programmeertaal? Moet het gericht zijn op C-ontwikkelaars of gemakkelijk te leren voor wie uit Python komt?
- Willen we dat het op een specifiek platform werkt (JVM, CLR)?
- Welke metaprogrammeermogelijkheden willen we ondersteunen, als die er zijn? Macro’s? Sjablonen? Reflectie?
In de tweede fase zullen we de taal blijven ontwikkelen terwijl we hem gebruiken. We zullen op problemen stuiten, op dingen die moeilijk of onmogelijk in onze taal uit te drukken zijn en uiteindelijk zullen we de taal verder ontwikkelen. De tweede fase is misschien niet zo glamoureus als de eerste, maar het is de fase waarin we onze taal blijven tunen om hem bruikbaar te maken in de praktijk, dus we moeten hem niet onderschatten.
Het bouwen van een compiler
Het bouwen van een compiler is de meest opwindende stap in het maken van een programmeertaal. Als we eenmaal een compiler hebben, kunnen we onze taal tot leven wekken. Een compiler stelt ons in staat om met de taal te spelen, hem te gebruiken en te ontdekken wat we missen in het oorspronkelijke ontwerp. Het maakt het mogelijk om de eerste resultaten te zien. Het is moeilijk om de vreugde te overtreffen van het uitvoeren van het eerste programma geschreven in onze gloednieuwe programmeertaal, hoe eenvoudig dat programma ook mag zijn.
Maar hoe bouwen we een compiler?
Zoals alles wat complex is doen we dat in stappen:
- We bouwen een parser: de parser is het deel van onze compiler dat de tekst van onze programma’s neemt en begrijpt welke commando’s ze uitdrukken. Het herkent de uitdrukkingen, de verklaringen, de klassen en het creëert interne gegevensstructuren om ze weer te geven. De rest van de parser zal werken met die gegevensstructuren, niet met de oorspronkelijke tekst
- (optioneel) We vertalen de parse tree in een Abstracte Syntax Tree. Typisch zijn de gegevensstructuren die door de parser worden geproduceerd een beetje laag niveau, omdat ze veel details bevatten die niet cruciaal zijn voor onze compiler. Daarom willen we vaak de gegevensstructuren herschikken in iets van een iets hoger niveau
- We lossen symbolen op. In de code schrijven we dingen als
a + 1
. Onze compiler moet uitzoeken waara
naar verwijst. Is het een veld? Is het een variabele? Is het een methodeparameter? We onderzoeken de code om dat te beantwoorden - We valideren de boom. We moeten controleren of de programmeur geen fouten heeft gemaakt. Probeert hij een boolean en een int op te tellen? Of heeft hij toegang tot een niet bestaand veld? We moeten de juiste foutmeldingen produceren
- We genereren de machinecode. Op dit punt vertalen we de code in iets dat de machine kan uitvoeren. Dat kan de eigenlijke machinecode zijn of bytecode voor een virtuele machine
- (facultatief) We maken de koppeling. In sommige gevallen moeten we de machinecode voor onze programma’s combineren met de code van statische bibliotheken die we willen opnemen, om één enkel uitvoerbaar programma te genereren
Hebben we altijd een compiler nodig? Nee. We kunnen het vervangen door andere middelen om de code uit te voeren:
- We kunnen een interpreter schrijven: een interpreter is in wezen een programma dat de stappen 1-4 van een compiler doet en dan direct uitvoert wat gespecificeerd is door de Abstracte Syntaxisboom
- We kunnen een transpiler schrijven: een transpiler doet wat is gespecificeerd in stap 1-4 en voert dan code uit in een of andere taal waarvoor we al een compiler hebben (bijvoorbeeld C++ of Java)
Deze twee alternatieven zijn volkomen geldig en vaak is het zinvol om een van deze twee te kiezen omdat de vereiste inspanning meestal kleiner is.
We hebben een artikel geschreven waarin wordt uitgelegd hoe je een transpiler schrijft. Kijk er eens naar als je een praktisch voorbeeld wilt zien, met code.
In dit artikel leggen we in meer detail het verschil uit tussen een compiler en een interpreter.
Een standaard bibliotheek voor uw programmeertaal
Elke programmeertaal moet een paar dingen kunnen:
- Printen op het scherm
- Toegang tot het bestandssysteem
- Netwerkverbindingen gebruiken
- GUI’s maken
Dit zijn de basisfunctionaliteiten om met de rest van het systeem te interageren. Zonder deze is een taal in principe nutteloos. Hoe bieden we deze functionaliteiten? Door een standaard bibliotheek te maken. Dit zal een verzameling functies of klassen zijn die kunnen worden aangeroepen in de programma’s die in onze programmeertaal zijn geschreven, maar die in een andere taal zullen worden geschreven. Veel talen hebben bijvoorbeeld standaardbibliotheken die ten minste gedeeltelijk in C zijn geschreven.
Een standaardbibliotheek kan dan veel meer bevatten. Bijvoorbeeld klassen om de belangrijkste verzamelingen zoals lijsten en kaarten te representeren, of om veelgebruikte formaten zoals JSON of XML te verwerken. Vaak zal het geavanceerde functionaliteiten bevatten om strings en reguliere expressies te verwerken.
Met andere woorden, het schrijven van een standaard bibliotheek is een hoop werk. Het is niet glamoureus, het is conceptueel niet zo interessant als het schrijven van een compiler, maar het is nog steeds een fundamenteel onderdeel om een programmeertaal levensvatbaar te maken.
Er zijn manieren om deze eis te omzeilen. Een daarvan is om de taal op een bepaald platform te laten draaien en het mogelijk te maken om de standaard bibliotheek van een andere taal te hergebruiken. Bijvoorbeeld, alle talen die op de JVM draaien kunnen eenvoudigweg de standaard Java bibliotheek hergebruiken.
Ondersteunende gereedschappen voor een nieuwe programmeertaal
Om een taal in de praktijk bruikbaar te maken moeten we vaak een paar ondersteunende gereedschappen schrijven.
De meest voor de hand liggende is een editor. Een gespecialiseerde editor met syntax highlighting, inline foutcontrole, en auto-completion is tegenwoordig een must om elke ontwikkelaar productief te maken.
Maar ontwikkelaars zijn tegenwoordig verwend en ze verwachten allerlei andere ondersteunende tools. Bijvoorbeeld, een debugger kan erg handig zijn om een vervelende bug aan te pakken. Of een build systeem zoals maven of gradle zou iets kunnen zijn waar gebruikers later om zullen vragen.
In het begin zou een editor genoeg kunnen zijn, maar als je gebruikersgroep groeit zal ook de complexiteit van projecten toenemen en zullen meer ondersteunende tools nodig zijn. Hopelijk is er op dat moment een gemeenschap bereid om te helpen bij het bouwen ervan.
Samenvatting
Het maken van een programmeertaal is een proces dat voor veel ontwikkelaars mysterieus lijkt. In dit artikel hebben we geprobeerd te laten zien dat het gewoon een proces is. Het is fascinerend en niet gemakkelijk, maar het kan gedaan worden.
Je wilt misschien een programmeertaal maken om verschillende redenen. Een goede reden is voor de lol, een andere is om te leren hoe compilers werken. Je taal kan uiteindelijk heel nuttig zijn of niet, afhankelijk van vele factoren. Maar als je plezier hebt en/of leert tijdens het bouwen, dan is het de moeite waard om er wat tijd in te investeren.
En natuurlijk kun je opscheppen met je mede-ontwikkelaars.
Als je meer wilt leren over het maken van een taal, kijk dan eens naar de andere bronnen die we hebben gemaakt: leer hoe je talen bouwt.
Je bent misschien ook geïnteresseerd in sommige van onze artikelen: