语言编译过程通常会被划分为多个不同的阶段,词法分析便是这一过程的起始步骤。执行词法分析的程序被叫做“词法分析器”,其主要功能是对输入的代码进行预处理,把其中的空格、制表符、换行符、注释等无效信息过滤掉,然后以词法单元的形式输出。尽管在2.1小节我们曾对词法分析器做过简单介绍,但是要设计出运行良好的词法分析程序,还需要理论方面的支撑。所以,本章的目标是更深入地学习词法分析器的运作原理和实现方式。
入正文之前,让我们先对案例做一下说明。笔者在前面文章中曾介绍过一个关于优惠规则的需求,非常适合作为案例来使用。因此,笔者就继续以其为例来展示词法分析器的实现思路。完整的DSL脚本如代码5-1所示:
代码5-1
set rate = 0.9 where current_date >= "2024-11-01" and
current_date <= "2024-11-15" and product_type = "phone" and brand = "apple";
笔者在前文中曾经说过,选择DSL脚本语法模式的时候,应尽量贴近主流通用编程语言的习惯,比如使用大括号({})表示代码块;使用分号表示换行等。上述案例模仿了SQL的语法模式,虽然后者也属于DSL,但考虑到其流行的程度,作为参考也是完全行得通的。另外一个值得注意的地方是代码5-1所示DSL脚本是大小写不敏感的,也就是说关键字SET和set具备相同的含义,这一点也是模仿了SQL。
语义模型部分,读者可参看图 1.3,此处不再重复贴图。
请读者做好心理准备,自本章起,内容将切入到本系列文章的核心模块,诸多与编译原理相关的知识体系会随章节推进逐步展开。首个学习单元为文法——此乃手动实现外部DSL流程中的关键环节。关于文法的基础概念,笔者曾在前文进行过概要性阐述,以下将进入系统性的深度探究阶段。
在形式语言理论范畴内,文法被定义为对特定语言语法规则的形式化精确描述体系。其核心功能在于界定语言合法句子的字符串集合,以及规范从基础符号构建合法句子的生成机制。具体体现为以下两个维度的作用:
- 构建语言语法规则的层次化描述体系,刻画语法结构的层级组织关系。
- 确立语法分析树的形式化生成规则,定义句法结构的推导逻辑。
编程语言的文法是构建编译器的基础,无论是手动实现语法分析器还是借助语法分析器生成器,均依赖于此。从形式化定义来看,文法是由产生式构成的集合。产生式通过特定符号(如箭头->)划分为左右两部分:箭头左侧的符号称为产生式的“左部”,右侧的符号序列称为产生式的“右部”。此处的箭头不仅是分隔符号,更表征“推导”这一形式化操作语义。以文法5-1为例,其完整描述了二进制字符串的句法构成规则:
文法5-1
S -> S BIT | BIT #产生式1
BIT -> '0' | '1' #产生式2
读者可以这样理解文法5-1所包含的两条产生式规则:
- 根据产生式1,可以由S推导出S BIT或BIT。
- 根据产生式2,可以由BIT推导出0或1。
文法5-1中的S和BIT称作“非终结符(Nonterminal)”,0和1称作“终结符(Terminal)”,非终结符和终结符统称为文法符号。
需要明确的是,非终结符既可以出现于产生式的左部,也可以出现于右部,而终结符只能出现于产生式的右部。产生式左部的符号必然是非终结符,这是由于所有推导操作均作用于非终结符。终结符作为原子性符号,其语义特性决定了无法通过推导产生其他符号,这一属性与其命名逻辑完全契合。
简单起见,本书中使用大写字母或由大写字母组成的单词表示非终结符。终结符号的表示方式相对来说要复杂一点,如下列表中的内容全都可以被视为终结符号:
- 各类小写字母或数字。
- 各类运算符号,如+、-、%、&&等。
- 各类界符,如{、(等。
- 各类DSL脚本中的关键字。
- 没有出现在产生式左部的符号。
为满足专业技术书籍的可读性规范,本书对终结符的描述统一采用单引号(')括注形式,关键字的表示亦遵循此规则。在工程实践中,可将Token与终结符视为等价概念——二者本质上指向同一语义单元,更直观地说:DSL脚本中的词素即对应终结符实体。
另一个具有特殊语义地位的非终结符是“S”,按照形式语言理论的惯例,其被定义为文法的开始符号(从理论层面而言,允许选用如START等任意符号作为开始符号),该符号必然作为文法中首个产生式的左部元素存在。
读者如果对“推导”的概念不太理解的话,可以将其想象成代数中的等式变换或方程推导。以表示数学加法的文法5-2为例:
文法5-2
z = x '+' y
x = '1'
y = '2'
计算z值的前提是首先将x、y的值确定下来并代入到第一个产生式中,这是小学学到的知识。那么x、y的值又是什么呢?根据最下面的两个产生式可以推导出它们的值分别是1和2。再根据第一个产生式,最终可以推导出z的值是3。可以看到,文法的推导过程与代数计算过程在思想上是非常类似的,只不过步骤更复杂一点。读者可以根据文法5-1尝试一下对字符串“1010”进行推导,看看是否可以将其分析出来。
每一次推导,都需要将产生式右部中的一个非终结符替换为对应的文法符号,但并不是随意找一个符号进行替换就可以的。正相反,必须遵从一定的规律。因此,我们可以将推导过程分为两类:“最左推导(Leftmost Derivation)”和“最右推导(Rightmost Derivation)”。使用最左推导时,每次都会选择产生式中最左面的非终结符;而最右推导则是选择产生式中的最右侧的非终结符。
在工程实践中,最左推导与自顶向下的语法分析方法形成直接映射关系,是手动实现语法分析器的典型技术路径。最右推导则对应自底向上的语法分析方法,由于其实现逻辑与调试过程的复杂性较高,通常不作为技术人员的优先选择。
以文法5-1为例,针对字符串“10”的最左推导过程如下:
S -> S BIT -> BIT BIT -> 1 BIT -> 10
可以看到,每次的推导都是从右部最左边的非终结符开始的。
产生式中的符号“|”表示“或者”的意思,类似Java中的或运算符(||)。使用|符号可以简化文法的书写,但并不是强制性的要求。以文法5-3为例,其效果与文法5-1完全一致,但看起来却不如后者简洁。
文法5-3
S -> S BIT
S -> BIT
BIT -> '0'
BIT -> '1'
为了让文法更易于阅读,建议读者按如下方式去编写:
- 各文法符号之间使用空格进行分陋,不要全挤在一起。当一行中的内容太长的时候,可考虑使用换行符。
- 文法中的关键字可用一些特别的方式进行强调,如黑体或斜体。一般来说,关键字都是终结符,因此可使用单引号(')将其括起来。
- 以方便推导的方式来定义产生式的顺序。
- 注意二义性。
- 为复杂文法加上注释。笔者个人比较习惯于使用#符号作为注释的前缀,读者亦可以根据自身习惯选择其它的字符,如//、/**/等,建议与通用语言的使用习惯对齐。
- 可以考虑使用一些特殊的符号如分号(;)来表示每一条产生式的结束。
上述建议之中,以第3项最为抽象。到底什么是“以方便推导的方式来定义产生式的顺序”呢?请笔者首先看一下文法5-4,其对应的是Java中变量声明“int x;”语句的语法规则。当然,笔者进行了适当的简化,包括变量的类型以及数量都做了限制。
文法5-4
S -> DECLARE
DECLARE -> TYPE IDENTITY ';'
TYPE -> 'int' | 'float' | 'double'
IDENTITY -> ...
与之对比,文法5-5在文法5-4的基础上对产生式的顺序进行了一些调整:
文法5-5
S -> DECLARE
IDENTITY -> ...
...
DECLARE -> TYPE IDENTITY ';'
TYPE -> 'int' | 'float' | 'double'
笔者刻意调整了IDENTITY和DECLARE两个产生式的定义顺序,并将二者的声明位置进行物理分隔。显然,此类文法虽无形式化错误,但显著增加了文本的阅读成本。依据人类认知习惯,当在首个产生式中遇见DECLARE符号时,理想情况是能够立即定位到该符号的定义。文法5-4通过优化产生式顺序实现了这一目标,而方法5-5的结构则未能达成——尤其是当IDENTITY与DECLARE的定义之间夹杂大量其他产生式时,这种文法组织方式与人类的阅读和思维习惯存在显著冲突。
需要指出的是,目前尚未形成标准化的技术规范对产生式的定义顺序进行约束,这使得文法编写过程中必须遵循“以人为本”的设计原则:唯有具备良好可读性和可理解性的文法,才是符合工程实践需求的优秀文法。这一设计理念与代码质量评判标准具有一致性——判断文法或代码优劣的首要准则,是其能否被开发者高效理解。
关于产生式的声明顺序,请读者务必注意非终结符号S的定义,它表示的是文法的记点,一个文法应该只有一个起点,且通常是第一个产生式。
从上述内容可知,仅凭借文法即可完整描述一门语言的语法规则,其作为语法分析器实现的基础,作用不言而喻。然而,若需在语法分析过程中执行更多操作(如组装语义模型、实施类型检查、构建语法树或进行实时翻译与计算等),单纯依靠文法是不够的,需要对其进行功能增强。增强的方式是将行为代码嵌入产生式中,以此告知语法分析器在语法分析前后应执行的具体逻辑。这些嵌入的代码片段称为“语义动作(Semantic Action)”,通常由语法分析器的实现语言编写。按照惯例,语义动作需用大括号({})括起,可放置于产生式中的任意位置,其位置即对应执行位置。文法5-6展示了将语义动作嵌入产生式尾部的方法:
文法5-6
S -> S BIT | BIT
BIT -> '0' {print('0')} | '1' {print('1')}
在第二个产生式中,笔者在终结符0和1的后面分别加入了一个语义动作,用于打印当前的字符。如果使用了递归下降的方式来实现语法分析器的话,只需要将动作代码插入到分析器中对应的位置上即可。以文法5-6为例,当语法分析程序对符号BIT进行解析的时候,无论输入是0还是1,都会在退出BIT的分析子程序之前将输入值进行打印。至于如何实现,如果语法分析器由Java编写的话,最简单的方式当然就是调用System.out.print()方法来将输入字符打印在控制台上。如果使用语法分析器生成器的话,那动作的处理就简单多了,您只需要按工具的要求将动作代码插入到文法中的适当位置即可,它们会被自动地执行。
根据笔者的实践经验,语义动作的核心功能在于引导技术人员在语法分析阶段执行实时翻译操作或构建抽象语法树。对于语法分析器生成器而言,语义动作则转化为可直接执行的代码实体。这意味着在手动实现语法分析器时,语义动作的描述不必完全受限于严格的书写规范,尤其当代码量较大时,可采用方法调用等简化手段替代具体实现细节。但需要注意的是,笔者不建议在文法中过度使用语义动作,以免导致文法的可读性和可维护性显著下降。
综上所述,尽管文法的基础概念看似简洁,但其设计过程中若考虑不周可能引入二义性问题——即同一输入序列可能对应多棵语法分析树。值得庆幸的是,这类问题在DSL设计场景中较为少见,且相关理论体系较为复杂,因此本书不展开深入讨论。感兴趣的读者可自行查阅形式语言理论的专业文献进行拓展学习。接下来,我们将继续围绕文法理论展开系统性探讨。
有关文法与语言,很容易产生这样的误解,即二者之间是一对一的关系。实际情况并非如此,它们的关系其实是多对一的,也就是说同一个语言可能会对应多个不同的文法。以前文的二进制字符串文法为例(即文法5-1),文法5-7的定义其实也是正确的:
文法5-7
S -> BIT | BIT S
BIT -> '0' | '1'
二者的本质差异体现于语法分析树的结构。尽管笔者仅对S与BIT两个符号的顺序进行了调换,但两个文法生成的语法分析树结构完全不同。以二进制序列“101”为例,图 5.1-a和图 5.1-b分别呈现了文法5-1和文法5-7对应的语法分析树形态。需要注意的是,并非所有文法均允许符号位置的调换——例如具有左结合性的减法文法,其符号顺序不可随意调整,后文将对此展开具体阐述。
截止到目前为止,相信读者已经对文法所涉及到概念有了一个大致的了解,接下来让我们再对它的理论定义做一下说明。
常见的文法有三类:正则文法(Regular Grammar,简称:RG)、上下文无关文法(Context-Free Grammar,简称:CFG)和上下文相关文法(Context-Sensitive Grammar,简称:CSG)。三者之间是一种层级关系,如图 5.2所示。
您可以这样理解它们的关系:RG文法是CFG文法,CFG文法是CSG文法。不过它们之间的区别并非我们的重点,读者只需要知道一点:描述DSL语言的文法都是CFG文法,包括前面内容当中的示例。读者也许会感到好奇:所谓的“上下文无关”,到底是什么意思呢?这一概念理解起来比较简单,其实就是指在应用产生式规则的时候,只需考虑当前的非终结符即可,其周围的符号并不会对它产生影响。以文法5-7中的BIT符号为例,无论它出现在什么位置以及其前后有什么样的符号,都可以将其替换为0或1。
CFG文法的优势在于它超强的表达能力以及简单性,其可以描述非常复杂的语法结构,比如嵌套、递归等。实际上,大部分现代的编程语言都使用了CFG文法来描述其大部分的内容。与此同时,大部分的语法分析器生成器也需要使用CFG文法对语法规则进行定义。当然,对于某些特殊的语言规则,仅使用CFG文法是不行的,比如“使用变量前必须先声明”这一要求,就需要依托符号表的帮助才行。除此这外,泛型、重载、名称绑定等也是CFG所无法描述的。所以,Java的语法实际上并不是CFG的,而是CSG。
CFG文法可表示为一个四元组:
G = { VN , VT , P , S }
- VN:非终结符集合
- VT:终结符集合
- P:产生式集合
- S:开始符号
通过定义可知,CFG文法由四部分组成,其中S描述了推导的起点、P描述了推导规则、VT描述了代码中的各类元素(关键字、标识符等)、VN描述了语言的结构(句子、表达式等)。读者可以回想一下前面所有的文法示例,是不是都包含了这四个部分?
细想下来,一门复杂的编程语言仅需四个核心元素即可完整表达其语法规则,这确实令人惊叹,而这也正是CFG文法强大表达能力的直观体现。不过需要明确的是,对于通用编程语言而言,若要完整覆盖语言规范,通常还需考虑CFG之外的其他因素。以Python、JavaScript、Ruby等脚本语言为例,其核心语法结构可通过CFG进行形式化描述,从而便于解析器处理;而诸如“动态类型检查”这类语言特性,则需要在语法分析阶段之后引入额外的语义处理机制进行支持。