C++多态

目录

1.多态的概念

2.多态的条件及实现

3.抽象类

4.多态的原理

5.单继承与多继承关系中的虚函数表

6.继承和多态常见面试题

1.多态的概念

多态的概念:通俗来说,就是多种形态,具体点就是去完成某个行为,当不同的对象去完成时会产生出不同的状态。

比如买票这个行为,当普通人买票时,是全价买票。学生买票时,是半价买票。

2.多态的条件及实现

1.虚函数:被virtual修饰的类成员函数

2.虚函数的重写: 派生类中有一个和基类完全相同的虚函数**(虚函数+三同:返回值类型 函数名 参数列表),** 就称子类虚函数重写了基类虚函数**。**

cpp 复制代码
class Person {
public:
virtual void BuyTicket() { cout << "买票-全价" << endl; }
};
class Student : public Person {
public:
virtual void BuyTicket() { cout << "买票-半价" << endl; }
void Func(Person&p)
{
p.BuyTicket();
}

注:

1.派生类的重写虚函数可以不加virtual,建议还是加上,基类必须加

2.协变:返回值类型可以不同,但是返回值必须是父子关系的指针或引用

cpp 复制代码
class A{};
class B : public A {};
class Person {
public:
virtual A* f() {return new A;}
};
class Student : public Person {
public:
virtual B* f() {return new B;}
}

3.析构函数的重写:如果基类的析构函数是虚函数,派生类析构函数无论加不加virtual都与它构成重写。编译器对析构函数的名称统一处理成了destructor。(例如下面情况,为了防止一个指针先指向父类,后改变指向子类,不能多态调用对应的析构函数了)

3.多态的条件:

i.必须通过基类的指针或引用调用虚函数

Q1:为什么不能用子类指针或引用?

子类指针和引用都只能指向派生类对象,调用派生类自身对象。

Q2:为什么不能用基类对象?

子类赋值给父类对象会发生切片 (只把父类那部分切过来赋值过去),不会拷贝虚表(如果拷贝虚表,父类对象的虚表就不知道是父类还是子类的虚函数了)。

ii.派生类必须对基类的虚函数进行重写

多态调用看的是指向的对象!!若为普通对象,看的是当前类型。

例题:

cpp 复制代码
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: 以上都不正确

因为func为虚函数,并且三同,所以构成虚函数重写。

因为虚函数的调用会根据对象实际类型(B)来决定,所以会调用 B 的func()

C++ 中虚函数的默认参数是在编译时根据指针 / 引用的类型确定的,而不是运行时的对象类型,所以p调用test时this指针的类型其实是A*,默认参数为1,所以最后答案是B->1。

4.C++11提供了新的两个关键字

override:检查派生类虚函数是否重写,没有

则报错。

final:修饰虚函数,让该虚函数不能被重写。

如何设计一个不想被继承的类?

方法一:基类构造函数私有

cpp 复制代码
class A
{
public: 
static A createobj()
{
return A();
}
private:
A()
{}
}

方法二:基类加一个final

cpp 复制代码
class A final
{
public:
.....
private:
.....

};

5.重载、重写(覆盖)、重定义(隐藏)的区别

重载:

两个函数在同一作用域

函数名相同,参数(个数/类型/类型顺序)不同

重写(覆盖):

两个函数分别在基类和派生类的作用域

两个函数必须是虚函数

函数名/参数/返回值必须相同(除协变外)

重定义(隐藏):

两个函数分别在基类和派生类的作用域

函数名相同即可

3.抽象类

1.概念:在虚函数的后面写上=0,则这个函数为纯虚函数。包含纯虚函数的类叫做抽象类(也叫接口类,这类在现实世界中没有对应实体),抽象类不能实例化出对象,派生类继承后必须重写才能实例化出对象。相当于间接强制派生类重写虚函数。

eg:

cpp 复制代码
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;
}
};
void Test()
{
Car* pBenz = new Benz;
pBenz->Drive();
Car* pBMW = new BMW;
pBMW->Drive();
}

2.接口继承和实现继承

普通函数的继承是实现继承,派生类继承的是基类函数的实现,虚函数的继承是一种接口继承,派生类继承的是基类虚函数的接口,为了重写达成多态。

如果不实现多态,就不要把函数定义为虚函数

4.多态的原理

1.虚函数表

cpp 复制代码
// 这里常考一道笔试题:sizeof(Base)是多少?
class Base
{
public:
virtual void Func1()
{
cout << "Func1()" << endl;
}
private:
int _b = 1;
};

运行结果是八个字节,为什么呢

通过调试我们发现除了_b成员,还多一个__vfptr放在对象的前面(注意有些平台可能会放到对象的最后面,这个跟平台有关),对象中的这个指针我们叫做虚函数表指针(v代表virtual,f代表function)。一个含有虚函数的类中都至少都有一个虚函数表指针,因为虚函数的地址要被放到虚函数表中,虚函数表也简称虚表。

那么如果是派生类中这个表放了些什么呢?我们看看下面这个例子

