创建型设计模式分析

一. 前言

  设计模式,究其本质,无非是发现变化、封装变化。而对于面向对象编程来说,变化本身分为了创建对象、对象之间的关系、对象之间的调用,即为创建型、结构性、行为型设计模式。本文就创建型设计模式进行分析,洞察其背后的逻辑,并比较各种创建型设计模式的区别和使用场景。

  为方便比较,创建型行为模式的几种我们采取同一个例子,即GOF一书中的迷宫创建为例。迷宫的创建至少包括了房间Room类、墙Wall类、门Door类几种组成元素和迷宫总体的表示Maze类,更合理的,我们可以为房间、墙、门抽象一个迷宫基本构件MapSite类。

  首先定义方向

1
2
3
4
5
6
7
enum Direction
{
North,
South,
East,
West
};

  基本构件MapSite需要的功能是可以进入

1
2
3
4
5
class MapSite
{
public:
virtual void Enter() = 0;
}

  房间联系门和墙

1
2
3
4
5
6
7
8
9
10
11
12
13
class Room : public MapSite
{
public:
Room(int nRoomID);

MapSite* GetSide(Direction) const;
void SetSide(Direction, MapSite*);

virtual void Enter();

private:
MapSite* m_sides[4];
}

  门和墙如下所示

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Wall : public MapSite
{
public:
Wall();
~Wall();

virtual void Enter();
};

class Door : public MapSite
{
public:
Door(Room* pOneSide, Room* pOtherSide);
virtual void Enter();
Room* GetOtherSide(Room* pNowRoom);

private:
Room* m_Room[2];
bool m_bOpen;
}

  迷宫类Maze由一系列的Room组成

1
2
3
4
5
6
7
8
9
10
11
12
class Maze
{
public:
Maze();
~Maze();

void AddRoom(Room* pRoom);
Room* GetRoom(int nIndex);

private:
std::map<int, Room*> m_RoomMap;
};

  最后定义游戏主类MazeGame

1
2
3
4
5
6
7
8
9
10
11
class MazeGame
{
public:
MazeGame();
~MazeGame();

void Init();
void UnInit();

Maze* CreateMazeNormal();
};

  假设我们想创建一个有两个房间的迷宫,则可以简单的写成如下形式

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
Maze* MazeGame::CreateMazeNormal()
{
Maze* pMaze = NULL;
Room* pRoom1 = NULL;
Room* pRoom2 = NULL;
Door* pDoor = NULL;

pMaze = new Maze();
pRoom1 = new Room(1);
pRoom2 = new Room(2);
pDoor = new Door(pRoom1, pRoom2);

pMaze->AddRoom(pRoom1);
pMaze->AddRoom(pRoom2);

pRoom1->SetSide(North, new Wall);
pRoom1->SetSide(East, pDoor);
pRoom1->SetSide(South, new Wall);
pRoom1->SetSide(West, new Wall);

pRoom2->SetSide(North, new Wall);
pRoom2->SetSide(East, new Wall);
pRoom2->SetSide(South, new Wall);
pRoom2->SetSide(West, pDoor);

return pMaze;
}

  这样的代码在简单示例中可以跑起来,但是放在工作中或者大项目,是不合格的,因为其极为不灵活:对布局的硬编码意味着每一次调整迷宫布局都需要修改代码,而每一次增加迷宫新功能、新元素,都得全部重来一次,扩展性极差而且难以维护。本文将使用以下几种创建型模式逐个解决该问题,并进行比较。其中单例模式例外,会放在最后进行示例说明。

二. 单例模式(Singleton Design Pattern)

  单例模式是一种常见的经典模式。关于单例模式,最重要的是理解以下几点

  • 为什么要使用单例模式
  • 何时使用单例模式
  • 单例模式的优缺点
  • 单例模式和静态类的区别
  • 单例模式的替代选择

2.1 定义及使用场景

  一个类只允许创建一个对象(或者实例),那这个类就是一个单例类,这种设计模式就叫作单例设计模式,简称单例模式。单例模式常见于需要保持全局唯一的类,如

  • 日志模块
  • 唯一序列号生成
  • 共享访问点、访问数据,如配置文件
  • 创建需要消耗过多资源的类,如IO、数据库
  • 需要定义大量静态常量、方法的环境

2.2 优缺点

  单例模式主要有以下优点:

  • 可以严格控制用户访问,避免对资源的多重占用
  • 避免了全局变量带来的名字空间污染
  • 减少了内存及性能开销

  其缺点主要有:

  • 扩展困难,比如原本仅有一种ID,突然增加需求希望多一个新的ID,则改动就会很大
  • 不利于单元测试
  • 隐藏了类之间的依赖关系,不利于代码阅读和维护

