一文读懂元编程

元编程(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"
%}
%%
[0−9]+                    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:在浏览器中玩语法解析

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 158,847评论 4 362
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,208评论 1 292
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,587评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,942评论 0 205
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,332评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,587评论 1 218
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,853评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,568评论 0 198
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,273评论 1 242
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,542评论 2 246
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,033评论 1 260
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,373评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,031评论 3 236
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,073评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,830评论 0 195
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,628评论 2 274
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,537评论 2 269