2-0

物件導向程式設計:為何說composition優於inheritance?

http://joostdevblog.blogspot.tw/2014/07/why-composition-is-often-better-than.html

學過物件導向程式設計的人,都知道繼承(inheritance)與複合(composition)這兩種觀念。前者是「is a」,後者則是「has a」的關係。近年來很多人提倡「favor composition over inheritance」,我也是支持者之一,但為何這樣,三言兩語又很難講清楚。我只能說,以前維護Java程式碼的時候,那一層又一層的繼承關係讓人腦袋發脹。

最近在整理一門JavaScript進階課程教材的過程中,剛好寫到JavaScript物件導向程式設計這個單元。我想起一年多前讀過的一篇好文:Why composition is often better than inheritance,作者Joost van Dongen是一位C++遊戲開發者,文章內容及舉的例子都很精彩,是那種有多年實戰經驗的人才寫得出來的東西。

我認為「composition over inheritance」這個觀念值得弄清楚,所以花了一些時間翻譯這篇文章,並與大家分享。


Photo credit: geraldford@flickr

以下是這篇文章的摘要翻譯。


在物件導向程式設計裡,要採用複合或繼承,是一個常見的問題。有時答案很明顯,例如「椅子有坐墊」屬於複合關係(has a),而「椅子是一種家具」則是繼承關係(is a)。但很多時候,該採用那種關係,答案並非顯而易見。例如遊戲中的某個角色,應該要「碰撞外框」(has a collision box),或者應該「是一種可碰撞物件」(is a collidable object)?這兩種不同的做法,都可以用來處理碰撞問題,到底該用哪種好呢?根據我的經驗,若順著直覺,我們傾向採用繼承,但它可能造成許多問題;在很多情況下,複合其實是更好的選擇。

讓我們來看一個實際例子。在遊戲中,我們建立了一個類別,用來處理遊戲角色的物理行為,包括重力、反彈、滑行、跳躍等等。我們可以讓Character(角色)繼承PhysicsObject(物理物件),也可以讓Character有一個PhysicsObject物件。

簡化過的程式碼大概長這樣:(CharacterInheritanceCharacterComposition分別採用繼承及複合兩種做法)

class PhysicsObject
{
  Vector2D position;

  void updatePhysics();
  void applyKnockback(Vector2D force);
  Vector2D getPosition() const;
};

// Using PhysicsObject through inheritance
class CharacterInheritance: PhysicsObject
{
  void update()
  {
    updatePhysics();
  }
};

// Using PhysicsObject through composition
class CharacterComposition
{
  PhysicsObject physicsObject;
 
  void update()
  {
    physicsObject.updatePhysics();
  }
 
  void applyKnockback(Vector2D force)
  {
    physicsObject.applyKnockback(force);
  }
 
  void getPosition() const
  {
    return physicsObject.getPosition();
  }
};

如同你看到的,CharacterInheritance的程式碼要短得多,而且看起來更自然,不需要再另外寫applyKnockbackgetPosition這類輔助方法。然而,在同時採用這兩種寫法多年之後,我學到的教訓是:複合的架構在這類情況下比起繼承更有彈性、不容易有bug,也更容易了解。

1. 彈性

讓我們從彈性(flexibility)開始講起。

假設我們想在遊戲中創造一種新的敵人角色,這種敵人由兩個物體組成,中間連著一條能量鏈,任何碰到能量鏈的人都會受到傷害。這兩個物體可以各自移動,增加打敗這種敵人的挑戰,因此使遊戲變得更吸引人。兩個物體都有各自的物理行為,碰撞其中一個物體並不影響另外一個物體,但它們實際上是同一個角色:同一組生命值,同一個AI(人工智慧),同時出現在一個小地圖(minimap)上。


Picture from Joost's Dev Blog

若我們採用複合架構,創造這種新敵人角色就變得很容易,因為我們只需讓新角色擁有幾個PhysicsObjects物件即可。若採用繼承架構,我們就沒辦法用單一角色做到,因為單一角色不能繼承PhysicsObjects兩次。我們可能會採取其它變通方式,但這很快就會使程式碼變得更複雜、更不直覺。

如果你認為這種情況不常發生,不值得擔心,那你可能還沒在一個有相當規模、「遊戲可玩性就是一切(gameplay is king)」的遊戲專案裡待過。遊戲設計者三不五時就會想出一些新的遊戲機制(game mechanics),是你目前程式架構無法處理的例外狀況。如果你說「不」,就會嚴重影響遊戲品質;畢竟最終,遊戲樂趣才是唯一的考量點(好吧,也許加上是否能在專案期限內達成)。

