自己动手写编程语言——源代码扫描

在任何编程语言中,第一步都是读取输入源代码的单个字符 ,并判断哪些字符应当归为一组 。类比自然语言,这就像查看相邻的字母序列以识别"单词"。在编程语言里,字符簇会组成变量名保留字 ,或有时是多个字符构成的运算符或标点 。本章将教你如何读取源代码,并用模式匹配从原始字符中识别出"单词"和"标点"。

本章将涵盖以下主题:

  • 词素(lexeme)、词法类别(lexical category)与记号(token)
  • 正则表达式(Regular expressions)
  • 使用 UFlex 与 JFlex
  • 为 Jzero 编写扫描器(scanner)
  • 什么时候"仅靠正则表达式还不够"

首先看看程序源代码中会出现的几类"单词"。自然语言的读者必须区分名词、动词和形容词,才能理解句子含义。同样地,你的编程语言也必须为源代码中的每个实体分类,以决定该如何解释它。

技术要求

本章会带你动手处理一些技术内容。你可以从本书的 GitHub 仓库下载示例代码:
github.com/PacktPublis...

本章的"Code in Action"演示视频在此: bit.ly/3Fnn2c2。

要跟着实践,你需要安装一些工具并下载示例。先看如何安装 UFlexJFlex 。UFlex 随 Unicon 提供,无需单独安装。

对于 JFlex,请从 jflex.de/download.ht... 下载 jflex-1.9.1.tar.gz(或更新版本)。根据你系统上 tar(1) 的版本,可能需要先用 gunzip 解压,把 .tar.gz 转成 .tar。你可以从 www.gzip.org/gnuwin32.sourceforge.net/packages/gzip.htm 等处获取 gunzip。

之后用 tar.tar 文件中解包。它会在你运行 tar 的目录下解出一个子目录(例如 jflex-1.9.1)。在 Windows 上,如果你没有把 JFlex 安装移动到 C:\JFLEX,则需要设置环境变量 JFLEX_HOME 指向你的安装位置,并把 JFLEX\bin 加入 PATH 。在 Linux 上,你可以把 JFLEX/bin 加入 PATH ,或为 JFLEX\bin\jflex 脚本创建符号链接。

如果你把 JFlex 解包在 /home/myname/jflex-1.9.1,可以这样把 /usr/bin/jflex 链接到解包后的脚本:

bash 复制代码
sudo ln -s /home/myname/jflex-1.9.1/bin/jflex /usr/bin/jflex

前文提到,本书的示例会以 UniconJava 两种版本并行给出。受版面限制,无法并排展示:我们先给出 Unicon 示例,再给出对应的 Java 代码。通常,Unicon 代码就是良好的可执行伪代码 ,Java 实现由此推导而来。安装并准备好 UFlex 和/或 JFlex 后,我们先讲要做什么;然后讲如何使用 UFlex 和 JFlex 生成词法分析器 (也称扫描器)的代码。

词素、词法类别与记号

编程语言会读取字符,并在这些字符属于同一语言实体时把相邻字符分组 。这个实体可能是多字符的名字或保留字 、一个常量值 ,或一个运算符

词素(lexeme)是指一串相邻字符,作为一个单一实体 出现。多数标点本身就是独立的词素,同时也起到把前后内容分隔开的作用。在"讲道理"的语言中,空白字符(空格、制表符等)除了承担分隔词素的职责外会被忽略。

几乎所有语言都支持注释 ,且通常把注释与空白同等看待:它们可以作为两个词素的边界,但会被丢弃,不再参与后续处理。

每个词素都有一个词法类别(lexical category) 。在自然语言里,这相当于词性(名词、动词、形容词等)。在编程语言实现里,词法类别通常用一个整数代码 表示,供语法分析使用。变量名 是一类词法类别;常量 至少也是一类------在多数语言中,不同数据类型的常量会有多种 类别。大多数保留字 都有各自的类别,因为它们在语法中的出现位置彼此不同;在很多文法里,即便某些保留字在语法上可互换,也仍然分别赋予独立类别。类似地,运算符 通常每个优先级至少一个类别,甚至每个运算符 独立成类。一个典型的编程语言往往有 50~100 种词法类别,远多于我们在自然语言中能叫出名字的"词性"。

编程语言为每个读入的词素收集的信息包称为记号(token) 。记号通常表示为一个结构体(或其指针)或一个对象。记号的字段一般包括:

  • 词素本身(字符串)
  • 类别(整数)
  • 文件名(该词素所在文件的字符串名)
  • 行号(该词素在文件中的行号,整数)
  • 可能的其他信息(列号、二进制表示等)

在阅读编程语言的书籍时,你会发现一些作者会在不同语境下用"token"分别指代字符串(词素)类别整数结构体/对象 。弄清 词素---类别---记号 这套术语后,我们接下来要看一套用于把一组词素 同其对应类别 关联起来的记法------这种模式就叫正则表达式

正则表达式

正则表达式(RE)是描述文件中符号模式 的最广泛使用的方式。它由非常简单、易懂的规则构成。对正则表达式而言,定义它们所用符号的那套集合称为字母表(alphabet) 。本节的"字母表"不是英语的 A~Z,而更接近所谓的 ASCII 集合。

在某些输入符号集上,正则表达式使用输入符号集的成员 再加上少量正则表达式运算符 ,来描述字符串集合 的模式。既然是"描述集合"的记法,我们谈论正则能匹配的字符串集合时,自然会使用成员、并集、交集等术语。下面先给出构造正则表达式的规则,再配合示例说明。

正则表达式规则

多年来,许多工具使用过正则表达式,并发明了不少非标准扩展 。本书只展示示例所需的运算符------它比理论书上给出的"最小必需集合"略多,但又避免了一些工具里少见且过度 的运算符。我们考虑的规则如下(除第一条外,其余都与把小正则串接成大正则有关,用以匹配更复杂的模式):

  1. 字母表中的任一符号 (如 a)本身就是一个正则,匹配该符号本身。反斜杠 `` 作为转义符,可以把一个"RE 运算符"转义为"仅匹配该符号本身"的正则。

  2. 括号 可包围一个正则表达式 (r),其匹配与 r 相同。括号用于强制优先级,使括号内的运算先于外部运算符应用。

  3. 连接(concatenation) :当两个正则 re1re2 相邻时,re1 re2 匹配"一个 re1 的实例后接 一个 re2 的实例"。这个连接运算很"隐形",因为它没有显式符号 。任意一段双引号 括起的字符串,表示"按原样的字符序列",即连接在一起。正则运算符在双引号内不生效 ,而 \n 等常见转义可使用。

  4. 选择(alternation) :任意两个正则 re1 | re2 匹配"属于 re1re2 的一个成员"。方括号 是对大量"竖线分隔的单字符选择"的速记[abcd] 等价于 (a|b|c|d);范围速记如 [a-d] 也等价于 (a|b|c|d)[^abcd] 则表示"不是 a/b/c/d 的任一单字符 "。对"速记的速记",还有点号 . :它等价于 [^ \n ],即匹配除换行以外 的任意单字符

  5. 重复运算符 :任意正则 re 后接

    • 星号 *re* 匹配"零次或多次 "重复的 re
    • 加号 +re+ 匹配"一次或多次 "重复的 re
    • 问号 ?re? 匹配"零次或一次 "的 re

