【C++】继承多态(深层详解)

在之前的CPP大作业中,为了应付期末(是这样的)关于继承和多态部分的内容只是草草过了一遍,并没有深挖背后的实现原理,以及使用的时候一些注意事项。

本篇博客是对类和对象继承多态部分的深化!

0.什么是封装

面向对象的三大特性:封装、继承、多态

面向对象还有 反射(C++中没有)、抽象 等特性

封装:

  • 不想让用户在类外访问的成员设计成私有,允许访问的设计成公有。相比C语言没有类和访问管理相比,封装能提高设计的安全性和完整性。
  • C语言中,如果设计的不好,不规范编写的代码容易出现错误访问struct结构体中的成员。
  • 同时,C++中的迭代器设计,也能给一批容器提供基本相同的访问接口,让用户能使用相同的代码,在不暴露容器底层结构的前提下访问容器中的值。
  • 暴露底层结构会提高容器的使用成本,代码也比较复杂,不同数据结构也不一样。
  • stack/queue/prioritiy_queue的适配器形式,能弄出来我们想要的东西,这也算是一种封装

1.继承派生关系

继承是提高代码复用性的一个重要手段。它允许我们在保持基类原有属性的基础上,对其进行一定的扩张,增加不同的功能以应对实际情况。

比如对于一个人来说,其都会有性别、年龄、身分证号等等信息。但不同职业就还会包含不同职业的特殊信息。这时候就可以通过继承,在基础一个公民的基本信息的同时,再去处理每一个职业的独立信息。这也实现了类在一定程度上的复用,减少了代码复杂性。

与其相似的增加代码复用性的语法,还有模板

1.1 基本用法

继承和派生是父与子的关系,其中子类拥有父类成员的同时,还会拥有自己的成员

  • 继承是一个特殊的语法,用于多个类有公共部分的时候
  • 父类:基类
  • 子类:派生类
//举例:网站的公共部分
class ART {
public:
	void header()//所有网站页面都有这个
	{
		cout << "文章" << "归档" << "友链" << endl;
	}

	void footer()//所有网站页面都有这个
	{
		cout << "关于我们" << endl;
		cout << "网站访问量" << endl;
	}

	void func()//文章页面
	{
		cout << "文章" << endl;
	}
};
class LINK {
public:
	void header()//所有网站页面都有这个
	{
		cout << "文章" << "归档" << "友链" << endl;
	}

	void footer()//所有网站页面都有这个
	{
		cout << "关于我们 " << " 网站访问量" <<endl;
	}

	void func()//友链页面
	{
		cout << "友链" << endl;
	}
};

在上面的情况中,ART和LINK类中都有网站的公共部分,这时候就出现了代码的重复。继承的出现就是用于解决这个问题的

//下面使用继承的方式来写,WEB类是网站的公共部分
class WEB {
public:
	void header()//所有网站页面都有这个
	{
		cout << "文章" << "归档" << "友链" << endl;
	}

	void footer()//所有网站页面都有这个
	{
		cout << "关于我们" << endl;
		cout << "网站访问量" << endl;
	}
};

//ART、LINK是两个子类,继承了WEB的公共部分
//这样就减少了代码量
class ART : public WEB{
public:
	void func()//文章页面
	{
		cout << "文章" << endl;
	}
};

class LINK : public WEB {
public:
	void func()//友链页面
	{
		cout << "友链" << endl;
	}
};

测试可以发现,ART和LINK作为派生类,在继承了基类WEB的成员的基础上,还拥有了它们独特的单独成员

image-20220527202138439

同一个类可以同时继承多个基类

class C : public A,public B{
//.....
};

1.2 权限问题

继承有3中类型:public、private、protected。这里会显示出类中protected权限和private权限的区别

class A{
public:
	int a;
protected:
	int b;
private:
	int c;
};

当我们分别用上面三种方式对类A进行继承的时候,得到的结果是不同的

  • 用什么继承方式,派生类中继承的基类成员就变成什么类型
  • 不管用什么继承方式,都无法访问基类中的私有成员
  • 可以使用 Min(成员在基类中的访问限定符,继承方式) 来计算某一个成员在子类中的访问限定符是什么。

image-20220527203404310

关于权限问题,我们还需要了解下面几点:

  • 基类的私有成员在派生类中不可见,但实际上它也被继承过去了。但是编译器和语法的限制让我们无法访问。
  • 保护限定符由此出现,如果在基类中的成员不想被外界直接访问,但又需要子类中访问,则可以定义为保护
  • class默认继承方式为私有,struct默认继承方式为保护
  • 实际中我们一般使用public继承,保护/私有方式不利于维护和拓展

1.3 同名问题(作用域)

在继承体系中,基类和子类都有自己独立的作用域

当基类和派生类中出现同名成员函数或者同名成员变量时,会出现冲突。这时候编译器会做一定的处理:直接访问变量名和函数名的时候,优先访问派生类自己的成员,而屏蔽掉基类的。

这种情况被称之为隐藏

  • 函数名相同构成隐藏(并非重载)
  • 成员变量名相同构成隐藏

实际操作中,强烈不建议写同名的成员,不管是成员函数还是成员变量

//继承同名成员的处理
//	普通的同名成员
class DAD1 {
public:
	DAD1()
	{
		_a = 100;
	}

	void func()//同名函数
	{
		cout << "DAD func" << endl;
	}
	void func(int i)
	{
		cout << "DAD func int: " << i << endl;
	}

	int _a;//基类中的该变量
};

class SON1 : public DAD1{
public:
	SON1()
	{
		_a = 20;
	}
	void Print()
	{
		cout <<"SON: " << _a << endl;//优先访问派生类的_a
		cout <<"DAD: " << DAD1::_a << endl;//访问基类的_a
	}

	void func()//同名函数
	{
		cout << "SON func" << endl;
	}

	int _a;//派生类的同名变量
};

下方的调用测试能看出结果;

image-20220527204445283

class A
{
public:
    void func()
    {
        cout << "A::func" << endl;
    }
};

class B : public A
{
public:
    void func(int a)
    {
        cout << "B::func " << a << endl;
    }
};

在这个栗子里面,A::func B::func两个函数之间是什么关系?

答案:二者是隐藏的关系,并非函数重载!函数重载要求两个函数是处于同一个作用域,才构成重载!

这点通过编译测试也能看出来

int main()
{
    B bt;
    bt.func();
    bt.func(1);
    
    return 0;
}

当我们使用如上函数进行编译的时候,编译器会报错找不到 B::func(),因为B的作用域中只有func(int a)这个需要传递参数的函数。如果A::func B::func的关系是函数重载的话,那这里应该可以直接调用才对。

test.cpp: In function ‘int main()’:
test.cpp:24:13: error: no matching function for call to ‘B::func()’
     bt.func();
             ^
test.cpp:15:10: note: candidate: ‘void B::func(int)’
     void func(int a)
          ^~~~
test.cpp:15:10: note:   candidate expects 1 argument, 0 provided

只有指定父类作用域才能调用到A::func

B bt;
bt.A::func();

1.4 静态成员

在继承体系中,基类的静态成员有且只能有一个。即所有的子类和他们的对象,都是只有那一个静态成员的。我们可以用这个特性来对继承派生中出现的对象进行计数。

class Person
{
public :
	Person () {++ _count ;}
protected :
	string _name ; // 姓名
public :
	static int _count; // 统计人的个数。
};
int Person :: _count = 0;

如果出现了与静态成员同名,访问方法就有所变化

//访问同名的静态成员
class DAD2{
public:
	static int D_a;

	static void Test1()
	{
		cout << "DAD2 Test1 " << endl;
	}
	static void Test1(int n)
	{
		cout << "DAD2 Test1(int)  " << n << endl;
	}
};

int DAD2::D_a = 100;

class SON2 : public DAD2 {
public:
	static int D_a;

	static void Test1()
	{
		cout << "SON2 Test1 " << endl;
	}
};

int SON2::D_a = 200;

image-20220527204928480

1.5 友元

友元关系不会被继承,基类的友元函数无法访问派生类的私有/保护成员

image-20220722200127880

1.6 默认成员函数

我们知道,C++类和对象中有6个默认成员函数

image-20220519181052014

在派生类中,这些默认成员函数有新的使用方法

  • 派生类的构造函数必须在初始化列表中调用基类的构造函数,初始化父类的一部分成员。如果你没有写,编译器会自动调用默认构造函数(先调用基类,在调用子类)
  • 派生类的拷贝构造同上,必须显式调用基类拷贝构造函数(将子类对象传过去,相当于将子类对象中的父类部分传入父类拷贝构造函数。这部分是编译器自动帮我们实现的切片操作)
  • 派生类的赋值重载也需要调用基类赋值重载完成操作
  • 派生类的析构函数编译器会自动调用基类,先析构派生类,再析构基类成员(符合栈后进先出原则)
  • 在基类析构函数不是虚析构的时候,子类析构和父类析构构成隐藏关系;
  • 因为多态的需要,析构函数会被统一命名为destructor(),构造函数并不会出现重命名。

