谈元编程与表达能力

在这篇文章中,作者会介绍不同的编程语言如何增强自身的表达能力,在写这篇文章的时候其实就已经想到这可能不是一篇有着较多受众和读者的文章。不过作者仍然想跟各位读者分享一下对不同编程语言的理解,同时也对自己的知识体系进行简单的总结。

当我们刚刚开始学习和了解编程这门手艺或者说技巧时,一切的知识与概念看起来都非常有趣,随着学习的深入和对语言的逐渐了解,我们可能会发现原来看起来无所不能的编程语言成为了我们的限制,尤其是在我们想要使用一些元编程技巧的时候,你会发现有时候语言限制了我们的能力,我们只能一遍一遍地写重复的代码来解决本可以轻松搞定的问题。

元编程

元编程(Metaprogramming)是计算机编程中一个非常重要、有趣的概念,维基百科 上将元编程描述成一种计算机程序可以将代码看待成数据的能力。

Metaprogramming is a programming technique in which computer programs have the ability to treat programs as their data.

如果能够将代码看做数据,那么代码就可以像数据一样在运行时被修改、更新和替换;元编程赋予了编程语言更加强大的表达能力,能够让我们将一些计算过程从运行时挪到编译时、通过编译期间的展开生成代码或者允许程序在运行时改变自身的行为。

总而言之,元编程其实是一种使用代码生成代码的方式,无论是编译期间生成代码,还是在运行时改变代码的行为都是『生成代码』的一种,下面的代码其实就可以看作一种最简单的元编程技巧:

int main() {
    for(int i = 0; i < 10; i++) {
        char *echo = (char*)malloc(6 * sizeof(char));
        sprintf(echo, "echo %d", i);
        system(echo);
    }
    return 0;
}

这里的代码其实等价于执行了以下的 shell 脚本,也可以说这里使用了 C 语言的代码生成来生成 shell 脚本:

echo 0
echo 1
...
echo 9

编译时和运行时

现代的编程语言大都会为我们提供不同的元编程能力,从总体来看,根据『生成代码』的时机不同,我们将元编程能力分为两种类型,其中一种是编译期间的元编程,例如:宏和模板;另一种是运行期间的元编程,也就是运行时,它赋予了编程语言在运行期间修改行为的能力,当然也有一些特性既可以在编译期实现,也可以在运行期间实现。

不同的语言对于泛型就有不一样的实现,Java 的泛型就是在编译期间实现的,它的泛型其实是伪泛型,在编译期间所有的泛型就会被编译器擦除(type erasure),生成的 Java 字节码是不包含任何的泛型信息的,但是 C# 对于泛型就有着不同的实现了,它的泛型类型在运行时进行替换,为实例化的对象保留了泛型的类型信息。

C++ 的模板其实与这里讨论的泛型有些类似,它会为每一个具体类型生成一份独立的代码,而 Java 的泛型只会生成一份经过类型擦除后的代码,总而言之 C++ 的模板完全是在编译期间实现的,而 Java 的泛型是编译期间和运行期间协作产生的;模板和泛型虽然非常类似,但是在这里提到的模板大都特指 C++ 的模板,而泛型这一概念其实包含了 C++ 的模板。

虽然泛型和模板为各种编程语言提供了非常强大的表达能力,但是在这篇文章中,我们会介绍另外两种元编程能力:宏和运行时,前者是在编译期间完成的,而后者是在代码运行期间才发生的。

宏(Macro)

宏是很多编程语言具有的特性之一,它是一个将输入的字符串映射成其他字符串的过程,这个映射的过程也被我们称作宏展开。


宏其实就是一个在编译期间中定义的展开过程,通过预先定义好的宏,我们可以使用少量的代码完成更多的逻辑和工作,能够减少应用程序中大量的重复代码。

很多编程语言,尤其是编译型语言都实现了宏这个特性,包括 C、Elixir 和 Rust,然而这些语言却使用了不同的方式来实现宏;我们在这里会介绍两种不同的宏,一种是基于文本替换的宏,另一种是基于语法的宏。

C、C++ 等语言使用基于文本替换的宏,而类似于 Elixir、Rust 等语言的宏系统其实都是基于语法树和语法元素的,它的实现会比前者复杂很多,应用也更加广泛。
在这一节的剩余部分,我们会分别介绍 C、Elixir 和 Rust 三种不同的编程语言实现的宏系统,它们的使用方法、适用范围和优缺点。

C

作者相信很多工程师入门使用的编程语言其实都是 C 语言,而 C 语言的宏系统看起来还是相对比较简单的,虽然在实际使用时会遇到很多非常诡异的问题。C 语言的宏使用的就是文本替换的方式,所有的宏其实并不是通过编译器展开的,而是由预编译器来处理的。

