程序设计 02:Strategy,让行为可替换而边界清楚

同一批离线样本,有时保留原值,有时裁剪异常值。策略模式把这部分可变化的行为移出使用它的流程。难点不在“多写一个接口”,而在确定替换发生的时机、状态由谁拥有,以及什么契约保持不变。

程序设计专题 · 上一篇:工厂的三种含义 · 下一篇:C++17

1. 从稳定流程中找出变化轴

Context(上下文)负责流程;Strategy(策略)负责一个可替换步骤。本例 Pipeline 接收一个数值并调用过滤规则,Identity 保留原值,Clamp 把它限制到给定区间。

这不要求消灭所有分支。应用仍然可能根据配置选择策略,只是选择发生在装配边界;稳定流程不需要反复理解所有策略细节。如果差异只是一个很短、很稳定的 if,把分支留在函数里往往更直接。

还要说明可替换契约:本例处理有限 double,不修改输入外部状态,输出仍是同一单位的数值。不处理 NaN、传感器故障、时间同步或线程并发,也不是可直接部署的实时机器人控制器。

2. 运行时多态:对象拥有当前策略

适合需要在运行时选择不同策略类型、每种策略有自己配置或状态的场景。Context 用 unique_ptr 独占策略,基类虚析构使通过接口释放对象正确。

下载完整源码

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

struct Filter {
virtual ~Filter() = default;
virtual double apply(double value) const = 0;
};
struct Identity final : Filter {
double apply(double value) const override { return value; }
};
class Clamp final : public Filter {
double low_, high_;
public:
Clamp(double low, double high) : low_(low), high_(high) {
if (!(low <= high)) throw std::invalid_argument("invalid bounds");
}
double apply(double value) const override {
return std::clamp(value, low_, high_);
}
};
class Pipeline {
std::unique_ptr<Filter> filter_;
public:
explicit Pipeline(std::unique_ptr<Filter> filter) {
set_filter(std::move(filter));
}
void set_filter(std::unique_ptr<Filter> next) {
if (!next) throw std::invalid_argument("filter is required");
filter_ = std::move(next);
}
double process(double value) const { return filter_->apply(value); }
};
int main() {
Pipeline pipeline(std::make_unique<Identity>());
std::cout << "identity=" << pipeline.process(12) << '\n';
pipeline.set_filter(std::make_unique<Clamp>(0, 10));
std::cout << "clamped=" << pipeline.process(12) << '\n';
try {
pipeline.set_filter(nullptr);
} catch (const std::invalid_argument& error) {
std::cout << error.what() << '\n';
}
std::cout << "still=" << pipeline.process(12) << '\n';
}
g++ -std=c++17 -Wall -Wextra -Werror -pedantic strategy_runtime.cpp -o strategy_runtime
./strategy_runtime

输出:

identity=12
clamped=10
filter is required
still=10

set_filter() 先验证新对象,再替换成员。因此这个特定的“拒绝空策略”失败路径不会丢掉当前策略,最后仍得到 10。它并不意味着任意有副作用的策略执行都能自动回滚。

Clamp 在构造时验证上下界顺序,满足 std::clamp 的前提;数值输入仍需遵守前面限定的有限值契约。外部数据校验应放在输入边界,而不是把演示代码当成完整控制保护逻辑。

创建新策略由调用者完成,可以直接 make_unique,也可以交给上一篇的简单工厂。策略接口本身无需了解配置文件格式。

3. 可调用对象与模板策略:不一定要建立类层级

单个行为签名清楚时,可以使用函数对象或 lambda。std::function<double(double)> 对不同可调用类型做类型擦除,因而同一个成员可在运行时换成另一种可调用对象;StaticPipeline<Filter> 则把策略类型固定为模板参数。

下载完整源码

#include <algorithm>
#include <functional>
#include <iostream>
#include <stdexcept>
#include <utility>

class CallablePipeline {
std::function<double(double)> filter_;
public:
explicit CallablePipeline(std::function<double(double)> filter) {
set_filter(std::move(filter));
}
void set_filter(std::function<double(double)> next) {
if (!next) throw std::invalid_argument("filter is required");
filter_ = std::move(next);
}
double process(double value) const { return filter_(value); }
};

