[python] [VI coding] 第十七章 類別與方法 - 教學區

[python] [VI coding] 第十七章 類別與方法

文章瀏覽次數 94

特種兵

特種兵圖像(預設)

2021-10-22 14:48:36

From:211.23.21.202

第十七章 類別與方法

雖然在前兩章已經開始使用物件導向的概念來設計程式,但這些還不夠。我們對於物件導向程式的特性並未善加利用,也還沒有讓程式在類別當中有更緊密的連結。

看了前兩章,你可能會覺得用類別來架構程式,似乎有點多此一舉,那是因為還沒把類別方法加進來,而且尚未學會把屬性與方法關聯好,許多類別的特性也未完全發揮。

17-1 物件導向功能

python 是一種物件導向程式的語言,含有這些物件導向的特徵與功能:

  • 定義類別與方法
  • 在方法中重新定義運算子的行為與一些運算
  • 在類別的物件通常可以對應到真實世界的一個東西,而方法就是操作這些東西的行為或方式

像是在第十六章,時間就是被真實世界定義出來的東西,即類別裡的屬性。我們對時間的處理,例如加或減,或者轉換等等的行為就是方法,由方法操作屬性。又如存款是一個類別物件,其中的金錢是屬性,存款或提款就是操作這些屬性的方法。

來看一下上一章之前時間類別的這些函數,它們至少有個共通性,就是擁有同樣的時間類別,所以應該把它們撰寫成類別的方法,來操作這些時間屬性。

之前的章節都在教大家使用原生類別的方法,例如列表有列表的方法,像是 append 添加列表元素等,數組與字典也各有其方法可以使用。本章主要是讓大家學習如何撰寫自訂類別的方法。

方法的語意很像函數,但語法上有幾點不同:

  1. 方法是定義在類別當中,通常也作用於類別與類別之間,讓它們彼此的關係更清晰
  2. 呼叫方法的語法與呼叫函數的語法不同

先針對第二點,其實已經學會了,那就是之前提過的函數與方法的不同。在後面的小節裡,我們試著一步步將上一章定義傳遞類別作為參數的函數,改寫成類別的方法,同時也會實作第一點。

17-2 列印物件

在上一章,曾經定義過一個類別叫 Time, 也寫過一個函數叫 printTime, 像這樣:

class Time:
	"""代表一天中的時間
	屬性: hour, minute, second"""

def printTime(time):
	print('%.2d:%.2d:%.2d' % (time.hour, time.minute, time.second))

論其功能,這個函數應該屬於 Time 類別之下,會較有一致性,但當時是額外定義在 class 之外,也就是當成一般的函數而已。

沒有定義成類別的方法時,若想要呼叫這個函數,必須先定義 Time 類別物件當作參數,整個傳遞進去才行:

>>> start = Time()
>>> start.hour = 9
>>> start.minute = 45
>>> start.second = 00
>>> printTime(start)
09:45:00

讓我們把 printTime 定義成 Time 類別的方法,具體的做法是這樣:

  • 將 printTime 定義移到 Time 類別內
  • 記得需要縮排
class Time:
	"""代表一天中的時間
	屬性: hour, minute, second"""
	def printTime(time):
		''' 顯示時間 iso 格式 時:分:秒'''
		print('%.2d:%.2d:%.2d' % (time.hour, time.minute, time.second))

現在,我們有兩種方式可以呼叫 printTime 方法。

第一種,使用函數呼叫語法,這比較少見:

>>> Time.printTime(start)
09:45:00

上例使用點(.)來連接,Time 是這個方法的類別,而 printTime 是這個類別下的一個方法,start 則是傳進這個方法的參數,它是類別物件的實例。

第二種,使用方法呼叫語法,這是常見的做法:

>>> start.printTime()
09:45:00

同樣是使用點(.)來連接,但 start 是類別物件的實例,可以直接調用屬於其類別下的 printTime 方法。

這個調用方式更加簡潔且直觀,也不需帶入任何參數。

