[python] [VI coding] 第十八章 繼承 - 教學區

[python] [VI coding] 第十八章 繼承

文章瀏覽次數 1308

特種兵

特種兵圖像(預設)

2021-11-12 17:27:09

From:211.23.21.202

第十八章 繼承

繼承是物件導向程式中經常被使用的特性,當新類別具有原類別之特性,但又有一些與原類別不同之處時,可以讓新類別繼承自原類別。

可將新類別視為原類別的修改版。本章將以一副牌與一手牌的兩個類別來演練繼承的概念。

在本章中提到的紙牌都是撲克牌,「牌堆」是指一副完整的牌,或者整副牌扣掉發給玩家後剩下的那些牌而言。

18-0 類別與物件屬性

「物件屬性」或「實例屬性」是相同的概念,上一章在「動動腦」貼上了類別與物件的屬性測試範例,有幾位學員對這個主題還是有些困惑,讓我們先仔細探討一下再進行本章的課程。

這裡將使用較實際的小例子加上說明,再慢慢讓類別複雜化或製造屬性的衝突,藉以釐清概念。

class Salary():
	''' 薪水類別 '''
	income = 22000  # 底薪 22k
	def __init__(self):
		pass  # 不做任何事

# 小明的薪水
ming = Salary()
print(Salary.income)
# 22000 類別屬性
print(ming.income)
# 22000 物件屬性

底薪 22000 為類別屬性,但為什麼物件屬性也是 22000 呢?

在 Salary 的類別中,並沒有定義物件屬性 income,此時 python 會先尋找是否有同名的物件屬性,此例是沒有。接著找是否有同名的類別屬性,結果是有的。

如果沒有,則會去父類別尋找同名的類別屬性,還是沒有,那才會報錯,所以 python 有找到同名屬性。顯示出來,就會如同上述的程式碼。

但物件屬性並沒有被初始化,沒有的就不會有,下次要再顯示,python 還是會依照上述規則再去找一次:

# 底薪加一萬
Salary.income += 10000
print(Salary.income)
# 32000 類別屬性被累加了
print(ming.income)
# 32000 因為沒有物件屬性,所以又去找一次類別屬性,這時類別屬性的值是 32000

這表示物件屬性並沒有在之前被初始化成 22000,如果有的話,就不會再去找了。

那反過來要證明的,是物件屬性找到類別屬性後,是不是就變成那個類別屬性了。特別是剛剛的範例,很容易讓人誤會成它們都是同一個類別屬性,但其實不是。

ming.income = 40000
print(ming.income)
# 40000 物件屬性被初始化了
print(Salary.income)
# 32000 類別屬性還是一樣沒變

由上例可看出,物件屬性並沒有因為任何操作或 python 去尋找同名屬性而變成類別屬性,當我們改變或為物件屬性初始化時,只是針對該物件屬性而已。

另外,Salary.income 在類別之外建議寫成 ming.__class__.income,可以避免類別被更名而漏改相關敘述的狀況。在物件裡就寫成 self.__class__.income,特別是在物件裡的定義,很容易沒修改到而產生錯誤。

如此一來,在 Salary 就同時擁有兩個 income 屬性,一個是類別屬性,一個是物件屬性。接下來,累加屬性的值:

# 重新實例化小明的薪水類別
ming = Salary()
# 小明表現良好,底薪從下個月開始多給 3000
ming.income += 3000
print(ming.income)
# 25000 物件屬性
print(ming.__class__.income)
# 22000 類別屬性

沒有物件屬性時,從類別屬性找到了 22000,加上 3000 後,因為有等號,所以把值給了物件屬性,也就是初始化它。

由上例可知,ming.income 不管怎樣也不會操作或影響到類別屬性,頂多是值看起來相同,但記憶體位置仍不同。

接著來改變類別屬性的值,影響的範圍會如何?

# 小華的薪水,實例化 Salary 類別
hua = Salary()
# 累加小明薪水類別屬性
ming.__class__.income += 5000
print(ming.income)
# 25000 因為上例已經初始化物件屬性,所以改變類別屬性,並不影響物件屬性的值
print(ming.__class__.income)
# * 27000 類別屬性在剛才被累加了
print(hua.__class__.income)
# * 27000 同樣的類別擁有相同的類別屬性,改變其中一個,所有類別屬性都變了。更精確地說,類別屬性永遠只有一份
print(Salary.income)
# * 27000 類別屬性
print(hua.income)
# 27000 因為 hua 物件屬性 income 不存在,所以會去找同名的類別屬性來顯示

