写C++写久了,你会发现真正麻烦的不是语法本身,而是怎么把对象“造”得既安全又灵活。原型模式(Prototype Pattern)在创建型模式里属于比较耐看的一种——它不和工厂方法抢“怎么生产新对象”的活,而是直接回答一个看似简单的问题:给我一个已经存在的对象,我能得到一份它的完整副本吗?注意,这里的“副本”不是值拷贝那么简单,而是多态的、运行时才知道具体类型的深复制。
我第一次接触原型模式时也很困惑:C++天生支持拷贝构造函数,为什么还要设计模式来“克隆”?直到我在一个图形编辑器里遇到“用户随手复制一个图形,但代码里只拿到了基类指针”的场景,才意识到原型模式真正解决的问题是什么。这篇就把我这些年用原型模式的经验、踩过的坑、以及几个可以直接抄的代码骨架一次说透。
1. 先弄清楚原型模式到底解决什么问题
1.1 最直观的场景:你手里只有基类指针
假设你在写一个绘图程序,画布上放着圆形、矩形、文本块。所有图形类继承自同一个Shape接口,画图管理模块里自然维护的是std::vector<std::unique_ptr<Shape>>。用户点击某个图形,按Ctrl+C复制,再按Ctrl+V粘贴。这时候你会遇到一个非常尴尬的问题:程序运行时才拿到这个图形的Shape*,你根本不知道它到底是Circle、Rectangle还是TextBlock。
你可能会想,那用dynamic_cast判断类型再逐个处理?如果只有三五个类型还好,一旦图形类型增加到几十种,if (dynamic_cast<Circle*>)... else if (dynamic_cast<Rectangle*>)...这种代码会变成一场噩梦,而且每新增一个类型都要改这段复制逻辑。
原型模式的思路很直接:既然我不知道它的具体类型,那我就让每个类型自己负责复制自己。基类里定义一个clone()虚函数,子类各自实现。用户复制时,代码只需要调ptr->clone(),多态会自动走到正确类型的复制函数。你不需要知道类型名,也不需要修改任何业务代码,就能得到一个新的、类型正确、内容完全独立的对象。
1.2 为什么不能直接用拷贝构造函数
很多人会问:C++的拷贝构造函数不也能复制对象吗?为什么还要专门搞一个clone()?
关键在于多态性。拷贝构造函数是编译期绑定的,你写Shape copy = *shapePtr;,编译器看到的是Shape类型的拷贝构造,即使shapePtr实际指向Circle,它复制的也只是Shape的基类部分。想实现多态复制,只能在每个子类里写自己的拷贝逻辑,再通过虚函数调度。
这其实是C++语言的一个结构性缺口:有虚析构、虚函数,但就是没有“虚构造函数”。原型模式本质上用clone()这个虚函数补上了“虚构造函数”的位置。你可以把它理解成:拷贝操作被提升为多态契约,任何子类都必须承诺自己能够被完整复制。
1.3 哪些场景真正适合原型模式
从实际工程经验看,原型模式不是万金油,它适合下面这几类场景:
- 对象创建成本很高。比如一个对象初始化时要读数据库、拉配置、解析JSON,那么每次创建都重复这些开销很浪费。你可以保留一个“模板对象”作为原型,每次克隆它,再小范围修改参数即可。
- 运行时的类型不确定。类似前面图形编辑器的例子,模块只持有基类指针或接口引用,却要创建同类型的新对象。
- 需要保留对象的“默认状态”。比如一个游戏里的怪物原型,初始血量、攻击力、移动速度都已经配好,生成新怪物时克隆原型,再随机微调属性。
- 同类型对象批量生成且每个实例需要独立修改。注意这里“独立”很关键,如果所有对象都只读共享,根本不需要克隆,共享指针就够了。
反过来,如果你的对象创建逻辑特别简单、类型编译期就确定,直接写拷贝构造或工厂函数反而更清晰。
2. 经典实现与C++代码层面的关键细节
2.1 一个最小可运行的骨架
先给一个现代C++风格的基础实现,你可以直接用这个结构去套自己的业务场景:
#include <iostream> #include <memory> #include <string> class Shape { public: virtual ~Shape() = default; virtual std::unique_ptr<Shape> clone() const = 0; virtual void draw() const = 0; }; class Circle : public Shape { public: Circle(double radius, std::string color) : radius_(radius), color_(std::move(color)) {} std::unique_ptr<Shape> clone() const override { return std::make_unique<Circle>(*this); } void draw() const override { std::cout << "Circle(radius=" << radius_ << ", color=" << color_ << ")\n"; } private: double radius_; std::string color_; }; class Rectangle : public Shape { public: Rectangle(double w, double h) : width_(w), height_(h) {} std::unique_ptr<Shape> clone() const override { return std::make_unique<Rectangle>(*this); } void draw() const override { std::cout << "Rectangle(w=" << width_ << ", h=" << height_ << ")\n"; } private: double width_; double height_; };调用侧非常简单:
int main() { std::unique_ptr<Shape> original = std::make_unique<Circle>(5.0, "blue"); auto copy = original->clone(); copy->draw(); // 输出 Circle(radius=5, color=blue) return 0; }核心就三件事:基类定义clone()纯虚函数,子类里用std::make_unique<子类>(*this)实现,调用方只依赖基类接口。这里特别要强调make_unique<子类>(*this)这个写法,它是clone唯一正确的打开方式。
2.2 clone返回类型的协变规则
如果你翻看老版本的C++设计模式书,通常会看到Shape* clone()的写法,子类返回Circle*。这种返回类型随派生类变化的行为叫“协变返回类型”,C++指针和引用是支持的。但到了现代C++,我们更推荐用std::unique_ptr包装返回结果,问题是unique_ptr<T>不是协变的,所以基类返回unique_ptr<Shape>、子类也返回unique_ptr<Shape>,只是内部实际构造的是Circle。
有人问:那我能不能基类返回unique_ptr<Shape>,子类返回unique_ptr<Circle>?答案是不行,编译器会直接报错。如果你确实想在调用侧拿到具体类型指针,有两个方案:
// 方案一:调用侧用 dynamic_pointer_cast auto circlePtr = std::dynamic_pointer_cast<Circle>(shape->clone()); // 方案二:基类 clone 返回裸指针支持协变,外部再包 unique_ptr virtual Shape* cloneRaw() const = 0; // 子类实现: // Circle* cloneRaw() const override { return new Circle(*this); }方案一更安全,也更符合资源管理的直觉。实战中,大多数业务方根本不需要知道具体类型,直接按基类处理就够了。
2.3 深拷贝和浅拷贝:最容易埋雷的边界
原型模式的实现有多简单,它的坑就有多深。一切问题都集中在“拷贝到什么程度”。
默认的make_unique<Circle>(*this)调用的是Circle的拷贝构造函数。如果Circle只包含值类型成员(如double、std::string),编译器生成的默认拷贝构造就够了。但一旦类里出现裸指针、文件句柄、套接字、ID、或者外部资源的引用,默认拷贝构造就会把指针值直接复制过去,结果就是两个对象指向同一块内存。改一个,另一个跟着变,析构时更是双重释放的定时炸弹。
正确的做法是自定义拷贝构造,把需要独立持有的资源真正“复制”一份:
class Texture { public: Texture(int w, int h) : width_(w), height_(h) { data_ = std::make_unique<unsigned char[]>(w * h); } // 拷贝构造:重新分配内存并复制像素数据 Texture(const Texture& other) : width_(other.width_), height_(other.height_) { data_ = std::make_unique<unsigned char[]>(w * h); std::memcpy(data_.get(), other.data_.get(), w * h); } private: int width_; int height_; std::unique_ptr<unsigned char[]> data_; }; class Sprite : public Shape { public: Sprite(Texture tex, std::string name) : texture_(std::move(tex)), name_(std::move(name)) {} std::unique_ptr<Shape> clone() const override { return std::make_unique<Sprite>(*this); // 拷贝构造会自动深拷贝 texture_ } private: Texture texture_; std::string name_; };这里有个非常重要的经验:不要在clone函数里手动复制成员,应该直接调用拷贝构造。因为你永远不知道未来会不会给类加新成员。如果clone里是return std::make_unique<Sprite>(*this),新增成员后拷贝构造自动覆盖;如果是手动new Sprite(other.radius_) + other.color_,每加一个成员都要来改,漏一个就是一个bug。
3. 实战:图形编辑器的“复制粘贴”怎么落地
3.1 需求拆解
我在某个项目里做过一个简化版的矢量编辑器,当时拿到一个很典型的需求:
- 画布上有各种图形,它们都继承自
Shape。 - 用户任意选中一个图形,点击“复制”,系统需要在原图旁边生成一个完全相同的图形。
- 生成的副本可以独立移动、改色、删除,不得影响原图。
- 系统可能在后继版本里新增更多图形类型,但复制功能不允许频繁改动。
这几乎就是为原型模式量身定做的场景。复制逻辑压根不需要知道用户选中的是什么图形,只要调用clone()。
3.2 完整实现与关键代码
#include <iostream> #include <memory> #include <vector> #include <string> class Shape { public: virtual ~Shape() = default; virtual std::unique_ptr<Shape> clone() const = 0; virtual void print() const = 0; virtual void move(double dx, double dy) = 0; }; class Line : public Shape { public: Line(double x1, double y1, double x2, double y2) : x1_(x1), y1_(y1), x2_(x2), y2_(y2) {} std::unique_ptr<Shape> clone() const override { return std::make_unique<Line>(*this); } void print() const override { std::cout << "Line(" << x1_ << "," << y1_ << ")->(" << x2_ << "," << y2_ << ")\n"; } void move(double dx, double dy) override { x1_ += dx; y1_ += dy; x2_ += dx; y2_ += dy; } private: double x1_, y1_, x2_, y2_; }; class TextBlock : public Shape { public: TextBlock(std::string text, double x, double y) : text_(std::move(text)), x_(x), y_(y) {} std::unique_ptr<Shape> clone() const override { return std::make_unique<TextBlock>(*this); } void print() const override { std::cout << "TextBlock(\"" << text_ << "\" at " << x_ << "," << y_ << ")\n"; } void move(double dx, double dy) override { x_ += dx; y_ += dy; } private: std::string text_; double x_, y_; }; class Canvas { public: void addShape(std::unique_ptr<Shape> shape) { shapes_.push_back(std::move(shape)); } // 关键操作:复制指定位置的图形 std::unique_ptr<Shape> duplicate(size_t index) const { return shapes_[index]->clone(); } void render() const { for (const auto& s : shapes_) { s->print(); } } private: std::vector<std::unique_ptr<Shape>> shapes_; };调用演示:
int main() { Canvas canvas; canvas.addShape(std::make_unique<Line>(0, 0, 10, 10)); canvas.addShape(std::make_unique<TextBlock>("hello", 3, 7)); auto newShape = canvas.duplicate(0); newShape->move(100, 100); canvas.addShape(std::move(newShape)); canvas.render(); return 0; }这个设计里,Canvas::duplicate就是原型模式的典型应用。它不需要任何类型判断,也不依赖dynamic_cast,纯粹的基类多态就能完成复制。
3.3 新增类型时系统怎么演化
如果后续要支持Arc(圆弧),只需要做两件事:继承Shape,实现clone()、print()和move()。Canvas和duplicate一行都不用改。这就是原型模式的核心收益,它把“新增类型”这件事的改动范围限制在新增文件本身,而不是蔓延到所有创建对象的调用点。
当然,如果Canvas想在新增加图形类型的按钮时用到具体类型,那还是需要在UI层登记。但复制、移动、渲染这些通用流程,完全可以在不知道具体类型的情况下运行。
4. 原型注册表:把原型模式工程化
4.1 为什么需要注册表
只用原型模式时,你仍然需要make_unique<Circle>(...)来创建第一个原型实例。如果系统里有几十种图形,调用方仍然要在创建原型时写一堆类型名。更麻烦的是,有些对象的原型需要复杂的初始化参数,比如一个“怪物”原型可能要从JSON配置里加载数据。
这时候可以把“原型实例”集中管理起来,按字符串名字或枚举类型存储到一个注册表里。想创建新对象时,只传一个key,注册表负责查找原型并克隆。这相当于原型模式加上工厂方法的叠加效果。
4.2 注册表实现
#include <unordered_map> #include <memory> #include <string> #include <stdexcept> class PrototypeRegistry { public: void registerPrototype(std::string key, std::unique_ptr<Shape> proto) { prototypes_[std::move(key)] = std::move(proto); } std::unique_ptr<Shape> create(const std::string& key) const { auto it = prototypes_.find(key); if (it == prototypes_.end()) { throw std::invalid_argument("prototype not found: " + key); } return it->second->clone(); } template <typename T> void registerSample(const std::string& key, const T& sample) { registerPrototype(key, sample.clone()); } template <typename T> std::unique_ptr<T> createAs(const std::string& key) const { auto raw = create(key); auto casted = dynamic_cast<T*>(raw.get()); if (!casted) { throw std::invalid_argument("type mismatch for key: " + key); } // 释放所有权后交给 unique_ptr<T> casted->addRef(); // 这里不需要,下面有更安全的做法 return std::unique_ptr<T>(dynamic_cast<T*>(raw.release())); } private: std::unordered_map<std::string, std::unique_ptr<Shape>> prototypes_; };实际使用中createAs可以直接做动态转换:
auto registry = PrototypeRegistry(); registry.registerSample("circle_red", Circle(2.5, "red")); registry.registerSample("rect_default", Rectangle(4, 2)); auto c = registry.createAs<Circle>("circle_red"); if (c) c->draw();4.3 生命周期与所有权问题
注册表内部保存的是unique_ptr<Shape>,它持有每个原型的唯一所有权。每次create()返回一个新克隆的unique_ptr,所有权转移给调用方,注册表自身不共享数据。这种做法所有权清晰,但也意味着原型对象必须是可以独立复制的,不需要依赖注册表的生命周期来存活。
一个常见的坑是:有人把某个全局单例对象直接作为原型存进注册表,然后克隆时只复制了指针。万一这个单例在程序结束前被析构,所有克隆对象都成了悬空指针。所以再次强调,原型模式里,克隆出来的对象一定要拥有自己的资源或至少通过shared_ptr管理共享资源的生命周期。
还有一点,注册表里registerSample<T>要求T必须继承自Shape,否则sample.clone()编译不通过。这是好事,编译期就能拦截类型错误。
5. 老司机也要小心的坑:问题排查与避坑指南
5.1 常见问题速查表
我在实际项目中把这些坑整理成一张表,排查时先对着看,能省不少时间。
| 症状 | 可能原因 | 解决方案 |
|---|---|---|
| 克隆对象和原对象共享内部数据,改一个另一个跟着变 | 类里有裸指针却用默认拷贝构造,浅拷贝了 | 自定义拷贝构造,对指针成员做深拷贝;能用容器就用容器 |
| clone返回的对象类型不对,调用的是基类方法 | clone里直接返回make_unique<Shape>(*this),构造了基类对象 | 必须返回make_unique<子类>(*this) |
| 克隆后程序崩溃,疑似双重释放 | 浅拷贝导致两个对象管理同一块堆内存,析构时重复释放 | 检查所有裸指针成员,改成深拷贝或shared_ptr |
| 原对象析构后,克隆对象访问数据崩溃 | clone时只复制了指向外部资源的指针,没有复制资源本身 | 对外部资源做深拷贝,或明确转移所有权 |
注册表报prototype not found | key拼写不一致,或原型未注册 | 统一用常量字符串或枚举,不要裸字符串排查 |
| 递归调用clone导致栈溢出 | 构造函数内部又调用了clone(),形成无限递归 | clone里使用拷贝构造,不要在构造过程中触发虚函数 |
5.2 漏掉成员是最隐蔽的bug
我处理过最典型的一个bug:TextBlock原本只有一个std::string text_,后来为了性能加了一个std::shared_ptr<TextLayout> layout_。开发者觉得shared_ptr复制不会出事,就完全没改拷贝构造。结果两个克隆对象共享同一个TextLayout,某个对象修改了字体大小,另一个也跟着变了,产品侧报了个非常诡异的“文本样式串台”问题。
排查了很久才定位到是layout_被共享了。修复方案很简单:layout_本来就该是共享的,所以不需要深拷贝,但问题在于业务方不知道这个共享关系,后续代码误以为副本完全独立。最终我们把TextLayout改成了有引用计数的单例样式表,同时在TextBlock里删除了拷贝共享的语义,明确每个副本都有独立样式。
这个教训说明了一点:原型模式下,你必须对每个成员明确回答一个问题——它该被共享,还是该被复制?这决定了拷贝构造怎么写,也决定了你提供给外界的语义是“深拷贝”还是“浅拷贝加引用计数”。
5.3 clone的异常安全与一致性
clone()通常是const成员函数,它不修改原对象。但如果深拷贝过程中内存分配失败抛出了std::bad_alloc,原对象不会受到影响,因为所有复制工作都在新对象里完成,原对象没有任何改动。这就是强异常安全的天然优势。
但有一种情况要注意:如果你在clone里先复制了部分状态,然后手动去new另一个资源,new失败抛出异常时,前面已经复制好的资源可能泄漏。所以最好的策略是依赖RAII——尽量使用std::make_unique、std::vector、std::string这些自管理类型,而不是到处new/delete。
// 反例:容易泄漏 std::unique_ptr<Shape> clone() const override { auto obj = std::make_unique<Sprite>(*this); obj->buffer_ = new char[100]; // 如果new失败,buffer_之前已有的资源也可能泄漏 return obj; } // 正例:所有成员都通过容器管理 std::unique_ptr<Shape> clone() const override { return std::make_unique<Sprite>(*this); }5.4 返回unique_ptr、shared_ptr还是裸指针
我的建议是优先返回std::unique_ptr。原因很直接:
unique_ptr语义清晰,表示克隆出来的对象有独立所有权。- 调用方想转成
shared_ptr只需要std::shared_ptr<Shape>(shape->clone())。 - 裸指针容易漏delete,除非你和一个老代码库需要协变返回类型,否则没有理由用裸指针。
如果类本身需要被多方共享,可以在返回前由调用方决定包一层shared_ptr,而不是让clone本身返回shared_ptr。因为共享策略属于使用场景,不该硬编码到对象自身的行为里。
6. 原型模式和其他创建型模式的取舍
6.1 三种创建型模式快速对照
| 维度 | 工厂方法 | 原型模式 | 单例模式 |
|---|---|---|---|
| 创建方式 | 通过工厂函数/类创建新对象 | 通过克隆已有实例创建新对象 | 全局唯一实例,直接获取 |
| 对象初始状态 | 通常从零开始构造 | 继承原型的默认状态 | 固定唯一状态 |
| 运行时类型多态 | 需要工厂按类型分支或模板 | 天然多态,无需知道类型 | 不涉及多态 |
| 新增类型的成本 | 可能需要新增工厂子类/分支 | 只需注册一个原型 | 不适用 |
| 适合场景 | 同一接口有多种产品 | 对象复制昂贵、需要模板副本 | 全局配置、状态中心 |
6.2 我实际项目里的选择原则
我的经验是:先看对象的“创建成本”和“类型确定性”。
如果类型编译期就确定,比如固定创建Circle,那就直接用构造函数或工厂函数,上原型模式属于过度设计。如果运行时要根据配置文件动态创建多种类型,且类型数量可能增加,优先考虑原型注册表方案。如果对象初始化依赖外部资源,比如数据库连接、模型文件、图片纹理,那么建一个样板对象反复克隆,比每次都重新加载资源要高效太多。
一个反面例子是,有人把所有类都塞进原型模式,结果一个非常简单的Point类也要写clone()、拷贝构造,代码反而臃肿。设计模式始终是工具,不是装饰,要评估收益再上。
按照我个人的实践,原型模式最值得用的地方是那两个:动态类型复制和昂贵对象的模板复制。只要这两点占一点,原型模式就是合适的选择;两点都不占,强行用只会给维护者添乱。
说句心里话,原型模式是我在C++里用得最多的创建型模式之一,但同时也是出bug最多的模式。大部分问题都出在“浅拷贝”上,而且这种bug很隐蔽,往往要等到某个对象被修改、另一个对象跟着变的时候才会暴露。如果你要在自己的项目里使用原型模式,我的建议是先花半小时检查类里每个成员变量的拷贝语义:哪些该深拷贝,哪些该共享,哪些只能移动。把这个账算清楚,原型模式写起来是非常省心的。