C++ 多态问题的梳理

Polymorphism of C++

Posted by Jayn on February 12, 2016

C++的多态性问题是一个比较重要的概念,看了好多遍,但是经常忘记或模糊其中的一些细节,今天再看一遍,把这个总结一下,希望这样可以帮助自己巩固一下。

C++多态的实现原理

先看一个例子:

class Animal{
    public:
        void eat(){
            cout<<"animal eat"<<endl;
        }
}

class Fish:public Animal{
    public:
        void eat(){
            cout<<"fish eat"<<endl;
        }    
}

int main(){
    Fish fh;
    Animal *pAn=&fh;
    pAn->eat();
}

上面这段代码的输出为:”animal eat”,而不是”fish eat”. 这是因为在编译器进行编译时,进行了early-binding.就是说,在创建*pAn时已经却确定了它的类型为Animal,将类型为Fish的实例指针强制转换为Animal类型,最后调用的自然也就是Animal的eat方法了。

要想实现多态,就需要使用虚函数来实现多态:

class Animal{
    public:
        virtual void eat(){
            cout<<"animal eat"<<endl;
        }
}

class Fish:public Animal{
    public:
        void eat(){
            cout<<"fish eat"<<endl;
        }    
}

int main(){
    Fish fh;
    Animal *pAn=&fh;
    pAn->eat();
}

这时,输出结果就为fish eat.原因是使用virtual关键字后,就实现了迟绑定(late-binding),即一种动态绑定技术,而不是在编译期间就确定了实例的类型。它的实现方法是使用虚表(virtual table)和虚表指针的方式来实现。创建每一个包含虚函数的类对应的实例都包含有一个虚表vtable,这个vtable是一个一维数组,存放的是这个类中的虚函数方法(注意虚表中存放的仅仅是虚函数方法,其他的非虚函数方法不再这个虚表中)。

如何定位每个虚表呢?编译器为每一个含有虚函数的类的对象实例提供一个虚表指针vptr.在程序运行时,根据对象实例的类型来初始化vptr,从而让vptr正确的指向所属类的虚表。在上例中,pAn指向的对象类型为Fish,所以对应的vptr指向的是Fish类的vtable,因此调用pAn->eat()调用的就是Fish类中vtable所指的Fish类的方法eat。

需要注意的是,上述的vtable和vptr都是在对象初始化后才产生的,而这个初始化的过程是在构造函数中完成的,因此构造函数不可以是虚函数!


一个有趣的问题

看下面的代码:

#include<iostream>
using namespace std;
class B0//基类BO声明
{
public://外部接口
virtual void display()//虚成员函数
{
    cout<<"B0::display0"<<endl;}
};
class B1:public B0//公有派生
{
public:
    void display() { cout<<"B1::display0"<<endl; }
};
class D1: public B1//公有派生
{
public:
    void display(){ cout<<"D1::display0"<<endl; }
};
void fun(B0 ptr)//普通函数
{
    ptr.display();
}
int main()//主函数
{
    B0 b0;//声明基类对象和指针

    B1 b1;//声明派生类对象
    
    D1 d1;//声明派生类对象
    
    fun(b0);//调用基类B0函数成员
    
    fun(b1);//调用派生类B1函数成员
    
    fun(d1);//调用派生类D1函数成员

}

上面这段代码的输出为什么呢?答案是:

B0::display() B0::display() B0::display()

为啥不是:B0::display() B1::display() D1::display()?为啥没有实现多态?原因是就是由于fun函数的参数是不是按地址传值,这样会转化为基类对象,直接调用基类的成员函数,如果是指针传递,改为B0 *ptr,ptr->display(),可以实现多态这样的话就无法进行动态绑定。

虚函数的动态绑定仅在基类指针或引用绑定派生类对象时发生,fun的形参不是指针,所以调用哪个版本的函数编译时就已经确定,根据形参静态类型确定调用B0的成员。

类的大小问题

一个空类的大小是多少呢?一个含有虚函数的类的大小是多少呢?看下面一段代码:

class testEmptyClass
{
public:
    testEmptyClass(){}
    ~testEmptyClass(){}
    virtual void f(){}
    
};

class testEmptyClass2:public testEmptyClass
{
public:
    testEmptyClass2();
    ~testEmptyClass2();
    void f(){}
};

class empty1
{
    public:
    empty1(){}
    ~empty1(){}  
};

class empty2
{};

class empty3
{
public:
    empty3(){}
    ~empty3(){}
    virtual void f(){};
private:
    char k[3];  
};

class empty4:public empty3
{
public:
    void f(){}
private:
    char p;
};


int main(){
    //testEmptyClass t;
    //empty e;

    cout<<"testEmptyClass:"<<sizeof(testEmptyClass)<<endl;
    cout<<"testEmptyClass2:"<<sizeof(testEmptyClass2)<<endl;
    cout<<"empty1:"<<sizeof(empty1)<<endl;
    cout<<"empty2:"<<sizeof(empty2)<<endl;
    cout<<"empty3:"<<sizeof(empty3)<<endl;
    cout<<"empty4:"<<sizeof(empty4)<<endl;
}

输出结果为:

testEmptyClass:8 testEmptyClass2:8 empty1:1 empty2:1 empty3:16 empty4:16

为什么空类的大小为1而不是0呢?因为为了区别不同的空类,系统需要为他们分配至少一个字节的不同存储空间以标识他们是不同的类,这样子空类的大小就为1;若增加了虚函数,则该类中就有虚函数表和指向虚函数表的虚表指针,这个虚表指针占据的空间大小为8 byte(64位系统为8 byte,32位系统为4 byte)。

为什么empty3empty4的大小都为16呢?empty3中的char k[3]大小为3,虚表指针大小为8,对齐后大小为16;empty4的成员变量由于继承了empty3中的char k[3],再加上自己的char p,大小为4,以及自己的虚表指针大小为8,对齐后就为16.

包含静态成员变量的类的大小要怎么计算呢?

既然测试了空类和包含虚函数的类的大小了,那么我们顺便也测试一下包含静态成员变量的类的大小。

class staticTest
{
    static char a;
    char b[3];
};

int main(){
    cout<<"staticTest:"<<sizeof(staticTest)<<endl;
}

输出结果为:

staticTest:3

这说明,static修饰的静态变量:不占用内容,原因是编译器将其放在全局变量区。

总结

C++的多态性用一句话概括就是:在基类的函数前加上virtual关键字,在派生类中重写该函数,运行时将会根据对象的实际类型来调用相应的函数。如果对象类型是派生类,就调用派生类的函数;如果对象类型是基类,就调用基类的函数。

如果在父类中的某个方法上添加了关键字virtual,那么它的所有子类的该方法不用再写关键字virtual,他们都是虚函数,都可以对父类进行方法覆盖。

类的大小是类中所有的成员变量大小之和(静态成员变量不包括在内),普通的成员函数不会增加类的空间大小。如果有虚函数存在的话,还要加上虚表指针的大小,同时要注意类的对齐问题!