Articles

Reading Code Right, With Some Help From The Lexer

Vaidehi Joshi
Vaidehi Joshi

Follow

Nov 27, 2017 · 16 min read

Reading code right, with some help from the lexer.

Software is all about logic. Programmeren heeft een reputatie van een veld dat is zwaar op de wiskunde en gekke vergelijkingen. En informatica lijkt de kern van deze misvatting te zijn.

zeker, er is wat wiskunde en er zijn een aantal formules – maar niemand van ons heeft echt PhD ‘ s in calculus nodig om te begrijpen hoe onze machines werken! In feite zijn veel van de regels en paradigma ’s die we leren tijdens het schrijven van code dezelfde regels en paradigma’ s die van toepassing zijn op complexe computerwetenschappelijke Concepten. Soms komen die ideeën eigenlijk uit de computerwetenschap, en we wisten het gewoon nooit.

ongeacht welke programmeertaal we gebruiken, wanneer de meesten van ons onze code schrijven, streven we ernaar om verschillende dingen in klassen, objecten of methoden te kapselen, waarbij we opzettelijk de verschillende delen van onze code scheiden. Met andere woorden, we weten dat het over het algemeen goed is om onze code te verdelen, zodat één klasse, object of methode slechts betrokken is bij en verantwoordelijk is voor één ding. Als we dit niet zouden doen, zouden de dingen super rommelig kunnen worden en verstrengeld in een puinhoop van een web. Soms gebeurt dit nog steeds, zelfs met scheiding van zorgen.

Het blijkt dat zelfs de interne werking van onze computers zeer vergelijkbare ontwerpparadigma ‘ s volgen. Onze compiler, bijvoorbeeld, heeft verschillende onderdelen, en elk deel is verantwoordelijk voor het behandelen van een specifiek deel van het compilatieproces. We kwamen een beetje van deze vorige week, toen we leerden over de parser, die verantwoordelijk is voor het creëren van parse bomen. Maar de parser kan onmogelijk met alles belast worden.

de parser heeft wat hulp nodig van zijn vrienden, en het is eindelijk tijd voor ons om te leren wie ze zijn!

toen we onlangs leerden over parsen, doopten we onze tenen in grammatica, syntaxis, en hoe de compiler reageert en reageert op die dingen binnen een programmeertaal. Maar we hebben nooit echt benadrukt wat een compiler precies is! Als we in de innerlijke werking van het compilatieproces komen, gaan we veel leren over compilerontwerp, dus het is essentieel voor ons om te begrijpen waar we het hier precies over hebben.

Compilers kunnen een beetje eng klinken, maar hun taken zijn eigenlijk niet te complex om te begrijpen-vooral als we de verschillende delen van een complier opsplitsen in hapklare delen.

maar eerst, laten we beginnen met de eenvoudigste definitie mogelijk. Een compiler is een programma dat onze code leest (of elke code, in elke programmeertaal), en vertaalt in een andere taal.

the compiler: a definition.

in het algemeen zal een compiler alleen code vertalen van een taal op hoog niveau naar een taal op lager niveau. De talen van het lagere niveau waarin een compiler code vertaalt, worden vaak assembly code, machine code of object code genoemd. Het is vermeldenswaard dat de meeste programmeurs niet echt te maken hebben met of het schrijven van een machine code; in plaats daarvan zijn we afhankelijk van de compiler om onze programma ‘ s te nemen en te vertalen in machine code, dat is wat onze computer zal draaien als een uitvoerbaar programma.

We kunnen compilers zien als de tussenpersoon tussen ons, de programmeurs en onze computers, die alleen uitvoerbare programma ‘ s in lagere talen kunnen draaien.

de compiler vertaalt wat we willen gebeuren op een manier die begrijpelijk en uitvoerbaar is voor onze machines.

