资讯详情

C++简单工厂模式实战:多态、智能指针与扩展性

📅 2026/10/14 10:02:58 | 华诺云谱 👁 阅读
C++简单工厂模式实战:多态、智能指针与扩展性
1. 简单工厂模式到底解决了什么问题1.1 先从一个改到吐的代码案例说起我最早接触设计模式是从一个计算器需求开始的那个场景特别典型很适合拿来讲简单工厂模式。当时我还在新手期接到一个任务写一个支持加减乘除的计算器。第一版代码顺理成章用了switch分支一个函数搞定。运行正常代码也不长当时觉得特别满意。但很快需求就来了——新要求是再加一个“取模”运算。于是我在switch里多补了一个case改了原来那个函数勉强交差。接着又来一个“平方根”又改一版。等到第六七个运算加进来的时候那个函数已经膨胀到看不下去每次新增运算都要手工翻找代码测试时候还得把所有分支重新回归一遍。这种痛感是真实的每次变更都意味着重新修改既有代码而修改既有代码就存在改出bug的风险。更麻烦的是函数越写越长分支越来越多里面稍微动一行都可能影响其他运算。简单工厂模式就是专门解决这个问题的思路把“具体怎么算”和“选择算哪种”这两件事拆开让每类运算自己管自己由工厂统一的入口去创建对应的对象。这样用户只需要告诉工厂“给我一个加法对象”而不需要关心加法类内部长什么样。这算是面向对象里“封装变化”思想最接地气的入门演示。可能有人会说一个计算器而已switch怎么了在小需求量下确实没问题可一旦业务持续扩张代码就会越来越难维护。我在实际项目里见过很多软件最初都挺简洁后来因为不断叠加分支逻辑变成一座勉强能跑的大泥球。与其等泥球变大了再重构不如在最开始就用合理结构把容易变化的点隔离起来。1.2 三个角色与一个生活化类比简单工厂模式里其实只有三个核心角色产品基类定义产品共有的接口抽象出操作行为。具体产品类继承产品基类实现各自的具体业务逻辑。工厂类负责根据外部传入条件创建并返回对应的具体产品对象。把这个结构放到现实世界最合适的类比就是街边外卖店。你去点餐告诉店员“来一份宫保鸡丁”店员不用当场给你演示怎么切菜炒菜厨房工厂会自己决定由哪个厨师具体产品来做最终把成品端到你面前。你只关心“点餐入口”和“菜品是否符合预期”不用关心后厨流程。对应代码里调用方就是顾客工厂类就是那个店员不同的运算类就是不同厨师。调用方和具体运算类之间的依赖被削弱了这是这个模式最核心的价值所在。不过必须强调一点简单工厂模式并不是GOF二十三种设计模式的正册成员它是一种非常常用的编程套路可以看作工厂方法模式的一个简化版。很多教材把它放在设计模式第一课讲是因为它足够简单、足够直观能让你快速理解“封装变化”“面向接口编程”这些基础概念。后面学工厂方法、抽象工厂都是在它的基础上做演进的。2. 为什么值得用C再讲一遍简单工厂2.1 多态才是这个模式真正的引擎网上讲简单工厂模式的资料很多语言也五花八门。但C版本的讲解往往比Java、C#版本要多出不少值得追问的细节原因在于C的内存模型和对象模型比较“硬核”很多坑必须自己踩过才明白。先看核心机制——多态。简单工厂之所以能让调用方只用“工厂方法”就拿到行为正确且可统一调用的新对象依赖的正是C的虚函数机制。基类指针指向派生类对象调用虚函数时由虚函数表vtable决定实际执行哪个版本的函数。我用大白话翻译一遍你手里拿着一张“通用类型”的门票基类指针进到体育馆后系统会根据你实际持有的座位信息把你带到对应区域这张票会自动匹配“哪个区域”并执行对应的入场逻辑。C的虚函数表就是那个动态匹配的中央系统。如果C里没有虚函数机制那代码就得退回到“基类指针走到哪都只能执行基类版本”的呆板状态简单工厂模式也就完全失去了意义。所以理解多态是理解这个模式的前提也是后续学其他创建型模式的共性基础。多态的关键特征是运行期绑定。代码在编译时并不确定调用哪个类的具体函数而是在运行时根据实际对象类型动态决定。这样带来的直接好处是你可以在不修改既有业务代码的前提下去扩展新的运算类型——只要让新类型继承同一个基类并实现统一的接口即可。这一点和设计模式里常说的“开闭原则”直接挂钩对扩展开放对修改封闭。2.2 C版本和高级语言版本有什么区别Java和C#里创建对象用new对象由垃圾回收器管理开发者的内存心智负担相对小。但C里没有自动垃圾回收谁new谁负责delete这句老话直接导致了一大类内存问题。所以在用C实现经典设计模式时同样的代码结构会比Java版多出两个非常实际的工程问题第一对象生命周期管理。工厂返回的是基类指针可能指向栈上可能指向堆上。若返回堆上对象调用方必须负责释放若忘记释放就是内存泄漏。这就是为什么我强烈建议用std::unique_ptr承载工厂返回值让RAII替你打理生存期。第二析构的多态性。基类析构函数如果不是虚函数通过基类指针delete派生类对象时只会调用基类析构派生类里动态申请的资源就得不到释放这在C里比内存泄漏更隐蔽更让人头疼。这些坑纯看Java版教程是学不到的必须结合C自身特性来处理。这篇内容我会把代码直接写成现代C风格用智能指针尽量规避传统裸指针踩雷的问题同时也会专门解释为什么会这么写。另外还有一个容易忽视的点C里传值和切片问题。如果工厂函数返回值类型是Operation基类对象本身而不是指针或引用那把派生类对象赋值给基类时会发生对象切片——派生类特有的成员和虚表特征会全部丢失。这是新手写C版工厂最容易踩的隐形坑后面看代码时我会专门举例。3. C实现的核心细节3.1 类层次设计基类与具体运算类先定义产品基类Operation。这个类只要承担两个职责保存操作数和提供计算结果接口。因为运算的种类不同“取结果”的具体行为不同所以GetResult()是虚函数由每个派生类各自实现。class Operation { protected: double numA_ 0.0; double numB_ 0.0; public: Operation() default; Operation(double a, double b) : numA_(a), numB_(b) {} virtual ~Operation() default; void SetA(double a) { numA_ a; } void SetB(double b) { numB_ b; } virtual double GetResult() const { return 0.0; } };注意这段代码里的两个细节析构函数被声明为virtual这是C多态基类的硬性要求。只要类里有虚函数就应该把析构函数声明为虚析构否则通过基类指针释放派生类对象时会产生未定义行为通常会表现为派生类资源没被释放。GetResult()被标记为const因为计算过程不应修改成员状态这也是一个常见的良好习惯。框架写好了扩展就很容易。Add、Sub、Mul、Div分别继承Operation各自重写GetResult()class Add : public Operation { public: double GetResult() const override { return numA_ numB_; } }; class Sub : public Operation { public: double GetResult() const override { return numA_ - numB_; } }; class Mul : public Operation { public: double GetResult() const override { return numA_ * numB_; } }; class Div : public Operation { public: double GetResult() const override { if (numB_ 0.0) { throw std::runtime_error(除数不能为0); } return numA_ / numB_; } };这里override关键字一定要写上。它不仅是文档式的说明更重要的是让编译期帮你检查如果基类没有对应虚函数或者函数签名对不上编译器会直接报错。我见过很多祖传代码里派生函数和基类虚函数签名不一致导致“改了函数但没生效”的诡异问题用override可以从根源上避免。除法里我直接抛了异常不要返回一个魔法值比如-1来表示错误那样很容易被上层当成正常的计算结果。C里处理这种前置条件错误抛出异常是相对清晰的方式。3.2 工厂类的实现与返回值写法工厂类是核心入口。它的做法很简单根据传入的运算符字符串创建一个对应运算对象并返回。返回值类型是基类指针或智能指针。最常见的传统写法是这样Operation* CreateOperation(char op) { switch (op) { case : return new Add(); case -: return new Sub(); case *: return new Mul(); case /: return new Div(); default: return nullptr; } }这种写法最大的问题在于调用方拿到裸指针后必须记得delete一旦忘记内存就泄漏了。尤其在函数有异常抛出、提前返回等路径时很容易漏掉释放逻辑。更推荐用std::unique_ptr来承载返回值。这样调用方不需要关心释放问题对象生命周期由智能指针自动管理异常安全也能得到保证。#include memory std::unique_ptrOperation CreateOperation(char op) { switch (op) { case : return std::make_uniqueAdd(); case -: return std::make_uniqueSub(); case *: return std::make_uniqueMul(); case /: return std::make_uniqueDiv(); default: throw std::invalid_argument(不支持的运算符); } }这里我把default分支从返回nullptr改成了抛异常。原因很实际如果工厂返回空指针调用方还是要写一个if (!ptr)的判断而这个判断一旦漏写后续调用就是解引用空指针程序直接崩溃。与其让错误在深处爆发不如在入口处显式暴露问题。抛异常是更好的失败策略。这里还有一个容易翻车的小细节自定义运算类的构造函数不能是private或protected的。std::make_unique需要在工厂函数里直接调用构造函数如果构造函数访问级别不对编译会报错。这个错误信息有时候不太直观初学者容易卡住。3.3 内存管理虚析构、RAII与对象切片C世界有个基本准则如果基类有虚函数那么它就应该有虚析构函数。原因是当通过基类指针去delete一个派生类对象时编译器必须知道如何正确销毁整个对象。如果析构函数不是虚函数那delete只会按“基类析构”来处理派生类中申请的资源不会得到释放。这在代码上的表现非常隐蔽可能不报错不崩溃只是某个内部缓冲区没释放内存慢慢涨上去。用std::unique_ptr后这个坑会被大幅弱化因为unique_ptr会正确调用delete但前提仍然是析构函数是虚函数。所以我们不能因为用了智能指针就放松对基类虚析构的要求。再单独讲“对象切片”。假设工厂返回值不是指针而是基类对象Operation CreateOperation(char op) { Add add; return add; // 对象切片add 被复制为 Operation }这段代码可以编译但返回的对象只是Operation切片GetResult()永远走基类版本加法逻辑完全丢失。这就是对象切片——派生类在按值传递时被“切”掉一层只剩基类部分。我在刚写C工厂的时候踩过这个坑当时困惑了很久明明调用了工厂返回的对象调GetResult()却一直返回0.0。后来才明白问题不在业务逻辑而在返回类型。处理多态对象时只能返回指针或引用绝对不要按值返回。栈上对象可以返回引用吗也不行。栈上对象在函数返回时就被销毁返回的引用变成悬空引用调用方拿着废地址访问属于未定义行为。综合来看返回堆上对象的智能指针是最稳妥的方案。4. 完整代码计算器Demo从零跑通4.1 先写一段“反面教材”对照组没有设计模式的常规写法大概长这样double Calc(double a, double b, char op) { double result 0.0; switch (op) { case : result a b; break; case -: result a - b; break; case *: result a * b; break; case /: result a / b; break; default: break; } return result; }这段代码本身没错但它把所有运算逻辑全部堆在一个函数里。如果只是教学练习完全可以接受。可一旦运算多了维护起来就很痛苦。你想加一个“求幂”运算就必须修改Calc函数本身加一个case。而所有调用Calc的地方都依赖这个函数的行为函数内部稍有改动整个程序的潜在影响面都扩大了。还有一个更隐蔽的问题这个函数是面向过程的运算逻辑和数据完全耦合在一起很难单独测试某一个算法。比如你想单测“除法返回错误”的行为必须把整个switch跑一遍还得专门构造一个除零场景很不方便。我把这段代码当作对照组放在这里是为了让后面的重构产生一个清晰的对比从“改函数”到“加类”差得其实就是一层封装。4.2 简单工厂重构后的完整版下面是完整可编译可运行的C代码直接抄就能跑#include iostream #include memory #include stdexcept class Operation { protected: double numA_ 0.0; double numB_ 0.0; public: Operation() default; Operation(double a, double b) : numA_(a), numB_(b) {} virtual ~Operation() default; void SetA(double a) { numA_ a; } void SetB(double b) { numB_ b; } virtual double GetResult() const { return 0.0; } }; class Add : public Operation { public: double GetResult() const override { return numA_ numB_; } }; class Sub : public Operation { public: double GetResult() const override { return numA_ - numB_; } }; class Mul : public Operation { public: double GetResult() const override { return numA_ * numB_; } }; class Div : public Operation { public: double GetResult() const override { if (numB_ 0.0) { throw std::runtime_error(除数不能为0); } return numA_ / numB_; } }; std::unique_ptrOperation CreateOperation(char op) { switch (op) { case : return std::make_uniqueAdd(); case -: return std::make_uniqueSub(); case *: return std::make_uniqueMul(); case /: return std::make_uniqueDiv(); default: throw std::invalid_argument(不支持的运算符); } } int main() { double a 0.0, b 0.0; char op 0; std::cout 输入操作数A: ; std::cin a; std::cout 输入运算符( - * /): ; std::cin op; std::cout 输入操作数B: ; std::cin b; try { auto oper CreateOperation(op); oper-SetA(a); oper-SetB(b); std::cout 结果: oper-GetResult() std::endl; } catch (const std::exception e) { std::cout 错误: e.what() std::endl; } return 0; }编译命令一般是这样g -stdc17 -Wall main.cpp -o calculator有几个点需要说明auto oper CreateOperation(op);中的auto类型会被推导成std::unique_ptrOperation。后续调用oper-GetResult()时因为是虚函数会走派生类的实际实现。CreateOperation返回的unique_ptr是所有权语义明确的对象在main函数结束时自动释放堆上的Operation派生类对象不需要也不能手动delete。我特意在main里包了try-catch。如果不包除零时程序会直接异常终止实际表现为打印一段terminate信息后退出给用户的体验非常差。这里包住异常并输出友好信息是工程上更合理的做法。运行效果输入操作数A: 10 输入运算符( - * /): / 输入操作数B: 4 结果: 2.5再试一次除零输入操作数A: 10 输入运算符( - * /): / 输入操作数B: 0 错误: 除数不能为0核心业务逻辑被分装到不同的类里main函数只负责输入输出和异常处理这就是“和计算有关的变化”和“和界面有关的不变”被初步分离的效果。4.3 扩展验证加一个“幂运算”要动什么为了验证简单工厂的扩展性我试着增加一个“求幂”运算。需要做的事情就两件第一新写一个幂运算类class Pow : public Operation { public: double GetResult() const override { return std::pow(numA_, numB_); } };第二在工厂的switch里加一个分支case ^: return std::make_uniquePow();调用方代码和基类代码完全不用改。这就是新手最容易体会到的“少改既有代码”的爽点——你只是新增了一个类开放扩展你只是改动了工厂修改了唯一的创建逻辑集散点。当然这里也确实暴露了简单工厂模式天然的一个“不够完美”的地方加运算时虽然不需要改业务类但仍然要改工厂本身。这意味着工厂并未完全关闭修改。所以从严格的开闭原则角度看简单工厂并没有完全满足面向对象设计的理想要求。这也是后续工厂方法模式出现的动机——把工厂本身再抽象一层让每个产品有自己对应的工厂类新增产品时整体上不再修改既有代码。不过在项目规模和需求变化频率没到那个程度之前简单工厂已经能解决大部分“创建对象”的重复代码问题。升级为工厂方法也会引入更多类过度设计同样是问题需要做取舍。5. 实操中的常见坑与排查思路5.1 内存泄漏最典型的入门事故很多人一开始用裸指针版本工厂写完之后跑几遍程序功能都正常就觉得没事了。等到程序长时间跑或者在某处循环里反复创建对象内存就像漏水的杯子一样刹不住。我见过一个比较典型的错误写法for (int i 0; i 100000; i) { Operation* op CreateOperation(); // 忘写了 delete op; }程序跑完内存占用持续上涨。排查的时候用Valgrind这类工具能看到明确的内存泄漏调用栈。这类问题不是“功能性的bug”但会在生产环境中带来严重的稳定性隐患。用unique_ptr之后同样的循环里不需要任何手写释放对象在每次循环迭代结束时自动析构并释放内存。这种用RAII管理资源的方式才是现代C的主流做法也天然规避了一大类问题。需要留意的是Operation类的析构函数必须声明为virtual。我在实际编码中曾经因为基类漏写virtual ~Operation()导致unique_ptrOperation释放派生类对象时只调用了基类析构。如果你的派生类里恰好有std::vector、std::string这类需要释放的成员就会出现资源没被正确释放的奇怪故障。排查这种问题比较费事不如一开始就写对。5.2 工厂硬编码开闭原则的反面教材简单工厂模式在扩展新运算时需要修改工厂类的switch或if分支。有些团队对这个情况非常敏感认为它违反了开闭原则的核心精神会在代码评审时提出质疑。我的观点是开闭原则是针对“变化频率较高且方向可预期”的场景。如果业务形态已经比较稳定新增运算频率很低那么一个switch硬编码是完全可以接受的。过度抽象才是更常见的工程问题——为了一年加一个类型提前设计出十几个类最后把人绕晕。不过如果你明确知道新运算类型会长期持续地增加比如一个不断扩展的插件系统那简单工厂就不合适了。这时候应该考虑把工厂本身拆成每个产品族独立的工厂类也就是向工厂方法模式演进。判断信号很简单看工厂函数的改动频率。如果每加一个产品工厂函数就要新增一条分支而且这样的需求每个月都在发生那就说明这个“变化点”没有真正被隔离干净。反之一年加不了两三次简单工厂结构清晰、上手简单反而是实用的选择。5.3 简单工厂 vs 工厂方法什么时候该升级我把两者放在一起做了个对比方便直观决策比较维度简单工厂工厂方法核心思想用一个工厂集中创建产品每个产品配一个专门工厂新增产品时修改现有工厂类新增一个工厂类即可类数量较少结构轻每个产品多一个工厂类较重开闭原则未完全满足更加贴近开闭原则适用规模产品少、变化慢产品多、变化频繁学习成本低中等有一类比较微妙的情况值得单独说如果工厂里的switch分支变得巨大动辄几十个case但是新增频率却不高这种时候我认为真正的问题反而不在“要不要换成工厂方法”而在于产品分类设计是否合理。几十个产品塞在一个工厂里说明这些产品很可能并不是同一个“家族”或许应该按业务域拆分成多个独立工厂。之前我在一个项目里就碰上过这种场景一个函数里堆了二十多个case创建不同类型对象代码长度超过六百行。当时我第一反应是升级成工厂方法但梳理完发现这二十多种类型可以分成三个清晰的大类别。最后我做的处理是拆成了三个简单工厂每个工厂负责自己那一组对象的创建。结构复杂度降低了团队理解成本也下降了很多。设计模式不是“越重越好”能贴合当前业务复杂度才是好设计。6. 我的使用体会与后续扩展建议简单工厂模式是我给新同事讲设计模式时的第一课原因不是它功能最强而是因为它足够短小精悍。一个人只要理解了“产品基类 具体产品 工厂入口”这三件事就已经摸到了面向对象设计的门槛面向抽象编程而不是面向具体实现编程。说句实话在实际的商业项目里我很少单独使用简单工厂。更多场景是把它作为某个复杂模式中的一环例如在策略模式里配合简单工厂由工厂根据运行期条件创建对应的策略对象再例如配合模板方法把同类对象的创建集中在工厂入口方便统一加监控日志、统一做参数校验。如果你刚接触C设计模式我的建议是不要急着追求“标准模式大全”。先把简单工厂的代码亲手敲一遍把多态、虚析构、智能指针这几个知识点在同一个例子里吃透再往下学工厂方法、抽象工厂和策略模式会顺畅很多。这段学习路线的价值你可能在写代码的前一两个月感受不到等真正开始接触中大型项目面对一堆难以维护的业务代码时会回想起这些“不起眼的小设计”如何帮你兜住复杂度。至少我的经验是这样的上手设计模式最好的时机正是你觉得每天改代码改到怀疑人生的那段时间。不是代码需要你而是你需要这些思路。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