一文读懂元编程



元编程(Metaprogramming)是编写、操纵程序的程序,简而言之即为用代码生成代码。元编程是一种编程范型,在传统的编程范型中,程序运行是动态的,但程序本身是静态的。在元编程中,两者都是动态的[1]。元编程将程序作为数据来对待,从而赋予了编程语言更加强大的表达能力。

编写元程序的语言称之为元语言,被操纵的语言称之为目标语言[2]。根据元语言和目标语言是否相同,我们可以将元编程分为两类:

当元语言即目标语言本身时,元编程是目标语言所支持的高级特性,是在编译期或运行期生成或改变代码的一种编程形式,是狭义上的元编程;当元语言并非目标语言时,元编程侧重代码内容的生成,并不关注目标语言代码的编译和执行,也可以称之为产生式编程(Generative Programming)或者代码生成技术(Code Generation)。我们按照从易到难的顺序来依次介绍这些技术。

元语言非目标语言

比较低阶的方式是用直接用处理文本的方式生成代码,其次是用IDE的可视化特性、以及用模版引擎的方式,而最高级的方式应该是用编译原理的方式实现。

1. 文本处理

几乎所有的编程语言都有输入输出文本的能力。利用文本输出能力生成具体代码是最简单的元编程手段。其实用这种方式可以生成任何一种语言的代码,之所以把它归类于”元语言非目标语言”,因为它对目标语言的代码仅仅当作一种文本来处理。来看一个bash脚本的示例

#!/bin/sh
# metaprogram
echo '#!/bin/sh' > program
for i in $(seq 992)
do
echo "echo $i" >> program
done
chmod +x program

这个脚本没有任何输入,生成了一个新的993行的脚本来打印输出数字1至992。这并不是打印一串数字最有效的方法。尽管如此,程序员可以在几分钟内编写和执行这个元程序,生成了近1000行的代码,简单粗暴。

#!/bin/sh
echo 1
echo 2
echo 3
...
echo 992

2. IDE特性

通过可视化IDE生成代码的编程探索可谓历史悠久,最早开始的是桌面端IDE,进入Web时代后诞生了富文本编辑器,随后又产生了一些脚手架框架。在页面上拖拖拽拽、快捷的操作命令就能生成代码,能够大大提升构建工程的速度。

VB 6.0的操作界面 - 图片来自于 Visual Basic

对于这种元编程方式而言,大都针对特定的IDE,大部分情况下我们只是普通用户,除了IDE的设计者很少有人去了解其背后的实现机制。当然有些IDE也会提供插件定制功能,这时候便有机会在其基础上进行元编程开发。

Eclipse上的Mybatis配置文件生成插件 - 图片截图于Eclipse Marketplace

3. 模板引擎

几乎所有的Web后端语言都有生成HTML的模版引擎技术(Template engines),通过变量替换、表达式处理等方式来简化前端页面编写逻辑,更好地实现用户界面与业务数据的分离,提高前端代码的可维护性。

模板引擎流程示意 - 图片来自于 Server-Side Template Injection

虽然现在前后端分离已经大行其道,大部分情况下后端程序员无需关心前端页面的实现,但是当后端逻辑里涉及到HTML、XML和其他格式化文本的生成时,模板引擎依然是我们的最佳备选方案。

不论是Java的FreeMarker/Velocity/Thymeleaf,JS的Pug,还是Python的Jinja/Tornado,上手都很简单。jinja2号称解析速度快被广泛使用,以它来做个示范:

模板文件

<!DOCTYPE html>
<html lang="en">
<head>
<title>My Webpage</title>
</head>
<body>
<ul id="navigation">
{% for item in navigation %}
<li><a href="{{ item.href }}">{{ item.caption }}</a></li>
{% endfor %}
</ul>
<h1>My Webpage</h1>
{{ a_variable }}
</body>
</html>

加载模板并传入变量

>>> from jinja2 import Environment, FileSystemLoader
>>> env = Environment(loader=FileSystemLoader('/path/to/templates'))
>>> template = env.get_template('mytemplate.html')
>>> print(template.render(navigation=[{'href':'foo.com','caption':'bar'}], a_variable='Hello World'))

解析结果

<!DOCTYPE html>
<html lang="en">
<head>
<title>My Webpage</title>
</head>
<body>
<ul id="navigation">

<li><a href="foo.com">bar</a></li>

</ul>
<h1>My Webpage</h1>
Hello World
</body>
</html>