zonder de compiler zouden we gedwongen worden om met onze computers te communiceren door machinecode te schrijven, wat ongelooflijk onleesbaar en moeilijk te ontcijferen is. Machinecode kan er vaak uitzien als een stel 0 ’s en 1′ s voor het menselijk oog — het is allemaal binair, Weet je nog? – wat het super moeilijk maakt om te lezen, schrijven en debuggen. De compiler abstraheerde machinecode voor ons als programmeurs, omdat het ons heel gemakkelijk maakte om niet na te denken over machinecode en programma ‘ s te schrijven met behulp van veel elegantere, duidelijkere en eenvoudig te lezen talen.

we zullen de komende weken meer en meer gaan uitpakken over de mysterieuze compiler, wat het hopelijk minder enigma zal maken in het proces. Maar voor nu, laten we terug naar de vraag bij de hand: wat zijn de eenvoudigste mogelijke onderdelen van een compiler?

elke compiler, ongeacht hoe het ontworpen zou kunnen worden, heeft verschillende fasen. Deze fasen zijn hoe we de unieke delen van de compiler kunnen onderscheiden.

Syntaxisanalyse: fase één van een compiler

We zijn al een van de fasen tegengekomen in onze compilatie avonturen toen we onlangs leerden over de parser en parse bomen. We weten dat parsing is het proces van het nemen van wat input en het bouwen van een parse boom uit het, die soms wordt aangeduid als de handeling van het ontleden. Zoals blijkt, het werk van het ontleden is specifiek voor een fase in het compilatieproces genaamd syntaxis analyse.

echter, de parser bouwt niet alleen een parse boom uit dunne lucht. Het heeft wat hulp! We zullen herinneren dat de parser wordt gegeven een aantal tokens (ook wel terminals), en het bouwt een parse boom van die tokens. Maar waar haalt het die penningen vandaan? Gelukkig voor de parser, het hoeft niet te werken in een vacuüm; in plaats daarvan, het heeft wat hulp.

Dit brengt ons bij een andere fase van het compilatieproces, één die voor de syntaxisanalyse fase komt: de lexicale analyse fase.

de beginfasen van een compiler

de term “lexicaal” verwijst naar de Betekenis van een woord los van de zin die het bevat, en ongeacht de grammaticale context. Als we proberen onze eigen betekenis uitsluitend op basis van deze definitie te raden, kunnen we stellen dat de lexicale analyse fase te maken heeft met de individuele woorden/termen in het programma zelf, en niets te maken heeft met de grammatica of de Betekenis van de zin die de woorden bevat.

de lexicale analysefase is de eerste stap in het compilatieproces. Het kent of geeft niet om de grammatica van een zin of de Betekenis van een tekst of programma; alles wat het Weet over zijn de Betekenis van de woorden zelf.

lexicale analyse moet plaatsvinden voordat een code van een bronprogramma kan worden ontleed. Voordat het kan worden gelezen door de parser, moet een programma eerst worden gescand, opgesplitst, en gegroepeerd op bepaalde manieren.

toen we vorige week begonnen te kijken naar de syntaxis analyse fase, leerden we dat de parse tree wordt gebouwd door te kijken naar afzonderlijke delen van de zin en het afbreken van uitdrukkingen in eenvoudiger delen. Maar tijdens de lexicale analyse fase, de compiler niet weet of toegang tot deze”afzonderlijke delen”. Integendeel, het moet eerst identificeren en vinden, en dan het werk van het splitsen van de tekst in afzonderlijke stukken.

bijvoorbeeld, wanneer we een zin uit Shakespeare lezen zoals To sleep, perchance to dream., weten we dat de spaties en de interpunctie de” woorden ” van een zin verdelen. Dit is natuurlijk omdat we getraind zijn om een zin te lezen, “lex” het, en het te ontleden voor grammatica.

maar voor een compiler zou dezelfde zin er zo uit kunnen zien de eerste keer dat hij het leest: Tosleepperhachancetodream. Als we deze zin lezen, is het een beetje moeilijker voor ons om te bepalen wat de werkelijke “woorden” zijn! Ik weet zeker dat onze compiler er hetzelfde over denkt.

