1. 程式人生 > >More Effective C++ Item M31:讓函式根據一個以上的物件來決定怎麼虛擬

More Effective C++ Item M31:讓函式根據一個以上的物件來決定怎麼虛擬

1.3 Item M31:讓函式根據一個以上的物件來決定怎麼虛擬
有時,借用一下Jacqueline Susann的話:一次是不夠的。例如你有著一個光輝形象、崇高聲望、豐厚薪水的程式設計師工作,在Redmond,Wshington的一個著名軟體公司--當然,我說的就是任天堂。為了得到經理的注意,你可能決定編寫一個video game。遊戲的背景是發生在太空,有宇宙飛船、太空站和小行星。
在你構造的世界中的宇宙飛船、太空站和小行星,它們可能會互相碰撞。假設其規則是:
* 如果飛船和空間站以低速接觸,飛船將泊入空間站。否則,它們將有正比於相對速度的損壞。
* 如果飛船與飛船,或空間站與空間站相互碰撞,參與者均有正比於相對速度的損壞。
* 如果小行星與飛船或空間站碰撞,小行星毀滅。如果是小行星體積較大,飛船或空間站也毀壞。
* 如果兩個小行星碰撞,將碎裂為更小的小行星,並向各個方向濺射。
這好象是個無聊的遊戲,但用作我們的例子已經足夠了,考慮一下怎麼組織C++程式碼以處理物體間的碰撞。
我們從分析飛船、太空站和小行星的共同特性開始。至少,它們都在運動,所以有一個速度來描述這個運動。基於這一點,自然而然地設計一個基類,而它們可以從此繼承。實際上,這樣的類幾乎總是抽象基類,並且,如果你留心我在Item M33中的警告,基類總是抽象的。所以,繼承體系是這樣的:
           GameObject
            |  |  |
           /   |   /
          /    |    /
         /     |     /
        /      |      /
SpaceShip SpaceStation Asteroid
class GameObject { ... };
class SpaceShip: public GameObject { ... };
class SpaceStation: public GameObject { ... };
class Asteroid: public GameObject { ... };
現在,假設你開始進入程式內部,寫程式碼來檢測和處理物體間的碰撞。你會提出這樣一個函式:
void checkForCollision(GameObject& object1,
                       GameObject& object2)
{
  if (theyJustCollided(object1, object2)) {
    processCollision(object1, object2);
  }
  else {
    ...
  }
}
問題來了。當你呼叫processCollision()時,你知道object1和object2正好相撞,並且你知道發生的結果將取決於object1和object2的真實型別,但你並不知道其真實型別;你所知道的就只有它們是GameObject物件。如果碰撞的處理過程只取決於object1的動態型別,你可以將processCollision()設為虛擬函式,並呼叫object1.processColliion(object2)。如果只取決於object2的動態型別,也可以同樣處理。但現在,取決於兩個物件的動態型別。虛擬函式體系只能作用在一個物件身上,它不足以解決問題。
你需要的是一種作用在多個物件上的虛擬函式。C++沒有提供這樣的函式。可是,你必須要實現上面的要求。現在怎麼辦呢?
一種辦法是扔掉C++,換種其它語言。比如,你可以改用CLOS(Common Lisp Object System)。CLOS支援絕大部分面向物件的函式呼叫體系中只能想象的東西:multi-method。multi-method是在任意多的引數上虛擬的函式,並且CLOS更進一步的提供了明確控制“被過載的multi-method將如何呼叫”的特性。
讓我們假設,你必須用C++實現,所以必須找到一個方法來解決這個被稱為“二重排程(double dispatch)”的問題。(這個名字來自於object-oriented programming community,在那裡虛擬函式呼叫的術語是“message dispatch”,而基兩個引數的虛呼叫是通過“double dispatch”實現的,推而廣之,在多個引數上的虛擬函式叫“multiple dispatch”。)有幾個方法可以考慮。但沒有哪個是沒有缺點的,這不該奇怪。C++沒有直接提供“double dispatch”,所以你必須自己完成編譯器在實現虛擬函式時所做的工作(見Item M24)。如果容易的話,我們可能就什麼都自己做了,並用C語言程式設計了。我們沒有,而且我們也不能夠,所以繫緊你的安全帶,有一個坎途了。
* 用虛擬函式加RTTI
虛擬函式實現了一個單一排程,這只是我們所需要的一半;編譯器為我們實現虛擬函式,所以我們在GameObject中申明一個虛擬函式collide。這個函式被派生類以通常的形式過載:
class GameObject {
public:
  virtual void collide(GameObject& otherObject) = 0;
  ...
};
class SpaceShip: public GameObject {
public:
  virtual void collide(GameObject& otherObject);
  ...
};
我在這裡只寫了派生類SpaceShip的情況,SpaceStation和Asteroid的形式完全一樣的。
實現二重排程的最常見方法就是和虛擬函式體系格格不入的if...then...else鏈。在這種刺眼的體系下,我們首先是發現otherObject的真實型別,然後測試所有的可能:
// if we collide with an object of unknown type, we
// throw an exception of this type:
class CollisionWithUnknownObject {
public:
  CollisionWithUnknownObject(GameObject& whatWeHit);
  ...
};
void SpaceShip::collide(GameObject& otherObject)
{
  const type_info& objectType = typeid(otherObject);
  if (objectType == typeid(SpaceShip)) {
    SpaceShip& ss = static_cast<SpaceShip&>(otherObject);
    process a SpaceShip-SpaceShip collision;
  }
  else if (objectType == typeid(SpaceStation)) {
    SpaceStation& ss =
      static_cast<SpaceStation&>(otherObject);
    process a SpaceShip-SpaceStation collision;
  }
  else if (objectType == typeid(Asteroid)) {
    Asteroid& a = static_cast<Asteroid&>(otherObject);
    process a SpaceShip-Asteroid collision;
  }
  else {
    throw CollisionWithUnknownObject(otherObject);
  }
}
注意,我們需要檢測的只是一個物件的型別。另一個是*this,它的型別由虛擬函式體系判斷。我們現在處於SpaceShip的成員函式中,所以*this肯定是一個SpaceShip物件,因此我們只需找出otherObject的型別。
這兒的程式碼一點都不復雜。它很容易實現。也很容易讓它工作。RTTI只有一點令人不安:它只是看起來無害。實際的危險來自於最後一個else語句,在這兒拋了一個異常。
我們的代價是幾乎放棄了封裝,因為每個collide函式都必須知道所以其它同胞類中的版本。尤其是,如果增加一個新的類時,我們必須更新每一個基於RTTI的if...then...else鏈以處理這個新的型別。即使只是忘了一處,程式都將有一個bug,而且它還不顯眼。編譯器也沒辦法幫助我們檢查這種疏忽,因為它們根本不知道我們在做什麼(參見Item E39)。
這種型別相關的程式在C語言中已經很有一段歷史了,而我們也知道,這樣的程式本質上是沒有可維護性的。擴充這樣的程式最終是不可想象的。這是引入虛擬函式的主意原因:將產生和維護型別相關的函式呼叫的擔子由程式設計師轉給編譯器。當我們用RTTI實現二重排程時,我們正退回到過去的苦日子中。
這種過時的技巧在C語言中導致了錯誤,它們C++語言也仍然導致錯誤。認識到我們自己的脆弱,我們在collide函式中加上了最後的那個else語句,以處理如果遇到一個未知型別。這種情況原則上說是不可能發生的,但在我們決定使用RTTI時又怎麼知道呢?有很多種方法來處理這種未曾預料的相互作用,但沒有一個令人非常滿意。在這個例子裡,我們選擇了丟擲一個異常,但無法想象呼叫者對這個錯誤的處理能夠比我們好多少,因為我們遇到了一個我們不知道其存在的東西。
* 只使用虛擬函式
其實有一個方法可以將用RTTI實現二重排程固有風險降到最低的,不過在此之前讓我們看一下怎麼只用虛擬函式來解決二重排程問題。這個方法和RTTI方法有這同樣的基本構架。collide函式被申明為虛,並被所有派生類重定義,此外,它還被每個類過載,每個過載處理一個派生型別:
class SpaceShip;                        // forward declarations
class SpaceStation;
class Asteroid;
class GameObject {
public:
  virtual void collide(GameObject&      otherObject) = 0;
  virtual void collide(SpaceShip&       otherObject) = 0;
  virtual void collide(SpaceStation&    otherObject) = 0;
  virtual void collide(Asteroid&        otherobject) = 0;
  ...
};
class SpaceShip: public GameObject {
public:
  virtual void collide(GameObject&       otherObject);
  virtual void collide(SpaceShip&        otherObject);
  virtual void collide(SpaceStation&     otherObject);
  virtual void collide(Asteroid&         otherobject);
  ...
};
其基本原理就是用兩個單一排程實現二重排程,也就是說有兩個單獨的虛擬函式呼叫:第一次決定第一個物件的動態型別,第二次決定第二個物件動態型別。和前面一樣,第一次虛擬函式呼叫帶的是GameObject型別的引數。其實現是令人吃驚地簡單:
void SpaceShip::collide(GameObject& otherObject)
{
  otherObject.collide(*this);
}
粗一看,它象依據引數的順序進行迴圈呼叫,也就是開始的otherObject變成了呼叫成員函式的物件,而*this成了它的引數。但再仔細看一下啦,它不是迴圈呼叫。你知道的,編譯器根據引數的靜態型別決定調那一組函式中的哪一個。在這兒,有四個不同的collide函式可以被呼叫,但根據*this的靜態型別來選中其中一個。現在的靜態型別是什麼?因為是在SpaceShip的成員函式中,所以*this肯定是SpaceShip型別。呼叫的將是接受SpaceShip引數的collide函式,而不是帶GameOjbect型別引數的collide函式。
所有的collide函式都是虛擬函式,所以在SpaceShip::collide中呼叫的是otherObject真實型別中實現的collide版本。在這個版本中,兩個物件的真實型別都是知道的,左邊的是*this(實現這個函式的類的型別),右邊物件的真實型別是SpaceShip(申明的形參型別)。
看了SpaceShip類中的其它collide的實現,就更清楚了:
void SpaceShip::collide(SpaceShip& otherObject)
{
  process a SpaceShip-SpaceShip collision;
}
void SpaceShip::collide(SpaceStation& otherObject)
{
  process a SpaceShip-SpaceStation collision;
}
void SpaceShip::collide(Asteroid& otherObject)
{
  process a SpaceShip-Asteroid collision;
}
你看到了,一點都不混亂,也不麻煩,沒有RTTI,也不需要為意料之外的物件型別拋異常。不會有意料之外的型別的,這就是使用虛擬函式的好處。實際上,如果沒有那個致命缺陷的話,它就是實現二重排程問題的完美解決方案。
這個缺陷是,和前面看到的RTTI方法一樣:每個類都必須知道它的同胞類。當增加新類時,所有的程式碼都必須更新。不過,更新方法和前面不一樣。確實,沒有if...then...else需要修改,但通常是更差:每個類都需要增加一個新的虛擬函式。就本例而言,如果你決定增加一個新類Satellite(繼承於GameObjcet),你必須為每個現存類增加一個collide函式。
修改現存類經常是你做不到的。比如,你不是在寫整個遊戲,只是在完成程式框架下的一個支撐庫,你可能無權修改GameObject類或從其經常的框架類。此時,增加一個新的成員函式(虛的或不虛的),都是不可能的。也就說,你理論上有操作需要被修改的類的許可權,但實際上沒有。打個比方,你受僱於Nitendo,使用一個包含GameObject和其它需要的類的執行庫進行程式設計。當然不是隻有你一個人在使用這個庫,全公司都將震動於每次你決定在你的程式碼中增加一個新型別時,所有的程式都需要重新編譯。實際中,廣被使用的庫極少被修改,因為重新編譯所有用了這個庫的程式的代價太大了。(參見Item M34,以瞭解怎麼設計將編譯依賴度降到最低的執行庫。)
總結一下就是:如果你需要實現二重排程,最好的辦法是修改設計以取消這個需要。如果做不到的話,虛擬函式的方法比RTTI的方法安全,但它限制了你的程式的可控制性(取決於你是否有權修改標頭檔案)。另一方面,RTTI的方法不需要重編譯,但通常會導致程式碼無法維護。自己做抉擇啦!
* 模擬虛擬函式表
有一個方法來增加選擇。你可以回顧Item M24,編譯器通常建立一個函式指標陣列(vtbl)來實現虛擬函式,並在虛擬函式被呼叫時在這個陣列中進行下標索引。使用vtbl,編譯器避免了使用if...then...else鏈,並能在所有呼叫虛擬函式的地方生成同樣的程式碼:確定正確的vtbl下標,然後呼叫vtbl這個位置上儲存的指標所指向的函式。
沒理由說你不能這麼做。如果這麼做了,不但使得你基於RTTI的程式碼更具效率(下標索引加函式指標的反引用通常比if...then...else高效,產生的程式碼也少),同樣也將RTTI的使用範圍限定在一處:你初始化函式指標陣列的地方。提醒一下,看下面的內容前最好做一下深呼吸( I should mention that the meek may inherit the earth, but the meek of heart may wish to take a few deep breaths before reading what follows)。
對GameObjcet繼承體系中的函式作一些修改:
class GameObject {
public:
  virtual void collide(GameObject& otherObject) = 0;
  ...
};
class SpaceShip: public GameObject {
public:
  virtual void collide(GameObject& otherObject);
  virtual void hitSpaceShip(SpaceShip& otherObject);
  virtual void hitSpaceStation(SpaceStation& otherObject);
  virtual void hitAsteroid(Asteroid& otherobject);
  ...
};
void SpaceShip::hitSpaceShip(SpaceShip& otherObject)
{
  process a SpaceShip-SpaceShip collision;
}
void SpaceShip::hitSpaceStation(SpaceStation& otherObject)
{
  process a SpaceShip-SpaceStation collision;
}
void SpaceShip::hitAsteroid(Asteroid& otherObject)
{
  process a SpaceShip-Asteroid collision;
}
和開始時使用的基於RTTI的方法相似,GameObjcet類只有一個處理碰撞的函式,它實現必須的二重排程的第一重。和後來的基於虛擬函式的方法相似,每種碰撞都由一個獨立的函式處理,不過不同的是,這次,這些函式有著不同的名字,而不是都叫collide。放棄過載是有原因的,你很快就要見到的。注意,上面的設計中,有了所有其它需要的東西,除了沒有實現Spaceship::collide(這是不同的碰撞函式被呼叫的地方)。和以前一樣,實現了SpaceShip類,SpaceStation類和Asteroid類也就出來了。
在SpaceShip::collide中,我們需要一個方法來對映引數otherObject的動態型別到一個成員函式指標(指向一個適當的碰撞處理函式)。一個簡單的方法是建立一個對映表,給定的類名對應恰當的成員函式指標。直接使用一個這樣的對映表來實現collide是可行的,但如果增加一箇中間函式lookup時,將更好理解。lookup函式接受一個GameObject引數,返回相應的成員函式指標。
這是lookup的申明:
class SpaceShip: public GameObject {
private:
  typedef void (SpaceShip::*HitFunctionPtr)(GameObject&);
  static HitFunctionPtr lookup(const GameObject& whatWeHit);
  ...
};
函式指標的語法不怎麼優美,而成員函式指標就更差了,所以我們作了一個型別重定義。
既然有了lookup,collide的實現如下:
void SpaceShip::collide(GameObject& otherObject)
{
  HitFunctionPtr hfp =
    lookup(otherObject);                // find the function to call
  if (hfp) {                            // if a function was found
    (this->*hfp)(otherObject);          // call it
  }
  else {
    throw CollisionWithUnknownObject(otherObject);
  }
}
如果我們能保持對映表和GameObject的繼承層次的同步,lookup就總能找到傳入物件對應的有效函式指標。人終究只是人,就算再仔細,錯誤也會鑽入軟體。這就是我們為什麼檢查lookup的返回值並在其失敗時拋異常的原因。
剩下的就是實現lookup了。提供了一個物件型別到成員函式指標的對映表後,lookup自己很容易實現,但建立、初始化和析構這個對映表是個有意思的問題。
這樣的陣列應該在它被使用前構造和初始化,並在不再被需要時析構。我們可以使用new和delete來手工建立和析構它,但這時怎麼保證在初始化以前不被使用呢?更好的解決方案是讓編譯器自動完成,在lookup中把這個陣列申明為靜態就可以了。這樣,它在第一次呼叫lookup前構造和初始化,在main退出後的某個時刻被自動析構(見Item E47)。
而且,我們可以使用標準模板庫提供的map模板來實現對映表,因為這正是map的功能:
class SpaceShip: public GameObject {
private:
  typedef void (SpaceShip::*HitFunctionPtr)(GameObject&);
  typedef map<string, HitFunctionPtr> HitMap;
  ...
};
SpaceShip::HitFunctionPtr
SpaceShip::lookup(const GameObject& whatWeHit)
{
  static HitMap collisionMap;
  ...
}
此處,collisionMap就是我們的對映表。它對映類名(一個string物件)到一個Spaceship的成員函式指標。因為map<string, HitFunctionPtr>太拗口了,我們用了一個型別重定義。(開個玩笑,試一下不用HitMap和HitFunctionPtr這兩個型別重定義來寫collisionMap的申明。大部分人不會做第二次的。)
給出了collisionMap後,lookup的實現有些虎頭蛇尾。因為搜尋工作是map類直接支援的操作,並且我們在typeid()的返回結果上總可以呼叫的(可移植的)一個成員函式是name()(可以確定(注11),它返回物件的動態型別的名字)。於是,實現lookup,僅僅是根據形參的動態型別在collisionMap中找到它的對應項、
lookup的程式碼很簡單,但如果不熟悉標準模板庫的話(再次參見Item M35),就不會怎麼簡單了。別擔心,程式中的註釋解釋了每一步在做什麼。
SpaceShip::HitFunctionPtr
SpaceShip::lookup(const GameObject& whatWeHit)
{
  static HitMap collisionMap;        // we'll see how to
                                     // initialize this below
  // look up the collision-processing function for the type
  // of whatWeHit. The value returned is a pointer-like
  // object called an "iterator" (see Item 35).
  HitMap::iterator mapEntry=
    collisionMap.find(typeid(whatWeHit).name());
  // mapEntry == collisionMap.end() if the lookup failed;
  // this is standard map behavior. Again, see Item 35.
  if (mapEntry == collisionMap.end()) return 0;
  // If we get here, the search succeeded. mapEntry
  // points to a complete map entry, which is a
  // (string, HitFunctionPtr) pair. We want only the
  // second part of the pair, so that's what we return.
  return (*mapEntry).second;
}
最後一句是return (*mapEntry).second而不是習慣上的mapEntry->second以滿足STL的奇怪行為。具體原因見Item M18。
* 初始化模擬虛擬函式表
現在來看collisionMap的初始化。我們很想這麼做:
// An incorrect implementation
SpaceShip::HitFunctionPtr
SpaceShip::lookup(const GameObject& whatWeHit)
{
  static HitMap collisionMap;
  collisionMap["SpaceShip"] = &hitSpaceShip;
  collisionMap["SpaceStation"] = &hitSpaceStation;
  collisionMap["Asteroid"] = &hitAsteroid;
  ...
}
但,這將在每次呼叫lookup時都將成員函式指標加入了collisionMap,這是不必要的開銷。而且它不會編譯通過,不過這是將要討論的第二個問題。
我們需要的是隻將成員函式指標加入collisionMap一次,在collisionMap構造時。這很容易完成;我們只需寫一個私有的靜態成員函式initializeCollisionMap來構造和初始化我們的對映表,然後用其返回值來初始化collisionMap:
class SpaceShip: public GameObject {
private:
  static HitMap initializeCollisionMap();
  ...
};
SpaceShip::HitFunctionPtr
SpaceShip::lookup(const GameObject& whatWeHit)
{
  static HitMap collisionMap = initializeCollisionMap();
  ...
}
不過這意味著我們要付出拷貝賦值的代價(見Item M19和M20)。我們不想這麼做。如果initializeCollisionMap()返回指標的話,我們就不需要付出這個代價,但這樣就需要擔心指標指向的map物件是否能在恰當的時候被析構了。
幸好,有個兩全的方法。我們可以將collisionMap改為一個靈巧指標(見Item M28)它將在自己被析構時delete所指向的物件。實際上,標準C++執行庫提供的模板auto_ptr,正是這樣的一個靈巧指標(見Item M9)。通過將lookup中的collisionMap申明為static的auto_ptr,我們可以讓initializeCollisionMap返回一個指向初始化了的map物件的指標了,不用再擔心資源洩漏了;collisionMap指向的map物件將在collisinMap自己被析構時自動析構。於是:
class SpaceShip: public GameObject {
private:
  static HitMap * initializeCollisionMap();
  ...
};
SpaceShip::HitFunctionPtr
SpaceShip::lookup(const GameObject& whatWeHit)
{
  static auto_ptr<HitMap>
    collisionMap(initializeCollisionMap());
  ...
}
實現initializeCollisionMap的最清晰的方法看起來是這樣的:
SpaceShip::HitMap * SpaceShip::initializeCollisionMap()
{
  HitMap *phm = new HitMap;
  (*phm)["SpaceShip"] = &hitSpaceShip;
  (*phm)["SpaceStation"] = &hitSpaceStation;
  (*phm)["Asteroid"] = &hitAsteroid;
  return phm;
}
但和我在前面指出的一樣,這不能編譯通過。因為HitMap被申明為包容一堆指向成員函式的指標,它們全帶同樣的引數型別,也就是GameObject。但,hitSpaceShip帶的是一個spaceShip引數,hitSpaceStation帶的是SpaceStation,hitAsteroid帶的是Asteroid。雖然SpaceShip、SpaceStation和Asteroid能被隱式的轉換為GameObject,但對帶這些引數型別的函式指標可沒有這樣的轉換關係。
為了擺平你的編譯器,你可能想使用reinterpret_casts(見Item M2),而它在函式指標的型別轉換中通常是被捨棄的:
// A bad idea...
SpaceShip::HitMap * SpaceShip::initializeCollisionMap()
{
  HitMap *phm = new HitMap;
  (*phm)["SpaceShip"] =
    reinterpret_cast<HitFunctionPtr>(&hitSpaceShip);
  (*phm)["SpaceStation"] =
    reinterpret_cast<HitFunctionPtr>(&hitSpaceStation);
  (*phm)["Asteroid"] =
    reinterpret_cast<HitFunctionPtr>(&hitAsteroid);
  return phm;
}
這樣可以編譯通過,但是個壞主意。它必然伴隨一些你絕不該做的事:對你的編譯器撒謊。告訴編譯器,hitSpaceShip、hitSpaceStation和hitAsteroid期望一個GameObject型別的引數,而事實不是這樣的。hitSpaceShip期望一個SpaceShip,hitSpaceStation期望一個SpaceStation,hitAsteroid期望一個Asteroid。這些cast說的是其它東西,它們撒謊了。
不只是違背了原則,這兒還有危險。編譯器不喜歡被撒謊,當它們發現被欺騙後,它們經常會找出一個報復的方法。這此處,它們很可能通過產生錯誤的程式碼來報復你,當你通過*phm呼叫函式,而相應的GameObject的派生類是多重繼承的或有虛基類時。如果SpaceStation。SpaceShip或Asteroid除了GameObject外還有其它基類,你可能會發現當你呼叫你在這兒搜尋到的碰撞處理函式時,其行為非常的粗暴。
再看一下Item M24中描述的A-B-C-D的繼承體系以及D的物件的記憶體佈局。
            A                       B Data Members
           / /                           vptr
          /   /              Pointer to virtual base clss
         B     C                    C Data Members
          /   /                          vptr
           / /               Pointer to virtual base class
            D                       D Data Members
                                    A Data Members
                                         vptr
