C++设计模式之单例模式、模板模式、状态模式、原型模式、CRTP 模式、组件模式、观察者模式、发布-订阅模式、访问者模式

csdn推荐

文章目录

一、单例模式 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 都只有一份对象。

二、模板模式 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 : Ball {
    unique_ptr<Ball> clone() override {
        return make_unique<RedBall>(*this);  // 调用 RedBall 的拷贝构造函数
    }
};
struct BlueBall : Ball {
    unique_ptr<Ball> clone() override {
        return make_unique<BlueBall>(*this);  // 调用 BlueBall 的拷贝构造函数
    }
    int someData;  // 如果有成员变量,也会一并被拷贝到新对象中
};

这样就保证了内存不会泄漏。

如果调用者需要的是 shared_ptr,怎么办?

答:unique_ptr 可以隐式转换为 shared_ptr。

如果调用者需要的是手动 delete 的原始指针,怎么办?

答:unique_ptr 可以通过 release,故意造成一次内存泄漏,成为需要手动管理的原始指针。

2.CRTP 模式自动实现 clone(深拷贝)

CRTP(Curiously Recurring Template Pattern)是一种模板元编程技术,它可以在编译期间把派生类的类型作为模板参数传递给基类,从而实现一些自动化的功能。

特点是,继承一个 CRTP 类时,需要把子类本身作为基类的模板参数。

并不会出现循环引用是因为,用到子类的具体类型是在基类的成员函数内部,而不是直接在基类内部,而模板类型的成员函数的实例化是惰性的,用到了才会实例化。

template <class Derived>
struct Pet {
    void feed() {
        Derived *that = static_cast<Derived *>(this);
        that->speak();
        that->speak();
    }
};
struct CatPet : Pet<CatPet> {
    void speak() {
        puts("Meow!");
    }
};
struct DogPet : Pet<DogPet> {
    void speak() {
        puts("Bark!");
    }
};

CRTP 常常是和虚函数一起出现的好搭档。

例如 CRTP 可以帮助原型模式实现自动化定义 clone 虚函数,稍后介绍的访问者模式中也会用到 CRTP。

struct Ball {
    virtual unique_ptr<Ball> clone() = 0;
};
template <class Derived>
struct BallImpl : Ball {  // 自动实现 clone 的辅助工具类
    unique_ptr<Ball> clone() override {
        Derived *that = static_cast<Derived *>(this);
        return make_unique<Derived>(*that);
    }
};
struct RedBall : BallImpl<RedBall> {
    // unique_ptr clone() override {       // BallImpl 自动实现的 clone 等价于
    //     return make_unique(*this);  // 调用 RedBall 的拷贝构造函数
    // }
};
struct BlueBall : BallImpl<BlueBall> {
    // unique_ptr clone() override {       // BallImpl 自动实现的 clone 等价于
    //     return make_unique(*this);  // 调用 BlueBall 的拷贝构造函数
    // }
};

五、组件模式

组件模式是游戏开发领域最重要的设计模式,它将游戏对象分为多个组件,每个组件只关心自己的逻辑,而不关心其他组件的逻辑。

错误用法:每个组件写成一个类,然后使用“多重继承”继承出一个玩家类来,并恬不知耻地声称“我也会组件模式了”。

然而,这样的缺点有:

1.游戏开发中普遍涉及到 update 函数,而玩家类的 update 需要轮流调用每个组件的 update 函数。而多重继承一旦遇到重名的 update 函数,会直接报错 “有歧义的函数名” 摆烂不干了,需要你手写新的 update 函数。

2.C++(和大多数非脚本语言都)不支持运行时添加或删除基类,也就是说,如果要添加一个新角色,或是修改现有角色的逻辑,就需要重新编译一遍整个游戏的源码。

3.Java 和 C# 都没有多重继承。你让人家基于 C# 的 Unity 怎么活?

struct Player : PlayerController, PlayerAppearance, PlayerAnimation {
    void update() {
        PlayerController::update();
        PlayerAppearance::update();
        PlayerAnimation::update();
    }
};

因此,真正的组件模式都会允许动态插入组件,而不是编译期写死。

游戏对象组件化后,可以灵活地组合出不同的游戏对象,而不必为每一种组合都写一个类。

struct Component {
    virtual void update(GameObject *go) = 0;
    virtual ~Component() = default;  // 注意!
};
struct GameObject {
    vector<Component *> components;
    void add(Component *component) {
        components.push_back(component);
    }
    void update() {
        for (auto &&c: components) {
            c->update(this);
        }
    }
};

