為什麼物件導向程式設計是有用的?(以一個角色扮演遊戲為例)

Janzou發表於2014-12-31

本文面向的是那些剛剛接觸程式設計,可能已經聽說過”物件導向程式設計”,”OOP”,”類”,”繼承/封裝/多型”,以及其他電腦科學術語的,但是仍然沒有真正明白如何使用OOP的朋友。在本文中,我將解釋為什麼使用OOP和如何輕鬆編碼。這篇文章使用Python 3程式碼,但其概念適用於任何程式語言。

有兩個關鍵的非物件導向程式設計概念需要馬上理解:

  1. 重複的程式碼是一件壞事。
  2. 程式碼永遠都在改變。

除了一些單任務和只執行一次的微小的”用完即棄”的程式,你幾乎總是需要為了解決bug或增加新功能而更新你的程式碼。大部分編寫良好的軟體是那種可讀性高,易於修改的軟體。

如果你經常在你的程式中複製/黏貼程式碼,那麼當你修改它的時候,就需要在很多地方做出同樣的改動。這是棘手的。如果在某些地方遺漏了修改,你將到處修改bug或者實現的新功能有不一致性。重複的程式碼是一件壞事。程式中重複的程式碼將會把你置於bug和頭痛之中。

函式讓你擺脫重複的程式碼。你只需要將程式碼寫到函式中一次,就可以在程式中的任何需要執行程式碼的地方呼叫它就可以。更新函式的程式碼就可以自動更新呼叫函式的地方。正如函式使得更新程式碼變得容易,使用物件導向程式設計技術也會組織你的程式碼使它更容易改變。記住程式碼總是在改變的。

一個角色扮演遊戲栗子

大多數OOP教程都是令人作惡的。它們有”汽車”類和”鳴笛”方法,其他一些例子與新手寫的或者之前接觸過的實際程式都是不相關的。因此本博文將使用一個RPG類視訊遊戲(回憶下魔獸,寵物小精靈,或龍與地下城的世界)。我們已經習慣於將遊戲中的事物想象成一些整數與字元的集合。看看Diablo(暗黑破壞神)角色螢幕或D&D角色表單上的數字:

 

從這些RPG視訊遊戲中去除圖片,角色,裝甲,其他物件只是變數形式的一個整數或者字元值的集合。不使用物件導向概念,你可以在Python中這樣實現這些事物:

以上變數名都是非常通用的。為了在遊戲中增加怪獸,你需要重新命名玩家角色變數,並且增加一個怪獸角色:

當然你希望有更多的怪物,接著你會有類似monster1Namemonster2Name等等的變數。這不是一種好的編碼方法,因此你可能會使用怪物變數列表:

然後列表索引0處是第一個哥布林的狀態,龍的狀態在索引1處,另一個哥布林在索引2處。這樣你可以在這些變數中存放很多怪物。

但是,這種程式碼容易導致錯誤。如果你的列表不同步,程式將無法正常工作。例如玩家擊敗了索引0處的哥布林,程式呼叫了vanquishMonster()函式。但這個函式有一個bug,它會(意外的)刪除列表中的所有值除了monsterInventory:

怪獸列表最後看起來像這樣:

現在龍的道具看起來跟之前哥布林的道具一樣。第二個哥布林的道具是之前龍的道具。遊戲迅速失控了。問題是你把一個怪物的資料散佈在多個變數中。解決這個問題的方法是將一個怪物的資料放入一個字典裡,然後使用一個字典列表:

啊哈!這段程式碼變得更加複雜了。例如,一個怪獸的狀態是一個字典列表中的字典項。假如咒語或者道具欄也有它們自己的屬性,且需要放到字典該怎麼辦?假如一個道具欄中的物品是一個揹包,它本身包含了其他道具該怎麼辦?這個怪物列表會變得緊張。

這點是物件導向程式設計通過建立一個新的資料型別就可以解決的。

建立類

類是你程式中新資料型別的藍圖。物件導向程式設計對裝甲,怪物等建模提供了新的方法,該方法比列表和字典的大雜燴好得多。雖然需要花些時間來熟悉OOP的概念。

事實上,因為英雄角色與怪獸們擁有相同的屬性(健康值,狀態值等等),我們只需要一個英雄與怪獸共享的通用的LivingThing類。你的程式碼可以變為:

嘿,瞧瞧,使用類已經把我們的程式碼削減了一半,因為我們可以對玩家角色和怪獸使用同樣的程式碼。

