策略类与策略模式:模板策略类,运行时策略与编译期策略的权衡

策略模式,说白了就是把「做什么」和「怎么做」拆开。我在项目中见过太多人把算法逻辑硬编码到业务代码里,结果每次需求一变,就得改核心逻辑,改完还要重新测试整个模块。嗯,这其实是可以避免的。

C++ 里实现策略模式,主要有两条路:编译期策略(用模板)和运行时策略(用虚函数或函数指针)。两条路各有各的适用场景,选错了,轻则代码冗余,重则性能崩盘。今天我们就来掰扯清楚。

什么是策略类?

策略类,就是一个封装了算法或行为的类。它不关心谁在用,只关心自己怎么干。你想想看,排序算法、压缩算法、日志输出方式、数据校验规则……这些都可以做成策略类。

举个例子,一个图像处理库,可能需要支持 JPEG、PNG、WebP 等多种编码格式。每种格式的编码逻辑完全不同,但对外暴露的接口可以是一样的。这就是策略类的典型场景。

核心思想:将变化的部分封装起来,让不变的部分稳定下来。

编译期策略:模板策略类

编译期策略,就是用模板参数来指定具体的行为。这种方式没有运行时开销,所有决策在编译时就确定了。

// 编译期策略:模板策略类
template <typename CompressionStrategy>
class ImageProcessor {
public:
    void process(const std::string& input, const std::string& output) {
        // 读取图像...
        std::vector<uint8_t> data = readImage(input);
        
        // 使用策略进行压缩
        CompressionStrategy strategy;
        auto compressed = strategy.compress(data);
        
        // 写入输出文件
        writeImage(output, compressed);
    }
};

// 具体策略
struct JPEGCompression {
    std::vector<uint8_t> compress(const std::vector<uint8_t>& data) {
        // JPEG 压缩逻辑
        return data;
    }
};

struct PNGCompression {
    std::vector<uint8_t> compress(const std::vector<uint8_t>& data) {
        // PNG 压缩逻辑
        return data;
    }
};

// 使用
ImageProcessor<JPEGCompression> jpegProcessor;
jpegProcessor.process("input.bmp", "output.jpg");

ImageProcessor<PNGCompression> pngProcessor;
pngProcessor.process("input.bmp", "output.png");

你看,这里 ImageProcessor 根本不知道具体用的是哪种压缩算法。它只要求策略类有一个 compress 方法。这就是所谓的「鸭子类型」——在模板的世界里,只要长得像鸭子、叫得像鸭子,那就是鸭子。

我的经验:编译期策略特别适合那些「在写代码时就知道要用什么策略」的场景。比如嵌入式系统里,硬件平台固定,压缩算法也就固定了。这时候用模板策略,零开销,编译器还能帮你做内联优化。

运行时策略:虚函数与多态

但现实往往没那么理想。很多时候,策略的选择取决于用户输入、配置文件或者运行时的某些条件。这时候就需要运行时策略了。

// 运行时策略:虚函数多态
class CompressionStrategy {
public:
    virtual ~CompressionStrategy() = default;
    virtual std::vector<uint8_t> compress(const std::vector<uint8_t>& data) = 0;
};

class JPEGCompression : public CompressionStrategy {
public:
    std::vector<uint8_t> compress(const std::vector<uint8_t>& data) override {
        // JPEG 压缩逻辑
        return data;
    }
};

class PNGCompression : public CompressionStrategy {
public:
    std::vector<uint8_t> compress(const std::vector<uint8_t>& data) override {
        // PNG 压缩逻辑
        return data;
    }
};

class ImageProcessor {
public:
    explicit ImageProcessor(std::unique_ptr<CompressionStrategy> strategy)
        : strategy_(std::move(strategy)) {}
    
    void process(const std::string& input, const std::string& output) {
        auto data = readImage(input);
        auto compressed = strategy_->compress(data);
        writeImage(output, compressed);
    }

private:
    std::unique_ptr<CompressionStrategy> strategy_;
};

// 使用
auto strategy = std::make_unique<JPEGCompression>();
ImageProcessor processor(std::move(strategy));
processor.process("input.bmp", "output.jpg");

运行时策略的灵活性是显而易见的。你可以在程序启动时读取配置文件,根据配置动态创建不同的策略对象。甚至可以在运行时切换策略——比如用户选择了「高质量模式」,你就换成无损压缩策略。

注意:虚函数调用是有开销的。每次调用 compress 都会走一次虚函数表查找。如果你的策略方法被频繁调用(比如每像素处理一次),这个开销可能会成为瓶颈。我曾经在一个实时视频处理项目里就踩过这个坑——用运行时策略处理每一帧,结果帧率直接掉了一半。

