使用基类的指针或者引用在运行期执行正确的操作这种行为我们称之为多态。
从这个基本的概念,我们可以简单的推断出多态所需要的关键技术之一便是继承,关于继承在第三章已经接触,不过并没有真正的深入,那么除了继承之外支撑多态行为的便是关键字virtual啦。那么,在介绍virtual之前我们先来看看下面这个模型:
//+----------------------------
class Ios{};
class IStream : public virtual Ios{};
class OStream : public virtual Ios{};
class IOStream : public IStream,public OStream{};
//+----------------------------
这是典型的钻石模型,他结构如下(由于家里电脑没有安装viso,所以这里的模型图都是用纸绘制出来的,粗糙了点):
下面我们来讨论IOStream的各个部分的大小:
//+-----------------------------
std::cout<<sizeof>
std::cout<<sizeof>
std::cout<<sizeof>
std::cout<<sizeof>
//+-----------------------------
如果我们不执行上面代码的话我们能够知道答案吗?好吧,我们简单的分析一下:
首先,Ios是一个空类,所以我们可以认为他的大小是0,但是……但是以前的都是废话,我们可以想想一下:
//+----------------------------
Ios os;
std::cout<
//+---------------------------
就比如上面的操作,当我们想对一个没有数据成员的对象取地址时,如果我们认为他的大小是0的,那么这里是不是非法呢?事实上我们这样做是没问题的,也就是说这种操作是合法,那么问题来了,既然合法,那我们还能够认为这个空类的大小是0吗?当然不是,事实是这样,当一个类为空类——也就是没有任何数据成员的类,编译器会他安插一个char进去,所以他的大小应该为1。
对于IStream的大小,想要分析那就稍微麻烦点了,在分析他大小之前我们先来说说虚基类的概念,虚基类不同于普通的基类,虚基类属于一个共享对象,所以在IStream的数据中并没有基类的对象,而是包含一个指向基类对象的指针,该模型如下:
在明白了虚基类这个概念之后我们进一步分析IStream的大小就比较容易了,首先IStream的对象里面包含了一个指针,该指针指向Ios对象,在win32平台下,一个指针的大小是4,这里需要注意,虽然IStream也是一个空类,但是此处我们有了一个指向基类对象的指针,所以就不需要在安插一个char,所以IStream的大小便是4。
同理OStream的大小也是4。
对于IOStream而言,这里他的大小也就是IStream的大小加上OStream的大小,所以IOStream的大小便是8.
我们上面的模型只是基本的继承,不具有多态性质,所以我们将问题稍微的复杂话一下,让他具有多态性质:
//+----------------------
class Ios{
public:
virtual Ios& operator<
virtual Ios& operator>>(int&){}
};
class IStream : public virtual Ios{};
class OStream : public virtual Ios{};
class IOStream : public IStream,public OStream{};
//+-----------------------
我们现在再来分析一下各部分的大小,首先还是Ios,此处Ios依旧没有数据成员,那么我们是不是任务他的大小是1呢?嗯,这里就复杂了,首先我们可以肯定他的大小不是1,那么是多少呢?这里我们就来挖掘多态背后的真相。
当一个类里面至少拥有一个虚函数时,那么编译器就会为该类生成一张虚函数表——vtbl,而该类的对象将会有持有一个指向该该表的指针——vptr。那么现在我们先来看看编译器是如何调用普通成员函数的:
//+--------------------------
class B {
public:
void Fun(){}
};
int main(){
B b;
b.Fun();
}
//+-------------------------
如上,当我们定义出一个B的对象后,我们调用Fun函数,编译背后的操作如下:
//+-------------------------
首先编译器会将成员函数Fun改成一个普通的外部函数函数:
void B_Fun(B* const this); // B_Fun 不一定是这个名字,编译器有他自己的命名规则,这里仅仅作为一个参考
于是
b.Fun() ==> B_Fun(&b)
//+-------------------------
从上面的过程我们可以看到使用class的封装和C语言的直接函数调用比起并没有带来任何效率上的损失。也就是说:
//+--------------------------
void Fun(B* const this);
void B::Fun();
B b;
b.Fun();
Fun(&b);
//+--------------------------
上面的两种执行效率是一样的。
那么如果成员函数有关键字virtual修饰的时候编译器还会这样将他改写为普通的外部函数吗?答案是否定,对于有virtual关键字修饰的成员数,编译器会将他放进虚函数表中,添加一个索引,使用chunk技术(该技术有些复杂,目的是为了提高调用效率,所以这里不细说),比如:
//+---------------------------
class B{
public:
virtual void Fun(){}
};
int main(){
B b;
b.Fun();
}
//+----------------------------
上面的b.Fun()的调用实际上将是执行下面的代码:
//+----------------------------
(*b.vptr[0])(&b);
//+----------------------------
此处的vptr是就虚函数指针,由于他指向的是一张虚函数表,B只有一个虚函数,就是索引为0的位置。
到了这一步,我们离揭开多态的技术内幕也仅仅只有一步之遥了,OK,那么我们现在来分析一下Ios的大小,我们上面说了,此时引入虚函数的Ios的大小已经不再是1,由于他是空类,所以他的大小为一个指针大小,该指针指向Ios的虚函数表,该虚函数表中有两个slot,分别是operator<>的函数指针,所以此处的Ios的大小是4。
知道Ios的大小是4之后我们来计算IStream的大小,由于IStream是虚列继承至Ios,所以他的大小不仅包含了Ios的大小,同时还有一个Ios的指针,模型如下,如果他的大小是8。
同理OStream的大小也是8,得出IStream和OStream的大小之后我们来看看IOStream的大小,那么弱弱的问一下,IOStream的大小是16吗?答案同样是否定,上面我们说了,对于虚基类整个对象中只有一个对象实例存在,所以对于IOStream来说,并不是IStream的大小加上OStream的大小这么简单,因为这里仅仅只有一个Ios对象存在,而一个Ios的大小是4,所以这里IOStream的大小为12,模型如下:
到了这里,我们可以继续深入啦:
//+----------------------------
class Ios{
public:
virtual Ios& operator<
virtual Ios& operator>>(int&){}
};
class IStream : public virtual Ios{
public:
Ios& operator>>(int&){}
};
class OStream : public virtual Ios{
public:
Ios& operator<
};
class IOStream : public IStream,public OStream{};
//+--------------------------
这里我们真正的引入了多态,在彻底揭开他背后的神秘面纱之前我们先来看看构造函数都做些啥,首先从简单的Ios说起:
//+--------------------------
Ios* Ios:Ios(Ios* const this){
this->vptr = vbtl;
return this;
}
//+--------------------------
上面的代码不能当真,可以把他当作伪码,这段代码实在构造一个对象的时候编译器扩展出来的,我们是不需要关心,这是编译器会为我们做的,所以当我们在完成一个对象构造之后我们就拥有一张行为正确的虚函数表,在执行期就能够得到正确行为,当然由于Ios本身就没有继承,所以这无关紧要,那么对于IStream的构造函数就比较麻烦一些,大致会扩展成如下:
//+--------------------------
IStream* IStream::IStream(IStream* const this,bool is_most_derived)
{
if(is_most_derived != false)
this->Ios::Ios(); // 构造基类
this->vptr = vbtl;// 设置虚函数表
this->Ios.vptr = vbtl_Ios; // vbtl是修改后的基类的虚函数表,对于这里来说是将slot2的地址换成&IStream::operator>>
return this;
}
//+--------------------------
当然这是虚继承的情况下会这么做,如果不是虚继承的时候就相对简单一些,比如:
//+--------------------------
class B {
public:
void Fun(){}
};
class D : public B {
public:
void Fun(){}
};
Dd构造函数的扩展如下
D* D::D(D* const this)
{
this->B::B();
this->vptr = vbtl;
this->vptr_B = vbtl_B;
return this;
}
//+------------------------
同理,我们知道了OStream的构造过程,那么对于IOStream的呢?他应该怎么构造呢?其实也是大同小异,如下:
//+------------------------
IOStream* IOStream::IOStream(IOStream* const this,bool is_most_derived)
{
if(is_most_derived != false)
this->Ios::Ios(); // 构造共享部分基类
this->IStream::IStream(false);
this->OStream::OStream(false);
// 初始化vptr
return this;
}
//+-------------------------
到了这里,多态背后的神秘面纱是不是已经被彻底揭开了呢?所谓多态就是根据基类持有的虚函数指针找到对应的虚函数表,从中执行指定函数指针然后得到正确的行为,对于构造函数的执行,都是先执行基类的构造函数,最后一层层的执行上来,最终执行到自己的构造函数,每一个构造函数中都有对基类的vptr进行修改,所以基类的vptr最终都是指向了最新的vbtl,这就是多态的秘密,而从这个结论中我们还得到一个铁律,那就是在构造函数中调用虚函数实际上等同于调用普通函数,因为在当前的构造函数我们所能够拿到最新的vbtl是当前类的vbtl,并非是最后的vbtl,所以我们所执行的函数就是当前类的函数,所以不论是是否为虚函数,那么他的效果都如果非虚函数,当然调用的过程可以有所区别:
//+-----------------------------
class B{
public:
B(){
fun();
}
virtual void fun(){}
};
其中的构造函数将会被扩展如下形式:
B* B::B(B* const this)
{
this->vptr = vbtl;
(*this->vptr[0])(this);
return this;
}
//+--------------------------------
ok,到此处,对于多态,想必大家都已经有了深入的认识,如果觉得还不够,那么大家可以去研究一下《深度探索C++对象模型》一书。
每天会更新论文和视频,还有如果想学习c++知识在晚上8.30免费观看这个直播:https://ke.qq.com/course/131973#tuin=b52b9a80
/<sizeof>/<sizeof>/<sizeof>/<sizeof>