【C++】什么是多态?虚函数的底层实现原理|多重继承|菱形继承

目录

文章1(循序渐进解析)

什么是多态

概念:

多态有什么好处?

虚函数的底层实现原理

文章2(直白解析)

1. 概述

2. 虚函数表构造过程

3. 虚函数调用过程

4. 多重继承

5. 菱形继承

虚析构函数的作用及其原理

面试常见问题

内存分布


文章1(循序渐进解析)

 

什么是多态

概念:

 定义

同一操作作用于不同的对象,可以有不同的解释,产生不同的执行结果,这就是多态性。简单的说:就是用基类的引用指向子类的对象。

用通俗的话说,多态是指多个子类有一个共有操作,我们在父类中定义一个统一的抽象虚接口,然后各个子类分别实现。

 实现:C++多态性主要是通过虚函数实现的,虚函数允许子类重写override


//code start
//B is base class,A -- C is sub - class

class B
{
public:
    virtual void do_sth() = 0;
};
class A : public B
{
public:
    void do_sth()
    {
        cout << "- A do_sth()\n";
    }
};
class C : public B
{
public:
    void do_sth()
    {
        cout << "- C do_sth()\n";
    }
};

void do_sth(B *id_b) //
{
    id_b->do_sth();
}
int main()
{

    A *id_a = new A();
    C *id_c = new C();

    do_sth(id_a);
    do_sth(id_c);
    return 0;
}// code end

函数运行输出结果是:

A do_sth()
C do_sth()

看 “ void do_sth(B *id_b)” ,  我们用的基类指针作为函数的接口参数,但是 “do_sth(id_a); ” 传递参数时,我们传的是 A 或者 C 对象的指针! 多态使得调用的接口一致,更利于抽象和简化。

 

多态有什么好处?

目的接口重用。封装可以使得代码模块化,继承可以扩展已存在的代码,他们的目的都是为了代码重用。而多态的目的则是为了接口重用。

  1. 应用程序不必为每一个派生类编写功能调用,只需要对抽象基类进行处理即可。大大提高程序的可复用性。//继承 
  2. 派生类的功能可以被基类的方法或引用变量所调用,这叫向后兼容,可以提高可扩充性可维护性。          //多态的真正作用

https://blog.csdn.net/shihuboke/article/details/79333585

 

虚函数的底层实现原理

看到一个说明很好,转过来:

在C++中,多态是利用虚函数来实现的。比如说,有如下代码:

#include <iostream>
using namespace std;
class Animal
{
public:
	void Cry()
	{
		cout << "Animal cry!" << endl;
	}
};
class Dog :public Animal
{
public:
	void Cry()
	{
		cout << "Wang wang!" << endl;
	}
};
void MakeAnimalCry(Animal& animal)
{
	animal.Cry();
}
int main()
{
	Dog dog;
	dog.Cry();
	MakeAnimalCry(dog);
	return 0;
}

输出如下图:

这里定义了一个Animal类,Dog类继承该类,并覆盖了它的Cry方法。有一个MakeAnimalCry方法,传入了Animal的引用,传入了dog对象,但是输出确是Animal的输出。理想的情况下,用户希望传入的是dog对象,就该调用dog的Cry方法。要实现这种多态行为,需要将Animal::Cry()声明为虚函数。可以通过Animal指针或者Animal引用来访问Animal对象,这种指针或者引用可以指向Animal、Dog、Cat对象,而不需要关心它们具体指向的是哪种对象。修改代码如下:
 

#include <iostream>
using namespace std;
class Animal
{
public:
	virtual void Cry()
	{
		cout << "Animal cry!" << endl;
	}
};
class Dog :public Animal
{
public:
	void Cry()
	{
		cout << "Wang wang!" << endl;
	}
};
class Cat:public Animal
{
public:
	void Cry()
	{
		cout << "Meow meow" << endl;
	}
};
void MakeAnimalCry(Animal& animal)
{
	animal.Cry();
}
int main()
{
	Dog dog;
	Cat cat;
	//dog.Cry();
	MakeAnimalCry(dog);
	MakeAnimalCry(cat);
	return 0;
}

