文章目录
-
- [1. 都能作为 Person 使用,为什么还是买了全价票?](#1. 都能作为 Person 使用,为什么还是买了全价票?)
- [2. 加上 virtual,改变的是哪一步?](#2. 加上 virtual,改变的是哪一步?)
-
- [2.1 指针没有变成 Student*,引用也没有换一种类型](#2.1 指针没有变成 Student*,引用也没有换一种类型)
- [2.2 怎样判断当前例子是否会表现出不同的行为?](#2.2 怎样判断当前例子是否会表现出不同的行为?)
- [3. 名字相同,就算重写了吗?](#3. 名字相同,就算重写了吗?)
-
- [3.1 先匹配声明,再谈重写](#3.1 先匹配声明,再谈重写)
- [3.2 重载、隐藏、重写,关注的是不同关系](#3.2 重载、隐藏、重写,关注的是不同关系)
- [3.3 final 限制后续重写](#3.3 final 限制后续重写)
- [4. 看起来已经写对了,为什么结果还是不一样?](#4. 看起来已经写对了,为什么结果还是不一样?)
-
- [4.1 按值传参,会把动态类型一起"带过去"吗?](#4.1 按值传参,会把动态类型一起“带过去”吗?)
- [4.2 函数由动态类型选,默认实参却由静态上下文选](#4.2 函数由动态类型选,默认实参却由静态上下文选)
- [5. 析构函数的重写](#5. 析构函数的重写)
-
- [5.1 普通虚函数和虚析构,各自解决一个问题](#5.1 普通虚函数和虚析构,各自解决一个问题)
- [5.2 不加 virtual,不只是"漏调用一次"](#5.2 不加 virtual,不只是“漏调用一次”)
- [6. 没有合理的默认行为:抽象类](#6. 没有合理的默认行为:抽象类)
-
- [6.1 "= 0" 表示接口要求,不是函数返回零](#6.1 “= 0” 表示接口要求,不是函数返回零)
- [6.2 抽象类并不是"只有空函数名"](#6.2 抽象类并不是“只有空函数名”)
- [7. 同一个调用位置,底层是如何找到不同的函数的](#7. 同一个调用位置,底层是如何找到不同的函数的)
-
- [7.1 先理解一个典型实现模型](#7.1 先理解一个典型实现模型)
- [7.2 常见说法理解](#7.2 常见说法理解)
- [7.3 使用 sizeof 观察具有虚函数的类大小](#7.3 使用 sizeof 观察具有虚函数的类大小)
- [8. 多继承多个接口](#8. 多继承多个接口)
-
- [8.1 一个对象,可以经两种基类接口使用](#8.1 一个对象,可以经两种基类接口使用)
- [8.2 虚继承和虚函数,virtual 的位置与目的都不同](#8.2 虚继承和虚函数,virtual 的位置与目的都不同)
- [9. 多态的使用限制与范围](#9. 多态的使用限制与范围)
- [10. 总结](#10. 总结)
1. 都能作为 Person 使用,为什么还是买了全价票?
上一篇继承文章已经说明:公开继承可以让 Student 对象通过 Person 的指针或引用使用。不过,与"能交给同一个函数"与"调用时会执行各自的行为"之间,还差一步。
假设一个买票场景,普通人买全价票,学生买半价票。我们让学生继承作为普通人的共同身份,再分别为各自提供不同的买票函数。
cpp
#include <iostream>
class Person
{
public:
void BuyTicket() const
{
std::cout << "Person: full price" << std::endl;
}
};
class Student : public Person
{
public:
void BuyTicket() const
{
std::cout << "Student: half price" << std::endl;
}
};
void BuyFor(const Person& person)
{
person.BuyTicket();
}
int main()
{
Person person;
Student student;
student.BuyTicket();
BuyFor(person);
BuyFor(student);
return 0;
}
运行示例:

直接写 student.BuyTicket()时,编译器能在 Student 类中找到学生自己的买票函数,所以第一行输出是半价。可是 BuyFor 的参数表达式 person 的类型是 const Person,它的普通成员函数调用选择的是 Person::BuyTicket。这样即便引用绑定到了学生对象,最后一行依然是全价。
如果是在 BuyFor 中先判断对象是什么身份,再写一连串分支用于实现不同身份的对应功能,这样的话,每增加一种身份就得修改这个函数。而我们真正希望的是:BuyFor 只表达"请这个人买票",至于买票这件事,各个身份自己决定怎样买。
这就是本篇关注的运行时多态:通过共同接口发出同一种请求,实际执行的实现随对象而变化。
2. 加上 virtual,改变的是哪一步?
cpp
#include <iostream>
class Person
{
public:
virtual void BuyTicket() const
{
std::cout << "Person: full price" << std::endl;
}
virtual ~Person() = default;
};
class Student : public Person
{
public:
void BuyTicket() const
{
std::cout << "Student: half price" << std::endl;
}
};
class Soldier : public Person
{
public:
void BuyTicket() const
{
std::cout << "Soldier: priority" << std::endl;
}
};
void BuyFor(const Person& person)
{
person.BuyTicket();
}
void BuyThroughPointer(const Person* person)
{
if (person != nullptr)
{
person->BuyTicket();
}
}
int main()
{
Person person;
Student student;
Soldier soldier;
BuyFor(person);
BuyFor(student);
BuyFor(soldier);
BuyThroughPointer(&student);
return 0;
}
运行示例:

2.1 指针没有变成 Student*,引用也没有换一种类型
调用 BuyFor(student) 时,形参仍然是 const Person&;调用 BuyThroughPointer(&student) 时,形参仍然是 const Person*。
为了把"代码中是什么类型"和"实际对象是什么类型"区分开,引入两个词:
- 静态类型 :从表达式和声明中确定的类型。例如
person表达式的静态类型是const Person,而指针参数的声明类型是const Person*。 - 动态类型 :这里指被访问的完整对象的实际类型。引用或指针关联的是学生对象时,动态类型是
Student。
表达式 person.BuyTicket() 先根据静态类型检查:Person 有没有这个成员?它是否能通过 const 对象调用?访问是否合法?匹配的是哪个重载?
这些检查通过之后,因为选中的是虚函数,调用才会根据对象选择对应的最终重写函数 。在当前例子中,学生对象对应 Student::BuyTicket,军人对象则对应 Soldier::BuyTicket。

2.2 怎样判断当前例子是否会表现出不同的行为?
最常用的模式是:公开继承,基类声明虚函数,派生类正确重写,通过基类指针或引用使用不同对象。
这里有几个边界需要一起理解:
- 指针或引用必须关联到仍在生命周期内的有效对象。
- 基类转换要满足可访问、无歧义等要求。
- 派生类没有重写某个虚函数时,可以继续使用继承来的实现,并不会因此失去所有虚函数特性。

3. 名字相同,就算重写了吗?
3.1 先匹配声明,再谈重写
重写,也称覆盖,是派生类为基类的某个虚函数提供对应实现。
先看基本情形:基类函数必须是虚函数,派生类函数的名字、参数类型列表,以及成员函数末尾的 const 等限定要匹配;返回类型通常也要相同。
override是 C++11 提供的重写检查说明符
参数名不需要相同,缺省值不参与是否重写的判断。
| 基类声明 | 派生类声明 | 结果 |
|---|---|---|
virtual void Print() const; |
void Print() const override; |
正确重写 |
virtual void Print() const; |
void Print() override; |
没有匹配末尾的 const,override 让编译器报错 |
virtual void Print(int); |
void Print(double) override; |
参数类型不匹配,报错 |
virtual void Print(int val = 1); |
void Print(int val = 2) override; |
可以重写,但默认实参不同容易造成误解 |
void Print(); |
void Print() override; |
基类函数不是虚函数,报错 |
派生类中正确匹配的函数,即便不再次写 virtual,也仍然是虚函数(但最好还是规范两边都写) 。在基类写 virtual,在派生类写 override,可以同时表达接口性质与重写意图。
override不会把一个错误匹配的函数"强行变成重写"。它的作用是要求编译器核实:"这个函数必须确实重写了基类的虚函数。"
3.2 重载、隐藏、重写,关注的是不同关系
| 名称 | 需要观察什么 | 常见情形 |
|---|---|---|
| 重载 | 同一作用域内同名函数能否通过参数区分 | Print(int) 与 Print(double);仅返回类型不同不能重载 |
| 隐藏 | 派生类名字查找是否遮住基类同名声明 | 派生类声明 Print(double),基类的 Print(int) 不自动参与派生类中的普通查找 |
| 重写 | 派生类声明是否对应某个基类虚函数 | 基类 virtual Print(int),派生类 Print(int) override |
3.3 final 限制后续重写
上一篇中的类级别的 final 禁止进一步继承。同时函数级别的 final 也会禁止派生类继续重写这个虚函数。
cpp
class Car
{
public:
virtual void Drive() final {}
};
class Benz : public Car
{
public:
void Drive() override {} // Car::Drive 已禁止继续重写。
};
int main() {}
4. 看起来已经写对了,为什么结果还是不一样?
4.1 按值传参,会把动态类型一起"带过去"吗?
继承中的对象切片,在多态场景里特别容易造成误判。把 Student 复制成一个独立 Person,得到的是基类对象,不再是学生对象。
cpp
#include <iostream>
class Person
{
public:
virtual void BuyTicket() const { std::cout << "Person: full price" << std::endl; }
virtual ~Person() = default;
};
class Student : public Person
{
public:
void BuyTicket() const override { std::cout << "Student: half price" << std::endl; }
};
void ByValue(Person person) { person.BuyTicket(); }
void ByReference(const Person& person) { person.BuyTicket(); }
int main()
{
Student student;
ByValue(student);
ByReference(student);
const Person& person = student;
person.Person::BuyTicket();
student.BuyTicket();
return 0;
}
运行示例:

第一行:ByValue 的参数是一个独立 Person 对象,学生的基类部分被复制进去,动态类型已经是 Person。
第二行:引用仍绑定原学生对象,执行学生实现。
第三行:person.Person::BuyTicket() 明确指定基类版本。
第四行:student.BuyTicket() 通过对象表达式直接调用自己内部实现。

4.2 函数由动态类型选,默认实参却由静态上下文选
这一步需要把"给函数传什么值"和"执行哪个函数"分开看。
cpp
#include <iostream>
class A
{
public:
virtual void Func(int value = 1) const
{
std::cout << "A -> " << value << std::endl;
}
virtual void Test() const
{
Func();
}
virtual ~A() = default;
};
class B : public A
{
public:
void Func(int value = 0) const override
{
std::cout << "B -> " << value << std::endl;
}
};
int main()
{
B object;
const A& base = object;
base.Func();
object.Func();
object.Test();
base.Func(7);
return 0;
}
运行示例:

base.Func()从A声明取得默认实参1,但对象是B,所以执行B::Func(1)。object.Func()从B声明取得默认实参0,执行B::Func(0)。object.Test()使用继承来的A::Test。进入这个函数后,Func()的默认实参按A::Test所在上下文取1;它相当于对当前对象执行this->Func(1)。当前完整对象仍是B,因此执行B::Func(1)。- 明确传入
7后,不再使用默认实参,执行B::Func(7)。
进入基类成员函数,不会自动把正常使用中的派生对象变成基类对象。基类函数内部的普通、未限定虚调用仍能调用派生实现。
5. 析构函数的重写
5.1 普通虚函数和虚析构,各自解决一个问题
cpp
#include <iostream>
//测试析构函数的重写
//如果~A(),不加virtual,那么deletep2时只调用的A的析构函数,没有调用
//B的析构函数,就会导致内存泄漏问题,因为资源在~B()中释放。
class A
{
public:
virtual ~A()
{
std::cout << "~A()" << std::endl;
}
};
class B : public A {
public:
~B()
{
std::cout << "~B()->delete: " << _p << std::endl;
delete[] _p;
}
protected:
int* _p = new int[10];
};
// 只有派生类Student的析构函数重写了Person的析构函数,下面的delete对象调用析构函数,才能
// 构成多态,才能保证p1和p2指向的对象正确的调用析构函数。
int main()
{
A* p1 = new A;
A* p2 = new B;
delete p1;
delete p2;
return 0;
}
运行示例:

先构造 A 和 B,随后delete p1 删除基类对象,执行 A 析构。delete p2 虽然使用 A*,但基类析构是虚函数,因此先执行 B 析构,释放数组,再自动析构基类部分。

5.2 不加 virtual,不只是"漏调用一次"
在上个例子中,若 A 的析构不是虚函数,却通过 A* 来删除 new B 创建的对象,就会导致内存泄漏的问题。
如果一个基类准备通过公开接口供调用者删除派生对象,通常设计公开虚析构。
析构函数的重写是语言专门规定的规则:基类析构为虚函数时,派生析构也是虚函数,包括编译器隐式生成的派生析构。
基类与派生类析构函数名字不同看起来不符合重写的规则,实际上编译器对析构函数的名称做了特殊处理,编译后析构函数的名称统一处理成destructor,所以基类的析构函数加了virtual修饰,派生类的析构函数就构成重写。
6. 没有合理的默认行为:抽象类
6.1 "= 0" 表示接口要求,不是函数返回零
含有尚未提供非纯最终重写实现的纯虚函数的类是抽象类,不能直接创建它的对象。派生类如果仍有未实现的纯虚接口,也仍是抽象类。
cpp
#include <iostream>
class Car
{
public:
virtual void Drive() const = 0;
virtual ~Car() = default;
};
class Benz : public Car
{
public:
void Drive() const override
{
std::cout << "Benz: 舒适" << std::endl;
}
};
class BMW : public Car
{
public:
void Drive() const override
{
std::cout << "BMW: 操控" << std::endl;
}
};
void TestDrive(const Car& car)
{
car.Drive();
}
int main()
{
Benz benz;
BMW bmw;
TestDrive(benz);
TestDrive(bmw);
return 0;
}
运行示例:


6.2 抽象类并不是"只有空函数名"
抽象类也可以有数据成员、构造函数、普通成员函数和已经实现的虚函数。所以派生对象仍包含它的基类部分,因此仍要构造和析构这个部分。
纯虚函数通常只需要声明;但纯虚函数也可以在类外提供定义,类仍然是抽象类。
其中有一个点:如果把析构声明为纯虚函数,它仍然需要一个定义,因为派生对象销毁时必须析构基类部分。
cpp
#include <iostream>
class Interface
{
public:
virtual ~Interface() = 0;
};
Interface::~Interface()
{
std::cout << "Interface destroyed" << std::endl;
}
class Concrete : public Interface
{
public:
~Concrete() override
{
std::cout << "Concrete destroyed" << std::endl;
}
};
int main()
{
Concrete object;
return 0;
}

示例中的Interface 仍是抽象类;Concrete 的析构提供了相应的非纯重写,所以它可以创建对象。
7. 同一个调用位置,底层是如何找到不同的函数的
7.1 先理解一个典型实现模型
C++ 实现多态通常使用虚函数表,简称虚表 ,以及对象中的虚表指针实现虚调用。在当前简单单继承模型中,可以这样理解:
- 类的虚函数实现信息被组织到表中,不需要每个对象复制一份函数代码。
- 对象中保存关联到适当表的指针。
- 虚调用沿这个关联找到对应条目,进入当前对象适用的函数实现。
虚函数代码与普通函数代码一样,通常都是编译后的指令;表记录的是调用所需的信息。
cpp
#include <iostream>
class Base
{
public:
virtual void First() const { std::cout << "Base::First" << std::endl; }
virtual void Second() const { std::cout << "Base::Second" << std::endl; }
virtual ~Base() = default;
};
class Derived : public Base
{
public:
void First() const override { std::cout << "Derived::First" << std::endl; }
virtual void Third() const { std::cout << "Derived::Third" << std::endl; }
};
int main()
{
Base base;
Derived first;
Derived second;
const Base* objects[] = {&base, &first, &second};
for (int i = 0; i < 3; ++i)
{
objects[i]->First();
objects[i]->Second();
}
first.Third();
// objects[1]->Third(); // Base 接口没有 Third,不能据此调用。
return 0;
}
运行示例:

在示例中,Derived 的表把 First 对应的实现关联到 Derived::First,保留 Second 对应的 Base::Second,再包含新增加的虚函数相关信息。两个正常使用中的同类型对象,通常共享相应虚表,而各自拥有自己的数据。

7.2 常见说法理解
| 问题 | 应怎样理解 |
|---|---|
| 虚表指针一定在对象第一个位置吗? | 常见简单布局中可能如此,语言标准不要求固定位置 |
| 一个对象一定只有一个虚表指针吗? | 简单单继承模型常如此,多继承等布局可能有多个 |
| 每个类一定只有一张表吗? | 不宜概括,多继承及构造阶段可能有更复杂的表组织 |
| 表一定是只有函数地址的数组吗? | 刚学习时可以这么认为,实际 ABI 还可能包含类型信息、偏移、调整入口等 |
| 表末尾一定有零吗? | 没有这种可移植保证,不能用"读到零"为止遍历虚表 |
| 表一定存在某个固定内存区域吗? | 没有标准指定的位置,调试器观察只能描述当前环境 |
| 非虚成员函数也放进虚表吗? | 普通非虚成员调用不依赖虚表分派 |
7.3 使用 sizeof 观察具有虚函数的类大小
以下程序在32位环境运行(指针变量为 4 字节大小):
cpp
#include <iostream>
class Plain
{
protected:
int _number = 1;
char _letter = 'x';
};
class WithVirtual
{
public:
virtual void Func() {}
protected:
int _number = 1;
char _letter = 'x';
};
int main()
{
std::cout << "Plain: " << sizeof(Plain) << std::endl;
std::cout << "WithVirtual: " << sizeof(WithVirtual) << std::endl;
return 0;
}
运行示例:

在32位环境中,Plain 的 int 和 char 加上尾部填充占 8 字节;WithVirtual 还包含一个 4 字节的虚表指针,整体占 12 字节。
8. 多继承多个接口
8.1 一个对象,可以经两种基类接口使用
由多继承可知,一个派生对象可以继承多个基类,也就可以提供多个不同的接口视角。
cpp
#include <iostream>
class Printable
{
public:
virtual void Print() const = 0;
virtual ~Printable() = default;
};
class Speakable
{
public:
virtual void Speak() const = 0;
virtual ~Speakable() = default;
};
class Robot : public Printable, public Speakable
{
public:
void Print() const override { std::cout << "Robot information" << std::endl; }
void Speak() const override { std::cout << "Robot says hello" << std::endl; }
};
int main()
{
Robot robot;
const Printable* printable = &robot;
const Speakable* speakable = &robot;
printable->Print();
speakable->Speak();
return 0;
}
运行示例:

两个指针分别关联到两个基类子对象,语言规则保证各自调用正确的最终实现。
8.2 虚继承和虚函数,virtual 的位置与目的都不同
class Student : virtual public Person 中的 virtual 用于虚继承,处理共同基类子对象的共享问题;virtual void Run() 中的 virtual 用于虚函数,处理实现选择问题。
二者可以同时出现,但不能相互替代。虚继承不会自动让普通成员函数成为虚函数,虚函数也不会自动合并普通菱形继承中的两份基类子对象。
9. 多态的使用限制与范围
在基类中,有些函数并不适合写 virtual,不要为了多态而多态。
| 函数类别 | 规则或原因 |
|---|---|
| 构造函数 | 不能声明为虚函数;构造时需要先确定要创建的类型,不能靠尚未完成的对象再选择构造实现 |
| 静态成员函数 | 不能是虚函数,没有关联某个对象的 this 来选择实现 |
| 非成员函数 | 不能声明为虚函数,虚函数属于类的非静态成员机制 |
10. 总结
- 多态的核心条件: 存在继承关系,基类函数使用
virtual修饰,派生类完成重写,通过基类指针或引用调用函数。值传递会发生对象切片,无法触发多态。 virtual的两种用途: 写在函数前代表虚函数,依靠虚表实现运行时动态绑定;写在继承列表前代表虚继承,用来解决菱形继承里公共基类重复存储的问题。二者作用相互独立,不能互相替换。- 虚函数底层依靠虚表与虚表指针实现: 每个含有虚函数的类会生成一张虚函数表,类对象头部存放虚表指针,运行时通过虚表指针查找对应函数地址。
- 函数重写要求: 函数名、参数列表、返回值符合规则,
const属性也要匹配。派生类重写的虚函数可以不加virtual,override关键字可以显式校验派生类中是否完成重写。 - 不要滥用
virtual: 虚函数会带来运行时开销,虚继承会增大对象体积。只有需要多态或者需要解决菱形继承时,才使用对应的virtual机制。