C++原型模式六种变体实战:虚克隆、注册表、CRTP、std::variant与写时复制

发布时间:2026/10/12 19:26:28
C++原型模式六种变体实战:虚克隆、注册表、CRTP、std::variant与写时复制
第一次尝试在 C 里实现原型模式时我犯了个很基础的错误拿着 GoF 的经典类图就往工程里搬写了个virtual clone()返回值却用裸指针。结果调用方漏了delete内存泄露检测直接报红一大片。后来我才意识到C 里的原型模式从来不是照着四个角色画类图就能落地的它的难点在于复制语义、多态边界、所有权归属这三件事纠缠在一起。这篇文章不以原型模式教程的框架去讲而是从我在实际代码里不断换方案的经验出发整理出六种我在项目里真正用过的原型模式变体覆盖虚函数、注册表、CRTP、std::variant、写时复制和原型池每种的适用场景、代码形态和坑我都会说清楚。开头先把结论放这儿C 里原型的核心不是从某个类 new 出一个新对象而是把某个已经存在的实例当作模板得到它的拷贝。Java 那套印象深刻但 C 有值语义、移动语义和智能指针直接照搬通常不是最优解。1. 拷贝构造函数的边界原型模式在 C 里为什么要单开一路1.1 当 C 的天然复制变成切片很多从 C 风格转过来的同事跟我说C 不是自带拷贝构造函数吗原型模式岂不是多余确实对普通值类型来说复制就是天然的原型操作struct Point { int x, y; Point(int x, int y) : x(x), y(y) {} }; Point a(10, 20); Point b a; // 这就是一次标准的原型复制但一旦牵扯到面向对象的多态继承事情就麻烦了。假设有如下继承关系struct Shape { virtual ~Shape() default; virtual void draw() const 0; }; struct Circle final : Shape { double radius; void draw() const override {} };现在写一个函数接收基类引用希望照原样复制一份void duplicate_and_redraw(Shape src) { Shape copy src; // 编译失败 }编译直接失败因为Shape是抽象类不能按值初始化。就算把Shape改成非抽象类这里的行为也只会形成对象切片Circle的radius会消失复制出来的只是一个基类片段。这种错误非常隐蔽尤其是当你把派生类对象当作基类值传递时编译器并不一定会报错。所以 C 里原型模式的第一个变体动机就出现了当你的可复制对象要通过基类接口来管理时默认的复制语义无法自动保持派生类型信息。这就是虚克隆存在的理由。1.2 经典虚克隆为什么该返回 unique_ptr最朴素的做法是按 GoF 的书实现一个普通的虚函数clone()struct Shape { virtual ~Shape() default; virtual void draw() const 0; virtual Shape* clone() const 0; }; struct Circle final : Shape { double radius; void draw() const override {} Shape* clone() const override { return new Circle(*this); } };这个版本能跑但工程上非常难用。调用者收到裸指针后必须自己delete一旦在异常路径里忘掉就是泄露。把一个资源所有权交给调用方的接口本质上是把 C 的 RAII 原则抛到一边。我现在在工程里倾向于用std::unique_ptr作为clone()的返回类型struct Shape { virtual ~Shape() default; virtual void draw() const 0; virtual std::unique_ptrShape clone() const 0; }; struct Circle final : Shape { double radius; void draw() const override {} std::unique_ptrShape clone() const override { return std::make_uniqueCircle(*this); } };返回unique_ptr用起来非常放心调用方直接auto p2 p1-clone();所有权自动接管不需要在业务代码里纠结谁负责释放。unique_ptrShape返回的是指向派生类对象的基类指针但内部实际指向的仍然是Circle所以克隆出来的对象调用draw()时行为正确。1.3 复制构造函数与 clone() 的职责分工有个细节很多人忽略clone()内部往往直接调用拷贝构造函数。也就是说拷贝构造函数负责怎么把当前对象的内存字段复制到新对象clone()负责把这种复制能力以多态的方式暴露出去。它们是相辅相成的不是替代关系。如果派生类里增加了内部缓冲区或动态资源你首先要保证拷贝构造函数按深拷贝语义写对否则clone()复制的只是一个表面壳子。下一节开始讲的注册表变体正是建立在这个虚克隆的基础之上。2. 注册表式原型工厂把创建参数记录在原型实例本身里2.1 从用模板实例看注册表的真正价值经典原型模式解决的场景是运行时得到某个对象实例但不知道它具体是什么派生类希望创建同类的新实例。实际项目中我还遇到过更具体的需求不止要创建同类实例还要让新实例带上某些默认配置。举个例子一个战斗游戏里每种角色有十几项数值如果每次创建角色都重新走一遍参数填充调用方容易漏填。更合理的方法是内置一份角色原型实例把标准配置固化下来新角色直接从这份原型clone()再按需覆盖个别字段。这就是注册表式原型工厂的雏形。注册表的角色在于它把提供原型实例和按标识符创建解耦了。外部模块不需要知道具体类名只需要一个键比如字符串 ID#include memory #include string #include unordered_map class Character; using CharacterPtr std::unique_ptrCharacter; class CharacterPrototypeRegistry { public: void registerPrototype(std::string key, char const* typeTag); CharacterPtr create(const std::string key) const; private: std::unordered_mapstd::string, CharacterPtr prototypes_; };这里registerPrototype创建的是样板原型create则把原型clone()一份返回。从外部看create(orc)就能直接得到一个满配兽人角色实例而调用方完全不需要知道Orc这个类型名。2.2 打印一个可按 key 克隆的注册表下面给出一个能直接编译的迷你示例。假设Character是抽象基类Orc是具体角色#include memory #include string #include unordered_map #include stdexcept class Character { public: virtual ~Character() default; virtual int health() const 0; virtual std::unique_ptrCharacter clone() const 0; }; class Orc final : public Character { public: Orc(int hp) : hp_(hp) {} int health() const override { return hp_; } std::unique_ptrCharacter clone() const override { return std::make_uniqueOrc(*this); } private: int hp_; }; class CharacterPrototypeRegistry { public: template typename T, typename... Args void add(const std::string key, Args... args) { prototypes_[key] std::make_uniqueT(std::forwardArgs(args)...); } std::unique_ptrCharacter create(const std::string key) const { auto it prototypes_.find(key); if (it prototypes_.end()) throw std::runtime_error(prototype not found: key); return it-second-clone(); } private: std::unordered_mapstd::string, std::unique_ptrCharacter prototypes_; };使用方CharacterPrototypeRegistry registry; registry.addOrc(orc, 100); auto mob registry.create(orc);模板函数addT会根据第一个模板参数推断具体类型并创建原型实例后续参数直接传给构造函数。这个设计让注册表非常干净注册时把创建参数封进原型创建时只按 key 取克隆。2.3 注册抽象类型还是注册具体原型实例注册表里到底存的是什么这里有两种不同的变体取向按类型注册注册表存放某类的一个标准实例每次克隆都得到该类型的新对象按实例语义注册注册表存放某个配置固定、但类型可能相同的多个不同原型比如兽人战士和兽人萨满在同一个类里但拥有不同数值。按类型注册适合类型一一对应一个默认配置的场景按实例语义注册则更贴近真实业务。我在实际里更多用的是后者允许同一个add被多次调用但传入不同初始化参数然后注册表里就有多份同类型但不同状态的原型。克隆出来的对象各自独立修改它们不会影响注册表里的原型这也是注册表的一个隐藏优势。2.4 与简单工厂的边界原型注册表面上看和简单工厂很像但两者核心差别值得列一张表关注点简单工厂注册表式原型工厂创建逻辑所在工厂内部记录创建规则原型实例自带复制能力新类型扩展方式修改工厂的 switch/if 判断注册新的原型实例默认配置管理构造参数由工厂硬编码配置固化在原型实例里创建成本每次从零构造拷贝已有对象往往更轻适合场景类型固定、参数简单类型多、配置复杂、强调模板实例一句话总结我的经验当创建参数组合比类型分支更复杂时用原型注册表。很多模块里的对象创建问题不是类型爆炸而是配置爆炸。3. CRTP 与编译期自动生成去掉 clone() 的重复样板3.1 把一模一样的 clone 逻辑搬到基模板里你如果挨个实现派生类的clone()会发现代码几乎都是相同的套路std::unique_ptrShape clone() const override { return std::make_uniqueCircle(*this); }类一旦多起来这种样板代码就变得又臭又长还容易漏写。C 的模板能力天然适合消灭这种重复这就是 CRTP 变体让基类模板接收派生类型自动生成正确的clone()。template typename Derived, typename Base class Cloneable : public Base { public: using Base::Base; std::unique_ptrBase clone() const override { return std::make_uniqueDerived( static_castDerived const(*this)); } };用法struct Shape { virtual ~Shape() default; virtual std::unique_ptrShape clone() const 0; virtual void draw() const 0; }; struct Circle final : CloneableCircle, Shape { double radius; void draw() const override {} };CloneableCircle, Shape为Circle生成一个clone()覆盖。内部static_castDerived const(*this)把this转回真实派生类型然后调用make_uniqueDerived。因为模板参数和我们需要的派生类完全一致所以转型是安全的只要Derived正确继承了Cloneable。3.2 自动 clone 的代价必须谨慎对待 static_castCRTP 看似完美但它有一类潜在风险如果某个派生类再做第二层继承比如struct EliteCircle : Circle { int extra_boost; };这时候EliteCircle如果不显式继承CloneableEliteCircle, Shape而只是继承Circle那么Circle里由 CRTP 生成的clone()仍然会返回unique_ptrShape实际指向的是Circle而不是EliteCircle。调用方克隆一个EliteCircle时得到的是它的基类副本额外字段会丢失。解决办法是要么严格要求所有叶子节点都直接继承一层CloneableDerived, Base要么禁止 CRTP 类再被继承。我在项目里通常把Derived对应的类设计为final这样既能保证安全也让意图更清晰。还有一点static_cast把this转成Derived const时要求CloneableDerived, Base的模板参数确实匹配实际对象类型。如果写错struct Circle : CloneableTriangle, Shape { ... };那么转型未定义行为大概率在运行时崩溃。所以我在工程规范里规定CRTP 模板参数必须和类名一致并由单元测试强制覆盖 clone 行为的类型校验。3.3 C20 concept 作为契约的修正变体C20 引入了 concept可以让 CRTP 变体更有约束力。比如规定Derived必须拷贝构造template typename Derived concept CloneableDerived std::is_copy_constructible_vDerived;这样当某个类没有拷贝构造函数时编译器给出的报错比static_cast不合法友好得多。这个改造成本很低但它让 CRTP 变体在团队协作里不容易被误用值得加上。4. 值语义变体std::variant 下的天然复制灾难终结者4.1 多态不需要指针原型模式在 C 里还有一个被低估的变体直接用值语义的多态。前面几节都是围绕通过基类指针来操作对象展开的但 C 本身还有一种替代方案——std::variant。它允许我们在一个封闭类型集合上做类型安全的联合而且复制语义天然就是按值复制。看一个配置节点的例子#include variant #include string struct CircleConfig { double radius; }; struct RectConfig { double width, height; }; using ShapeVariant std::variantCircleConfig, RectConfig; ShapeVariant source CircleConfig{1.5}; ShapeVariant copy source; // 直接复制无需 clone 函数这里copy就是source的深拷贝所有附带类型信息都保留。没有虚函数、没有指针、没有clone()。只要variant里的每个备选项都满足复制构造整个variant就是可复制的。对原型模式来说这是最省事的形态复制这个动作由编译器生成对象所有字段和类型标签一起复制。4.2 variant 复制原型时限制了哪些能力std::variant并不是万能替代品它有一个本质限制类型集合是封闭的。多态继承是开放式的你可以随时新增派生类而不动基类但variant的候选类型必须在一开始全部列出新增类型意味着修改using ShapeVariant ...这一行所有相关分支都要重新编译。另一个限制是variant的复制总是携带全部静态类型信息但它并不提供统一的运行时多态接口。你想在某个函数里统一处理所有几何体必须通过std::visit或get_if来访问而不能像基类指针那样直接调用draw()。void draw_shape(const ShapeVariant shape) { std::visit([](const auto s) { // 这里可以调用所有备选项共有的成员前提是它们存在 }, shape); }如果每个备选项都实现了draw()std::visit的 lambda 泛型也能工作这其实是一种编译期多态。很多场景下我反而更喜欢这种不依赖 vtables 的方式局部性更好不需要堆分配复制行为简单可控。4.3 值语义与注册表工厂混搭的实用形状variant变体可以和注册表结合。比如一个配置系统想通过字符串 ID 拿到一份个性化配置副本但不想引入继承。可以这样做注册表存ShapeVariantcreate直接返回拷贝。class VariantRegistry { public: void add(std::string key, ShapeVariant proto); ShapeVariant clone(const std::string key) const; private: std::unordered_mapstd::string, ShapeVariant protos_; };这种做法的体验非常舒服没有虚析构函数的烦恼也没有所有权转移的争论所有对象都是普通的值。缺点仍然是无法轻易扩展新类型所以它在配置中心、协议消息、几何图元这类类型稳定、变化不频繁的领域里表现极好。5. 高性能场景的数据变体写时复制、原型池与浅共享5.1 深拷贝的账不能不算原型模式的常规实现毕竟是一次深拷贝。如果一个对象里嵌套着大量缓存、纹理句柄、脚本对象深拷贝的成本可能高得让人无法接受。我在处理某个跨平台图形模块时一个节点克隆下来要复制几十 MB 的参数和纹理对象哪怕只是临时改一个颜色也会触发整棵树的完整复制。这时候就需要引入性能变体。5.2 写时复制变体共享比克隆更快写时复制的思路是把对象内部状态放进std::shared_ptr初始克隆时共享同一个内部实现直到某个对象要修改数据时再真正复制。#include memory #include string class SpriteNode { public: explicit SpriteNode(const std::string texture) : data_(std::make_sharedData(texture, 1)) {} SpriteNode clone() const { SpriteNode result *this; // 共享不深拷贝 result.data_ data_; // 仍然共享 return result; } void setLayer(int layer) { ensure_unique(); data_-layer layer; } private: struct Data { std::string texture; int layer; Data(std::string tex, int l) : texture(std::move(tex)), layer(l) {} }; void ensure_unique() { if (data_.use_count() 1) { data_ std::make_sharedData(*data_); } } std::shared_ptrData data_; };这里clone()几乎是 O(1) 的两个SpriteNode指向同一个内部数据块只增加引用计数。当其中一个要修改时ensure_unique()发现共享计数大于 1才真正复制。这种变体能显著降低原型克隆的峰值开销。但你要注意COW 变体改变了克隆后互不影响的直觉。如果你心里期待的是严格的深拷贝COW 只在写入时体现读操作仍然是共享的。业务逻辑里若有人从外部拿到内部数据并持有引用就可能出现意外的串数据现象。所以 COW 适合数据以短生命周期读取为主的场景不适合数据会被长期缓存并随时被修改的场景。5.3 原型池与按需 clone 的对冲原型池是另一种性能变体它不是每次 clone 都重新分配内存而是预先从原型批量生成一批对象放进空闲池里。需要使用时直接从池中掏出干净副本用完再归还池子。class CharacterPool { public: explicit CharacterPool(std::unique_ptrCharacter prototype, size_t size) : prototype_(std::move(prototype)) { for (size_t i 0; i size; i) { freeItems_.push_back(prototype_-clone()); } } std::unique_ptrCharacter acquire() { if (freeItems_.empty()) { return prototype_-clone(); } auto item std::move(freeItems_.back()); freeItems_.pop_back(); return item; } void release(std::unique_ptrCharacter item) { freeItems_.push_back(std::move(item)); } private: std::unique_ptrCharacter prototype_; std::vectorstd::unique_ptrCharacter freeItems_; };在我处理帧循环代码时这种池化方案很有效每帧需要创建大量临时对象但它们的最终状态都会在帧结束被清空。池化避免了反复分配和释放带来的碎片化。不过你要小心归还对象后池内对象的内部状态可能还残留上一帧的痕迹所以acquire()返回前最好对对象做一个复位操作否则就会把脏数据带进新的一帧。5.4 浅克隆、深克隆的语义约定浅克隆和深克隆在使用原型模式时一定要在工程文档里写清楚。我的习惯是语义内部指针/引用适合场景深克隆复制所有可写资源数据齐全、期待完全隔离浅克隆共享只读数据、复制句柄配置对象、元数据COW 克隆共享可变数据、写时复制大对象、读多写少如果团队里有的人默认clone 等于深拷贝有的人觉得clone 轻量设计评审时就会吵起来。提前定好标准可以省很多事。6. 选型参考与常见坑六个我真实踩过的6.1 原型树与工厂模式的对比表先给一个我常用来做选型判断的对比带箭头可以直接照着用情况建议变体类型数量少、类型稳定std::variant值语义类型开放、需要基类接口虚clone()或 CRTP创建参数复杂、配置需要模板实例注册表式原型工厂对象大、读多写少COW 变体对象短生命周期、频繁创建销毁原型池对外还要控制具体类名注册表 虚克隆我并不是说某一种变体会永远优于另一种很多时候一个系统里同时用两三种变体也很正常核心对象走虚克隆配置数据走variant高频临时对象走池化。选型的关键是看复制操作是被谁、在什么频率下触发。6.2 坑六条第一基类成员在克隆时被忽略。如果派生类的复制构造函数写得不好只初始化了自己的字段忘了对基类成员做正确复制clone()就会截断数据。排查这种问题是出了名的麻烦因为它不会直接崩溃只是数值不对。我后来在派生类复制构造函数里习惯显式写Base(*static_castBase const*(this))确保基类部分被完整复制。第二虚 clone() 的返回类型被隐藏。基类有一个Shape::clone()派生类某个模块里又正好有个成员函数也叫clone这就会造成名字隐藏。调用circle.clone()可能不会调用到多态的clone()。添加override关键字可以规避绝大多数这种问题。第三浅拷贝了不该共享的内部资源。给对象里一个std::shared_ptr命名的内部缓冲区做了浅拷贝结果两个克隆对象修改了同一个缓冲。修复过程很痛苦因为没有编译错误。我后来做代码评审时对clone()里的每个成员字段都追问它应该共享还是深拷贝第四注册表的原型生命周期失控。如果注册表持有的原型被外部模块反注册掉了其他线程可能仍在按 key 查找。这个问题在并发条件下特别容易偶发。我给注册表加了一个简单的读写锁并在create()里对查找结果做空指针校验至少保证了崩溃的确定性不至于把问题藏起来。第五把 COW 当深拷贝用。前面说过COW 在写时才真正复制。如果业务方拿着克隆对象的某个内部引用长期保存然后修改另一个克隆对象会发现自己的数据也变了。这不是 bug是语义误用。遇到这种需求就别省那个深拷贝。第六克隆后的对象和原型共用了某个池子。原型池里如果复用对象一定要有一个明确的 reset 协议。我最开始只是简单地acquire()返回一个clone()结果对象里残留的前一帧状态让我排查了一个下午。后来我在基类上加了一个reset()虚函数每次从池中取出时强制调用。6.3 一点个人判断我在经过多次反复后现在对新项目里等于类型的定义会默认倾向std::variant 值语义只有在明确需要开放扩展时才引入基类和虚clone()。原因很简单编译器能够自动生成复制意味着少写很多代码也少很多所有权纠葛。但它也不是银弹一旦类型集合会频繁变化封闭性就会成为累赘。原型模式的每种变体都不是凭空发明的新东西它们是在回答同一个问题你想把哪个对象的哪些信息复制出来以及你希望这次复制付出多大代价。把这个问题问清楚代码的组织形态自然就浮现出来了。我自己最后悔的一次技术选择就是在一开始没定义好克隆的语义标准导致深拷贝和 COW 混用后来花了两周才把数据的一致性收敛好。希望你在引入原型模式时先把这些边界理清楚别走我这条路。