在下方栗子中,当我们写B类的深拷贝的时候,可以通过指定类作用域的方式来调用A父类的operator=重载(这里必须要指定类的作用域,否则调用的还是B类自己的operator=重载,相当于无效的递归调用,最终会因为死循环导致栈溢出)

因为我们是将子类赋值给父类,所以都是编译器自动帮我们进行的切片操作。

class A
{
public:
    A(int a)
        : _numa(a)
    {
    }

    A(const A &a)
    {
        _numa = a._numa;
    }

    A &operator=(const A &a)
    {
        if (this != &a)
        {
            _numa = a._numa;
        }

        return *this;
    }

    void func()
    {
        cout << "A::func" << endl;
    }

    ~A()
    {
        cout << "~A()" << endl;
        _numa = 0;
    }

private:
    int _numa;
};
class B : public A
{
public:
    B(int a = 1, int b = 1)
        : A(a), _numb(b)
    {
    }

    B(const B &b)
        : A(b)
    {
        _numb = b._numb;
    }

    B &operator=(const B &b)
    {
        if (this != &b)
        {
            A::operator=(b); // 指定作用域调用A类的赋值重载
            _numb = b._numb;
        }

        return *this;
    }

    void func(int a)
    {
        cout << "B::func " << a << endl;
    }

    ~B()
    {
        cout << "~B()" << endl;
        //A::~A();//显示调用会报错
        _numb = 0;
    }

private:
    int _numb;
};

而在析构函数中,子类的析构调用完毕后,会自动调用父类的析构,以保证先析构子类,在析构父类。

所以并不需要我们显式调用;显示调用父类析构的时候会报错

test.cpp: In destructor ‘B::~B()’:
test.cpp:74:15: error: no matching function for call to ‘B::~B()’
         A::~A();//显示调用会报错
               ^
test.cpp:32:5: note: candidate: ‘A::~A()’
     ~A()
     ^
test.cpp:32:5: note:   candidate expects 1 argument, 0 provided

构造和析构顺序

子类继承父类后,当创建子类对象,也会调用父类的构造函数

  • 继承中先调用父类构造函数
  • 再调用子类构造函数

析构顺序与构造相反

显示调用父类构造函数

如何显示调用父类的构造函数呢,下面是一个代码示例

#include <iostream>
using namespace std;

class Person
{
public:
    Person(string name, string sex, int age)
    {
        _name = name;
        _sex = sex;
        _age = age;
    }
protected:
    string _name;
    string _sex;
    int _age;
};

class Student : public Person
{
public:
    // 在初始化列表中调用父类的构造函数
    Student(string name,string sex,int age,int no)
        :Person(name,sex,age),
        _No(no)
    {}

    int _No;//学号
};


int  main()
{
    Student sobj("李华","男",18,1000);
}

运行结果如下,可见子类正常调用了基类构造函数并进行了初始化

image-20220723184035493

这里也涉及到之前学过的一个小知识:在CPP中,类中成员的初始化顺序是依照声明的顺序来初始化的!而基类中的成员声明早于子类成员,自然也是先初始化基类的。

1.7 基类和派生类赋值问题

派生类成员可以赋值给基类的 对象/指针/引用。一般我们把这种情况称为切片,形象地表示把派生类中父类那部分切来赋值过去。

但是!反过来是不行的哦,你不能把基类对象赋值给派生类对象

基类的指针/引用可以用强制类型转换给派生类的指针/引用。但是这样不够安全,除非基类的指针指向的是对应的派生类。

如果基类是多态类型,可以使用RTTI(运行时类型识别)的dynamic_cast来进行安全处理

#include <iostream>
using namespace std;

class Person
{
protected :
    string _name;
    string _sex; 
    int _age;
};

class Student : public Person
{
public :
    int _No ;//学号
};


int  main()
{
    Student sobj ;
    // 1.子类对象可以赋值给父类对象/指针/引用
    Person pobj = sobj ;
    Person* p1 = &sobj;
    Person& p2 = sobj;
       
    // 2.基类对象不能赋值给派生类对象
    //sobj = pobj;//err
    
    // 3.基类的指针可以通过强制类型转换赋值给派生类的指针
    p1 = &sobj;//子类对象给基类指针
    Student* ps1 = (Student*)p1; //基类指针指向子类,正常转换
    ps1->_No = 15; 
    cout<<ps1->_No <<endl;

    p1 = &pobj;//基类对象给基类指针
    Student* ps2 = (Student*)p1; //转换虽然可以,但是会存在越界访问
    ps2->_No = 10;
    cout<<ps2->_No <<endl;

    return 0;
}

image-20220723182953327

关于最后提到的越界访问问题,我们知道,指针变量的大小都是相同的,其指针类型的区别主要在访问能力的不同。比如char*指针解引用只能访问1个字节,int*指针解引用可以访问4个字节,以此类推,Student*指针解引用可以访问sizeof(Student)个字节的空间。

而子类对象的大小都是大于等于基类对象的大小的。这就导致子类指针访问基类对象内容时,一次解引用访问的空间超长,造成了越界访问


实际上,当我们切片讲子类对象赋值给父类对象的时候,编译器会进行切片操作,即新的父类对象中的内容只会包含父类的成员。子类多出去的那一部分成员会被剔除。

这一点我们可以在VS的调试中证实

image-20220723183948793

因为基类的成员变量被设置成了保护,所以我们不能直接在外部进行修改。需要显式调用基类的构造函数来初始化基类的成员。

1.8 虚继承(菱形继承问题)

有的时候,继承会出现下面这种情况:一个子类继承了两个基类,而这两个基类又同时是一个基类的派生类

未命名_副本

这时候,D里面就会有两份A的内容,相当于两份公共部分。这是我们不想看到的,因为会造成空间浪费。而且直接访问的时候,编译器会报错“对变量X的访问不明确”


比如:intel和amd联合推出的NUC小电脑中,有一款CPU是他们合作开发的

如何解决同时继承AMD和INTEL的问题?

  • 这时候会出现两个同名变量,一个是AMD里面有的,另外一个是INTEL里面有的
    因为他们是从CPU里面继承来的。
  • 虽然我们可以指定作用域来分别修改和访问。但是实际上这个公共部分就出现了浪费(比如是网站的公共部分,多给你一份没有啥意义)

image-20220527205808893

和前面说道的同名问题一样,我们可以指定作用域来访问特定的变量,但是这样是治标不治本的方法,并没有解决空间浪费的问题。

	//解决方法1(治表不治本)
	//用类域来修改和访问
	cout << "intel: " << n1.INTEL::_Structure << endl;
	cout << "amd: "   << n1.AMD::_Structure << endl;

这就需要我们使用虚继承来操作:给B和C对A的继承加上virtural关键字(对公共基类的继承添加上虚继承关键字)

class CPU {
public:
	CPU()
		:_Structure("x86")
	{ }

	char _Structure[100];
};

class INTEL : virtual public CPU {
public:
	INTEL()
		:i_Brand("intel")
	{}

	char i_Brand[10];
};

class AMD : virtual public CPU {
public:
	AMD()
		:a_Brand("amd")
	{}

	char a_Brand[10];
};

//同时继承AMD和INTEL
//相当于有两个_Structure变量
//实际上我们只需要一个就够了
class NUC :public AMD, public INTEL {

};
void test1()
{
	NUC n1;
	//对“_Structure”的访问不明确
	//cout << n1._Structure << endl;//err

	//解决方法1(治表不治本)
	//用类域来修改和访问
	cout << "intel: " << n1.INTEL::_Structure << endl;
	cout << "amd: "   << n1.AMD::_Structure << endl;

	//解决方法2,在AMD和INTEL对CPU的继承上加virtual
	cout << "n1访问:" << n1._Structure << endl;
	//现在就没有报错了
	//因为这时候AMD和INTEL中的_Structure都会指向同一个地址
	cout << "&intel: " << &(n1.INTEL::_Structure) << endl;
	cout << "&amd:   " << &(n1.AMD::_Structure) << endl;

	//修改INTEL中的_Structure,也会连代修改AMD中的_Structure
}

这时候直接访问变量就不会报错了。因为这时候,B和C中的该变量指向了同一个地址,修改操作会同步。

image-20220527205708237

