(car 《为自己写本-Guile-书》)
前言中,我说要写一个文式编程工具。它的名字叫 zero,是个命令行程序,运行时需要由使用者提供一些参数与文式编程元文档路径。zero 读取元文档,然后根据使用者设定的参数对元文档进行处理,最终给出相应的输出。本章内容主要讲述如何用 Guile 写一个命令行程序的界面——对于使用者而言,zero 程序可见的部分。
分割命令行文本
C 程序可以通过 main
函数的参数获得命令行文本的分割结果,即一个字符串数组:
/* foo.c */
#include
int
main(int argc, char **argv) {
for (int i = 0; i < argc; i++) {
printf("%s\n", argv[i]);
}
return 0;
}
设编译所得程序为 foo,执行它,
$ ./foo bar foobar
可得
./foo
bar
foobar
用 Guile 语言也能写出类似的程序:
;; foo.scm
(define (display-args args)
(cond ((null? args) #nil)
(else (begin
(display (car args)) (newline)
(display-args (cdr args))))))
(display-args (command-line))
需要用 Guile 解释器来运行这个程序:
$ guile foo.scm bar foobar
程序运行结果为:
foo.scm
bar
foobar
如果在上述 Guile 代码的首部增加
#!/usr/bin/guile -s
!#
然后将 foo.scm
文件改名为 foo
,并使之具备可执行权限:
$ chomd +x ./foo
这样,这个 Guile 脚本程序在行为上与上述 C 程序完全一样。
现在,假设 C 语言未提供 for
与 while
循环(迭代)结构,那么使用函数对自身的调用来模拟迭代过程,可以写出与上述 Guile 代码相似的形式:
#include
void
display_args(char **args, int i, int n) {
if (i >= n) {
return;
} else {
printf("%s\n", args[i]);
display_args(args, i + 1, n);
}
}
int
main(int argc, char **argv) {
display_args(argv, 0, argc);
return 0;
}
如果将 argv
转换为一个以 NULL
为结尾的字符串数组,便可以让 C 语言版的 display_args
在形式上很像 Guile 版的 display-args
函数:
#include
#include
#include
void
display_args(char **args) {
if (*args == NULL) {
return;
} else {
printf("%s\n", *args);
display_args(args + 1);
}
}
int
main(int argc, char **argv) {
char **new_argv = malloc((argc + 1) * sizeof(char *));
memcpy(new_argv, argv, argc * sizeof(char *));
new_argv[argc] = NULL;
display_args(new_argv);
free(new_argv);
return 0;
}
上文中的 Guile 代码,经过 C 代码的诠释,可观其大略——用函数的递归形式模拟了 C 的循环结构。至于代码中的一些细节,后文逐一给出解释。
列表,见其首,不见其尾
在 C 程序中,命令行文本是保存在 main
函数的 argv
参数中的,这个参数是个字符串数组。在 Guile 脚本中,命令行文本是通过函数 command-line
函数在运行时获取的,即
(command-line)
该函数的返回结果是一个字符串列表。这行代码便是 command-line
函数的调用代码。command-line
函数不需要参数,对它的调用,可用下面这行 C 代码来诠释:
command-line(); /* 伪代码,因为 C 语言不支持这种函数命名方式 */
那么,command-line
函数的返回结果——字符串列表是怎样的一种数据结构?答案是,不清楚。我们只知道,它是列表类型的数据。
在 Guile 中,对于所有的列表类型的数据,使用 car
函数可以从列表中取出首个元素;使用 cdr
函数可以从列表中取出除首个元素之外的所有元素,所取出的元素构成一个新的列表,并且这些元素在新列表中的次序与原列表相同。
下面这份 Guile 脚本:
;; demo-01.scm
#!/usr/bin/guile -s
!#
(display (car (command-line)))
(newline)
(display (cdr (command-line)))
(newline)
(display (car (cdr (command-line))))
(newline)
执行它:
$ ./demo-01.scm foo bar foobar
得到的结果依序如下:
./demo-01.scm
(foo bar foobar)
foo
如果看不懂上述 Guile 代码,可以看下面的等效的伪 C 代码:
printf("%s", car(command-line()));
printf("\n");
printf("%s", cdr(command-line()));
printf("\n");
printf("%s", car(cdr(command-line())));
printf("\n");
通过这些等效的伪 C 代码,可以理解 Guile 函数的调用方式,以及 display
与 newline
函数的效用。
条件表达式
下面这段 Guile 代码
(cond ((null? args) #nil)
(else (begin
(display (car args)) (newline)
(display-args (cdr args))))))
与之等效的 C 代码如下:
if (*args == NULL) {
return;
} else {
printf("%s\n", *args);
display_args(args + 1);
}
cond
是 condition
的缩写,其用法如下:
(cond (<谓词 1> <表达式 1>)
(<谓词 2> <表达式 2>)
... ...
(<谓词 n> <表达式 n>)
(else <表达式 n + 1>)
等效的 C 条件表达式结构如下:
if (<谓词 1>) {
<表达式 1>
} else if (<谓词 1>) {
} else if (...) {
...
} else if (<谓词 n>) {
<表达式 n>
} else {
<表达式 n + 1>
}
所谓的谓词是指可以返回『真』或『假』的计算过程。(null? args)
便是 Guile 的一个谓词——如果 args
列表非空,它返回『假』,否则返回『真』。
下面这个条件表达式
(cond ((null? args) #nil)
(else (car args)))
它表达的意思是,如果列表 args
为空,那么这个条件表达式的计算结果为 #nil
——空的列表,否则计算结果为 args
的首元素。
顺序求值
cond
表达式中,对各个条件分支中的谓词是按顺序求值的。在这个过程中,如果某个谓词的求知结果为真,那么该谓词之后的表达式的求值结果便是 cond
表达式的求值结果。
有时,我们需要无条件的依序执行一些计算过程,例如:
(display (car args))
(newline)
(display-args (cdr args))
这在 C 语言里是很平淡无奇的过程,但是 Guile 语言却不能直接支持,因为它的任何语句都必须是一条完整的表达式,而不能使多个独立的表达式的陈列。为了能够依序执行一组表达式,可以用 begin
语句:
(begin <表达式 1> <表达式 2> ... <表达式 n>)
<表达式 n>
的求值结果是 begin
语句的求值结果。
下面这条 begin
语句:
(begin
(display (car args)) (newline)
(display-args (cdr args)))
它的含义应该很明显了。
函数
下面这些代码,除了 args
之外,其他元素都是确定的,这意味着 args
是个未知数或变量。
(cond ((null? args) #nil)
(else (begin
(display (car args)) (newline)
(display-args (cdr args)))))
如果一个未知的事物与一些确定的事物之间存在着确定的联系,这些联系可以将未知的事物转换为另一个未知的事物,这个过程就是所谓的『映射』或『函数』。在 Guile 中,定义一个函数需要遵守下面这样的格式:
(define (<函数> <未知的事物>) <未知的事物与一些确定的事物之间所存在的确定的联系>)
前文中,我们已经定义了一个函数 display-args
:
(define (display-args args)
(cond ((null? args) #nil)
(else (begin
(display (car args)) (newline)
(display-args (cdr args))))))
函数 y = f(x)
,如果我们已知 x = 2
,那么根据 f(2)
就可以得到相应的 y
值。在 C 语言中,这叫函数调用。在 Guile 中,这叫函数应用。没必要在这些文字游戏上浪费时间,本质上就是将确定的自变量 x = 2
代入 y = f(x)
这个函数或映射,从而得到确定的因变量。在编程中,我们通常将自变量称为参数,将因变量称为返回值。这其实都是玩弄文字的把戏……
有些函数是没有求值结果的,例如 display
函数,它的任务是将用户传入的参数显示于终端(显示器屏幕或文件)。这类似于,你给朋友一些钱,让他去书店为你买本书,这本书是『你朋友从你哪里接过钱,然后去书店买书』这个过程的『求值结果』,但是你给一个画家一些钱,让他在人民公园的墙上为你涂鸦,结果你得到了什么?可能是他人的驻足围观,也可能是公园管理人员给你开罚单……
对于 display-args
函数而言,如果它的参数是列表类型,那么它总是有求值结果的,即 #nil
,但是它除了可以得到这个结果,在其执行过程中还不断的在终端中涂鸦……也就是说 display-args
是个有副作用的函数。它的副作用是 display
函数带来的。
数学家们不喜欢有副作用的函数,因为他们是数学家。他们喜欢的那种编程语言,叫做『纯函数式编程语言』。像 C 语言这种到处都充满着副作用的编程语言,他们是非常非常的拒绝的,他们讨厌 x = x - 1
这样的代码,因为他们认为 0 = -1
这样的推导结果是荒谬的。想必他们对现实世界也非常的不习惯吧,他们从药瓶里倒出一粒药吃下去,然后他们得到了两个药瓶 :D
如果像下面这样应用 display-args
函数:
(display-args (cons 1 (cons 2 (cons 3 #nil))))
可以得到什么结果?可以得到 #nil
,同时终端中会显示:
1
2
3
(cons 1 (cons 2 (cons 3 #nil)))
是什么?它是一连串 cons
运算符的应用。如果将 cons
视为一个函数,那么等效的 C 代码如下:
cons(1, cons(2, cons(3, #nil)));
结果是一个列表,其元素依次为 1, 2, 3。将这个列表传入 display-args
,便会将其元素逐一显示于终端。
cons
运算符的第一个参数可以是任意类型的数据,而它的第二个参数必须是列表类型。它的工作是,将第一个参数所表示的数据添加到第二个参数所表示的列表的首部,然后返回这个新的列表。上文中说过,#nil
表示空的列表。(cons 3, #nil)
可将 3
添加到一个空的列表的首部,返回一个新的列表——只含有元素 3 的列表。以此类推,(cons 2 (cons 3 #nil))
的结果是依序包含 2
与 3
的列表,(cons 1 (cons 2 (cons 3 #nil)))
的结果是依序包含 1
, 2
, 3
的列表。
zero 程序界面的实现
zero 程序的用法如下:
$ zero [选项] 文件
zero 程序可以支持以下选项:
-m, --mode=moon 或 sun 指定 zero 的工作模式是 moon 还是 sun
-e, --entrance=代码块 将指定的代码块设为代码的抽取入口
-o, --output=文件 将提取到的代码输出至指定的文件
-b, --backtrace 开启代码反向定位功能
-p, --prism=棱镜程序 为 sun 模式指定一个棱镜程序
由于这些选项在形式上大同小异,因此下面仅以 -m
与 --mode
选项为例,讲述如何为 zero 程序构造一个简单的命令行界面。-m
选项为短选项,--mode
为长选项,它们是同一个选项的两种表现形式。也就是说,下面这两行代码是等价的:
$ zero -m moon foo.zero
$ zero --mode=moon foo.zero
要构建的这个命令行界面程序的主要任务是,从命令行文本中获取 -m
或 --mode
的参数值以及文件名。对于上面示例中的 zero
命令行文本而言,要获取的是 moon
与 foo.zero
。
文件名解析
(define (get-filename args)
(cond ((null? (cdr args)) (car args))
(else (get-filename (cdr args)))))
这个函数的求值结果为字符串类型,是 zero 程序要读取的文件的名字(或路径)。
命令行选项参数解析
由于 -m
或 -mode
选项只有两个值 moon
与 sun
可选,可以将它们映射为整型数:
参数
moon
对应 1;参数
sun
对应 2;若经过解析,发现命令行文本中即未出现
-m
也未出现--mode
,这种情况对应 0;若命令行文本中即出现了
-m
或--mode
,但是参数值既非moon
,亦非sun
,这种情况对应 -1.
根据上述映射,写出以下 Guile 代码:
#!/usr/bin/guile -s
!#
(define (filter-mode-opt args)
(cond ((null? args) 0)
(else (let ((fst (car args))
(snd (cadr args)))
(cond ((string=? fst "-m")
(cond ((string=? snd "moon") 1)
((string=? snd "sun") 2)
(else -1)))
((string-prefix? "--mode=" fst)
(let ((mode (cadr (string-split fst #\=))))
(cond ((string=? mode "moon") 1)
((string=? mode "sun") 2)
(else -1))))
(else (filter-mode-opt (cdr args))))))))
(display (filter-mode-opt (command-line)))
(newline)
上述代码中,出现了上文未涉及的一些语法——let
,cadr
,string-prefix?
,string=?
,string-split
。这些语法的含义,暂时不予追究,先来看下面的等效 C 代码:
#include
#include
#include
int
filter_mode_opt(char **args) {
if (*args == NULL) {
return 0;
} else {
if (strcmp(*args, "-m") == 0) {
char *next_arg = *(args + 1);
if (strcmp(next_arg, "moon") == 0) return 1;
else if (strcmp(next_arg, "sun") == 0) return 2;
else return -1;
} else if (strncmp(*args, "--mode", 6) == 0) {
int mode;
char *new_arg = malloc((strlen(*args) + 1) * sizeof(char));
strcpy(new_arg, *args);
strtok(new_arg, "=");
char *mode_text = strtok(NULL, "=");
if (strcmp(mode_text, "moon") == 0) mode = 1;
else if (strcmp(mode_text, "sun") == 0) mode = 2;
else mode = -1;
free(new_arg);
return mode;
} else {
filter_mode_opt(args + 1);
}
}
}
int
main(int argc, char **argv) {
char **new_argv = malloc((argc + 1) * sizeof(char *));
memcpy(new_argv, argv, argc * sizeof(char *));
new_argv[argc] = NULL;
printf("%d\n", filter_mode_opt(new_argv));
free(new_argv);
return 0;
}
C 代码看上去要罗嗦一点,主要是因为 C 语言在字符串处理方面的功能弱一些,不过在逻辑上与上面的 Guile 代码等价。如果我们动用 for
循环,C 的代码反而会更清晰一些:
#include
#include
#include
int
filter_mode_opt(int argc, char **args) {
int mode = 0;
for (int i = 0; i < argc; i++) {
if (strcmp(args[i], "-m") == 0) {
if (strcmp(args[i + 1], "moon") == 0) return 1;
else if (strcmp(args[i + 1], "sun") == 0) return 2;
else return -1;
} else if (strncmp(args[i], "--mode", 6) == 0) {
char *new_arg = malloc((strlen(args[i]) + 1) * sizeof(char));
strcpy(new_arg, args[i]);
strtok(new_arg, "=");
char *mode_text = strtok(NULL, "=");
if (strcmp(mode_text, "moon") == 0) mode = 1;
else if (strcmp(mode_text, "sun") == 0) mode = 2;
else mode = -1;
free(new_arg);
}
}
return mode;
}
int
main(int argc, char **argv) {
printf("%d\n", filter_mode_opt(argc, argv));
return 0;
}
上述的 Guile 程序可以简化为:
#!/usr/bin/guile -s
!#
(define (which-mode? x)
(cond ((string=? x "moon") 1)
((string=? x "sun") 2)
(else -1)))
(define (filter-mode-opt args)
(cond ((null? args) 0)
(else (let ((fst (car args))
(snd (cadr args)))
(cond ((string=? fst "-m") (which-mode? snd))
((string-prefix? "--mode=" fst)
(which-mode? (cadr (string-split fst #\=))))
(else (filter-mode-opt (cdr args))))))))
(display (filter-mode-opt (command-line)))
(newline)
同理,也可将 C 程序简化为:
#include
#include
#include
int
which_mode(char *mode_text) {
if (strcmp(mode_text, "moon") == 0) {
return 1;
} else if (strcmp(mode_text, "sun") == 0) {
return 2;
} else {
return -1;
}
}
int
filter_mode_opt(int argc, char **args) {
int mode = 0;
for (int i = 0; i < argc; i++) {
if (strcmp(args[i], "-m") == 0) mode = which_mode(args[i + 1]);
else if (strncmp(args[i], "--mode", 6) == 0) {
char *new_arg = malloc((strlen(args[i]) + 1) * sizeof(char));
strcpy(new_arg, args[i]);
strtok(new_arg, "=");
mode = which_mode(strtok(NULL, "="));
free(new_arg);
}
}
return mode;
}
int
main(int argc, char **argv) {
printf("%d\n", filter_mode_opt(argc, argv));
return 0;
}
现在来看一些之前未遭遇的一些细节。首先看 let
:
(let ((args (cons 1 (cons 2 (cons 3 #nil)))))
(let ((fst (car args))
(snd (car (cdr args))))
(begin (display fst)
(newline)
(display snd)
(newline))))
上述这段代码,经 Guile 解释器运行后,会输出以下结果:
1
2
与之大致等效的 C 代码如下:
#include
int
main(void) {
/* 局部块 */ {
int args[] = {1, 2, 3, 4};
/* 局部块 */ {
int fst = *args; /* args[0] */
int snd = *(args + 1); /* args[1] */
{
printf("%d", fst);
printf("\n");
printf("%d", snd);
printf("\n");
}
}
}
}
也就是说,let
每次都能构建一个『局部环境』,然后定义一些局部变量以供为这个局部环境内代码使用,其语法结构如下:
(let ((<变量 1> <表达式 1>)
(<变量 2> <表达式 2>)
... ... ...
(<变量 n> <表达式 n>))
<需要使用上述变量的表达式>)
上面的 let
语句示例中,出现了 (car (cdr args))
这样的表达式,它的含义是取 args
列表的第 2 个元素。Guile 为这种操作提供了一个简化运算符 cadr
,用法为 (cadr args)
。同理,对于 (cdr (cdr args))
这样的运算,Guile 提供了 cddr
,用法为 (cddr args)
。
因为在解析命令行文本过程中,一些字符串运算是不可避免的。Guile 为字符串运算提供了很丰富的函数。本节中用到了 string-prefix?
,string=?
,string-split
。只需通过下面几个示例便可了解它们的功能及用法。在终端中输入 guile
命令,进入 Guile 交互解释器环境,然后执行以下代码:
> (string-prefix? "--mode" "--mode=sun")
$1 = #t
> (string-prefix? "--mode" "--node=sun")
$2 = #f
> (string=? "sun" "sun")
$3 = #t
> (string=? "sun" "moon")
$4 = #f
> (string-split "--mode=sun" #\=)
$5 = ("--mode" "sun")
> (string-split "--mode=sun=cpu" #\=)
$6 = ("--mode" "sun" "cpu")
在 Guile 中,#t
与 #f
分别表示布尔真值(True)与假值(False),而 ("--mode" "sun")
与 ("--mode" "sun" "cpu")
这样结构是列表。
通用过程
为每个命令行选项都像上一节中所做的那样,写一个专用的解析函数,这太过于浪费代码了。考察 filter-mode-opt
过程:
(define (filter-mode-opt args)
(cond ((null? args) 0)
(else (let ((fst (car args))
(snd (cadr args)))
(cond ((string=? fst "-m") (which-mode? snd))
((string-prefix? "--mode=" fst)
(which-mode? (cadr (string-split fst #\=))))
(else (filter-mode-opt (cdr args))))))))
在这个过程中,只有 -m
, --mode
以及 which-mode?
函数需要特别指定。如果将这些需要特别指定的因素作为参数传递给 filter-mode-opt
这样的函数,那么 filter-mode-opt
的通用性便会得到显著提升——它不仅仅能够处理 zero
的 -m
与 --mode
选项,只要是将选项参数映射为整数的任务,它都能做。这时,再称它为 filter-mode-opt
就不是很合理了,叫它 arg-to-int-parser
吧。
(define (arg-to-int-parser args short-opt long-opt text-to-int)
(cond ((null? args) 0)
(else (let ((fst (car args))
(snd (cadr args)))
(cond ((string=? fst short-opt) (text-to-int snd))
((string-prefix? long-opt fst)
(text-to-int (cadr (string-split fst #\=))))
(else (arg-to-int-parser (cdr args)
short-opt
long-opt
text-to-int)))))))
要用这个函数解析 -m
或 --mode
选项,只需:
(arg-to-int-parser (command-line) "-m" "--mode" which-mode?)
如果将 arg-to-int-parser
函数的最后一个参数 text-to-int
重命名为 map_text_into_what?
,然后将第一个条件分支
(null? args) 0)
改为
(null? args) (map_text_into_what? "")
然后将 "arg-to-int-parser" 重命名为 arg-parser
,便可得到:
(define (arg-parser args short-opt long-opt map-text-into-what?)
(cond ((null? args) 0)
(else (let ((fst (car args))
(snd (cadr args)))
(cond ((string=? fst short-opt) (map-text-into-what? snd))
((string-prefix? long-opt fst)
(map-text-into-what? (cadr (string-split fst #\=))))
(else (arg-parser (cdr args)
short-opt
long-opt
map-text-into-what?)))))))
只要能提供正确的 map-text-into-what?
函数,那么 arg-parser
函数几乎可胜任所有的命令行解析工作,其用法示例如下:
(define (which-mode? x)
(cond ((string=? x "") 0)
((string=? x "moon") 1)
((string=? x "sun") 2)
(else -1)))
(display (arg-parser (command-line) "-m" "--mode" which-mode?))
(newline)
现在,等效的 C 代码已经很难写出来了,因为 C 语言是静态(编译型)语言,它难以实现 arg-parser
这种返回值类型是动态可变的函数。不过,在现实中,arg-parser
的返回值类型并不是太多,可以为每种类型定义一个 arg-parser
,例如:
int arg_parser_return_int(int argc,
char **argv,
char *short-opt,
char *long-opt,
int (*map_text_into_int)(char *));
char * arg_parser_return_str(int argc,
char **argv,
char *short-opt,
char *long-opt,
char * (*map_text_into_text)(char *));
如果不畏惧指针与内存惯例,那么想要一个万能的 arg_parser
,可以用 void *
类型:
void * arg_parser(int argc,
char **argv,
char *short-opt,
char *long-opt,
void * (*map_text_into_int)(char *));
虽然我不会去实现这些函数,但是对于 void *
版本的 arg_parser
,我可以给出它的一个用法示例,即用于解析 zero 程序的 -m
或 --mode
选项:
int *mode = arg_parser(argc, argv, "-m", "--mode", map_text_into_int);
printf("%d\n", *mode);
free(mode);
总结
C 能写的程序,Guile 也能写得出来,反之亦然。不要再说 C 能直接操作内存,操作硬件,而 Guile 不能……用 Guile 也可以模拟出内存和硬件,然后再操作。大致的感觉是,用 C 写程序,会觉得自己在摆弄一台小马达,而用 Guile 写程序,则觉得自己拿了根小数树枝挑拨一只毛毛虫。
Guile 语言最显著的特点有两个。第一个特点是,列表无处不在,甚至函数的定义、应用也都以列表的形式呈现的。第二个特点是,前缀表达式无处不在,正因为如此,我们可以在函数命名时可以使用 =
,-
,?
之类的特殊符号。这两个特点是其他语言所不具备的,当然它也带来重重的括号。说到括号,可能像 Guile 这些 Scheme 系的 Lisp 风格的语言,它们的括号吓退了许多初学者。事实上,只要有个好一些的编辑器——我用的是 Emacs,然后动手写一些代码,很快就不怕了,甚至会感觉它们很自然。
Guile 语言在语法上未提供循环,初次用递归来模拟迭代,会有些不直观。多写写就习惯了。事实上,Guile 以宏的形式提供了功能强大的循环机制,对此以后再作介绍……其实现在我还不会用。在符合 Scheme 语言标准的前提下,Guile 也实现了一些属于它自己的东西。本文中用到的 #nil
以及一些字符串运算函数,这都是 Scheme 语言标准之外的东西。
(cdr《为自己写本-Guile-书》)