23种设计模式
设计模式(Design Patterns)是在软件设计过程中,针对特定问题或场景的、经过反复验证的、可复用的解决方案。它们不是具体的代码,而是一套思想、蓝图或最佳实践,可以帮助开发者编写出更易于理解、维护和扩展的代码。
这23种设计模式源于“四人帮”(Gang of Four, GoF)——Erich Gamma、Richard Helm、Ralph Johnson和John Vlissides——合著的经典书籍《设计模式:可复用面向对象软件的基础》。
这些模式根据其目的和范围,被分为三大类:创建型模式、结构型模式和行为型模式。
创建型模式 (Creational Patterns)
工厂方法模式 (Factory Method Pattern)
工厂模式的核心在于将对象的创建过程与使用过程分离。
在没有使用工厂模式的情况下,客户端代码通常需要这样创建对象:
1 | // 客户端代码直接依赖具体的实现类 |
同时, 在上面的情况中实体类的使用者必须知道实际的子类名称,以及会使程序的扩展性和维护变得越来越困难。
工厂模式通过引入一个“工厂”来解决这个问题。客户端不再直接 new 对象,而是向工厂请求一个对象, 从而实现了下面两大好处: - 封装对象的创建,避免客户端直接依赖具体类。 - 具体化类的工作延迟到了子类中。
1 | // 抽象产品:咖啡 |
理解将“对象的选择和创建”与“对象的使用”分离
在上面的示例代码中, 为了得到一杯拿铁,new Latte() 其实比 new LatteFactory() 然后再 createCoffee() 要简单直接得多。
那么,为什么我们还要推崇工厂模式呢?
关键在于,软件设计的优劣,看的不是在 main 函数里写几行测试代码的复杂度,而是在大型、复杂、需要长期维护和扩展的系统中的表现, 这就是工厂模式的核心: 将“对象的选择和创建”与“对象的使用”分离。
在上面的例子中,“决定要一杯拿铁”和“使用这杯拿铁”这两件事都发生在同一个地方(main 函数)。但在真实项目中,这两件事通常发生在系统的不同模块、不同层次。
使用方 (Client):这是系统的业务逻辑部分。它只关心“我需要一个Coffee对象”,然后调用它的show()方法。它不应该,也不想关心这个Coffee对象具体是Latte还是Americano。使用方依赖的是抽象。
创建方 (Creator/Assembler):这是系统的配置或初始化部分。它的职责是根据某些条件(如配置文件、用户输入、环境变量等)来决定到底应该创建哪一个具体的工厂。创建方负责处理具体实现。
一个更实际的场景是,
假设我们正在开发一个咖啡店点单系统。系统的启动逻辑(main函数或某个初始化模块)会读取一个配置文件
config.txt, 其内容可能是CoffeeType = Latte
那么,系统的初始化代码和业务端的代码会是这样的:
1 |
|
现在,我们来看看这样做的好处:
客户端(run_business_logic函数)完全解耦:它只认识抽象的 CoffeeFactory 和 Coffee。它根本不知道 Latte 或 LatteFactory 的存在。你可以把这个函数打包成一个库给别人用,别人只需要提供一个 CoffeeFactory 的实现就能工作。
易于扩展:现在我想增加一种“卡布奇诺”(Cappuccino)。我需要做什么?
创建 Cappuccino 类和 CappuccinoFactory 类。
只需要在 initialize_factory_from_config 函数里增加一个 else if (type == “Cappuccino”) 的分支。
run_business_logic 函数的代码一行都不用改! 这就是开闭原则的体现。
如果不用工厂模式,run_business_logic 里面就必须写 if/else 来 new 不同的咖啡,那么每次增加新品种,都得修改这个核心业务函数。
工厂模式通过牺牲一点点“初始的简单性”(需要多定义几个工厂类),换来了整个系统长期的解耦、灵活性和可维护性, 使得客户端不必了解它要使用的具体类,只需要”无脑”地通过工厂接口来获取对象, 从而使得高层模块(业务逻辑)不依赖于底层模块(具体实现)而是依赖于抽象。
另一方面, 工厂模式也能封装复杂的创建过程, 因为有时候,创建一个对象不仅仅是 new 一下那么简单。可能需要从数据库获取配置信息, 检查库存, 初始化多个内部组件, 记录创建日志等, 这些复杂的逻辑如果散落在客户端代码的各个角落,将是一场维护的噩梦。 而使用工厂模式,就可以把这些复杂的逻辑全部封装在 LatteFactory::createCoffee() 方法里。客户端只需要简单地调用 factory->createCoffee(),代码会变得非常干净。
抽象工厂模式 (Abstract Factory Pattern)
工厂方法模式是针对一个产品的创建,而抽象工厂模式是它的升级版:它处理的是一族(或一个系列)相互关联的产品。因此,它也被称为“工厂的工厂”(Factory of Factories)。
抽象工厂模式的核心目的是提供一个接口,用于创建一系列相关或相互依赖的对象,而无需指定它们具体的类。
想象一下,我们不只是要生产一杯咖啡,而是要生产一整套风格统一的“下午茶套餐”,这个套餐里包括:一杯饮品 (Drink)和一份甜点 (Dessert). 现在有两种风格的套餐:
意式风情 (Italian Style):包含 Espresso (浓缩咖啡) 和 Tiramisu (提拉米苏)。
美式风情 (American Style):包含 Americano (美式咖啡) 和 Cheesecake (芝士蛋糕)。
这里的关键点是:Espresso 必须和 Tiramisu 搭配,Americano 必须和 Cheesecake 搭配。你不能把 Espresso 和 Cheesecake 混在一个套餐里,这样会破坏风格的一致性。
抽象工厂模式就是为了解决这个问题而生的。它能确保你创建的所有产品都属于同一个“产品族”,从而保证它们之间是相互兼容和匹配的。
结构组成
抽象工厂模式的结构比工厂方法模式更复杂一些,它包含以下角色:
AbstractFactory (抽象工厂):声明一组用于创建不同抽象产品的方法。例如,createDrink() 和 createDessert()。
ConcreteFactory (具体工厂):实现抽象工厂的接口,负责创建一族具体的产品。例如,ItalianDessertFactory 会实现 createDrink() 来返回 Espresso,实现 createDessert() 来返回 Tiramisu。
AbstractProduct (抽象产品):为某一类产品声明接口。例如,Drink 接口和 Dessert 接口。
ConcreteProduct (具体产品):实现抽象产品的接口,是由具体工厂创建的实例。例如,Espresso, Tiramisu, Americano, Cheesecake。
Client (客户端):使用抽象工厂和抽象产品的接口。客户端只与抽象层交互,从而与具体的产品实现解耦。

