前言 :C++ 是一门支持面向对象编程(OOP)的语言,其三大特性------封装、继承、多态,是构建高内聚、低耦合、可扩展软件的基石。本文将从概念到实现,结合代码详细讲解每一个特性,并深入剖析多态的底层机制(vptr、vtable、虚析构原理),帮助你彻底理解 C++ 的 OOP 精髓。
一、封装(Encapsulation)
封装 是将数据(属性)和操作数据的方法(函数)捆绑在一起,并隐藏内部实现细节,仅对外暴露必要的接口。
1.1 为什么需要封装?
-
安全性 :防止外部 代码随意修改对象内部状态。
-
模块化 :使用者只需关心接口,不依赖内部实现。
-
维护性 :内部逻辑可以自由修改,只要接口不变,外部代码不受影响。
1.2 访问控制修饰符
| 修饰符 | 类内部 | 派生类 | 类外部 |
|---|---|---|---|
private |
✅ | ❌ | ❌ |
protected |
✅ | ✅ | ❌ |
public |
✅ | ✅ | ✅ |
1.3 代码示例:银行账户
cpp
#include <iostream>
#include <string>
using namespace std;
class BankAccount {
private:
double balance; // 私有成员,外部无法直接访问
string password; // 密码也隐藏起来
public:
BankAccount(string pwd, double init = 0.0)
: password(pwd), balance(init) {}
// 公开的存款接口
void deposit(double amount) {
if (amount > 0) balance += amount;
}
// 公开的取款接口(内部验证密码)
bool withdraw(string pwd, double amount) {
if (pwd != password) return false;
if (amount > 0 && amount <= balance) {
balance -= amount;
return true;
}
return false;
}
// 只读查询余额(不暴露修改能力)
double getBalance(string pwd) const {
if (pwd == password) return balance;
return -1;
}
};
int main() {
BankAccount acc("1234", 1000);
// acc.balance = 9999; // 错误:private 成员不可访问
acc.deposit(500);
acc.withdraw("1234", 200);
cout << "余额: " << acc.getBalance("1234") << endl; // 输出 1300
return 0;
}
封装的好处 :银行账户的余额只能通过公开的 deposit / withdraw 修改,无法直接篡改。密码验证也隐藏在内部,外部无需关心。
二、继承(Inheritance)
继承 允许一个类(子类/派生类)获得另一个类(父类/基类)的成员(属性和方法),并可以增加新的功能或重写已有的功能。
2.1 继承的类型
-
实现继承:派生类直接复用基类的实现代码。
-
接口继承:派生类只继承基类的函数声明(纯虚函数),必须自己实现。
-
可视继承 :主要与 GUI相关,一般指子类继承父类的界面外观。
2.2 继承的访问控制
| 继承方式 | 基类 public |
基类 protected |
基类 private |
|---|---|---|---|
public 继承 |
仍是 public |
仍是 protected |
不可访问 |
protected 继承 |
变成 protected |
变成 protected |
不可访问 |
private 继承 |
变成 private |
变成 private |
不可访问 |
实际开发中,public 继承最常用。
2.3 代码示例:交通工具
cpp
#include <iostream>
using namespace std;
// 基类
class Vehicle {
protected:
string brand;
int speed;
public:
Vehicle(string b, int s = 0) : brand(b), speed(s) {}
void accelerate(int delta) { speed += delta; }
void show() const {
cout << brand << " 当前速度: " << speed << " km/h" << endl;
}
};
// 派生类 Car(public 继承)
class Car : public Vehicle {
private:
int doors;
public:
Car(string b, int d, int s = 0) : Vehicle(b, s), doors(d) {}
void honk() const { cout << "嘟嘟!" << endl; }
// 可以重写 show 函数
void show() const {
Vehicle::show(); // 调用基类 show
cout << "车门数: " << doors << endl;
}
};
int main() {
Car myCar("Tesla", 4, 50);
myCar.accelerate(30); // 继承自 Vehicle
myCar.honk(); // Car 自己的方法
myCar.show(); // 重写的方法
return 0;
}
继承的好处 :Car 复用了 Vehicle 的 brand、speed 和 accelerate,避免重复代码 ,并且可以扩展新功能(honk、增加车门数)。
三、多态(Polymorphism)
多态的意思是"同一个接口,不同的实现"。C++ 支持两种形式的多态:
-
编译时多态(静态多态) :在编译阶段确定调用哪个函数。包括函数重载 、运算符重载 、模板。
-
运行时多态(动态多态) :在程序运行时根据对象实际类型决定调用哪个函数。通过虚函数和继承实现。
3.1 编译时多态
(1) 函数重载
同名函数,参数列表不同(类型、个数或顺序),编译器根据实参选择合适版本。
cpp
#include <iostream>
using namespace std;
void print(int i) { cout << "整数: " << i << endl; }
void print(double d) { cout << "浮点数: " << d << endl; }
void print(string s) { cout << "字符串: " << s << endl; }
int main() {
print(42); // 调用 print(int)
print(3.14); // 调用 print(double)
print("Hello"); // 调用 print(string)
return 0;
}
(2) 运算符重载
允许自定义类型使用 +、-、[] 等运算符。
cpp
#include <iostream>
using namespace std;
class Vector2D {
public:
int x, y;
Vector2D(int x = 0, int y = 0) : x(x), y(y) {}
Vector2D operator+(const Vector2D& other) const {
return Vector2D(x + other.x, y + other.y);
}
};
int main() {
Vector2D v1(1, 2), v2(3, 4);
Vector2D v3 = v1 + v2;
cout << v3.x << ", " << v3.y << endl; // 输出 4, 6
return 0;
}
(3) 模板(泛型编程)
模板实现了"编译时多态"的另一种形式:同一段代码可以操作不同数据类型。
cpp
#include <iostream>
using namespace std;
template <typename T>
T max(T a, T b) {
return (a > b) ? a : b;
}
int main() {
cout << max(3, 7) << endl; // int
cout << max(3.14, 2.71) << endl; // double
cout << max('a', 'c') << endl; // char
return 0;
}
模板的实例化在编译 时完成,编译器根据调用参数生成不同版本函数,因此没有运行时开销。
3.2 运行时多态(虚函数)
运行时多态是面向对象最核心的机制:用基类指针或引用指向派生类对象,调用虚函数时会执行派生类的版本。
3.2.1 虚函数基本示例
cpp
#include <iostream>
using namespace std;
class Animal {
public:
virtual void speak() const { // 声明为虚函数
cout << "Animal makes a sound." << endl;
}
virtual ~Animal() {} // 虚析构函数(后面详解)
};
class Dog : public Animal {
public:
void speak() const override { // override 表示重写
cout << "Dog barks: Woof!" << endl;
}
};
class Cat : public Animal {
public:
void speak() const override {
cout << "Cat meows: Meow!" << endl;
}
};
// 多态函数:接受基类指针,调用实际对象版本的 speak()
void makeSound(const Animal* a) {
a->speak();
}
int main() {
Dog d;
Cat c;
makeSound(&d); // 输出 Dog barks: Woof!
makeSound(&c); // 输出 Cat meows: Meow!
return 0;
}
为什么需要 virtual?
如果没有 virtual,makeSound 会根据编译时类型(Animal*)调用 Animal::speak,永远输出"Animal makes a sound."。virtual 让调用在运行时动态决定。
3.2.2 虚函数的底层原理:vptr 和 vtable(彻底详解)
这是理解多态的关键。我们先从内存布局入手。
① 没有虚函数时的对象
cpp
class Animal {
int age;
public:
void eat() {}
};
sizeof(Animal) 在 32 位系统下是 4 字节(只有 age)。普通成员函数不占用对象内存,它们像普通函数一样存在代码段。
② 引入一个虚函数
cpp
class Animal {
int age;
public:
virtual void speak() {}
};
现在 sizeof(Animal) 在 32 位系统下通常是 8 字节 (int age 4字节 + 一个隐藏指针 4字节)。这个隐藏指针称为 vptr (虚指针)。
在 64 位系统下,指针是 8 字节,所以对象大小可能是 16 字节(对齐后)。
③ 每个类都有自己的虚函数表(vtable)
编译器在编译时,会为每个包含虚函数的类 生成一张 虚函数表(vtable) 。vtable 是一个数组 ,里面存储了该类的所有虚函数的函数指针。
-
Animal的 vtable:包含&Animal::speak -
Dog的 vtable:包含&Dog::speak -
Cat的 vtable:包含&Cat::speak
④ 每个对象都有一个 vptr,指向它所属类的 vtable
cpp
Animal a; // 对象 a 的 vptr 指向 Animal 的 vtable
Dog d; // 对象 d 的 vptr 指向 Dog 的 vtable
内存布局示意图(32位系统):
cpp
Animal 对象(无虚函数):
+-------+
| age | 4字节
+-------+
Animal 对象(有虚函数):
+-------+-------+
| vptr | age | 8字节(vptr 在对象开头)
+-------+-------+
|
+---------> Animal 的 vtable:
+----------------+
| &Animal::speak |
+----------------+
Dog 对象:
+-------+-------+
| vptr | age | (继承自 Animal 的成员)
+-------+-------+
|
+---------> Dog 的 vtable:
+--------------+
| &Dog::speak |
+--------------+
⑤ 动态绑定的具体过程
当通过基类指针调用虚函数时,比如:
cpp
Animal* p = new Dog();
p->speak();
编译器生成的代码大致等价于:
cpp
// 1. 从 p 指向的对象中取出 vptr
void** vptr = *(void***)p;
// 2. 从 vtable 中取出 speak 函数的地址(假设 speak 是 vtable 的第一个条目)
void* func = vptr[0];
// 3. 调用该函数
((void(*)())func)();
因为 p 指向的实际对象是 Dog ,所以它的 vptr 指向 Dog 的 vtable ,因此调用的是 Dog::speak。
⑥关键总结
| 概念 | 说明 |
|---|---|
vtable(虚函数表) |
类级别的 ,每个有虚函数的类都有一张表,存储虚函数地址。 |
vptr(虚指针) |
对象级别 的,每个对象都有一个隐藏的 vptr ,指向所属类的 vtable。 |
| 动态绑定过程 | 通过 vptr 找到 vtable ,再通过偏移取出函数地址并调用。 |
| 性能开销 | 一次间接寻址(比普通函数调用稍慢),但通常可以忽略。 |
3.2.3 override 和 final 关键字
-
override:显式声明函数重写基类的虚函数,如果签名不匹配会编译报错,避免笔误。 -
final:禁止派生类继续重写该虚函数,或者禁止类被继承。
cpp
class Base {
virtual void foo();
virtual void bar() final; // 不能在派生类中重写
};
class Derived : public Base {
void foo() override; // 正确
// void bar() override {} // 错误:bar 是 final
};
3.2.4 虚析构函数:为什么基类析构函数必须是虚函数?(彻底讲透)
先看一个错误的例子:
cpp
#include <iostream>
using namespace std;
class Base {
public:
~Base() { cout << "Base destructor" << endl; } // 非虚
};
class Derived : public Base {
private:
int* data;
public:
Derived() { data = new int[100]; }
~Derived() {
delete[] data;
cout << "Derived destructor" << endl;
}
};
int main() {
Base* p = new Derived();
delete p; // 只调用 Base::~Base()
return 0;
}
运行结果:
cpp
Base destructor
Derived 的析构函数没有被调用 !这导致了 data 指向的堆内存泄漏。
为什么会这样?
-
当
delete p时,编译器看到**p是Base*类型** 。如果Base的析构函数不是虚函数 ,那么编译器就静态绑定 ,直接调用Base::~Base()。 -
它不会去查找实际对象类型(
Derived),因此Derived的析构函数不会执行。
解决方案 :将基类析构函数声明为 virtual。
cpp
class Base {
public:
virtual ~Base() { cout << "Base destructor" << endl; }
};
修改后运行结果:
cpp
Derived destructor
Base destructor
为什么 virtual 就能调用子类析构?
-
析构函数也是虚函数。当一个类有虚函数时,它的对象就有 vptr。
-
当
delete p时,编译器生成代码:通过p的 vptr 找到虚函数表,从表中取出析构函数的地址(也是动态绑定)。 -
由于
p实际指向Derived对象,vptr 指向Derived的 vtable,所以调用的是Derived::~Derived()。 -
Derived的析构函数执行完毕后,会自动调用基类析构函数(这是 C++ 保证的)。
虚析构函数的规则:
-
只要一个类会被作为基类使用,就应该将其析构函数声明为
virtual。 -
如果一个类没有虚函数(不可能被继承),析构函数不需要虚析构。
-
抽象类的析构函数也应该是虚函数
3.3 模板多态 vs 运行时多态
| 特性 | 模板多态(编译时) | 虚函数多态(运行时) |
|---|---|---|
| 绑定时机 | 编译期 | 运行期 |
| 性能 | 无额外开销(内联友好) | 一次间接寻址,稍微慢一点 |
| 灵活性 | 类型必须编译期确定 | 可以在运行时动态决定对象类型 |
| 代码膨胀 | 可能产生多份实例化代码 | 无额外膨胀 |
| 适用场景 | 高性能、类型严格、无继承的泛型 | 多态容器、插件架构、框架设计 |
3.4 常见面试追问与扩展
Q1:内联函数可以是虚函数吗?
答 :可以,但内联请求会被忽略 。因为内联是在编译期展开函数体,而虚函数调用是运行时动态绑定,两者矛盾。不过如果通过对象(而非指针/引用)调用虚函数,编译器可能静态解析并内联。
Q2:静态成员函数可以是虚函数吗?
答 :不能。静态成员函数属于类,没有 this 指针,无法参与动态绑定。
Q3:构造函数可以是虚函数吗?
答 :不能。构造函数执行时,对象的vptr 还未正确设置(还没完全构造完成),无法进行动态绑定。
Q4:一个类最多有几个虚函数表?
答 :单继承时有一个 ;多继承时,每个直接或间接基类如果含有虚函数,就会有自己的 vtable 子表,实际编译器可能会生成多个 vtable 或一个包含多个部分的大表。
Q5:虚函数表存放在哪里?
答 :通常是只读数据段 (.rodata 或类似),不是堆也不是栈。所有对象共享同一张表。
四、三大特性协同工作示例
最后,用一个完整的例子展示封装、继承和多态如何配合。
cpp
#include <iostream>
#include <vector>
#include <memory>
using namespace std;
// 抽象基类(接口)
class Shape {
public:
virtual double area() const = 0; // 纯虚函数
virtual ~Shape() = default; // 虚析构
};
// 派生类:Rectangle
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;
}
};
// 派生类:Circle
class Circle : public Shape {
private:
double radius;
public:
Circle(double r) : radius(r) {}
double area() const override {
return 3.14159 * radius * radius;
}
};
// 多态函数
void printArea(const Shape& s) {
cout << "面积: " << s.area() << endl;
}
int main() {
vector<unique_ptr<Shape>> shapes;
shapes.push_back(make_unique<Rectangle>(4, 5));
shapes.push_back(make_unique<Circle>(3));
for (const auto& sp : shapes) {
printArea(*sp); // 运行时多态调用正确的 area()
}
return 0;
}
-
封装 :
Rectangle的width和height私有,外部只能通过构造函数或接口间接使用。 -
继承 :
Rectangle和Circle继承了Shape接口,并实现了纯虚函数。 -
多态 :
printArea接受基类引用 ,调用虚函数area动态分发到具体类版本。
五、总结
| 特性 | 核心作用 | 实现方式 |
|---|---|---|
| 封装 | 隐藏内部数据,提供安全接口 | private / protected 成员 |
| 继承 | 复用代码,建立 is-a 关系 | class Derived : public Base |
| 多态 | 同一接口,不同行为(扩展性) | 虚函数(运行时),模板/重载(编译时) |
理解虚函数表(vtable)和虚指针(vptr)是掌握多态的关键:
-
每个有虚函数的类 都有一张 vtable。
-
每个对象 都有一个 vptr 指向 vtable。
-
通过 vptr 间接调用虚函数实现了运行时动态绑定。
-
基类析构函数必须是虚函数 ,否则派生类资源无法正确释放。
希望本文能帮助你彻底理解 C++ 的封装、继承、多态,并在实际开发中灵活运用。