1. 程式人生 > >遊戲編程模式--單例模式

遊戲編程模式--單例模式

卡頓 rdquo 多個 特性 簡單的 pri 新的 類繼承 con

單例模式

  定義:確保一個類只有一個實例,並為其提供一個全局的訪問入口。

  那麽什麽情況下使用單例?最常見的情況就是一個類需要與一個維持自身狀態的外部系統進行交互,比如說打印機。大多數情況下都是多人共用一個打印機,這意味著可能由多個人同時向這個打印機發送打印任務,這個時候管理打印機的類就必須熟悉打印機的當前狀態並協調這些任務的執行。這個時候就不允許存在多個打印機的實例,因為實例無法知道其他的實例所做的操作,也就無法進行整體的管理。

  我們先看看最常見的單例的實現方式:

class FileSystem
{
public:
    static FileSystem& instance_()
    {
        
if(instance_ == nullptr) { instance_ = new FileSystem(); } return instance_; } private: FileSystem(){} static FileSystem* instance_; };

  c++11保證一個局部靜態變量初始化只進行一次,哪怕實在多線程的情況下也是如此,所以c++11中這樣寫更優雅。

class FileSystem
{
public:
    static
FileSystem& instance() { static FileSystem& instance_ = new FileSystem(); return instance_; } private: FileSystem(){} };

特性

  從代碼實現上看,單例模式由以下幾個特性:

  •   如果我們不使用它,就不會創建實例。
  •   它在運行時初始化。還有一種方法是使用靜態類,但靜態類有個局限就是:自動初始化。而且它是在main函數之前初始化,這也就意味了它不能使用運行時才能知道的信息,並且不能相互依賴——編譯器並不能保證靜態函數間初始化的順序。
  •   你可以繼承單例,這可以讓我們更好的控制我們的代碼,比如對於多平臺的文件系統,我們定義兩個子類繼承FileSystem的接口,通過一個編譯指令控制文件系統類型的綁定,程序的其他代碼可以與文件系統解耦(因為其他代碼只是用FileSystem::instance())。

後悔使用單例模式的原因

  (1)它是個全局變量

   根據前人的經驗,全局變量時有害的,我們應該遠離全局變量。為什麽了?

  • 它令代碼晦澀難懂。本來在一個函數中,我們只需要關註函數段的局部代碼即可,但如果在函數中使用了全局變量,則我們就需要追蹤所有能改變全局變量狀態的代碼,如果這樣的代碼由成百上千行,你就會痛恨全局變量了。
  • 全局變量促進了耦合。因為全局變量的特性,你只需要包含相應的頭文件,就可以使用這個變量,這就增加了代碼的耦合程度。
  • 它對並發並不友好,這個顯而易見。

  (2)它是個畫蛇添足的方案

   從定義上看出,單例模式其實是解決了兩個問題:第一保證一個實例,第二提供已訪問入口。保證一個單例是很有用的,但誰說我們希望誰都能操作它?而第二個問題,便利的訪問通常是我們使用單例的主要原因。但這同時也會引出新的問題,比如一個日誌類,一開始大家都使用這個單例的日誌類時很方便,但隨著項目的深入,對於日誌的需求也復雜了起來,比如要求分類寫入多個日誌文件,這個時候因為你是單例,所以為了支持多個實例,你就要修改每個你調用這個類的地方,結果便利的訪問也就不那麽便利了。

  (3)延遲初始化剝離了你的控制

   延遲初始化也就是在第一次調用的時候初始化,這樣也就不能保證你初始化的時機。這通常在對性能要求非常高的遊戲中時不被允許的,設想一個音頻單例單例,初始化需要幾百毫秒,而且伴隨著內存的分配,如果你的遊戲進行中突然調用這個單例,則會進行初始化操作,這件帶來不可接受的遊戲掉幀和卡頓。而且也不利於內存布局的控制。

  在遊戲中通常使用這樣的方式來實現單例模式:

class FileSystem
{
public:
    static FileSystem& instance()
    {
        return instance_;
    }

private:
    FileSystem(){}
    static FileSystem instance_;
};

我們應該使用單例嗎?

  (1)首先看看你需不需類

  在遊戲中,我看見了太多的“manager”類了,它們的初衷時為了管理其它對象,雖然有時確實有用,但我更多的時看到它被濫用。比如下面的一個例子:

class Bullet
{
public:
    int getX() const {return x_;}
    int getY() const {return y_;}
    void setX(int x) {x_=x;}
    void setY(int y) {y_=y;}

private:
    int x_;
    int y_;
};

