刚接触C++设计模式的时候,我总觉得原型模式是几个创建型模式里最“鸡肋”的一个。单例好歹有明确的全局访问需求,工厂模式天天见,原型模式看起来就只是“拷贝一个对象”而已,好像没什么技术含量。直到后来在项目里处理一份配置对象,需要根据运行时类型复制出大量带状态的对象,才意识到原型模式的价值被我严重低估了。
简单说,原型模式解决的问题是:当你不知道具体类型、或者创建对象的成本很高时,用已有的实例作为“模板”,通过拷贝来生成新对象。比直接new一个对象再逐个赋值,要稳妥得多,也更符合面向对象的扩展原则。
这篇文章想聊清楚三件事:第一,原型模式在C++里到底怎么设计才不踩坑;第二,深浅拷贝、切片、虚函数这些C++特有的坑怎么绕;第三,一个完整的实战案例,帮你看懂什么场景下原型模式才是最优解。适合正在学设计模式、或者想在项目里落地原型模式的C++开发者参考。
1. 先搞清楚:原型模式到底在解决什么问题
1.1 一个场景:创建对象的成本到底贵在哪
先抛一个我实际经历过的问题。某跨平台系统里有一类图形编辑器,支持用户往画布上拖入各种图形组件:圆形、矩形、折线、自由曲线。每个组件创建时都要做一堆事情——注册事件监听器、加载纹理资源、建立与图元管理器的关联关系。当时最初版本的实现是这么写的:
std::shared_ptr<Shape> createShape(const std::string& type) { if (type == "circle") { return std::make_shared<Circle>(/* 一堆初始化参数 */); } else if (type == "rect") { return std::make_shared<Rect>(/* 一堆初始化参数 */); } else if (type == "polyline") { return std::make_shared<Polyline>(/* 一堆初始化参数 */); } // 新增组件类型时还要继续加else if return nullptr; }这个写法有几个明显的问题。首先是创建开销:每次新建一个组件,都要重新走一遍事件注册、资源加载的流程,即便新组件和画布上已有的某个组件只有参数微调的区别。其次是扩展性:增加新组件类型时,这个工厂函数就要跟着改,违反了开闭原则。第三是运行时灵活性:有些操作是“复制当前选中组件”,此时调用方根本不知道选中的组件到底是什么类型,只能通过基类指针操作,用传统的工厂方法根本无从下手。
1.2 原型模式的核心思路:复制而不是重新构造
原型模式的思路是完全不同的路子。它把“创建对象”这件事,变成“复制一个已有对象”。既然对象已经存在,那它的类型、状态、初始化结果都是现成的,直接拷贝出一份新对象来,比从头构造要快得多,也更省心。
UML上的核心参与者也很好理解,一个Prototype抽象基类声明clone()接口,具体的ConcretePrototype实现clone()。C++里通常是这样的骨架:
class Shape { public: virtual ~Shape() = default; virtual std::unique_ptr<Shape> clone() const = 0; }; class Circle : public Shape { public: std::unique_ptr<Shape> clone() const override { return std::make_unique<Circle>(*this); } };核心机制就是:clone()内部调用拷贝构造函数,把当前对象完整复制一份,返回时擦除具体类型,以基类指针交还给调用方。这样调用方拿到的是一个和原型同类型、同状态的新对象,但完全不需要知道原型的具体类型。这就是原型模式“用实例定义创建的种类,通过拷贝创建新对象”的本质。
我当时设计组件系统时,把工厂函数的创建逻辑改成“先创建若干原型实例注册到原型管理器里,复制操作直接调用原型的clone()”,新增类型时只需要往管理器里注册一个原型实例,创建函数再也不用改了。
2. C++实现原型模式时的关键设计
2.1 虚clone接口的写法与设计考量
原型模式在C++里的第一个核心问题:clone()接口的返回类型怎么写。网上能看到好几种写法,但落地时需要考虑不同的场景。
第一种是返回原始指针:
class Shape { public: virtual Shape* clone() const = 0; };这种写法最简单,但调用方拿到裸指针,还需自己管理生命周期。如果项目里没用智能指针,或者有严格的接口约定说“clone返回的指针由调用方接管”,倒也可以。只是我见过的多数现代C++项目已经全面转向智能指针了,裸指针接口容易在新老代码交接时出问题。
第二种是返回unique_ptr:
class Shape { public: virtual std::unique_ptr<Shape> clone() const = 0; };我最推荐这种。返回值语义明确,所有权清晰:调用方拿到一个独占所有权的新对象,不用纠结谁负责delete。实现子类时配合make_unique拷贝构造,代码非常直观。需要共享所有权时,调用方再自己转成shared_ptr。
第三种是直接返回shared_ptr,省得调用方转换。但这里有个被很多人忽略的坑:clone()每调用一次就创建一个新对象,如果内部都按shared_ptr返回,对象的生命周期就完全绑在调用方手上。假设一个原型管理器里保存的是shared_ptr原型,clone()返回shared_ptr,这两个对象如果不小心走了同一条共享所有权路径,就可能出现原型被意外释放的情况。相比之下,unique_ptr把“复制品”和“原型”的所有权重任分开,更不容易出逻辑错误。
我建议的原则是:接口所有权语义越清晰越好。unique_ptr作为clone()的返回值,语义明确,兼容性也广,优先考虑。
2.2 拷贝构造函数与深浅拷贝的抉择
clone()内部的实现,本质上就是调用拷贝构造。所以拷贝构造函数写得好不好,直接决定原型模式在C++里稳不稳。这里最经典的坑就是浅拷贝和深拷贝的选择。
先看一个反面案例:
class Texture { public: explicit Texture(const std::string& path) : path_(path) { // 加载纹理资源 } ~Texture() { /* 释放GPU资源 */ } private: std::string path_; }; class Circle : public Shape { public: Circle(std::string id, Texture* tex) : id_(std::move(id)), texture_(tex) {} // 拷贝构造函数:默认浅拷贝 Circle(const Circle& other) = default; std::unique_ptr<Shape> clone() const override { return std::make_unique<Circle>(*this); } private: std::string id_; Texture* texture_; };这里Circle持有Texture*指针,默认的浅拷贝会让两个Circle共享同一个Texture资源。如果其中一个Circle被销毁,析构函数释放了Texture,另一个Circle继续访问texture_就变成了悬垂指针。轻则画面花屏、纹理丢失,重则程序崩溃,这类问题非常隐蔽,不容易在测试阶段暴露。
正确做法是深拷贝:
class Circle : public Shape { public: Circle(std::string id, const Texture& tex) : id_(std::move(id)), texture_(std::make_shared<Texture>(tex)) {} Circle(const Circle& other) : id_(other.id_), texture_(std::make_shared<Texture>(*other.texture_)) {} std::unique_ptr<Shape> clone() const override { return std::make_unique<Circle>(*this); } private: std::string id_; std::shared_ptr<Texture> texture_; };深拷贝的代价是复制资源,但换来的是对象独立性。在实际项目中,究竟该深拷贝还是浅拷贝,取决于你的资源是否允许共享。如果纹理这类资源是只读的、全局共享的,用shared_ptr浅拷贝也说得通;如果资源带状态、需要隔离,那就必须深拷贝。判断标准只有一条:复制出来的对象,被修改或被销毁时,会不会影响到原型和其他副本。会,就深拷贝;不会,才考虑共享。
2.3 失控的切片问题与防止方案
C++实现原型模式时有个特别有意思的坑:切片。这个问题不只在原型模式里出现,但在clone()的调用链里尤其危险。
想象一下这样的代码:
Circle circle("c1", texture); Shape shape = circle; // 切片发生:Circle被切割成Shape Shape* p = &circle; // 指针没问题 Shape& ref = circle; // 引用没问题切片的意思是,用基类对象去接收派生类对象时,派生类部分被“切掉”,对象只剩基类成分。这在原型模式里一旦发生,后果是灾难性的。假设clone()实现时不小心用了值语义:
std::unique_ptr<Shape> clone() const override { Shape copy = *this; // 切片!circle变成了Shape return std::make_unique<Shape>(copy); // 返回的是Shape,不是Circle }虽然代码能编译,但复制出来的对象类型已经变了。调用方拿着Shape指针,以为自己在操作Circle,实际上所有Circle特有的成员和方法都没了,动态类型丢失,行为完全不可预期。
防止切片的方案其实很简单:clone()里一定用具体类型的拷贝构造,而不是基类拷贝:
std::unique_ptr<Shape> clone() const override { return std::make_unique<Circle>(*this); // 保持类型 }更保险的做法是在类层次中把拷贝构造和拷贝赋值声明为protected或delete掉,从编译层面禁止基类直接拷贝:
class Shape { protected: Shape(const Shape&) = default; Shape& operator=(const Shape&) = default; public: Shape() = default; virtual ~Shape() = default; virtual std::unique_ptr<Shape> clone() const = 0; };这样外部代码想写Shape s = circle;,编译器直接报错,从根源上堵住切片。
3. 一个完整的实战案例:可配置对象的原型克隆
3.1 需求背景与类结构设计
去年做一个数据处理工具时,遇到了一个特别适合用原型模式的场景。工具需要从数据库里读取一批配置记录,每条记录代表一个数据转换节点的配置。这些配置包含节点类型、参数集合、依赖关系、回调钩子等。系统运行时,需要根据当前输入数据的结构,动态复制一份配置并做局部调整,再执行转换流程。
为什么不用工厂模式?因为节点类型非常多,而且配置不是写死在代码里的,是从数据库读出来的。如果为每个节点类型写一个工厂分支,每增加一种节点就要改工厂代码。而配置对象本身天然适合作为原型:数据库读取后先构建一个“基准配置”,后续直接克隆再改参数,既保留了原始配置的完整状态,又避免了重复解析数据库的开销。
类结构设计如下:
class TransformNode { public: explicit TransformNode(std::string type); virtual ~TransformNode() = default; // 原型接口 virtual std::unique_ptr<TransformNode> clone() const = 0; // 业务方法 void setParameter(const std::string& key, const std::string& value); void setDependency(const std::string& nodeId); virtual void execute() = 0; protected: // 禁止基类切片拷贝 TransformNode(const TransformNode&) = default; TransformNode& operator=(const TransformNode&) = default; std::string type_; std::map<std::string, std::string> params_; std::vector<std::string> dependencies_; }; class FilterNode final : public TransformNode { public: FilterNode() : TransformNode("filter") {} FilterNode(const FilterNode&) = default; std::unique_ptr<TransformNode> clone() const override { return std::make_unique<FilterNode>(*this); } void execute() override { /* 过滤实现 */ } }; class AggregatorNode final : public TransformNode { public: AggregatorNode() : TransformNode("aggregator") {} AggregatorNode(const AggregatorNode&) = default; std::unique_ptr<TransformNode> clone() const override { return std::make_unique<AggregatorNode>(*this); } void execute() override { /* 聚合实现 */ } };转型节点管理器维护着一个原型注册表:
class NodeRegistry { public: void registerPrototype(std::shared_ptr<TransformNode> proto) { prototypes_[proto->type()] = std::move(proto); } std::unique_ptr<TransformNode> createNode(const std::string& type) const { auto it = prototypes_.find(type); if (it == prototypes_.end()) { throw std::runtime_error("unknown node type: " + type); } return it->second->clone(); } private: std::map<std::string, std::shared_ptr<TransformNode>> prototypes_; };3.2 代码实现与关键细节
一个值得仔细展开的细节是,配置对象从数据库读取并构建原型后,它的状态是非常丰富的。可能有几十个参数、好几层依赖关系。如果走传统的工厂方法,调用方需要为每种节点类型传入完整的参数集,代码繁琐不说,还容易漏参数。但克隆一个原型,所有状态天然被复制,调用方只需要修改需要调整的那几个参数。
我当时的实现里,从数据库读取配置构建原型的代码简化如下:
NodeRegistry registry; auto filterProto = std::make_shared<FilterNode>(); filterProto->setParameter("threshold", "0.5"); filterProto->setParameter("mode", "strict"); filterProto->setDependency("source"); registry.registerPrototype(filterProto); auto aggProto = std::make_shared<AggregatorNode>(); aggProto->setParameter("function", "sum"); aggProto->setParameter("window", "300"); aggProto->setDependency("filter_node"); registry.registerPrototype(aggProto);运行时需要为新输入的数据流创建一组节点时,不再去new具体类型,而是从注册表里clone:
auto node1 = registry.createNode("filter"); node1->setParameter("threshold", "0.8"); // 只需要微调 auto node2 = registry.createNode("aggregator"); node2->setParameter("window", "600"); // 调整窗口大小这样“基于模板创建、按需微调”的工作流就很自然了。而且调用方完全不依赖FilterNode、AggregatorNode这些具体类型,彻底解耦。
这里我总结几个实际切换原型模式时的代码习惯:
第一,构造函数里尽量别直接做重资源操作。原型模式要求clone()是高效的,如果在构造函数里加载纹理、注册事件、解析配置,那每次克隆都会重新走一遍重流程,性能优势可能就没了。更好的做法是把重资源的加载放到惰性初始化中,或者让原型构造阶段统一完成加载,克隆时只拷贝轻量状态。
第二,所有子类都要实现clone(),而且返回make_unique<具体类型>(*this)。少一个类忘记实现了,运行时通过注册表创建对象时就可能创建出基类对象,这种错误的排查成本很高。可以用一个编译期校验,或者在基类里把clone()做成纯虚函数,强制子类必须实现。
第三,考虑在Debug模式下对clone()返回的对象做一次动态类型检查。写个简单的assert:assert(dynamic_cast<FilterNode*>(cloned.get()) != nullptr);,能在开发阶段帮助发现切片问题。
3.3 什么场景下选深拷贝,什么场景下选浅拷贝
在这个配置对象案例中,参数是map<string,string>,依赖关系是vector ,都是值语义容器,默认深拷贝没有问题。但真实系统里经常会出现共享资源,比如转换节点依赖了一个全局的字典表。这个字典表很大,好几万条数据,每次克隆都深拷贝一份,内存和性能都很不划算。
我当时遇到的实际决策过程是这样的:
先说共享的场景。五个节点共享同一个字典表,字典表只读,不随节点状态变化。复制整个字典并没有意义——无非是五个指针各指一份内容相同的内存,纯属浪费。这种场景用shared_ptr共享引用计数,浅拷贝语义就够。
再说必须深拷贝的场景。如果节点的状态包含了游标、会话上下文、临时缓存这一类可变数据,继续共享就会导致“改了A节点影响B节点”的诡异问题。我调试过一个线上问题:两个节点共享同一个会话上下文,A节点修改了上下文里的分页游标,B节点执行时就跳过了前面的数据,数据直接少了一页。当时的修复就是把会话上下文改成深拷贝。
判断深浅拷贝有一个简单的规则:先问自己这三个问题。
- 资源是只读的吗?
- 资源会被原型修改吗?
- 资源被复制品修改会影响原型吗?
三个问题里有一个是“否”,就应该深拷贝。三个都是“是”,才考虑共享。
注意,clone()里深拷贝的实现方式也要讲究。最省心的是让所有成员变量都具备值语义,这样编译器自动生成的拷贝构造就是深拷贝。一旦出现裸指针或引用计数指针,就得手动处理拷贝构造。手动写拷贝构造时,别忘了赋值运算符也要对应实现,否则node1 = *node2;就可能出现浅拷贝和深拷贝混用的情况。
4. 实际问题排查与经验总结
4.1 常见错误一:浅拷贝导致的悬垂指针
这种问题的表现很典型:程序一开始运行正常,但在复制对象销毁后,原对象再访问某资源时崩溃,而且崩溃位置完全随机,很难稳定复现。我排查过一个具体案例,印象非常深刻。
某个图形组件对象内部持有一个Image*,Image是它的私有纹理数据。复制组件时走了默认浅拷贝,两个对象的Image*指向同一块内存。组件A先被销毁,析构函数释放了Image。组件B继续用Image渲染,渲染线程访问到已释放的内存,画面出现随机花屏,偶尔崩溃。
定位这种问题的排查思路是:
第一步,先看崩溃时的调用栈,确认是不是悬垂指针访问,最好用AddressSanitizer跑一次,它能直接报告“heap-use-after-free”。
第二步,检查持有指针的成员变量来源,看构造和拷贝过程是否合理。通常会发现拷贝构造或者赋值运算符是隐式默认的。
第三步,根据资源是否允许共享决定是深拷贝,还是改用shared_ptr管理共享所有权。
我给自己的提醒是:原型模式里,clone()的行为完全取决于拷贝构造函数,所以拷贝构造是第一个要检查的地方。如果用户自定义了析构函数,却没有自定义拷贝构造和赋值运算符,这本身就是一个危险信号,需要认真审视。
4.2 常见错误二:clone()内部不小心调用虚函数
这个坑比较隐蔽。原型模式下,clone()如果想在复制完对象后做一些初始化动作,比如重置状态、重新生成ID,初学者很容易写出这样的代码:
std::unique_ptr<TransformNode> clone() const override { auto p = std::make_unique<FilterNode>(*this); p->resetState(); // 虚调用 return p; }看着没问题,但如果resetState()是虚函数,而clone()是在FilterNode里实现的,事情就变得复杂了。更危险的是在基类里实现clone()模板方法时:
class TransformNode { public: virtual ~TransformNode() = default; virtual std::unique_ptr<TransformNode> clone() const = 0; protected: TransformNode(const TransformNode&) = default; void initAfterClone() { resetState(); // 动态绑定 } virtual void resetState() { /* base version */ } };如果在基类的initAfterClone()里调用resetState(),此时虚函数按动态类型解析,但如果子类还没完全构造好,或者子类resetState()依赖了子类特有成员,而这些成员还没初始化,就会出问题。为了避免这种麻烦,我的习惯是:clone()过程中不调用任何虚函数,必要的初始化放到clone()返回之后,或者只调用非虚的辅助函数。
4.3 常见错误三:原型管理器中的对象被意外修改
原型模式有个容易忽视的语义:原型本身应该是不变的、只读的,所有修改要在clone出来的副本上进行。但代码里经常有人把原型对象当成普通对象直接改参数。如果注册表存的是裸指针,别的地方拿到了同一个指针,改了原型的状态,后续所有克隆出来的对象都会带着被修改后的状态,排查起来非常头痛。
我建议两个层面的防护。第一,原型管理器注册原型时,把接口改成接受const引用或值,内部存为unique_ptr或shared_ptr,对外只暴露非修改接口。第二,业务代码中约定“原型不入流”,读取原型可以,修改必先克隆。如果项目纪律约束不住,可以给原型的成员方法加上const限制,让修改操作在编译期暴露。
下面整理一份我在实际工程里整理的原型模式速查表,供参考:
| 问题现象 | 可能原因 | 排查方向 |
|---|---|---|
| 克隆后对象动态类型丢失 | 切片,clone()返回了基类对象 | 检查clone()是否用make_unique<具体类型> |
| 修改副本影响原对象 | 浅拷贝共享了可变资源 | 检查拷贝构造,将可变资源改为深拷贝或者独立所有权 |
| 程序随机崩溃/heap-use-after-free | 悬垂指针,复制后释放顺序问题 | 用ASan,检查裸指针和析构逻辑 |
| 新类型加入后注册表不生效 | 忘了注册原型,或者注册类型名不一致 | 检查注册码,统一类型名字符串 |
| clone()后初始化状态异常 | clone()里调用了虚函数 | 避免在clone()构造函数期间进行虚调用 |
4.4 实操心得:写clone()时不要图省事
写clone()这件事,最大的诱惑是偷懒,直接return std::make_unique<Base>(*this);。短代码看着清爽,但这里藏着三个隐患:切片、拷贝不完整、子类状态丢失。
我现在的写法是固定的四步:
- 第一步,确认拷贝构造函数。优先让编译器默认生成,前提是成员变量都是值语义或智能指针语义。
- 第二步,手动写clone()时,返回类型用unique_ptr<基类>,实现体里make_unique<具体类型>(*this),不要用static_cast或C风格转换。
- 第三步,对于深浅拷贝混用的成员,写一个私有辅助函数deepCopyMember,避免在拷贝构造和clone()里写两遍。
- 第四步,如果类层次很深,考虑在每个层次都定义clone()并用final关键字防止被意外覆盖。
5. 原型模式的取舍与应用边界
5.1 与工厂模式的对比:各干各的活
经常有朋友问:原型模式和工厂模式都是创建对象,到底有什么区别,什么时候用哪个。
一句话概括:工厂模式是从零开始创建对象,原型模式是从已有对象复制对象。
工厂模式适合场景是:类型数量有限且稳定,创建流程清晰,每次创建都从无到有走一遍初始化过程。原型模式适合场景是:类型数量动态变化,或者对象的最终状态高度依赖已有实例的状态,从克隆一个模板开始会更自然。
两者还可以配合使用。模块A从工厂拿到一个对象做初始化,然后把这个对象注册为原型,模块B通过原型克隆快速创建变体。这就是两种模式各自的优势叠加。
我在设计图形编辑器的组件系统时,底层入口用的就是工厂+注册表的组合:首次创建组件走工厂,随后所有的复制操作、模板创建操作都走原型克隆。这样既有工厂模式的统一接口,又有原型模式的灵活性。
5.2 什么时候别用原型模式
原型模式并不是万能的。如果你遇到下面几种情况,建议慎用甚至别用:
第一,对象里存在大量外部依赖、文件句柄、网络连接、数据库事务等复杂资源时。克隆一个持有文件句柄的对象,你要决定是否重新打开文件,是否复制连接状态,这个逻辑复杂度远超过直接new一个对象。
第二,类层次太深、成员变量太多导致拷贝构造函数写起来痛苦不堪时。原型模式要求对象具备“良好的自我复制能力”,如果一个类连拷贝构造都写不清楚,那原型模式只会放大问题。
第三,对象类型纯粹由工厂参数决定,不存在“根据已有对象动态生成变体”的需求时。用原型模式纯粹是给自己找麻烦,工厂模式就够了。
判断标准其实很简单:什么时候你会写出“复制一个初始模板再改几个参数”这类代码,什么时候原型模式就是恰当的。这种需求越多,原型模式越有价值。
5.3 后续还可以这样扩展
原型模式在实际项目中一般会搭配几个机制让使用体验更好。我在自己的代码里做了两个扩展,效果都不错:
第一个是原型注册表+配置文件。把原型的初始参数从配置文件或者数据库读取,然后注册到管理器里。运行时想增加新类型的节点,不需要重新编译代码,只要往注册表里注册一个新的原型实例就行。
第二个是给克隆对象自动分配新ID。复制一个对象时,很多业务场景要求它拥有独立的新身份。我给TransformNode加了一个非虚的assignNewId()方法,clone()返回后立刻调用一次,保证副本和原型的标识不冲突。
implement统一的接口,结合这两点,原型模式就不只是理论上的设计模式了,是可以长期演进的工程机制。
在实际项目中,原型模式帮我处理过不少看起来“麻烦”的需求。但有一点我越做越清楚:它解决的是“以正确的类型复制正确的状态”的核心问题,但深浅拷贝、切片、虚调用这些C++特有的坑,必须由开发者自己守住。只要把握住了这些细节,原型模式就是一个好用又不花哨的工具。