资讯详情

资讯详情

C++原型模式实战:深拷贝、多态克隆与避坑指南

写C写久了你会发现真正麻烦的不是语法本身而是怎么把对象“造”得既安全又灵活。原型模式Prototype Pattern在创建型模式里属于比较耐看的一种——它不和工厂方法抢“怎么生产新对象”的活而是直接回答一个看似简单的问题给我一个已经存在的对象我能得到一份它的完整副本吗注意这里的“副本”不是值拷贝那么简单而是多态的、运行时才知道具体类型的深复制。我第一次接触原型模式时也很困惑C天生支持拷贝构造函数为什么还要设计模式来“克隆”直到我在一个图形编辑器里遇到“用户随手复制一个图形但代码里只拿到了基类指针”的场景才意识到原型模式真正解决的问题是什么。这篇就把我这些年用原型模式的经验、踩过的坑、以及几个可以直接抄的代码骨架一次说透。1. 先弄清楚原型模式到底解决什么问题1.1 最直观的场景你手里只有基类指针假设你在写一个绘图程序画布上放着圆形、矩形、文本块。所有图形类继承自同一个Shape接口画图管理模块里自然维护的是std::vectorstd::unique_ptrShape。用户点击某个图形按CtrlC复制再按CtrlV粘贴。这时候你会遇到一个非常尴尬的问题程序运行时才拿到这个图形的Shape*你根本不知道它到底是Circle、Rectangle还是TextBlock。你可能会想那用dynamic_cast判断类型再逐个处理如果只有三五个类型还好一旦图形类型增加到几十种if (dynamic_castCircle*)... else if (dynamic_castRectangle*)...这种代码会变成一场噩梦而且每新增一个类型都要改这段复制逻辑。原型模式的思路很直接既然我不知道它的具体类型那我就让每个类型自己负责复制自己。基类里定义一个clone()虚函数子类各自实现。用户复制时代码只需要调ptr-clone()多态会自动走到正确类型的复制函数。你不需要知道类型名也不需要修改任何业务代码就能得到一个新的、类型正确、内容完全独立的对象。1.2 为什么不能直接用拷贝构造函数很多人会问C的拷贝构造函数不也能复制对象吗为什么还要专门搞一个clone()关键在于多态性。拷贝构造函数是编译期绑定的你写Shape copy *shapePtr;编译器看到的是Shape类型的拷贝构造即使shapePtr实际指向Circle它复制的也只是Shape的基类部分。想实现多态复制只能在每个子类里写自己的拷贝逻辑再通过虚函数调度。这其实是C语言的一个结构性缺口有虚析构、虚函数但就是没有“虚构造函数”。原型模式本质上用clone()这个虚函数补上了“虚构造函数”的位置。你可以把它理解成拷贝操作被提升为多态契约任何子类都必须承诺自己能够被完整复制。1.3 哪些场景真正适合原型模式从实际工程经验看原型模式不是万金油它适合下面这几类场景对象创建成本很高。比如一个对象初始化时要读数据库、拉配置、解析JSON那么每次创建都重复这些开销很浪费。你可以保留一个“模板对象”作为原型每次克隆它再小范围修改参数即可。运行时的类型不确定。类似前面图形编辑器的例子模块只持有基类指针或接口引用却要创建同类型的新对象。需要保留对象的“默认状态”。比如一个游戏里的怪物原型初始血量、攻击力、移动速度都已经配好生成新怪物时克隆原型再随机微调属性。同类型对象批量生成且每个实例需要独立修改。注意这里“独立”很关键如果所有对象都只读共享根本不需要克隆共享指针就够了。反过来如果你的对象创建逻辑特别简单、类型编译期就确定直接写拷贝构造或工厂函数反而更清晰。2. 经典实现与C代码层面的关键细节2.1 一个最小可运行的骨架先给一个现代C风格的基础实现你可以直接用这个结构去套自己的业务场景#include iostream #include memory #include string class Shape { public: virtual ~Shape() default; virtual std::unique_ptrShape clone() const 0; virtual void draw() const 0; }; class Circle : public Shape { public: Circle(double radius, std::string color) : radius_(radius), color_(std::move(color)) {} std::unique_ptrShape clone() const override { return std::make_uniqueCircle(*this); } void draw() const override { std::cout Circle(radius radius_ , color color_ )\n; } private: double radius_; std::string color_; }; class Rectangle : public Shape { public: Rectangle(double w, double h) : width_(w), height_(h) {} std::unique_ptrShape clone() const override { return std::make_uniqueRectangle(*this); } void draw() const override { std::cout Rectangle(w width_ , h height_ )\n; } private: double width_; double height_; };调用侧非常简单int main() { std::unique_ptrShape original std::make_uniqueCircle(5.0, blue); auto copy original-clone(); copy-draw(); // 输出 Circle(radius5, colorblue) return 0; }核心就三件事基类定义clone()纯虚函数子类里用std::make_unique子类(*this)实现调用方只依赖基类接口。这里特别要强调make_unique子类(*this)这个写法它是clone唯一正确的打开方式。2.2 clone返回类型的协变规则如果你翻看老版本的C设计模式书通常会看到Shape* clone()的写法子类返回Circle*。这种返回类型随派生类变化的行为叫“协变返回类型”C指针和引用是支持的。但到了现代C我们更推荐用std::unique_ptr包装返回结果问题是unique_ptrT不是协变的所以基类返回unique_ptrShape、子类也返回unique_ptrShape只是内部实际构造的是Circle。有人问那我能不能基类返回unique_ptrShape子类返回unique_ptrCircle答案是不行编译器会直接报错。如果你确实想在调用侧拿到具体类型指针有两个方案// 方案一调用侧用 dynamic_pointer_cast auto circlePtr std::dynamic_pointer_castCircle(shape-clone()); // 方案二基类 clone 返回裸指针支持协变外部再包 unique_ptr virtual Shape* cloneRaw() const 0; // 子类实现 // Circle* cloneRaw() const override { return new Circle(*this); }方案一更安全也更符合资源管理的直觉。实战中大多数业务方根本不需要知道具体类型直接按基类处理就够了。2.3 深拷贝和浅拷贝最容易埋雷的边界原型模式的实现有多简单它的坑就有多深。一切问题都集中在“拷贝到什么程度”。默认的make_uniqueCircle(*this)调用的是Circle的拷贝构造函数。如果Circle只包含值类型成员如double、std::string编译器生成的默认拷贝构造就够了。但一旦类里出现裸指针、文件句柄、套接字、ID、或者外部资源的引用默认拷贝构造就会把指针值直接复制过去结果就是两个对象指向同一块内存。改一个另一个跟着变析构时更是双重释放的定时炸弹。正确的做法是自定义拷贝构造把需要独立持有的资源真正“复制”一份class Texture { public: Texture(int w, int h) : width_(w), height_(h) { data_ std::make_uniqueunsigned char[](w * h); } // 拷贝构造重新分配内存并复制像素数据 Texture(const Texture other) : width_(other.width_), height_(other.height_) { data_ std::make_uniqueunsigned char[](w * h); std::memcpy(data_.get(), other.data_.get(), w * h); } private: int width_; int height_; std::unique_ptrunsigned char[] data_; }; class Sprite : public Shape { public: Sprite(Texture tex, std::string name) : texture_(std::move(tex)), name_(std::move(name)) {} std::unique_ptrShape clone() const override { return std::make_uniqueSprite(*this); // 拷贝构造会自动深拷贝 texture_ } private: Texture texture_; std::string name_; };这里有个非常重要的经验不要在clone函数里手动复制成员应该直接调用拷贝构造。因为你永远不知道未来会不会给类加新成员。如果clone里是return std::make_uniqueSprite(*this)新增成员后拷贝构造自动覆盖如果是手动new Sprite(other.radius_) other.color_每加一个成员都要来改漏一个就是一个bug。3. 实战图形编辑器的“复制粘贴”怎么落地3.1 需求拆解我在某个项目里做过一个简化版的矢量编辑器当时拿到一个很典型的需求画布上有各种图形它们都继承自Shape。用户任意选中一个图形点击“复制”系统需要在原图旁边生成一个完全相同的图形。生成的副本可以独立移动、改色、删除不得影响原图。系统可能在后继版本里新增更多图形类型但复制功能不允许频繁改动。这几乎就是为原型模式量身定做的场景。复制逻辑压根不需要知道用户选中的是什么图形只要调用clone()。3.2 完整实现与关键代码#include iostream #include memory #include vector #include string class Shape { public: virtual ~Shape() default; virtual std::unique_ptrShape clone() const 0; virtual void print() const 0; virtual void move(double dx, double dy) 0; }; class Line : public Shape { public: Line(double x1, double y1, double x2, double y2) : x1_(x1), y1_(y1), x2_(x2), y2_(y2) {} std::unique_ptrShape clone() const override { return std::make_uniqueLine(*this); } void print() const override { std::cout Line( x1_ , y1_ )-( x2_ , y2_ )\n; } void move(double dx, double dy) override { x1_ dx; y1_ dy; x2_ dx; y2_ dy; } private: double x1_, y1_, x2_, y2_; }; class TextBlock : public Shape { public: TextBlock(std::string text, double x, double y) : text_(std::move(text)), x_(x), y_(y) {} std::unique_ptrShape clone() const override { return std::make_uniqueTextBlock(*this); } void print() const override { std::cout TextBlock(\ text_ \ at x_ , y_ )\n; } void move(double dx, double dy) override { x_ dx; y_ dy; } private: std::string text_; double x_, y_; }; class Canvas { public: void addShape(std::unique_ptrShape shape) { shapes_.push_back(std::move(shape)); } // 关键操作复制指定位置的图形 std::unique_ptrShape duplicate(size_t index) const { return shapes_[index]-clone(); } void render() const { for (const auto s : shapes_) { s-print(); } } private: std::vectorstd::unique_ptrShape shapes_; };调用演示int main() { Canvas canvas; canvas.addShape(std::make_uniqueLine(0, 0, 10, 10)); canvas.addShape(std::make_uniqueTextBlock(hello, 3, 7)); auto newShape canvas.duplicate(0); newShape-move(100, 100); canvas.addShape(std::move(newShape)); canvas.render(); return 0; }这个设计里Canvas::duplicate就是原型模式的典型应用。它不需要任何类型判断也不依赖dynamic_cast纯粹的基类多态就能完成复制。3.3 新增类型时系统怎么演化如果后续要支持Arc圆弧只需要做两件事继承Shape实现clone()、print()和move()。Canvas和duplicate一行都不用改。这就是原型模式的核心收益它把“新增类型”这件事的改动范围限制在新增文件本身而不是蔓延到所有创建对象的调用点。当然如果Canvas想在新增加图形类型的按钮时用到具体类型那还是需要在UI层登记。但复制、移动、渲染这些通用流程完全可以在不知道具体类型的情况下运行。4. 原型注册表把原型模式工程化4.1 为什么需要注册表只用原型模式时你仍然需要make_uniqueCircle(...)来创建第一个原型实例。如果系统里有几十种图形调用方仍然要在创建原型时写一堆类型名。更麻烦的是有些对象的原型需要复杂的初始化参数比如一个“怪物”原型可能要从JSON配置里加载数据。这时候可以把“原型实例”集中管理起来按字符串名字或枚举类型存储到一个注册表里。想创建新对象时只传一个key注册表负责查找原型并克隆。这相当于原型模式加上工厂方法的叠加效果。4.2 注册表实现#include unordered_map #include memory #include string #include stdexcept class PrototypeRegistry { public: void registerPrototype(std::string key, std::unique_ptrShape proto) { prototypes_[std::move(key)] std::move(proto); } std::unique_ptrShape create(const std::string key) const { auto it prototypes_.find(key); if (it prototypes_.end()) { throw std::invalid_argument(prototype not found: key); } return it-second-clone(); } template typename T void registerSample(const std::string key, const T sample) { registerPrototype(key, sample.clone()); } template typename T std::unique_ptrT createAs(const std::string key) const { auto raw create(key); auto casted dynamic_castT*(raw.get()); if (!casted) { throw std::invalid_argument(type mismatch for key: key); } // 释放所有权后交给 unique_ptrT casted-addRef(); // 这里不需要下面有更安全的做法 return std::unique_ptrT(dynamic_castT*(raw.release())); } private: std::unordered_mapstd::string, std::unique_ptrShape prototypes_; };实际使用中createAs可以直接做动态转换auto registry PrototypeRegistry(); registry.registerSample(circle_red, Circle(2.5, red)); registry.registerSample(rect_default, Rectangle(4, 2)); auto c registry.createAsCircle(circle_red); if (c) c-draw();4.3 生命周期与所有权问题注册表内部保存的是unique_ptrShape它持有每个原型的唯一所有权。每次create()返回一个新克隆的unique_ptr所有权转移给调用方注册表自身不共享数据。这种做法所有权清晰但也意味着原型对象必须是可以独立复制的不需要依赖注册表的生命周期来存活。一个常见的坑是有人把某个全局单例对象直接作为原型存进注册表然后克隆时只复制了指针。万一这个单例在程序结束前被析构所有克隆对象都成了悬空指针。所以再次强调原型模式里克隆出来的对象一定要拥有自己的资源或至少通过shared_ptr管理共享资源的生命周期。还有一点注册表里registerSampleT要求T必须继承自Shape否则sample.clone()编译不通过。这是好事编译期就能拦截类型错误。5. 老司机也要小心的坑问题排查与避坑指南5.1 常见问题速查表我在实际项目中把这些坑整理成一张表排查时先对着看能省不少时间。症状可能原因解决方案克隆对象和原对象共享内部数据改一个另一个跟着变类里有裸指针却用默认拷贝构造浅拷贝了自定义拷贝构造对指针成员做深拷贝能用容器就用容器clone返回的对象类型不对调用的是基类方法clone里直接返回make_uniqueShape(*this)构造了基类对象必须返回make_unique子类(*this)克隆后程序崩溃疑似双重释放浅拷贝导致两个对象管理同一块堆内存析构时重复释放检查所有裸指针成员改成深拷贝或shared_ptr原对象析构后克隆对象访问数据崩溃clone时只复制了指向外部资源的指针没有复制资源本身对外部资源做深拷贝或明确转移所有权注册表报prototype not foundkey拼写不一致或原型未注册统一用常量字符串或枚举不要裸字符串排查递归调用clone导致栈溢出构造函数内部又调用了clone()形成无限递归clone里使用拷贝构造不要在构造过程中触发虚函数5.2 漏掉成员是最隐蔽的bug我处理过最典型的一个bugTextBlock原本只有一个std::string text_后来为了性能加了一个std::shared_ptrTextLayout layout_。开发者觉得shared_ptr复制不会出事就完全没改拷贝构造。结果两个克隆对象共享同一个TextLayout某个对象修改了字体大小另一个也跟着变了产品侧报了个非常诡异的“文本样式串台”问题。排查了很久才定位到是layout_被共享了。修复方案很简单layout_本来就该是共享的所以不需要深拷贝但问题在于业务方不知道这个共享关系后续代码误以为副本完全独立。最终我们把TextLayout改成了有引用计数的单例样式表同时在TextBlock里删除了拷贝共享的语义明确每个副本都有独立样式。这个教训说明了一点原型模式下你必须对每个成员明确回答一个问题——它该被共享还是该被复制这决定了拷贝构造怎么写也决定了你提供给外界的语义是“深拷贝”还是“浅拷贝加引用计数”。5.3 clone的异常安全与一致性clone()通常是const成员函数它不修改原对象。但如果深拷贝过程中内存分配失败抛出了std::bad_alloc原对象不会受到影响因为所有复制工作都在新对象里完成原对象没有任何改动。这就是强异常安全的天然优势。但有一种情况要注意如果你在clone里先复制了部分状态然后手动去new另一个资源new失败抛出异常时前面已经复制好的资源可能泄漏。所以最好的策略是依赖RAII——尽量使用std::make_unique、std::vector、std::string这些自管理类型而不是到处new/delete。// 反例容易泄漏 std::unique_ptrShape clone() const override { auto obj std::make_uniqueSprite(*this); obj-buffer_ new char[100]; // 如果new失败buffer_之前已有的资源也可能泄漏 return obj; } // 正例所有成员都通过容器管理 std::unique_ptrShape clone() const override { return std::make_uniqueSprite(*this); }5.4 返回unique_ptr、shared_ptr还是裸指针我的建议是优先返回std::unique_ptr。原因很直接unique_ptr语义清晰表示克隆出来的对象有独立所有权。调用方想转成shared_ptr只需要std::shared_ptrShape(shape-clone())。裸指针容易漏delete除非你和一个老代码库需要协变返回类型否则没有理由用裸指针。如果类本身需要被多方共享可以在返回前由调用方决定包一层shared_ptr而不是让clone本身返回shared_ptr。因为共享策略属于使用场景不该硬编码到对象自身的行为里。6. 原型模式和其他创建型模式的取舍6.1 三种创建型模式快速对照维度工厂方法原型模式单例模式创建方式通过工厂函数/类创建新对象通过克隆已有实例创建新对象全局唯一实例直接获取对象初始状态通常从零开始构造继承原型的默认状态固定唯一状态运行时类型多态需要工厂按类型分支或模板天然多态无需知道类型不涉及多态新增类型的成本可能需要新增工厂子类/分支只需注册一个原型不适用适合场景同一接口有多种产品对象复制昂贵、需要模板副本全局配置、状态中心6.2 我实际项目里的选择原则我的经验是先看对象的“创建成本”和“类型确定性”。如果类型编译期就确定比如固定创建Circle那就直接用构造函数或工厂函数上原型模式属于过度设计。如果运行时要根据配置文件动态创建多种类型且类型数量可能增加优先考虑原型注册表方案。如果对象初始化依赖外部资源比如数据库连接、模型文件、图片纹理那么建一个样板对象反复克隆比每次都重新加载资源要高效太多。一个反面例子是有人把所有类都塞进原型模式结果一个非常简单的Point类也要写clone()、拷贝构造代码反而臃肿。设计模式始终是工具不是装饰要评估收益再上。按照我个人的实践原型模式最值得用的地方是那两个动态类型复制和昂贵对象的模板复制。只要这两点占一点原型模式就是合适的选择两点都不占强行用只会给维护者添乱。说句心里话原型模式是我在C里用得最多的创建型模式之一但同时也是出bug最多的模式。大部分问题都出在“浅拷贝”上而且这种bug很隐蔽往往要等到某个对象被修改、另一个对象跟着变的时候才会暴露。如果你要在自己的项目里使用原型模式我的建议是先花半小时检查类里每个成员变量的拷贝语义哪些该深拷贝哪些该共享哪些只能移动。把这个账算清楚原型模式写起来是非常省心的。
觉得有用,分享给同行:

为您的企业打造数字门面

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

立即咨询 →