深入理解C++Virtual与动态联编

分类 - 代码 共有 0 条评论

在看这篇文章之前我想让大家先明确以下几个概念:

  1. 重载(Overload):在一个大项目当中,我们希望通过几个函数来实现一些操作,而这些操作是相似的。如果不用重载的话就必须编写多个不同的函数。

这对于一个大项目的管理来说是十分不利的。为了方便管理,C++ 允许用同一函数名来定义多个函数,这些函数的参数个数参数类型或者是参数位置不同,通过同名函数来实现不同功能,这就是函数的重载。

  1. 覆盖(Override):覆盖也可以称为重写,覆盖是指派生类中存在重新定义的函数,它的函数名参数表返回值类型必须与父类中被覆盖的函数严格一致,它们俩之间的区别仅仅只有函数体的不同。当派生类对象调用它与父类同名的函数时,会自动调用派生类对象的覆盖版本,而不是父类当中的被覆盖的版本。
  2. 隐藏(Hide):类似于C语言里全局变量与局部变量的概念,隐藏指的是派生类当中存在与父类同名的成员。派生类会自动屏蔽从基类继承下来的成员。对于成员函数来讲,只要函数名相同就会屏蔽父类的同名函数。

虽然在程序的运行当中覆盖和隐藏体现出来的是一样的行为。但是,覆盖和隐藏在本质上是不同的,覆盖是在在多态中可以通过父类指针自动操作派生类的与父类同名的虚函数,而不是调用父类的虚函数。隐藏可以理解成在派生类的名字空间中没有引用父类的名字空间,从而执行派生类中与父类同名的函数。
下面就通过具体讲解这三者之间的关系来介绍今天的主要内容:
请大家看一下几段代码。

重载:

#include<iostream>
#include<windows.h>
using namespace std;
void print(int x)
{
    cout << "X=" << x<<endl;
}
void print(int x, int y)
{
    cout << "X+Y=" << x + y<<endl;
}
int main()
{
    print(3);
    print(1, 4);
    system("pause");
    return 0;
}

text1

在这里可以看到相同函数使用了不同的参数,进而可以可以实现不同的功能,这就是重载的意义。

覆盖:

覆盖只出现在多态中,其函数的关键词必须有virtual

#include<iostream>
#include<windows.h>
using namespace std;
class Base
{
public:
    virtual void print()
    {
        cout << "我是Base"<<endl;
    }
    /*void print()
    {
        cout << "我是Base"<<endl;
    }*/
};
class Div :public Base
{
public:
    void print()
    {
        cout << "我是Div"<<endl;
    }
};
int main()
{
    Base *p_Div = new Div;// 父类指针指向的子类对象,用virtual声明函数,具有多态的性质
    p_Div-> print();
    system("pause");
    return 0;
}

这里是无virtual关键词的情况:
text3

静态调用&静态联编&函数签名
1.当父类指针指向派生类未加virtual的“虚函数”时,这就是静态调用。
静态调用指的是在源代码当中就已经可以确定某条语句调用的哪一个具体的函数,无二义性。
那为什么这里Base 类型的静态调用不会调用子类的函数呢?
这是依据它的指针类型来决定的(而不是指向的对象),当指针类型为Base 时,只能调用Base 类型的(也就是父类的)函数,要深层次解释这个原理就要讲讲函数签名(Function Signature)的概念了。
函数签名
大家应该都知道程序需要经过编译产生目标文件。在早期,编译器产生的目标文件中的函数的符号名和源代码中的符号名是相同的。这对于一个大工程来讲时十分不利的,很容易造成重名的现象。C++ 增加了名字空间,就是为了防止许多模块的的名称冲突。所以符号修饰(Name Decoration)、符号改编(Name Mangling)应运而生。但是C++ 有着更多更复杂的特性,这使得它的符号管理十分复杂。因此就提出了函数签名的概念。函数根据它的参数类型、函数名、返回类型等确定它在目标文件的符号名。所以说在源代码当中函数名相同的函数在目标文件中也会被认为是不同的函数。
这也就解释了父类指针为什么不会调用子类函数,因为这是静态联编(静态绑定)。在程序编译阶段就已经确定好了程序需要运行的语句,这里函数的指针已经指向了其父类函数的地址。所以 Base *p_Div-> print() 其实指的就是Base 类中的print() 函数。
用一段程序来理解一下:

#include<iostream>
#include<windows.h>
using namespace std;
class Base
{
public:
     void print(int i)
    {
    }
};
class Div :public Base
{
public:
    void print(double d)
    {
    }
};
int main()
{
    Div *p_Div = NULL;
    Base *p_Base = new Div;
    p_Div= (Div *)p_Base;
    p_Div->print(3);
    p_Base->print(1);
    return 0;
}

经过了解我们得知gcc 编译器的函数签名为: _ZN+类名长+类名+...+函数名长+函数名+E +参数类型缩写 ,举个例子: Base::Div::Print(double x, int y)的函数签名为 _ZN4Base3Div5PrintEdi。
了解了这个知识以后我们使用 gcc 编译器将这段代码编译成目标文件并以ANSI编码格式打开:
text6

可以看见p_Div->print()p_Base->print()是不同的。函数签名不同,父类指针指向的print()在编译的时候就已经绑定完成了。
这里是有virtual关键词的情况:
text2

2.当指针是父类指针,并且指向派生类对象虚函数的时候,会产生覆盖,从而执行派生类的覆盖函数,这就属于动态联编
动态联编指的是编译程序在编译阶段并不能确切的知道将要调用的函数,只有在程序运行时才能确定将要调用的函数(比如if()),为此要确切知道该调用的函数,要求联编工作要在程序运行时进行,这种在程序运行时进行联编工作被称为动态联编。动态联编在C++ 当中最重要的表现就是多态了。
介绍多态之前先做一个引入,请大家看一段程序分析:

#include<iostream>
#include<windows.h>
using namespace std;
class Base
{
public:
    int a;
    void print() {};
    //virtual void print() {};
};
int main()
{
    cout <<"Base的大小为:"<< sizeof(Base)<<endl;
    system("pause");
    return 0;
}

这里是无virtual的情况:
text4

这里是有virtural的情况:
text5

这里可以看到有virtual 关键词Base 类型的大小比没有virtual 关键字的大4个字节。(博主的电脑是64位的)再次对有virtual的类进行分析:
text6

我们可以看到A当中多了一个void *类型的指针,在64 位的系统下 void 所占用的内存大小sizeof(void ) = 4 ,通过查阅资料我们可以知道多态的实现原理:
在包含虚函数的类当中,编译器在编译的时候会给每个类创建一个虚表(vtable)。这是一个指针数组,用于存放每个虚函数的地址。即使子类没有virtual 关键词,由于父类当中声明过了该函数为虚函数,子类同意也会自动加上virtual 前缀,进而也形成一张虚表。
则实例化一个带继承父类虚函数的子类的过程如下:

  1. 实例化父类对象,并创建父类虚函数的虚表,用_vfptr指向这个虚表的表头。
  2. 实例化子类对象,创建子类虚函数的虚表的同时继承父类的虚表,将“同样的”(仅有函数体不同)的函数地址用子类相应的函数地址覆盖,子类没有声明的父类中定义了的虚函数也继承继承到虚表中,子类自己的虚函数如果在派生类中没有声明定义,那么也不用添加到子类的表中。

总结来讲:如果任何一个类的父类有虚函数的定义,子类有虚函数的重写,那么这个类一定有一个自己的虚表指针和虚表。也就是说,一个实例化的对象是可能有多个虚表指针和虚表的。它们都有一个特点:那就是他们的虚表指针只指向自己的虚表,并且在对象构造的过程中根据对象的类型初始化自己的虚表指针。
下面举一个继承的例子来解释一下:

class Grand
{
public:
    int age;
    Grand(){}
    virtual ~Grand() {}
    void print() {}
    virtual void hello() {}
};
class Parent:public Grand
{
public:
    int year;
    Parent(){}
    virtual ~Parent() {}
    virtual void hello() {}
    virtual void print(){}
};
class Son :public Parent
{
public:
    int day;
    Son(){}
    virtual ~Son() {}
    void print() {}
};

实例化一个Grand Grand *A= New Grand;

text7

这里绿色的部分就是A的虚表(vtable),其中包含实例A 的虚析构函数~Grand()和虚函数hello()的地址。构造该对象的时候_vfptr默认指向 _vfptr[0],即该对象自己的虚表。

实例化一个Parent Grand *B= New Parent;
text8

这里因为子类当中有父类的虚函数定义,所以将虚表当中的Grand::hello()换成了Parent::hello(),当执行Grand *p = new Parent; p->hello()时,将执行parent中的hello()虽然这里是基类的指针,但是它所指向的对象的虚表指针指向的是这个对象自己的虚表,所以就会调用parent::hello(),这与非虚函数不同的是,虚函数是有虚表的,非虚函数没有虚表,也就没有办法通过基类指针来调用派生类函数。 这就是多态的实现原理。

用一句话来概况就是:利用一个函数指针数组来存取虚函数的地址,根据对象的类型去初始化_vfptr虚表指针让他指向自己的虚表,无论用什么类型的指针去调用函数,这个对象虚表所指向的函数地址总是不变的(前提是编译器要通过)。
这里贴一张实例化Son 的内存排列图,大家可以思考一下。
text9

看完了这么多,那我们现在来讲虚析构函数:
首先给大家一个例子:

#include<iostream>
#include<windows.h>
using namespace std;
class Base
{
public:
    Base()
    {
        cout << "Base 被创建了" << endl;
    }
    ~Base()
    {
        cout << "Base 被销毁了" << endl;
    }
};
class Div :public Base
{
public:
    Div()
    {
        cout << "Div 被创建了" << endl;
    }
    ~Div()
    {
        cout << "Div 被销毁了" << endl;
    }
};
int main()
{
    Base *p_Base = new Div;
    delete p_Base;
    system("pause");
    return 0;
}

运行该程序:
text10

我们发现Div 没有被销毁,查看该内存,我们发现该数据依然在内存当中,这就造成了内存泄露,这对于一个程序来说是一个重大的隐患。所以为了我们需要分析一下,为什么会有这样的结果:
刚刚讲了静态联编的很多概念,从中我们可以知道,如果用基类指针是不能访问子类非虚函数的。而这个程序利用基类指针初始化一个子类对象,这是静态联编、早期绑定。在这个过程在程序编译的时候就已经写好了。默认指向的是基类的函数。所以导致无法调用派生类的析构函数,只能调用基类的析构函数。进而显示出没有析构子类对象的现象。
解决办法:
最好的解决办法莫过于多态了,利用虚函数覆盖(override)析构函数,实现子类对象的析构。

virtual ~Base()
    {
        cout << "Base 被销毁了" << endl;
       }

做法就是在基类的析构函数前面添加一个virtual 关键词。
运行一下:
text11

结果正常了,我们来分析一下:
通过运行调试可以发现,添加了virtual 关键词以后,可以在虚表当中找到这个析构函数。这就说明这个函数是通过多态的覆盖来实现调用子类析构函数的。
不难发现,虽然每个类的析构函数都有不同的名称。但是在其内部,它们的名称、返回值,传入的参数都相同。这也正好应证了多态的定义。(PS:这个地方是我自己的推断,我看了另一篇博客里面有不一样的解释)

