有一年我做一个游戏服务端,不同技能、不同数值的敌人在场景里成百上千地出现,一开始每个敌人类型都配一个工厂类,后来工厂数量越来越多,维护成本变得非常难看。有人建议我试试原型模式,把每种敌人的模板实例注册好,要生成时直接克隆。这个思路本身没问题,但真到了 C++ 里写起来,会立刻遇到一连串“Java 风格教程不会告诉你”的麻烦:裸指针怎么管、深拷贝浅拷贝怎么处理、clone 函数每个类都得重写一遍,脑壳疼。
这一篇我就把 C++ 中原型模式的几类变体摊开聊清楚:经典写法为什么别扭、CRTP 怎么把重复代码干掉、原型注册表为什么在配置驱动场景特别好用、Pimpl 如何让原型看起来像值语义,以及 C++17 之后 std::variant 对原型模式的冲击。适合刚学完设计模式想落地到 C++ 的人,也适合已经在用原型模式但总感觉哪里不对劲的人。看完你会发现,原型模式在 C++ 里根本不是“一个模式”,而是一族互相补充的套路。
1. 经典原型模式在 C++ 里的水土不服
1.1 先搞清楚:clone 和拷贝构造的关系
要理解原型模式在 C++ 的处境,必须先承认一个事实:C++ 的类天生自带拷贝能力,只要没有显式禁用,拷贝构造和赋值运算符都会自动生成。那为什么还需要 clone?因为拷贝构造不是虚函数。
这里的关键是“切片”问题。你有一个 Shape* 指针,实际指向 Circle,但如果你写出 Shape s2 = *s1;,编译器依据静态类型把 *s1 拷给了 Shape,派生类那部分成员全被切掉了,多态信息完全丢失。原型模式的核心动机正是填补这个空白:提供一个虚函数 clone,当通过基类指针调用时,能正确复制出真实类型的新对象。
经典写法长这样:
cpp复制class Shape {
public:
virtual ~Shape() = default;
virtual Shape* clone() const = 0;
};
class Circle : public Shape {
public:
Shape* clone() const override {
return new Circle(*this);
}
};
class Rectangle : public Shape {
public:
Shape* clone() const override {
return new Rectangle(*this);
}
};
逻辑没有问题:调用 p->clone() 时虚函数分派会走 Circle::clone 或 Rectangle::clone,new Circle(*this) 借助拷贝构造函数生成一个新对象,运行时多态保住了。这个模式放到 Java 或 C# 里顺理成章,但直接塞进现代 C++ 代码库,后面的事情会让人很不舒服。
1.2 隐藏在虚 clone 背后的三个坑
第一个坑是裸指针的归属权没人管。clone 返回 Shape*,调用方用完以后谁来 delete?忘了就是内存泄漏;delete 两次直接崩溃;虽然可以包进 std::unique_ptr,但 clone 函数签名本身没有任何所有权语义提示,团队里每个人对“这个指针该由谁释放”的理解都可能不一样。
第二个坑是每个派生类都要重复写 clone,哪怕实现内容一模一样,只是类型名不同。派生类一多,整片代码全是样板,一旦工具链或日志逻辑有变化,你就要在每个类的 clone 里改一遍,很容易漏。
第三个坑最阴险:new Circle(*this) 到底做的深拷贝还是浅拷贝,取决于 Circle 的成员怎么定义。如果成员里有原始指针,默认拷贝构造会把指针值原样复制,clone 出来的对象和原对象共享同一块堆内存,析构时 double free 几乎必然发生,而崩溃现场往往离原型代码十万八千里,排查起来极其痛苦。
所以经典原型本身没有错,错在把它原封不动搬进现代 C++,必须在所有权、样板代码、拷贝语义三个层面做修正。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. CRTP 中间层:把 clone 重写从每个类里踢出去
2.1 最简 CRTP 克隆基类
很多 C++ 开发者想到的第一个优化就是 CRTP,它有个特别长的全称叫奇异递归模板模式,核心思想非常简单:让基类模板知道派生类的具体类型,从而在基类里替派生类完成 clone 的编写。
cpp复制template <typename Derived>
class Cloneable {
public:
Derived* clone() const {
return new Derived(static_cast<const Derived&>(*this));
}
};
class Circle : public Cloneable<Circle> {
public:
int radius = 10;
};
class Rectangle : public Cloneable<Rectangle> {
public:
int width = 10;
int height = 20;
};
这里的 static_cast<const Derived&>(*this) 是灵魂。Cloneable<Circle> 在实例化时编译器已经知道 Derived 就是 Circle,所以在编译期就把 *this 转成 const Circle&,然后 new Derived(...) 调用的是 Circle 的拷贝构造。一行代码,覆盖了所有派生类,也不用担心有人忘记实现 clone。如果某个派生类没有写拷贝构造,编译器会提示,问题能前置暴露。
2.2 为了多态,CRTP 还得再配一层接口基类
但现在的版本有个明显的限制:每个类的 clone 都返回 Derived*,Circle 的 clone 返回 Circle*,Rectangle 的 clone 返回 Rectangle*,这两个类型之间没有继承关系,没法把它们放进同一个容器里统一管理。你不能写 std::vector<???> 同时存放 Circle 和 Rectangle。
破局的办法是再加一层非模板接口基类,CRTP 层从它继承,用户类再从 CRTP 层继承。
cpp复制class ICloneable {
public:
virtual ~ICloneable() = default;
virtual ICloneable* clone() const = 0;
};
template <typename Derived>
class Cloneable : public ICloneable {
public:
ICloneable* clone() const override {
return new Derived(static_cast<const Derived&>(*this));
}
};
class Circle : public Cloneable<Circle> {
public:
int radius = 10;
};
class Rectangle : public Cloneable<Rectangle> {
public:
int width = 10;
int height = 20;
};
现在的继承层次是 ICloneable -> Cloneable<Circle> -> Circle,以及 ICloneable -> Cloneable<Rectangle> -> Rectangle。通过 ICloneable* 就可以持有任何派生对象,调用 clone() 时虚函数分派先走到 Cloneable<Circle>::clone,再在内部经由 static_cast 转回 Circle,完成复制。整个过程只写了一次 clone,就把继承体系里所有派生类覆盖了。你可以在 std::vector<std::unique_ptr<ICloneable>> 里放不同形状,需要复制的对象随时克隆一份。这套组合在实际工程里出现频率相当高。
2.3 协变返回类型:clone 返回更具体的指针
C++ 支持协变返回类型,覆写函数的返回值可以是基类返回类型的派生类型。上面 Cloneable<Circle>::clone 如果直接写 ICloneable* clone() const override 也没问题,但稍微优化一下可以返回 Circle*:
cpp复制template <typename Derived>
class Cloneable : public ICloneable {
public:
Derived* clone() const override {
return new Derived(static_cast<const Derived&>(*this));
}
};
Derived* 和 ICloneable* 是协变类型,override 合法性没问题。返回 Derived* 的好处是:当你确切知道一个对象是 Circle 时,调用 clone 直接得到 Circle*,省掉一次向下转换。对使用者来说,类型信息被保留得更多,代码也更安全。
需要注意,协变返回类型要求编译器在实例化时能看到 Derived 的完整定义,所以这个模板通常写在类内,作为隐式 inline 函数,或者放到头文件里,避免跨编译单元使用时的可见性问题。另外这套方案多了一层模板实例化和虚函数调用,但对绝大多数项目来说,这点开销完全可以忽略,换来的是 clone 样板代码的大幅缩减。
3. 原型注册表:把“new 一个”变成“查表复制”
3.1 注册表的基本结构
原型模式经常被当成一个“对象复制工具”来理解,但它在工程里还有另一个更重要的变体:不是从手头这个对象复制,而是从一个注册表里按名字取出原型再复制。这就是原型注册表,也有人叫它注册表原型。
游戏项目里最常见的场景是读配置生成角色:配置表里有一行 enemy_id=100, type=orc,程序根据 type 字段在注册表里找到 Orc 原型,clone 一份出来,再给副本设置血量、坐标等运行时数值。这样一来,代码里就没有 if (type == "orc") 这种又臭又长的分支,新增一个敌人类型只需要注册一个新原型。
cpp复制class ShapeRegistry {
public:
static ShapeRegistry& instance() {
static ShapeRegistry registry;
return registry;
}
template <typename T>
void registerShape(const std::string& key) {
static_assert(std::is_base_of_v<ICloneable, T>,
"T must inherit ICloneable");
prototypes_[key] = std::make_unique<T>();
}
std::unique_ptr<ICloneable> create(const std::string& key) const {
auto it = prototypes_.find(key);
if (it == prototypes_.end()) {
return nullptr;
}
return std::unique_ptr<ICloneable>(it->second->clone());
}
private:
std::unordered_map<std::string, std::unique_ptr<ICloneable>> prototypes_;
};
注册时传入类型 T,std::is_base_of_v 在编译期检查 T 是否继承了 ICloneable,如果不满足直接编译失败,把一部分错误提前挡在编译期。创建时先用 unordered_map 查名字,再调用原型对象的 clone 生成新实例,最后通过 unique_ptr 把所有权交给调用方。
3.2 生命周期和线程安全的实操处理
注册表本质是一个单例,单例的生命周期管理是绕不开的话题。上面的写法用了 Meyers' Singleton,也就是函数内静态局部变量,C++11 起标准保证这种初始化是线程安全的,不用额外加锁。prototypes_ 成员在第一次调用 instance() 时构造,程序退出时由静态对象的析构自动清理,所有原型都会被 unique_ptr 逐个释放。
真正需要操心的是运行期线程竞争。如果 registerShape() 和 create() 在运行期间被频繁调用,unordered_map 并发读写就会产生数据竞争。实际项目里我强烈推荐一个约定:启动阶段把注册做完,运行阶段只做查表复制。注册过程放到 main 函数最早的位置或者专门的初始化函数里,运行阶段只有 create 被多个线程调用。这样 unordered_map 上全是 const 的 find 操作,多个线程同时读是安全的,只要没有线程在写它。
如果确实需要在运行期动态注册新原型,那就老老实实在 instance() 内部加 mutex,或者用读写锁。但我个人建议不到万不得已不要这么做。原型注册表最大的价值是“把类型选择收敛到一处”,运行期动态注册会让这个收敛失去意义,排查未知类型时你根本不知道这个 key 是什么时候、从哪里注册进来的,调试成本会直线上升。
3.3 原型注册表和对象池怎么配合
注册表原型还有一个很自然的扩展方向:对象池。游戏里的子弹、特效、小怪这类“高频创建、低频存活”的对象,如果每次都 new 一个、用完 delete,内存分配器和构造析构的开销会非常难看。原型注册表负责“选类型”,对象池负责“省分配”,两者搭配才是完整方案。
具体做法是:先把原型注册到表里,运行时 create() 通过 clone 产生一个副本,用完以后不销毁,放回池子等待下次复用。第一次创建时原型负责产出“种子对象”,之后池子里的复用对象只需要重置状态,不再触发深拷贝。这样“类型多样性”和“数量复用”两个问题被分开解决,各自都做得很干净。
我曾经把注册表原型和对象池配合用在技能特效系统里,创建特效的开销从原来的平均两百多纳秒降到几十纳秒,帧率卡顿的问题也就消失了。关键是别把原型模式和对象池看成二选一的竞争关系,它们在真实系统里是天然互补的两层。
4. Pimpl 与值语义变体:让用户看不见“原型”
4.1 为什么有人想要值语义的原型
现代 C++ 开发者普遍追求“值语义”:对象像 int 一样用,拷贝是深拷贝,销毁是自动的,std::vector、std::string 都是典范。值语义的心智负担低,不需要关心指针生命周期,也不用担心内存泄漏。
但多态和值语义天然冲突。一个派生类对象被塞进基类变量里会切片,多态信息全丢。设计模式教程通常建议用指针保存多态,代价就是手动管理生命周期,或者使用 unique_ptr 管理,可 unique_ptr 本身不可拷贝,复制语义得自己写。
Pimpl(Pointer to Implementation)变体的思路是:对外暴露一个“看起来像值”的类,内部保存指向 Impl 的 unique_ptr,外部类的拷贝构造和赋值运算符由我们自己定义,定义方法是通过 Impl 的 clone() 复制一份新的 Impl。这样对外它是值类型,可以放进 std::vector,按值传参也不怕切片,内部却保留了多态能力。
4.2 手写拷贝构造与赋值运算符的完整姿势
cpp复制class Widget {
public:
Widget() : impl_(std::make_unique<Impl>()) {}
Widget(const Widget& other)
: impl_(other.impl_ ? other.impl_->clone() : nullptr) {}
Widget& operator=(const Widget& other) {
if (this != &other) {
impl_ = other.impl_ ? other.impl_->clone() : nullptr;
}
return *this;
}
Widget(Widget&&) noexcept = default;
Widget& operator=(Widget&&) noexcept = default;
~Widget() = default;
private:
class Impl;
std::unique_ptr<Impl> impl_;
};
这里两个关键点:拷贝构造和拷贝赋值都从 other.impl_ 克隆一份新的 Impl 给自己,nullptr 检查专门处理 other 被移动过的空状态。移动构造和移动赋值直接 default,因为 unique_ptr 本身支持移动语义,把指针的归属权转走就行。
要注意的是,如果 Impl 内部有裸指针或非值语义成员,必须在 Impl 类里也自定义拷贝构造和赋值运算符,否则 clone 调用 new Impl(*this) 时执行的还是浅拷贝,Pimpl 层写得多漂亮都没用,坑会直接转移到内部类里。
4.3 这种变体的适用边界
Pimpl + Clone 变体最大的价值是改变了代码的使用方式。过去拿着 Shape* 用,现在可以直接拿着 Widget 的值用,函数参数可以按值传,容器可以直接存 std::vector<Widget>,外部使用者完全不知道内部有 Impl 的存在。这种封装在插件系统的配置类、渲染引擎的资源句柄、需要存进容器的多态对象场景里特别合适。
缺点是实现复杂度比 CRTP 高不少,外部类和内部类的双重设计会让基础薄弱的读者看不懂。如果团队成员对现代 C++ 的移动语义和 unique_ptr 不熟悉,不建议一上来就用这套,可以先从上一节讲到的 “unique_ptr + clone” 版本过渡,大家对值语义有感觉了再改造也不迟。原型模式变体的选择从来不是越复杂越好,而是要匹配团队的熟悉度。
5. std::variant 与编译期原型的取舍
5.1 当“变体”遇到 std::variant
C++17 带来了 std::variant,这个话题一下子变得更微妙了。你可以完全放弃继承和 clone,把可能的类型直接放进 variant 里:
cpp复制using WidgetVariant = std::variant<Circle, Rectangle, Triangle>;
WidgetVariant w = Circle{10};
auto cloned_w = w; // 自动深拷贝
std::variant 里存了什么类型,编译器在编译期就知道,复制操作直接调用目标类型的拷贝构造,没有切片风险,不需要虚函数,更不需要 clone。如果类型的集合是固定且编译期已知的,std::variant 完全可以在很大程度上替代原型模式,而且更快、更安全。
配合 std::visit 能安全地处理 variant 中每一种类型:
cpp复制std::visit([](const auto& obj) {
std::cout << obj.area() << std::endl;
}, cloned_w);
这里的 lambda 是泛型的,编译器会为每种类别生成一个特化版本,在性能上等价于手写分支。后续新增一种类型时,编译器会检查所有访问逻辑是否需要补充,类型安全由编译器兜底,这种体验是虚函数体系给不了的。
5.2 无继承原型的优点与限制
std::variant 的优点很突出:值语义自然、性能高、类型安全,没有虚函数表开销,也不需要 clone 虚函数。但限制同样明显。
类型集合一旦需要增加,所有使用 std::visit 的地方都可能要重新处理,如果你用的是泛型 lambda 还好,非泛型 lambda 就必须显式扩展新分支。最根本的限制是运行时动态添加类型完全不可能,这与虚函数的世界有本质区别。如果有插件系统,第三方要在运行时注册一个新类型,std::variant 一点办法都没有。
内存布局方面也有个坑:std::variant 会占用所有成员类型中最大的那个的大小,还要额外加上一个 tag 来记录当前激活的类型。如果成员类型之间大小差距很大,比如一个 int 和一个巨大的 Texture,内存浪费就很明显。所以原型模式在 C++ 里并不会因为 std::variant 出现而消亡,而是退回到它真正擅长的领域:类型集合在运行时才完全确定、需要配置驱动类型选择、或者第三方要在运行时扩展类型。许多以前硬用原型模式的地方,换成 std::variant 之后反而整体代码量更少、更不容易出错。
6. 我在真实项目里对原型变体的选择与踩坑记录
6.1 一次浅拷贝引发的双重释放排查
有一次我在项目里用 CRTP 版本 clone 敌人对象,敌人的一个成员是自定义的 SkillSlot 类,内部有一个原始指针指向技能数据。我当时图省事没有给 SkillSlot 写拷贝构造函数,以为 clone 会“自动深拷贝”。上线测试时,两个敌人同时析构,技能数据被释放两次,程序直接崩溃。
排查过程非常煎熬,我先怀疑是注册表生命周期的问题,又怀疑是对象池里存在悬垂引用,最后用地址打印的方式才定位到问题:clone 时依赖了默认拷贝构造,而默认拷贝构造对裸指针成员做的是浅拷贝,两个对象的 SkillSlot 持有同一块地址,析构自然双重释放。
这件事让我总结出一个硬规矩:原型模式的 clone 语义,完全取决于类型体系里拷贝构造的语义。如果依赖默认拷贝构造完成复制,每个成员必须符合值语义;一旦出现裸指针,要么显式禁用拷贝,要么实现深拷贝。更好的建议是直接把裸指针改成 shared_ptr 或提供值类型封装的接口,把“深拷贝”的职责下放到每个成员,而不是在 clone 一层到处打补丁。
6.2 高频克隆场景的性能数据
原型模式在低频创建时性能无所谓,但高频场景要注意。我统计过一次每秒克隆上万次对象的测试,发现 clone 的开销大头往往不在 new 本身,而在深拷贝内部的容器和资源。一个敌人的 SkillSlot 里如果挂了几百条 Buff 记录,每次克隆都要分配几百次内存,性能直接崩掉。
整理成表格更直观:
| 对象原型类型 | 单次 clone 耗时 | 主要瓶颈 |
|---|---|---|
| 简单值成员 | 约 30 ns | new 分配 |
| 带 vector 成员 | 约 150 ns | vector 拷贝与分配 |
| 带多级指针成员 | 约 1200 ns | 深拷贝递归 |
| 带对象池复用 | 约 90 ns | 池子回收复用 |
最后的优化方案是“原型注册表 + 对象池”的组合:原型只保留一份负责提供类型模板,高频创建的对象从池子里拿。原型模式解决“类型多样性”,对象池解决“数量复用”,这两件事本来就该分开处理。
6.3 原型模式变体选择速查表
| 场景特征 | 推荐方案 |
|---|---|
| 继承层级浅,对象数量少 | 经典 clone + unique_ptr |
| 派生类多,clone 代码重复 | CRTP 中间层 |
| 配置驱动创建,类型运行期才决定 | 原型注册表 |
| 希望对外暴露值语义接口 | Pimpl + 自定义拷贝构造 |
| 类型集合编译期已知 | 优先使用 std::variant |
再分享一条经验:无论选哪种变体,建议 clone 方法统一返回 std::unique_ptr 而不是裸指针。这样调用方根本不可能忘记 delete,也从语言层面杜绝了悬垂指针问题。如果你看到老代码里还到处是裸指针 clone,别急着嘲笑,先把返回类型改成 unique_ptr,收益立竿见影。
回看最开始那个敌人 AI 需求,我的最终方案是“原型注册表 + CRTP 中间层 + 对象池”,三件事各管一段:注册表把 type 字符串映射到原型对象,CRTP 把 clone 重复代码压缩到一行,对象池负责复用高频对象。整体代码量比最初那一堆工厂函数少了一半,可维护性高了很多。如果你也在为“同一个创建需求搞出几百个工厂类”而头疼,建议先别急着写工厂,理一理类型是编译期已知还是运行期已知,再决定用 std::variant、经典原型还是注册表变体,可能比你想象的要省事得多。