继承模型

普通菱形继承

下图中的继承模型是一个简单的菱形继承,我们能看到d中关于两个公共A._a的位置是不相同的;

image-20230731104907516

在cpu继承模型中也是如此,amd和intel继承的cpu类中X86字符串的地址是不相同的

image-20220723202515227

这里因为内存对齐的问题,我们无法看清楚它的全貌。

但通过这里的继承模型,可以看出来在菱形继承问题中,不使用虚继承会造成两个CPU对象的多次继承,导致访问不明确的特性。

虚继承模型

那换成虚继承之后的模型是什么样子的呢?

先用d本身访问d._a,可以看到红色箭头所指区域的内存被初始化为0

image-20230731105334008

再指定作用域B::进行访问,会发现其修改的依旧是这个地址的数据!

image-20230731105446223

用作用域C::来访问的结果也是如此,依旧修改的是相同内存位置的数据

image-20230731105535444

由此可见,菱形继承了之后,_a变量的地址就被确定为一个地址了。所有作用域中的_a指向的都是这个公共地址,修改的都是这个公共地址的值,也就不会出现二义性问题!

最终运行完毕,内存窗口如图,A被丢到了最后面

image-20230731105858019

再说回上方提到的cpu继承模型,进入调试窗口,可以看到这里分别分为了3个模块,保存了不同基类的成员。而它们之中的_Stucture成员只有一个(指向"x86"字符串的地址是相同的),所以就不会出现异义;

image-20220723190101443

此时我们会发现,不管是上方ABCD的继承模型,还是这里的CPU继承模型,内存都出现了一定的空置;那这里空着的空间是用来做什么的呢?也是内存对齐吗?非也

虚基表

  • 通过在虚基表中存放虚基类的偏移量,可以解决菱形继承产生的二义性问题。

下图能帮你了解这个虚继承模型中,内存的区块是怎么划分的;可以看到B和C这两个父类都会有一个虚基表的指针,指向虚基表的地址。地址中存放的是B和C对象跟A对象地址的偏移量。

B和C两个对象都有自己独立的虚基表地址,而不是共用一个,是为了方便切片时候的查询。

这样,虚基表就帮我们避免了在访问菱形继承模型时出现异义的问题。不过,因为多了一层间接的偏移量查询,访问公共基类的成员的效率会有所降低。

image-20230731111119744

cpu的继承模型也是如此,在amd和intel这两个字符串存储位置上方,存放的就是一个虚基表的地址。而虚基表的这个地址之后紧跟着的就是一个当前对象跟基类对象的偏移量的数据;

同时也能总结出一个规律,虚基表的地址中的数据以全0开头,第二个(准确来说应该是偏移4个字节)的地址才是基类偏移量的数据

image-20230731111050252

这样做就有一个好处,即便我们使用不同的基类指针(比如amd或者intel)来指向nuc的子类对象;

NUC n1;
AMD* amd = &n1;
INTEL* itl = &n1;

这里的赋值需要对NUC对象进行切片,要获取到AMD/INTEL这两个父类的成员的同时,还需要获取到公共基类CPU成员的位置;

此时因为存在虚基表,它们都可以通过各自虚基表里面存放的偏移量,来计算公共基类CPU成员的位置,从而获取到了CPU类的成员。

另外,当AMD和INTEL采用虚继承来继承CPU的时候,他们类内就已经会有虚基表了。跟他们自己是否存在子类无关!这样是为了保证访问时候的统一性。比如如下代码

NUC n;
AMD a;
AMD* amd1 = &n;
AMD* amd2 = &a;

对于编译器而言,其并不知道AMD*指针到底指向的是本类还是子类,而AMD对象本身也有虚基表,就能保证不管是本类还是子类,都能通过同样的方式(通过虚基表查询偏移量)来找到虚继承的父类CPU的地址,从而访问到父类对象成员。

使用虚基表还可以让开发者灵活控制编译器对内存区块划分的优化。比如上面的两个栗子中,在VS2019里面,公共基类一般都是处于最下方的。

但如果我想设计公共基类放在最上方,也可以通过虚基表中的偏移量来实现。

而如果cpp强制规定公共基类必须要在普通基类的下方,而不使用虚基表来存放基类偏移量,那就限制了编译器的开发,也不方便实际的查找

C++STD中的IO流就使用了菱形继承来进行设计。

但对于我们而言,由于菱形继承实在过于复杂,一般不建议你这么“作死”;

1.9 继承和组合

  • 继承:上述所说。每一个派生类对象都是一个基类对象is-a
  • 组合:在一个类里面包含另外一个类的对象成员。每一个B对象中都包含了一个A has-a;比如我们在自己的类中使用std::string,此时我们自己的类和std::string的关系就是组合

组合是黑盒复用,继承是百盒复用(子类能知道父类的细节,称为白盒)

实际情况中,建议优先选择组合,而不是继承。

  • 继承增加了代码的复用性,但是在一定程度上破坏了基类的封装性。派生类和基类的关联很强,耦合度高。
  • 对象组合是另外一种复用的选择,这时候,对象A的内部结构是不得而知的。这样就减小了对象之间的关联性,耦合度低,保护了封装,更方便代码的维护

不过,继承还有另外一种用途,那就是多态。我们下边会讲解的!

在软件设计中,追求高内聚,低耦合,不同模块之间的关联度应该竟可能的低。在设计类间关系,和不同功能模块的时候,需要考虑具体场景来进行继承和组合的选用。

比如A继承B,此时两个类就被强关联在一起了,耦合度相对较高。对父类A的任何修改i,都会影响达到B,甚至导致B无法正常运行。

总结

多继承所导致的菱形继承问题,在一定程度上让C++的语法变得复杂了。比如java是没有多继承的。在实际使用情况中,不建议使用多继承。

QQ图片20220424132540

2.多态

  • 静态多态:函数重载
  • 动态多态:派生类和虚函数组成的多态

多态通俗地讲就是多种形态,当不同的对象去完成相同的事情的时候,会产生不同的状态。

比如买票这个行为,会衍生出全票、儿童票、学生票等等类型。不同身份的人过来买票,应该调用不同的处理流程。使用多态,就能将这些不同流程的相同类型函数(都是在买票)给拟合成不同子类对象中的同名函数;

注意,多态只是实现这个场景的方式之一;你当然可以封装毫无相干的类,或者是使用函数重载,多个函数,判断语句来解决此类问题。

2.1 虚函数

2.1.1 基本使用以及动态多态

虚函数,并不代表这个函数是虚无的。而表示这个函数在一定情况下会被替换(就好比继承中的虚继承问题)。要实现动态多态,就需要借助虚函数来实现。

这里顺便提一嘴函数的三种关系:重载、隐藏(继承中同名问题)、覆盖(多态中虚函数被子类覆盖)

虚函数需要满足两个条件

  • 函数名、参数、返回值都相同
  • 父类中该函数使用了virtual关键字来修饰此函数

而调用的时候,必须是父类指针/引用指向子类的对象的时候,才会调用子类重写后的虚函数(如果没有重写该函数,则调用的依旧是父类的函数)


以下面这个动物说话的代码为例

#include <iostream>
using namespace std;

class Animal {
public:
	//void Talk()
	virtual void Talk()//虚函数
	{
		cout << "Animal is talking" << endl;
	}
};

class CAT : public Animal{
public:
	void Talk()//同名函数
	{
		cout << "CAT is talking" << endl;
	}
};

class DOG : public Animal {
public:
	void Talk()//同名函数
	{
		cout << "DOG is talking" << endl;
	}
};
//基类中不使用虚函数时,该函数的内容已确定
//不管传参什么类,都会调用Animal自己的Talk函数
//加上虚函数virtual后,会调用CAT和DOG的Talk函数
void MakeTalk(Animal& it) {
	it.Talk();//调用对应的Talk函数
}

当基类Animal中的Talk函数没有用virtual修饰时,不管给这个函数传参什么类的对象,它都会调用Animal自己的Talk函数

image-20220527213937251

当我们用虚函数进行修饰后,就会调用派生类CAT和DOG的Talk函数,这就实现了一个简单的动态多态。

image-20220527214022282

对于虚函数,有几点需要注意:

  • 当基类的指针或引用指向派生类的对象时,就会触发动态多态,派生类中的同名函数会覆写基类中的虚函数
  • 不能定义静态虚函数——因为静态函数是属于整个类的,而不是属于某一个对象
  • 不能定义虚构造函数——总不能用派生类的构造来覆写基类的构造吧?这不符合继承中对构造函数的要求
  • 析构函数可以是虚函数

2.1.2 虚析构函数