编译器 GCC 根据『长相』将 C 语言中的宏分为两种,其中的一种宏与编程语言中定义变量非常类似:

#define BUFFER_SIZE 1024

char *foo = (char *)malloc(BUFFER_SIZE);
char *foo = (char *)malloc(1024);

这些宏的定义就是一个简单的标识符,它们会在预编译的阶段被预编译器替换成定义后半部分出现的字符,这种宏定义其实比较类似于变量的声明,我们经常会使用这种宏定义替代一些无意义的数字,能够让程序变得更容易理解。

另一种宏定义就比较像对函数的定义了,与其他 C 语言的函数一样,这种宏在定义时也会包含一些宏的参数:

#define plus(a, b) a + b
#define multiply(a, b) a * b

通过在宏的定义中引入参数,宏定义的内部就可以直接使用对应的标识符引入外界传入的参数,在定义之后我们就可以像使用函数一样使用它们:

#define plus(a, b) a + b
#define multiply(a, b) a * b

int main(int argc, const char * argv[]) {
    printf("%d", plus(1, 2));       // => 3
    printf("%d", multiply(3, 2));   // => 6
    return 0;
}

上面使用宏的代码与下面的代码是完全等价的,在预编译阶段之后,上面的代码就会被替换成下面的代码,也就是编译器其实是不负责宏展开的过程:

int main(int argc, const char * argv[]) {
    printf("%d", 1 + 2);    // => 3
    printf("%d", 3 * 2);    // => 6
    return 0;
}

宏的作用其实非常强大,基于文本替换的宏能做到很多函数无法做到的事情,比如使用宏根据传入的参数创建类并声明新的方法:

#define pickerify(KLASS, PROPERTY) interface \
    KLASS (Night_ ## PROPERTY ## _Picker) \
    @property (nonatomic, copy, setter = dk_set ## PROPERTY ## Picker:) DKColorPicker dk_ ## PROPERTY ## Picker; \
    @end \
    @implementation \
    KLASS (Night_ ## PROPERTY ## _Picker) \
    - (DKColorPicker)dk_ ## PROPERTY ## Picker { \
        return objc_getAssociatedObject(self, @selector(dk_ ## PROPERTY ## Picker)); \
    } \
    - (void)dk_set ## PROPERTY ## Picker:(DKColorPicker)picker { \
        objc_setAssociatedObject(self, @selector(dk_ ## PROPERTY ## Picker), picker, OBJC_ASSOCIATION_COPY_NONATOMIC); \
        [self setValue:picker(self.dk_manager.themeVersion) forKeyPath:@keypath(self, PROPERTY)];\
        NSMutableDictionary *pickers = [self valueForKeyPath:@"pickers"];\
        [pickers setValue:[picker copy] forKey:_DKSetterWithPROPERTYerty(@#PROPERTY)]; \
    } \
    @end

@pickerify(Button, backgroundColor);

上面的代码是我在一个 iOS 的开源库 DKNightVersion 中使用的代码,通过宏的文本替换功能,我们在这里创建了类、属性并且定义了属性的 getter/setter 方法,然而使用者对此其实是一无所知的。

C 语言中的宏只是提供了一些文本替换的功能再加上一些高级的 API,虽然它非常强大,但是强大的事物都是一把双刃剑,再加上 C 语言的宏从实现原理上就有一些无法避免的缺陷,所以在使用时还是要非常小心。

由于预处理器只是对宏进行替换,并没有做任何的语法检查,所以在宏出现问题时,编译器的报错往往会让我们摸不到头脑,不知道哪里出现了问题,还需要脑内对宏进行展开分析出现错误的原因;除此之外,类似于 multiply(1+2, 3) 的展开问题导致人和机器对于同一段代码的理解偏差,作者相信也广为人知了;更高级一些的分号吞噬、参数的重复调用以及递归引用时不会递归展开等问题其实在这里也不想多谈。

multiply(1+2, 3) // #=> 1+2 * 3

卫生宏

然而 C 语言宏的实现导致的另一个问题却是非常严重的:

#define inc(i) do { int a = 0; ++i; } while(0)

int main(int argc, const char * argv[]) {
    int a = 4, b = 8;
    inc(a);
    inc(b);
    printf("%d, %d\n", a, b); // => 4, 9 !!
    return 0;
}

这一小节与卫生宏有关的 C 语言代码取自 Hygienic macro 中的代码示例。

上述代码中的 printf 函数理应打印出 5, 9 然而却打印出了 4, 9,我们来将上述代码中使用宏的部分展开来看一下:

int main(int argc, const char * argv[]) {
    int a = 4, b = 8;
    do { int a = 0; ++a; } while(0);
    do { int a = 0; ++b; } while(0);
    printf("%d, %d\n", a, b); // => 4, 9 !!
    return 0;
}

这里的 a = 0 按照逻辑应该不发挥任何的作用,但是在这里却覆盖了上下文中 a 变量的值,导致父作用域中变量 a 的值并没有 +1,这其实就是因为 C 语言中实现的宏不是卫生宏(Hygiene macro)。

作者认为卫生宏(Hygiene macro)是一个非常让人困惑的翻译,它其实指一些在宏展开之后不会意外捕获上下文中标识符的宏,从定义中我们就可以看到 C 语言中的宏明显不是卫生宏,而接下来要介绍的两种语言的宏系统就实现了卫生宏。

Elixir

Elixir 是一门动态的函数式编程语言,它被设计用来构建可扩展、可维护的应用,所有的 Elixir 代码最终都会被编译成二进制文件运行在 Erlang 的虚拟机 Beam 上,构建在 Erlang 上的 Elixir 也继承了很多 Erlang 的优秀特性。然而在这篇文章中并不会展开介绍 Elixir 语言以及它的某些特点和应用,我们只想了解 Elixir 中的宏系统是如何使用和实现的。

宏是 Elixir 具有强大表达能力的一个重要原因,通过内置的宏系统可以减少系统中非常多的重复代码,我们可以使用 defmacro 定义一个宏来实现 unless 关键字:

defmodule Unless do
  defmacro macro_unless(clause, do: expression) do
    quote do
      if(!unquote(clause), do: unquote(expression))
    end
  end
end

这里的 quote 和 unquote 是宏系统中最重要的两个函数,你可以从字面上理解 quote 其实就是在一段代码的两侧加上双引号,让这段代码变成字符串,而 unquote 会将传入的多个参数的文本原封不动的插入到相应的位置,你可以理解为 unquote 只是将 clause 和 expression 代表的字符串当做了返回值。

Unless.macro_unless true, do: IO.puts "this should never be printed"

上面的 Elixir 代码在真正执行之前会被替换成一个使用 if 的表达式,我们可以使用下面的方法获得宏展开之后的代码:

iex> expr = quote do: Unless.macro_unless true, do: IO.puts "this should never be printed"
iex> expr |> Macro.expand_once(__ENV__) |> Macro.to_string |> IO.puts
if(!true) do
  IO.puts("this should never be printed")
end
:ok

当我们为 quote 函数传入一个表达式的时候,它会将当前的表达式转换成一个抽象语法树:

{{:., [], [{:__aliases__, [alias: false], [:Unless]}, :macro_unless]}, [],
 [true,
  [do: {{:., [], [{:__aliases__, [alias: false], [:IO]}, :puts]}, [],
    ["this should never be printed"]}]]}

在 Elixir 中,抽象语法数是可以直接通过下面的 Code.eval_quoted 方法运行:

iex> Code.eval_quoted [expr]
** (CompileError) nofile:1: you must require Unless before invoking the macro Unless.macro_unless/2
    (elixir) src/elixir_dispatch.erl:97: :elixir_dispatch.dispatch_require/6
    (elixir) lib/code.ex:213: Code.eval_quoted/3
iex> Code.eval_quoted [quote(do: require Unless), expr]
{[Unless, nil], []}

我们只运行当前的语法树,我们会发现当前的代码由于 Unless 模块没有加载导致宏找不到报错,所以我们在执行 Unless.macro_unless 之前需要先 require 对应的模块。

在最开始对当前的宏进行定义时,我们就会发现宏其实输入的是一些语法元素,实现内部也通过 quote 和 unquote 方法对当前的语法树进行修改,最后返回新的语法树:

defmacro macro_unless(clause, do: expression) do
  quote do
    if(!unquote(clause), do: unquote(expression))
  end
end

iex> expr = quote do: Unless.macro_unless true, do: IO.puts "this should never be printed"
{{:., [], [{:__aliases__, [alias: false], [:Unless]}, :macro_unless]}, [],
 [true,
  [do: {{:., [], [{:__aliases__, [alias: false], [:IO]}, :puts]}, [],
    ["this should never be printed"]}]]}

iex> Macro.expand_once expr, __ENV__
{:if, [context: Unless, import: Kernel],
 [{:!, [context: Unless, import: Kernel], [true]},
  [do: {{:., [],
     [{:__aliases__, [alias: false, counter: -576460752303422687], [:IO]},
      :puts]}, [], ["this should never be printed"]}]]}

Elixir 中的宏相比于 C 语言中的宏更强大,这是因为它不是对代码中的文本直接进行替换,它能够为我们直接提供操作 Elixir 抽象语法树的能力,让我们能够参与到 Elixir 的编译过程,影响编译的结果;除此之外,Elixir 中的宏还是卫生宏(Hygiene Macro),宏中定义的参数并不会影响当前代码执行的上下文。
```
defmodule Example do
defmacro hygienic do
quote do
val = 1
end

top Created with Sketch.