dus, hoe gaat onze machine om met dit probleem? Tijdens de lexicale analyse fase van het compilatieproces, doet het altijd twee belangrijke dingen: het scant de code, en evalueert het.

de twee stappen van het lexicale analyseproces!

het werk van scannen en evalueren kan soms worden samengevoegd tot één enkel programma, of het kunnen twee afzonderlijke programma ‘ s zijn die van elkaar afhankelijk zijn; het is eigenlijk gewoon een vraag hoe een complier is ontworpen. Het programma binnen de compiler die verantwoordelijk is voor het doen van het werk van het scannen en evalueren wordt vaak aangeduid als lexer of tokenizer, en de gehele lexicale analyse fase wordt soms genoemd het proces van Lexen of tokenizing.

om te scannen, mogelijk om

te lezen de eerste van de twee kernstappen in lexicale analyse is scannen. We kunnen aan scannen denken als het werk van eigenlijk” lezen ” sommige input Tekst. Onthoud dat deze invoertekst een tekenreeks, zin, uitdrukking of zelfs een heel programma kan zijn! Het maakt niet echt uit, want in deze fase van het proces is het gewoon een gigantische klodder karakters die nog niets betekent, en één aaneengesloten brok is.

laten we eens kijken naar een voorbeeld om te zien hoe dit precies gebeurt. We gebruiken onze oorspronkelijke zin, To sleep, perchance to dream., wat onze brontekst of broncode is. Voor onze compiler wordt deze brontekst gelezen als invoertekst die eruit ziet als Tosleep,perchancetodream., wat slechts een tekenreeks is die nog moet worden ontcijferd.

het scanproces, stap 1.

het eerste wat onze compiler moet doen is eigenlijk die blob van tekst in zijn kleinst mogelijke stukken verdelen, wat het veel gemakkelijker maakt om te identificeren waar de woorden in de blob van tekst eigenlijk zijn.

De eenvoudigste manier om een groot stuk tekst op te duiken is door het langzaam en systematisch te lezen, één teken tegelijk. En dit is precies wat de compiler doet.

vaak wordt het scanproces afgehandeld door een apart programma genaamd de scanner, wiens enige taak het is om het werk te doen van het lezen van een bronbestand/tekst, één teken per keer. Voor onze scanner maakt het niet echt uit hoe groot onze tekst is; alles wat het zal zien wanneer het “leest” ons bestand is een teken per keer.

Hier is wat onze Shakespeare zin zou worden gelezen als door onze scanner:

het scanproces, stap 2.

We zullen merken dat To sleep, perchance to dream. is opgesplitst in individuele karakters door onze scanner. Bovendien worden zelfs de spaties tussen de woorden als karakters behandeld, net als de interpunctie in onze zin. Er is ook een karakter aan het einde van deze reeks dat bijzonder interessant is: eof. Dit is het teken “end of file”, en het is vergelijkbaar met tabspace, en newline. Aangezien onze brontekst slechts één zin is, leest onze scanner het einde van het bestand (in dit geval het einde van de zin) en behandelt het het als een karakter.

dus, in werkelijkheid, toen onze scanner onze invoertekst las, interpreteerde het deze als individuele tekens wat resulteerde in dit: .

het scanproces, stap 3.

nu onze scanner onze brontekst heeft gelezen en opgesplitst in de kleinst mogelijke delen, zal het een veel gemakkelijker tijd hebben om de” woorden ” in onze zin uit te zoeken.

vervolgens moet de scanner kijken naar de opgesplitste karakters in volgorde, en bepalen welke karakters deel zijn van een woord, en welke niet. Voor elk teken dat de scanner leest, markeert het de lijn en de positie van waar dat teken werd gevonden in de brontekst.

