让我们做一个Teeny Tiny编译器

2020-06-07 04:28:21

外面天气很好,让我们做个编译器吧。您不需要任何关于编译器如何工作的知识就可以跟上。我们将使用Python来实现我们自己的编程语言Teeny Tiny,它将编译成C代码。它将需要大约500行代码,并提供定制编译器所需的初始基础设施,并将其扩展为您自己的数十亿美元的生产就绪编译器。

本教程是一系列帖子,循序渐进地构建可工作的编译器。所有源代码都可以在GitHub资源库中找到。如果你跟随所有的帖子,我猜你只需要几个小时。

我们将要实现的Teeny Micro语言是BASIC的一种方言。语法简洁明了。如果您更喜欢类似C的语法,那么在最后修改编译器将是微不足道的。下面是一个用我们的Teeny Tmall语言编写的示例程序:

打印您需要多少斐波那契数字?";输入数字让a=0让b=1WHILE数字&>0重复打印a让c=a+b让a=b让b=cENDWHILE。

这个程序根据用户的输入打印出斐波纳契数列的项:0 1 1 2 3 5 13.。

我们的语言将允许您从编程语言中期望的各种基本操作。它将特别支持:

虽然这是一个标准的功能子集,但是您可能注意到,它没有函数、没有数组、没有从文件读取/写入的方法,甚至没有ELSE语句。但是,仅使用这一小组构造,您实际上可以做很多事情。它还将以这样一种方式设置编译器,以便以后可以直接添加许多其他功能。

我们的编译器将遵循如上所述的三个步骤。首先,给定输入的源代码,它会将代码分解为令牌。这些就像英语中的单词和标点符号。其次,它将解析令牌以确保它们的顺序在我们的语言中是允许的。就像英语一样,句子遵循特定的动词和名词结构。第三,它将发出我们的语言将转换成的C代码。

我们将使用这三个步骤作为代码的主要组织。词法分析器、解析器和发射器都有自己的Python代码文件。本教程也根据这些步骤分为3个部分。如果您要扩展编译器,还需要添加一些额外的步骤,但我们将暂缓讨论这些步骤。

我们编译器的第一个模块称为词法分析器。给定一串Teeny微代码,它将逐个字符迭代来做两件事:决定每个令牌的开始/停止位置和令牌的类型。如果lexer无法做到这一点,则它将报告无效令牌的错误。

该图演示了词法分析器的输入和输出示例。给定Teeny微小代码,词法分析器必须确定令牌和类型(例如,关键字)的位置。您可以看到空格没有被识别为令牌,但是词法分析器会将它们用作了解令牌何时结束的一种方式。

让我们最后进入一些代码,从lex.py文件中的词法分析器的结构开始:

class lexer:def__init__(self,input):pass#处理下一个字符。def nextChar(Self):pass#返回前视字符。def peek(Self):PASS#INVALID TOKEN FOUND,打印错误消息并退出。def Abort(self,message):传递#Skip空白(换行符除外),我们将用它来指示语句的结束。def skipWhitespace(Self):在代码中传递#Skip注释。def skipComment(Self):pass#返回下一个令牌。def getToken(Self):过程。

我喜欢勾勒出我认为需要的所有功能,然后回去填写。函数getToken将成为词法分析器的核心。每次编译器准备好下一个令牌时都会调用它,并且它将执行对令牌进行分类的工作。nextChar和peek是用于查看下一个字符的帮助器函数。SkipWhitespace占用了我们不关心的空格和制表符。Abort是我们将用于报告无效令牌的命令。

词法分析器需要跟踪输入字符串中的当前位置以及该位置上的字符。我们将在构造函数中初始化这些内容:

def__init__(self,input):self.Source=input+';\n';#将源代码转换为字符串形式的lex。追加换行符以简化最后一个令牌/语句的词法分析/解析。self.curChar=';';#字符串中的当前字符。self.curPos=-1#字符串中的当前位置。self.nextChar()

