不管你相不相信 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版)》《计算机是怎样跑起来的》《自制搜索引擎》等。