程序设计 00:从职责、组合到所有权

程序设计思想首先回答:一段需求变化后,哪些代码应该一起变化,哪些应该保持稳定?本系列把对象创建、可替换行为、资源所有权和现代 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 才出现。

下载完整源码

#include <iostream>
#include <memory>
#include <stdexcept>
#include <utility>

struct SampleSource {
virtual ~SampleSource() = default;
virtual double read() const = 0;
};

class FixedSource final : public SampleSource {
double value_;
public:
explicit FixedSource(double value) : value_(value) {}
double read() const override { return value_; }
};

class Monitor {
std::unique_ptr<SampleSource> source_;
double threshold_;
public:
Monitor(std::unique_ptr<SampleSource> source, double threshold)
: source_(std::move(source)), threshold_(threshold) {
if (!source_) throw std::invalid_argument("source is required");
}
bool above_threshold() const { return source_->read() > threshold_; }
};

int main() {
Monitor monitor(std::make_unique<FixedSource>(12.0), 10.0);
std::cout << std::boolalpha << "above=" << monitor.above_threshold() << '\n';
try {
Monitor invalid(nullptr, 10.0);
} catch (const std::invalid_argument& error) {
std::cout << error.what() << '\n';
}
}

在下载文件所在目录运行:

g++ -std=c++17 -Wall -Wextra -Werror -pedantic composition.cpp -o composition
./composition

输出:

above=true
source is required

按对象寿命读一遍:

  1. make_unique 创建一个 FixedSource,返回独占所有权。
  2. 构造函数按值接收智能指针,再用 std::move 把所有权放进成员。std::move 本身是允许移动的类型转换,不负责分配或复制数据。
  3. 构造阶段拒绝空来源,因此成功构造的 Monitor 有可用来源。
  4. Monitor 离开作用域时,成员智能指针销毁来源;通过基类指针删除派生对象需要这里的虚析构函数。

这个小例子只演示构造和正常使用,没有把对象移动到另一个变量后再调用原对象。真实接口若允许移动,还需定义 moved-from 状态可做哪些操作,不能把构造时的不变量无条件套到所有后续状态。

4. 所有权:值优先,独占优先,借用要有边界

资源可以是内存、文件、锁或连接。RAII 把资源的释放绑定到对象析构,所以正常返回和异常展开都能经过清理路径。它解决的是“谁负责释放”,并不自动保证一次操作失败后全部状态回滚,也不自动消除数据竞争。

表达方式 谁管理生命周期 适用关系
普通值成员 外层对象直接管理 数据体积和复制/移动成本合适时最直接
unique_ptr<T> 一个明确的拥有者 需要多态或动态寿命,且没有共享所有权
shared_ptr<T> 多个共享拥有者 真实存在共同管理寿命的需求;循环引用需单独设计
T&、const T&、裸指针、视图 通常是借用 被借用对象必须活得足够久,指针是否可空要明确

不要为“方便传来传去”默认选择 shared_ptr,也不要把裸指针一概视为错误。短时借用与所有权转移是不同关系,应让类型和接口把意图说清楚。03、04 会进一步解释 string_view 和 span 的借用边界。

5. 什么时候保持简单

只有一种数据来源、对象创建没有额外规则时,直接使用一个具体值就足够。出现第二种来源也不必立刻建一个庞大的类树:先看它是否真的需要在运行时切换,是否共享契约,是否在多处使用。

一个好的抽象应当让某个具体变化局部化。例如加入文件回放来源时,阈值判断无需知道文件格式;但文件读取失败如何传递,仍然需要设计。后续的工厂、策略和 expected 都是为这类具体问题服务。

6. 阅读顺序与历史笔记

原有工厂模式笔记和策略模式笔记保留为历史补充,不属于本次逐一编译验证的示例集。本系列下载源码、标准版本和输出对应关系见示例说明。

参考

C++ Core Guidelines 的 C.120 / C.129 讨论类层级和接口继承,C.35 讨论基类析构函数,R.1 / R.20 / R.21 讨论 RAII 与智能指针。这里的 Monitor 是为说明职责和所有权编写的独立教学示例。

知识共享许可协议