結果與我們預期的相同,累加類別屬性並不影響物件屬性的值,它們是分開的。

請注意,上例中,數字前打星號的都是指同一個類別屬性。改寫一下 Salary 類別,在裡面加入另一個 income 屬性:

class Salary():
	''' 薪水類別 '''
	income = 22000  # 底薪 22k 類別屬性
	def __init__(self):
		income = 30000  # 區域變數
	def showIncome(self):
		print('self.income =', self.income)  # 物件屬性不存在
		print('income =', income)  # 這個方法中並沒有 income 區域變數

# 小明的薪水
ming = Salary()
ming.showIncome()
# 執行結果
self.income = 22000  # 由前文可知,顯示的值來自於類別屬性,因為我們沒有定義物件屬性 self.income
Traceback (most recent call last):
  File "D:\test.py", line 13, in <module>
    ming.showIncome()
  File "D:\test.py", line 9, in showIncome
    print('income =', income)
NameError: name 'income' is not defined
# 名稱錯誤:income 沒有被定義

在 showIncome 方法中,的確沒有定義 income 屬性或變數,因為 income 是一般的區域變數,所以 python 不會去其他方法找。在 showIncome 方法的定義中,因為有 self 參數,就會把整個物件帶進來,而 self.__class__.income (類別屬性) 不需要傳遞,就可以在類別中自由使用。

至於 __init__ 裡的 income 在 __init__ 方法結束就消失了,除非當成參數傳遞給其他方法。self 傳遞的是整包物件,並不會傳入方法的區域變數。

最後,整理如下:

# 類別屬性
Salary.income = Self.__class__.income
# 物件屬性
self.income
# 一般區域變數
income

上面的觀念清楚後,回頭檢視上一章動動腦的第一部分,並加上註解分析:

class Test():
	a = 123  # 類別屬性
	def __init__(self):
		pass  # 沒有宣告物件屬性,沒做任何事
	def pr(self):
		print(self.a)  # 被調用時會去尋找類別屬性的值

t1 = Test()
t2 = Test()
t1.pr()
# 123 物件屬性,值是找出來的類別屬性值
t2.pr()
# 123 物件屬性,值是找出來的類別屬性值
print(Test.a)
# 123 類別屬性
t1.a = 10 # t1 初始化物件屬性的值,只針對 t1 這個物件而已
t1.pr()
# 10 因為物件屬性已經被初始化為 10 了,所以不會再往上找
t2.pr()
# 123 這裡 t2 並沒有初始化物件屬性的值,所以還是與類別屬性的值相同
print(Test.a)
# 123 我們並沒有改變過類別屬性的值,所以值不變
Test.a = 100  # 改變了類別屬性的值,會影響同一類別的所有物件中的類別屬性
t1.pr()
# 10 別忘了,t1 的物件屬性已經在之前初始化為 10 了
t2.pr()
# 100 t2 一直沒有初始化物件屬性的值,所以會去找類別屬性的值,而類別屬性的值剛才被我們改變了
print(Test.a)
# 100 類別屬性
t2.a = 1000 # t2 初始化物件屬性的值,只針對 t2 這個物件而已
t1.pr()
# 10 還是 10,因為物件屬性是不會相互影響的
t2.pr()
# 1000 剛剛已經初始化物件屬性了
print(Test.a)
# 100 類別屬性

上一章動動腦的第二部分也加上註解分析:

class Uest():  # 沒寫錯,因為剛剛用了 Test ,那就來個 Uest 吧
	def __init__(self):
		self.a = 123  # 初始化物件屬性,沒有類別屬性
	def pr(self):
		print(self.a)  # 顯示物件屬性

u1 = Uest()
u2 = Uest()
u1.pr()
# 123 物件屬性
u2.pr()
# 123 物件屬性
print(Uest.a)
# AttributeError: type object 'Uest' has no attribute 'a'
# 沒有定義類別屬性,當然就找不到
u1.a = 10  # u1 改變自己物件屬性的值
u1.pr()
# 10 物件屬性
u2.pr()
# 123 u2 物件屬性的值並沒有被改變

