第一次尝试在 C++ 里实现原型模式时,我犯了个很基础的错误:拿着 GoF 的经典类图就往工程里搬,写了个virtual clone(),返回值却用裸指针。结果调用方漏了delete,内存泄露检测直接报红一大片。后来我才意识到,C++ 里的原型模式从来不是"照着四个角色画类图"就能落地的,它的难点在于复制语义、多态边界、所有权归属这三件事纠缠在一起。这篇文章不以"原型模式教程"的框架去讲,而是从我在实际代码里不断换方案的经验出发,整理出六种我在项目里真正用过的原型模式变体,覆盖虚函数、注册表、CRTP、std::variant、写时复制和原型池,每种的适用场景、代码形态和坑我都会说清楚。
开头先把结论放这儿:C++ 里原型的核心不是"从某个类 new 出一个新对象",而是"把某个已经存在的实例当作模板,得到它的拷贝"。Java 那套印象深刻,但 C++ 有值语义、移动语义和智能指针,直接照搬通常不是最优解。
1. 拷贝构造函数的边界:原型模式在 C++ 里为什么要单开一路
1.1 当 C++ 的天然复制变成切片
很多从 C 风格转过来的同事跟我说,C++ 不是自带拷贝构造函数吗,原型模式岂不是多余?确实,对普通值类型来说,复制就是天然的原型操作:
struct Point { int x, y; Point(int x, int y) : x(x), y(y) {} }; Point a(10, 20); Point b = a; // 这就是一次标准的原型复制但一旦牵扯到面向对象的多态继承,事情就麻烦了。假设有如下继承关系:
struct Shape { virtual ~Shape() = default; virtual void draw() const = 0; }; struct Circle final : Shape { double radius; void draw() const override {} };现在写一个函数,接收基类引用,希望"照原样复制一份":
void duplicate_and_redraw(Shape& src) { Shape copy = src; // 编译失败 }编译直接失败,因为Shape是抽象类,不能按值初始化。就算把Shape改成非抽象类,这里的行为也只会形成对象切片:Circle的radius会消失,复制出来的只是一个基类片段。这种错误非常隐蔽,尤其是当你把派生类对象当作基类值传递时,编译器并不一定会报错。
所以 C++ 里原型模式的第一个变体动机就出现了:当你的可复制对象要通过基类接口来管理时,默认的复制语义无法自动保持派生类型信息。这就是虚克隆存在的理由。
1.2 经典虚克隆为什么该返回 unique_ptr
最朴素的做法是按 GoF 的书实现一个普通的虚函数clone():
struct Shape { virtual ~Shape() = default; virtual void draw() const = 0; virtual Shape* clone() const = 0; }; struct Circle final : Shape { double radius; void draw() const override {} Shape* clone() const override { return new Circle(*this); } };这个版本能跑,但工程上非常难用。调用者收到裸指针后必须自己delete,一旦在异常路径里忘掉,就是泄露。把一个资源所有权交给调用方的接口,本质上是把 C++ 的 RAII 原则抛到一边。
我现在在工程里倾向于用std::unique_ptr作为clone()的返回类型:
struct Shape { virtual ~Shape() = default; virtual void draw() const = 0; virtual std::unique_ptr<Shape> clone() const = 0; }; struct Circle final : Shape { double radius; void draw() const override {} std::unique_ptr<Shape> clone() const override { return std::make_unique<Circle>(*this); } };返回unique_ptr用起来非常放心:调用方直接auto p2 = p1->clone();,所有权自动接管,不需要在业务代码里纠结谁负责释放。unique_ptr<Shape>返回的是指向派生类对象的基类指针,但内部实际指向的仍然是Circle,所以克隆出来的对象调用draw()时行为正确。
1.3 复制构造函数与 clone() 的职责分工
有个细节很多人忽略:clone()内部往往直接调用拷贝构造函数。也就是说:
- 拷贝构造函数负责"怎么把当前对象的内存字段复制到新对象";
clone()负责"把这种复制能力以多态的方式暴露出去"。
它们是相辅相成的,不是替代关系。如果派生类里增加了内部缓冲区或动态资源,你首先要保证拷贝构造函数按深拷贝语义写对,否则clone()复制的只是一个表面壳子。下一节开始讲的注册表变体,正是建立在这个虚克隆的基础之上。
2. 注册表式原型工厂:把创建参数记录在原型实例本身里
2.1 从"用模板实例"看注册表的真正价值
经典原型模式解决的场景是:运行时得到某个对象实例,但不知道它具体是什么派生类,希望创建同类的新实例。实际项目中,我还遇到过更具体的需求:不止要创建同类实例,还要让新实例带上某些默认配置。
举个例子,一个战斗游戏里每种角色有十几项数值;如果每次创建角色都重新走一遍参数填充,调用方容易漏填。更合理的方法是:内置一份"角色原型实例",把标准配置固化下来;新角色直接从这份原型clone(),再按需覆盖个别字段。这就是注册表式原型工厂的雏形。
注册表的角色在于,它把"提供原型实例"和"按标识符创建"解耦了。外部模块不需要知道具体类名,只需要一个键,比如字符串 ID:
#include <memory> #include <string> #include <unordered_map> class Character; using CharacterPtr = std::unique_ptr<Character>; class CharacterPrototypeRegistry { public: void registerPrototype(std::string key, char const* typeTag); CharacterPtr create(const std::string& key) const; private: std::unordered_map<std::string, CharacterPtr> prototypes_; };这里registerPrototype创建的是"样板原型",create则把原型clone()一份返回。从外部看,create("orc")就能直接得到一个满配兽人角色实例,而调用方完全不需要知道Orc这个类型名。
2.2 打印一个可按 key 克隆的注册表
下面给出一个能直接编译的迷你示例。假设Character是抽象基类,Orc是具体角色:
#include <memory> #include <string> #include <unordered_map> #include <stdexcept> class Character { public: virtual ~Character() = default; virtual int health() const = 0; virtual std::unique_ptr<Character> clone() const = 0; }; class Orc final : public Character { public: Orc(int hp) : hp_(hp) {} int health() const override { return hp_; } std::unique_ptr<Character> clone() const override { return std::make_unique<Orc>(*this); } private: int hp_; }; class CharacterPrototypeRegistry { public: template <typename T, typename... Args> void add(const std::string& key, Args&&... args) { prototypes_[key] = std::make_unique<T>(std::forward<Args>(args)...); } std::unique_ptr<Character> create(const std::string& key) const { auto it = prototypes_.find(key); if (it == prototypes_.end()) throw std::runtime_error("prototype not found: " + key); return it->second->clone(); } private: std::unordered_map<std::string, std::unique_ptr<Character>> prototypes_; };使用方:
CharacterPrototypeRegistry registry; registry.add<Orc>("orc", 100); auto mob = registry.create("orc");模板函数add<T>会根据第一个模板参数推断具体类型并创建原型实例,后续参数直接传给构造函数。这个设计让注册表非常干净:注册时把创建参数封进原型,创建时只按 key 取克隆。
2.3 注册抽象类型还是注册具体原型实例
注册表里到底存的是什么,这里有两种不同的变体取向:
- 按类型注册:注册表存放"某类的一个标准实例",每次克隆都得到该类型的新对象;
- 按实例语义注册:注册表存放"某个配置固定、但类型可能相同的多个不同原型",比如兽人战士和兽人萨满在同一个类里,但拥有不同数值。
按类型注册适合"类型一一对应一个默认配置"的场景;按实例语义注册则更贴近真实业务。我在实际里更多用的是后者:允许同一个add被多次调用,但传入不同初始化参数,然后注册表里就有多份同类型但不同状态的原型。克隆出来的对象各自独立,修改它们不会影响注册表里的原型,这也是注册表的一个隐藏优势。
2.4 与简单工厂的边界
原型注册表面上看和简单工厂很像,但两者核心差别值得列一张表:
| 关注点 | 简单工厂 | 注册表式原型工厂 |
|---|---|---|
| 创建逻辑所在 | 工厂内部记录创建规则 | 原型实例自带复制能力 |
| 新类型扩展方式 | 修改工厂的 switch/if 判断 | 注册新的原型实例 |
| 默认配置管理 | 构造参数由工厂硬编码 | 配置固化在原型实例里 |
| 创建成本 | 每次从零构造 | 拷贝已有对象,往往更轻 |
| 适合场景 | 类型固定、参数简单 | 类型多、配置复杂、强调模板实例 |
一句话总结我的经验:当"创建参数组合"比"类型分支"更复杂时,用原型注册表。很多模块里的对象创建问题,不是类型爆炸,而是配置爆炸。
3. CRTP 与编译期自动生成:去掉 clone() 的重复样板
3.1 把一模一样的 clone 逻辑搬到基模板里
你如果挨个实现派生类的clone(),会发现代码几乎都是相同的套路:
std::unique_ptr<Shape> clone() const override { return std::make_unique<Circle>(*this); }类一旦多起来,这种样板代码就变得又臭又长,还容易漏写。C++ 的模板能力天然适合消灭这种重复,这就是 CRTP 变体:让基类模板接收派生类型,自动生成正确的clone()。
template <typename Derived, typename Base> class Cloneable : public Base { public: using Base::Base; std::unique_ptr<Base> clone() const override { return std::make_unique<Derived>( static_cast<Derived const&>(*this)); } };用法:
struct Shape { virtual ~Shape() = default; virtual std::unique_ptr<Shape> clone() const = 0; virtual void draw() const = 0; }; struct Circle final : Cloneable<Circle, Shape> { double radius; void draw() const override {} };Cloneable<Circle, Shape>为Circle生成一个clone()覆盖。内部static_cast<Derived const&>(*this)把this转回真实派生类型,然后调用make_unique<Derived>。因为模板参数和我们需要的派生类完全一致,所以转型是安全的,只要Derived正确继承了Cloneable。
3.2 自动 clone 的代价:必须谨慎对待 static_cast
CRTP 看似完美,但它有一类潜在风险:如果某个派生类再做第二层继承,比如:
struct EliteCircle : Circle { int extra_boost; };这时候EliteCircle如果不显式继承Cloneable<EliteCircle, Shape>,而只是继承Circle,那么Circle里由 CRTP 生成的clone()仍然会返回unique_ptr<Shape>,实际指向的是Circle而不是EliteCircle。调用方克隆一个EliteCircle时得到的是它的基类副本,额外字段会丢失。
解决办法是:要么严格要求所有叶子节点都直接继承一层Cloneable<Derived, Base>,要么禁止 CRTP 类再被继承。我在项目里通常把Derived对应的类设计为final,这样既能保证安全,也让意图更清晰。
还有一点,static_cast把this转成Derived const&时,要求Cloneable<Derived, Base>的模板参数确实匹配实际对象类型。如果写错:
struct Circle : Cloneable<Triangle, Shape> { ... };那么转型未定义行为,大概率在运行时崩溃。所以我在工程规范里规定:CRTP 模板参数必须和类名一致,并由单元测试强制覆盖 clone 行为的类型校验。
3.3 C++20 concept 作为契约的修正变体
C++20 引入了 concept,可以让 CRTP 变体更有约束力。比如规定Derived必须拷贝构造:
template <typename Derived> concept CloneableDerived = std::is_copy_constructible_v<Derived>;这样当某个类没有拷贝构造函数时,编译器给出的报错比"static_cast不合法"友好得多。这个改造成本很低,但它让 CRTP 变体在团队协作里不容易被误用,值得加上。
4. 值语义变体:std::variant 下的天然复制灾难终结者
4.1 多态不需要指针
原型模式在 C++ 里还有一个被低估的变体:直接用值语义的多态。前面几节都是围绕"通过基类指针来操作对象"展开的,但 C++ 本身还有一种替代方案——std::variant。它允许我们在一个封闭类型集合上做类型安全的联合,而且复制语义天然就是按值复制。
看一个配置节点的例子:
#include <variant> #include <string> struct CircleConfig { double radius; }; struct RectConfig { double width, height; }; using ShapeVariant = std::variant<CircleConfig, RectConfig>; ShapeVariant source = CircleConfig{1.5}; ShapeVariant copy = source; // 直接复制,无需 clone 函数这里copy就是source的深拷贝,所有附带类型信息都保留。没有虚函数、没有指针、没有clone()。只要variant里的每个备选项都满足复制构造,整个variant就是可复制的。对原型模式来说,这是最省事的形态:复制这个动作由编译器生成,对象所有字段和类型标签一起复制。
4.2 variant 复制原型时限制了哪些能力
std::variant并不是万能替代品,它有一个本质限制:类型集合是封闭的。多态继承是开放式的,你可以随时新增派生类而不动基类;但variant的候选类型必须在一开始全部列出,新增类型意味着修改using ShapeVariant = ...这一行,所有相关分支都要重新编译。
另一个限制是,variant的复制总是携带全部静态类型信息,但它并不提供统一的"运行时多态接口"。你想在某个函数里统一处理所有几何体,必须通过std::visit或get_if来访问,而不能像基类指针那样直接调用draw()。
void draw_shape(const ShapeVariant& shape) { std::visit([](const auto& s) { // 这里可以调用所有备选项共有的成员,前提是它们存在 }, shape); }如果每个备选项都实现了draw(),std::visit的 lambda 泛型也能工作,这其实是一种编译期多态。很多场景下,我反而更喜欢这种不依赖 vtables 的方式:局部性更好,不需要堆分配,复制行为简单可控。
4.3 值语义与注册表工厂混搭的实用形状
variant变体可以和注册表结合。比如一个配置系统,想通过字符串 ID 拿到一份"个性化配置副本",但不想引入继承。可以这样做:注册表存ShapeVariant,create直接返回拷贝。
class VariantRegistry { public: void add(std::string key, ShapeVariant proto); ShapeVariant clone(const std::string& key) const; private: std::unordered_map<std::string, ShapeVariant> protos_; };这种做法的体验非常舒服:没有虚析构函数的烦恼,也没有所有权转移的争论,所有对象都是普通的值。缺点仍然是"无法轻易扩展新类型",所以它在配置中心、协议消息、几何图元这类类型稳定、变化不频繁的领域里表现极好。
5. 高性能场景的数据变体:写时复制、原型池与浅共享
5.1 深拷贝的账不能不算
原型模式的常规实现毕竟是一次深拷贝。如果一个对象里嵌套着大量缓存、纹理句柄、脚本对象,深拷贝的成本可能高得让人无法接受。我在处理某个跨平台图形模块时,一个节点克隆下来要复制几十 MB 的参数和纹理对象,哪怕只是临时改一个颜色,也会触发整棵树的完整复制。这时候就需要引入性能变体。
5.2 写时复制变体:共享比克隆更快
写时复制的思路是:把对象内部状态放进std::shared_ptr,初始克隆时共享同一个内部实现,直到某个对象要修改数据时,再真正复制。
#include <memory> #include <string> class SpriteNode { public: explicit SpriteNode(const std::string& texture) : data_(std::make_shared<Data>(texture, 1)) {} SpriteNode clone() const { SpriteNode result = *this; // 共享,不深拷贝 result.data_ = data_; // 仍然共享 return result; } void setLayer(int layer) { ensure_unique(); data_->layer = layer; } private: struct Data { std::string texture; int layer; Data(std::string tex, int l) : texture(std::move(tex)), layer(l) {} }; void ensure_unique() { if (data_.use_count() > 1) { data_ = std::make_shared<Data>(*data_); } } std::shared_ptr<Data> data_; };这里clone()几乎是 O(1) 的:两个SpriteNode指向同一个内部数据块,只增加引用计数。当其中一个要修改时,ensure_unique()发现共享计数大于 1,才真正复制。这种变体能显著降低"原型克隆"的峰值开销。
但你要注意,COW 变体改变了"克隆后互不影响"的直觉。如果你心里期待的是严格的深拷贝,COW 只在写入时体现,读操作仍然是共享的。业务逻辑里若有人从外部拿到内部数据并持有引用,就可能出现意外的"串数据"现象。所以 COW 适合数据以短生命周期读取为主的场景,不适合数据会被长期缓存并随时被修改的场景。
5.3 原型池与按需 clone 的对冲
原型池是另一种性能变体:它不是每次 clone 都重新分配内存,而是预先从原型批量生成一批对象,放进空闲池里。需要使用时直接从池中掏出干净副本,用完再归还池子。
class CharacterPool { public: explicit CharacterPool(std::unique_ptr<Character> prototype, size_t size) : prototype_(std::move(prototype)) { for (size_t i = 0; i < size; ++i) { freeItems_.push_back(prototype_->clone()); } } std::unique_ptr<Character> acquire() { if (freeItems_.empty()) { return prototype_->clone(); } auto item = std::move(freeItems_.back()); freeItems_.pop_back(); return item; } void release(std::unique_ptr<Character> item) { freeItems_.push_back(std::move(item)); } private: std::unique_ptr<Character> prototype_; std::vector<std::unique_ptr<Character>> freeItems_; };在我处理帧循环代码时,这种池化方案很有效:每帧需要创建大量临时对象,但它们的最终状态都会在帧结束被清空。池化避免了反复分配和释放带来的碎片化。不过你要小心,归还对象后,池内对象的内部状态可能还残留上一帧的痕迹,所以acquire()返回前最好对对象做一个"复位"操作,否则就会把脏数据带进新的一帧。
5.4 浅克隆、深克隆的语义约定
浅克隆和深克隆在使用原型模式时,一定要在工程文档里写清楚。我的习惯是:
| 语义 | 内部指针/引用 | 适合场景 |
|---|---|---|
| 深克隆 | 复制所有可写资源 | 数据齐全、期待完全隔离 |
| 浅克隆 | 共享只读数据、复制句柄 | 配置对象、元数据 |
| COW 克隆 | 共享可变数据、写时复制 | 大对象、读多写少 |
如果团队里有的人默认"clone 等于深拷贝",有的人觉得"clone 轻量",设计评审时就会吵起来。提前定好标准可以省很多事。
6. 选型参考与常见坑(六个我真实踩过的)
6.1 原型树与工厂模式的对比表
先给一个我常用来做选型判断的对比,带箭头可以直接照着用:
| 情况 | 建议变体 |
|---|---|
| 类型数量少、类型稳定 | std::variant值语义 |
| 类型开放、需要基类接口 | 虚clone()或 CRTP |
| 创建参数复杂、配置需要模板实例 | 注册表式原型工厂 |
| 对象大、读多写少 | COW 变体 |
| 对象短生命周期、频繁创建销毁 | 原型池 |
| 对外还要控制具体类名 | 注册表 + 虚克隆 |
我并不是说某一种变体会永远优于另一种,很多时候一个系统里同时用两三种变体也很正常:核心对象走虚克隆,配置数据走variant,高频临时对象走池化。选型的关键是看"复制操作是被谁、在什么频率下触发"。
6.2 坑六条
第一,基类成员在克隆时被忽略。如果派生类的复制构造函数写得不好,只初始化了自己的字段,忘了对基类成员做正确复制,clone()就会截断数据。排查这种问题是出了名的麻烦,因为它不会直接崩溃,只是数值不对。我后来在派生类复制构造函数里习惯显式写Base(*static_cast<Base const*>(this)),确保基类部分被完整复制。
第二,虚 clone() 的返回类型被隐藏。基类有一个Shape::clone(),派生类某个模块里又正好有个成员函数也叫clone,这就会造成名字隐藏。调用circle.clone()可能不会调用到多态的clone()。添加override关键字可以规避绝大多数这种问题。
第三,浅拷贝了不该共享的内部资源。给对象里一个std::shared_ptr命名的内部缓冲区做了浅拷贝,结果两个克隆对象修改了同一个缓冲。修复过程很痛苦,因为没有编译错误。我后来做代码评审时,对clone()里的每个成员字段都追问:它应该共享还是深拷贝?
第四,注册表的原型生命周期失控。如果注册表持有的原型被外部模块反注册掉了,其他线程可能仍在按 key 查找。这个问题在并发条件下特别容易偶发。我给注册表加了一个简单的读写锁,并在create()里对查找结果做空指针校验,至少保证了崩溃的确定性,不至于把问题藏起来。
第五,把 COW 当深拷贝用。前面说过,COW 在写时才真正复制。如果业务方拿着克隆对象的某个内部引用长期保存,然后修改另一个克隆对象,会发现"自己的数据也变了"。这不是 bug,是语义误用。遇到这种需求,就别省那个深拷贝。
第六,克隆后的对象和原型共用了某个池子。原型池里如果复用对象,一定要有一个明确的 reset 协议。我最开始只是简单地acquire()返回一个clone(),结果对象里残留的前一帧状态,让我排查了一个下午。后来我在基类上加了一个reset()虚函数,每次从池中取出时强制调用。
6.3 一点个人判断
我在经过多次反复后,现在对新项目里等于类型的定义会默认倾向std::variant+ 值语义,只有在明确需要开放扩展时才引入基类和虚clone()。原因很简单:编译器能够自动生成复制,意味着少写很多代码,也少很多所有权纠葛。但它也不是银弹,一旦类型集合会频繁变化,封闭性就会成为累赘。
原型模式的每种变体都不是凭空发明的新东西,它们是在回答同一个问题:你想把哪个对象的哪些信息复制出来,以及你希望这次复制付出多大代价。把这个问题问清楚,代码的组织形态自然就浮现出来了。我自己最后悔的一次技术选择,就是在一开始没定义好"克隆"的语义标准,导致深拷贝和 COW 混用,后来花了两周才把数据的一致性收敛好。希望你在引入原型模式时,先把这些边界理清楚,别走我这条路。