一. 前言
在面向对象编程中,我们经常希望通过统一的接口来操作不同类型的对象,而不必关心对象的具体类型。这种同一接口,不同行为的能力,被称为多态(Polymorphism)。
在 C++ 中,多态并不是一个独立的语法点,而是建立在继承、虚函数、指针/引用 等机制之上的一整套语言特性。理解多态,关键不在于记住关键字,而在于搞清楚:什么时候会发生动态绑定,什么时候不会。
本篇将从多态的基本概念入手,重点介绍虚函数与抽象类的使用规则,为后续理解多态的底层实现打下基础。
二. 什么是多态
通俗来说,多态就是"多种形态"。在 C++ 中,多态主要分为编译时多态(静态多态) 和运行时多态(动态多态)
2.1. 编译时多态(静态多态)
编译时多态主要包括我们之前讲过的:
-
函数重载(Function Overload)
-
函数模板(Function Template)
通过传入不同类型或不同个数的参数,调用不同的函数实现,从而表现出多种形态
cpp
int add(int a, int b);
double add(double a, double b);
根据传入参数类型不同,编译器在编译阶段 就已经确定要调用哪个函数。也就是说,实参与形参的匹配是在编译期完成的,因此称为编译时多态或静态多态
这里的静态,指的是行为在程序运行前就已经确定
2.2 运行时多态(动态多态)
运行时多态则不同,它强调的是:
同一个行为(函数调用),在运行时根据传入对象的不同,表现出不同的执行结果。
也就是说,函数的具体执行版本是在程序运行时才决定的,因此称为运行时多态或动态多态
我们可以用一个简单的例子来理解:
例1:买票
同样是买票这个行为:
-
普通人买票 → 全价
-
学生买票 → 半价或七五折
-
军人买票 → 优先或免费
买票这个函数没有变,但由于对象不同,执行结果不同,这就是运行时多态。
例2:动物叫
同样是叫这个行为:
-
传入猫对象 → "喵"
-
传入狗对象 → "汪汪"
调用的是同一个接口,但表现形式不同,这正是多态的体现。
三. C++ 中的运行时多态
3.1 多态成立的必要条件
在 C++ 中,并不是只要有继承就一定能形成多态。真正的运行时多态,必须同时满足以下三个条件
条件一:存在继承关系
多态建立在 基类 -- 派生类 的关系之上,没有继承,就谈不上多态
cpp
class Base {};
class Derive : public Base {};
条件二: 基类中存在虚函数,派生类对其进行重写
基类函数必须使用 virtual 声明,派生类提供自己的实现,才能形成同一接口,不同行为
cpp
class Base
{
public:
virtual void func()
{
std::cout << "Base::func" << std::endl;
}
};
class Derived : public Base
{
public:
void func()
{
std::cout << "Derived::func" << std::endl;
}
};
条件三:通过基类指针或引用调用虚函数
cpp
Base* pb = new Derive();
pb->func(); // 调用 Derived::func,形成多态
如果是派生类对象本身调用,则不构成多态
cpp
Derived d;
d.func();
只有基类指针/引用 + 虚函数调用,才会触发运行时多态。
3.2 虚函数的作用
virtual 的核心作用只有一个:
允许函数调用在运行阶段,根据对象的真实类型决定调用哪一个实现
在没有 virtual 的情况下,函数调用采用静态绑定,在编译阶段就已经确定。
cpp
class Base
{
public:
void func()
{
std::cout << "Base::func" << std::endl;
}
};
class Derived : public Base
{
public:
void func()
{
std::cout << "Derived::func" << std::endl;
}
};
Base* pb = new Derived;
pb->func(); // 调用 Base::func(非多态)
而一旦加上 virtual
cpp
class Base
{
public:
virtual void func()
{
std::cout << "Base::func" << std::endl;
}
};
同样的调用语句
cpp
pb->func(); // 调用 Derived::func(多态)
virtual 关键字并非用于使函数变得特殊,而是向编译器表明该函数的最终实现将在运行时动态确定
3.2 虚函数重写(override)
当派生类中定义了一个与基类虚函数完全相同的函数时(即函数名、参数列表、返回值类型完全一致,协变返回类型除外),称派生类的该虚函数重写了基类的虚函数
虚函数重写是实现运行时多态的前提条件之一,它保证了通过基类指针或引用调用虚函数时,能够在运行时根据对象的实际类型,执行派生类中的函数实现
注意事项
在重写基类虚函数时,派生类中的函数即使不显式写 virtual 关键字,也依然能够构成重写。这是因为基类中的虚函数在被继承到派生类后,仍然保持虚函数属性。因此,只要函数签名匹配,派生类函数依旧是虚函数,多态行为也能够正常生效
不过,这种写法不符合代码规范,不建议采用
cpp
class Animal
{
public:
virtual void speak()
{}
};
class Dog : public Animal
{
public:
void speak() // 函数重写
{
std::cout << "Woof" << std::endl;
}
}
override 和 final
在 C++ 中,虚函数的重写(override)规则相对严格。派生类只有在函数名、参数列表、返回类型(协变返回除外)以及 const、引用限定符等完全匹配基类虚函数时,才能真正构成重写
但在实际开发中,由于疏忽,例如函数名拼写错误、参数类型或个数写错、遗漏 const 修饰等情况,派生类中的函数往往并没有重写基类虚函数,而是定义了一个全新的成员函数。这种错误在编译阶段通常不会报错,程序依然可以正常通过编译,但在运行时却无法触发预期的多态行为,最终只能通过调试才能发现问题,代价较高
为了解决这一类隐蔽错误,C++11 引入了 override 关键字。将派生类函数显式标记为 override后,编译器会检查该函数是否确实重写了基类中的虚函数;如果没有构成重写,编译阶段就会直接报错,从而帮助开发者尽早发现问题
如果我们不希望某个虚函数在派生类中被重写,可以使用 final 关键字进行修饰。被 final 修饰的虚函数,任何派生类都无法再对其进行重写
cpp
class Dog : public Animal
{
public:
void speak() override
{
std::cout << "Woof" << std::endl;
}
}
3.4 经典面试题
以下程序输出结果是什么()
A: A->0 B: B->1 C: A->1 D: B->0 E: 编译出错 F: 以上都不正确
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() {
B* p = new B;
p->test();
return 0;
}
很多人的第一反应是 B->0,因为调用的是 B 类的重写函数,自然用 B 类的默认参数。但实际上,正确答案是 B->1
核心原理解析:动态绑定 vs 静态绑定
这道题完美地将虚函数的动态绑定和默认参数的静态绑定融合在了一起。我们需要分两步来看:
1. 为什么输出前缀是 B->?(多态的动态绑定)
在 main 函数中,我们创建了一个 B 类对象。调用 p->test() 时,因为 B 类没有重写 test(),所以继承调用了基类 A::test()
在 A::test() 内部,调用了 func()。这里的 func() 其实隐式地包含了 this 指针,也就是 this->func()。
注意:此时虽然我们在 A 类的作用域内,但这个 this 指针在运行时(动态)实际指向的是一个 B 类对象。因为 func() 是虚函数,触发了多态,所以最终执行的是派生类 B::func()。
2. 为什么参数是 1 而不是 0?(默认参数的静态绑定)
这是整个 C++ 设计中最容易踩坑的地方:虚函数的调用是运行时动态绑定的,但默认参数的值却是在编译时静态绑定的
编译器在编译阶段需要确定默认参数填什么。在 A::test() 内部,this 指针的静态类型被视为 A*。因此,编译器毫不犹豫地把 A::func() 的默认参数 1 给塞了进去,把代码偷偷变成了 this->func(1)
到了运行阶段,多态机制把函数调用转发给了 B::func,但带着的干粮(参数)已经在编译期被写死成 1 了
在多态条件下,函数声明由基类规范,而具体实现则由派生类决定
以下代码能编译通过吗, 如果能, 输出结果是什么
cpp
class A
{
public:
virtual void func() { std::cout << "A::func" << std::endl; }
};
class B : public A
{
private:
void func() override { std::cout << "B::func" << std::endl; }
};
int main() {
A* p = new B;
p->func();
return 0;
}
声明按基类走: 编译时,编译器看 p 是 A*,去 A 类里一看,func() 是 public 的。好,权限检查通过,允许放行!
实现按派生类走: 运行时,虚表指针指向 B 的函数体,最终成功打印出 B::func。
我们居然通过基类指针,在外部合法地调用了派生类的私有(private)函数!
通过上面两个极其反直觉的经典陷阱,我们必须深刻认识到 C++ 多态机制中静态绑定(编译期)与动态绑定(运行期)的割裂感。正如我们前面总结的那句:"声明按基类走,实现按派生类走"。
在编译器眼里,函数的默认参数和访问控制权限都属于声明的一部分,它们在编译阶段就被按基类的静态类型死死地焊住了;而虚函数的具体执行体,则是等到代码真正跑起来时,才根据对象的实际类型去动态调用的。
为了防止代码出现跑着派生类的业务逻辑,却用着基类的默认参数,甚至是在外部越权调用派生类私有方法这种极其精神分裂的诡异行为,我们在实际开发中必须严格遵守以下两条铁律:
绝不!绝不!绝不在重写虚函数时重新定义默认参数(如果非要用默认参数,请确保派生类与基类完全一致,或者干脆不在虚函数中使用默认参数)
重写虚函数时,尽量保持派生类的访问权限与基类严格一致(不要试图在派生类中通过 private 来隐藏一个在基类中是 public 的虚函数,因为多态会轻易击穿这种防线)
3.5 协变返回值(Covariant Return Type)
在 C++ 的运行时多态中,函数重写时不仅参数必须一致,返回值在某些情况下也可以放宽,这就是所谓的协变返回值
派生类重写虚函数时,可以将返回值类型从基类类型改为派生类类型
前提是必须是指针或者是引用
cpp
class Animal
{
public:
virtual Animal* speak() { return nullptr; }
};
class Dog : public Animal
{
public:
Dog* speak() // 函数重写
{
std::cout << "Woof" << std::endl;
return nullptr;
}
}
为什么要有协变?
如果没有协变,派生类只能写成这样
cpp
Animal* speak()
{
std::cout << "Woof" << std::endl;
return nullptr;
}
这会带来两个问题:
-
调用方必须再手动向下转型
-
丢失了派生类接口的信息
而协变允许在不破坏多态规则的前提下,返回更精确的类型,让代码更自然、更安全。
协变返回值让多态"更像人写的代码",而不是被类型系统强行压扁
3.6 重载 / 重写 / 隐藏的对比