最後是上一章動動腦的第三部分,也加上註解分析:

class Bank():
	''' 銀行類別 '''
	#money = 1000 被註解掉了喔
	def addMoney(self, m):
		''' 加總存款並回傳 '''
		return self.money + m  # m 是這個方法的參數,也就是區域變數
	def printMoney(self):
		''' 顯示有多少存款 '''
		print(f'我有 {self.money} 元')

# Bank 類別沒有定義類別屬性,也沒有定義物件屬性
b = Bank()
c = Bank()
b.money = 123  # b 初始化物件屬性 money
x = b.addMoney(100)
print('x = ', x)
# 223 因為已經初始化物件屬性 money 為 123,那加上 100 就是 223,把這個值回傳給 x
b.printMoney()
# 123 因為在 addMoney 方法中,物件屬性 self.money 的值並沒有被改變,沒有等號,所以式子算完 return 回傳就結束了
print(Bank.money)
# AttributeError: type object 'Bank' has no attribute 'money'
# 沒有定義類別屬性,所以找不到
Bank.money += 100
# AttributeError: type object 'Bank' has no attribute 'money'
# 一樣,沒有定義類別屬性,沒有初始值,無法做累加運算
b.printMoney()
# 123 物件屬性並沒有被改變
c.printMoney()
# AttributeError: 'Bank' object has no attribute 'money'
# 沒有定義類別與物件屬性,在找不到物件屬性,往父類別也找不到類別屬性的情況下,只好報錯

最後,整理幾條規則供大家參考:

  • 類別屬性會影響同一類別的所有物件中的類別屬性
  • 類別屬性沒有定義就是不存在
  • 沒有初始化物件屬性,又直接使用,就會搜尋同名的屬性值,從物件 -> 類別 -> 父類別的順序開始找,找不到就報錯
  • 當物件屬性已經初始化,就不會再去找類別屬性了,因為上一點優先順序的關係
  • 物件屬性就是物件屬性,類別屬性還是類別屬性,意思就是操作物件屬性不可能變成操作類別屬性,反之亦然

18-1 紙牌物件

一副牌有52張,包含4種花色及1到13點。每張牌上有一種花色跟一個點數。

4種花色由大到小分別為:黑桃、紅心、方塊、梅花,13點分別為:A、2、3、4、5、6、7、8、9、10、J、Q、K。依據遊戲的不同,有時 A 會大於 K 且小於 2 ,所以在比較大小時,要特別注意該遊戲本身的規則。

如果要定義紙牌類別,裡面需要兩個屬性,很明顯地分別是花色與點數,但不明顯之處是需要用哪一種型態較佳。

一個可能是使用字串,包含花色與點數 J、Q、K 等。這些文字都可以很順利地存進屬性,但在執行上遇到的問題是不方便做大小的比較。

另外一個選擇是使用整數來對花色與點數做編碼,編碼是指使用數字來替代花色與點數,下列是整數與花色的編碼對照:

  • 黑桃 -> 3
  • 紅心 -> 2
  • 方塊 -> 1
  • 梅花 -> 0

如此可以方便地比較花色的大小,剛好整數的大小就等於實際花色的大小。

整數要對應點數也很簡單,大部分的牌就按照牌本身的點數,只是要特別注意這幾張牌是例外:

  • A -> 1
  • J -> 11
  • Q -> 12
  • K -> 13

最後,紙牌的類別定義如下:

class Card:
	''' 代表撲克牌 '''
	def __init__(self, s=0, r=2):
		self.suit = s
		self.rank = r

__init__ 初始化方法使用了有預設值的參數寫法,若沒有給定預設值時,它會是「梅花 2」這張牌。

現在可以產生撲克牌了,想要什麼花色與點數,都可以帶進紙牌物件中,下一節再來處理顯示部分:

q_of_d = Card(1, 12)

18-2 類別屬性

需要使用實際玩牌的方式來顯示紙牌,我們很自然地會使用字串列表,那就給一個列表作為類別屬性:

# 在紙牌類別中
	suit_names = ['梅花', '方塊', '紅心', '黑桃']
	rank_names = [None, 'A', '2', '3', '4', '5', '6', '7', '8', '9', '10', 'J', 'Q', 'K']
	def __str__(self):
		return '%s %s' % (Card.suit_names[self.suit], Card.rank_names[self.rank])

像 suit_names 與 rank_names 這樣的變數,被定義在 Card 裡,而在所有方法外,那就是類別屬性,它與 Card 類別直接關聯。

而 self.suit 與 self.rank 稱為實例屬性或物件屬性,因為它們與特定的物件實例相關聯。

這兩種屬性都是使用點符號 (.) 來呼叫,以 __str__ 方法來說,self 代表 Card 物件,self.rank 就是自己這個類別的物件屬性。相對地,Card 是一個類別,Card.rank_names 就是字串列表的類別屬性。

每一個 card 物件都有自己的 self.suit 與 self.rank,但它們的 suit_names 與 rank_names 都是同一個。

Card.suit_names[self.suit] 是使用物件屬性 suit 當作類別屬性 suit_names 的索引,並且對應正確的字串來顯示。

rank_names 的第一個元素是 None,因為沒有 0 點的牌,這樣做可以保持索引對應的完整性,讓整數 2 可以對應到字串 2,才不會發生索引與實際數字少 1 的狀況。

之後也可以考慮使用字典來代替字串實作,現在來呈現一下紙牌的內容:

>>> card1 = Card(2, 11)
>>> print(card1)
紅心 J

結果沒問題,可以繼續延伸到下一節。

18-3 比較紙牌

使用內建的型別可以利用比較運算子 (<, >, ==) 等來比較大小,但如果是自己定義的型態呢?就像撲克牌。

看來得改寫這些運算子的原型方法來覆蓋原有的,像是 __lt__ 對應到小於運算子的行為等。

__lt__ 方法接受兩個參數,分別是 self 與 other,如果 self 確實小於 other 就回傳 True,否則回傳 False。

在撲克牌的世界裡,因為不同的遊戲,所以比大小的規則也不一樣。先以梅花 3 和方塊 2 為例,哪張牌比較大?

以點數而言,3 比 2 大;但以花色來說,方塊會比梅花大。所以必須先決定,花色或點數哪個比較重要。

在此為求簡單,直接決定花色永遠比點數重要,也就是先比花色,再比點數。按照這個規則,方塊 2 會大於梅花 3。

這就是為什麼我們得自己撰寫比較運算子的方法,因為比較的方式與常規不同。決定好比大小規則後,就可以開始寫 __lt__ 方法了:

# 在 Card 類別中
	def __lt__(self, other):
		# 先比較花色
		if self.suit < other.suit: return True
		if self.suit > other.suit: return False
		# 花色相同,再比較點數
		return self.rank < other.rank

可以使用數組讓整個方法更簡潔:

# 在 Card 類別中改寫 __lt__ 方法
	def __lt__(self, other):
		t1 = self.suit, self.rank
		t2 = other.suit, other.rank
		return t1 < t2

18-4 整副牌類別

現在我們有 Card 類別,接著要來產生一副牌就不難了。產生一個 Deck 類別,在初始方法用列表來存放 52 張牌。

class Deck:
	''' 代表一副牌 '''
	def __init__(self):
		self.cards = []
		for suit in range(4):
			for rank in range(1, 14):
				card = Card(suit, rank)
				self.cards.append(card)

使用巢狀迴圈來初始化 52 張牌較簡單,外層迴圈是花色 0 到 3,內層迴圈是點數 1 到 13。

每一張牌都是 card 物件實例,將每張牌都添加到列表中就完成了。

18-5 顯示整副牌

這是 Deck 類別的 __str__ 方法:

# 在 Deck 類別中
	def __str__(self):
		res = []
		for card in self.cards:
			res.append(str(card))
		return '\n'.join(res)

這個方法用很有效率的方式來累積一個大字串,建立一個字串列表,並且使用了字串方法 join 將列表轉為字串,在每張牌後加上換行符號,然後回傳。

結果像這樣:

>>> deck = Deck()
>>> print(deck)
梅花 A
梅花 2
梅花 3
...
黑桃 10
黑桃 J
黑桃 Q
黑桃 K

