(cons '(壹 . 命令行程序界面) 《为自己写本-Guile-书》)

(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 语言未提供 forwhile 循环(迭代)结构,那么使用函数对自身的调用来模拟迭代过程,可以写出与上述 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 函数的调用方式,以及 displaynewline 函数的效用。

条件表达式

下面这段 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);
}

condcondition 的缩写,其用法如下:

(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)) 的结果是依序包含 23 的列表,(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 命令行文本而言,要获取的是 moonfoo.zero

文件名解析

(define (get-filename args)
  (cond ((null? (cdr args)) (car args))
        (else (get-filename (cdr args)))))

这个函数的求值结果为字符串类型,是 zero 程序要读取的文件的名字(或路径)。

命令行选项参数解析

由于 -m-mode 选项只有两个值 moonsun 可选,可以将它们映射为整型数:

  • 参数 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)

上述代码中,出现了上文未涉及的一些语法——letcadrstring-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-书》)

你可能感兴趣的:(c,scheme,guile)