cpp 复制代码
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;
};

调试一下我们发现ptr1和ptr2中的虚表地址不同,但ptr3和ptr1是一样的,为什么?

  1. Derive同时继承Base1Base2(都含虚函数)时,编译器会为Derive生成两张虚表

    • 第一张与Base1的继承体系关联(包含Base1的虚函数及重写版本)
    • 第二张与Base2的继承体系关联(包含Base2的虚函数及重写版本)
  2. 指针类型决定访问哪个 vtable

    • ptr1Base1*类型:指向Derive对象中与Base1相关的部分,因此使用第一张 vtable
    • ptr2Base2*类型:指向Derive对象中与Base2相关的部分,因此使用第二张 vtable
    • ptr3Derive*类型:默认指向对象的起始地址(与Base1的 vtable 位置一致),因此与ptr1的 vtable 地址相同

ptr3作为派生类指针,默认指向第一个基类的 vtable,因此与ptr1一致

被重写后func1在ptr1,ptr2的地址也不同,这是为什么呢?

Derive重写了func1,但这个重写版本会同时放入两张 vtable 中 (分别替换Base1::func1Base2::func1的位置)。虽然两个 vtable 中的func1最终都指向Derive::func1的实现,但由于两张 vtable 本身的地址不同,导致调试时看到的 "func1 地址"(即 vtable 中存储的函数指针位置)表现为不同值。

为什么要这么做呢,让我们看看他的底层是怎么实现的。

上半部分:ptr1->func1() 的执行流程

  • mov ecx, dword ptr [ptr1]:取出 ptr1 指向的 Derive 对象的虚表指针(vptr)。
  • mov eax, dword ptr [eax]:通过虚表指针,找到虚表中 func1 对应的函数指针 (此时指向 Derive::func1 的入口)。
  • call eax:调用该函数指针,最终会 jmpDerive::func1 的实际代码(地址 06C2840h)。

此时 this 指针无需额外调整**,因为 ptr1 是第一个基类(Base1)的指针,Derive 对象中 Base1 的部分与对象起始地址一致。**

下半部分:ptr2->func1() 的执行流程

  • 同样先通过 ptr2 的虚表指针找到 func1 的函数指针,但此时找到的是一个 "跳板函数" (地址 06C134Dh),而非直接跳转到 Derive::func1
  • 执行 jmp Derive::func1 (06C28C0h) 进入 "修正 this 指针" 的逻辑:
    • sub ecx, 8:调整 ecx 寄存器(this 指针的载体 )的值,因为 ptr2 是第二个基类(Base2)的指针,Base2Derive 对象中的内存位置比 Base1 晚 8 字节(两个 int 成员)。
    • jmp Derive::func1 (06C123Fh):最终跳转到和 ptr1 调用时逻辑一致Derive::func1 实现。

总而言之,因为我们需要先传this指针,再call它的地址调用func1,Base1的this恰好与Derive都指向最开始,而Base2需要减去一个Base1的大小才能指向最开始。

func3也是虚函数,他的虚表指针被放在哪里了呢?

在多重继承中,新增的虚函数默认添加到第一个基类对应的 vtable

可以通过下面的方式打印虚表,验证

cpp 复制代码
​
typedef void(*VFPTR) ();
void PrintVTable(VFPTR vTable[])
{
// 依次取虚表中的虚函数指针打印并调用。调用就可以看出存的是哪个函数
cout << " 虚表地址>" << vTable << endl;
for (int i = 0; vTable[i] != nullptr; ++i)
{
printf(" 第%d个虚函数地址 :0X%x,->", i, vTable[i]);
VFPTR f = vTable[i];
f();
}
cout << endl;
}
int main()
{
Base b;
Derive d;
// 思路:取出b、d对象的头4bytes,就是虚表的指针,前面我们说了虚函数表本质是一个存虚函数
//指针的指针数组,这个数组最后面放了一个nullptr
// 1.先取b的地址,强转成一个int*的指针
// 2.再解引用取值,就取到了b对象头4bytes的值,这个值就是指向虚表的指针
// 3.再强转成VFPTR*,因为虚表就是一个存VFPTR类型(虚函数指针类型)的数组。
// 4.虚表指针传递给PrintVTable进行打印虚表
// 5.需要说明的是这个打印虚表的代码经常会崩溃,因为编译器有时对虚表的处理不干净,虚表最
//后面没有放nullptr,导致越界,这是编译器的问题。我们只需要点目录栏的-生成-清理解决方案,再
//编译就好了。
VFPTR* vTableb = (VFPTR*)(*(int*)&b);
PrintVTable(vTableb);
VFPTR* vTabled = (VFPTR*)(*(int*)&d);
PrintVTable(vTabled);
return 0;
}

​

注:

虚函数表本质是一个存虚函数指针的指针数组 ,一般这个数组最后放了一个nullptr

派生类的虚表生成:

1.先将基类中的虚表内容拷贝一份到派生类虚表中

