news 2026/10/9 9:05:14

现代C++设计模式实战:从RAII到智能指针的工程实现

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
现代C++设计模式实战:从RAII到智能指针的工程实现

设计模式这四个字,在C++这条技术栈里的位置一直有点微妙。一方面,GoF那本《设计模式》的示例代码几乎全是C++写的,按说C++应该是设计模式的主场;另一方面,你拿C++98时代那套类图和写法放进现代C++工程里,往往事倍功半,甚至会被同事批评"过度设计"。为什么会这样?因为C++从来不是一门单纯的面向对象语言,虚函数、模板、lambda、智能指针这些工具叠加在一起,让同一个模式在C++里至少有三种完全不同的落地姿势,选错了就是灾难。

这篇文章不打算把23种设计模式全部过一遍,那种写法既像教科书又像八股文题库。我会从实际工程出发,挑出几个高频模式,用可编译的C++代码讲清楚"为什么这样写""不这样做会踩什么坑",顺带把期末大作业、面试手撕代码这些场景需要掌握的核心知识都覆盖到。适合正在做设计模式大作业的学生、准备C++面试的开发者,以及想用设计模式重构老模块但不知道如何下手的工程人。先把一句话放在前面:设计模式在C++里的核心不是类图,而是搞清楚"变化的点在哪里,谁负责对象的生命周期"。

1. 项目概述:为什么C++里的设计模式值得单独讲

1.1 C++在设计模式这件事上到底是个什么位置

设计模式的概念最早来自建筑领域,后来被GoF引入软件工程。那本经典著作《Design Patterns: Elements of Reusable Object-Oriented Software》里的示例代码,用的就是C++和Smalltalk。所以很多初学者会形成一个印象:C++是天生的面向对象语言,23种设计模式当然应该在C++里逐条落地。这个印象一半对,一半不对。

对的一面是,C++确实提供了完整的面向对象武器库:类、继承、虚函数、抽象基类、运行时多态。工厂模式要抽象产品接口、策略模式要抽象算法接口、观察者模式要发布订阅结构,这些在C++里用虚函数都能规规矩矩画出来,和Java的体验差不多。不对的一面在于,C++远远不只面向对象这一条路。模板可以让代码在编译期完成派发,std::function可以让函数成为一等公民直接传递,lambda可以让行为封装不再需要单独定义一个类。同样是"策略",你在Java里基本只能写"接口+实现类",但在C++里至少有四种写法:虚函数接口、函数对象、模板参数、lambda注入。跨度之大,在其他主流语言里很少见。

这就带来一个C++特有的问题:设计模式在C++里没有所谓的"标准实现"。网上随便搜一个单例模式,能搜出饿汉、懒汉、双重检查锁、静态局部变量、模板单例五六种写法,每种都有人自称最标准。其实它们各自有不同的适用场景:有的图启动快,有的图线程安全,有的图延迟加载,有的试图兼顾所有优点。真正工程里要做的是根据场景选对实现,而不是背一个模板套到所有项目里。后面我会把这些写法的取舍一条一条拆开。

1.2 哪些人需要这篇内容,能解决什么问题

我平时会收到不少跟"设计模式+C++"相关的提问,梳理下来读者基本分成三类,诉求差别很大。

第一类是学生,正在做设计模式期末大作业或者课程设计。这类读者最需要的不是概念,而是一个能串起多个模式的综合案例,代码能编译能运行,答辩时能讲清楚每个模式解决了什么问题。第二类是准备面试的开发者,面试官很喜欢问"手写单例模式""说说观察者模式""设计模式八大原则",光背概念不够,还得扛得住追问:你的单例线程安全吗?观察者列表里的指针悬垂了怎么办?第三类是写C++工程的老手,代码跑了好几年,业务逻辑越来越复杂,if-else成堆,想用模式来重构,但又怕引入过度设计,把简单事情搞复杂。

这三类读者的需求点不一样,我尽量在后面的内容里都覆盖到:综合案例放在第4章给第一类,高频模式的完整代码和追问点放在第3章给第二类,设计思路和取舍原则散落在全篇给第三类。所以这篇内容的组织方式不是23种模式逐一报幕,而是围绕C++的具体特性,把最常用、最容易踩坑的几种模式讲透。