修改后的输出如下:


这就是多态的效果,将派生类对象视为基类对象,并执行派生类的Cry实现。如果基类指针指向的是派生类对象,通过该指针调用运算符delete时,即对于使用new在自由存储区中实例化的派生类对象,如果将其赋给基类指针,并通过该指针调用delete,将不会调用派生类的析构函数。这可能会导致资源未释放、内存泄露等问题,为了避免这种问题,可以将基类的析构函数声明为虚函数。

在上面的程序中,演示了多态的效果,即在函数MakeAnimalCry中,虽然通过Animal引用调用Cry方法,但是实际调用的确是Dog::Cry或者Cat::Cry方法。在编译阶段,编译器并不知道将要传递给该函数的是哪种对象,无法确保在不同的情况下执行不同的Cry方法。应该调用哪个Cry方法显然是在运行阶段决定的。这是使用多态的不可见逻辑实现的,而这种逻辑是编译器在编译阶段提供的。下面详细地说明一下虚函数的底层实现原理。

比如说有下面的基类Base,它声明了N个虚函数:
 

class Base
{
public:
	virtual void Func1()
	{
		//Func1的实现代码 
	}
	virtual void Func2()
	{
		//Func2的实现代码 
	}
	//Func3、Func4等虚函数的实现 
	virtual void FuncN()
	{
		//FuncN的实现代码 
	}
};

下面的Derived类继承了Base类,并且覆盖了除Func2之外的其他所有虚函数,

class Derived:public Base
{
public:
	virtual void Func1()
	{
		//Func2覆盖Base类的Func1代码 
	}
	//除去Func2的其他所有虚函数的实现代码 
	virtual void FuncN()
	{
		//FuncN覆盖Base类的FuncN代码 
	}
};

编译器见到这种继承层次结构后,知道Base定义了虚函数,并且在Derived类中覆盖了这些函数。在这种情况下,编译器将为实现了虚函数的基类和覆盖了虚函数的派生类分别创建一个虚函数表(Virtual Function Table,VFT)。也就是说Base和Derived类都将有自己的虚函数表。实例化这些类的对象时,将创建一个隐藏的指针VFT*,它指向相应的VFT。可将VFT视为一个包含函数指针的静态数组,其中每个指针都指向相应的虚函数。Base类和Derived类的虚函数表如下图所示:


每个虚函数表都由函数指针组成,其中每个指针都指向相应虚函数的实现。在类Derived的虚函数表中,除一个函数指针外,其他所有的函数指针都指向本地的虚函数实现。Derived没有覆盖Base::Func2,因此相应的虚函数指针指向Base类的Func2的实现。这就意味着,当执行下面的代码时,编译器将查找Derived类的VFT,确保调用Base::Func2的实现:


Derived objDerived;
objDerived.Func2();

调用被覆盖的方法时,也是这样:

void DoSomething(Base& objBase)
{
    objBase.Func1();
}
int main()
{
    Derived objDerived;
    DoSomething(objDerived);
}

在这种情况下,虽然将objDerived传递给了objBase,进而被解读成一个Base实例,但该实例的VFT指针仍然指向Derived类的虚函数表,因此通过该VFT执行的是Derived::Func1.虚函数表就是通过上面的方式来实现C++的多态。

要验证虚函数表的存在其实也很简单,可以通过比较同一个类,一个包含虚函数,一个不包含,对比其大小就知道了。
 

#include <iostream>
using namespace std;
class Test 
{
public:
	int a,b;
	void DoSomething()
	{	} 
};
class Base
{
public:
	int a,b;
	virtual void DoSomething()
	{	} 
};
int main()
{
	cout<<"sizeof(Test):"<<sizeof(Test)<<endl;
	cout<<"sizeof(Base):"<<sizeof(Base)<<endl;
	return 0;
}

执行输出如下:


虽然两个类几乎相同,因为Base中的DoSomething方法是一个虚函数,编译器为Base类生成了一个虚函数表,并为其虚函数表指针预留空间,所以Base类占用的内存空间比Test类多了8个字节。

 