示例代码
上面情境的代码实现如下:
1 |
|
这里的client_code 函数是我们的使用方。它只认识抽象的 DessertFactory,并用它来创建 Drink 和 Dessert。它完全不知道 ItalianDessertFactory 或 Espresso 的存在。
在 main 函数中,我们作为配置方,决定了今天是用 ItalianDessertFactory 还是 AmericanDessertFactory。一旦工厂被选定并传入 client_code,客户端创建的所有产品(饮品和甜点)都保证是同一个风格(产品族)的,实现了风格的统一和强制约束。
对比上面的工厂方法模式,如果你的系统只需要根据不同情况创建不同版本的同一种对象,用工厂方法。
如果你的系统需要创建一整套对象,并且要保证这套对象互相兼容、风格统一,用抽象工厂。
单例模式 (Singleton Pattern)
单例模式 (Singleton Pattern)是 GoF 23种设计模式中最著名也最简单的一种,但同时也是在实际应用中充满争议的一种。
单例模式的核心目的是:确保一个类在任何情况下都绝对只有一个实例,并提供一个全局访问点来获取这个唯一的实例。换句话说,单例模式做了三件事:
- 保证唯一性:一个类只能创建一个对象。
- 自我创建:这个唯一的对象由类自己来创建。
- 提供全局访问:必须提供一个公共的方法,让系统中的任何其他代码都能访问到这个对象。
而要在 C++ 中实现一个单例类,必须满足以下几个关键条件,以“堵死”所有可能创建多个实例的途径:
私有化构造函数 (Private Constructor):阻止外部代码通过 new Singleton() 的方式自由地创建实例。只有类自己内部才能调用构造函数。
禁用拷贝构造函数和赋值运算符 (Delete Copy Constructor & Assignment Operator):防止通过拷贝 Singleton s2 = s1; 或赋值 s2 = s1; 的方式创建出新的实例副本。在 C++11 及以后,通常使用 = delete; 关键字来明确禁用它们。
提供一个静态的公共访问方法 (Public Static getInstance Method):这是外界获取唯一实例的唯一途径。通常命名为 getInstance() 或 instance()。
在类内部持有一个静态的私有实例指针/对象 (Private Static Instance):用于保存那个独一无二的实例。
C++ 中的实现方式
单例模式的实现有多种变体,主要区别在于实例化的时机(“懒汉式” vs “饿汉式”)和线程安全性。
懒汉式 (Lazy Initialization) - 非线程安全 这种方式在第一次调用 getInstance() 时才创建实例,比较“懒惰”。
这种实现的优点是实现了延迟加载,只有在需要时才创建实例,节省了资源。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
class Singleton {
private:
// 1. 私有化构造函数
Singleton() {
std::cout << "单例对象已创建" << std::endl;
}
// 4. 持有静态私有实例指针
static Singleton* m_instance;
public:
// 2. 禁用拷贝和赋值
Singleton(const Singleton&) = delete;
Singleton& operator=(const Singleton&) = delete;
// 3. 提供静态公共访问方法
static Singleton* getInstance() {
// 步骤说明:只有当实例不存在时,才进行创建。
if (m_instance == nullptr) {
m_instance = new Singleton();
}
return m_instance;
}
};
// 在类外初始化静态成员
Singleton* Singleton::m_instance = nullptr;
// 客户端代码
int main() {
Singleton* s1 = Singleton::getInstance();
Singleton* s2 = Singleton::getInstance();
if (s1 == s2) {
std::cout << "s1 和 s2 是同一个实例。" << std::endl;
}
// 注意:这种简单的实现需要手动释放内存,否则会造成内存泄漏。
// delete s1; // 但这又会带来新的问题,比如悬挂指针
return 0;
}不过缺点是线程不安全:在多线程环境下,两个线程可能同时进入 if (m_instance == nullptr) 判断,导致创建出两个实例。
而且也存在内存泄漏风险:需要手动管理 new 出来的内存,容易忘记释放。
线程安全的懒汉式 (使用 std::mutex 或 std::call_once) 为了解决线程安全问题,一个常见的思路是加锁。
这种方式通过互斥锁保证了在多线程环境下只有一个线程能创建实例,解决了线程安全问题。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
class Singleton {
private:
Singleton() {}
static Singleton* m_instance;
static std::mutex m_mutex; // 引入互斥锁
public:
Singleton(const Singleton&) = delete;
Singleton& operator=(const Singleton&) = delete;
static Singleton* getInstance() {
// 使用双重检查锁定 (Double-Checked Locking) 优化性能
if (m_instance == nullptr) {
std::lock_guard<std::mutex> lock(m_mutex); // 自动加锁和解锁
if (m_instance == nullptr) {
m_instance = new Singleton();
}
}
return m_instance;
}
};
Singleton* Singleton::m_instance = nullptr; // 初始化静态成员
std::mutex Singleton::m_mutex;但缺点是每次调用 getInstance() 都需要加锁,可能会影响性能。
Meyers’ Singleton(懒汉式, C++11 及以后) 这种方式利用了 C++11 引入的局部静态变量的线程安全特性,实现了简单且高效的单例模式。
这种实现方式非常简洁,实现了延迟加载, 不需要手动管理内存(因为保存在静态存储区, 其生命周期由程序管理,会在程序结束时自动析构),也不需要显式加锁,性能较好。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20class Singleton {
private:
Singleton() {}
public:
Singleton(const Singleton&) = delete;
Singleton& operator=(const Singleton&) = delete;
// 步骤说明:返回一个静态局部对象的引用。
// C++11标准保证,这个静态局部对象的初始化是懒惰的、线程安全的,
// 且在程序结束时会自动销毁。
static Singleton& getInstance() {
static Singleton instance; // 核心!
return instance;
}
void showMessage() {
std::cout << "Hello from Singleton!" << std::endl;
}
};饿汉式 (Eager Initialization) 这种方式在程序启动时就创建实例,适用于实例创建开销不大且一定会被使用的场景, 通常是程序开始执行 main 函数之前,作为静态成员变量初始化的一部分。
饿汉式的优点是实现简单,线程安全(这是它最大的优点。因为实例是在程序启动阶段、进入多线程环境之前,由主线程在静态初始化阶段创建的。当后续多个线程调用 getInstance() 时,它们只是在读取一个已经被初始化的变量,不存在竞争条件(Race Condition),因此完全不需要加锁。)1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34class Singleton {
private:
// 1. 私有化构造函数
Singleton() {
std::cout << "单例对象已创建" << std::endl;
}
// 4. 持有静态私有实例对象
static Singleton m_instance;
public:
// 2. 禁用拷贝和赋值
Singleton(const Singleton&) = delete;
Singleton& operator=(const Singleton&) = delete;
// 3. 提供静态公共访问方法
static Singleton& getInstance() {
return m_instance;
}
};
// 在类外初始化静态成员
Singleton Singleton::m_instance;
// 客户端代码
int main() {
Singleton& s1 = Singleton::getInstance();
Singleton& s2 = Singleton::getInstance();
if (&s1 == &s2) {
std::cout << "s1 和 s2 是同一个实例。" << std::endl;
}
return 0;
}缺点是无法延迟加载,如果实例创建开销较大且不一定会被使用,可能会浪费资源。 > 还有一个值得注意的风险是“静态初始化顺序灾难” (Static Initialization Order Fiasco):在复杂的 C++ 项目中,如果一个静态对象(饿汉式单例)的构造函数依赖于另一个不同编译单元(.cpp 文件)中的静态对象,C++ 标准不保证它们的初始化顺序。这可能导致在构造单例时,其依赖的另一个对象还未被创建,从而引发程序崩溃。
当开发环境是 C++11 或更高版本时,强烈推荐使用 Meyers’ Singleton(即函数内静态变量的实现方式),因为它完美结合了懒汉式的优点(延迟加载)和饿汉式的优点(实现简单、线程安全)。
单例模式的优缺点
尽管单例模式很常用,但它也像一个“全局变量”,因此备受争议。
优点:
资源共享与控制:确保了对唯一实例的受控访问,可以严格控制客户怎样以及何时访问它。
节省资源:由于只有一个实例,避免了对资源的多重占用。
延迟加载:懒汉式实现可以在需要时才创建对象。
缺点:
违反单一职责原则:一个类既要负责其核心业务逻辑,又要负责保证自己是单例,职责不单一。
难以测试:单例模式引入了全局状态,使得单元测试变得困难。因为测试用例之间会相互影响,无法轻松地用一个模拟(mock)对象来替换单例实例。
扩展性差:单例的实现是硬编码的,如果想扩展成一个类允许有N个实例,就需要大幅修改代码。
隐藏依赖:代码的调用方可能不知道自己依赖了一个全局的单例对象,使得模块间的耦合关系变得不明确。
总之, 单例模式是一个强大的工具,但应谨慎使用。在确实需要一个全局唯一的对象(如全局配置、日志服务)时,它非常有用。但在其他情况下,最好优先考虑依赖注入(Dependency Injection)等其他方案来管理对象的生命周期和依赖关系。
依赖注入(Dependency Injection, DI)
依赖注入(Dependency Injection, DI)是一种设计原则,用于实现控制反转(Inversion of Control, IoC)。它通过将对象的依赖关系从内部创建转移到外部提供,从而提高代码的灵活性和可测试性。
与单例模式相比,依赖注入允许更灵活地管理对象的生命周期和作用域,避免了全局状态带来的问题。
什么是依赖
为了理解依赖注入,我们首先要理解什么是“依赖”。
- 依赖 (Dependency):如果类 A 的一个方法中使用了类 B 的一个实例,那么我们就说类 A 依赖于类 B。
传统方式 (没有依赖注入):在传统的编程模式中,一个对象通常会自己负责创建它所依赖的对象。
1 | // 日志类 |
高耦合:Car 类和 Engine、Logger 这两个具体类焊死在了一起。如果我想给 Car 换一个依赖类 V8Engine (V8引擎),或者换一个 FileLogger (记录到文件的日志),我必须修改 Car 类的源代码。这违反了开闭原则。
难以测试:我想对 Car 类进行单元测试,但我不想在测试时真的启动一个复杂的 Engine 对象。我希望能用一个假的 MockEngine 来代替。在上面的代码中,这几乎是不可能的,因为 Car 自己锁死了 Engine 的创建。
依赖注入的实现方式
依赖注入的核心思想正好相反:一个对象不应该自己创建它所依赖的对象,而应该由外部的“容器”或“框架”来创建这些依赖,并通过某种方式“注入”给它。对象的控制权发生了反转:
- 之前:对象自己控制、创建依赖。
- 现在:对象的依赖由外部控制和提供。 这就是控制反转 (IoC)。
注入依赖的主要方式是以下两种:
构造函数注入 (Constructor Injection): 这是最常用、也是最推荐的一种方式。依赖通过类的构造函数传入。
注意这里从原先的构造函数自己创建了具体类, 变成了构造函数接收外部的抽象类, 从而实现了依赖抽象+控制反转, 通过构造函数的参数,从外部接收一个已经创建好的 IEngine 实例。创建依赖的控制权被反转给了外部的调用者(组装层)。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56// ===== 首先,我们依赖于抽象而非具体 =====
class IEngine { // 引擎接口
public:
virtual void start() = 0;
virtual ~IEngine() {}
};
class ILogger { // 日志接口
public:
virtual void log(const std::string& message) = 0;
virtual ~ILogger() {}
};
// ===== 具体的实现 =====
class V6Engine : public IEngine { ... }; // V6引擎
class FileLogger : public ILogger { ... }; // 文件日志
// ===== 汽车类 (Car) 通过构造函数接收依赖 =====
class Car {
private:
IEngine* m_engine;
ILogger* m_logger;
public:
// 步骤说明:依赖关系通过构造函数的参数被“注入”进来。
// Car类不再关心engine和logger是如何被创建的,它只知道自己需要它们。
Car(IEngine* engine, ILogger* logger)
: m_engine(engine), m_logger(logger) {
if (!m_engine || !m_logger) {
throw std::invalid_argument("Engine and Logger must not be null.");
}
}
void drive() {
m_logger->log("开始驾驶");
m_engine->start();
}
};
// ===== “组装层” 或 “配置层” (main函数) =====
int main() {
// 步骤说明:对象的创建和组装工作由外部完成。
// 这里是整个系统的配置中心。
V6Engine myEngine;
FileLogger myLogger;
// 将具体的依赖注入到Car中
Car myCar(&myEngine, &myLogger);
myCar.drive();
// 如果我想换一个V8引擎和控制台日志,只需要改变这里即可
// V8Engine v8;
// ConsoleLogger consoleLogger;
// Car mySuperCar(&v8, &consoleLogger);
// mySuperCar.drive();
}优点:
依赖明确:构造函数清晰地表明了这个类“必须拥有”哪些依赖才能正常工作。
保证不变性:一旦对象被创建,其核心依赖就无法被更改,状态更加稳定。
缺点:如果依赖项过多,构造函数会变得很长。
Setter 注入 (Setter Injection): 通过公共的 setter 方法将依赖注入到对象中。这种方式相对灵活,但可能导致对象在创建后处于不完整状态。
通过公开的 set 方法来注入依赖。这种方式通常用于可选的依赖。 优点:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31class Car {
private:
IEngine* m_engine = nullptr; // 必须的依赖
ILogger* m_logger = nullptr; // 可选的依赖
public:
// 必须的依赖仍然通过构造函数注入
Car(IEngine* engine) : m_engine(engine) {}
// 步骤说明:为可选的依赖提供一个Setter方法。
void setLogger(ILogger* logger) {
m_logger = logger;
}
void drive() {
// 步骤说明:在使用可选依赖前,最好进行检查。
if (m_logger) {
m_logger->log("开始驾驶");
}
m_engine->start();
}
};
int main() {
V6Engine myEngine;
FileLogger myLogger;
Car myCar(&myEngine); // 先创建Car对象
myCar.setLogger(&myLogger); // 然后注入可选的依赖
myCar.drive();
}灵活性高:可以在对象的生命周期内随时更改依赖。
解决了构造函数参数过多的问题。
缺点:
对象可能处于一个“不完整”的状态(比如忘记调用 setLogger)。
依赖关系被隐藏在代码内部,不如构造函数注入那样一目了然。
总结
依赖注入的巨大优势在于: - 降低耦合度 (Decoupling): 这是最核心的优势。类不再依赖于具体的实现,而是依赖于抽象(接口)。这使得替换具体实现变得轻而易举,大大提高了代码的灵活性和可维护性。
极大地提升了可测试性 (Testability): 在单元测试中,我们可以轻松地创建一个“模拟对象”(Mock Object),并将其注入到被测试的类中。这样,我们就可以在完全隔离的环境下测试一个类的逻辑,而不用担心其依赖项的干扰。
促进并行开发 (Parallel Development): 团队成员可以先定义好接口,然后各自独立地开发实现这些接口的具体组件。只要接口不变,大家就可以并行工作,最后再通过依赖注入将它们“组装”在一起。
集中管理配置: 对象的创建和依赖关系的管理被集中到了一个或少数几个地方(“组装层”)。这使得整个应用的配置和结构一目了然,易于管理和修改。
依赖注入不是目的,而是手段。 它的最终目的是为了构建一个低耦合、高内聚、易于测试和维护的软件系统。
简单来说,就是把“我需要什么,我自己去拿”的思维方式,转变为“我需要什么,你给我什么,我就用什么”的思维方式。这种控制权的反转,是现代软件架构设计的基石之一。
