C++多态

多态的概念

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

多态的使用

要触发 C++ 的多态调用(运行时动态绑定),需同时满足以下 3 个核心条件:

条件1父类声明虚函数 :在父类中,将需要实现多态的成员函数前添加virtual关键字(析构函数?后文详说),该函数成为虚函数。

条件2子类完成虚函数的合法重写 (Override):1.父子类中两个虚函数,三同(函数名,参数,返回)(理解成隐藏的一个子集)2.例外,协变返回类型,后文详说 。 总结为"基本三同+协变返回 "。

条件3父类的指针或引用去调用虚函数反例 :若用子类 指针 / 引用指向子类对象 (静态类型 = 动态类型),或用父类对象(非指针 / 非引用 )调用虚函数,均会触发编译期静态绑定(普通调用),无法实现多态。

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

class Student : public Person
{
public:
	virtual void BuyTicket() { cout << "买票-半价" << endl; }
};

void Func(Person& p)
{
	p.BuyTicket();
}
int main()
{
	Person ps;
	Student st;

	Func(ps);
	Func(st);
}

注意:基类加virtual,派生类自动继承虚函数属性,不用显式写virtual。

关于普通调用与多态调用

普通调用(编译时静态绑定) :普通调用是指编译器在编译阶段就根据"调用者的类型"(而非实际对象类型)确定要调用的函数地址 ,直接将函数调用指令写入编译后的代码中,运行时无需额外判断。

普通调用的核心是"看类型,不看实际对象"

普通调用触发条件: 调用非虚函数时(无论通过对象、指针还是引用调用); 调用虚函数,但通过对象直接调用(非指针 / 引用)时(此时也会退化为普通调用)。

多态调用(运行时动态绑定) :多态调用是指编译器在编译阶段无法确定函数调用目标,需在运行阶段,根据 "指针 /引用实际指向的对象类型" ,动态找到对应的函数地址并调用。

多态调用的核心是"看实际对象的类型"

满足多态调用的三个必备条件(三者缺一不可): 1.基类中声明虚函数 2.派生类重写虚函数 3.通过基类指针 / 引用调用

总结:普通调用看指针 / 引用类型,多态调用看指向内容(实际对象)的类型。

绑定方式 调用类型 绑定时机 核心特征
静态绑定 普通调用 编译期 编译时就确定调用哪个函数
动态绑定 多态调用 运行期 运行时才确定调用哪个函数

补充 :静态多态与动态多态 动态多态:运行期绑定的多态 ,就是上面所说的多态 静态多态:编译期绑定的多态,也叫 "编译期多态"------ 编译器在编译阶段就确定要调用的函数。常见例子:函数重载,模版

虚函数重写的两个例外

协变返回值

1.协变返回值,父类与子类的虚函数的返回值可以不同(要求虚函数的返回值必须是父子类关系的指针或引用)

c 复制代码
先写一个父子类AB
class A
{};
class B : public A 
{};

class Member 
{
public:
    virtual A* f() //虚函数的返回值必须是一个父子类的指针或引用
    {
        return new A;
    }
};
class Student : public Member 
{
public:
    virtual B* f() //虚函数的返回值必须是一个父子类的指针或引用
    {
        return new B;
    }
};

总结

  • 返回值必须是指针或引用(不能是值类型,比如A f()和B f()不构成协变);
  • 派生类返回 的指针 / 引用类型,必须是基类返回类型的直接 / 间接派生类(这里B继承A,满足条件)。

析构函数的重写

析构函数的名字特殊处理,构成隐藏,建议函数名前加virtual。

这句话是关键基类指针指向派生类对象 时,非虚析构 会触发普通调用虚析构 会触发多态调用 ​​

c 复制代码
class Member {
public:
	~Member() 
	{ 
		cout << "~Member()" << endl; 
	}
};
class Student : public Member {
public:
	~Student() 
	{ 
		cout << "~Student()" << endl; 
	}
};
//此时析构函数构成隐藏
int main()
{
	Member* p1 = new Member;
	Member* p2 = new Student;

	delete p1;
	delete p2;
	return 0;
}

结果为: ​​​​

​​​​现象:观察发现在第二个指针析构时只析构了父类,而没有析构子类,可能会导致内存泄漏。

根本原因:是普通调用,只看指针\引用类型,而不看指向内容的类型。

分析:

  1. 两个析构函数构成了隐藏(析构函数虽然名称格式特殊(~类名()),但编译器会将其视为 "同名函数",因此Student::~Student()会隐藏Member::~Member()。 ),实际上也满足了重写条件(由于析构函数的特殊规则,导致不能叫重写,加上virtual后变成虚函数后,自动满足重写)------这样达成2个条件 1.父类声明虚函数 2.子类虚函数重写。

