(cons '(伍 . 宏) 《为自己写本-Guile-书》)

(car《为自己写本-Guile-书》)

对于 Scheme 语言的初学者而言,Scheme 的宏似乎永远是他们津津乐道的重要特性之一。譬如,我在上一章的结尾处说过,『也许不会再有比 Scheme 更高层次的编程语言了。虽然人类的大脑依然在源源不断的构造着抽象之抽象的概念,但是 Scheme 自身可以随之进化——通过宏来定义新的语法』。这句话的背景似乎非常宏伟,但我确信它是初学者的言论。如果稍微考察一下汇编语言,不难发现,汇编语言的宏也具备与 Scheme 宏的相似的特性。

对于 Guile 而言,它所实现的 Scheme 标准以及 Guile 自家的一些模块已经为我们定义了足够用的宏了。在我们的程序里,几乎不需要触及宏。本章之所以要讲述宏的基本知识,用意在于揭示宏是一种很简单的编程范式,从而消除自己对宏的过度崇拜或恐惧之心。

无论是过程式编程,面向对象编程,泛型编程,函数式编程,还是搞明白范畴论之后再编程,所要解决的基本问题是怎样更有效的复用既有代码。

最原始的代码复用方式是 Copy & Paste。这种最原始的代码复用方式为程序的 Bug 的繁衍做出了不可磨灭的贡献,也许现在它还在兢兢业业的创造 Bug。否则,程序员们不会整天将 DRY(Do not Repeat Yourself)挂在嘴边。

为了消除代码块的 Copy & Paste 带来的 Bug,有一些程序员开窍了,写出来宏处理器。这样,就可以将重复使用的代码块单独抽离出来,给它取个名字,然后将代码块中需要变化的部分用一些占位符代替,并将占位符作为宏的形式参数。于是,就可以将代码块转化为模板之类的东西。宏,本质上就是一种简单但是自由的代码模板,它对模板参数不做任何检查。当一个宏被调用时,展开所得的代码块中的占位符会被宏处理器替换为这个宏所接受的参数。如果宏的参数有副作用,通常会在宏的展开结果中创造出难以察觉的 Bug。不过,这总比 Copy & Paste 安全多了,而且也高效多了。

C++ 的模板比宏高级了一些,但这是以大幅度牺牲宏的自由性并且大幅度增加编译器的复杂性为代价的。C++ 编译器要求模板参数只能是数据类型——数据类型是永远都没有副作用的。与之相应,编译器需要实现模板形式参数的替换、函数重载、重复模板实例消除等功能。C++ 有点像大禹,挖沟开河,折腾了许多年,终于将宏这种难以驾驭的洪水猛兽在一定程度上控制住了。C++ 的模板比宏要安全一些,而且项目开发效率也提升了一个台阶。

C++ 模板本质上依然是宏。尽管模板的参数是类型,但是 C++ 编译器无法确定对参数是否正确。一旦 C++ 模板参数出错,编译器就会愚蠢的给出一堆不知所云的错误信息。换句话说,从 C++ 编译器的角度看,模板的参数本质上只是文本,它无法对这种文本进行逻辑上的判断。发现这个问题的存在之后, C++ 社区又发展出一个新的概念,这个概念就叫做概念(Concept)。基于 Concept,可以对模板参数的『行为』进行约束。对于传递给模板的类型,C++ 编译器会检查这个类型是否拥有模板参数应该具有的行为——有点像动态类型语言里的鸭子类型(Duck Typing)。Concept 就是类型的类型。不知是何原因,C++ 标准委员会三番五次的否决 Concept 的提案。

Haskell提供了一种比模板更高级的代码复用形式。C++ 的模板参数,对于 Haskell 而言就是函数签名以及编译器的自动类型推导。C++ 社区梦寐以求的类型的类型,对于 Haskell 而言就是类型类(TypeClass)。也就是说,Haskell 已经将以数据类型为形参的宏的副作用彻底的消除了。单从语言层面上来看,如果能够习惯 Haskell 不支持赋值运算这一特点,可以将 Haskell 视为更好的 C++。

这一切看上去都很美好,但是借助宏来扩展自身的语法,这种需求似乎被大部分编程语言的设计者刻意的忽视了。可能他们认为,对语言自身进行扩展,那是语言标准委员会以及编译器开发者的任务,而不是软件开发者的任务。很多人认为,纵容软件开发者对语言进行扩展会造成语言的分裂。他们会说,不妨统计一下,这个世界上有多少个版本的 Lisp 与 Scheme 语言的实现。对宏进行弱化,能解决语言分裂的问题么?我觉得这只是回避问题的办法,而不是解决问题的办法。可以想一想,有些库自称是框架,它们所做的事情是不是企图基于类或高阶函数对语言本身进行扩展?如果能够很面向特定领域,为语言增加一些扩展,使之成为领域专用语言,这岂不是比框架要好得多得多?

宏的真正用武之地就在于操纵语言自身的语法,为某些特定的问题领域定制更易于理解与应用的语法。所谓的元编程,其用意大抵也是如此。

譬如 C 语言的宏,虽然其功能极弱——只能展开一次,但是依然能为 C 扩展出好用一些的语法。例如 GObject 库为基于 C 语言提供面向对象提供了有力支持,下面是它的一个空的类的定义示例:

typedef struct _MyObject{
        GObject parent_instance;
} MyObject;
 
typedef struct _MyObjectClass {
        GObjectClass parent_class;
} MyObjectClass;

G_DEFINE_TYPE(MyObject, my_object, G_TYPE_OBJECT);

它等效于下面的 C++ 代码:

class MyObject {
};

G_DEFINE_TYPE 可以将一个结构体类型注册到 GObject 实现的动态类型系统,从而产生一个类似于 C++ 的『类』的类型。

C 编译器展开 G_DEFINE_TYPE 宏后,大致可以得到以下 C 代码:

static void my_object_init(MyObject * self);
static void my_object_class_init(MyObjectClass * klass);
static gpointer my_object_parent_class = ((void *) 0);
static gint MyObject_private_offset;
static void
my_object_class_intern_init(gpointer klass)
{
    my_object_parent_class = g_type_class_peek_parent(klass);
    if (MyObject_private_offset != 0)
        g_type_class_adjust_private_offset(klass, &MyObject_private_offset);
    my_object_class_init((MyObjectClass *) klass);
}

__attribute__ ((__unused__))
static inline gpointer
my_object_get_instance_private(const MyObject * self)
{
    return (((gpointer) ((guint8 *) (self) + (glong) (MyObject_private_offset))));
}

GType
my_object_get_type(void)
{
    static volatile gsize g_define_type_id__volatile = 0;
    if (g_once_init_enter(&g_define_type_id__volatile)) {
                GType g_define_type_id = g_type_register_static_simple(((GType) ((20) << (2))),
                                                                       g_intern_static_string("MyObject"),
                                                                       sizeof(MyObjectClass),
                                                                       (GClassInitFunc) my_object_class_intern_init,
                                                                       sizeof(MyObject),
                                                                       (GInstanceInitFunc) my_object_init,
                                                                       (GTypeFlags) 0);
        }
        return g_define_type_id__volatile;
};

虽然使用 GObject 来编写面向对象的 C 程序要比 C++ 繁琐一些,但是学习成本却低了许多。如果 C 的宏能够像 m4 那样强,在语言层面基于宏精心扩展,在语言层面支持面向对象编程范式并非难事。我曾经用 GNU m4 实现过一个简单的单层匿名函数机制,偶尔可以吓到一些人。

include(`c-closure.m4')dnl
#include 

_C_CORE
int
main(void) {
        char *str_array[5] = {"fetch", "foo", "foobar", "sin", "atan"};
        @(`char *', `foo', `"foo"');
        qsort(str_array, 5, sizeof(char *), _LAMBDA(`const void *p1, const void *p2', `int',
                                                    `int d1 = dist(* (char * const *) p1, &(`foo'));
                                                     int d2 = dist(* (char * const *) p2, &(`foo'));
                                                     if (d1 < d2) {
                                                             return -1;
                                                     } else if (d1 == d2) {
                                                             return 0;
                                                     } else {
                                                             return 1;
                                                     }'));
        for (int i = 1; i < 5; i++) {
                printf("%s\n", str_array[i]);
        }
        exit(EXIT_SUCCESS);
}

语法规则

从现在开始,就应该牢记:宏是用来扩展语法的,不要将它作为函数来用。Scheme R5RS 标准中强调了这一点,并且将宏定义语法设置为以下格式:

(define-syntax macro
  )

下面这段代码为 Guile 定义了类似 C 语言的 if .. else if ... else 语法:

(define-syntax if'
  (syntax-rules ()
    ((if' e result) (cond (e result)))
    ((if' e result else' ) (cond (e result)
                                         (else )))
    ((if' e result else' if'  ...) (cond (e result)
                                            (else (if'  ...))))))

由于 Guile 有自己的 if 语法,所以我在 if 后面加了个单引号以示区别。

if' 宏的用法如下:

(if' #f (display "1")
     else' if' #f (display "2")
     else' if' #f (display "3")
     else' (display "nothing"))

由于我现在只是 Guile 的初学者,所以我并不保证 if' 的定义是否正确。为了写出这个宏,我大概折腾了半个下午。不过,这个宏的意图很简单,它可以让我们在编写条件分支时少写一些括号。

这个 if' 宏是由三条语法规则——syntax-rules 块中的三个表达式构成的。来看第一条语法规则:

((if' e result) (cond (e result)))

这条语法规则描述的是,如果遇到像 (if' e result) 这样的表达式,Guile 便将其转换为 (cond (e result)))。表达式 (if' e result) 被称为模式,它表示的是含有三个元素的列表。也就是说,凡是含有三个元素的列表,都是 (if' e result) 模式,这样的列表有无数个,但是其中大部分不是我们需要的。因为如果要使用 if' 宏,这个列表的第一个元素应该是 if' 符号,其余两个元素应该满足 cond 的要求。下面这些表达式都符合 (if' e result) 模式:

(if' #t (display "Hello world!"))
(if' 2 3)
(if' (< 2 3) #t)
(if' (display "Hello") (display " world!"))

在上述表达式中,我们使用 if' 宏,本质上是让 (if' e result) 这个模式与上述这些表达式进行匹配,这个过程被称为模式匹配。一旦模式匹配成功,Guile 会将模式中的各个符号便会与其匹配的子表达式绑定起来。在语法规则中,位于模式表达式右侧的那个表达式称为模板。每个模式表达式对应着一个模板。模板通过模式中的符号来引用这些符号所绑定的子表达式。可以将模式理解为宏的名字及其参数的声明,将模板视为宏的定义

Scheme 采用语法规则的方式来定义宏,好处是可以定义多个同名的宏。用面向对象的术语来说,就是 Scheme 宏是多态的。if' 的其他两个版本是:

((if' e result else' ) (cond (e result)
                                     (else )))
((if' e result else' if'  ...) (cond (e result)
                                        (else (if'  ...))))                       

需要注意,Scheme 宏是允许递归的。(if' e result else' if' ...) 模式所对应的模板中含有 if' 的递归。因为有了这个递归,所以 if' 宏可以支持多条 else' if' 从句。

关键字

按照 if' 宏的第二条语法规则中的 (if' e result else' ) 模式,可以像下面这样使用 if'

(if' #f #f
     else' #t)

但是,下面这个表达式:

(if' #f #f
     i-am-guest-actor #t)

它也符合 (if' e result else' ) 模式,因为 i-am-guest-actor 会被绑定到 else' 符号,而 else' 符号在模板中并没有用到,所以它绑定了什么是无所谓的。但是,我们显然是希望 else' 有意义。

针对此类问题,Scheme 为语法规则提供了关键字支持。只需将上一节给出的 if' 宏的定义修改为:

(define-syntax if'
  (syntax-rules (else' if')
    ((if' e result) (cond (e result)))
    ((if' e result else' ) (cond (e result)
                                         (else )))
    ((if' e result else' if'  ...) (cond (e result)
                                            (else (if'  ...))))))

这样,模式中的 if'else' 便都被设定为关键字。在使用 if' 宏时,如果再随便用别的符号来代替 else'else' if',那么 Guile 便会报错,说找不到匹配模式。

let

let 是个很有用的语法,它可以在函数内开辟一个包含一些变量与一个计算过程的『局部环境』。如果没有 let,就只能通过函数的嵌套来做这件事,结果会导致代码有些扭曲。

假设存在一个数学函数(取自 SICP 1.3.2 节):

$$f(x,y)=x(1+xy)^2+y(1-y)+(1+xy)(1-y)$$

现在为它写编一个 Guile 函数。众所周知,Guile 的前缀表达式在表现复杂的代数运算式时会失于繁琐。例如:

(define (f x y)
  (+ (* x (* (+ 1 (* x y)) (+ 1 (* x y))))
     (* y (- 1 y))
     (* (+ 1 (* x y)) (- 1 y))))

这么多年,是哪些人没良心的吹嘘 Scheme 简单又优美呢?

如果将上述的数学函数写为 $f(x,y)=xa^2 + yb + ab$,其中 $a$ 与 $b$ 分别为 $(1+xy)$ 与 $(1-y)$,那么就可以将 Guile 代码简化为:

(define (f x y)
  (+ (* x (* a a))
     (* y b)
     (* a b)))

但是不可避免的面临一个问题,在函数 f 内,如何制造两个局部变量 ab 呢?可以像下面这样来做:

(define (f x y)
  ((lambda (a b)
     (+ (* x (* a a))
        (* y b)
        (* a b)))
   (+ 1 (* x y))
   (- 1 y)))

就是在函数 f 内部定义一个匿名函数,并应用它。在应用这个匿名函数时,Guile 会将其形参 ab 便会分别与实参 (+ 1 (* x y))(- 1 y) 绑定起来。

用上面这样的方式写代码,是不是世界观有些扭曲?不过,我们可以将这种扭曲的代码用宏封装起来,形成 let 语法。事实上,在 Guile 中,let 本身就是用宏实现的语法:

(define-syntax let
  (syntax-rules ()
    ((let ((name val) ...) body1 body2 ...)
     ((lambda (name ...) body1 body2 ...)
      val ...))))

有了 let,就可以将上面那个函数写为:

(define (f x y)
  (let ((a (+ 1 (* x y)))
        (b (- 1 y)))
    (+ (* x (* a a))
       (* y b)
       (* a b))))

宏的健康性

下面是 C 的一个宏的定义:

#define SWAP(x, y, type) {type c = x; x = y; y = c;}

这个宏用于交换两个同类型变量的值,其用例如下:

int a = 3, b = 7;
SWAP(a, b, int);

结果 a 的值会变为 7b 的值会变为 3,也就是说 ab 的值被 SWAP 宏交换了。

看上去,SWAP 这个宏没有什么问题,但是它有着一种匪夷所思的副作用。看下面这个例子:

int b = 3, c = 7;
SWAP(b, c, int);

如果不去看 SWAP 的定义,我们会想当然的认为 bc 的值会被 SWAP 交换,但事实上二者的值不会被交换。因为 C 预处理器会将上述代码处理为:

int b = 3, c = 7;
{int c = b; b = c; c = c;}

{ ... } 里的 c 是一个局部变量,对它进行任何修改都不会影响 { ... } 外部的 c

如果将 SWAP 的定义修改为

#define SWAP(x, y, type) {type _______c = x; x = y; y = ______c;}

这样可以大部分情况下可以避免宏定义内部的临时变量与宏调用环境中的变量重名所带来的问题。不过,无人能保证不会有人向 SWAP 宏传递一个名字是 _______c 的参数。

这个故事告诉我们,在使用 C 的宏时,最好对其定义有所了解。

现在来看 Guile 版本的 SWAP 宏的定义及用例:

(define-syntax swap
  (syntax-rules ()
    ((swap x y) (let ((c y))
                  (set! y x)
                  (set! x c)))))
(define b 2)
(define c 9)
(swap b c)

结果 bc 的值互相交换了。

syntax 环境中所用的临时变量,Guile 会自动对临时变量进行重命名,并且保证这个名字从未在宏的定义之外使用过。单凭这一功能,Scheme 的宏就可以藐视其他语言的宏了。

讨论

Scheme 的宏之所以能像耍魔术一样的制造出一些有用的语法,原因在于 Scheme 语法形式上的统一,这种形式就是所谓的 S-表达式。无论传递给宏的文本有多么复杂,只要它在形式上是 S-表达式,那么在宏的内部便可以对这种文本重新组织。

用编译原理中的术语来说,Scheme 的宏可以将其接受的一棵语法树转换为其他形式的语法树。所谓语法树,是目前任何一种比汇编语言高级的编程语言经编译器/解释器作语法分析后所得结果的抽象表示。Scheme 代码本身就是语法树,Scheme 解释器无需再对其进行语法分析,在这个层面上,通过宏可以自由的对语法树进行重新组织。只有运行于语法分析树层面上的宏机制才能具有 Scheme 宏这样的能力。

C/C++ 所提供的宏机制,运行于编译或解释阶段之前的预处理阶段,它的工作只是遵循特定规则的文本替换,预处理器并不知道自己所处理的文本的逻辑结构。此类宏机制类似于外科手术中的器官移植,而基于 S-表达式的 Scheme 宏则类似于基因重组。

本章的导言中说『如果稍微考察一下汇编语言,不难发现,汇编语言的宏也具备与 Scheme 宏的相似的特性』。虽然汇编语言的宏机制本质上也是文本替换,但是汇编语言向机器语言的转换是不需要语法分析的,而是汇编指令向机器指令的直接映射。因此,汇编语言的宏也是在做『基因重组』的工作。

像 TeX 与 m4 这样的软件,它们提供的宏功能比 C/C++ 宏机制更强大,但本质上依然是遵循特定规则的文本替换。由于文本自身就是它们操作的对象,所以它们的宏本质上是将文本视为『基因』进行重组。从这个角度来看,可以说它们的宏也具备与 Scheme 宏相似的特性。例如,m4 常用的宏基于一组基本的宏构建而成。Autoconf 是基于 m4 构建的一种领域专用语言。TeX 不计其数的宏包,每个宏包都是一种领域专用语言。不过,得之于宏,也失之于宏,譬如 TeX 虽然极为擅长展自身语法,但是它在编程方面却非常贫弱。正是由于这个缘故,所以才会出现 LuaTeX 项目,该项目尝试将 Lua 解释器嵌入 TeX 宏处理器,从而以 Lua 语言补了 TeX 在编程方面的不足。

Haskell 为软件开发者留了一个可以访问其语法分析结果的接口,因此 Haskell 也具有类似于 Scheme 宏的能力,只不过 Haskell 没有在语法层面提供宏。Haskell 爱好者们认为,Haskell 提供了惰性计算、准引用以及 GHC 的 API,基于这些机制,Scheme 宏能做到的事,Haskell 也能做到。

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