在上面的程式碼中,你可以定義新的資料型別/類(除了學院派,但是這兩個術語基本上是一樣的。參見Stack Overflow – What’s the difference between a type and a class?)名為LivingThing。你可以例項化LivingThing變數/物件(重複一次,這兩個術語基本上也是相同的)就好像你可以擁有整形值,字元值或布林值一樣。

上面程式碼特定於Python的細節:

上面class宣告定義了一個新類,就像def宣告定義一個新函式。該類名是LivingThing。

上面程式碼為LivingThing類定義了一個方法。”方法“只是命名屬於這個類的函式。(參考Stack Overflow – What is the difference between a method and a function?)

這個方法很特別。__init__()用於類的建構函式(或者稱為”構造方法”,”建構函式”,簡寫為”ctor”)。而一個類是一個新資料型別的藍圖,你還需要建立這個資料型別的值,以便於儲存到變數或者使用函式傳遞。

當呼叫構造器建立新物件時,執行構造器中的程式碼,並返回一個新物件。這就是

這行的意思。無論類名是什麼,構造器總是被命名為__init__。

如果類沒有__init__()方法,Python會為類提供通用的構造方法,該方法什麼都不做。但是__init__()是初始化建立一個新物件的絕佳地方。

在Python語言中,方法中的第一個變數是self。self變數用於建立成員變數,後面會做解釋。

建構函式體:

這看上去有點重複,但這段程式碼所作的就是對由建構函式建立的物件的成員變數賦值。成員變數開頭是self.表示這些成員變數屬於建立的物件,且不是函式中的普通區域性變數。

呼叫構造器

Python中呼叫構造器就像是一個函式呼叫,該函式名為類名。因此LivingThing()就是呼叫LivingThing類的__init__()構造器。

呼叫建立了一個新的LivingThing物件,並儲存在到hero變數裡。以上程式碼還建立了3個怪獸LivingThing物件並儲存在monsters列表中。

至此我們開始看到了物件導向程式設計的好處。如果其他程式設計師讀了你的程式碼,當他們看到LivingThing()呼叫,他們知道他們可以搜尋LivingThing類,然後從LivingThing類中找出所有他們想知道的細節。

但一個更大的好處是當你試圖更新LivingThing類時才能體會的。

更新類

假如你想給你的RPG增加”飢餓”度屬性。如果一個英雄或怪獸的飢餓度為0,他們一點也不餓。但如果他們的飢餓度超過100,那麼他們將受到傷害並且健康值每天遞減。你可以這樣改變__init__()函式:

不需要修改其他任何程式碼行,你遊戲中所有LivingThing物件現在都有了飢餓度。你不需要擔心某些LivingThing物件有hunger成員變數,而有些沒有:所有LivingThing物件都更新了。

你也不需要改變任何構造器呼叫,因為你沒有在__init__()函式的引數列表總中增加一個新的飢餓度引數。這是因為隊一個新的LivingThing物件的飢餓度來說0是一個很好的預設值。如果你在__init__()函式的引數列表總中增加一個新的飢餓度引數,那麼你需要更新所有呼叫構造器的程式碼。但這對其他函式也是一樣的。

如果你的RPG有很多類似的預設值,通過使用類的構造器進行預設值賦值,就可以避免當量的”樣板”程式碼。

方法

方法具有執行程式碼來影響物件本身的用途。例如,你可以編碼來直接修改LivingThing物件的健康度:

但這樣處理傷害不是一個非常健壯的方式。每當有什麼東西受到傷害時就需要檢查很多其他的遊戲邏輯。例如,假設你想要檢查一個角色在受到傷害後,它是否死亡。你需要這樣的程式碼:

以上方法的問題是你需要檢查各處程式碼來減少LivingThing物件的健康值。但是重複的程式碼是一件壞事。阻止重複的程式碼的非OOP方式可能是把以上方法放入一個函式中:

這是一個更好的解決方案,因為任何更新takeDamage()(例如裝甲防護,保護性法術,增益效果等)只需要增加到takeDamage()函式中。

然而,不利的一面是,當您的程式規模增長,takeDamage()函式很容易迷失在其中。takeDamage()函式與LivingThing類的關係並不明顯。如果你的程式有成百上千的函式,它將很難指出哪一個函式與LivingThing類有關係。

解決的方法是將這個函式變成LivingThing類的方法:

一旦你的程式有很多類,每個類有許多方法和成員變數,你將開始看到OOP可以幫助組織你的程式而使他更易於管理。

公共與私有方法

方法與成員變數可以被標示為public或private。公共方法可以和公共成員變數可以在類內部或外部的任何程式碼呼叫和賦值。私有方法和私有成員變數只能在物件自己的類內部被呼叫和賦值。