按照習慣,在定義方法時,通常會把類別方法的第一個參數寫成 self ,也就是代表自己本身的類別,修改如下:

class Time:
	"""代表一天中的時間
	屬性: hour, minute, second"""
	def printTime(self):
		''' 顯示時間 iso 格式 時:分:秒'''
		print('%.2d:%.2d:%.2d' % (self.hour, self.minute, self.second))

這兩種呼叫的語法,使用敘述來解釋,會像以下的說明,並附上原文:

  1. 第一種稱為函數調用,描述是: printTime 給你一個物件,請列印一下時間
    原文: Hey printTime! Here’s an object for you to print
  2. 第二種稱為方法調用,描述是: start 物件請自行列印時間
    原文: Hey start! Please print yourself

大家可以練習一下,把上一章的 timeToInt 改成方法看看,如果沒有修改,下一節的範例將會無法執行。

17-3 其他例子

繼續和大家一起練習,將上一章的 increment 改寫成方法:

class Time:
	"""代表一天中的時間
	屬性: hour, minute, second"""
	def increment(self, seconds):
		''' 將時間加上指定秒數 '''
		seconds += self.timeToInt()
		return intToTime(seconds)

我們假定 timeToInt 已經被改寫成方法,而且 increment 是一個純函數。以下是調用 increment 的過程:

>>> start.printTime()
09:45:00
>>> end = start.increment(1337)
>>> end.printTime()
10:07:17

上面的案例中,increment 方法除了 self 參數外,還帶了一個 seconds 的秒數,作為第二個參數。

這種機制可能容易讓初學者犯錯,例如帶兩個參數給 increment 調用,就會發生錯誤:

>>> end = start.increment(1337, 460)
TypeError: increment() takes 2 positional arguments but 3 were given
# 型態錯誤:increment 函數只接受兩個參數,但給了三個。

因為在方法定義中 self, seconds 看起來是兩個參數,但事實上 self 是每個方法都預設擁有的,所以只有一個 seconds 參數。我們在上例調用給了兩個參數,這樣就錯了,多了一個參數。

這個現象可以作為一個主題被廣為討論,至少得先記住 python 的方法有這個特性,才不會犯錯。

17-4 一個更複雜的例子

接著將第十六章的 isAfter 函數改寫為方法,稍微複雜一點,因為它有兩個時間物件參數。

習慣上,我們會把代表自己類別的第一個參數命名為 self, 這部分跟上一節一樣,其他的參數則沒有特別命名習慣。

class Time:
	"""代表一天中的時間
	屬性: hour, minute, second"""
	def isAfter(self, other):
		''' 如果第一個參數的時間在第二個參數之後,就回傳 True,否則回傳 False '''
		return self.timeToInt() > other.timeToInt()

想要使用這個方法,必須在一個時間物件下調用它,並且給它另一個時間物件當作參數:

>>> end.isAfter(start)
True

上述的語法讀起來也非常符合英文的文法,「結束晚於開始」。

17-5 __init__ 方法

init 是 initialization 的縮寫,它是一個在類別中預設的方法,當類別物件被實例化後,第一個被自動調用的函數就是 init 了。

其實這個方法的完整名稱是在最前面與最後面各加上兩條底線,像這樣 __init__,之後會介紹幾個類似的特殊函數。

對於 Time 類別而言,__init__ 方法像這樣:

class Time:
	"""代表一天中的時間
	屬性: hour, minute, second"""

	def __init__(self, hour=0, minute=0, second=0):
		self.hour = hour
		self.minute = minute
		self.second = second

除了 self 之外,通常 __init__ 方法會有與該類別屬性一樣多的參數,它用來初始化類別所有屬性的值。

當呼叫類別物件給予參數時,__init__ 會分別傳入每個屬性的值為參數值。

但沒有給參數時,__init__ 也會給予每個屬性一個預設值。所以,這裡參數的設計方式選擇了可選參數的定義方法。

>>> time = Time()
>>> time.printTime()
00:00:00

如果只給一個參數,它會被推定為小時:

>>> time = Time(9)
>>> time.printTime()
09:00:00

如果只給兩個參數,它會被推定為小時與分鐘:

>>> time = Time(9, 45)
>>> time.printTime()
09:45:00

當然,如果給了三個參數,則所有屬性的預設值都會被覆寫。

17-6 str 方法

在類別中,__str__ 也是一個特別的方法,它的功用是回傳自訂格式的字串給物件。

下面是一個 Time 類別的 __str__ 方法:

class Time:
	"""代表一天中的時間
	屬性: hour, minute, second"""

	def __init__(self, hour=0, minute=0, second=0):
		self.hour = hour
		self.minute = minute
		self.second = second

	def __str__(self):
		return '%.2d:%.2d:%.2d' % (self.hour, self.minute, self.second)

當列印物件時,python 就會調用這個方法:

>>> time = Time(9, 45)
>>> print(time)
09:45:00

在創建一個新類別時,習慣先撰寫好 __init__ 方法,再來撰寫其他的方法。而 __str__ 方法通常用來除錯。

17-7 運算子多載

其實類別中還有其他的特殊方法,像是可以按照自己的習慣或需求去自訂運算子的行為。

舉例來說,假設定義了一個在 Time 類別裡的 __add__ 方法,那麼就可以在 Time 物件中使用 + (加號)來呼叫它。

定義如下:

class Time:
	"""代表一天中的時間
	屬性: hour, minute, second"""

	def __init__(self, hour=0, minute=0, second=0):
		self.hour = hour
		self.minute = minute
		self.second = second

	def __add__(self, other):
		seconds = self.timeToInt() + other.timeToInt()
		return intToTime(seconds)

接著來使用它:

>>> start = Time(9, 45)
>>> duration = Time(1, 35)
>>> print(start + duration)
11:20:00

在 Time 類別運用了 + 運算子,Time 類別就會調用 __add__ 方法。

當列印物件時, python 會去呼叫 __str__ 方法,如果有的話。

像這種改變運算子原本的行為,就稱為「運算子多載」。所以,在 python 中,每個運算子都有一個特殊的方法。

關於 __add__ 的詳細說明,可以看這篇 詳解.

17-8 基礎類型分派

在上一節中,我們對兩個 Time 物件做相加,但有時的需求會是需要第三個整數來做遞增,如下版本:

class Time:
	"""代表一天中的時間
	屬性: hour, minute, second"""

	def __init__(self, hour=0, minute=0, second=0):
		self.hour = hour
		self.minute = minute
		self.second = second

	def __add__(self, other):
		if isinstance(other, Time):
			return self.addTime(other)
		else:
			return self.increment(other)

	def addTime(self, other):
		seconds = self.timeToInt() + other.timeToInt()
		return intToTime(seconds)

	def increment(self, seconds):
		seconds += self.timeToInt()
		return intToTime(seconds)

內建函數 isinstance() 可以用來判斷不同的類型,也包括我們自訂的類別。判斷該物件為指定類別則回傳 True ,否則回傳 False。

上例中,other 如果是 Time 物件,那 __add__ 就會調用 addTime 方法來計算累加,否則就會把 other 當成是數字調用 increment 來計算。它會根據不同的參數類型來調用不同的方法,這就稱為基本類型分派。

底下是一個使用加號運算的類型分派範例:

>>> start = Time(9, 45)
>>> duration = Time(1, 35)
>>> print(start + duration)
11:20:00
>>> print(start + 1337)
10:07:17

當給定的都是 Time 物件,就可以直接相加。若為其他整數,則額外處理相加動作。

但上例的參數順序不能任意交換,當一般非 Time 物件的整數被放在第一個參數時,會發生錯誤:

>>> print(1337 + start)
TypeError: unsupported operand type(s) for +: 'int' and 'instance'
# 型態錯誤:不支援的運算元類型 +:整數與物件實例

問題出在我們的方法是規劃 python 使用 Time 類別物件加一個數值整數,或加一個一樣是 Time 類別的整數,卻給了 python 整數加上 Time 類別物件的參數,造成python出錯。

