ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

C++原型模式四种变体:多态clone、类型擦除、CRTP与std::variant实践

C++原型模式四种变体:多态clone、类型擦除、CRTP与std::variant实践 1. 原型模式在 C 里为什么总是差点意思先说一个我经常被问到的场景项目里有一个UIButton它经过一堆配置之后状态已经很复杂了——背景渐变、边框、阴影、甚至挂着两个异步回调。这时候我想在界面上再复制一份一模一样的按钮出来最直接的办法是什么写构造器重新设置一遍除非写够 50 行字段赋值代码否则很难保证新对象和旧对象的状态完全一致。需求再换一下不是按钮而是一个GameUnit它身上带着技能列表、Buff 状态、AI 行为树节点、甚至还有一部分运行时才生成的寻路缓存。这种对象既不是纯粹的数据 DTO也不是逻辑独立的服务它更像一个“被运行过程修改过的实体”。对它做复制构造函数的死角就暴露了——构造函数擅长的是“从零开始构建”但原型模式擅长的是“照着一个现成实例再捏一个”。这也是 C 里原型模式处境微妙的原因。Java、C# 里谈论原型模式通常是因为那套语言没有 C 这种原生复制机制必须手动写clone()。而 C 本身就有拷贝构造函数和operator所以在很多 C 开发者眼里原型模式是个“多余的设计模式”要复制对象直接MyObject b a;不就行了问题恰恰出在这里。C 的“复制对象”能力本身没有问题问题是默认的复制语义通常只停留在浅拷贝。而且更关键的一点是当你要复制的是一个多态对象时直接按基类拷贝会发生切片得到的不再是完整对象。真正需要原型的场景并不是在编译期已经明确类型的普通值对象而是那些“运行时才能知道具体类型”的对象。这也是原型模式在 C 里必须被重新想一遍的根本原因。接下来要聊的这四个变体是我在实际项目里反复试过的做法它们分别面向不同类型的需求也可以混用。我会把代码、踩过的坑、选型依据都摊开讲清楚。2. 变体一多态 clone继承型“自我复制”这是最接近 GoF 原始定义的做法也是大部分 C 教程里讲原型模式时给的答案——给基类定义一个virtual clone()子类各自实现自己的复制。2.1 最落地的写法unique_ptr 与 virtual clone()现代 C 里我建议这样定义基类class IComponent { public: virtual ~IComponent() default; virtual std::unique_ptrIComponent clone() const 0; };返回类型为什么是std::unique_ptrIComponent而不是裸指针很简单C 里“复制出来的对象”所有权必须有明确归属unique_ptr是默认最安全的选择。返回std::shared_ptr也不是不行但如果原型对象本身不应该被共享你会把语义搞混。子类实现起来大概是这样的class Button : public IComponent { std::string label_; std::vectorstd::shared_ptrEffect effects_; public: std::unique_ptrIComponent clone() const override { auto c std::make_uniqueButton(*this); c-effects_.clear(); for (const auto e : effects_) { c-effects_.push_back(e-clone()); } return c; } };这里有一个非常关键的细节std::make_uniqueButton(*this)先调用了编译器合成的拷贝构造函数把label_这样的值成员复制掉而effects_里的每个元素是指向Effect基类的shared_ptr直接拷贝只会让新旧按钮指向同一批 Effect 对象修改一个就会影响另一个。所以要先清空指针容器、再逐个对每个效果调用它自己的clone()实现真正意义上的深拷贝。2.2 继承链上的深拷贝节奏到了继承层级比较多的时候有人会问深拷贝逻辑写在哪一层最合适我现在的经验是每一层只拷贝自己声明的成员然后调用父类的拷贝构造完成父类部分的复制。C 的拷贝构造函数天然支持这一点——你在派生类拷贝构造函数的初始化列表里调用基类拷贝构造函数基类字段自然就按基类语义复制了。class TextButton : public Button { std::string icon_svg_; public: TextButton(const TextButton other) : Button(other), icon_svg_(other.icon_svg_) {} std::unique_ptrIComponent clone() const override { return std::make_uniqueTextButton(*this); } };注意这里我用的是Button(other)把other当基类引用传进去代码没毛病。但你如果在TextButton::clone()里写成std::make_uniqueButton(*this)返回的表面上还是unique_ptrIComponent实际工厂函数却已经退化成 Button 构造了——对象被切片了。这是多态 clone 里最常见的隐藏 Bug。2.3 多态 clone 的三大经典坑第一个坑是协变返回类型。子类里其实可以声明std::unique_ptrTextButton clone() const override这在 C 里是合法的“协变返回值”。但要小心如果你在某个调用方那里用基类指针接收返回值编译器还是会帮你转成基类指针此时一切正常可一旦你直接auto p obj-clone();而obj是基类指针auto推出来依然可能是基类unique_ptr。这不叫 Bug但容易让初读代码的人误解“clone 返回的是不是子类”。第二个坑是拷贝构造函数本身被重载了。有些类为了性能把拷贝构造函数删了或者写成了移动语义主导。这时make_uniqueDerived(*this)可能直接编译失败。原型模式要求类型必须有可用的复制语义这一点在选型时就要评估而不是写完了才发现。第三个坑更隐蔽const 成员和引用成员。假设Button里有一个const std::shared_ptrTexture background_;拷贝构造函数复制后你还能改这个指针吗不行因为是 const。你的 clone 想给复制品换一张纹理的话会非常痛苦。同理引用成员无法重新绑定这几乎等于宣告这个类不适合做原型。所以一个类要支持原型模式设计阶段就要避免把“可变状态”放在 const 字段里。多态 clone 的适用面很清晰你有完整继承体系、需要在不知道具体子类类型的情况下复制对象。它的代价是虚函数调用、每个类都要自己维护 clone 逻辑以及继承树上任何一层忘记深拷贝就会导致部分字段被共享。3. 变体二类型擦除的克隆器把原型对象和复制方式绑在一起第二种变体解决的是另一个痛点我不一定想让所有类都继承同一个基类或者我拿到的对象来自第三方库没法给它加虚函数。这种情况下“原型”不一定非得是“对象自己 clone”它可以退化成“一个能复制该对象的闭包”。3.1 用 std::function 包住拷贝逻辑整个思路是这样的class 的继承结构动不了但复制能力还得有。很自然的办法就是在外面包一层函数函数体内完成了类型安全和深拷贝。#include functional #include memory #include any template typename T std::functionstd::any() makeCloner(const T prototype) { return [prototype]() - std::any { return prototype; // 拷贝一份存进 any }; }用的时候也很简单Circle unit_circle{10, {255, 0, 0}}; auto cloner makeCloner(unit_circle); std::any copied cloner(); auto c std::any_castconst Circle(copied); // 取出复制品这里的核心变化是克隆动作从一个虚函数变成了一个可调用对象。原型实例被 lambda 捕获每次调用 clonerlambda 内部都会拷贝一次捕获的实例。由于 lambda 捕获时按值捕获原型活得比 cloner 更久或者同寿命复制也就有了凭据。3.2 多个原型放一个注册表这个变体的价值在“需要管理一批不同类型原型”的时候尤其突出。我曾在编辑器工具里维护过一张原型注册表里面存了十来种控件模板。当时这些控件的接口是历史遗留的 C 风格结构体加虚函数几乎等于重构整个模块于是我用类型擦除做了个注册表class PrototypeRegistry { std::unordered_mapstd::string, std::functionstd::any() factories_; public: template typename T void registerPrototype(std::string name, const T instance) { factories_[std::move(name)] [instance]() - std::any { return instance; }; } std::any create(std::string_view name) const { auto it factories_.find(name); if (it factories_.end()) { throw std::runtime_error(prototype not registered); } return it-second(); } };注册方式直观PrototypeRegistry registry; registry.registerPrototype(red_button, RedButton{/*初始状态*/}); registry.registerPrototype(circle_icon, CircleIcon{/*初始状态*/});需要什么资源按名字克隆出来就好。这种写法把“复制”抽成了“注册时捕获原型、创建时触发复制”对一个团队协作的项目来说新增原型类型的成本被降得很低。3.3 类型擦除的代价和我踩过的问题第一层代价是性能std::function本身有间接调用开销拷贝进std::any的过程等于又构造了一次而any_cast要运行时检查类型。对渲染引擎里一帧可能复制几百个控件的行为来说这种间接开销有时候不能接受。所以注册表模式更适合低频克隆场景比如编辑器里的创建命令、资源加载时的模板实例化。第二层代价是类型安全被推迟了。注册表里存的不是 T 类型而是std::functionstd::any()取出的时候必须你自己保证类型对得上。如果注册的时候写错名字取出来的 any 里装的是 A 类你却用any_castB去接运行时会直接抛 bad_any_cast。这不像编译期错误那么友好但至少是显式错误不会静默出错。第三层是个隐蔽的坑lambda 捕获对象的方式决定了浅拷贝还是深拷贝。我在早期版本里写错过捕获方式把[instance]写成了[instance]结果注册表存储的引用指向的是局部变量函数返回后引用悬空后面创建出来的对象里的字符串全部变成乱码。这个坑在本地小测试里很难暴露因为局部变量的栈内存往往还没被覆写。建议在这种注册表里一律按值捕获并且优先让原型对象长期存活比如直接放在配置里或者池子里。4. 变体三CRTP 静态克隆编译期定型的快速通道第三个变体是我个人最喜欢在性能敏感代码里用的CRTPCuriously Recurring Template Pattern中文叫奇异递归模板模式。它能把“克隆”这个能力在编译期附加到具体类型上省掉虚函数调用。4.1 一份极小但完整的实现template typename Derived struct Cloneable { std::unique_ptrDerived clone() const { return std::make_uniqueDerived( static_castconst Derived(*this)); } };使用方式struct Vec2 : CloneableVec2 { float x 0.0f; float y 0.0f; };然后Vec2 a{1.f, 2.f}; auto b a.clone(); // b 类型是 unique_ptrVec2拷贝成功这里没有虚函数没有基类指针连运行时类型信息都不需要。CloneableDerived唯一做的事情就是把Derived的拷贝构造函数包成了一个叫clone()的方法。整个过程中对象的复制仍然走 C 原生的拷贝构造因此深拷贝与否取决于Derived自己成员的复制语义。4.2 CRTP 静态克隆的适用范围这种变体适合什么场景适合那些类型确定、但你又希望统一提供 clone 接口的代码。举个例子你在写一个数学库里面有Vec2、Vec3、Matrix4、Quaternion想让它们都有clone()成员。这些类型全都是值语义本身拷贝构造就已经是深拷贝但接口统一起码比较不容易记错。你还能顺手给自己的类型添加其余功能比如带标签的版本、带对齐上限的类型。CRTP 都处理得来。另一个常见场景是“原型本身是模板”。你用模板生成了几十种具体配置对象想对任意一种都调用.clone()但不想每种都手写一遍 clone 方法。CRTP 可以把这层逻辑一次性写好。4.3 静态克隆的限制不能放容器里装异类要注意 CRTP 是一次编译期的握手。它给clone()返回的类型是unique_ptrDerived不是unique_ptrBase。这意味着它不能直接塞进“存放一堆不同子类克隆器”的容器里——因为每个Derived的 clone 返回类型不同。如果你想用 CRTP 又想让不同对象共享接口那就得叠加额外的类型擦除层比如前面变体二里的注册表。我也见过很多项目为了省虚函数试图用 CRTP 继承 动态数组结果发现必须把cloneableA、cloneableB统一转型成某个“静态基类”一转就回到变体一了绕了一圈又绕回去。所以我的建议很明确如果只有一个具体类型、不需要多态容器优先用 CRTP如果你的复制对象最终是要塞进一个vectorunique_ptrIBase的直接用变体一别用 CRTP 强行套壳得不偿失。4.4 编译器生成的拷贝别忽略了移动语义CRTP 克隆绕不开一个问题它依赖拷贝构造。C11 之后很多类为了实现高性能把拷贝构造删掉、只保留移动构造。如果Derived删了拷贝构造那make_uniqueDerived(static_castconst Derived(*this))你的代码直接编译失败。这其实是正确保护——一个只能移动的对象从本质上就不适合做原型。不过偶尔会遇到第三方库类型只删了拷贝没留替代方案这时要去做原型就必须绕到变体二用“换个构造方式”的策略而不是硬凑 CRTP。另外一点CRTP 克隆返回的是独占所有权的unique_ptr。如果你希望复制品还能被多个观察者共享那要改成std::shared_ptrDerived或者自己处理生命周期。大多数场景下克隆对象本来就是独立所有权的用 unique_ptr 最省事。5. 变体四std::variant 原型盒在有限类型集合里拼复制逻辑第四个变体是 C17 之后才值得认真考虑的它就是字面上的“原型 variant”。很多 C 开发者习惯了用继承表达“一组可替换的类型”但其实当你明确知道候选类型就那几个并且不想引入继承体系和虚函数时std::variant更合适。5.1 用 variant 当原型容器using Shape std::variantCircle, Rectangle, Polygon; struct CloneShape { Shape operator()(const Shape s) const { return std::visit([](const auto shape) - Shape { return shape; // 拷贝构造 }, s); } }; Shape proto Circle{5}; Shape copy std::visit(CloneShape{}, proto);这段代码里Circle、Rectangle、Polygon不需要任何公共基类它们只是普通的、可拷贝的类。CloneShape是一个访问器它匹配到 variant 当前装的是什么类型就按该类型做一次拷贝。对 variant 而言std::visit在编译期就展开了所有分支不需要虚函数表。写法更简洁一点的话可以直接用泛型 lambdaShape clone(const Shape s) { return std::visit([](const auto shape) - Shape { return shape; }, s); }5.2 为什么要拿 variant 做原型首先它让原型的类型范围显式化了。编译器知道Shape只能装三种类型所以被复制的时候只可能复制这三种类型。你不可能意外塞进一个Triangle因为Triangle压根不在 variant 类型列表里。这个编译期约束比继承体系要严格得多也更容易定位问题。其次它天然支持值语义。variant 本身是可复制的Shape proto Circle{5}; Shape copy proto;两行就完成复制连自定义 clone 结构体都可以省掉。需要深拷贝的时候只要保证 variant 里的每个具体类型都具备正确的深拷贝语义即可。5.3 递归对象是唯一的例外如果原型对象内部还有同类型的子对象比如一个Group里包含std::vectorShape那 variant 会遇到一个问题Shape的定义里出现了Group而Group又依赖Shape形成递归类型。C17 里std::variant不能直接持有不完整类型需要通过std::unique_ptr或std::shared_ptr包装一下。更常见的做法是让Group内部直接持有std::vectorstd::unique_ptrShape。这样 variant 就不再递归了因为Shape定义里只有unique_ptrShape而unique_ptrShape允许不完整类型。这时如果需要深度复制Group的拷贝构造要遍历所有子节点对每个unique_ptrShape调用clone()——这时你可能会意识到我们的Shape里没有clone()虚函数因为所有具体类型是可拷贝的那就用访问器做深拷贝或者给每个具体 Shape 类写一个clone()成员。递归对象的复制在这种“值语义 variant”结构里最容易写得脱缰。所以我的经验是如果层级超过两层就别硬用 variant 表达整个对象树可以考虑把它和变体一混合起来——顶层用多态底层具体类型用 variant。5.4 variant 原型盒的遍历成本std::visit的展开只发生在编译期可到了运行期它仍然需要用一个整型索引来记录当前 variant 存的是哪一种类型。访问的时候会有一次分支判断但不会比虚函数调用慢。对大多数业务代码来说这个成本低到可以忽略。唯一的痛点是编译期std::visit会把所有可能分支的代码全部生成出来如果 variant 的类型列表很长比如十几二十种编译耗时和生成的代码体积都有明显上涨。如果你还要在多个源文件里反复 visit情况更严重。一般我会控制在一个 variant 类型列表不超过七八种左右超过了我就会回头考虑继承方案。6. 实践之后我如何从这几种变体里选型聊到这可能有人会问四种变体我到底该记哪一种我的真实答案是它们不是四个竞争方案而是四套解决不同层级的工具。我根据自己的项目经验做了个粗略的选型表变体核心机制适合场景代价多态 clone虚函数 基类指针对象类型在运行时才知道需要放进异质容器虚调用、每个子类都要实现 clone、继承层级越深维护越累类型擦除克隆器std::function 包住复制逻辑无法改动类本身、需要按名字注册模板std::function std::any 运行时开销类型安全推迟CRTP 静态克隆模板把拷贝构造包装成 clone()具体类型确定、希望统一接口、性能敏感不能直接放进异质容器、依赖拷贝构造std::variant 原型盒编译期类型集合 访问器复制类型集合有限、想避免继承体系时递归结构要做额外设计、variant 类型太多编译变慢实际工程中常出现的组合是底层数据结构用 CRTP 或 variant 复制上层“不知道具体类型”时再用多态 clone 包装而“不能改类定义”的旧模块则用类型擦除注册表兜底。它们并不互斥。我印象最深的一次使用是做一个自定义编辑器里面有控件拓扑、节点状态、临时缓存三种数据。控件拓扑部分最终用了多态 clone因为节点类型在插件系统里才能确定节点状态部分用了 variant因为节点状态枚举明确就四五个而临时缓存部分直接禁了复制根本不让它做原型——不该被复制的逻辑状态就早起写成 move-only少给自己留坑。原型模式真正解决的不是“我不用 new 了”而是把“已经运行到一个特定状态的对象”当作最可靠的构造蓝图。这几种变体孰优孰劣取决于你的对象是在哪一层形成的而不是取决于设计模式书里它叫什么名字。你只要抓住一个原则——复制出来的东西必须和原型保持一致的“可工作状态”而不是和原型共享所有内部指针——选哪种写法只是风格问题。
返回列表