现在让我们来举一个具体的例子,来体会元类编程思想的冰山一角。
我们知道,日志对于调试和维护一个程序具有非常重要的作用,传统编程方法中,我们通常在需要输出一个日志的位置调用一个输出日志的函数,如下面的代码:
void foo() {
// do something...
log_printf(log_level, "some logs", ...);
// do something else
}
然而,由于输出日志通常会影响程序的性能,我们在软件发布时就需要屏蔽大部分日志,可是正如我们都知道的,一个软件即使在发布后,仍然会存在一些bug,这个时候如果我们不具备复现bug的条件,那么要么我们前往出问题的现场环境进行调试,要么编译一个带大量日志的临时版本去替换原来的程序,这都是极其麻烦而且低效的,而且直接导致我们的代码维护工作成本非常高昂(而且间接推高了开发和测试的成本。那种产品发布前没日没夜加班的日子,我想每一个程序员都经历过吧!)。那么有没有可能,在不修改原始程序的情况下,通过一些方法就能获得我们需要的日志,而这些日志的内容完全不是预设的,而是可以根据具体的问题动态决定的(这样我们就可以灵活的掌握程序运行的状态信息,而又不会过多的影响程序性能了)?我想你应该已经猜到答案了,是的,没错,可以使用元类编程技术!
这里我们需要通过一些代码来演示元类编程的思想,但是因为C++语言并不直接支持元类编程,所以我们需要自行设计一些类以满足以上例子中的要求。那么如何设计我们的元类呢?这需要一点面向对象的基本知识。首先让我们来回顾一下什么是对象?简单的说,对象就是封装了一组数据以及访问这组数据的一组函数的数据结构,见下面的示意图:
+---------------+---------------------+
| 对象的数据 | 访问数据的函数 | // 对象概念的示意图
+---------------+---------------------+
借鉴之前我们描述“生物”元类的例子,让我们想想要如何描述上图中的对象概念。首先,对象应该有一个名字(即类名);然后,我们应该需要知道这个对象(在内存中)的大小,以便我们构造出一个对象;其次,我们需要一个记录访问函数(即成员函数)的表;最后,如果我们需要知道两个对象的(继承)关系,我们还需要一个记录其父/子对象的表。当然,在上面的问题中,我们不关心对象的关系,所以最后这一项可以省略(在此提到这一可省略项是为了说明我们需要根据待解决的具体问题来设计不同的元类)。这样我们就有了一个简单的模型来描述对象:
(名字,数据大小,成员函数表)
用C++语法写出来,会象这样:
struct runtime_class {
const char * name;
size_t dataSize;
size_t methodCount;
void * methodTable;
};
粗看起来很简单,但再仔细思考一下会发现,这个结构还有一些问题,是什么问题呢?在我们待解决的问题中,我们的目的很明显是要(在运行时)改变一个已实现对象的成员函数的行为(即在原来成员函数的执行前或执行后,增加一个输出对象状态日志的行为),而一个对象的各个成员函数的参数表(及返回值)可以是不同的,因此我们不能用一个简单的数组来描述成员函数表,那么该怎么描述成员函数表呢?我们可以用一个函数类的数组来描述,而每个不同的函数类我们同样可以用元类来描述(我们可称其为元函数类),下面我跳过分析,直接给出一个模型:
(函数名,参数表大小,函数入口,返回值大小)
这里需要特别注意的是,当我们说对元函数类进行实例化(即给每个field赋值),是指用元函数类去定义了一个函数类(还记得元类的概念吗?),而如果我们要执行这个函数的话,我们需要用这个函数类去实例化一个函数对象(即将具体的参数复制到函数对象的内存中)并执行它(即进入函数入口)。如果你对前面这句话感到很困惑的话,没关系,让我们先来了解另一个与函数有关的概念:闭包。
闭包的概念简单来说就是下面这个数据结构:
struct closure {
void * args;
void * (*funcAddr) (void *);
};
它是将函数的入口地址与调用它时所需要的参数封装在一起的一个数据结构,请注意它的数据性,这意味着,我们可以从一个函数中返回一个闭包,然后在另一个函数中去执行它。比如我们有一个累加函数:
int increase(int i) {
return i + 1;
}
通常,当我们调用 increase(3) 时,它就立刻完成计算,并返回结果4,如果我们把调用increase函数时的参数3和increase函数的地址放入一个闭包里,那么我们就可以控制执行 increase(3) 的时机了,这通常也叫做“延时求值”,这正是(运行时)元类编程技术的重要基础。
将闭包的数据结构与C++的类进行比较,不难发现我们可以用一个类去实现它,改写一下就是这样:
struct closure {
// 请注意这里不再需要args成员,因为我们将闭包定义为一个接口,而在实现这个接口时,我们完全可以把args的具体结构作为成员变量的结构。
virtual void * closureEntry() = 0; // 同理,closureEntry也不再需要一个参数来传递args,this指针充当了这个角色
};
下面,让我们回到元函数类,之前说了,对元函数类进行实例化,是指定义一个函数类,现在我们换句话说,实例化一个元函数类,就是定义一个闭包,即我们需要指定一个闭包的参数表大小,入口地址和它的返回值大小,这正是我之前给出的元函数类的模型。下面我们用C++语法写出来就是:
struct meta_closure {
const char * name;
size_t argSize;
void * (*entryAddr) (void *);
size_t retSize;
};
现在我们的元类数据结构就可以修改为:
struct runtime_class {
const char * name;
size_t dataSize;
size_t methodCount;
meta_closure * methodTable;
};
细心的朋友们可能已经发现,上面元类的数据结构严格说来是C的语法,而不是C++的,之所以这样写,是为了方便我们理解这个数据结构的各个组成部分,在理解了这个数据结构是怎么构成之后,我们就可以将其改写成下面的形式:
struct meta_closure {
virtual const char * name() const = 0;
virtual size_t argSize() const = 0;
virtual size_t retSize() const = 0;
virtual void * closureEntry(void * args) = 0; // 请注意这里的closureEntry的声明与closure中的不同!
};
struct runtime_class {
virtual const char * name() const = 0;
virtual size_t dataSize() const = 0;
virtual size_t methodCount() const = 0;
virtual meta_closure ** methodTable() const = 0;
};
等等,这个修改后的版本似乎有点问题,为什么meta_closure中closureEntry的声明和closure中的不同?难道是作者犯晕,写错了?不,没有错!这里正是元类编程和传统面向对象编程最大的区别,也最容易混淆的地方!meta_closure是描述closure(特性)的类,它本身不是closure类,如果把它改写成下面的形式,你大概就不容易搞混:
struct meta_closure {
virtual const char * name() const = 0;
virtual size_t argSize() const = 0;
virtual size_t retSize() const = 0;
};
那么,为什么不采用后一种(不容易混淆的)形式呢?原因有二:
首先,采用后一种形式,将使得我们实例化(即实现一个)meta_closure时,必须同时实现一个closure,并将它与meta_closure的实例关联起来(比如作为成员对象),这是不经济(需要额外的代码)也是不必要的(每一个实例的执行函数的实现应当不同)!
其二,也是更重要的,当我们采用第二种形式时,考虑通过meta_closure来构造closure的情况,首先我们需要利用argSize来分配一块内存,那么这块内存就对应着一个closure对象,然而我们知道,一个对象不经过初始化(即构造)是不能直接拿来用的,而每个closure的实现,其构造函数是不同的,这就使我们必须为每一个closure的实现,再写一个初始化函数,这样既麻烦,也失去了元类编程的精髓(即用一个统一的数据结构来描述不同的类);而在第一种表示中,argSize表示的并不是closure类的大小,而是其参数表的大小,那么我们就不需要针对不同的closureEntry的实现提供不同的初始化函数了,因为通过argSize分配的内存只是一个传递参数的缓冲区。
确切的说,第一种表示定义的是C语言风格的closure。然而,第二种表示也并非完全没有意义,在其它一些情况下,使用这一表示来设计元类也能带来一些好处。我们可能会在将来加以详细讨论。