M4 说 C 要有闭包,C 就有了单重闭包

在 『M4 说要有 Lambda,就有了 Lambda』一文的末尾,指出了使用 M4 所模拟的匿名函数无法作为闭包使用,并断言无法在 C 标准之内实现闭包。事实上,这个结论是武断的,因为当时我没有考虑 C 语言的全局变量的效用。

qsort 的故事

C 标准库中提供了一个 qsort 函数,它可以对任意类型的值构成的数组进行排序,其声明如下:

void qsort(void *base,
           size_t nmemb,
           size_t size,
           int (*compar)(const void *, const void *));

它需要用户传入一个用于判断数组中任意两个元素的大小的函数,例如:

#include 
#include 
#include 

static int cmpstring(const void *p1, const void *p2) {
        return strcmp(* (char * const *) p1, * (char * const *) p2);
}

int main(void) {
        char *str_array[5] = {"fetch", "foo", "foobar", "sin", "atan"};
        qsort(str_array, 5, sizeof(char *), cmpstring);
        for (int i = 1; i < 5; i++) {
                printf("%s\n", str_array[i]);
        }
        exit(EXIT_SUCCESS);
}

cmpstring 就是由用户提供的一个判断字符串数组中任意两个字符串大小的函数。

理论上说,cmpstring 此刻已经是一个闭包了,因为它有能力访问它所处的环境中的某些变量,只不过这些变量需要你显式的传递给它,可以将这样的闭包称为显式闭包。也就是说,要让 cmpstring 被调用时访问更多的变量,那么你必须将这些变量作为参数值传递给它。为了说明这一点,需要将这个字符串排序的程序弄得略微复杂一些:对于 str_array 中任意两个字符串 s1s2,设它们到字符串 "foo" 的『距离』分别为 d1d2,然后根据 d1d2 的值的大小来决定 s1s2 的大小。

无需理睬两个字符串之间的『距离』应该如何定义,假设现在已经有了一个 dist 函数,它能计算两个字符串之间的距离,这样 cmpstring 的定义就需要修改为:

static int cmpstring(const void *p1, const void *p2, const char *foo) {
        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;
        }
}

然后,我们就意识到 qsort 不支持这样的 cmpstring,因为在它看来,用户所传入的函数只需要 2 个参数,而不是 3 个!

为了解决这个问题,GNU 在它所实现的 C 标准库中增加了一个 qsort_r 的实现,其声明如下:

void qsort_r(void *base, size_t nmemb, size_t size,
                  int (*compar)(const void *, const void *, void *),
                  void *arg);

想必你是能理解 GNU 之所以这样做的原因了。然而,虽然 qsort_r 能够接受一个具有 3 个参数的函数,但它终归不是 C 标准库函数,要使用它,不得不在代码中定义 _GNU_SOURCE 宏来起用它。

如果不使用 qsort_r,坚持使用 qsort,那么对于上述的基于字符串『距离』的排序问题,只能是将 str_array 映射为一个 dist_array,然后对 dist_array 排序,最后再将 dist_array 映射为 str_array,在这个过程中必须再引入一个数组来记录 str_arraydist_array 中各个元素的对应关系。结果就会产生一段繁琐又臃肿的代码,不信可以动手试一下。还有一种可选的方案,就是自己去实现一个像 qsort_r 的函数。

全局变量的效用

C 语言若能支持函数嵌套定义,上一节的问题便能够优雅的得以解决。例如:

int main(void) {
        char *str_array[5] = {"fetch", "foo", "foobar", "sin", "atan"};
        char *foo = "foo";
        static int cmpstring(const void *p1, const void *p2) {
                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;
                }
        }
        qsort(str_array, 5, sizeof(char *), cmpstring);

        for (int i = 1; i < 5; i++) {
                printf("%s\n", str_array[i]);
        }
        
        exit(EXIT_SUCCESS);
}

这样的 cmpstring 函数,就是货真价实的闭包了。

可惜,迄今为止,C 标准依然不支持函数的嵌套定义。尽管一些 C 编译器通过扩展的形式可以支持函数的嵌套定义,但是将自己的代码设计依赖某种 C 编译器的特性,通常也是不可取的行为,除非你确定你的代码永远是用这种 C 编译器编译的。例如 Linux 内核代码就是确定依赖 GCC 的某些特性的。

不过,如果我们将代码变为:

static char *foo = "foo";
static int cmpstring(const void *p1, const void *p2) {
        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;
        }
}

int main(void) {
        char *str_array[5] = {"fetch", "foo", "foobar", "sin", "atan"};
        qsort(str_array, 5, sizeof(char *), cmpstring);

        for (int i = 1; i < 5; i++) {
                printf("%s\n", str_array[i]);
        }
        
        exit(EXIT_SUCCESS);
}

