不管你相不相信 4 个 % 就能实现一个类似 cat 的程序,请先新建一个文件,然后在里面按照如下格式——每行 2 个 %——共输入 4 个 %。
$ # 假设新建文件的名字就叫“%%%%”
$ cat %%%%
%%
%%
接下来,请再依次执行如下 2 条命令,
$ lex %%%%
$ gcc lex.yy.c -ll
好了,大功告成!
我们得到了一个名为 a.out 的可执行文件,而且这个程序的功能与 cat 命令的功能几乎差不多。比如,我们可以用 a.out 来查看配置文件的内容,省得用 vim 查看了又被喷“素养不够”。
$ ./a.out < /etc/hosts
127.0.0.1 localhost
# The following lines are desirable for IPv6 capable hosts
::1 ip6-localhost ip6-loopback
fe00::0 ip6-localnet
...
刚刚发生了什么
显然不存在什么“百分号编程语言”。
而 gcc 是 C 语言的编译器,我们刚刚用 gcc 编译了一个叫作 lex.yy.c 的 C 语言的源文件,一定是这个源文件中含有类似 cat 命令功能的代码。
那 lex.yy.c 又是怎么产生的呢,关键点就在于 lex 这个命令。看起来是 lex 做了某种转换,将 4 个 % 转变成了 C 语言代码。
lex 其实是一个词法分析器生成器,它能够根据用户提供的规则生成一大段 C 语言的源代码,这段代码可用于识别输入或文件中的标记(token)。词法分析器(lexical analysis)也叫扫描器(scanner)。
既然 lex 的输入是规则(规则文件),输出是 C 语言的源代码,那这 4 个 % 难道代表某种规则吗?怎么会有这么晦涩、无法一目了然的规则呢?
其实,2 个连续的百分号,即 %%, 只不过是 lex 规则文件(lex specifications)中各部分的分隔符。lex 规则文件的模版长这样,
... 定义部分 ...
%%
... 规则部分 ...
%%
... 用户代码部分 ...
我们刚刚只不过是定义了一个既没有定义部分,也没有规则部分和用户代码部分的、空的规则文件。
lex 还有个默认行为——若输入的字符不符合任何规则,就原样输出这个字符。
我们的空白规则文件中连规则都没有,也就是说连检查是否匹配的机会都没有给输入中的字符,那输入的字符自然是不匹配任何规则,因此在 lex 默认规则的作用下,所有字符都原样输出了,就好像 cat 命令所做的一样。
我们先不去管 lex 规则文件中的其他部分,回顾一下刚刚做了什么。
我们只不过是编写了一个除了 %% 分隔符,没有任何实际内容的 lex 规则文件,然后使用 lex 命令将规则文件转换成了 C 语言的源文件(lex.yy.c),最后用 gcc 编译器把这个生成的源文件编译成了所谓的词法分析器。这里还利用了 lex 的默认规则——不能识别的字符就原样输出。就这样耍了个小聪明,拼凑出了一个类似 cat 命令的程序。
若要把默认规则写出来,规则文件的内容则类似下面这样,
%%
.|\n { printf("%s", yytext); }
%%
可以看到,规则由两部分组成,正则表达式和一段 C 语言代码。上面这条规则的含义是,
.表示除换行符以外的任意单个字符,\n表示换行符.|\n用|连起来表示或的关系,即当遇到除换行符以外的任意单个字符或者换行符时,也就是无论遇到什么字符都执行大括号中的代码:{ printf("%s", yytext); }- 这段代码中的
yytext是一个 lex 内置的字符串变量,代表当前匹配到的文本
4个 % 的伎俩太低级了吧,那能不能利用 lex 做个稍微像样点的程序呢?当然可以,而且很简单。
用 lex 高亮 SQL 语句
下面我们来试着编写一个可以高亮 SQL 语句的程序。只需要两步哦!
第一步是编写如下的 lex 规则文件。
%{
#include <stdio.h>
#define BOLD "\e[1;30m"
#define GREEN "\e[32m"
#define BLUE "\e[34m"
#define FORMAT_RESET "\e[0m"
#define _format(format, str) "%s%s%s", format, str, FORMAT_RESET
%}
%%
SELECT |
FROM |
WHERE { printf(_format(BOLD, yytext)); }
[0-9]+ { printf(_format(GREEN, yytext)); }
\"[^\"]*\" { printf(_format(BLUE, yytext)); }
%%
int main() {
printf("%s\n", "Input SQL:");
yylex();
}
首先,在由 %{ 和 %} 扩起来的部分(定义部分),我们定义了一些 ANSI 转义序列的宏,用于设置文本的颜色和样式。我们还定义了一个宏 _format,用于将字符串和指定的样式连接起来,以此格式化字符串。
接下来,在第一个 %% 分隔符之后的规则部分中,我们定义了 3 条规则:
SELECT、FROM和WHERE是 SQL 语句中的关键字。当遇到这些关键字时,我们使用_format宏将其输出为加粗的文本。[0-9]+表示数字。当匹配到数字时,我们将其输出为绿色的文本。\"[^\"]*\"表示用双引号括起来的字符串。当匹配到字符串时,我们将其输出为蓝色的文本。
最后,在第二个 %% 分隔符之后的用户代码部分,我们定义了 main() 函数。在该函数中,先打印出提示信息 Input SQL:,然后调用 lex 内置的 yylex() 函数来解析用户输入的 SQL 语句。
第二步还是依次执行 lex 和 gcc 命令。
$ lex sql.l
$ gcc lex.yy.c -ll
好了,又大功告成了!我们赶紧来测试一下。
看起来还不错,虽然有 Bug(表名 t1 中的 1 不应该是绿色),但用 lex 生成一个 SQL 语句高亮显示程序似乎比纯手写一个这样的程序要简单得多。
main() 函数去哪里了
不知道诸位有没有注意到,在一开始仅有 4 个 % 的空白规则文件中,我们并没有定义 main() 函数。但经过 lex 和 gcc 的转换和编译后,程序竟然能够运行!
main() 函数不是 C 语言程序不可或缺的入口吗?
其实,lex 的静态库(libl.a)中提供了一个默认的 main() 函数。在用 gcc 编译时,参数 -ll 就是用于告诉 gcc 要去链接这个库。
若用 nm 命令来列出该静态库中包含的所有符号(函数、变量等),就会发现这个默认的 main() 函数,
$ nm /usr/lib/x86_64-linux-gnu/libl.a
libmain.o:
U _GLOBAL_OFFSET_TABLE_
U exit
0000000000000000 T main
U yylex
libyywrap.o:
0000000000000000 T yywrap
而 gcc 若发现我们自己定义了 main() 函数就会忽略 lex 提供的默认 main() 函数(默认 main() 的源代码)。
现在诸位应该都知道“只用 4 个 % 就实现了一个伪 cat 命令”这背后的小把戏了吧。lex 是个十分方便的工具,通常与 yacc 一起使用,广泛应用于生成编程语言编译器和解释器、配置文件解析器、语法高亮程序等场景。
不过,比起用 lex 自动生成词法分析器,有些大佬似乎更喜欢纯手写,比如 SQLite 和 PHP 2 的词法分析器都是手写的。你还知道哪些手写的词法分析器呢?
软件工程师、技术图书译者。译有《图解云计算架构》《图解量子计算机》《图解TCP/IP(第6版)》《计算机是怎样跑起来的》《自制搜索引擎》等。