news 2026/10/15 5:20:02

C++原型模式实战:多态Clone、深拷贝与智能指针

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
C++原型模式实战:多态Clone、深拷贝与智能指针

聊到 C++ 的创建型设计模式,原型(Prototype)算是很实用但经常被低估的一个。它的核心思路非常直白:与其每次用构造函数从零搭建一个对象,不如从一个已经存在的实例身上“复制”出新对象。这句话听起来简单,真要在 C++ 里写出一个安全、通用、不多此一举的版本,会牵扯到虚函数、拷贝构造、深拷贝、智能指针、生命周期管理一堆问题。很多时候你会发现,George 那本经典书里的原型模式示例,换成 C++ 之后就变得没那么“显然”,因为 C++ 的复制语义比其他语言更直接也更危险。

这篇内容适合已经写过一些 C++、对设计模式有基本了解但没系统落地过原型模式的开发者。我会把实现原型模式时真正绕不开的细节拆开讲,包括该用 raw pointer 还是 unique_ptr、拷贝构造函数该怎么配合 Clone、深浅拷贝的坑在哪里、注册表怎么设计,最后给一个可以直接编译跑通的完整示例。读完你不只是知道“原型模式是什么”,而是能在代码里把它用对、用稳。

1. 原型模式到底在解决什么问题

1.1 从一个现实场景说起

假设你正在写一个图形编辑器,用户每次往画布上拖一个“圆形”节点,你都得创建一个完整的 Circle 对象。Circle 本身不复杂,但它的构造函数的肚子里藏了大量工作:读取默认主题配置、加载纹理、初始化着色器、检查模板合法性。这个初始化过程在程序启动时做一次没问题,用户每拖一次就完整重跑一遍,体验会很糟糕,白白浪费时间。

更麻烦的是,用户还经常会基于已经调整好的节点来创建新节点。比如把某个圆形的颜色、描边、动画参数全调好了,然后希望在这个基础上修改出一批变体。如果走“构造函数 + 参数赋值”路线,调用方必须知道所有构造函数参数的含义,耦合度很高;如果让编辑器来管理这些状态,状态一多就要写一堆 setter,既繁琐又容易漏项。

原型模式给出的解法是:把“当前这个对象”当作模板,通过一个统一的Clone()入口复制出独立的新对象。复制出来的对象带着原有对象的全部状态,再按需调整个别字段。用户不需要知道对象内部有多少个成员,也不需要按顺序逐项重新赋值。你负责在一开始把模板对象准备好,后续的创建逻辑全部走复制这条路径。

1.2 原型模式和工厂模式的分工差异

有朋友会问:工厂模式不也是把“创建对象”封装起来吗?原型模式和它有什么本质区别?我一般这样区分:工厂模式强调的是“根据参数决定创建哪种类型的实例”,它负责的是选择类型和初始化数据,但你最终拿到的通常是一个默认状态的新对象。原型模式强调的是“以某个已有对象为样板进行复制”,你最终拿到的是携带了模板现场状态的新对象。

打个比方,工厂模式像是快餐店的点餐台,你选“牛肉汉堡”,后厨按标准配方给你做一份;原型模式像是你从自家冰箱里拿出一块做好的蛋糕,想再要一份时,直接把这块蛋糕送去复制一份出来。前者每一步都是标准化的生产流程,后者从源头上保留了模板的“个性”。

所以原型模式真正的价值不只是“不直接调用构造函数”,而是“默认值本身也是对象”。当系统的默认配置复杂到难以用一组静态常量描述时,用对象做模板天然更灵活。在编辑器、游戏引擎、服务器节点配置这类场景里,原型模式非常常见。

2. C++ 里落地原型模式,最关键的设计点

2.1 先解决“多态克隆”:虚 Clone 方法与返回类型

原型模式在 C++ 里第一个核心难点,是如何在只知道基类指针的情况下,复制出正确类型的派生对象。假设你有一块std::unique_ptr<Shape>,它实际指向的是Circle,你希望复制一份完全独立的Circle。如果只是在Shape基类里定义一个Shape* Clone()然后返回new Shape(*this),那么复制出来的是Shape对象,派生类的成员全部丢失,这就是典型的对象切片。