在某些語言中,例如Java,這種”可以被呼叫/賦值”由編譯器嚴格的保證。而Python,卻沒有”私有”和”公共”的概念。所有方法和成員變數都是”公共”的。然而,語言規定如果一個方法名開頭是下劃線,它就被認為是一個私有方法。這就是為什麼你將看到_takeDamage()等方法了。你可以方便的編寫程式碼從物件的類的外部呼叫私有函式或者設定私有成員變數,但你已經被徹底警告不要去這樣做了。

公共/私有的區別的原因是為了解釋類如何與外部程式碼進行互動的。(參考Stack Overflow – Why “private” methods in the object oriented?)類的程式設計師期望其他人不會編寫程式碼呼叫私有方法或設定私有成員變數。

例如,takeDamage()方法包括健康值低於0就檢查死亡。你可能想要在程式碼中新增各種各樣的其他檢查。護甲、敏捷性和防護法術來減少傷害的可能因素。LivingThing物件可能穿著一件魔法斗篷,通過增加抗損害值,而不是減少它們的健康值來進行治療。這個遊戲的所有邏輯都可以放入takeDamage()方法中。

如果你偶然的把程式碼放到那裡,所有的OOP結構就毫無意義了

語句hero.health -= 50會減少50點健康值,而不會考慮Elsa穿著哪種裝甲,或者她有防護法術,或者她穿著魔法治療披風。這段程式碼將直截了當的減少50點健康值。

很容易忘掉takeDamage()方法並且偶爾寫出這樣的程式碼。它不會檢查英雄物件的健康值是否低於0。遊戲繼續執行好像Elsa還活著,及時她的健康值是負數!我們可以使用公共/私有成員和方法來避免這個bug。

如果你重新命名health成員變數為_health且標記為私有的,那麼當你寫成這樣就很容易捕獲這個bug:

在一種語言中如Java,如果編譯器確保私有/公共訪問,它就不可能編寫非法訪問自由成員和方法的程式。物件導向程式設計幫助我們防止這種bug。

繼承

使用LivingThing類表示龍是不錯的,但是除了LivingThing類提供的屬性外,龍有很多其他的屬性。因此你想建立一個新的Dragon類,它包含如airSpeed和breathType(可以使用 'fire','blizzard''lightning''poison gas'等字串表示)等成員變數。

因為Dragon物件也包含health,magicPoints,inventory和其他LivingThing物件的屬性,你可以建立一個新的Dragon類,並且從LivingThing類複製/黏貼所有程式碼。但這將導致重複的程式碼這一壞習慣。

相反,可以使Dragon類作為LivingThing類的子類

實際上是說,”一個龍也是一種LivingThing,還有一些附加的方法和成員變數”。當你建立龍物件時,它會自動的擁有LivingThing的方法和成員變數(拯救我們脫離重複的程式碼)。但它也有龍特有的方法和成員變數。進一步說,任何處理LivingThing物件的程式碼都可以自動的操作龍物件,因為龍物件已經擁有了LivingThing成員變數和方法。這個原則被稱為子型別多型性

然而在實踐中,繼承是容易濫用的。你必須確保任何對LivingThing類做出的改變和更新都適用於Dragon類和所有其他LivingThing的子類。這可能不總是那麼簡單直接。

例如,如果你建立了LivingThing類的Monster和Hero子類,接著建立了Monster類的 FlyingMonster 和MagicalMonster子類,新的Dragon類繼承自FlyingMonster 類還是MagicalMonster類?或者可能它只是Monster類的子類

這就是繼承和OOP開始變得棘手且嚴謹的爭論哪種是”正確”設計類的方式之所在。我希望報紙這篇博文簡短而簡單,因此我要把這些問題留給讀者作為練習來調查。(參考 Stack Overflow – Prefer composition over inheritance? 和Wikipedia – Composition over inheritance)

總結

我討厭由物件導向程式設計開始的面向初學者的程式教程。OOP是十分抽象的概念。有一些經驗和編寫過大型程式之前,你不會理解為什麼使用類和物件使程式設計更容易。相反,它會給初學者留下一條陡峭的學習曲線去爬,他們不知道為什麼攀登它。我希望這個RPG例子至少讓你領略了為什麼OOP是有幫助的。有更多的OOP。如果你想了解更多,試試搜尋“python object oriented design” 和 “python design patterns”

如果你仍然對OOP概念感到迷惑,放心大膽的編寫沒有類的程式。你不需要它們,它們將導致過度設計的程式碼。但是一旦你已經有了一些編碼經驗,物件導向程式設計的好處會變得更加明顯。祝你好運!

相關文章