析构函数的特殊规则 :析构函数是否构成 "重写" 或 "隐藏",完全取决于基类析构函数是否被声明为virtual------ 基类析构虚函数 时,派生类析构 构成 "重写 ";基类析构非虚 时,派生类析构 构成 "隐藏"。

  1. 基类指针指向派生类对象 时(达成最后一个条件:3.父类的指针调用虚函数 ),非虚析构 会触发普通调用虚析构 会触发多态调用("虚"满足父类声明虚函数,"析构"满足子类虚函数重写)

总结以上:也就是说只要加上virtual就可以达成多态调用的3个条件,从而进行多态调用根据指向内容的类型,进行多态析构。

解决方式析构函数特殊处理,在函数名前面加上virtual,变成虚函数从而满足3个多态调用的条件 ,从而变成多态调用,指向内容是父类类型调父类析构,指向内容是子类类型调子类析构,这个时候p1 delete时就调用Member的析构函数,p2 delete 时就调用Student的析构函数(子类析构完时自动调用父类析构函数)。

回顾:delete的底层 分为p->destructor() + operator delete(p)这两步 第一步 :析构函数调用p->~类名() 只有当p指向的是类类型对象(包括自定义类、标准库类等)时,这一步才会执行;若p指向内置类型(int、char等),则无此步骤,直接进入内存释放阶段。 第二步 :内存释放operator delete(p) 将p指向的内存块归还给堆分配器。 标准库默认的operator delete函数,其底层通常调用 C 语言的free()函数。

c 复制代码
class Member {
public:
	virtual ~Member() 
	{ 
		cout << "~Member()" << endl; 
	}
};
class Student : public Member {
public:
	~Student()  // 自动成为虚析构(无需显式写virtual)
	{ 
		cout << "~Student()" << endl; 
	}
};
// 只有派生类Student的析构函数重写了Person的析构函数,下面的delete对象调用析构函
//数,才能构成多态,才能保证p1和p2指向的对象正确的调用析构函数。
int main()
{
	Member* p1 = new Member;
	Member* p2 = new Student;

	delete p1;
	delete p2;
	return 0;
}

结果如下:​​

一道难题

c 复制代码
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()
{
	B* p = new B;
	p->test();
	return 0;
}

类 A 的定义virtual void func(int val = 1):虚函数func,默认参数为 1,输出A->val。 virtual void test() { func(); }:虚函数test,内部调用func()。 类 B 的定义void func(int val = 0):重写基类A的虚函数func,默认参数改为 0,输出B->val。 注意B没有重写test函数 ,因此test仍使用基类A的实现。 main 函数执行流程B* p = new B;:创建B类型对象,指针p的静态类型是B*,实际类型也是B。 p->test();:调用test函数: 第一步 :test函数未被B重写,因此执行基类A的test函数。 第二步 :A的test中调用func(),由于func是虚函数,动态绑定到实际对象类型(B)的func,因此执行B::func。 第三步 :func的默认参数遵循静态绑定(由调用点的静态类型决定):test属于A类,因此默认参数使用A::func的1,而非B::func的0。 最终输出:B->1。

C++11 override和final

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

c 复制代码
class A
{
public:
	virtual void func() final
	{
		cout << "A" << endl;
	}
};

class B : public A
{
public:
	virtual void func()//出错,不允许重写
	{
		cout << "B" << endl;
	}
};

例子:实现一个类,让这个类不能被继承

c 复制代码
//法一:将构造函数私有化
class A
{
public:
	int _a;
private://将构造函数私有,派生类的构造,必须调用基类的构造,这里继承过去不可见,无法调用
	A(){}
};

class B :public A
{
};

int main()
{
	B bb;
}

final:修饰类,这个类不能被继承

c 复制代码
//法二:类后加final成为最终类
class A final//加final关键字,final修饰的类为最终类,不能被继承
{
public:
	int _a;
};
class B : public A//出错
{

};

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

c 复制代码
class A
{
public:
	virtual void func()
	{
		cout << "A" << endl;
	}
};

class B : public A
{
public:
	virtual void func() override//加在派生类的某个虚函数
	{
		cout << "B" << endl;
	}
};

抽象类

概念 在虚函数后面写上 = 0 ,则这个函数为纯虚函数 。 包含纯虚函数的类叫做抽象类 (也叫做接口类 ),抽象类不能实例化出对象。派生类继承后也不能实例化出对象,只有重写纯虚函数,派生类才能实例化出对象。 例子:抽象类不能实例化