转自:https://blog.csdn.net/u011000290/article/details/50498683

 

文章2(直白解析)

 

1. 概述

简单地说,每一个含有虚函数(无论是其本身的,还是继承而来的)的类都至少有一个与之对应的虚函数表,其中存放着该类所有的虚函数对应的函数指针。例:

其中:

  • B的虚函数表中存放着B::foo和B::bar两个函数指针。
  • D的虚函数表中存放的既有继承自B的虚函数B::foo,又有重写(override)了基类虚函数B::bar的D::bar,还有新增的虚函数D::quz。

提示:为了描述方便,本文在探讨对象内存布局时,将忽略内存对齐对布局的影响。

2. 虚函数表构造过程

从编译器的角度来说,B的虚函数表很好构造,D的虚函数表构造过程相对复杂。下面给出了构造D的虚函数表的一种方式(仅供参考):

提示:该过程是由编译器完成的,因此也可以说:虚函数替换过程发生在编译时。

3. 虚函数调用过程

以下面的程序为例:

编译器只知道pb是B*类型的指针,并不知道它指向的具体对象类型 :pb可能指向的是B的对象,也可能指向的是D的对象。

但对于“pb->bar()”,编译时能够确定的是:此处operator->的另一个参数是B::bar(因为pb是B*类型的,编译器认为bar是B::bar),而B::bar和D::bar在各自虚函数表中的偏移位置是相等的。

无论pb指向哪种类型的对象,只要能够确定被调函数在虚函数中的偏移值,待运行时,能够确定具体类型,并能找到相应vptr了,就能找出真正应该调用的函数。

提示:本人曾在“C/C++杂记:深入理解数据成员指针、函数成员指针”一文中提到:虚函数指针中的ptr部分为虚函数表中的偏移值(以字节为单位)加1。

B::bar是一个虚函数指针, 它的ptr部分内容为9,它在B的虚函数表中的偏移值为8(8+1=9)。

当程序执行到“pb->bar()”时,已经能够判断pb指向的具体类型了:

  • 如果pb指向B的对象,可以获取到B对象的vptr,加上偏移值8((char*)vptr + 8),可以找到B::bar。
  • 如果pb指向D的对象,可以获取到D对象的vptr,加上偏移值8((char*)vptr + 8) ,可以找到D::bar。
  • 如果pb指向其它类型对象...同理...

4. 多重继承

当一个类继承多个类,且多个基类都有虚函数时,子类对象中将包含多个虚函数表的指针(即多个vptr),例:

其中:D自身的虚函数与B基类共用了同一个虚函数表,因此也称B为D的主基类(primary base class)。

虚函数替换过程与前面描述类似,只是多了一个虚函数表,多了一次拷贝和替换的过程。

虚函数的调用过程,与前面描述基本类似,区别在于基类指针指向的位置可能不是派生类对象的起始位置,以如下面的程序为例:

5. 菱形继承

本文不讨论菱形继承的情形,个人觉得:菱形继承的复杂度远大于它的使用价值,这也是C++让人又爱又恨的原因之一。

如果想要深入研究,可以参考:Itanium C++ ABI

 

原文:https://www.cnblogs.com/malecrab/p/5572730.html

 

虚析构函数的作用及其原理

https://www.jb51.net/article/159253.htm

总的来说虚析构函数是为了避免内存泄露,而且是当子类中会有指针成员变量时才会使用得到的。

也就说虚析构函数使得在删除指向子类对象基类指针时可以调用子类的析构函数达到释放子类中堆内存的目的,而防止内存泄露的.

我们知道,用C++开发的时候,用来做基类的类的析构函数一般都是虚函数。可是,为什么要这样做呢?下面用一个

小例子来说明:

#include<iostream>
using namespace std;

class ClxBase
{
  public:
    ClxBase() {};
    virtual ~ClxBase() { cout<<"delete ClxBase"<<endl; };

    virtual void DoSomething() { cout << "Do something in class ClxBase!" << endl; };

};

