资讯详情

资讯详情

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_ptrShape它实际指向的是Circle你希望复制一份完全独立的Circle。如果只是在Shape基类里定义一个Shape* Clone()然后返回new Shape(*this)那么复制出来的是Shape对象派生类的成员全部丢失这就是典型的对象切片。正确的做法是在基类里声明一个虚函数每个派生类自己实现“按自己的真实类型去复制”。这就是多态克隆class Shape { public: virtual ~Shape() default; virtual std::unique_ptrShape Clone() const 0; };派生类负责返回自己的新实例class Circle : public Shape { public: std::unique_ptrShape Clone() const override { return std::make_uniqueCircle(*this); } };这里有两个细节值得展开。第一为什么用std::unique_ptrShape而不是裸指针Shape*作为返回类型这主要是为了杜绝“克隆出对象后忘了释放”这类问题。在老式 C 教材里原型模式通常返回Base*调用方要记住delete一旦异常路径没处理就泄漏。用unique_ptr之后对象所有权明确调用方拿到手就是一个 RAII 对象可以直接放进容器也可以移交给其他对象。第二注意这里没有办法直接返回std::unique_ptrCircle并在override时声明为协变返回类型。C 的 return type covariance 只对指针和引用生效unique_ptrDerived和unique_ptrBase是两种不同的类型编译器不认。所以派生类必须声明成std::unique_ptrShape Clone() const override在函数体内部通过make_uniqueCircle(*this)创建具体对象。这个写法看着多写了一个类型名实际反而更安全因为你没法在不创建派生对象的情况下伪造返回值。如果你用的是比较旧的代码风格坚持返回裸指针也是可以的但我建议至少把所有裸指针放进unique_ptr或shared_ptr再返回。当年我写第一个版本时偷懒返回Shape*后来某个模块在克隆对象之后忘了释放内存泄漏排查了整整一个下午。用unique_ptr之后这个坑基本就堵死了。2.2 拷贝构造函数默认的未必够用但乱写更危险Clone()方法内部的关键动作是make_uniqueCircle(*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_uniqueDerived(*this)把复制动作封装起来。2.3 用注册表把“可复制模板”管起来光有Clone()还不够。实际项目中你往往希望用户通过一个名字来取模板而不是在业务代码里手写auto a std::make_uniqueCircle(...); a-Configure(...);。这时可以引入一个原型注册表内部用一个std::unordered_mapstd::string, std::unique_ptrShape管理所有模板对象。注册表的核心接口只需要两个注册模板、按名字克隆。class PrototypeRegistry { public: void Register(const std::string key, std::unique_ptrShape prototype) { templates_[key] std::move(prototype); } std::unique_ptrShape Create(const std::string key) const { auto it templates_.find(key); if (it templates_.end()) { return nullptr; } return it-second-Clone(); } private: std::unordered_mapstd::string, std::unique_ptrShape templates_; };这个注册表的收益在于调用方只需要知道字符串 key不需要知道具体类名也不需要在创建点写switch/case或者一堆工厂函数。将来新增一种对象类型时只要把它的模板注册进去原有逻辑完全不用改。系统启动时把所有模板放进注册表运行期间一直复用这些模板做克隆对象创建的真实路径就变得非常统一。3. 深拷贝还是浅拷贝一个容易翻车的选择题3.1 默认拷贝语义到底复制了什么C 的默认拷贝行为是“逐成员复制”它只复制成员变量的值。如果成员变量是一个int那没问题如果是一个std::stringstring自己会复制堆上的字符缓冲区没问题如果是一个裸指针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_uniqueunsigned 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_ptrunsigned char[] pixels_; };这里我选择用std::unique_ptrunsigned char[]而不是裸指针主要原因是想尽量用 RAII 管理生命周期同时保留“深拷贝时需要手动分配新内存”的教学意义。写自定义拷贝构造函数时逻辑很简单先把目标对象的尺寸拿过来再为新对象分配独立内存最后把数据复制过去。这样得到的两个 Bitmap 对象操作同一段视觉内容但物理上互不影响。有朋友可能问直接用std::vectorunsigned char不是更省事吗确实vector默认就具备深拷贝能力写起来更短。但如果你的项目依赖一个已有的 C 风格图像库缓冲区必须是一块连续内存而且可能会被外部函数直接写那么用unique_ptrT[]配合自定义拷贝构造的情况就会频繁出现。这时候正确理解“指针成员需要深拷贝”这个原则比选什么容器更重要。3.3 什么时候可以放心共享并不是所有成员都必须深拷贝。比如一个全局材质库里的只读纹理每个对象只是持有它的引用没必要复制一份内存。问题是如果单纯用裸指针共享你需要自己保证那个纹理对象的生命周期比所有引用者都长程序一旦出现释放顺序错误就是隐蔽的悬垂指针。更稳妥的做法是改用std::shared_ptrTexture。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_ptrShape 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_ptrShape Clone() const override { return std::make_uniqueCircle(*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_ptrShape Clone() const override { return std::make_uniqueRectangle(*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_ptrShape prototype) { templates_[key] std::move(prototype); } std::unique_ptrShape Create(const std::string key) const { auto it templates_.find(key); if (it templates_.end()) { return nullptr; } return it-second-Clone(); } private: std::unordered_mapstd::string, std::unique_ptrShape templates_; };这段代码里Circle和Rectangle的拷贝构造函数我都显式写出来了但其实不写、让编译器生成默认版本也一样能工作。之所以显式写是为了强调当你决定手动维护复制逻辑时要记得显式调用基类拷贝构造Shape(other)把基类部分的成员正确复制进来。很多新手在这里只复制自己新增的成员忘记复制基类成员结果对象类型对了、内容缺了一截。再看main函数int main() { PrototypeRegistry registry; registry.Register(circle, std::make_uniqueCircle(5.0f, default_circle)); registry.Register(rect, std::make_uniqueRectangle(4.0f, 6.0f, default_rect)); std::vectorstd::unique_ptrShape 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] radius5 Circle[default_circle] radius5 Rectangle[default_rect] size4x64.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_uniqueCircle(3.0f, temp)); }看似很基础但我在代码评审里见过不少次类似的低级问题根源都是“注册表需要存储模板”这件事没想清楚把所有权留在了外面。5.2 一个看似正常但数据丢失的逻辑问题还有一种情况不崩溃但结果不对派生类Clone()返回基类类型时不小心在函数体里写了return std::make_uniqueShape(*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_uniqueDerived(*this)返回新对象而不是手动new一个半成品。所有裸指针成员要么在拷贝构造里做深拷贝要么换成shared_ptr。如果派生类写了自己的拷贝构造函数先确认第一行正确调用了基类拷贝构造。注册表统一用unique_ptr持有模板不允许外部裸指针注册。我自己在实际项目里用“模板注册表 多态克隆”这套方案最舒服的地方是新增一种可创建对象时只需要写一个派生类、加一行注册代码创建逻辑完全不用动。最开始我也踩过深拷贝的坑后来把“复制语义”当成设计的一部分来对待花在查 bug 上的时间就少多了。如果读者已经开始在项目里使用原型模式建议你现在就检查一下自己的Clone()返回类型和拷贝构造函数这两个点只要有一个不对后面迟早会付出调试的代价。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →