C++原型模式实战:虚函数、深拷贝与多态对象复制
1. 我为什么开始研究原型模式一个“运行时类型未知”的复制需求1.1 一个看似简单的问题我想复制多态容器里的一个元素做编辑器类项目的朋友应该都碰到过这类需求画布上摆了圆、矩形、多边形、文字框按下 CtrlC 复制一个元素再按下 CtrlV 生成一个新对象。麻烦来了——我手里可能只有一个指向Shape*的基类指针程序运行时根本不知道它到底是Circle还是Rectangle。如果用最直接的办法auto copy *shapePtr;前提是你敢让对象切片发生。结果就是一个Circle被切成了Shape半径、填充色这些子类字段全丢了。更严重点如果基类有纯虚函数你连这一行都编译不过去。这个问题本质上是构造函数的静态类型世界和运行时多态对象之间是有断层的。new Circle(...)必须写死类的名字但我在运行时拿到的是一个Shape*。构造函数本身是“非虚”的没办法通过一个基类指针去触发一次“正确类型”的构造。1.2 没有原型模式之前的解法dynamic_cast 链和它的局限很多初学者第一反应是写一段dynamic_cast判断std::unique_ptrShape cloneShape(const Shape src) { if (auto* c dynamic_castconst Circle*(src)) { return std::make_uniqueCircle(*c); } if (auto* r dynamic_castconst Rectangle*(src)) { return std::make_uniqueRectangle(*r); } // 以后每加一个类型就要来这里补一段 return nullptr; }这段代码确实能跑但它是典型的“坏味道”每往类层次里加一个新类型就得到处找这种函数并且往里补分支。这个函数和所有具体类都耦合在一起一旦类多了维护成本就指数上升。何况别人写的第三方子类你还改不了这个函数。原型模式解决这个问题的方式非常“偷懒”且优雅让对象自己告诉我怎么复制自己。我不需要在外面维护一长串类型判断只需要调用一个虚函数clone()具体是圆的复制还是矩形的复制由对象自己的虚表去跳转。每个新类型只要在自己的类里实现clone()就自动被整个复制流程兼容了。我用过之后最大的感受是设计模式听起来是学术名词但遇到这种“运行时才知道类型”的场景它就是最省事、最直接的工程方案。2. 原型模式在C里的标准骨架虚克隆函数与多态复制2.1 最朴素的实现虚函数返回裸指针如果先忘掉 C 的现代风格原型模式的核心其实是三大件class Shape { public: virtual ~Shape() default; virtual Shape* clone() const 0; virtual void draw() const 0; }; class Circle : public Shape { public: explicit Circle(double radius) : radius_(radius) {} Circle* clone() const override { return new Circle(*this); } void draw() const override { // 画一个圆 } private: double radius_; };这里有个 C 特有的语法细节叫协变返回类型。派生类的clone()返回Circle*而基类里声明的是Shape*这算合法的重写。调用方拿到的指针可以自动转换成Shape*同时实际对象类型不会丢。这是 C 允许的一种“重写时返回更具体类型”的例外Java 和 C# 也有类似机制。裸指针版本的缺点很明显调用方负责delete稍不注意就内存泄漏。所以我更推荐下面这个更稳的写法。2.2 更稳的写法unique_ptr 私有虚函数版我在项目里通常使用这套骨架既能规避切片又能让所有权语义清晰class Shape { public: virtual ~Shape() default; // 对外统一返回 unique_ptr所有权清晰 std::unique_ptrShape clone() const { return std::unique_ptrShape(clone_impl()); } virtual void draw() const 0; private: // 内部真正干活的虚函数 virtual Shape* clone_impl() const 0; }; class Circle : public Shape { public: explicit Circle(double radius) : radius_(radius) {} void draw() const override { // 画一个圆 } private: Shape* clone_impl() const override { return new Circle(*this); } double radius_; };这样设计有一个不小的好处clone()是公开非虚函数clone_impl()才是虚函数。外面调用的永远是统一入口回来的unique_ptrShape不用操心delete内部每个类型自己负责new一个什么。还有个附加优势是如果某个子类忘了重写clone_impl()编译器会直接报错因为它是纯虚函数。相比“忘记重写 clone 导致切片”这种结构从源头就堵死了一大半问题。虽然clone_impl()返回裸指针看起来有点“原始”但这只是为了在构造函数里接住new出来的对象然后立刻转给unique_ptr。这是很常见也很安全的写法。2.3 克隆与构造函数的本质区别很多第一次接触原型模式的朋友会问我直接写拷贝构造函数不行吗当然可以拷贝构造函数解决的是“同类对象之间的复制”而原型模式解决的是“类型未知、但行为要正确的复制”。两者并不冲突原型模式内部往往就是调用拷贝构造函数。打个比方拷贝构造函数像你去裁缝店报自己的尺寸让裁缝做一件新衣服原型模式像你把身上的衣服直接脱下来给裁缝做样板。你不需要描述尺寸也不需要知道衣服的款式里有多少暗袋、褶皱和特殊缝线原型天然把这些细节都打包好了。更具体地说一个对象除了字段值还可能有已加载的资源、缓存的中间结果、运行时生成的内部状态。用构造函数重新创建这些东西全靠外部重新初始化用克隆这些状态直接跟着走。这就是为什么我经常用“模板对象 克隆”来替代“工厂参数 构造”的方案。3. 深拷贝与浅拷贝原型模式里最绕不过去的坎3.1 浅拷贝现场两个对象共享内存的后果只要你的类不是“全是值类型成员”原型模式就绕不开深拷贝和浅拷贝的分岔路。先看一个经典反面教材class Sprite { public: explicit Sprite(size_t size) : size_(size), data_(new uint8_t[size]) {} ~Sprite() { delete[] data_; } // 默认的拷贝构造函数a.data_ 和 b.data_ 指向同一块内存 size_t size_; uint8_t* data_; }; Sprite a(1024); Sprite b(a); // 编译器自动生成的浅拷贝 delete a.data_; // 或者析构时 // b.data_ 已经成了空悬指针之后 delete 就是双重释放如果你的clone()是这样写的Sprite* clone() const { return new Sprite(*this); }那恭喜你成功复制出了一个“看起来一样但随时崩溃”的假克隆。浅拷贝的结果就是两个对象共享同一块底层资源谁先析构谁就把资源释放了另一个再析构就崩溃。我在早期项目里因为这个 bug 被折腾到半夜最后用 ASan 一查double free。3.2 深度复制的首选思路值语义优先在写clone()之前先检查一遍你的成员变量。能用值类型表达的千万别为了“性能”提前上指针。比如图片缓冲我见过不少人非要用uint8_t*加new[]然后手动深拷贝。其实换成std::vectoruint8_t拷贝构造函数、赋值运算符、移动语义全都由标准库处理好clone()里一行make_uniqueSprite(*this)自动完成深拷贝干净得不行。class Sprite { public: explicit Sprite(size_t size) : data_(size) {} private: std::vectoruint8_t data_; std::string spriteName; };值语义优先不是逃避指针而是把资源管理交给明确的 RAII 类。像std::string、std::vector、std::array它们本身就是“深拷贝”的。只要成员全对默认拷贝构造生成的克隆就是安全的深拷贝。这套哲学在 C 社区里有个名字叫“Rule of Zero”。3.3 指针成员与智能指针何时共享、何时复制当成员确实需要指针或者引用语义时要果断分清楚两种目标共享所有权多个副本共享同一个底层对象用std::shared_ptr。比如多个实例共享一份只读配置。独占所有权 深拷贝每个副本有自己的底层对象用std::unique_ptr并且要在拷贝构造或clone里头手动new一份。举个例子技能对象里有一个特效资源class Skill { public: Skill(const Skill other) : name_(other.name_) , effect_(std::make_uniqueParticleEffect(*other.effect_)) { } std::unique_ptrSkill clone() const { return std::make_uniqueSkill(*this); } private: std::string name_; std::unique_ptrParticleEffect effect_; };这里ParticleEffect有完整的拷贝构造所以我们能通过*other.effect_创建新实例。如果ParticleEffect不能拷贝那就得考虑是不是真的要深拷贝还是改用shared_ptr共享或者给ParticleEffect增加一个“克隆工厂”。我在实际项目里总结出的原则很简单不用急着让所有东西都深拷贝先问自己“如果两个对象共享这个成员其中一个修改了它另一个会不会疯掉”。如果答案是“无所谓”共享就完事了如果答案是“会疯”那就老老实实写深拷贝。4. 原型注册表从“会克隆”到“按需取物”4.1 注册表的基本设计原型模式如果只停留在“每个类会克隆自己”那它的价值最多算个变种拷贝构造。真正让它在项目里发光的是原型注册表Prototype Registry我预先创建好一批模板对象把它们放进一个表里之后通过名字或 ID 随时取用其克隆。class ShapeRegistry { public: void add(const std::string name, std::shared_ptrShape prototype) { prototypes_[name] std::move(prototype); } std::unique_ptrShape create(const std::string name) const { auto it prototypes_.find(name); if (it prototypes_.end()) { return nullptr; } return it-second-clone(); } bool contains(const std::string name) const { return prototypes_.count(name) 0; } private: std::mapstd::string, std::shared_ptrShape prototypes_; };使用起来很简单ShapeRegistry registry; registry.add(circle, std::make_sharedCircle(30.0)); registry.add(rect, std::make_sharedRectangle(20.0, 40.0)); auto obj1 registry.create(circle); auto obj2 registry.create(circle);obj1和obj2是两块独立内存各自拥有自己的半径和状态。后续你想改“circle”的默认半径只需要在注册表里替换这一个原型所有后续创建的新对象都会拿到新默认值以前已经克隆出去的不受影响。这种“改一个以后都是新的”的语义是非常实用的工程特性。4.2 为什么注册表比一堆 else-if 更健康在没有注册表时创建对象通常长这样if (name circle) return make_uniqueCircle(); else if (name rect) return make_uniqueRectangle(); else if (name text) ...如果有几十上百个类型这个函数会膨胀到没法看而且所有类型都堆在一个文件里互相之间形成强耦合。注册表方案把“类型名”和“如何创建对象”的映射关系拆开了每个类自己负责怎么创建注册表只负责按名查表和调用clone()。这样新增一个类型只需要在程序初始化阶段调用一次registry.add(hexagon, ...)调用方和注册表本身的代码一个字都不用改。这有点像一个轻量级的“反射”机制。C 没有完整的反射但你想要“根据字符串创建任意类型的对象”时注册表加原型模式就是最贴近反射、又完全不依赖编译期魔法的通用套路。4.3 生命周期问题谁持有原型谁托管副本用注册表有个容易忽略的问题原型对象放在注册表里谁负责它的生命周期我的选择是用std::shared_ptrShape持有原型。这样原型可以随时被多处引用注册表销毁时如果还有别人引用对象也不会悬空。克隆出来的副本我统一返回std::unique_ptrShape表示“调用方独占这个新对象责任明确”。如果你非要返回shared_ptr也行但要清楚那意味着副本和原型可能混在一个所有权模型里容易出边界问题。注意注册表里的原型对象和克隆出来的副本应该是相互独立的。如果你希望“原型变已有副本也变”那就不是克隆而是共享了应该考虑直接分发shared_ptrShape而不是走clone()。这个区别一定要想清楚否则代码维护时会产生非常诡异的 bug。5. 原型模式 vs 工厂模式真正的选型逻辑5.1 三者对比速览表建型模式经常被混着讨论特别是原型模式、工厂方法、抽象工厂这三兄弟。我先放一张对比表再解释我怎么选。对比维度原型模式工厂方法抽象工厂核心动作克隆一个已有对象调用一个工厂方法产生新对象通过一组工厂方法创建产品族新增产品类型新增类并实现 clone注册即可新增类和对应的工厂子类需要修改抽象工厂接口影响所有工厂对已有对象状态的处理完整保留复制运行时状态通常通过参数初始化新对象通常通过参数初始化新对象调用方依赖不依赖具体类型甚至不依赖构造参数不依赖具体类型依赖工厂接口不依赖具体类依赖抽象工厂接口典型场景模板对象、配置快照、运行时动态类型单一产品等级结构多种产品族联合生产5.2 什么时候该让原型模式上场我选原型模式的条件很简单这个对象“长什么样”本身是运行期才知道的而且它的初始状态很复杂直接用参数构造又要重建一遍。举一个我常举的例子网络游戏里的技能。设计技能时你希望策划可以用配置文件定义技能的各种数值、特效、音效然后游戏启动时读配置生成一套“技能模板对象”玩家学习技能时从模板克隆一份属于自己的实例。因为一份模板可能要服务成千上万个玩家直接共享同一个对象会导致某个玩家改了技能等级其他人也跟着变。这种情况下如果走工厂方法你要给每个技能新写一个工厂类而且技能的初始状态比如特效用例、动画资源引用都堆在工厂参数里参数列表会极丑。原型模式就很合适模板本身就带着全套配置克隆一次就是一份独立的技能实例后续升级只改克隆体的数值不影响模板。5.3 原型注册表可以兼任工厂很多时候“注册表 原型”本身就能承担工厂方法的职责。你完全可以对外只暴露一个Registry::create(fireball)内部存的是原型而不是一组创建函数。调用方感受不到区别但它拿到了一个完整独立的对象。所以我的选型逻辑有时候也会倒过来想如果我不需要产品族的一致性也不需要每次创建都用全新参数那我为什么要写一堆工厂类直接用原型注册表反而代码量更少、新增类型更友好。只有当创建逻辑必须依赖一组必传参数并且这个参数不适合预先塞进对象里时我才会认真考虑工厂方法。6. 实战复盘游戏技能模板和那个偷偷切片的子类6.1 技能模板共享定义独立运行时态我们来看一个更完整的例子。假设项目里有个技能基类Skill派生类有FireballSkill、HealSkill、ShieldSkill。我想让每个怪物都有自己的技能实例方便独立计算冷却、伤害加成。class Skill { public: virtual ~Skill() default; std::unique_ptrSkill clone() const { return std::unique_ptrSkill(clone_impl()); } virtual void execute(Unit* caster, Unit* target) 0; void setCooldownRemain(float value) { cooldownRemain_ value; } float cooldownRemain() const { return cooldownRemain_; } private: virtual Skill* clone_impl() const 0; float cooldownRemain_ 0.0f; }; class FireballSkill : public Skill { public: explicit FireballSkill(float radius, float damage) : radius_(radius), damage_(damage) {} void execute(Unit* caster, Unit* target) override { // 计算伤害播放特效 } private: Skill* clone_impl() const override { return new FireballSkill(*this); } float radius_; float damage_; };游戏初始化时auto fireballTemplate std::make_sharedFireballSkill(3.0f, 80.0f); auto healTemplate std::make_sharedHealSkill(40.0f);每个怪物创建技能实例auto skill fireballTemplate-clone(); skill-setCooldownRemain(1.5f);这样每个怪物手里都是一个独立副本改冷却、改伤害都不会污染模板。一个阶段如果策划要整体调整伤害直接在模板上改新实例生效老实例不变这个“版本隔离”效果非常符合游戏业务逻辑。6.2 一个隐蔽错误子类忘掉覆盖克隆前面说过用“公开非虚 私有虚函数”这套结构子类忘重写会编译失败。但如果你用的是最初那种“公开虚 clone”写法就会踩一个隐蔽的坑。假设有class Circle : public Shape { public: Circle* clone() const override { return new Circle(*this); } }; class RedCircle : public Circle { // 忘记重写 clone };当我执行auto obj std::make_uniqueRedCircle(...); auto copy obj-clone();表面上copy是个Circle*或者转成Shape*但实际上它是个Circle对象不是RedCircle。我复制了一个对象结果子类特有信息悄悄丢了。这在运行期非常难排查因为代码不报错只有后面用到RedCircle专属字段时才发现数据不对。这是“切片”的变种传播路径不是发生在拷贝赋值时而是发生在克隆调用返回时。为了避免它我遇到过两种实用做法克隆接口的继承链上把clone_impl()设计成纯虚函数让每个中间子类继续声明纯虚强制叶子类实现。给基类加一个编译期自检用static_assert或者一些模板技巧在创建时断言类型一致。不过这个方法代价偏高适合要求严格的项目。我现在写项目基本都采用“私有虚函数 纯虚”的结构从根上不让这种麻烦进门。6.3 三个让代码更稳的实践建议每个具体类型的克隆函数里优先写new X(*this)而不是手写成员复制。拷贝构造如果不够用就先补拷贝构造而不是在 clone 里逐个字段复制。克隆后立刻脱离原型的所有可变共享状态。如果有些成员是shared_ptr要想清楚是共享还是复制。模糊地带写注释说明。对外接口统一返回unique_ptrBase内部函数负责new派生类。这样调用方不可能因为忘记析构而泄漏也不可能随意把裸指针到处传。7. 边界情况与三个容易打脸的场景7.1 引用成员、const成员与不可拷贝成员并不是所有类都能顺利写clone()。碰到以下成员类型时需要额外设计引用成员引用一旦绑定就无法指向另一个对象拷贝构造会把引用绑定到源对象的同一个目标上。两个对象其实共享同一个被引用的东西如果这不是你想要的就得重新设计。const 成员const 成员不能重新赋值拷贝构造虽然能初始化它但赋值运算符会直接报错某些容器操作可能没法用。如果成员本身是 const 的我一般会先反思这个 const 是否真的必要。std::unique_ptr 成员默认拷贝构造是删除的类一定义clone()就没法直接用make_uniqueX(*this)编译。这时候要么实现自己的拷贝构造要么换成shared_ptr要么把原指针“重新发明”一份。真实项目中我遇到最多的是unique_ptr成员想实现“深拷贝的 unique_ptr”但unique_ptr本身不允许复制。一个可用但有些繁琐的办法是加一个内部拷贝工厂std::unique_ptrResource cloneResource() const { return std::make_uniqueResource(*res_); }7.2 带循环引用的模型一不小心就递归爆栈如果对象模型里有父子关系比如一个节点有children_同时又有parent_指回父节点那么克隆时必须特别小心。如果你天真地在子节点的克隆函数里也复制parent_那么克隆一个子树时每个子节点都去克隆它的父节点父节点又去克隆它的子节点疯狂递归栈溢出不商量。我的处理办法是克隆时只复制“向下”的关系不复制“向上”的关系。先在克隆后的整棵树上重建父指针或者在节点克隆后统一做一次 parent 修理。更多时候我会把树结构拆开不让节点持有不必要的parent_反向指针用容器关系来维护层级。7.3 性能关注RVO、移动语义与频繁克隆最后聊聊性能。返回unique_ptr的克隆函数在现代 C 中通常不会有二次拷贝。return new Circle(*this)是直接构造出对象再放进unique_ptr这个路径本身很干净。真正值得关注的不是克隆那一瞬间的耗时而是克隆出来的对象体积以及它是否复制了大量其实可以共享的数据。如果某个“重成员”的构造成本高但内容几乎不变我就把它的生命周期降到最细要么共享shared_ptr要么延迟加载内存要么拆分成“轻量句柄 重型资源”两个层。别让原型模式替你做出了一个巨大副本那反而不是好事。实践中我还养成了一个习惯在克隆函数里放一个断点或临时统计观察哪些对象在出入口之间复制了超过预期的字节数。这一步排查成本低但能救回头无数性能问题。原型模式在 C 里的落地关键从来不是“模式怎么背”而是“这个类怎么设计拷贝、共享和生命周期”。你只要抓住“谁持有、谁复制、谁共享、谁销毁”这四个问题原型模式就能帮你写出既灵活又不失控的代码。