class ClxDerived : public ClxBase
{
  public:
    ClxDerived() {};
    ~ClxDerived() { cout << "Output from the destructor of class ClxDerived!" << endl; };

    void DoSomething() { cout << "Do something in class ClxDerived!" << endl; };

};

int main(int argc, char const* argv[])
{
   ClxBase *pTest = new ClxDerived;
   pTest->DoSomething();
   delete pTest;
  return 0;
}

但是,如果把类ClxBase析构函数前的virtual去掉,那输出结果就是下面的样子了:

没有调动子类的析构函数

也就是说,类ClxDerived的析构函数根本没有被调用!一般情况下类的析构函数里面都是释放内存资源,而析构函数不被调用的话就会造成内存泄漏。我想所有的C++程序员都知道这样的危险性。当然,如果在析构函数中做了其他工作的话,那你的所有努力也都是白费力气。

所以,文章开头的那个问题的答案就是--这样做是为了当一个基类的指针删除一个派生类的对象时,派生类的析构函数会被调用。

当然,并不是要把所有类的析构函数都写成虚函数。因为当类里面有虚函数的时候,编译器会给类添加一个虚函数表,里面来存放虚函数指针,这样就会增加类的存储空间。所以,只有当一个类被用来作为基类的时候,才把析构函数写成虚函数。

执行 Base *b = new Der;之后b的最终形态

面试常见问题

1、C++ 虚函数表是属于类的还是属于对象的

虚函数表属于类,同一个类的多个对象共享同一张虚函数表。https://www.cnblogs.com/cswuyg/archive/2010/08/20/1804069.html

2、为什么构造函数不能为虚函数?

虚函数的调用需要虚函数表指针,而该指针存放在对象的内存空间中;若构造函数声明为虚函数,那么由于对象还未创建,还没有内存空间,更没有虚函数表地址用来调用虚函数——构造函数了。

3、为什么析构函数可以为虚函数,如果不设为虚函数可能会存在什么问题?

    首先析构函数可以为虚函数,而且当要使用基类指针或引用调用子类时,最好将基类的析构函数声明为虚函数,否则可以存在内存泄露的问题。

    举例说明:

    子类B继承自基类A;A *p = new B; delete p;

  1) 此时,如果类A的析构函数不是虚函数,那么delete p;将会仅仅调用A的析构函数,只释放了B对象中的A部分,而派生出的新的部分未释放掉。

     2) 如果类A的析构函数是虚函数,delete p; 将会先调用B的析构函数,再调用A的析构函数,释放B对象的所有空间。

    补充: B *p = new B; delete p;时也是先调用B的析构函数,再调用A的析构函数。

4、

1)每个类都有虚指针和虚表;
2)如果不是虚继承,那么子类将父类的虚指针继承下来,并指向自身的虚表(发生在对象构造时)。有多少个虚函数,虚表里面的项就会有多少。多重继承时,可能存在多个的基类虚表与虚指针;
3)如果是虚继承,那么子类会有两份虚指针,一份指向自己的虚表,另一份指向虚基表,多重继承时虚基表与虚基表指针有且只有一份。
原文链接:https://blog.csdn.net/xiaxzhou/article/details/76576516

5、虚析构函数的作用

虚析构函数使得在删除指向子类对象基类指针时可以调用子类的析构函数达到释放子类中堆内存的目的,而防止内存泄露的.

 

多重继承会有多个虚函数表,几重继承,就会有几个虚函数表。这些表按照派生的顺序依次排列,如果子类改写了父类的虚函数,那么就会用子类自己的虚函数覆盖虚函数表的相应的位置,如果子类有新的虚函数,那么就添加到第一个虚函数表的末尾。

6、虚函数表存放在哪里