D中的四個類的部分,其地址都不同。這很重要,因為雖然指標和引用的行為並不相同(見Item M1),編譯器產生的程式碼中通常是通過指標來實現引用的。於是,傳引用通常是通過傳指標來實現的。當一個有多個基類的物件(如D的物件)傳引用時,最重要的就是編譯器要傳遞正確的地址--匹配於被調函式申明的形參型別的那個。
但如果你對你的編譯器撒謊說你的函式期望一個GameObject而實際上要的是一個SpaceShip或一個SpaceStation時,發生什麼?編譯器將傳給你錯誤的地址,導致執行期錯誤。而且將非常難以定位錯誤的原因。有很多很好的理由說明為什麼不建議使用型別轉換,這是其中之一。
OK,不使用型別轉換。但函式指標型別不匹配的還沒解決只有一個辦法:將所有的函式都改為接受GameObject型別:
class GameObject {                    // this is unchanged
public:
  virtual void collide(GameObject& otherObject) = 0;
  ...
};
class SpaceShip: public GameObject {
public:
  virtual void collide(GameObject& otherObject);
  // these functions now all take a GameObject parameter
  virtual void hitSpaceShip(GameObject& spaceShip);
  virtual void hitSpaceStation(GameObject& spaceStation);
  virtual void hitAsteroid(GameObject& asteroid);
  ...
};
我們基於虛擬函式解決二重排程問題的方法中,過載了叫collide的函式。現在,我們理解為什麼這兒沒有照抄而使用了一組成員函式指標。所有的碰撞處理函式都有著相同的引數型別,所以必要給它們以不同的名字。
現在,我們可以以我們一直期望的方式來寫initializeCollisionMap函數了:
SpaceShip::HitMap * SpaceShip::initializeCollisionMap()
{
  HitMap *phm = new HitMap;
  (*phm)["SpaceShip"] = &hitSpaceShip;
  (*phm)["SpaceStation"] = &hitSpaceStation;
  (*phm)["Asteroid"] = &hitAsteroid;
  return phm;
}
很遺憾,我們的碰撞函式現在得到的是一個更基本的CameObject引數而不是期望中的派生類型別。要想得到我們所期望的東西,必須在每個碰撞函式開始處採用dynamic_cast(見Item M2):
void SpaceShip::hitSpaceShip(GameObject& spaceShip)
{
  SpaceShip& otherShip=
    dynamic_cast<SpaceShip&>(spaceShip);
  process a SpaceShip-SpaceShip collision;
}
void SpaceShip::hitSpaceStation(GameObject& spaceStation)
{
  SpaceStation& station=
    dynamic_cast<SpaceStation&>(spaceStation);
  process a SpaceShip-SpaceStation collision;
}
void SpaceShip::hitAsteroid(GameObject& asteroid)
{
  Asteroid& theAsteroid =
    dynamic_cast<Asteroid&>(asteroid);
  process a SpaceShip-Asteroid collision;
}
如果轉換失敗,dynamic_cast會丟擲一個bad_cast異常。當然,它們從不會失敗,因為碰撞函式被呼叫時不會帶一個錯誤的引數型別的。只是,謹慎一些更好。
* 使用非成員的碰撞處理函式
我們現在知道了怎麼構造一個類似vtbl的對映表以實現二重排程的第二部分,並且我們也知道了怎麼將對映表的實現細節封裝在lookup函式中。因為這張表包含的是指向成員函式的指標,所以在增加新的GameObject型別時仍然需要修改類的定義,這還是意味著所有人都必須重新編譯,即使他們根本不關心這個新的型別。例如,如果增加了一個Satellite型別,我們不得不在SpaceShip類中增加一個處理SpaceShip和Satellite物件間碰撞的函式。所有SpaceShip的使用者不得不重新編譯,即使他們根本不在乎Satellite物件的存在。這個問題將導致我們否決只使用虛擬函式來實現二重排程,解決方法是隻需做小小的修改。
如果對映表中包含的指標指向非成員函式,那麼就沒有重編譯問題了。而且,轉到非成員的碰撞處理函式將讓我們發現一個一直被忽略的設計上的問題,就是,應該在哪個類裡處理不同型別的物件間的碰撞?在前面的設計中,如果物件1和物件2碰撞,而正巧物件1是processCollision的左邊的引數,碰撞將在物件1的類中處理;如果物件2正巧是左邊的引數,碰撞就在物件2的類中處理。這個有特別的含義嗎?是不是這樣更好些:型別A和型別B的物件間的碰撞應該既不在A中也不在B中處理,而在兩者之外的某個中立的地方處理?
如果將碰撞處理函式從類裡移出來,我們在給使用者提供類定義的標頭檔案時,不用帶上任何碰撞處理函式。我們可以將實現碰撞處理函式的檔案組織成這樣:
#include "SpaceShip.h"
#include "SpaceStation.h"
#include "Asteroid.h"
namespace {                     // unnamed namespace - see below
  // primary collision-processing functions
  void shipAsteroid(GameObject& spaceShip,
                    GameObject& asteroid);
  void shipStation(GameObject& spaceShip,
                   GameObject& spaceStation);
  void asteroidStation(GameObject& asteroid,
                       GameObject& spaceStation);
  ...
  // secondary collision-processing functions that just
  // implement symmetry: swap the parameters and call a
  // primary function
  void asteroidShip(GameObject& asteroid,
                    GameObject& spaceShip)
  { shipAsteroid(spaceShip, asteroid); }
  void stationShip(GameObject& spaceStation,
                   GameObject& spaceShip)
  { shipStation(spaceShip, spaceStation); }
  void stationAsteroid(GameObject& spaceStation,
                       GameObject& asteroid)
  { asteroidStation(asteroid, spaceStation); }
  ...
  // see below for a description of these types/functions
  typedef void (*HitFunctionPtr)(GameObject&, GameObject&);
  typedef map< pair<string,string>, HitFunctionPtr > HitMap;
  pair<string,string> makeStringPair(const char *s1,
                                     const char *s2);
  HitMap * initializeCollisionMap();
  HitFunctionPtr lookup(const string& class1,
                        const string& class2);
} // end namespace
void processCollision(GameObject& object1,
                        GameObject& object2)
{
  HitFunctionPtr phf = lookup(typeid(object1).name(),
                              typeid(object2).name());
  if (phf) phf(object1, object2);
  else throw UnknownCollision(object1, object2);
}
注意,用了無名的名稱空間來包含實現碰撞處理函式所需要的函式。無名名稱空間中的東西是當前編譯單元(其實就是當前檔案)私有的--很象被申明為檔案範圍內static的函式一樣。有了名稱空間後,檔案範圍內的static已經不贊成使用了,你應該儘快讓自己習慣使用無名的名稱空間(只要編譯器支援)。
理論上,這個實現和使用成員函式的版本是相同的,只有幾個輕微區別。第一,HitFunctionPtr現在是一個指向非成員函式的指標型別的重定義。第二,意料之外的類CollisionWithUnknownObject被改叫UnknownCollision,第三,其建構函式需要兩個物件作引數而不再是一個了。這也意味著我們的對映需要三個訊息了:兩個型別名,一個HitFunctionPtr。
標準的map類被定義為只處理兩個資訊。我們可以通過使用標準的pair模板來解決這個問題,pair可以讓我們將兩個型別名捆綁為一個物件。藉助makeStringPair的幫助,initializeCollisionMap的實現如下:
// we use this function to create pair<string,string>
// objects from two char* literals. It's used in
// initializeCollisionMap below. Note how this function
// enables the return value optimization (see Item 20).
namespace {          // unnamed namespace again - see below
  pair<string,string> makeStringPair(const char *s1,
                                     const char *s2)
  { return pair<string,string>(s1, s2);   }
} // end namespace
namespace {          // still the unnamed namespace - see below
  HitMap * initializeCollisionMap()
  {
    HitMap *phm = new HitMap;
    (*phm)[makeStringPair("SpaceShip","Asteroid")] =
      &shipAsteroid;
    (*phm)[makeStringPair("SpaceShip", "SpaceStation")] =
      &shipStation;
  ...
  return phm;
}
} // end namespace
lookup函式也必須被修改以處理pair<string,string>物件,並將它作為對映表的第一部分:
namespace {          // I explain this below - trust me
  HitFunctionPtr lookup(const string& class1,
                        const string& class2)
  {
    static auto_ptr<HitMap>
      collisionMap(initializeCollisionMap());
    // see below for a description of make_pair
    HitMap::iterator mapEntry=
      collisionMap->find(make_pair(class1, class2));
    if (mapEntry == collisionMap->end()) return 0;
    return (*mapEntry).second;
  }
} // end namespace
這和我們以前寫的程式碼幾乎一樣。唯一的實質性不同就是這個使用了make_pair函式的語句:
HitMap::iterator mapEntry=
  collisionMap->find(make_pair(class1, class2));