c 复制代码
class Car//抽象类
{
public:
	virtual void Drive() = 0;//纯虚函数
};

int main()
{
	Car c;//出错
	return 0;
}

例子:抽象类的一种使用方式

c 复制代码
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;
	}
};
int main()
{
	//Car* a = new Benz;普通调用
	//a->Drive();
	//Car* b = new BMW;普通调用
	//b->Drive();
	Car* a = new Benz;//多态调用
	a->Drive();
	Car* b = new BMW;//多态调用
	b->Drive();
	return 0;
}

结果:普通调用与多态调用的结果一样(注)

关于接口继承和实现继承

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

多态的原理

回顾结构体内存对齐规则 规则 1:成员的偏移量规则 结构体中每个成员的「偏移量 」(相对于结构体起始地址的字节数),必须是该成员「自身大小 」的整数倍。如果不够,编译器会在该成员前填充「空字节」。 规则 2:整体大小规则 结构体的总大小,必须是结构体「最大对齐数 」的整数倍。 「最大对齐数」= 结构体中所有成员的自身大小的最大值(比如包含char和int,最大对齐数 = 4)规则 3:嵌套结构体规则 如果结构体嵌套了另一个结构体,嵌套的结构体的偏移量,必须是「嵌套结构体的最大对齐数」的整数倍;最终整体大小仍遵循规则 2。

c 复制代码
class Base
{
public:
	virtual void Func1()
	{
		cout << "Func1()" << endl;
	}
private:
	int _b = 1;
};

int main()
{
	cout << sizeof(Base) << endl;
	return 0;
}

通常情况下,我们认为这个类实例化后的大小为4字节(成员函数在公共代码区,不计算大小)。 但是结果为:

​实际上有了虚函数以后,对象里面会多一个指针。 在看一个稍微复杂例子

c 复制代码
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 _ch = 'a';
};
class Derive : public Base
{
public:
	virtual void Func1()
	{
		cout << "Derive::Func1()" << endl;
	}
private:
	int _d = 2;
};
int main()
{
	cout << sizeof(Base) << endl;
	Base bb;

	cout << sizeof(Derive) << endl;
	Derive dd;
	return 0;
}

结果:

对于基类:

在Base类中,有三个函数:其中两个函数是虚函数,另一个函数是普通的成员函数,普通成员函数在公共代码区不用管,另外的两个虚函数的地址实际上被储存在一个虚函数表中,由虚函数表的指针指向,实际上bb这个对象中仅仅包含这个虚函数表的指针。

注意 :他们虚函数表中的虚函数的顺序是类中虚函数声明的顺序。

此外额外实例化对象的虚表指针实际上都指向同一个虚表的起始地址。

Base b1; Base b2; Base b3;

对于派生类:

在Derive 类中,将func1进行了重写,func2没有管,实际上在虚函数表中,[0]被重写(被"覆盖"更贴切)为Derive::Func1(),[1]还是原来的Base::Func2()没有动。

虚函数的重写也叫做覆盖重写语法层 的概念 覆盖原理层的概念

从内存视角下

探究多态调用的底层

例子:一段多态调用的代码

c 复制代码
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;
	char _ch = 'a';
};
class Derive : public Base
{
public:
	virtual void Func1()
	{
		cout << "Derive::Func1()" << endl;
	}
private:
	int _d = 2;
};

void f(Base* ptr)
{
	ptr->Func1();//多态调用
}
int main()
{
	cout << sizeof(Base) << endl;
	Base bb;

	cout << sizeof(Derive) << endl;
	Derive dd;

	f(&bb);
	f(&dd);
	return 0;
}

结果:

情况 1: Base* ptr = &bb(指针指向父类对象 bb)

  1. ptr变量里存的是bb对象(父类Base)的首地址
  2. bb对象的内存布局里,第一个成员就是_vfptr(虚表指针)------ 这个指针是bb专属的,它只指向 Base 类的虚函数表(Base 虚表的 [0] 是Base::Func1()的地址);
  3. 执行ptr->Func1()时,按图里的汇编步骤:
  • 先从ptr里取出bb的首地址(也就是_vfptr的地址);
  • 再通过_vfptr找到 Base 类的虚表;
  • 从虚表第 0 项取出Base::Func1()的地址;
  • 最后调用这个地址的函数→ 所以最终调用的是父类的虚函数。

