如果你在 C++ 里混得久一点,大概都走过这么一条路:
- 刚入行那会儿,哪儿要对象就
new,心里只有“能跑就行”; - 项目一胖起来,
new满地乱窜,谁也不敢乱改,只好拉一层工厂把它们关起来; - 再往后业务一层层压上来,你发现最花时间的,已经不是“造对象”,而是“把一整套配置慢慢调顺”。
走到这一步,你就会明白:真正贵的,不是那一个 new,而是那份“终于调对了的样板姿势”。
而“同款再来一份”,本质上就是——我不想重走一遍流程,只想在这份样板上再复制一份、改几刀。
Prototype(原型模式)干的事也就一句话:
先让系统里长出一批“调顺的对象”,把它们当样板;
后面要同款,就别从零重造,先 clone() 一份,再在上面微调。
听起来很香,对吧。
可一落到 C++ 代码里,老问题就接踵而至:多态对象怎么复制才算“同款”?按值一拷为什么会切片?clone() 出来的东西又该谁来 delete?
1. 先回答一个疑问:为什么要“复制”,而不是“重新构造”?
你的直觉没错:如果对象很好构造,那当然可以每次都重新 new 一个新的。
另外也要先把一个常见误解说清楚:Prototype 里的“复制”不是“复用同一个对象”。 你依然是在拿到一个全新的实例,只是它的构造方式不是“从零填参数”,而是“从一份已经配好的样板拷一份状态,再做少量调整”。
Prototype 真正想解决的不是“有没有能力 new 一个对象”,而是下面这类更工程化的痛点:
1.1 配置很长,但每次只想改一两项
当配置项一多,重建的麻烦往往不在“写代码”,而在“你得把每个字段都想起来”。
struct JobConfig {
int threads = 8;
int retry = 3;
int timeoutMs = 5000;
int priority = 10;
};
JobConfig base;
JobConfig cfg = base;
cfg.timeoutMs = 8000;
这里的要点是:
base把“默认怎么配”这件事打包成一个对象;- 你创建新配置时,不需要重新把每个字段再写一遍;
- 你只改那 1-2 个真正要变的点。
1.2 构造过程很重,甚至包含一张对象图
有些对象的创建不是“填几个字段”,而是:读配置、建对象图、做一堆初始化。
auto* pipeline = buildPipelineFromJson(jsonText);
auto* p2 = pipeline->clone();
p2->setTimeoutMs(8000);
delete p2;
delete pipeline;
这里第一次出现 clone(),需要先把它讲清楚:
clone()的意思是:基于当前对象,创建一个“内容相同但地址不同”的新对象。- 也就是说,
clone()返回的是一个全新的实例,而不是把原对象“再引用一遍”。
你可以把 clone() 粗略理解为“把拷贝构造函数变成一个可多态调用的接口”。
1.2.1 clone() 和拷贝构造有什么区别?
先给一个“只用于解释概念”的最小例子(更完整的版本在第 3 节):
struct Shape {
virtual Shape* clone() const = 0;
virtual ~Shape() = default;
};
struct Circle : Shape {
double r = 1.0;
Shape* clone() const override {
return new Circle(*this);
}
};
当你知道确切类型时,拷贝构造就够用了:
Circle a;
Circle b = a;
但当你手里只有一个基类引用/指针(多态场景)时,拷贝构造就会卡住:不是你不会拷,而是你在调用点根本不知道“该拷成谁”。
一个更贴近工程的场景是:你在做一个图形编辑器/流程编排器,所有图元/节点都装在多态容器里,用户选中一个对象按“复制一份”:
std::vector<Shape*> shapes = /* ... */;
Shape* selected = shapes[0];
此时你的动机非常朴素:
- 我只拿到了一个
Shape*; - 我想得到一个“同款新对象”(动态类型不变、状态也先复制过去);
- 然后我再改 1-2 个字段。
你会自然想到拷贝构造,但很快会发现两种常见直觉写法都不行:
- 你没法写
Circle b = *selected;,因为selected的静态类型是Shape*,编译器不知道它是不是Circle。 - 你如果退一步想“按基类值去拷贝”(比如
Shape copy = *selected;),要么编译不过(基类在真实项目里往往是抽象类),要么即使能编译也会发生对象切片。
为什么会切片?因为你此时要得到的是一个 Shape 值对象:
Shape copy = ...这句已经把“目标对象的静态类型/大小”钉死成Shape;- 复制时最多只能把“那一段
Shape子对象”拷进去; - 派生类比
Shape多出来的那部分数据没地方放,只能被丢掉。
拷贝构造不参与多态(不是虚函数)——你拿着 Shape*,就无法“自动按动态类型”复制出一个新对象。
你以为“那我 new 一个基类,把 *selected 拷进去不就完了”,但现实里常常直接编译不过。
Shape* p = new Shape(*selected);
这行在真实项目里通常会直接编译失败(因为 Shape 有纯虚函数,不能实例化)。
对象切片的最小复现长这样:
struct Shape {
int color = 0;
virtual ~Shape() = default;
};
struct Circle : Shape {
double r = 1.0;
};
Circle c;
Shape s = c; // slicing:s 里只剩 Shape 部分,Circle::r 被切掉
切片发生在“把派生类对象拷贝成一个 Shape 值”的那一刻;如果你只是拿 Shape& / Shape* 指向 Circle,并不会切片。
很多代码库在没有 clone() 之前,最后只能靠 RTTI 去“猜类型再拷贝”,比如:
Shape* duplicate(Shape* s) {
if (Circle* c = dynamic_cast<Circle*>(s)) return new Circle(*c);
return nullptr;
}
这个写法能跑,但它把“复制一个对象”的细节变成了上层的负担:一旦你新增别的派生类,上层就得继续加分支。
这时 clone() 的价值就出来了:
Circle a;
Shape* s = &a;
Shape* c = s->clone();
delete c;
这里 c 指向的是一个动态类型仍然是 Circle 的新对象:调用点不需要知道派生类是谁,只需要调用 clone()。
当“构造成本”很高时,clone() 的价值就很直观:
- 样板对象在启动阶段构造一次;
- 运行时按样板复制,再做少量微调。
1.3 调用方不知道确切类型,但就是要“同款来一份”
在插件化/多态体系里,上层常常只拿到一个 Shape*,并不知道它的真实派生类型。
这时“重新构造”会逼着你做 RTTI 分支或把构造细节泄漏给上层。
Prototype 的思路是:
- 上层不负责猜类型;
- 对象自己负责“复制我自己”。
有了这些动机之后,再回头看 C++ 里为什么会流行 virtual clone()(虚拟拷贝构造),就比较自然了。
工厂家族主要解决的是第一类问题:
“我要新搞一个对象,从无到有,怎么搞得干净点?”
原型模式关心的是第二类:
“我这已经有一个配好了的对象, 现在就想照着它再复制一份, 能不能别每次都从零开始重新装修?”
在不少老项目里,你能看到这样的经典画面:
- 系统启动时,先在某个初始化阶段,把一堆“模板对象”配好;
- 业务代码需要新实例时,不是重新配置,而是先复制模板,再改几处关键参数;
- 刚开始大家拿
memcpy或手写copy硬抄; - 慢慢地,某个老同事受不了,提了一句:
“要不,咱们干脆在基类上约个
clone(), 让对象自己负责怎么复制自己算了?”
这背后,就是原型模式(Prototype)最朴素的出发点:
- 在基类上约定一个
clone()接口; - 每个派生类自己决定“我该怎么复制我自己”;
- 上层只需要握着一个多态指针, 在不知道确切类型的前提下, 就能复制出一份“一模一样的新对象”。
GoF 那本《Design Patterns》里就专门单独拎出了 Prototype, 但真正把它玩明白、玩深入的,其实是 C++ 这一拨人:
- 一边要面对“对象切片、拷贝构造、多态”这些底层细节;
- 一边还要在 GUI 库、游戏引擎、工作流引擎里, 给策划 / 产品 / 业务提供“按样板房复制对象”的体验。
下面我们就从 C++ 里非常典型、
也非常容易踩坑的“对象切片”讲起,
顺便看一眼:为什么一大摊老 C++ 代码里,都有个 virtual clone()。
2. 对象切片与多态拷贝:拷贝构造并不总够用
这一节要解决两个问题:
- 你为什么会“拷贝成功但内容不对”(对象切片);
- 你为什么在
std::vector<Shape*>里很难“正确地复制一份”。
2.1 为什么多态容器特别容易触发切片?
很多人第一次写多态容器时,会下意识写成“装值”的容器:
std::vector<Shape> shapes;
shapes.push_back(Circle{}); // Circle -> Shape,发生切片
这行代码看起来像“塞进一个 Circle”,实际发生的是:
Circle{}先被转换/拷贝成一个Shape值;vector<Shape>里永远不可能保存派生类那部分。
为了保住动态类型,你通常会改成“装指针”:
std::vector<Shape*> shapes;
shapes.push_back(new Circle());
for (Shape* p : shapes) delete p;
这一步解决了“切片”,但新的问题来了:怎么复制它?
2.2 多态对象的复制:难点不在 new,而在“我不知道你是谁”
如果你希望“复制一份容器再改动”,直觉会让你想这么写:
std::vector<Shape*> copy = shapes; // 浅拷贝:只是把指针值复制了一份
这行代码的结果通常是:能编译、也能运行,但它并不是你想要的“复制一批 Shape 对象”,只是复制了指针。
这会把你带进另一个常见坑:
- 两个容器里的元素指向同一批对象;
- 你如果在两处都
delete,很容易 double free; - 你如果不
delete,又会漏。
而更关键的是:即便你决定“我要深拷贝”,你仍然绕不过一个核心事实:
- 你手里只有
Shape*/Shape&,并不知道它到底是Circle还是Rectangle; - “按值拷贝 Shape” 会切片;
- “按类型分支 + dynamic_cast” 会把你拖进一坨 if/switch 的维护地狱。
原型模式的落点就是:
在基类上约定一个
clone(),让对象自己负责“怎么复制我自己”。
3. 一个经典的 C++ 原型写法:virtual clone()
在 C++ 圈里,Prototype 最接地气的一种写法就是:
在基类上约定一个
virtual clone()。
老 C++ 人不一定叫它 Prototype,更爱说“虚拟拷贝构造”(virtual copy constructor)。原因很朴素:拷贝构造只在你“知道确切类型”的时候好使,一旦上升到 Shape* 这层,多态就把你挡在门外。再加上 C++ 没有“虚构造函数”,想在基类指针上得到派生类的新对象,最后大家就约定了这么一个 clone()。GoF 只是把这套江湖规矩写进书里,给了它一个正式名字。
3.1 基类约定:我能被克隆
先把“复制能力”提升到多态层次:
struct Shape {
virtual void setColor(int c) = 0;
virtual Shape* clone() const = 0;
virtual ~Shape() = default;
};
这里放一个 setColor() 只是为了演示“clone() 之后做少量配置调整”的使用方式:它代表“基类上暴露的最小可配置接口”,并不意味着原型模式一定要在基类上设计这种 setter。
这一句的含义很具体:
- 只要你是
Shape,你就必须回答“我怎么复制我自己”; - 调用方只需要知道你是
Shape,并不需要知道你具体是谁。
3.2 派生类实现:复制自己最懂自己
派生类的 clone() 一般非常短:
struct Circle : Shape {
double r = 1.0;
int color = 0;
void setColor(int c) override {
color = c;
}
Shape* clone() const override {
return new Circle(*this);
}
};
这段代码等价于说:
- “复制一个 Circle”的细节交给
Circle的拷贝构造; clone()只负责把返回类型擦成Shape*(多态接口)。
这里不会产生对象切片:
- 对象切片发生在“按值拷贝成一个
Shape值对象”时(比如Shape x = Circle{};); clone()返回的是Shape*,你拿到的是“指向新对象的指针”,对象本体仍然是Circle,只是调用方用Shape*来看它(比如Shape* p = new Circle(...);)。
如果你还有别的派生类,它们各自写各自的 clone():
struct Rectangle : Shape {
double w = 1.0, h = 1.0;
int color = 0;
void setColor(int c) override {
color = c;
}
Shape* clone() const override {
return new Rectangle(*this);
}
};
3.3 调用方复制容器:不需要知道具体类型
复制多态对象的方式就变成了固定模板:
std::vector<Shape*> copy;
for (Shape* s : shapes) {
copy.push_back(s->clone());
}
for (Shape* p : copy) delete p;
这里最关键的一点是:
- 调用方从头到尾只接触
Shape; - 新增一个派生类时,你只改“派生类自己”,调用方逻辑不动。
这就是地地道道的“原型模式”:
Shape作为原型接口,只负责说清楚:“我能被克隆”;Circle/Rectangle作为具体原型,各自决定“怎么拷贝出一个自己”;- 调用方只管拿着一个
Shape&或Shape*, 调clone()就能得到一份同款新对象。
如果你去翻一些老一点的 C++ GUI 库、游戏引擎、流程引擎的源码, 经常能看到类似签名:
- 早年:
virtual Shape* clone() const = 0;, - 也有人把所有权写进返回类型,但为了更直观地强调“谁负责 delete”,本文例子统一用
Shape*。
3.4 clone() 签名的演进:把“谁负责 delete”说清楚
你在老代码里经常能看到这样的签名:
virtual Shape* clone() const = 0;
它的问题是:
- 所有权约定是“口头的”(谁拿到谁
delete),很容易出事故; - 一旦出现 early return / exception path,
delete漏写就更常见。
当然,你也可以把所有权写进返回类型,用类型系统去约束“释放责任”,不过这超出了本文的例子范围。
这些 clone(),背后做的都是一件事:
“拿一个已经配置好的样板对象, 在多态层次上复制出一份一模一样的新对象。”
4. 原型 vs 工厂 / Builder:它到底解决的是什么?
说到这里,评论区(或者 code review)里最常出现的一句是:
“这不还是在创建对象吗?跟工厂 / Builder 比,原型究竟特别在哪?”
还是用房子的比喻更直观:
- 工厂 / 抽象工厂:
- 你在售楼处选“哪一个户型 / 哪一套家居套餐”;
- 开发商按一套既定规则重新造一套给你;
- Builder:
- 你和装修队长一起,从毛坯房开始一步步选材选方案;
- 关注的是“整个装修过程是否清晰、可控”;
- Prototype 原型:
- 你先逛一圈样板间,
- 看中其中一套之后说:“就按这套给我复制一套, 地板颜色稍微浅一点就行。”
落到代码里,你可以用一个最短对比来理解它们的差别。
4.1 工厂:从“规则/参数”出发创建
工厂更像“给我一堆参数,我按规则造一份”:
Shape* makeBigCircle() {
Circle* c = new Circle();
c->r = 10.0;
return c;
}
Shape* x = makeBigCircle();
delete x;
当你要改配置时,改动通常体现为:
- 改函数签名(参数变多);
- 改函数内部(构造过程变复杂);
- 或者引入 Builder 来把过程拆开。
4.2 原型:从“现有对象”出发复制
原型更像“这套我已经调到能跑、能看、数值也差不多了,你别让我从零再装修一遍”。
说白了就一句:
你不是想“再创建一个 Circle”。 你是想“再来一个同款 Circle”。
所以原型的姿势通常是:先搞一个样板,把它调顺;后面需要新对象,就复刻一份:
Circle proto;
proto.r = 10.0;
Shape* x = proto.clone();
delete x;
当你要“复印后微调”时,典型用法是:
Shape* y = proto.clone();
y->setColor(2);
delete y;
上面这段“微调”只是为了表达意图:真实项目里你可能不是 setColor(),而是一堆配置项、甚至一张小对象图。
但原型的核心流程不变:clone -> 小改。
4.3 什么时候 Prototype 更占便宜?
原型模式更适合这种情况:
- 对象的配置非常复杂,而且这一套配置已经比较稳定了;
- 以后你会经常“按某个模板再来一份”, 只在少数字段上做小差异;
- 复制出来的对象可能是多态层次里的任何一个派生类。
这时候当然也可以用工厂, 但体验很容易变成:
- 你得重新把所有配置参数排一遍队;
- 稍微改个默认值,就要在两三处地方一起改;
- 如果模板对象本身是运行时算出来的, 工厂函数也很难写死在代码里。
原型模式则更像是顺势而为:
- 先让系统里自然长出一批“配置好的对象”;
- 在基类上约一个
clone(); - 真正用的时候,先
clone()一份,再在上面做小修改。
一句话归纳这俩的分工:
工厂擅长“从规则出发创建新对象”, 原型擅长“从现有对象出发复制新对象”。
很多成熟系统里,这两者是同时存在、各司其职的:
- 工厂负责第一次造出样板对象;
- 原型负责后续按样板批量复制;
- Builder 则在“第一次造样板”的过程中, 把那一坨复杂初始化写得更可读一些。
5. 在 C++ 里用好 Prototype,需要注意什么?
Prototype 在书上看着挺顺, 真落到 C++ 代码里,坑还是那几个老坑——而且特别喜欢挑你赶版本的时候冒出来。
这一节我按“一个坑 + 一个短例子 + 解释”的方式拆开。
5.1 对象切片:一旦“装值”,多态就没了
对象切片在上文已经用例子解释过了,这里只保留一条工程结论:多态对象如果需要保留动态类型,就别按值存进 Base 类型里;需要“复制同款”时,走 clone() 这条路。
5.2 深拷贝 vs 浅拷贝:clone() 很短,但“拷贝语义”可能很难
很多类的 clone() 都写成这一行:
return new Derived(*this);
这行本质上是调用拷贝构造。因此真正的坑不在 clone(),而在“你的拷贝构造到底做了什么”。
一个典型风险是:类里有裸指针/手动资源,默认拷贝只会“拷指针”。
struct Bad {
int* p = nullptr;
~Bad() { delete p; }
};
Bad a;
Bad b = a; // 两个对象的 p 指向同一块资源
这类情况如果还配上 clone(),经常会演变成:
- “我 clone 出来两份”,
- “怎么一析构就炸(double free)/ 怎么数据互相串味”。
解决方向通常是二选一(取决于你想要的语义):
- 你要共享资源:用“共享所有权”的方式把共享写进类型;
- 你要独占资源:实现真正的深拷贝,让“拷贝构造/赋值”拷出一份新资源。
5.3 资源所有权:别让 clone() 变成“谁来 delete”的猜谜游戏
如果你的接口是这样:
virtual Shape* clone() const = 0;
那调用方必须记得写:
Shape* p = s->clone();- 以及某处的
delete p;
在真实业务里,只要路径一多(return/throw/多个分支),就很容易漏。
所以更推荐把释放责任说清楚,并尽量集中管理:由谁创建、由谁持有、由谁 delete,在接口和调用约定里明确下来。
于是又回到那个常见的问题:
既然有了拷贝构造,为什么还需要
clone()?
原因其实很直接:
- 拷贝构造只能在“我知道你确切类型”的时候用;
- 多态场景下,很多时候你只有一个
Shape&或Shape*; - 这个时候,
virtual clone()是极少数不用 RTTI /dynamic_cast, 就能优雅解决问题的办法之一。
你要是不信,去你项目里搜一搜:
- 有没有那种“先
new一个对象、配好一大堆参数, 再在不同地方复制来复制去”的写法? - 有没有一串
switch(type) { case Circle: ... case Rect: ... }, 里面偷偷在做“多态对象的拷贝”?
如果有,这些地方大概率都可以抽象成“原型 + 注册表”的组合:
- 一方面减轻调用方的心智负担,
- 另一方面也让新增一个派生类这件事, 更接近“只改局部、不动大局”。
说到这里,创建型这条线上的几个主力角色, 基本都露过一面了:
- 简单工厂:从满地
new到集中开工厂; - 工厂方法:当简单工厂胖到没人敢改时的自然进化;
- 抽象工厂:一整套家居套餐一起换皮;
- 建造者:给复杂对象找一个靠谱的“装修队长”;
- 原型:给配置复杂的对象准备几套“样板房”, 以后按样板复制一份、再轻微改造。
以后你再写 C++, 每次准备“创建一个对象”的时候, 可以在心里默念几句:
这次我是要“选户型 / 套餐”?
还是在“装一套复杂的房子”?
还是已经有了一套样板房, 只想按它的样子再来一份?
能把这几个问题想清楚, 往往比背下一整张 UML 图都管用得多。
模式这东西,书上画的是 UML,项目里救的是真人。