这些规则 包含正则表达式里的空白注释 ------编程语言有这些概念,但正则记法没有 !如果你的模式需要一个空格,可以把空格转义 ,或放在双引号方括号 里;但如果你在正则里看到一个未转义的空格或注释 ,那就是bug 。不能为了"美观"就随意在正则中插入空白。如果你必须写注释来解释一个正则,那你大概把它写得过于复杂 了;正则本应是自说明 的------若做不到,考虑重写它们。

尽管我一再主张"保持简单",这 5 条简单规则可以组合出十分强大的模式,匹配非常丰富的字符串集合。进入使用这些规则的词法分析器生成器之前,我们先通过一些额外示例,帮助你直观把握正则表达式能描述的模式类型。

正则表达式示例

一旦你写过几个正则表达式,就会发现它们其实不难。下面是一些有可能在你的扫描器中用到的示例:

  • 正则表达式 while 可以看作由五个正则拼接而成,每个字母一个:while。它匹配字符串 while(不含双引号)。

  • 正则表达式 "+"|"-"|"*"|"/" 匹配长度为 1 的字符串,该字符要么是加号、要么是减号、要么是星号、要么是斜杠。这里用双引号是为了确保这些标点不会 被当作正则运算符解释。你也可以写成 [+-*/]。注意:像 * 这样的正则运算符在方括号内 不生效;在方括号里,标点符号被当作普通符号(除了像连字符 - 或插入符号 ^ 这类在方括号内有特殊含义的字符,需要用反斜杠转义)。

  • 正则表达式 [0-9]*.[0-9]* 匹配"零个或多个数字,接一个点,再接零个或多个数字"。点号要转义,否则它表示"除换行外的任意单字符"。虽然这个模式像是在匹配实数,但它允许点号两边都没有数字!你需要写得更严谨些。比如:

    css 复制代码
    ([0-9]+.[0-9]*|[0-9]*.[0-9]+)

    虽然啰嗦,但至少能保证这个 token 确实是某种数字。

  • 正则表达式 """[^"]*"""(即 """[^"]*""")匹配:一个双引号,然后是零个或多个 "非双引号字符",再跟一个双引号。这是新手常见的"字符串常量"正则写法。问题在于:它允许换行 出现在字符串中------而多数编程语言不允许。另一个问题是:它不能 在字符串常量里放入双引号。多数语言会提供转义机制 来实现这一点。一旦允许转义字符,你就必须明确规定它们。若只允许转义双引号,可写成:

    python 复制代码
    """([^"\\n]|\")*"""

    对于像 C 这样的语言,更通用的版本可能是:

    python 复制代码
    """([^\\n]|\([abfnrtv\?0]|[0-7][0-7][0-7]|x[0-9a-fA-F][0-9a-fA-F]))*"""

这些例子说明,正则表达式可以从微不足道庞然大物 不等。某种程度上,正则是一种"只写不读"的记法------ 起来比 起来容易。有时如果你的正则写错了,重写 比调试反而更省事。看过这些示例之后,接下来我们来了解用正则表达式记法来生成源代码扫描器 的工具------也就是 UFlexJFlex

使用 UFlex 与 JFlex

手写扫描器对想"吃透底层工作原理"的程序员来说确实很有趣,但它会拖慢语言开发进度,并让后续维护更困难。

好消息是:源自 UNIX 的一类工具 lex ,可以把正则表达式生成 成扫描函数。主流语言几乎都有与 lex 兼容的工具。C/C++ 最常用的是 Flexgithub.com/westes/flex... 使用 UFlex ,Java 则可以用 JFlex 。这些工具各有扩展,但在与 UNIX lex 兼容的范围内,我们可以把它们当作同一种"扫描器描述语言"来讲解。本书的示例经过精心编写,甚至可以让 UniconJava 两个实现共用同一份输入文件

lex 的输入文件通常称为(lex)规范 ,扩展名为 .l,由若干部分组成,用 %% 分隔。本文泛称它们为 "lex 规范",既可以供 UFlex 也可以供 JFlex 使用,而且大多数情况下,同样也能被 C 的 Flex 接受。

一个 lex 规范包含必需 的几个部分:头部(header)正则表达式 部分,以及可选的辅助函数 部分。JFlex 还在最前面加了一个 imports 部分,因为 Java 需要 import,并且需要能在"类定义之前"和"类定义之内"分别插入代码片段。就现在而言,你需要关注的是头部正则表达式这两个部分。我们先看头部。

头部(Header)部分

大多数 Flex 家族工具都支持在头部开启一些选项(各家略有不同,用到再说)。你也可以在这里插入宿主语言的代码片段,比如变量声明。不过,头部的主要目的 是定义命名宏,以便在正则表达式部分多次复用某些模式。lex 中的命名宏一行一个,格式如下:

复制代码
name        regex

在宏行里,name 是一段字母序列,后面跟一个或多个空格,再跟一个正则表达式。之后在"正则表达式"部分,你可以用花括号 把宏名包起来来替换它,比如 {name}。新手最常犯的错误是------试图在宏定义的正则后面加注释 ,千万别这么做。lex 在这些行里不支持注释,你写的内容会被当作正则表达式的一部分来解析。

有点"悲剧"的是,JFlex 打破了兼容性,要求在名字后加一个等号,所以它的宏写法是:

ini 复制代码
name=regex

这个与 UNIX lex 的不兼容性确实挺"过分",因此本书示例里选择不使用宏 。在写作本书期间,我们给 UFlex 做了扩展,使其既能接受传统语法,也能接受带等号的语法。如果你愿意加一些宏,示例代码会稍微短一点。若不使用宏,你的头部 几乎是空的------那么接下来就进入 lex 规范的下一部分:正则表达式部分。

正则表达式部分

在一份 lex 规范中,正则表达式部分 是主体。每条正则表达式各占一行,后面跟一些空白,再跟一段语义动作 ------也就是当该正则被匹配时要执行的宿主语言代码(在本书中是 Unicon 或 Java)。注意:虽然每条正则规则都从新行开始 ,但如果语义动作按常规用花括号 包围一个语句块,那么它可以跨越多行源码;在找到配对的右花括号之前,lex 都不会去寻找下一条正则。

