ARTICLE DETAIL

资讯详情

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

C++设计模式实战:RAII、智能指针与模板如何重塑经典模式

C++设计模式实战:RAII、智能指针与模板如何重塑经典模式 我第一次用C在真实项目里写业务代码时犯过一个非常典型的错误把《Head First设计模式》里那套Java写法原封不动搬进了C工程。类图画得漂漂亮亮的菱形结构接口用纯虚函数模拟new出来的对象统一塞进智能指针编译器居然也一路放行。结果代码一跑起来就翻车——内存泄漏、对象切片、生命周期错乱最折磨的是我根本不知道问题出在哪。后来才想明白设计模式本身在C中完全成立但C的执行环境、资源管理模型、语言特性决定了每个模式的落地姿势和Java完全不同。这篇博文我想把设计模式在C中的实现方式系统梳理一遍重点讲清楚三件事哪些经典的Gof模式在C里有特殊写法哪些C独有机制RAII、模板、智能指针会重塑模式的实现思路以及实战中我踩过的那些坑怎么避免。无论你是在准备设计模式大作业、应付期末考还是正在用C做游戏或基础库开发这篇文章都值得你从头读到尾。1. 为什么在C里谈设计模式先得丢掉Java的思维惯性我见过太多C新手问为什么没有interface关键字为什么单例的double-checked locking这么麻烦为什么多态对象不能直接放进vector。这些问题背后的共性就是大家还在用Java的心智模型去套C。C的语法环境、对象模型、资源管理模式都和Java不同设计模式在这里必须变形。1.1 接口的实现不是interface关键字而是纯虚函数Java里有interfaceC里没有。C的标准做法是用一个只含纯虚函数的类来扮演接口class Drawable { public: virtual ~Drawable() default; virtual void draw() const 0; };关键点是析构函数。如果接口类将来要作为基类指针被delete析构函数必须声明为virtual否则通过基类指针释放派生类对象时会触发未定义行为。而且注意一旦有纯虚函数这个类就不能实例化只能被继承这正是接口的语义。还有一个容易混淆的细节析构函数可以是纯虚函数但必须给定义。因为派生类析构时会隐式调用基类析构没有实现就会链接失败。所以要么写成virtual ~Drawable() default;要么写成virtual ~Drawable() 0;并且在外层补一个空实现。我习惯用前者直观又安全。在C里对接口的另一个理解是抽象基类——一个类既可以定义纯虚接口也可以提供某些默认实现。这和Java的abstract class类似但C还支持多继承。多继承让C实现接口时可以同时继承多个接口类但随着C引入更多的现代机制多继承的使用需要克制后面聊适配器模式时我会细说。1.2 垃圾回收缺失改变了所有模式的资源语义Java世界里new一个对象写代码的人基本不需要关心它什么时候被回收有GC兜底。C世界里没有这个兜底new出来的对象如果不delete就会一直占用资源直到进程结束。设计模式的核心是对象之间的协作关系——谁创建了谁、谁持有谁、谁在什么时候销毁谁。在C中这个对象的所有权和生命周期就成了模式实现时必须先回答的问题。打个比方Java的模式代码像住酒店退房时有人打扫C的模式代码像自己租房水电煤气、退租交割全得自己管。这让同一个模式在C里天然多了一层复杂度。好在C11之后我们有了三件套std::unique_ptr表示独占所有权std::shared_ptr表示共享所有权std::weak_ptr表示不参与所有权的弱引用。后面讲工厂、观察者、代理这些模式时你会发现C几乎处处都要为谁持有谁做一个决策。很多从Java照搬过来的模式代码之所以崩就是因为没做这个决策——对象被释放后还有地方持有裸指针。1.3 值语义与指针语义C独有的选择权Java里的对象本质上都是引用你把对象塞进List存的是引用。C里则有两种含义按值存储时对象就是实实在在的内存块按指针/引用传递时才是引向某个对象。这个差异在实现多态时特别容易翻车。最经典的翻车现场是对象切片。假设有一个基类Shape和派生类Circle你写std::vectorShape shapes; shapes.push_back(Circle());编译器不会报错但Circle被切成了Shape虚函数表也丢了调用draw()时执行的是基类实现。这就是没有理解值语义和多态的关系。正确做法是用std::vectorstd::unique_ptrShape让容器持有一堆对象句柄。C给设计模式带来的反而是更多选择可以用指针实现运行时多态也可以用模板实现编译期多态可以用继承复用逻辑也可以用组合 std::function复用行为。很多Gof模式在C里其实有更现代的实现形态这是我后面想重点展开的。2. 创建型模式落地从new出来到安全构造创建型模式负责对象的创建过程。在C里创建过程往往涉及所有权转移、异常安全和初始化参数的复杂性所以这一节的内容最接近实战。2.1 单例模式的四种写法重点说C11之后的静态局部变量单例是学习者接触最多的模式也是争议最大的模式。C里的经典写法大概有四种懒汉式线程不安全第一次使用时判断指针是否为空再new。多线程下两个线程可能同时进入if分支产出两个实例直接用会炸。饿汉式在类外定义静态对象进程启动时构造。简单安全但延迟初始化做不到而且会拖慢启动速度。加锁懒汉式在懒汉式基础上加互斥锁。安全了但每次调用都要抢锁性能不好。C11 magic static利用函数局部静态变量的初始化线程安全保证。第四种是现代C的推荐写法class Singleton { public: static Singleton instance() { static Singleton inst; return inst; } Singleton(const Singleton) delete; Singleton operator(const Singleton) delete; private: Singleton() default; };C11标准规定局部静态变量的初始化在多线程环境下只会执行一次。这种写法既懒加载又线程安全还没有显式加锁的开销是唯一我推荐的单例姿势。构造函数设成private禁止拷贝构造和赋值外部只能通过instance()拿引用。如果你是在做设计模式大作业把四种写法对比展示并说明为什么选magic static老师很难不给高分。还有一个细节面试官爱问如果单例析构时要释放资源怎么办答案是可以再套一层嵌套类来管理析构顺序但对大部分场景进程退出时系统会回收内存不必过度设计。四种写法的对比可以看这张表写法线程安全懒加载性能开销推荐度懒汉式裸指针否是低不推荐饿汉式静态对象是否无一般加锁懒汉式是是高不推荐magic static是是极低推荐2.2 工厂模式的选择简单工厂、工厂方法还是抽象工厂工厂模式的本质是把创建哪个具体类型的决策从调用方手里拿走集中到工厂里。C实现时有一个必须注意的点工厂函数的返回值应该用智能指针因为对象的创建和释放跨越了函数边界裸指针容易泄漏。简单工厂的例子class ShapeFactory { public: static std::unique_ptrShape create(const std::string type) { if (type circle) return std::make_uniqueCircle(); if (type square) return std::make_uniqueSquare(); return nullptr; } };简单工厂把类型判断集中在一个函数里适合类型较少的场景。问题是每次新增类型都要改工厂函数违反开闭原则。于是有了工厂方法模式把创建逻辑放回虚函数让子类决定实例化哪个类。class Dialog { public: virtual ~Dialog() default; virtual Button* createButton() 0; };抽象工厂则是系列对象的创建。比如一套主题系统需要同时创建匹配的按钮、输入框、背景色抽象工厂可以在接口中声明一组创建方法由具体的主题工厂实现。这段描述很抽象用游戏界面UI举例就直观多了Windows风格工厂产出Windows按钮、Windows文本框Mac风格工厂产出Mac按钮、Mac文本框。在C里实现抽象工厂时我的经验是接口的返回值尽量统一用unique_ptr或shared_ptr不要混用否则调用方还得人肉记住该用哪种指针管理。还有如果系列对象的数量很多抽象工厂的接口会膨胀可以考虑把一组参数集中到一个Config结构里传进去减少接口变更。2.3 建造者模式与参数爆炸用std::unique_ptr串联构建步骤建造者模式解决的是构造参数太多、对象初始化分阶段的问题。经典的C做法是建造者对象持有目标对象的部分或全部状态最后提供一个build()返回成品。一个非常常见的场景是配置HTTP请求class RequestBuilder { public: RequestBuilder setUrl(const std::string u) { url u; return *this; } RequestBuilder setMethod(const std::string m) { method m; return *this; } RequestBuilder setTimeout(int s) { timeout s; return *this; } std::unique_ptrRequest build() { auto req std::make_uniqueRequest(); req-url url; req-method method; req-timeout timeout; return req; } private: std::string url; std::string method GET; int timeout 30; };链式调用的窍门是每个setter都返回*this的引用。这里的核心好处是调用方可以用一行代码完成多参数设置且每个setter名称自解释阅读性远好于一长串构造函数参数。build()返回unique_ptr也有讲究Request对象一旦构建完成所有权就明确交给了调用方不会出现建造者还持有一个已经交付的对象之后不小心又改一遍的问题。如果你在写建造者模式时发现对象内部状态太多了可以考虑把状态字段收敛成几个结构体字段而不是几十个散落的变量。参数爆炸的终极解法是引入一个Options结构体再由建造者分步骤完成组装这样模式代码本身也更清爽。3. 结构型模式接口适配与组合在C中的实际姿态结构型模式负责对象之间的组合、适配和包装。C在这里有两个特色一是多继承给了类适配器更多发挥空间二是智能指针让包装关系更加安全。但多继承也会引入菱形继承、歧义等新坑必须带着安全意识去用。3.1 适配器模式类适配器与对象适配器怎么选适配器的目标是让接口不兼容的类协同工作。最常见的就是把第三方SDK的接口包装成自己业务层的接口。类适配器通过多继承实现同时继承目标接口和被适配类这样适配器本身既是接口的实现又直接拥有被适配类的实现细节。class OldPrinter { public: void printOld(const std::string text); }; class NewPrinter : public PrinterInterface, private OldPrinter { public: void print(const std::string text) override { printOld(text); } };对象适配器则是组合适配器持有一个被适配对象的指针在接口方法中转发调用。class PrinterAdapter : public PrinterInterface { public: explicit PrinterAdapter(std::unique_ptrOldPrinter old) : old_(std::move(old)) {} void print(const std::string text) override { old_-printOld(text); } };经验上对象适配器更值得优先考虑。原因有三点组合比继承更容易控制生命周期如果被适配的类还有更多子类对象适配器不用为每个子类写一个适配器最要命的是菱形继承和虚继承在复杂项目中容易把依赖关系搞得一团乱。类适配器只在一种情况下有优势——你需要适配器同时获得被适配类的受保护成员访问权或者被适配类的接口是纯函数且完全不想持有状态。否则老老实实用对象适配器。3.2 装饰器与组合模式用组合基类处理嵌套结构装饰器和组合模式在类图上长得很像都是通过持有同类对象实现递归结构。C实现时最核心的设计点是基类要定义统一的接口并且要定义虚析构函数、禁止拷贝但不禁止移动因为持有智能指针确保派生类在递归链中安全释放。装饰器的典型例子是给流对象叠加缓冲、加密、压缩能力class Stream { public: virtual ~Stream() default; virtual void write(const char* data, size_t len) 0; }; class BufferedStream : public Stream { public: explicit BufferedStream(std::shared_ptrStream inner) : inner_(std::move(inner)) {} void write(const char* data, size_t len) override { // 先写进缓冲区满后再调用 inner_-write(...) } private: std::shared_ptrStream inner_; std::vectorchar buffer_; };BufferedStream和底层的FileStream实现了同一个接口我可以在外层不断嵌套装饰器每层只负责给功能做增量。C实现装饰器的一个常见坑是拷贝语义。因为装饰器持有了一个sered_ptr如果忘记把拷贝构造函数和拷贝赋值删掉编译器会给出一个浅拷贝行为共享底层的同一个inner导致状态混乱。明确装饰器对象不可复制会省去一堆麻烦。组合模式的思路类似用统一的Component接口描述叶子和组合节点的共同行为。比如文件系统里File和Directory都是NodeDirectory持有子节点列表。运行时沿着递归结构调用方法时多态机制自动把操作分派给叶子或继续深入。游戏场景里的怪物小队、UI控件树都是典型应用场景。C实现组合模式有一个必须注意的边界父节点持有子节点时用unique_ptr表示独占还是shared_ptr表示共享我的默认选择是独占所有权子节点不跨树共享这样树的析构天然安全。只有子节点需要被多个组合节点引用时才考虑shared_ptr。别一上来就全部shared_ptr那会让所有权管理形同虚设。3.3 代理模式智能指针本身就是最典型的代理代理模式的目标是控制对真实对象的访问在客户端和真实对象之间插入一个代理层代理可以处理权限、延迟加载、分布式调用等。C里最出名的代理例子其实天天在用——std::shared_ptr。它对裸指针没有增加业务逻辑但通过引用计数控制了对象的释放时机。你每次调用shared_ptr的operator-时本质都是通过代理对象在访问真实目标。再说远一点std::unique_ptr是独占式代理std::weak_ptr是不干预生命周期的观察式代理。设计模式教材里的远程代理也很有意思客户端调用代理的接口代理把请求序列化后发到远程服务器再把响应反序列化返回。C里做网络RPC时经常能看到这种结构。另一种是延迟加载代理游戏场景里的高模资源加载就特别典型——代理先占用一个轻量的占位对象只有真正渲染时才让代理去加载完整模型。我在实战中区分代理和装饰器有一个判断标准装饰器给对象增加能力且客户端知道自己在装饰代理是控制访问客户端甚至不知道代理的存在。面向面试和考试时把这个区别说清楚说明你真的理解了模式的意图而不只是背类图。4. 行为型模式回调、策略与事件处理机制的C写法行为型模式处理的是算法与交互的灵活性。C在实现这类模式时有得天独厚的工具std::function、lambda表达式、模板参数、信号槽模式。但也不能因此抛弃Gof的经典思路关键要根据场景权衡。4.1 策略模式的三条路线虚函数、函数指针还是std::function策略模式解决算法可替换的需求。类图上是上下文持有策略接口具体策略实现算法。在C里策略的载体至少有三条路线。经典路线是抽象基类class SortStrategy { public: virtual ~SortStrategy() default; virtual void sort(std::vectorint data) 0; }; class QuickSortStrategy : public SortStrategy { public: void sort(std::vectorint data) override { /* 快排实现 */ } };这条路线保持了完整的模式结构适合策略数量多且需要携带状态的情况。缺点是有virtual调用开销且需要为每个策略写一个类代码庞大。C11之后更轻的路线是std::functionclass DataProcessor { public: using SortFn std::functionvoid(std::vectorint); void setSortStrategy(SortFn fn) { sortFn_ std::move(fn); } private: SortFn sortFn_; };调用方直接传lambda就行了无需为每个排序算法新建类。这种写法特别适合算法比较轻量、策略之间差异小、且不需要长期持有状态的场景。配合现代C代码量减少一半不止。还有第三条路线——模板参数作为编译期策略适合策略类型在编译期就确定、运行时不会切换的场景。这个我会在第五章单独展开。三条路线的对比路线运行时切换策略携带状态virtual开销代码量抽象基类支持方便有大std::function lambda支持需捕获无小模板参数不支持方便无中经验而言如果项目要求运行时动态切换算法比如用户切换排序方式用std::function路线如果策略在编译期就锁死模板参数路线更优只有策略本身重到需要一个类来管理生命周期和状态时才回到抽象基类路线。4.2 观察者模式weak_ptr解决订阅对象失效问题观察者模式是事件系统的核心C实现时最让人头疼的就是生命周期管理主题维护一个观察者列表观察者析构后主题还不知道还能照样发事件给一个悬空指针。纯Java的写法观察者对象永远不会被你主动delete在C里根本不成立。所以我的建议是观察者列表存weak_ptr事件分发时先尝试lock()如果lock成功说明观察者还活着才发送事件。一个简化实现class EventBus { public: void subscribe(const std::weak_ptrListener listener) { listeners_.push_back(listener); } void publish(const Event e) { for (auto it listeners_.begin(); it ! listeners_.end();) { auto sp it-lock(); if (sp) { sp-onEvent(e); it; } else { it listeners_.erase(it); // 顺手清理死掉的订阅者 } } } private: std::vectorstd::weak_ptrListener listeners_; };这个写法的好处是订阅者不需要在析构函数里主动取消订阅弱引用到期后自然会被清理。很多人会把取消订阅逻辑写进析构函数结果在析构中途调用EventBus的接口又碰上锁或半析构状态容易引发二次崩溃。用weak_ptr后这个坑就绕开了。还有两个细节值得注意。第一如果事件分发在多个线程同时进行listeners_容器本身要加锁或用无锁结构第二如果监听器回调中又触发了新的subscribe或publish如果没有特殊策略可能引发迭代器失效。简单做法是先把lock成功的shared_ptr临时复制到局部vector里再统一回调分发期间尽量不操作listeners_。游戏UI系统和信号槽库比如Qt的信号槽机制在底层都碰到过这类问题解法就是拷贝一份当前订阅者快照再逐个通知。4.3 模板方法模式继承与std::variant的组合实现模板方法模式的目标是骨架固定步骤可定制基类定义算法流程子类重写某个步骤。经典C写法很清晰class DataParser { public: void parse(const std::string data) { validate(data); // 骨架 auto fields split(data); handleFields(fields); // 可定制 postProcess(); } virtual ~DataParser() default; protected: virtual void handleFields(const std::vectorstd::string fields) 0; private: void validate(const std::string data) { /* 通用校验 */ } void postProcess() { /* 通用收尾 */ } };这里的骨架方法parse不是虚函数而是按照固定顺序调用普通私有方法和虚函数。子类只能替换handleFields不能打乱流程这就是模板方法的威力。现代C里还有一个替代思路用一个组合处理器 std::variant来实现同一算法对不同类型分支处理。比如处理“数字、字符串、布尔值”三种配置项可以定义variant类型再通过std::visit分发到不同的lambda。这本质上是用类型组合替代继承代码更紧凑也避免了继承层级过深。但要注意std::visit是编译期分派适合类型集合固定的场景而模板方法模式的价值恰恰在于运行期通过虚函数动态扩展。如果需求允许编译期确定类型集合variant是更好的选择如果项目里随时可能增加新的子类继承式的模板方法更灵活。二者不是互相取代的关系是不同场景下的两套工具。5. C模式实现中常被低估的四个机制RAII、Pimpl、CRTP、模板只翻Gof原书永远不会想到C还有这四个大杀器。它们在C项目中不是设计模式却比很多设计模式更能改善代码结构。下面的内容是我在维护大型C工程时最常向同事推荐的隐藏模式。5.1 RAII不是模式但它决定模式怎么写RAII资源获取即初始化的核心思想是把资源的生命周期绑定到一个栈对象上构造函数获取资源析构函数释放资源。这样无论正常返回还是抛出异常析构函数都会被调用资源一定不会泄漏。这个机制深刻影响了C里所有模式的实现方式。比如前面讲工厂模式时返回值用unique_ptr而不是裸指针就是RAII思想的延伸——unique_ptr的析构函数负责delete即使中途有异常也不会泄漏。再看观察者模式里用weak_ptr定位对象其实也是让对象失效这种状态变成一种自动清理的资源。实战中RAII最常见的落地是锁class LockGuard { public: explicit LockGuard(std::mutex m) : m_(m) { m_.lock(); } ~LockGuard() { m_.unlock(); } LockGuard(const LockGuard) delete; LockGuard operator(const LockGuard) delete; private: std::mutex m_; };当然现代C已经有std::lock_guard我只是想说明这个模式的思想如果你想保证某个对象在持有期间获得互斥锁不用手动lock/unlock把锁的生命周期交给栈对象就够了。很多人嫌RAII抽象但我认为这是C中最重要的半隐藏模式理解它你在写单例、工厂、观察者时就不会再纠结资源什么时候释放。5.2 PimplC特有的桥接模式改善编译依赖PimplPointer to Implementation是C工程里极具存在感的惯用法。头文件里只暴露一个裸指针通常用unique_ptr包装所有私有成员都藏在cpp文件里的Impl结构体中。头文件class Widget { public: Widget(); ~Widget(); Widget(Widget) noexcept; Widget operator(Widget) noexcept; private: struct Impl; std::unique_ptrImpl impl_; };cpp文件里定义struct Widget::Impl { /* 所有私有成员 */ };以及构造函数、析构函数的实现。Pimpl能带来的好处非常实际。第一是编译防火墙Widget的需求变动不会再触发所有include了Widget头文件的源文件重新编译大型项目里这个性能差异极其明显。第二是ABI稳定性如果Widget内部要新增一个成员变量只要Impl不变外部二进制接口就不变这个特性对SDK库的维护者极有价值。代价是对象整体存储在堆上每次访问成员多一层指针解引用拷贝构造和移动构造需要自己定义。实际开发中我用Pimpl主要面向公开头文件可能频繁变动的类。它和桥接模式在结构上很像但目的不同桥接解耦抽象和实现Pimpl隐藏实现细节并改善编译。面试时如果能讲清楚这一点非常加分。5.3 CRTP静态多态实现可复用的接口逻辑CRTPCuriously Recurring Template Pattern是C模板与继承结合的产物基类是一个模板派生类把自己作为模板参数传进去template typename Derived class Addable { public: Derived operator(const Derived other) { Derived self static_castDerived(*this); self.value_ other.value_; return self; } }; class Point : public AddablePoint { public: int value_ 0; };基类Addable通过static_cast把this转成Derived类型然后调用Derived的方法或成员。它实现了代码复用 静态分派没有虚函数表没有virtual调用开销。如果你在做性能敏感的基础算法库比如游戏引擎里的向量运算CRTP往往比虚继承表现更好。这里要注意CRTP最典型的C20前应用是std::enable_shared_from_this这个机制依赖CRTP让对象获得shared_ptr的强引用。你去看标准库的实现就是这个套路。把它和一个普通设计模式放在一起比较没有意义但如果你能灵活运用它很多用继承复用逻辑但不想承担虚函数开销的场景会豁然开朗。5.4 模板作为编译期工厂更符合现代C的手段模板能做的远不止CRTP。它本质上是一种编译期元编程机制在编译阶段根据类型参数生成对应的代码。这意味着一些经典的运行时模式可以提前到编译期完成省掉类型检查、虚函数调用和解引用开销。最简单的例子是策略/工厂二合一。不像运行时工厂那样写switch-case可以用模板直接实例化template typename Strategy class Processor { public: void run() { Strategy s; s.execute(/* 参数 */); } };调用方只要ProcessorQuickSortContext p; p.run();就可以使用指定策略。编译器在实例化时就已经把策略类型写死在代码里性能和手动调用一个具体类的方法没有区别。我见过很多新人一提到设计模式就想到虚函数和继承但C的模板把模式在编译期实现变成了可能。当然模板的代价是编译时间增长和报错信息晦涩不适合用在运行时需要动态扩展类型的场景。在现代C项目里正确做法是运行时扩展用虚函数/策略模式固定类型集合用模板/静态多态。把这条边界想清楚你在架构设计上的判断力会明显上一个台阶。6. 实际项目中我踩过的坑与选型经验最后这部分全部来自我的真实工程经验。设计模式本身的讲解很容易但这些坑只有动过手才会真正体会到。6.1 最经典的坑基类忘了虚析构这是C多态里最常见的问题没有之一。当你通过基类指针delete一个派生类对象时如果基类析构函数不是虚函数编译器会按照静态类型来析构只释放基类部分派生类持有的资源全部泄漏。我自己第一次踩这个坑是在写一个插件系统时抽象基类漏写了virtual析构加载了上百个插件后卸载时内存泄漏报告排山倒海而来。当时排查了很久才意识到问题出在接口设计时忘了把析构标记为虚。规律是凡是你希望作为接口/基类使用的类析构函数要么是virtual要么是protected非虚禁止外部delete基类指针。从合作开发角度说我建议直接写成virtual。这个坑也侧面印证了之前章节反复强调的一个点C的接口设计必须先想清楚生命周期再谈业务逻辑。6.2 共享状态的生命周期为什么难管设计模式中很多模式会引入共享对象事件总线被多个模块订阅工厂集中创建对象代理持有真实对象的引用。一旦共享对象的管理没有明确所有权模型代码就会出现偶发崩溃对象已被某个模块释放另一个模块还拿着裸指针调用它。我的处理原则是问自己三个问题。第一这个对象是被一个模块独占的还是要被多个模块共享的独占用unique_ptr共享用shared_ptr。第二如果共享能否让某个核心持有者负责它的生命周期其他模块只借用裸指针可以的话就用明确的拥有者观察者关系。第三如果观察者可能先于对象销毁就把观察者保存成weak_ptr先lock再使用。观察者模式里那种对象可能在事件发布过程中被销毁的情况一定要用weak_ptr或者确保销毁动作会先取消订阅。很多C线程和事件系统崩溃追到根因都是这里。我甚至见过同事为了省事把事件总线改成订阅者快照复制来避免回调中迭代器失效——这也是一种可接受的方案但最好在代码注释里说明原因。6.3 到底该不该用设计模式——我的判断标准很多人在项目里强行上模式代码反而越来越难懂每个需求变化都要改三个类业务逻辑散落在十几个文件里。这就是过度设计。我现在的判断标准很简单只有三条如果需求只需要一个if-else判断分支就不要先写抽象工厂如果只有一处会用到某段逻辑不要急着抽象成基类和策略如果代码重复且变化点明确才用模式去把变化点封住。真实的做法是先写能跑通的最小实现等发现多处重复、继承层次需要统一接口、或者扩展需要新增大量分支时再引入设计模式做重构。设计模式不是装饰品它们是你在代码出现结构性问题时的手术刀。我还想分享一个实用技巧如果团队里的成员对设计模式的理解程度不一就要把模式应用的原因写进注释里比如这里用观察者模式而不是直接依赖是因为模块B和C都需要监听状态变化且B/C可能独立消亡。有了这样的注释后来者维护代码时才不会在你的模式结构上乱改。写注释这件事在我经历的大坑里救了我很多次。最后说说我个人的体会。C里的设计模式从来不只是一种类图它必须和语言特性紧密咬合RAII决定资源管理模板提供编译期抽象智能指针锁定了所有权语义值语义和指针语义的取舍决定你在用什么方式建模。真正理解这一点你会发现在C里实现设计模式不是套模板而是一种更高层次的灵活性——你可以根据项目场景自由组合这些机制让代码既安全又高效。这套经验我用在C业务开发、图形渲染、网络库还有工具链上都很顺手。如果这篇文章能帮你少走几个我走过的弯路那就值了。
返回列表