策略这东西。
比“策略模式”这个词老得多。
我第一次在项目里被“策略”折磨。
不是因为我不知道 Strategy。
是因为我以为我不需要它。
那是一个结算系统。
一开始很简单。
满减。
打折。
会员。
新客。
你看。
这不就是几条规则吗。
于是代码也很“诚实”。
规则写哪。
就写在结算函数里。
double calcFinalPrice(const Cart& cart, const User& user) {
double total = cart.totalPrice();
if (user.isVip()) {
total *= 0.9;
}
if (cart.hasCoupon()) {
total -= cart.couponValue();
}
if (cart.totalPrice() > 300) {
total -= 30;
}
return std::max(0.0, total);
}
这段代码。
跑得飞快。
也不难读。
上线那天。
大家都很满意。
直到第二周。
运营同学拿着需求来了。
“VIP 也分等级。”
“新人首单另算。”
“不同品类不同折扣。”
“节假日活动叠加规则不一样。”
“还有。 要做 AB 实验。 同一个用户不同策略。”
你看。
世界开始变得真实。
于是 if 开始长毛。
else if 开始分叉。
你想把它们整理一下。
结果发现。
你根本不知道该按什么维度整理。
按活动整理。
还是按用户类型整理。
还是按品类整理。
最后变成一个事实。
你写的不是结算。
你写的是一棵会长大的圣诞树。
树上每多挂一个彩灯。
测试就多一圈。
线上事故也多一圈。
策略模式要解决的痛点。
一句话。
把“怎么做”从业务流程里拆出来。
让算法可以被替换。
可以被组合。
可以被测试。
可以被配置。
而不是被一堆分支语句硬焊死。
业务流程应该负责“什么时候做”。 策略负责“怎么做”。
一开始我们都这么写:分支把业务写成迷宫
再举个更常见的。
比如重试逻辑。
有的服务要指数退避。
有的要固定间隔。
有的要按错误码决定要不要重试。
void callWithRetry() {
for (int i = 0; i < 5; ++i) {
auto r = callRemote();
if (r.ok()) return;
if (r.code() == ErrorCode::BadRequest) {
// 4xx 这种你重试一万次也没用
throw std::runtime_error("bad request");
}
// 这里开始出现“策略”
// 但它被硬编码成了睡眠时间
std::this_thread::sleep_for(std::chrono::milliseconds(50 * (i + 1)));
}
throw std::runtime_error("retry exhausted");
}
问题不是代码丑。
问题是它很难演进。
今天你要把退避从线性改成指数。
明天你要对不同错误码用不同退避。
后天你要对不同下游服务用不同策略。
你会发现。
“重试”这个动作没有变。
变的是“决定怎么重试”的规则。
而规则是最容易变的。
规则应该被隔离。
GoF 的时代。
大家讲究接口。
讲究多态。
Strategy 的结构也很清爽。
Strategy:策略接口。
ConcreteStrategy:具体策略。
Context:使用策略的上下文。
先写一个策略接口。
以“退避时间”为例。
struct BackoffStrategy {
virtual ~BackoffStrategy() = default;
virtual std::chrono::milliseconds nextDelay(int attempt) = 0;
};
然后是具体策略。
struct LinearBackoff final : BackoffStrategy {
std::chrono::milliseconds nextDelay(int attempt) override {
return std::chrono::milliseconds(50 * (attempt + 1));
}
};
struct ExponentialBackoff final : BackoffStrategy {
std::chrono::milliseconds nextDelay(int attempt) override {
auto base = 50;
auto v = base * (1 << attempt);
return std::chrono::milliseconds(v);
}
};
最后是上下文。
它只负责流程。
不负责算法细节。
class Retrier {
public:
explicit Retrier(std::unique_ptr<BackoffStrategy> backoff)
: backoff_(std::move(backoff)) {}
template <class F>
auto run(F&& f) {
for (int i = 0; i < max_attempts_; ++i) {
auto r = f();
if (r.ok()) return r;
std::this_thread::sleep_for(backoff_->nextDelay(i));
}
return Result::error(ErrorCode::RetryExhausted);
}
private:
int max_attempts_{5};
std::unique_ptr<BackoffStrategy> backoff_;
};
这时候你要换策略。
不需要进 run() 里改逻辑。
只要换一个对象。
Retrier r(std::make_unique<ExponentialBackoff>());
auto res = r.run([] { return callRemote(); });
你会发现。
Strategy 这东西。
并不神秘。
它只是把“可变的部分”从“稳定的流程”里拔出来。
拔出来以后。
你就可以单测它。
可以替换它。
可以按配置选择它。
现代 C++ 的三种 Strategy 写法:虚函数、std::function、以及不想付运行时成本的那一派
C++11 之后。
大家的工具箱变大了。
写 Strategy 的方式也多了。
但多不一定是好。
你得知道自己在付什么成本。
1) 经典虚函数:清晰,稳定,最“工程化”
优点你已经看到了。
接口清晰。
可扩展。
可替换。
缺点也很现实。
需要动态分配。
你也可以对象池。
但那是另一套工程。
有虚调用成本。
大多数时候不致命。
但热点路径要小心。
策略对象的生命周期要管好。
如果你的策略需要状态。
虚函数版本通常最顺手。
比如“按服务动态调整退避”。
策略内部可以记指标。
这点 std::function 也能做。
但用对象表达状态。
往往更可读。
2) 用 lambda / std::function:写得快,但你得知道它是类型擦除
很多项目里。
Strategy 最快落地的写法是这样的。
把策略当成一个“可调用对象”。
using BackoffFn = std::function<std::chrono::milliseconds(int)>;
class Retrier2 {
public:
explicit Retrier2(BackoffFn backoff) : backoff_(std::move(backoff)) {}
template <class F>
auto run(F&& f) {
for (int i = 0; i < max_attempts_; ++i) {
auto r = f();
if (r.ok()) return r;
std::this_thread::sleep_for(backoff_(i));
}
return Result::error(ErrorCode::RetryExhausted);
}
private:
int max_attempts_{5};
BackoffFn backoff_;
};
用起来也很爽。
Retrier2 r([](int i) {
return std::chrono::milliseconds(50 * (1 << i));
});
但你要记住。
std::function 不是“免费午餐”。
它是类型擦除。
也就是说。
它把你的 lambda 装进了一个盒子。
盒子里可能会动态分配。
也可能不会。
取决于你的实现和对象大小。
此外。
它拷贝起来也可能很贵。
所以这里有两个很容易踩的坑。
第一。
别在性能敏感路径上。
随手把策略全换成 std::function。
你以为你在“写得现代”。
实际上你可能在“写得更慢”。
第二。
别在 lambda 里捕获悬空引用。
这是现代 C++ 最常见的“优雅崩溃”。
Retrier2 makeRetrierBad() {
int base = 50;
return Retrier2([&](int i) {
return std::chrono::milliseconds(base * (1 << i));
});
}
// base 早就死了
你看。
Strategy 模式本来是为了让代码更可控。
结果你用错工具。
反而让生命周期更不可控。
如果你要捕获。
优先捕获值。
或者把状态放进一个对象里。
3) 模板/Policy:把策略变成编译期选择
还有一派人。
看到虚函数就皱眉。
看到 std::function 就更皱眉。
他们会说。
“策略既然是算法。
能不能在编译期确定?”
当然可以。
这就是 Policy-based design。
template <class BackoffPolicy>
class Retrier3 {
public:
explicit Retrier3(BackoffPolicy policy = {})
: policy_(std::move(policy)) {}
template <class F>
auto run(F&& f) {
for (int i = 0; i < max_attempts_; ++i) {
auto r = f();
if (r.ok()) return r;
std::this_thread::sleep_for(policy_.nextDelay(i));
}
return Result::error(ErrorCode::RetryExhausted);
}
private:
int max_attempts_{5};
BackoffPolicy policy_;
};
struct ExpBackoffPolicy {
std::chrono::milliseconds nextDelay(int attempt) {
return std::chrono::milliseconds(50 * (1 << attempt));
}
};
这版的特点。
没有虚调用。
没有类型擦除。
很多时候还能被内联。
代价也很明确。
策略不能在运行时随便换。
你要换策略。
得换类型。
得重新编译。
所以它适合“性能敏感、策略不需要运行时切换”的场景。
比如某些基础库。
或者你在写一个给别人用的组件。
让使用方在编译期选策略。
很合理。
但如果你说。
“运营想在后台改配置实时切换。”
那模板这派就沉默了。
不想虚函数也不想 std::function?你可以用 std::variant 把策略装进一个‘明确的盒子’
还有一种折中。
你只允许有限几种策略。
但你又想运行时切换。
这时候 std::variant 常常很好用。
struct Linear {
std::chrono::milliseconds nextDelay(int attempt) const {
return std::chrono::milliseconds(50 * (attempt + 1));
}
};
struct Exp {
std::chrono::milliseconds nextDelay(int attempt) const {
return std::chrono::milliseconds(50 * (1 << attempt));
}
};
using Backoff = std::variant<Linear, Exp>;
std::chrono::milliseconds nextDelay(const Backoff& b, int attempt) {
return std::visit([&](const auto& s) { return s.nextDelay(attempt); }, b);
}
它的好处是。
策略集合是封闭的。
编译器知道你有哪些策略。
很多错误可以早一点暴露。
坏处也很清楚。
你要加一种策略。
得改 variant 的类型列表。
这意味着“扩展”不再是开闭原则那种扩展。
更像是“允许扩展,但要改一处注册表”。
工程上能不能接受。
看你的场景。
Strategy 最容易写错的地方:你以为你在拆算法,其实你在拆锅
我见过很多“看起来用了 Strategy”。
但最后反而更难维护。
原因通常不是模式本身。
而是边界没划清。
1) 策略里塞太多上下文
策略应该关心算法。
不要顺手把半个业务对象都塞进去。
比如价格策略。
如果策略对象里拿着 User、Cart、PromotionService、Logger。
那你其实不是 Strategy。
你是把一个巨型函数搬进了一个类。
类变长了。
依赖变多了。
耦合一点没少。
策略的输入。
尽量收敛成一个“算法需要的数据结构”。
别把整个世界都传进去。
2) 选择策略的逻辑也会长出一棵树
Strategy 把算法抽出来。
但“选哪个策略”这件事。
也可能变成新的 if-else。
这并不丢人。
因为选择本来就是业务。
只是你要把它放到合适的位置。
常见做法是。
配置驱动。
简单工厂。
依赖注入。
别让 Context 在执行流程里每次都临时判断。
那会让你的流程重新变乱。
3) 不要为了“模式”把本来清晰的分支搞复杂
有些分支。
就两条路。
而且不会变。
这时候。
一个 if 比十个类更诚实。
Strategy 是为“变化”服务的。
不是为“面向对象”服务的。
它和几个近亲的区别:Strategy、State、Command
这三个经常被新人混在一起。
你不用背定义。
记住一个工程味的区分。
Strategy:你关心的是“算法怎么换”。
通常由外部选择。
State:你关心的是“对象在不同状态下行为不同”。
状态切换往往由对象自己驱动。
Command:你关心的是“把一次动作变成对象”。
方便排队。
撤销。
回放。
有时候它们会一起出现。
比如一个编辑器。
输入法是 Strategy。
编辑器模式(插入/覆盖/只读)是 State。
用户操作(插入、删除、加粗)是 Command。
看起来都像“多态”。
但解决的是不同的痛点。
什么时候你会真的需要 Strategy
当你满足其中几个条件时。
Strategy 往往能救你。
同一件事有多种做法。
做法会经常变。
做法需要按配置。
按环境。
按用户动态选择。
你想把算法单独测。
你不想每次加一个规则就改一堆老代码。
如果没有这些。
别急着上模式。
工程里最贵的不是 if。
是你给未来预支的复杂度。
策略模式的价值。
也不是“把 if-else 变成面向对象”。
而是。
当变化真的来了。
你能把它关进一个小屋子里。
别让它在整个代码库里放烟花。