2. 核心设计思路:现代C++实现设计模式的三个底层工具

GoF写书的时候,C++还是C++98:没有auto,没有unique_ptr,没有lambda,更没有std::function。所以当时实现设计模式基本只能靠"类+虚函数"这一条路。但现代C++(至少是C++11以及之后的版本)引入了几个彻底改变实现方式的特性。在进入具体模式之前,我先把这三个底层工具讲清楚,后面所有代码都会反复用到它们。

2.1 RAII:资源安全是设计模式能跑起来的前提

RAII(Resource Acquisition Is Initialization,资源获取即初始化)是C++独有的资源管理思想:把资源的生命周期绑定到一个栈对象上,构造时获取资源,析构时自动释放。这个思想对设计模式的影响被很多人低估了——它直接决定了模式里的对象协作是否安全。

举个最典型的例子。策略模式在Java里用接口定义算法,C++用纯虚基类也能做到一模一样,但关键问题是:谁来管理算法对象的生命周期?很多C++新手写的策略模式是这样的:

class IStrategy { public: virtual void execute() = 0; virtual ~IStrategy() = default; }; class AStrategy : public IStrategy { public: void execute() override { /* ... */ } }; class Context { IStrategy* strategy_; public: explicit Context(IStrategy* s) : strategy_(s) {} void run() { strategy_->execute(); } };

这段代码能编译能运行,但藏着资源泄漏隐患。Context持有一个裸指针,却没有说明这个指针的所有权到底归谁。调用方new一个AStrategy传进来,Context用完不delete会泄漏;Context好心delete掉,调用方如果继续用这个指针,就变成悬垂指针。双方都在猜责任,最后就是内存问题。用RAII思想重新设计,把裸指针换成unique_ptr,语义一下子清晰了:这份算法对象的所有权明确归Context所有,析构时自动释放,谁都不会误解。这就是RAII对设计模式的意义——它帮你把模式结构里最麻烦的对象管理问题交给语言机制解决。

2.2 多态的两条路线:运行时虚函数与编译期模板

GoF模式依赖的核心机制是多态。传统C++多态用虚函数实现,也就是运行时根据对象的真实类型调用对应实现。这种多态的好处是灵活:对象可以在运行时被替换,某个组件只要换一个新实现,整个系统的行为就跟着变。代价是性能损耗和代码膨胀,每个具体实现都要写一个类,虚函数调用本身也多了一次跳转。

C++还提供第二种多态——模板。比如策略模式用模板参数传入算法,完全不需要虚函数:

template <typename Strategy> class Context { Strategy strategy_; public: explicit Context(Strategy s) : strategy_(std::move(s)) {} void run() { strategy_.execute(); } };

编译器在实例化模板时会直接把合适的算法代码嵌进Context,没有虚函数调用,内联机会也更大,性能明显更好。缺点同样明显:Context一旦用某个策略类型实例化,运行时就再也没法换成其他策略了。所以两条路线的取舍很清晰:需要运行时切换策略,选虚函数或std::function;策略在编译期就能确定,优先选模板。好多C++开发者只熟悉虚函数这一条路,遇到模板就懵,其实模板才是C++里真正区别于Java的优势所在。

下面这个表格可以帮助快速对比两种多态的差异。

对比维度虚函数多态模板多态
决策时机运行时编译期
灵活性可运行时替换类型固定
性能有间接调用开销可内联,更高
可读性类图清晰依赖类型推导
适用模式大部分GoF模式策略、观察者回调等

2.3 生命周期语义与所有权传递

现代C++把对象生命周期分成几种明确角色:独占所有权的unique_ptr、共享所有权的shared_ptr、不拥有对象但能安全观察的weak_ptr。设计模式里的创建型模式(工厂、单例)和结构型模式(代理、组合)都涉及对象在不同对象之间传递,所有权语义只要不清,bug就跟着来。

