今天主要来看Token和tokenizer。 主要涉及Parser文件夹下的token.c,tokenizer.c,tokenizer.h。
前排提醒:不要学Python这么写Tokenizer。至少不要像Python的这个一样goto和hack满天飞。
Python在实现自己的Parser时并没有使用类似flex或lex之类的词法检查生成器,以及yacc或bison之类的LALR Parser 生成器,而是选择自己写了一个tokenizer和自己的一个LL(1) Parser。这篇文章的重点在Tokenizer。
在第一篇文章里提到了Grammar的替换,其实token.c就是利用Grammar/Tokens和Tools/scripts/generate_token.py生成出来的,主要处理那些特殊符号和操作符。
但剩余的词法规则几乎都是靠tokenizer.c手工写成的。
相较于传统的编译器前端的tokenizer,python的tokenizer有一些别的作用,比如检查文件编码,编码内部转换等。主要也是因为支持UTF-8,UTF-16等编码,这些编码检查也是错误处理的一部分。
真正进入tokenizer的部分,首先会看到struct tok_state,具体定义在tokenizer.h中。可以看到其主要部分有:Buffer相关,包括当前位置,buffer终点,buffer内容终点,如果来源是文件的话还有文件指针等等
当前状态,包括错误状态,缩进层数,括号层数,行号等等
交互模式下的提示符
编码相关
async相关
有一说一,你个tokenizer保留async的状态有点诡异啊……
这个buffer由以下几个指针构成:buf:开始位置
cur:下一个字符
inp:最后一个字符
end:buffer的末尾
在正常情况下,这几个指针应当从小到大排开。
tokenizer的最主要的功能就是提供下一个token。而最核心的部分是读取下一个字符。这两个步骤也分别对应tok_get和tok_nextc。
先来看tok_nextc。洋洋洒洒近两百行,其实主要是在处理各种各样的潜在错误。buffer没用完?直接读下一个字符。
有错误?返回EOF。
在读字符串?往inp后面接着读一行
在交互模式下?让用户再输入一行
(代码来自文件)读下一行
上面的情况3、4、5读进来的下一行也有可能有各种各样的问题,比如说下一行为空或者遇到EOF,总之有各种各样的问题需要解决。就算没遇到什么问题,也要维护tokenizer的内部状态,甚至有的时候还要手动处理字符串对象的引用计数。
tok_get更加长,六百多行……
首先先搞明白,p_start和p_end这两个参数是用来给tok_get写的,标出这个token的范围的,实际上真的传进来的用来计算的参数只有tok。
进入函数第一件事把p_start和p_end清空。如果现在在新的一行的开头就先尝试着获取当前的缩进等级,并根据情况抛IndentError(比方说层数超上限了,或者缩进混用了,或者空格数量不统一了)。(别的语言可以把Whitespace略过,但这里是Python,一行开头的空格不能跳)
随后根据缩进层数的变化,根据情况返回INDENT或DEDENT。缩进层数的变化被抽象成了各自独立的token(可以理解为一对括号……嗯没错,可以理解为Python的缩进在Tokenize的时候已经被重写成括号了)。随后甚至还要处理type comment意外终止async函数体的问题……
啊放下这些奇怪的问题不管,我们继续往下看。
之后就是处理type comment。在ast库中可能会把type_comments设为True。这时type comment就不会被忽略了。
处理完EOF之后就是各种类型的token的手工处理了。
高能预警,这个东西写的真的……大家手工写完这么复杂的东西一定记得检查有没有漏情况。
先是一套针对bruf这四个字母的检查。为啥?当然是因为他们可以作为字符串前缀啊。如果看到双引号或者单引号,跳转到专门处理字符串的地方。
我们先假装不是字符串往下看。因为async和await需要特判,所以要先处理一下。这里有段很骚的注释,我给大家分享一下:
/* The next token is going to be 'def', so instead ofreturning a plain NAME token, return ASYNC. */
哇,上下文敏感tokenizer。
之后是对换行的手工判断,可能是NL也可能是NEWLINE。
随后是点。有可能是小数点,有可能是ELLIPSIS,也有可能是一个普通的DOT。
然后是数字开头的token。数字也要分各种奇奇怪怪的情况。比方说不能以0开头后接数字,因为以0开头意味着后面一个字母是表进制的,或者后接小数点。Python支持x(十六进制)o(八进制)和b(二进制)以及下划线分割(从Python 3.6开始)。还要考虑.(小数点)e(科学计数法)和j(虚数)。
插一句,以0开头后接数字是在PEP 3127里禁止的,主要是为了防止编程新手困惑,具体可以去看看这个PEP对相关问题的讨论。
接着看字符串。字符串会被原样传给parser,不做转义处理,开头的那些字母(bruf)会被一并传给parser。
随后是"\\\n"这种情况的特判,三字符和二字符特殊运算符的检查(token.c的东西)和括号匹配。最后是单字符特殊符号。
可以看到,想在Python里新增一种语法,在修改tokenizer这个层面上看是一件可能还行但也可能很麻烦的事情。对于海象运算符:=可能只是在Grammar/Tokens里加上COLONEQUAL然后重新生成一遍相关文件的事情,但是加入Async/Await却可能产生各种意想不到的问题,然后加上各种惊为天人的patch。
考虑到各种C-API的兼容问题,估计这个东西也不会改了(都被打上static了还改个啥),而是拿别的函数包一包,调用那些看着还行的接口。现在看着这东西可能还能苟段时间,调理也姑且还算清晰,但谁知道会不会暴雷呢?就像加入async的时候那样。
要写个结论的话……自己别这么写,嗯。
下一篇文章大概是关于Parser的一些周围设施,比如语法树节点以及Parser如何和Tokenizer对接等话题。