新手在"正则表达式部分"最常见的错误,是为了可读性在正则里插入空格或注释 。不要这么做;在正则中间插入空格会把正则截断在空格处 ,空格之后的内容会被当作宿主语言代码来解释。这样通常会得到一些让人摸不着头脑的报错信息。

当你运行 UNIX 的 lex(一个 C 工具)时,它会生成一个名为 yylex() 的函数,为每个词素返回一个整数类别 ;同时还会通过全局变量提供其他有用信息。整数 yychar 保存类别;字符串 yytext 保存该词素被匹配的字符;yyleng 告诉我们匹配了多少个字符。各家 lex 工具在这个公开接口上的兼容性不尽相同,有的工具还会自动为你多做一些工作。例如,JFlex 必须在类内部 生成扫描器,并通过成员函数 提供 yytext()。编程语言实现通常还需要更多细节,比如记号来自第几行。下面我们通过示例循序渐进地实现这些需求。

编写一个简单的源代码扫描器

这个示例可以用来检查你是否能成功运行 UFlexJFlex 。它足够短小,你可以从本书的 GitHub 下载代码,也可以跟着边看边敲。它还能帮助你了解 UFlex 与 JFlex 的使用相似度。这个示例扫描器只识别名字数字空白 ;lex 规范放在名为 nnws.l 的文件中。编程语言工具读取源代码时首先要做的事,就是标定每个词素的类别 并返回它。这个示例把名字 记为 1数字 记为 2空白被丢弃 ;其余任何内容都视为错误

nnws.l 的主体如下所示。这份规范可同时作为 UFlex 与 JFlex 的输入。下载或键入之后,本书会演示如何分别为 UniconJava 构建它------你可以选择其一或都做。由于 UFlex 的语义动作用 Unicon 代码、JFlex 的语义动作用 Java 代码,因此我们需要有所克制

只有当我们把语义动作限制在两种语言共同的语法 (如方法调用return 表达式 )时,它才会在 Java 与 Unicon 中都合法。如果你加入 if 语句、赋值或任何语言特有 的语法,你的 lex 规范就会变成某个宿主语言(比如 Unicon 或 Java)专用

即便是这个小例子,也包含了一些后面会用到的思路。前两行是给 JFlex 用的,UFlex 会忽略 。最开始的 %% 表示空的 JFlex import 部分 结束;第二行是头部 中的一个 JFlex 选项 。默认情况下,JFlex 的 yylex() 返回 Yytoken 类型的对象;%int 选项让它像 C 的 FlexUFlex 一样返回整数 。第三行以 %% 进入正则表达式部分 。第四行的正则 [a-zA-Z]+ 匹配一个或多个 大小写字母:它会尽可能多地匹配相邻字母,并返回 1 。副作用是,被匹配的字符会被存入 yytext 。第五行的 [0-9]+ 匹配尽可能多的数字,并返回 2 。第六行用 [ \t\r\n]+ 匹配空白,不返回任何值 ;扫描器会继续在输入文件中前进,尝试匹配其它正则。除了大家熟悉的空格外,\t制表符\r回车\n换行 。第七行的点号 . 匹配除换行外的任意字符 ,因此能捕获前面模式都不允许的源代码,并在这种情况下报告错误 。错误通过一个名为 lexErr() 的函数上报(用于词法错误),该函数位于名为 simple 的对象中。后续编译流程我们还会需要更多报错函数:

csharp 复制代码
%%
%int
%%
[a-zA-Z]+  { return 1; }
[0-9]+     { return 2; }
[ \t\r\n]+ {  }
.          { simple.lexErr("unrecognized character"); }

这份规范会从 main() 函数中被调用,对输入中的每个单词 调用一次。每次调用时,它都会把当前输入位置同时 拿去尝试匹配所有正则 (这里是 4 条),并选择在当前位置匹配字符数最多 的那一条。如果有两条或更多正则出现"最长匹配并列 ",那么在规范文件中出现得更靠前 的那条获胜

多种 lex 工具都能提供一个默认的 main() ,但若想完全掌控流程,建议自己编写 。此外,自己写 main() 还能演示如何在单独文件 中调用 yylex()------下一章把扫描器接到语法分析器上时,你就会需要这样做。

main() 的写法因语言而异Unicon 采用类似 C++ 的组织方式,main()任何对象之外 起始;而 Java 则把 main() 放在类内部。除此之外,两者代码有很多共同点。

Unicon 实现 可以放在任意以 .icn 为扩展名的文件中;这里叫它 simple.icn 。文件包含一个 main() 过程和一个名为 simple单例类 ------仅仅因为我们在 nnws.l 里用了一种"兼容 Java 的方式 "调用词法错误助手函数,即 simple.lexErr()main() 过程通过把类构造函数替换为它返回的单个实例 来初始化 simple 类;随后从命令行第一个参数给出的文件名打开输入文件。词法分析器通过 yyin 知道读取哪个文件。之后在循环里调用 yylex() ,直到扫描结束:

arduino 复制代码
procedure main(argv)
   simple := simple()
   yyin := open(argv[1])
   while i := yylex() do
      write(yytext, ": ", i)
end

class simple()
   method lexErr(s)
      stop(s, ": ", yytext)
   end
end

Java 版本main() 必须放在一个类里,文件名为类名加 .java ;这里叫它 simple.java 。它通过创建 FileReader 打开文件,并在创建词法分析器 Yylex 对象时把 FileReader 作为参数传入。因为 FileReader 可能失败,我们需要在 main() 上声明会抛出异常。构造好 Yylex 对象后,main() 就反复调用 yylex() 直到输入耗尽;yylex() 在结束时返回 Yylex.YYEOF 这个哨兵值。

虽然代码更长一些,但这个 main() 做的事与 Unicon 版本相同。与 Unicon 的 simple 类相比,Java 版多了一个代理方法 yytext(),方便 simple 类中的其他函数或编译器其他部分在没有 Yylex 引用的情况下获取"最近一次词素字符串":

arduino 复制代码
import java.io.FileReader;
public class simple {
   static Yylex lex;
   public static void main(String argv[]) throws Exception {
      lex = new Yylex(new FileReader(argv[0]));
      int i;
      while ((i=lex.yylex()) != Yylex.YYEOF) 
         System.out.println("token "+ i +": "+ yytext());
   }
   public static String yytext() {
      return lex.yytext();
   }
   public static void lexErr(String s) {
      System.err.println(s + ": " + yytext());
      System.exit(1);
   }
}

