第28讲:C++中的模板与设计原则——模板元编程与OCP,策略类模板,类型擦除

模板在C++里是个很有意思的东西。很多人觉得它只是泛型编程的工具,用来写个容器、算法什么的。但在我看来,模板是C++实现设计原则的“瑞士军刀”。尤其是开闭原则(OCP),说白了就是“对扩展开放,对修改关闭”。用模板来实现OCP,比用虚函数要轻量得多,而且没有运行时开销。

这一讲,我打算聊聊三个方向:模板元编程如何支撑OCP、策略类模板怎么用、以及类型擦除这个“反模板”的技巧。嗯,这三个东西放在一起,其实能解决很多实际工程问题。

模板元编程与OCP:编译期的多态

OCP的核心思想是:你写了一个模块,别人想加新功能,不应该改你的代码,而是通过扩展来实现。传统的做法是用虚函数、接口继承。但虚函数有运行时开销,而且破坏了值语义。

模板元编程提供了一种编译期的多态。你想想看,模板参数本身就是一种“策略”或“类型”的注入。调用方通过特化模板,就能改变行为,而不需要修改模板本身的代码。

核心观点:模板元编程让OCP从“运行时约定”变成了“编译期契约”。

举个例子。假设我们要写一个排序函数,支持不同的比较策略。用虚函数的话,你得定义一个抽象基类,然后派生各种比较器。用模板的话,直接传一个函数对象或lambda就行:

template <typename Iter, typename Comp>
void sort(Iter begin, Iter end, Comp comp) {
    // 排序实现,使用 comp 进行比较
    // 不关心 comp 的具体类型
}

调用方可以传lambda、函数指针、或者仿函数。sort函数本身不需要修改。这就是OCP——对扩展开放(你可以传任意比较器),对修改关闭(sort的实现不用动)。

我在项目中遇到过一种情况:业务方要求支持多种排序规则,而且规则经常变。如果用虚函数,每次加规则都要改基类,或者新增派生类,还得注册。用模板的话,调用方自己写个lambda就搞定了。代码量少了一半,而且编译期就能检查类型错误。

策略类模板:比策略模式更轻量

策略模式是GoF的经典设计模式。传统实现是用抽象接口+具体策略类。但在C++里,模板可以把这个模式做得更优雅。

策略类模板,说白了就是把策略作为模板参数。这样策略的选择在编译期就确定了,没有虚函数调用,也没有动态内存分配。

template <typename CompressionStrategy>
class DataProcessor {
    CompressionStrategy strategy;
public:
    void process(const std::string& data) {
        auto compressed = strategy.compress(data);
        // 处理压缩后的数据
    }
};

// 具体策略
class ZipCompression {
public:
    std::string compress(const std::string& data) {
        // zip 压缩实现
        return data;
    }
};

class GzipCompression {
public:
    std::string compress(const std::string& data) {
        // gzip 压缩实现
        return data;
    }
};

// 使用
DataProcessor<ZipCompression> processor;
processor.process("hello");

这里有个细节要注意:策略类模板要求所有策略提供相同的接口(比如compress方法)。这其实是一种“鸭子类型”——编译期检查,而不是运行时检查。

我的习惯:如果策略的数量在编译期就已知,而且不会动态变化,我优先用模板而不是虚函数。性能更好,代码也更直观。

我曾经在一个嵌入式项目里用过这种手法。内存只有几百KB,虚函数表太奢侈了。用策略类模板,所有代码都在编译期展开,运行时零开销。嗯,那项目后来顺利通过了性能测试。

类型擦除:当模板不够用时

模板虽好,但有一个硬伤:模板参数必须在编译期确定。如果你需要在运行时动态选择类型或策略,模板就无能为力了。

这时候就需要类型擦除。类型擦除是一种技术,它把不同类型的对象包装成统一的接口,同时隐藏具体类型。std::function、std::any、std::variant 都是类型擦除的例子。

类型擦除的核心思想是:用一个非模板的包装类,内部通过虚函数或函数指针来调用具体类型的操作。这样外部看起来是统一的,内部可以持有任意类型。

