拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
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里有interface,C++里没有。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++里天然多了一层复杂度。

好在C++11之后我们有了三件套:std::unique_ptr表示独占所有权,std::shared_ptr表示共享所有权,std::weak_ptr表示不参与所有权的弱引用。后面讲工厂、观察者、代理这些模式时,你会发现C++几乎处处都要为"谁持有谁"做一个决策。很多从Java照搬过来的模式代码之所以崩,就是因为没做这个决策——对象被释放后还有地方持有裸指针。

1.3 值语义与指针语义:C++独有的选择权

Java里的"对象"本质上都是引用,你把对象塞进List,存的是引用。C++里则有两种含义:按值存储时对象就是实实在在的内存块,按指针/引用传递时才是"引向某个对象"。这个差异在实现多态时特别容易翻车。

最经典的翻车现场是对象切片。假设有一个基类Shape和派生类Circle,你写:

std::vector<Shape> shapes; shapes.push_back(Circle());

编译器不会报错,但Circle被切成了Shape,虚函数表也丢了,调用draw()时执行的是基类实现。这就是没有理解值语义和多态的关系。正确做法是用std::vector<std::unique_ptr<Shape>>,让容器持有一堆"对象句柄"。

C++给设计模式带来的反而是更多选择:可以用指针实现运行时多态,也可以用模板实现编译期多态;可以用继承复用逻辑,也可以用组合 + std::function复用行为。很多Gof模式在C++里其实有"更现代的实现形态",这是我后面想重点展开的。

2. 创建型模式落地:从new出来到安全构造

创建型模式负责"对象的创建过程"。在C++里,创建过程往往涉及所有权转移、异常安全和初始化参数的复杂性,所以这一节的内容最接近实战。

2.1 单例模式的四种写法,重点说C++11之后的静态局部变量

单例是学习者接触最多的模式,也是争议最大的模式。C++里的经典写法大概有四种:

  1. 懒汉式(线程不安全):第一次使用时判断指针是否为空再new。多线程下两个线程可能同时进入if分支,产出两个实例,直接用会炸。
  2. 饿汉式:在类外定义静态对象,进程启动时构造。简单安全,但延迟初始化做不到,而且会拖慢启动速度。
  3. 加锁懒汉式:在懒汉式基础上加互斥锁。安全了,但每次调用都要抢锁,性能不好。
  4. C++11 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; };

C++11标准规定,局部静态变量的初始化在多线程环境下只会执行一次。这种写法既懒加载又线程安全,还没有显式加锁的开销,是唯一我推荐的单例姿势。构造函数设成private,禁止拷贝构造和赋值,外部只能通过instance()拿引用。

如果你是在做设计模式大作业,把四种写法对比展示并说明为什么选magic static,老师很难不给高分。还有一个细节面试官爱问:如果单例析构时要释放资源怎么办?答案是可以再套一层嵌套类来管理析构顺序,但对大部分场景,进程退出时系统会回收内存,不必过度设计。

四种写法的对比可以看这张表:

写法线程安全懒加载性能开销推荐度
懒汉式裸指针否是低不推荐
饿汉式静态对象是否无一般
加锁懒汉式是是高不推荐
magic static是是极低推荐

2.2 工厂模式的选择:简单工厂、工厂方法还是抽象工厂

工厂模式的本质是把"创建哪个具体类型"的决策从调用方手里拿走,集中到工厂里。C++实现时有一个必须注意的点:工厂函数的返回值应该用智能指针,因为对象的创建和释放跨越了函数边界,裸指针容易泄漏。

简单工厂的例子:

class ShapeFactory { public: static std::unique_ptr<Shape> create(const std::string& type) { if (type == "circle") return std::make_unique<Circle>(); if (type == "square") return std::make_unique<Square>(); 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_ptr<Request> build() { auto req = std::make_unique<Request>(); 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_ptr<OldPrinter> 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_ptr<Stream> inner) : inner_(std::move(inner)) {} void write(const char* data, size_t len) override { // 先写进缓冲区,满后再调用 inner_->write(...) } private: std::shared_ptr<Stream> inner_; std::vector<char> buffer_; };

BufferedStream和底层的FileStream实现了同一个接口,我可以在外层不断嵌套装饰器,每层只负责给功能做增量。C++实现装饰器的一个常见坑是:拷贝语义。因为装饰器持有了一个sered_ptr,如果忘记把拷贝构造函数和拷贝赋值删掉,编译器会给出一个浅拷贝行为,共享底层的同一个inner,导致状态混乱。明确"装饰器对象不可复制"会省去一堆麻烦。

组合模式的思路类似:用统一的Component接口描述叶子和组合节点的共同行为。比如文件系统里File和Directory都是Node,Directory持有子节点列表。运行时沿着递归结构调用方法时,多态机制自动把操作分派给叶子或继续深入。游戏场景里的怪物小队、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::vector<int>& data) = 0; }; class QuickSortStrategy : public SortStrategy { public: void sort(std::vector<int>& data) override { /* 快排实现 */ } };

这条路线保持了完整的模式结构,适合策略数量多且需要携带状态的情况。缺点是有virtual调用开销,且需要为每个策略写一个类,代码庞大。

C++11之后更轻的路线是std::function:

class DataProcessor { public: using SortFn = std::function<void(std::vector<int>&)>; 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_ptr<Listener>& 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::vector<std::weak_ptr<Listener>> 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::vector<std::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 Pimpl:C++特有的"桥接模式"改善编译依赖

Pimpl(Pointer to Implementation)是C++工程里极具存在感的惯用法。头文件里只暴露一个裸指针(通常用unique_ptr包装),所有私有成员都藏在cpp文件里的Impl结构体中。

头文件:

class Widget { public: Widget(); ~Widget(); Widget(Widget&&) noexcept; Widget& operator=(Widget&&) noexcept; private: struct Impl; std::unique_ptr<Impl> impl_; };

cpp文件里定义struct Widget::Impl { /* 所有私有成员 */ };以及构造函数、析构函数的实现。

Pimpl能带来的好处非常实际。第一是编译防火墙:Widget的需求变动不会再触发所有include了Widget头文件的源文件重新编译,大型项目里这个性能差异极其明显。第二是ABI稳定性:如果Widget内部要新增一个成员变量,只要Impl不变,外部二进制接口就不变,这个特性对SDK库的维护者极有价值。

代价是:对象整体存储在堆上,每次访问成员多一层指针解引用;拷贝构造和移动构造需要自己定义。实际开发中我用Pimpl主要面向"公开头文件可能频繁变动"的类。它和桥接模式在结构上很像,但目的不同:桥接解耦抽象和实现,Pimpl隐藏实现细节并改善编译。面试时如果能讲清楚这一点,非常加分。

5.3 CRTP:静态多态实现可复用的接口逻辑

CRTP(Curiously Recurring Template Pattern)是C++模板与继承结合的产物:基类是一个模板,派生类把自己作为模板参数传进去:

template <typename Derived> class Addable { public: Derived& operator+=(const Derived& other) { Derived& self = static_cast<Derived&>(*this); self.value_ += other.value_; return self; } }; class Point : public Addable<Point> { public: int value_ = 0; };

基类Addable通过static_cast把this转成Derived类型,然后调用Derived的方法或成员。它实现了"代码复用 + 静态分派",没有虚函数表,没有virtual调用开销。如果你在做性能敏感的基础算法库(比如游戏引擎里的向量运算),CRTP往往比虚继承表现更好。

这里要注意,CRTP最典型的C++20前应用是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(/* 参数 */); } };

调用方只要Processor<QuickSortContext> 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 到底该不该用设计模式——我的判断标准

很多人在项目里强行上模式,代码反而越来越难懂:每个需求变化都要改三个类,业务逻辑散落在十几个文件里。这就是过度设计。我现在的判断标准很简单,只有三条:

  1. 如果需求只需要一个if-else判断分支,就不要先写抽象工厂;
  2. 如果只有一处会用到某段逻辑,不要急着抽象成基类和策略;
  3. 如果代码重复且变化点明确,才用模式去把变化点封住。

真实的做法是:先写能跑通的最小实现,等发现多处重复、继承层次需要统一接口、或者扩展需要新增大量分支时,再引入设计模式做重构。设计模式不是装饰品,它们是你在代码出现结构性问题时的手术刀。

我还想分享一个实用技巧:如果团队里的成员对设计模式的理解程度不一,就要把"模式应用的原因"写进注释里,比如"这里用观察者模式而不是直接依赖,是因为模块B和C都需要监听状态变化,且B/C可能独立消亡"。有了这样的注释,后来者维护代码时才不会在你的模式结构上乱改。写注释这件事,在我经历的大坑里救了我很多次。

最后说说我个人的体会。C++里的设计模式从来不只是一种类图,它必须和语言特性紧密咬合:RAII决定资源管理,模板提供编译期抽象,智能指针锁定了所有权语义,值语义和指针语义的取舍决定你在用什么方式建模。真正理解这一点,你会发现在C++里实现设计模式不是"套模板",而是一种更高层次的灵活性——你可以根据项目场景自由组合这些机制,让代码既安全又高效。这套经验我用在C++业务开发、图形渲染、网络库还有工具链上都很顺手。如果这篇文章能帮你少走几个我走过的弯路,那就值了。

返回列表