{"choices":[{"message":{"content":"","reasoning_content":"我们要求:改写文章标题,提升SEO表现。但用户没有给出原始标题!需要仔细看任务描述:
编译期策略模式使用模板替代虚函数,在编译期选定策略,避免运行时多态的间接跳转与内联阻碍,实现零成本抽象,从而显著提升高频路径性能。
经典策略模式在 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 strategy_;
public:
explicit TextProcessor(std::unique_ptr s) : strategy_(std::move(s)) {}
void setStrategy(std::unique_ptr s) { strategy_ = std::move(s); }
std::string process(const std::string& text) const
{
return strategy_->transform(text);
}
};// 使用示例
int main()
{
TextProcessor proc(std::make_unique()) ;
auto result1 = proc.process("Hello World"); // "HELLO WORLD"
proc.setStrategy(std::make_unique());
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 upperProc;
TextProcessor lowerProc;
std::string text = "Hello World";
upperProc.process(text); // 直接内联
lowerProc.process(text);
编译器在实例化 TextProcessor 时,看到循环中的 TransPolicy::transform(c),知道 TransPolicy 就是 UpperPolicy,transform 是静态成员函数,于是直接将三元运算符展开到循环中。
这种写法已经具备了编译期策略的雏形:策略就是类型,类型就是参数,决策在编译期完成。
我们可以给策略类添加静态成员,但如果涉及非静态成员,需要实例化一个策略对象作为成员。只要策略类型在编译期确定,编译器依然可以将方法调用内联。
使用 template 本身不难,但需要在约束 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(this)->writeImpl(data, len);
}
};class FileBuffer : public Buffer
{
public:
void writeImpl(const char* data, size_t len)
{
// 写文件
}
};class NetworkBuffer : public Buffer
{
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
#include 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
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> proc;
这里使用继承来复用 prefix/suffix,同时添加 transform 以满足 concept。如果 LegacyPolicy 的 prefix 是静态成员函数,继承可以正常访问;如果是普通成员,可能需要调整。
Concepts 加上这种适配能力,大大降低了编译期策略的集成成本。我们可以为不同的策略家族定义核心概念,然后用薄薄的适配层让新老代码和谐共处。
当然,我们也可以结合概念与部分特化来自动选择适配器:
template <typename Policy>
struct PolicyAdapter
{
// 默认不做适配
using type = Policy;
};template <typename Policy>
requires (!TransPolicy && requires { { Policy::convert('a') } -> std::same_as<char>; })
struct PolicyAdapter
{
// 如果 Policy 有 convert 但没有 transform,则包装一下
struct type : Policy
{
static char transform(char c) { return Policy::convert(c); }
};
};template <typename Policy>
using PolicyAdapter_t = typename PolicyAdapter::type;
说明:
- 若 Policy 已满足 TransPolicy(有 transform/prefix/suffix),则直接使用原类型。
- 若 Policy 没有 transform 但有 static char convert(char),则自动生成一个继承类并添加 transform;同时继承原类型的 prefix / suffix。
然后将 TextProcessor 写成接受任何类型,内部使用 PolicyAdapter_t 处理:
template <typename Policy>
class TextProcessor
{
public:
void process(std::string& text) const
{
using AdaptedPolicy = PolicyAdapter_t; // 自动适配
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 给这三个策略定义契约,避免某个不完整的 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<: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<:string>;
};
现在开始搭建 Logger 模板,它将三个策略组合起来:
template
class Logger
{
public:
// 构造函数接受一个 sink 实例
explicit Logger(SP sink) : sink_(std::move(sink)) {} template
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<:debug>(msg); }
void info(std::string_view msg) { log<:info>(msg); }
void warn(std::string_view msg) { log<:warning>(msg); }
void error(std::string_view msg) { log<: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;
DevLogger devLogger((ConsoleSink{}));
devLogger.debug("This is a debug message");
devLogger.warn("This is a warning"); // 生产环境日志:只记录 Warning 以上,输出到 NullSink
using ProdLogger = Logger;
ProdLogger prodLogger((NullSink{}));
prodLogger.debug("You will never see this"); // 这句不会显示
prodLogger.error("Critical failure!"); // 这句会被编译,但因为 NullSink 什么都不写,照样空
}
至此,一个完全编译期定制的日志记录器就搭建起来了。我们可以在不牺牲任何灵活性的情况下,将日志对性能的影响压到最低。当然,代价是每换一种组合就会生成一份新的 Logger 实例代码,这是代码膨胀的老话题。
结尾
我并不主张在任何场合都一棍子打死虚函数。在有真正运行时切换需求、或者代码热路径不那么烫手的情况下,经典策略模式依然是最易理解、最易维护的选择。编译期策略模式并非来抢地盘,它只是在追求极致性能时,为我们提供了一种额外的选择。
