文章目录
一、单例模式
1.饿汉模式
全局管理类,保证整个程序(进程)中只有一个实例对象存在。有很多种常见的写法:
Game game;
2.懒汉模式
作为函数内部的 static 变量(懒汗模式)
Game &getGame() {
static Game game;
return game;
}
getGame().updatePlayers();
效果:第一次调用 getGame() 时会初始化,之后的调用会直接返回上次创建的实例。
根据你的需要,如果你需要在程序一启动时 game 对象就可用,就用饿汗模式。
如果 game 的初始化需要某些条件,例如创建 Game 类前需要 OpenGL 初始化,那么可用懒汗模式:
int main() {
glfwInit(); // 初始化 OpenGL
getGame().initialize(); // 第一次调用 getGame 会初始化 game 单例
getGame().updatePlayers(); // 之后的调用总是返回对同一个 game 单例的引用
}
提示:如果要把单例对象的定义放在头文件中,务必添加 inline 修饰符,而不是 static,否则会导致多个 cpp 文件各自有一个 Game 对象。
// Game.hpp
inline Game game;
inline Game &getGame() {
static Game game;
return game;
}
3.封装在类内部
由于所有单例全部暴露在全局名字空间,容易产生混乱。 一般会把单例对象或函数封装在类内部,并且把 Game 的构造函数设为 private,避免用户不慎直接创建出本应只有单个实例的 Game 类。
(1)作为全局变量(饿汗模式)
struct Game {
...
Game(Game &&) = delete;
private:
Game() {
... }
public:
inline static Game instance; // 如果定义在头文件中,需要 inline!
};
Game::instance.updatePlayers();
(2)作为函数内部的 static 变量(懒汗模式)
struct Game {
...
Game(Game &&) = delete;
private:
Game() {
... }
public:
inline static Game &instance() {
// 这里的 inline 可以省略,因为类体内就地实现的函数自带 inline 效果
static Game game;
return game;
}
};
Game::instance().updatePlayers();
4.通用的单例模式模板
template <class T>
inline T &singleton() {
// 这里的 inline 可以省略,因为就地实现的模板函数自带 inline 效果
// 只有第一次进入时会构造一遍 T,之后不会再构造
// 不同的 T 会实例化出不同的 singleton 实例,各自体内的 static 变量独立计算,互不干扰
static T inst;
return inst;
}
singleton<Game>().updatePlayers();
singleton<Other>().someMethod();
任何类型 T,只要以 singleton<T>() 形式获取,都能保证每个 T 都只有一份对象。
二、模板模式
1.模板模式
注意:模板模式和 C++ 的模板并没有必然关系!模板模式只是一种思想,可以用模板实现,也可以用虚函数实现(大多反而是用虚函数实现的)
模板模式用于封装游戏中一些相似的处理逻辑,把共同的部分集中到一个基类,把不同的细节部分留给子类实现。
和策略模式很像,只不过这里接收策略的直接就是基类自己。
例如,一个角色固定每一帧需要移动 3 次,然后绘制 1 次。显然需要把“移动”和“绘制”作为两个虚函数接口,让子类来实现。
struct Character {
virtual void draw() = 0;
virtual void move() = 0;
};
struct Player : Character {
void draw() override {
drawPlayer();
}
void move() override {
movePlayer();
}
};
struct Enemy : Character {
void draw() override {
drawEnemy();
}
void move() override {
moveEnemy();
}
};
如果让负责调用 Character 的人来实现每一帧需要移动 3 次 + 绘制 1 次的话,就破坏了开闭原则。
struct Game {
vector<Character *> chars;
void update() {
for (auto &&c: chars) {
c->move();
c->move();
c->move();
c->draw();
}
}
}
改为把移动 3 次 + 绘制 1 次封装为一个 Character 的普通函数 update。
struct Character {
protected:
virtual void draw() = 0;
virtual void move() = 0;
public:
void update() {
move();
move();
move();
draw();
}
};
struct Game {
vector<Character *> chars;
void update() {
for (auto &&c: chars) {
c->update();
}
}
}
这样调用者就很轻松了,不必关心底层细节,而 update 也只通过接口和子类通信,满足开闭原则和依赖倒置原则。
2.模板模式还是策略模式:如何选择?
当一个对象涉及很多策略时,用策略模式;当只需要一个策略,且需要用到基类的成员时,用模板模式。
例如,一个角色的策略有移动策略和攻击策略,移动方式有“走路”、“跑步”两种,攻击策略又有“平A”、“暴击”两种。
那么就用策略模式,让角色分别指向移动策略和攻击策略的指针。
struct Character {
MoveStrategy *moveStrategy;
AttackStrategy *attackStrategy;
void update() {
if (isKeyPressed(GLFW_KEY_S) {
moveStrategy->move();
} else if (isKeyPressed(GLFW_KEY_W)) {
moveStrategy->run();
}
while (auto enemy = Game::instance().findEnemy(range)) {
attackStrategy->attack(enemy);
}
}
};
而如果只有一个策略,比如武器类,只需要攻击策略,并且攻击策略需要知道武器的伤害值、射程、附魔属性等信息,那就适合模板模式。
struct Weapon {
protected:
double damage;
double charge;
MagicFlag magicFlags;
double range;
virtual void attack(Enemy *enemy);
public:
void update() {
while (auto enemy = Game::instance().findEnemy(range)) {
attack(enemy);
}
}
};
3.最常见的是 do_xxx 封装
例如,一个处理字符串的虚接口类:
struct Converter {
virtual void process(const char *s, size_t len) = 0;
};
这个接口是考虑 实现 Converter 子类的方便,对于 调用 Converter 的用户 使用起来可能并不方便。
这时候就可以运用模板模式,把原来的虚函数接口改为 protected 的函数,且名字改为 do_process。
struct Converter {
protected:
virtual void do_process(const char *s, size_t len) = 0;
public:
void process(string_view str) {
return do_process(str.data(), str.size());
}
void process(string str) {
return do_process(str.data(), str.size());
}
void process(const char *cstr) {
return do_process(cstr, strlen(cstr));
}
};
实现 Converter 的子类时,重写他的 do_process 函数,这些函数是 protected 的,只能被继承了 Converter 的子类访问和重写。
外层用户只能通过 Converter 基类封装好的 process 函数,避免外层用户直接干涉底层细节。
标准库中的 std::pmr::memory_resource、std::codecvt 等都运用了 do_xxx 式的模板模式封装。
三、状态模式
游戏中的角色通常有多种状态,例如,一个怪物可能有“待机”、“巡逻”、“追击”、“攻击”等多种状态,而每种状态下的行为都不一样。
如果用一个枚举变量来表示当前状态,那每次就都需要用 switch 来处理不同的状态。
enum MonsterState {
Idle,
Chase,
Attack,
};
struct Monster {
MonsterState state = Idle;
void update() {
switch (state) {
case Idle:
if (seesPlayer())
state = Chase;
break;
case Chase:
if (canAttack())
state = Attack;
else if (!seesPlayer())
state = Idle;
break;
case Attack:
if (!seesPlayer())
state = Idle;
break;
}
}
};
这或许性能上有一定优势,缺点是,所有不同状态的处理逻辑堆积在同一个函数中,如果有多个函数(不只是 update),那么每添加一个新状态就需要修改所有函数,不符合开闭原则。
而且如果不同的状态含有不同的额外数值需要存储,比如 Chase 状态需要存储当前速度,那就需要在 Monster 类中添加 speed 成员,而 state 不为 Chase 时又用不到这个成员,非常容易扰乱思维。
状态不是枚举,而是类
为此,提出了状态模式,将不同状态的处理逻辑分离到不同的类中。他把每种状态抽象为一个类,状态是一个对象,让角色持有表示当前状态的对象,用状态对象的虚函数来表示处理逻辑,而不必每次都通过 if 判断来执行不同的行为。
struct Monster;
struct State {
virtual void update(Monster *monster) = 0;
};
struct Idle : State {
void update(Monster *monster) override {
if (monster->seesPlayer()) {
monster->setState(new Chase());
}
}
};
struct Chase : State {
void update(Monster *monster) override {
if (monster->canAttack()) {
monster->setState(new Attack());
} else if (!monster->seesPlayer()) {
monster->setState(new Idle());
}
}
};
struct Attack : State {
void update(Monster *monster) override {
if (!monster->seesPlayer()) {
monster->setState(new Idle());
}
}
};
struct Monster {
State *state = new Idle();
void update() {
state->update(this);
}
void setState(State *newState) {
delete state;
state = newState;
}
};
四、原型模式
1.将拷贝构造函数封装为虚函数clone(深拷贝)
原型模式用于复制现有的对象,且新对象的属性和类型与原来相同。
如何实现?
1.为什么拷贝构造函数不行?
拷贝构造函数只能用于类型确定的情况,对于具有虚函数,可能具有额外成员的多态类型,会发生 object-slicing,导致拷贝出来的类型只是基类的部分,而不是完整的子类对象。
RedBall ball;
Ball newball = ball; // 错误:发生了 object-slicing!现在 newball 的类型只是 Ball 了,丢失了 RedBall 的信息
2.为什么拷贝指针不行?
指针的拷贝是浅拷贝,而我们需要的是深拷贝。
Ball *ball = new RedBall();
Ball *newball = ball; // 错误:指针的拷贝是浅拷贝!newball 和 ball 指向的仍然是同一对象
3.需要调用到真正的构造函数,同时又基于指针
Ball *ball = new RedBall();
Ball *newball = new RedBall(*dynamic_cast<RedBall *>(ball)); // 可以,但是这里显式写出了 ball 内部的真正类型,违背了开闭原则
4.将拷贝构造函数封装为虚函数
原型模式将对象的拷贝方法作为虚函数,返回一个虚接口的指针,避免了直接拷贝类型。但虚函数内部会调用子类真正的构造函数,实现深拷贝。
struct Ball {
virtual Ball *clone() = 0;
};
struct RedBall : Ball {
Ball *clone() override {
return new RedBall(*this); // 调用 RedBall 的拷贝构造函数
}
};
struct BlueBall : Ball {
Ball *clone() override {
return new BlueBall(*this); // 调用 BlueBall 的拷贝构造函数
}
int someData; // 如果有成员变量,也会一并被拷贝到
};
好处是,调用者无需知道具体类型,只需要他是 Ball 的子类,就可以克隆出一份完全一样的子类对象来,且返回的也是指针,不会发生 object-slicing。
Ball *ball = new RedBall();
...
Ball *newball = ball->clone(); // newball 的类型仍然是 RedBall
clone 返回为智能指针
struct Ball {
virtual unique_ptr<Ball> clone() = 0;
};
struct RedBall :