C++的诗行:一文读懂C++中的多态机制
本文介绍了C++中多态的概念及实现机制,包括多态的构成条件(继承关系、虚函数声明与重写、基类指针/引用调用)、特殊重写情况(缺省参数、协变、析构函数)、override和final关键字的作用,以及纯虚函数和抽象类的特性。重点分析了多态的实现原理,通过虚表指针和虚函数表实现动态绑定,使程序在运行时根据对象实际类型调用对应函数。文章通过多个代码示例展示了多态的实际应用,并解释了经典题目中的多态行为,
目录
一、多态的概念
在 C++ 中,多态(Polymorphism)是面向对象编程的核心特性之一,指的是不同对象对同一消息作出不同响应的能力。简单来说,就是使用统一的接口(如父类指针或引用)调用方法时,程序会根据对象的实际类型执行对应的实现。
#include <iostream>
using namespace std;
// 父类
class Animal {
public:
// 声明虚函数
virtual void makeSound() {
cout << "动物发出声音" << endl;
}
};
// 子类1
class Dog : public Animal {
public:
// 重写父类虚函数
void makeSound() {
cout << "汪汪汪" << endl;
}
};
// 子类2
class Cat : public Animal {
public:
// 重写父类虚函数
void makeSound() {
cout << "喵喵喵" << endl;
}
};
int main() {
Animal* animal1 = new Dog(); // 父类指针指向子类对象
Animal* animal2 = new Cat(); // 父类指针指向子类对象
// 同一调用,不同结果(运行时确定具体调用哪个函数)
animal1->makeSound(); // 输出:汪汪汪
animal2->makeSound(); // 输出:喵喵喵
delete animal1;
delete animal2;
return 0;
}
例如在上述代码中,当基类也就是Animal类的指针(或引用)指向不同的子类时,调用makeSound时运行结果取决于基类的指针或引用指向哪个子类。比如当Animal的指针(或引用)指向Dog时makeSound输出汪汪汪,指向Cat时结果为喵喵喵,也就是指向谁,调用谁。
二、 多态的构成条件
1. 必须存在继承关系
多态的实现依赖类的继承层次,需要有一个基类(父类)和至少一个派生类(子类),派生类通过public方式继承基类(保证接口可访问)。
class Base { ... }; // 基类
class Derived : public Base { ... }; // 派生类(public继承)
2. 基类中必须声明虚函数
在基类中,需要将被派生类重写的函数声明为虚函数,通过virtual关键字标识。这会告诉编译器:该函数可能在派生类中被重写,需要支持动态绑定。
class Base {
public:
// 声明虚函数(关键)
virtual void func() {
cout << "Base::func()" << endl;
}
};
3. 派生类必须重写基类的虚函数
派生类需要重写(覆盖)基类中的虚函数,函数签名(返回值类型、函数名、参数列表)必须与基类完全一致。
class Derived : public Base {
public:
// 重写基类虚函数(函数签名必须完全一致)
void func() override { // override可选,用于检查重写是否正确
cout << "Derived::func()" << endl;
}
};
需要注意的是:
- 基类虚函数的访问权限(
public/protected/private)与派生类重写函数可不同(例如基类public,派生类protected),但不影响重写的成立。
4. 必须通过基类指针或引用调用虚函数
多态的触发需要通过基类指针或基类引用指向派生类对象,然后调用虚函数。此时编译器会在运行时根据对象的实际类型(派生类),调用对应的重写函数。
int main() {
Base* ptr = new Derived(); // 基类指针指向派生类对象(关键)
ptr->func(); // 运行时调用Derived::func(),而非Base::func()
Base& ref = *new Derived(); // 基类引用绑定派生类对象(关键)
ref.func(); // 同样调用Derived::func()
delete ptr;
delete &ref;
return 0;
}
为什么必须通过基类的指针或引用调用虚函数才能实现多态?
在继承机制中,派生类对象可以赋值给基类的指针或引用,此时这些指针或引用实际上指向派生类对象中的基类部分。正是由于这个特性,同一个基类指针或引用可以指向不同的派生类对象(虽然同一时刻只能指向一个特定对象)。当我们通过基类指针或引用调用虚函数时,由于派生类已经重写了该虚函数的实现,编译器就能根据实际指向的对象类型,调用相应派生类中的重写方法,从而实现运行时的多态效果。
总结一下,重写的构成条件有:
- 必须存在继承关系;
- 基类中必须声明虚函数;
- 派生类必须重写基类的虚函数;
- 必须通过基类指针或引用调用虚函数;
2.1 重写概念的补充
虚函数的重写/覆盖:派生类中有⼀个跟基类完全相同的虚函数(即派生类虚函数与基类虚函数的返回值 类型、函数名字、参数列表完全相同,实现方式可以不同),称派生类的虚函数重写了基类的虚函数。
class Person
{
public:
virtual void BuyTicket() {
cout << "买票全价" << endl;
}
};
class Student :public Person
{
public:
virtual void BuyTicket() {
cout << "买票半价" << endl;
}
};
注意:在重写基类虚函数时,派生类的虚函数在不加virtual关键字时,也可以构成重写(因为继承后基类的虚函数被继承下来了在派生类依旧保持虚函数属性),但是该种写法不是很规范,不建议这样 使用,不过在考试选择题中,经常会故意买这个坑,让你判断是否构成多态。
下面我们对重写中的一些特殊的情况进行一些说明:
1. 缺省值的说明
派生类虚函数与基类虚函数的返回值 类型、函数名字、参数类型完全相同就构成了重写/覆盖,但是没有对参数列表的缺省值进行要求,那么缺省值会有哪些情况呢?
在 C++ 的虚函数重写中,缺省参数(默认参数)的行为比较特殊:当通过基类指针或引用调用重写的虚函数时,函数的缺省参数值由基类的函数声明决定,而非派生类的重写版本。这是因为缺省参数是编译时确定的,而虚函数的多态调用是运行时确定的,两者的绑定时机不同。
也就是说派生类对基类中虚函数的重写/覆盖本质上是重写/覆盖了函数的实现方法,如果派生类对基类虚函数进行了重写/覆盖后又对函数缺省值进行了要求那么当形成多态效果时这些缺省值是无效的,举个例子:
#include <iostream>
using namespace std;
class Base {
public:
// 基类虚函数,缺省参数为10
virtual void show(int num = 10) {
cout << "Base: " << num << endl;
}
};
class Derived : public Base {
public:
// 派生类重写虚函数,缺省参数为20(但不会生效)
void show(int num = 20) override {
cout << "Derived: " << num << endl;
}
};
int main() {
Base* ptr = new Derived(); // 基类指针指向派生类对象
ptr->show(); // 调用的是Derived::show(),但缺省参数用Base的10
// 输出:Derived: 10(而非20)
delete ptr;
return 0;
}

