1. 多态
1.1 多态概念
多态(polymorphism)通俗来讲就是多种形态。
多态分为编译时多态(静态多态)和运行时多态(动态多态),这里我们重点讲运行时多态,编译时多态(静态多态)和运行时多态(动态多态)。
编译时多态(静态多态)主要就是我们前面讲的函数重载和函数模板,他们传不同类型的参数就可以调用不同的函数,通过参数不同达到多种形态,之所以叫编译多态,是因为他们实参传给形参的参数匹配是在编译时完成的,我们把编译时一般归为静态,运行寸归为动态。
运行时多态,具体点就是去完成某个行为(函数),可以传不同的对象就会完成不同的行为,就达到多种形态。比如买票这个行为,当普通人买票时,是全个买票;学生买票时,是优惠买票(5折或75折);军行为(函数),传猫对象过去,就是喵喵喵”(>^ω^<),人买票时是优先买票。再比如,同样是动物叫的一喵“,传狗对象过去,就是“汪汪"。
1.2多态的定义及条件
1.2.1 多态的构成条件
多态是一个继承关系的下的类对象,去调用同一函数,产生了不同的行为。比如Student继承了Person。Person对象买票全价,Student对象优惠买票。
其他两个重要的条件:
(1)必须指针或者引用调用虚函数 (2)被调用的函数必须是虚函数。
说明 : 要实现多态效果,第一必须是基类的指针或引用,因为只有基类的指针或引用才能既指向派生类对象:第二派生类必须对基类的虚函数重写1覆盖,重写或者覆盖了,派生类才能有不同的函数,多态的不同形态效果才能达到。
如果不是基类的指针或者引用的话,那么这个指针或者引用是没有办法去指向其他派生类的,这样多态是实现不了的。
对于这个函数重写/覆盖 :
虚函数的重写/覆盖:派生类中有一个跟基类完全相同的虚函数(即派生类虚函数与基类虚函数的返回值类型、函数名字、参数列表完全相同),称派生类的虚函数重写了基类的虚函数。
注意:在重写基类虚函数时,派生类的虚函数在不加virtual关键字时,虽然也可以构成重写(因为继承后基类的虚函数被继承下来了在派生类依旧保持虚函数属性),但是该种写法不是很规范,不建议这样使用,不过在考试选择题中,经常会故意做这个坑,让你判断是否构成多态。
这个就是实现多态的第二个条件,你要实现多态,那么就要去对基类的虚函数进行一个重写,以达到覆盖的效果。
1.3 虚函数
类成员函数前面加virtual修饰,那么这个成员函数被称为虚函数。注意非成员函数不能加virtual修饰。
1.4 virtual理解
所以加了virtual,其实可以理解成这个函数可以多次重写实现多态,说白了就是标记 “ 可多态的函数 ”。但是必须在基类做这个事情,子类可以不用,只是为了更快速识别多态,格式统一,尽量都加上更好。
因此基类必须加 virtual,子类可加可不加(但建议加 override)
(1)基类加virtual:
virtual是启动多态的 “前提”。没有这个标记,编译器不会为函数生成虚函数表,子类重写也无法触发多态(会被当作普通函数重载)。
(2)子类加不加virtual:
其实就是不加也可以:因为基类的 virtual 会 “继承” 给子类,子类重写的函数自动成为虚函数(C++ 语法规定)。但是建议加 override 而非 virtual:override 是 C++11 新增的关键字,明确标记 “这个函数是重写基类的虚函数”,编译器会帮你检查是否真的重写了(比如函数名、参数是否匹配),比virtual更安全、可读性更好。
1.5 多态细节
我们看一个很有意思的代码
#include <iostream>
using namespace std;
class A
{
public:
virtual void func(int val = 1) { cout << "A->" << val << endl; }
virtual void test() { func(); }
};
class B : public A
{
public:
void func(int x = 0) { cout << "B->" << x << endl; }
};
int main()
{
B* pointer = new B;
pointer->test();
return 0;
}
我们可以思考一下输出的结果是什么。
首先我们要明确的一点就是A当中的test(),继承以后里面的this到底是 A*还是 B *。首先对于这个test而言,this默认就是指向A的,这个在编译期就被确定了。但是由于它被继承了,pointer调用以后,是B类指针的调用,所以就this指向的是B类。
然后我们两个函数都使用了缺省值或者说形参的默认初始化。这个形参的缺省值和this静态时期的指向是一个道理的,在编译期就被确定了。所以pointer调用test以后,会调用func函数,func实现了多态,传入B类对象,自然用的就是B的func。但是最关键的是test没有被重写,它依然在A类中,因此默认的实现是func (1),这个是编译时期确定的。
又因为func是多态,所以会走B类的func,所以输出结果就变成了B->1。
所以总结一下:
-
test()在A中,其内部this的静态类型是A\*(编译期固定),但运行时this实际指向B对象(动态类型是B*)。 -
test()调用func()时,func()是虚函数,因此根据this的动态类型(B*),触发多态,执行B::func()。 -
func()的默认参数由静态类型(A*)决定(编译期确定),因此使用A::func()声明的val=1。
所以代码改成这样的话:
#include <iostream>
using namespace std;
class A
{
public:
virtual void func(int val = 1) { cout << "A->" << val << endl; }
virtual void test() { func(); }
};
class B : public A
{
public:
void func(int x = 0) { cout << "B->" << x << endl; }
void test() { func(); }
};
int main()
{
B* pointer = new B;
pointer->test();
return 0;
}
输出结果就是:B->0 了。因为我们对test也进行了重写,这个时候pointer用的直接是自己的test,这个时候func默认用的也是自己的func。不过这个时候其实也压根没用到多态了。因为是自己类的指针调用自己的成员。
我们把pointer的类型再换一下:
#include <iostream>
using namespace std;
class A
{
public:
virtual void func(int val = 1) { cout << "A->" << val << endl; }
virtual void test() { func(); }
};
class B : public A
{
public:
void func(int x = 0) { cout << "B->" << x << endl; }
};
int main()
{
A* pointer = new B;
pointer->test();
return 0;
}
这个时候输出结果又会是:B->1 了。为什么呢?还是一样的道理。因为B类根本没有test,所以test我们依然是进入的A类的test,然后const A*(this)指向B类对象。但是由于缺省值已被确定输出结果就还是B->1 了。
为什么说这个有意思呢?我们给B类重写个test吧。
#include <iostream>
using namespace std;
class A
{
public:
virtual void func(int val = 1) { cout << "A->" << val << endl; }
virtual void test() { func(); }
};
class B : public A
{
public:
void func(int x = 0) { cout << "B->" << x << endl; }
void test() { func(); }
};
int main()
{
A* pointer = new B;
pointer->test();
return 0;
}
这个时候又跟B* pointer那会给B类重写test又不一样了。那会实际上不是多态了,但是这个时候不一样,它本质还是用的多态。
这个时候的输出结果就又变回:B->0 了。为什么呢?因为pointer调用test的时候,由于指向B类对象,这个时候我们又刚好对test进行了重写,触发多态,走的是B类的test,所以调用的func是缺省值就是0。B :: test()里的this静态类型是B *,动态类型也是B *,所以func的多态匹配结果就是B :: func,同时默认参数也随B *的静态类型取0,因此输出B->0。
所以关键点就在这个test,因为有多态决定进入的是哪个类的test。不然的话这个test在不同类中的情况下,func的缺省值几乎是被绑死的,哪怕func是虚函数也只能是实现不同。
// 核心规则: 进入哪个类的 test() → 决定使用哪个类的默认参数 func() 是否是虚函数 → 决定执行哪个类的实现
对于这些代码而言:
-
test() 的归属决定参数:谁家的test,就用谁家的默认参数
-
func() 的虚特性决定实现:虚函数机制决定最终执行谁的代码
-
多态是开关:是否重写test决定了走哪条路径
1.6 再探父类指针与子类
其实经过指针偏移问题也就变相解释了父类指针指向子类的合理性了,子类指针指向自己和父类指针指向子类,两个指针指向的地方是一样的(多个继承一个就会涉及到偏移量,不过编译器自己会处理)。
不过只有通过我们这个多态,才能让父类指针去调用到子类的成员函数,达到多态所需要的效果。
1.7 虚函数机制与多态
这就引发了一个问题:使用了虚函数机制的一定是多态吗?或者我们说一个类的指针自己指向自己,一定能发生多态吗?
就算出现了C继承B,B继承A这个情况,然后B的指针指向自己,并调用了一个虚函数(A B C三者均有重写),这种算是多态吗?
我们得从多个角度来看这个问题:
(1)从语法角度来看,它调用了虚函数这个机制,是属于多态的。因为当 B* 指向 B 对象并调用虚函数时,编译器会按照虚函数的调用流程处理(通过虚表查找函数地址),这在语法上符合 “虚函数调用” 的特征。
(2)从语义角度来看,它只有一种固定行为(始终调用 B::func),没有 “多态性”(多样性),因此不被视为 “多态场景”。
(3)从底层机制来看,当 B* ptr = new B; ptr->func() ; 时,底层流程是:通过 ptr 找到对象的 vptr → 访问 B 的虚表 → 调用 B::func(和多态调用的流程完全一致)。但因为对象是 B 类型,vptr 固定指向 B 的虚表,所以结果唯一,没有体现 “根据对象类型动态切换行为” 的核心价值。说白了就是编译器完全可以通过静态类型直接就去调用func了。
因此做个小总结:
-
虚函数是多态的必要条件,但不是充分条件:没有虚函数一定没有多态,但有虚函数也未必是多态。
-
多态的关键是 “对象的实际类型不同”:只有当父类指针 / 引用指向 “不同子类对象” 时,调用虚函数产生的行为差异才叫多态。
-
“自己指向自己”(如 B * 指向 B 对象):即使函数是虚函数,也只有一种固定行为,不满足 “多样性”,因此不算多态。
1.8 虚函数重写本质
所以虚函数重写其实本质是在干什么呢?本质其实是对虚函数的实现进行了重写。相当于用的是父类虚函数的缺省值,用的是子类虚函数的实现。
1.9 虚函数重写的一些其他问题
1.9.1 协变
派生类重写基类虚函数时,与基类虚函数返回值类型不同。即基类虚函数返回基类对象的指针或者引用,派生类虚函数返回派生类对象的指针或者引用时,称为协变。不过协变的实际意义并不大,所以我们了解一下即可。
1.9.2 析构函数的重写
基类的析构函数为虚函数,此时派生类析构函数只要定义,无论是否加virtual关键字,都与基类的析构函数构成重写,虽然基类与派生类析构函数名字不同看起来不符合重写的规则,实际上编译器对析构函数的名称做了特殊处理,编译后析构函数的名称统一处理成destructor,所以基类的析构函数加了vialtual修饰,派生类的析构函数就构成重写。
注意:这个问题面试中经常考察,大家一定要结合类似下面的样例才能讲清楚,为什么基类中的析构函数建议设计为虚函数。
#include <iostream>
using namespace std;
class A
{
public:
A(int val = 0):
val(val){ }
~A()
{
cout << "A的析构函数的调用。" << endl;
}
int val;
};
class B : public A
{
public:
B(int val = 1):
val(val){ }
~B()
{
cout << "B的析构函数的调用。" << endl;
}
int val;
};
int main()
{
A* pointer = new B;
delete pointer;
return 0;
}
在这个代码当中我们会发现,只会调用A的析构函数,但是B的却没有。这是很危险的,因为B没有被正确的释放会造成内存泄漏(不过pointer是B *类型的话是可以被正确释放的,A B的析构函数都会被调用)。
因此在继承部分如果有多态我们还需要对析构函数进行重写。因为析构函数的行为,取决于你调用它的对象的“静态类型”,而不是它指向的“动态类型”。但更关键的是,要实现正确的析构,析构函数必须是虚函数。
所以为什么要虚析构根本原因正是“析构函数没有多态行为”导致编译器仅根据指针的静态类型(父类)调用析构函数,而忽略了对象的实际动态类型(子类)。
但是为什么pointer变成B *就可以自动释放A和B全部了呢?那是因为编译器知道B类完整信息,可以完整调用析构函数。
不过如果我们不涉及指针或者引用指向某一个对象的话其实也不太需要虚析构。
1.9.3 虚析构怎么工作
比如 A* ptr = new B;:
-
非虚析构:程序编译时就认定 “ptr 是 A 类型指针”,直接调用 A 的析构,不管实际指向的是 B 对象;
-
虚析构:程序运行时会 “检查 ptr 实际指的是谁”,发现是 B 对象后,就先调用 B 的析构 —— 相当于 “掀开指针的‘表面标签’,看到了对象的真实身份”。
所以这也就刚好解决了无法完全释放内存导致泄露的痛点。
但是为什么编译器会去做这个检查呢?是因为这背后是 C++ 的动态绑定规则—— 一旦父类析构被声明为virtual(虚析构),编译器就会自动给程序 “植入” 一个逻辑:在销毁对象时,先去确认指针实际指向的对象类型,再调用对应类型的析构函数,而不是像非虚析构那样 “只看指针表面类型”。
1.10 override和final关键字
这两个关键字都是C++11才提供的。
从上面可以看出,C++对虚函数重写的要求比较严格,但是有些情况下由于疏忽,比如函数名写错参数写错等导致无法构成重写,而这种错误在编译期间是不会报出的,只有在程序运行时没有得到预期结果才来debug会得不偿失,因此C++11提供了override,可以帮助用户检测是否重写。如果我们不想让派生类重写这个虚函数,那么可以用final去修饰。
所以遇到重写的情景最好都加上个override,不然像drive,dirve两个函数你打错字了你都没发现就完犊子了,编译器也没发现。
1.11 纯虚函数和抽象类
在虚函数的后面写上 =0,则这个函数为纯虚函数,纯虚函数不需要定义实现(实现没啥意义因为要被派生类重写,但是语法上可以实现),只要声明即可。
包含纯虚函数的类叫做抽象类,抽象类不能实例化出对象,如果派生类继承后不重写纯虚函数,那么派生类也是抽象类。纯虚函数某种程度上强制了派生类重写虚函数,因为不重写实例化不出对象。
说白了,这个东西没啥用,因为没有他们你也能实现多态。只是我们仍然推荐使用基类作为抽象类其实就是禁止去实例化出基类对象,同时也让人更清楚的了解这个函数必须重写,编译器也会对此检查,而且能让你的代码逻辑更清晰、约束更严格。
1.12 多态底层原理
我们可以先看看这个代码,然后思考一下运行结果。
#include <iostream>
using namespace std;
class Test
{
public:
virtual void func()
{
cout << "猜猜我这个类的大小" << endl;
}
Test()
{
age = 1;
x = 'a';
}
int age;
char x;
};
int main()
{
Test test;
cout << sizeof(test) << endl;
return 0;
}
输出结果会是16,而不是我们了解多态原理之前认为的8。(64位)
1.12.1 虚函数表指针
其实我们每一个有包含虚函数的类(或从有虚函数的类继承)都会在对象中含有虚函数表指针(vptr)。而这个虚函数表指针就是虚函数表的头指针。虚函数表其实就是一个数组,存放着所有虚函数的指针,这些指针都是函数指针来的,指向每一个虚函数。不过要注意的就是每一个类的虚函数表都是独有的,并不是公用的。
1.12.2 多态怎么实现的
第一步:编译期生成 “虚函数表(vtable)”
编译器在编译时,会为每个包含虚函数的类(包括父类和子类)生成一张虚函数表(本质是一个存储函数地址的数组):
-
父类
Animal的虚表:[&Animal::bark](存放父类自己的虚函数地址)。 -
子类
Dog的虚表:[&Dog::bark](用自己重写的bark地址,覆盖父类虚表中对应位置)。 -
子类
Cat的虚表:[&Cat::bark](同理,用自己的bark地址)。
虚表是 “类级别的全局数据”,每个类只有一份,所有对象共享。
第二步:对象创建时初始化 “虚指针(vptr)”
当创建对象时(如 new Dog()),编译器会在对象的内存中自动插入一个虚指针(vptr)(隐藏的成员变量,通常在对象内存的最开头):
-
vptr的作用是指向当前对象所属类的虚表。 -
例如:
-
Animal对象的vptr→ 指向Animal的虚表。 -
Dog对象的vptr→ 指向Dog的虚表。 -
Cat对象的vptr→ 指向Cat的虚表。
-
这一步在对象的构造函数中自动完成(编译器会偷偷插入初始化 vptr 的代码)。
第三步:运行时通过 “vptr + vtable” 动态找函数
当用父类指针 / 引用调用虚函数时(触发多态的关键场景),程序在运行时会做三件事:
-
找 vptr:从指针指向的对象中,取出
vptr(因为对象的vptr已经指向了自己类的虚表)。 -
查 vtable:根据虚函数在表中的位置(索引),从
vptr指向的虚表中找到函数的具体地址。 -
调用函数:执行找到的函数地址对应的实现。
说白了就是,编译器看到了virtual,知道调用了多态。然后生成对象的时候自动初始化虚函数指针还有虚函数表,虚函数指针初始化指向虚函数表首位置。然后运行的时候根据形参的指针或者引用拿到的对象是谁,去找对应的虚函数表。
不过首先要提出第一个问题:父类指针指向子类,形参切出来的看到的不都是父类吗?咋知道是具体谁的多态呢?
这个其实是编译器(更准确地说,是编译器生成的代码在运行时)通过虚指针(vptr)指向的虚函数表(vtable),可以 “间接” 对象所属的具体类 —— 但这不是通过 “直接识别类名” 实现的,而是通过虚表与类的一一对应关系间接确定的。
首先切片这个东西并不是意味着改变本质,真的只拿出来父类部分,其实更多是限制可见范围,不让访问或者操作子类独有部分。然后虚函数指针其实是对象级成员,生成一个对象以后,对象自己就会有一个虚函数指针,指向自己这个类的虚函数表的首位置。然后编译器拿到虚函数表指针以后看到了指向的地址就会知道这是哪个类的对象了,自然也就知道具体用谁的多态了。
不过说仔细点就是虚函数表其实在编译期间就已经生成了,每一个对象的带有的虚函数表的虚函数都会在那放着,这也就是为什么编译器认得每一个对象的虚函数表的地址。
所以这个过程是怎么样的呢?其实是在编译期间,会自动生成每一个类的虚函数表,然后当我们建立对象的时候虚函数表指针会初始化指向虚函数表。编译器看到虚函数传过来的对象的虚函数指针的时候,由于虚函数表每个类只有一个,地址也在编译期间被确定了,所以编译器看到地址直接就可以确定是哪个类了。因此动态绑定成功,实现多态。
1.12.3 静态绑定与动态绑定
对不满足多态条件(指针或者引用+调用虚函数)的函数调用是在编译时绑定,也就是编译时确定调用函数的地址,叫做静态绑定。
满足多态条件的函数调用是在运行时绑定,也就是在运行时到指向对象的虚函数表中找到调用函数的地址,也就做动态绑定
说白了静态绑定其实就是在编译时期就已经被确定的,动态绑定则是运行的时候确定的。
1.12.4 详解虚函数表
基类对象的虚函数表中存放基类所有虚函数的地址。
派生类中重写的基类的虚函数,派生类的虚函数表中对应的虚函数就会被覆盖成派生类重写的虚函数地址。
派生类的虚函数表中包含,基类的虚函数地址,派生类重写的虚函数地址,派生类自己的虚函数地址三个部分。
虚函数表本质是一个存虚函数指针的指针数组,一般情况这个数组最后面放了一个0x00000000标记。(这个C++并没有进行规定,各个编译器自行定义的,vs系列编译器会再后面放个0x00000000标记,g++系列编译不会放)。
虚函数存在哪的?虚函数和普通函数一样的,编译好后是一段指令,都是存在代码段的,只是虚函数的地址又存到了虚表中。
虚函数表存在哪的?这个问题严格说并没有标准答案C++标准并没有规定,我们写下面的代码可以对比验证一下。vs下是存在代码段(常量区)
1.12.5 虚函数表与虚函数表指针
这里还是要注意一下:
-
虚函数表(vtable)是类级别的
-
每个有虚函数的类(或从有虚函数的类继承而来的类)在编译期就会生成唯一的一个虚函数表
-
这个表存在于程序的只读数据段,所有该类的对象共享同一个虚函数
-
虚函数表指针(vptr)是对象级别的
-
每个对象都有自己独立的vptr,作为对象的隐藏成员
-
这个vptr存储在对象的内存布局中(通常在最前面)
-
每个对象的vptr都指向自己所属类的那个虚函数表
所以一个类不会有单独的虚函数表指针,一直都是每一个对象都会有一个独立的虚函数表指针,只是他们都在生成的时候初始化指向了自己这个类的虚函数表的首位置。而不是每一个类都他妈有一个虚函数表指针然后让对象共享。
1.13 获取虚函数表地址方式
首先要明确, C++ 是没有直接提供访问 vptr 的语法的,所以需要通过底层指针操作来间接获取。这种写法是为了 “绕开” C++ 的类型系统,直接读取对象内存中 vptr 的值(即虚表地址)。
所以我们才需要把虚函数表的地址先通过强制类型转换,变成int *类型然后再进行解引用。
不过问题来了,为什么必须要转换成int *类型呢?
1. 内存布局:虚指针(vptr)的大小与 int 一致
在大多数平台中,指针的大小等于 int 的大小(例如 32 位系统中都是 4 字节,64 位系统中都是 8 字节)。而对象的 vptr 本质是一个 “指针”,它在内存中占用的字节数与 int 相同。
将 p3 转成 int*,是为了以 “整型指针” 的语义去读取 vptr 所在内存的 “整数值”(因为 vptr 存储的是 “地址”,而地址本质是一个整数)。
2. 指针操作:解引用需要明确类型
如果不转换类型,直接对 p3(Person* 类型)解引用,语法上是不允许的(Person* 是对象指针,解引用后是 Person 对象,不是 “地址值”)。
而转成 int* 后,解引用 *(int*)p3 就可以直接读取 vptr 存储的 “地址数值”(即虚表的地址),这正是我们想要的结果。
3. 目的:获取 “地址的数值形式”
printf 的 %p 格式需要一个 “地址” 作为参数,而 *(int*)p3 最终得到的是虚表地址的 “数值形式”(比如 0x12345678),正好可以传递给 %p 输出。
如果不转换类型,就无法直接从对象指针中提取出这个 “数值形式的地址”。
1.14 多态总结
所以多态这个东西很复杂,但是却又用处多多。我们其实还有别的方法做到同样的效果。就是我们可以在子类当中写一些单独的成员,就好像这个猫叫狗叫,而不是做成多态。
这个说法其实一点错都没有,不用多态,靠子类自己写独立的成员函数(比如 catVoice()、dogVoice()),确实能实现 “猫叫、狗叫” 的效果 —— 但这两种方式的 “扩展性” 和 “维护成本” 天差地别 。多态的核心价值不是 “实现功能”,而是 “ 让功能在新增类型时,不用修改旧代码 ”。
我们看看具体区别: (1)不用多态:加 1 种动物,改 2 处代码
先写新类(比如鸟):
class Bird { public: void birdVoice() { 叽叽叽; } };
再去循环里加判断:
for (auto a : animals) {
if (是狗) 狗叫;
else if (是猫) 猫叫;
else if (是鸟) 鸟叫; // 新增这行
}
如果再加 “猪”“羊”,就要再写 2 行:1 行类函数 + 1 行循环判断 —— 加 N 种动物,改 N 次循环。
(2)用多态:加 1 种动物,只写 1 处代码
写新类(鸟):
class Bird : public Animal { public: void voice() override { 叽叽叽; } };
循环里啥都不用改
for (auto a : animals) {
a->voice(); // 旧代码不动,自动识别鸟的叫声
}
加 “猪”“羊” 也一样,只写新类,循环永远不用改 —— 加 N 种动物,改 0 次循环。
所以简单概括出来就是,不用多态,每次加新动物都要 “动旧代码”(改循环里的判断);用多态,加新动物只 “加新代码”(写新类),旧代码永远不动。次数少的时候看着差不多,次数多了,不用多态的循环会堆成 “if-else 山”,改起来又慢又容易错 —— 这就是多态省的功夫。
937

被折叠的 条评论
为什么被折叠?