多态屏蔽&权限问题:

  1. 构造函数屏蔽多态,因为这个可以通过编译但无法通过链接,有兴趣的可以百度一下。
  2. 子类私有虚函数,限于篇幅这里就简要的讲讲:所谓子类私有虚函数就是指基类中声明的虚函数,在子类中定义在private 当中。经过实验可以得出,即使派生类中的某个虚函数是私有的,通过基类指针仍然能够正确调用。也就是说,派生类中的虚函数的访问权限不影响虚函数的动态联编,也就是说,多态与成员函数的访问权限是没有关系的。只要基类定义了一个共有的(public)虚函数,无论派生类的虚函数处于什么访问权限下,都能够正常访问。但是如果基类的虚函数是私有的(private),那么程序将会报错。感兴趣的可以尝试一下,欢迎在评论区留言讨论。
  3. 私密虚函数的调用:
#include<iostream>
#include<windows.h>
using namespace std;
class Base
{
public:
    void print() 
    {
        print1();
    }
private:
    virtual void print1() { cout << "private print1" << endl; }
};
class Div :public Base
{
public:
    virtual void print1(){cout << "public print1" << endl;}
};
int main()
{
    Div *p_Base = new Div;
    p_Base->print();
    delete p_Base;
    system("pause");
    return 0;
}

text14

这里发现程序调用了子类当中的print1()函数,这就是一个多态的实现。print1()被声明成私有的,这其中的意思是,派生类可以修改这个实现,也可以继承其基类默认的实现。另一方面,可以通过派生类修改基类中的私有函数。

  1. 虚函数的构造函数调用:

这个是我想讲的重点,请大家看一段代码:

#include<iostream>
#include<windows.h>
using namespace std;
class Base
{
public:
    Base() 
    {
        print();
    }
    virtual void print() { cout << "Base print1" << endl; }
};
class Div :public Base
{
public:
    virtual void print(){cout << "public print1" << endl;}
};
int main()
{
    Base *p_Base = new Div;
    delete p_Base;
    system("pause");
    return 0;
}

text15

这里发现没有实现多态,而是调用了父类中的函数。究其原因,我们知道 C++再实例化一个子类的时候首先要实例化父类,所以当运行到父类的构造函数时,子类还未实例化。程序运行到这里有两种方案,因为通过了编译,所以程序可能一直找子类的覆盖版本,从而陷入死循环。因为根本没有进而导致所以导致多态失效。其次是直接用基类的虚函数定义,这样就失去了虚函数的特性。反过来如果在析构函数中定义,程序先析构子类的对象,接着再析构父类的对象。同样也会遇到这些问题。

还有很多点,限于篇幅原因,以后再细讲。

隐藏:

什么是函数的隐藏?这里也先贴出一段代码,具体分析:

#include<iostream>
#include<windows.h>
using namespace std;
class Base
{
public:
    void print(int x, int y) {};

};
class Div :public Base
{
public:
    void print(int x, double y) {};
    void print(int x) {};
    
};
int main()
{
    Div *p_Base = new Div;
    p_Base->print();
    delete p_Base;
    system("pause");
    return 0;
}

text13

这里可以发现只有两个重载函数,在基类当中的同名函数被隐藏起来了,而不是被继承下来。

个人总结:

经过这么长时间翻阅书籍翻阅资料,让我对多态与虚函数的理解加深了不少。特别是对虚函数的调用这块部分。

参考资料:

  1. C++基础之基类派生类指针间强转问题(基类派生类傻傻分不清) - Eden's space - CSDN博客
  2. C++动态联编的好处,为什么要用基类的指针指向派生类的方式来实现重写 - o_bvious的博客 - CSDN博客
  3. vfptr - chaiyu2002的专栏 - CSDN博客
  4. C++虚指针与vtbl。 - flyingwaters - 博客园

本文由Lw原创,转载请说明出处。如果你有什么好的想法,意见或建议,欢迎评论区留言。

文章评论
  1. 小白的白粉

    小白好棒啊啊啊啊啊啊啊!!!!!!!!!!!!!!!!!!!!!! @(乖) @(乖)

    回复