我特别理解那种处境——C++的面向对象理论学完一遍,书上的例子也都能看懂,可真要自己动手设计一个类的时候,突然就卡住了:这个成员到底该不该私有?析构函数什么时候必须写成虚函数?拷贝构造函数写了又好像跟没写一样?不是说学会了面向对象三大特性就能写代码吗,怎么一碰实际项目全是问题?
这篇文章我想换个讲法。不按教科书顺序把封装、继承、多态一个个摆出来,而是从"真实项目里这些概念到底在解决什么问题"出发,把它们串成一条线。你会发现所谓面向对象的核心,归根到底就是两件事:怎么把数据和操作数据的东西绑在一起(封装),怎么让不同对象之间用统一的方式协作(多态)。继承只是实现协作的一种手段,还经常被用错。
适合谁来读?学过C++语法或者用过其他面向对象语言、现在想认真把C++面向对象这块打通的人。如果你是想搞懂虚函数机制、构造函数和资源管理、接口设计这些实际操作层面的问题,那这篇内容正好对胃口。
1. 从一个"能跑"的类开始:封装到底保护了什么
很多人对封装的理解停留在"把数据设为private,外面就不能直接改了"。这句话没错,但它只说了手段,没说目的。封装真正的价值不是防贼,而是给你的类留下一块可控的演化空间。
1.1 没有封装的时代有多痛苦
先看一个反面例子。假设你在写一个简单的账户系统,最初可能就这么干:
struct Account { std::string owner; double balance; }; void deposit(Account& acc, double amount) { acc.balance += amount; }代码完全能跑。但项目一长大,问题就来了:有一天业务要求"账户余额不允许超过十万",你得在全工程里搜有多少处代码直接写了acc.balance,每一处都检查一遍逻辑。更麻烦的是有些地方直接给余额塞了个负数进去,而你没有地方统一拦截。
再往后,需求变了,余额不想用double,改成用long long存分。整个工程凡是碰过balance的地方都可能要改。数据和行为是拆开散的,牵一发而动全身。
1.2 封装加进来之后发生了什么
把数据收进private,对外只暴露明确的方法:
class Account { public: Account(std::string owner, double initial) : owner_(std::move(owner)), balance_(initial) { if (initial < 0) { throw std::invalid_argument("余额不能为负数"); } } void deposit(double amount) { if (amount <= 0) { throw std::invalid_argument("存款金额必须为正"); } if (balance_ + amount > kMaxBalance) { throw std::runtime_error("超出单账户余额上限"); } balance_ += amount; } double getBalance() const { return balance_; } const std::string& getOwner() const { return owner_; } private: static constexpr double kMaxBalance = 100000.0; std::string owner_; double balance_; };看到差别没有?我故意写了getBalance()但没写setBalance()。外面的人只能通过deposit()这一个入口改余额,所有检查都集中在入口处。以后想加"每月只能存取五次"这种规则,只改这一个方法就够了,不用满世界找代码。
1.3 封装的第一条实操判断标准
判断封装做得好不好,不看"private有多少",而看"对外暴露的方法是否足够表达这个类的行为意图"。
好的封装,类的接口读起来像一个任务清单:deposit()、withdraw()、getBalance()。差的封装读起来像一个数据仓库:setXXX()、getXXX()、getXXX2()。前者在描述"这个类能帮你做什么",后者只是在"把内部数据仓库做仓库管理员"。
这也是为什么我一直建议:所有成员变量一律private起步,对外接口尽量给行为而不是给数据。不是每个字段都需要一个getter,更不是每个字段都需要setter。你刚写完一个类觉得这个getter以后可能用得上,大概率以后也用不上,等真的需要时再写,成本低得很。
封装还有一个被忽略的好处:它是实现约束的载体。类的每个方法都可以假定"成员变量一定处于合法状态",因为这世界只有两条路能改它们——要么通过构造函数建立初始状态,要么通过成员函数流转状态。跳出类的范围审查状态合法性,那是调用方的责任,不需要你操心了。
2. 构造函数与析构函数:资源所有权交接的战场
构造函数是哪个面试里都会考的东西,但很多人对它的理解停留在"给成员变量赋初值"。在我看来,构造函数承担着比赋初值重要得多的责任:建立"类不变量"并获取资源所有权。前面说的"余额不能负数""金额不能超出上限",这些约束必须在构造函数里先建立起来,否则后面所有方法都会在假设上出现问题。
2.1 没有构造函数时,对象会处于什么状态
先理解一下C++默认会给你什么。如果你写了这样一个类:
class Logger { public: void log(const std::string& msg); private: std::ofstream file_; };在 C++ 里,成员变量file_会自动调用ofstream的默认构造函数完成初始化,哪怕你没写构造函数。真正麻烦的是那些没有默认构造函数的成员,比如std::mutex就没有默认构造函数,你必须显式初始化它。
很多新手踩的首要问题,是以为"我没写构造函数,对象就不需要构造",然后发现某个成员变量是莫名奇妙的值。这是个误区:凡是类类型的成员,不管你有没有写构造函数,它自己都会构造。内置类型(int、double、裸指针)如果不初始化,才是未知值。
所以规则很简单:如果类里有内置类型成员,就别依赖默认初始化。自己写构造函数,把它们明确设成合法初值——哪怕只是int count_ = 0;这种就地初始化。
2.2 析构函数和RAII:一种把所有事情安排好的思路
析构函数的核心价值在于析构时机确定:当对象的生命周期结束时,析构函数必然被执行。这看似简单,实际上成了一个非常有用的机制——资源清理和异常安全。
举个例子。你有一段代码要操作一个文件:
std::ofstream file("output.txt"); if (!file.is_open()) { return; } // ... 各种处理 ... file.close(); // 如果你忘了这一步,文件会在ofstream析构时自动关闭就算你忘了写file.close(),ofstream的析构函数也会自动关闭文件。这就是把资源获取和释放绑定在对象生命周期上,统一叫RAII(Resource Acquisition Is Initialization,资源获取即初始化)。这个命名不太直观,我更愿意叫"生命周期绑定资源"技术。
后台处理数据库连接时这类问题最为常见:
class DbConnection { public: explicit DbConnection(const std::string& connStr) : conn_(std::make_shared<DbHandle>(connect(connStr))) {} ~DbConnection() { if (conn_) { close(conn_); } } // 禁止拷贝,只允许移动 DbConnection(const DbConnection&) = delete; DbConnection& operator=(const DbConnection&) = delete; DbConnection(DbConnection&& other) noexcept : conn_(std::move(other.conn_)) { other.conn_ = nullptr; } DbConnection& operator=(DbConnection&& other) noexcept { if (this != &other) { conn_ = std::move(other.conn_); other.conn_ = nullptr; } return *this; } void execute(const std::string& sql) { ... } private: std::shared_ptr<DbHandle> conn_; };地址资源清理的问题解决了,连接不会忘关,异常发生时连接也一样会释放。但这类代码的价值远不止"不会忘",它把异常安全做到位了——因为析构函数一定会执行,哪怕中间抛了异常,数据库连接也不会泄漏。
2.3 构造函数里的异常:初始化一半了怎么办
这是很多实际项目中最容易被问的问题。构造函数里如果抛出异常,这个对象的成员函数不会被调用,但已经构造好的成员变量会被依次析构,资源不会泄漏。这点C++保证得很好,你甚至不需要在构造函数里写try-catch来清理已经分配的资源——那是裸指针时代的技巧了。
比如:
class ResourceHolder { public: ResourceHolder(const std::string& filePath) : file_(filePath), buf_(new char[1024]) { if (!file_.is_open()) { throw std::runtime_error("文件打不开"); } } ~ResourceHolder() { delete[] buf_; } private: std::ofstream file_; char* buf_; };如果构造过程中file_打开失败抛了异常,成员buf_也已经分配了内存,但析构函数并不会执行,因为对象本身没构造成功。buf_就泄漏了。要处理这种情况得自己管理,但既然buf_是裸指针,就只能手写智能指针——实际上,buf_换成std::vector<char>就完全不用纠结这个问题。
结论:构造函数正确写法,是从一开始就不用裸指针管理资源,用vector、string、unique_ptr这些自带RAII语义的标准组件。构造函数越简单,越不需要防御式清理代码。
3. 拷贝与移动:同一个类为什么会有两种复制命运
本章聊聊C++里最容易在细节上卡人的地方——拷贝和移动。这个坑有多深?我自己见过的实际项目里,遇到异常或性能问题的代码,有相当一部分根因都在拷贝语义上。
3.1 拷贝构造、拷贝赋值、移动构造、移动赋值,四兄弟一次性理清
先看代码:
class Buffer { public: Buffer(size_t size) : size_(size), data_(new char[size]) {} // 拷贝构造:用一个已有的对象构造新对象 Buffer(const Buffer& other) : size_(other.size_), data_(new char[other.size_]) { std::copy(other.data_, other.data_ + size_, data_); } // 拷贝赋值:已存在的对象被另一个对象覆盖 Buffer& operator=(const Buffer& other) { if (this != &other) { delete[] data_; size_ = other.size_; data_ = new char[size_]; std::copy(other.data_, other.data_ + size_, data_); } return *this; } // 移动构造:把other的资源"搬"到新对象,other变成空壳 Buffer(Buffer&& other) noexcept : size_(other.size_), data_(other.data_) { other.size_ = 0; other.data_ = nullptr; } // 移动赋值:把other的资源"搬"给已存在的对象 Buffer& operator=(Buffer&& other) noexcept { if (this != &other) { delete[] data_; size_ = other.size_; data_ = other.data_; other.size_ = 0; other.data_ = nullptr; } return *this; } ~Buffer() { delete[] data_; } private: size_t size_; char* data_; };这四者的区别,我总结成一个表格:
| 场景 | 调用的函数 | 核心行为 |
|---|---|---|
Buffer b1 = b2;(从已有对象初始化新对象) | 拷贝构造 | 深拷贝数据 |
Buffer b1; b1 = b2;(对象已存在,覆盖) | 拷贝赋值 | 释放旧资源,深拷贝数据 |
Buffer b1 = std::move(b2);(临时对象初始化新对象) | 移动构造 | 转移资源指针,把b2置空 |
Buffer b1; b1 = std::move(b2);(临时对象覆盖已有对象) | 移动赋值 | 释放旧资源,转移指针,置空b2 |
什么时候发生移动?要么你显式写了std::move(),要么表达式本身就是右值(比如函数返回临时对象)。理解了这一点,很多"不听话"的行为就说得通了。
3.2 深浅拷贝:裸指针出现的地方,默认拷贝就是灾难
再设想你已经写完了上面的Buffer类,但故意不写拷贝构造函数和拷贝赋值运算符。编译器会帮你生成一个默认拷贝——逐成员拷贝(shallow copy)。对内置类型它是值拷贝,对裸指针它就是拷贝指针本身。
于是:
Buffer a(1024); Buffer b = a; // b.data_ 和 a.data_ 指向同一块内存两个对象析构时,同一块内存被delete两次。程序不是崩溃,就是内存被破坏得一塌糊涂。这就是经典的"浅拷贝导致双delete"问题。
各类书籍都会告诉你"写了析构函数,通常就应该写拷贝构造和拷贝赋值",因为你一旦接管了资源管理,编译器默认的逐成员拷贝方式已经不适用于裸指针了。编译器没法知道你那个指针是"资源的唯一入口"还是"指向别人资源的借用指针",它只能照搬,结果就是复制了"地址"而不是"内容"。
3.3 三/五法则:一条规则就能避开一半的内存问题
这条规则我用得特别狠,简单好记又很管用。只要你有自定义析构函数、拷贝构造、拷贝赋值中的一个,那么另外两个基本也必须有——因为析构函数是"资源所有权"的信号。有了析构函数,就一定自己管理资源,默认的拷贝方式大概率是不对的。
写成清单就是:
- 如果你写了析构函数,拷走拷贝构造函数和拷贝赋值运算符;
- 如果你想禁止拷贝(比如管理互斥锁、数据库连接),把拷贝构造和拷贝赋值声明为
= delete; - 如果你希望类可以高效传递(比如大容器、字符串),就把移动构造和移动赋值写成
= default(前提是你能保证成员都能正确移动)或者自己实现。
更好的消息是:如果你用std::vector、std::string、std::unique_ptr这类组件管理资源,那么你甚至不需要手写析构函数。默认析构函数会自动调用成员的析构,资源就释放了。这就是为什么我强烈建议:你的类里尽量不要出现裸指针成员。裸指针一多,三/五法则就全套到身上了。
3.4 移动语义真正解决的问题
C++11引入移动语义,主要想解决两类问题。第一是性能问题:旧标准里,return一个含有大量数据的对象,会触发深拷贝,被拷出来的临时对象效率低下。有了移动构造,可以把指针"搬"走,拷贝100MB的vector瞬间变成拷贝几个指针。
第二是"只移型别"对象的问题:像std::unique_ptr、std::thread、std::fstream这类对象,拷贝在语义上就不允许(同一份资源不可能有两个独占所有者),但它们确实需要从一个地方传递到另一个地方。移动语义给了它们出路:资源所有权可以转移,但不会复制。
什么时候该给类写移动操作?我的经验是:凡是管理了独占资源、并且需要放进容器或按值传参返回的类,都应该支持移动。而移动操作写好后,函数参数传值、返回大对象这些场景,就会自动走移动路径,不需要到处写std::move(),那是过度操作。
4. 继承与虚函数:设计接口时的关键抉择
继承可能是面向对象里被误解最深的概念。"复用代码"确实是很多人用继承的第一念头,但这种出发点往往会导致设计越来越僵化。我后来慢慢意识到:继承在C++里的正确定位,更偏向接口设计,而不是代码复用。
4.1 虚函数机制的底层真相
先简单拆一下机制。当你给基类函数加上virtual,编译器会为这个类生成一张虚函数表(vtable),存放每个虚函数的地址。每个含有虚函数的对象,开头会藏一个虚表指针(vptr)。调用虚函数时,程序先通过vptr找到vtable,再从里面取出真正应该调用的函数地址。这就是动态绑定的本质:调用哪个函数,是在运行时根据对象真实类型决定的。
没有virtual的普通成员函数,在编译期就确定调用谁,这叫静态绑定。
表格对比如下:
| 特征 | 普通成员函数 | 虚函数 |
|---|---|---|
| 绑定时机 | 编译期 | 运行期(通过vtable) |
| 性能开销 | 几乎为零 | 一次间接跳转,可被CPU分支预测吸收 |
| 多态能力 | 无 | 支持基类指针/引用调用派生类重写逻辑 |
| 适用场景 | 不需要扩展的固定行为 | 允许派生类改变行为 |
4.2 为什么基类析构函数几乎总是virtual
先看这个经典问题:
class Base { public: ~Base() { std::cout << "Base destructor\n"; } }; class Derived : public Base { public: ~Derived() { std::cout << "Derived destructor\n"; } }; Base* p = new Derived(); delete p; // 只调用Base的析构,Derived的析构不被调用如果基类析构函数不是虚函数,delete p只会调用Base::~Base(),Derived的析构逻辑(比如释放它自己的资源)彻底被跳过,资源泄漏还是轻的,如果Derived里有申请的内存和锁,基本就是双delete或死锁问题。
解决方式也很简单:基类析构函数声明为virtual。这样delete p就能沿着vtable动态找到Derived的析构函数,执行完派生类析构逻辑后再执行基类析构,全程安全。
经验教训:凡是设计出来要被继承的类,析构函数一律
virtual。不想被继承?那就把构造函数也禁了,或者用final明确封死。半吊子的设计最坑人——允许别人继承,却不给虚析构,就跟埋雷一样。
4.3 纯虚函数与抽象基类
把函数声明成纯虚(用= 0),对应做成抽象基类。这玩意儿的作用,不是给你"写了一半的类"当摆设,而是定义一套接口契约。
class IShape { public: virtual ~IShape() = default; virtual double area() const = 0; virtual void draw() const = 0; };IShape不能实例化——它压根没有面积和绘制逻辑。但它是一个约定:任何继承它的类必须实现area()和draw(),否则你都无法通过编译。这就是接口或者说抽象基类的威力:多态协作的边界,在编译期就被固定住了。
模板编程里也有类似概念,但C++的模板多态(编译期多态)和这里说的运行期多态是两条路线。模板在编译期生成具体代码,效率更高,但类型必须明确;虚函数在运行期分发,灵活但多一层间接。项目里两种都会用到,关键看你在哪个维度做抽象。
4.4 自己设计继承体系时的自检清单
这么多年下来,我给自己写了个清单,每次设计继承体系都要过一遍:
- 这个基类真的有必要存在吗?还是我只是想把两个类的公共函数放一起?
- 如果两个派生类的
area()实现完全不同,那基类里就不应该有任何"通用实现"。 - 基类析构函数是不是已经
virtual了? - 我不想被继承的类,有没有用
final封死? - 派生类重写函数时,加没加
override?(加了之后,基类虚函数签名一改,编译器就报错,防手滑神器) - 我是不是只是在传值返回基类类型,结果把派生类切片了?
第一个问题尤其值得多想。Dog继承Animal,看起来天经地义,但实际业务里大概率用不上多态——你不会有一个std::vector<Animal*>来装一堆狗和猫。而没有多态的需求,继承只是在制造耦合。继承只有在"你需要通过基类接口操作多种派生类对象"的时候才有意义,这个前提不成立,就别继承。
5. 组合优于继承:设计层面的取舍
从前有一段时间,我看什么都想用继承抽象一下。后来被现实教育了几回:每加一层继承,代码的可读性、可测试性和重构的灵活性都在掉。相比之下,"组合"——把一个类的对象嵌到另一个类里——往往更直白、更灵活。
5.1 IS-A关系和HAS-A关系
继承表达的是 IS-A(是一个):Derived是一种Base。组合表达的是 HAS-A(有一个):Car拥有一台Engine。
// 继承式写法 class TcpServer : public NetworkService { ... }; // 组合式写法 class TcpServer { public: void start() { network_.start(); } void send(const Message& m) { codec_.encode(m); network_.send(codec_.data()); } private: NetworkClient network_; MessageCodec codec_; };组合的好处是,TcpServer和NetworkClient之间没有继承关系,不需要满足Liskov替换原则,NetworkClient换个实现(比如从TCP换成UDP)也不需要改TcpServer的对外接口。想换一个组件,直接换成员类型就行,牵连面要小得多。
5.2 什么时候可以理直气壮地用继承
我的判断标准很干:
- 你需要把不同类型对象放进同一个容器,用基类指针/引用统一操作它们;
- 这些类型在业务语义上确实是"属于/是一种"关系,而且基类的不变量(比如余额非负)能被所有派生类维持;
- 你需要让用户代码在编译期看不到具体类型,只依赖抽象接口(也就是做接口抽象)。
以上几条至少满足两条,才考虑继承。如果只是为了省几个重复函数,组合加自由函数就够了。
5.3 经典反面案例:一个被继承毁掉的业务模型
假设要做支付系统,你可能会自然地写一个Payment基类,然后AlipayPayment、WechatPayment都继承它。开始没问题,但突然有一天,这两种支付的退款逻辑完全不一样了,而基类里已经有一份共同的退款流程。于是你开始往基类里加virtual方法,让不同的派生在各自方向上重写,基类里堆满了被重写的空函数。又过几次迭代,基类变成了一堆"什么都不是"的接口糊墙,派生类之间关系纠缠不清。
用组合就简单了:让每种支付方式各自实现一个RefundStrategy,Payment类持有"支付策略"和"退款策略"的组合。每种策略自己维护自己的状态,互不干扰。
这就是我经常说的:继承关系是"纵向"的,每加一层就是强化耦合,而组合关系是"横向"的,组件之间各自独立,替换起来非常容易。设计上优先选择横向扩展,不要轻易把体系往深了做。
6. 现代C++对传统面向对象的重塑
说实话,现在写C++和十年前已经完全是两种风格了。C++11以来,移动语义、智能指针、lambda、std::function这些工具进来之后,传统的"类继承打天下"的思路已经变了很多。很多人学完面向对象后,代码里还在用最原始的new/delete和到处继承,这可不对——现代C++给了你更好的选择,善用这些选择,代码质量和效率都有全面提升。
6.1 所有权语义:从手动管理到智能指针
传统面向对象的资源管理,靠的是"谁new谁delete"这种人为约定,出错率很高。现代C++引进了std::unique_ptr和std::shared_ptr,把所有权表达在类型系统里:
std::unique_ptr<T>:独占所有权,不可拷贝,只能移动;std::shared_ptr<T>:共享所有权,引用计数,最后一个释放时销毁对象;std::weak_ptr<T>:不增加引用计数,用于打破循环引用,或者观察shared_ptr管理的对象是否还活着。
这段对比很有用:
// 老式写法 Widget* w = createWidget(); use(w); delete w; // 现代写法 std::unique_ptr<Widget> w(createWidget()); use(w.get()); // 不用delete,析构自动执行工厂函数也经常这样设计:
std::unique_ptr<Widget> data = std::make_unique<Widget>(params);一旦所有权边界用类型表达清楚了,"这资源到底谁负责释放"的问题,从运行期靠人脑记忆,变成了编译期靠类型保证,这件事对整个项目影响很大。
6.2 lambda与std::function:多态的一种轻量替代
传统多态需要设计接口、定义类、继承、重写,一整套流程。有时候你其实只是想"在回调里做点不一样的事",这时候lambda往往更轻便:
// 传统接口设计 class IEventHandler { public: virtual void onEvent(const Event& e) = 0; }; class MyHandler : public IEventHandler { public: void onEvent(const Event& e) override { // 处理逻辑... } }; eventDispatcher.registerHandler(std::make_unique<MyHandler>()); // 现代写法 eventDispatcher.registerHandler([](const Event& e) { // 处理逻辑... });少了两个类和一个继承关系,逻辑还能内联在一起,读代码的人不需要跳来跳去找实现。配合std::function做类型擦除,甚至可以把lambda存下来以后调用。
C++模板算另一种"结构多态"——只要类型支持某些操作(比如有area()成员函数),就能直接使用,不需要从某个基类继承。编译期确定调用对象,没有虚函数开销,也能支持完全无关的类。这就是所谓的鸭子类型。所以说,面向对象思维本身没有过时,但表达这个思维的工具,早已不局限于"抽象基类+继承"这一个模式了。
6.3 std::optional与std::variant:比继承更轻的"同一事物多种形态"
有一种多态场景很多人用继承解:一个函数可能返回多种不同类型的值。以前就只能定义一个基类,再让几种类型继承它,返回基类指针或者std::unique_ptr<Base>。但现在有更轻的选择:
// 一个可能没有结果的函数 std::optional<Record> findRecord(int id); // 一个可能由多个不同类型构成的结果 std::variant<Circle, Rectangle, Triangle> shape = createRandomShape();std::variant本质上是一个类型安全的联合体,它把"几种不同类型的事情"放进了同一个类型,用std::visit来统一处理。这比继承体系要简单,也没有动态分配。什么时候用继承、什么时候用variant?我的经验是:领域模型里"类型的数量"很可能不断增加、且每个类型都有特有行为时用继承;类型的组合相对有限、且处理和类型无关时用variant更清爽。
从这个角度看,现代C++并不是要消灭面向对象,而是给了面向对象更丰富的工具箱。封装、接口、资源生命周期这些核心思想仍然在,只是你实现它们的路径多了好几条。
7. 设计原则:从写对到写不坏
前面讲了具体的技术点,最后想谈几个贯穿所有场景的设计原则。它们不是语法,但比语法更能决定你的类设计是否会慢慢腐化。
7.1 单一职责原则:类不应该同时干两件事
一个类,如果它的职责既是"存取数据"又是"序列化JSON"又是"打印日志",那么任何一处变化都要连带改它。更好的做法是拆成三个类,分别管正确的事。这条原则要落到C++里,可以这样实践:每写一个方法,问一句"这个行为是这个类本来就应该负责的吗?"如果不该,就挪走。
数据模型和IO操作分离是常见场景:
// 不理想 class Order { public: void saveToDatabase(); std::string toJson(); void printReceipt(); // 数据成员... }; // 改进方向 struct OrderData { /* 纯数据 */ }; class OrderSerializer { public: static std::string toJson(const OrderData&); }; class OrderRepository { public: void save(const OrderData&); };类越小,接口越明确,组合它的路径就越多。
7.2 接口隔离原则:不要逼调用方依赖他们不需要的方法
接口设计失败的经典症状:一堆类继承一个"万能"基类,但每个派生类只用得到其中两个方法,其余方法全是空实现或者抛异常。这种设计,大家在改基类的时候很痛苦——你只想加一个方法,结果所有派生类都被迫改了一遍。
C++里的解决方式:不搞巨型接口,而是把接口拆小。用多个小的抽象类,每个只约定一个维度的能力:
class IWorker { public: virtual ~IWorker() = default; virtual void work() = 0; }; class IReporter { public: virtual ~IReporter() = default; virtual void report() = 0; }; // 需要两个能力的类,同时实现两个接口 class SomeWorker : public IWorker, public IReporter { public: void work() override; void report() override; };调用方拿到的类型就是纸面写的那样小而确切,不用为了不需要的能力买单。
7.3 依赖倒置原则:让高层依赖抽象而不是依赖细节
传统做法是上层类直接依赖下层具体类,于是下层一变化上层就要跟着改。依赖倒置的意思是:让上下层都依赖抽象接口,而不是直接依赖具体实现。具体到C++,就是多依赖接口(或者std::function),少直接依赖具体类型的对象。
class NotificationService { public: explicit NotificationService(std::function<void(const std::string&)> sender) : sender_(std::move(sender)) {} void notify(const std::string& msg) { sender_(msg); } private: std::function<void(const std::string&)> sender_; };在这个设计里,NotificationService根本不认识"邮件实现""短信实现"这回事,只依赖一个函数对象。将来要加微信推送,直接创建一个新lambda就行,不需要动NotificationService一行代码。控制流和依赖方向反过来了,高层不再被底层绑架。
7.4 面向对象与性能:什么时候该放开手脚
最后聊一个务实话题。很多人担心虚函数、智能指针影响性能。我的看法很明确:先把代码写正确、写好维护,再回来找热点。虚函数调用在现代CPU分支预测面前几乎是可以接受的,shared_ptr的引用计数开销也不是大问题。真正能拖垮性能的,往往是你在不该深拷贝的地方深拷贝了、该用移动却用了拷贝、在循环里频繁new/delete——这些都是设计问题,不是面向对象本身的锅。
真到性能热点再优化也不迟:确定属于热点,可以先试试改用模板或std::variant消除动态分配,不行再考虑手动管理内存方案。但无论如何,先保证设计是清晰的、数据流是明确的。一个藏着一堆裸指针和到处复制大对象的类,不管多"偏向性能优先",最后都会让维护成本和出bug概率同时上升。
写C++面向对象最好的状态就是:脑子里有一套清晰的资源所有权观念,知道自己的数据边界在哪里,能控制类的生命周期和对外承诺,再用继承和虚函数只做真正需要多态的事情。绕过那些形式化的条条框框,直击问题本质,你会发现这些概念根本不是"必须背下来考高分"的知识点,而是一套帮你把复杂系统组织得井井有条的思维方法。
我自己的体会是,面向对象这东西,写个两年左右才能慢慢感觉到"设计感"。一开始写出来的类永远是过度设计或者设计不足的,但都不怕,怕的是不再迭代。每次重构时想想封装边界是不是变模糊了、继承是多了还是该拆了、资源所有权是不是清晰——一点点打磨,代码质量是能稳定向上走的。最后补一个小技巧:如果环境支持C++20,把std::span、std::string_view这些视图类型也纳入你的类接口设计里,传参传只读视图,数据所有权留在调用方,整个类会变得意外地简洁。