De hier getoonde afbeelding illustreert dit proces voor onze Shakespeareaanse zin. We kunnen zien dat onze scanner de lijn en de kolom markeert voor elk teken in onze zin. We kunnen denken aan de lijn en kolom representatie als een matrix of array van karakters.

bedenk dat, aangezien ons bestand slechts één regel bevat, alles op Regel0leeft. Echter, als we ons een weg door de zin werken, neemt de kolom van elk karakter toe. Het is ook vermeldenswaard dat, aangezien onze scanner spaces leest, newlineseof, en alle interpunctie als tekens, deze ook in onze Tekentabel verschijnen!

het scanproces, stap 4.

zodra de brontekst is gescand en gemarkeerd, is onze compiler klaar om deze tekens in woorden om te zetten. Omdat de scanner niet alleen weet waar de spacesnewlines, en eof in het bestand zijn, maar ook waar ze leven in relatie tot de andere tekens die hen omringen, kan het door de karakters heen scannen en ze zo nodig in afzonderlijke tekenreeksen verdelen.

in ons voorbeeld zal de scanner kijken naar de karakters T, dan o, en dan een space. Wanneer het een spatie vindt, zal het To in zijn eigen woord verdelen-de eenvoudigste combinatie van tekens die mogelijk is voordat de scanner een spatie tegenkomt.

Het is een vergelijkbaar verhaal voor het volgende woord dat het vindt, dat is sleep. In dit scenario leest het echter s-l-e-e-p, en leest vervolgens een ,, een interpunctiemarkering. Aangezien deze komma wordt geflankeerd door een teken (p) en een space aan beide zijden, wordt de komma zelf beschouwd als een “woord”.

zowel het woord sleep als het interpunctiesymbool , worden lexemen genoemd, die substrings zijn van de brontekst. Een lexeme is een groepering van de kleinst mogelijke opeenvolgingen van tekens in onze broncode. De lexemen van een bronbestand worden beschouwd als de individuele “woorden” van het bestand zelf. Zodra onze scanner klaar is met het lezen van de enkele karakters van ons bestand, zal het een set lexemen retourneren die er als volgt uitzien: .

het scanproces, stap 5.

merk op hoe onze scanner een blob tekst als invoer nam, die het in eerste instantie niet kon lezen, en deze vervolgens één teken per keer scande, en tegelijkertijd de inhoud las en markeerde. Vervolgens verdeelde het de tekenreeks in hun kleinst mogelijke lexemes door de spaties en interpunctie tussen tekens als scheidingstekens te gebruiken.

echter, ondanks al dit werk, op dit punt in de lexicale analyse fase, onze scanner weet niets over deze woorden. Zeker, het splitst de tekst in woorden van verschillende vormen en maten, maar voor zover wat die woorden zijn de scanner heeft geen idee! De woorden kunnen een letterlijke tekenreeks zijn, of ze kunnen een interpunctie teken zijn, of ze kunnen iets heel anders zijn!

De scanner weet niets over de woorden zelf, of wat voor” type ” woord ze zijn. Het weet gewoon waar de woorden eindigen en beginnen binnen de tekst zelf.

Dit stelt ons op voor de tweede fase van lexicale analyse: evaluatie. Zodra we onze tekst hebben gescand en de broncode hebben opgesplitst in afzonderlijke lexeme — eenheden, moeten we de woorden evalueren die de scanner ons heeft teruggegeven en uitzoeken met welke soorten woorden we te maken hebben-in het bijzonder moeten we zoeken naar belangrijke woorden die iets speciaals betekenen in de taal die we proberen te compileren.

het evalueren van de belangrijke delen

zodra we klaar zijn met het scannen van onze brontekst en onze lexemen hebben geïdentificeerd, moeten we iets doen met onze lexeme “woorden”. Dit is de evaluatiestap van lexicale analyse, die in complier design vaak wordt aangeduid als het proces van het Lexen of tokeniseren van onze input.

