C++设计模式实战:单例、观察者、工厂等核心模式解析与避坑指南
2026/7/23 10:42:05 网站建设 项目流程

1. 项目概述:为什么C++开发者绕不开设计模式?

如果你用C++写过一些项目,尤其是规模稍微大一点、或者需要长期维护的,大概率会遇到这样的场景:代码越写越乱,新加一个功能要改好几个地方,牵一发而动全身;或者想复用一段逻辑,却发现它和当前业务耦合得太紧,根本抽不出来。这时候,你需要的可能不仅仅是语法技巧,而是一套组织代码的“兵法”,这就是设计模式。

设计模式不是什么高深莫测的黑魔法,它就是前辈程序员在解决特定类型问题时,总结出来的一套行之有效的代码结构模板。你可以把它理解为乐高积木的经典拼法,或者象棋里的经典开局。知道这些“套路”,不是为了炫技,而是为了在遇到相似问题时,能快速找到一个稳健、可扩展的解决方案,避免重复踩坑。对于C++这种强调性能和控制力,同时又缺乏一些现代语言“糖分”(如成熟的反射、垃圾回收)的语言来说,设计模式尤为重要。它们能帮你弥补语言层面的某些“不便”,构建出更清晰、更灵活、更易于管理的对象关系。

网上关于设计模式的资料很多,但很多要么是照本宣科,讲一堆UML图却不说人话;要么是例子太简单(“动物类”、“形状类”),和实际工程脱节。这篇内容,我想结合自己这些年用C++趟过的坑,聊聊几个最常见、最实用,也最容易用错的设计模式。我会重点讲清楚三个问题:这个模式到底解决了什么痛点?在C++里怎么实现才地道?以及,什么情况下该用,什么情况下用了反而添乱?目标就是让你看完之后,能立刻在项目里找到它们的用武之地。

2. 核心设计模式解析与C++实现要点

设计模式通常被分为创建型、结构型和行为型三大类。对于C++开发者,我们不必追求大而全,先把几个基石性的模式吃透,就能解决80%的架构设计问题。下面我挑几个最“硬核”的来详细拆解。

2.1 单例模式:全局访问点的利与弊

单例大概是争议最大的模式了。它的意图很简单:确保一个类只有一个实例,并提供一个全局访问点。听起来很美好,比如日志管理器、配置管理器、线程池,似乎都应该是单例。

C++实现的经典坑与最佳实践

新手最容易写出线程不安全的版本:

