最新下载
热门教程
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
编译期策略模式:当模板化作策略容器
时间:2026-06-05 08:21:54 编辑:袖梨 来源:一聚教程网
经典策略模式在 C++ 史上地位显赫,它用虚函数帮我们实现了运行时多态下的算法替换。

但随着模板元编程的成熟,有人就会想:既然很多策略编译期就能定死,为什么还要忍受间接跳转和内联屏障?于是编译期策略模式登场了。
运行时策略模式的利与弊
我们先写个运行时策略模式,回忆一下那种万物皆对象的舒适感。
假设我们有一个简单的文本处理上下文,它需要把一段文本转换成大写或小写,运行时随便换策略。
// 经典策略接口
class ITextStrategy
{
public:
virtual std::string transform(const std::string& text) const = 0;
virtual ~ITextStrategy() = default;
};// 大写策略
class UpperStrategy : public ITextStrategy
{
public:
std::string transform(const std::string& text) const override
{
std::string result = text;
for (auto& c : result) c = toupper(c);
return result;
}
};// 小写策略
class LowerStrategy : public ITextStrategy
{
public:
std::string transform(const std::string& text) const override
{
std::string result = text;
for (auto& c : result) c = tolower(c);
return result;
}
};// 组合策略
class TextProcessor
{
std::unique_ptr<ITextStrategy> strategy_;
public:
explicit TextProcessor(std::unique_ptr<ITextStrategy> s) : strategy_(std::move(s)) {}
void setStrategy(std::unique_ptr<ITextStrategy> s) { strategy_ = std::move(s); }
std::string process(const std::string& text) const
{
return strategy_->transform(text);
}
};// 使用示例
int main()
{
TextProcessor proc(std::make_unique<UpperStrategy>());
auto result1 = proc.process("Hello World"); // "HELLO WORLD"
proc.setStrategy(std::make_unique<LowerStrategy>());
auto result2 = proc.process("Hello World"); // "hello world"
}
这设计多棒啊,多态替换,开闭原则,单一职责。想加新策略?再写个类,顶多改改工厂,核心业务逻辑纹丝不动,单元测试啥的也轻轻松松。在业务代码里,这种灵活度确实舒服。
但虚函数调用到底在底层干了些什么?它先要从对象的 vptr 里把那该死的虚表地址捞出来,再根据偏移量算出函数指针的地址,最后才通过那个函数指针间接跳转过去。
这还不算啥,最重要的是无法内联。哪怕我们的策略类里只有一行 return a + b,编译器也只能眼睁睁看着,却不敢把这行代码塞进调用的热循环里。因为它不知道运行时到底会是哪个子类,它必须规规矩矩地生成那个 call 指令。
所以运行时的策略模式就是这么一个让人又爱又恨的东西:它给了我们架构上的体面,却可能在高频执行的路径上把性能底裤扯得稀烂。而这,就是为什么我们需要编译期策略模式。
初识编译期策略
1. 模板模板参数初试
这玩意的核心思想很简单:策略本身就是一个类模板,我们把它当参数传给宿主模板,宿主用这个策略类型来实例化内部逻辑。
比如我们把刚才的字符转换策略写成一个有静态方法的类模板:
// 转大写
struct UpperPolicy
{
static char transform(char c)
{
return (c >= 'a' && c <= 'z') ? c - 32 : c;
}
};// 转小写
struct LowerPolicy
{
static char transform(char c)
{
return (c >= 'A' && c <= 'Z') ? c + 32 : c;
}
};// 接受一个策略类型作为模板参数
template <typename TransPolicy>
class TextProcessor
{
public:
void process(std::string& text) const
{
for (auto& c : text)
{
c = TransPolicy::transform(c);
}
}
};// 使用
TextProcessor<UpperPolicy> upperProc;
TextProcessor<LowerPolicy> lowerProc;
std::string text = "Hello World";
upperProc.process(text); // 直接内联
lowerProc.process(text);
编译器在实例化 TextProcessor<UpperPolicy> 时,看到循环里的 TransPolicy::transform(c),它知道 TransPolicy 就是 UpperPolicy,transform 是个静态成员函数,于是直接把那个三元运算符展开在循环里。
这种写法已经具备了编译期策略的雏形:策略是类型,类型是参数,决策在编译期完成。
我们可以给策略类加静态成员,但非静态成员需要我们实例化一个策略对象作为成员,只要把策略对象作为模板参数传进去,只要类型在编译期确定,编译器依然能把方法调用内联掉。
template <typename Policy> 本身写起来不难,难的是当我们需要约束这个 Policy 必须有哪些静态方法时,C++20 之前只能用 SFINAE 或者 static_assert + decltype 来搞,一出错就有的我们头疼。但好在,只要我们把策略的接口定义好,代码本身的可读性还不错。
2. 策略的扩展
之前的 UpperPolicy 只有一个静态 transform,需求一变,我们可能需要策略提供多个操作、内部状态以及类型成员。比如我们要写一个字符串处理框架,策略不仅要变换字符,还要决定是否加前后缀、提供分隔符等等。
编译期策略扩展的第一个要点:策略就是一个类型,我们可以往里面塞任何编译期能确定的东西。静态方法、成员类型别名、静态常量啥的统统丢进去。
// 扩展策略
struct UpperPolicy
{
// 字符变换
static char transform(char c)
{
return (c >= 'a' && c <= 'z') ? c - 32 : c;
}
// 返回前缀
static const char* prefix() { return ">> "; }
// 返回后缀
static const char* suffix() { return " <<"; }
// 可以定义所使用的字符类型
using char_type = char;
};template <typename Policy>
class TextProcessor
{
public:
void process(std::string& text) const
{
std::string result;
result += Policy::prefix();
for (char c : text)
{
result += Policy::transform(c);
}
result += Policy::suffix();
text.swap(result);
}
};
在这段代码中策略膨胀了,但宿主模板完全不受影响,它只管用 Policy:: 去拿东西。这种扩展性就是编译期策略的优势:我们可以在策略类里塞进任意编译期常量,把组合玩出花来。
3. 模板方法 + 策略
模板方法模式本来是个运行期的设计模式:基类定义算法骨架,子类实现具体步骤。如果这些步骤在编译期就能确定,就能不使用虚函数了。我们直接把策略作为模板参数,在基类里定义骨架,让策略提供细节,用非虚函数完成多态。
这种写法我们之前也已经见过了,这里再举个例子:设计一个数据导出骨架,步骤固定为“验证数据 -> 格式化头 -> 格式化每一行 -> 格式化尾 -> 写入”,但不同格式各有实现。运行期策略模式会让基类 Exporter 里全是虚函数,而我们用编译期策略,把 CsvPolicy / JsonPolicy 当成模板参数塞进去。
struct Data { /* ... */ };
struct Record{ /* ... */ };template <typename ExportPolicy>
class Exporter
{
public:
void exportData(const Data& data, std::ostream& os) const
{
ExportPolicy::validate(data);
os << ExportPolicy::formatHeader();
for (const auto& record : data)
{
os << ExportPolicy::formatRecord(record);
}
os << ExportPolicy::formatFooter();
}
};// 策略示例
struct CsvPolicy
{
static void validate(const Data& d) { /* ... */ }
static std::string formatHeader() { return "id,namen"; }
static std::string formatRecord(const Record& r) { return std::to_string(r.id) + "," + r.name + "n"; }
static std::string formatFooter() { return ""; }
};
这就是编译期的模板方法 + 策略模式:流程骨架被 exportData 死死定住,算法细节完全由策略类型提供。
唯一让人遗憾的是,每个不同的策略实例化都会产生一份完整的 exportData 副本,导致代码膨胀。如果策略有十几个,整个 Exporter 的骨架逻辑就会被复制十几遍。
4. CRTP 的引入
CRTP(奇特递归模板模式),它做的事很简单:让基类在编译期就能访问派生类的具体实现,从而把虚函数调用替换为静态绑定。
传统模板方法往往依赖虚函数让基类调用派生类,而 CRTP 把这层间接彻底拍扁。写法长这样:
template <typename Derived>
class Buffer
{
public:
void write(const char* data, size_t len)
{
static_cast<Derived*>(this)->writeImpl(data, len);
}
};class FileBuffer : public Buffer<FileBuffer>
{
public:
void writeImpl(const char* data, size_t len)
{
// 写文件
}
};class NetworkBuffer : public Buffer<NetworkBuffer>
{
public:
void writeImpl(const char* data, size_t len)
{
// 发送网络包
}
};
不过这和我们之前的编译期策略有什么关系?关系大了,CRTP 本质上就是一种编译期策略的注入方式:FileBuffer 选择了文件写入策略,NetworkBuffer 选择了网络写入策略,而 Buffer 提供了一个稳定骨架。策略类就是派生类,基类提供模板方法骨架,派生类提供具体步骤,沟通桥梁是那个静态转型。
我们可以结合策略模板,让 CRTP 基类接受一个独立的策略参数,而不是直接依赖于派生类,这通常被称为 Mixin。
用 C++20 Concepts 定义策略契约
我们都知道模版一报错那信息跟天书一样,Concepts 让编译器替我们检查策略的契约,报错不再像天书,适配也能很方便的完成。
1. Concepts 登场
Concepts 本质上是编译期布尔谓词,用来描述类型必须满足的语法和语义要求。我们可以定义一个 TransPolicy concept,用它替换掉 typename 那松垮的口头约定。
先定义 concept:
#include <concepts>
#include <type_traits>template <typename T>
concept TransPolicy = requires(char c)
{
// 必须有 transform 静态方法,接受 char 返回 char
{ T::transform(c) } -> std::same_as<char>;
// 必须有 prefix 静态方法,返回 const char*
{ T::prefix() } -> std::same_as<const char*>;
// 必须有 suffix 静态方法,返回 const char*
{ T::suffix() } -> std::same_as<const char*>;
};
这里 requires (T, char c) 创建一个需求表达式,依次检查三个调用是否合法,并用 -> std::same_as<...> 约束返回类型。
把 concept 用到宿主模板上:
template <TransPolicy Policy>
class TextProcessor
{
// ... 内部实现不变
};
Concepts 不只在出错的地方给出干净的错误,它还能在重载决议中发挥威力。我们可以针对不同强度的策略写不同实现,用 if constexpr 配合 concept 做编译期分派:
template <typename Policy>
void SmartProcess(const std::string& text)
{
if constexpr (requires { Policy::prefix(); })
{
std::cout << Policy::prefix();
}
// ......
}
以前这种写法需要 SFINAE 或者 void_t 的奇淫巧技,现在用 requires 表达式直接问编译器“这玩意能调用吗”,简单的跟做梦一样。
不过 Concepts 目前还不能完全取代接口类那种一组行为的集合,因为它更像一套检查清单,而不是一个可以持有引用的类型。我们依然没法定义一个 TransPolicy& 然后指向不同的具体类型,Concepts 只用于编译期多态和约束。所以它和虚函数是互补而非替代,别想着用 Concepts 把虚函数连根拔起。
2. Concepts 约束下的策略适配
有了 Concepts 这个标准接口,我们就能方便地适配那些形状不太对的遗留策略类了。
假设我们有一个上古的 LegacyLower,它有个 convert 方法做小写转换,但没有 transform:
struct LegacyLower
{
static char convert(char c)
{
return (c >= 'A' && c <= 'Z') ? c + 32 : c;
}
static const char* prefix() { return "["; }
static const char* suffix() { return "]"; }
};
// 显然它不满足 TransPolicy,因为缺少 transform。
我们不可能去改这个遗留代码,但可以写一个轻量级的适配器,让它符合 TransPolicy 概念:
struct LegacyLowerAdapter
{
static char transform(char c) { return LegacyLower::convert(c); }
static const char* prefix() { return LegacyLower::prefix(); }
static const char* suffix() { return LegacyLower::suffix(); }
};
这个适配器就是个壳,把调用转发过去。由于所有方法都是静态的、编译期可知的,编译器在实例化时会把调用链彻底展开。这比运行时的 Adapter 模式高效得多,因为我们没有引入任何虚表或间接调用。
如果我们需要适配的类型是一堆而非一个,可以用模板化的适配器:
template <typename LegacyPolicy>
struct LegacyToTrans : public LegacyPolicy
{
static char transform(char c) { return LegacyPolicy::convert(c); }
// prefix/suffix 直接继承,但要求 LegacyPolicy 已经提供了它们
};// 使用
TextProcessor<LegacyToTrans<LegacyLower>> proc;
这里用继承来复用 prefix/suffix,同时添加 transform 以满足 concept。但如果 LegacyPolicy 的 prefix 是静态成员函数,继承可以正常访问;如果是普通成员,可能需要调整。
Concepts 加上这种适配能力,让编译期策略的集成成本急剧下降。我们可以为不同的策略家族定义核心概念,然后用薄薄的适配层让新老代码和谐共处。
当然,我们也可以用概念结合部分特化来自动选择适配器,比如:
template <typename Policy>
struct PolicyAdapter
{
// 默认不做适配
using type = Policy;
};template <typename Policy>
requires (!TransPolicy<Policy> && requires { { Policy::convert('a') } -> std::same_as<char>; })
struct PolicyAdapter<Policy>
{
// 如果 Policy 有 convert 但没有 transform,则包装一下
struct type : Policy
{
static char transform(char c) { return Policy::convert(c); }
};
};template <typename Policy>
using PolicyAdapter_t = typename PolicyAdapter<Policy>::type;
说明:
- 若 Policy 已满足 TransPolicy(有 transform/prefix/suffix),则直接使用原类型。
- 若 Policy 没有 transform 但有 static char convert(char),则自动生成一个继承类并添加 transform;同时继承原类型的 prefix / suffix。
然后把 TextProcessor 写成接受任何类型,内部用 PolicyAdapter_t<Policy> 处理:
template <typename Policy>
class TextProcessor
{
public:
void process(std::string& text) const
{
using AdaptedPolicy = PolicyAdapter_t<Policy>; // 自动适配
std::string result;
result += AdaptedPolicy::prefix();
for (char c : text)
{
result += AdaptedPolicy::transform(c);
}
result += AdaptedPolicy::suffix();
text.swap(result);
}
};
这套路叫编译期适配器自动选择,是 Concepts 与模板元编程的合体技,威力巨大。不过我们也看到了,它会大大提高阅读门槛。
构建一个编译期可定制的日志记录器
我们手搓一个编译期可定制的日志记录器,让这个框架在零虚函数的前提下达到同样的灵活性,而且低级别日志能被直接编译死。
1. 设计思路
现在回到一个问题上:编译期策略能干什么?
我们把那些在程序运行前就已经确定的决策,可以交给编译器去优化。而当我们在程序运行时真的需要切换行为时,再去使用运行时策略模式。
比如这个日志记录器:
- 我们的日志是打到错误输出上还是文件?编译期就知道,因为只编译一份 release 版本,目标固定。
- 我们需要带时间戳的格式还是纯文本?编译期选好。
- 哪些日志级别在发布版本里需要保留?直接以模板参数形式写死。
根据这些需求,我们设计三个策略维度:
- LevelPolicy:决定当前日志记录器支持的最低级别,并提供级别标签字符串。
- SinkPolicy:负责最终输出,比如 StderrSink、FileSink、NullSink。
- FormatPolicy:控制每条日志的前缀格式。
把这些维度作为模板参数注入一个统一的 Logger 类模板,内部的所有调用在实例化时就能完全展开。
2. 代码实现步骤
首先,我们得用 Concepts 给这三个策略定义契约,免得哪个缺心眼传一个只有 operator()(const char*) 的 sink,然后报错如瀑布般直下三千尺。我们先定义日志级别:
enum class Level { Debug, Info, Warning, Error };
然后分别定义三个 concept。
LevelPolicy 契约:
template <typename T>
concept LevelPolicy = requires
{
// 当前策略处理的最低级别
{ T::minimum_level } -> std::same_as<const Level&>;
// 把级别转成字符串
{ T::to_string(Level{}) } -> std::same_as<std::string_view>;
};
SinkPolicy 契约:
template <typename T>
concept SinkPolicy = requires(T sink, std::string_view msg)
{
{ sink.write(msg) } -> std::same_as<void>;
{ sink.flush() } -> std::same_as<void>;
};
这里我们先强制所有 sink 必须提供 flush,实际上可以通过 if constexpr 探测可选接口,但为了示范,我决定一刀切。
FormatPolicy 契约:
template <typename T>
concept FormatPolicy = requires(std::string_view level_str, std::string_view msg)
{
{ T::format(level_str, msg) } -> std::same_as<std::string>;
};
好,现在我们开始搭 Logger 模板,它把三个策略组合起来:
template <LevelPolicy LP, SinkPolicy SP, FormatPolicy FP>
class Logger
{
public:
// 构造函数接受一个 sink 实例
explicit Logger(SP sink) : sink_(std::move(sink)) {} template <Level L>
void log(std::string_view msg)
{
if constexpr (L < LP::minimum_level) return;
std::string formatted = FP::format(LP::to_string(L), msg);
sink_.write(formatted);
sink_.flush();
} // 便捷接口
void debug(std::string_view msg) { log<Level::Debug>(msg); }
void info(std::string_view msg) { log<Level::Info>(msg); }
void warn(std::string_view msg) { log<Level::Warning>(msg); }
void error(std::string_view msg) { log<Level::Error>(msg); }private:
SP sink_;
};
在 if constexpr (L < LP::minimum_level) 这一行,它是编译期日志消除的关键。如果我们的 LevelPolicy 的 minimum_level 是 Level::Warning,那么所有 debug() 和 info() 调用在编译后根本不存在。
3. 代码演示
先定义几个简单的策略实现。
控制台输出 sink:
struct ConsoleSink
{
void write(std::string_view msg)
{
fwrite(msg.data(), 1, msg.size(), stderr);
fwrite("n", 1, 1, stderr);
}
void flush() { fflush(stderr); }
};
带时间戳的格式化策略:
struct TsFormatter
{
static std::string format(std::string_view level_str, std::string_view msg)
{
auto now = std::chrono::system_clock::now();
auto tt = std::chrono::system_clock::to_time_t(now);
std::string ts = std::ctime(&tt);
ts.pop_back();
return ts + " [" + std::string(level_str) + "] " + std::string(msg);
}
};
发布用的 sink:
struct NullSink
{
void write(std::string_view) {}
void flush() {}
};
默认的 LevelPolicy:
struct DefLvlPolicy
{
static constexpr Level minimum_level = Level::Debug;
static std::string_view to_string(Level l)
{
using namespace std::string_view_literals;
switch (l)
{
case Level::Debug: return "DEBUG"sv;
case Level::Info: return "INFO"sv;
case Level::Warning: return "WARN"sv;
case Level::Error: return "ERROR"sv;
}
return "???"sv;
}
};
生产环境只用 Warning 以上的策略:
struct RelLvlPolicy : DefLvlPolicy
{
static constexpr Level minimum_level = Level::Warning;
};
好了,现在把它们组装起来:
int main()
{
// 开发环境日志:全级别输出到控制台
using DevLogger = Logger<DefLvlPolicy, ConsoleSink, TsFormatter>;
DevLogger devLogger((ConsoleSink{}));
devLogger.debug("This is a debug message");
devLogger.warn("This is a warning"); // 生产环境日志:只记录 Warning 以上,输出到 NullSink
using ProdLogger = Logger<RelLvlPolicy, NullSink, TsFormatter>;
ProdLogger prodLogger((NullSink{}));
prodLogger.debug("You will never see this"); // 这句不会显示
prodLogger.error("Critical failure!"); // 这句会被编译,但因为 NullSink 什么都不写,照样空
}
完事,一个完全编译期定制的日志记录器就这么搭起来了。我们可以在不牺牲任何灵活性的情况下,把日志对性能的影响压到最低。当然,代价是每换一种组合就会生成一份新的 Logger 实例代码,这是代码膨胀那个老话题了。
结尾
我不主张在所有场合都一棍子打死虚函数。在有真正运行时切换需求、或者代码热路径不那么烫手的时候,经典策略模式依然是最易理解、易维护的选择。编译期策略模式不是来抢地盘的,它只是让我们在追求极致性能时,多了一种选择摆了。
相关文章
- OSPod.Forum 论坛系统 v2.2.0 集成版本 06-05
- jsp灭天远程管理 v1.1 06-05
- NocoBase开源无代码开发平台 v1.8.0版 06-05
- ionic HTML5 移动应用框架 v8.6.2 06-05
- FastAPI高性能Web框架 版本v0.115.14 06-05
- Kubernetes生产级别的容器编排系统 v1.33.2 06-05