当前位置: 首页 > news >正文

(二十六)深度解析领域特定语言(DSL)第四章——词法分析:基于正则表达式的词法分析器

         基于正则表达式的词法分析器,其核心在于预先为各类Token定义由正则表达式构成的词法规则列表,并通过正则表达式与DSL脚本的模式匹配完成Token识别,例如词法5-1所示。需要强调的是,词法规则列表中的元素具有严格的顺序性,这一设计机制能够有效解决Token识别过程中的规则冲突问题。

        有了词法规则之后,我们应该怎样去使用它们呢?基本步骤如下:

  1. 按照词法规则的顺序构建词法规则列表对象。
  2. 将所有源代码转换成字符串输入到词法分析器中,以该字符串的头部(左部)作为Token识别的起点。按顺序扫描词法规则列表中的元素并调用正则表达式引擎与源代码字符串进行匹配,如果发现了匹配的词素,便将其转换成Token对象;未发现的的话,则通过异常的方式来结束整个词法分析流程(或者返回UNKNOWN类型的Token)。注意,正则匹配必须从源代码字符串的头部开始,这就意味着词法5-1中的正则表达式都需要加上“^”符号,以表示从目标字符串的开头进行匹配。
  3. 每完成一次Token的识别便对源代码字符串的内容进行修正,将本次匹配到的词素从原串中剔除掉并将新的字符串用于下一次的识别。
  4. 重复第3步,直至源代码字符串为空串或异常终止。

        针对给定的源代码,词法分析器可一次性完成全量扫描并输出Token列表(List),后续语法分析流程将直接以该列表为输入进行处理。这种设计的优势在于能够显著简化语法分析阶段可能涉及的回溯逻辑——若采用逐Token获取的交互模式,回溯操作的复杂度将大幅增加。  

        关于“回溯”(Backtracking)概念,其本质可类比迷宫寻路问题:当在节点A面临B、C、D三条可选路径时,由于无法预先判断正确方向,需任选其一(如B)进行试探。若路径B受阻,则需退回节点A并尝试路径C;若路径C仍不通,则需再次退回节点A并尝试路径D。在此过程中,从错误路径返回至前序状态以重新选择的操作,即称为回溯。

        那么为什么在进行语法分析的时候会产生回溯呢?请读者看一下文法5-9:

文法5-9S -> 'select' A | 'select' B
A -> 'name'
B -> 'date'

        假设脚本内容为select date,当处理终结符select时,可能因S右部存在多个可选产生式(如select A和select B)而引发分析歧义。如何解决这一问题?可先尝试使用第一个右部select A进行推导。若通过符号A仅能推导出终结符name(与输入date不匹配),此时语法分析器不能直接报错——因为另一个右部select B可能成功推导出输入字符串,表明输入语法正确。  

        正确的处理逻辑应为:若select A推导失败,需回溯并尝试select B推导,仅当所有可选产生式均推导失败时,方可判定输入脚本语法错误。由此可见,选择结构是产生回溯的直接诱因,而其根本原因在于文法的不确定性。以下通过一个更极端的示例做进一步说明,如文法5-10所示:

文法5-10S -> 'hello' NAME
NAME -> 'John' | 'Mike' | 'Alan' | ... | 'World'

        当输入脚本为hello World的时候会出现什么情况呢?我们可能需要将NAME右部中的所有符号都尝试推导一遍,才能最终确定语法分析的结果是否正确。注意,这还仅仅是最简单的情况。假如产生式的深度非常高,如文法5-11所示:

文法5-11A -> A1 A2 A3 A4 ... Ax | A1 A2 A3 A4 ... Ay
A1 -> ...
A2 -> ...
Ax -> ...
Ay -> ...

        文法5-11中的A1、A2、A3、...、Ax和Ay等符号都是非终结符。可以想象,当输入的内容符合A产生式中的第二个右部的时候,语法分析的效率将是非常低下的。因为我们必须先将第一个右部中的符号,即A1、A2、A3、...、Axx全都推导一遍,发现不匹配后才会使用第二个右部进行推导。很明显,回溯的存在使得语法分析器做了很多的无用功。

        虽然本章对回溯机制进行了较为深入的探讨,但如何系统性解决回溯问题并非本书核心内容。实际上,该问题的解决方案涉及First集合、左公因子提取等编译原理概念,建议读者查阅专门资料进行深入学习。若实际设计DSL时遇到必须处理回溯的场景,可采用以下策略:1)通过文法重构消除回溯(如提取左公因子、改写递归结构);2)在DSL设计阶段主动规避可能引发回溯的语法结构;3)对于脚本规模较小的DSL,可接受适度回溯带来的性能损耗——由于处理文本量有限,此类场景下的性能问题通常不显著。

        说了一些题外的内容,让我们继续回到词法分析器工作原理的话题。接下来,笔者以代码5-1为例,通过图形的方式来对基于正则表达式的词法分析器工作流程进行说明。简单起见,笔者只从中摘取了部分内容,如代码5-7所示:

代码5-7set rate = 0.9

        图 5.5展示了词法分析器对代码5-7进行分析时,其内部各种信息的变化趋势。可以看到,随着分析工作的进行,源代码字符串会变得越来越短;而待输出的Token列表中的内容则会越来越多。待所有的源代码分析完成之后(即第五次扫描完成),词法分析器会将该Token列表对象作为结果进行输出。

图 5.5 基于正则表达式的词法分析器内部工作流程图示

        由于词法分析器采用一次性读入DSL脚本并生成完整Token列表的工作模式,DSL编译过程呈现为纯粹的线性处理流程,如图 5.6所示。在此架构下,词法分析器与语法分析器形成单向数据传递关系——词法分析器输出Token列表后,语法分析器独立完成后续处理,二者无动态交互。这种设计模式在一定程度上简化了DSL编译器的实现复杂度。需特别说明的是,图 5.6所示流程仅为本案例的特定实现方式,编译程序的通用流程与Token识别模式并无必然关联。此处展示该方案旨在拓宽读者的技术视野,为语法分析器的设计与实现提供多维度的思路参考。

图 5.6 基于正则表达式的词法分析器运作流程

        在阐述完基本原理后,接下来将展示具体实现代码。如前文代码5-3所示,已对词法单元类型TokenType进行了基础定义。针对基于正则表达式的词法分析器实现,需对该定义进行扩展,将词法规则显式关联至TokenType中。修正后的代码片段如代码5-8所示:

代码5-8enum TokenType {SET("^set"),LE("<=", "^<="),LESS_THAN("<", "^<"),GE(">=", "^>="),GREATER_THAN(">", "^>"),SEMICOLON(";", "^;"),KEYWORD("<keyword>", ""),...FIELD("field", "^[a-zA-Z][a-zA-Z0-9_]*"),//优惠条件名称NUM("num", "^[0-1](\.\d)?\d?$"),//数字STRING("string", "^"[\s\S]*?""), //字符串OPERATOR("<operator>", ""),//运算符,LE、GE等符号的超类...;boolean isA(TokenType type) {if (type == TokenType.OPERATOR) {switch (this) {case LE:case EQ:case GE:case GREATER_THAN:case LESS_THAN:return true;}}return false;}
}

        为避免重复,笔者刻意省略了一部分代码。此外,尽管代码5-3和代码5-8十分的相似,但仍有如下三点不同值得注意:

  1. 所有的正则表达式必须要以符号“^”作为前缀,表示匹配过程从字符串的起始位置开始。
  2. 代码5-8中的枚举值定义具有顺序性。比如“<=”要在“<”的前面;“>=”要在“>”的前面。再次提醒一下,针对词法规则冲突的处理,这是一种取巧的手段,使用符号表的方式才是最好的选择,具体细节可参看下一篇文章。
  3. 新增加了一个方法isA()。该方法用于判断具备继承关系的词法单元类型。截止到现在为止,我们并没有谈及过继承的概念。它表示的是什么意思呢?以等于(=)运算符为例,它独占了一个类型EQ,我们可以将其看作是一个具体类型。与此同时,它是一种运算符,所以在业务上我们认为它其继承自TokenType.OPERATOR。类似地,SET、WHERE等关键字都继承于TokenType.KEYWORD(简单起见,isA()方法中省略了与关键字相关的逻辑),同样也是业务上的概念。

        展示词法分析器的实现代码之前,我们还需要再看一下Token类的定义,如代码5-9所示:

代码5-9class Token {TokenType type;String lexeme;int colIndex;Token(TokenType type, String lexeme, int colIndex) {this.type = type;this.lexeme = lexeme;this.colIndex = colIndex;}
}

        不同于代码5-2,笔者在新版本中加入了一项关键元数据——列号colIndex,用于标识Token对应词素在DSL脚本中的起始列位置。需要说明的是,由于本案例中的DSL采用行式结构(即每行作为独立执行单元,类似于CRUD类型的SQL),因此无需维护行号信息。不过,如果脚本支持跨行语法,仅靠列信息是不够的,后续案例会对此进行优化。列号信息的价值在于提高错误诊断能力,尽管示例脚本相对简单,但这种设计显著增强了系统的可维护性。值得强调的是,这一改进并未增加词法分析器的实现复杂度,只需补充以下两个步骤即可:

  1. 维护一个状态变量,实时记录当前扫描位置的列索引。
  2. 在Token生成过程中,将当前列索引值注入Token对象的元数据中。

        预热工作完成之后,下面开始介绍今天的主角:词法分析器。按照惯例,当代码过长时笔者会使用分段的方式进行说明。代码5-10展示了词法分析器类的定义:

代码5-10class RegexBasedLexer {String sourceCode;List<TokenMatcher> tokenMatchers = loadTokenMatchers();Pattern empty = Pattern.compile("^\\s");int lexemeColIndex = 1;RegexBasedLexer(String sourceCode) {this.sourceCode = sourceCode;}
}

        在词法分析器初始化阶段,sourceCode字段会被赋值为DSL脚本的完整文本内容。需注意的是,该字段的值会随着词法分析过程的推进而动态变化(具体变化过程可参考图 5.5)。其余字段主要用于支持词法分析的内部逻辑实现,在实际工程实践中需关注其访问控制级别。为简化代码示例,笔者未显式声明public或private访问修饰符,实际开发中建议根据封装需求进行显式声明。

        字段tokenMatchers是一个包含了词法单元识别器的列表,而每一个词法单元识别器又包含了编译后的正则表达式。RegexBasedLexer类型在其初始化过程中通过调用静态方法loadTokenMatchers()来对该字段进行初始化,这样的设计可以提升词素匹配的效率,尤其是当DSL脚本量特别大的时候。代码5-11展示了该方法的实现:

代码5-11static List<TokenMatcher> loadTokenMatchers() {List<TokenMatcher> matchers = new ArrayList<>();for (TokenType type : TokenType.values()) {if (!StringUtils.hasLength(type.regex)) {continue;}Pattern p = Pattern.compile(type.regex, Pattern.CASE_INSENSITIVE);matchers.add(new TokenMatcher(type, p));}return matchers;
}

        鉴于代码5-1所示DSL脚本采用大小写不敏感设计,在编译正则表达式时需显式指定忽略大小写模式(如Java中Pattern.compile()方法的第二个参数)。若采用非Java语言实现词法分析器,需确保目标语言的正则表达式引擎支持类似的大小写忽略机制。此外,由于TokenType枚举值的定义顺序直接关系到词法规则冲突的解决策略(优先级由上至下),loadTokenMatchers()方法在初始化词法匹配器列表时严格保持了这种顺序性,最终体现在tokenMatchers字段的元素排列中。理解这一实现细节对正确把握词法分析流程至关重要。

        词法单元识别器TokenMatcher是一个自定义数据结构,其核心功能是建立编译后的正则表达式与Token类型之间的映射关系。该类封装了两个关键属性:预编译的正则表达式对象和对应的TokenType枚举值。在词法分析过程中,当某个词素被成功匹配时,系统将从对应的TokenMatcher实例中提取Token类型信息,并用于构建最终的Token对象。代码5-12展示了TokenMatcher的完整定义:

代码5-12class TokenMatcher {TokenType type;Pattern pattern;TokenMatcher(TokenType type, Pattern pattern) {this.type = type;this.pattern = pattern;}
}

        接下来要展示的是词法分析器RegexBasedLexer的核心逻辑,它会对输入的DSL脚本进行分析、扫描并生成Token序列,如代码5-13所示:

代码5-13TokenBuffer scan() {TokenBuffer buffer = new TokenBuffer(new ArrayList<>());while (!sourceCode.isEmpty()) {//重点代码一this.removeEmpty();if (sourceCode.isEmpty()) {break;}boolean matched = false;for (TokenMatcher tokenMatcher : tokenMatchers) {//重点代码二Matcher matcher = tokenMatcher.pattern.matcher(sourceCode);if (matcher.find()) {//重点代码三String lexeme = matcher.group();buffer.add(new Token(tokenMatcher.type, lexeme, lexemeColIndex));lexemeColIndex += matcher.end();sourceCode = sourceCode.substring(matcher.end());matched = true;break;}}if (!matched) {buffer.add(new Token(TokenType.UNKNOWN));break;}}if (sourceCode.isEmpty()) {buffer.add(new Token(TokenType.EOF));}return buffer;
}

        首先,请读者关注方法scan()的定义:该方法无输入参数且返回值类型为TokenBuffer,此为自定义类型,其具体实现在后续章节阐述。当前阶段,读者只需明确该类型包含词法分析后的Token序列,该序列作为语法分析器的输入。  

        其次,笔者对代码5-13中的几处重点逻辑进行了标注,这些代码构成词法分析器的核心运行逻辑,具体功能如下:  

  1. 重点代码一。此逻辑用于判断DSL脚本是否完成扫描。当源代码字符串值为空时,词法分析工作正式终止。循环体内将调用removeEmpty()方法剔除源代码中的空白字符,具体实现见代码 5-14。需注意的是,removeEmpty()方法引用了RegexBasedLexer类定义的empty对象,该对象为正则表达式实例,用于判断目标字符串是否以空白符作为前缀。此外,该方法会对源代码字符串sourceCode执行修改操作,移除字符串头部的空白字符。  
  2. 重点代码二。此逻辑为词法分析程序内部的嵌套循环,通过遍历词法单元识别器列表实现对词素的识别。  
  3. 重点代码三。若源代码字符串头部内容与某一词法单元识别器匹配,则创建Token类型对象并将其添加至输出结果。匹配成功后,还将对源代码字符串值及Token的列索引信息lexemeColIndex进行更新。
代码 5-14void removeEmpty() {Matcher matcher = empty.matcher(this.sourceCode);if (matcher.find()) {this.lexemeColIndex += matcher.end();sourceCode = sourceCode.substring(matcher.end());}
}

        逻辑是不是非常简单?不过此刻还有两个问题没有解决:一是scan()方法输出结果类型TokenBuffer的定义,笔者先在此留下一个悬念,后续在讲解语法分析的时候再向您展示其具体的实现细节;二是关于词法分析器的结束条件和异常处理。

        词法分析逻辑的结束条件有如下两个:

  1. 当源代码字符串的值变成空字符串的时候。
  2. 当词法分析过程中遇到一个无法识别(TokenType.UNKNOWN)的Token的时候。之所以采用这样的设计,是因为未知类型的Token元对于语法分析器而言也是一种结束条件。既然如此,提前终止词法分析工作就是一个很不错的选择,因为没有理由去浪费时间做一些没有意义的事情。在软件开发和系统设计理念之中,这便是所谓的“Fail-fast”原则。

        关于词法分析过程中遇到未知类型Token时是否采用异常抛出机制终止分析流程的问题,需要从编译系统的整体架构设计层面进行权衡。在处理大规模、结构相对简单的DSL脚本时,异常抛出机制确实能够通过快速终止编译流程有效提升性能表现,但其代价是错误诊断信息的完整性受损(注:依据本系统设计原则,更具可读性的错误提示应由语法/语义分析阶段提供)。从职责边界角度审视,词法分析器作为编译前端的基础组件,其核心功能应严格限定于将源代码解析为Token序列,而Token的消费与语义验证属于语法分析器的职责范畴。因此,从设计模式的单一职责原则出发,词法分析阶段宜采用静默终止策略,将错误处理逻辑集中交由具备上下文感知能力的语法分析器实现。这种分层处理机制不仅遵循了关注点分离的设计理念,也为后续维护提供了更清晰的责任边界,使得错误定位与修复过程更具可追溯性。

        最后,让我们看一下词法分析器的输出对象结构。笔者将待输出的Token列表通过JSON工具进行了序列化,结果如代码5-15所示。输出内容较多,所以笔者只截取了其中的一小部分。

代码5-15[{"type": "FIELD","lexeme": "current_date","colIndex": 23},{"type": "GE","lexeme": ">=","colIndex": 36},{"type": "STRING","lexeme": "\"2024-01-01\"","colIndex": 39},...
]

        至此已完成对基于正则表达式的词法分析器的讲解。从整体实现来看,其原理及对应代码均保持较高简洁性,唯一需要投入精力的环节是对正则表达式语法的研究。不过,鉴于互联网资源的丰富性以及ChatGPT等AI编程工具的辅助,这一学习成本可被有效控制。

        下一章节将介绍另一种词法分析器的实现范式。该方案虽未采用正则表达式,且工作机制与当前案例存在差异,但在编程实践层面具备独特的探索价值。

上一章  下一章

相关文章:

  • 【从零学习JVM|第七篇】快速了解直接内存
  • 用volatile修饰数组代表什么意思,Java
  • # Flask:Python的轻量级Web框架入门之旅(超级实用!)
  • 动态多目标进化算法:MOEA/D-SVR求解CEC2018(DF1-DF14),提供完整MATLAB代码
  • mvc与mvp
  • pysnmp 操作流程和模块交互关系的可视化总结
  • Genio 1200 Evaluation MT8395平台安装ubuntu
  • ​​​​​​​《TCP/IP协议卷1》第9章 IP选路
  • Gemini 2.5 Pro 和Claude 3.7 理综物理真题,考研数学真题实战对比,国内直接使用
  • 腾讯云:6月30日起,自动禁用,及时排查
  • Odoo 基于规则的线索自动分配实践指南
  • SQL进阶之旅 Day 28:跨库操作与ETL技术
  • List ToMap优化优化再优化到极致
  • 报表工具顶尖对决系列 — Echarts 展现与导出
  • window 显示驱动开发-为视频处理创建渲染目标图面
  • chrome138版本及以上el-input的textarea输入问题
  • Mongodb学习(Windows版本)
  • Java 中使用 Redis 注解版缓存——补充
  • 分布式MQTT客户端看门狗机制设计与实现
  • FOC电机三环控制
  • 谢家华做网站/建站平台如何隐藏技术支持
  • 电子商务网站建设与管理实训报告/深圳网站建设的公司
  • 咨询公司排行榜/优化seo招聘
  • wordpress主题不显示图片/aso榜单优化
  • web个人网站开发/石家庄seo全网营销
  • 信息发布网站怎么做/网络营销推广的基本手段