Skip to content

C++ 设计模式入门:设计原则与常用模式实现 ​

设计模式是可复用面向对象软件的基础,但背UML图意义不大——每个模式都是在回答「什么在变化,如何隔离这个变化」。本文先给出六大设计原则与模式分类作为地图,然后逐个展开六个常用模式:观察者、装饰、工厂方法、抽象工厂、单例、职责链,每个都从动机讲起,配 C++ 代码与类图。适合有一定 C++ 基础、想系统理解设计模式动机的读者。

六大设计原则 ​

原则是模式的底层依据,比模式本身更稳定:

  1. 单一职责原则:就一个类而言,应该仅有一个引起它变化的原因。
  2. 开放封闭原则:软件实体可以扩展,但是不可修改。面对新需求,通过增加代码完成改动,而不是修改现有代码。
  3. 里氏代换原则:使用基类的地方一定适用于其派生类——把基类替换成派生类,程序行为不应变化。
  4. 依赖倒转原则:抽象不应该依赖细节,细节应该依赖抽象。针对接口编程,不针对实现编程。
  5. 迪米特原则:两个类不直接通信,就不应发生直接的相互作用;需要调用时可通过第三个类转发。
  6. 接口隔离原则:接口中不应存在派生类用不到却必须实现的方法;否则应将接口拆分。

模式分类 ​

经典的 GoF 23 个模式按目的分为三类:

类别 模式
创建型 单例、工厂方法、抽象工厂、建造者、原型
结构型 适配器、桥接、外观、组合、装饰、享元、代理
行为型 责任链、命令、解释器、迭代器、中介者、备忘录、观察者、状态、策略、模板方法、访问者

另一个有价值的视角是从封装变化的方向分类:组件协作(观察者、模板方法、策略)、单一职责(装饰、桥接)、对象创建(工厂方法、抽象工厂、原型)、对象性能(单例、享元)。同一个模式在不同视角下归入不同类别,说明分类只是理解工具,不是目的。

观察者模式 ​

动机 ​

软件中常存在「通知依赖关系」:一个对象(目标)状态改变,所有依赖对象(观察者)都要得到通知。如果目标直接依赖观察者的具体实现(比如进度通知直接依赖某个进度条控件类),这种紧密依赖使软件难以应对展现方式的变化。观察者模式用抽象的通知接口把这种依赖弱化为稳定关系,实现松耦合。

定义 ​

定义对象间一种一对多的依赖关系,当一个对象(Subject)的状态发生改变时,所有依赖于它的对象都得到通知并自动更新。——GoF

结构 ​

例子:文件分割器的进度通知 ​

把一个大文件分割为几个小文件,需要展示进度。直接做法是在分割器类中持有具体的通知控件(如 ProgressBar):

  • 违背依赖倒置原则——分割器的核心逻辑依赖了进度条这一细节;
  • 进度的展现方式可能变化:GUI 控件、控制台输出、日志,每换一种展现都要改分割器。

观察者模式的做法:

  1. 定义抽象通知接口 IProgress,分割器只依赖这个接口;
  2. 观察者类继承 IProgress,在自己的 DoProgress 实现里更新具体控件;
  3. 有多个观察者时,目标维护观察者列表,提供 add/remove 方法。

要点 ​

  • 目标发送通知时无需指定观察者,通知(可携带信息作为参数)自动传播。
  • 观察者自己决定是否订阅,目标对象对此一无所知,二者可以独立变化。
  • 观察者模式是基于事件的 UI 框架中最常用的设计模式之一,也是 MVC 的重要组成部分。

装饰模式 ​

动机 ​

用继承扩展对象功能是静态的:扩展维度一多,各种功能组合会导致子类数量急剧膨胀。例如 IO 流有文件流、网络流、内存流三种主体,再各派生加密流、缓冲流,子类就是三乘二再加组合。装饰模式改用「组合 + 运行时装配」:主体类只管业务操作,扩展操作持有主体对象,在调用前后添加额外行为。

定义 ​

动态(组合)地给一个对象增加一些额外的职责。就增加功能而言,装饰模式比生成子类(继承)更为灵活。——GoF

结构 ​

装饰类在接口上是 is-a Component(继承同一接口),在实现上是 has-a Component(持有被装饰对象):

代码 ​

cpp
// 业务操作
class Stream {
public:
    virtual char Read(int number) = 0;
    virtual void Seek(int position) = 0;
    virtual void Write(char data) = 0;
    virtual ~Stream() {}
};

