词法分析与 Token¶
词法分析到底在解决什么问题¶
源代码一开始只是字符序列。
编译器不适合直接在原始字符串上完成后续所有工作,因为那样后面的阶段会不断重复判断:
- 当前是不是关键字
- 这里是不是标识符
- 这个
=是赋值还是比较的一部分 - 字符串字面量在哪里结束
词法分析干的就是这件事:先别急着理解程序,先把源码切成一块一块能认的东西,也就是 Token。
用一段最小代码实际看一眼¶
例如源码:
在进入 Lexer 之前,它只是普通字符串。
进入 Lexer 之后,更接近下面这种结果:
这一步做完之后,编译器终于不用再一边往前挪一边猜“这串字符到底是啥”,而是能直接拿着已经分类好的东西往下走。
你可以把 Token 理解成什么¶
可以把 Token 理解成“已经初步分类过的词片段”。
例如:
大致会变成:
return:关键字a:标识符+:运算符1:整数;:分号
这样后面的 Parser 就不用再猜每个字符片段是什么身份了。
这个项目的 Lexer 是手写的¶
当前实现没有使用词法生成器,而是手写 Lexer。
这种做法的好处很直接:
- 控制流一眼能看懂
- 报错位置容易控制
- 加一个新 Token 类型时改动直接
它的核心流程是:
- 跳过空白和注释
- 观察当前字符
- 根据字符类型选择扫描分支
- 产出一个 Token
- 继续直到文件结束
如果你去看 src/c_core_compiler/lexer.py,会发现它本质上就是一台很直接的扫描机:
- 主循环不断往前走
- 每次先跳过空白和注释
- 然后根据当前字符决定进入哪条扫描分支
也就是说,这个 Lexer 没想搞什么花活,它就是老老实实让你能看明白“它到底怎么一格一格往前扫”。
它支持扫描哪些东西¶
从当前实现看,Lexer 支持:
- 标识符
- 关键字
- 十进制整数
- 字符字面量
- 字符串字面量
- 单字符运算符与分隔符
- 双字符运算符
还支持跳过:
- 空格、制表、换行
//单行注释/* ... */多行注释
这也解释了为什么这一层现在还算好读:项目支持的是受控子集,不是整本 C 语言标准,所以不会一上来就把你拖进最恶心的词法角落。
为什么双字符运算符要先识别¶
像下面这些:
==!=<=>=&&||
如果不优先识别,就会被拆成两个单字符 Token。
例如 == 会错误地变成两个 =。所以 Lexer 会先看两个字符能不能组成一个合法运算符,再决定是否走单字符分支。
这类细节看起来小,但恰好最能说明 Lexer 为什么不能糊弄。它的工作不是“差不多切一下”,而是尽量在最前面就把边界切准。
位置信息为什么重要¶
当前项目里的每个 Token 都会记录行号和列号。
这件事看起来像细节,其实很关键,因为后面的报错都依赖它。
比如:
- 词法错误要告诉你非法字符在哪
- 语法错误要告诉你期望的成分在哪里缺失
- 语义错误也需要指向具体位置
如果前面不记录位置,后面排错体验会很差。
在当前项目里,Token 里保存的行列号会一路被后续阶段用到,所以 Lexer 不只是“产出类别”,还要产出“类别出现的位置”。
字符串和字符字面量为什么单独麻烦一点¶
因为它们不是“扫到下一个空格就结束”这么简单。
例如:
'a''\n'"hello""a\nb"
这里涉及:
- 起始引号和结束引号
- 转义字符
- 未闭合字面量
所以 Lexer 必须单独处理,并且在未闭合时抛出明确错误。
如果你去看源码,会发现这里的处理方式也很直白:
- 看见起始引号就进入专门扫描函数
- 遇到反斜杠就知道可能有转义
- 到文件末尾还没闭合就报错
这正是手写 Lexer 的好处之一:你能非常具体地看到每类字面量是怎么被处理的。
词法阶段并不理解程序结构¶
需要特别强调:Lexer 只负责“切块”,不负责理解“这些块怎么组成语句或表达式”。
也就是说,Lexer 知道 if 是关键字,知道 ( 是左括号,但它不知道:
- 这个
if的条件是什么 - 这个括号是不是成对匹配
- 这里是不是合法表达式
这些都属于 Parser 的工作。
如果非要把词法层压成一句人话,那就是:
- 它负责把“字符”变成“带类别的符号”
- 但它不负责把“符号”变成“程序结构”
这个阶段的典型输出价值¶
如果你运行:
你看到的是一串已经被分类的 Token。它很适合用来判断:
- 问题到底出在词法阶段还是语法阶段
- 某个字面量有没有被正确识别
- 某个运算符有没有被错误拆分
这也是为什么 Token 输出是一个很重要的调试入口。