class Singleton { public: static Singleton* getInstance() { if (instance == nullptr) { // 危险!多线程环境下可能创建多个实例 instance = new Singleton(); } return instance; } private: Singleton() {} static Singleton* instance; }; Singleton* Singleton::instance = nullptr;

在C++11之后,我们有更优雅且线程安全的实现——利用局部静态变量:

class Singleton { public: static Singleton& getInstance() { // 返回引用通常比指针更安全 static Singleton instance; // C++11保证此初始化是线程安全的 return instance; } // 删除拷贝构造和赋值操作,彻底杜绝复制 Singleton(const Singleton&) = delete; Singleton& operator=(const Singleton&) = delete; private: Singleton() { /* 初始化操作 */ } ~Singleton() { /* 清理操作 */ } };

这种“Meyers' Singleton”写法简洁、安全,是现代C++中的首选。但请注意,它的析构顺序依赖于静态变量的生命周期,如果单例依赖其他静态对象,可能会引发“静态初始化顺序问题”。

什么时候该用,什么时候不该用?

注意:单例本质上是一个“美化了的全局变量”。它会带来隐式耦合,让单元测试变得困难(因为你很难替换这个全局实例)。所以,请谨慎使用。问问自己:这个对象真的在程序整个生命周期中只需要一个吗?通过依赖注入传递这个对象是否更清晰?在很多现代架构中,单例模式正逐渐被 IoC(控制反转)容器所替代。

2.2 观察者模式:解耦事件源与事件处理

这是行为型模式中最实用的一种,用于建立一种对象间的一对多依赖关系,当一个对象状态改变时,所有依赖它的对象都会自动收到通知并更新。GUI框架的消息机制、游戏中的成就系统、业务逻辑中的事件驱动架构,核心都是观察者模式。

C++实现的关键:避免悬空指针与资源管理

一个典型的实现包含Subject(主题)和Observer(观察者)两个接口。

// 观察者接口 class Observer { public: virtual ~Observer() = default; virtual void update(const std::string& message) = 0; }; // 主题接口 class Subject { public: virtual ~Subject() = default; virtual void attach(Observer* obs) = 0; virtual void detach(Observer* obs) = 0; virtual void notify(const std::string& msg) = 0; };

具体的主题实现需要管理一个观察者列表。这里最大的坑在于对象生命周期管理。如果观察者对象被销毁了,但主题类里的指针还在,调用update就会导致未定义行为(悬空指针)。

解决方案1(推荐):使用std::weak_ptr这是现代C++最安全的方式。主题持有观察者的weak_ptr,观察者自身由shared_ptr管理生命周期。

class ConcreteSubject : public Subject { public: void attach(std::weak_ptr<Observer> obs) override { observers.push_back(obs); } void notify(const std::string& msg) override { for (auto it = observers.begin(); it != observers.end(); ) { if (auto obs = it->lock()) { // 尝试提升为shared_ptr obs->update(msg); ++it; } else { // 观察者已失效,从列表中移除 it = observers.erase(it); } } } private: std::vector<std::weak_ptr<Observer>> observers; };

解决方案2(简单场景):使用引用和标识符如果观察者生命周期明显长于主题,或者架构简单,也可以让观察者在析构时主动向主题注销自己,主题使用原始指针或引用,并配合一个唯一ID来管理。

实操心得:在游戏开发中,我常用观察者模式处理玩家属性变更(如血量、金币)。UI控件作为观察者,订阅玩家数据主题。当后台数据变化时,所有相关的UI元素自动刷新,业务逻辑和显示逻辑完全解耦,添加新的显示项(如新出的一个特效提示)变得非常容易。

2.3 工厂模式与抽象工厂:封装对象创建复杂性

当你的代码中充斥着new ConcreteClass(),并且这些new散落在各处时,一旦需要更换具体类,修改点就会非常多。工厂模式的核心思想是将对象的创建过程封装起来

简单工厂模式这更像一种编程习惯,而非严格的设计模式。它提供一个静态方法,根据传入的参数返回不同的产品对象。

class Product { public: virtual void use() = 0; virtual ~Product() = default; }; class ProductFactory { public: enum ProductType { TYPE_A, TYPE_B }; static std::unique_ptr<Product> createProduct(ProductType type) { switch(type) { case TYPE_A: return std::make_unique<ProductA>(); case TYPE_B: return std::make_unique<ProductB>(); default: return nullptr; } } };

它的缺点是,每增加一个新产品,都要修改工厂类的createProduct方法,违反了开闭原则。

工厂方法模式这才是正宗的“工厂”。它定义一个用于创建对象的接口,但让子类决定实例化哪一个类。

class Creator { public: virtual ~Creator() {} // 这就是工厂方法 virtual std::unique_ptr<Product> factoryMethod() const = 0; void someOperation() const { auto product = this->factoryMethod(); product->use(); } }; class ConcreteCreatorA : public Creator { public: std::unique_ptr<Product> factoryMethod() const override { return std::make_unique<ProductA>(); } };

这样,客户端代码只依赖Creator接口和Product接口,具体创建什么产品,由ConcreteCreatorAConcreteCreatorB决定。这在开发插件系统或框架时非常有用,框架定义创建接口,第三方实现具体创建逻辑。

抽象工厂模式当产品不止一种,并且属于不同家族时,就需要抽象工厂。例如,一个UI库需要为不同操作系统(Windows, Mac)创建一套控件(按钮、文本框)。

// 抽象产品:按钮 class Button { public: virtual void paint() = 0; virtual ~Button() = default; }; // 抽象产品:文本框 class TextBox { public: virtual void paint() = 0; virtual ~TextBox() = default; }; // 抽象工厂 class GUIFactory { public: virtual std::unique_ptr<Button> createButton() = 0; virtual std::unique_ptr<TextBox> createTextBox() = 0; virtual ~GUIFactory() = default; }; // 具体工厂:Windows风格 class WinFactory : public GUIFactory { public: std::unique_ptr<Button> createButton() override { return std::make_unique<WinButton>(); // 返回Windows风格按钮 } std::unique_ptr<TextBox> createTextBox() override { return std::make_unique<WinTextBox>(); } };

使用抽象工厂,可以确保创建出来的一整套产品(如所有UI控件)是风格一致的。切换整个产品族(如从Windows风格换到Mac风格)只需要更换一个具体的工厂实例即可。

C++实现要点:务必使用智能指针(如std::unique_ptr)来管理工厂创建的产品,明确所有权转移,避免内存泄漏。工厂模式经常和单例结合使用(例如,将具体的工厂类实现为单例),但这要权衡好全局状态带来的利弊。

3. 结构型模式:组合、适配与装饰

这类模式关注如何将类或对象组合成更大、更复杂的结构,同时保持结构的灵活和高效。

3.1 组合模式:处理树形结构的统一接口

当你需要表示“部分-整体”的层次结构,并且希望客户端以统一的方式对待单个对象和组合对象时,就用组合模式。文件系统(文件与文件夹)、UI组件树(按钮与容器)、公司组织架构(员工与部门)都是典型例子。

核心设计:定义一个抽象的Component接口,声明所有类(包括叶子节点和容器)的共同操作。Leaf类实现这些操作,Composite类除了实现操作,还包含一个子Component的集合,并负责管理它们(添加、删除、遍历)。

class Graphic { public: virtual void draw() const = 0; virtual void add(std::shared_ptr<Graphic>) { /* 叶子节点默认空实现,或抛出异常 */ } virtual void remove(std::shared_ptr<Graphic>) {} virtual ~Graphic() = default; }; // 叶子节点:圆 class Circle : public Graphic { public: void draw() const override { std::cout << "Drawing a circle.\n"; } }; // 容器:复合图形 class CompositeGraphic : public Graphic { public: void draw() const override { for (const auto& child : children_) { child->draw(); // 递归绘制所有子图形 } } void add(std::shared_ptr<Graphic> graphic) override { children_.push_back(graphic); } void remove(std::shared_ptr<Graphic> graphic) override { children_.erase(std::remove(children_.begin(), children_.end(), graphic), children_.end()); } private: std::vector<std::shared_ptr<Graphic>> children_; };

注意事项:在C++中实现组合模式,需要仔细考虑子对象的所有权和管理。通常使用shared_ptr来共享所有权,这样叶子对象可以被多个复合对象包含(虽然不常见)。如果所有权关系明确是唯一的,使用unique_ptr并配合转移语义可能更合适。另外,add/remove方法在基类中的默认实现需要设计好,是提供空实现、抛异常,还是采用其他方式,取决于你的具体需求。

3.2 适配器模式:让不兼容的接口协同工作

也叫包装器模式。当你有一个现成的类,它的接口不符合你系统的需求,而你又不能(或不想)修改这个类的源代码时,适配器就派上用场了。这在使用第三方库、遗留代码或进行系统集成时非常常见。

两种实现方式:

  1. 类适配器(通过多重继承):适配器同时继承目标接口和被适配者。这在C++中可行,但多重继承容易带来复杂性,不推荐为首选。

    // 目标接口(我们系统需要的) class Target { public: virtual void request() = 0; }; // 被适配者(已有的,接口不兼容的类) class Adaptee { public: void specificRequest() { std::cout << "Adaptee's specific request.\n"; } }; // 类适配器 class ClassAdapter : public Target, private Adaptee { public: void request() override { specificRequest(); // 调用被适配者的方法 } };
  2. 对象适配器(通过组合):适配器内部持有一个被适配者的实例(指针或引用)。这是更灵活、更常用的方式。

    class ObjectAdapter : public Target { public: ObjectAdapter(Adaptee* adaptee) : adaptee_(adaptee) {} void request() override { if (adaptee_) { adaptee_->specificRequest(); } } private: Adaptee* adaptee_; // 通常用智能指针管理 };

C++实战场景:假设你有一个旧的日志库OldLogger,它的接口是void writeToFile(const char*)。但你的新系统期望的日志接口是void log(const std::string&)。你就可以写一个LoggerAdapter,内部包含一个OldLogger对象,在log方法内部调用writeToFile,并完成字符串格式的转换。

3.3 装饰器模式:动态扩展对象功能

装饰器模式提供了一种比继承更灵活的扩展功能的方式。它允许向一个现有对象添加新的功能,同时又不改变其结构。想想游戏里的角色装备系统,一件装备就是一层装饰器。

关键特性:

  • 装饰器和被装饰对象实现相同的接口。
  • 装饰器内部包含一个对该接口对象的引用。
  • 可以在调用被装饰对象的方法前后,添加自己的行为。
// 组件接口 class Beverage { public: virtual std::string getDescription() const = 0; virtual double cost() const = 0; virtual ~Beverage() = default; }; // 具体组件:浓缩咖啡 class Espresso : public Beverage { public: std::string getDescription() const override { return "Espresso"; } double cost() const override { return 1.99; } }; // 装饰器基类 class CondimentDecorator : public Beverage { protected: std::unique_ptr<Beverage> beverage_; // 持有一个饮料对象的引用 public: CondimentDecorator(std::unique_ptr<Beverage> bev) : beverage_(std::move(bev)) {} // getDescription 和 cost 仍然是纯虚函数,留给具体装饰器实现 }; // 具体装饰器:摩卡 class Mocha : public CondimentDecorator { public: Mocha(std::unique_ptr<Beverage> bev) : CondimentDecorator(std::move(bev)) {} std::string getDescription() const override { return beverage_->getDescription() + ", Mocha"; } double cost() const override { return beverage_->cost() + 0.20; } };

使用方式:

auto myDrink = std::make_unique<Espresso>(); myDrink = std::make_unique<Mocha>(std::move(myDrink)); // 加一份摩卡 myDrink = std::make_unique<Mocha>(std::move(myDrink)); // 再加一份摩卡 std::cout << myDrink->getDescription() << " $" << myDrink->cost() << std::endl; // 输出:Espresso, Mocha, Mocha $2.39

优势与陷阱:优势在于可以动态、透明、无限地组合功能,避免了创建大量子类(如EspressoWithMocha,EspressoWithMochaAndWhip等)。在C++中实现,要特别注意对象的所有权转移(如上例使用unique_ptr),确保装饰链上的对象生命周期正确。过度使用装饰器会导致产生大量小对象,增加系统复杂度,调试时调用栈也会很深。

4. 行为型模式:策略、状态与命令

行为型模式主要关注对象之间的职责分配和通信方式。

4.1 策略模式:封装可互换的算法族

定义一系列算法,将每个算法封装起来,并使它们可以互相替换。策略模式让算法的变化独立于使用算法的客户端。最常见的例子就是排序算法、支付方式、折扣计算策略。

C++实现:通常结合函数对象或std::function传统做法是定义一个策略接口和一系列具体策略类。现代C++中,利用模板和可调用对象可以让代码更简洁。

// 传统面向对象方式 class SortStrategy { public: virtual void sort(std::vector<int>& data) const = 0; virtual ~SortStrategy() = default; }; class QuickSort : public SortStrategy { /*...*/ }; class BubbleSort : public SortStrategy { /*...*/ }; class Context { std::unique_ptr<SortStrategy> strategy_; public: void setStrategy(std::unique_ptr<SortStrategy> strat) { strategy_ = std::move(strat); } void executeSort(std::vector<int>& data) { if (strategy_) strategy_->sort(data); } };

更现代、更C++的方式:使用std::function

using SortStrategy = std::function<void(std::vector<int>&)>; void quickSortImpl(std::vector<int>& data) { /* 快速排序实现 */ } void bubbleSortImpl(std::vector<int>& data) { /* 冒泡排序实现 */ } class Context { SortStrategy strategy_; public: void setStrategy(SortStrategy strat) { strategy_ = std::move(strat); } void executeSort(std::vector<int>& data) { if (strategy_) strategy_(data); } }; // 使用 Context ctx; ctx.setStrategy(quickSortImpl); // 传入函数指针 // 或者传入lambda表达式 ctx.setStrategy([](std::vector<int>& data) { std::sort(data.begin(), data.end()); });

这种方式更灵活,策略可以是任何可调用对象(函数、函数对象、lambda、bind表达式等),减少了类的定义,代码更直观。

4.2 状态模式:允许对象在内部状态改变时改变其行为

当一个对象的行为取决于它的状态,并且它需要在运行时根据状态改变行为时,状态模式就把状态逻辑抽象成独立的类,从而消除庞大的条件判断语句(if-else 或 switch-case)。

经典场景:网络连接、订单状态、游戏角色状态( idle, walking, attacking)。没有状态模式时,代码可能是这样的:

class Connection { enum State { CLOSED, LISTENING, ESTABLISHED } state_; public: void open() { if (state_ == CLOSED) { /* ... */ state_ = LISTENING; } else if (state_ == LISTENING) { /* 错误处理 */ } // ... 更多else if } void close() { if (state_ == ESTABLISHED) { /* ... */ state_ = CLOSED; } // ... 更多else if } };

使用状态模式重构后:

// 状态接口 class ConnectionState { public: virtual void open(Connection* context) = 0; virtual void close(Connection* context) = 0; virtual ~ConnectionState() = default; }; // 具体状态类 class ClosedState : public ConnectionState { public: void open(Connection* context) override { // 执行打开连接的具体逻辑... std::cout << "Connection opening from Closed state.\n"; // 状态转移 context->setState(std::make_unique<ListeningState>()); } void close(Connection* context) override { std::cout << "Connection is already closed.\n"; } }; // 上下文类,持有当前状态 class Connection { std::unique_ptr<ConnectionState> state_; public: Connection() : state_(std::make_unique<ClosedState>()) {} void setState(std::unique_ptr<ConnectionState> newState) { state_ = std::move(newState); } void open() { state_->open(this); } void close() { state_->close(this); } };

优势:将每个状态的行为局部化到对应的类中,符合单一职责原则。添加新状态只需增加新的状态类,无需修改上下文或其他状态类,符合开闭原则。注意事项:状态对象通常需要知道上下文(Context)以触发状态转移,这会产生双向依赖。要小心处理状态对象的创建和销毁,如果状态是无状态的(行为不依赖实例变量),可以考虑使用单例模式来共享状态实例。

4.3 命令模式:将请求封装为对象

命令模式将一个请求封装成一个对象,从而使你可以用不同的请求对客户进行参数化,支持请求的排队、记录、撤销/重做等操作。编辑器里的“撤销”(Undo)功能,宏命令,任务队列,都是命令模式的典型应用。

核心角色:

  • Command(命令接口):声明执行操作的接口。
  • ConcreteCommand(具体命令):将一个接收者对象绑定于一个动作,实现execute方法。
  • Invoker(调用者):要求命令执行请求。
  • Receiver(接收者):知道如何实施与执行一个请求相关的操作。
// 接收者:知道如何干活 class Light { public: void turnOn() { std::cout << "The light is ON.\n"; } void turnOff() { std::cout << "The light is OFF.\n"; } }; // 命令接口 class Command { public: virtual void execute() = 0; virtual void undo() = 0; // 支持撤销 virtual ~Command() = default; }; // 具体命令:开灯命令 class TurnOnCommand : public Command { Light& light_; bool wasExecuted_{false}; public: TurnOnCommand(Light& light) : light_(light) {} void execute() override { light_.turnOn(); wasExecuted_ = true; } void undo() override { if (wasExecuted_) { light_.turnOff(); wasExecuted_ = false; } } }; // 调用者:遥控器 class RemoteControl { std::vector<std::unique_ptr<Command>> commandHistory_; public: void pressButton(std::unique_ptr<Command> cmd) { cmd->execute(); commandHistory_.push_back(std::move(cmd)); } void undoLast() { if (!commandHistory_.empty()) { commandHistory_.back()->undo(); commandHistory_.pop_back(); } } };

C++实现要点:命令对象通常需要存储执行操作所需的所有信息,包括接收者对象。可以使用智能指针管理命令的生命周期。为了实现撤销,命令对象可能需要存储执行前的状态(备忘录模式常与此结合)。在需要高性能的场景,可以考虑使用命令池来复用命令对象,避免频繁的动态内存分配。

5. 模式应用中的常见陷阱与性能考量

知道模式怎么写只是第一步,在真实的C++项目里用对、用好,才是关键。这里分享几个我踩过的坑和总结的经验。

5.1 过度设计与模式滥用

这是新手(包括当年的我)最容易犯的错误。看到一个问题,脑子里立刻蹦出三四种模式,然后硬往上套,结果把简单的代码搞得无比复杂。设计模式是解决复杂问题的工具,而不是用来增加复杂度的。

一个简单的判断标准:如果引入一个模式后,代码的行数、类的数量、理解的难度都显著增加了,但带来的灵活性在可预见的未来根本用不上,那就可能是过度设计。KISS原则(Keep It Simple, Stupid)永远优先。很多时候,一个简单的函数、一个清晰的if-else、一个良好的数据结构,比生搬硬套一个模式要有效得多。

5.2 C++特性与模式的结合

现代C++(C++11/14/17/20)提供了很多新特性,可以让一些模式的实现更简洁、更安全、性能更好。