这样至少是符合 C99 标准的。这样做,就不需要去调用非 C 标准库函数 qsort_r 了,也不需要迂回曲折的去做数组的映射了,更不需要自己再去实现一个像 qsort_r 那样的函数了。但是,这种情况下 cmpstring 是否还能被称为闭包?理论上是不能的,因为闭包只能访问它内部的局部变量以及它外部的非全局变量,而此时 cmpstring 函数访问的是一个全局变量 foo

编程专家告诉我们,慎用全局变量。幸好,他们说的是慎用,而不是永远不用。滥用全局变量是不太好的,特别是用全局变量来表示程序的运行状态,而且这种状态不时的被程序自身所修改。

然而,对于本文中的问题而言,foo 虽然是一个全局变量,但是如果我们能确定它仅被一个函数使用,这样它就可以与 cmpstring 构成一个货真价实的闭包了!因为,如果一个全局变量仅被某个函数所使用,那么它是挂着全局变量的招牌卖的局部变量的肉,也就是说,它本质上即不是全局变量,也不是局部变量,当一个函数访问了它,那么在理论上它就可以与这个函数构成闭包。事实上,在 C 的编程实践中,只要我们愿意将某个 .c 文件视为一个,那么这份文件内所有的计算,都对这个包是封闭的。

要弱化一个全局变量的全局性,首先应该给它加上 static 修饰,告诉编译器,这个变量只对它所在的文件内的函数可见;其次就是控制它的命名,使之对 C 命名空间具有尽可能小的污染性,例如上例中的 foo,我们可以将其命名为 var_foo_in_cmpstring_func,名字越长,越生僻,它对 C 命名空间的污染就越小。

注意一旦决定要在某个函数中使用全局变量,那么这个函数就不可能是线程安全的

C 闭包基本范式

现在,我们有了 C 闭包模拟的基本原则:弱化一个全局变量的全局性。遵循这个原则,可以总结出几个基本的闭包模拟范式。之所以要总结范式,是因为有了范式,就可以为 C 的闭包模拟过程编写代码生成器。

变量传递范式

如果一个局部变量 x 与一个函数 f 构成一个闭包,那么必须建立一个 static 修饰的全局变量 var_x_just_in_f,并在 f 被应用之前,将 x 的值赋给 var_x_just_in_f。例如:

/* var_x_in_f 与 f 构成了闭包 */
static int var_x_just_in_f;
static int f(int y) {
        return var_x_just_in_f + y;
}

... ... ...

int foo(int y) {
        int x = 1;
        var_x_just_in_f = x;
        return f(y);
}

对于上文中的 qsort 示例来说,如果要基于字符串数组 str_array 中的每个元素与字符串 foo 的相对距离来排序,按照变量传递范式来写,就是这样:

static char *var_foo_just_in_cmpstring;
static int cmpstring(const void *p1, const void *p2) {
        ... ... ...
}

int main(void) {
        char *str_array[5] = {"fetch", "foo", "foobar", "sin", "atan"};
        char *foo = "foo";
        var_foo_just_in_cmpstring = foo;
        qsort(str_array, 5, sizeof(char *), cmpstring);

        ... ... ...

        exit(EXIT_SUCCESS);
}

匿名函数范式

闭包通常是由匿名函数及其外部变量(对于匿名函数而言,非全局变量,亦非局部变量)构成。虽然 C 不支持匿名函数,但是凭借代码生成器可以自动生成一组函数,它们的名字具有一种特殊格式:_LAMBDA_N,例如,_LAMBDA_0_LAMBDA_1,……我们将这些函数视为 C 的匿名函数,并禁止任何非匿名函数使用这种格式命名。

对于 qsort 的示例而言,如果用匿名函数,可以写为:

static char *var_foo_just_in_LAMBDA_0;
static int _LAMBDA_0(const void *p1, const void *p2) {
        ... ... ...
}

int main(void) {
        char *str_array[5] = {"fetch", "foo", "foobar", "sin", "atan"};
        char *foo = "foo";
        var_foo_just_in_cmpstring = foo;
        qsort(str_array, 5, sizeof(char *), _LAMBDA_0);

        ... ... ...

        exit(EXIT_SUCCESS);
}

如果借助 GNU M4 来生成 C 的函数,并在相应的位置调用它们,可参考 『M4 说要有 Lambda,就有了 Lambda』这篇文章。

闭包制造者范式

全局变量是危险的。闭包制造者必须遵守一个契约:用于构建闭包的全局变量仅被所构建的闭包访问

虽然禁止一个全局变量被多个闭包共享会导致程序运行时所占用的内存空间变大——全局变量位于程序的静态存储区域,但是相对于共享的全局变量所带来的风险而言,牺牲一些内存空间是值得的,而且闭包这种技术在实践中也不能滥用。事实上,那些支持闭包的语言在闭包的使用上也存在着内存的消耗。

M4 的实现

在『M4 说要有 Lambda,就有了 Lambda』一文所模拟的匿名函数的基础上,只需将上文所述的变量传递范式模拟出来,就可以实现 C 闭包了。不过,为了宏的安全性,我对匿名函数的 M4 模拟代码进行了一些变动,导致代码不那么直观了,在此先给出它们,具体细节日后再叙。

divert(-1)
changeword(`[_a-zA-Z@&][_a-zA-Z0-9]*')
define(`_C_CLOSURE', `divert(1)')
define(`_C_CORE',    `divert(2)')

define(`_LAMBDA_SET_VAR', `define(`$1', `$2')')
_LAMBDA_SET_VAR(`?N', 0)

define(`_LAMBDA',
`_C_CLOSURE`'static $2 _LAMBDA_`'defn(`?N')`('$1`)'{$3;}
_C_CORE`'_LAMBDA_`'defn(`?N')`'dnl
_LAMBDA_SET_VAR(`?N', incr(defn(`?N')))`'dnl
')

define(`_VAR_IN_L_N', `var_$1_just_in_LAMBDA_`'defn(`?N')')
define(`@', `_C_CLOSURE`'static $1 _VAR_IN_L_N($2); _C_CORE`'$1 $2 = $3; _VAR_IN_L_N($2) = $2`'')
define(`&', `_VAR_IN_L_N($1)')
divert(0)dnl

假设上述代码保存在 c-closure.m4 文件中,一个模拟的匿名闭包示例如下:

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

_C_CORE
int main(void)
{
        @(`int', `x', `1');
        if(_LAMBDA(`int y', `int', `return &(`x') > y')(2)) {
                printf("False!\n");
        } else {
                printf("True!\n");
        }
}

其中,@& 都被定义为 GNU M4 的宏了。GNU M4 提供了一个 changeword 的宏,借助它可以使得一些特殊字符也能作为宏名。虽然据 GNU M4 官方文档所称,M4 2.0 版本可能会提供更好的机制来取代 changeword 宏,但是考虑到 M4 的版本刚到 1.4.17,距离 2.0 还早着,所以先不管那么多了。

@ 宏接受 3 个参数,第一个参数是局部变量的类型,第二个参数是局部变量的名字,第三个参数是局部变量的值。@ 宏的作用就是套取一个局部变量,按照变量传递范式,将它变为闭包变量。

& 宏只接受一个参数,即 @ 所套取的局部变量的名字。

上述代码经 GNU m4 展开后,得到以下结果:

#include 

static int var_x_just_in_LAMBDA_0;
static int _LAMBDA_0(int y)
{
    return var_x_just_in_LAMBDA_0 > y;
}

int main(void)
{
    int x = 1;
    var_x_just_in_LAMBDA_0 = x;
    if (_LAMBDA_0(2)) {
        printf("False!\n");
    } else {
        printf("True!\n");
    }
}

借助函数指针,在一个函数中返回一个闭包也是可以的,例如:

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

typedef int (*Func)(int);

_C_CORE
Func test(void) {
        @(`int', `x', `1');
        return _LAMBDA(`int y', `int', `return &(`x') > y');
}

int main(void) {
        if (test()(2)) {
                printf("False!\n");
        } else {
                printf("True!\n");
        }
}

展开结果为:

#include 

typedef int (*Func) (int);

static int var_x_just_in_LAMBDA_0;
static int _LAMBDA_0(int y)
{
    return var_x_just_in_LAMBDA_0 > y;
}

Func test(void)
{
    int x = 1;
    var_x_just_in_LAMBDA_0 = x;
    return _LAMBDA_0;
}

int main(void)
{
    if (test()(2)) {
        printf("False!\n");
    } else {
        printf("True!\n");
    }
}

讨论

如果担心 C 『匿名』函数所用的全局变量泛滥成灾导致程序所占用的内存空间过大,可以考虑构建一个全局的列表,用于存储匿名函数的外部变量,这些变量的存储空间可以在匿名函数结束之前释放掉。有时间我会试试。

经 @nareix 的提醒,发现 M4 模拟的闭包无法支持嵌套,即一个匿名函数的定义中不能再嵌套一个匿名函数的定义,这是因为 M4 宏只能单次展开的局限性导致的。我需要再考虑一下,怎样解决这个问题。

附:qsort 不再有故事

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);
}

你可能感兴趣的:(匿名函数,闭包,c,gnu-m4)