make_pair只是標準執行庫中的一個轉換函式(模板)(見Item E49和Item M35),它使得我們避免了在構造pair物件時需要申明型別的麻煩。我們本來要這樣寫的:
HitMap::iterator mapEntry=
  collisionMap->find(pair<string,string>(class1, class2));
這樣寫需要多敲好多字,而且為pair申明型別是多餘的(它們就是class1和class2的型別),所以make_pair的形式更常見。
因為makeStringPair、initializeCollisionMap和lookup都是申明在無名的名稱空間中的,它們的實現也必須在同一名稱空間中。這就是為什麼這些函式的實現在上面被寫在了一個無名名稱空間中的原因(必須和它們的申明在同一編譯單元中):這樣連結器才能正確地將它們的定義(或說實現)與它們的前置申明關聯起來。
我們最終達到了我們的目的。如果增加了新的GaemObject的子類,現存類不需要重新編譯(除非它們用到了新類)。沒有了RTTI的混亂和if...then...else的不可維護。增加一個新類只需要做明確定義了的區域性修改:在initializeCollisionMap中增加一個或多個新的對映關係,在processCollision所在的無名的名稱空間中申明一個新的碰撞處理函式。我們花了很大的力氣才走到這一步,但至少努力是值得的。是嗎?是嗎?
也許吧。
* 繼承與模擬虛擬函式表
我們還有最後一個問題需要處理。(如果,此時你奇怪老有最後一個問題要處理,你將認識到設計一個虛擬函式體系的難度。)我們所做的一切將工作得很好,只要我們不需要在呼叫碰撞處理函式時進行向基類對映的型別轉換。假設我們開發的這個遊戲某些時刻必須區分貿易飛船和軍事飛船,我們將對繼承體系作如下修改,根據Item M33的原則,將實體類CommercialShip和MilitaryShip從抽象類SpaceShip繼承。
           GameObject
            |  |  |
           /   |   /
          /    |    /
         /     |     /
        /      |      /