會有52行的輸出結果,如果沒有使用換行符號,一整行會變得太冗長。等下被 Hand 繼承後,可以利用在顯示玩家的一手牌內容。

18-6 新增、刪除與洗牌方法

為了發牌,我們需要一個 remove 方法,從整副牌移除一張牌並回傳。列表的 pop 方法剛好符合這種需求。

# 在 Deck 方法中
	def popCard(self):
		return self.cards.pop()

發牌的分解動作,先從牌堆拿走一張牌,然後給玩家。使用 pop 方法刪除列表的最後一張牌,因此我們是從整副牌的最底部發牌。

至於給玩家那張牌,對玩家來說就是新增一張牌,用列表的 append 方法來完成:

# 在 Deck 類別中
	def addCard(self, card):
		self.cards.append(card)

像這兩個方法都是使用內建方法達成任務,而沒有額外再多做什麼,有時也被稱為 veneer (木皮或單板)。

接下來,利用 random 模組中的 shuffle 函數,來完成我們的洗牌方法:

# 在 Deck 類別中
	def shuffle(self):
		random.shuffle(self.cards)

別忘了,實際使用前,要在程式的最前面 import random 模組。

18-7 繼承

假設我們現在需要一個新類別來代表一手牌。一手牌就是一個玩家手中所持有的牌。

一手牌跟一副牌的相似之處,是兩者都需要初始化,也就是拿牌,而且會有增加或移除撲克牌的動作。

但一手牌不同於一副牌,一手牌會有一些不同的操作,像是比較兩個玩家,也就是兩手牌誰獲勝,或者計算一手牌的總分。

像這樣兩個類別,有一些共同點,但其中又各有不同的情況,就很適合使用繼承。

一個新類別繼承原有的類別,新的類別稱為子類別,被繼承的原類別稱為父類別:

class Hand(Deck):
	''' 代表一個玩家的一手牌 繼承自 Deck '''

這樣的定義表示,Hand 類別繼承了 Deck 類別,Hand 類別可以使用 Deck 類別的所有方法,像是 addCard 或 popCard 等。

但以這個例子來看,Hand 繼承了 Deck 的 __init__ 方法,它初始化52張牌,這不是我們想要的。玩家的一手牌應該先給一個空列表,依據不同的遊戲,再發給正確數目的牌。

如果我們撰寫了 Hand 的 初始化方法 __init__,那麼在 Hand 中,原 Deck 的 __init__ 將被覆蓋:

# 在 Hand 類別中
	def __init__(self, label=''):
		self.cards = []
		self.label = label

當建立 Hand 類別時,python 會使用 Hand 自己的 __init__ 方法:

>>> hand = Hand('玩家一')
>>> hand.cards
[]
>>> hand.label
'玩家一'

label 只是用來區別不同的玩家,其他沒有改寫的方法都會被繼承下來:

>>> deck = Deck()
>>> card = deck.popCard()
>>> hand.addCard(card)
>>> print(hand)
黑桃 K

下一步要被封裝的程式碼就是 moveCards 方法,功能是把牌從 A 移到 B:

# 在 Deck 類別中
	def moveCards(self, hand, num):
		for i in range(num):
			hand.addCard(self.popCard())

moveCards 方法接受兩個參數,分別是 hand 與 num,hand 是一個物件,而 num 是紙牌的數量。它會修改 self 與 hand 這兩個物件,然後回傳 None。

在某些撲克牌遊戲中,玩家的牌可以互相交換(抽鬼牌)。我們可以把 moveCards 應用在 self 與 Hand 或 self 與 Deck 之間。而 self 可以是 Hand 或 Deck。

繼承是一個好用的特性,一些經常重複又沒有繼承的程式,可以使用繼承的特性來改寫得更優雅,就像 moveCard 可以取代 popCard 與 addCard。

繼承可以提高程式碼的重用率,讓我們撰寫父類別後無須擔心有狀況時還得回來修改。在真實世界的狀況或問題,有時正符合繼承的特性,讓使用繼承的概念來開發程式變得直覺。

繼承雖然好用,但必須視案件情況決定是否使用。有時繼承不利於程式碼的閱讀,特別是在方法被調用時,相關的程式碼散落在各個模組中,想找到正確的定義變得困難,本章除錯章節會詳述。