2. 协变(了解)
在 C++ 中,协变(Covariance) 是虚函数重写时的一种特殊规则,派生类重写基类虚函数时,与基类虚函数返回值类型不同。即基类虚函数返回基类对象的指针或者引用,派生类虚函数返回派生类对象的指针或者引用时,称为协变(其他函数签名必须完全一致)。
#include <iostream>
using namespace std;
// 基类
class Base {
public:
// 基类虚函数,返回Base*
virtual Base* clone() const {
cout << "Base::clone()" << endl;
return new Base(*this);
}
virtual ~Base() = default;
};
// 派生类
class Derived : public Base {
public:
// 重写clone(),返回Derived*(Base*的派生类型)—— 协变
Derived* clone() const override { // 注意返回值是Derived*
cout << "Derived::clone()" << endl;
return new Derived(*this);
}
};
int main() {
Base* basePtr = new Derived();
Base* copy = basePtr->clone(); // 多态调用Derived::clone()
// copy实际类型是Derived*,但通过Base*接收
delete copy;
delete basePtr;
return 0;
}
需要注意的是像这种情况也会构成协变:
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;
}
3. 析构函数的重写
基类的析构函数为虚函数,此时派生类析构函数只要定义,无论是否加virtual关键字,都与基类的析构函数构成重写。虽然基类与派生类析构函数名字不同看起来不符合重写的规则,实际上编译器对析构函数的名称做了特殊处理,编译后析构函数的名称统⼀处理成destructor,因为基类的析构函数加了 vialtual修饰,派生类的析构函数就构成重写。
下面的代码我们可以看到,如果~A(),不加virtual,那么delete p2时只调用的A的析构函数,没有调用B的析构函数,就会导致内存泄漏问题,因为~B()中在释放资源。
class A
{
public:
~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* p2 = new B;
// 只有派⽣类B的析构函数重写了A的析构函数,下⾯的delete对象调⽤析构函数,才能构成多态,
//才能保证p1和p2指向的对象正确的调⽤析构函数。
delete p2;
return 0;
}
所以在一般情况下,基类的析构函数建议设计成为虚函数。
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];
};
三、override 和final关键字
在 C++11 及以后的标准中,override和final是用于修饰虚函数的关键字,它们的主要作用是增强代码的可读性和安全性,明确开发者的意图并帮助编译器捕获错误。
3.1. override关键字
override用于显式声明派生类中的函数是重写基类的虚函数,其核心作用是让编译器检查重写的正确性,避免因函数签名不一致导致的意外 “隐藏” 而非 “重写” 的问题。
用法与特点:
- 只能用于派生类的成员函数,且该函数必须重写基类的虚函数。
- 若函数签名(函数名、参数列表、返回值类型,协变情况除外)与基类虚函数不一致,编译器会直接报错。
class Car {
public:
virtual void Dirve()
{}
};
class Benz :public Car {
public:
//这里由于类名写错没有构成重写,编译器检查后报错
virtual void Drive() override
{
cout << "Benz舒适" << endl;
}
};
int main()
{
return 0;
}