class BulletManager
{
public:
    Bullet* create(int x,int y)
    {
        Bullet* bullet = new Bullet();
        bullet->setX(x);
        bullet->setY(y);

        return bullet;
    }

    bool isOnScreen(Bullet& bullet)
    {
        return bullet.getX() >=0
            && bullet.getY >=0
            && bullet.getX() <= SCREEN_WIDTH
            && bullet.getY() <= SCREEN_HEIGHT;
    }

    void move(Bullet& bullet)
    {
        bullet.setX(bullet.getX() + 5);
    }
};

  這個例子有點極端,但現實中很多manager類簡化後就是這樣的一個邏輯。我們通過一個單例來管理Bullet,感覺上好像合理,但仔細分析後,發現這個manager根本就沒有存在的必要,設計出這樣一個類的人應該對OOP不太熟悉。首先我們分析這三個方法:

  1.   create創建一個Bullet,如果我們想要更好的管理Bullet的創建,那我們應該使用工廠模式,它會使我們的代碼可維護性更高,或者直接在Bullet中提供一個靜態函數來創建一個新的對象,從設計上來說更顯得合理;
  2.   isOnScreen判斷是否在屏幕中,這個方法既可以放在業務層代碼中也可以放入Bullet中(因為可以理解我為這是bullet的一個狀態),把它放在這個Manager類中顯得不倫不類;
  3.   move是移動bullet,這個就好設計了,move本來就是bullet的行為,所以如果沒有特別的需求,move應該放入Bullet類中。

  所以,修改後,我們只需要一個Bullet類:

class Bullet
{
public:
    Bullet(int x,int y):x_(x),y_(y)
    {

    }

    bool isOnScreen()
    {
        return x_ >=0
            && y_ >=0
            && x_ <= SCREEN_WIDTH
            && y_ <= SCREEN_HEIGHT;
    }

    void move()
    {
        x_ += 5;
    }
};

  這樣修改後,類的設計顯得更合理,更自然。我們完全不需要一個額外的manager單例來幫助我們管理,所以,在我們設計單例時,首先就要分析我們是否真的需要這個單例。

  (2)將類限制為單一實例

  我們使用單例模式,很多時候只是要限制該類只有一個實例,但這並不意味著我們要提供一個全局訪問,我們可能只是想在某一部分代碼中訪問這個實例,這個時候如果使用單例模式提供一個全局的訪問接口,將會削弱整體的框架。我們可以有幾種方式避免這種情況的出現。比如:

class FileSystem
{
public:
    FileSystem()
    {
        assert(!instantiated);
        instantiated = true;
    }

    ~FileSystem()
    {
        instantiated = false;
    }

private:
    static bool instantiated;
};

bool FileSystem::instantiated = false;

  通過一個斷言,保證FileSystem只有一個實例。

  (3)為實例提供便捷的訪問方式

  使用單例模式的另一個需求就是便利的訪問,它能讓我們隨時隨地的獲取這個唯一的實例。但這與我們通用的編程準則不符,我們通常是在保證功能的情況下盡量限制變量使用的一個範圍,這樣我們就只需要記住它的地方機會少很多(想想全局變量帶來的問題)。那在不適用單例模式的時候,我們還有什麽其它的途徑訪問一個對象了?通常我們會有這麽幾種方式:

  •   作為參數傳遞進去。這個是最簡單,通常也是最好的方法。但有時我們會碰到這樣的情況,即這個對象與函數的內容沒什麽必然的聯系,比如我們執行一個渲染函數時要記錄日誌,如果把日誌對象加入到函數的參數列表中,將會非常的奇怪,對於這種情況,我們需要一些其它的辦法。
  •   在基類中獲取它。這需要設計一個良好的繼承體系,既然所有的子類都要訪問這個對象,我們可以把這個對象放到父類中讓所有的子類都能訪問到它。
  •   使用其它全局對象訪問它。現實中我們不太可能把所有的全局變量都移除,比如在大部分的遊戲代碼中我們都會定義一個代表整個遊戲狀態的Game或者World對象,我們可以把全局對象放入這些已有的全局變量中來減少它們的數量。
  •   使用服務定位其來訪問。這是一種專門設計一個類來給對象做全局訪問的,將會在服務器定位模式一節講解。

結語

  所以,我們應該在什麽時候使用單例了?老師說,單例並沒有你想象的那樣重要,如果你要確保類只被實例化一次,可以簡單的使用一個靜態類,如果還不滿足要求,可以使用一個靜態的標識符在運行時檢查是否只有一個實例被創建。不過使用與否還是要視你自己需求來定,但一定要防止單例模式的濫用,這不會給你帶來任何的好處。

遊戲編程模式--單例模式