最近在写一个图形编辑器的图元复制功能时,遇到了一个挺有意思的需求:工具栏上放着一排图形模板,用户点一下就能拖出一个同类型的实例,但每类图元的构造函数、初始化参数完全不同。最开始我用一层层的 if-else 判断类型再分别 new,每次加一种新图元,就要去改那段分发逻辑,改多了自己都嫌烦。
后来我把思路调转过来:既然每个对象最清楚自己是什么类型、该怎么构造,那为什么不直接让对象“克隆自己”?这就是 C++ 里原型模式(Prototype Pattern)的出发点。它解决的问题不是“怎么创建一个对象”,而是“怎么不依赖具体类型也能创建一个同类的对象”。这篇文章我把原型模式从原理到工程落地完整拆开讲一遍,包括底层机制、深浅拷贝的坑、注册表管理方式、与工厂模式的取舍,以及我在实际项目里踩过的几个典型问题。
1. 先搞清楚原型模式到底想解决什么问题
想理解原型模式,先得转变一个看待“对象创建”的视角。
1.1 构造和克隆,是两条完全不同的路径
平时我们创建对象,走的是构造这条路径:
cpp复制Circle c(10); // 构造一个圆形,半径10
Rectangle r(4, 5); // 构造一个矩形,宽4高5
构造的前提是知道具体类型。你调用 Circle 的构造函数,就必须明确知道要造的是 Circle,不是 Rectangle,也不是某个还没定义的新类型。
但有一种场景很尴尬:你手上只有一个 Shape* 指针,指向的实际对象可能是个 Circle,也可能是个 Triangle,甚至可能是别人在另一个模块里新加的类型。你希望复制它,得到一个和它同类型的独立副本。
用构造函数根本做不到——你连它真正是什么类型都不知道,怎么调构造函数?
原型模式的思路很直接:让每个对象自己提供一个“克隆”能力,调用方不用关心具体类型,只要喊一句“复制一份”就行。
1.2 一个和生活对得上的类比
可以类比复印机和打印机。打印机要输出一份文件,你得先把文档用 Word、PDF 这些格式构建好,走的是“从零创建”的流程。复印机就简单了——把原稿放上去,按一下键,出来一份内容一模一样的副本。你不需要知道原稿是用什么软件做的,也不需要了解它的排版过程。
原型模式里的原型对象就是“原稿”,clone 就是那台复印机。
从设计模式的角度说,原型模式属于创建型模式,它的核心意图是:用原型实例指定待创建对象的种类,并通过复制这些原型来创建新对象。这句话没有一句废话,拆开看:
- “用原型实例指定种类”——种类的信息编码在实例本身,而不是在外部的类型判断逻辑里;
- “通过复制来创建”——创建路径是复制,不是构造。
1.3 没有原型模式时的代码长什么样
很多人在项目里写的第一版“创建逻辑”是这样的:
cpp复制Shape* createShapeByType(const string& type, const vector<double>& args) {
if (type == "circle") {
return new Circle(args[0]);
} else if (type == "rectangle") {
return new Rectangle(args[0], args[1]);
} else if (type == "triangle") {
return new Triangle(args[0], args[1], args[2]);
}
return nullptr;
}
这个写法本身没有错,但它有一个结构性问题:每增加一种新的 Shape 子类,都得回来改这个函数。改着改着,这个函数就会变成一个巨大的分发现场,还容易漏掉分支。原型模式通过把“创建能力”下放到每个类自身来化解这个增长点:新增类型时,外部代码一行都不用动。
1.4 原型模式的适用面比你想的更宽
除了图形编辑器,很多场景下原型模式都是最优解:
- 游戏开发:生成怪物、子弹、特效时,常从一个“模板实例”克隆出大量个体,每个个体再各自调整属性。
- 文档编辑器:复制粘贴一个复杂图形、表格、文本框,实际底层做的就是原型克隆。
- 配置系统:从一套默认配置克隆出一份副本,再在副本上做局部修改。
- 撤销/重做系统:保存对象某个时刻的状态快照,本质就是深拷贝一份原型。
在这些场景里,“复制”这个动作本身和具体类型解耦了,这是原型模式最大的价值。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 为什么“复制”不能直接写拷贝构造函数
看到这里,有 C++ 基础的读者可能会问:C++ 不是有拷贝构造函数吗?Shape b = *a; 不就复制了?还需要什么 clone?
这是个特别好的问题,也是理解原型模式底层机制的关键。答案是:拷贝构造函数只能在静态类型层面工作,无法处理多态对象的复制。
2.1 对象切片:静态类型复制的致命缺陷
看这段代码:
cpp复制Shape* a = new Circle(10); // 静态类型 Shape*,动态类型 Circle*
Shape b = *a; // 拷贝构造一个 Shape 对象
*a 的静态类型是 Shape,虽然它动态指向的是 Circle,但编译器在生成拷贝构造调用时,只依据静态类型来匹配。结果就是:b 只是一个 Shape,圆形的半径数据、Circle 自己的成员——全部丢失。这就是所谓的对象切片(object slicing)。
如果图形类层次里 Shape 是抽象基类,上面这段代码甚至直接编译报错。
也就是说,拷贝构造函数这把刀,只够削静态类型,削不了多态。
2.2 虚 clone:把复制权利交还给对象自己
原型模式的解法,是给基类定义一个 virtual clone(),让每个派生类自己实现“复制真正的自己”。
cpp复制class Shape {
public:
virtual ~Shape() = default;
virtual Shape* clone() const = 0;
virtual void draw() const = 0;
};
class Circle : public Shape {
public:
explicit Circle(double r) : radius_(r) {}
Shape* clone() const override {
return new Circle(*this); // 这里调用的是 Circle 的拷贝构造函数
}
void draw() const override {
std::cout << "Circle with radius " << radius_ << std::endl;
}
private:
double radius_;
};
关键点在于:clone() 里执行 new Circle(*this) 时,this 的静态类型是 const Circle*,所以调用的拷贝构造函数是 Circle 的拷贝构造函数。这就绕开了对象切片。
当外部拿着基类指针调用 clone() 时,虚函数机制会动态绑定到实际类型的实现:
cpp复制Shape* a = new Circle(10);
Shape* b = a->clone(); // b 的实际类型是 Circle,半径数据完整复制
这就是原型模式在 C++ 里最核心的落点:虚函数 + 拷贝构造。
2.3 一个容易被忽略的细节:clone 的声明要不要 const
我在代码里写的是 Shape* clone() const。这个 const 不是随便加的,它意味着我们承诺克隆操作不会修改原对象。这样设计有一个直观的好处:即使你手上只有一个 const Shape*,也可以安全地克隆一份。
cpp复制void duplicator(const Shape* s) {
Shape* copy = s->clone(); // 如果没有 const,这行直接编译失败
// ...
}
很多 C++ 设计模式教材里的示例不写这个 const,但工业代码里建议写上。原因有两点:
const让 clone 的语义更加明确:克隆是只读操作;- 持有 const 指针的场景很常见,不写 const 你会到处碰壁。
2.4 派生类的深拷贝链:virtual 拷贝构造
如果你熟悉 C++ 的“虚拟拷贝构造函数”这个概念,会发现原型模式的 clone 其实就是它的标准实现。GoF 在《设计模式》里也说得很清楚:在 C++ 中,原型模式实现的关键技术之一是虚构造函数,而 C++ 没有原生的 virtual 构造函数,所以通常用 clone 函数来模拟。
这套机制的完整形态是这样的:
cpp复制class Shape {
public:
virtual ~Shape() = default;
virtual Shape* clone() const = 0;
};
class Rectangle : public Shape {
public:
Rectangle(double w, double h) : width_(w), height_(h) {}
Shape* clone() const override {
return new Rectangle(*this);
}
// ...
private:
double width_;
double height_;
};
每一次新增子类,代码模板几乎一样:写一个 clone() override,里面 new Derived(*this)。代码冗余吗?有一点,但这几行的价值是让整个调用方逻辑彻底简化。
2.5 为什么基类必须定义虚析构函数
这不是原型模式的专属要求,而是 C++ 多态的通用规则,但原型模式里特别容易踩。因为 clone 函数返回的是 Shape*,调用方往往通过基类指针来管理生命周期:
cpp复制Shape* s = factory->getPrototype("circle")->clone();
delete s; // 如果基类析构函数不是虚的,这里只调用 ~Shape(),Circle 的析构不会被调用
如果 Circle 里有 std::string、std::vector、或者裸指针资源,那这就是一次内存泄漏甚至未定义行为。基类不写虚析构,多态删除就是一颗定时炸弹。
3. 克隆不只是复制:深拷贝的坑与工程化解法
原型模式到了实际工程里,最大的坑不来自虚函数,而来自“复制”的语义。如果某个类成员里有指针或者引用计数资源,浅拷贝和深拷贝的差别会直接决定程序是稳如泰山还是随机崩溃。
3.1 浅拷贝典型事故:双重释放
假设有一个 Bitmap 类,内部持有一个原始图像数据的指针:
cpp复制class Bitmap {
public:
Bitmap(size_t size) : data_(new char[size]), size_(size) {}
~Bitmap() { delete[] data_; }
// 没有自定义拷贝构造函数和拷贝赋值运算符
private:
char* data_;
size_t size_;
};
Bitmap 没有写拷贝构造,编译器会生成“逐成员复制”的默认版本。两个 Bitmap 对象里的 data_ 指向同一块内存。
然后你把它放进一个需要深拷贝的结构里面,比如原型模式的 clone:
cpp复制class ImageItem : public ItemBase {
public:
ItemBase* clone() const override {
return new ImageItem(*this); // 浅拷贝!两个 ImageItem 共享 data_
}
private:
Bitmap bitmap_;
};
当副本和原对象同时析构时,同一个 data_ 指针被 delete[] 两次——double free,程序在退出阶段随机崩溃,排查起来极其痛苦。
3.2 深拷贝的三种标准写法
要解决这个问题,Bitmap 必须实现真正的深拷贝。工程上我见过三种做法,各有适用场景。
方案一:经典手工管理
cpp复制class Bitmap {
public:
Bitmap(size_t size) : data_(new char[size]), size_(size) {}
Bitmap(const Bitmap& other) : data_(new char[other.size_]), size_(other.size_) {
std::copy(other.data_, other.data_ + size_, data_);
}
Bitmap& operator=(const Bitmap& other) {
if (this != &other) {
delete[] data_;
size_ = other.size_;
data_ = new char[size_];
std::copy(other.data_, other.data_ + size_, data_);
}
return *this;
}
Bitmap(Bitmap&& other) noexcept : data_(other.data_), size_(other.size_) {
other.data_ = nullptr;
other.size_ = 0;
}
Bitmap& operator=(Bitmap&& other) noexcept {
if (this != &other) {
delete[] data_;
data_ = other.data_;
size_ = other.size_;
other.data_ = nullptr;
other.size_ = 0;
}
return *this;
}
~Bitmap() { delete[] data_; }
private:
char* data_;
size_t size_;
};
这套代码逻辑完整,但写起来长。如果每个类都要自己写这些,项目里会积累大量重复的样板代码。
方案二:Rule of Zero——用 RAII 成员替代裸指针
C++ 社区这些年最推荐的做法是让类尽量遵守“Rule of Zero”:如果成员全是 RAII 类型(std::string、std::vector、std::shared_ptr 等),编译器生成的拷贝语义天然就是深拷贝。
cpp复制class Bitmap {
public:
explicit Bitmap(size_t size) : data_(size) {}
// 不需要自定义拷贝构造/析构/移动,全部交给 vector
private:
std::vector<char> data_;
};
std::vector 的拷贝是深拷贝,而 vector 自己处理内存分配和释放。Bitmap 的默认拷贝构造、赋值、析构全部正确。把资源管理的复杂度交给标准库,这是最省心也最不容易出错的路线。
方案三:写时复制(Copy-on-Write,COW)
如果你希望拷贝出来的对象共享底层数据,直到某一边要修改时才真正复制,可以用 std::shared_ptr 管理实现 COW 语义:
cpp复制class Bitmap {
public:
explicit Bitmap(size_t size)
: data_(std::make_shared<std::vector<char>>(size)) {}
char getPixel(size_t i) const { return (*data_)[i]; }
void setPixel(size_t i, char v) {
// 修改前检查引用计数,如果被共享则复制一份
if (!data_.unique()) {
data_ = std::make_shared<std::vector<char>>(*data_);
}
(*data_)[i] = v;
}
private:
std::shared_ptr<std::vector<char>> data_;
};
COW 的优缺点都很鲜明。好处是复制成本低,适合“复制多、修改少”的场景;坏处是每个修改操作都要检查 unique(),逻辑容易漏,而且多线程环境下共享可变状态要格外小心。
我的建议:默认用方案二。只有当你明确分析出深拷贝是性能瓶颈,才考虑 COW。
3.3 clone 的语义层次:深拷贝还是浅拷贝,必须提前约定
原型模式里有一个容易被忽略的约定问题:clone() 到底做深拷贝还是浅拷贝?我见过不少项目在这里翻车。
在大多数业务场景里,clone 应当具备值语义,也就是深拷贝。你复制一份配置,修改副本不应该影响原配置;你复制一个敌人单位,改变副本的血量,原单位不该受影响。如果 clone 只做浅拷贝,这些逻辑全部会出错。
但有一种场景浅拷贝是合理的:如果原型对象持有一个“只读共享资源”的 shared_ptr,clone 时共享这个资源反而是正确的设计,还能省内存。
所以在设计阶段就应该明确:
clone() 的默认契约是深拷贝,使副本完全独立于原对象。
比如下面这个带 std::string 成员的类:
cpp复制class TextShape : public Shape {
public:
TextShape(string text, int size) : text_(std::move(text)), size_(size) {}
Shape* clone() const override {
return new TextShape(*this); // string 深拷贝,size 拷贝
}
// ...
private:
std::string text_;
int size_;
};
因为 std::string 自己实现了深拷贝,TextShape 的克隆就是干净的独立副本。这再次说明,底层组件选对,上层逻辑能省一大半心。
3.4 拷贝并交换:赋值运算符的优雅解法
提到深拷贝,就绕不开赋值运算符。经典的“拷贝并交换”(Copy-and-Swap)写法在原型模式相关的资源管理类里非常实用:
cpp复制class Bitmap {
public:
void swap(Bitmap& other) noexcept {
using std::swap;
swap(data_, other.data_);
swap(size_, other.size_);
}
Bitmap& operator=(const Bitmap& other) {
Bitmap temp(other); // 拷贝构造,失败时 temp 构造失败,原对象不受影响
swap(temp); // 成功后才交换,temp 析构时带走旧资源
return *this;
}
}
这个写法把“分配新资源”和“释放旧资源”两个动作天然隔离开:拷贝构造失败,原对象保持原样,异常安全;交换成功后才释放旧资源,避免自赋值问题。如果你要手写资源管理类,这个模式是首选。
4. 原型还要“管起来”:注册表设计与对象工厂的配合
原型模式的单点妙用解决了“多态克隆”问题,但工程里更大的价值在于把原型注册表和对象创建流程结合。这一步能让你的代码从“少了一些 if-else”进化成“彻底消灭类型分发”。
4.1 从字符串到对象的映射:原型注册表
在一个图形编辑器里有几十种图元类型。如果不用原型模式,你需要维护一个巨大的类型分发函数。用原型模式,你可以:
- 每种图元创建后,注册到一个全局表中;
- 外部用一个字符串(或枚举)查找原型;
- 查到了就 clone。
实现如下:
cpp复制// 原型管理类
class PrototypeRegistry {
public:
static PrototypeRegistry& instance() {
static PrototypeRegistry reg;
return reg;
}
void registerPrototype(const string& type, unique_ptr<Shape> prototype) {
prototypes_[type] = std::move(prototype);
}
Shape* create(const string& type) const {
auto it = prototypes_.find(type);
if (it == prototypes_.end()) {
return nullptr; // 查不到就返回空,调用方自行处理
}
return it->second->clone();
}
private:
unordered_map<string, unique_ptr<Shape>> prototypes_;
};
使用的时候先注册:
cpp复制void registerAllShapes() {
auto& reg = PrototypeRegistry::instance();
reg.registerPrototype("circle", make_unique<Circle>(10));
reg.registerPrototype("rectangle", make_unique<Rectangle>(4, 5));
reg.registerPrototype("triangle", make_unique<Triangle>(3, 4, 5));
}
创建的时候只需要:
cpp复制Shape* s = PrototypeRegistry::instance().create("circle");
新增一种图元类型的成本,变成了“写一个类 + 注册一行代码”,不用再改任何创建分发逻辑。
4.2 静态注册:让类自己报名
上面这种手动注册方式有一个小瑕疵:你需要记住在程序启动时调用 registerAllShapes()。如果项目大了,很容易漏掉某个类型的注册。
一个更“自动”的做法是用静态变量注册。在类的实现文件里定义一个静态注册对象,它在程序初始化时自动注册自己:
cpp复制// circle.cpp
namespace {
bool registerCircle() {
PrototypeRegistry::instance().registerPrototype(
"circle", make_unique<Circle>(10));
return true;
}
bool registered = registerCircle(); // 静态初始化时自动注册
}
这种方式的好处是:新增类型只需要添加新的 .cpp 文件,主程序一行不用动。缺点是静态初始化顺序不可控,如果 PrototypeRegistry::instance() 依赖了其他静态对象,可能出现未定义行为。工程上我一般建议先用显式注册,等注册点确实变多了,再考虑静态注册。
4.3 原型 + 工厂:组合拳的打法
前面我说过,原型注册表本身已经可以替代大部分简单工厂逻辑。但事实上,原型模式和工厂模式并非互斥关系,它们经常组合使用。
一种典型组合是:用注册表管理“模板原型”,用工厂方法封装“创建并初始化”的过程。
cpp复制class EnemyFactory {
public:
EnemyFactory() {
// 初始化阶段,注册所有敌人原型
registry_.registerPrototype("soldier", make_unique<Soldier>());
registry_.registerPrototype("boss", make_unique<Boss>());
}
// 创建敌人,并根据关卡调整参数
unique_ptr<Enemy> spawnEnemy(const string& type, int difficulty, int posX, int posY) {
auto enemy = registry_.create(type); // 克隆原型
enemy->setDifficulty(difficulty);
enemy->setPosition(posX, posY);
return unique_ptr<Enemy>(enemy);
}
private:
PrototypeRegistry registry_;
};
这样,工厂负责“出生”时的初始化,原型负责“复制模板”,各司其职。将 unique_ptr 和裸指针混用时须注意所有权转移。
4.4 原型复制的属性覆写:参数化克隆
另一个实用小技巧是在克隆后覆写属性。比如编辑器里复制一个带默认样式的按钮,复制完成后立刻修改它的文本和颜色。有两种做法:
- 先 clone,再调用
setText、setColor; - 或者为 clone 设计一个可传参数的版本:
cloneWith(properties)。
前者通用性更好,大部分代码路径都只需要默认的 copy。后者适合特定场景下需要一步到位的批量创建。我倾向于前者,理由很简单:保持 clone 接口的统一性,覆写逻辑分散在调用方更灵活。
5. 原型模式和工厂模式的取舍:不是二选一,而是合适优先
有经验的读者看到这里可能会想:我到底是用原型模式,还是继续用工厂模式?这是一个非常实际的问题,很难用一个简单的“谁更好”来回答,得看场景。
5.1 两种模式的核心差异
先摆一个对照表,方便做决策:
| 维度 | 原型模式 | 工厂模式 |
|---|---|---|
| 创建方式 | 复制一个已有实例 | 调用构造函数创建新实例 |
| 类型依赖 | 不依赖具体类型 | 工厂内部往往依赖具体类型的分发 |
| 对象初始化开销 | 复制成本,可能高或低取决于深浅拷贝 | 构造成本,通常比复制便宜 |
| 新增类型 | 新增类 + 注册,外部代码不变 | 新增类 + 修改工厂分支 |
| 每个对象是否有默认值 | 原型自带默认状态 | 工厂构造时需逐一指定 |
| 适用场景 | 类型多、运行时才确定、需要复制模板 | 类型稳定、构造参数明确 |
最直观的差异:工厂模式是“从无到有”,原型模式是“从有到有”。
如果创建一批对象时,它们初始状态基本一致,只是个别属性不同,原型模式会非常高效——直接克隆默认模板,再覆盖差异点。如果每个对象都是完全不同的参数组合,工厂模式更自然。
5.2 什么时候优先用原型
按照我的经验,下面这些信号出现时,优先考虑原型模式:
- 类型集合在运行时才完整:插件系统、动态加载、游戏模组,用户可能在运行阶段新增类型,原型注册表比硬编码工厂分支优雅得多;
- 需要保持每类对象的“默认状态”:比如编辑器里的“图形模板”,每种图形都保存了一套默认样式,直接 clone 模板得到底层状态一致的对象;
- 拷贝是一等需求:撤销、复制粘贴、快照,这些功能天然就是“复制现有对象”,原型模式直接匹配;
- 避免工厂类爆炸:当类型多到一定程度,工厂里的 if-else 分支会变成维护噩梦,原型模式把创建能力分散到各个类本身,消除集中的分发逻辑。
5.3 什么时候工厂模式更合适
反过来,原型模式也有明显的短板:
- 每个类都要实现 clone:一个类层级的类很多时,clone 的实现和维护是额外负担;
- 深拷贝语义容易失控:类成员里有复杂指针关系时,clone 的深拷贝逻辑写错就是潜伏的内存 bug;
- 复制成本高:如果对象内部有大量数据,频繁克隆的代价可能高于构造。
如果项目中类型数量稳定、创建参数明确、又没有复制需求,那么工厂模式是更轻量可靠的选择。不要为了用而用。
5.4 一个真实场景的取舍过程
我之前在游戏项目里同时用过两种模式,最后的划分方式很有参考价值:
- 技能系统:种类极多,每个技能都有一个默认配置,玩家释放技能时需要基于模板复制实例,还要叠加等级加成。这里我用原型注册表,把每种技能的类型和默认状态绑定在一起。新增技能时,只需写技能类,配置一个默认实例注册进表里。
- 关卡场景中的障碍物:种类有限,创建逻辑简单——读配置文件,构造对应的障碍物对象。这里我用传统的工厂方法,每个障碍物类型对应一个构造函数调用。
这让我体会特别深:模式不是互相替代的关系,它们是工具箱里不同的工具,哪个顺手用哪个。
6. 完整示例:一个可直接运行的 C++ 原型模式应用
理论讲了这么多,不如直接看一个完整的、能编译运行的工程示例。这个例子模拟一个简单的“图形编辑器”原型注册表,包含两个具体图形类型,展示克隆、注册和使用的完整链路。
6.1 代码实现
cpp复制#include <iostream>
#include <memory>
#include <string>
#include <unordered_map>
// 抽象基类
class Shape {
public:
virtual ~Shape() = default;
virtual Shape* clone() const = 0;
virtual void info() const = 0;
};
// 具体图元:圆形
class Circle : public Shape {
public:
explicit Circle(double radius) : radius_(radius) {}
Shape* clone() const override {
return new Circle(*this);
}
void info() const override {
std::cout << "Circle, radius = " << radius_ << std::endl;
}
void setRadius(double r) { radius_ = r; }
private:
double radius_ = 0.0;
};
// 具体图元:矩形
class Rectangle : public Shape {
public:
Rectangle(double w, double h) : width_(w), height_(h) {}
Shape* clone() const override {
return new Rectangle(*this);
}
void info() const override {
std::cout << "Rectangle, " << width_ << " x " << height_ << std::endl;
}
void setSize(double w, double h) {
width_ = w;
height_ = h;
}
private:
double width_ = 0.0;
double height_ = 0.0;
};
// 原型注册表
class PrototypeRegistry {
public:
static PrototypeRegistry& instance() {
static PrototypeRegistry reg;
return reg;
}
void registerPrototype(const std::string& type, std::unique_ptr<Shape> proto) {
prototypes_[type] = std::move(proto);
}
// 返回裸指针,调用方负责用 unique_ptr 接管
Shape* create(const std::string& type) const {
auto it = prototypes_.find(type);
if (it == prototypes_.end()) {
return nullptr;
}
return it->second->clone();
}
private:
PrototypeRegistry() = default;
std::unordered_map<std::string, std::unique_ptr<Shape>> prototypes_;
};
int main() {
auto& registry = PrototypeRegistry::instance();
// 注册原型模板
registry.registerPrototype("circle", std::make_unique<Circle>(10));
registry.registerPrototype("rectangle", std::make_unique<Rectangle>(4, 5));
// 从原型创建实例,并修改副本属性
std::unique_ptr<Shape> shape1(registry.create("circle"));
shape1->info();
// 这一段用 static_cast 演示了修改具体类型的操作
Circle* circleCopy = static_cast<Circle*>(shape1.get());
circleCopy->setRadius(20);
std::cout << "After modifying the copy:" << std::endl;
shape1->info();
std::unique_ptr<Shape> shape2(registry.create("circle"));
shape2->info();
std::unique_ptr<Shape> shape3(registry.create("rectangle"));
shape3->info();
// 查找不存在的类型
if (registry.create("triangle") == nullptr) {
std::cout << "Triangle not registered, got nullptr." << std::endl;
}
return 0;
}
6.2 运行结果
text复制Circle, radius = 10
After modifying the copy:
Circle, radius = 20
Circle, radius = 10
Rectangle, 4 x 5
Triangle not registered, got nullptr.
注意第二个“Circle, radius = 10”是从原型再次克隆出来的,完全没有被第一次修改影响。这就验证了 clone 的深拷贝语义——如果 clone 是浅拷贝,第二次创建出来的对象会共享半径数据,应该打印出 20。这正是原型模式不可妥协的边界:默认必须深拷贝,副本之间互不干扰。
6.3 这段代码里的工程细节
代码看着简单,但里面埋了几个值得注意的细节:
- 注册表用
unique_ptr<Shape>持有原型,注册表销毁时自动释放原型对象; create返回裸指针,调用方用unique_ptr接管,所有权转移清晰;clone()返回裸指针,和unique_ptr搭配时注意直接用unique_ptr<Shape>(registry.create(...))构造,避免中间裸指针管理失控;- 查不到类型时返回
nullptr,调用方要警惕空指针,项目里可以在调用处做防御性检查。
6.4 如果把 clone 改成 shared_ptr 会怎样
有些代码风格喜欢让 clone 返回 shared_ptr<Shape>,这样可以完全不用裸指针中转:
cpp复制std::shared_ptr<Shape> cloneShared() const {
return std::shared_ptr<Shape>(clone());
}
好处是调用方直接 auto p = shape->cloneShared(); 即可,内存管理更安全。代价是 clone 接口不再是单纯的“返回新对象”,而是隐含了“共享所有权”的语义。两种风格我都用过,如果是新项目且团队没有历史包袱,我更推荐返回 shared_ptr 的做法,配合现代 C++ 风格更顺手。
7. 工程落地中最容易踩的五个坑与规避方式
原型模式代码写起来容易,但落地过程中,下面这五个坑如果不注意,会让你的程序变成玄学崩溃现场。
7.1 坑一:忘记定义虚析构函数
前面反复提到过,再强调一次:基类析构函数不是虚函数,多态删除就是未定义行为。很多原型模式示例代码为了精简,把析构函数省了,这在教程里可以,但工程代码必须写。
cpp复制class Shape {
public:
virtual ~Shape() = default; // 必须
virtual Shape* clone() const = 0;
};
7.2 坑二:clone 做成浅拷贝却不自知
这是最常见的隐藏 bug。类成员里有裸指针、栈上大对象时,你很可能在 clone 时不小心做成了浅拷贝。规避方法是:
- 新代码尽量用
std::string、std::vector、std::shared_ptr这类 RAII 成员,让默认拷贝构造自动深拷贝; - 必须手写深拷贝时,clone 里要显式处理所有动态分配的成员,不要指望编译器帮你;
- 写完 clone 后,做一个“修改副本不影响原对象”的单元测试。
7.3 坑三:clone 不返回相同类型的指针
比如基类 clone() 返回 Shape*,子类 override 时返回的却是 Circle*。C++ 支持协变返回类型(covariant return type),这是合法的:
cpp复制class Circle : public Shape {
public:
Circle* clone() const override; // 返回更具体的类型
};
用协变返回类型可以让调用方在明确知道具体类型时少一些 cast。但如果项目里 clone 经常被子类 override 成不同类型,要注意一致性问题——一个类层级里,有的返回基类指针,有的返回派生类指针,会让使用方困惑。我建议统一返回基类指针,保持接口一致性。
7.4 坑四:递归克隆导致的性能灾难
如果一个对象层级很深,对象里持有大量其他对象,clone 可能触发底层的递归深拷贝。比如一个 Node 持有子 Node 列表,clone 根节点时会递归克隆整个子树。这在数据量小的时候没问题,但如果每个对象都这样克隆,性能会很难看。
规避方案有几个:
- 明确 clone 的深度:一层拷贝还是深拷贝,设计文档里写清楚;
- 在需要递归复制的场景,考虑 COW 或 shared_ptr 共享不可变子树,只有修改时才真正分支;
- 在克隆热点路径上做性能基线测量,不要凭感觉优化。
7.5 坑五:注册表里的原型被意外修改
这是原型模式特有的坑。原型对象相当于“模具”,但如果你在某个地方拿到了原型的非 const 引用并修改了它,那之后所有从该原型克隆出来的对象都会带上次修改的默认值。
规避方式:
- 注册表对外暴露 create 接口时,不要暴露
getPrototype()的裸引用; - 保持原型对象的不可变性:原型只负责提供默认状态,一旦注册,不应再被修改;
- 如果确实需要修改模板,通过注册表提供的更新的接口,经过 log 和确认,而不是直接拿到指针去改。
我在项目里用了一个土办法:注册表只存 const Shape*,对外只提供 clone() 操作。这样原型在注册后就是“只读模板”,从制度上避免了误改。
8. 现代 C++ 语境下的原型模式:写法演进
原型模式是 GoF 年代提出的,但现代 C++ 的语法和标准库让它的写法有了不少变化。
8.1 unique_ptr 接管裸指针时代
GoF 原书里的示例用的是裸指针 new,因为那个年代还没有智能指针。现代 C++ 工程里,clone 返回的裸指针必须被智能指针接管,否则很容易泄漏。前面示例里的 unique_ptr<Shape>(registry.create("circle")) 就是这种思路。
8.2 std::enable_shared_from_this 场景下的 clone
当对象本身是通过 shared_ptr 管理的,clone 时如果想返回 shared_ptr,有个细节:如果在 clone 里调用 shared_from_this() 会抛 bad_weak_ptr 异常,因为新对象还没有被 shared_ptr 管理。正确的做法是直接 new 一个对象再放进 shared_ptr,比如:
cpp复制class Widget : public std::enable_shared_from_this<Widget> {
public:
std::shared_ptr<Widget> cloneShared() const {
return std::shared_ptr<Widget>(new Widget(*this));
}
};
注意不要写成 return shared_from_this()——那返回的是原对象的 shared_ptr,不是复制品。
8.3 基于模板的 clone 基类
为了减少每个子类都要写 clone 的样板代码,可以用 CRTP(Curiously Recurring Template Pattern)做一个 clone 辅助基类:
cpp复制template <typename Derived>
class Cloneable {
public:
virtual ~Cloneable() = default;
virtual std::unique_ptr<Derived> clone() const = 0;
};
class Circle : public Cloneable<Circle> {
public:
std::unique_ptr<Circle> clone() const override {
return std::make_unique<Circle>(*this);
}
};
这样每个子类的 clone 返回值已经是 unique_ptr<Derived>,省去了调用方的裸指针管理。缺点是模板让类型层次关系变复杂,如果项目读者对模板不熟,可读性会有折扣。小团队内部使用尚可,大型跨团队项目建议保持简单直接的写法。
8.4 constexpr 与原型模式有关系吗
搜原型模式相关热词时,有人会把 constexpr 拉进来问。严格说,constexpr 是编译期求值,原型模式是运行期复制,两者不直接相关。但如果你在编译期就能确定所有模板对象的内容,可以考虑用 constexpr 构造原型对象,减少运行时初始化开销。这个用法偏小众,一般项目用不到太多。
8.5 新代码风格的核心建议
综合下来,现代 C++ 实现原型模式的推荐风格是:
- clone 返回
std::unique_ptr<Base>或std::shared_ptr<Base>,避免裸指针; - 类的成员尽量用 RAII 类型,让编译器默认生成深拷贝;
- 注册表持有原型时用
std::unique_ptr<Base>,对外只暴露 create 接口; - 基类虚析构永远写好;
- 如果需要参数化克隆,在 clone 之后调用 setter,不要污染 clone 的接口签名。
9. 原型模式在真实项目中的一些进阶思考
到这里,原型模式的核心机制和实施细节已经讲完了。最后聊聊我在真实项目中使用原型模式的一些进阶体会。
如果你要在自己的项目里落地原型模式,建议按下面的顺序来:
- 先确认确有“复制”需求,不要为了模式而模式;
- 从最简单的虚 clone 开始,先不加注册表,等创建分发逻辑变复杂了再引入注册表;
- 所有涉及 clone 的类,第一个版本就用深拷贝语义;
- 为 clone 写单元测试:克隆对象、修改副本、验证原对象不变;
- 再往下,如果类型数量爆炸,再把注册表、静态注册这些机制加进来。
我在实际项目里踩过比较多的坑是:一开始图省事,clone 里直接浅拷贝,等到一份副本牵动原对象的 bug 出现才回头改。改的时候因为对象关系复杂,深拷贝逻辑写了整整两天,还引入了新的问题。这让我后来对“复制语义”这件事特别敏感,任何类定义了 clone,默认就按深拷贝来写,没有例外。
还有一个体会是:原型模式能让系统“活起来”。当你的代码里不存在那个巨大的类型分发 if-else 时,新增一个类型变成一件很轻的事,写新类型的同事不用去读别人的创建逻辑,他自己就把事情办完了。这种对开发效率的提升,往往比模式本身带来的运行期收益更大。
如果你现在正在纠结某个对象的创建逻辑该怎么设计,可以先把代码里类型分发的地方找出来,数一数有多少处 if-else 或 switch 是“根据类型决定创建什么对象”。如果这个数字已经让你觉得头疼,原型模式大概率是合适的解药。
