C/C++ 面向对象编程(OOP)18 题精讲
**摘要:**本文系统梳理了 C/C++ 面向对象编程的 18 个核心面试知识点,围绕类与对象、封装、继承、多态、构造函数与析构函数、static 成员、虚函数、虚指针和虚表等内容展开。文章重点辨析了重载与覆盖、三种继承方式、基类析构函数为何要虚、构造函数为何不能虚以及多态的编译时与运行时实现机制,并最终结合插件化推理框架,说明虚表、虚指针和对象模型在真实工程中的应用价值,帮助读者建立从基础语法到底层实现,再到工程落地的完整知识链。
一句话总览:OOP 的核心是用"对象"封装状态与行为,用"抽象/封装"隐藏复杂度,用"继承"扩展和复用,用"多态"让同一接口表现出不同实现;C++ 主要通过类、访问控制、构造/析构、虚函数、虚表和虚指针把这些概念落地。
0. 18 个知识点之间的关系
OOP 总基础
├─ 1. C/C++ 中的 OOP 思想
└─ 8. 对象、类、抽象、封装、继承、多态
│
├─ 抽象与实例化控制
│ ├─ 2. 纯虚函数、抽象类
│ └─ 14. 如何阻止类被实例化
│
├─ 封装与对象生命周期
│ ├─ 5. 类默认特殊成员函数
│ ├─ 6. 多对象共享的 static 成员变量
│ ├─ 7. 基类析构函数为什么常是虚函数
│ ├─ 9. 构造函数为什么不能是虚函数
│ └─ 15. static 成员的优点与访问方式
│
├─ 继承与接口关系
│ ├─ 4. 重载与覆盖
│ ├─ 12. public/protected/private 继承
│ └─ 16. 子类不写 virtual 是否仍多态、是否包含父类成员
│
├─ 运行时多态底层
│ ├─ 3/11. 多态是什么、有什么用
│ ├─ 10. 虚函数以及为什么不能所有函数都虚
│ ├─ 13. 虚指针
│ └─ 17. 多态的实现方式
│
└─ 工程落地
└─ 18. C++ 对象模型、虚表、虚指针在插件化推理框架中的意义
学习时不要把 18 个点当成孤立面试题。它们实际回答四个问题:
- 怎样组织数据和行为:类、对象、封装、抽象。
- 怎样复用和扩展:继承、访问控制、重载、覆盖。
- 对象怎样正确创建和销毁:构造函数、析构函数、默认成员函数。
- 程序怎样在运行时选择正确实现:虚函数、虚表、虚指针、多态,以及插件架构。
1. C/C++ 中面向对象的相关知识
核心:C 语言不是原生面向对象语言,但可以用 struct、函数指针、不透明指针模拟 OOP;C++ 则在语言层面提供类、继承、多态、构造析构和访问控制。
面向对象编程强调把数据和操作数据的函数绑定在一起,而不是让全局函数随意操作散落的数据。C 语言虽然没有 class、public、private、virtual,但仍然可以按照 OOP 思想设计程序。例如,用结构体保存对象状态,用函数指针表示不同行为,用"第一个成员是基类对象"的方式模拟继承,用头文件中只暴露不完整类型、源文件中隐藏实现细节的方式模拟封装。
// C 风格:用结构体 + 函数指针模拟多态
#include <stdio.h>
struct AnimalC {
const char* name;
void (speak)(struct AnimalC self);
};
void dog_speak(struct AnimalC* self) {
printf("%s says: Wang!\n", self->name);
}
int main() {
struct AnimalC dog = {"Buddy", dog_speak};
dog.speak(&dog);
}
C++ 把这些模式交给编译器和类型系统支持:
#include <iostream>
#include <memory>
#include <string>
class Animal {
protected:
std::string name;
public:
explicit Animal(std::string n) : name(std::move(n)) {}
// 纯虚函数:定义统一接口
virtual void speak() const = 0;
// 多态基类通常需要虚析构函数
virtual ~Animal() = default;
};
class Dog : public Animal {
public:
using Animal::Animal;
void speak() const override {
std::cout << name << " says: Wang!\n";
}
};
class Cat : public Animal {
public:
using Animal::Animal;
void speak() const override {
std::cout << name << " says: Miao!\n";
}
};
void make_speak(const Animal& animal) {
animal.speak(); // 运行时根据真实类型调用
}
int main() {
Dog dog{"Buddy"};
Cat cat{"Mimi"};
make_speak(dog);
make_speak(cat);
}
C 风格 OOP 的缺点是:约束主要靠程序员自觉,函数指针表需要手动维护,继承关系和类型转换缺少编译器检查,对象销毁也容易遗漏。C++ 的优势在于:通过 class 实现封装,通过 public/protected/private 控制访问,通过继承表达类型关系,通过虚函数实现运行时多态,通过构造函数和析构函数保证对象生命周期。理解这一点后,后面的虚表、虚指针本质上都可以看作"编译器自动帮我们维护的函数指针表"。
2. 什么是纯虚函数、抽象类
核心:纯虚函数是只规定接口、暂不提供默认实现的虚函数,写法是 virtual 返回类型 函数名() = 0;;包含纯虚函数的类是抽象类,不能直接创建对象,但可以定义指针或引用。
抽象类用于表达"接口"或"尚未完成的概念"。例如"图形"一定可以求面积,但"图形本身"并不知道如何计算面积,只有圆形、矩形、三角形才知道。因此可以把 Shape 设计成抽象类,强制每个具体图形实现 area()。
#include <cmath>
#include <iostream>
#include <memory>
class Shape {
public:
// 纯虚函数:规定所有图形都必须能计算面积
virtual double area() const = 0;
// 普通虚函数:可提供统一描述
virtual void describe() const {
std::cout << "area = " << area() << '\n';
}
// 抽象类作为多态基类,虚析构函数是必要的
virtual ~Shape() = default;
};
class Circle : public Shape {
double radius;
public:
explicit Circle(double r) : radius(r) {}
double area() const override {
return std::acos(-1.0) * radius * radius;
}
};
class Rectangle : public Shape {
double width, height;
public:
Rectangle(double w, double h) : width(w), height(h) {}
double area() const override {
return width * height;
}
};
int main() {
// Shape s; // 错误:Shape 是抽象类,不能实例化
std::unique_ptr<Shape> s1 = std::make_unique<Circle>(2.0);
std::unique_ptr<Shape> s2 = std::make_unique<Rectangle>(3.0, 4.0);
s1->describe();
s2->describe();
}
抽象类有几个关键点。第一,抽象类不能直接实例化,因为它至少有一个成员函数没有完整实现。第二,抽象类可以有构造函数、普通成员函数、成员变量,也可以有虚析构函数,并不要求所有函数都是纯虚函数。第三,派生类必须实现所有继承来的纯虚函数,否则它仍然是抽象类。第四,纯虚函数也可以提供函数体,但类仍然是抽象类;这种写法较少见,常用于给派生类提供公共默认实现。
Shape(抽象类:定义 area 接口)
├─ Circle:实现 area,成为具体类
├─ Rectangle:实现 area,成为具体类
└─ WeirdShape:没有实现 area,仍然是抽象类
如果一个类只包含纯虚函数和虚析构函数,它通常被称为"接口类"。接口类最适合作为模块边界,例如推理插件、数据库驱动、日志后端。调用方只依赖接口,不依赖具体实现,从而降低耦合。易错点是:抽象类虽然不能创建对象,但可以声明 Shape* 或 Shape&,这正是运行时多态的基础。
3. 什么是多态?多态有什么作用?
核心:多态是"同一接口,不同实现;同一消息,不同对象作出不同响应"。它的主要作用是降低调用方与具体实现之间的耦合,使程序更容易扩展、替换和测试。
多态可以分为编译时多态和运行时多态。编译时多态包括函数重载、模板、运算符重载、隐式类型转换等;运行时多态主要通过基类指针或引用调用虚函数实现。面试中如果只说"多态就是虚函数",答案会偏窄,因为 C++ 是一门同时支持静态多态和动态多态的语言。
#include <iostream>
#include <memory>
#include <vector>
class Animal {
public:
virtual void speak() const = 0;
virtual ~Animal() = default;
};
class Dog : public Animal {
public:
void speak() const override {
std::cout << "Wang\n";
}
};
class Cat : public Animal {
public:
void speak() const override {
std::cout << "Miao\n";
}
};
class Duck : public Animal {
public:
void speak() const override {
std::cout << "Gaga\n";
}
};
void let_it_speak(const Animal& animal) {
// 调用方只知道 Animal,不关心具体动物
animal.speak();
}
int main() {
Dog dog;
Cat cat;
Duck duck;
let_it_speak(dog);
let_it_speak(cat);
let_it_speak(duck);
}
如果没有多态,let_it_speak 可能需要写成:
void let_it_speak(const Dog&) { /* ... */ }
void let_it_speak(const Cat&) { /* ... */ }
void let_it_speak(const Duck&) { /* ... */ }
每增加一种动物,就要修改旧函数或增加分支,违反"对扩展开放、对修改关闭"的原则。有了多态后,新增 Sheep 时只需要新增类并实现 speak(),旧的调用代码基本不用改。
多态的作用主要有四点。第一,解耦 :调用方依赖抽象接口,而不是某个具体类。第二,扩展 :新增实现时不必大规模修改旧代码。第三,替换 :可以在运行时根据配置、用户输入或插件加载结果选择实现。第四,测试 :业务逻辑依赖接口,单元测试时可以传入模拟对象。例如真实环境使用 RealModel,测试环境使用 FakeModel,二者都实现 IModelInfer 接口。
需要注意,运行时多态必须通过指针或引用触发。如果写成值对象 Animal a = dog;,由于 Animal 是抽象类无法编译;即使基类不是抽象类,值拷贝也可能发生对象切割,只保留基类部分,丢失派生类行为。因此多态代码中常见 Base*、Base&、unique_ptr<Base>,而很少直接使用基类值对象保存派生类对象。
4. 重载和覆盖有什么区别?
核心:重载是同一作用域内函数名相同但参数列表不同,编译时根据实参选择;覆盖是派生类重新实现基类的虚函数,要求函数签名匹配,运行时通过虚函数机制选择。
重载和覆盖都表现为"同名函数有不同版本",但它们发生的时间、作用域、参数要求和设计目的完全不同。
#include <iostream>
#include <string>
class Printer {
public:
// 重载:函数名相同,参数列表不同
void print(int x) {
std::cout << "int: " << x << '\n';
}
void print(double x) {
std::cout << "double: " << x << '\n';
}
void print(const std::string& x) {
std::cout << "string: " << x << '\n';
}
};
class Base {
public:
virtual void show(int x) {
std::cout << "Base::show(" << x << ")\n";
}
virtual ~Base() = default;
};
class Derived : public Base {
public:
// 覆盖:签名与 Base::show(int) 匹配
void show(int x) override {
std::cout << "Derived::show(" << x << ")\n";
}
};
int main() {
Printer p;
p.print(1);
p.print(3.14);
p.print("hello");
Base* ptr = new Derived;
ptr->show(10); // 输出 Derived::show
delete ptr;
}
| 对比项 | 重载 Overload | 覆盖 Override |
|---|---|---|
| 发生时间 | 编译时 | 运行时 |
| 作用域 | 通常在同一作用域 | 基类与派生类之间 |
| 参数列表 | 必须不同 | 必须匹配 |
| 是否需要 virtual | 不需要 | 基类函数必须是虚函数 |
| 选择依据 | 实参类型、个数 | 对象真实类型 |
| 返回值 | 不参与重载区分 | 通常应与基类一致 |
| C++ 辅助关键字 | 无特殊关键字 | 推荐写 override |
覆盖时最容易犯的错误是"以为自己覆盖了,实际上只是隐藏或重载了"。例如基类是 virtual void f(int),派生类写成 void f(double),参数不同,就不是覆盖;写成 void f(int) const,常量性不同,也不是覆盖。加上 override 后,编译器会检查是否真的覆盖了基类虚函数,如果签名不匹配会直接报错。
class Bad : public Base {
public:
// void show(double x) override; // 错误:没有覆盖 Base::show(int)
};
另外,派生类如果重定义了同名函数,会隐藏基类中所有同名重载函数。若希望它们继续参与重载解析,需要使用 using Base::func;。简单记忆:重载看参数,覆盖看虚函数和对象真实类型;重载解决"用不同参数调用同名操作",覆盖解决"同一接口在不同子类中有不同实现"。
5. C++ 中类默认有哪些成员函数?
核心:C++11 起,一个空类通常涉及六个特殊成员函数:默认构造函数、拷贝构造函数、移动构造函数、拷贝赋值运算符、移动赋值运算符、析构函数;但它们是否真的隐式生成、是否被删除,要受用户声明情况影响。
如果写一个空类:
class Empty {};
编译器在需要时可以近似把它视为:
class Empty {
public:
Empty() { } // 默认构造函数
Empty(const Empty& other) { } // 拷贝构造函数
Empty(Empty&& other) noexcept { } // 移动构造函数
Empty& operator=(const Empty& other) { } // 拷贝赋值
Empty& operator=(Empty&& other) noexcept { } // 移动赋值
~Empty() { } // 析构函数
};
它们分别负责对象的创建、复制、移动和销毁。
Empty a; // 默认构造
Empty b = a; // 拷贝构造
Empty c = std::move(b); // 移动构造
b = a; // 拷贝赋值
c = std::move(b); // 移动赋值
// 作用域结束时析构
但"编译器会声明"不等于"永远会生成可用函数"。例如,如果用户自己声明了构造函数,编译器就不再隐式生成默认构造函数;如果类包含不能拷贝的成员,如 std::unique_ptr,拷贝构造函数可能被定义为删除;如果用户自定义了析构函数、拷贝构造或拷贝赋值,移动操作通常不会自动生成。现代 C++ 推荐两条规则:
-
Rule of Zero(零法则):类成员自己管理资源,业务类尽量不自定义析构、拷贝和移动。
-
Rule of Five(五法则):只要自定义了析构、拷贝构造、拷贝赋值、移动构造、移动赋值中的任意一个,通常就要考虑其余几个。
#include
#include
class Student {
private:
std::string name;
std::vectorscores;
public:
// 不需要手写析构函数
// string 和 vector 会自己管理资源
};
class IntBuffer {
private:
int* data;
public:
explicit IntBuffer(int n) : data(new int[n]) {}
~IntBuffer() {
delete[] data;
}IntBuffer(const IntBuffer& other)
: data(new int[/* 需要知道长度,此处仅示意 */]) {
// 深拷贝
}IntBuffer& operator=(const IntBuffer& other) {
if (this != &other) {
// 深拷贝,释放旧资源
}
return *this;
}IntBuffer(IntBuffer&& other) noexcept : data(other.data) {
other.data = nullptr;
}IntBuffer& operator=(IntBuffer&& other) noexcept {
if (this != &other) {
delete[] data;
data = other.data;
other.data = nullptr;
}
return *this;
}
};
面试中回答此题时,不要只背"默认构造、析构、拷贝构造、拷贝赋值",否则停留在 C++03。更准确的说法是:现代 C++ 有六个特殊成员函数,编译器按规则隐式声明,在需要时生成;类成员的可拷贝/可移动性会影响这些函数是否可用。
6. C++ 中哪一种成员变量可以在一个类的实例之间共享?
核心:static 静态成员变量属于类本身,而不是属于某个对象,因此该类的所有实例共享同一份静态成员变量。
普通非静态成员变量每个对象各有一份。例如三个 Student 对象,各自有独立的 name、age。但如果想统计程序中一共创建了多少个学生对象,就不能把计数器放在普通成员变量中,否则每个对象都会有一个计数器,无法共享。此时应使用 static int count。
#include <iostream>
class Student {
private:
std::string name;
// C++17 可以用 inline static 直接在类内初始化
inline static int total = 0;
public:
explicit Student(std::string n) : name(std::move(n)) {
++total;
}
Student(const Student& other) : name(other.name) {
++total; // 拷贝构造也创建了新对象
}
~Student() {
--total;
}
static int get_total() {
return total; // 静态函数只能直接访问静态成员
}
};
int main() {
std::cout << Student::get_total() << '\n'; // 0
{
Student a("Alice");
Student b("Bob");
std::cout << Student::get_total() << '\n'; // 2
}
std::cout << Student::get_total() << '\n'; // 0
}
内存关系可以这样理解:
普通成员:
对象 a: name ──> Alice
对象 b: name ──> Bob
对象 c: name ──> Charlie
静态成员:
对象 a ┐
对象 b ├──> 共享同一份 Student::total
对象 c ┘
静态成员变量存储在静态存储区,而不是对象内存中,因此 sizeof(Student) 通常不包含静态成员变量的大小。它的生命周期从程序启动后首次初始化开始,到程序结束时销毁。访问方式有两种:推荐使用 Student::total,表示它属于类;也可以通过对象访问,如 a.total,但语义上仍然是同一份类成员。
使用静态成员变量时要注意三个问题。第一,线程安全:多个线程同时修改共享变量需要互斥锁或原子变量。第二,初始化顺序:不同编译单元中的全局静态对象初始化顺序不应相互依赖。第三,访问权限:private static 成员只能由类的成员函数或友元访问,public static 成员才能通过类名直接访问。静态成员适合做对象计数器、全局配置、缓存、工厂注册表等,但不应滥用为"换了名字的全局变量"。
7. 继承层次中,为什么基类析构函数是虚函数?
核心:当使用 基类指针 指向 派生类对象,并通过基类指针 delete 时,如果基类析构函数不是虚函数,可能只调用基类析构函数,导致派生类资源没有释放;把基类析构函数设为虚函数,可以从外到内完整析构。
典型错误场景如下:
#include <iostream>
class Base {
public:
Base() {
std::cout << "Base 构造\n";
}
~Base() {
std::cout << "Base 析构\n";
}
};
class Derived : public Base {
private:
int* buffer;
public:
Derived() : buffer(new int[100]) {
std::cout << "Derived 构造\n";
}
~Derived() {
delete[] buffer;
std::cout << "Derived 析构\n";
}
};
int main() {
Base* p = new Derived;
delete p; // 危险:Base::~Base 不是虚函数,行为未定义
}
正确写法:
class Base {
public:
Base() = default;
virtual ~Base() = default; // 关键
};
此时删除顺序为:
delete p
└─ 调用 Derived::~Derived()
├─ 释放 Derived 自己的资源
├─ 按声明逆序析构 Derived 成员
└─ 调用 Base::~Base()
└─ 析构基类部分
构造顺序与析构顺序相反:
构造:Base → Derived 成员 → Derived 函数体
析构:Derived 函数体 → Derived 成员 → Base
为什么必须这样?因为派生类对象包含基类子对象。销毁时应先销毁派生类新增部分,再销毁基类部分。如果反过来,派生类析构函数还想访问基类成员,基类部分可能已经不存在。虚析构函数的作用就是让编译器在 delete Base* 时根据对象真实类型找到最外层派生类析构函数。
但并不是所有类都必须有虚析构函数。虚函数会带来虚指针和虚表开销。如果一个类不打算作为多态基类,例如简单值类型 Point、Date,就没必要把析构函数设为虚函数。判断标准很简单:如果类中有虚函数,并且外部可能通过基类指针删除派生类对象,就应该把析构函数声明为虚函数;如果类不会被公开继承或不需要多态删除,就不必增加开销。
8. 面向对象技术的基本概念是什么,三个基本特性是什么?
核心:面向对象以"对象"为中心,对象由属性和方法组成;类是对象的抽象,对象是类的实例。通常说的三个基本特性是封装、继承、多态,而抽象是贯穿三者的重要设计原则。
封装是把数据和操作数据的方法放在一起,并通过访问控制隐藏内部细节。继承是在已有类基础上扩展新类,实现复用和类型层次。多态是同一接口在不同对象上有不同表现。抽象则是提取关键特征、忽略无关细节,例如用 IModel 表示"所有模型都能推理",但不暴露具体模型内部权重如何存储。
#include <iostream>
#include <string>
// 封装:数据和行为绑定,内部状态不随意暴露
class BankAccount {
private:
std::string owner;
double balance = 0;
public:
explicit BankAccount(std::string name) : owner(std::move(name)) {}
void deposit(double money) {
if (money > 0) balance += money;
}
bool withdraw(double money) {
if (money <= 0 || money > balance) return false;
balance -= money;
return true;
}
double get_balance() const {
return balance;
}
};
// 继承与多态
class Animal {
public:
virtual void speak() const = 0;
virtual ~Animal() = default;
};
class Dog : public Animal {
public:
void speak() const override {
std::cout << "Wang\n";
}
};
class Cat : public Animal {
public:
void speak() const override {
std::cout << "Miao\n";
}
};
四者关系如下:
抽象:先识别"什么是重要接口"
↓
封装:把状态和实现细节藏起来,只暴露接口
↓
继承:基于已有抽象扩展新类型
↓
多态:通过统一接口操作不同具体类型
封装解决"如何安全管理状态"的问题。它不允许外部代码任意修改 balance,而是必须通过 deposit/withdraw,这样类可以保证余额始终合法。继承解决"如何复用和扩展"的问题,例如 Dog、Cat 都属于 Animal,可以复用动物的公共属性。多态解决"如何减少分支和耦合"的问题,调用方只写 animal.speak(),运行时自动选择具体实现。抽象解决"如何设计稳定接口"的问题,它让调用方依赖概念而不是细节。
面试中如果问"三个基本特性",标准回答是封装、继承、多态;如果问"基本概念",还应补充类、对象、属性、方法、接口、消息等。真正理解 OOP 不是记住语法,而是能判断什么时候该暴露接口、什么时候该隐藏细节、什么时候用继承表达"is-a"关系,以及什么时候用多态隔离变化。
9. 为什么构造函数不能为虚函数?
核心:虚函数依赖对象已经存在的虚指针和类型信息,而构造函数的任务正是创建对象;在构造函数运行前,对象的虚指针、基类子对象和成员都还没有准备好,因此构造函数不能也无法按普通虚函数方式动态分派。
虚函数调用的大致过程是:通过对象中的虚指针找到虚表,再从虚表中找到函数地址。可是构造函数执行之前,对象还没有完全形成,虚指针也尚未被设置为当前正在构造的类的虚表。此时要求"按对象真实类型调用构造函数"会陷入逻辑矛盾:对象都还没构造完成,又如何知道它的真实类型并调用对应构造函数?
派生类对象的构造顺序如下:
1. 分配内存
2. 构造虚基类/直接基类
3. 设置当前层虚指针
4. 按声明顺序构造成员对象
5. 执行构造函数体
6. 进入更外层派生类构造
在 Base 构造期间,对象的动态类型被视为 Base,即使它最终会变成 Derived。因此构造函数中调用虚函数,也不会调用到派生类重写版本。
#include <iostream>
class Base {
public:
Base() {
std::cout << "Base constructor\n";
init(); // 这里不会调用 Derived::init
}
virtual void init() {
std::cout << "Base::init\n";
}
virtual ~Base() = default;
};
class Derived : public Base {
public:
Derived() {
std::cout << "Derived constructor\n";
}
void init() override {
std::cout << "Derived::init\n";
}
};
int main() {
Derived d;
}
输出中,Base 构造阶段调用的是 Base::init,而不是 Derived::init。这是合理的,因为 Derived 的成员变量此时还没有初始化,如果调用 Derived::init,它可能访问尚未初始化的派生类成员,造成未定义行为。
如果确实需要"根据类型创建不同对象",常见方案不是虚构造函数,而是工厂函数:
#include <memory>
#include <stdexcept>
#include <string>
class Base {
public:
virtual ~Base() = default;
};
class A : public Base {};
class B : public Base {};
class Factory {
public:
static std::unique_ptr<Base> create(const std::string& type) {
if (type == "A") return std::make_unique<A>();
if (type == "B") return std::make_unique<B>();
throw std::invalid_argument("unknown type");
}
};
也可以用"两阶段初始化":构造函数只完成安全、简单的成员初始化,虚函数调用放在对象构造完成后的 initialize() 中。但两阶段初始化容易忘记调用,现代 C++ 更推荐工厂模式或直接在设计上避免在构造期间调用可被派生类覆盖的函数。总结一句:虚函数是对象形成之后的动态行为,构造函数是对象形成之前的创建过程,所以构造函数不能是虚函数。
10. 虚函数是什么?为什么不能把所有函数设为虚函数?
核心:虚函数是在基类中用 virtual 声明、可被派生类覆盖的成员函数;通过基类指针或引用调用时,程序根据对象真实类型决定调用哪个版本。不能把所有函数都设为虚函数,是因为虚函数有空间、时间、设计和二进制接口成本。
#include <iostream>
class Base {
public:
virtual void f() {
std::cout << "Base::f\n";
}
virtual ~Base() = default;
};
class Derived : public Base {
public:
void f() override {
std::cout << "Derived::f\n";
}
};
void call(Base& obj) {
obj.f(); // 虚调用
}
int main() {
Base b;
Derived d;
call(b); // Base::f
call(d); // Derived::f
}
虚函数并不是"函数本身变虚",而是调用点可能在运行时分派。编译器通常为每个多态类生成一张虚表,为每个多态对象放置一个虚指针。
Derived 对象
┌──────────────┐
│ vptr ────────┼──> Derived 虚表
│ Base 子对象 │ ├─ &Derived::f
│ Derived 成员 │ └─ RTTI/类型信息
└──────────────┘
不把所有函数都设为虚函数,主要有五个原因。
第一,空间成本:含有虚函数的类通常每个对象多一个虚指针。64 位程序中一个指针是 8 字节,如果创建百万级小对象,额外内存不可忽略。
第二,时间成本:虚调用通常需要先读虚指针,再按固定偏移读取函数地址,然后间接跳转。虽然推理、IO 等场景下这点开销很小,但高频底层计算中可能影响性能。
第三,阻碍内联:普通非虚函数在编译期已知目标,编译器容易内联;虚函数的真实目标可能运行时才知道,编译器不一定能内联。
第四,设计成本:虚函数意味着"这个函数设计为允许派生类改变行为"。并非所有函数都应该被改变,例如数值运算、不变式维护、生命周期管理函数。随意开放虚函数可能破坏类的约束。
第五,接口与 ABI 成本:虚函数顺序、虚表布局会影响动态库和插件兼容性;一旦发布,修改虚函数接口比修改普通函数更谨慎。
C++ 提供了静态多态替代方案。模板和概念可以在编译期分派,没有虚调用开销:
template <class T>
void speak(const T& obj) {
obj.speak();
}
因此,虚函数适合"运行时还不知道具体类型"的场景,如插件、依赖注入、策略对象;模板适合"编译期已经知道类型、追求零开销抽象"的场景。正确原则是:需要运行时替换才使用虚函数,不要为了"看起来面向对象"把所有函数都设为虚函数。
11. 什么是多态?多态有什么作用?(实现机制角度)
核心:从实现角度看,C++ 多态分为静态多态和动态多态。静态多态在编译期确定调用版本,动态多态通过虚函数、虚指针和虚表在运行期确定调用版本。
多态这个词字面意思是"多种形态"。在 C++ 中,同一个函数名、同一个接口、同一段模板代码,可能作用于不同类型并产生不同行为,这都是多态。
11.1 编译时多态
函数重载根据实参类型选择函数:
void add(int a, int b);
void add(double a, double b);
void add(const std::string& a, const std::string& b);
模板根据类型生成对应代码:
template <class T>
T maximum(T a, T b) {
return a > b ? a : b;
}
CRTP(奇异递归模板模式)可以在编译期实现类似多态的效果:
template <class Derived>
class Base {
public:
void interface() {
static_cast<Derived*>(this)->implementation();
}
};
class Impl : public Base<Impl> {
public:
void implementation() {
// 具体实现
}
};
11.2 运行时多态
class Payment {
public:
virtual void pay() const = 0;
virtual ~Payment() = default;
};
class Alipay : public Payment {
public:
void pay() const override;
};
class WeChatPay : public Payment {
public:
void pay() const override;
};
void checkout(const Payment& method) {
method.pay();
}
调用过程:
checkout(Payment& method)
│
├─ method 的静态类型:Payment
├─ method 的动态类型:Alipay / WeChatPay
├─ 通过对象 vptr 找到对应虚表
├─ 在 pay 所在槽位读取函数地址
└─ 调用 Alipay::pay 或 WeChatPay::pay
多态的作用不仅是"少写 if-else"。更重要的是建立稳定抽象。业务流程可以依赖 Payment、IModel、ILogger 这样的接口,具体支付方式、模型后端、日志实现可以独立变化。新增支付方式时,只要实现同一接口,旧的结账流程不需要修改,这就是开闭原则。
静态多态优点是无虚调用开销、类型检查早、可优化程度高,缺点是不同类型实例化会增加代码体积,且不能在运行时动态加载。动态多态优点是接口统一、可以运行时替换、适合插件和容器存储异构对象,缺点是有间接调用开销,并要求通过指针或引用使用。
面试中回答该题,可以按"定义---分类---代码---底层---工程价值"的顺序:多态是同一接口作用于不同对象;C++ 有编译时和运行时两类;运行时多态靠虚表实现;它的价值是解耦、扩展、替换和测试。这样比单纯回答"父类指针指向子类对象"更完整。
12. 什么是公有继承、受保护继承、私有继承?
核心:三种继承方式决定了基类成员在派生类中的对外可见性。公有继承表达"is-a"关系;受保护继承和私有继承更多表示"复用实现",不是常用的公开接口继承。
C++ 中,成员访问权限和继承方式是两件事。成员本身有 public、protected、private;继承方式也有 public、protected、private。派生类能否访问基类成员,以及外部能否通过派生类对象访问基类成员,要综合判断。
class Base {
public:
int a = 1;
protected:
int b = 2;
private:
int c = 3;
};
class PublicDerived : public Base {
public:
void test() {
a = 10; // 可以
b = 20; // 可以
// c = 30; // 错误:Base 的 private 成员不可访问
}
};
class ProtectedDerived : protected Base {
public:
void test() {
a = 10; // 可以,但 a 在本类中变为 protected
b = 20; // 可以
}
};
class PrivateDerived : private Base {
public:
void test() {
a = 10; // 可以,但 a 在本类中变为 private
b = 20; // 可以
}
};
| 继承方式 | 基类 public 成员在派生类中 | 基类 protected 成员在派生类中 | 基类 private 成员 |
|---|---|---|---|
| public 继承 | public | protected | 不可直接访问 |
| protected 继承 | protected | protected | 不可直接访问 |
| private 继承 | private | private | 不可直接访问 |
外部访问差异:
PublicDerived pd;
pd.a = 1; // 可以,因为公有继承后 a 仍是 public
ProtectedDerived ptd;
// ptd.a = 1; // 错误,a 变成 protected
PrivateDerived prd;
// prd.a = 1; // 错误,a 变成 private
关系语义如下:
public 继承:Dog is-a Animal
派生类对象也是一个基类对象,可以替换基类
protected/private 继承:Derived 根据 Base 实现
外部不应把 Derived 当作 Base
公有继承是最常见、最符合面向对象设计的继承方式,要求派生类严格满足"是一种基类"。例如 Dog : public Animal,任何需要 Animal 的地方都可以接受 Dog。私有继承表示"我借用 Base 的实现,但不承认对外是 Base",它有时可用于组合的替代写法,但现代 C++ 通常更推荐"对象组合",即把 Base 作为成员变量。受保护继承介于二者之间,基类的公开接口对进一步派生类仍可见,但对外部不可见,实际使用较少。
另外要注意:class 默认私有继承,struct 默认公有继承。无论哪种继承,基类的 private 成员仍然存在于派生类对象中,只是派生类不能直接命名和访问。这也是第 16 题的重要基础。
13. 什么是虚指针?
核心:虚指针通常是对象内部一个隐藏指针,指向该对象真实类型对应的虚函数表;程序通过虚指针找到虚表,再调用正确的虚函数。虚指针对象有一份,虚表通常每个多态类有一份。
C++ 标准并没有强制规定必须使用"虚指针 + 虚表"的实现方式,但主流编译器,如 GCC、Clang、MSVC,基本都采用类似模型。理解这个模型有助于理解对象大小、构造顺序、多态性能和插件 ABI。
class Base {
public:
virtual void f();
virtual void g();
virtual ~Base();
};
class Derived : public Base {
public:
void f() override;
};
典型内存结构:
Base 对象
┌─────────────┐
│ vptr │ ──────┐
│ base_data │ │
└─────────────┘ │
▼
Base 虚表
┌──────────────┐
│ &Base::f │
│ &Base::g │
│ &Base::~Base │
│ type_info │
└──────────────┘
Derived 对象
┌─────────────┐
│ vptr │ ──────┐
│ Base 子对象 │ │
│ derived_data│ │
└─────────────┘ │
▼
Derived 虚表
┌─────────────────┐
│ &Derived::f │ ← 被覆盖
│ &Base::g │ ← 未覆盖,沿用基类
│ &Derived::~Base │
│ type_info │
└─────────────────┘
当写出:
Base* p = new Derived;
p->f();
底层近似过程是:
1. 从 p 指向的对象开头读取 vptr
2. 通过 vptr 找到 Derived 的虚表
3. 在 f 对应的槽位读取函数地址
4. 跳转到 Derived::f
虚指针通常在构造函数执行期间被设置。基类构造时,vptr 先指向基类虚表;进入派生类构造时,vptr 被改写为派生类虚表。这解释了为什么构造函数中调用虚函数不会分派到尚未构造完成的派生类版本。
需要注意几个细节。第一,只有声明了虚函数的类才通常有虚指针。第二,虚指针增加对象大小,64 位平台通常为 8 字节。第三,多重继承、虚继承场景下可能出现多个虚指针或调整 this 指针的 thunk,内存布局更复杂。第四,虚表一般是只读的共享数据,不属于每个对象;每个对象共享同一个类的虚表。第五,虚指针不是可以由程序员直接访问的普通成员,它由编译器生成和维护。
一句话记忆:虚表描述"这个类有哪些虚函数版本",虚指针记录"这个对象真实是什么类";多态调用就是沿着虚指针找到虚表,再间接调用函数。
14. C++ 如何阻止一个类被实例化?
核心:常见方法有三种:把类设计成包含纯虚函数的抽象类;把构造函数放到 private/protected;将构造函数声明为 delete。不同方法表达的设计意图不同。
方法一:抽象类
class AbstractBase {
public:
virtual void run() = 0;
virtual ~AbstractBase() = default;
};
// AbstractBase a; // 错误:不能创建抽象类对象
class Impl : public AbstractBase {
public:
void run() override;
};
这是最常见、最符合面向对象的方法。它表达的含义是:"这个类只是接口或抽象概念,具体对象必须由派生类创建。"
方法二:私有或保护构造函数
class Singleton {
private:
Singleton() = default;
static Singleton instance;
public:
static Singleton& get_instance() {
return instance;
}
Singleton(const Singleton&) = delete;
Singleton& operator=(const Singleton&) = delete;
};
构造函数私有后,外部不能直接创建对象,但类的静态成员函数、友元或嵌套类可以创建。它适合单例、工厂、只包含静态工具函数的类。
保护构造函数则允许派生类调用基类构造,但外部不能直接创建基类对象:
class ProtectedBase {
protected:
ProtectedBase() = default;
};
class Derived : public ProtectedBase {
// Derived 可以正常构造
};
方法三:删除构造函数
class NoInstance {
public:
NoInstance() = delete;
};
= delete 表示该函数被显式弃用,重载解析时如果选中它会编译错误。这种方式非常强硬,连友元、派生类也不能正常构造,适合完全不允许产生对象的工具类。
三种方式比较:
| 方法 | 外部能否实例化 | 派生类能否实例化 | 典型用途 |
|---|---|---|---|
| 纯虚函数/抽象类 | 不能 | 实现纯虚函数后可以 | 接口、抽象基类 |
| private 构造 | 不能 | 视情况,通常需静态工厂/友元 | 单例、工厂 |
| protected 构造 | 不能 | 可以 | 只允许作为基类 |
= delete 构造 |
不能 | 基本不能 | 纯静态工具类 |
还要注意,仅把析构函数设为私有并不能可靠阻止对象创建,反而可能影响栈对象和删除操作;把类标记为 final 只能阻止继承,不能阻止实例化。面试中最佳回答通常是:如果目的是定义接口,用抽象类;如果目的是控制创建路径,用私有构造加静态工厂;如果目的是完全不允许对象,用 = delete 构造函数。
15. 类使用 static 成员的优点,如何访问?
核心:static 成员属于类而不是对象,所有实例共享。它适合保存类级别的状态、工具函数、配置、计数器和注册表;静态成员变量通过 类名::成员 访问,静态成员函数通过 类名::函数() 调用。
#include <iostream>
#include <string>
class Config {
private:
inline static std::string model_name = "default-model";
inline static int thread_num = 4;
public:
static void set_model(std::string name) {
model_name = std::move(name);
}
static std::string get_model() {
return model_name;
}
static void set_threads(int n) {
if (n > 0) thread_num = n;
}
static int get_threads() {
return thread_num;
}
};
int main() {
// 不需要创建 Config 对象
Config::set_threads(8);
Config::set_model("resnet50");
std::cout << Config::get_model() << '\n';
std::cout << Config::get_threads() << '\n';
}
静态成员的优点主要有四个。第一,共享状态 :所有对象使用同一份数据,例如对象计数器、全局配置、缓存。第二,无需对象即可调用 :工具函数不必为了使用而创建无意义对象。第三,访问控制比全局变量更好 :private static 成员只能通过类接口访问,便于维护不变式。第四,语义清晰 :看到 Math::sqrt、Config::get_threads 就知道它不依赖某个具体对象状态。
静态成员函数与普通成员函数最大区别是没有 this 指针:
class Example {
private:
int x = 1;
static int y;
public:
static void f() {
// ++x; // 错误:静态函数不知道属于哪个对象,不能访问非静态 x
++y; // 正确:可以访问静态成员
}
};
因此静态成员函数不能声明为 const,因为 const 修饰的是"该函数不会修改当前对象",而静态函数根本没有当前对象;它也不能是虚函数,因为虚函数调用依赖对象的虚指针和 this。
访问方式如下:
class Counter {
public:
inline static int count = 0;
static void increase() {
++count;
}
};
Counter::increase(); // 推荐
Counter::count = 10; // public 时可以
Counter c;
c.increase(); // 语法允许,但不强调对象依赖
C++17 之前,类内只能声明静态成员,通常还需要在类外定义:
// 旧写法
class Old {
public:
static int value;
};
int Old::value = 0;
C++17 起可以使用 inline static 直接在类内初始化:
class New {
public:
inline static int value = 0;
};
使用静态成员时要警惕:它本质上是共享状态,多线程下可能产生数据竞争;它可能让对象之间隐式耦合,降低可测试性;全局初始化顺序也可能出问题。因此静态成员适合真正属于"类级别"的信息,而不是为了方便到处访问而滥用。
16. 父类写了 virtual,子类覆盖时不加 virtual,能否实现多态?子类对象里有没有父类函数和私有变量?
核心:可以。基类函数一旦声明为虚函数,派生类中即使不写 virtual,只要函数签名匹配,仍然是覆盖,仍然实现多态。派生类对象内部包含基类子对象,因此也包含基类私有变量,只是派生类代码不能直接访问;基类函数仍然存在于作用域和虚表机制中。
#include <iostream>
class Base {
private:
int secret = 42; // 基类私有变量
public:
virtual void show() {
std::cout << "Base::show, secret = " << secret << '\n';
}
virtual ~Base() = default;
};
class Derived : public Base {
public:
// 没有写 virtual,但仍然是覆盖
void show() override {
std::cout << "Derived::show\n";
// std::cout << secret; // 错误:Derived 不能访问 Base 的 private 成员
Base::show(); // 正确:可以显式调用基类版本
}
};
int main() {
Base* p = new Derived;
p->show(); // 调用 Derived::show
delete p;
}
virtual 属性不会因为派生类不写而丢失。也就是说,虚性是"沿继承链传递"的:
class A {
public:
virtual void f();
};
class B : public A {
public:
void f(); // 仍然是虚函数,并且覆盖 A::f
};
class C : public B {
public:
void f() override; // 仍然可以继续覆盖
};
现代 C++ 强烈建议显式写 override,但 override 只是让编译器检查覆盖关系,并不是让函数变成虚函数的必要条件。
关于"子类空间里有没有父类函数和私有变量",要区分逻辑访问权限 和物理内存存在。
Derived 对象内存
┌────────────────────────┐
│ vptr │
│ Base 子对象 │
│ ├─ public 成员 │
│ ├─ protected 成员 │
│ └─ private secret │ ← 存在,但 Derived 不能直接访问
│ Derived 自己的成员 │
└────────────────────────┘
基类私有变量 secret 确实存在于派生类对象中,否则基类成员函数无法工作。派生类不能直接访问它,是语法和访问控制层面的限制,不代表它没有占用内存。基类虚函数也仍然存在:如果派生类没有覆盖,虚表槽位指向 Base::show;如果派生类覆盖了,虚表槽位指向 Derived::show,但仍可通过 Base::show() 显式调用基类版本。
Derived d;
Base& ref = d;
ref.show(); // 虚调用:Derived::show
d.Base::show();// 非虚限定调用:Base::show
需要注意,如果派生类函数签名写错,它就不是覆盖,而是隐藏基类同名函数:
class Wrong : public Base {
public:
void show() const; // const 不同,不是覆盖
};
因此结论是:不写 virtual 可以多态,但最好写 override;派生类对象包含基类子对象和基类私有成员,只是不能直接访问私有成员;基类函数没有消失,未覆盖时仍可调用,覆盖后也能用 Base::函数名 显式调用。
17. 多态实现方式是什么?
核心:C++ 多态主要通过两类方式实现:编译时多态靠函数重载、模板和 CRTP;运行时多态靠虚函数、虚指针和虚表。面试常问的"多态底层实现"通常指后者。
17.1 编译时多态
#include <iostream>
// 函数重载
void print(int x) {
std::cout << "int: " << x << '\n';
}
void print(double x) {
std::cout << "double: " << x << '\n';
}
// 模板多态
template <class T>
void print_twice(const T& x) {
std::cout << x << ' ' << x << '\n';
}
int main() {
print(1);
print(3.14);
print_twice("hello");
}
编译时多态在编译期就知道调用哪个函数,优点是效率高、可内联、类型安全;缺点是不同类型会生成不同模板实例,且无法等程序运行后再根据配置动态决定类型。
17.2 运行时多态
#include <iostream>
#include <memory>
class Animal {
public:
virtual void speak() const = 0;
virtual ~Animal() = default;
};
class Dog : public Animal {
public:
void speak() const override {
std::cout << "Wang\n";
}
};
class Cat : public Animal {
public:
void speak() const override {
std::cout << "Miao\n";
}
};
int main() {
std::unique_ptr<Animal> animal = std::make_unique<Dog>();
animal->speak();
animal = std::make_unique<Cat>();
animal->speak();
}
运行时多态的底层结构:
每个多态类:一张虚表 vtable
每个多态对象:一个虚指针 vptr
Dog 对象
┌───────┐
│ vptr │ ──> Dog vtable ──> &Dog::speak
└───────┘
Cat 对象
┌───────┐
│ vptr │ ──> Cat vtable ──> &Cat::speak
└───────┘
调用 animal->speak() 时:
1. animal 是 Animal 指针/引用
2. 读取对象内部 vptr
3. 通过 vptr 找到真实类型的虚表
4. 从 speak 槽位取出函数地址
5. 调用 Dog::speak 或 Cat::speak
运行时多态必须满足几个条件:基类函数声明为 virtual;派生类函数签名匹配并完成覆盖;通过指针或引用调用;对象没有被切割。若直接使用基类值对象,可能发生切片:
Dog dog;
Animal a = dog; // 如果 Animal 不是抽象类,只复制 Animal 部分
a.speak(); // 调用 Animal::speak,而不是 Dog::speak
除了虚函数,C++ 中还可用 std::variant + std::visit 实现封闭类型集合的运行时分派;用概念和模板实现零开销静态多态;用函数对象 std::function 表达策略回调。选择方式时要看类型集合是否开放、类型是否运行时加载、性能要求如何。若类型集合固定且追求性能,可用模板或 variant;若要插件化、运行时替换、依赖注入,则虚函数更合适。
18. C++ 对象模型、虚表、虚指针在插件化推理框架中有什么意义?
核心:插件化推理框架依赖稳定抽象接口隔离主程序与具体模型实现。C++ 对象模型中的虚表和虚指针,使主程序可以只持有基类接口指针,在运行时调用动态加载插件中的派生类实现。
一个推理框架可能支持多种后端:TensorRT、ONNX Runtime、OpenVINO、自研 NPU 后端,甚至由第三方以动态库形式提供插件。主程序不应该在编译时知道所有插件,否则每增加一个后端都要重新编译框架。更合理的方式是定义统一接口:
// IInferencePlugin.hpp
#pragma once
#include <cstdint>
struct Tensor {
float* data = nullptr;
int64_t size = 0;
};
class IInferencePlugin {
public:
virtual bool initialize(const char* model_path) = 0;
virtual Tensor infer(const Tensor& input) = 0;
virtual const char* name() const = 0;
virtual void release() = 0;
protected:
// protected 析构:禁止外部通过接口指针直接 delete
// 也可以使用 public virtual ~IInferencePlugin()
virtual ~IInferencePlugin() = default;
};
// C ABI 工厂函数,避免 C++ name mangling 带来的跨编译器问题
extern "C" {
IInferencePlugin* create_plugin();
void destroy_plugin(IInferencePlugin* plugin);
}
插件内部实现:
// TensorRTPlugin.cpp
#include "IInferencePlugin.hpp"
#include <iostream>
class TensorRTPlugin : public IInferencePlugin {
public:
bool initialize(const char* model_path) override {
std::cout << "load model: " << model_path << '\n';
return true;
}
Tensor infer(const Tensor& input) override {
// 真实场景中调用 TensorRT engine
return input;
}
const char* name() const override {
return "TensorRT";
}
void release() override {
// 释放设备上下文、engine、stream 等资源
}
};
IInferencePlugin* create_plugin() {
return new TensorRTPlugin;
}
void destroy_plugin(IInferencePlugin* plugin) {
delete plugin;
}
主程序运行时加载动态库:
// Linux: dlopen/dlsym
// Windows: LoadLibrary/GetProcAddress
IInferencePlugin* plugin = create_plugin();
plugin->initialize("resnet50.engine");
Tensor output = plugin->infer(input);
plugin->release();
destroy_plugin(plugin);
整体结构:
推理主程序
│
│ 只依赖 IInferencePlugin 抽象接口
▼
┌────────────────────┐
│ IInferencePlugin │ 虚表接口
└────────────────────┘
▲ ▲ ▲
│ │ │
TensorRT OpenVINO 第三方插件
插件 插件 动态库
虚表和虚指针在这里的意义非常具体:主程序拿到的是 IInferencePlugin*,它不知道对象真实类型是 TensorRTPlugin 还是 OpenVINOPlugin,但对象内部的虚指针指向插件自己的虚表。调用 initialize/infer 时,会通过虚表跳到动态库中的具体实现。这就是运行时多态在插件架构中的应用。
它带来四个工程价值。第一,模块解耦 :主程序依赖接口,不依赖具体后端。第二,独立发布 :插件可以单独编译成 .so、.dll,运行时加载。第三,扩展开放 :新增后端无需修改主流程。第四,便于测试和替换 :测试时可加载 FakePlugin。
但也要注意风险。虚表布局属于 C++ ABI 的一部分,不同编译器、不同编译选项、不同 STL 配置之间未必完全兼容,因此跨插件边界通常使用 extern "C" 工厂函数,尽量少直接传递 STL 容器,保证对象在同一插件内创建和销毁。虚调用本身通常远小于一次模型推理耗时,不是性能瓶颈;真正要关注的是资源生命周期、线程安全、异常不能跨 C ABI 边界传播、接口版本兼容。
总结
18 个知识点可以压缩成一条主线:
类与对象
├─ 封装数据和行为:1、8
├─ 控制创建与共享:5、6、14、15
├─ 建立继承关系:4、12、16
├─ 保证生命周期正确:7、9
├─ 通过虚函数实现多态:2、3、10、11、13、17
└─ 落地到插件化推理框架:18
最关键的结论是:
- 封装隐藏细节,保证对象状态安全。
- 继承表达类型扩展和复用,但优先使用公有继承表达 "is-a"。
- 多态让程序依赖抽象接口,而不是依赖具体实现。
- 虚函数通过虚指针和虚表完成运行时分派。
- 构造函数不能虚,多态基类析构函数通常要虚。
static成员属于类,所有对象共享。- 插件框架是虚函数和对象模型的典型工程应用。