wat betekent het om de gescande code te evalueren?

wanneer we onze gescande code evalueren, is het enige wat we echt doen, het bekijken van elk van de lexemen die onze scanner gegenereerd heeft. Onze compiler zal naar elk lexeme woord moeten kijken en beslissen wat voor woord het is. Het proces om te bepalen wat voor soort lexeme elk “woord” in onze tekst is hoe onze compiler maakt van elke individuele lexeme in een token, waardoor tokenizing onze input string.

We kwamen voor het eerst tokens tegen toen we leerden over ontleedbomen. Tokens zijn speciale symbolen die op de crux van elke programmeertaal. Tokens, zoals (, ), +, -, if, else, then, helpen een compiler om te begrijpen hoe verschillende delen van een expressie en verschillende elementen zich met elkaar verhouden. De parser, die centraal staat in de syntaxis analyse fase, hangt af van het ontvangen van tokens van ergens en dan verandert deze tokens in een parse boom.

Tokens: een definitie.

nou, raad eens? We hebben eindelijk de “ergens”gevonden! Het blijkt dat de tokens die naar de parser worden gestuurd, worden gegenereerd in de lexicale analysefase door de tokenizer, ook wel de lexer genoemd.

Tokenizing our Shakespearean straf!

dus hoe ziet een token er precies uit? Een token is vrij eenvoudig, en wordt meestal weergegeven als een paar, bestaande uit een token naam, en een waarde (die optioneel is).

bijvoorbeeld, als we onze Shakespeareaanse tekenreeks tokeniseren, zouden we eindigen met tokens die meestal tekenreeksletters en scheidingstekens zouden zijn. We zouden de lexeme "dream” kunnen voorstellen als een token zoals dit: <string literal, "dream">. Op dezelfde manier kunnen we de lexeme . als token voorstellen, <separator, .>.

We zullen merken dat elk van deze tokens het lexeme helemaal niet wijzigt — ze voegen er gewoon extra informatie aan toe. Een token is lexeme of lexicale eenheid met meer detail; specifiek, het toegevoegde detail vertelt ons welke categorie van token (welk type “woord”) we te maken hebben met.

nu we onze Shakespeareaanse zin hebben gemaakt, kunnen we zien dat er niet zoveel variatie is in de soorten tokens in ons bronbestand. Onze zin had alleen snaren en interpunctie in het-maar dat is slechts het topje van de token ijsberg! Er zijn tal van andere soorten “woorden” die een lexeme kan worden gecategoriseerd in.

veel voorkomende vormen van tokens gevonden binnen onze broncode.

De hier getoonde tabel illustreert enkele van de meest voorkomende tokens die onze compiler zou zien bij het lezen van een bronbestand in vrijwel elke programmeertaal. We zagen voorbeelden van literals, wat elke string, getal of logica / Booleaanse waarde kan zijn, evenals separators, die elk type interpunctie zijn, inclusief accolades ({}) en haakjes (()).

However, there are also keywords, which are terms that are reserved in the language (such as ifvarwhilereturn), as well as operators, which operate on arguments and return some value ( +-x/). We kunnen ook lexemes tegenkomen die kunnen worden tokenized als identifiers, die meestal variabele namen of dingen zijn die door de gebruiker/programmeur zijn geschreven om naar iets anders te verwijzen, evenals , die regel-of blokcommentaren kunnen zijn die door de gebruiker zijn geschreven.

onze oorspronkelijke zin toonde ons slechts twee voorbeelden van tokens. Laten we onze zin herschrijven om in plaats daarvan te lezen: var toSleep = "to dream";. Hoe kan onze samensteller lex deze versie van Shakespeare?

Hoe zal onze lexer deze zin tokeniseren?