有的时候,我们需要析构一个子类对象时,往往会给基类的析构函数加上virtual修饰,这样只要传派生类的对象给基类的指针/引用,就可以直接调用派生类对应的析构函数,完成不同的析构操作。

而不是都呆呆的调用基类的析构函数——那样就会产生内存泄漏,因为子类部分的成员并没有被析构!

这也是为何,类中析构函数会被统一重命名为destructor(),便是为了让父类和子类的析构函数在设置了virtual关键字后,函数同名,可以构成多态!

所以,如果一个类是基类,最好将析构设置成虚析构。

测试
#include <iostream>
using namespace std;

class Queue
{
public:
    Queue()
        : _a(new int[10])
    {
    }
    ~Queue()
    {
        cout << "~Queue" << endl;
        delete[] _a;
    }

private:
    int *_a;
};

class MyStack : public Queue
{
public:
    MyStack(int capa)
        : _a1(new int[capa])
    {
    }
    ~MyStack()
    {
        cout << "~MyStack" << endl;
        delete[] _a1;
    }

private:
    int *_a1;
};

int main()
{
    Queue *q1 = new Queue();
    delete q1; // 调用父类的析构函数

    Queue *q2 = new MyStack(4); // 父类指针指向子类
    delete q2;                  // 如果加了虚析构,就会调用子类的析构函数

    return 0;
}

其中我们将子类MyStack的指针赋值给了父类。运行这个函数,会发现父类的析构函数被正常调用了两次,但子类的析构函数并没有被调用。

这就导致子类对象中的int *_a1;指针申请的内存没有被正常释放,从而导致内存泄露;

    virtual ~Queue()
    {
        cout << "~Queue" << endl;
        delete[] _a;
    }

当我们给父类的析构添加上virtual关键字后,再次运行这个代码

~Queue
~MyStack
~Queue

此时父类和子类的析构都被成功调用了!

为了更好的观察析构顺序,给两个类都新增了一个成员变量作为标记位,在析构的时候打印。

class Queue
{
public:
    Queue(int no)
        : _a(new int[10]), _no(no)
    {
    }
    virtual ~Queue()
    {
        cout << "~Queue " << _no << endl;
        delete[] _a;
    }

private:
    int _no;
    int *_a;
};

class MyStack : public Queue
{
public:
    MyStack(int capa, int no)
        : _a1(new int[capa]), Queue(no), _nos(no)
    {
    }
    ~MyStack()
    {
        cout << "~MyStack " << _nos << endl;
        delete[] _a1;
    }

private:
    int *_a1;
    int _nos;
};

int main()
{
    Queue *q1 = new Queue(1);
    delete q1; // 调用父类的析构函数

    Queue *q2 = new MyStack(4, 2); // 父类指针指向子类
    delete q2;                     // 如果加了虚析构,就会调用子类的析构函数

    return 0;
}

运行结果如下,可以看到,第二个指针q2delete释放的时候,先调用了子类的析构函数,后调用了父类的析构函数。

~Queue 1
~MyStack 2
~Queue 2

这样就不会出现内存泄露了!

2.1.3 子类不重写

在这个继承模型中,子类Stu并没有重写父类函数,运行的时候,调用的都是父类的成员函数。这是一个普通的继承调用。

class Person
{
public:
    virtual A *f()
    {
        cout << "virtual A* f()" << endl;
        return nullptr;
    }
};

class Stu : public Person
{
public:
    int _a;
};

int main()
{
    Stu s;
    Person p;

    Person* ptr = &p;
    ptr->f();

    ptr = &s;
    ptr->f();

    return 0;
}

输出结果

virtual A* f()
virtual A* f()

2.1.4 协变

虚函数重写的时候,对返回值还会有一个例外的要求:协变

前面提到,虚函数构成重写,必须要保证返回值相同。但协变的存在就新增了一个规定,我们的返回值并不一定要严格相同。

父类甲中函数返回值是某个父类乙的指针/引用时,子类丙虚函数重写的时候,返回值可以是子类丁/父类乙指针/引用(对应父子关系即可,在这里,甲丙/乙丁是两对父子)

// B类继承了A类
class Person
{
public:
    virtual A *f()
    {
        cout << "virtual A* f()" << endl;
        return nullptr;
    }
};

class Stu : public Person
{
public:
    virtual B *f()
    {
        cout << "virtual B* f()" << endl;
        return nullptr;
    }
};

int main()
{
    Stu s;
    Person p;

    Person* ptr = &p;
    ptr->f();

    ptr = &s;
    ptr->f();

    return 0;
}

上面的代码中,Stu子类对父类虚函数的重写,返回值就是子类的指针;编译通过并运行,结果如下。可见的确构成了多态。

$ g++ test.cpp -o test 
$ ./test
virtual A* f()
virtual B* f()

如果带上引用,效果也是一样的

class Person
{
public:
    virtual A *f()
    {
        cout << "virtual A* f()" << endl;
        return nullptr;
    }

    virtual A &func_a(A &a, B &b)
    {
        cout << "virtual A &func_a(A &a, B &b)" << endl;
        return a;
    }
};

class Stu : public Person
{
public:
    virtual B *f()
    {
        cout << "virtual B* f()" << endl;
        return nullptr;
    }

    virtual B &func_a(A &a, B &b)
    {
        cout << "virtual B &func_a(A &a, B &b)" << endl;
        return b;
    }
};

int main()
{
    Stu s;
    Person p;
    B test_b;

    Person *ptr = &p;
    ptr->f();
    ptr->func_a(test_b,test_b);

    ptr = &s;
    ptr->f();
    ptr->func_a(test_b,test_b);

    return 0;
}

这里我给func_a设计了两个参数,保证两个函数参数相同;需要注意子类的引用没办法赋值父类的对象。只有父类的引用才能赋值子类对象。(权限只能缩小不能扩大)

virtual A* f()
virtual A &func_a(A &a, B &b)
virtual B* f()
virtual B &func_a(A &a, B &b)
~B()
~A()

下面的这种情况就是不允许的!两个函数的参数不同,虽然满足协变的条件,但不满足虚函数重写的规定;可以看到运行后,两次调用都是父类的func_a函数;

image-20230731162200428

2.1.5 重写不带virtual

子类重写该函数的时候,可以不带virtual关键字。即便不带,依旧保有虚函数特性,可以被二次重写。这是因为子类继承父类的时候,先继承了虚函数的声明(相当于从父类中继承了virtual关键字)

记住这点,后面要考

虽然这个关键字可以被省略,但不建议你省略它。这个关键字能告诉其他开发者,这个函数是一个重写了父类的虚函数(也有可能是一个即将被重写的虚函数)。相当于一个提示。

class Person
{
public:
    virtual A *f()
    {
        cout << "virtual A* f()" << endl;
        return nullptr;
    }

    virtual A &func_a(A &a, B &b)
    {
        cout << "virtual A &func_a(A &a, B &b)" << endl;
        return a;
    }
};

class Stu : public Person
{
public:
    B *f()//可以省略virtual关键字
    {
        cout << "virtual B* f()" << endl;
        return nullptr;
    }

    virtual B &func_a(A &a, B &b)
    {
        cout << "virtual B &func_a(A &a, B &b)" << endl;
        return b;
    }
};

class XiaoMing : public Stu
{
public:
    B* f()
    {
        cout << "XiaoMing virtual B* f()" << endl;
        return nullptr;
    }
};

int main()
{
    Stu s;
    Person p;
    XiaoMing xiao;
    B test_b;

    Person *ptr = &p;
    ptr->f();
    ptr->func_a(test_b, test_b);

    ptr = &s;
    ptr->f();
    ptr->func_a(test_b, test_b);

    ptr = &xiao;
    ptr->f();

    return 0;
}

输入结果如下

virtual A* f()
virtual A &func_a(A &a, B &b)
virtual B* f()
virtual B &func_a(A &a, B &b)
XiaoMing virtual B* f()
~B()
~A()

截图说明

image-20230731163708802

坑人的问题

这个知识点就可以引伸出一个比较坑人的问题了

class Dad{
public:
    virtual void func(int a = 3)
    {
        cout << "Dad -> " << a << endl;
    }
    virtual void test()
    {
        func();
    }
};

class Son:public Dad{
public:
    virtual void func(int a = 1)
    {
        cout << "Son -> " << a << endl;
    }
};

int main()
{
    Son* s = new Son();
    s->test();

    return 0;
}

请问如上代码的输出结果是什么?它调用的到底是谁的func函数呢?打印的a的值又是多少呢?

A   Dad -> 3
B   Dad -> 1
C   Son -> 1
D   Son -> 3
E   编译不通过
F   以上都不正确

