程序设计思想首先回答:一段需求变化后,哪些代码应该一起变化,哪些应该保持稳定?本系列把对象创建、可替换行为、资源所有权和现代 C++ 类型工具放在同一条主线上,用小而完整的程序解释选择依据。
程序设计专题 · 下一篇:01 工厂的三种含义
1. 先识别变化,再选择模式
设想一个离线传感器记录分析器:数据可能来自模拟器或历史文件;分析时可能保留原始读数、裁剪异常值,或者输出不同格式。如果一个类同时负责打开文件、解释单位、挑选算法和展示结果,修改输出格式也可能触碰采样逻辑。
拆分的依据是变化原因,而不是“每个函数都变成一个类”。可以先把纯计算提成函数,把配置信息放进值对象;只有确实存在替换需求时,才引入接口。
| 变化发生在哪里 | 优先考虑的工具 | 本系列对应 |
|---|---|---|
| 只是一条稳定的条件判断 | 普通函数、if / switch |
不必强行引入模式 |
| 创建哪个对象、如何初始化 | 简单工厂;有框架扩展点时考虑工厂方法 | 01 |
| 同一任务有多种可互换行为 | 函数对象、策略接口或模板策略 | 02 |
| “没有值”或多种事件状态 | optional / variant |
03,C++17 |
| 泛型算法要求什么操作,借用哪段数据 | concepts / span / ranges |
04,C++20 |
| 失败原因也要成为返回类型 | expected |
05,C++23 |
模式是沟通结构的词汇,不是数量指标。“开闭原则”也不是永远不改任何旧文件:例如简单工厂新增一种产品通常就要修改工厂分支。应当保护的是已经选定的稳定边界。
2. 单一职责、依赖方向与替换契约
职责可以理解为一组共同变化的规则。读数来源负责拿到样本,阈值判断负责解释一个数值是否超限,展示层负责如何告诉用户结果。三者的错误类型、修改频率和测试方法都可能不同。
依赖倒置不是“所有东西都抽象”。它意味着核心流程依赖自己需要的最小契约,而不直接绑死某个复杂设备库。调用者在程序入口把具体实现传进来,这就是构造注入;无需引入依赖注入框架或全局服务定位器。
可替换性也比函数签名更强。两个来源都叫 read(),还需要约定单位、失败方式、是否阻塞,以及是否改变内部状态。一个返回摄氏温度、另一个返回未经说明的 ADC 计数,不能仅凭共同基类就认为等价。
在下面的例子中,契约特意缩小为:读取一个有限的模拟数值,单位已由上游统一,失败策略由实际应用另行定义。它不连接设备,不承诺线程安全、硬实时延迟或实机安全。
3. 组合:Monitor 拥有数据来源,而不是一种数据来源
Monitor “有一个” SampleSource,并不“是一个” SampleSource。这类关系用成员组合更自然。继承只用来实现来源的可替换接口,避免把业务流程、共享实现和类型层级混在一起。
下面是完整程序。基础实现用到 C++11 的 unique_ptr、移动语义和 override,以及 C++14 的 make_unique;统一以 C++17 编译不代表这些特性在 C++17 才出现。
|
在下载文件所在目录运行:
g++ -std=c++17 -Wall -Wextra -Werror -pedantic composition.cpp -o composition |
输出:
above=true |
按对象寿命读一遍:
make_unique创建一个FixedSource,返回独占所有权。- 构造函数按值接收智能指针,再用
std::move把所有权放进成员。std::move本身是允许移动的类型转换,不负责分配或复制数据。 - 构造阶段拒绝空来源,因此成功构造的
Monitor有可用来源。 Monitor离开作用域时,成员智能指针销毁来源;通过基类指针删除派生对象需要这里的虚析构函数。
这个小例子只演示构造和正常使用,没有把对象移动到另一个变量后再调用原对象。真实接口若允许移动,还需定义 moved-from 状态可做哪些操作,不能把构造时的不变量无条件套到所有后续状态。
4. 所有权:值优先,独占优先,借用要有边界
资源可以是内存、文件、锁或连接。RAII 把资源的释放绑定到对象析构,所以正常返回和异常展开都能经过清理路径。它解决的是“谁负责释放”,并不自动保证一次操作失败后全部状态回滚,也不自动消除数据竞争。
| 表达方式 | 谁管理生命周期 | 适用关系 |
|---|---|---|
| 普通值成员 | 外层对象直接管理 | 数据体积和复制/移动成本合适时最直接 |
unique_ptr<T> |
一个明确的拥有者 | 需要多态或动态寿命,且没有共享所有权 |
shared_ptr<T> |
多个共享拥有者 | 真实存在共同管理寿命的需求;循环引用需单独设计 |
T&、const T&、裸指针、视图 |
通常是借用 | 被借用对象必须活得足够久,指针是否可空要明确 |
不要为“方便传来传去”默认选择 shared_ptr,也不要把裸指针一概视为错误。短时借用与所有权转移是不同关系,应让类型和接口把意图说清楚。03、04 会进一步解释 string_view 和 span 的借用边界。
5. 什么时候保持简单
只有一种数据来源、对象创建没有额外规则时,直接使用一个具体值就足够。出现第二种来源也不必立刻建一个庞大的类树:先看它是否真的需要在运行时切换,是否共享契约,是否在多处使用。
一个好的抽象应当让某个具体变化局部化。例如加入文件回放来源时,阈值判断无需知道文件格式;但文件读取失败如何传递,仍然需要设计。后续的工厂、策略和 expected 都是为这类具体问题服务。
6. 阅读顺序与历史笔记
- 01:简单工厂、工厂方法、抽象工厂,分清创建点、继承扩展点和产品族。
- 02:Strategy 的三种实现边界,比较运行时多态、可调用对象和模板策略。
- 03:C++17 的值、状态与借用。
- 04:C++20 的约束、范围与视图。
- 05:C++23 的显式错误与可移动行为。
原有工厂模式笔记和策略模式笔记保留为历史补充,不属于本次逐一编译验证的示例集。本系列下载源码、标准版本和输出对应关系见示例说明。
参考
C++ Core Guidelines 的 C.120 / C.129 讨论类层级和接口继承,C.35 讨论基类析构函数,R.1 / R.20 / R.21 讨论 RAII 与智能指针。这里的 Monitor 是为说明职责和所有权编写的独立教学示例。