跳到主要内容
C++ 多态概念、实现及原理详解 | 极客日志
C++ 算法
C++ 多态概念、实现及原理详解 C++ 多态分为编译时多态和运行时多态。运行时多态需满足基类指针或引用调用虚函数且派生类重写该函数。虚函数通过 virtual 关键字声明,重写需保证三同(返回值、函数名、参数列表)。纯虚函数使类成为抽象类,无法实例化。多态底层依赖虚函数表指针(vptr)和虚函数表(vtable),在运行时动态绑定函数地址,实现不同对象对同一接口的不同行为响应。析构函数建议设为虚函数以防止内存泄漏。override 和 final 关键字用于辅助重写检查和禁止重写。
数字游民 发布于 2026/3/26 更新于 2026/4/23 1 浏览1. 多态的概念
多态 (polymorphism) 的概念:通俗来说,就是 多种形态 。
多态分为编译时多态 (静态多态)和 运行时多态 (动态多态) 。
编译时多态 (静态多态) 主要就是我们前面讲的函数重载 和函数模板 ,它们传入不同类型的参数就可以调用不同的函数 ,通过参数不同达到多种形态。之所以叫编译时多态 ,是因为它们实参传给形参的参数匹配是在编译时完成的 。我们把编译时一般归为静态 ,运行时归为动态 。
运行时多态 ,具体点就是去完成某个行为 (函数),可以传不同的对象就会完成不同的行为,就达到多种形态。比如买票这个行为,当普通人买票时,是全价买票;学生买票时,是优惠买票 (5 折或 75 折);军人买票时是优先买票。再比如,同样是动物叫的一个行为 (函数),传猫对象过去,就是'喵喵',传狗对象过去,就是'汪汪'。
2. 多态的定义及实现
2.1 多态的构成条件
多态是一个继承关系下的类对象,去调用同一函数,产生了不同的行为 。比如 Student 继承了 Person。Person 对象买票全价,Student 对象优惠买票。
2.1.1 实现多态还有两个必须重要条件:
必须基类指针或者引用调用虚函数
被调用的函数必须是虚函数。
**说明:**要实现多态效果,第一必须是基类的指针或引用调用虚函数 ,因为只有基类的指针或引用才能既指向基类对象,又指向派生类对象;第二派生类必须对基类的虚函数重写/覆盖 ,重写或者覆盖了,派生类才能有不同的函数,多态的不同形态效果才能达到。
这里我先用代码来感受一下运行时多态:
class Person {
public :
virtual void BuyTicket () { cout << "普通人买票全价!" << endl; }
protected :
};
class Student : public Person {
public :
virtual void BuyTicket () { cout << "学生买票半价!" << endl; }
protected :
};
void fun {
p. ();
}
{
Person p1;
Student s1;
(p1);
(s1);
;
}
(Person& p)
BuyTicket
int main ()
fun
fun
return
0
我们可以看到上面这段程序,我们通过传递不同类型的对象 ,调用同一个函数 (执行同一种行为),但是它们所表现出来的结果却是不同的 。这就是动态多态(运行时多态)!
2.1.2 虚函数 类成员函数前面加virtual 修饰 ,那么这个成员函数被称为虚函数。注意非成员函数不能加 virtual 修饰。
class Person {
public :
virtual void BuyTicket () { cout << "买票 - 全价" << endl; }
};
注意:这里用关键字 virtual 修饰成员函数 和之前讲的继承时用 virtual 修饰从而达到虚继承 这两者是没有任何关联的!
2.1.3 虚函数的重写/覆盖 虚函数的重写/覆盖 :派生类中有一个跟基类完全相同的虚函数 (即派生类虚函数与基类虚函数的返回值类型 、函数名字 、参数列表 完全相同),称派生类的虚函数重写了基类的虚函数 。
**注意:**在重写基类虚函数时,派生类的虚函数在不加 virtual 关键字时,虽然也可以构成重写 (因为继承后基类的虚函数被继承下来了在派生类依旧保持虚函数属性),但是该种写法不是很规范,不建议这样使用,不过在考试选择题中,经常会故意埋这个坑,让你判断是否构成多态。
2.1.4 多态场景的一个选择题 以下程序输出结果是什么()
A: A->0 B: B->1 C: A->1 D: B->0 E:编译出错 F:以上都不正确
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 ;
}
详解:首先我们来思考一下,p 在调用 test 函数时有没有构成多态。我们先来看看在继承体系下,有没有实现多态的前提条件 ,即 子类是否完成了父类虚函数的重写 !虽然子类的函数没有用 virtual 修饰,但这并不重要,重要的是子类里面的缺省值和父类并不相同,那这构不构成重写呢?我们还是回到重写的定义三同(返回值类型相同,函数名相同,参数列表相同 )。注意,我们所说的参数列表相同是指形参的类型、顺序和个数相同 !与形参的缺省值是否相同并没有关系,甚至和形参名是否相同也没有关系!
那我们再来看看,以上程序是否使用了基类的指针或引用调用虚函数 !
我们 new 了一个子类对象,然后再用子类的指针去调用 test 函数,我们知道 test 函数是被子类从父类继承下来的。之前我们学了继承都知道,(子类继承父类后,会把父类的所有成员继承下来)这是形象的说法,实际上子类真的会把父类的成员函数拷贝一份放到子类当中吗?当然不会!我们在调用子类继承下来的成员函数时,实际上是 去父类当中调用相应的函数 (如果没有构成隐藏)。
而父类的 test 函数中隐藏了形参(A* this)是父类的指针!this->fun();即满足了基类的指针或者引用调用虚函数这个条件!
**那这个题目的答案是不是 B->0 呢?**运行一下看看结果。
这里我就要再讲一下重写的本质了:重写的本质是对父类虚函数的函数体的重写!
**说明:**在继承体系下,子类的成员函数一旦对父类的虚函数构成重写(满足三同),那么子类的成员函数除函数体外其他内容(子类缺省值失效)被父类完全覆盖!这也说明了为什么重写(重写函数体)又叫做覆盖!这也解释了为什么在子类中成员函数不用加 virtual 依然构成重写!因为继承后基类的虚函数被继承下来了在派生类依旧保持虚函数属性。
int main () {
A* p = new B;
p->test ();
return 0 ;
}
p 调用的不应该是 A 类的 func 吗,即使构成多态,不应该是不同类型的对象调用不同的函数吗?为什么这里还是调到了 B 类重写的函数。这和继承当中讲的父类和派生类间的转换有关 :当我们把子类对象赋值给父类的指针或者引用时,这里有个形象的说法叫切片或者切割。寓意把子类中父类那部分切出来,父类的指针或者引用指向的是子类中切出来的基类的那部分。(这也进一步解释了:要实现多态效果,第一必须是基类的指针或引用调用虚函数,因为只有基类的指针或引用才能既指向基类对象,又指向派生类对象)
所以这时 A*类型的 p 还是指向 B 类型的对象,指向谁调用谁,所以我们还是调用到了 B!
2.1.5 虚函数重写的一些其他问题
协变 (了解)
派生类重写基类虚函数时,与基类虚函数返回值类型不同。即基类虚函数返回基类对象的指针或者引用,派生类虚函数返回派生类对象的指针或者引用时,称为协变。协变的实际意义并不大,所以我们了解一下即可。
class A {};
class B : public A {};
class Person {
public :
virtual A* BuyTicket () { cout << "买票 - 全价" << endl; return nullptr ; }
};
class Student : public Person {
public :
virtual B* BuyTicket () { cout << "买票 - 打折" << endl; return nullptr ; }
};
void Func (Person* ptr) {
ptr->BuyTicket ();
}
int main () {
Person ps;
Student st;
Func (&ps);
Func (&st);
return 0 ;
}
析构函数的重写
基类的析构函数为虚函数 ,此时派生类析构函数只要定义,无论是否加 virtual 关键字,都与基类的析构函数构成重写 。虽然基类与派生类析构函数名字不同看起来不符合重写的规则,实际上编译器对析构函数的名称做了特殊处理,编译后析构函数的名称统一处理成 destructor,所以基类的析构函数加了 virtual 修饰,派生类的析构函数就构成重写 。
注意:这个问题面试中经常考察,大家一定要结合类似下面的样例才能讲清楚,为什么基类中的析构函数建议设计为虚函数。
class A {
public :
virtual ~A () { cout << "~A()" << endl; }
};
class B : public A {
public :
~B () { cout << "~B()->delete:" << _p << endl; delete _p; }
protected :
int * _p = new int [10 ];
};
int main () {
A* p1 = new A;
A* p2 = new B;
delete p1;
delete p2;
return 0 ;
}
如果我们在父类当中的析构函数中不加 virtual,则子类和父类的析构函数不构成重写!所以我们在析构 p2 时并不会发生多态,从而导致 B 部分无法完成析构!如果 B 中有资源的申请,还会发生内存泄漏的问题!
2.1.6 override 和 final 关键字 从上面可以看出,C++ 对函数重写的要求比较严格,但是有些情况下由于疏忽,比如函数名写错参数写错等导致无法构成重写,而这种错误在编译期间是不会报出的,只有在程序运行时没有得到预期结果才来 debug 会得不偿失,因此 C++11 提供了 override,可以帮助用户检测是否重写。如果我们不想让派生类重写这个虚函数,那么可以用 final 去修饰。
class Car {
public :
virtual void Dirve () {}
};
class Benz : public Car {
public :
virtual void Drive () override { cout << "Benz-舒适" << endl; }
};
int main () {
return 0 ;
}
class Car {
public :
virtual void Drive () final {}
};
class Benz : public Car {
public :
virtual void Drive () { cout << "Benz-舒适" << endl; }
};
int main () {
return 0 ;
}
2.1.7 重载/重写/隐藏的对比
3. 纯虚函数和抽象类 在虚函数的后面写上=0 ,则这个函数为纯虚函数,纯虚函数不需要定义实现 (实现没啥意义因为要被派生类重写,但是语法上可以实现),只要声明即可。包含纯虚函数的类叫做抽象类 ,抽象类不能实例化出对象 ,如果派生类继承后不重写纯虚函数,那么派生类也是抽象类 。纯虚函数某种程度上强制了派生类重写虚函数,因为不重写实例化不出对象。
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 car;
Car* pBenz = new Benz;
pBenz->Drive ();
Car* pBMW = new BMW;
pBMW->Drive ();
return 0 ;
}
4. 多态的原理
4.1 虚函数表指针 下面编译为 32 位程序的运行结果是什么()
A.编译报错 B.运行报错 C.8 D.12
class Base {
public :
virtual void Func1 () { cout << "Func1()" << endl; }
protected :
int _b = 1 ;
char _ch = 'x' ;
};
int main () {
Base b;
cout << sizeof (b) << endl;
return 0 ;
}
上面题目运行结果 12bytes,除了_b 和_ch 成员,还多一个__vfptr 放在对象的前面 (注意有些平台可能会放到对象的最后面,这个跟平台有关),对象中的这个指针我们叫做虚函数表指针 (v 代表 virtual,f 代表 function)。一个含有虚函数的类中都至少都有一个虚函数表指针(简称虚表指针) ,因为一个类所有虚函数的地址要被放到这个类对象的虚函数表中,虚函数表也简称虚表。
虚函数表实际上就是存储虚函数地址的一个数组,也就是函数指针数组 !
4.2 多态的原理
4.2.1 多态是如何实现的 从底层的角度 Func 函数中 ptr->BuyTicket(),是如何作为 ptr 指向 Person 对象调用 Person::BuyTicket,ptr 指向 Student 对象调用 Student::BuyTicket 的呢?
通过下图我们可以看到,满足多态条件后,底层不再是编译时通过调用对象确定函数的地址 ,而是运行时到指向的对象的虚表中确定对应的虚函数的地址 ,这样就实现了指针或引用指向基类就调用基类的虚函数 ,指向派生类就调用派生类对应的虚函数 。第一张图,ptr 指向的 Person 对象,调用的是 Person 的虚函数;第二张图,ptr 指向的 Student 对象,调用的是 Student 的虚函数。
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* ptr) {
ptr->BuyTicket ();
}
int main () {
Person ps;
Student st;
Soldier sr;
Func (&ps);
Func (&st);
Func (&sr);
return 0 ;
}
4.2.2 动态绑定与静态绑定
4.3 虚函数表 class Base {
public :
virtual void func1 () { cout << "Base::func1" << endl; }
virtual void func2 () { cout << "Base::func2" << endl; }
void func5 () { cout << "Base::func5" << endl; }
protected :
int a = 1 ;
};
class Derive : public Base {
public :
virtual void func1 () { cout << "Derive::func1" << endl; }
virtual void func3 () { cout << "Derive::func1" << endl; }
void func4 () { cout << "Derive::func4" << endl; }
protected :
int b = 2 ;
};
int main () {
Base b;
Derive d;
return 0 ;
}
基类对象的虚函数表中存放基类所有虚函数的地址 。
派生类由两部分构成,继承下来的基类 和自己的成员 ,一般情况下,继承下来的基类中有虚函数表指针,自己就不会再生成虚函数表指针 。但是要注意的这里继承下来的基类部分虚函数表指针 和基类对象的虚函数表指针不是同一个 ,就像基类对象的成员和派生类对象中的基类对象成员也独立的。
派生类中重写的基类的虚函数 ,派生类的虚函数表中对应的虚函数就会被覆盖 成派生类重写的虚函数地址。
派生类的虚函数表中包含,基类的虚函数地址 ,派生类重写的虚函数地址 ,派生类自己的虚函数地址三个部分 。
虚函数表本质是一个存虚函数指针的指针数组 ,一般情况下这个数组最后面放了一个 0x00000000 标记。(这个 C++ 并没有进行规定,各个编译器自行定义的,vs 系列编译器会再后面放个 0x00000000 标记,g++ 系列编译不会放)
虚函数存在哪的?虚函数 和普通函数一样的,编译好后是一段指令,都是存在代码段的 ,只是虚函数的地址又存到了虚表中 。
虚函数表存在哪的?这个问题严格说并没有标准答案 C++ 标准并没有规定,我们写下下面的代码可以对比验证一下。vs 下是存在 代码段 (常量区)
int main () {
int i = 0 ;
static int j = 1 ;
int * p1 = new int ;
const char * p2 = "xxxxxxxx" ;
printf ("栈:%p\n" , &i);
printf ("静态区:%p\n" , &j);
printf ("堆:%p\n" , p1);
printf ("常量区:%p\n" , p2);
Base b;
Derive d;
Base* p3 = &b;
Derive* p4 = &d;
printf ("Person 虚表地址:%p\n" , (void *)*(int *)p3);
printf ("Student 虚表地址:%p\n" , (void *)*(int *)p4);
printf ("虚函数地址:%p\n" , &Base::func1);
printf ("普通函数地址:%p\n" , &Base::func5);
return 0 ;
}
下面我们再来验证一下在 Linux 下虚函数表在哪里?
int main () {
int i = 0 ;
static int j = 1 ;
int * p1 = new int ;
const char * p2 = "xxxxxxxx" ;
printf ("栈:%p\n" , &i);
printf ("静态区:%p\n" , &j);
printf ("堆:%p\n" , p1);
printf ("常量区:%p\n" , p2);
Base b;
Derive d;
Base* p3 = &b;
Derive* p4 = &d;
printf ("Person 虚表地址:%p\n" , *(double *)p3);
printf ("Student 虚表地址:%p\n" , *(double *)p4);
printf ("虚函数地址:%p\n" , (void *)&Base::func1);
printf ("普通函数地址:%p\n" , (void *)&Base::func5);
return 0 ;
}
注意:Linux 下默认是 64 位,指针大小为 8 个字节,所以我们要把 int 换位 double 取 8 个字节!
我们观察可以看到 Linux(Ubuntu)下的虚表地址是在堆区上的!
相关免费在线工具 加密/解密文本 使用加密算法(如AES、TripleDES、Rabbit或RC4)加密和解密文本明文。 在线工具,加密/解密文本在线工具,online
Base64 字符串编码/解码 将字符串编码和解码为其 Base64 格式表示形式即可。 在线工具,Base64 字符串编码/解码在线工具,online
Base64 文件转换器 将字符串、文件或图像转换为其 Base64 表示形式。 在线工具,Base64 文件转换器在线工具,online
Markdown转HTML 将 Markdown(GFM)转为 HTML 片段,浏览器内 marked 解析;与 HTML转Markdown 互为补充。 在线工具,Markdown转HTML在线工具,online
HTML转Markdown 将 HTML 片段转为 GitHub Flavored Markdown,支持标题、列表、链接、代码块与表格等;浏览器内处理,可链接预填。 在线工具,HTML转Markdown在线工具,online
JSON 压缩 通过删除不必要的空白来缩小和压缩JSON。 在线工具,JSON 压缩在线工具,online