C++多态的底层实现原理分析

147 阅读9分钟

开始本文主题之前,先来看下面这个例子。

例1:

#include <iostream>
using namespace std;

class Animal{
    public:
        void talk(){cout << "Animal is talking..." << endl;}
};

class Person: public Animal
{
    public:
        void talk(){cout << "hello, How are you!" << endl;}
};

class Dog: public Animal
{
    public:
        void talk(){cout << "Wang! Wang!" << endl;}
};

class Cat:public Animal
{
    public:
        void talk(){cout << "Miao! Miao!" << endl;}
};

//该处的引用可以改为指针类型(需要修改对应程序),执行结果一样,下文会统一使用指针类型。
void how_talk(Animal &a)
{
    a.talk();
}

int main(int argc, char *argv[])
{
    Animal a;
    a.talk();

    Person p;
    p.talk();

    Dog d;
    d.talk();

    Cat c;
    c.talk();

    cout << "sizeof(Animal) = " << sizeof(Animal) << endl;

    return 0;
}

执行结果如下:

Animal is talking...  
hello, How are you!  
Wang!  Wang!  
Miao!  Miao!  
sizeof(Animal)  =  1

例2.修改例1中的main函数部分:修改内容如下所示:

int main(int argc, char *argv[])
{
    Animal a;
    Person p;
    Dog d;
    Cat c;

    how_talk(a);
    how_talk(p);
    how_talk(d);
    how_talk(c);

    return 0;
}

执行结果如下:

Animal is talking...  
Animal is talking...  
Animal is talking...  
Animal is talking...

通过例1,我们可以得到以下信息:

  1. 类的成员函数不占类的空间,也就是说无成员变量的类,不占内存空间(只不过为了标识其存在,编译器会默认给其分配1个字节的空间)。
  2. 类的继承会发生名字隐藏的现象。(同名函数,调用时调用的是子类的函数,而屏蔽了父类的函数)

对于例1的结果,应该都没有疑问,那么例2的输出结果可能就有疑惑了;为什么how_talk()传的参数不同,4次输出结果都一样呢,而且都是调用了父类的talk()方法?

我们调用how_talk()是在执行时调用的,编译器编译时并不知道我们要调用那个类的talk()方法,因此编译器会根据参数类型自动判断,参数类型为Animal的引用,编译器就会认为此处是要调用Animal这个类的talk()方法,然后编译器就将talk()绑定到了Animal类的talk()上去了。因此后面执行的时候,不管参数传什么,都会调用父类Animal的talk(),而不是我们所想的,传递不同参数,调用各自的talk()方法。这叫编译时绑定

由此,C++又引入了一个新的面向对象特征:多态。有了多态,这个问题就迎刃而解了。

多态

多态提供了在程序运行时,依据对象的类型,使用父类指针或父类的引用来调用相应的函数的方法。没有多态的话,程序调用哪个函数只能在编译时决定,这样就没有考虑多态的机制灵活。多态使用virtual关键字。

多态的3要素

  1. 多态要有继承;
  2. 多态需要使用virtual虚函数,子类中要实现和父类同名的函数,函数的名字和参数列表必须要一样。
  3. 多态使用父类的指针或者父类的引用来调用不同子类中的函数。

多态实例程序

#include <iostream>
using namespace std;

class Animal
{
    public:
        virtual void talk(){cout << "Animal is talking..." << endl;}    //virtual修饰的虚函数
};

//如果在子类中有与父类中的虚函数同名的函数(包括参数列表也要相同),那么子类中对应的函数也会成为虚函数
//因此子类中不加virtual修饰也可,加上也不会错。学完多态后看,可以自己测试。
class Person: public Animal
{
    public:
        void talk(){cout << "hello, How are you!" << endl;}
};

class Dog: public Animal
{
    public:
        void talk(){cout << "Wang! Wang!" << endl;}
};

class Cat:public Animal
{
    public:
        void talk(){cout << "Miao! Miao!" << endl;}
};

void how_talk(Animal *a)
{
    a->talk();
}

int main(int argc, char *argv[])
{
    Animal a;
    Person p;
    Dog d;
    Cat c;

    how_talk(&a);
    how_talk(&p);
    how_talk(&d);
    how_talk(&c);

    cout << "sizeof(Animal)" << sizeof(Animal) << endl;
    
    return 0;
}

来看一下执行结果:

Animal is talking...  
hello, How are you!  
Wang!  Wang!  
Miao!  Miao!  
sizeof(Animal)  =  4

