只写 4 个 % 就能实现一个类似 Linux 上 cat 命令的程序

140 阅读1分钟

不管你相不相信 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
...
vim-config-file

刚刚发生了什么

显然不存在什么“百分号编程语言”。

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 条规则:

  1. SELECTFROMWHERE 是 SQL 语句中的关键字。当遇到这些关键字时,我们使用 _format 宏将其输出为加粗的文本。
  2. [0-9]+ 表示数字。当匹配到数字时,我们将其输出为绿色的文本。
  3. \"[^\"]*\" 表示用双引号括起来的字符串。当匹配到字符串时,我们将其输出为蓝色的文本。

最后,在第二个 %% 分隔符之后的用户代码部分,我们定义了 main() 函数。在该函数中,先打印出提示信息 Input SQL:,然后调用 lex 内置的 yylex() 函数来解析用户输入的 SQL 语句。

第二步还是依次执行 lexgcc 命令。

$ lex sql.l 
$ gcc lex.yy.c -ll

好了,又大功告成了!我们赶紧来测试一下。

lex-sql-highlighter

看起来还不错,虽然有 Bug(表名 t1 中的 1 不应该是绿色),但用 lex 生成一个 SQL 语句高亮显示程序似乎比纯手写一个这样的程序要简单得多。

main() 函数去哪里了

不知道诸位有没有注意到,在一开始仅有 4 个 % 的空白规则文件中,我们并没有定义 main() 函数。但经过 lexgcc 的转换和编译后,程序竟然能够运行!

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