虚函数表vtable在Linux/Unix中存放在可执行文件的只读数据段中(rodata),这与微软的编译器将虚函数表存放在常量段(CONST    SEGMENT)存在一些差别。(https://www.cnblogs.com/laiqun/p/5887372.html

(存放在全局数据区)

1.虚函数表是全局共享的元素,即全局仅有一个.

2.虚函数表类似一个数组,类对象中存储vptr指针,指向虚函数表.即虚函数表不是函数,不是程序代码,不肯能存储在代码段.

3.虚函数表存储虚函数的地址,即虚函数表的元素是指向类成员函数的指针,而类中虚函数的个数在编译时期可以确定,即虚函数表的大小可以确定,即大小是在编译时期确定的,不必动态分配内存空间存储虚函数表,所以不再堆中.

根据以上特征,虚函数表类似于类中静态成员变量.静态成员变量也是全局共享,大小确定.

 

内存分布

 

假设有一个基类ClassA,一个继承了该基类的派生类ClassB,并且基类中有虚函数,派生类实现了基类的虚函数。
我们在代码中运用多态这个特性时,通常以两种方式起手:
(1) ClassA *a = new ClassB();
(2) ClassB b; ClassA *a = &b;

以上两种方式都是用基类指针去指向一个派生类实例,区别在于第1个用了new关键字而分配在堆上,第2个分配在栈上。

在这里插入图片描述

二、类的虚函数表与类实例的虚函数指针


首先不考虑继承的情况。

如果一个类中有虚函数,那么该类就有一个虚函数表。
这个虚函数表是属于类的,所有该类的实例化对象中都会有一个虚函数表指针去指向该类的虚函数表。
第一部分的图中我们也能看到,一个类的实例要么在堆上,要么在栈上。也就是说一个类可以有很多很多个实例。但是!一个类只能有一个虚函数表。在编译时,一个类的虚函数表就确定了,这也是为什么它放在了只读数据段中。

在这里插入图片描述

继承

只要基类有虚函数,子类不论实现或没实现,都有虚函数表。


(1) ClassA是基类, 有普通函数: func1() func2() 。虚函数: vfunc1() vfunc2() ~ClassA()
(2) ClassB继承ClassA, 有普通函数: func1()。虚函数: vfunc1() ~ClassB()
(3) ClassC继承ClassB, 有普通函数: func2()。虚函数: vfunc2() ~ClassB()

基类的虚函数表和子类的虚函数表不是同一个表。下图是基类实例与多态情形下,数据逻辑结构。注意,虚函数表是在编译时确定的,属于类而不属于某个具体的实例。虚函数在代码段,仅有一份。

 ClassB继承于ClassA,其虚函数表是在ClassA虚函数表的基础上有所改动的,变化的仅仅是在子类中重写的虚函数。如果子类没有重写任何父类虚函数,那么子类的虚函数表和父类的虚函数表在内容上是一致的。

ClassB,ClassB继承ClassA

这是一个多次单继承的情况。

在这里插入图片描述

四、多继承下的虚函数表 (同时继承多个基类)

ClassA1是第一个基类,拥有普通函数func1(),虚函数vfunc1() vfunc2()。
ClassA2是第二个基类,拥有普通函数func1(),虚函数vfunc1() vfunc2(),vfunc4()。
ClassC依次继承ClassA1、ClassA2。普通函数func1(),虚函数vfunc1() vfunc2() vfunc3()。

 在这里插入图片描述

在多继承情况下,有多少个基类就有多少个虚函数表指针前提是基类要有虚函数才算上这个基类
如图,虚函数表指针01指向的虚函数表是以ClassA1的虚函数表为基础的,子类的ClassC::vfunc1(),和vfunc2()的函数指针覆盖了虚函数表01中的虚函数指针01的位置、02位置。当子类有多出来的虚函数时,添加在第一个虚函数表中

当有多个虚函数表时,虚函数表的结尾是0代表没有下一个虚函数表。" * "号位置在不同操作系统中实现不同,代表有下一个虚函数表。
注意:
1.子类虚函数会覆盖每一个父类的每一个同名虚函数。
2.父类中没有的虚函数而子类有,填入第一个虚函数表中,且用父类指针是不能调用。
3.父类中有的虚函数而子类没有,则不覆盖。仅子类和该父类指针能调用。

https://blog.csdn.net/rusbme/article/details/97619675

 

posted on 2022-10-04 01:28  bdy  阅读(112)  评论(0编辑  收藏  举报

导航