Hier zullen we zien dat we een grotere variëteit aan tokens hebben. We hebben een keyword in var, waar we een variabele declareren, en een identifiertoSleep, wat de manier is waarop we onze variabele een naam geven of verwijzen naar de toekomstige waarde. Vervolgens is ons=, wat eenoperator token is, gevolgd door de letterlijke tekenreeks"to dream". Ons statement eindigt met een ; scheidingsteken, dat het einde van een regel aangeeft en witruimte scheidt.

een belangrijk ding om op te merken over het tokenizatieproces is dat we geen witruimte tokeniseren (spaties, nieuwe regels, tabs, einde van de regel, enz.), noch het doorgeven aan de parser. Vergeet niet dat alleen de tokens worden gegeven aan de parser en zal eindigen in de parse boom.

Het is ook vermeldenswaard dat verschillende talen verschillende karakters zullen hebben die als witruimte vormen. Bijvoorbeeld, in sommige situaties gebruikt de programmeertaal Python inspringen – inclusief tabbladen en spaties — om aan te geven hoe de omvang van een functie verandert. Dus, de tokenizer van de Python compiler moet zich bewust zijn van het feit dat, in bepaalde situaties, een tab of space eigenlijk moet worden tokenized als een woord, omdat het eigenlijk moet worden doorgegeven aan de parser!

beperkingen van de lexer vs de scanner.

dit aspect van de tokenizer is een goede manier om te contrasteren hoe een lexer/tokenizer verschilt van een scanner. Terwijl een scanner onwetend is, en alleen weet hoe een tekst in zijn kleinere mogelijke delen (zijn “woorden”) te splitsen, is een lexer/tokenizer veel bewuster en nauwkeuriger in vergelijking.

De tokenizer moet de fijne kneepjes en specificaties kennen van de taal die wordt gecompileerd. Als tabs belangrijk zijn, moet het weten dat; als newlines bepaalde betekenissen kan hebben in de taal die wordt gecompileerd, moet de tokenizer op de hoogte zijn van die details. Aan de andere kant, de scanner weet niet eens wat de woorden die het verdeelt zelfs zijn, laat staan wat ze betekenen.

De scanner van een complier is veel taal-agnostisch, terwijl een tokenizer per definitie taal-specifiek moet zijn.

deze twee delen van het lexicale analyseproces gaan hand in hand en staan centraal in de eerste fase van het compilatieproces. Natuurlijk zijn verschillende compliers op hun eigen unieke manier ontworpen. Sommige compilers doen de stap van scannen en tokenizing in een enkel proces en als een enkel programma, terwijl anderen zullen ze op te splitsen in verschillende klassen, in welk geval de tokenizer zal roepen naar de scanner Klasse wanneer het wordt uitgevoerd.

Lexical analysis: a quick visual summary!

in beide gevallen is de stap van lexicale analyse super belangrijk voor de compilatie, omdat de syntaxisanalyse direct ervan afhangt. En ook al heeft elk onderdeel van de compiler zijn eigen specifieke rollen, ze leunen op elkaar en zijn afhankelijk van elkaar — net zoals goede vrienden altijd doen.

Resources

omdat er veel verschillende manieren zijn om een compiler te schrijven en te ontwerpen, zijn er ook veel verschillende manieren om ze te leren. Als je genoeg onderzoek doet naar de basisprincipes van compilatie, wordt het vrij duidelijk dat sommige verklaringen veel gedetailleerder zijn dan andere, wat al dan niet nuttig kan zijn. Als je merkt dat je meer wilt leren, hieronder zijn een verscheidenheid aan bronnen over compilers — met een focus op de lexicale analyse fase.hoofdstuk 4 – crafting Interpreters, Robert Nystrom

  • Compiler Construction, Professor Allan Gottlieb
  • Compiler Basics, Professor James Alan Farrell
  • schrijven van een programmeertaal-The Lexer, Andy Balaam
  • opmerkingen over hoe Parsers en Compilers werken, Stephen Raymond Ferg
  • Wat is het verschil tussen een token en een lexeme?, StackOverflow