至此,你已经看完了第一个扫描器 的全部代码:一份 .l 文件中的词法规范 (由此生成 yylex()),以及一份 .icn.javamain()(调用 yylex() 并检查其运作)。这个简单扫描器的主要目的,是让你看清各组件如何连线 。无论你是下载了代码还是手敲完成,现在都可以在 UniconJava 或两者上试跑,以确保这套"管道"按我们预期工作。

运行你的扫描器

让我们在下面这个(很简单的)输入文件上运行示例,文件名为 dorrie.in

csharp 复制代码
Dorrie is 1 fine puppy

在运行程序之前,你必须先编译 。UFlex 与 JFlex 会生成分别由 Unicon 或 Java 编写的、供你的编程语言其余部分调用的代码。若你想知道编译流程长什么样,下面这张图给出了示意:在 Unicon 中,这两个源文件会被编译并链接成名为 simple 的可执行文件;在 Java 中,这两个文件会被分别编译成 .class 文件;你通过包含 main() 方法的 simple.class 启动 Java,其他类在需要时再加载:

图 3.1:使用 nnws.l 同时构建 Unicon(左)与 Java(右)程序

你可以按下列"左列/右列"的命令,在 Unicon 或 Java 中编译并运行该程序:

复制代码
uflex nnws.l                 jflex nnws.l
unicon simple nnws           javac simple.java Yylex.java
simple dorrie.in             java simple dorrie.in

无论采用哪种实现,你都应该看到如下五行输出:

yaml 复制代码
token 1: Dorrie
token 1: is
token 2: 1
token 1: fine
token 1: puppy

到目前为止,这个示例只是用正则表达式对输入字符分组并分类,判断找到的是什么类型的词素。为了让编译器的其余部分工作,我们还需要关于该词素的更多信息,并把它们存放到**记号(token)**里。

记号与词法属性

除了标明每个词素所属的整数类别 之外,编程语言实现(在我们的案例中是编译器)还要求扫描器分配一个对象 来保存该词素的全部相关信息。这个对象称为记号(token)

一个 token 持有一组命名字段,称为词法属性(lexical attributes) 。需要记录哪些信息,取决于语言与实现。通常,token 会记录:整数类别、词素的字符串文本,以及该 token 来自的行号 。在真实的编译器里,token 往往还包含更多信息,比如文件名 以及该词素在所在行里的列号 。对某些 token(字面量常量)来说,编译器或解释器还可能会存储该字面量所表示的实际二进制值

你可能会问:为什么要存 token 的列号 ?既然有词素文本,通常看看源码那一行就能找到它;而且大多数编译器在报错时只给行号 ,不给列号。确实,并不是所有实现都会存列号。不过,存了列号 的实现可以在同一行里同一 token 出现多次消除歧义 :错误出在第一个右括号,还是第三个?你可以把猜测留给人,也可以记录更多细节。是否存列号还取决于你的词法分析器是否会被 IDE 使用------当出错时,IDE 会把光标跳到具体的出错 token。如果这是你的需求之一,你就需要列号来实现这个功能。

扩展示例以构造记号

通常,每次调用 yylex() 都会分配一个新的 token 实例 。在 lex 中,token 通过把"指向新实例的指针"放入全局变量 yylval 的方式传递给语法分析器,每次调用 yylex() 都会这样做。作为迈向"真实扫描器"的过渡,我们将扩展前面的示例,让它能够分配这些 token 对象

最优雅、最可移植的做法是:在语义动作 中插入一个名为 scan() 的函数;scan() 负责分配 token 对象,然后(通常)返回它的参数 ------即前面示例中的整数类别码

实现这一点的 lex 规范在 nnws-tok.l 文件中。需要注意的是,在 JFlex 里,回车符 既不属于"换行",也不属于"除了换行之外的任意字符"的点号运算符;因此若使用 JFlex,必须显式处理回车 。在本例中,回车放在换行前是可选的:

ini 复制代码
%%
%int
%%
[a-zA-Z]+  { return simple2.scan(1); }
[0-9]+     { return simple2.scan(2); }
[ \t]+     {  }
\r?\n      { simple2.increment_lineno(); }
.          { simple2.lexErr("unrecognized character"); }

下面是 Unicon 中更新后的 main(),文件名 simple2.icnscan() 依赖一个名为 yylineno 的全局变量:它在 main() 中设定,并在 yylex() 每次匹配到换行时更新。和前一个示例一样,simple2 是一个单例类 ,目的是让这份 lex 规范在 UniconJava 两端都无需修改 即可复用。token 的表示用 Unicon 的 record 类型 定义(类似 C/C++ 的 struct,或"无方法的类")。目前它只包含:整数类别码、词素字符串本身、以及其来源的行号:

css 复制代码
global yylineno, yylval
procedure main(argv)
   simple2 := simple2()
   yyin := open(argv[1]) | stop("usage: simple2 filename")
   yylineno := 1
   while i := yylex() do
      write("token ", i, " (line ",yylval.lineno, "): ", yytext)
end

class simple2()
   method lexErr(s)
      stop(s, ": line ", yylineno, ": ", yytext) 
   end
   method scan(cat)
      yylval := token(cat, yytext, yylineno)
      return cat
   end
   method increment_yylineno()
      yylineno +:= 1
   end
end

record token(cat, text, lineno)

对应的 Java 版本 main() 位于 simple2.java,如下:

arduino 复制代码
import java.io.FileReader;
public class simple2 {
   static Yylex lex;
   public static int yylineno;
   public static token yylval;
   public static void main(String argv[]) throws Exception {
      lex = new Yylex(new FileReader(argv[0]));
      yylineno = 1;
      int i;
      while ((i=lex.yylex()) != Yylex.YYEOF) 
         System.out.println("token "+ i +
                  " (line " +yylval.lineno + "): "+ yytext());
   }
   public static String yytext() {
      return lex.yytext();
   }
   public static void lexErr(String s) {
      System.err.println(s + ": line " + yylineno +
           ": " + yytext());
      System.exit(1);
   }
   public static int scan(int cat) {
      yylval = new token(cat, yytext, yylineno);
      return cat;
   }
   public static void increment_lineno() {
      yylineno++;
   }
}

simple2 示例还需要另一个 Java 文件。token.java 定义了我们的 token 类(下一节还会扩展它):

arduino 复制代码
public class token {
   public int cat;
   public String text;
   public int lineno;
   public token(int c, String s, int l) {
      cat = c; text = s; lineno = l;
   }
}

下面这个输入文件 dorrie2.in 被扩展为多行,并在末尾加了一个句点,以便当出现"无法识别的字符"时能看到行号

csharp 复制代码
Dorrie
is 1
fine puppy.

你可以这样在 Unicon 或 Java 中运行程序:

markdown 复制代码
uflex nnws-tok.l              jflex nnws-tok.l
                              javac token.java