// 主体类
class FileStream : public Stream {
public:
    virtual char Read(int number)  { /* 读文件流 */ }
    virtual void Seek(int position){ /* 定位文件流 */ }
    virtual void Write(char data)  { /* 写文件流 */ }
};

class NetworkStream : public Stream {
public:
    virtual char Read(int number)  { /* 读网络流 */ }
    virtual void Seek(int position){ /* 定位网络流 */ }
    virtual void Write(char data)  { /* 写网络流 */ }
};

class MemoryStream : public Stream {
public:
    virtual char Read(int number)  { /* 读内存流 */ }
    virtual void Seek(int position){ /* 定位内存流 */ }
    virtual void Write(char data)  { /* 写内存流 */ }
};

// 扩展操作:共同持有的 Stream* 成员上提到中间类
class DecoratorStream : public Stream {
protected:
    Stream* stream;
    DecoratorStream(Stream* stm) : stream(stm) {}
};

class CryptoStream : public DecoratorStream {
public:
    CryptoStream(Stream* stm) : DecoratorStream(stm) {}
    virtual char Read(int number) {
        // 额外的加密操作...
        return stream->Read(number);
    }
    virtual void Seek(int position) {
        stream->Seek(position);
        // 额外的加密操作...
    }
    virtual void Write(char data) {
        // 额外的加密操作...
        stream->Write(data);
        // 额外的加密操作...
    }
};

class BufferedStream : public DecoratorStream {
public:
    BufferedStream(Stream* stm) : DecoratorStream(stm) {}
    // 同样在转发前后添加缓冲逻辑
};

void Process() {
    // 运行时装配:主体与扩展自由组合
    FileStream* s1 = new FileStream();
    CryptoStream* s2 = new CryptoStream(s1);       // 加密文件流
    BufferedStream* s4 = new BufferedStream(s2);   // 又加缓冲
}

要点 ​

  • 装饰类接口上继承 Component、实现上组合 Component,这是它比继承灵活的来源。
  • 装饰模式解决的是「主体类在多个方向上的扩展功能」,而不是单纯减少子类数量。
  • 三个主体类加两种扩展,继承需要 3×2 个子类且代码重复;装饰只需要 3 + 2 个类。

工厂方法模式 ​

动机 ​

new 具体类型是编译期绑定:要创建的具体类型一变,客户程序就得改。工厂方法把「实例化哪个类」延迟到工厂子类决定,隔离对象使用者与具体类型。

定义 ​

定义一个用于创建对象的接口,让子类决定实例化哪一个类。工厂方法使一个类的实例化延迟到子类(目的:解耦,手段:虚函数)。——GoF

结构 ​

例子:文件分割器 ​

需求变化:要支持多种文件(二进制、文本、图片、视频)。直接在主界面 MainForm 里 new BinarySplitter(),主界面就依赖了具体类,违背依赖倒置原则。

工厂方法的做法:定义分割器工厂基类 SplitterFactory,声明 CreateSplitter 接口;每个具体分割器对应一个具体工厂。MainForm 构造时接收工厂基类指针,需要分割器时调用 CreateSplitter——主界面只认识两个抽象类,新增文件类型只需新增一对「具体分割器 + 具体工厂」。

要点 ​

  • 解决「单个对象」的创建变化,代价是每种具体类都要配套一个工厂类。
  • 把选择判断移到客户端(配置、注入处),符合开放封闭原则。
  • 要求各类的创建方法签名相同。

抽象工厂模式 ​

动机 ​

经常面临的不是单个对象的变化,而是「一系列相互依赖的对象」:比如数据库访问中的连接、命令、读取器三者必须同属一个系列(都是 SQL 或都是 Oracle)。用三个独立的工厂方法,系列一致性约束就散落在调用方。抽象工厂把一系列相关对象的创建合并到同一个工厂接口。

定义 ​

提供一个接口,让该接口负责创建一系列相关或者相互依赖的对象,无需指定它们具体的类。——GoF

结构 ​

要点 ​

  • 「系列对象」指某一特定系列内部相互依赖、相互配合的对象,不同系列之间不能混用。
  • 抽象工厂应对「新系列」的变动很方便(新增一个工厂类);应对「新对象种类」的变动困难(所有工厂都要改)。
  • 没有多系列需求时,不必上抽象工厂,简单工厂即可。

单例模式 ​