-
重载(Overload):同一作用域内,函数名相同、参数列表不同,是编译时根据参数类型/个数确定调用的静态多态形式。
-
重写/覆盖(Override):在继承体系中,派生类对基类的虚函数提供相同签名的实现,这才是真正触发运行时多态。
-
隐藏(Hiding):当派生类定义了与基类同名的函数但参数不匹配时,派生类的这个名字会遮住基类所有同名版本,不论参数签名是否不同。
四. 抽象类与纯虚函数
在 C++ 中,如果在类里将某个虚函数后面写上 = 0,则这个函数就称为 纯虚函数(pure virtual function)。纯虚函数本质上是一个没有函数体、只提供函数声明的虚函数,它告诉编译器这个函数必须由派生类提供实现
cpp
class Animal
{
public:
virtual void speak() = 0;
};
含有至少一个这种纯虚函数的类,就是 抽象类(abstract class)。抽象类不能直接创建对象,因为它并不完整:它有接口定义却缺少具体实现
抽象类的主要作用就是定义一个公共接口规范,让派生类按这个规范去实现不同的行为。这样代码就能依赖基类的接口,而在运行时根据实际派生类对象表现出不同的行为
需要注意
-
即使抽象类中还含有普通成员函数或数据成员,它依然是抽象类,只要存在一个纯虚函数即可
-
派生类继承抽象类后,只有重写了所有纯虚函数,这个派生类才变成可实例化的具体类。
否则这个派生类仍然是抽象的,不允许创建对象
- 抽象类可以被用作指针或引用的类型,用于实现运行时多态,但不能直接创建实例
cpp
class Animal
{
public:
virtual void speak() = 0; // 纯虚函数,使 Animal 成为抽象类
};
class Dog : public Animal
{
public:
void speak() override { cout << "Woof" << std::endl; }
};
int main() {
Animal* a = new Dog;
a->speak(); // 调用 Dog::speak
delete a;
}
为什么抽象类不能创建对象?
抽象类至少有一个函数还没有具体实现,所以它在语义上是不完整的。编译器禁止实例化抽象类,是为了防止程序在运行时调用没有实现的函数。这也是从语言设计层面保护类型安全的一种机制。
编译器之所以要阻止运行时调用没有实现(未定义)的纯虚函数 、以及 禁止实例化含纯虚函数的抽象类,背后有一个非常直接的原因:
如果一个函数在类中声明了但没有提供具体实现,那么在运行时调用它就意味着程序要执行一段根本不存在的代码,这是未定义行为且没有任何语义可依赖。
如果允许实例化这样的基类对象,后续通过基类指针/引用调用这些函数就会尝试跳转到一个没有定义的地址,这在运行时是无意义的 ,无法产生可预期的行为,因此 C++ 的语言设计规定:含有纯虚函数的类是抽象的,不能实例化,从语义上杜绝这种危险调用
五. 多态中的析构函数
在多态的使用场景中,我们经常会用基类的指针去指向派生类的对象(比如 A* p = new B)。当我们使用完毕,准备 delete p 来释放内存时,一个极其致命的陷阱就出现了
1. 核心问题
如果我们不把基类的析构函数设为虚函数,编译器在执行 delete p 时,因为基类与派生类中的析构函数不构成多态,指针 p 根本看不到派生类的析构函数。 结果就是只调用了基类 A 的析构函数,而派生类 B 的析构函数被完全忽略了,此时,如果派生类 B 在堆区申请了属于自己的资源(比如 new 了一块内存),这部分资源将永远无法释放,直接导致严重的内存泄漏
2. 解决方式
为了解决这个问题,我们强烈建议:只要一个类可能被作为基类继承,就应该把它的析构函数设计为虚函数
一旦基类的析构函数变成了虚函数,派生类的析构函数只要被定义,无论你是否显式地加上 virtual 关键字,它都会自动与基类构成重写
核心原理解析:名字不同怎么重写?
你可能会问:重写的严格条件之一是函数名相同,可基类叫 ~A(),派生类叫 ~B(),名字根本不一样啊?
这就是编译器在底层玩的瞒天过海之术,实际上,编译器在编译阶段会对所有析构函数的名称进行特殊处理,统一重命名为 destructor。有了这个统一的底层代号,基类和派生类的析构函数就完美符合了重写规则。从而在 delete p 时触发动态绑定,准确地先调用派生类的析构函数,再调用基类的析构函数,确保资源被彻底清理。
3. 代码演示
cpp
class A
{
public:
A() { std::cout << "A的构造函数" << std::endl; }
virtual ~A() { std::cout << "A的析构函数" << std::endl; }
};
class B : public A
{
public:
B()
{
std::cout << "B的构造函数" << std::endl;
_ptr = new int[10]; // 派生类申请属于自己的资源
}
~B()
{
std::cout << "B的析构函数,清理派生类资源" << std::endl;
delete[] _ptr; // 释放派生类的资源
}
private:
int* _ptr;
};
int main()
{
A* p = new B();
delete p;
return 0;
}
六. 本篇总结
多态是 C++ 面向对象的核心特性之一,它允许程序在运行时根据对象的实际类型选择函数实现,而不是根据变量的静态类型决定。实现运行时多态的关键在于:基类使用 virtual 声明虚函数,派生类重写该函数,并通过基类的指针或引用来调用。虚函数机制使得同一接口在不同对象上表现出不同的行为,从而提高了代码的灵活性和可扩展性。抽象类则通过纯虚函数(= 0)定义接口规范,强制派生类提供具体实现。理解这些基础概念是掌握 C++ 多态的第一步