SpaceShip SpaceStation Asteroid
           /        /
          /          /
 Commercial Ship  Military Ship
假設貿易飛船和軍事飛船在碰撞過程中的行為是一致的。於是,我們期望可以使用相同的碰撞處理函式(在增加這兩類以前就有的那個)。尤其是,在一個MilitaryShip物件和一個Asteroid物件碰撞時,我們期望呼叫
void shipAsteroid(GameObject& spaceShip,
                  GameObject& asteroid);
它不會被呼叫的。實際上,拋了一個UnknownCollision的異常。因為lookup在根據型別名“MilitaryShip”和“Asteroid”在collisionMap中查詢函式時沒有找到。雖然MilitaryShip可以被轉換為一個SpaceShip,但lookup卻不知道這一點。
而且,沒有沒有一個簡單的辦法來告訴它。如果你需要實現二重排程,並且需要這兒的向上型別對映,你只能採用我們前面討論的二次虛擬函式呼叫的方法(同時也意味著增加新類的時候,所有人都必須重新編譯)。
* 初始化模擬虛擬函式表(再次討論)
這就是關於二重排程的所有要說的,但是,用如此悲觀的條款來結束是令人很不愉快的。因此,讓我們用概述初始化collisionMap的兩種方法來結束。
按目前情況來看,我們的設計完全是靜態的。每次我們註冊一個碰撞處理函式,我們就不得不永遠留著它。如果我們想在遊戲執行過程中增加、刪除或修改碰撞處理函式,將怎麼樣?不提供。
但是是可以做到的。我們可以將對映表放入一個類,並由它提供動態修改對映關係的成員函式。例如:
class CollisionMap {
public:
  typedef void (*HitFunctionPtr)(GameObject&, GameObject&);
  void addEntry(const string& type1,
                const string& type2,
                HitFunctionPtr collisionFunction,
                bool symmetric = true);               // see below
  void removeEntry(const string& type1,
                   const string& type2);
  HitFunctionPtr lookup(const string& type1,
                        const string& type2);
  // this function returns a reference to the one and only
  // map - see Item 26
  static CollisionMap& theCollisionMap();
private:
  // these functions are private to prevent the creation
  // of multiple maps - see Item 26
  CollisionMap();
  CollisionMap(const CollisionMap&);
};
這個類允許我們在對映表中進行增加和刪除操作,以及根據型別名對查詢相應的碰撞處理函式。它也使用了Item E26中講的技巧來限制CollisionMap物件的個數為1,因為我們的系統中只有一個對映表。(更復雜的遊戲需要多張對映表是可以想象到的。)最後,它允許我們簡化在對映表中增加對稱性的碰撞(也就是說,型別T1的物件撞擊T2的物件和T2的物件撞擊T1的物件,其效果是相同的。)的過程,它自動增加對稱的對映關係,只要addEntry被呼叫時可選引數symmetric 被設為true。
藉助於CollisionMap類,每個想增加對映關係的使用者可以直接這麼做:
void shipAsteroid(GameObject& spaceShip,
                  GameObject& asteroid);