這時可以利用特殊的方法 __radd__ ,也就是右相加,當類別物件是右邊參數時被調用,以下是定義:

# 在 Time class 補上這個方法
	def __radd__(self, other):
		return self.__add__(other)

那就可以這樣使用:

>>> print(1337 + start)
10:07:17

練習撰寫一個可使用小數來相加且適用於數組的 __add__ 方法,功能如下:

  • 如果第二個運算元是一個小數,那麼方法應該回傳一個 x 的和與 y 的和的新小數
  • 如果第二個運算元是一個數組,那麼應該讓第一個數組元素跟 x 相加,第二個跟 y 相加,最後回傳一個新小數的結果

17-9 多型

基礎類型分派很方便,但它不是必要的。實務上可以透過撰寫提供不同資料型態參數的方法,來避免這個問題。

當時我們在 11-2 寫了一個計算字母的函數 histogram:

def histogram(s):
	d = dict()
	for c in s:
		if c not in d:
			d[c] = 1
		else:
			d[c] = d[c]+1
	return d

這個函數的通用性很高,只要 s 是可哈西數列 (hashable) 的資料型態,就可以被用來遍歷當作鍵 (key), 看一下這篇 可散列 的補充,像是列表、數組和字典都適用。

>>> t = ['spam', 'egg', 'spam', 'spam', 'bacon', 'spam']
>>> histogram(t)
{'bacon': 1, 'egg': 1, 'spam': 4}

這是一個函數使用在多種型態的狀況,即這個函數具有「多型」性,使得函數的可用性提高。

又如內建函數 sum 提供加總運算,只要帶入的參數是能做加法運算的,都可以使用它。

像本章的 Time 類別在 17-7 提供了加法方法,所以也適用 sum 函數:

>>> t1 = Time(7, 43)
>>> t2 = Time(7, 41)
>>> t3 = Time(7, 37)
>>> total = sum([t1, t2, t3])
>>> print(total)
23:01:00

最好的多型函數並非刻意產生,而是當定義的類別有需要時再撰寫。

17-10 介面與實作

想設計好一個類別的介面是不容易的,隨著時間與經驗,慢慢設計得更佳完善。但對使用這個類別的人來說,其實只要了解該怎麼使用就好。

因此,未來開發者若改變介面的設計或用法,要考慮向下相容性,也可能因為更改而產生問題,讓使用者無法操作。

如果有良好的介面,之後的實作就會簡單很多,可能只是方法的增加,而不影響原本類別的整個架構或用法。

物件導向的設計考慮程式易於維護與提高易用性,但太過考慮可用性,或許會有過度設計或複雜化的問題。新手總是急著想把程式寫完,但寫完是最基本的,如何寫好,是下一個需要練習的課題。

除錯

在類別物件中隨時定義屬性是合理的,但隨時加入的屬性,可能讓開發者難以掌控。

在同一個類別物件中,該怎麼確認有哪些屬性,才不會出錯?

首先,就寫程式方面來說,統一在類別的 __init__ 初始方法中定義屬性,是個好的選擇,如此可以很快速地在裡面查找所有屬於該類別的屬性。

然後,參考第十五章的除錯,使用 hasattr 函數可以確認某個類別物件有沒有某個屬性。

還有一個內建的 vars 函數,可以查看某個類別物件現在有哪些屬性:

>>> p = Point(3, 4)
>>> vars(p)
{'y': 4, 'x': 3}

為了進行除錯,遍歷該類別物件的 vars() 字典所有屬性會很實用,像這樣寫一個函數,來列出該類別物件:

def printAttributes(obj):
	for attr in vars(obj):
		print(attr, getattr(obj, attr))

getattr() 接受類別物件與屬性名稱作為參數,並且回傳這個屬性的值。

動動腦