遊戲程式開發的一個重要目標,就是彈性:使你的程式碼具備彈性,不管遊戲設計者今天提出甚麼古怪想法,都能相對容易的加進遊戲裡。在絕大多數情況下,複合要比繼承有彈性得多。

2. 可讀性

我下一個反對繼承的論點,是可讀性(readibility)。可讀性總是伴隨著bug出現的頻率:可讀性越高的代碼,bug出現機會越低,因為如果程式員讀不懂程式,他就容易在修改代碼時把程式弄壞。

我們團隊的其中一條重要原則,是讓每個類別的代碼保持在500行內。雖然有時做不到,但它的目標很清楚:讓類別代碼相對短一些,更容易了解,以便程式員能把整個類別的運作機制裝進腦袋裡。

舉個例子,隨著很多新功能加進來,我們的代碼越長越大,CharacterPhysicsObject都長到500行了。我們還加進了Pickup(撿拾)和Projectile(投射),兩者都用到PhysicsObject,也都長到500行。

                        +-----------------+
                        |  PhysicsObject  |
                        +-----------------+
                                 ^
           +---------------------|---------------------+
           |                     |                     |
  +-----------------+   +-----------------+   +-----------------+
  |    Character    |   |     Pickup      |   |   Projectile    |
  +-----------------+   +-----------------+   +-----------------+

在這種情況下,採用繼承架構的代碼,常常令人很困惑。即使我們已盡可能將更多成員保持在私有(private)狀態,最終還是會出現越來越多的保護(protected)及虛擬(virtual)成員。在以上例子,PhysicsObject已經和它的3個子類別CharacterPickupProjectile糾纏在一起,難以分開。在我的經驗裡面,這件事情幾乎總是會發生:隨著時間過去,繼承的類別一起產生更複雜的行為,彼此糾纏得越來越緊密。

這本身還不是最大問題。現在當我們要了解其中一個類別,就必須了解它們全部:例如,當我們為了給Projectile加進一個新功能而改寫PhysicsObject,此時CharacterPickup也必須同時納入考量。要了解這整件事,程式員現在必須將4個類別、2000行程式碼都裝進他腦袋。以我個人經驗,這很快就變得不可能:程式碼已經多到沒辦法一次同時了解。結果就是代碼可讀性降低,程式員則更容易因為疏忽而造成bug出現。

當然採用複合的架構,並不會使所有問題自動神奇消失,但它確實有助於讓程式碼保持在簡單與可讀的狀態。採用複合架構,就沒有保護(protected)及虛擬(virtual)成員,所以PhysicsObject在一邊,CharacterPickupProjectile在另一邊,它們之間的界線很清楚。這避免了類別之間隨著時間纏繞在一起,使它們更容易真正保持分開。理論上,採用繼承也能保持類別分開,但根據我的經驗,這在現實上很難維持,而複合則強制你做到這一點。在我們的代碼裡有數不清的例子,採用繼承架構的類別程式碼長得太大,大到再也無法輕易理解。

菱形問題

任何有關繼承與複合的討論,如果不提菱形問題(diamond problem)就談不上完整。例如,類別A繼承B和C,而這二者同時又繼承D,會發生甚麼事?A會繼承D兩次,問題也因此產生。

           +-------+
           |   D   |
           +-------+
               ^
       +-------|-------+
       |               |
   +-------+       +-------+
   |   B   |       |   C   |
   +-------+       +-------+
       ^               ^
       +-------|-------+
               |
           +-------+
           |   A   |
           +-------+

在C++裡,菱形問題的解法有幾種。例如你可以直接接受A有兩個D,或者你也可以用虛擬繼承(virtual inheritance)。但這兩種解法都會造成一堆問題與潛在bug,所以最好是一開始就避免菱形問題。

在遊戲程式的初始架構裡,這種問題通常並不存在,但隨著新功能加入,它可能會偶爾開始出現。問題在於,當它一旦出現,通常很難有好的解法,除非做大量的程式碼重構(refactor)。

不過在我12年的物件導向程式設計經驗裡,菱形問題只出現過幾次,所以它也許不是反對繼承的強有力論點。

(順便一提,即使沒有出現菱形問題,多重繼承的架構也容易變得很骯髒,這在我之前寫的「Hardcore C++: why "this" sometimes doesn't equal "this"」文章中有探討。)

繼承可以用,但少用

這些論點,代表我整體上反對使用繼承嗎?不,絕對不是!繼承對一些事情很有用,例如多型(polymorphism),或者設計模式裡的偵聽者(listner)、工廠(factory)模式等。我的重點是,繼承不像它表面上看起來那麼好用。在多數情況下,繼承也許看似最乾淨的解法,但實務上,複合很可能才是更好的選擇。


以上就是文章的摘錄。希望這篇文章,能幫助大家了解「composition over inheritance」的真正原因!

0筆討論 回應文章