template<class Filter>
struct StaticPipeline {
Filter filter;
double process(double value) const { return filter(value); }
};
struct Twice {
double operator()(double value) const { return value * 2; }
};
int main() {
const double limit = 10;
CallablePipeline dynamic([limit](double value) {
return std::clamp(value, 0.0, limit);
});
std::cout << "callable=" << dynamic.process(12) << '\n';
dynamic.set_filter(Twice{});
std::cout << "replaced=" << dynamic.process(12) << '\n';
StaticPipeline<Twice> fixed{Twice{}};
std::cout << "static=" << fixed.process(12) << '\n';
try {
dynamic.set_filter({});
} catch (const std::invalid_argument& error) {
std::cout << error.what() << '\n';
}
}
g++ -std=c++17 -Wall -Wextra -Werror -pedantic strategy_values.cpp -o strategy_values
./strategy_values

输出:

callable=10
replaced=24
static=24
filter is required

lambda 按值捕获 limit,不会借用一个已经离开作用域的局部变量。把 [limit] 机械地改为 [&] 再长期保存,是常见的悬垂引用来源。若确实捕获指针或引用,需要另外保证被引用对象的寿命和同步。

模板版本的 StaticPipeline<Twice> 只能保存 Twice 类型的策略。它可以更改同类型策略的状态,但不能在运行时随意塞入一个不相关的 lambda 类型;换模板参数得到的是另一种 Context 类型。不要把编译期可替换和运行时可替换混称为一种能力。

4. 三种表达的成本与限制

表达 选择时机 所有权/限制 适合起点
虚接口 + unique_ptr 运行时 本例独占且可空,所以要验证;通常涉及动态分配 有状态对象、稳定接口、模块边界
std::function 运行时 C++11;目标需可复制,可能分配;空调用抛 bad_function_call 单一签名的回调或局部算法
模板 + 函数对象 编译期固定类型 无须类型擦除;实例化可能增加编译时间和代码体积 热路径且具体类型可在编译期确定

不能仅凭写法断言“模板一定快”或“虚调用一定慢”。编译器可能内联、去虚化,也可能因代码体积影响指令缓存。应测量真实数据、调用频率和构建选项;一个对已知具体对象的循环不能自动代表所有插件边界的性能。

std::function 不能直接保存一个捕获 unique_ptr 的不可复制 lambda。C++23 的 std::move_only_function 可以表达这一需求,见 05。它不是 std::function 的无条件替代品:接口若要求复制,换成只能移动就会改变契约。

5. Strategy 与相邻模式的边界

  • Factory 关心创建哪个对象;Strategy 关心对象之后怎样完成某个步骤。一个可以没有另一个。
  • State 关心对象状态与合法迁移,行为随内部状态变化;Strategy 通常由外部选择算法。结构相似,动机不同。
  • Template Method 通常让基类定义流程、派生类覆盖部分步骤;Strategy 通过组合把步骤交给另一对象。
  • Decorator 在现有行为外叠加职责;它不只是从多个备选行为里挑一个。

共享一个策略对象也不等于线程安全。即使指针替换有锁,策略内部的可变状态仍需自己的同步或明确单线程约束。本例只有单线程、无状态算法;移动后的 Context 不参与后续调用,若工程中需要支持该状态应明确接口约定。

6. 阅读历史笔记时保留哪些判断

原策略模式笔记保留了更多扩展示意。本篇只把两个完整程序作为验证对象,不把历史对象池、事务回滚或性能数字当作已验证结论。首先确认寿命、错误传播和状态边界,再决定是否需要池化、并发或更多模式组合。

参考

C++ Core Guidelines F.53讨论非局部使用 lambda 时的引用捕获风险;C++17 草案 N4659 的 [func.wrap.func]、[unique.ptr] 与 [alg.min.max] 可核对 std::function、独占指针和 clamp 的契约。代码与运行输出是本篇自己的可复现示例。

知识共享许可协议