2.3 实现

  经典的单例类Singleton定义及实现如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Singleton
{
public:
static Sigleton* Instance();
private:
Singleton();
static Singleton* m_pInstance;
};

Singleton* Singleton::Instance()
{
if (m_pInstance == NULL)
{
m_pInstance = new Singleton();
}
return m_pInstance;
}

  注意这里使用了延迟初始化(lazy Initialization),即当第一次使用时才会真正的创建。延迟初始化可以起到节约资源,但是在游戏开发中不一定是优秀的设计,例如在游戏步入高潮时因为延迟初始化加载声音、动画等导致的卡顿、掉帧,是大大影响游戏体验的行为。

2.4 单例模式扩展使用

  首先创建一个文件系统类FileSystem

1
2
3
4
5
6
7
8
9
10
11
12
13
class FileSystem
{
public:
static FileSystem* Instance();

virtual ~FileSystem() {}
virtual char* Read(char* pszPath) = 0;
virtual char* Write(char* pszPath, char* pszText) = 0;

protected:
FileSystem() {}
static Singleton* m_pInstance;
};

  假设我们需要在NS/PS5跨平台开发游戏,则需要两个不同的文件系统NSFileSystem以及PS5FileSystem

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
class NSFileSystem : public FileSystem
{
public:
virtual char* Read(char* pszPath)
{
// Use NS file IO API
}

virtual char* Write(char* pszPath)
{
// Use NS file IO API
}
};

class PS5FileSystem : public FileSystem
{
public:
virtual char* Read(char* pszPath)
{
// Use PS5 file IO API
}

virtual char* Write(char* pszPath)
{
// Use PS5 file IO API
}
};

  单例类可以通过基类的简单编译跳转来完成

1
2
3
4
5
6
7
8
9
10
FileSystem* FileSystem::Instance()
{
#ifdef PS5_PLATFORM
static FileSystem* m_pInstance = new PS5FileSystem();
#elif NS_PLATFORM
static FileSystem* m_pInstance = new NSFileSystem();
#endif

return m_pInstance;
}

2.5 如何避免使用单例模式

  单例模式带来的种种困扰使得其成为一个用起来颇为顺手但实际后续麻烦无穷的设计模式。为了不至于滥用单例模式,我们可以有以下选择

  • 传值。很多时候我们并不需要单例模式来做一个全局的功能操作,传值给一个类、栈单独负责去做比起使用单例模式要更优。
  • 基类或者基础全局类。很多游戏会有一个Game或者World类表示整个游戏世界,既然已经有一个了,那就都使用它就好了,不要再创建更多。
  • 通过工厂模式等形式替代。
  • 静态方法替代,但是静态方法并不比单例模式好。

三. 工厂模式

  一般情况下,工厂模式分为三种更加细分的类型:简单工厂、工厂方法和抽象工厂。不过,在 GoF 的《设计模式》一书中,它将简单工厂模式看作是工厂方法模式的一种特例,所以工厂模式只被分成了工厂方法和抽象工厂两类。

3.1 工厂方法

定义一个用于创建对象的接口,让子类决定实例化哪一个类。工厂模式使一个类的实例化延迟到其子类。GoF的定义并不好理解,这里仅放出来做一个参考,其实际意思就是定义一个抽象产品类Product负责产品的共性,再由一个抽象创建类Creator创建产品,具体创建哪种可以在子类具体实现,,也就是对生产产品的抽象。

3.2 抽象工厂

提供一个接口以创建一系列相关或者相互依赖的对象,而无需指定它们具体的类型。如果说工厂方法是生产多种产品,那抽象工厂就是生产多个不同品牌的产品,即对工厂方法的抽象。

3.2.1 适用场景

  • 一个系统要独立于它的产品的创建、组合和表示
  • 一个系统要由多个产品系列中的一个来配置
  • 强调一系列相关产品对象的设计
  • 强调产品类库,只想显示接口而非实现

3.3 实现

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 MazeFactory
{
public:
MazeFactory();
~MazeFactory();

virtual Maze* MakeMaze() const
{
return new Maze;
}

virtual Wall* MakeWall() const
{
return new Wall;
}

virtual Room* MakeRoome(int nIndex) const
{
return new Room(nIndex);
}

virtual Door* MakeDoor(Room* pRoom1, Room* pRoom2)
{
return new Door(pRoom1, pRoom2);
}
};

四. 原型模式

五. 建造者模式

总结

参考文献

[1] 《设计模式》

[2] 《设计模式之禅》

[3] 《游戏设计模式》

[4] 《极客时间专栏:设计模式之美》

坚持原创,坚持分享,谢谢鼓励和支持