举个例子,工厂模式如果返回裸指针,调用方看着返回值心里打鼓:我要不要delete?如果不delete,泄漏;如果delete,万一工厂内部还缓存着这份指针,程序崩给看。把返回值改成unique_ptr之后,语义立刻明确:产品对象的所有权交给了调用方,调用方能安全释放,工厂不再负责。观察者模式里也是同理,Subject保存Observer指针,但Observer可能提前销毁,如果存的是shared_ptr就可能造成循环引用,改用weak_ptr才能既持有又不影响生命周期。设计一个模式时,先问一句"这个对象在生命周期的每个阶段由谁负责释放",很多设计难题瞬间就解开了。

3. 经典模式在C++中的落地实现

从这一章开始进入具体代码。我选了四个在C++工程里用得最多、期末和面试也最容易考到的模式:单例、工厂、观察者、策略。每个模式都会先交代经典GoF意图,再给出现代C++实现,最后点出关键坑位。

3.1 单例模式:线程安全的各种实现细节

单例模式是最简单也最容易被问烂的一个模式,意图只有一个:确保一个类只有一个实例,并提供全局访问点。但"唯一实例"在C++里涉及构造控制、线程安全、延迟加载、销毁顺序等一堆细节,每个细节都能玩出花样。

先看经典的懒汉写法,这也是新手最容易写、坑最多的版本:

class Singleton { private: static Singleton* instance_; public: static Singleton* getInstance() { if (instance_ == nullptr) { instance_ = new Singleton(); } return instance_; } Singleton(const Singleton&) = delete; Singleton& operator=(const Singleton&) = delete; private: Singleton() = default; }; Singleton* Singleton::instance_ = nullptr;

这个写法在单线程下没问题,多线程下就出事:两个线程同时判断instance_为空,然后各自new,内存泄漏加实例不唯一。于是有人加互斥锁:

static Singleton* getInstance() { std::lock_guard<std::mutex> lock(mutex_); if (instance_ == nullptr) { instance_ = new Singleton(); } return instance_; }

线程安全了,但每次读实例都要加锁。对于读多写少的场景,这种全局锁的性能开销不小,于是又有人写出双重检查锁(Double-Checked Locking Pattern):

static Singleton* getInstance() { if (instance_ == nullptr) { std::lock_guard<std::mutex> lock(mutex_); if (instance_ == nullptr) { instance_ = new Singleton(); } } return instance_; }

双重检查锁在C++里有个著名陷阱:new表达式在编译器视角里大致分三步——分配内存、调用构造函数、把地址赋给instance_。C++98/03没有严格的内存模型保证,处理器和编译器都可能乱序,另一个线程可能看到instance_非空,但构造函数还没跑完,拿到一个半成品对象。C++11以后可以用原子变量加内存序修正,但普通开发者很容易写错。所以现代C++最推荐的写法是Meyers Singleton,直接用函数局部静态变量:

class Singleton { public: static Singleton& getInstance() { static Singleton instance; return instance; } Singleton(const Singleton&) = delete; Singleton& operator=(const Singleton&) = delete; private: Singleton() = default; };

C++11标准明确规定:函数局部静态变量的初始化是线程安全的,编译器自动加保护。这段代码既延迟加载又线程安全,还不用手动delete,是工程里的首选。它唯一解决不了的是销毁顺序问题:如果多个单例互相依赖,程序退出时析构顺序不受控制,可能崩在奇奇怪怪的地方。实际写代码时,尽量让单例之间不互相持有引用,把生命周期问题交给依赖注入来管理更稳妥。

提示:面试时如果被问"单例和静态全局变量有什么区别",答案核心是两点:局部静态变量拥有延迟初始化和线程安全两种优势。能主动说出"但无法处理销毁顺序",说明你不是在背八股。

3.2 工厂模式:当简单工厂遇到智能指针

工厂模式的本质是把对象的创建逻辑从使用方剥离,让调用方不关心具体类型。GoF定义了三种:简单工厂不是正式模式但最常见、工厂方法通过子类延迟创建对象、抽象工厂创建一族相关对象。C++工程里日常用得最多的是简单工厂和带注册表的工厂,先把简单工厂写出来:

class Product { public: virtual void use() = 0; virtual ~Product() = default; }; class ProductA : public Product { public: void use() override { std::cout << "Product A" << std::endl; } }; class ProductB : public Product { public: void use() override { std::cout << "Product B" << std::endl; } }; class Factory { public: static std::unique_ptr<Product> create(const std::string& type) { if (type == "A") { return std::make_unique<ProductA>(); } else if (type == "B") { return std::make_unique<ProductB>(); } return nullptr; } };

返回unique_ptr是重点。它把产品对象的所有权明确交给调用方,调用方不需要手动delete,即使create中途抛异常也不会泄漏。如果调用方确实需要多处共享一个产品对象,再显式把它转换成shared_ptr即可,所有权变更清晰可见。

但简单工厂有个明显问题:每增加一种产品,就要改create函数里的if-else链,违背开闭原则。改进方案是注册表工厂,用map把类型名映射到创建函数:

class RegistryFactory { public: using Creator = std::function<std::unique_ptr<Product>()>; static void registerCreator(const std::string& type, Creator creator) { registry()[type] = std::move(creator); } static std::unique_ptr<Product> create(const std::string& type) { auto it = registry().find(type); if (it == registry().end()) { return nullptr; } return it->second(); } private: static std::map<std::string, Creator>& registry() { static std::map<std::string, Creator> reg; return reg; } }; // 模块内注册 static bool regA = [] { RegistryFactory::registerCreator("A", [] { return std::make_unique<ProductA>(); }); return true; }();

这种注册表工厂在游戏插件系统、GUI控件注册、日志格式扩展里非常常见。新增产品不需要改已有工厂代码,只写新的产品类和注册语句,符合开闭原则。期末大作业如果能从简单工厂延伸到注册表工厂,再解释一下"对扩展开放、对修改封闭"的含义,会比单纯写一个if-else工厂有说服力得多。

3.3 观察者模式:从手写接口到回调函数

观察者模式也叫发布订阅模式,核心是一个对象状态变化时自动通知所有依赖它的对象。GoF经典实现是Subject和Observer两个抽象类,现代C++里用std::function替代Observer抽象基类,代码会轻便很多,也贴合C++的函数对象特性。

经典写法大致是这样:Observer是抽象接口,ConcreteObserver继承后重写update方法;Subject维护Observer指针列表,状态改变时遍历列表调update。这个写法最大的问题不是复杂,而是两个:每个观察者都必须实现整个接口,哪怕它只想关心其中一个事件;并且如果观察者的生命周期比Subject短,Subject里还留着裸指针,发布事件时会直接访问已销毁对象。

用std::function改造之后,观察者不再是一个类,而是一个可调用对象,订阅者可以用lambda、函数对象、成员函数任意组合:

class EventBus { public: using Handler = std::function<void(const std::string&)>; void subscribe(const std::string& event, Handler handler) { handlers_[event].push_back(std::move(handler)); } void notify(const std::string& event, const std::string& data) { auto it = handlers_.find(event); if (it == handlers_.end()) { return; } for (const auto& handler : it->second) { handler(data); } } private: std::unordered_map<std::string, std::vector<Handler>> handlers_; };

使用时可以这样:

EventBus bus; bus.subscribe("login", [](const std::string& user) { std::cout << "user login: " << user << std::endl; }); bus.notify("login", "alice");

这段代码把"谁发布、谁订阅"彻底解耦,发布者不需要知道订阅者是什么类型。唯一的生命周期隐患是:如果订阅者对象已析构,但lambda回调还留在handlers_里,通知时会触发未定义行为。常规做法是订阅时生成一个token,退订时用token删除;或者让回调持有weak_ptr,执行前lock判断对象是否存活。后面第5章会专门展开这个问题。

3.4 策略模式与模板方法:选择运行期还是编译期接口

策略模式定义一族算法,让它们可以互相替换,属于行为型模式里出现频率最高的一个。C++实现策略模式至少有三种形态:经典虚函数版、std::function版、模板参数版,我把三种放到一起对比,能帮助你做选型。

第一种是虚函数版,也就是GoF类图的标准样子:策略接口、具体策略、上下文三个角色。优点是对象可以在运行期切换策略,缺点是小算法也得写成一个完整类,文件数量蹭蹭上涨。第二种是std::function版,上下文直接持有一个函数对象:

class Context { public: using SortStrategy = std::function<std::vector<int>(std::vector<int>)>; explicit Context(SortStrategy strategy) : strategy_(std::move(strategy)) {} void setStrategy(SortStrategy strategy) { strategy_ = std::move(strategy); } std::vector<int> execute(std::vector<int> data) { return strategy_(std::move(data)); } private: SortStrategy strategy_; }; Context ctx([](std::vector<int> v) { std::sort(v.begin(), v.end()); return v; }); ctx.setStrategy([](std::vector<int> v) { // 另一种排序算法,比如逆序 std::sort(v.rbegin(), v.rend()); return v; });

这个版本写起来极轻,策略就是一行lambda,非常适合策略数量不多、逻辑不复杂的场景。第三种是模板参数版,它把策略作为编译期类型绑定在上下文里,没有虚函数调用,性能最好,但运行期无法替换策略。

三种写法之间没有绝对优劣,决策标准很简单:策略需要在运行期换来换去,选虚函数或std::function;策略类型编译期能确定,追求极致性能,选模板参数。我最常用的判断方式是在性能和灵活度之间划一条线——如果这个模块未来极大概率不会在运行时换策略,就上模板;只要有一丝运行时替换的可能,就选std::function,因为它的改动成本最低。

模板方法模式跟策略有点像,它用基类定义算法骨架,把某些步骤延迟到子类实现。C++里同样可以变通:骨架不变,变化的部分用std::function注入,这样既有模板方法的稳定性,又不必为每个变体都写一个子类。两者联合使用时,我通常把"固定流程"放在普通函数里,把"可变动作"作为函数参数传入,简单直接,比硬套继承树更容易维护。

4. 实操过程:用四种设计模式搭建一个C++日志系统

很多同学做设计模式大作业,喜欢做成"模式展示大全":每个模式单独一段代码,互相之间没有关系。这种做法的问题在于答辩时很难讲明白"这些模式为什么能协同工作"。我看过的优秀作业,几乎都是用一个综合案例把多个模式自然地串起来。这里我选择日志系统作为案例,理由有两个:一是日志系统几乎每个C++项目都需要,场景接地气;二是它能自然地把单例、工厂、观察者、策略四种模式融进同一套代码里,逻辑上一点都不生硬。

4.1 需求分析与模式选型

先明确日志系统的需求。日志器要接收消息;消息在输出前可能需要格式化,比如加时间戳、加日志级别标签;输出目标可能是控制台、文件、网络等;全局只需要一个日志入口,避免到处new一个Logger;后续还要能轻松新增输出目标类型。

按这个需求做模式选型:日志器本身用单例模式,保证全局只有一个入口;输出目标用观察者模式,日志器发布消息,各输出端订阅;消息格式化用策略模式,运行期可以根据需要切换格式;日志器的创建和输出目标创建交给工厂模式,把创建逻辑集中管理。四个模式各司其职,没有一个是硬凑的。

4.2 完整代码实现与关键点讲解

下面给出完整代码,这一段可以直接复制到支持C++17的编译环境里运行:

#include <iostream> #include <fstream> #include <memory> #include <vector> #include <functional> #include <string> #include <map> #include <ctime> enum class LogLevel { Info, Warning, Error }; // 策略模式:消息格式化器 class IFormatter { public: virtual std::string format(const std::string& msg, LogLevel level) = 0; virtual ~IFormatter() = default; }; class DefaultFormatter : public IFormatter { public: std::string format(const std::string& msg, LogLevel level) override { return "[LOG] " + msg; } }; class DetailedFormatter : public IFormatter { public: std::string format(const std::string& msg, LogLevel level) override { std::time_t t = std::time(nullptr); char buf[32] = {0}; std::strftime(buf, sizeof(buf), "%Y-%m-%d %H:%M:%S", std::localtime(&t)); std::string levelStr; switch (level) { case LogLevel::Info: levelStr = "INFO"; break; case LogLevel::Warning: levelStr = "WARN"; break; case LogLevel::Error: levelStr = "ERROR"; break; } return std::string(buf) + " [" + levelStr + "] " + msg; } }; // 观察者模式:输出目标订阅者接口 class ILogSink { public: virtual void write(const std::string& formatted) = 0; virtual ~ILogSink() = default; }; class ConsoleSink : public ILogSink { public: void write(const std::string& formatted) override { std::cout << formatted << std::endl; } }; class FileSink : public ILogSink { public: explicit FileSink(const std::string& filename) { ofs_.open(filename, std::ios::app); } void write(const std::string& formatted) override { if (ofs_.is_open()) { ofs_ << formatted << std::endl; } } private: std::ofstream ofs_; }; // 单例模式:日志器 class Logger { public: using SinkPtr = std::shared_ptr<ILogSink>; using FormatterPtr = std::shared_ptr<IFormatter>; static Logger& instance() { static Logger logger; return logger; } void attach(SinkPtr sink, const std::string& name) { sinks_[name] = std::move(sink); } void detach(const std::string& name) { sinks_.erase(name); } void setFormatter(FormatterPtr formatter) { formatter_ = std::move(formatter); } void log(const std::string& msg, LogLevel level = LogLevel::Info) { std::string formatted = formatter_->format(msg, level); for (auto& item : sinks_) { item.second->write(formatted); } } private: Logger() : formatter_(std::make_shared<DefaultFormatter>()) {} Logger(const Logger&) = delete; Logger& operator=(const Logger&) = delete; std::map<std::string, SinkPtr> sinks_; FormatterPtr formatter_; }; // 工厂模式:创建输出目标 class SinkFactory { public: enum class SinkType { Console, File }; static std::shared_ptr<ILogSink> create(SinkType type, const std::string& file = "") { switch (type) { case SinkType::Console: return std::make_shared<ConsoleSink>(); case SinkType::File: return std::make_shared<FileSink>(file); default: return nullptr; } } }; int main() { Logger& logger = Logger::instance(); logger.attach(SinkFactory::create(SinkFactory::SinkType::Console), "console"); logger.attach(SinkFactory::create(SinkFactory::SinkType::File, "app.log"), "file"); logger.setFormatter(std::make_shared<DetailedFormatter>()); logger.log("application started", LogLevel::Info); logger.log("resource not found", LogLevel::Warning); logger.log("failed to connect", LogLevel::Error); return 0; }

这段代码里有几个细节值得展开说。第一,Logger的构造函数是private,同时删除了拷贝构造和赋值运算符,这是单例模式的标准控制手段,外部代码不可能偷偷再创建第二个日志器。第二,sinks_容器用shared_ptr保存输出目标,是因为同一个输出目标可能被多个订阅场景共享引用,shared_ptr能保证只要还有使用者,目标就不会被提前释放。第三,Logger内部持有的是FormatterPtr而不是裸指针,通过setFormatter方法可以在运行期自由切换格式化策略,这就是策略模式在运行期生效的地方。如果你在vscode里配置好C/C++环境,新建cpp文件把代码粘贴进去,再编译运行,应该能在控制台看到带时间戳的日志输出,同时app.log文件里也会写入同样的记录。

注意:Logger::instance()返回的是引用而不是指针,这是Meyers Singleton的推荐形态。如果返回指针,调用方可能会错误地delete它;返回引用在语义上更安全,也更难被误用。

4.3 如何扩展这个案例以满足期末答辩

这个日志系统的扩展点非常多,作业总结或者答辩时可以多准备几个方向。比如想支持按级别过滤,可以在Logger里加一个levelThreshold成员,log函数里先对比当前级别和阈值,不满足就直接返回;想支持异步写日志,可以把sinks_收到的消息先压进队列,由另一个工作线程消费,这就是一个典型的生产者消费者模型;想让输出目标的创建完全符合开闭原则,可以把SinkFactory从switch换成第3.2节里的注册表工厂,新增大日志类型时不再改动Factory代码。

我还想特别提一个思路:如果你想做小游戏类的设计模式大作业,也不一定要去抄一份跑酷游戏源码。小游戏里同样能串起设计模式——游戏主循环用模板方法定义骨架,角色生成用工厂模式,玩家事件分发用观察者模式,计分规则用策略模式。模式选型跟着职责走,不要反过来让模式安排代码结构。日志系统也好、小游戏也好,核心都是先找变化点,再配模式。

5. 常见问题与排查技巧实录

写代码这么多年,我在设计模式的落地过程中踩过不少坑,也帮别人排查过很多类似问题。这一章把最典型的四个问题整理成速查表,每个都能直接对应到你以后写代码的场景里。

5.1 单例的线程安全与销毁顺序陷阱

第一个坑是双重检查锁,前面已经详细讲过了,这里补充一个实际排查场景。我曾经遇到过一个服务程序,退出时概率性崩溃,gdb看了很久发现是Logger单例和另一个配置单例的析构顺序问题。程序退出时,配置单例先析构,另一方面配置单例的某个函数还在给Logger发消息,此时Logger已经销毁,调用即崩溃。

这种问题解决起来很棘手,因为析构顺序由全局变量的构造顺序决定,而全局变量跨编译单元的构造顺序本身是未定义的。我的经验是两条路:一是尽量在main函数结束前显式清理依赖关系,让单例之间不要你中有我;二是使用依赖注入而不是直接访问单例,把Logger指针显式传给需要它的对象,生命周期完全由外部控制。虽然依赖注入写起来更啰嗦,但它能从根本上消除这类"幽灵依赖"。

5.2 观察者模式中的悬垂指针与循环引用

观察者模式有两个方向相反的生命周期问题:悬垂和循环引用。悬垂发生在Subject保存Observer指针,但Observer先被销毁的情况下。用裸指针必然有风险,用shared_ptr又会带来第二个问题——如果Observer的回调里又引用Subject,两者互相持有shared_ptr,引用计数永远不为零,内存就泄漏了,这就是典型的循环引用。

工程上的标准解法是打破环:一端用weak_ptr,比如Subject持有weak_ptr观察者列表,通知前先lock判断对象是否存活;或者Observer不直接持有Subject的shared_ptr,而是通过weak_ptr观察。我在第3.3节写的EventBus例子其实留下了一个隐患,就是订阅者销毁后lambda仍然留在列表里,严谨的做法是在订阅时生成token,退订时通过token移除。这里我建议你在期末或者面试时主动提一句weak_ptr对循环引用的作用,这会是加分项。

5.3 模式滥用:过度设计的三个典型信号

设计模式不是越多越好,这句话说了无数遍,但实际code review里还是经常看到过度设计。最常见的是三个信号:一是类数量爆炸,每个if分支都抽象成一个策略类,最后整个项目没人能看懂对象之间怎么协作;二是到处用单例,每个模块都要全局唯一,结果隐式全局状态污染了所有测试;三是人为制造接口,只有一个实现类也要抽象出接口,多态需求根本不存在,白白增加一层跳转。

我的判断标准其实非常简单:如果未来只有一种可能的变化,就不要为它引入模式;等变化真的来了,再抽象也来得及,成本往往比预先设计低得多。设计模式的本质是响应变化,而不是预防一切可能的变化。用这个标准去检查自己的代码,能砍掉一半以上为了用模式而用模式的类。

5.4 面试追问:如何看待"C++为什么没有普遍使用GoF设计模式"

网上有人搜"C++为什么没有普遍"这种问题,我猜完整版很可能是"C++为什么没有普遍使用GoF设计模式"或者"为什么C++社区不像Java那样强调设计模式"。我的看法是,C++社区并不是不用设计模式,而是很多模式被语言特性"内化"了:策略模式被std::function和模板吸收,观察者模式被信号槽和回调函数吸收,迭代器模式直接被标准库的迭代器和范围for循环覆盖。C++用更轻量的方式实现了同样的意图,所以你不会在工程里频繁看到StrategyImpl.cpp这种文件,但模式的思路仍然藏在高阶函数、泛型算法和RAII封装里。

面试时如果被问到这个问题,可以从语言范式角度回答:C++支持多范式,面向对象只是手段之一,设计模式不是C++唯一的代码组织思路。这个回答既展示了你对C++特性的理解,也说明你没有把设计模式当成教条来背。

6. 实操总结与个人经验

6.1 一次真实重构带来的启发

我自己在最开始用设计模式时也走过弯路:想给老模块引入工厂模式,结果先是把简单工厂写成了switch工厂,然后又堆了一堆类,重构完代码量翻倍,可读性反而下降了。后来有一次真正让我尝到甜头的重构,是把一个到处if-else根据配置项选算法的模块改成注册表工厂加策略模式。新算法只需要写一个独立文件并注册,主函数从那以后整整三个月没动过。那次我彻底明白了一件事:设计模式的价值不在于代码有多少类,而在于把变化的点隔离在接口背后。你对变化点判断得越准,模式用得就越轻。

6.2 给期末大作业和面试备战的两点建议

如果你正在准备设计模式期末大作业,我的建议是不要死记23种模式的类图。先画一张"问题到模式"的映射表:想全局唯一入口,选单例;想解耦创建逻辑,选工厂;想行为可替换,选策略;想状态变更通知别人,选观察者。练熟六七种高频模式,比背完23种但一个都不会写强得多。面试也是同样的道理,考官问"手写单例",真正想看的不是你会背Meyers Singleton,而是你能不能在追问中讲清楚为什么这样写、双检锁有什么问题、销毁顺序怎么处理。这些细节比模式本身的类图值钱得多。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/9 9:05:08

MyBatis Plus 字段自动填充:生产级原理分析与实现方案

先抛个结论&#xff1a;MyBatis Plus 的字段自动填充&#xff0c;不是帮你少写两行 setter 的小工具&#xff0c;而是把数据审计、创建时间、更新时间、操作人这些横切字段的赋值逻辑&#xff0c;统一收敛到一个可复用、可维护的地方。只要你的项目要落库&#xff0c;基本都绕不…

作者头像 李华
网站建设 2026/10/9 9:03:12

高性能计算框架实现:从GPU利用率到训练效率的全面优化

1. 先别急着写代码&#xff1a;算力账单逼出来的框架需求 今年初我们团队遇到一个所有做深度学习的人都懂的尴尬&#xff1a;GPU服务器的账单比上季度翻了一倍&#xff0c;但模型迭代速度反而更慢了。查了一圈监控才发现&#xff0c;集群的整体GPU利用率只有40%出头&#xff0c…

作者头像 李华
网站建设 2026/10/9 9:02:52

多平台短视频无水印解析工具 v3.0 技术拆解与源码实践

短视频这个赛道做了三年多&#xff0c;手头最常用的工具不是剪辑软件&#xff0c;而是一个自己写的解析程序。每次从各平台保存素材&#xff0c;默认下载总带个水印&#xff0c;剪辑时还得手动裁剪或者打码遮挡&#xff0c;费时又难看。这段时间我把自己的解析工具重构成了第三…

作者头像 李华
网站建设 2026/10/9 9:02:51

JVM面试全攻略:从内存模型到垃圾回收与调优实战

1. JVM基础概念题&#xff1a;别让“送分题”变成送命题1.1 面试官问“什么是JVM”&#xff0c;到底在考什么JVM面试题有一个很有意思的现象&#xff1a;越是看起来基础的问题&#xff0c;越容易把候选人筛选掉。我面过不少简历上写着“熟练掌握JVM”的人&#xff0c;一问“JVM…

作者头像 李华
网站建设 2026/10/9 9:02:44

AI辅助文献综述写作:从结构生成到引用规范的全流程指南

写文献综述这事儿&#xff0c;经历过的人都懂&#xff1a;文献检索一大堆、读了就忘、动笔时脑子空空&#xff0c;好不容易憋出一段&#xff0c;又被导师批“只是文献罗列&#xff0c;没有综述的样子”。我这些年帮学生改过不少综述&#xff0c;也自己写过几篇&#xff0c;算是…

作者头像 李华
网站建设 2026/10/9 9:02:43

Python+Vue前后端分离搭建婴幼儿用品销售网站实战

做婴幼儿用品销售网站&#xff0c;是目前很多学Python的朋友喜欢拿来练手的一个项目&#xff0c;刚好能把Python、Vue、Pycharm、Django/Flask这一整套技术串起来。我之前带毕业设计的时候&#xff0c;遇到过不少同学问&#xff1a;后端到底用Django还是Flask&#xff1f;前端为…

作者头像 李华