  1. 智能指针是基石:如前所述,在工厂、观察者、组合、命令等模式中,对象所有权和生命周期管理是核心问题。std::unique_ptr(独占所有权)、std::shared_ptr(共享所有权)和std::weak_ptr(打破循环引用)是你的最佳伙伴。它们能极大减少手动new/delete带来的内存泄漏和悬空指针风险。
  2. std::function与策略/命令模式:如前文策略模式所示,std::function可以替代传统的策略接口类,让策略可以是任何可调用对象,代码更灵活、更函数式。
  3. 移动语义与性能:在工厂模式返回对象、命令模式传递命令时,利用移动语义(std::move)可以避免不必要的拷贝,提升性能。
  4. Lambda表达式:它是创建轻量级命令对象或策略的利器。对于一次性使用的简单行为,定义一个完整的类可能太重了,一个lambda就搞定。
    // 使用lambda作为简单命令 auto cmd = []() { std::cout << "Simple command executed.\n"; }; invoker.executeCommand(cmd);

5.3 性能影响分析

使用设计模式通常会带来一定的抽象开销,主要体现在:

  • 虚函数调用:大多数模式(如工厂方法、策略、状态)都依赖多态,这意味着虚函数表查找。在极端性能敏感的热路径(比如每帧调用上万次的循环里),虚函数开销可能需要考虑。这时可以考虑使用CRTP(奇异递归模板模式)这样的静态多态技术来消除虚函数开销,但这会牺牲一些动态灵活性。
  • 对象创建与内存分配:装饰器模式会创建大量小对象,工厂模式也可能频繁创建对象。在实时系统或游戏引擎中,可能需要使用对象池来管理这些对象的生命周期,减少动态内存分配。
  • 间接层增加:每多一层抽象,就多一层间接调用,可能对缓存不友好。在数据导向设计(Data-Oriented Design)中,有时会为了极致性能而避免使用面向对象的设计模式。

基本原则是:不要过早优化。首先用清晰、可维护的模式把结构搭好。当性能分析(Profiling)确实表明某处是瓶颈,并且与模式的使用强相关时,再考虑针对性的优化。99%的情况下,设计模式带来的抽象开销远小于它带来的可维护性收益。

5.4 测试与模式

设计模式,特别是那些引入了较多接口和依赖的模式(如观察者、策略),实际上能让单元测试更容易。因为你可以方便地用模拟对象(Mock)来替换真实的依赖。

  • 测试一个使用策略模式的类,你可以注入一个简单的、行为确定的测试策略。
  • 测试观察者模式中的主题,你可以注入一个模拟观察者来验证通知是否被正确发送。

关键在于,在实现模式时,要依赖接口而非具体类,这本身就是良好设计的原则,也为测试打开了方便之门。使用Google Test、Catch2等测试框架,结合模拟库,可以很好地测试基于模式的代码。

说到底,学习设计模式,学的不是23种固定招式,而是一种设计思维。它训练你在看到代码的“坏味道”(如冗长的条件判断、散落的对象创建、紧耦合的类关系)时,能下意识地想到可能的解决方案。最终目标,是写出易于理解、易于修改、易于测试的代码。在C++的世界里,结合语言的强大能力和这些经过时间考验的设计智慧,你就能构建出既高效又健壮的系统。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询