Carbon 语言运算符 Token 设计解析:固定符号集、Max Munch 词法与空白决定的中缀/前缀/后缀
【免费下载链接】carbon-langCarbon Language's main repository: documents, design, implementation, and related tools. (NOTE: Carbon Language is experimental; see README)项目地址: https://gitcode.com/GitHub_Trending/ca/carbon-lang
本篇文章基于 Carbon Language 仓库中的设计提案 proposals/p000601-operator-tokens.md,系统讲解 Carbon 运算符 token 的设计决策:语言为何采用固定运算符 token 集、符号 token 如何通过"max munch" 最长匹配规则进行词法切分、以及如何借助运算符两侧空白同时区分中缀(binary)、前缀(prefix)与后缀(postfix)三种运算符形式。读完本文,你将理解 Carbon 词法层运算符设计的完整脉络,并能对照 token_kind.def、lex.cpp 与 tokenized_buffer.h 中的真实实现印证这些规则。
问题背景:运算符 token 从何而来
任何编程语言都需要一组 token 来表示运算符。围绕"运算符 token 如何生成",业界存在两条截然不同的技术路线:
- 固定 token 集路线:语言规范预先定义好一组运算符 token,开发者不能自行发明新的运算符符号。例如:
- C++:有一组固定的运算符 token,且
and、or等关键字形式的运算符是&&、||等符号运算符的词法同义词; - Rust:同样在语言层面预定义运算符集合。
- C++:有一组固定的运算符 token,且
- 可扩展运算符路线:语言提供可扩展的运算符定义规则,允许开发者注册不属于基础语言的运算符。例如Swift与Haskell都允许自定义运算符。
除了"是否可扩展"这一维度,还存在"词法切分策略"的差异。对于一段连续的符号字符序列,主流做法有三种:
- 每次词法切分都取"已知的最长运算符":在每一步词法切分时,从当前位置的剩余字符序列中,匹配出规范中定义的最长运算符。例如在 C++ 中,
a += b是 3 个 token(a、+=、b),而a =+ b是 4 个 token(a、=、+、b),因为语言中存在+、=、+=,却不存在=+。这种方法通常被称为"max munch"。 - 每次词法切分都取"最长的符号字符序列"作为运算符:若该序列并非已定义的运算符,则程序直接非法。例如在一个类 C++ 语言中,
a =+ b会因=+不是合法运算符而报错,而不是被解释为a = (+b)。 - 借助语义信息切分:依据操作数类型等语义信息决定如何把一串符号字符拆分成一个或多个运算符。
提案核心:固定 token 集 + 两种 token 种类
提案给出的答案是:Carbon 使用由语言规范定义的一组固定 token 来表示运算符,开发者不能定义新 token 来代表新运算符(运算符重载等设施属于提案范围之外的内容)。这组 token 分为两种:
- 符号 token(Symbolic tokens):由一个或多个符号字符组成,不含任何可在标识符中出现的字符、不含引号字符、不含空白。
- 关键字 token(Keywords):遵循单词的词法规则。
符号 token 采用max munch 规则词法切分:每次词法切分时,若当前位置存在规范定义的符号 token 起始,则切出最长的那一个。
一个重要的澄清:并非语法中所有符号 token 都充当"运算符"。例如(与)用于界定多种语法产生式,.也不宜视为运算符——因为它的"右操作数"并不是表达式。
两类 token 的分工:不是同义改写
提案特别强调,两种 token 面向不同的用途,而不是同一功能的两种拼写:
- 符号 token用于广为人知的运算符,如数学运算符
+、*、<等。这类符号运算符通常预期对某些用户自定义类型也有意义,是未来实现运算符重载时的候选对象。 - 关键字 token用于以下场景:
- 执行流程控制的运算符,如
and、or、throw、yield,以及与它们紧密相关的not。这些运算符的行为超越了"求值操作数并计算值",必须从其他运算符中突显出来; - 罕见、不值得占用有限符号 token 预算的运算符(如可能的 xor 或循环移位);
- 优先级极低、以及部分优先级极高的运算符;
- 没有约定俗成符号、也不打算发明符号的专用运算符,例如
as。
- 执行流程控制的运算符,如
提案明确声明:本节的示例运算符仅用于解释两类 token 的设计动机,并不在本提案中实际引入这些运算符。
源码印证:在 toolchain/lex/token_kind.def 中可以看到,
and、or、not、as等都被定义为CARBON_KEYWORD_TOKEN(关键字 token),而+、*、&、=等被定义为CARBON_SYMBOL_TOKEN(符号 token),与提案的分类完全一致。
符号 token 初始清单
提案给出的初始符号 token 清单如下:
( | ) | { | } | [ | ] |
, | . | ; | : | * | & |
= | -> | => |
该清单仅覆盖当时已批准的语法产生式,并会随着后续语言提案引入更多符号 token 而持续扩充。
实现中的演进:从 15 个到一整套符号表
对照当前仓库的 toolchain/lex/token_kind.def,可以看到这张初始清单已经大幅扩展。文件以 X-macro 的方式统一声明所有 token,并明确要求符号 token 按拼写从长到短排序,注释直接写道:"symbols need to be ordered from longest to shortest to effectively provide max-munch lexing"(见 token_kind.def)。当前实现包含:
- 三字符符号:
>>=、<=>、<<=、->?; - 两字符符号:
&=、^=、:=、:?、==、=>、!=、>=、>>、<=、<>、<<、<-、-=、->、--、%=、|=、+=、++、/=、*=、~=; - 单字符符号:
&、@、\、^、:、=、!、>、<、-、%、.、|、+、?、/、*、~; - 成对分组符号:
(/)、{/}、[/],通过CARBON_OPENING_GROUP_SYMBOL_TOKEN与CARBON_CLOSING_GROUP_SYMBOL_TOKEN建立开闭关联; - 单字符终结/分隔符:
,与;,被定义为CARBON_ONE_CHAR_SYMBOL_TOKEN——这类符号"构造上恰好是一个字符,无法再与其他字符组合成新符号",因此无需参与 max-munch 匹配(见 token_kind.def)。
这种 X-macro 设计让"新增符号 token"只需在token_kind.def中加一行声明,编译期即可自动生成分发表,恰好呼应提案中"该清单应随提案持续扩展"的预期。
Max munch 在词法器中的真实实现
lex.cpp 中的LexSymbolToken是多字符符号 token 切分的核心实现。它借助 LLVM 的StringSwitch从当前位置的子串开始匹配,并针对token_kind.def中的每个CARBON_SYMBOL_TOKEN生成一个.StartsWith(Spelling, TokenKind::Name)分支:
TokenKind kind = llvm::StringSwitch<TokenKind>(source_text.substr(position)) #define CARBON_SYMBOL_TOKEN(Name, Spelling) \ .StartsWith(Spelling, TokenKind::Name) // ... #include "toolchain/lex/token_kind.def" .Default(TokenKind::Error); if (kind == TokenKind::Error) { return LexError(source_text, position); } TokenIndex token = LexToken(kind, position); position += kind.fixed_spelling().size(); return token;正是"先声明三字符、再声明两字符、最后声明单字符"的排序,保证了对->与->?、=与==、=>这类"短拼写是长拼写前缀"的 token 总能优先匹配更长的那一个——这就是提案中 max munch 规则的落地点。单字符符号、分组符号则由LexOneCharSymbolToken、LexOpeningSymbolToken、LexClosingSymbolToken等专用函数经分发表单独处理(见 lex.cpp),其中开闭符号还会维护open_groups_栈,用于括号配对与错误恢复。
空白规则:同一符号 token 如何兼作中缀/前缀/后缀
提案最核心、也最独特的设计是:用运算符两侧空白的有无来确定运算符的"fixity"(中置性),与人类读者的阅读直觉保持一致。以*为例(issue #523 已决定*未来要同时支持前缀、中缀、后缀三种用法),表达式a * - b存在两种合法解析:
a * (- b):a乘以b的取负;(a *) - b:从指针类型a *减去b。
仅靠 token 本身无法区分,因此提案以空白为判据:
a * -b采用第一种解释(*两侧有空白 → 中缀;-与操作数b之间无空白 → 一元前缀);a* - b采用第二种解释(*右侧有空白、左侧无空白 → 后缀;-两侧有空白 → 中缀);- 其余组合(
a*-b、a *- b、a* -b、a * - b、a*- b、a *-b)一律作为错误拒绝。
空白规则的正式表述
完整规则如下:
- 一元运算符与其操作数之间不得有空白;
- 二元运算符两侧的空白必须一致:要么两侧都有空白,要么两侧都无空白;
- 若二元运算符两侧均无空白,则运算符之前的 token 必须是标识符、字面量或任意类型的闭括号(如
)、]、}),运算符之后的 token 必须是标识符、字面量或任意类型的开括号(如(、[、{)。
规则 3 的意义在于:允许2*x*x + 3*x + 1这类用无空白表达更高优先级的写法。判定"操作数开始/结束"的 token 集合被明确定义为:
- 标识符,如
x*x + y*y; - 字面量,如
3*x + 4*y或"foo"+s; - 任意类型的括号,方向背对运算符,如
f()*(n + 3)或args[3]*{.real=4, .imag=1}。
五种 token 变体
从 token 形成的角度看,空白规则使每个符号 token 拥有四种变体(variants):
| 变体 | 空白分布 | 可充当的角色 |
|---|---|---|
| 二元变体(binary) | 两侧均有空白 | 二元(中缀)运算符 |
| 二元变体(binary) | 两侧均无空白,且前一 token 为标识符/字面量/闭括号、后一 token 为标识符/字面量/( | 二元(中缀)运算符 |
| 一元变体(unary) | 两侧均无空白且不满足上一行条件 | 前缀或后缀运算符 |
| 前缀变体(prefix) | 仅在左侧有空白 | 前缀运算符 |
| 后缀变体(postfix) | 仅在右侧有空白 | 后缀运算符 |
使用规则为:在非运算符上下文中,任何变体都可接受;在运算符上下文中,只有二元变体可作二元运算符,只有前缀或一元变体可作前缀运算符,只有后缀或一元变体可作后缀运算符。
错误恢复的边界情况
从错误恢复角度,该规则要求"没有表达式上下文可以以看似操作数结束的 token 开头,也没有表达式上下文可以以看似操作数开始的 token 结尾"。一个已知例外出现在函数定义中:
fn F(p: Int *) -> Int * { return p; }这里的两个Int *都是错误的(*后直接跟闭括号或{,无法构成合法的一元后缀用法,也不满足两侧无空白二元变体的条件)。第一个Int *容易检测与诊断;第二个则更具挑战性——前提是{...}本身是合法的表达式形式。提案预期可以容易地区分"以{开始的代码块"与"以{开始的表达式"({}除外)。而{}作为带返回类型函数体并不合理,因此"错放空白 +{}"组合导致的错误预计罕见,剩余情况也能良好恢复。
实验性声明与实现状态
提案明确标注:"禁止一元运算符与其操作数之间存在空白"这一选择是实验性的。空白规则当时已在两个层面落地:
- Carbon 工具链(PR #576)通过在 token 中记录其后是否带尾随空白,对所有运算符实施该规则;
- 可执行语义(commit
04d3a885ae01a779aadb19f51ec7a5a12ffe295c)针对*运算符,按上文描述形成四种不同的 token 变体。
源码印证:当前仓库中,空白信息由 tokenized_buffer.h 暴露两个查询接口——
HasLeadingWhitespace(TokenIndex)与HasTrailingWhitespace(TokenIndex)。其实现(tokenized_buffer.h)读取TokenInfo中的has_leading_space()标志:某 token 是否"带尾随空白",实际等价于检查其后继 token 是否带前导空白。也就是说,每个 token 只需记录自己是否紧跟空白,解析器即可据此推断运算符两侧的空白分布,进而判定其 fixity 变体。
设计理由:与 Carbon 语言目标的对齐
提案从 Carbon 的三项核心目标出发给出理由:
软件与语言演进(Software and language evolution)
- 不允许用户自定义运算符,降低了未来语言新增运算符与既有用法冲突的可能性。虽然 max munch 规则意味着新增运算符可能改变既有代码的解释,但由于运算符集合预先可知,这类问题易于检测和解决。
易于阅读、理解和编写的代码
- 固定运算符集意味着开发者无需理解无界、可扩展的运算符及其优先级规则;不适合用知名符号表达的功能,倾向于以具名操作而非符号暴露,提升了不熟悉特定代码库的开发者的可读性;
- 要求运算符周围空白使用一致,减少混淆性格式化的可能;
- 允许二元运算符"两侧同时有空白"或"两侧同时无空白",使
2*x*x + 3*x + 1这类通过省略空白提升可读性的写法获得语言官方认可,格式化工具因而可以预期保留用户的空白选择; - 选择"切分最长已知符号 token"而非"切分最长符号字符序列",使
x = -*p;这类连续前缀/后缀运算符的表达更易书写。
与既有 C++ 代码的互操作与迁移
- 固定运算符集使 Carbon 运算符到 C++ 运算符的映射更简单——无需处理把任意自定义运算符映射为 C++ 形式的需求;
- "固定运算符集 + max munch 规则"与 C++ 采用的方案一致,对 C++ 开发者而言非常熟悉;
- 空白规则允许
*同时承担乘法、解引用、指针类型构成三种角色(与 C++ 相同),同时让 Carbon 能够把类型表达式当作表达式处理。
备选方案回顾
提案还讨论了三种被否定的备选方案,可作为理解本设计权衡的参照:
备选一:切分最长符号字符序列而非最长已知运算符
- 优点:新增运算符无需改动词法规则;若未知运算符一律拒绝,新增运算符也不会改变既有合法代码的含义。
- 缺点:连续的前缀/后缀运算符将被迫加括号或用空白分隔。例如
Int**会被切分为Int+ 单个**token,**p会被切分为单个**+p(当不存在**运算符时)。虽然可以定义**、***……为运算符,但会给语言规则增加复杂性与不一致性。
备选二:可扩展运算符集
- 优点:提升表达力,尤其是嵌入式领域特定语言(DSL)。
- 缺点:损害可读性(至少对不熟悉相关代码库的人);自定义运算符可能与新引入的标准运算符冲突,损害语言演进能力(虽可通过为自定义运算符提供独立词法语法来降低风险);要么采用"最长符号字符序列"切分(有备选一的缺点),要么引入更复杂的切分规则(如依据当前作用域内可用的运算符重载来切分),增加复杂度。
备选三:不同的空白限制
- 要求二元运算符后跟
[或{时必须加空白(issue #520 有完整讨论与 leads 决策)。对fn F() -> Int*{ return Null; }与var n: Int = pointer_to_array^[i];这类例子,该规则可以形成一元运算符而非二元运算符,更符合开发者预期。- 优点:为新增产生数组的后缀
^解引用运算符(或类似后缀运算符)留出空间,不会对"指向数组的指针"造成意外;也允许函数体的{前一致地省略空白。 - 缺点:规则更复杂且不对称(必须允许闭方括号出现在无空白二元运算符之前,以支持
arr[i]*3);与以[或{开头的表达式形式相互干扰,例如Time.Now()+{.seconds = 3}或names+["Lrrr"]。
- 优点:为新增产生数组的后缀
小结:一份"少即是多"的词法契约
p000601提案确立的运算符 token 设计可以概括为三条契约:
- 固定符号集:运算符 token 由语言规范唯一确定,符号 token 用 max munch 最长匹配切分,关键字 token 走单词词法,两者分工明确、不可互相替代;
- 空白即语义:运算符两侧空白的有无决定其中缀/前缀/后缀身份,为
*这类"一符多用"的运算符提供了简单、无歧义、利于错误恢复的解析依据; - 演进受控:任何新运算符都必须经由语言提案进入 token_kind.def 的符号表,同时由于集合固定且顺序已知,新增 token 对既有代码的影响可被预先评估。
这份设计既吸收了 C++ 固定运算符与 max munch 的成熟经验,又以"空白对称性"换取了远超 C++ 的表达式书写自由度,是理解 Carbon 词法与语法层后续设计(如 p001191-bitwise-and-shift-operators.md、p000601 之后的运算符扩展提案)的重要基石。若希望进一步研究实现细节,建议从 toolchain/lex/lex.cpp 的LexSymbolToken与 toolchain/lex/tokenized_buffer.h 的空白查询接口入手,配合 toolchain/lex/tokenized_buffer_test.cpp 中的测试用例验证各类空白组合的切分结果。
【免费下载链接】carbon-langCarbon Language's main repository: documents, design, implementation, and related tools. (NOTE: Carbon Language is experimental; see README)项目地址: https://gitcode.com/GitHub_Trending/ca/carbon-lang
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考