答案揭晓,选择的是D,输出结果是Son -> 3

$ ./test
Son -> 3

刚开始遇到这道题的时候,我也是一脸蒙蔽。直到看了题解才知道这里多坑人。

其中E和F肯定是不能选的,一般情况下这两个选项都是过来迷惑你的。

比如有人可能会觉得new了之后没有delete,有语法错误!但实际上你不delete编译器是不会报错的,要不然也不会存在因为忘记delete而出现的内存泄露问题了。

回到 2.1.5小点 的开头, 提到了子类继承父类函数的时候,会先继承父类函数的声明

对于普通函数而言,声明无伤大雅。但这里,子类和父类函数声明中参数a的缺省值不相同!

最终我们通过子类对象调用test()函数的时候,是将子类对象的指针交给了父类对象的指针。不要忘记了,类中所有成员函数都会有一个隐藏的this指针传参!

实际上,test函数的声明应该是下面这个。我们用子类对象掉用的时候,传入的this指针是子类对象的指针,自然就出现了将子类对象赋值给父类指针的情况。

virtual void test(Dad* this) {
    this->func();
}

此时就满足了虚函数的两个条件:父类指针指向子类对象;子类重写了父类的虚函数。

这时候调用的func()函数,自然是子类中被重写了的func()函数,但由于继承了父类的函数声明,a的缺省值被修改成了父类中func()函数的3,最终就打印出了 Son -> 3 的结果;

为了验证这个结论,我们还可以把子类中func函数的缺省值删除

class Dad{
public:
    virtual void func(int a = 3)
    {
        cout << "Dad -> " << a << endl;
    }
    virtual void test()
    {
        func();
    }
};

class Son:public Dad{
public:
    virtual void func(int a)
    {
        cout << "Son -> " << a << endl;
    }
};

int main()
{
    Son* s = new Son();
    s->test();

    return 0;
}

理论上来说,子类函数重写了父类的func,此时这个函数没有缺省值,调用一个没有传参的func()函数应该是会报错的。

但由于其继承了父类中的函数声明,并没有报错,编译通过了,输出的结果不变

$ g++ test.cpp -o test 
$ ./test
Son -> 3

坑爹呢这是

所以啊,为了避免这种情况,虚函数请不要设计缺省值!


还是上面那道题,如果是直接调用func,应该输出什么?

class Dad{
public:
    virtual void func(int a = 3)
    {
        cout << "Dad -> " << a << endl;
    }
    virtual void test()
    {
        func();
    }
};

class Son:public Dad{
public:
    virtual void func(int a = 1)
    {
        cout << "Son -> " << a << endl;
    }
};

int main()
{
    Son* s = new Son();
    s->func();
    return 0;
}

这时候就和什么继承父类函数声明没有关系了,直接调用的就是子类自己重写了的函数,可以理解为是一个普通的函数调用

$ ./test
Son -> 1

多态必须要父类指针/引用指向子类的时候才能触发!

int main()
{
    Son* s = new Son();
    s->func();//普通调用

    // 这个才是多态调用
    Dad* dd = s;
    dd->func();

    return 0;
}
$ ./test
Son -> 1
Son -> 3

2.2 C++11 override和final

C++11中新增了override和final这两个关键字

2.2.1 final

final用于类内成员函数之后,作用是让这个虚函数无法被重写

virtual void Func1() final
{}

image-20230802123422146

这个关键字的第二个做用,修饰类,被修饰后的类无法被继承

// C++11直接用关键字final修饰,B类就不能被继承了
class B final
{
	//...
};

image-20230802123710960

2.2.2 override

该关键字用于子类中,也是丢在函数后,用于验证是否完成重写

class A
{
public:
    virtual void test1(){}
};

class B :public A
{
public:
    void test1() {}
    void test2() override {}
};

比如在上面的代码中,基类中并没有test2存在,此时我们在test2后加上了override,编译器就会进行检查并报错。因为test2并不是一个对基类中函数的重写

image-20230802124114029

将override添加到test1函数之后,就不会报错了。

class A
{
public:
    virtual void test1(){}
};

class B :public A
{
public:
    void test1() override  {}
    void test2()  {}
};

但如果将基类A的test1的虚函数virtual属性去掉,则又会报错;

image-20230802124211291

如果基类和子类两个同名函数的参数不相同,不构成重写,也会报错

image-20230802124323739

这个关键字就可以用于在多态类设计中,比如所有子类都会有一个buy的函数重写,那就可以在buy函数后添加一个override来检查我的重写是否完成,参数是否与基类中该函数的参数相同,以及函数名是否正确。

2.3 重载、覆盖(重写)、隐藏(重定义)的对比

常考,要理解并记忆

image-20230802193432749

3.抽象类

包含纯虚函数的类就是抽象类,抽象类不能实例化对象

3.1 纯虚函数

在虚函数的基础上,C++定义了纯虚函数:有些时候,在基类里面定义某一个函数是没有意义的,这时候我们可以把它定义为纯虚函数,具体的实现让派生类去同名覆写。

纯虚函数的基本形式如下

//virtual 函数返回类型 函数名()=0;
virtual void Print()=0;

派生类中,必须重写基类的纯虚函数,否则该类也是抽象类

class A {
public:
	//virtual void Print();//虚函数
	virtual void Print() = 0;//纯虚函数
};

class B :public A {
public:
	void Print() {
		cout << "B print " << endl;
	}
};
class C :public A {
public:
	void Print() {
		cout << "C print " << endl;
	}
};

当我们在派生类中覆写了该函数后,即可实例化对象并调用该函数

image-20220527220700695

和虚函数一样,使用基类的引用或指针来接收派生类的对象,即可调用对应的函数

image-20220527220929573

纯虚函数内部是可以写函数实现的,但是没有任何意义。因为纯虚函数必须要被子类重写,这个纯虚函数本身是不能被调用的。

3.2 抽象类

包含纯虚函数的类就是抽象类,抽象类有下面几个特点:

  • 抽象类无法实例化对象
  • 抽象类的派生类必须重写基类的纯虚函数,不然派生类也是抽象类
  • 如果在基类中定义的纯虚函数是const修饰的,则派生类中对应的函数也需要用const修饰

image-20220527220539119

如果我们在子类里面修改了函数的参数,那就不构成重写;此时子类B也是抽象类,无法被实例化对象了

class A {
public:
	//virtual void Print();//虚函数
	virtual void Print() = 0;//纯虚函数
};

class B :public A {
public:
	void Print(int a) { // 新增了一个参数
		cout << "B print " << endl;
	}
};

4.实现继承和接口继承

普通函数的进程是一种实现继承,派生类继承了基类的函数,可以使用这个函数。此时继承的就是函数的实现;

多态中的虚函数是一种接口继承,子类继承的是父类中虚函数的接口,目的是为了在子类中进行重写,以达成多态的目的。此时继承的是函数的接口。

所以,如果不是为了多态,那就不要把父类的函数定义成虚函数。

4.1 动态绑定和静态绑定

  • 静态绑定又称前期绑定(早绑定),在程序编译期间确定了程序的行为,也成为静态多态(函数重载)
  • 动态绑定又称为后期绑定(晚绑定),是在程序运行期间,根据具体拿到的类型来决定程序的行为,调用具体的函数,又称为动态多态。

5.包含虚函数的类的大小

请问下面的代码中,b和d对象的大小分别是什么?

class Base
{
public:
	virtual void Func1()
	{
		cout << "Base::Func1()" << endl;
	}

	virtual void Func2()
	{
		cout << "Base::Func2()" << endl;
	}

	void Func3()
	{
		cout << "Base::Func3()" << endl;
	}

private:
	int _b = 1;
};

class Derive : public Base
{
public:
	virtual void Func1()
	{
		cout << "Derive::Func1()" << endl;
	}

	void Func3()
	{
		cout << "Derive::Func3()" << endl;
	}
private:
	int _d = 2;
};

int main()
{
	Base b;
	Derive d;
	cout << "b: " << sizeof(b) << endl;
	cout << "d: " << sizeof(d) << endl;

	return 0;
}

结果如下,b的大小是8,d的大小是12

image-20230802122525747

当我们使用了virtual关键字修饰函数之后,类中就会出现一个虚函数表,简称虚表(需要和虚基表区分开来)

image-20230802201334448

后文将解释虚函数表的作用,只有虚函数才会存在于虚表中

这个虚函数表是一个指针_vfptr,指针的大小是4/8字节,b类的大小由虚函数表指针和int组成,d类的大小由虚函数表指针和两个int组成。

  • 在32位下,这两个类的大小分别是8和12;
  • 在64位下,这两个类的大小分别是16和24(除了指针是8字节外,还需要内存对齐);