注意:Component 的析构函数必须为虚函数。否则,当 Component 被 delete 时,只会调用到 Component 这个基类的析构函数,而不会调用到子类的析构函数。

否则,如果你的子类有 string、vector 这种持有内存资源的容器类,会发生内存泄漏,导致游戏运行越久内存占用越大。

神奇的是,如果你的 Component 全部都是用 make_shared 创建的,那就没有内存泄漏了,这得益于 shared_ptr 会对 deleter 做类型擦除。 make_unique 和 new 创建的就会泄漏,因为他们 delete 时是以基类指针去 delete 的,而 shared_ptr 会在构造时就记住子类的 deleter。

所有组件,都支持 update(每帧更新)操作:

struct Movable : Component {
    glm::vec3 position;
    glm::vec3 velocity;
    void update(GameObject *go) override {
        position += velocity * dt;
    }
};

struct LivingBeing : Component {
    int ageLeft;
    void update(GameObject *go) override {
        if (ageLeft < 0)
            go->kill();
        else
            ageLeft -= 1;
    }
};

1.组件的创建

组件有两种创建方式:

(1)组件作为一个普通对象,由 GameObject 的构造函数创建。

struct Player : GameObject {
    Movable *movable;
    LivingBeing *livingBeing;
    PlayerController *playerController;
    PlayerAppearance *playerAppearance;
    Player() {
        movable = new Movable();
        livingBeing = new LivingBeing(42);
        playerController = new PlayerController();
        playerAppearance = new PlayerAppearance();
        add(movable);
        add(livingBeing);
        add(playerController);
        add(playerAppearance);
    }
};

2.不再需要定义 Player 类及其构造函数了,只需一个普通函数创建具有 Player 所需所有组件的 GameObject 对象即可。

GameObject *makePlayer() {
    GameObject *go = new GameObject();
    go->add(new Movable());
    go->add(new LivingBeing(42));
    go->add(new PlayerController());
    go->add(new PlayerAppearance());
    return go;
}

正经游戏引擎都采用后者,不用添加 C++ 源码,只是从 xml 等配置文件读取每个类所依赖的组件,就能创建新的玩家类,方便动态更新游戏逻辑而无需重新发布 dll。

2.组件之间如何通信

缺点是,组件之间的通信需要通过 GameObject 来实现,而 GameObject 并不知道它的组件是什么,这样就无法直接访问组件的成员。

例如,PlayerController 组件想要改变 Movable 组件的 velocity,就无法直接改。

struct PlayerController : Component {
    void update(GameObject *go) override {
        if (isKeyPressed(GLFW_KEY_W)) {
            go->velocity.y += 1; // 错误!velocity 是 Movable 组件的成员,而不是 GameObject 里直接有的
        }
        if (isKeyPressed(GLFW_KEY_S)) {
            go->velocity.y -= 1;
        }
        if (isKeyPressed(GLFW_KEY_A)) {
            go->velocity.x -= 1;
        }
        if (isKeyPressed(GLFW_KEY_D)) {
            go->velocity.x += 1;
        }
    }
};

如何解决组件之间通信难的问题?

(1)把常用的字段,例如 position 和 velocity 直接放在 GameObject 里,供所有组件直接访问。

struct GameObject {
    glm::vec3 position;
    glm::vec3 velocity;
    ...
};

(2)允许用户根据其他组件的类型,直接获取出其他组件的指针,即可访问其成员。

struct PlayerController : Component {
    void update(GameObject *go) override {
        Movable *movable = go->getComponent<Movable>();
        if (!movable) {
            throw runtime_error("这个对象似乎不支持移动");
        }
        if (isKeyPressed(GLFW_KEY_W)) {
            movable->velocity.y += 1;
        }
        if (isKeyPressed(GLFW_KEY_S)) {
            movable->velocity.y -= 1;
        }
        if (isKeyPressed(GLFW_KEY_A)) {
            movable->velocity.x -= 1;
        }
        if (isKeyPressed(GLFW_KEY_D)) {
            movable->velocity.x += 1;
        }
    }
};

然而,getComponent 如何实现?

struct GameObject {
    template <class T>
    T *getComponent() {
        for (auto &&c: components) {
            if (T *t = dynamic_cast<T *>(c)) {
                return t;
            }
        }
        return nullptr;
    }
};

用到了 dynamic_cast,这是比较低效的一种实现方式,而且也不符合开闭原则。

更好的实现方式是利用 typeid 做 map 的键,加速查找。没有性能问题,但依然不符合开闭原则。

struct GameObject {
    unordered_map<type_index, Component *> components;
    template <class T>
    T *getComponent() {
        if (auto it = components.find(typeid(T)); it != components.end()) {
            return dynamic_cast<T *>(it->second);
        } else {
            return nullptr;
        }
    }
    void add(Component *component) {
        components[typeid(*component)] = component;
    }
};

让 PlayerController 发出指定类型的消息对象,由 Movable 检查并处理。

消息类型也是多态的,初学者可以先通过 dynamic_cast 实现类型检查。稍后我们会介绍更专业的访问者模式。

通常来说,我们只能把子类指针转换为基类指针。

而 dynamic_cast 可以把基类指针转换为子类指针。

如果他指向的对象确实就是那个子类类型的话,就正常返回子类指针了。

否则,如果类型不匹配,dynamic_cast 会返回 nullptr。只需判断返回的指针是不是 nullptr 就知道是否类型匹配了。

七、观察者模式

这就是所谓的观察者模式,每个组件都可以收到所有消息。

但这样做的缺点是,每个组件都需要处理所有消息,不论是否是自己需要的,如果组件数量多,消息类型又多,就会出现性能问题。

struct Message {
    virtual ~Message() = default;  // C++ 规定:只有多态类型才能 dynamic_cast,这里我们用不到虚函数,那就只让析构函数为虚函数,即可使 Message 变为多态类型
};
struct MoveMessage : Message {
    glm::vec3 velocityChange;
};
struct Component {
    virtual void update(GameObject *go) = 0;
    virtual void handleMessage(Message *msg) = 0;
    virtual ~Component() = default;
};
struct Movable : Component {
    glm::vec3 position;
    glm::vec3 velocity;
    void handleMessage(Message *msg) override {
        // 所有不同的消息类型都会进入此函数
        if (MoveMessage *mm = dynamic_cast<MoveMessage *>(msg)) {
            // 但只有真正类型为 MoveMessage 的消息会被处理
            velocity += mm->velocityChange;
        }
    }
};
struct PlayerController : Component {
    void update(GameObject *go) override {
        if (isKeyPressed(GLFW_KEY_W)) {
            MoveMessage mm;
            mm.velocityChange.y += 1;
            go->send(&mm);
        }
        if (isKeyPressed(GLFW_KEY_S)) {
            MoveMessage mm;
            mm.velocityChange.y -= 1;
            go->send(&mm);
        }
        if (isKeyPressed(GLFW_KEY_A)) {
            MoveMessage mm;
            mm.velocityChange.x -= 1;
            go->send(&mm);
        }
        if (isKeyPressed(GLFW_KEY_D)) {
            MoveMessage mm;
            mm.velocityChange.x += 1;
            go->send(&mm);
        }
    }
};
struct PlayerController : Component {
    void update(GameObject *go) override {
        if (isKeyPressed(GLFW_KEY_W)) {
            MoveMessage mm;
            mm.velocityChange.y += 1;
            go->send(&mm);
        }
        if (isKeyPressed(GLFW_KEY_S)) {
            MoveMessage mm;
            mm.velocityChange.y -= 1;
            go->send(&mm);
        }
        if (isKeyPressed(GLFW_KEY_A)) {
            MoveMessage mm;
            mm.velocityChange.x -= 1;
            go->send(&mm);
        }
        if (isKeyPressed(GLFW_KEY_D)) {
            MoveMessage mm;
            mm.velocityChange.x += 1;
            go->send(&mm);
        }
    }
};
struct GameObject {
    vector<Component *> components;
    void add(Component *component) {
        components.push_back(component);
    }
    void update() {
        for (auto &&c: components) {
            c->update(this);
        }
    }
    void send(Message *msg) {
        for (auto &&c: components) {
            c->handleMessage(msg);
        }
    }
};

文章来源:https://blog.csdn.net/u011436427/article/details/139217573



微信扫描下方的二维码阅读本文

© 版权声明
THE END
喜欢就支持一下吧
点赞6 分享
评论 抢沙发
头像
欢迎您留下宝贵的见解!
提交
头像

昵称

取消
昵称表情代码图片

    暂无评论内容