4. 编译原理

同样是处理结构化文本,基于词法分析和语法分析而实现的元编程比模板引擎更为强大灵活,要知道,编译器就是最常见的元编程形式(将源代码编译为机器指令)。基于Lex、Yacc和ANTLR之类的编译工具,即便我们不精通编译原理,也能完成语法的解析和代码的生成,甚至创造一门自己的编程语言。

语法解析基本流程- 图片来自于ANTLR 4权威指南

Lex & YACC

Lex和YACC是UNIX环境下的编译工具,其中Lex是一个词法解析器(Lexical analyzers)生成工具,而YACC是一个语法解析器(Parser)生成工具,两者搭配使用可完成语法解析,生成C/C++代码。

例如,我们想用一门简单的语言去控制一个温度调节器[7][8],例如:

heat on //  => 输出Heat turned on or off!
heat off // => 输出Heat turned on or off!
target temperature 22 // => 输出Temperature set!

我们需要辨别的符号有:heat,on/off(STATE),target,temperature,NUMBER。对应的Lex文件如下:

%{
#include <stdio.h>
#include "y.tab.h"
%}
%%
[09]+ return NUMBER;
heat return TOKHEAT;
on|off return STATE;
target return TOKTARGET;
temperature return TOKTEMPERATURE;
\n /* ignore end of line */;
[ \t]+ /* ignore whitespace */;
%%

这里将符号进行了转换,转换后的符号的定义在头文件y.tab.h中,该头文件则由YACC从语法文件中生成:

/*省略开头部分*/

%token NUMBER TOKHEAT STATE TOKTARET TOKTEMPERATURE

commands: /* empty */
| commands command
;

command:
heat_switch
|
target_set
;

heat_switch:
TOKHEAT STATE
{
printf("\tHeat turned on or off\n");
}
;

target_set:
TOKTARGET TOKTEMPERATURE NUMBER
{
printf("\tTemperature set\n");
}
;

语法树中支持heat_switchtarget_set两种命令,每种命令都定义了需要执行的操作。Lex和YACC的代码编译后会生成一个可执行文件:

$ ./example4
heat on
Heat turned on or off
heat off
Heat turned on or off
target temperature 10
Temperature set
target humidity 20
error: parse error
ANTLR

ANTLR 是一个强大的语法分析器生成工具,可以使来读取、处理、执行或翻译结构化文本和二进制文件。它相当于Lex和YACC的组合,而且能支持更广泛的编程语言。它被应用于学术领域和工业生产实践,是众多语言、工具和框架的基石。大名鼎鼎的ORM框架Hibernate便是使用ANTLR来处理HQL语言。

用ANTLR解析语法树示例 - 图片截图自官网

ANTLR的上手流程与Lex&YACC类似。在ANTLR:在浏览器中玩语法解析这篇博文中,作者展示了如何利用ANTLR来快速完成代码的解析和执行,简单易懂,有兴趣的朋友可以进一步了解。

利用ANTLR将目标语言解析并执行 - 图片来自原文

元语言即目标语言

现代的编程语言大都会为我们提供不同的元编程能力。静态元编程主要有宏和泛型,允许程序在编译期展开生成或者执行代码。动态元编程主要靠反射机制,允许程序在运行时改变自身的行为。

5. 静态元编程

宏一个将输入的字符串映射成其他字符串的过程,这个映射的过程也被称作宏展开。很多编程语言,尤其是编译型语言都实现了宏这个特性,然而这些语言却使用了不同的方式来实现宏:一种是基于文本替换的宏,另一种是基于语法的宏[3]

C语言中的文本替换宏,只是一个简单的标识符,它们会在预编译的阶段被预编译器替换成宏定义中后半部分的字符,类似于变量声明:

#define BUFFER_SIZE 1024

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

C语言中同样有简单形式的语法宏,通过在宏的定义中引入参数,宏定义的内部就可以直接使用对应的标识符引入外界传入的参数,同样也是在预编译阶段完成替换,类似于函数定义:

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

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

上面两个例子都来自于《谈元编程与表达能力》这篇博文,文中还介绍了Elixir和Rust这两种语言中更高阶的语法宏,不仅卫生宏问题得到了解决,还可以直接使用宏操作上下文的语法树,甚至进行模式匹配、递归解析,的确能够刷新我们对宏的认识。

泛型