当我们把Base类中的函数修改回普通函数,可以看到类的大小又变成只包含一个int的4字节了。而Dervie类由于依旧有virtual的存在,所以大小不变。

image-20230802122954378

6.虚函数表(虚表)

以这个类为示例,让我们来看看虚表的样子

class Base
{
public:
	virtual void Func1()
	{
		cout << "Base::Func1()" << endl;
	}

	virtual void Func2()
	{
		cout << "Base::Func2()" << endl;
	}

	void Func3()
	{
		cout << "Base::Func3()" << endl;
	}

private:
	int _b = 1;
};

class Derive : public Base
{
public:
	virtual void Func1()
	{
		cout << "Derive::Func1()" << endl;
	}

	void Func3()
	{
		cout << "Derive::Func3()" << endl;
	}
private:
	int _d = 2;
};

在内存窗口中,可以看到这两个对象的基本模块。子类对象中也存在一个虚表,而且可以发现,父子类的虚表中,只有func1的函数地址是不同的。

image-20230802201334448

这里的_vfptrvirtual func pointer的缩写,中文名是虚函数表指针,可以简称为虚表指针

一定要区分虚函数表(多态)和虚基表(菱形继承)!

6.1 虚函数重写和覆盖的概念区别

这里就需要提及重写和覆盖这两个概念的区别了

  • 虚函数重写:语法层的概念,指子类中重写父类中虚函数的函数实现
  • 虚函数覆盖:原理层的概念,子类对象的序表中,子类拷贝了父类的虚表,重写后的函数的函数指针覆盖了基类对应虚函数的指针

多态的实现,就依赖于子类虚表中对函数指针的覆盖,运行时,去指定对象的虚表中,调用对应的函数指针。这是一种运行时决议调用方法的操作;

在VS的调试窗口中,我们能看到一个完整的父类Base对象,这也是父类指针指向子类对象的实现原理。此时父类的指针是完全没有办法知道自己指向的是父类对象,还是某个子类对象;

虚函数表的存在,帮我们实现了通过相同的函数调用方法,实际却触发了不同函数的流程的操作。

6.1.1 运行时决议和编译时决议

  • 多态调用,运行时决议:运行到这里时确定调用函数的地址
  • 普通调用,编译时决议:在编译时就确定调用的函数的地址
  • 因为存在一层通过虚函数表的跳转,所以多态调用会比普通调用的速度慢一些。

依旧是上方的两个类,在基类和子类中同时存在一个普通函数Func3(),此时通过父类指针去调用的时候,就会发现二者调用的都是父类的Func3

image-20230802211156871

这正是因为非虚函数是没有进入虚函数表,此时对Func3的调用就是一个普通函数调用;此时Func3函数的地址在编译出可执行文件的时候,就已经被确定为了基类中的函数地址。

Func1因为是虚函数,存在于虚函数表中,所以是通过运行时查询这个虚函数表,来找到父子类不同的函数地址,最终实现多态调用。

6.1.2 看看汇编

从图中可以看到,对于Func1的调用,最终是从虚函数表中提取出来的地址,call eax寄存器中的地址,这便体现了运行时决议;

而对Func3的调用,是编译时决议,直接已经确定了的基类中该函数的地址,直接call 09511CCh这个函数地址来调用函数了。

image-20230802211738847

对于指向基类对象的调用也是这样

image-20230802211937870

这里就能很直观的看到,多态中虚函数表,让父类指针不管是指向子类对象、还是指向父类对象,都能通过相同的汇编指令来调用正确的函数。

6.2 子类对象赋值给父类为何无法实现多态?

我们都知道,继承了之后,如果把子类对象赋值给父类,则会产生切片。此时无法构成多态。

这是为什么呢?

因为编译器在编译的时候,就已经确定了这些函数的地址。

  • 编译器检查是否符合多态的语法
  • 不符合多态的语法,则直接确定对类函数调用的成员函数地址
  • 符合多态的语法,那就编译出运行时决议的汇编语句

此时地址就已经确定了,根本不存在从虚函数表中找函数地址的步骤,自然就不能实现多态调用了。

image-20230802214329278

这时候可能有些人就会有个不成熟的想法:如果将子类对象赋值给父类对象,切片的时候把子类对象的虚表指针也复制到父类中,那不就能实现多态了吗?

不行!

Derive dd;
Base bb = dd; // 子类对象赋值给父类
Base* ptr1 = &bb; // 父类指针指向父类对象
Base* ptr2 = &dd; // 父类指针指向子类对象
// 引用本质也是指针,这里就不写了

以上面的代码为例,当我们把一个对象赋值给父类的指针时,程序运行的时候并不知道,这个指针指向的到底是父类还是子类对象。

假设我们在切片的时候,将子类对象dd的虚表指针也拷贝复制给父类了,那就会出现一个严重的问题:ptr1在调用函数的时候,调用的也是子类的函数!

这不就乱套了吗?!

理论上bb是一个父类对象,赋值给Base*指针后,我们调用函数的预期是调用父类的函数。但由于bb对象是从子类对象切片而来的,拷贝了子类的虚表指针,此时找到的也是子类的函数地址,不符合预期地调用了子类重写后的虚函数!

所以!为了避免这种不符合语法预期的问题,在切片的时候,只会将子类对象中的成员变量拷贝给父类,并不会拷贝虚表指针!切片生成的父类对象,虚表指针依旧是父类自己的虚表指针!

下图中可见,b3是切片而来的父类对象,其虚表指针以及虚表中的函数地址和Base b1完全相同。一个类的虚表其实只有一张。

image-20230802221417892

所以,对象并不能实现多态。即便理论上可行,但依旧不能这么做!

6.3 子类中新增虚函数,但监视窗不显示

6.3.1 实地探索

class Base
{
public:
	virtual void Func1()
	{
		cout << "Base::Func1()" << endl;
	}

	virtual void Func2()
	{
		cout << "Base::Func2()" << endl;
	}

	void Func3()
	{
		cout << "Base::Func3()" << endl;
	}

private:
	int _b = 1;
};

class Derive : public Base
{
public:
	virtual void Func1()
	{
		cout << "Derive::Func1()" << endl;
	}

	void Func3()
	{
		cout << "Derive::Func3()" << endl;
	}

	virtual void Func4()
	{
		cout << "Derive::Func4()" << endl;
	}

private:
	int _d = 2;
};

当我们在子类中新增了一个虚函数Func4之后,再次打开监视,会发现子类的虚表中依旧只有两个函数指针。这是怎么回事?难道说子类没有被另外一个类继承,它的虚函数就不会进这里的虚表吗?

image-20230802223318886

