前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >【C++】多态

【C++】多态

作者头像
野猪佩奇`
发布2023-03-28 14:36:39
4510
发布2023-03-28 14:36:39
举报
文章被收录于专栏:C/C++ 后台开发学习路线

文章目录

一、多态基础知识

1、多态的概念

通俗来说,多态就是指就是多种形态,具体点就是对于完成某个行为,当不同的对象去完成时会产生出不同的状态。比如买票这个行为,当普通人买票时是全价买票,学生买票时是半价买票,军人买票时是优先买票。

2、虚函数

虚函数的概念

被关键字 virtual 修饰的类成员函数我们称其为虚函数:

代码语言:javascript
复制
class Person {
public:
	virtual void BuyTicket() { cout << "买票-全价" << endl; }
};

虚函数的重写/覆盖

在继承关系中,如果子类中有一个跟父类完全相同的虚函数 – 函数名、函数参数、函数返回值都相同 (三同),则称子类的虚函数重写或者覆盖了父类的虚函数。

代码语言:javascript
复制
class Person {
public:
	virtual void BuyTicket() { cout << "买票-全价" << endl; }
};

class Student : public Person {
public:
	virtual void BuyTicket() { cout << "买票-半价" << endl; }  //虚函数重写
};

class Soldier : public Person {
public:
	virtual void BuyTicket() { cout << "买票-优先" << endl; }  //虚函数重写
};

虚函数重写的两个特例

虚函数重写存在两个特例:

第一,子类虚函数中的 virtual 可以省略,但是父类的 virtual 一定不能省略 – 虚函数的继承是接口继承,也就是说,子类中继承得到的虚函数和父类虚函数的函数接口是完全相同的,而子类如果对虚函数进行重写,重写的也只是虚函数的实现,并没有改变虚函数的接口,所以即使我们不加 virtual 子类虚函数的类型也和父类一样,是虚函数类型。但为了程序的可读性,我们建议子类虚函数也加上 virtual

image-20230312203925012
image-20230312203925012
image-20230312204033374
image-20230312204033374

第二,子类虚函数和父类虚函数的返回值可以不同,但必须是子类类型或父类类型的指针或者引用,我们将这种特性称为 协作。需要注意的是,这里的子类和父类不一定非要是当前子类父类,使用其他子类或父类类型作为函数返回值也可以

image-20230312204834258
image-20230312204834258
image-20230312205428965
image-20230312205428965

注:如果子类函数和父类函数不满足这四个条件中的任意一个 – 虚函数、返回值相同、参数类型相同、函数名相同,也不属于两个特例的话,那么它们一般构成隐藏,因为隐藏只要求函数名相同

总结构成重写的条件:虚函数 + 三同 + 两种特殊情况;同时,需要特别注意的时,虽然在实际开发中,我们较少会遇到重写中的两种特殊情况,特别是协作,在工作中可以说几乎不会遇到 (比菱形继承的应用场景还少),但是校招中很喜欢用这两个特例来考察构成重写的条件

3、多态的定义和实现

多态是在不同继承关系的类对象,去调用同一函数,产生了不同的行为。比如 Student 继承了 Person,Person 对象买票全价,Student对象买票半价。

构成多态的三个条件如下:

  • 继承中才有多态;
  • 必须通过父类的指针或者引用调用虚函数;
  • 被调用的函数必须是虚函数,且子类必须对父类的虚函数进行重写。
代码语言:javascript
复制
class Person {
public:
	virtual void BuyTicket() { cout << "买票-全价" << endl; }
};

class Student : public Person {
public:
	virtual void BuyTicket() { cout << "买票-半价" << endl; }  //虚函数重写
};

class Soldier : public Person {
public:
	virtual void BuyTicket() { cout << "买票-优先" << endl; }  //虚函数重写
};

void Func(Person& p)
{
	p.BuyTicket();
}
image-20230312210538870
image-20230312210538870

如上,在 Func 函数中,我们使用父类的引用来调用虚函数 BuyTicket(),然后分别使用 父类对象 和 子类对象作为 Func 函数的参数,实现了 不同的对象进行同一种行为产生不同的状态,从而构成了多态。

普通调用和多态调用

多态调用:

像什么 BuyTicket 函数的调用我们就称为多态调用,多态调用必须满足构成多态的几种条件 – 继承、虚函数重写、由父类的指针/引用来调用虚函数,多态调用与调用此函数的对象指向/引用的类型有关; 比如在上面的 Func 函数中,pe 是 person 的对象,它作为实参传递给 p,此时 P 是 pe 的别名,p 调用 BuyTicket 函数,满足多态条件,由于 p 引用的对象 pe 的类型是 Person,所以实际上调用的是 Person 的虚函数 BuyTicket,得到结果 “买票-全价”; st 是 Student 的对象,它作为实参传递给 p (此过程发生切片,无类型转换),此时 P 是 st 的别名,p 调用 BuyTicket 函数,满足多态条件,由于 p 引用的对象 st 的类型是 Student,所以实际上调用的是 Student 的虚函数,得到结果 “买票-半价”; so 是 Soldier 的对象,它作为实参传递给 p (此过程发生切片,无类型转换),此时 P 是 so 的别名,p 调用 BuyTicket 函数,满足多态条件,由于 p 引用的对象 so 的类型是 Soldier,所以实际上调用的是 Soldier 的虚函数,得到结果 “买票-优先”;

image-20230312212858771
image-20230312212858771

普通调用:

多态调用之外的为普通调用,即只要不满足多态调用条件中任意一个的就是普通调用,普通调用与调用此函数的对象的类型有关

image-20230312213138322
image-20230312213138322

如上,当我们将 Func 中的引用去掉以后,由于此时调用 BuyTicket 不满足多态的第三个条件 – 由父类的指针/引用调用,所以即使我们调用的是虚函数,子类中也重写了虚函数,这里仍然是普通调用,普通调用与对象的类型有关,与对象指向的类型无关,而这里不管且=切不切片,p 的类型都是 Person,所以全部调用对象类型 Person 的 BuyTicket 函数。

4、析构函数的重写

在上一节继承中我们提到,为了满足子类先析构,父类后析构的特性,子类析构函数调用完成后会自动调用父类析构函数完成父类成员的清理工作,不需要我们在子类析构中显式调用父类析构;对于一般场景来说,这种做法是没问题的。

代码语言:javascript
复制
class A {
public:
	~A() {
		cout << "~A:" << this << endl;
	}
protected:
	int* _a = new int[10];
};

class B : public A {
public:
	~B() {
		cout << "~B:" << this << endl;
	}
protected:
	int* _b = new int[20];
};
image-20230312214238541
image-20230312214238541

但是在一些特殊场景下,上述代码可能会发生内存泄漏:

image-20230312214502075
image-20230312214502075

可以看到,在切片场景下和动态开辟空间的场景下,由于 delete b 的步骤如下:使用指针调用析构函数析构、调用 operator delete 释放堆上 B 对象;这里虽然将调用 A 析构 将 A 的资源释放了,但是 b 是由 B 切片得来的,所以 b 中 B 对象的那部分资源并没有被释放,从而发生内存泄露。

为了解决上面这种情况,我们一般将父类的析构函数定义为虚函数,然后在子类中对虚函数进行重写:析构函数没有返回值和参数,并且所有的析构函数的函数名在编译阶段都会变成 destructor,再加上子类虚函数的 virtual 关键字可以省略,所以只要父类析构函数定义为虚函数,子类虚函数就一定可以构成重写

在子类析构和父类析构构成重写的前提下,如果再遇到上面 切片和动态开辟 相结合的情况,就会满足多态的条件 – 子类虚函数重写 + 父类的指针/引用调用虚函数,为多态调用,与调用函数的对象 (父类的对象) 指向的类型 (子类类型) 有关,所以会去调用子类的析构;同时,由于子类析构调用完毕会自动调用父类析构,所以使得子类和父类的资源都得以清理

image-20230312220124552
image-20230312220124552

注:可能设计 C++ 的大佬也是考虑到这样的场景才设计出子类虚函数可以省略 virtual 的语法。

总结:我们在继承关系中,可以无脑的将父类析构定义为虚函数;虽然虚函数会建立虚函数表,使得时空效率有一丢丢的浪费,但是它避免了可能存在的内存泄漏的风险,是完全值得的。

5、C++11 override 和 final

从上面可以看出,C++ 对虚函数重写的要求是非常严格的,但是有些情况下由于疏忽,可能会发生函数名字母次序写反等问题,从而导致无法构成重写,而这种错误在编译期间是不会报出的,只有在程序运行时没有得到预期结果,我们反才来debug 才能发现,这就得不偿失了。因此,C++11 提供了 override 和 final 两个关键字,可以帮助用户检测是否重写。、

final:修饰虚函数,表示该虚函数不能再被重写

代码语言:javascript
复制
class Car {
public:
	virtual void Drive() final {}
};

class Benz : public Car {
public:
	virtual void Drive() { cout << "Benz-舒适" << endl; }
};
image-20230312221129502
image-20230312221129502

override: 检查派生类虚函数是否重写了父类某个虚函数,如果没有重写则直接编译报错

代码语言:javascript
复制
class Car {
public:
	virtual void Drive() {}
};

class Benz :public Car {
public:
	virtual void Drvie() override { cout << "Benz-舒适" << endl; }  //故意写错函数名
};
image-20230312221345193
image-20230312221345193

6、重载/隐藏/重写的对比

重载

  • 在同一作用域内;
  • 函数名相同,参数不同 (参数类型、参数个数、参数顺序),返回值不做要求;

隐藏/重定义

  • 在子类和父类中;
  • 子类中定义和父类同名的成员变量造成父类成员变量隐藏,需要指定父类作用域访问;
  • 子类定义和父类同名函数 (只要求函数名相同) 造成父类成员函数隐藏,需要指定父类作用域访问;

重写/隐藏

  • 在子类和父类中;
  • 虚函数 + 三同 – 子类和父类都使用 virtual 修饰,子类父类虚函数的函数名、函数参数、函数返回值都完全相同构成重写;
  • 存在两个特例 – 子类 virtual 可以省略,子类父类虚函数的返回值可以不同,但要求是子类类型/父类类型的指针/引用;

二、抽象类

1、抽象类概念

在虚函数的后面写上 =0 ,则这个函数为纯虚函数包含纯虚函数的类叫做抽象类(也叫接口类), 抽象类有如下特征:

  • 抽象类不能实例化出对象;
  • 继承抽象类的子类也不能实例化出对象,除非重写纯虚函数;
  • 纯虚函数相当于规范了派生类必须进行虚函数重写,另外纯虚函数更体现出了接口继承。
代码语言:javascript
复制
class Car {  //纯虚类
public:
	virtual void Drive() = 0;  //纯虚函数
};

class Benz :public Car {
public:
	virtual void Drive() { //重写纯虚函数,不可以实例化对象
		cout << "Benz-舒适" << endl;
	}
};

class BMW :public Car {
public:
	virtual void Drive() {
		cout << "BMW-操控" << endl;
	}
};
image-20230312222711831
image-20230312222711831

override 和 纯虚函数的区别:override 是检查重写,没重写直接编译报错;纯虚函数是强制我们重写,因为不重写就不能实例化出对象,但是不会不实例化对象也可以不重写。

2、接口继承和实现继承

普通函数的继承是一种实现继承,派生类继承了基类函数,可以使用函数,继承的是函数的实现。虚函数的继承是一种接口继承,派生类继承的是基类虚函数的接口,目的是为了重写,达成多态,继承的是接口,重写的是实现。所以如果不实现多态,不要把函数定义成虚函数。

注:函数接口指的是函数控制块 {} 上面的部分,即函数声明,函数实现指的是函数控制块 {} 中的部分。这其实也解释了为什么子类函数不加 virtual 修饰也是虚函数。

代码语言:javascript
复制
class Person {
public:
	virtual void Print(int a = 1) {
		cout << "Car::Drive" << this << endl;
	}
};

//函数接口
virtual void print(int a = 1)
//函数实现
cout << "Car::Drive" << this << endl;

3、一道非常坑的笔试/面试题

以下程序输出结果是什么()

代码语言:javascript
复制
class A {
public:
	virtual void func(int val = 1) { std::cout << "A->" << val << std::endl; }
	virtual void test() { func(); }
};

class B : public A {
public:
	void func(int val = 0) { std::cout << "B->" << val << std::endl; }
};

int main(int argc, char* argv[]) {
	B* p = new B;
	p->test();
	return 0;
}

A: A->0 B: B->1 C: A->1 D: B->0 E: 编译出错 F: 以上都不正确

相信这道题可以难倒九成以上的同学,先说答案:B;解析如下:

1、main 函数中,p 指向一个 B 对象,然后 p 调用 test 函数,由于 B 继承 A,所以 B 中也会有 test 函数;但是由于 B 中没有对 test 函数进行隐藏或重写,所以 B 中的 test 和 A 中的 test 一模一样,包括隐藏的 this 指针;显示写出来如下:

代码语言:javascript
复制
//为了方便理解,我们将隐藏的 this 指针也写出来
class A {
public:
	virtual void func(int val = 1) { std::cout << "A->" << val << std::endl; }
	virtual void test(A* this) { this->func(); }  //this的类型是A*
};

class B : public A {
public:
	void func(int val = 0) { std::cout << "B->" << val << std::endl; }
    //继承过来的test函数和A中的test一模一样
    virtual void test(A* this) { this->func(); }  //this的类型是A*
};

2、在此处,由于 p 是的类型是 B*,而 test 函数的 this 指针类型是 A*,所以发生切片; 3、然后,test 函数在其内部调用 func 函数,由于子类对 func 函数进行了重写,再加上 this 指针的类型是 A*,满足多态条件,所以这里是一个多态调用,多态调用看调用对象指向的类型,this 指向 p 中属于父类的那一部分,所以 this 指向的类型是 B,调用 B 的 func; 4、很多同学想到这一步之后可能直接就选 D 选项了,但是 D 是错误的,因为在多态调用中,虚函数的继承是接口继承,重写只是重写实现,所以 B 中 func 函数的接口应该使用从 A 中继承下来的接口,即 val 的缺省值为 1;至此,答案选 B。

总结:其实在绝大多数情况下,大家想到第三步就已经能够得到正确答案了,因为这里主要考察的知识点是多态的条件;而最后一步的接口继承说实话太坑了,一般都不会这样考。


三、多态的原理

1、虚函数表/虚表

我们以一道笔试题来引出虚函数表:在下面的程序中,sizeof(Base) 是多少?

代码语言:javascript
复制
class Base {
public:
	virtual void Func1() {
		cout << "Func1()" <<  endl;
	}

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

	void Func3() {
		cout << "Func3()" << endl;
	}
private:
	int _b = 1;
};

上面这种考法还是比较简单的,因为只要稍微想一想就知道答案绝不可能是 4 这么简单,怕的是它像下面这样出题:

代码语言:javascript
复制
class Base {
public:
	virtual void Func1() {
		cout << "Func1()" <<  endl;
	}

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

	void Func3() {
		cout << "Func3()" << endl;
	}
private:
	int _b = 1;
	char _c = 'a';
};

如果没学多态或者学的不扎实的同学看到这道题会心想,这道题不就是考察内存对齐嘛 – b 对齐 4 的倍数,占 0~3 号位置,c 对齐 1 的倍数,占 4 号位置,合计 5 个字节,再整体对齐一下,答案是 8;但答案真的是这样吗?我们看运行结果:

image-20230313191911261
image-20230313191911261
image-20230313214323487
image-20230313214323487

通过运行结果调试窗口我们可以看到,Base 类的大小是 12 字节,比我们预期的 8 字节多了四字节,这 其实是因为 Base 类中还存在一个默认的虚函数表指针 vfPtr,该指针指向一个虚函数表,也称为虚表,该虚函数表中存放的是虚函数的地址,因为普通函数 Func3 的地址并没有被放进虚表中。

注意事项:

1、在 VS 下,虚表指针放在对象的最前面,且虚基表的最后一个元素是空,但这并不代表其他平台下也是这样的,就比如 gcc 下虚表最后一个元素并不为 nullptr; 2、注意区分虚表和虚基表 – 虚表是多态中的概念,该表本质上是一个函数指针数组,里面存放的是虚函数的地址;而虚基表是菱形虚拟继承中的概念,该表本质上是一个整形数组,里面存放的是当前类与虚基类的偏移量。

现在我们对上面的代码进行改造,去除 Base 类中的 c 成员,然后增加一个派生类 Derive 去继承 Base,并在派生类中重写虚函数 Func1:

代码语言:javascript
复制
class Base {
public:
	virtual void Func1() {
		cout << "Func1()" << endl;
	}

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

	void Func3() {
		cout << "Func3()" << endl;
	}
private:
	int _b = 1;
};

class Derive : public Base {
public:
	virtual void Func1() {
		cout << "Derive::Func1()" << endl;
	}
private:
	int _d = 2;
};
image-20230313194516844
image-20230313194516844

观察调试窗口,我们可以发现:

1、子类对象 d 由两部分构成,一部分是父类成员,一部分是自己的成员;同时我们发现,子类对象中也有虚表指针,并且该虚表指针是存在于子类对象中父类那一部分中的,说明子类对象的虚基表是从父类继承/拷贝过来的。 2、我们发现,虽然子类并没有对 func2 进行重写,但是虚表中仍然有 func2 函数的地址,这是因为子类会继承父类的 func2;同时,还可以发现子类虚表指针指向的虚表和父类虚表指针指向的虚表的内容是不同的,并且不同的那部分内容刚好是子类中进行虚函数重写的那部分内容;所以子类虚表是通过拷贝父类虚表,然后将子类进行重写得到的新的虚函数地址覆盖掉虚表中该需函数原来的地址得到的。 3、这也是为什么重写也叫作覆盖的原因 – 重写指的是重写虚函数,覆盖指的是将虚表中已重写的虚函数的原地址覆盖掉;重写是语法层的叫法,覆盖是原理层的叫法 4、同时,父类中的 Func3 函数也被继承下来了,但由于 Fun3 不是虚函数,所以并不会进虚表。

总结 :子类虚表是如何生成的

  • 先将基类中的虚表内容拷贝一份到派生类虚表中;
  • 如果派生类重写了基类中某个虚函数,用派生类自己的虚函数覆盖虚表中基类的虚函数;
  • 派生类自己新增加的虚函数按其在派生类中的声明次序增加到派生类虚表的最后。

拓展学习:写一个函数来找出虚表的存储位置

代码语言:javascript
复制
int main() {
	int a;
	cout << "栈 " << &a << endl;

	int* p = new int;
	cout << "堆 " << p << endl;

	const char* str = "hello world";
	cout << "代码段/常量区 " << (void*)str << endl;

	static int b;
	cout << "静态区/数据段 " << &b << endl;

	Base be;
	cout << "虚表 " << (void*)*((int*)&be) << endl;

	Derive de;
	cout << "虚表 " << (void*)*((int*)&de) << endl;
}
image-20230313212759967
image-20230313212759967

如上,我们可以将位于各个区域的类型的变量的地址和虚表的地址进行比对,看虚表地址和哪个类型变量的地址最接近,那么虚表就存储在哪个区域 – 因为虚表地址存储在虚表指针 vfptr 中,所以我们设法得到 vfptr 即可,而 vfptr 是一个指针变量,32 位平台下指针 4 字节,所以我们将类对象的地址强转为 int*,然后对其解引用就能得到 vfptr 了。

需要注意的是,当我们将类对象地址强转为 int* 后,此时再对其解引用得到的就是一个整形,而整形不方便和地址进行对比,所以我们可以将其转换为 指针类型,在使用 cout 输出即可。

可以看到,虚表的地址和代码段中变量的地址最接近,所以虚表存储在代码段中;同时,由于虚表存储在代码段中,所以同一类型的虚表是共享的

注意:有的老铁可以认为虚表如果在代码段是不是就不能对其进行覆盖了,其实不是的,子类的虚表是先拷贝父类虚表,然后进行覆盖,覆盖完毕后才存储到代码段中的。

2、多态的原理

讲了怎么多,那么多态的原理到底是什么呢?我们还是以下面的代码为例:

代码语言:javascript
复制
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;
};
image-20230313210052590
image-20230313210052590
image-20230313205456275
image-20230313205456275

如上,结合运行结果和反汇编分析:

1、可以看到,当调用 Func3 时,由于 func3 是普通函数,所以即使调用 func3 函数的是父类的指针也仍然不满足多态调用,此时调用只与对象有关,与对象指向的类型无关,由于 ptr 是 Base 的指针,所以全部调用 Base 的是父类的 func3;从反汇编我们也可以看到,调用 Func3 时是直接 call 父类 Func3 函数的地址。 2、然而当调用 Func1 时,由于 func1 是虚函数,且子类对其进行了重写,再加上是父类的指针去调用,所以这里满足多态调用;此时调用与对象指向的类型有关,当 ptr 指向父类对象 b 时,调用父类的 Func1 函数,指向子类对象 d 时,调用子类的 Func1 函数; 3、从反汇编我们能更清楚的看出多态的调用逻辑 – 当进行多态调用时,编译器会先将 ptr 移动到 eax 中,然后取出 eax 中的前四个字节内容移动到 edx 中 (这个步骤相当于取出 ptr 指向对象的虚表指针 vfptr), 然后再取出 edx 中的前四个字节内容移动到 eax 中 (因为 Func1 函数是最先定义的虚函数,所以其地址位于虚表中的前四个字节,所以这个步骤相当于取出 ptr 指向对象的虚表中 Func1 函数的地址),最后 call eax 中存放的地址,也就是 call Func1。

总结:

当进行多态调用时,父类指针 ptr 如果指向的是父类对象,就去父类对象的虚表里面取被调函数的地址,此时取出来的地址是父类的虚函数地址; 如果指向的是子类对象中父类的那一部分,则去子类对象中属于父类对象那部分中找到虚表,然后从虚表里面取出被调函数的地址,由于子类对象对虚表进行了覆盖,所以取出来的地址是子类重写后虚函数的地址; 这样,就实现了父类的指针/引用指向父类对象就调父类虚函数,指向子类对象就调用子类虚函数,从而实现了多态。所以,多态的运行原理是: 依靠虚表实现指向谁就调用谁。

3、动态绑定与静态绑定

  1. 静态绑定又称为编译时绑定,它在程序编译期间就确定了程序的行为,也称为静态多态;函数重载是静态绑定的典型案例。
  2. 动态绑定又称运行时绑定,它是在程序运行期间,根据具体拿到的类型确定程序的具体行为,调用具体的函数,也称为动态多态;像多态调用这种,在运行时去指向的对象的虚表中取被调虚函数的地址,根据取得的地址来调用具体的虚函数的行为就是典型的动态绑定。
  3. 我们上面讲解多态原理时给出的反汇编代码很好的解释了什么是编译时绑定和运行时绑定。(一个在编译时就确定函数地址,后面运行时直接调用该地址;一个在运行时才去别处取函数的地址进行调用)

拓展思考:为什么父类对象不能实现多态,而必须是父类的指针/引用?

因为子类对象赋值给父类对象时不会将子类对象的虚表拷贝给父类,也就无法运行时动态绑定;而且就算拷贝,父类也区分不了此时的父类对象的虚表是父类本身的虚表还是从子类拷贝过来的虚表,所以只能实现静态绑定,而无法实现多态。


四、单继承和多继承的虚表

需要注意的是在单继承和多继承关系中,下面我们去关注的是派生类对象的虚表模型,因为基类的虚表模型前面我们已经看过了,就是将所有的虚函数都放进虚表中,然后在类对象中增加一个 vfptr 来指向这个虚表而已,模型比较简单。

1、单继承的虚表

代码语言:javascript
复制
class Base {
public:
	virtual void func1() { cout << "Base::func1" << endl; }
	virtual void func2() { cout << "Base::func2" << endl; }
private:
	int a;
};

class Derive :public Base {
public:
	virtual void func1() { cout << "Derive::func1" << endl; }
	virtual void func3() { cout << "Derive::func3" << endl; }
	virtual void func4() { cout << "Derive::func4" << endl; }
private:
	int b;
};
image-20230313220022470
image-20230313220022470

观察监视窗口,我们发现,子类重写了父类的 func1,继承了父类的 func2,但是这里发生了一个奇怪的事情 – 子类自己的虚函数 func3 和 func4 并不在监视窗口中;这里其实是编译器结果优化处理后在监视窗口故意隐藏了这两个函数,也可以认为是他的一个小 bug,我们可以通过内存窗口来查看子类的虚表:

image-20230313220601234
image-20230313220601234

但是,每次都要通过调用内存窗口来查看虚表很不方便,所以我们可以自己写一个虚表的打印函数,如下:

代码语言:javascript
复制
//将返回值为void,参数为void的函数指针重命名为 VFPTR
typedef void(*VFPTR)();  

void PrintVTable(VFPTR vTable[])
{
	// 依次取虚表中的虚函数指针打印并调用,调用可以看出虚表中存的是哪个函数
	cout << " 虚表地址>" << vTable << endl;
	for (int i = 0; vTable[i] != nullptr; ++i)  
	{
		printf(" [%d]:%p,->", i, vTable[i]);
		vTable[i]();  
	}
	cout << endl;
}
image-20230313222337457
image-20230313222337457

此时,我们就可以很清楚的观察到了:在单继承中,子类首先会拷贝父类的虚表,然后进行重写,最后将自己特有的虚函数的地址填入虚表中

注意事项:

  • 此函数仅保证能在 VS 下成功运行,因为在其他平台下虚表最后一个元素不一定是 nullptr;
  • 函数指针的 typedef 与其他类型的 typedef 不同,重命名的名字要放在括号里面作为函数名 (函数名就是函数指针);
  • 使用函数指针调用函数是不必解引用,因为函数名就是函数指针。
  • 由于编译器的某些原因,如果在 VS 下运行 PrintVTable 出现运行崩溃的情况,可以在生成选项中先清理解决方案,然后重新运行即可。

2、多继承的虚表

代码语言:javascript
复制
class Base1 {
public:
	virtual void func1() { cout << "Base1::func1" << endl; }
	virtual void func2() { cout << "Base1::func2" << endl; }
private:
	int b1;
};

class Base2 {
public:
	virtual void func1() { cout << "Base2::func1" << endl; }
	virtual void func2() { cout << "Base2::func2" << endl; }
private:
	int b2;
};

class Derive : public Base1, public Base2 {
public:
	virtual void func1() { cout << "Derive::func1" << endl; }
	virtual void func3() { cout << "Derive::func3" << endl; }
private:
	int d1;
};
image-20230313230018347
image-20230313230018347
image-20230313230114569
image-20230313230114569

在上述代码中,父类 Base1 中有两个虚函数,要建立虚表;Base2 中也有两个虚函数,也要建立虚表;然后子类同时继承了 Base1 和 Base2,通过监视窗口,我们可以看到,子类拥有两张虚表,一张是从 Base1 拷贝过来的,一张是从 Base2 拷贝过来的;

同时,因为子类有两种虚表,所以我们也需要对子类对象调用两次 PrintVTable 函数,一次打印子类对像中父类 Base1 的虚表,另一次打印 Base2 的虚表;通过观察 PrintVTable 的打印结果我们可以发现,子类中特有的虚函数存放在第一个继承的父类的虚表的后面。

所以,在多继承中,父类一共有多少张虚表,那么子类就会拷贝多少张虚表,然后进行重写,最后将自己特有的虚函数的地址添加到最先继承的父类的虚表后面

image-20230313231153296
image-20230313231153296

需要特别注意的是,子类在对一个虚函数进行重写时,如果同时对应了不同父类的虚函数,则在进行虚表覆盖时不同父类的虚表中的同一个被重写函数覆盖的不是同一个地址。

image-20230313231103129
image-20230313231103129

3、菱形继承、菱形虚拟继承的虚表

在上一节继承中我们就提到,实际中我们不建议设计出菱形继承及菱形虚拟继承,一方面太复杂容易出问题,另一方面这样的模型,访问基类成员有一定得性能损耗;实际上研究菱形继承和菱形虚拟继承在实际工作中是几乎没有意义的

但是在继承中,我们说要掌握菱形继承和菱形虚拟继承,这是因为校招时要考察;但是几乎没人会去考察菱形继承和菱形虚拟继承的虚表模型,所以我们这里不再对其进行深入探索,如果好奇心比较强的童鞋,可以看看程皓大佬写的这两篇文章:

C++ 虚函数表解析 | 酷 壳 - CoolShell

C++ 对象的内存布局 | 酷 壳 - CoolShell


五、继承和多态常见的面试问题

1、概念考察

  1. 下面哪种面向对象的方法可以让你变得富有 ( ) A: 继承 B: 封装 C: 多态 D: 抽象
  2. ( ) 是面向对象程序设计语言中的一种机制。这种机制实现了方法的定义与具体的对象无关, 而对方法的调用则可以关联于具体的对象。 A: 继承 B: 模板 C: 对象的自身引用 D: 动态绑定
  3. 面向对象设计中的继承和组合,下面说法错误的是?() A:继承允许我们覆盖重写父类的实现细节,父类的实现对于子类是可见的,是一种静态复 用,也称为白盒复用 B:组合的对象不需要关心各自的实现细节,之间的关系是在运行时候才确定的,是一种动 态复用,也称为黑盒复用 C:优先使用继承,而不是组合,是面向对象设计的第二原则 D:继承可以使子类能自动继承父类的接口,但在设计模式中认为这是一种破坏了父类的封 装性的表现
  4. 以下关于纯虚函数的说法,正确的是( ) A:声明纯虚函数的类不能实例化对象 B:声明纯虚函数的类是虚基类 C:子类必须实现基类的纯虚函数 D:纯虚函数必须是空函数
  5. 关于虚表说法正确的是( ) A:一个类只能有一张虚表 B:基类中有虚函数,如果子类中没有重写基类的虚函数,此时子类与基类共用同一张虚表 C:虚表是在运行期间动态生成的 D:一个类的不同对象共享该类的虚表
  6. 假设A类中有虚函数,B继承自A,B重写A中的虚函数,也没有定义任何虚函数,则( ) A:A类对象的前4个字节存储虚表地址,B类对象前4个字节不是虚表地址 B:A类对象和B类对象前4个字节存储的都是虚基表的地址 C:A类对象和B类对象前4个字节存储的虚表地址相同 D:A类和B类虚表中虚函数个数相同,但A类和B类使用的不是同一张虚表
  7. 下面程序输出结果是什么? ()
代码语言:javascript
复制
#include<iostream>
using namespace std;
class A {
public:
	A(char* s) { cout << s << endl; }
	~A() {}
};
class B :virtual public A
{
public:
	B(char* s1, char* s2) :A(s1) { cout << s2 << endl; }
};
class C :virtual public A
{
public:
	C(char* s1, char* s2) :A(s1) { cout << s2 << endl; }
};
class D :public B, public C
{
public:
	D(char* s1, char* s2, char* s3, char* s4) :C(s1, s3), B(s1, s2),  A(s1)
	{
		cout << s4 << endl;
	}
};
int main() {
	D* p = new D("class A", "class B", "class C", "class D");
	delete p;
	return 0;
}

A:class A class B class C class D B:class D class B class C class A C:class D class C class B class A D:class A class C class B class D 解析:A B C D 是菱形虚拟继承关系,所以 D 对象中只存在一份 A,所以 A 只能调用一次构造函数,并且 A 对象应该由 D 对象来调用其构造;同时,变量初始化的顺序与变量在初始化列表出现的顺序无关,而与变量的声明顺序有关,对应到继承关系中,就是先被继承的先完成初始化。 变形:下面程序输出结果是什么? ()

代码语言:javascript
复制
#include<iostream>
using namespace std;
class A {
public:
	A(char* s) { cout << s << endl; }
	~A() {}
};
class B : public A
{
public:
	B(char* s1, char* s2) :A(s1) { cout << s2 << endl; }
};
class C : public A
{
public:
	C(char* s1, char* s2) :A(s1) { cout << s2 << endl; }
};
class D :public B, public C
{
public:
	D(char* s1, char* s2, char* s3, char* s4) :C(s1, s3), B(s1, s2),  A(s1)
	{
		cout << s4 << endl;
	}
};
int main() {
	D* p = new D("class A", "class B", "class C", "class D");
	delete p;
	return 0;
}

解析:A B C D 为菱形继承,所以 D 对象中的属于 B 和 属于 C 的部分各有一个 A,即 A 应该调用两次构造;同时,变量初始化 的顺序与变量在初始化列表出现的顺序无关,而与变量的声明顺序有关,对应到继承关系中,就是先被继承的先完成初始化。

所以,输出顺序是:ABACD 8. 多继承中指针偏移问题?下面说法正确的是( )

代码语言:javascript
复制
class Base1 { public:  int _b1; };
class Base2 { public:  int _b2; };
class Derive : public Base1, public Base2 { public: int _d; };
int main() {
	Derive d;
	Base1* p1 = &d;
	Base2* p2 = &d;
	Derive* p3 = &d;
	return 0;
}

A:p1 == p2 == p3 B:p1 < p2 < p3 C:p1 == p3 != p2 D:p1 != p2 != p3

变形:下面说法正确的是( )

代码语言:javascript
复制
class Base1 { public:  int _b1; };
class Base2 { public:  int _b2; };
class Derive : public Base2, public Base1 { public: int _d; };
int main() {
	Derive d;
	Base1* p1 = &d;
	Base2* p2 = &d;
	Derive* p3 = &d;
	return 0;
}

解析:子类对象模型中先被继承的父类,其父类模型会被放在子类对象模型的上方;栈的使用规则是先使用高地址,再使用低地址,而对象模型内部是先使用低地址,再使用高地址,即先继承的父类其对象模型在子类模型的低地址处。

所以,大小关系为:p1 > p2 == p3 9. 以下程序输出结果是什么()

代码语言:javascript
复制
class A
{
public:
	virtual void func(int val = 1) { std::cout << "A->" << val << std::endl; }
	virtual void test() { func(); }
};
class B : public A
{
public:
	void func(int val = 0) { std::cout << "B->" << val << std::endl; }
};
int main(int argc, char* argv[])
{
	B* p = new B;
	p->test();
	return 0;
}

A: A->0 B: B->1 C: A->1 D: B->0 E: 编译出错 F: 以上都不正确

解析:在上面抽象类那里讲了。

下面函数输出结果是( )

代码语言:javascript
复制
class A {
public:
	virtual void f() {
		cout << "A::f()" << endl;
	}
};

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

int main() {
	A* pa = (A*)new B;
	pa->f();
}

A.B::f() B.A::f(),因为子类的f()函数是私有的 C.A::f(),因为强制类型转化后,生成一个基类的临时对象,pa实际指向的是一个基类的临时对象 D.编译错误,私有的成员函数不能在类外调用 解析:虽然子类函数为私有,但是多态仅仅是用子类函数的地址覆盖虚表,最终调用的位置不变,只是执行函数发生变化。

参考答案:

  1. A 2. D 3. C 4. A 5. D 6. D 7. A 8. C 9. B 10. A

2、问答题

  1. 什么是多态?-- 静态多态 (重载) 和 动态多态 (虚表) 结合回答。
  2. 什么是重载、重写 (覆盖)、重定义 (隐藏)?-- 参照上一节继承。
  3. 多态的实现原理?-- 参照上面多态的原理。
  4. inline 函数可以是虚函数吗?-- 从语义上来讲,如果是多态调用,虚函数不可以是内联函数,因为多态要进行动态绑定,如果是普通调用,可以是内联函数;从语法上来讲,不管是普通调用还是多态调用,将虚函数定义为内联函数都不会编译报错,因为 inline 只是一个建议关键字,具体是否为内联由编译器决定。
  5. 静态成员可以是虚函数吗?-- 不能,因为静态成员没有 this 指针,而虚函数要通过虚表调用,虚表要通过对象中的虚表指针来查找,无法使用类型::成员函数的调用方式直接访问虚表。
  6. 构造函数可以是虚函数吗?-- 不能,因为如果构造函数是虚函数的话,实例化对象时需要先从虚表中取出构造函数的地址,虽然虚表在编译时就生成好了,但是虚表指针在构造函数的初始化列表完成初始化的,所以再对象完成构造之前,我们无法通过虚表指针来访问虚表。
  7. 析构函数可以是虚函数吗?-- 可以,并且我们建议将父类的析构函数都定义为虚函数,具体原因参照上一节继承。
  8. 对象访问普通函数快还是虚函数更快?如果虚函数不是多态调用,则一样块;如果虚函数是多态调用,则普通函数访问更快,因为虚函数多态调用需要运行时到虚表中去取虚函数的地址,然后再 call。
  9. 虚函数表是在什么阶段生成的,存在哪的? – 虚函数表在编译阶段生成,存在于代码段/常量区,并且同一类型共享一张虚表。
  10. C++菱形继承存在的问题?虚继承是如何解决菱形继承存在的问题的?-- 参照上一节继承。
  11. 什么是抽象类?抽象类的作用?-- 参照上面抽象类。

结语:继承和多态是 C++ 中非常重要的两个知识点,在面试中出现十分频繁,希望大家能真正把它理解,而不是浅尝辄止,亦或是在面试前背八股突击。


本文参与 腾讯云自媒体同步曝光计划,分享自作者个人站点/博客。
原始发表:2023-03-16,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 作者个人站点/博客 前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 文章目录
  • 一、多态基础知识
    • 1、多态的概念
      • 2、虚函数
        • 3、多态的定义和实现
          • 4、析构函数的重写
            • 5、C++11 override 和 final
              • 6、重载/隐藏/重写的对比
              • 二、抽象类
                • 1、抽象类概念
                  • 2、接口继承和实现继承
                    • 3、一道非常坑的笔试/面试题
                    • 三、多态的原理
                      • 1、虚函数表/虚表
                        • 2、多态的原理
                          • 3、动态绑定与静态绑定
                          • 四、单继承和多继承的虚表
                            • 1、单继承的虚表
                              • 2、多继承的虚表
                                • 3、菱形继承、菱形虚拟继承的虚表
                                • 五、继承和多态常见的面试问题
                                  • 1、概念考察
                                    • 2、问答题
                                    领券
                                    问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档