词法分析器需要输入代码,我们在它后面追加一个换行符(这只是简化了后面的一些检查)。curChar是词法分析器将不断检查的内容,以确定它是哪种令牌。为什么不干脆做source[curPos]呢?因为这会使带有边界检查的代码变得杂乱无章。相反,我们在nextChar中执行此操作:

#处理下一个字符。def nextChar(Self):self.curPos+=1,如果self.curPos>;=len(self.source):self.curChar=';\0';#EOF ELSE:self.curChar=self.Source[self.curPos]。

这会增加词法分析器的当前位置并更新当前角色。如果我们到达输入的末尾,则将字符设置为文件结束标记。这是我们要修改curPos和curChar的唯一位置。但有时我们希望在不更新curPos的情况下向前看下一个字符:

#返回前视字符。def peek(Self):if self.curPos+1>;=len(self.Source):return';\0';return self.Source[self.curPos+1]。

我们应该确保这些功能正常工作。让我们通过创建一个新文件teenytiny.py:

from lex import*def main():input=";let foobar=123";lexer=lexer.peek()!=';\0';:print(lexp.curChar)lexfor.nextChar()main()。

运行下面的命令,输出应该是输入字符串的每个字符,让foobar=123,在新行上:

但是我们不只想要字符,我们还想要代币!我们需要计划如何将单个字符组合在一起形成一个令牌,它的工作方式与状态机非常相似。以下是Teeny Tmall语言的主要词法分析器规则:

接线员。匹配的一个或两个连续字符:+-*/=!=<;<;>;=<;=。

绳子。双引号,后跟零个或多个字符和一个双引号。例如:你好,世界!和#34;";";

数。一个或多个数字字符,后跟一个可选小数点和一个或多个数字字符。例如:15和3.14。

关键字。精确文本匹配:标签、转到、打印、输入、let、IF、THEN、ENDIF、WHILE、REPEAT、ENDWHILE。

#返回下一个令牌。def getToken(Self):#检查此令牌的第一个字符,看看我们是否可以确定它是什么。#如果是多字符运算符(例如,!=)、数字、标识符或关键字,则我们将处理其余部分。if self.curChar==';+';:传递#Plus令牌。Elif self.curChar==';-';:传递#减号令牌。Elif self.curChar==';*';:传递#星号标记。Elif self.curChar==';/';:传递#斜杠标记。Elif self.curChar==';\n';:传递#newline标记。Elif self.curChar==';\0';:传递#EOF令牌。否则:#未知令牌!传递self.nextChar()。

这将检测一些可能的令牌,但还不会做任何有用的事情。接下来,我们需要一个令牌类来跟踪它是什么类型的令牌以及代码中的确切文本。现在将其放在Lex.py中:

#Token包含原始文本和内标识的类型。class内标识:def__init__(self,tokenText,tokenKind):self.text=tokenText#内标识的实际文本。用于标识符、字符串和数字。self.kind=tokenKind#此令牌分类为的TokenType。

要指定令牌的类型,我们将创建TokenType类作为枚举。它看起来很长,但它只是指定了我们的语言允许的每一个可能的标记。将import枚举添加到Lex.py的顶部,并添加此类:

#TokenType是所有令牌类型的枚举。class TokenType(枚举.Enum):EOF=-1 NEWLINE=0 NUMBER=1 IDENT=2 STRING=3#关键字。标签=101转到=102打印=103输入=104 LET=105 IF=106 THEN=107 ENDIF=108 WHILE=109 REPEAT=110 ENDWHILE=111#运算符。EQ=201加=202减=203星号=204斜杠=205 EQEQ=206 NOTEQ=207 LT=208 LTEQ=209 GT=210 GTEQ=211。

现在,我们可以扩展getToken,使其在检测到特定令牌时实际执行某些操作:

#返回下一个令牌。def getToken(Self):Token=None#检查此令牌的第一个字符,看看我们是否可以确定它是什么。#如果是多字符运算符(例如,!=)、数字、标识符或关键字,则我们将处理其余部分。if self.curChar==';+';:Token=Token(self.curChar,TokenType.PLUS)Elif self.curChar=';-';:Token=Token(self.curChar,TokenType.MINUS)Elif self.curChar==';*';:Token=Token(self.curChar,TokenType.ASTERISK)Elif。用法:Token=Token(self.curChar,TokenType.NEWLINE)Elif self.curChar==';\0';参数:Token=Token(';';,TokenType.EOF)否则:#未知Token!传递self.nextChar()返回令牌。

这段代码将lexer设置为检测基本算术运算符以及新行和文件结束标记。ELSE子句用于捕获不允许的所有内容。

def main():input=";+-*/";lexer=lexer(Input)Token=lexfor.getToken()While token.ind!=TokenType.EOF:print(token.kind)Token=lexper.getToken()。

TokenType.PLUSTokenType.EOF:文件";e:/projects/teenytiny/part1/teenytiny.py";,行12,在Main()中文件";e:/projects/teenytiny/part1/teenytiny.py";,行8,在Main中,而Token.Kind!=TokenType.EOF:AttributeError:';无类型';对象没有属性';Kind';

啊哈!出了点问题。getToken返回NONE的唯一方式是采用Else分支。我们应该更好地处理这件事。将import sys添加到lex.py的顶部,并定义中止函数,如下所示:

#找到无效令牌,打印错误消息并退出。def中止(自身,消息):sys.exit(";词法错误。";+消息)

仍然存在一个问题,但现在我们可以更有意义地理解它。看起来在前两个令牌之后出了点问题。未知令牌不可见。回头看看输入字符串,您可能会注意到我们不是在处理空格!我们需要实现skipWhitespace函数:

#跳过除换行符以外的空格,我们将使用换行符指示语句结束。def skipWhitespace(Self):While self.curChar==';';或self.curChar=';\t';或self.curChar==';\r';:self.nextChar()。

现在将self.skipWhitespace()作为getToken的第一行。运行该程序,您应该会看到输出:

此时,我们可以继续对由两个字符组成的操作符进行词法分析,例如==和>;=。所有这些操作符都将以相同的方式进行词法分析:检查第一个字符,然后偷看第二个字符,看看它是什么,然后再决定要做什么。在getToken中斜杠标记的elif之后添加以下内容:

Elif self.curChar==';=';:#检查此内标识是=还是==if self.peek()==';=';:lastChar=self.curChar self.nextChar()Token=Token(lastChar+self.curChar,TokenType.EQEQ)Else:Token=Token(self.curChar,TokenType.EQ)。

使用peek函数允许我们在不丢弃curChar的情况下查看下一个字符。下面是其余运算符的代码,它们的工作方式相同:

Elif self.curChar==';>;';:#检查这是>;还是>;=if self.peek()==';=';:lastChar=self.curChar self.nextChar()Token=Token(lastChar+self.curChar,TokenType.GTEQ)Else:Token=Token(self.curChar,Token.curChar。=';:lastChar=self.curChar self.nextChar()Token=Token(lastChar+self.curChar,TokenType.LTEQ)Else:Token=Token(self.curChar,TokenType.LT)elif self.curChar==';!';:if self.peek()==';=';:lastChar=self.curChar。

唯一稍有不同的运算符是!=。那是因为!字符本身无效,因此它后面必须跟=。其他字符本身是有效的,但是词法分析器很贪婪,如果可能的话,它将接受它作为多字符操作符之一。

我们可以通过将输入更新为";+-*/>;>;==!=";来测试这些运算符,这将在您运行程序时为您提供以下输出:

程序现在接受所有语言的运算符。那么还剩下什么呢?我们需要添加对注释、字符串、数字、标识符和关键字的支持。让我们一个接一个地检查一下,边走边测试。

#字符将指示注释的开始。每当lexer看到它时,我们就知道要忽略它后面的所有文本,直到换行符。注释不是记号,但是词法分析器将丢弃所有这些文本,以便它可以找到我们关心的下一个内容。同样重要的是,我们不要丢弃评论末尾的换行符,因为这是它自己的标记,可能仍然需要。填写skipComment:

#跳过代码中的注释。def skipComment(Self):if self.curChar==';#';:While self.curChar!=';\n';:self.nextChar()。

很简单!现在从nextToken调用它,这样函数的前几行如下所示:

使用输入";+-#进行测试!\n*/";您应该会看到:

我们的语言支持打印字符串,该字符串以双引号开始,一直持续到另一个引号。我们不允许使用某些特殊字符来使以后更容易编译成C语言。将以下代码添加到getToken';的Else If语句的大块中:

Elif self.curChar==';\";';:#获取引号之间的字符。self.nextChar()startPos=self.curPos,而self.curChar!=';\";';:#不允许字符串中有特殊字符。没有转义字符、换行符、制表符或%。#我们将在此字符串上使用C';的printf。如果self.curChar==';\r';或self.curChar==';\n';或self.curChar=';\t';或self.curChar==';\\';或self.curChar==';%';:self.bort(";)self.next.。Token=Token(tokText,TokenType.STRING)

您将看到代码只是一个WHILE循环,一直持续到第二个引号。如果发现任何无效字符,它将中止并显示错误消息。与我们到目前为止介绍的其他令牌不同的是:我们将令牌的文本设置为字符串的内容(减去引号)。

使用";+-\";This is a string\";#This is a Comment!\n*/";再次更新输入,然后运行程序:

接下来就是数字了。我们的语言将数字定义为一个或多个数字(0-9),后跟一个可选的小数点,该小数点必须后跟至少一个数字。因此,48和3.14是允许的,但0.9和1.是不允许的。我们将再次使用peek函数向前看一个字符。与字符串标记类似,我们跟踪数字的开始和结束点,以便可以将标记的文本设置为实际数字。

Elif self.curChar.isdigit():#前导字符是数字,因此必须是数字。#获取所有连续数字和小数(如果有)。startPos=self.curPos While self.peek().isdigit():self.nextChar()if self.peek()==';.';:#Decimal!self.nextChar()#小数后必须至少有一个数字。如果不是self.peek().isdigit():#错误!self.bort(";数字中的非法字符。";),而self.peek().isdigit():self.nextChar()tokText=self.Source[startPos:self.curPos+1]#获取子字符串。Token=Token(tokText,TokenType.NUMBER)。

最后一件大事是处理标识符和关键字。标识符的规则是以字母字符开头,然后是零个或多个字母数字字符。但在我们称其为TokenType.IDENT之前,我们必须确保它不是我们的关键字之一。将此代码添加到getToken:

Elif self.curChar.isalpha():#前导字符是字母,因此必须是标识符或关键字。#获取所有连续的字母数字字符。startPos=self.curPos while self.peek().isalnum():self.nextChar()#检查令牌是否在关键字列表中。tokText=self.Source[startPos:self.curPos+1]#获取子字符串。Keyword=Token.checkIfKeyword(TokText)如果Keyword==None:#Identifier Token=Token(tokText,TokenType.IDENT)否则:#Keyword Token=Token(tokText,Keyword)。

与其他代币非常相似。但是我们需要在Token类中定义checkIfKeyword:

@staticmethod def checkIfKeyword(TokenText):对于TokenType中的KIND:#依赖于所有关键字枚举值为1XX。如果kind.name==tokenText和kind.value>;=100和kind.value<;200:返回Kind,返回NONE

这只是检查令牌是否在关键字列表中,我们已将其任意设置为将101-199作为枚举值。

我们找到了。我们的词法分析器可以正确识别我们的语言需要的每一个标记!我们已经成功地完成了编译器的第一个模块。

如果你认为这件事平淡无奇,那就先别放弃!我认为词法分析器实际上是编译器中最乏味但最无趣的部分。接下来,我们将解析代码,即确保令牌的顺序有意义,然后我们将发出代码。

到目前为止,可以在Github repo中找到完整的源代码。