通过内存窗口,我们可以看到这里的出现了两个监视窗口中已有的函数地址,但后面还有一个和前面两个很接近,但在监视窗口中没有出现的地址。而在这个地址之后是一行全0(即nullptr

image-20230802225539031

nullptr做结尾作为for循环的判断条件,我们可以把虚函数表中函数的地址都打印出来

//重定义函数指针,需要将新的名字放在括号中间
typedef void(*V_FUNC)();

void PrintVfptrTable(V_FUNC* arr)
{
	for (int i = 0; arr[i] != nullptr; i++)
	{
		printf("[%d] %p\n", i, arr[i]);
	}
}

int main()
{
	Base b1;
	Derive d;
    // 因为我们已经知道了,在VS中,虚函数表的指针就是对象的前4个字节
	// 这里是先将对象的指针强转为int*,取出前4个字节的地址
	// 再将这个地址解引用,相当于将地址转成int数字
	// 最后再将这个数字重新强转为V_FUNC*函数指针数组的指针,传给我们的打印函数
	PrintVfptrTable((V_FUNC*)(*((int*)&d)));
}

运行结果如下, 可以看到成功打印出了3个函数的地址,和内存窗口中看到的数据一致

image-20230802230201218

typedef void(*V_FUNC)();

void PrintVfptrTable(V_FUNC* arr)
{
	for (int i = 0; arr[i] != nullptr; i++)
	{
		printf("[%d] %p -> ", i, arr[i]);
		V_FUNC f = arr[i];
		f();
	}
}

既然是函数指针,最终我们是可以通过函数指针来调用函数的。添加了函数调用部分的代码后,再运行,可以看到最后一个函数的确是子类中新增的虚函数Func4

image-20230802230531421

所以,VS的监视窗口中不显示Func4是因为VS认为这个函数没有被子类重写,无关痛痒,于是在监视窗口中隐藏了。

实际上,只要是虚函数,那就是会进入到这个类中的虚函数表里面的!

记住,只要是虚函数就一定会进虚表

6.3.2 为什么不新增一个子类的虚表?

这里我还思考过另外一个问题,既然这个是子类自己的虚函数,那为什么没有多开一个虚表来存放这个函数的指针,而是直接放入到了继承自基类的虚函数表中呢?

下图是Derive在VS2019的内存分布模型
————————————
|  _vfptr  | Base
|  int _b  | Base
|  int _d  | Derive
————————————

假设要新增一个指针,那按VS的规则,也是应该放在对象的最前面;
此时模型就变成了下面这样
————————————
|  _vfptr  | Derive
|  _vfptr  | Base
|  int _b  | Base
|  int _d  | Derive
————————————
新增了一个指针的内存占用不说,还把原本泾渭分明的内存模型,变成了两面包夹芝士;
怎么说都是追加在Base的虚表之后更加靠谱,
因为原本继承自Base的虚表就是子类对象的前4个字节!

说明参考代码块中的注释。

6.4 虚表的存储位置

虚表是存在哪里的?

下图中的b1和b3是两个不同的Base对象,但我们会发现它们的虚表地址包括函数指针的地址都完全相同。毕竟这是两个完全相同的类,虚表里面的内容确实是相同的。

这就告诉了我们,相同的类,其虚表在内存里面只有一张。初始化的时候,将这个类的虚表找到,并插入到类中。

image-20230802221417892

那么虚表是存在内存中的那个区域里面的呢?

首先排除栈和堆,栈是随时用随时开辟的,而堆需要动态内存管理,对于这种编译器自己完成的操作,也不应该是这样。

静态区/数据段放的是全局数据或者静态变量,相比之下,常量区/代码段更靠谱。

有了猜想之后,就要来验证了。

我们将常用的存在不同位置的数据类型都弄出来,分别打印它们的地址

int c = 2;

int main()
{
	Base b1;
    
	int a = 0;
	static int b = 1;
	const char* str = "hello world";
	int* p = new int[10];
	printf("栈:%p\n", &a);
	printf("静态区/数据段:%p\n", &b);
	printf("静态区/数据段:%p\n", &c);
	printf("常量区/代码段:%p\n", str);
	printf("堆:%p\n", p);
	printf("虚表:%p\n", (*((int*)&b1)));
	printf("函数地址:%p\n", &Derive::Func3);
	printf("函数地址:%p\n", &Derive::Func2);
	printf("函数地址:%p\n", &Derive::Func1);

	return 0;
}

输出结果如下

image-20230803073231110

这时候可以发现,虚表的地址和常量区/代码段的地址开头相似,都是00DF9B,说明它更加靠近代码段的区域。

而虚表的地址00DF9B34是小于常量区/代码段的00DF9B6C的,这就表明了在内存中,虚表的地址比这个常量区参数的地址更低。而在内存中,不同区域的分布如下,常量区就是在最低处的。

栈
堆
静态区/数据段
常量区/代码段

实锤了,虚表就是存在常量区里面的!类的虚函数表是在编译阶段就已经生成了的

6.5 多继承中的虚表

先说结论,如果出现了多继承,那么子类中会根据继承的父类分别产生独立的虚表(如果不是独立的,那就没有办法实现某个父类指针指向子类时,对子类的切片)

以下就是一个最简单的多继承

class Base
{
public:
	virtual void Func1()
	{
		cout << "Base::Func1()" << endl;
	}

	virtual void Func2()
	{
		cout << "Base::Func2()" << endl;
	}

	void Func3()
	{
		cout << "Base::Func3()" << endl;
	}

private:
	int _b1 = 1;
};

class Base2
{
public:
	virtual void Func1()
	{
		cout << "Base2::Func1()" << endl;
	}

private:
	int _b2 = 1;
};

class Derive : public Base,public Base2
{
public:
	virtual void Func1()
	{
		cout << "Derive::Func1()" << endl;
	}

	void Func3()
	{
		cout << "Derive::Func3()" << endl;
	}

	virtual void Func4()
	{
		cout << "Derive::Func4()" << endl;
	}

private:
	int _d = 2;
};

通过监视窗口,能看到这个对象的模型大概是如下图所示

image-20230803082210045

其中能看到子类独有的虚函数Func4是存在第一张虚表里面的(VS监视窗口依旧没有显示出来)

image-20230803081656878

这里还会发现一个问题:Base和Base2这两个基类中都有虚函数Func1,那为什么子类中这两个类的虚表中,这两个被子类重写的Func1函数的地址不相同呢?

通过之前写的打印函数来打印第二章虚表里面的函数

typedef void(*V_FUNC)();

void PrintVfptrTable(V_FUNC* arr)
{
	for (int i = 0; arr[i] != nullptr; i++)
	{
		printf("[%d] %p -> ", i, arr[i]);
		V_FUNC f = arr[i];
		f();
	}
}

这里需要注意的是,我们对d这个子类的指针+1的时候,会直接跳过sizeof(Derive)个空间的大小。为了能精准地通过+sizeof(Base)找到Base2基类的虚表,就需要将子类的指针强转为char*,这样每次+1就是移动一个字节的空间。

PrintVfptrTable((V_FUNC*)(*((int*)((char*)&d+sizeof(Base)))));

运行可以看到,即便内存不同,但实际上调用的依旧是子类的Func1函数;也能看到子类单独新增的虚函数只会放在第一个虚表中。

image-20230803082638559

6.5.1 Func1地址不同?

在上面VS打印的虚表中,会发现一个问题:Base和Base1父类中的两个Func1函数的地址不相同,但最终我们看到的运行结果又都是子类重写后的Func1

把相同的代码挪到liunx环境下,编译运行,发现出现了段错误

$ ./test
[0] 0x400b74 -> Derive::Func1()
[1] 0x400b48 -> Base::Func2()
[2] 0x400ba6 -> Derive::Func4()
Segmentation fault (core dumped)

顺带一提,在linux下直接编译本博客中的代码会出现如下警告,因为我们对指针进行了多次强转,不用管他

g++ test.cpp -o test -std=c++11
test.cpp: In function ‘int main()’:
test.cpp:92:44: warning: cast to pointer from integer of different size [-Wint-to-pointer-cast]
    PrintVfptrTable((V_FUNC *)(*((int *)&d)),3);
                                           ^
test.cpp:93:69: warning: cast to pointer from integer of different size [-Wint-to-pointer-cast]
    PrintVfptrTable((V_FUNC *)(*((int *)((char *)&d + sizeof(Base)))),1);
                                                                    ^

这是因为我们在打印虚函数表中,判断条件是当前函数指针为空,这是VS下对虚函数表的结束规定(以nullptr结尾),并不是linux下的操作,也不是C++对虚表的统一规定。

所以,为了能正常打印出虚函数表,我们需要将打印函数的判断条件改成固定值;因为我们已经知道了虚函数表中函数的个数了。

// 重定义函数指针,需要将新的名字放在括号中间
typedef void (*V_FUNC)();

void PrintVfptrTable(V_FUNC *arr, size_t size)
{
    printf("_vfptr: %p\n",arr);
    for (int i = 0; i < size; i++)
    {
        printf("[%d] %p -> ", i, arr[i]);
        V_FUNC f = arr[i];
        f();
    }
}
// 如下是修改后的调用
PrintVfptrTable((V_FUNC *)(*((int *)&d)),3);
PrintVfptrTable((V_FUNC *)(*((int *)((char *)&d + sizeof(Base)))),1);

再次编译运行,也出现了相同的结果,两个基类虚函数表中的Func1地址不相同

$ ./test
_vfptr: 0x400d10
[0] 0x400b86 -> Derive::Func1()
[1] 0x400b5a -> Base::Func2()
[2] 0x400bb8 -> Derive::Func4()
_vfptr: 0x400d38
[0] 0x400bb1 -> Derive::Func1()

再新增一个直接对Derive::Func1函数本身地址的打印

    PrintVfptrTable((V_FUNC *)(*((int *)&d)),3);
    PrintVfptrTable((V_FUNC *)(*((int *)((char *)&d + sizeof(Base)))),1);

    printf("\n");
    printf("Derive::Func1 %p\n",(void*)&Derive::Func1);

输出结果如下,可以看到这个函数本身的地址和第一张虚表里面的Derive::Func1()地址是吻合的,但是和第二章虚表的地址不相符合

_vfptr: 0x400cd0
[0] 0x400b60 -> Derive::Func1()
[1] 0x400b34 -> Base::Func2()
[2] 0x400b92 -> Derive::Func4()
_vfptr: 0x400cf8
[0] 0x400b8b -> Derive::Func1()

Derive::Func1 0x400b60

而在windows的vs2019中,打印的地址就更奇怪了,其和两个虚表中的地址都对不上!

_vfptr: 002D9B84
[0] 002D141F -> Derive::Func1()
[1] 002D1393 -> Base::Func2()
[2] 002D106E -> Derive::Func4()
_vfptr: 002D9B98
[0] 002D10B9 -> Derive::Func1()

Derive::Func1 002D1122

这里我还发现了一个奇怪的问题,相同的代码在windows下和linux下的效果不同

// 下面的代码在windows下可以正常打印函数地址,linux下打印出来的是0x1
printf("Derive::Func1 %p\n",(&Derive::Func1));
// 下面的函数在linux下可以正常打印函数地址,在windows下报错“强制类型转换失效”
printf("Derive::Func1 %p\n",(void*)&Derive::Func1);

有人知道这是为啥吗?😂

你可以理解这是在不同平台下,对虚表中函数指针的一个处理,其最终还是会调用到正确的函数的。

在windows下查看反汇编,能看到其最终是调用了ebp-14h的一个地址,在内存窗口中可以看到,这个地址正是虚表中存放的Func1函数地址

image-20230803090855304

使用调试在反汇编窗口中逐条运行,能进到这个call [ebp-14h]语句中,可以看到在002910B9这个地址上存放的就是子函数中的Func1函数地址,这里的汇编指令jmp相当于跳转到这个函数地址上

image-20230803091038151

再进一步观察会发现,这里显示的地址和打印出来的func1函数的地址还是不相同

image-20230803091651052

再跳转,还是不同

image-20230803091321822

再次跳转,依旧是不同

image-20230803091719566

再一次跳转,就跑到了子类中Func1函数的执行流里面了。此时就开始执行这个函数了!

image-20230803091731208

所以,这只是编译器在某些层面上的处理而已。包括第一个基类的虚表,也是这样的函数地址跳转。在linux下和windows的不同编译器下观察到的情况都不一样,我们没必要过多纠结于这里,只要知道有这类编译器处理的存在就可以了。


最终两个基类对func1的函数调用的汇编流程如下图

image-20230803110713759

这其中,我们要发现Base2对Func1的调用,主要是多了下面这两句非常不同的汇编

008426B0 83 E9 08             sub         ecx,8  
008426B3 E9 67 ED FF FF       jmp         Derive::Func1 (084141Fh)

原本走到这一步,ecx寄存器的值是 0x00849b98。这一步执行完毕后,ecx的值是0x00849b90,可以看到更新后的值比原本的值少了8字节;正好是Base类的大小!

这是因为在当前对象模型中,两个父类对象需要调用的Func1都是子类的Func1,此时使用的this指针应该是子类Derive的this指针,处于子类对象地址的起始位置。对于Base* ptr1来说,其指向的地址本身就是子类的起始地址,所以不需要进行修正。

Base2* ptr2指向的位置并不是子类的起始地址,此时就需要-8回到起始位置,用修正后的this指针来调用子类的Func1函数;

image-20230803111315242

这也就能解释为什么两个虚表中存放的函数指针地址不相同,因为调用的流程不一样,Base2的指针在调用的时候需要对ecx寄存器中的this指针进行修正。

6.5.2 指针切片地址不同

当我们用不同的父类指针指向这个子类对象的时候,由于会发生不同位置的切片,最终的地址并不相同。这点我们通过对象模型也能看出来,不同的父类都需要指向自己的那部分,所以切片后的地址不同。

	Derive d;
	Base* ptr1 =  &d;
	Base2* ptr2= &d;
	Derive* ptr3 = &d;

	printf("Base: %p\nBase2: %p\nDerive: %p\n", ptr1, ptr2, ptr3);

image-20230803083327674

6.6 菱形虚拟继承中的虚函数表

class A
{
public:
	virtual void func()
	{
		cout << "A:func" << endl;
	}

	int _a;
};

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

class C : public A
{
public:
	int _c;
};

class D : public B, public C
{
public:
	int _d;
};

使用上图代码进行虚拟继承的时候,内存模型如图,在B类和C类中都会有一个继承自A类的虚表;因为这里没有进行函数重写,所以地址是一样的。

image-20230803113830741

当我们在B和C类中重写此函数,对象模型如下,B类和C类中虚表的函数指针不同

image-20230803114238024

但如果我们把B和C对A的继承都改成虚继承,此时就会报错了!

image-20230803114518251

在前面的虚继承讲解中,提到了在VS下,是将公共基类放在子类的最后面的,此时模型的顺序大概如下

B
C
D
A

由于B和C都使用了虚继承,解决了数据二义性问题,但没有解决A中的虚表到底是存B重写后的func,还是存C重写后的func的问题;

这个时候我们就需要在D里面重写func,这时候就能确定最终使用的是D里面对func的重写,也就不会有到底是选B还是选C的分歧问题了。

image-20230803114719980

6.7 虚函数和inline

6.7.1 状态观察

我们先尝试给一个虚函数加上inline内联关键字

class A
{
public:
	virtual inline void f1()
	{
		cout << "f1()" << endl;
	}
	virtual void f2();
};
// 声明和定义分离就不是内联函数了
void A::f2()
{
	cout << "f2()" << endl;
}


int main()
{
	A aa;
	aa.f1();
	aa.f2();

	return 0;
}

修改VS2019项目的属性

image-20230803115444907

image-20230803115210723

转到反汇编,可以看到f1函数被展开,f2函数依旧是call地址的调用

image-20230803115538575

class A
{
public:
	virtual inline void f1()
	{
		cout << "f1()" << endl;
	}
	virtual void f2()
	{
		cout << "f2()" << endl;
	}

};

如果不将声明和定义分离,可以观察到两个函数都被编译器认作是内联而展开了。

image-20230803115641759

此时新增一个继承,再来看看反汇编

class A
{
public:
	virtual inline void f1()
	{
		cout << "f1()" << endl;
	}
	virtual void f2();
};
// 声明和定义分离就不是内联函数了
void A::f2()
{
	cout << "f2()" << endl;
}

class B:public A
{
public:
	virtual void f1()
	{
		cout << "B f1()" << endl;
	}
};


int main()
{
	A aa;
	aa.f1();
	aa.f2();

	return 0;
}

此时我们发现,似乎f1函数依旧是有内联的属性

image-20230803120105176

这说明虚函数是可以用virtual关键字来修饰的。

但如果用多态调用呢?

	A* aa = new B();
	aa->f1();
	aa->f2();

此时就能发现,原本的多态展开,就变回了call函数地址的调用

image-20230803120223673

这是因为内联函数是没有地址的!而多态基于虚基表实现,虚基表中必须要存放函数的地址!

6.7.2 结论

结论就是,在多态中,对虚函数的inline修饰不会报错,但会被编译器忽略(不会有内联的属性),依旧是个普通的函数

7.静态成员函数不能是虚函数

静态成员函数属于整个类,无法被指定对象重写。

而且静态成员函数没有this指针,可以直接用类名来调用,但这也决定了其无法访问到虚表,自然也无法实现多态。

所以静态成员函数是不能做虚函数的,在VS中这样写会直接报错

image-20230803120648439

8.构造函数不能是虚函数

通过调试可以发现,虚函数表中的指针原本是随机值,是在构造函数中被初始化为正确的函数地址的

image-20230803121101020

image-20230803121105181

既然是在构造函数中初始化的,那么就不能先于构造函数被初始化出来,也就没有办法通过虚表来实现多态。

所以构造函数是不能为虚函数的!

9.菱形继承构造顺序

如下虚菱形继承中,调用构造函数的顺序是什么?

class A {
public:
	A(const char* s) { cout << s << endl; }
	~A() {}
};
class B :virtual public A
{
public:
	B(const char* s1, const  char* s2) :A(s1) { cout << s2 << endl; }
};
class C :virtual public A
{
public:
	C(const char* s1, const  char* s2) :A(s1) { cout << s2 << endl; }
};
class D :public B, public C
{
public:
	D(const char* s1, const char* s2, const  char* s3, const  char* s4) :B(s1, s2), C(s1, s3), A(s1)
	{
		cout << s4 << endl;
	}
};
int main() 
{
	D* p = new D("class A", "class B", "class C", "class D");
	delete p;
	return 0;
}

我们只需要知道,类的对象在实例化的时候,初始化的顺序就是类声明的顺序

依照代码中的顺序流程读下来,就是构造函数被初始化的顺序;

而且A的构造函数也是由最终子类D直接发起的,而不是B或者C发起的。

image-20230803121510612

The end

内容丰富的继承和多态的博客终于补充完毕了!

如果有问题还请提出!

  • 10
    点赞
  • 12
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 2
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

慕雪华年

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值