从零开始的编译器前端之Lexer
这是第三篇文章,打算简单介绍一下Lexer的概念。跟之前的篇幅不同,这部分内容就完全是自己瞎弄出来的了。
不保证正确性但是应该也可以作为一种思路吧。
之前也提到过用token
组合子来在Parsing过程中进行Tokenization的操作,如果遇到了注释,就会出现问题。像是//
这样的注释,会从注释的起始位置一直延伸到行末,又比如/* ... */
这样的注释需要一直延伸到*/
为止,但是针对单个字符的组合子无法用来匹配长度为2的终结符号。如果用symbol
来强行匹配的话会死循环。
卡在这里了……不过有句话是这么说的,没有什么是不能通过抽象解决的,如果不能解决,那就再加一层抽象。如果说组合子不能用在字符串上,那么预先对字符串进行处理,获得一系列token再去对这些token的流进行解析呢?
比如说a = "b" // xxx
就可以转化为[Id "a", ASSIGN, Str "b"]
(这里直接消除了注释),对于后者来说,组合子应该是可以无障碍地运作的。
这个对字符串进行预处理的过程,就叫Lexer,或者说Lexical analysis,意思是把一个字符串翻译成附加了语义的token的流。
Lexer的写法相比Parser其实原理要简单许多,基本上就是递归循环、模式匹配和字符串处理函数的组合。当然,在实际使用中会遇到各种特殊情况,也会让函数的实现变得更复杂。
首先定义Token的数据类型,基本上就是目标语言中会使用到的关键词、符号和变量常量的集合,或者说,语法里的终结符:
1 |
|
那么一个tokenize
函数的构成基本上是这样的:
1 |
|
首先需要注意的是在模式匹配里面最先匹配的应该是更加特殊的字符,然后才是更通用的字符,例如先匹配"""
,如果匹配不到再去匹配"
。不然的话,这个特殊的分支就会被忽略掉了。
这里的matchUntil
的作用是不断匹配当前字符串和回调函数是否对的上,如果对的上的时候停止匹配返回当前位置以前和以后的子串。简单的写一下就可以了。因为是字符串操作,然后整个tokenize
函数是线性递归的,所以不会出现死循环的情况。这样一来,对注释的解析也就很简单了:一直匹配直到符合换行符或者注释结尾,然后切掉前面的部分。
1 |
|
同时也可以注意到,目前的tokenize
函数的类型是String -> [Token]
,如果把它改造成String -> Int -> Int -> [Token]
,是不是就可以在解析过程中把token所在的行列位置存起来呢。
这样做的话首先需要改造一下Token
的定义,原先的Token
变成TokenType
,另外新增Token
数据结构:
1 |
|
然后把tokenize
的每一行签名改为tokenize (...) r p = (Token TOKEN r p : tokenize xs r p)
这样的形式。
空格可以用tokenize (x:xs) r p | isSpace x = tokenize xs r (p+1)
来解决,对应的,换行可以用tokenize (x:xs) r p | x == '\n' = tokenize xs (r + 1) posBegin
来定义。这里需要注意换行的匹配必须在空格前面,否则换行会当作空格来处理。
变量和关键词的解析可以通过另写一个函数来处理,例如这样:
1 |
|
注意这里使用了span
来处理字符串,从起始点开始向后贪婪地延伸直到不再匹配到属于关键词/变量名的字符,同时记下经过的字符长度。而matchCharacterizedToken
本身只是去匹配一系列的预先设定的字符串看是否有属于其中的,这些是关键词。如果都不能匹配中,那么就是变量名。
通过这种方式去处理的话,也可以避免出现例如func
和func1
会相撞的情况。在一些Parser
Generator例如ANTLR或者ocamllex里会出现如果定义了func
作为关键词那么就不能以func1
作变量名的情况,在这里因为匹配始终是贪婪的,所以不会发生。
对操作符的处理和变量名相似:先贪婪地匹配符合条件的字符,然后去做模式匹配,如果匹配中了某一预设规则就返回这个token,否则返回一个自定义操作符的token。
1 |
|
值得注意的是span
的匹配逻辑可以根据吃入的第一个字符的不同情况而不同。这样的话可以稍微提高语法的灵活度,比如说,在读到.
的时候只匹配特定的字符,这样的事情。
多行字符串的处理其实就是简单的覆盖到"""
之前,包括所有空格和换行符。一条matchUntil
就足够了。这里假设多行字符串不需要做插值处理。
至于带有插值处理的单行字符串,需要做的其实就是把完整的字符串分割为不同的字符串片段,还有穿插其间的表达式片段。比如说"1 + 1 = \(1.plus(1))"
就可以转换为[Str "1 + 1 = ", LINTR, Numeric "1", DOT, Ident "plus", LPAREN, Numeric "1", RPAREN, RINTR, Str ""]
,这里的LINTR
和RINTR
就是字符串和表达式之间的分隔符。
用这样的思路就可以把表达式和字符串分隔开来,方便在Parser里进行处理。
我在实现自己的语言的时候,这部分的规则分为了两个部分,一部分放在tokenize
里进行模式匹配,另一部分由parseSimpleString
函数做预处理,简单说,预处理的内容是以"
或者\
作为起始点,处理反转义符,并且和tokenize
联动进行字符串的解析:
tokenize
读到"
,用parseSimpleString
提取出直到"
或者\(
或\
且并非转义作用的部分,生成一个Str
Token,然后把剩下的部分喂给tokenize
函数递归处理这里还需要记录括号的嵌套深度,以便在右侧进行校对
tokenize
做对表达式的模式匹配,直到吃到)
的时候,判断括号数,如果为0则进入字符串模式,生成一个RINTR
然后调用parseSimpleString
提取出直到下一个"
或者\(
或者\
的部分生成Str
,然后再调用tokenize
函数以此类推
要数括号的话,就需要一个变量来存储当前有多少个右括号,但是,因为表达式里面可以有字符串,字符串里面也可以有表达式,
嵌套起来的话……所以用来存储左括号的需要是一个栈(aka
List),这样的话,tokenize
函数的签名就变成了tokenize :: String -> Int -> [Int] -> Int -> Int -> ([Token], Int, [Int])
,同时也需要修改之前的模式匹配在读入左右括号的时候修改对应的变量。
由此也可以看到一个本来是很简单的函数可以因为实际需求而变得很复杂,当然,主要思路还是很简单的。
最后需要提到的部分是泛型标注符的解析,因为泛型的语法里面涉及到了左公共部分的问题。
也就是说,比如说func()
和func<a, b>()
,直接解析的话,会出现无法提取出<a, b>
的问题,而把整个func<a, b>
当作变量名去解析。
解决方法是在matchCharacterizedToken
也就是处理变量名的地方开洞,首先把函数的返回值变成一个列表然后把之前的所有Token都返回为一个singleton
list。然后利用span
函数来提取出属于变量名字符的部分,对剩下的部分匹配<
,>
和,
,去掉可能的空格,并且提取出不同的Ident
Token。
1 |
|
第八行这里需要限定匹配的字符是变量名字符,否则如果进入的是其他字符,那么span
会返回空字符串导致死循环。
仔细一看的话,其实相当于在matchCharacterizedToken
里面写了个简单的tokenize
函数。虽然可能不是很优雅,但是还是能用的。
Tokenizer可以写的很复杂,不过基本原理就是把裸字符串变成带有语义的token流,然后可能按照不同的需求去添加需要的附加信息。写起来也不复杂,主要就是依靠各种字符串操作(尤其是span
)和模式匹配。
总之大概就是这样。