对比本文最开始的两个例子,可以发现,Animal类占的空间变了,成了4;常见的4字节类型有int类型、指针类型等,暂时记一下,立马讨论该问题。还有一个不同就是,通过传递给how_talk()不同的参数,可以调用各自对应的talk()方法,而不再是编译时绑定的情况了,从执行结果来看,现在变成了执行时绑定,这就是多态的作用!!

由此引出了一个新概念:虚函数表。虚函数有一个对应的表,称为虚函数表,虚函数表中保存的是类中所有虚函数的地址,即各虚函数的函数指针。(马上揭晓sizeof(Animal) = 4的由来)

虚函数表

类中有一个或者多个虚函数时,编译器会为这个类添加一张类型独有的虚函数表,表中放着指向虚函数的函数指针。并且编译器会自动给对象添加一个指针成员(4字节问题的答案已经揭晓)。该指针位于类的前4个字节。这个指针指向的是虚函数表。相同类型的对象,这个指针应该指向同一张虚函数表。 在这里插入图片描述 在这里插入图片描述

下面来做个实验验证一下上述说法,改动main函数即可。如下所示:

int main(int argc, char *argv[])
{
    Animal a, b;
    Person p;
    Dog d;
    Cat c;

    //取每个对象前4字节的内容
    cout << hex << *(int *)&a << endl;
    cout << *(int *)&b << endl;
    cout << *(int *)&p << endl;
    cout << *(int *)&d << endl;
    cout << *(int *)&c << endl;
    cout << dec << endl;

    return 0;
}

输出结果如下

8048df8  
8048df8  
8048de8  
8048dd8  
8048dc8
  1. ab两个对象类型相同,得出前4个字节的值(即虚函数表的地址)相同;所以相同类型的对象,共用一份虚函数表。
  2. p,d,c3个对象的类型不同,得出的结果也不同;

通过上图的分析,我们得知,输出结果里的4个值便是虚函数表里存放各函数指针的地址了。如地址==8048df8==里保存的就是Animal类中虚函数talk()的函数指针。接下来我们尝试一下,通过地址的强制类型转换来执行一下这些函数,来真正的测试一下这些地址对应的,到底是不是我们各自类里的虚函数。

修改main函数,如下所示:

int  main(int argc, char *argv)  
{  
	Animal a, b;  
	Person p;  
	Dog d;  
	Cat c;  
	  
	cout  <<  "-----------------------"  <<  endl;  
	void  (*func)()  =  (void  (*)())(*(int  *)(*(int  *)&a));  
	func();  
	func  =  (void  (*)())(*(int  *)(*(int  *)&p));  
	func();  
	func  =  (void  (*)())(*(int  *)(*(int  *)&d));  
	func();  
	func  =  (void  (*)())(*(int  *)(*(int  *)&c));  
	func();  
	cout  <<  "-----------------------"  <<  endl;  
	  
	return  0;  
}

输出结果如下所示:

-----------------------  
Animal is talking...  
hello, How are you!  
Wang! Wang!  
Miao! Miao!  
-----------------------

OK,经过实验,验证了我们之前所有的说法;下面在类里再添加一个虚函数,然后利用上述方式调用执行。

代码如下所示:

#include <iostream>
using namespace std;

class Animal
{
    public:
        virtual void talk(){cout << "Animal is talking..." << endl;}
        virtual void foo(){cout << "Animal foo..." << endl;}
};

class Person: public Animal
{
    public:
        void talk(){cout << "hello, How are you!" << endl;}
        void foo(){cout << "hello, foo" << endl;}
};

class Dog: public Animal
{
    public:
        void talk(){cout << "Wang! Wang!" << endl;}
        void foo(){cout << "Wang! foo" << endl;}
};

class Cat:public Animal
{
    public:
        void talk(){cout << "Miao! Miao!" << endl;}
        void foo(){cout << "Miao! foo" << endl;}
};

void how_talk(Animal *a)
{
    a->talk();
}

