面向对象编程(OOP)是 C++ 最重要的编程范式之一。很多人学了一堆语法------类、继承、多态------却依然写不出好的面向对象代码。原因在于:语法只是表象,思想才是内核。
今天这篇文章,我们从思想层面出发,串起 C++ 面向对象的四大支柱和五大原则,帮你建立系统的 OOP 认知框架。
1. 什么是面向对象?先理解它的思想
面向过程的思路是:数据 + 函数,程序是一系列操作的流水线。数据丢进去,函数处理,输出结果。
面向对象的思路是:对象 = 数据 + 行为,程序是对象之间通过消息进行交互。
cpp
// 面向过程:数据和操作分离
struct Rectangle { double width, height; };
double area(const Rectangle& r) { return r.width * r.height; }
// 面向对象:数据和操作封装在一起
class Rectangle {
double width, height;
public:
double area() const { return width * height; }
};
本质转变:从"我该做什么"变成"谁来做这件事"。这种思维方式的转变是 OOP 的核心。
2. 四大支柱:封装、继承、多态、抽象
2.1 封装:隐藏实现,暴露接口
封装的核心是信息隐藏。对象的内部状态应该私有,只通过公开的接口与外界交互。
cpp
class BankAccount {
private:
double balance; // 隐藏内部状态
public:
void deposit(double amount) {
if (amount > 0) balance += amount; // 可以加入验证逻辑
}
double getBalance() const { return balance; }
};
为什么封装重要?
- 保护数据不被随意修改
- 内部实现改变不影响外部调用者
- 降低耦合,提高可维护性
C++ 的访问控制:
private:只有类自己和友元可访问protected:派生类也可访问public:所有人都可访问
2.2 继承:复用接口和实现
继承让我们可以基于已有类创建新类,实现代码复用和多态的基础。
cpp
class Animal {
public:
virtual void speak() const { std::cout << "Animal sound\n"; }
virtual ~Animal() = default; // 基类析构函数应该是虚的
};
class Dog : public Animal {
public:
void speak() const override { std::cout << "Woof!\n"; }
};
继承的三种类型:
- public 继承(最常用):基类的 public 成员在派生类中仍是 public
- protected 继承:基类的 public 成员在派生类中变成 protected
- private 继承:基类的 public 成员在派生类中变成 private
关键原则 :public 继承表达"是一个"(is-a)关系。Dog 是一个 Animal。如果不符合这个关系,不应使用 public 继承。
2.3 多态:同一个接口,不同行为
多态让我们用基类指针/引用调用派生类的函数,实现"一个接口,多种实现"。
cpp
void makeSound(const Animal& a) {
a.speak(); // 调用哪个版本?看实际对象的类型
}
Dog dog;
Cat cat;
makeSound(dog); // Woof!
makeSound(cat); // Meow!
多态的两类:
- 动态多态 (运行时):通过
virtual函数 + 基类指针/引用实现 - 静态多态(编译时):通过模板、函数重载实现
实现原理:虚函数表(vtable)。每个有虚函数的类有一个虚函数表,对象通过 vptr 指向它。调用虚函数时,运行时根据 vptr 找到正确的函数地址。
2.4 抽象:只定义契约,不关心实现
抽象是定义一个接口,而不提供完整实现。在 C++ 中通过纯虚函数 和抽象类实现。
cpp
class Shape { // 抽象类
public:
virtual double area() const = 0; // 纯虚函数
virtual ~Shape() = default;
};
class Circle : public Shape {
double radius;
public:
double area() const override { return 3.14159 * radius * radius; }
};
// Shape s; // 错误!不能实例化抽象类
Shape* s = new Circle(5.0); // 正确
抽象的意义:依赖抽象而非具体实现,使系统更灵活、更容易扩展。
3. SOLID 五大原则:写出好代码的秘诀
3.1 单一职责原则(SRP)
一个类应该只有一个理由去改变
cpp
// 不好:一个类干了太多事
class Report {
string data;
public:
void loadData();
void formatReport();
void printReport();
void saveToFile();
};
// 好:拆分成不同职责
class ReportData { void load(); };
class ReportFormatter { void format(); };
class ReportPrinter { void print(); };
class ReportSaver { void save(); };
3.2 开闭原则(OCP)
对扩展开放,对修改关闭
cpp
// 不好:每加一个新形状就得改代码
double area(const Shape& s) {
if (type == CIRCLE) { /* ... */ }
else if (type == RECT) { /* ... */ }
}
// 好:通过多态扩展,不修改已有代码
class Shape {
public:
virtual double area() const = 0;
};
class Circle : public Shape { /* 实现 */ };
class Rectangle : public Shape { /* 实现 */ };
// 加新形状只需新增派生类,不动原有代码
3.3 里氏替换原则(LSP)
子类必须能替换基类,而不破坏程序正确性
cpp
class Rectangle {
public:
virtual void setWidth(int w);
virtual void setHeight(int h);
};
class Square : public Rectangle {
// 错误示范:Square 破坏了 Rectangle 的预期行为
void setWidth(int w) override {
// 同时改了高,违反 Rectangle 的预期
Rectangle::setWidth(w);
Rectangle::setHeight(w);
}
};
如果 Square 从 Rectangle 继承,外部代码用 Rectangle 的行为预期来操作 Square 就会出问题。这说明正方形不是一个矩形(在可变对象的意义下),不该这样设计继承。
3.4 接口隔离原则(ISP)
不应该强迫客户依赖它们不使用的方法
cpp
// 不好:一个臃肿的接口
class Worker {
public:
virtual void work() = 0;
virtual void eat() = 0;
virtual void sleep() = 0;
};
// 机器人被迫实现 eat(),毫无意义
// 好:拆分成小接口
class Workable { virtual void work() = 0; };
class Eatable { virtual void eat() = 0; };
class Sleepable { virtual void sleep() = 0; };
3.5 依赖倒置原则(DIP)
高层模块不应依赖低层模块,两者都应依赖抽象
cpp
// 不好:高层直接依赖底层
class EmailSender {
void send(const string& msg);
};
class Notification {
EmailSender email; // 紧耦合到 EmailSender
};
// 好:依赖接口
class IMessageSender {
public:
virtual void send(const string& msg) = 0;
};
class Notification {
IMessageSender& sender; // 依赖抽象
};
// 现在可以注入 EmailSender、SMSSender 等任何实现
4. 继承 vs 组合:什么时候用谁?
优先使用组合而非继承
继承表达的是 is-a (是一个),组合表达的是 has-a(有一个)。
cpp
// 继承:Car is-a Vehicle
class Car : public Vehicle { };
// 组合:Car has-a Engine
class Car {
Engine engine; // 组合
Wheel wheels[4]; // 组合
};
为什么组合优先?
- 继承是白盒复用(知道基类内部实现),耦合度高
- 组合是黑盒复用(只通过接口交互),耦合度低
- 继承在编译时确定关系,组合可以在运行时改变
使用继承的条件:
- 确实存在 is-a 关系
- 需要基类指针/引用统一操作(多态)
- 派生类是基类的特化,且符合里氏替换原则
5. C++ 特有关注点
5.1 虚析构函数
只要类有可能作为基类,析构函数就应该是虚的。
cpp
class Base {
public:
virtual ~Base() = default; // 必须虚析构
};
Base* p = new Derived();
delete p; // 如果析构不虚,Derived 的析构函数不会被调用,资源泄漏!
5.2 虚函数 vs 非虚函数 vs 纯虚函数
| 类型 | 语法 | 含义 |
|---|---|---|
| 非虚函数 | void f(); |
不希望派生类重写 |
| 虚函数 | virtual void f(); |
希望派生类可选择重写,有默认实现 |
| 纯虚函数 | virtual void f() = 0; |
派生类必须重写,定义接口 |
5.3 override 和 final
cpp
class Base {
public:
virtual void f() const;
};
class Derived : public Base {
public:
void f() const override; // 显式表明重写,编译器会检查签名是否匹配
// void f() override; // 编译错误!签名不匹配(少了 const)
};
class FinalDerived final : public Derived {
// 不能被进一步继承
};
永远使用 override 关键字,让编译器帮你检查是否真的覆盖了基类虚函数。
6. 面试常考清单
6.1 面向对象的四大特性是什么?请用一句话解释每一个。
答案要点:封装(隐藏内部状态)、继承(复用接口)、多态(同一接口不同行为)、抽象(只定义契约)。
6.2 重载(Overload)和重写(Override)有什么区别?
答案要点:
- 重载:同一作用域,函数名相同,参数列表不同,编译时决定
- 重写:派生类覆盖基类的虚函数,函数签名相同,运行时决定
6.3 虚函数表(vtable)是如何实现多态的?
答案要点:每个有虚函数的类有一张 vtable,存储虚函数地址。对象包含 vptr 指向 vtable。调用虚函数时,运行时根据对象的 vptr 查找 vtable 中正确的函数指针并调用。
6.4 为什么基类析构函数必须是虚的?
答案要点 :如果基类析构函数不是虚的,通过基类指针 delete 派生类对象时,只会调用基类的析构函数,派生类部分不会被正确析构,导致资源泄漏。
6.5 什么是抽象类?它和接口有什么区别?
答案要点:包含至少一个纯虚函数的类是抽象类,不能实例化。C++ 中没有显式的接口关键字,用纯虚函数 + 抽象类模拟接口(全部都是纯虚函数,通常没有成员变量)。
6.6 组合和继承有什么区别?什么时候用哪个?
答案要点:继承是 is-a 关系,组合是 has-a 关系。优先使用组合,因为耦合度更低。需要多态和统一操作时才使用继承。
6.7 什么是钻石问题(菱形继承)?C++ 如何解决?
答案要点 :D 继承 B 和 C,B 和 C 继承 A,A 的数据在 D 中出现两份,产生歧义。C++ 通过虚继承 (virtual 继承)解决,让 B 和 C 共享同一个 A 的副本。
7. 总结
面向对象不只是一种写法,更是一种组织和管理复杂度的思想:
- 封装让你关注"我能做什么"而非"我怎么做"
- 继承让你复用设计而非重复代码
- 多态让你写出可扩展的系统
- 抽象让你在更高层次思考
五条 SOLID 原则则告诉你,什么样的继承是好的,什么样的设计是好维护的。
最后记住 C++ 之父 Bjarne Stroustrup 的一句话:
"C++ 的设计目标是让认真的程序员编写出更好、更易维护的代码。"