unicon simple2 nnws-tok       javac simple2.java Yylex.java
simple2 dorrie2.in            java simple2 dorrie2.in

无论采用哪种实现,预期输出如下:

arduino 复制代码
token 1 (line 1): Dorrie
token 1 (line 2): is
token 2 (line 2): 1
token 1 (line 3): fine
token 1 (line 3): puppy
unrecognized character: line 3: .

这个示例的输出包含了行号 ,而输入文件中故意包含了一个无法识别的字符 ,以便看到错误消息同样包含行号

为 Jzero 编写扫描器

本节给出一个更大的示例:为 Jzero(我们定义的 Java 子集)编写扫描器。从这里开始,示例已足够庞大,如果你想运行它,多数读者会选择从本书的 GitHub 下载代码。这个示例把前面的 simple2 扩展到更接近真实语言的规模,并加入了列号 信息,以及字面量常量 的额外词法属性。最大的变化是:为了处理比先前更复杂的模式,引入了大量正则表达式 。扫描器能够识别整个 Java 语言 ,但其中相当一部分 Java 类别会导致执行以错误退出 ,这样下一章的语法(以及编译器其余部分)就不必考虑它们。

Jzero 的 flex 规范

与前面的示例相比,真实编程语言的 lex 规范会包含更多、且更复杂 的正则表达式。下面这个文件名为 javalex.l,会分几段展示。

