模板与设计模式:工厂模式、单例模式的模板化实现

设计模式这东西,说白了就是前人踩坑踩出来的经验总结。我早年刚接触C++那会儿,觉得设计模式就是金科玉律,照着抄准没错。后来写多了才发现——很多模式用模板一包装,代码量能砍掉一半,可维护性反而更高。

今天咱们就聊聊两个最常用的模式:工厂模式和单例模式。看看怎么用模板把它们写得既优雅又实用。

为什么要把设计模式模板化?

你想想看,传统设计模式最大的痛点是什么?重复劳动。每加一个新类型,就得写一堆相似的代码。我曾在项目中维护过一个产品工厂,里面十几个if-else分支,每次新增产品都得改工厂类——改完还要提心吊胆,生怕改漏了。

模板化之后呢?类型作为参数传进去,编译器帮你生成代码。新增产品?写个新类,模板自动适配。这才是C++该有的样子。

核心思想: 将类型信息从运行时决策转移到编译期决策,利用模板消除重复的模式实现代码。

模板化单例模式

单例模式,大家都不陌生。全局只有一个实例,谁用谁拿。但传统写法有个问题——每个单例类都得写一遍GetInstance(),还得处理线程安全。

我个人的习惯是,用CRTP(奇异递归模板模式)做一个通用的单例基类:

template<typename T>
class Singleton {
public:
    static T& GetInstance() {
        static T instance;
        return instance;
    }

    Singleton(const Singleton&) = delete;
    Singleton& operator=(const Singleton&) = delete;

protected:
    Singleton() = default;
    ~Singleton() = default;
};

// 使用方式
class Logger : public Singleton<Logger> {
    friend class Singleton<Logger>;  // 基类需要访问构造函数
public:
    void Log(const std::string& msg) { /* ... */ }
private:
    Logger() = default;
};

这里有个坑,我必须要提醒你。很多人忘了加 friend class Singleton<Logger>,结果编译报错,然后一脸懵。为什么?因为 GetInstance() 里要构造 T 对象,但构造函数是私有的——基类不是友元的话,编译器不让调。

注意: 静态局部变量的初始化,在C++11之后是线程安全的。所以上面的实现不需要额外加锁。但如果你用的是老编译器,嗯……还是老老实实加mutex吧。

这种写法,我用了好几年。一个项目里十几个单例类,每个就三五行代码。清爽得很。

模板化工厂模式

工厂模式比单例复杂一些。传统工厂要维护一个“类型标识到创建函数”的映射。每新增一个产品,就得往映射表里注册。

用模板怎么搞?我见过两种主流做法,咱们都看看。

方案一:基于继承的模板工厂

template<typename Base, typename Key, typename... Args>
class Factory {
public:
    using CreatorFunc = std::function<std::unique_ptr<Base>(Args...)>;

    template<typename Derived>
    static void Register(const Key& key) {
        creators_[key] = [](Args... args) -> std::unique_ptr<Base> {
            return std::make_unique<Derived>(std::forward<Args>(args)...);
        };
    }

    static std::unique_ptr<Base> Create(const Key& key, Args... args) {
        auto it = creators_.find(key);
        if (it != creators_.end()) {
            return it->second(std::forward<Args>(args)...);
        }
        return nullptr;
    }

private:
    static std::map<Key, CreatorFunc> creators_;
};

这个方案的好处是,注册和创建完全解耦。你可以在不同的编译单元里注册产品,甚至可以在运行时动态注册。我在一个插件系统里用过这个模式——每个插件DLL加载时自动注册自己,主程序完全不知道有哪些插件存在。

方案二:基于类型列表的编译期工厂

如果你不需要运行时动态注册,那编译期工厂更香。用类型列表和变参模板,让编译器帮你生成所有分支:

template<typename... Types>
class TypeList {};

template<typename Base, typename TypeList>
class CompileTimeFactory;

template<typename Base, typename... Types>
class CompileTimeFactory<Base, TypeList<Types...>> {
public:
    template<typename... Args>
    static std::unique_ptr<Base> Create(size_t index, Args... args) {
        // 这里用if constexpr + 递归展开,或者用数组索引
        static constexpr std::array<std::function<std::unique_ptr<Base>(Args...)>, sizeof...(Types)>
        creators = {
            [](Args... args) { return std::make_unique<Types>(std::forward<Args>(args)...); }...
        };
        if (index < creators.size()) {
            return creators[index](std::forward<Args>(args)...);
        }
        return nullptr;
    }
};

这种写法,说白了就是把运行时多态变成了编译期多态。没有虚函数开销,没有动态内存分配(除了产品对象本身)。性能敏感的场景下,我强烈推荐。

我的经验: 编译期工厂适合产品类型固定、不频繁变动的场景。如果产品类型经常增减,或者需要热插拔,还是用运行时工厂更灵活。没有银弹,只有权衡。

两种模式的组合使用

单例和工厂,其实经常一起出现。比如一个全局的工厂管理器,用单例包装一下:

using WidgetFactory = Singleton<Factory<Widget, std::string>>;

// 注册产品
WidgetFactory::GetInstance().Register<Button>("button");
WidgetFactory::GetInstance().Register<Slider>("slider");

// 创建产品
auto btn = WidgetFactory::GetInstance().Create("button");

你看,两行代码就搞定了全局工厂。不用到处传指针,也不用担心多实例问题。

知识体系总览

下面这张图,把咱们聊的内容串起来了:

模板化设计模式知识体系 模板化单例模式 CRTP基类 + 静态局部变量 线程安全(C++11起) 友元声明:friend class Singleton<T> 模板化工厂模式 运行时工厂:map + std::function 编译期工厂:类型列表 + 变参模板 动态注册 vs 静态展开 组合:单例包装工厂 → 全局工厂管理器

避坑指南

最后,分享几个我踩过的坑:

  • 单例的析构顺序:静态局部变量的析构顺序是构造的逆序。如果两个单例互相依赖,程序退出时可能崩溃。我建议单例之间不要有循环依赖。
  • 工厂的线程安全:上面的运行时工厂,RegisterCreate 没有加锁。如果多线程同时注册和创建,会出问题。加个读写锁就能解决。
  • 编译期工厂的索引:用 size_t index 来选类型,索引必须和类型列表顺序一致。我习惯用一个枚举类来定义索引,避免硬编码数字。
  • 模板代码膨胀:每个不同的模板参数都会生成一份代码。如果工厂的模板参数组合太多,二进制体积会暴涨。这时候可以考虑用 extern template 显式实例化。

嗯,差不多就这些。模板和设计模式结合,写出来的代码既灵活又高效。但记住一点——不要为了用模板而用模板。如果你的项目里就两三个产品类型,手写工厂可能更简单。工具是拿来用的,不是拿来炫的。


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