2.4 虚拟成员函数
这 |
这是本文中最复杂也最有趣的话题了。虚拟函数也是和继承这个话题相伴相生,所以本节将纳入对单继承、多重继承和虚拟继承,一起描述他们之间的关系,这样,对C++对虚拟函数的调用,以及由此所变现出来的多态的理解,应该是非常清晰了。
2.4.1 单继承下的虚拟成员函数
对于虚拟函数,我们首先引入两个数据结构,为什么引入一会就知道了。
1. Virtual table. 大名鼎鼎的vtbl,如果一个类有虚拟函数,编译器首先一堆指向virtual function的指针,这些指针,就存放在了这个vtbl之中。
2. vptr. 编译器会为每个或自己有,或其父类/祖爷类等有虚拟函数的类的实例压入一个指针,指向相关联的virtual table,这个指针就是vptr。
先不管为什么要这么做,先看看这么一些数据结构引入之后,编译器怎么来处理虚拟函数调用的问题。考虑代码段7:
class base{
public:
virtual int sayhello(){
std::cout<<"Hello, I'm a BASE lass instance!/n";
}
};
class derived : public base{
public:
virtual int sayhello(){
std::cout<<"Hello, I'm a DERIVED class instance!/n";
}
};
base b;
derived d;
base* pB = &d;
pB->sayhello();
pB = &b;
pB->sayhello();
对于这句:pB->sayhello();
虚拟函数的关键——从效用角度讲就是多态的关键——就是为sayhello()找到适当的执行体。为此我们必须好好理解多态。
/ *******************************************************插叙:关于多态
我的理解是:同样的操作,得到不同的结果。我们或许接触得多的是override,因为一开始比较正式和系统的讲多态这个概念的时候是虚函数的 override 引发的,但是不尽然,按照“同样的操作,得到不同的结果”的观点,override和overload都是实现多态的手段。(当然还有其它的手段)。
l override意思是重写,仅仅发生在继承这个过程,在基类中定义了某个函数,且这个函数是virtual的——这是必要条件——再从基类继承出一个新的派生类,就可以在派生类重新定义这个函数”。override的条件比较苛刻,继承+虚函数。
l overload就是重载了,允许多个函数具有相同的名字,这里的函数既可以是类的成员函数——如构造函数就可以重载多个版本,也可以是全局的函数。更明显的例子是运算符重载,complex类中复数的相加等运算就是+的重载,可以把运算符看成函数,从而被overload。
由上面的讨论可以看出,override和overload最大的相同点是:多个函数具有相同的名字。最大的不同点是:override是在程序运行的时候才决定调用哪个函数,overload是在代码被编译的时候决定调用哪个函数——静态联编。
那么,多态到底有什么用呢?
Google一下,曰:多态是一种不同的对象以单独的方式作用于相同消息的能力。注意这几个相同与不同,这个概念是从自然语言中引进的——这个意识对于理解OOP是很好的——我的一种学习体会就是,尽量在自然界中寻找神似的感觉,嘿,OOP还是很好理解嘛!举个例子,动词“关闭”应用到不同的事务上其意思是不同的。关门,关闭银行账号或关闭一个程序的窗口都是不同的行为,其实际的意义取决于该动作所作用的对象。这个比方应该对理解多态有帮助,总之还是那句话:同样的操作,不同的对象执行,就会出现不同的结果。
大多数面向对象语言的多态特性都仅以虚拟函数的形式来实现,但C++除了一般的虚拟函数形式之外,还多了两种“类似于静态的”(因为我觉得没有虚函数那样足够灵活,不过,也够强大的了)多态机制:
1、操作符重载(函数重载的一种):例如,对整型和string对象应用 += 操作符时,每个对象都是以单独的方式各自进行解释。显然,潜在的 += 实现在每种类型中是不同的。但是从直观上看,我们能够预期结果是什么。
2、模板:例如,当接受到相同的消息时,整型vector对象和串vector对象对消息反映是不同的,我们以关闭行为为例:
vector<int> vi;
vector<string> names;
string name("C++有点BT呀");
vi.push_back(5);
names.push_back(name);
静态的多态机制不会导致和虚拟函数相关的运行时开销。此外,操作符重载和模板两者是通用算法最基本的东西,在STL中体现得尤为突出。
关于多态的优点,说不清,可能主要是编程实践不够,很多书上是这样说的(比如C++ primer)依赖动态联编,达到统一的接口,不同的实现的功能。从代码执行角度来看,动态联编产生对象的静态类型和动态类型的区别,用户通过对象动态类型来匹配相应的实现,使得同样的代码有了不同的表现。多态使得类的接口和实现分离,降低了程序的耦合性和编译的依赖性,提高了软件的模块化,从而诞生了各种各样所谓的模式。概括起来多态所带来的优点——灵活。
*******************************************************************/
罗里吧嗦一大堆,让我们再次回到代码段7。为了实现所谓的根据对象的实际情况作出相应动作的所谓“多态”,必须首先能够对于多态对象有某种形式的运行期对象识别办法。也就是说,我们需要在运行期获得pB->sayhello();中关于pB的某些相关信息,pB他老人家到底指向了啥子捏?前面我(猜想)着说过,指针类型中可能加入了某些类似于sizeof的信息,好吧,计算这个猜想是对的,也不能保证多态就一定可以实现——单纯这样一个信息太老土了,不够。万一子类没有引入新的数据成员怎么办呢?那好吧,我就直接引入一个对象类型的编码,比如我用某些bit位表示表示类,但是这样对空间要求增加了,而且,这样也不优雅,不简洁。
根据《Inside the C++ Object Model》,这些额外(类型)信息是有的——我前面的猜测部分是正确的——但是不是和指针放在一起。我们一步步来,首先,额外信息到底是什么?知道了这个,我们可以精确的评估开销。其次,我们到底把这些信息放哪里呢?放对了地方,才有可能争取时间与空间的优势。
对于第一个问题,我们需要知道:
A. pB所指对象的真实类型,到底是base还是derived?
B. sayhello()函数体在内存中间的位置。
对于第二个问题,C++的办法是,在每一个需要多态(有virtual函数)的类对象身上压入两个成员:
a) 一个字符串或数组,表示class的类型,即type-info;
b) 一个指针,指向某个表格,表格中保存了类和类的继承链中virtual函数的运行期地址。
这两点,分别对应于前面A, B两项需求。而对于b)中提到的两份数据,就是本小节一开始提到的vptr和vtbl了。
vtbl中的virtual函数地址从何得知呢?在C++中,virtual函数可以在编译期间就得到,此外,这一组地址是固定不变的,运行期不可能增加或更改。由于程序在执行中vtbl的大小和内容不会改变,所以vtbl构造可以完全由编译器掌控,不需要运行期的任何介入。
然而,为运行期准备好这些地址雷锋还只做了一半。还有一个问题就是找到这些地址。这个,就是vptr的用途了。首先,vptr将指向编译器分配好的vtbl表格,然后被压入类的实例中,这样,我们借助这个vptr找到了vtbl,又因为vtbl表格中一个个表项就是这些virtual的地址,所以万里长征终于到头了。剩下的,就是运行期在vtbl中找到特点的表项,取出virtual函数的地址即可。
一个类只有一个vtbl,每个vtbl内含其对应的类对象中所有虚函数实体的地址,这些虚函数包括:
1. 该类所定义的函数实体。它会override一个可能存在的基类中的虚函数。
2. 继承自基类的函数实体,这些是在子类决定不改写虚拟函数时才会出现的情况。
3. 一个纯虚函数实体。
每一个虚拟函数都被指派一个固定的索引值,这个索引在整个集成体系中保持与特定的虚函数的关联。考虑一个实例代码段8:
class Parent {
public:
Parent():nParent(888) {}
virtual void sayhello() { cout << "Perent()::sayhello()" << endl; }
virtual void walk() { cout << "Parent::walk()" << endl; }
virtual void sleep() { cout << "Parent::sleep()" << endl; }
protected:
int nParent;
};
class Child : public Parent {
public:
Child():nChild(88) {}
virtual void sayhello() { cout << "Child::sayhello()" << endl; }
virtual void walk_child() { cout << "Child::walk_child()" << endl; }
virtual void sleep_child() { cout << "Child::sleep_child()" << endl; }
protected:
int nChild;
};
class GrandChild : public Child{
public:
GrandChild():nGrandchild(8) {}
virtual void sayhello() { cout << "GrandChild::sayhello()" << endl; }
virtual void walk_child() { cout << "GrandChild::walk_child()" << endl; }
virtual void sleep_grandchild() { cout << "GrandChild::sleep_grandchild()" << endl; }
protected:
int nGrandchild;
};
现在,我们使用一个int** pVtbl 来作为遍历对象内存布局的指针,这样可以方便地像使用数组一样来遍历所有的成员包括其虚函数表:
typedef void(*Fun)(void);
GrandChild gc;
int** pVtbl = (int**)&gc;
cout << "[0] GrandChild::_vptr->" << endl;
for(int i=0; (Fun) pVtbl[0][i]!=NULL; i++){
pFun = (Fun) pVtbl[0][i];
cout << " ["<<i<<"] ";
pFun();
}
cout << "[1] Parent.nParent = " << (int)pVtbl[1] << endl;
cout << "[2] Child.nChild = " << (int) pVtbl[2] << endl;
cout << "[3] GrandChild.nGrandchild = " << (int) pVtbl[3] << endl;
运行结果如下:
(未完待续...)
我们发现,当一个子类继承父类时:
1. 它可以继承父类中所声明的virtual函数的函数实体,准确地说,是该函数实体的地址会被拷贝到子类的虚拟函数表中;
2. 它可以使用自己的函数体,如Child::sayhello()和GrandChild::walk_child()。
3. 它可以加入新的虚函数。
由前面的讨论,我们已经可以画出这三个类的内存布局图了,如下页图6所示。
由这个图,如果我们计算sizeof(GrandChild),对于结果应该就不会差异了:3个int变量,再加上一个指针。
下一步,就是编译期间如何对pB->sayhello()设定对虚函数的调用呢?
1. 首先,我们并不知道pB所指对象的真正类型,但是我知道经由pB可以存取到该对象的虚拟函数表。
2. 虽然我并不知道哪个sayhello()应该被调用,但是我知道每一个sayhello()函数的地址都放在vbtl的某个表项中,比如上述代码中的第1表项。
由以上的这些信息,编译器已经可以将pB->sayhello()转化为:
(*pB->vptr[1]) (pB);
在这个转化中,vptr表示编译器所压入的指针,指向vtbl,1表示sayhello()在vtbl中的索引号。。唯一一个需要在运行期才能知道的信息是:该索引所对表项到底是哪一个sayhello()的函数实体,这个可以借助type-info的信息获得,因为pB也被压入了参数列表中。