一聚教程网:一个值得你收藏的教程网站

最新下载

热门教程

编译期策略模式:当模板化作策略容器

时间: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 charprefix() return ">> "; }
    // 返回后缀
    static const charsuffix() 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 charprefix() return "["; }
    static const charsuffix() return "]"; }
};
// 显然它不满足 TransPolicy,因为缺少 transform。

我们不可能去改这个遗留代码,但可以写一个轻量级的适配器,让它符合 TransPolicy 概念:

struct LegacyLowerAdapter
{
    static char transform(char c) return LegacyLower::convert(c); }
    static const charprefix() return LegacyLower::prefix(); }
    static const charsuffix() 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"11, 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 实例代码,这是代码膨胀那个老话题了。

结尾

我不主张在所有场合都一棍子打死虚函数。在有真正运行时切换需求、或者代码热路径不那么烫手的时候,经典策略模式依然是最易理解、易维护的选择。编译期策略模式不是来抢地盘的,它只是让我们在追求极致性能时,多了一种选择摆了。

热门栏目