int main(int argc, char *argv[])
{
    Animal a, b;
    Person p;
    Dog d;
    Cat c;

    cout << "-----------------------" << endl;
    void (*func)() = (void (*)())(*(int *)(*(int *)&a));
    func();
    func = (void (*)())(*(int *)(*(int *)&p));
    func();
    func = (void (*)())(*(int *)(*(int *)&d));
    func();
    func = (void (*)())(*(int *)(*(int *)&c));
    func();
    cout << "-----------------------" << endl;

    cout << "-----------------------" << endl;
    //实现思想:指针偏移到下一个函数指针的位置,然后调用。
    void (*f)() = (void (*)())(*((int *)(*(int *)&a)+1));
    f();
    f = (void (*)())(*((int *)(*(int *)&p)+1));
    f();
    f = (void (*)())(*((int *)(*(int *)&d)+1));
    f();
    f = (void (*)())(*((int *)(*(int *)&c)+1));
    f();
    cout << "-----------------------" << endl;

    return 0;
}

执行结果如下:

-----------------------  
Animal is talking...  
hello, How are you!  
Wang! Wang!  
Miao! Miao!  
-----------------------  
-----------------------  
Animal foo...  
hello, foo  
Wang! foo  
Miao! foo  
-----------------------

上述程序的执行过程,不再具体分析。

关于虚函数和虚函数表的相关知识,暂时介绍到这里。下面介绍一些虚函数的使用过程中需要注意的一些地方。

不能成为虚函数的函数

  1. 只能在类的定义范围内使用,不属于类的函数(如main函数)不能成为虚函数。
  2. 类中的static静态成员函数也不能成为虚函数:virtual static goo();
  3. 构造函数也不能成为虚函数。

通常情况下,应该把类的析构函数定义为虚函数。

如下所示,父类指针指向子类对象,会造成内存泄漏。

#include <iostream>
using namespace std;

class A
{
    public:
        A(int a):data(NULL)
        {
            cout << "A::A(int)" << endl;
            data = new int(a);
        }
        ~A()
        {
            cout << "A::~A()" << endl;
            delete data;
        }
    private:
        int *data;
};

class B:public A
{
    public:
        B():A(10), bdata(NULL)
        {
            cout << "B::B(int)" << endl;
            bdata = new int(10);

        }
        ~B()
        {
            cout << "B::~B()" << endl;
            delete bdata;
        }
    private:
        int *bdata;
};

int main(int argc, char *argv[])
{
    A *pb = new B;
    delete pb;

    return 0;
}

输出结果如下:

A::A(int)  
B::B(int)  
A::~A()

通过输出发现,程序结束时,在执行delete语句时,触发了父类A的析构函数;但是一直到程序结束,也没有触发子类B的析构函数。这样就会造成内存泄漏,对程序带来了极大的不稳定性。现在对父类A的析构函数加virtual进行修饰,再尝试执行本程序。

#include <iostream>
using namespace std;

class A
{
    ...
        virtual ~A()    //析构函数定义为虚函数
        {
            cout << "A::~A()" << endl;
            delete data;
        }
    ...
};

class B: public A
{
    ...
}

int main(int argc, char *argv[])
{
    A *pb = new B;
    delete pb;

    return 0;
}

此时程序的执行结果如下:

A::A(int)  
B::B(int)  
B::~B()  
A::~A()

此时,程序就会正常执行了,并且也释放了之前申请的内存空间。这样就避免了内存泄漏的产生。

由此可见,把类的析构函数定义为虚函数,当程序调用该类的析构函数时,程序会自动的先调用其子类的析构函数,然后再调用自己的析构函数,通过这种方式来层层释放,最终申请的资源全都释放完毕,保证了程序的稳定性。

子类指针指向父类对象的bug

//演示子类指针指向父类对象的bug
#include <iostream>
using namespace std;

class A{
    public:
        int x;
};

class B:public A{
    public:
        int y;
};

int main(int argc, char *argv[])
{
    int i = 10;
    cout << "i=" << i <<endl;
    A a;
    B * pb = (B*)&a;
    pb->y = 20;

    cout << "i=" << i << endl;

    return 0;
}

执行结果如下:

i=10  
i=20

分析:如下图所示,检查main函数的栈可知,bp->yi的地址相同;修改了bp->y的值,i的值也会相应的改变。 在这里插入图片描述 通过gdb调试,也可以检查出原因:

(gdb) print  &i  
$1  =  (int  *)  **0xbffff3bc**  
(gdb) print pb  
$2  = (B  *) 0xbffff3b8  
(gdb) print  &pb->x  
$3  =  (int  *) 0xbffff3b8  
(gdb) print  &pb->y  
$4  =  (int  *)  **0xbffff3bc**

通过gdb打印变量ipb->y的地址,可以看到,它们是一样的,也就是说i就是pb->ypb->y就是i。修改两者任一个,另一个也会变化。