26. 函数对象的现代应用:策略模式、状态机、延迟计算
聊到函数对象,很多人第一反应就是「哦,就是重载了 operator() 的类」。没错,但它的威力远不止于此。我在项目中摸爬滚打这些年,发现函数对象真正的价值,体现在三个经典场景里:策略模式、状态机、延迟计算。今天咱们就一个一个掰开揉碎了讲。
26.1 策略模式:把算法「拎」出来
策略模式说白了就是:把不同的算法封装成独立的类,然后在运行时自由切换。函数对象在这里简直是天选之子。
我举个例子。假设你要写一个数据压缩模块,支持 ZIP、GZIP、Brotli 三种算法。传统做法是写一堆 if-else,或者用虚函数继承。但用函数对象,代码会清爽很多。
// 定义策略接口
using CompressFunc = std::function<std::vector<char>(const std::vector<char>&)>;
// 具体策略
struct ZipCompress {
std::vector<char> operator()(const std::vector<char>& data) const {
// 实际 ZIP 压缩逻辑
return data; // 示意
}
};
struct GzipCompress {
std::vector<char> operator()(const std::vector<char>& data) const {
// GZIP 压缩逻辑
return data;
}
};
// 使用
CompressFunc strategy = ZipCompress{};
auto result = strategy(inputData);
核心优势:策略对象可以携带状态。比如你可以给 ZipCompress 加一个压缩等级成员变量,在构造时设定。这是普通函数指针做不到的。
我在项目中遇到过一种情况:需要根据文件后缀名自动选择压缩策略。用函数对象 + map 就能轻松搞定:
std::map<std::string, CompressFunc> strategyMap = {
{".zip", ZipCompress{9}}, // 压缩等级 9
{".gz", GzipCompress{}},
{".br", BrotliCompress{11}}
};
auto it = strategyMap.find(extension);
if (it != strategyMap.end()) {
auto compressed = it->second(rawData);
}
你想想看,如果以后要加新算法,只需要新增一个函数对象,往 map 里插一条记录就行。完全符合开闭原则。
26.2 状态机:用函数对象管理状态流转
状态机是另一个让我对函数对象刮目相看的领域。传统的状态机实现,要么是巨大的 switch-case,要么是状态模式那一套虚函数继承。但函数对象提供了一种更轻量的方式。
我个人的习惯是:把每个状态定义为一个函数对象,状态之间的转移通过返回下一个状态函数对象来实现。
// 状态类型
using StateFunc = std::function<StateFunc()>;
// 具体状态
struct IdleState {
StateFunc operator()() {
// 处理空闲逻辑
if (/* 收到开始信号 */) {
return RunningState{};
}
return *this; // 保持当前状态
}
};
struct RunningState {
int progress = 0;
StateFunc operator()() {
progress += 10;
if (progress >= 100) {
return DoneState{};
}
return *this;
}
};
struct DoneState {
StateFunc operator()() {
// 完成状态,不再转移
return *this;
}
};
// 使用
StateFunc current = IdleState{};
while (true) {
current = current(); // 执行并转移
}
避坑指南:我曾经在状态机里直接返回 lambda 表达式,结果因为捕获列表导致对象切片。后来我统一用函数对象类,确保状态数据完整。记住:状态对象是有生命的,别让它「失忆」。
这种方式的妙处在于:每个状态可以携带自己的数据。比如 RunningState 里的 progress 变量,天然就是该状态的私有数据。你不需要额外搞一个全局的状态变量池。
26.3 延迟计算:把计算「存」起来
延迟计算,也叫惰性求值。说白了就是:先别急着算,等我真正需要结果的时候再算。函数对象在这里扮演了「计算配方」的角色。
我举个实际例子。假设你要从数据库里查一批用户,然后对每个用户做一系列复杂的转换。如果用户量很大,但实际只用到了其中一部分字段,提前全部计算就是浪费。
// 定义一个延迟计算器
template<typename T>
class LazyValue {
public:
template<typename F>
LazyValue(F&& f) : computer_(std::forward<F>(f)) {}
const T& get() {
if (!computed_) {
value_ = computer_();
computed_ = true;
}
return value_;
}
private:
std::function<T()> computer_;
T value_;
bool computed_ = false;
};
// 使用
LazyValue<UserProfile> lazyUser([]() {
// 这里才是真正的数据库查询 + 转换
return fetchAndTransformUser(userId);
});
// ... 中间可能有很多逻辑 ...
if (needProfile) {
auto& profile = lazyUser.get(); // 此时才真正计算
}
关键点:延迟计算的核心是「计算与使用分离」。函数对象保存了计算逻辑,但直到 get() 被调用时才执行。而且只执行一次,后续直接返回缓存结果。
我在项目中遇到过一种场景:配置文件的解析。有些配置项可能整个运行周期都用不到,但解析起来却很耗时。用 LazyValue 包装后,只有真正访问时才解析,启动速度提升了 40%。
26.4 三者结合:一个真实案例
说了这么多,咱们来个综合案例。假设你要写一个网络请求重试机制,它同时用到了策略模式、状态机和延迟计算。
// 重试策略
using RetryStrategy = std::function<bool(int attempt, const Error&)>;
struct ExponentialBackoff {
int maxRetries;
std::chrono::milliseconds baseDelay;
bool operator()(int attempt, const Error& err) {
if (attempt >= maxRetries) return false;
auto delay = baseDelay * (1 << attempt);
std::this_thread::sleep_for(delay);
return true; // 继续重试
}
};
// 重试状态机
struct RetryStateMachine {
RetryStrategy strategy;
int attempt = 0;
LazyValue<Response> lastResponse;
StateFunc operator()() {
auto response = doRequest();
if (response.isSuccess()) {
return DoneState{response};
}
if (strategy(attempt, response.error())) {
attempt++;
return *this; // 继续重试
}
return FailedState{response.error()};
}
};
你看,策略模式负责「怎么重试」,状态机负责「重试流程」,延迟计算负责「只在需要时构造响应对象」。三个模式配合得天衣无缝。
注意:函数对象虽然灵活,但不要滥用。每个函数对象都是一个闭包,会捕获外部变量。如果捕获了 shared_ptr 或者大型容器,要注意生命周期和内存开销。我见过有人把整个数据库连接池捕获进 lambda,结果内存泄漏查了两天。
26.5 总结
函数对象的现代应用,说白了就是三件事:
- 策略模式:把算法封装成对象,运行时自由切换。适合「多种做法选一种」的场景。
- 状态机:用函数对象表示状态,返回值表示转移。适合「不同阶段做不同事」的场景。
- 延迟计算:把计算逻辑存起来,真正需要时才执行。适合「可能用不到但计算很贵」的场景。
我个人觉得,函数对象最大的魅力在于:它让代码的「行为」变得和「数据」一样,可以被传递、存储、组合。这种能力,在 C++11 之前是很难想象的。嗯,用好它,你的代码会更有弹性。
一句话总结:函数对象不是语法糖,而是一种设计思维。它把「做什么」和「怎么做」彻底分离,让你的代码从「死板」变得「灵活」。下次写代码时,不妨问问自己:这里能不能用函数对象?