CollisionMap::theCollisionMap().addEntry("SpaceShip",
                                         "Asteroid",
                                         &shipAsteroid);
void shipStation(GameObject& spaceShip,
                 GameObject& spaceStation);
CollisionMap::theCollisionMap().addEntry("SpaceShip",
                                         "SpaceStation",
                                         &shipStation);
void asteroidStation(GameObject& asteroid,
                     GameObject& spaceStation);
CollisionMap::theCollisionMap().addEntry("Asteroid",
                                         "SpaceStation",
                                         &asteroidStation);
...
必須確保在發生碰撞前就將對映關係加入了對映表。一個方法是讓GameObject的子類在建構函式中進行確認。這將導致在執行期的一個小小的效能開銷。另外一個方法是建立一個RegisterCollisionFunction 類:
class RegisterCollisionFunction {
public:
  RegisterCollisionFunction(
          const string& type1,
          const string& type2,
          CollisionMap::HitFunctionPtr collisionFunction,
          bool symmetric = true)
  {
    CollisionMap::theCollisionMap().addEntry(type1, type2,
                                             collisionFunction,
                                             symmetric);
  }
};
使用者於是可以使用此型別的一個全域性物件來自動地註冊他們所需要的函式:
RegisterCollisionFunction cf1("SpaceShip", "Asteroid",
                              &shipAsteroid);