情况 2: Base* ptr = &dd(指针指向子类对象 dd)

  1. ptr变量里存的是dd对象(派生类Derive)的首地址虽然ptr的类型是 Base*,但它实际指向的是 dd 的内存);
  2. dd对象的内存布局里,第一个成员也是_vfptr,但这个_vfptr是dd专属的 ------ 它指向 Derive 类的虚函数表(Derive 虚表的 [0] 是Derive::Func1()的地址,因为子类重写了 Func1,覆盖了虚表的这一项);
  3. 执行ptr->Func1()时,汇编步骤和上面完全一样(因为指针类型还是 Base*),但寻址的目标变了:
  • 从ptr里取出dd的首地址(也就是dd的_vfptr地址);
  • 通过dd的_vfptr找到 Derive 类的虚表;
  • 从虚表第 0 项取出Derive::Func1()的地址;
  • 最后调用这个地址的函数→ 所以最终调用的是子类的虚函数。

多态调用:运行时区虚函数表中找函数的地址,进行调用,所以指向父类调的是父类虚函数,指向子类调用的是子类的虚函数。

如下就是多态调用的底层原理

关于虚函数表与虚函数的一些问题

虚函数表是存在哪个区域?

如下代码进行验证

c 复制代码
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;
	}
private:
	int _d = 2;
};

int main()
{
	Base bb;
	Derive dd;

	int i = 0;
	static int j = 1;
	int* p1 = new int;
	const char* p2 = "xxxx";
	printf("栈:%p\n", &i);
	printf("静态区:%p\n", &j);
	printf("堆:%p\n", p1);
	printf("常量区:%p\n", p2);

	Base* p3 = &bb;
	Derive* p4 = &dd;
	printf("Base虚表地址:%p\n", *(int*)p3);//取到虚表指针头四个字节
	printf("Derive虚表地址:%p\n", *(int*)p4);
	return 0;
}

虚表在常量区存储。

虚表在什么阶段生成的

编译的时候

虚表指针什么时候初始化的

构造函数构造时,在其他成员变量初始化之前,虚表指针就初始化好了。

注意:虚函数表中只是存储虚函数的指针,而虚函数的实现在代码段。

多继承下的类底层结构(含虚函数)

c 复制代码
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 func2() { cout << "Derive::func2" << endl; }
private:
	int d1;
};

int main()
{
	cout << sizeof(Derive) << endl;
}

Base1中有一个虚函数指针和int成员变量,共8字节 Base2中有一个虚函数指针和int成员变量,共8字节 所以Derive中有自身的一个int成员变量d1,和继承下来的Base1与Base2对象,共4+8+8=20字节。

实际上Derive中有两个不同的虚表指针,指向两个不同的虚表。 在代码中,在Derive类中,我们重写了func1函数,如下可以看出重写的Derive::func1()覆盖了Base1与Base2的虚表中func1()。

那么在类Derive中,不是重写的虚函数func3()放在哪里了? 放在继承的第一个类Base1的虚表的最后(在菱形继承中也有例子体现),验证如下。

c 复制代码
typedef void(*VF_PTR)();

//打印虚表,本质打印指针(虚函数指针)数组
void PrintVFT(VF_PTR vft[])
{
	for (size_t i = 0; vft[i] != nullptr; i++)
	{
		printf("[%d]:%p->", i, vft[i]);
		VF_PTR f = vft[i];
		f();
	}
}
int main()
{
	Derive d;

	Base1* ptr1 = &d;
	Base2* ptr2 = &d;
	printf("Base1虚表地址:%p\n",ptr1);
	PrintVFT((VF_PTR*)(*(int*)ptr1));
	cout << endl;
	printf("Base2虚表地址:%p\n", ptr2);
	PrintVFT((VF_PTR*)(*(int*)ptr2));
}

普通多重继承中,新增虚函数优先挂到第一个直接基类的虚表(如果它有 vfptr 虚表指针),不管有没有重写其他基类的虚函数。

总结:

菱形继承下的类底层结构(含虚函数情况)

例子1

c 复制代码
class A
{
public:
	virtual void funca1() { cout << "A::funca1" << endl; }
	virtual void funca2() { cout << "A::funca2" << endl; }
	virtual void funca3() { cout << "A::funca3" << endl; }
public:
	int a;
};

class B :virtual public A
{
public:
	int b;
};

class C :virtual public A
{
public:
	int c;
};
class D :public B, public C
{
public:
	int d;
};

int main()
{
	D dd;
	dd.B::a = 1;
	dd.C::a = 2;
	dd.b = 3;
	dd.c = 4;
	dd.d = 5;
	return 0;
}

分析如下:

例子2

c 复制代码
class A
{
public:
	virtual void func1() { cout << "A::func1" << endl; }
	virtual void func2() { cout << "A::func2" << endl; }
	virtual void func3() { cout << "A::func3" << endl; }
public:
	int a;
};

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

class C :virtual public A
{
public:
	virtual void func2() { cout << "C::func2" << endl; }
public:
	int c;
};
class D :public B, public C
{
public:
	virtual void func1() { cout << "D::func1" << endl; }
public:
	int d;
};

int main()
{
	D dd;
	dd.a = 1;
	dd.b = 2;
	dd.c = 3;
	dd.d = 4;
	return 0;
}

如上类B对类A的func1进行了重写,类C对类A的func2进行了重写,而类D对类B的func1进行了重写。 B、C 只是重写了 A 的虚函数(比如 B 重写 A 的 func1),这些重写的函数可以共享虚基类 A 的虚函数表(因为 A 的虚表会记录最终覆盖后的函数地址)。

例子3

c 复制代码
class A
{
public:
	virtual void func1() { cout << "A::func1" << endl; }
	virtual void func2() { cout << "A::func2" << endl; }
	virtual void func3() { cout << "A::func3" << endl; }
public:
	int a;
};

class B :virtual public A
{
public:
	virtual void func4() { cout << "B::func4" << endl; }
	virtual void func5() { cout << "B::func5" << endl; }
public:
	int b;
};

class C :virtual public A
{
public:
	virtual void func6() { cout << "C::func6" << endl; }
	virtual void func7() { cout << "C::func7" << endl; }
public:
	int c;
};
class D :public B, public C
{
public:
	virtual void func8() { cout << "D::func8" << endl; }
	virtual void func9() { cout << "D::func9" << endl; }
public:
	int d;
};

int main()
{
	D dd;
	dd.a = 1;
	dd.b = 2;
	dd.c = 3;
	dd.d = 4;
	return 0;
}

现在给 B 加了func4、func5,给 C 加了func6、func7------ 这些是A 没有的独有的虚函数,无法存到 A 的虚函数表里,所以 B、C 必须各自有一个自己的虚函数表指针(vfptr),指向自己的虚函数表(用来存这些独有的虚函数地址)。

如下图,类中的结构顺序为:虚函数表指针,虚机表指针,类成员变量

注意:这里类D(最终类)中有虚函数,但没有像类B,类C那样新增一个虚函数表指针写到成员变量的上面,而是把这些虚函数的地址都放到了,第一个类B的虚函数表中。

普通多重继承中,新增虚函数优先挂到第一个直接基类的虚表(如果它有 vfptr 虚表指针),不管有没有重写其他基类的虚函数。

这里举一个例子:C->B->A(长一点的继承) 结果预测是B,C中的新的虚函数,都放在了A的虚函数表中,B,C中并没有新增虚函数表。

c 复制代码
class A
{
public:
	virtual void func1(){}
public:
	int a;
};

class B:public A
{
public:
	virtual void func2(){}
public:
	int b;
};

class C :public B
{
public:
	virtual void func3() {}
public:
	int c;
};

int main()
{
	C cc;
	cc.a = 1;
	cc.b = 2;
	cc.c = 3;
	return 0;
}

确实如此

场景 新增虚函数的挂靠规则
普通多重继承(如 D 继承 B、C) 优先挂到第一个直接基类的虚表
菱形虚拟继承(如 B 虚拟继承 A) 必须自己建虚表,不能挂 A 的虚表

简单理解:对于"共享类"(例如A),不能挂到他自身,应优先挂到他直接派生类的虚表,对于"普通类",多重继承中,他的直接派生类优先挂到他的虚表。

相关推荐
golang学习记2 小时前
🔥 Go Gin 不停机重启指南:让服务在“洗澡搓背”中无缝升级
后端
MX_93592 小时前
Spring的命名空间
java·后端·spring
moyueheng2 小时前
Python 工具生态深度解析:从 Pyright 到 Astral 家族
后端
昭牧De碎碎念3 小时前
AI Agents
后端
李广坤3 小时前
Redisson 实战指南
后端
千寻girling3 小时前
面试官: “ 说一下你对 Cookie 的理解 ? ”
前端·后端
辜月十3 小时前
Anaconda
后端
唐叔在学习3 小时前
用python实现类AI自动执行终端指令
后端·python·自动化运维
码界奇点3 小时前
基于SpringBoot+Vue的新冠物资管理系统设计与实现
vue.js·spring boot·后端·spring·车载系统·毕业设计·源代码管理