编译期 vs 运行时:如何权衡?

这个问题没有标准答案,但我可以给你一些实用的判断依据。

维度 编译期策略(模板) 运行时策略(虚函数)
性能 零开销,可内联 有虚函数调用开销
灵活性 编译时确定,无法动态切换 运行时动态选择,可切换
代码体积 每个策略生成一份代码(模板膨胀) 共享一份代码
接口约束 鸭子类型,隐式接口 继承体系,显式接口
调试难度 模板错误信息难读 虚函数调用链清晰
适用场景 策略固定、性能敏感 策略多变、需要动态配置

我个人习惯是:优先考虑编译期策略。为什么?因为大部分时候,策略的选择其实没那么「动态」。你想想看,一个项目里,JPEG 压缩和 PNG 压缩,通常是在编译时就定好的——产品经理不会让你在运行时让用户选「今天用 JPEG,明天用 PNG」。如果真有这种需求,那也多半是不同模块用不同策略,而不是同一个模块在运行时来回切换。

但有一种情况我会毫不犹豫地用运行时策略:插件系统。比如你的图像处理库需要支持第三方开发者编写自定义压缩算法。这时候你不可能在编译期知道所有策略,只能用虚函数接口让插件动态注册。

混合策略:两全其美?

有没有办法既享受编译期的性能,又保留运行时的灵活性?有,但需要一点技巧。

// 混合策略:模板 + 类型擦除
class AnyCompression {
public:
    template <typename T>
    AnyCompression(T strategy)
        : self_(std::make_shared<Model<T>>(std::move(strategy))) {}
    
    std::vector<uint8_t> compress(const std::vector<uint8_t>& data) {
        return self_->compress(data);
    }

private:
    struct Concept {
        virtual ~Concept() = default;
        virtual std::vector<uint8_t> compress(const std::vector<uint8_t>& data) = 0;
    };
    
    template <typename T>
    struct Model : Concept {
        explicit Model(T strategy) : strategy_(std::move(strategy)) {}
        std::vector<uint8_t> compress(const std::vector<uint8_t>& data) override {
            return strategy_.compress(data);
        }
        T strategy_;
    };
    
    std::shared_ptr<Concept> self_;
};

// 使用
AnyCompression strategy = JPEGCompression{};
// 或者
AnyCompression strategy = PNGCompression{};

这种模式叫「类型擦除」。它用虚函数隐藏了具体类型,但对外暴露的接口是值语义的。你可以在运行时赋值、拷贝、甚至放入容器。而内部的具体策略类,仍然可以是轻量级的、可内联的普通类。

我的建议:类型擦除是个好东西,但别滥用。它本质上还是虚函数调用,只是把虚函数表藏在了内部。如果你的策略类本身就很复杂(比如有几十个方法),类型擦除的维护成本会很高。我一般只在策略接口只有一两个方法时用这种技巧。

避坑指南

讲几个我踩过的坑,希望能帮你少走弯路。

  • 模板策略类不要有虚函数。 模板和虚函数混用,会让编译器很难做优化。而且模板的实例化会生成多份代码,每份代码里都有一张虚函数表,代码体积会爆炸。
  • 运行时策略的析构函数一定要是虚的。 这个老生常谈了,但我还是见过有人忘记加 virtual 析构函数,导致派生类的资源没释放。嗯,血的教训。
  • 不要为了「未来可能的变化」而提前使用运行时策略。 我曾经在一个项目里,把所有算法都设计成运行时策略,结果项目做完发现,所有地方用的都是同一个策略。白白增加了代码复杂度和性能开销。记住:YAGNI(You Ain't Gonna Need It)。
  • 编译期策略的模板参数不要太多。 如果一个类有七八个模板参数,每个参数又有三四种选择,那组合出来的代码量是天文数字。这时候就该考虑用运行时策略了。

总结

策略模式的核心,就是把变化隔离出去。C++ 给了你两种选择:编译期用模板,运行时用虚函数。选哪个,取决于你的场景——性能敏感、策略固定,选模板;需要动态切换、支持插件,选虚函数。

我个人更倾向于「先模板,后虚函数」。先用编译期策略把核心逻辑写稳,等真的需要运行时灵活性了,再重构也不迟。毕竟,C++ 的模板和虚函数是可以共存的——你完全可以在一个项目里,有的模块用模板策略,有的模块用运行时策略。

最后送你一句话:没有银弹,只有权衡。 理解每种策略的代价和收益,你就能做出更好的设计决策。


公众号:蓝海资料掘金营,微信deep3321