3.2. final关键字
final有两种用法:禁止虚函数被进一步重写,或禁止类被继承。
用法一:修饰虚函数,禁止派生类重写
- 用于基类或派生类的虚函数,声明后,任何派生类都不能重写该函数。
class Base {
public:
// 声明为final,禁止派生类重写
virtual void func() final {
cout << "Base::func()" << endl;
}
};
class Derived : public Base {
public:
// 错误:试图重写final函数,编译器报错
// void func() override { }
// 错误提示:'func' marked 'override' but is not virtual
};
用法二:修饰类,禁止该类被继承
- 用于类的声明,声明后,任何类都不能继承自该类。
// 声明为final,禁止被继承
class FinalClass final {
public:
void func() {}
};
// 错误:试图继承final类,编译器报错
// class Derived : public FinalClass { };
// 错误提示:cannot derive from 'final' base 'FinalClass' in derived type 'Derived'
四、纯虚函数与抽象类
在虚函数的后面写上=0,则这个函数为纯虚函数,纯虚函数不需要定义实现(实现没啥意义因为要被 派⽣类重写,但是语法上可以实现),只要声明即可。
包含纯虚函数的类叫做抽象类,抽象类不能实例化出对象,如果派生类继承后不重写纯虚函数,那么派生类也是抽象类。纯虚函数某种程度上强制了 派生类重写虚函数,因为不重写实例化不出对象。
#include <iostream>
using namespace std;
// 抽象类:包含纯虚函数
class Shape { // 形状(抽象概念)
public:
// 纯虚函数:强制派生类实现面积计算
virtual double area() const = 0;
// 纯虚函数:强制派生类实现周长计算
virtual double perimeter() const = 0;
// 普通成员函数(有实现)
void printInfo() const {
cout << "面积: " << area() << ", 周长: " << perimeter() << endl;
}
};
// 具体类:重写所有纯虚函数
class Circle : public Shape {
private:
double radius;
public:
Circle(double r) : radius(r) {}
// 重写纯虚函数
double area() const override {
return 3.14 * radius * radius;
}
// 重写纯虚函数
double perimeter() const override {
return 2 * 3.14 * radius;
}
};
// 具体类:重写所有纯虚函数
class Rectangle : public Shape {
private:
double width, height;
public:
Rectangle(double w, double h) : width(w), height(h) {}
double area() const override {
return width * height;
}
double perimeter() const override {
return 2 * (width + height);
}
};
int main() {
// Shape s; // 错误:抽象类不能实例化
Shape* circle = new Circle(5); // 基类指针指向派生类对象
Shape* rect = new Rectangle(3, 4);
circle->printInfo(); // 面积: 78.5, 周长: 31.4
rect->printInfo(); // 面积: 12, 周长: 14
delete circle;
delete rect;
return 0;
}
五、多态的原理
5.1 虚表指针与虚函数表
当我们定义的一个类中含有虚函数时(或含有虚函数类的派生类),编译器在编译的时候会生成一张只读的虚函数表。这张虚函数表本质上是函数指针数组,记录的是类中每一个虚函数的地址。
虚函数表不直接存储在类中,而是存储在全局数据区。每个包含虚函数的类的对象,在其内存布局的最开头(或固定位置)会被编译器自动插入一个虚表指针,指向该对象所属类的虚函数表。
同一个类的所有对象共享同一份虚函数表
class Person {
public:
virtual void BuyTicket()
{
cout << "买票全价" << endl;
}
};
int main()
{
Person p1;
Person p2;
Person p3;
return 0;
}