动机 ​

有些类(配置中心、日志系统、资源管理器)必须保证全局只有一个实例,且这个约束应由类设计者保证,而不是寄希望于使用者自律。

定义 ​

保证一个类仅有一个实例,并提供一个访问它的全局访问点。——GoF

结构与实现 ​

单例的写法演进本身就记录了多线程知识的发展:

cpp
class Singleton {
private:
    Singleton();
    Singleton(const Singleton& other);
public:
    static Singleton* getInstance();
    static Singleton* m_instance;
};

Singleton* Singleton::m_instance = nullptr;

// 版本一:线程非安全
Singleton* Singleton::getInstance() {
    if (m_instance == nullptr) {
        m_instance = new Singleton();
    }
    return m_instance;
}

// 版本二:线程安全,但每次访问都加锁,代价过高
Singleton* Singleton::getInstance() {
    Lock lock;
    if (m_instance == nullptr) {
        m_instance = new Singleton();
    }
    return m_instance;
}

// 版本三:双检查锁(DCLP)——仍有隐患
Singleton* Singleton::getInstance() {
    if (m_instance == nullptr) {
        Lock lock;
        if (m_instance == nullptr) {
            m_instance = new Singleton();
        }
    }
    return m_instance;
}

// 版本四:C++11 std::atomic 实现的 DCLP,跨平台正确
std::atomic<Singleton*> Singleton::m_instance;
std::mutex Singleton::m_mutex;

Singleton* Singleton::getInstance() {
    Singleton* tmp = m_instance.load(std::memory_order_relaxed);
    std::atomic_thread_fence(std::memory_order_acquire);
    if (tmp == nullptr) {
        std::lock_guard<std::mutex> lock(m_mutex);
        tmp = m_instance.load(std::memory_order_relaxed);
        if (tmp == nullptr) {
            tmp = new Singleton;
            std::atomic_thread_fence(std::memory_order_release);
            m_instance.store(tmp, std::memory_order_relaxed);
        }
    }
    return tmp;
}

版本三的隐患在于编译器的内存读写重排:new Singleton() 实际是「分配内存、构造对象、赋值指针」三步,重排后指针可能先于构造完成被赋值,另一个线程随即拿到未构造完的对象。C++11 之前这一点无法跨平台修复(volatile 不是线程同步原语,不解决重排问题);C++11 之后既可以用 acquire/release 语义的原子操作修复 DCLP(版本四),也有一个更简单的选择——

cpp
static Singleton& getInstance() {
    static Singleton instance;  // C++11 起局部静态变量初始化线程安全
    return instance;
}

C++11 标准保证:多线程并发进入局部静态变量的初始化时,后到的线程会等待初始化完成。这就是 Meyers 单例,无锁、无重排问题,是现代 C++ 的首选写法。

要点 ​

  • 构造函数私有,拷贝构造与 Clone 接口一律删除——否则唯一实例约束形同虚设。
  • 实例构造器可设为 protected 以允许子类派生。
  • 多线程环境优先用局部静态变量(C++11 起)或原子操作 DCLP,不要裸用双检查锁,更不要指望 volatile。

职责链模式 ​

动机 ​

一个请求可能有多个候选处理者,但每个请求最终只有一个真正的接收者。若发送者显式指定接收者,二者紧耦合,接收者变化会波及发送者。职责链让多个对象串成链,请求沿链传递,直到有一个对象处理它为止。

定义 ​

使多个对象都有机会处理请求,从而避免请求的发送者和接收者之间的耦合关系。将这些对象连成一条链,并沿着这条链传递请求,直到有一个对象处理它为止。——GoF

结构 ​

要点 ​

  • 适用于「多个候选接收者、最终只有一个处理」的场景,发送者与接收者解耦。
  • 各处理类只认识自己的后继,链的结构可以在运行时组装。
  • 在现代框架里,这一模式常被事件系统、中间件机制以其他形式实现,独立的职责链类结构已较少直接使用。

结语 ​

六个模式对应六种变化:观察者隔离「通知关系」的变化,装饰隔离「功能扩展方向」的变化,工厂方法与抽象工厂隔离「创建类型」的变化,单例约束「实例数量」,职责链隔离「请求接收者」的变化。遇到模式先问「它在封装哪个变化」,比记住结构图更有用;六大原则(尤其是依赖倒置与开放封闭)是所有模式共同的底层逻辑。

最近更新

基于 VitePress 构建