1111111111
实现计算器之前,先给出实现这个计算器的大致地图:
我们会支持下面的表达式:
345*(3+5)
我们先大致讲下实现这个计算器的流程。首先,我们要实现一个扫描器,把我们输入的代码:345*(3+5)转换成token数据。
我们要从345*(3+5)中对应的词素(lexemes)中生成标记(token),在这里,345三个字符代表一个数字三百四十五,这三个字符连在一起是有意义的,但如果是单个的3或4或5,是没意义的。所以,扫描器 的目的就是把对应的字符串,处理成一个一个有意义的词素对应的token数据。所以上面的345要连起来处理成一个词素的token,而后面的单个字符*就可以处理成一个词素的token,因为*单个字符就有意义,它代表的是算术法则里的乘以。上面表达式有下面几个词素:
345,*,(,3,+,5,)共七个词素。
token数据有好几个维度,比如这个token的类别,它在原来字符串中的text, 它代码的值(字面量)是多少。生成的token是为了下面的解析器(parser)服务的,会作为parser的输入,parser根据token记录的类别,来分别对应处理。比如如果当前token的类别是"NUMBER",就会生成Literal类型的表达式,而如果是"+"号,就会递归下降调用乘法的方法。又比如:我们知道,每一门语言都有自己的关键词(keywords),比如python里有for ,while, def, class,我们的解析器就可以根据这些不同的关键词来分析处理,像for,我们会把它生成的token的类别设置成FOR。这样解析器就可以根据token的类别来对应处理逻辑。我们现在实现的计算器没有关键词后面在实现parser的时候,再回头来看这段,就会明白了。讲这一点是想表达,扫描器,解析器,解释器它们是分层处理,每一层都有输出的数据类型,以供下一层作为输入使用,到实现解释器(interpreter)的时候,同样会看到,第二层解析器(parser)生成的不同表达式,如Binary,Grouping,Unary,也会作为下一层解释器(interpreter)的输入,然后解释器同样根据这些表达式的不同属性,分别对应处理相应逻辑。刚才说了,现在不明白这些没关系。但头脑中要有个分层的思想。
扫描器生成的token数据可以用数据结构封装,如果不考虑其它方面,用对象,元组,字典都是可以的。我们先假装自己是个初学者,先用元组试着封装下。所以,345*(3+5)就可以封装成以下几个token:
我们规定:元组第一个值代表token的类型,第二个值代码token在源字符中的文本,第三个值代码源字符中符号代表的字面量(专业说法叫运行时对象。比如345这个字符串,如果用python表示,应该表示成345.0这样的浮点类型,在我们计算器中,只有数字才有代表的字面量值,其它的字面量值都是None)
345 -> ("NUMBER", "345", 345.0), 说明:345三个字符组成一个数字类型(所以第一个值是"NUMBER")的token, 它在原字符串是中“345”的字符串文本,它代表值是345.0,用python表示的浮点值的类型。
* -> ("STAR", "*", None), 说明:*代表乘法,我们用"STAR"表示类型, 在源代码中是"*"字符,它是一个操作符合,不是数字,所以它的值我们用python表示,就是None,如果我们是用java实现的,那就用null,
( -> ("LEFT_PAREN", "(", None ), 说明:(语言中的括号,如果我们在表达式中用了(),这里面的表达式我们是要优先计算的。我们用"LEFT_PAREN"表示类型, 在源代码中是"("字符,它是一个操作符合,不是数字,所以它的值我们用python表示,就是None
)-> ("LEFT_PAREN", "(", None ), 说明同上面的(。
我们先实现这个计算器的扫描器。你可以先把代码下载到本地,跑起来先感性直观的体会下。
好,我们现在开始讲解下代码实现。
python3 tl.py运行文件,可以打断点一步步跟着看代码。
def main():
"""
这个方法主要实现:根据参数的长度决定是用文件还是从命令行输入
"""
args = sys.argv[1:]
if len(args)>1:
print("usage:python ")
elif len(args)==1:
runFile()
else:
runPrompt()
这是入口文件tl.py的主方法,这里支持根据参数来决定是从命令行还是文件中读取字符,我们先实现简单的计算器,所以进入下面的runPrompt()方法:
def runPrompt():
while True:
try:
line = input("> ")
if line == "":
break
run(line)
had_error = False
except EOFError:
break
except Exception as e:
print(f"An error occurred: {e}")
这个函数会调用run方法:
def run(line):
tokens = scanTokens(line)
print(tokens)
这样分开写是为了解耦,因为后面的实现runFile()方法也会调用run()方法。
现在真正进入我们的扫描器了,进入scanTokens()方法:
def scanTokens(source):
"""
如果是只有+-*/和数字。输入是:2+4*3,变成下面和形式:
NUMBER 2 2.0
PLUS + null
NUMBER 4 4.0
STAR * null
NUMBER 3 3.0
"""
tokens = []
start = 0
current = 0
while not isAtEnd(source, current):
start = current
c = source[current]
if c == '+':
tokens.append(("PLUS", c, None))
current += 1
elif c == '-':
tokens.append(("MINUS", c, None))
current += 1
elif c == '*':
tokens.append(("STAR", c, None))
current += 1
elif c == '/':
tokens.append(("SLASH", c, None))
current += 1
elif c == "(":
tokens.append(("LEFT_PAREN", c, None))
current += 1
elif c == ")":
tokens.append(("LEFT_PAREN", c, None))
current += 1
else:
while isDigit(source[current]):
current += 1
if isAtEnd(source, current):
break
text = source[start: current]
obj = float(text)
tokens.append(("NUMBER", text, obj))
return tokens
这段代码实现逻辑很简单,就是循环遍历字符串,然后生成tokens列表并且返回
tokens = []
用来存储最后生成的token
start = 0
current = 0
start是记录每个词素开始的位置,所以,每次生成完一个token,start的值都会更新:
start = current
这段代码有几个要说的点:
def isAtEnd(source, current):
return current >= len(source)
def isDigit(c):
return c >= '0' and c <= '9'
我定义了两个辅助函数,一个判断循环是否到最后了,一个判断当前字符是否是数字。
因为我们实现的是计算器,所以这个循环里只有两种情况,一种是单个字符,比如+,-,*,/,(,)。一种是数字,这种就复杂点:代码如下:
while isDigit(source[current]):
current += 1
if isAtEnd(source, current):
break
text = source[start: current]
obj = float(text)
tokens.append(("NUMBER", text, obj))
return tokens
当前字符如果是数字,会不断循环看下一个字符,如果也是数字,就一直循环,直到不是数字。标记字符位置的current随着不断加1,而start还是一开头的值。这时我们就通过start和current的区间记录出来当前数字的文本字符。比如上面例子的345。我们后面的解释器是使用python的float类型来表示数字,所以把这个文本字符通过python的float()把它转换成345.0,这个值后面会被解释器(interpreter)使用。最后,把上面两步的值封装到一个元组中:
text = source[start: current]
obj = float(text)
tokens.append(("NUMBER", text, obj))
说明:为了最小实现计算器,我们的计算器不直持小数运算。我们一开始的目的是为了体会实现一门语言的大致框架,先把一些细节隐藏起来。后面随着迭代会支持的。
我是用面向过程实现这个简单的扫描器,读者后面可以把它改装成面向对象。体会下面向对象的好处。比如把上面记录字符位置的start,current,以及存入token的tokens作为类的属性。
到此,我们的扫描器就完成了。读者可以自己在这个扫描器的基础上玩一玩,比如刚才说的:如何支持小数的运算?
下一讲,我们接着实现一个解析器(parser),上面提到过,解析器是用我们这一讲生成的token列表,来组装成更丰富,更复杂的表示形式----树。会很有意思,也更有挑战。