泛型(generics)同样是一种编程范式,它允许程序员在强类型程序设计语言中编写代码时使用一些以后才指定的类型,在实例化时作为参数指明这些类型。各种程序设计语言和其编译器、运行环境对泛型的支持均不一样。Java、C#、F#和Swift等语言称之为泛型;Scala 和 Haskell 称之为参数多态;C++ 和D称之为模板[5]

只有在编辑期能够展开代码的泛型,才能算符合元编程的范畴。以C++ 的模板为例,下面的这个计算阶乘的案例中,factorial<5>::value的值是在编译时而非运行时计算出来的。换句话说,这段代码以模板形式通过编译器生成了新的代码,并在编译期间获得执行[1]

template <int N>
struct factorial
{
enum { value = N * factorial<N - 1>::value };
};

template <> // 特化(specialization)
struct factorial<0> // 递归中止
{
enum { value = 1 };
};

void main()
{
// 以下等价于 cout << 120 << endl;
cout << factorial<5>::value << endl;
}

基于Java的“伪泛型”则无法进行元编程。它是在编译期进行了类型擦除(Type erasure),生成的字节码不包含任何的泛型信息,类型变量被其限定类型(无限定的变量用Object)替换,然后在运行时识别变量的具体类型,所以Java 的泛型要靠编译期和运行期协作实现。

public class MaximumTest
{
// 比较三个值并返回最大值
public static <T extends Comparable<T>> T maximum(T x, T y, T z)
{
T max = x; // 假设x是初始最大值
if ( y.compareTo( max ) > 0 ){
max = y; //y 更大
}
if ( z.compareTo( max ) > 0 ){
max = z; // 现在 z 更大
}
return max; // 返回最大对象
}
public static void main( String args[] )
{
// 输出结果:3, 4 和 5 中最大的数为 5
System.out.printf( "%d, %d 和 %d 中最大的数为 %d\n\n",
3, 4, 5, maximum( 3, 4, 5 ) );
}

在这个案例中,T会被替换为Comparable。而在maximum( 3, 4, 5 )中变量的实际类型是Integer

6. 动态元编程

反射

反射是指计算机程序在运行时可以访问、检测和修改它本身状态或行为的一种能力。用比喻来说,反射就是程序在运行的时候能够“观察”并且修改自己的行为[6]

一般来说,程序中代码的执行逻辑是明确的,运行时引擎(Runtime engine)将代码解析为机器指令,然后计算机按照顺序执行,在此过程中代码无法自己改变执行的顺序,但反射机制允许在运行过程中通过调用运行时引擎暴露的API来实时获取和改变代码,从而可以改变源代码中预设的执行顺序。

反射的实现因语言而异,这也就让不同的语言有不同的元编程体验。

Javascript中的eval函数,将字符串解析为代码并执行:

// 等同于new Foo().bar()
eval('new Foo().bar()')

Java中利用ClassMethod类,在运行时加载一个编译时未引用的类,并执行其方法:

try{
Object foo = Class.forName("com.package.Foo").newInstance();
Method m = foo.getClass().getDeclaredMethod("bar");
m.invoke(foo);
} catch(Exception e){
// Catching Exception
}

Objective-C中利用class_addMethod动态的为当前的类添加新的方法和对应的实现[3]

void dynamicMethodIMP(id self, SEL _cmd) { }

+ (BOOL)resolveInstanceMethod:(SEL)aSEL {
if (aSEL == @selector(resolveThisMethodDynamically)) {
class_addMethod([self class], aSEL, (IMP) dynamicMethodIMP, "v@:");
return YES;
}
return [super resolveInstanceMethod:aSel];
}

Ruby中利用define_singleton_method动态的为当前的类添加新的方法和对应的实现[3]

class Dog
def method_missing(m, *args, &block)
if m.to_s.start_with? 'find'
define_singleton_method(m) do |*args|
puts "#{m}, #{args}"
end
send(m, *args, &block)
else
super
end
end
end

反射是比较高级的元编程方式,可以用来简化日志处理、异常处理和权限管理等重复的逻辑,提高编程的效率。

引用

  1. 冒号课堂:编程范式与OOP思想

  2. Wikipedia: Metaprogramming

  3. 谈元编程与表达能力

  4. A Guide to Code Generation

  5. 维基百科:泛型

  6. Wikipedia: Reflection)

  7. Lex & YACC HOWTO

  8. 如何使用Lex/YACC (上文的翻译)

  9. ANTLR:在浏览器中玩语法解析