類別屬性與物件屬性
類別屬性:
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.pr()
# 10
t2.pr()
# 123
print(Test.a)
# 123
Test.a = 100
t1.pr()
# 10
t2.pr()
# 100
print(Test.a)
# 100
t2.a = 1000
t1.pr()
# 10
t2.pr()
# 1000
print(Test.a)
# 100
物件屬性:
class 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.pr()
# 10
u2.pr()
# 123

那麼在第十五章中,我們將屬性從類別之外定義進去,是屬於哪一種屬性呢?為什麼屬性的值沒有累加?

class Bank():
	''' 銀行類別 '''
	#money = 1000
	def addMoney(self, m):
		''' 加總存款並回傳 '''
		return self.money + m
	def printMoney(self):
		''' 顯示有多少存款 '''
		print(f'我有 {self.money} 元')

b = Bank()
c = Bank()
b.money = 123
x = b.addMoney(100)
print('x = ', x)
b.printMoney()
#print(Bank.money)
#Bank.money += 100
#b.printMoney()
#c.printMoney()
docstring 文件字串

在過去介紹註解的章節時,提過三個引號 ''' 來包夾字串,可以當作是多行註解,但嚴格說來, python 並沒有多行註解。

三個引號是按照原格式來儲存多行字串的語法,做個小實驗就會明白:

'用這個來當作單行註解也可以嗎'
'''用這個
來當作多行註解
也可以嗎
'''
s = '''我是
多行字串
'''
print(s)

由此可見,在檔案中使用引號包夾的字串,又沒有給定變數名稱,不會跳出語法錯誤,但這並不是正規的用法,單行註解還是使用 python 提供的 # 較恰當,因為 # 開頭的才會被忽略執行。其他的還是會被讀取,只是沒有顯示在畫面上而已。

三個引號的另一個用途,就是撰寫文件說明,也就是 docstring 文件字串,可以放在模組、類別或方法開頭,並且用來當作說明被查閱。通常 ide 會讀取這些資訊,並顯示給開發者看,如下範例:

>>> class Bank():
...  ''' I am docstring '''
...
>>> Bank.__doc__
' I am docstring '
>>> help(Bank)
Help on class Bank in module __main__:

class Bank(builtins.object)
 |  I am docstring
 |
 |  Data descriptors defined here:
 |
 |  __dict__
 |      dictionary for instance variables (if defined)
 |
 |  __weakref__
 |      list of weak references to the object (if defined)

至於撰寫的格式上,多行的文件字串推薦換行縮排書寫,第一行撰寫主要功能後空一行,撰寫各參數說明。

但這不是硬性規定,只是想讓使用 help 呼叫時顯示的格式比較整齊。

習題

練習 1 檔名: 17-1.py AddressBook.py

寫一個通訊錄程式,需要有以下功能:

  1. 新增一筆通訊錄資料
  2. 查閱所有通訊錄資料
  3. 搜尋某筆通訊錄資料(依照姓名或電話)
  4. 修改某筆通訊錄資料
  5. 刪除某筆通訊錄資料
  6. 清空所有通訊錄資料

通訊錄資料包括:

  • 姓名(必填)
  • 電話(必填)
  • 地址(選填)
  • 備註(選填)
  • 建立日期(自動帶當下時間 yyyy-mm-dd hh:mm:ss)

程式需要符合以下架構:

  • 使用類別、屬性與方法設計整個程式
  • 使用檔案或資料庫存取通訊錄

額外條件:

  • 先以開發者的角度設計通訊錄模組,放到 AddressBook.py
  • 再以想使用這個通訊錄的程式開發者身分,在 17-1.py 調用這個模組,並完成執行的邏輯
  • 最後以使用者的角度來使用這個通訊錄程式

你會發現不同身分的人各有什麼不同的考量點與立場。

備註:

  • 需要設計介面,例如主選單
  • 通訊錄資料可以保存,並非關閉程式後就消失
  • 資料量大時需要分頁顯示

影片

第十七章 類別與方法 part one
第十七章 類別與方法 part two

最後更新:2021-10-23 09:52:45

From: 111.249.166.140

By: 特種兵