javalex.l 的开头包含头部 以及注释与空白 的正则表达式。这些正则会匹配并消耗源代码中的字符,但不会返回 对应的整数代码;对编译器其余部分而言,它们是不可见 的。作为 Java 的子集,Jzero 同时包含 C 风格注释 (以 /**/ 包围)与 C++ 风格注释 (以 // 开始直到行末)。C 风格注释的正则表达式非常"巨大";如果你的语言也有这类模式,很容易也很常见 会写错。它的含义是:从 /* 开始,随后"吃掉"一串非星号字符或不构成注释结束的星号,直到遇到"一个或多个星号跟一个斜杠"为止:

erlang 复制代码
%%
%int
%%
"/*"([^*]|"*"+[^/*])*"*"+"/" { j0.comment(); }
"//".*\r?\n                  { j0.comment(); }
[ \t\r\f]+                   { j0.whitespace(); }
\n                           { j0.newline(); }

javalex.l 的下一部分是保留字 ,它们的正则非常直接。由于这些词经常出现在语义动作中,因此用双引号强调"它们就是这些字符本身",以免看起来像语义动作代码。

这里的许多整数类别码语法分析器类 (在单独的文件中指定)提供。在本书后续章节中,整数代码都由语法分析器定义。为了让编译器的这两个阶段能成功通信,词法分析器必须使用语法分析器的这些代码。

你或许会问:为什么每个保留字都要用不同的整数类别码?实际上,只有当它们在语法中承担不同角色时,才需要分配不同的代码。能在同一处出现的保留字可以共用一个类别码。这样语法会更短,但具体差异 就留到后续语义分析 去处理,同时让语法变得更含糊 。例如 truefalse:从语法上看它们是同类,因此都可以作为 BOOLLIT 返回。我们也可能发现其他保留字(如类型名)可以共用同一类别码------这就是一个需要权衡的设计决策 。拿不准时,建议稳妥 起见:每个保留字分配一个整数码

kotlin 复制代码
"break"                { return j0.scan(parser.BREAK); }
"double"               { return j0.scan(parser.DOUBLE); } 
"else"                 { return j0.scan(parser.ELSE); }
"false"                { return j0.scan(parser.BOOLLIT); }
"for"                  { return j0.scan(parser.FOR); }
"if"                   { return j0.scan(parser.IF); }
"int"                  { return j0.scan(parser.INT); }
"null"                 { return j0.scan(parser.NULLVAL); }
"return"               { return j0.scan(parser.RETURN); }
"string"               { return j0.scan(parser.STRING); }
"true"                 { return j0.scan(parser.BOOLLIT); }
"bool"                 { return j0.scan(parser.BOOL); }
"void"                 { return j0.scan(parser.VOID); }
"while"                { return j0.scan(parser.WHILE); }
"class"                { return j0.scan(parser.CLASS); }
"static"               { return j0.scan(parser.STATIC); }
"public"               { return j0.scan(parser.PUBLIC); }

javalex.l 的第三部分是运算符与标点 。这些正则同样加引号 ,表示它们就是字面字符本身。与保留字类似,在某些情况下,如果若干运算符具有相同的优先级与结合性 ,也可以合并到一个共享的类别码,这会让语法更短,但也会更含糊。

与保留字相比还有一个"小状况":很多运算符与标点只占一个字符 。这时,用它们的 ASCII 码 作为整数类别码更短也更直观,因此我们就这么做。函数 j0.ord(s) 提供了在 Unicon 与 Java 上都通用的实现方式。对多字符运算符 ,像保留字那样使用语法分析器常量

kotlin 复制代码
"("              { return j0.scan(j0.ord("(")); }
")"              { return j0.scan(j0.ord(")")); }
"["              { return j0.scan(j0.ord("[")); }
"]"              { return j0.scan(j0.ord("]")); }
"{"              { return j0.scan(j0.ord("{")); }
"}"              { return j0.scan(j0.ord("}")); }
";"              { return j0.scan(j0.ord(";")); }
":"              { return j0.scan(j0.ord(":")); }
"!"              { return j0.scan(j0.ord("!")); }
"*"              { return j0.scan(j0.ord("*")); }
"/"              { return j0.scan(j0.ord("/")); }
"%"              { return j0.scan(j0.ord("%")); }
"+"              { return j0.scan(j0.ord("+")); }
"-"              { return j0.scan(j0.ord("-")); }
"<"              { return j0.scan(j0.ord("<")); }
"<="             { return j0.scan(parser.LESSTHANOREQUAL);}
">"              { return j0.scan(j0.ord(">")); }
">="             { return j0.scan(parser.GREATERTHANOREQUAL);}
"=="             { return j0.scan(parser.ISEQUALTO); }
"!="             { return j0.scan(parser.NOTEQUALTO); }
"&&"             { return j0.scan(parser.LOGICALAND); }
"||"             { return j0.scan(parser.LOGICALOR); }
"="              { return j0.scan(j0.ord("=")); }
"+="             { return j0.scan(parser.INCREMENT); }
"-="             { return j0.scan(parser.DECREMENT); }
","              { return j0.scan(j0.ord(",")); }
"."              { return j0.scan(j0.ord(".")); }

javalex.l 的第四部分(也是最后一部分)包含更困难的正则表达式。变量名 的规则(其整数类别为 IDENTIFIER )必须放在所有保留字之后 。之所以保留字能**"压过"更一般的标识符规则,是因为 lex 在 最长匹配出现 并列时,会选择规范文件中更靠前**的那条规则。

如果能提高可读性,你可以写任意多条 正则表达式让它们返回同一个 整数类别。本例就为实数 (带小数点、科学计数法,或两者兼有)使用了多条规则。最后一条是兜底模式:当源代码出现二进制或其他奇怪字符时,生成词法错误。

ini 复制代码
[a-zA-Z_][a-zA-Z0-9_]*{ return j0.scan(parser.IDENTIFIER);}
[0-9]+                { return j0.scan(parser.INTLIT); }
[0-9]+"."[0-9]*([eE][+-]?[0-9]+)? { return j0.scan (parser.DOUBLELIT);}
[0-9]*"."[0-9]+([eE][+-]?[0-9]+)? { return j0.scan (parser.DOUBLELIT);}
([0-9]+)([eE][+-]?([0-9]+))  {return j0.scan (parser.DOUBLELIT);}
"([^"])|(\.)*"    { return j0.scan(parser.STRINGLIT); }
.                   { j0.lexErr("unrecognized character");}

虽然这里分成四段来展示,javalex.l 实际并不长,大约 58 行 。而且它同时适用于 UniconJava ,这已经是非常高的性价比 了。的确,配套的 Unicon 与 Java 代码不算简单,但我们把大部分活儿交给了 lex(UFlex 与 JFlex) 来做。

Unicon 端的 Jzero 代码

Jzero 扫描器在 Unicon 中的实现位于 j0.icn 。Unicon 有预处理器,通常通过 $include 文件引入符号常量定义 。为了让同一份 lex 规范 可同时用于 UniconJava ,这个 Unicon 扫描器创建了一个 parser 对象,其字段(如 parser.WHILE)保存整数类别码:

css 复制代码
global yylineno, yycolno, yylval
procedure main(argv)
   j0 := j0()
   parser := parser(257,258,259,260,261,262,263,264,265,
                    266, 267,268,269,270,273,274,275,276,
                    277,278,280,298,300,301,302,303,304,
                    306,307,256)
   yyin := open(argv[1]) | stop("usage: simple2 filename")
   yylineno := yycolno := 1
   while i := yylex() do
      write("token ", i, ":",yylval.lineno, " ", yytext)
end

j0.icn 的第二部分是 j0 类。与前面 simple2.icn 的 simple2 类相比,增加了若干方法,供语义动作在遇到不同的空白注释 时调用。借此可以在全局变量 yycolno 中维护当前列号

scss 复制代码
class j0()
   method lexErr(s)
      stop(s, ": ", yytext) 
   end
   method scan(cat)
      yylval := token(cat, yytext, yylineno, yycolno)
      yycolno +:= *yytext
      return cat
   end
   method whitespace()
      yycolno +:= *yytext
   end
   method newline()
      yylineno +:= 1; yycolno := 1
   end
   method comment()
      yytext ? {
         while tab(find("\n")+1) do newline()
         yycolno +:= *tab(0)
      }
   end
   method ord(s)
      return proc("ord",0)(s[1])
   end
end

j0.icn 的第三部分把 token 从 record 提升为 class ,因为它的构造器变复杂了,并新增了一个方法用于处理字符串转义计算字符串字面量的二进制表示 。在 Unicon 中,构造器代码写在方法末尾的 initially 区块中。

deEscape() 方法会丢弃首尾双引号 ,然后用 Unicon 的字符串扫描 逐字符处理字符串字面量。在字符串扫描控制结构 s ? { ... } 中,对字符串 s 从左到右检查;函数 move(1) 取出下一个字符,并将扫描位置前移 1。关于字符串扫描的更详细说明见附录 Unicon Essentials

deEscape() 中,普通字符会原样从输入 sin 拷贝到输出 sout。遇到反斜杠引出的转义 ,后续一个或多个字符会被以不同方式解释。Jzero 子集只处理制表符换行 ;Java 里还有更多转义可补充。把反斜杠后跟 "t" 变成一个制表符看起来有点"魔法",但你用过的每个编译器都做过类似的事:

vbnet 复制代码
class token(cat, text, lineno, colno, ival, dval, sval)
   method deEscape(sin)
      local sout := ""
      sin := sin[2:-1]
      sin ? {
         while c := move(1) do {
            if c == "\" then {
               if not (c := move(1)) then
                  j0.lexErr("malformed string literal")
               else case c of {
                  "t":{ sout ||:= "\t" }
                  "n":{ sout ||:= "\n" }
                  }
               }
            }
            else sout ||:= c
         }
      }
      return sout
   end
initially
   case cat of {
     parser.INTLIT:    { ival := integer(text) }
     parser.DOUBLELIT: { dval := real(text) }
     parser.STRINGLIT: { sval := deEscape(text) }
   }
end

record parser(BREAK,PUBLIC,DOUBLE,ELSE,FOR,IF,INT,RETURN,VOID,
            WHILE,IDENTIFIER,CLASSNAME,CLASS,STATIC,STRING,
            BOOL,INTLIT,DOUBLELIT,STRINGLIT,BOOLLIT,
            NULLVAL,LESSTHANOREQUAL,GREATERTHANOREQUAL,
            ISEQUALTO,NOTEQUALTO,LOGICALAND,LOGICALOR,
            INCREMENT,DECREMENT,YYERRCODE)

对熟练的 Unicon 程序员来说,这里把一堆 token 类别名放在一个parser 记录 里看起来有点"多此一举":你完全可以用 $define 定义这些名字,而无需引入 parser 类型。但请记住 ,这么做是为了与 Java (尤其是 byacc/j兼容

Java 版 Jzero 代码

Jzero 扫描器在 Java 中的实现包含一个放在 j0.java 文件里的主类。它与前面的 simple2.java 示例相似。此处分四部分展示。第一部分包含 main() 函数------除了新增了用于跟踪当前列号yycolno 等变量外,应该都很眼熟:

java 复制代码
import java.io.FileReader;
public class j0 {
   static Yylex lex;
   public static int yylineno, yycolno;
   public static token yylval;
   public static void main(String argv[]) throws Exception {
      lex = new Yylex(new FileReader(argv[0]));
      yylineno = yycolno = 1;
      int i;
      while ((i=lex.yylex()) != Yylex.YYEOF) {
         System.out.println("token " + i + ":" + yylineno + " " + 
             yytext());
      }
   }

j0 类接下来给出若干在此前示例中已出现过的辅助函数:

arduino 复制代码
   public static String yytext() {
      return lex.yytext();
   }
   public static void lexErr(String s) {
      System.err.println(s + ": line " + yylineno +
                             ": " + yytext());
      System.exit(1);
   }
   public static int scan(int cat) {
      last_token = yylval =
         new token(cat, yytext(), yylineno, yycolno);
      yycolno += yytext().length();
      return cat;
   }
   public static void whitespace() {
      yycolno += yytext().length();
   }
   public short ord(String s) {return(short)(s.charAt(0));}

j0 类中用于处理换行符 的函数会将行号加一,并把列号重置为 1。处理注释的方法会逐字符遍历注释,以保持行号与列号的正确性:

csharp 复制代码
   public static void newline() {
      yylineno++; yycolno = 1;
   }
   public static void comment() {
      int i, len;
      String s = yytext();
      len = s.length();
      for(i=0; i<len; i++)
         if (s.charAt(i) == '\n') {
             yylineno++; yycolno=1;
         }
         else yycolno++;
   }
}

还有一个名为 parser.java 的支撑模块。它提供一组命名常量 (类似枚举),但直接将常量声明为 short 整数,以兼容下一章将要讨论的 iyacc 语法分析器。常量取值从 256 以上开始,因为 iyacc 也是从那里开始编号,以避免与我们通过 j0.ord() 产生的单字节词素的整数类别码冲突:

ini 复制代码
public class parser {
public final static short BREAK=257;
public final static short PUBLIC=258;
public final static short DOUBLE=259;
public final static short ELSE=260;
public final static short FOR=261;
public final static short IF=262;
public final static short INT=263;
public final static short RETURN=264;
public final static short VOID=265;
public final static short WHILE=266;
public final static short IDENTIFIER=267;
public final static short CLASSNAME=268;
public final static short CLASS=269;
public final static short STATIC=270;
public final static short STRING=273;
public final static short BOOL=274;
public final static short INTLIT=275;
public final static short DOUBLELIT=276;
public final static short STRINGLIT=277;
public final static short BOOLLIT=278;
public final static short NULLVAL=280;
public final static short LESSTHANOREQUAL=298;
public final static short GREATERTHANOREQUAL=300;
public final static short ISEQUALTO=301;
public final static short NOTEQUALTO=302;
public final static short LOGICALAND=303;
public final static short LOGICALOR=304;
public final static short INCREMENT=306;
public final static short DECREMENT=307;
public final static short YYERRCODE=256;
}

另一个支撑模块 token.java 定义了 token 类。它增添了列号 字段;对字面量常量,还将其二进制表示分别存入 ivalsvaldval(对应整数、字符串、双精度)。用于构造字符串字面量"二进制值"的 deEscape() 方法在 Unicon 版本中已讨论过。其算法仍是逐字符 处理:通常直接拷贝字符;若遇到反斜杠,则取出后续字符并按转义规则解释。把这段代码与 Unicon 版本对比,可以直观体会 Java String 类的便利:

arduino 复制代码
public class token {
   public int cat;
   public String text;
   public int lineno, colno, ival;
   String sval;
   double dval;
   private String deEscape(String sin) {
      String sout = "";
      sin = String.substring(sin,1,sin.length()-1);
      int i = 0;
      while (sin.length() > 0) {
         char c = sin.charAt(0);
         if (c == '\') {
            sin = sin.substring(1);
            if (sin.length() < 1)
               j0.lexErr("malformed string literal");
            else {
               c = sin.charAt(0);
               switch(c) {
               case 't': sout = sout + "\t"; break;
               case 'n': sout = sout + "\n"; break;
               default: j0.lexErr("unrecognized escape");
               }
             }
         }
           else sout = sout + c;
         sin = sin.substring(1);
      }
      return sout;
   }
   public token(int c, String s, int ln, int col) {
      cat = c; text = s; lineno = ln; colno = col;
      switch (cat) {
      case parser.INTLIT:
         ival = Integer.parseInt(s);
         break;
      case parser.DOUBLELIT:
         dval = Double.parseDouble(s);
         break;
      case parser.STRINGLIT:
         sval = deEscape(s);
         break;
      }
   }
}

token 构造器对所有记号都做了四项相同的赋值(即初始化各字段),随后针对三类字面量 记号通过 switch 进行额外初始化。这里用到 Java 内置的 Integer.parseInt()Double.parseDouble() 来转换词素,这对 Jzero 来说是一种简化 ------真正的 Java 编译器在此还需要做更多工作。svaldeEscape() 生成,因为 Java 中并没有"直接把源码里的字符串字面量 转换成实际字符串值 "的内置函数。固然可以找到第三方库,但对 Jzero 而言,自备一个更简单。

运行 Jzero 扫描器

你可以像下面这样在 UniconJava 中运行程序。这一次,我们在如下名为 hello.java 的示例输入文件上运行:

typescript 复制代码
public class hello {
   public static void main(String argv[]) {
      System.out.println("hello, jzero!");
   }
}

请记住:对你的扫描器而言,这个 hello.java 程序只是一串词素。编译与运行 Jzero 扫描器的命令与之前示例类似,只是出现了更多 Java 文件:

markdown 复制代码
uflex javalex.l              jflex javalex.l
unicon j0 javalex            javac j0.java Yylex.java
                             javac token.java parser.java
j0 hello.java                java j0 hello.java

无论采用哪种实现,预期输出应类似如下:

arduino 复制代码
token 258:1 public
token 269:1 class
token 267:1 hello
token 123:1 {
token 258:2 public
token 270:2 static
token 265:2 void
token 267:2 main
token 40:2 (
token 267:2 String
token 267:2 argv
token 91:2 [
token 93:2 ]
token 41:2 )
token 123:2 {
token 267:3 System
token 46:3 .
token 267:3 out
token 46:3 .
token 267:3 println
token 40:3 (
token 277:3 "hello, jzero!"
token 41:3 )
token 59:3 ;
token 125:4 }
token 125:5 }

到下一章,当扫描器的输出作为语法分析器 的输入时,Jzero 扫描器会更有意义。不过在继续之前需要提醒你:正则表达式并不能覆盖词法分析可能需要做的一切;有时必须超越 lex 的扫描模型。下一节将给出一个真实世界的例子。

正则表达式并非万能

如果你上过"计算理论"课程,大概率会见到这样的证明:正则表达式无法匹配 编程语言中某些常见模式,尤其是同一模式可以嵌套自身的情形。本节还将展示,正则表达式在其他方面也并非总能胜任。

当正则表达式无法覆盖语言中的所有词法任务时,怎么办?你可以手写词法分析器 来处理由正则生成器搞不定的古怪情形------代价可能是多花上一天、一周,甚至一个月。不过,在几乎所有真实的编程语言中,正则表达式已经足够接近成品扫描器的需求,你只需再加**少数"额外招式"**即可。下面给出一个真实世界的小例子。

UniconGo 都提供了分号自动插入 。语言定义了一套词法规则,按需插入分号,从而让程序员大多数时候不必操心。你也许已经注意到:Unicon 的代码示例里几乎没有分号。不幸的是,这些"自动插入分号"的规则无法用正则表达式描述

Go 中,你几乎可以这么做:记住上一个返回的 token ,并在"匹配到换行符"的语义动作里做一些检查;若满足条件,就把这个换行当作分号返回 。但在 Unicon 中,你必须继续向前扫描 ,在换行后读下一个 token,再决定是否应插入分号!因此,Unicon 的分号插入更精确,比 Go 更少出问题。比如,在 Go 中,你不能写出经典的 C 风格换行大括号:

csharp 复制代码
func main()
{
   ...
}

必须把左花括号写在函数头这一行:

csharp 复制代码
func main() {
   ...
}

要避免这种让人发笑的限制,词法分析器必须提供一个 token 的前瞻 :它需要读到下一行的第一个 token,再决定是否在换行处插入分号。

在我们的 Jzero 扫描器里实现分号插入,会很"不像 Java"。但如果真要做,也可以走 Go 式Unicon 式 两条路。这里我们展示 Go 式 的一个子集。供参考,Go 的分号插入语义见:golang.org/ref/spec#Se...

本例体现了 Go 分号插入语义的规则 #1 :看见换行 了------要不要插分号?我们只要记住上一个 token ,如果它是标识符、字面量、breakcontinuereturn++--)]} ,那么这个换行本身 就应该返回一个新的"伪造"分号 token 。你可以修改 newline() 方法:若应插入分号则返回布尔值 true

这会打破我们"一份 lex 规范同时兼容 Unicon 与 Java "的策略。我们需要在 lex 规范里写"要不要返回分号 "的条件判断,但两种语言的语法不同 。在 Unicon 中,lex 规范里的 if 可能写成:

kotlin 复制代码
\n         { if j0.newline() then return j0.semicolon() }

而在 Java 中,需要括号且没有关键字 then

kotlin 复制代码
\n         { if (j0.newline()) return j0.semicolon(); }

本书的 GitHub 仓库提供了带分号插入 的 Unicon 主模块修改版 j0go.icn 。它在 j0.icn 的基础上新增了全局变量 last_token,修改了 scan()newline(),并添加了构造"人工 token"的 semicolon() 方法。变更的方法如下。判断"上一个 token 的类别是否属于触发分号的一组类别"时,示例顺便展示了 Unicon 的生成器 :表达式 !")]}" 是把 ")" | "]" | "}" 依次送入 ord() 的巧写法:

matlab 复制代码
method scan(cat)
   last_token := yylval := token(cat, yytext, yylineno)
   return cat
end

method newline()
   yylineno +:= 1
   if (\last_token).cat ===
        ( parser.IDENTIFIER|parser.INTLIT|
          parser.DOUBLELIT|parser.STRINGLIT|
          parser.BREAK|parser.RETURN|
          parser.INCREMENT|parser.DECREMENT|
          ord(!")]}") ) then return
end

method semicolon()
   yytext := ";"
   yylineno -:= 1
   return scan(parser.SEMICOLON)
end

这里有两点很有意思。其一,同样是换行字符 (在多数语言里只是空白),有时 要返回一个(插入的分号)整数码有时 什么也不返回------这就是我们在"换行"的语义动作里引入 if 的原因。其二,semicolon() 产出的人工 token :它的效果与"程序员在源码里键入了一个分号"无异

Java 版本在仓库中的 j0go.java 。关键部分如下。它与 Unicon 版 j0go.icn 行为一致:新增 last_token,修改 scan()newline(),并添加构造人工 token 的 semicolon()。只是代码略长。在 newline() 中,使用 switch 检查上一个 token的类别是否触发分号插入:

csharp 复制代码
public static int scan(int cat) {
   last_token = yylval =
      new token(cat, yytext(), yylineno);
   return cat;
}

public static boolean newline() {
   yylineno++;
   if (last_token != null)
      switch(last_token.cat) {
         case parser.IDENTIFIER: case parser.INTLIT:
         case parser.DOUBLELIT: case parser.STRINGLIT:
         case parser.BREAK: case parser.RETURN:
         case parser.INCREMENT: case parser.DECREMENT:
         case ')': case ']': case '}':
            return true;
      }
   return false;
}

public int semicolon() {
   yytext = ";";
   yylineno--;
   return scan(parser.SEMICOLON);
}

完整的 Go 分号插入语义更复杂一些,但在匹配到换行的扫描阶段 插入分号其实不难。想了解 Unicon 如何做得更好的,可参考 Unicon Implementation Compendiumwww.unicon.org/book/ib.pdf

总结

本章介绍了编程语言在读取源代码字符 时用到的关键技术与工具。得益于这些技能,你的编译器/解释器处理的不再是海量字符,而是规模更小的词/记号序列

我们覆盖了大量内容:当输入字符被读入时,会被分析并分组成词素(lexeme) ;词素要么被丢弃(注释与空白),要么被分类供后续语法分析使用。

除了对词素分类,你还学会了如何把它们封装为 token :每个被分类的词素会对应一个对象实例,记录词素文本、类别,以及其来源位置等信息。

这些词素类别 将作为下一章语法分析 算法的主要输入;在解析过程中,token 终将作为语法树的叶子节点被插入。

现在,你已准备好把"词"串成"短语"。下一章将讲述解析(parsing) :检查这些短语是否符合语言的文法

相关推荐
数据智能老司机2 小时前
自己动手写编程语言——编程语言设计
架构·编程语言·编译原理
一只拉古2 小时前
C# 代码审查面试准备:实用示例与技巧
后端·面试·架构
失散132 小时前
分布式专题——4 大厂生产级Redis高并发分布式锁实战
java·redis·分布式·缓存·架构
听风同学2 小时前
向量数据库---Chroma数据库入门到进阶教程
后端·架构
hello 早上好3 小时前
Spring MVC 类型转换与参数绑定:从架构到实战
spring·架构·mvc
FOLLOW ME3113 小时前
MySQL集群高可用架构
数据库·mysql·架构
一个帅气昵称啊4 小时前
C#,RabbitMQ从入门到精通,.NET8.0(路由/分布式/主题/消费重复问题 /延迟队列和死信队列/消息持久化 )/RabbitMQ集群模式
分布式·微服务·架构·rabbitmq·.net
歪歪1004 小时前
Redux和MobX在React Native状态管理中的优缺点对比
前端·javascript·react native·react.js·架构·前端框架
失散134 小时前
分布式专题——6 Redis缓存设计与性能优化
java·redis·分布式·缓存·架构