RegisterCollisionFunction cf2("SpaceShip", "SpaceStation",
                              &shipStation);
RegisterCollisionFunction cf3("Asteroid", "SpaceStation",
                              &asteroidStation);
...
int main(int argc, char * argv[])
{
  ...
}
因為這些全域性物件在main被呼叫前就構造了,它們在建構函式中註冊的函式也在main被呼叫前就加入對映表了。如果以後增加了一個派生類
class Satellite: public GameObject { ... };
以及一個或多個碰撞處理函式
void satelliteShip(GameObject& satellite,
                   GameObject& spaceShip);
void satelliteAsteroid(GameObject& satellite,
                       GameObject& asteroid);
這些新函式可以用同樣方法加入對映表而不需要修改現存程式碼:
RegisterCollisionFunction cf4("Satellite", "SpaceShip",
                              &satelliteShip);
RegisterCollisionFunction cf5("Satellite", "Asteroid",
                              &satelliteAsteroid);
這不會改變實現多重排程沒有完美解決方法的事實。但它使得容易提供資料給基於map的實現,如果我們認為這種實現最接近我們的需要的話。
* 注11:
要指出的是,不是那麼可完全確定的。C++標準並沒有規定type_info::name的返回值,不同的實現,其行為會有區別。(例如,對於類Spaceship,type_info::name的一個實現返回“class SpaceShip”。)更好的設計是通過它所關聯的type_info物件的地址了鑑別一個類,因為每個類關聯的type_info物件肯定是不同的。HitMap於是應該被申明為map<cont type_info *, HitFunctionPtr>。