class Drawable {
    struct Concept {
        virtual ~Concept() = default;
        virtual void draw() const = 0;
    };

    template <typename T>
    struct Model : Concept {
        T object;
        Model(T obj) : object(std::move(obj)) {}
        void draw() const override { object.draw(); }
    };

    std::unique_ptr<Concept> pimpl;

public:
    template <typename T>
    Drawable(T obj) : pimpl(std::make_unique<Model<T>>(std::move(obj))) {}

    void draw() const { pimpl->draw(); }
};

// 使用
class Circle { public: void draw() const { /* 画圆 */ } };
class Square { public: void draw() const { /* 画方 */ } };

std::vector<Drawable> shapes;
shapes.emplace_back(Circle{});
shapes.emplace_back(Square{});
for (const auto& s : shapes) s.draw();

这段代码里,Drawable 是一个类型擦除的包装。它内部用虚函数实现了多态,但对外暴露的是值语义的接口。调用方不需要知道具体类型,只需要知道它们都能 draw()。

注意:类型擦除有运行时开销(虚函数调用、动态内存分配)。如果性能敏感,或者类型在编译期就已知,优先用模板。类型擦除是为了“运行时灵活性”而付出的代价。

我个人习惯是:能用模板就用模板,实在需要运行时多态才用类型擦除。类型擦除写起来比较啰嗦,而且调试起来也麻烦。但有些场景,比如插件系统、配置驱动的逻辑,不用类型擦除还真不行。

三者之间的关系

模板元编程、策略类模板、类型擦除,这三者其实是互补的。模板元编程和策略类模板解决的是“编译期多态”的问题,类型擦除解决的是“运行时多态”的问题。它们都服务于OCP,只是适用的场景不同。

我画了一张图,帮你理清它们的关系:

模板与设计原则:OCP实现路径 模板元编程 编译期多态 类型特化 零运行时开销 策略类模板 编译期策略注入 鸭子类型检查 轻量级策略模式 类型擦除 运行时多态 统一接口包装 有运行时开销 共同目标:支持开闭原则(OCP) 对扩展开放:允许通过模板参数、策略注入、类型包装来扩展功能 对修改关闭:核心代码不需要修改,只需新增模板特化或策略实现 选择依据:编译期已知用模板,运行时需要动态性用类型擦除

从图中你可以看到,这三个技术都指向同一个目标:支持OCP。区别在于,模板元编程和策略类模板在编译期完成多态,类型擦除在运行时完成多态。选择哪个,取决于你的需求——性能优先还是灵活性优先。

避坑指南

我踩过不少模板的坑,这里分享几个经验:

  • 模板代码膨胀:每个模板实例化都会生成一份代码。如果模板参数很多,二进制体积会暴涨。我曾经在一个项目里,因为模板嵌套太深,编译出来的可执行文件大了三倍。后来用类型擦除替代了部分模板,才控制住。
  • 编译错误信息难读:模板编译错误是出了名的“天书”。我的建议是:尽量用static_assert和concept(C++20)来约束模板参数,这样错误信息会友好很多。
  • 类型擦除的性能陷阱:类型擦除内部用了虚函数,每次调用都有间接跳转。如果调用非常频繁(比如每秒百万次),性能会下降。我建议在热点路径上避免类型擦除,改用模板。
  • 策略类模板的接口一致性:所有策略必须提供相同的接口,否则编译报错。这其实是个优点——编译期就能发现问题。但如果你需要运行时切换策略,策略类模板就不适用了。

我的建议:先想清楚你的需求。如果策略在编译期就确定,用模板。如果策略需要在运行时动态切换,用类型擦除。如果两者都需要,可以考虑“模板+类型擦除”的组合——外部用类型擦除提供统一接口,内部用模板实现具体逻辑。

好了,这一讲的内容就到这里。模板与设计原则的结合,其实是一个很大的话题。我个人觉得,理解了模板元编程、策略类模板、类型擦除这三者的关系和适用场景,你就能在C++里灵活地实现OCP,写出既高效又易扩展的代码。

专注资料整理