2.如果派生类重写了基类中某个虚函数,用派生类自己的虚函数覆盖虚表中基类的虚函数

同类型的对象共用虚表,一个类的不同对象共享该类的虚表。

虚表存的是虚函数指针,不是虚函数,虚函数和普通函数一样的,都是存在代码段,只是

他的指针又存到了虚表中。另外对象中存的不是虚表,存的是虚表指针。

动态绑定和静态绑定

  1. 静态绑定又称为前期绑定(早绑定),在程序编译期间确定了程序的行为,也称为静态多态,比如:函数重载

  2. 动态绑定又称后期绑定(晚绑定),是在程序运行期间,根据具体拿到的类型确定程序的具体行为,调用具体的函数,也称为动态多态。

6.继承和多态常见面试题

什么是多态?

通过传不同参数实现静态多态(函数重载)

通过传不同对象实现动态多态(继承中虚函数的重写+父类指针调用),更方便灵活的实现多种形态调用

多态的实现原理?

函数名修饰规则 虚函数表

inline函数可以是虚函数吗?

可以,不过编译器会忽略inline属性,这个函数就不再是inline,因为虚函数要放到虚表中去。

静态成员可以是虚函数吗?

不能,因为静态成员函数没有this指针,使用类型::成员函数的调用方式无法访问虚函数表,所以静态成员函数无法放进虚函数表,无法实现多态,语法会强制检查。

构造函数可以是虚函数吗?

不能,因为对象中的虚函数表指针是在构造函数初始化列表阶段才初始化的。

以下程序输出结果是( )

class A

{

public:

A ():m_iVal(0){test();}

virtual void func() { std::cout<<m_iVal<<' ';}

void test(){func();}

public:

int m_iVal;

};

class B : public A

{

public:

B(){test();}

virtual void func()

{

++m_iVal;

std::cout<<m_iVal<<' ';

}

};

int main(int argc ,char* argv[])

{

A*p = new B;

p->test();

return 0;

}

A.1 0

B.0 1

C.0 1 2

D.2 1 0

E.不可预期

F. 以上都不对

分析:

1.new B时先调用父类A的构造函数,执行test()函数,在调用func()函数,由于此时还处于对象构造阶段,多态机制还没有生效 ,所以,此时执行的func函数为父类的func函数,打印0

构造完父类后执行子类构造函数,又调用test函数,然后又执行func(),由于父类已经构造完毕,虚表已经生成,func满足多态的条件,所以调用子类的func函数,对成员m_iVal加1,进行打印,所以打印1

最终通过父类指针p->test(),也是执行子类的func,所以会增加m_iVal的值,最终打印2,

所以答案为C 0 1 2

析构函数可以是虚函数吗?什么场景下析构函数是虚函数?

可以,并且最好把基类的析构函数定义成虚函数。例如car *p=new benz;delete p;

对象访问普通函数快还是虚函数更快?

首先如果是普通对象,是一样快的。

如果是指针对象或者是引用对象,则调用的普通函数快,因为构成多态,运行时调用虚函数需要到虚函数表中去查找。

虚函数表是在什么阶段生成的,存在哪的?

虚函数表是在编译阶段 就生成的,一般情况下存在**代码段(常量区)**的。

什么是抽象类?抽象类的作用?

参考(3.抽象类)。抽象类强制重写了虚函数,另外抽象类体现出了接口继承关系。

C++菱形继承的问题?虚继承的原理?

数据冗余和二义性。

核心原理

  1. 虚继承会让派生类(A 和 B)不直接存储基类(Base)的成员,而是存储一个间接指针(通常称为 "虚基类指针",vbptr)
  2. 这个指针指向一个 "虚基类表"(vbtable),表中记录了当前类到虚基类成员的偏移量
  3. 最终派生类(C)会直接包含一份虚基类(Base)的成员,A 和 B 通过偏移量共享这一份成员
相关推荐
让我们一起加油好吗3 小时前
【基础算法】01BFS
数据结构·c++·算法·bfs·01bfs
_w_z_j_4 小时前
C++11----列表初始化和initializer_list
开发语言·c++
1白天的黑夜14 小时前
递归-24.两两交换链表中的节点-力扣(LeetCode)
数据结构·c++·leetcode·链表·递归
1白天的黑夜15 小时前
递归-206.反转链表-力扣(LeetCode)
数据结构·c++·leetcode·链表·递归
Fcy6485 小时前
C++ vector容器的解析和使用
开发语言·c++·vector
无限进步_5 小时前
C语言文件操作全面解析:从基础概念到高级应用
c语言·开发语言·c++·后端·visual studio
_OP_CHEN5 小时前
C++基础:(十五)queue的深度解析和模拟实现
开发语言·c++·stl·bfs·queue·容器适配器·queue模拟实现
sulikey5 小时前
一文彻底理解:如何判断单链表是否成环(含原理推导与环入口推算)
c++·算法·leetcode·链表·floyd·快慢指针·floyd判圈算法
起床气2335 小时前
C++海战棋开发日记(序)
开发语言·c++