Tuesday, May 12, 2009

【转载】抽象的代价

转载自德明泰科技博客:http://blog.sina.com.cn/s/blog_5faf4b830100dtdy.html

随着设计模式的发展,抽象类被越来越广泛的使用。很多设计模式都要求用户接口全部使用抽象类,这样可以随意修改派生类,而维持接口不变。但是我们为此付出了什么代价呢?让我们来看一个简单的例子。

我们先定义两个类,AbstractClass与ConcreteClass,他们实现了完全一致的功能。不同的是,AbstractClass是通过派生AbstractBaseClass实现的。

class AbstractBaseClass

{

public:

virtual int func() const = 0;

};

class AbstractClass :public AbstractBaseClass

{

public:

int func() const {return 1;}

};

class ConcreteClass

{

public:

int func() const {return 1;}

};

好的,现在假设我们有一个接口需要传入一个上述class。如果我们没有使用抽象的话,我们可以直接传入一个ConcreteClass,即:

void CallConcrete(const ConcreteClass &c)

{

c.func();

}

但是在很多设计模式中,推荐像下面这样做,传入一个AbstractBaseClass类型,这样我们可以任意改动其具体实现而不影响接口。

void CallAbstract(const AbstractBaseClass& c)

{

c.func();

}

无疑,后一种方式有着好得多的可扩展性、可维护性。但是在性能上,我们付出了怎样的代价呢?让我们具体调用他们试一下。

int main()

{

AbstractBaseClass& ac = AbstractClass();

ConcreteClass cc;

CallAbstract(ac);

CallConcrete(cc);

return 0;

}

我们来看一下编译器生成的代码。

AbstractBaseClass& ac = AbstractClass();

0041157E lea ecx,[ebp-14h]

00411581 call AbstractClass::AbstractClass (411136h)

00411586 lea eax,[ebp-14h]

00411589 mov dword ptr [ac],eax

ConcreteClass cc;

CallAbstract(ac);

0041158C mov eax,dword ptr [ac]

0041158F push eax

00411590 call CallAbstract (41102Dh)

00411595 add esp,4

CallConcrete(cc);

00411598 lea eax,[cc]

0041159B push eax

0041159C call CallConcrete (41109Bh)

004115A1 add esp,4

return 0;

004115A4 xor eax,eax

从这里我们可以看得很清楚,ac由于是一个抽象类型,显然编译器是无法知道他具体需要占用多少空间的,只能在运行时在自由空间上面进行分配。而cc则是编译时完全可确定的,不需要在运行时进行分配。

两个方法调用在这里看是一样的,让我们深入函数的内部看一下,执行过程究竟有何不同。函数首尾的例行操作我这里省略了,只看关键部分。

void CallConcrete(const ConcreteClass &c)

{

c.func();

0041149E mov ecx,dword ptr [c]

004114A1 call ConcreteClass::func (4110F5h)

}

void CallAbstract(const AbstractBaseClass& c)

{

c.func();

0041143E mov eax,dword ptr [c]

00411441 mov edx,dword ptr [eax]

00411443 mov esi,esp

00411445 mov ecx,dword ptr [c]

00411448 mov eax,dword ptr [edx]

0041144A call eax

}

可以看到,在具体类的调用中,一切都很明确,编译器知道需要调用哪个函数(例子中4110F5h)。而在对抽象类的调用中,编译器就不知道究竟应该调用哪个函数了。怎么办?在代码里可以看得,c的地址被传过去了。这里究竟有什么呢?我们实际的去看一眼,这里设一个断点,可以看到c的地址。

 5faf4b83t68529a218e1e&690

c的地址位于0x415640。0x415640有什么呢?打开内存监视看一眼(不要用反汇编了,这个反汇编会搞成错误的语句)。我们看到这一行数据。

0x00415640 0e 11 41 00 00 00 00 00 70 64 41 00 6d 11 41 00 00 00 00 00 88 64 41 00 e1 10 41 00 00 00

第一条数据是0x41110E。这是什么,去看看。

0041110E E9 1D 05 00 00 jmp AbstractClass::func (411630h)

哈哈,终于找到正主AbstractClass::func了。这下明白了,这0x415640不就是传说中的虚函数表嘛!嗯,至少在VS2005编译器(我试验用的)里,一个对象最开始放的就是虚函数表。

说的有点儿远了,总结一下。当CallAbstract拿到一个AbstractBaseClass的对象c的时候,它并不知道这个c是什么类型的。调用c.func()自然也不知道该调用哪个函数。但编译器有这样一种机制,它把一个对象中实际要调用的函数地址列表(也就是虚函数表)放在了对象的一开始。于是CallAbstract方法拿到一个AbstractBaseClass的对象c之后,只需要去它的一开始去找到这个虚函数表,就能够调用正确的函数。也就是说具体调用哪个函数,CallAbstract是不知道的,但是进来的对象自己知道,这就够了。

好了,我们简单的使用了抽象类接口之后,编译器在背后帮我们做了这许多事情。比起直接使用具体类,明显复杂了不少。这就是抽象的代价了。