若派生类新增了虚函数,这些函数地址会被追加到派生类的虚函数表末尾

若派生类重写了基类的虚函数,派生类的虚函数表中会用派生类的函数地址替换基类对应的虚函数地址。
class A {
public:
virtual void Function1()
{
cout << "基类的虚函数" << endl;
}
};
class B :public A{
public:
//派生类B重写A中的虚函数,虚函数表中Function1的地址与A不同
virtual void Function1()
{
cout << "重写基类的虚函数" << endl;
}
};
class C :public A {
public:
//派生类C不重写A中的虚函数,虚函数表中Function1的地址与A相同
};
int main()
{
A p1;
B p2;
C p3;
return 0;
}

如图上代码所示,类类型B与A存在继承关系并且B中的Function1重写了A中的Function1,C与A只存在继承关系。因为B中的Function1重写了A中的Function1,所以B中的虚表指针所指向的虚函数表中会使用B中重写的函数地址覆盖A的虚函数地址。反之,C中的虚函数表中的函数地址与A相同。
5.2 动态绑定(多态本质)
动态绑定的核心是 "同名函数的调用延迟到运行时决定",即:当通过基类指针 / 引用调用一个函数时,程序会根据该指针 / 引用实际指向的对象类型(而非指针 / 引用本身的静态类型),选择对应的函数实现。
这种机制使得不同派生类可以重写基类的虚函数,而基类指针 / 引用无需修改就能调用到正确的派生类实现,体现了 "一个接口,多种实现" 的多态思想。
当基类的指针或引用指向派生类对象后,且派生类中重写了基类对应的虚函数,这时通过基类的指针或引用调用重写后的虚函数接口时动态绑定是这样发生的:
- 程序通过指向的对象找到该对象的虚表指针(Vptr)
- 再通过 Vptr 找到对应的虚函数表(Vtable);
- 找到被调用函数的地址并调用实际的函数。
举个例子:
#include <iostream>
using namespace std;
class Animal {
public:
// 基类虚函数
virtual void speak() {
cout << "动物发出声音" << endl;
}
virtual ~Animal() {} // 虚析构函数,确保动态绑定析构
};
class Dog : public Animal {
public:
// 重写基类虚函数
void speak() override {
cout << "狗叫:汪汪汪" << endl;
}
};
// 接收基类引用,体现多态
void makeSound(Animal* animal) {
animal->speak(); // 动态绑定:调用实际对象的speak()
}
int main() {
Animal animal;
Dog dog;
makeSound(&animal); // 输出:动物发出声音(实际类型Animal)
makeSound(&dog); // 输出:狗叫:汪汪汪(实际类型Dog)
return 0;
}
在这个例子中我们首先看到在makeSound接口中基类的指针都调用了speak。此时编译器在运行的时候会检查基类指针animal的指向,如果指向Animal类型会在该类型对象中查找虚表指针找到虚函数表并将其中的speak函数地址放入指定寄存器并执行函数。因为Dog中重写了speak接口,在虚函数表中speak接口的地址会被重写后的函数地址覆盖,此时虽然接口与Animal相同但执行了不同的实现方法,这就是多态。

学习了多态这一重要机制后我们接下来看一道比较经典的关于多态类型的题目:、
以下程序输出结果是什么()
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(int argc, char* argv[])
{
B* p = new B;
p->test();
return 0;
}
首先我们发现在主函数中使用指向派生类B的指针调用了未被重写的虚函数text,因为虚函数text未被重写所以在B的虚函数表中text的函数地址未被覆盖还是A中的函数地址。所以在调用时调用的是A中的text。关键在于text中的this指针指向B还是A,因为text未被B重写且B中也未定义text接口。所以此时A中的text接口中的this指针还是指向A,此时调用func接口满足多态的前提条件形成多态。
在前面我们讲过,通过基类指针或引用调用重写的虚函数时,函数的缺省参数值由基类的函数声明决定,而非派生类的重写版本。所以调用B中的func函数时,重新定义的val参数的缺省值会失效还是使用基类中事先声明的缺省值。所以程序的运行结果是B->1,选B。
更多推荐





所有评论(0)