精读《C++20设计模式》——创造型设计模式:原型模式
精读《C++20设计模式》——创造型设计模式:原型模式
现在我们来到了第三个创造型的设计模式,也就是Prototype,原型模式中的“原型”指的是一个被克隆的原始对象实例。听着好像有点奇怪。但是别着急,咱们慢慢来谈论这个设计模式(我需要承认一点,这个设计模式我几乎没有自己设计过,用的时候也是用过别人写好的)
所以,什么是原型模式?
原型模式中的“原型”指的是一个被克隆的原始对象实例。在该模式中,我们通过复制这个已存在的原型对象来创建新对象,而不是通过新建类或依赖复杂的初始化过程。原型对象充当了创建新对象的模板,克隆过程可以由原型自身或一个专门的克隆方法(如
clone()
)实现,它根据当前原型的状态创建一个新对象。这种方式特别适用于创建成本较高的对象,或当系统需要独立于具体类来创建新实例时。
有种看教科书看到头晕眼花感觉不知所云的感觉了。整理一下说人话:
大型的软件工程项目,我们总是跑不了创建一簇复杂对象——面对“从零创建一个复杂对象”的场景,咱们采用的是构造器模式,面对具备同一逻辑的一类对象的创建,咱们使用的是工厂模式和他们的变种。这些是我们已经早有定论的。
构造器模式:精读《C++20设计模式》——创造型设计模式:构建器系列-CSDN博客
工厂模式:精读《C++20设计模式》:创造性模式——工厂方法和抽象工厂模式-CSDN博客
现在我们想,如果是同一个类的对象产生大量的实例,他们之间仅仅只是值不一样。举个例子:
- 有一堆小怪,他们只是数值略有区别,但是他们都是哥布林。我们是否有必要每次都要从0创建一大堆哥布林呢?
- 我们现在需要描述1000个人的办公地点,区别仅是所在的办公室的门牌号不太一样,看起来我们每创建一个人就要去请求一下全国的个人信息数据库获取他所在的省市地址太蠢了
- 对于复杂对象创建,任何其他实际上只有部分值存在差异的场景
这就是原型模式的用武之地,它的核心思想很简单:把一个“已存在”的对象当成模板(原型),通过复制它来创建新对象,而不是每次都从头 new、执行复杂初始化或调用一堆工厂/构造器逻辑。我们可以节约大量的时间(比如说不用在去重新请求一些耗时操作重复的初始化)
做最简单的原型模式
说的挺吓人的,但是实际上,下面就是一种最直白的原型模式:
struct Address {
public:// some other details...std::string door_number;bool accessible_current {};
};// init the proto somewhere
Address proto;
// ....
Address other = proto; // copy the proto
other.door_number = "A501"
other.accessible_current = true; // OK, we have get a new Address
看到了?其实还是很简单的。这里,我们的Address如果不是平凡类型的,自然还需要好好定义一下如何正确的处理拷贝对象这个工作。这个是C++的基本功问题,笔者不在这里做出讨论。
考虑问题:那对于复杂继承体系该怎么办?
然而,我们要面对一个很现实的问题——我们的工程中的对象很有可能是非常复杂的。举个例子,我们在一个处理链条中,发生了这个情况。在之前,我们可能习惯的将上面的操作封装成了一个函数:
Address* make_from_proto(Address* a, const std::string& door_number){Address* t = new Address(*a); // 或者是其他的办法处理,这里笔者决定用一种最短的方式展开t->door_number = m;return t;
}
在之后,我们可能会扩展我们的Address的功能,比如说搞一个ExAddress
struct ExAddress : Address {...};
问题来了,这个时候我们的make_from_proto拷贝是不完全的——因为我们传递信息的时候已经传丢了我们的地址类是ExAddress而不是Address,我们还不能修改这个接口,因为其他人可能也在派生这个类做自己的事情。所以,答案是将具体的克隆内置到我们自己的Address类中,并且标记上virtual。
struct Address {
public:// some other details...std::string door_number;bool accessible_current {};// New ⭐virtual Address* clone(){ ... }; // 返回自己的拷贝
};
然后,每一个子类去实现自己的clone机制:
struct ExAddress : Address {virtual ExAddress* clone() override { ... }
};
你可以看到现在,我们只需要修改一下:
Address* make_from_proto(Address* a, const std::string& door_number){Address* t = a->clone();t->door_number = m;return t;
} // 一切照常
不是原型模式自身的内容,但是对于C++我们特别需要注意:
如果对象含有指针/资源(裸指针、
std::shared_ptr
、文件句柄、内含容器等),单纯的位拷贝/默认拷贝构造可能只做浅拷,导致多个实例共享底层资源或悬空指针。clone()
应明确实现为语义正确的深拷或按需共享(比如共享只读资源就可以用shared_ptr
)。示例:含动态数组或指针的对象:
struct Foo {std::vector<int> data; // vector 默认拷贝会复制元素 -> 实际上是深拷(对内置元素)std::shared_ptr<SomeLargeBlob> blob; // 这里拷贝会共享底层数据(shared_ptr),若需复制必须显式clonestd::unique_ptr<int> p; // unique_ptr 强制移动,不可拷贝 —— clone 必须手动 new };
这就回到了我们说的
clone()
,它的实现应当清晰地处理成员的复制语义 —— 哪些是深拷,哪些是共享,哪些需要重新初始化。
把原型聚集起来:原型注册表(Prototype Registry)
咱们在工厂模式已经玩过这个小花招了。我们完全可以将最多用的一些原型集中管理。我们只请求一次原型们的初始化,而不需要调用其他接口找到了原型在将原型塞给原型构造器构造我们的对象。
#include <unordered_map>
#include <string>
#include <memory>
#include <iostream>class Widget {
public:virtual ~Widget() = default;virtual std::unique_ptr<Widget> clone() const = 0;virtual void draw() const = 0;
};class Button : public Widget {
public:std::string label;std::unique_ptr<Widget> clone() const override {return std::make_unique<Button>(*this);}void draw() const override { std::cout << "Button: " << label << "\n"; }
};class WidgetFactory {std::unordered_map<std::string, std::unique_ptr<Widget>> protos;
public:void register_proto(const std::string& name, std::unique_ptr<Widget> proto) {protos[name] = std::move(proto);}std::unique_ptr<Widget> create(const std::string& name) const {auto it = protos.find(name);if (it == protos.end()) return nullptr;return it->second->clone();}
};
这给我们后面的配置化实际上打下了样板,但是这就逐渐远离我们的主题了,暂且不论。
总结一下!
我们在试图解决什么问题?
我们在工程里频繁创建一类结构相同但部分字段不同的复杂对象时,如何高效、可靠地生成这些对象?
展开的说,包括:对象从零构造很昂贵(I/O、计算、远程请求、复杂初始化);类层次存在多态/派生,直接拷贝会发生切片或丢失信息;对象含有资源(裸指针、unique_ptr、文件句柄等),需要合理的深拷/共享语义。
我们如何解决(一组可选方案)
下面把常见方案分条列出,每项给出做法、优点与缺点、适用场景。
最简单的原型(直接拷贝/赋值)
- 做法:事先准备好一个或几个“原型对象”,通过拷贝(
proto
->other = proto
)得到新实例,再修改少量字段。 - 优点:实现极其简单;对 POD / 无需特殊资源的类型非常快。
- 缺点:若存在继承、多态或资源成员,将出现切片或错误共享;拷贝语义(浅/深)需小心。
- 何时用:类型平凡、没有动态资源,或在单一类内大量生成相似对象。
多态 clone()
(典型的原型模式实现)
- 做法:基类声明
virtual std::unique_ptr<Base> clone() const = 0;
,每个派生类实现自己的clone()
(通常调用拷贝构造或按语义复制成员)。 - 优点:保留动态类型(无切片);实现自定义深拷语义;结合智能指针更安全。
- 缺点:每个类需实现 clone(维护成本);clone 的深拷也可能昂贵。
- 何时用:存在继承体系、需要运行时以原型模板创建具体派生实例时。
原型注册表(Prototype Registry)
- 做法:把常用原型放在 map/registry 中(按名字或 id),需要时 lookup 并
clone()
。 - 优点:配置化、便于脚本/配置驱动创建;避免重复初始化成本。
- 缺点:需要管理注册/生命周期;若原型状态可变,需注意并发/一致性。
- 何时用:游戏实体、UI 主题、配置化对象库等需要运行时按模板生成大量对象的场景。
方案 | 多态/派生 支持 | 性能(创建) | 实现难度 | 资源语义控制 | 适用场景 |
---|---|---|---|---|---|
直接构造 | ✅ | 取决于初始化 | 低 | 明确(没有共享) | 简单对象或需要严格初始化 |
工厂 | ✅ | 与直接构造类似 | 中 | 由实现决定 | 类型选择集中管理 |
直接拷贝原型 | ❌(切片风险) | 快(浅拷) | 低 | 容易误用浅拷 | POD/无资源对象 |
多态 clone() | ✅ | 快于完整构造(视 clone 实现) | 中 | 明确可控(深/浅) | 有继承、需模板化创建 |
原型注册表 | ✅ | 与 clone 相同 | 中 | 与 clone 相同 | 配置化模板 |