47、现代C++设计模式:基于策略类的设计,编译期多态

说到设计模式,很多人第一反应就是GoF那23种经典模式。但说实话,那些模式大多是为Java这类运行时多态语言准备的。在C++里,我们有更好的选择——基于策略类的设计,也就是所谓的Policy-Based Design。它利用模板和编译期多态,把代码的灵活性和性能都推到了一个新的高度。

我个人习惯把这种设计叫做「零开销的抽象」。为什么?因为它在编译期就完成了所有决策,运行时没有任何虚函数调用的开销。你想想看,这不正是C++一直追求的吗?

什么是策略类设计

策略类设计的核心思想很简单:把类的行为拆分成多个独立的策略,每个策略作为一个模板参数传入。这样,你可以在不修改类本身的情况下,自由组合不同的行为。

我在项目中遇到过这样一个场景:需要实现一个内存分配器,但不同的场景对分配策略的要求完全不同。有的需要线程安全,有的需要快速分配,有的需要内存对齐。如果用传统的继承方式,要么搞出一堆子类,要么用虚函数牺牲性能。后来我用策略类设计,问题迎刃而解。

核心思想:将行为抽象为策略,通过模板参数组合,实现编译期多态。

一个简单的例子

先看一个最基础的例子。假设我们要实现一个智能指针,但它的线程安全策略需要可配置:

// 线程安全策略
struct SingleThreadPolicy {
    void lock() {}
    void unlock() {}
};

struct MultiThreadPolicy {
    void lock() { mutex_.lock(); }
    void unlock() { mutex_.unlock(); }
private:
    std::mutex mutex_;
};

// 智能指针,策略作为模板参数
template<typename T, typename ThreadPolicy>
class SmartPtr {
public:
    explicit SmartPtr(T* ptr) : ptr_(ptr) {}
    
    T* operator->() {
        policy_.lock();
        // 访问逻辑...
        policy_.unlock();
        return ptr_;
    }
    
private:
    T* ptr_;
    ThreadPolicy policy_;
};

// 使用
SmartPtr<int, SingleThreadPolicy> single_ptr(new int(42));
SmartPtr<int, MultiThreadPolicy> multi_ptr(new int(42));

看到了吗?同样的SmartPtr,通过不同的策略参数,得到了完全不同的行为。而且这一切都在编译期完成,没有运行时开销。

策略类的组合与嵌套

实际项目中,一个类往往需要多个策略。比如一个容器类,可能需要分配策略、线程安全策略、迭代策略等。这时候,策略类的组合能力就体现出来了。

我曾经参与过一个高性能日志库的开发。日志库需要支持多种输出目标(文件、网络、控制台)、多种格式化方式(JSON、纯文本、二进制)、多种过滤级别。如果用传统方式,光排列组合就能搞出几十个类。但用策略类设计,我们只需要定义几个策略族:

// 输出策略
struct FileOutput {
    void write(const std::string& msg) { /* 写入文件 */ }
};

struct NetworkOutput {
    void write(const std::string& msg) { /* 发送网络 */ }
};

// 格式化策略
struct JsonFormatter {
    std::string format(const LogEntry& entry) { /* JSON格式化 */ }
};

struct PlainTextFormatter {
    std::string format(const LogEntry& entry) { /* 纯文本格式化 */ }
};

// 日志器,组合多个策略
template<typename OutputPolicy, typename FormatterPolicy>
class Logger : private OutputPolicy, private FormatterPolicy {
public:
    void log(const LogEntry& entry) {
        auto msg = FormatterPolicy::format(entry);
        OutputPolicy::write(msg);
    }
};

// 使用
using FileJsonLogger = Logger<FileOutput, JsonFormatter>;
using NetworkPlainLogger = Logger<NetworkOutput, PlainTextFormatter>;

小技巧:使用私有继承而不是成员变量来持有策略对象,可以启用空基类优化(EBO),避免额外的内存开销。

编译期多态 vs 运行时多态

这里我想重点聊聊编译期多态和运行时多态的区别。说白了,运行时多态靠虚函数表,编译期多态靠模板实例化。两者各有适用场景。

特性 编译期多态(策略类) 运行时多态(虚函数)
性能 零开销,内联优化 虚函数调用开销
灵活性 编译期固定,运行时不可变 运行时动态切换
代码体积 模板实例化导致代码膨胀 单一实现,体积小
类型安全 强类型,编译期检查 运行时类型检查
适用场景 性能敏感、策略固定 需要动态切换、插件系统