正确的做法是在基类里声明一个虚函数,每个派生类自己实现“按自己的真实类型去复制”。这就是多态克隆:

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); } };

这里有两个细节值得展开。

第一,为什么用std::unique_ptr<Shape>而不是裸指针Shape*作为返回类型?这主要是为了杜绝“克隆出对象后忘了释放”这类问题。在老式 C++ 教材里,原型模式通常返回Base*,调用方要记住delete,一旦异常路径没处理就泄漏。用unique_ptr之后,对象所有权明确,调用方拿到手就是一个 RAII 对象,可以直接放进容器,也可以移交给其他对象。

第二,注意这里没有办法直接返回std::unique_ptr<Circle>并在override时声明为协变返回类型。C++ 的 return type covariance 只对指针和引用生效,unique_ptr<Derived>和unique_ptr<Base>是两种不同的类型,编译器不认。所以派生类必须声明成std::unique_ptr<Shape> Clone() const override,在函数体内部通过make_unique<Circle>(*this)创建具体对象。这个写法看着多写了一个类型名,实际反而更安全,因为你没法在不创建派生对象的情况下伪造返回值。

如果你用的是比较旧的代码风格,坚持返回裸指针也是可以的,但我建议至少把所有裸指针放进unique_ptr或shared_ptr再返回。当年我写第一个版本时偷懒返回Shape*,后来某个模块在克隆对象之后忘了释放,内存泄漏排查了整整一个下午。用unique_ptr之后这个坑基本就堵死了。

2.2 拷贝构造函数:默认的未必够用,但乱写更危险

Clone()方法内部的关键动作是make_unique<Circle>(*this),这句会调用Circle的拷贝构造函数。如果Circle没有显式定义拷贝构造,编译器会生成一个默认的:它依次复制基类子对象和所有非静态成员。对于std::string、std::vector、std::shared_ptr这类类型,默认拷贝行为通常符合预期,但对于裸指针成员,默认拷贝只会复制指针值,两个对象最终指向同一块内存,这往往是悬垂指针和 double free 的根源。

更隐蔽的问题是:如果派生类的拷贝构造函数写得不完整,会出现“部分复制”的诡异状态。比如你写了:

Circle(const Circle& other) : Shape(other), radius_(other.radius_) { // 漏掉了 text_ 或其他新增成员 }

这样克隆出来的Circle虽然类型正确,但部分字段是默认构造值,表现就是每个克隆体看起来都“怪怪的”。所以经验法则是:当类里有裸指针、句柄、外部资源时,一定要明确实现深拷贝;当所有成员都是 RAII 类型时,直接让编译器生成默认拷贝构造函数通常是最安全的选择,你只需要在Clone()里用make_unique<Derived>(*this)把复制动作封装起来。

2.3 用注册表把“可复制模板”管起来

光有Clone()还不够。实际项目中你往往希望用户通过一个名字来取模板,而不是在业务代码里手写auto a = std::make_unique<Circle>(...); a->Configure(...);。这时可以引入一个原型注册表,内部用一个std::unordered_map<std::string, std::unique_ptr<Shape>>管理所有模板对象。

注册表的核心接口只需要两个:注册模板、按名字克隆。

class PrototypeRegistry { public: void Register(const std::string& key, std::unique_ptr<Shape> prototype) { templates_[key] = std::move(prototype); } std::unique_ptr<Shape> Create(const std::string& key) const { auto it = templates_.find(key); if (it == templates_.end()) { return nullptr; } return it->second->Clone(); } private: std::unordered_map<std::string, std::unique_ptr<Shape>> templates_; };

这个注册表的收益在于:调用方只需要知道字符串 key,不需要知道具体类名,也不需要在创建点写switch/case或者一堆工厂函数。将来新增一种对象类型时,只要把它的模板注册进去,原有逻辑完全不用改。系统启动时把所有模板放进注册表,运行期间一直复用这些模板做克隆,对象创建的真实路径就变得非常统一。

3. 深拷贝还是浅拷贝:一个容易翻车的选择题

3.1 默认拷贝语义到底复制了什么

C++ 的默认拷贝行为是“逐成员复制”,它只复制成员变量的值。如果成员变量是一个int,那没问题;如果是一个std::string,string自己会复制堆上的字符缓冲区,没问题;如果是一个裸指针Texture* texture_,那复制出来的新对象和旧对象就指向同一块Texture内存。你改新对象的纹理,旧对象也跟着变;两个对象析构时,各自delete texture_,第一次 delete 成功,第二次 delete 就是 double free。

理解这一点,是正确处理深拷贝的前提。我们常说“原型模式复制对象”,但复制到什么程度,取决于对象的成员语义:

  • 所有权型成员:对象生命周期由当前对象负责,必须深拷贝。
  • 共享型成员:多个对象共享同一份资源,生命周期由组合机制负责,可以用shared_ptr实现安全的共享。
  • 不可变资源:资源创建后不再变化,允许共享引用,但也要小心生命周期。
  • 普通值成员:int、double、std::string、std::vector等默认复制即可。

实际项目里最常见的错误,是下面这种:某个类里有一个char*或int*成员,直接用默认拷贝构造函数,于是一份数据被多个对象“共用”。这种问题在单线程下可能刚开始看不出问题,数据量大了以后各种怪现象一个接一个来。

3.2 裸指针成员的深拷贝示范

假设你有一个位图类,内部用裸指针保存像素数据:

class Bitmap { public: Bitmap(int w, int h) : width_(w), height_(h), pixels_(new unsigned char[w * h]) { } Bitmap(const Bitmap& other) : width_(other.width_), height_(other.height_) , pixels_(new unsigned char[other.width_ * other.height_]) { std::memcpy(pixels_.get(), other.pixels_.get(), width_ * height_); } Bitmap& operator=(const Bitmap& other) { if (this == &other) { return *this; } width_ = other.width_; height_ = other.height_; pixels_ = std::make_unique<unsigned char[]>(width_ * height_); std::memcpy(pixels_.get(), other.pixels_.get(), width_ * height_); return *this; } ~Bitmap() = default; private: int width_ = 0; int height_ = 0; std::unique_ptr<unsigned char[]> pixels_; };

这里我选择用std::unique_ptr<unsigned char[]>而不是裸指针,主要原因是想尽量用 RAII 管理生命周期,同时保留“深拷贝时需要手动分配新内存”的教学意义。写自定义拷贝构造函数时,逻辑很简单:先把目标对象的尺寸拿过来,再为新对象分配独立内存,最后把数据复制过去。这样得到的两个 Bitmap 对象操作同一段视觉内容但物理上互不影响。

有朋友可能问:直接用std::vector<unsigned char>不是更省事吗?确实,vector默认就具备深拷贝能力,写起来更短。但如果你的项目依赖一个已有的 C 风格图像库,缓冲区必须是一块连续内存,而且可能会被外部函数直接写,那么用unique_ptr<T[]>配合自定义拷贝构造的情况就会频繁出现。这时候,正确理解“指针成员需要深拷贝”这个原则比选什么容器更重要。

3.3 什么时候可以放心共享

并不是所有成员都必须深拷贝。比如一个全局材质库里的只读纹理,每个对象只是持有它的引用,没必要复制一份内存。问题是,如果单纯用裸指针共享,你需要自己保证那个纹理对象的生命周期比所有引用者都长,程序一旦出现释放顺序错误,就是隐蔽的悬垂指针。

更稳妥的做法是改用std::shared_ptr<Texture>。shared_ptr默认的拷贝行为会让引用计数加一,不需要额外写深拷贝逻辑,生命周期也由最后一个持有者释放,不会出现双删。所以我的建议是:优先用智能指针表达共享语义,尽量不要用“裸指针 + 大家自觉”的方案。

这里有个容易混淆的点:如果成员是unique_ptr,默认拷贝构造函数是不能用的,因为unique_ptr不允许复制。你必须在拷贝构造里为它指向的资源创建一份新内容,或者把成员改成shared_ptr。反过来,如果你把shared_ptr放进原型里,克隆出的多个对象会共享同一个底层资源,这是有意为之还是意外结果,得在设计时想清楚。我见过一个项目把某个本应深拷贝的资源做成shared_ptr,导致两个克隆对象互相影响,用户调试了很久才定位到“原来它们共享了同一个对象”。

4. 一个可编译的完整样例:资源模板库 + 克隆

4.1 设计目标

这一节我们抛开零碎代码,拼一个完整可运行的小项目。场景是:一个绘图系统里维护几个模板形状,系统启动时把默认模板注册进注册表,之后所有“新建形状”都通过注册表克隆完成。

类设计如下:

  • Shape:抽象基类,提供Clone()、Draw()、SetName()、Name()。
  • Circle:具体形状,持有半径、名称、显示颜色,重写Clone()。
  • Rectangle:具体形状,持有宽、高、名称,重写Clone()。
  • PrototypeRegistry:注册表,按名称管理模板并克隆。

因为不需要复杂深拷贝场景,形状成员用float和std::string就足够,重点展示多态克隆和注册表的配合。

4.2 完整代码

头文件和实现放在一个文件里写,方便你直接编译测试:

#include <iostream> #include <memory> #include <string> #include <unordered_map> #include <vector> class Shape { public: Shape() = default; Shape(const Shape&) = default; virtual ~Shape() = default; virtual std::unique_ptr<Shape> Clone() const = 0; virtual void Draw() const = 0; void SetName(const std::string& name) { name_ = name; } const std::string& Name() const { return name_; } protected: std::string name_; }; class Circle : public Shape { public: Circle(float radius, const std::string& name) : radius_(radius) { name_ = name; } Circle(const Circle& other) : Shape(other), radius_(other.radius_) { } std::unique_ptr<Shape> Clone() const override { return std::make_unique<Circle>(*this); } void Draw() const override { std::cout << "Circle[" << name_ << "] radius=" << radius_ << "\n"; } private: float radius_ = 0.0f; }; class Rectangle : public Shape { public: Rectangle(float width, float height, const std::string& name) : width_(width), height_(height) { name_ = name; } Rectangle(const Rectangle& other) : Shape(other), width_(other.width_), height_(other.height_) { } std::unique_ptr<Shape> Clone() const override { return std::make_unique<Rectangle>(*this); } void Draw() const override { std::cout << "Rectangle[" << name_ << "] size=" << width_ << "x" << height_ << "\n"; } private: float width_ = 0.0f; float height_ = 0.0f; }; class PrototypeRegistry { public: void Register(const std::string& key, std::unique_ptr<Shape> prototype) { templates_[key] = std::move(prototype); } std::unique_ptr<Shape> Create(const std::string& key) const { auto it = templates_.find(key); if (it == templates_.end()) { return nullptr; } return it->second->Clone(); } private: std::unordered_map<std::string, std::unique_ptr<Shape>> templates_; };

这段代码里,Circle和Rectangle的拷贝构造函数我都显式写出来了,但其实不写、让编译器生成默认版本也一样能工作。之所以显式写,是为了强调:当你决定手动维护复制逻辑时,要记得显式调用基类拷贝构造Shape(other),把基类部分的成员正确复制进来。很多新手在这里只复制自己新增的成员,忘记复制基类成员,结果对象类型对了、内容缺了一截。

再看main函数:

int main() { PrototypeRegistry registry; registry.Register("circle", std::make_unique<Circle>(5.0f, "default_circle")); registry.Register("rect", std::make_unique<Rectangle>(4.0f, 6.0f, "default_rect")); std::vector<std::unique_ptr<Shape>> shapes; auto first = registry.Create("circle"); first->SetName("ui_avatar_circle"); shapes.push_back(std::move(first)); auto second = registry.Create("circle"); shapes.push_back(std::move(second)); auto rect = registry.Create("rect"); shapes.push_back(std::move(rect)); for (const auto& s : shapes) { s->Draw(); } }

运行输出:

Circle[ui_avatar_circle] radius=5 Circle[default_circle] radius=5 Rectangle[default_rect] size=4x6

4.3 验证复制独立性

上面输出里最关键的一点是:第二个克隆出来的圆,名字依然是default_circle,说明第一次复制后SetName("ui_avatar_circle")并没有污染注册表里的模板,也没有污染后续克隆体。这正是原型模式的基本要求:每个克隆对象必须拥有自己的状态,修改一个克隆体不能波及另一个。

如果这个示例里哪个成员是裸指针,并且你忘了深拷贝,修改克隆体 A 的某个字段时,克隆体 B 也会跟着变。这个现象在测试初期很难发现,因为输出看起来仍然“正常”,等到数据量变大、并发一上来,问题才会集中爆发。

5. 实战现场:原型模式高频问题和排查技巧

5.1 两个经典崩溃现场

崩溃现场之一:double free。现象是程序退出或析构容器时,在 delete 一个指针的地方崩掉。排查思路是先看哪个成员是裸指针,检查克隆对象是否共享了同一块资源。如果你用了 AddressSanitizer,它会很快定位到同一块地址被释放两次。解决方式就是深拷贝,或者把共享资源改成shared_ptr。

崩溃现场之二:悬垂指针。现象是克隆对象拿到一个指针,但原对象提前销毁了,克隆体再访问时触发访问越界。这个问题通常出现在模板注册表把某个局部对象注册进去,然后这个局部对象在注册表存续期间被提前 delete。解决方式是注册表用unique_ptr持有模板,对象生命周期跟随注册表;克隆出的对象同样用unique_ptr持有,避免裸指针到处乱飞。

// 错误示范:注册表存了局部对象的裸指针 void InitBad(PrototypeRegistry& registry) { Circle temp(3.0f, "temp"); registry.Register("bad", &temp); // 生命周期结束就悬垂 }
// 正确做法:注册表接管所有权 void InitGood(PrototypeRegistry& registry) { registry.Register("good", std::make_unique<Circle>(3.0f, "temp")); }

看似很基础,但我在代码评审里见过不少次类似的低级问题,根源都是“注册表需要存储模板”这件事没想清楚,把所有权留在了外面。

5.2 一个看似正常但数据丢失的逻辑问题

还有一种情况不崩溃,但结果不对:派生类Clone()返回基类类型时,不小心在函数体里写了return std::make_unique<Shape>(*this);,或者干脆忘了 override,导致调用基类的Clone()。这时编译器可能还能通过,但复制出来的是一个Shape对象,或者一个丢失全部派生成员的“残缺对象”。

如果把Shape的Clone()声明成纯虚函数,子类不实现就编译不过,这是最有效的强制手段。我自己写原型模式时,基类里永远是纯虚Clone(),不给“默认实现”留面子。因为一旦给了默认实现,某个派生类忘写 override 时,编译器不报错,行为却是错的,这种 bug 比直接崩溃更难排查。

5.3 多线程与生命周期小提醒

如果注册表会被多个线程同时读写,std::unordered_map本身是线程不安全的。最简单的方式是启动阶段单线程注册,运行阶段只读克隆;实在要动态注册,就给Register和Create加锁,或者用读写锁。模板本身最好设计成只读,克隆操作只读取它,不修改模板内部状态。如果你需要在运行期修改模板,建议先克隆出一个临时对象再改,改完把新模板放回注册表,不要直接对注册表里的模板动手。

生命周期方面,我再强调一次:注册表持有unique_ptr,克隆持有unique_ptr,这两个所有权链条畅通之后,原型模式的内存管理基本上不会有问题。最怕的是“模板存放在 A 处,引用散落在 B 处,删除却发生在 C 处”。

5.4 我习惯的检查方法

用过几次之后,我现在写原型模式会有一组固定检查动作:

  • 基类析构函数是virtual,否则通过基类指针删除派生对象时行为未定义。
  • Clone()是纯虚函数,每个具体类都显式 override。
  • 每个Clone()函数体里都通过make_unique<Derived>(*this)返回新对象,而不是手动new一个半成品。
  • 所有裸指针成员,要么在拷贝构造里做深拷贝,要么换成shared_ptr。
  • 如果派生类写了自己的拷贝构造函数,先确认第一行正确调用了基类拷贝构造。
  • 注册表统一用unique_ptr持有模板,不允许外部裸指针注册。

我自己在实际项目里用“模板注册表 + 多态克隆”这套方案,最舒服的地方是新增一种可创建对象时,只需要写一个派生类、加一行注册代码,创建逻辑完全不用动。最开始我也踩过深拷贝的坑,后来把“复制语义”当成设计的一部分来对待,花在查 bug 上的时间就少多了。如果读者已经开始在项目里使用原型模式,建议你现在就检查一下自己的Clone()返回类型和拷贝构造函数,这两个点只要有一个不对,后面迟早会付出调试的代价。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/15 5:20:01

C++原型模式实战指南:深拷贝、切片与clone()避坑

刚接触C设计模式的时候&#xff0c;我总觉得原型模式是几个创建型模式里最“鸡肋”的一个。单例好歹有明确的全局访问需求&#xff0c;工厂模式天天见&#xff0c;原型模式看起来就只是“拷贝一个对象”而已&#xff0c;好像没什么技术含量。直到后来在项目里处理一份配置对象&…

作者头像 李华
网站建设 2026/10/15 5:19:52

计算机体系结构量化分析:良品率、Amdahl定律与缓存DRAM详解

简介&#xff1a;《计算机体系结构量化分析&#xff08;第六版&#xff09;》课后答案PDF&#xff0c;面向计算机体系结构方向的本科生、考研族及硬件设计入门者&#xff0c;针对第一章芯片制造、良品率、功耗及Amdahl定律的课后案例提供完整计算与推导。内容具体到Phoenix和Re…

作者头像 李华
网站建设 2026/10/15 5:19:12

工业AI落地核心:物理世界建模与边缘原生智能

1. 工业AI不是“把算法搬进车间”&#xff0c;而是重新定义产线的呼吸节奏“他山之石可攻玉”这句老话&#xff0c;用在工业AI上特别贴切——它不是凭空造出一套新系统&#xff0c;而是把已在互联网、金融、医疗等领域锤炼成熟的AI能力&#xff0c;精准嫁接到工厂最真实、最粗粝…

作者头像 李华
网站建设 2026/10/15 5:19:02

WANA:直接在Wasm字节码上构建符号执行引擎的安全分析实践

1. 从标题拆解这篇论文到底在解决什么问题看到"WANA: Symbolic Execution of Wasm Bytecode"这个标题&#xff0c;第一反应应该是三个关键词的碰撞&#xff1a;Wasm字节码、符号执行、WebAssembly生态的安全分析。这三个词单独拎出来都不算新鲜&#xff0c;但组合在一…

作者头像 李华
网站建设 2026/10/15 5:19:02

VASPKIT中文手册精读:能带、态密度与后处理避坑指南

简介&#xff1a;VASPKIT是一款面向VASP软件预处理与后处理的命令行工具&#xff0c;广泛用于材料科学、催化与计算物理研究。这份PDF中文手册系统讲解VASPKIT的安装配置与核心功能&#xff0c;包括输入文件生成检查、PBE/HSE06能带计算、分波态密度、热力学量校正、差分电荷密…

作者头像 李华
网站建设 2026/10/15 5:18:51

NLP入门实战:新闻文本分类从TF-IDF到BERT微调全流程复盘

新闻文本分类这事儿&#xff0c;很多刚开始碰NLP的朋友都会把它当成第一道正式练习题。去年我在某云赛事平台上完整跑过一场新闻文本分类赛&#xff0c;从最朴素的 TF-IDF 加逻辑回归&#xff0c;一路做到 BERT 微调加模型融合&#xff0c;前后折腾了差不多两周。回过头看&…

作者头像 李华