最後補充,繼承的關係還不只這些,還有多層繼承、多重繼承等,想多了解的學員可看這篇 python繼承實用教學

18-8 類別特性

  • 類別中的物件也可能參考自其他類別物件,例如之前 Rectangle 包含對 Point 的引用;本章 Deck 引用了 Card 等。
  • 類別可以繼承其他類別,例如 Hand 繼承 Deck。
  • 類別可能依賴其他類別,例如把另外一個類別當作方法的參數,或者利用其他類別來做運算等。

可以參考英文講義 18-8 的類別關係圖: 本章原文

18-9 資料封裝

對於物件的規劃與開發又稱為「物件導向設計」。在定義物件時,通常會依據它們在真實或數學世界的對應關係來規劃。

但在一些情境可能比較難確定需要的物件及它們怎麼作用,此時需要一個不同的開發計劃。

就像透過封裝與一般化的過程來確認函數最終需要的介面,同樣的,也利用資料封裝來發現類別介面。

除錯

繼承會讓除錯變得困難,如前所述,當方法被調用時,很難弄清到底是哪個物件的方法被調用了,特別是類別的關係比較複雜的專案。

在 Hand 物件裡寫的函數,都會希望它在所有 Hand 物件運作良好,像是撲克牌、橋牌等。

比方在 Hand 物件呼叫了 shuffle 洗牌方法,它被定義在 Deck 類別裡,但如果 Hand 物件裡覆寫了這個方法,那麼呼叫到的版本會是覆寫的那個,這可能很便利,但也同時帶來困擾。

當有任何不確定性時,最簡單的方式就是直接在方法的開頭使用 print 來確認。

例如在 Hand 裡的 shuffle,除錯階段可以在 shuffle 的一開始加入類似 print('執行 Hand shuffle') 語句,如此便於追蹤程式的執行流程,確認是否正確。

還有個替代方案,這裡有個函數,接受兩個參數,分別是物件名稱與方法名稱(字串型態),它會回傳這個方法是在哪個類別被定義的:

def find_defining_class(obj, meth_name):
	for ty in type(obj).mro():
		if meth_name in ty.__dict__:
			return ty

馬上來練習一下:

>>> hand = Hand()
>>> find_defining_class(hand, 'shuffle')
<class '__main__.Deck'>

可以看出 shuffle 是被定義在 Deck 類別。我們是用 mro 方法來完成這個任務,mro 正是 'method resolution order' 的英文縮寫。

動動腦

跳脫這一章講義對於撲克牌類別的規劃,如果是你,會想要怎麼規劃這些類別及它們的關係?

  • 紙牌類別,就是講義原本的 Card
  • 撲克牌容器類別
    1. 新增牌方法
    2. 刪除牌方法
    3. 交換牌方法
    4. ...
  • 不管是玩家的牌或是牌堆,都是撲克牌容器類別的一個物件實例

不過這樣的架構就用不到繼承了。

習題

練習 1 檔名: 18-1.py

寫一小段程式來呼叫 moveCards 這個方法,模擬兩個玩家,一次把 5 張牌從 A 玩家移到 B 玩家。

練習 2 檔名: 18-2.py

在 Hand 類別中,寫一個計算玩家所有牌總分的方法。計分規則如下:

  • 花色為紅心與方塊的牌才算分
  • 點數 A = 50 分
  • 點數 J、Q、K = 10 分
  • 點數 2 到 10 的分數就跟點數一樣
練習 3 檔名: 18-3.py

讓兩個玩家從牌堆中各任意取得 5 張牌,寫一個方法來分別計算一手牌的分數,再寫一個方法,帶入兩個玩家來判斷是誰獲勝。

計分規則:

  • 出現一對就拿 5 分(兩張點數一樣)
  • 出現三條就拿 10 分(三張點數一樣)
  • 全部相同花色就拿 12 分
  • 拿到鐵支 20 分(四張點數都一樣)
  • 拿到順子 30 分(點數按照順序排列,如 A 2 3 4 5,或 10 J Q K A,但 J Q K A 2 不算)

注意,如果出現四張點數一樣的要算鐵支,而不是兩個一對。

影片

第十八章 繼承

最後更新:2021-11-13 08:42:29

From: 111.249.155.121

By: 特種兵