嗯,这里要注意:不要盲目追求编译期多态。如果你的策略需要在运行时动态改变,那还是老老实实用虚函数吧。我曾经在一个项目中强行把所有虚函数都改成模板,结果代码膨胀得厉害,编译时间也暴涨。后来我学乖了,该用虚函数的地方还是用虚函数。

策略类的默认参数与特化

为了让策略类更易用,我们可以给模板参数提供默认值。这样用户只需要在需要定制行为时才指定策略:

// 默认使用单线程策略
template<typename T, typename ThreadPolicy = SingleThreadPolicy>
class SmartPtr {
    // ...
};

// 用户可以直接使用默认版本
SmartPtr<int> default_ptr(new int(42));

// 需要多线程时再指定
SmartPtr<int, MultiThreadPolicy> thread_safe_ptr(new int(42));

还可以对特定策略进行特化,提供更优化的实现:

// 对单线程策略进行特化,去掉所有锁操作
template<typename T>
class SmartPtr<T, SingleThreadPolicy> {
public:
    explicit SmartPtr(T* ptr) : ptr_(ptr) {}
    
    T* operator->() {
        return ptr_;  // 没有锁操作
    }
    
private:
    T* ptr_;
};

策略类的设计原则

经过多年的实践,我总结了几条策略类设计的原则:

  • 策略接口要最小化:每个策略只暴露必要的接口,不要贪多。接口越多,组合的复杂度越高。
  • 策略之间要正交:不同的策略应该互不依赖,这样才能自由组合。如果策略A依赖策略B的某个行为,那它们就不应该作为独立的策略。
  • 提供合理的默认值:让80%的用户不需要关心策略选择,直接使用默认配置就能工作。
  • 文档要清晰:每个策略的语义、前置条件、后置条件都要写清楚。我在项目中见过太多因为策略文档不全导致的bug。

避坑指南:我曾经在一个大型项目中,把策略类的接口设计得过于灵活,结果团队成员在使用时经常搞混策略的组合方式。后来我们强制要求每个策略类必须提供静态断言来检查组合的合法性,才解决了这个问题。

策略类与C++17/20的新特性

C++17和C++20给策略类设计带来了新的可能性。比如用if constexpr来简化策略的实现:

template<typename ThreadPolicy>
class Container {
public:
    void insert(int value) {
        if constexpr (std::is_same_v<ThreadPolicy, MultiThreadPolicy>) {
            std::lock_guard<std::mutex> lock(mutex_);
            data_.push_back(value);
        } else {
            data_.push_back(value);
        }
    }
    
private:
    std::vector<int> data_;
    // 只有多线程策略才需要mutex
    [[no_unique_address]] ThreadPolicy policy_;
};

你看,[[no_unique_address]]属性配合空基类优化,可以让单线程策略不占用任何额外空间。而if constexpr则让同一个函数体可以同时适配多种策略,代码更简洁。

SVG:策略类设计模式的核心逻辑

策略类设计模式核心逻辑 主类(Host Class) template<typename Policy> 策略A族 线程安全策略 策略B族 内存分配策略 策略C族 日志输出策略 SingleThread MultiThread MallocAlloc PoolAlloc FileOutput ... 编译期组合:主类 + 策略A实现 + 策略B实现 + 策略C实现 → 生成具体类型 零运行时开销,所有决策在编译期完成

实际项目中的应用

我记得在做一个网络库的时候,需要支持多种协议编解码方式。如果用继承,每个协议都要写一个子类,然后通过虚函数调用。但用策略类设计,我们把编解码逻辑作为策略,网络传输作为主类,轻松实现了各种协议的支持。

更妙的是,因为所有策略都在编译期确定,编译器可以内联掉所有的函数调用。最终生成的代码,性能几乎和手写的专用代码一样好。这就是策略类设计的魅力所在。

当然,策略类设计也不是银弹。它最大的问题是代码膨胀和编译时间。每个策略组合都会生成一份独立的代码,如果策略太多,二进制体积会急剧增大。另外,模板的错误信息往往非常难读,调试起来也比较麻烦。

但话说回来,在性能敏感的系统级开发中,策略类设计依然是不可或缺的工具。它让我们既能享受抽象带来的灵活性,又不用付出运行时性能的代价。这种「既要又要」的能力,正是C++最迷人的地方。

总结:策略类设计通过模板参数将行为抽象为策略,在编译期完成多态决策。它适合性能敏感、策略固定的场景,但要注意代码膨胀和编译时间的问题。合理使用默认参数和特化,可以让策略类更易用、更高效。