跳至主要内容

物件導向程式設計 - 入門篇

物件導向程式設計

「物件導向程式設計(Object-Oriented Programming, OOP)」這個名詞聽起來好像很厲害,但它跟前面函數章節曾經提過的「函數式程式設計」一樣,都是一種程式設計的範式(Paradigm),OOP 主要是藉由「物件」這種擬人、擬物化的方式,用我們比較容易想像的方式來組織、管理程式碼。

什麼是物件?

這個問題如果問不同行業的人會有不同的答案,例如對於房仲業的從業人員來說,物件指的可能是一棟房子,但對軟體工程師來說,物件通常指的是一小塊記憶體位置,裡面包含資料和程式碼的資料結構。

在現實生活中,路上跑的車子、天上飛的鳥,同樣都是人類的你我,這種看得到、摸得到的都可通稱為物件,白話的說,物件就是「東西(Thing)」。物件會有「狀態」跟「行為」,例如我本人有「黑色頭髮」、「黃色皮膚」、「年紀 18 歲(?)」等狀態,也有「吃飯」、「睡覺」、「走路」、「講話」等行為。

物件就是一個東西,這個東西身上會有一些狀態跟行為:

  • 狀態:髮色、膚色、年齡、身體、體重...等名詞
  • 行為:走路、跑步、吃飯、講話、睡覺、追劇、打電動、寫程式...等動詞

所以:

物件(Object) = 狀態(State) + 行為(Behavior)

狀態,就是物件的「屬性(Attribute)」,而行為就是物件的「方法(Method)」,也就是寫在物件裡的函數。

為了讓大家更容易理解、學習程式,許多程式語言都有引進「擬人化」或「擬物化」的設計,讓我們可以用一般人更容易想像的方式來組織或管理程式碼,這就是所謂的物件導向程式設計。問題是,我們怎麼在 Python 建立或使用物件?這就得先跟大家介紹一下「類別(Class)」。

類別與物件

我記得我以前自學程式的時候,每次看書上講到關於物件導向的介紹,常會說類別就像建築物或是飛機的「藍圖」一樣,透過藍圖就能建立很多物件。但我不會蓋房子也不會造飛機,這藍圖的比喻對我來說有點難想像,所以我改用我們這種平民老百姓更容易遇到、更生活化的範例。不知道大家有沒有在路邊或夜市看過或吃過雞蛋糕,有小貓、小狗、皮卡丘等可愛動物造型,只要把調配好的麵粉糊倒進烤盤,蓋上蓋子,等幾分鐘就有香噴噴又造型可愛的雞蛋糕可以吃了:

類別

photo by Bryan Liu

以物件導向的專有名詞來說,這個烤盤模具,就是「類別(Class)」的概念。一樣形狀的模具,放一樣的原料進去,如果沒壓壞的話做出來雞蛋糕的形狀就都會長得一樣。透過烤盤做出來的雞蛋糕,以物件導向程式設計的專有名詞來說會稱之「實體(Instance)」,接下來的章節我都會使用這些名詞。

在 Python 定義類別,使用的關鍵字是 class

class Cat:
pass

因為目前還不知道要放什麼內容,所以我先放 pass 卡個位。這樣我們就定義了一個名為 Cat 的類別,不過目前這個類別裡面空空的,沒什麼用途就是了。

建立物件

有類別之後,就可以透過它來產生物件:

kitty = Cat()
nancy = Cat()

這就跟用烤盤模具可以一直做出雞蛋糕一樣,透過 Cat() 類別你想做幾隻貓都沒問題,這裡我先做了兩個 Cat 的實體,分別叫做 kittynancy。雖然這兩個物件都是用 Cat 類別生成的,但它們是不同的物件,就像你跟我一樣雖然都是 人類 這個類別產生的實體,但我們都是不同的個體,雖然有相同的行為,但各自有不同的狀態。

看到這裡,應該也不難發現類別用起來的手感跟呼叫函數有點像。我們可以來看看這些東西長什麼樣子:

>>> kitty
<__main__.Cat object at 0x104c6d160>
>>> nancy
<__main__.Cat object at 0x104c6f0b0>

後面那個 0x104c6d160 或是 0x104c6f0b0 這種看起來像隨機英文數字組合的東西,它代表這顆物件所在的記憶體位置。這兩個數值不一樣,表示這兩顆物件所在的記憶體位置是不一樣的,也就是說,它們是兩個不同且獨立的個體。

雖然 kittynancy 是不同的個體,但都是一種 Cat

>>> isinstance(kitty, Cat)
True
>>> isinstance(nancy, Cat)
True

透過內建函數 isinstance() 可以判斷物件是否為某個類別的實體,這裡我們可以看到 kittynancy 都是 Cat 類別的實體,但不是字串 str 的實體:

>>> isinstance(nancy, str)
False

另外,每個物件,不管是我們自己設計的或是 Python 內建的,一定都是某個類別生出來的,沒有例外。透過 __class__ 屬性可以觀察到這顆物件是由哪個類別產生的:

>>> kitty.__class__
<class '__main__.Cat'>
>>> kitty.__class__.__name__
'Cat'

再補個 __name__ 屬性,就可以看到這個類別的名字。

初始化與實體屬性

物件如果只是個空殼那就太無聊了,就像雞蛋糕裡沒有塞任何餡料一樣,所以我們可以試著在建立這些小貓物件的時候,順便幫牠們加點料:

kitty = Cat("凱蒂", "白色", 18)
nancy = Cat("南茜", "黑色", 20)

這裡我想幫這兩個小貓物件加上名字、顏色以及年紀,這樣就能透過屬性來區分這兩隻貓。不過這樣寫會出錯:

Traceback (most recent call last):
File "/demo/cat.py", line 5, in <module>
kitty = Cat("凱蒂", "白色", 18)
^^^^^^^^^^^^^^^^^^^^^^^
TypeError: Cat() takes no arguments

的確,我們在定義 Cat 類別的時候沒有設定任何參數所以 Python 會給這樣的錯誤訊息也是合理的。不過要解決這個問題不能直接對 Cat 類別本身下手,而是在這個類別裡定義一個名字有點特別的函數 __init__()

class Cat:
def __init__(name, color, age):
pass

當我們在 Python 裡透過類別建立一個實體的時候,定義在這個類別裡的 __init__() 函數就會自動被呼叫來進行「初始化」的行為,這也是這個函數名稱的由來。這個方法的名稱是固定的寫法,在 init 的前後各都有兩個底線,寫錯字或是底線數量不對就沒效果了。

當我們執行 Cat("凱蒂", "白色", 18) 的時候,Python 會先使用 Cat 類別建立一個實體,接著在實體建立完成之後馬上跟著執行 __init__() 函數,並且把剛剛收到的參數全部丟給它。參數雖然補上去了,但這樣執行之後還是會出錯:

Traceback (most recent call last):
File "/demo/cat.py", line 6, in <module>
kitty = Cat("凱蒂", "白色", 18)
^^^^^^^^^^^^^^^^^^^^^^^
TypeError: Cat.__init__() takes 3 positional arguments but 4 were given

錯誤訊息告訴我們 __init__() 函數只接受 3 個參數,但 Python 卻說我們給了 4 個。咦?有嗎?我應該沒眼花,我們這裡只給了 3 個不是嗎?事實上,這個 __init__() 函數有一個沒有那麼明顯的參數,就是自己這顆物件本身。所以 __init__() 需要調整成這樣:

class Cat:
def __init__(this_is_me, name, color, age):
pass

這樣再次執行應該就不會出錯了。關於 __init__() 函數的第一個參數,有些網路上的教學資料會跟你說第一個參數要放 self,但這裡我先故意用 this_is_me,目的就是跟大家說這個參數的名字你想取什麼名字都可以,self 並不是什麼特別的變數或設定,它就只是個參數名稱而已。雖然不是很明顯,但這個參數指的就是自己這顆實體本身,所以通常才會叫它 self。我也不要太標新立異,接下來我還是會順著大家的慣例把它改成 self,但請記得這個 self 就只是個變數名稱,不是什麼特別的東西。

但為什麼這裡會要帶 self 參數給 __init__() 呢?這是因為當我們呼叫 Cat 類別來建立實體的時候,例如 kitty = Cat(),Python 會依序幫我們做幾件事:

  1. 建立一個 Cat 類別的實體並指定給變數 kitty,在這個很短的瞬間,這顆物件裡面沒有什麼屬性。
  2. 立刻執行 kitty.__init__() 方法並且把其他的參數傳給它,然後才透過 self 把這些參數指定變成屬性。

其他也有物件導向程式設計的程式語言,大多有個叫做「建構子(Constructor)」的設計,借用一下維基百科上關於建構子的資料:

In class-based, object-oriented programming, a constructor is a special type of function called to create an object.

根據維基百科記載,建構子是一個特別的函數,主要用來建立物件。你可能曾聽過有些人會說 __init__() 函數就是「建構子(Constructor)」,但這樣的說法對 Python 來說不太正確。當執行 __init__() 方法的時候,這顆物件早就建立好了,也就是這顆物件並不是由 __init__() 函數建立的。__init__() 正如它的名字,就只是在做「初始化」的工作而已,所以才叫做 init。也因如此,在呼叫 __init__() 的時候才需要 self 當做第一個參數,self 指的就是剛剛產生的這顆物件。如果要說建構子的話,在 Python 另外有個跟 __init__() 方法有點關係的方法叫做 __new__(),它比較接近其他程式語言在做的建構子的事,這部份在下個章節的進階篇有更詳細的介紹。

網站連結

另外,有些人可能會好奇為什麼 Python 要這麼麻煩,還要自己定義 self 這樣的東西,你看看人家 JavaScript 不用特別定義就有個內建的 this 可以直接用。的確,一開始會覺得很多地方都要寫這個 self 有點麻煩,但這樣做的好處是你會知道這個 self 變數是什麼東西以及它是怎麼來的,這也符合 Python 的設計哲學之一:

Explicit is better than implicit.

明瞭優於隱晦。

我個人認為這樣的設計挺好的,再回頭看看 JavaScript 的 this,你真的知道它是什麼意思嗎?

那麼在 __init__() 函數裡應該要做些什麼事?其實它就只是個函數,所以你想做什麼都行,不過比較常見的就是把帶進來的參數指定成物件的屬性:

class Cat:
def __init__(self, name, color, age):
self.name = name
self.color = color
self.age = age

因為 self 指的就是自己這顆物件本身,所以 self.name 或是 self.age 這樣的寫法就是在設定這顆物件的屬性。這樣我們就可以透過這些屬性來區分這兩隻貓:

kitty = Cat("凱蒂", "白色", 18)
nancy = Cat("南茜", "黑色", 20)

print(kitty.name) # 印出 凱蒂
print(nancy.color) # 印出 黑色

除了用 . 的方式之外,也可以透過物件身上的 __dict__ 屬性來檢視這顆物件身上的屬性:

>>> kitty.__dict__
{'name': '凱蒂', 'color': '白色', 'age': 18}
>>> nancy.__dict__
{'name': '南茜', 'color': '黑色', 'age': 20}

可以看的出來不同的實體之間的屬性,或說是狀態,都是獨立不受彼此影響的。如果想要修改這些屬性,可以透過 . 進行修改:

kitty = Cat("凱蒂", "白色", 18)

print(kitty.color) # 印出 白色
kitty.color = "橘色" # 改成 橘色
print(kitty.color) # 印出 橘色

因為這些屬性就像是這顆實體裡面的變數,我們也會稱這些屬性為「實體變數(Instance Variable)」。

除了使用 . 的方式來存取屬性,Python 有兩個內建的函數可以做一樣的事,分別是 getattr()setattr()

>>> getattr(kitty, "name")
'凱蒂'
>>> setattr(kitty, "name", "露茜")
>>> getattr(kitty, "name")
'露茜'

大部份時候用 . 的方式就好,簡單又直覺,使用這兩個函數存取屬性的時機通常是因為屬性名稱是變動的,就能透過組合變數的方式來組合屬性,例如:

for i in range(10):
setattr(kitty, f"attr_{i}", i)

這樣一口氣就能設定 10 個屬性(attr_0attr_9),這在某些特定的情況下挺好用的。另外,使用 . 方式取得屬性,如果屬性不存在會得到 AttributeError 的錯誤訊息,但使用 getattr() 的話,可以在第 3 個參數指定預設值,這樣就不會出錯:

>>> kitty.hello
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
AttributeError: 'Cat' object has no attribute 'hello'

>>> getattr(kitty, "hello", "Hello Kitty")
'Hello Kitty'

實體方法

除了屬性之外,我們也可以在類別裡面定義函數,在呼叫這些函數的時候,常會稱它們叫「方法(Method)」。在類別裡定義方法跟定義一般函數沒什麼不同,也都是使用 def 關鍵字,在函數章節學到的內容,像是參數預設值、位置引數、關鍵字引數...等等,在這裡也都用的上。比較特別的是,在定義方法的時候,第一個參數是自己這顆物件,也就是前面講到的 self,這樣我們才能透過 self 變數來存取自己這顆物件身上其他的屬性或方法:

class Cat:
def __init__(self, name, color, age):
self.name = name
self.color = color
self.age = age

def speak(self, sound="喵"):
print(f"我是{self.name}{sound}")

在定義 speak() 函數的時候,不管有沒有用到 self 參數,在執行這個函數的時候都會自動幫我們把自己這顆物件傳進去當第一個參數,所以在定義函數的時候參數還是不能少。是說,如果在定義的時候少給了 self 參數倒也不一定會出錯,但那是別的意思,晚點我們就會提到。方法定義好之後,接下來就能透過物件來操作它了:

kitty = Cat("凱蒂", "白色", 18)
kitty.speak() # 我是凱蒂,喵
kitty.speak("汪!") # 我是凱蒂,汪!

因為這個方法定義在 Cat 類別裡,所以可以透過 Cat 類別的實體們,例如 kittynancy 來執行它,也因為這些方法看起來是作用在這些實體上,所以也常會稱它們為「實體方法(Instance Method)」。當 Python 看到我們試著執行 kitty.speak() 方法的時候,它會自動把 kitty 這顆物件傳給 speak() 函數做為第一個參數,也就是 self,所以別忘了在定義實體方法的時候把 self 參數加上去。

只要你是 Cat 類別所產生的實體,就能夠使用這個類別裡的方法以及存取裡面的屬性,透過這樣的設計,我們可以把相關的資料和操作都放在一起同一個類別裡,讓程式碼更有組織性,也更容易維護。

類別屬性

在 Python 裡很多東西都是物件,不只是產生出來的 kittynancy 是物件,包括 Cat 類別本身也是物件。既然是物件,就一樣會有「狀態(屬性)」跟「行為(方法)」,只是定義的方式稍微有些不同。在 Python 要在類別裡定義屬性,就直接寫在類別裡就好,像這樣:

class Cat:
count = 0
actions = []

這些屬性是定義在類別本身,故稱之「類別屬性(Class Attribute)」。類別屬性跟實體屬性不同,類別屬性是屬於類別本身的,而不屬於類別所產生出來的實體,以上面的範例來說,就是這些屬性屬於 Cat,但不屬於 kittynancy。如果我們想要存取類別屬性的話,不用先建立實體,直接透過類別就行了:

>>> Cat.count
0
>>> Cat.actions
[]

跟實體屬性一樣,我們也可以透過 __dict__ 這個屬性來看類別的屬性:

>>> Cat.__dict__
mappingproxy({'__module__': '__main__', 'count': 0, 'actions': [], '...略...'})

看起來的東西稍微比實體的屬性多了一些,但應該還是可以看到剛才定義的 countactions 屬性,這些屬性是存放在類別裡的。跟實體屬性一樣,如果要修改屬性的值的話,可以透過 . 的方式,或是 getattr()setattr() 函數也都行的通。

如果想要在實體方法裡面取用類別變數,可以連名帶姓的把類別名稱寫上去:

class Cat:
count = 0
actions = []

def __init__(self, name, color, age):
self.name = name
self.color = color
self.age = age
Cat.count += 1 # <-- 在這裡

在上面這個例子中,因為是寫在 __init__() 方法裡,效果就變成每次建立新的實體的時候,count 屬性的值就會加一:

>>> Cat.count
0
>>> kitty = Cat("凱蒂", "白色", 18)
>>> Cat.count
1
>>> nancy = Cat("南茜", "黑色", 20)
>>> Cat.count
2

是說,類別變數需要要搭配類別名稱才能取用嗎?倒也不一定,我們再回來看一下 kitty 實體,如果我們試著印出 kitty.count 會發生什麼事?注意,我這裡是透過實體存取屬性,不是類別喔:

>>> kitty = Cat("凱蒂", "白色", 18)
>>> nancy = Cat("南茜", "黑色", 20)
>>> kitty.count
2

咦?竟然還是可以找的到?這個 count 不是類別屬性嗎?剛剛不是說這是屬於類別的嗎,怎麼現在又能透過實體取得呢?這是因為 Python 在找東西的時候,會先在實體自己本身找看看有沒有,如果找到就沒事,如果找不到也不會馬上放棄,會再接著找看看這個類別裡有沒有。所以當我們印出 kitty.count 的時候,Python 在 kitty 這個實體裡的確是找不到,所以接著到它所屬的類別 Cat 裡面找,然後就找到了。

看到這裡不知道你的眉頭有沒有皺了一下,如果你曾經寫過其他物件導向設計的程式語言的話,Python 這樣設計可能會讓你感到困惑。在其他 OOP 的程式語言裡,實體變數跟類別變數比較像是互相不影響的平行線,彼此之間不是不能取用,而是通常得透過其他方式,例如透過類別名稱來取用,但在 Python 裡,會因為找不到實體變數就往類別變數找。

關於屬性,我們再來看個例子:

>>> kitty = Cat("凱蒂", "白色", 18)
>>> kitty.__dict__
{'name': '凱蒂', 'color': '白色', 'age': 18}
>>> kitty.count
1

如上面所說,因為在 kitty 這顆物件身上找不到 count 屬性,的確從 __dict__ 裡也沒看到,所以 Python 就往類別裡找然後找到了,這沒問題。接下來,我想要在 kitty 這顆物件身上新增一個 count 屬性的話,也是可以的:

>>> kitty.count = 9527
>>> kitty.__dict__
{'name': '凱蒂', 'color': '白色', 'age': 18, 'count': 9527}

這時會發現原本的 __dict__ 裡多了一個 count 屬性。好,我們再印一次 kitty.count 屬性看看:

>>> kitty.count
9527

印出來的值是 9527,而不是原本的 1。還是一樣的遊戲規則,Python 會先在 kitty 物件找看看有沒有 count 屬性,但這回卻找到了。接著我再新增另一個 nancy 物件來做些實驗:

>>> nancy = Cat("南茜", "黑色", 20)
>>> nancy.__dict__
{'name': '南茜', 'color': '黑色', 'age': 20}
>>> nancy.count
2

可以看到這個 nancy 物件身上並沒有 count 屬性,所以根據規則 nancy.count 會得到 2。最後如果我們直接印出 Cat.count 這個類別變數的話:

>>> Cat.count
2

也就是說,剛才我試著設定 kitty.count = 9527 的時候,這並不是修改 Cat 類別身上的 count 屬性,而是直接在 kitty 物件身上新增了一個 count 屬性,所以 Cat.count 並不會受到影響。所以,屬性的讀取跟設定在流程上有一些不一樣,簡單整理一下:

如果是讀取的話...

像這樣:

print(kitty.count)
  • 如果在 kitty 實體裡找不到屬性,Python 會接著往這顆物件的所屬類別 Cat 找。
  • 如果在 Cat 類別裡還是找不到也不會這麼快就放棄,會再繼續往更上層的類別找,這個我們晚點再介紹。
  • 如果一路找到最後都還是沒找到,Python 會給一個 AttributeError 的錯誤訊息。

如果是新增或修改的話...

像這樣:

kitty.count = 9527
  • 如果在 kitty 實體裡找到這個屬性,就會直接修改這個屬性的值
  • 如果在實體裡找不到屬性,Python 會在這顆 kitty 物件上新增屬性,並且設定值。

所以,在設定物件的屬性的時候,基本上跟實體或類別屬性沒太大關係,也就是說,設定物件屬性一定會成功,不會有找不到屬性的情況發生。

關於物件的屬性設定與讀取這件事,其實還有一個隱藏關卡的魔王「描述器(Descriptor)」,描述器在 Python 是一個很重要的設計,屬性的讀取跟設定都跟它有關,不過這個主題比較進階一些,在下個章節會有更完整的介紹。

一開始接觸 Python 的物件導向的時候,可能會發現實體變數跟類別變數的邊界感沒那麼清楚,特別對有其他程式語言經驗的人來說更容易拿著明朝的劍斬清朝的官,拿著其他程式語言的物件導向觀念來學 Python 還滿容易踩到坑的,不過只要理解了這些遊戲規則,就會發現其實也沒有那麼困難。

屬性裝飾器

看到現在應該也有發現,在 Python 裡要讀取或設定物件的屬性很容易,都可以透過 . 的方式就能直接取用或修改,這樣一來,可能會有一些不太安全的情況發生,舉個例子:

class Hero:
def __init__(self, title, name, age):
self.title = title
self.name = name
self.age = age

himmel = Hero("勇者", "欣梅爾", 18)

print(himmel.__dict__) # {'title': '勇者', 'name': '欣梅爾', 'age': 18}

這時候,如果我們想要改變 himmel 的年齡,只要這樣就可以了:

>>> himmel.age = 1000
>>> himmel.age
1000

簡單改一下數值就變成千年老妖了,這樣可能不太好。如果想要限制這個 age 屬性的存取,在其他的程式語言有些會使用 gettersetter 的設計來限制屬性的存取,像這樣:

class Hero:
def __init__(self, title, name, age):
self.title = title
self.name = name
self._age = age

def get_age(self):
return self._age

def set_age(self, age):
if age <= 0 or age > 120:
raise ValueError("年齡設定錯誤")
self._age = age

這裡我刻意把原本的 self.age 前面加上一個底線 _ 變成 self._age,然後另外寫了 .get_age() 以及 .set_age() 這兩個方法來讀取或設定 self._age 的值,同時在 .set_age() 裡面加上簡單的判斷,如果設定的年齡不在適當範圍內就會丟出錯誤訊息:

>>> himmel.get_age()
18

# 正常操作
>>> himmel.set_age(30)
>>> himmel.get_age()
30

# 不正常操作
>>> himmel.set_age(1000)
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "/private/tmp/tt.py", line 12, in set_age
raise ValueError("年齡設定錯誤")
ValueError: 年齡設定錯誤

雖然這樣寫可以控制屬性的存取,但是這樣的寫法有點囉嗦,重點是在使用的時候還得加上小括號來呼叫它,這用起來一點都不像屬性。Python 提供了一個名為 property 的函數裝飾器可以做到類似屬性的效果:

class Hero:
def __init__(self, title, name, age):
self.title = title
self.name = name
self._age = age

@property # <-- 掛在這裡會得到 getter
def age(self):
return self._age

@age.setter # <-- 這是 setter
def age(self, age):
if age <= 0 or age > 120:
raise ValueError("年齡設定錯誤")
self._age = age

這跟我們在函數章節學到的裝飾器差不多,只是這裡的 property 本身並不是函數而是一個 Python 內建的類別,不過這不影響我們理解,可以先把它當做一般的函數看待就好。age() 函數本身的內容沒變,只是在定義的時候多掛上了 propertyage.setter 這兩個裝飾器,這樣在存取 age 屬性的時候,不需要加上小括號就能使用:

>>> himmel.age
18
>>> himmel.age = 38
>>> himmel.age
38
>>> himmel.age = 1000
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "/private/tmp/tt.py", line 14, in age
raise ValueError("年齡設定錯誤")
ValueError: 年齡設定錯誤

這樣看起來就像在存取物件的屬性了吧!

私有屬性?

是說,大家不要誤會,不管是使用 gettersetter 的設計或是掛上 property 函數裝飾器都一樣,並不是在前面加上個底線,這個屬性就不能被外部存取了。先說結論,Python 並沒有真正的私有變數或屬性的設計,加上底線只是一個慣例,讓開發者知道這個屬性不建議直接存取,但如果你無視這個慣例,硬是要拿來用也是可以的:

>>> himmel._age
18
>>> himmel._age = 1000
>>> himmel._age
1000

看到了吧,前面那些根本只是做做樣子而已。

一個底線不夠?各位可能會在網路上看到一些教學資料甚至是坊間的出版的書籍,說如果要讓屬性不能被存取就要在前面加兩個底線給它,它就會變成私有屬性。真的是這樣嗎?我來把 _age 改成兩個底線的 __age 給大家看看:

class Hero:
def __init__(self, title, name, age):
self.title = title
self.name = name
self.__age = age # 這裡改成兩個底線的 __age

himmel = Hero("勇者", "欣梅爾", 18)

來實驗看看:

>>> himmel.__age
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
AttributeError: 'Hero' object has no attribute '__age'

唉唷,好像真的拿不到了耶!但是,如果我們翻一下它的 __dict__ 屬性:

>>> himmel.__dict__
{'title': '勇者', 'name': '欣梅爾', '_Hero__age': 18}

就發現屬性其實都還在,只是原本的 __age 變成了 _Hero__age,這是 Python 的「名字改編(Name Mangling)」的特殊設計,Python 會把以兩個底線開頭的名字改以 _類別名稱__屬性名字 的方式呈現。既然我們已經看破這個招式,只要知道這個屬性叫什麼名字的話,那就可以直接動手啦:

>>> himmel._Hero__age
18
>>> himmel._Hero__age = 1000
>>> himmel._Hero__age
1000

所以基本上想要惡搞的話,都是擋不住的啦!

因此,有些人會針對 Python 沒有真正的私有屬性批評它的物件導向設計有問題,以物件導向的角度來看,的確,我不否認,但如果真的有哪個程式語言是完美的早就統一程式語言圈了。更何況身為程式開發者,明明知道這裡有顆石頭然後硬是要搬這顆石頭砸自己或是同事的腳,好像也不應該抱怨為什麼這裡有這顆石頭可以搬吧。

限定屬性存取

Python 的物件基本上是很自由、隨性的,先不管這樣做有沒意義,但如果想要,隨時都能對某個物添加屬性:

class Cat:
pass

kitty = Cat()
kitty.age = 18 # 新增 age 屬性

像這樣,你想在 kitty 物件加什麼屬性都可以,甚至原本 Cat 類別裡沒有定義的也可以。如果沒特別做什麼處理的話,這些屬性預設都會被設定到 kitty 物件的 __dict__ 屬性裡。什麼是特別的處理?你到下個章節看到「描述器(Descriptor)」的時候就會知道我在說什麼了,現在先當我沒說。

Python 的類別有一個特殊的屬性叫做 __slots__,這個屬性可以限制物件的屬性只有設定在 __slots__ 名單裡的才行,如果你試著在物件上新增名單以外的屬性,Python 會拋出 AttributeError 的例外:

class Cat:
__slots__ = ('name', 'age')

這個 __slots__ 屬性可以是任何可迭代物件,像是串列、元組、集合,只要可迭代物件裡的元素是字串就可以了,不過因為 Tuple 的設計是不可變的,所以我比較喜歡使用 Tuple 來設定 __slots__ 屬性。設定 __slots__ 像是給這個類別的屬性設定了白名單,這樣我們就可以限制 Cat 類別的物件只能有 nameage 這兩個屬性:

>>> kitty = Cat()
>>> kitty.age = 18
>>> kitty.age
18

# 設定不存在 __slots__ 裡的屬性
>>> kitty.message = "hello"
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
AttributeError: 'Cat' object has no attribute 'message'

slot 就只有限制屬性的特性嗎?當然不只。首先,你猜猜看上面這個 nameage 屬性會存在哪裡?之前我們知道屬性可能會存放在物件的 __dict__ 屬性裡,但你看看這個:

>>> kitty.__dict__
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
AttributeError: 'Cat' object has no attribute '__dict__'. Did you mean: '__dir__'?

咦?kitty 物件竟然沒有 __dict__ 屬性了!有興趣翻 CPython 原始碼的話,會發現如果類別有設定 __slots__ 屬性的話,在建立物件的時候會把物件的 __dict__ 屬性給拿掉。

所以它是存到哪裡去了?雖然 Python 把物件的 __dict__ 拿掉了,但會另外在我們看不到的地方偷偷的開一個陣列來存放這些值,也由於 __slots__ 一開始就先定義好物件身上會有哪些屬性,所以 Python 在建立物件的時候就會預留固定的空間來儲存這些屬性。原本透過 __dict__ 動態存放屬性雖然方便,但使用字典會使用更多的記憶體空間來存放這些值。

偷偷劇透一下,在 __slots__ 設定的屬性,本身也是個「描述器(Descriptor)」,是的,又是它!關於描述器的細節我們就到下一章再來看吧。

另外,本章晚點才會講到的類別繼承,你也可以看完繼承的介紹再回頭看這一段。如果有類別繼承的話,__slots__ 的效果可能會跟大家想像的不太一樣,例如:

class Animal:
__slots__ = ('name', 'age')

def __init__(self, name, age):
self.name = name
self.age = age

class Cat(Animal):
pass

我們來試用看看:

>>> kitty = Cat("Kitty", 18)
>>> kitty
<__main__.Cat object>
>>> kitty.name
'Kitty'
>>> kitty.hey = "Hey"
>>> kitty.__dict__
{'hey': 'Hey'}

可以看的出來 kitty 身上還是有 nameage 這兩個透過 __slots__ 定義的屬性,但是 __dict__ 屬性卻又回來了,這是因為 Cat 類別本身並沒有設定 __slots__ 屬性,所以用它來建立物件的時候,還是會留有 __dict__ 屬性,操作起來就跟原本沒什麼差別,nameage 這兩個屬性的是在上層類別所設定的 __slots__

如果反過來寫的話:

class Animal:
pass

class Cat(Animal):
__slots__ = ('name', 'age')

繼續做個簡單的實驗:

>>> kitty = Cat()
>>> kitty.__dict__
{}

雖然 Cat 類別設定了 __slots__,所以透過它產生的物件的確應該不會有 __dict__ 屬性沒錯,但 Cat 類別的的上層是一般的類別,這樣 kitty 還是會有 __dict__ 屬性,這點要特別注意。

那如果上層類別跟衍生類別都有 __slots__ 的話呢?這時候就會變成這樣:

class Animal:
__slots__ = ('name', 'age')

class Cat(Animal):
__slots__ = ('address')

這樣的話,Cat 類別產生出來的物件,除了會有自己 __slots__ 屬性設定的 address 屬性,由上層類別的 __slots__ 所設定的屬性也會繼續延用,而且因為 AnimalCat 類別都用了 __slots__,所以產生的物件就不會有 __dict__ 了。

另外,雖然透過 __slots__ 設定之後物件的 __dict__ 屬性就消失了,但其實也是可以把它加回來,像這樣:

class Cat:
__slots__ = ('name', 'age', '__dict__')

這樣就能跟原本一樣,正常使用 __dict__ 屬性了,只是如果要這樣的話,就乾脆不要用 __slots__ 啦,因為這就違反 __slots__ 設計的本意了,也佔不到任何便宜。

事實上,有個跟資料庫操作有關的套件 SQLAlchemy,它在內部設計的時候也用上了 __slots__ 的特性來讓效能更好。

使用 __slots__ 的確可以讓屬性的存取速度更快一點點,而且也比較節省記憶體,不過遇到繼承的時候就要稍微注意,一不小心可能就會出現跟預期不太一樣的結果。

函數與方法

我們曾經在函數章節介紹過函數跟方法的差異,接下來我們可再來細看這兩者在物件導向的世界裡的不同。為了讓事情簡單一點,我另外寫了一個類別 Duck,裡面就只有一個 say_hello() 函數,同時我也使用 Duck 類別建立了一個實體 donald

class Duck:
def say_hello(self):
print("Hello, I'm a duck!")

donald = Duck()

這裡的 say_hello 有好幾個身份:

  1. 這個函數定義在 Duck 類別裡,所以會在 Duck 類別裡多一個叫做 say_hello 的屬性。
  2. 承上,這個屬性的值剛好是一個可以呼叫的函數,所以也可以說這是 Duck 類別裡面的函數(但這不是類別方法)。
  3. 當我們試著想要印出 donald.say_hello 的時候,因為 donald 實體本身並沒有這個屬性,所以它會往它的類別 Duck 找,所以就以結果來說,硬是要說 say_hellodonald 實體的屬性也是勉強說的過去(事實上並不是)。
  4. 承上,這個屬性的值又剛好是一個可以呼叫的函數,所以要說它是 donald 這個實體的方法也是說的通。

覺得有點混亂嗎?好啦,先不要管這些身份的東西,或是等過陣子對 Python 比較熟之後再回來看也可以,我們先來看看這些東西在 Python 裡面長什麼樣子:

>>> Duck.say_hello
<function Duck.say_hello>
>>> donald.say_hello
<bound method Duck.say_hello of <__main__.Duck object>>

>>> type(Duck.say_hello)
<class 'function'>
>>> type(donald.say_hello)
<class 'method'>

一樣都是 say_hello,一個是函數物件,另一個卻是方法物件,特別是方法物件還多了一個 bound 字樣在前面,這是什麼意思?bound 字樣是指這個方法有跟某個實體綁在一起了,也就是說,當我們用 donald.say_hello 的寫法,這個方法已經知道待會要操作哪一顆實體了。

方法物件身上有幾個比較有趣的屬性:

>>> donald.say_hello.__func__
<function Duck.say_hello>

>>> donald.say_hello.__self__
<__main__.Duck object>

方法物件的 __func__ 屬性會指出這個方法是本來定義在哪個類別,__self__ 屬性則是指出這個方法是跟哪個實體綁在一起的。當我們呼叫 donald.say_hello() 方法的時候,其實差不多就是等同於呼叫這個方法物件的 __func__ 屬性指向的函數,並且把自己,也就是這個方法物件的 __self__ 當做第一個參數傳給它。把上面這段話寫成程式大概像這樣:

>>> donald.say_hello.__func__(donald.say_hello.__self__)
Hello, I'm a duck!

有點複雜嗎?沒關係,如果能看懂最好,看不懂也不用太介意,反正平常我們不會這樣寫,就大概知道這些東西是怎麼運作的,以及呼叫實體方法的時候,為什麼第一個參數要帶 self 給它。如果對裡面的細節有興趣,我們可以下個章節再來細聊。

類別方法與靜態方法

我們先來看個範例:

class Duck:
def all():
print("鴨鴨們,集合囉!")

Duck.all()

因為這個 all() 函數定義在 Duck 類別裡,在 Duck 類別裡就會多一個 all 屬性,所以執行 Duck.all() 的時候就會印出 鴨鴨們,集合囉! 字樣。這沒什麼問題,有些其他程式語言背景的人可能就會說這就叫做類別方法(Class Method),但在 Python 裡並不是這樣,它就只是剛好定義在類別裡的函數而已,跟類別其實沒有什麼關係。

再讓我們回頭看看實體方法,所謂的實體方法,就是綁定在「實體」的方法,在執行的時候 Python 會把這個實體傳給這個方法做為第一個參數。同樣的概念,類別方法(Class Method)就是綁定在「類別」的方法,在執行的時候會把類別傳給這個方法當做第一個參數。

在 Python 裡要定義類別方法,通常會使用內建的函數裝飾器 classmethod

class Duck:
def all():
print("鴨子們,集合囉!")

@classmethod
def list(cls):
print(f"{cls.__name__}們,集合囉!")

list() 函數裡的 clsself 是差不多的概念,這不是什麼特殊的變數,就只是因為傳進來的是一個類別,所以慣例上會把參數命名為 cls,沒辦法,誰叫 class 在 Python 是個關鍵字不能拿來用所以只好妥協用 cls。我們先來看看這個 list() 方法長什麼樣子:

>>> Duck.all
<function Duck.all>
>>> type(Duck.all)
<class 'function'>

>>> Duck.list
<bound method Duck.list of <class '__main__.Duck'>>
>>> type(Duck.list)
<class 'method'>

看到了嗎?這裡的 Duck.list 是個方法物件,前面也有個 bound 字樣,表示這個 .list() 方法是綁定在 Duck 類別上的。相對的原本的 .all() 就是一般的函數而已。我們來試試看:

>>> Duck.list()
Duck們,集合囉!

雖然 .list().all() 使用起來的樣子都長一樣,但 .list() 方法是綁定在 Duck 類別上的,所以可以透過第一個參數取得這個類別本身,這才是真正的類別方法。而 .all() 只是個定義在類別裡的函數,雖然要取用類別也可以直接使用 Duck 類別,但這就不是綁定在類別身上的類別方法。

雖然說是「類別」方法,但實體也可以用:

>>> donald = Duck()
>>> donald.list
<bound method Duck.list of <class '__main__.Duck'>>
>>> donald.list()
Duck們,集合囉!

還記得物件在找屬性或方法的遊戲規則嗎?當 donald 實體要找 .list() 方法的時候,它會先找自己身上的有沒有,沒有的話就會去找它的所屬類別,不過這個執行的結果跟實體本身沒關係,因為這個 .list() 類別方法綁定的是類別,所以執行 donald.list() 跟執行 Duck.list() 的結果是一樣的。

附帶一提,上面 .all() 的寫法在 Python 3 沒問題,但在 Python 2 執行的時候會出現錯誤:

# 注意:這是 Python 2
>>> Duck.all()
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: unbound method all() must be called with Duck instance as first argument (got nothing instead)

也就是說,這樣的寫法在 Python 2 是類別方法,它要我們帶個類別給它當第一個參數。

另一個看起來也是很像的做法做叫「靜態方法(Static Method)」,同樣也是透過內建的函數裝飾器 staticmethod 來裝飾方法:

class Duck:
@staticmethod
def make_sound(sound="呱呱呱"):
print(sound)

所謂的靜態方法,就是跟類別或實體都沒有關係的方法,它不需要也不會接收任何隱藏的參數,也就是不用像實體方法或類別方法一樣在第一個參數把自己傳進來。來看看它長什麼樣子:

>>> Duck.make_sound
<function Duck.make_sound>
>>> type(Duck.make_sound)
<class 'function'>

>>> Duck.make_sound()
呱呱呱

它看起來就像一般的函數,但是這個函數是寫在 Duck 類別裡的,所以我們可以透過 Duck 類別來呼叫它。透過 staticmethod 裝飾器裝飾過的 .make_sound() 方法就是個靜態方法,它跟類別或實體都沒有直接關係,只是因為功能上可以把它歸類在這個地方的獨立函數而已。如果光是這樣好像跟一般的函數沒什麼差別?其實還是有點不一樣的,可能剛才大家沒注意到而已,先看看這個例子:

class Duck:
def all():
print("鴨鴨們,集合囉!")

@classmethod
def walk(cls):
print(f"{cls}會走路")

@staticmethod
def make_sound(sound="呱呱呱"):
print(sound)

我各準備了一個類別方法、靜態方法以及一般的方法,接著進 REPL 做點實驗:

# 一般方法
>>> Duck.all
<function Duck.all>

# 靜態方法
>>> Duck.make_sound
<function Duck.make_sound>

# 類別方法
>>> Duck.walk
<bound method Duck.walk of <class '__main__.Duck'>>

類別方法很明顯有綁定東西,也就是那個類別本身,但一般方法跟靜態方法看起來好像都是函數,也都沒綁定,這樣這兩種寫法有什麼差別?有的,讓我們從實體的角度來看:

>>> donald = Duck()

# 一般方法
>>> donald.all
<bound method Duck.all of <__main__.Duck object>>

# 類別方法
>>> donald.walk
<bound method Duck.walk of <class '__main__.Duck'>>

# 靜態方法
>>> donald.make_sound
<function Duck.make_sound>

對實體來說,不管是一般的方法或是類別方法都有綁定個什麼東西,也就是它會有個隱藏的參數帶進去(可能是物件或類別),但靜態方法依舊還是一般的函數而已,也不會偷渡一些看不到的參數進去。

繼承

到目前我們看到的都是一個類別就搞定所有工作,但在真實世界裡的情況是更複雜的。我想要再加入一個小狗 Dog 類別,它跟鴨子類別以及貓類別都是一種動物,既然都是動物,應該會有一些共通的行為。

如果程式碼可以重複利用,我們會考慮把它包成函數,這個概念在類別也一樣。在往下介紹之前,大家以前在學校不知道有沒有學過生物分類法(Taxonomy),就是「界門綱目科屬種」的七字口訣:

中文/英文果蠅豌豆
界 kingdom動物界 Animalia動物界 Animalia植物界 Plantae
門 division節肢動物門 Arthropoda脊索動物門 Chordata被子植物門 Magnoliophyta
綱 class昆蟲綱 Insecta哺乳綱 Mammalia雙子葉植物綱 Dicotyledoneae
目 order雙翅目 Diptera靈長目 Primates豆目 Fabales
科 family果蠅科 Drosophilidae人科 Hominidae豆科 Fabaceae
屬 genus果蠅屬 Drosophila人屬 Homo豌豆屬 Pisum
種 species黑腹果蠅 D. melanogaster智人 H. sapiens豌豆 P. sativum

資料來源:維基百科

這本書不是在介紹 Python 程式語言嗎?怎麼突然變成生物課了?難道是跟 Python 的「蟒蛇」有關?當然不是。在生物分類法裡,我們會把有共同特性的物種分類在同一個分類裡,例如,我們人類跟猴子在生物學上有一些共同特徵跟行為,人類跟猴子都被歸為靈長目,只要是靈長目的動物就都有五隻手指,而且通常拇指與其他四指可分開對立、抓握物體,所以「五指對握」是靈長目的特有功能,如果用 Python 來寫,大概可以寫成這樣:

class Primate:
def grab(self, something=None):
if something:
return f"抓{something}"

return "抓東西"

上面這段程式碼應該不會太難懂,接下來如果猴子跟人類也都要有五指對握的功能的話,難道要分別在 MonkeyHuman 類別裡都寫一份嗎 .grab() 方法嗎?不用這麼辛苦的,在物件導向程式設計裡有個「繼承(Inheritance)」的設計可以幫我們做這件事:

class Primate:
def grab(self, something=None):
if something:
return f"抓{something}"

return "抓東西"

class Human(Primate):
pass

class Monkey(Primate):
pass

除了原本的靈長目之外,我另外做了 Human 以及 Monkey 這兩個類別,比較特別是在類別後面加上了小括號,用物件導向程式設計的專有名詞可以說「Human 類別繼承自 Primate 類別」以及「Monkey 類別繼承自 Primate 類別」,這樣一來,就算 HumanMonkey 類別什麼都沒寫,像這樣:

>>> goku = Monkey()
>>> someone = Human()
>>> goku.grab("香蕉")
'抓香蕉'
>>> someone.grab("錢錢")
'抓錢錢'

繼承的設計可以讓我們可以把共通的功能寫在上層類別裡,就不用一直重複寫一樣的程式碼了。

看到這裡,我們可以整理一下在 Python 裡的物件在找屬性或方法時候的流程,以 someone.grab() 為例:

  • 首先,會先看看 someone 這個物件本身的 __dict__ 屬性裡有沒有這個 .grap() 方法,如果有就直接執行。
  • 如果沒有,就會繼續往 someone 這個物件的所屬類別,也就是 Human 類別,看看它的 __dict__ 裡有沒有符合的,如果有就執行。
  • 假設還是沒有,就會看看 Human 有沒有繼承哪個類別,就會繼續往 Human 類別的上層類別,也就是 Primate 類別,看看它的 __dict__ 裡有沒有符合的,如果有就執行。

我這裡的例子也許過於簡化,類別繼承自另一個類別,大部份不會只是為了使用那個類別的方法而已,通常也會寫一些自己類別的專屬功能,不過這裡僅為了示範繼承的概念,所以就沒有多做舉例。

是說,Inheritance 這個字通常翻譯成「繼承」,但講到繼承你會聯想到什麼?大部份的人想到的可能是長輩很有錢,我是爸媽的小孩,所以可以繼承家產,當個開開心心的富二代。但在 OOP 指的繼承比這個再廣泛一些,與其說是「繼承」,我更偏好「分類自」的說法(class 不就是分類的意思嗎?),像是人類「分類自」靈長目或猴子「分類自」靈長目。想想看,你有五指可以對握的功能,跟你是不是你爸媽的小孩並沒有關係,就算你是隔壁老王的小孩你還是有這個功能。因為你我以及猴子都是一種靈長目動物,所以才有這功能。這就是為什麼我們會把共通的功能寫在 Primate 類別裡,HumanMonkey 類別就可以繼承來使用。

再講個比較跟技術比較沒關的事,我知道不少網路上的教學文件或書籍都會使用「父類別」以及「子類別」的用法,但我個人對這些詞有些意見,原本英文是用 Parent Class 跟 Child Class,怎麼翻成中文就變成了「父」類別跟「子」類別?為什麼不是翻譯成「母」類別跟「女」類別?所以在本書中,我會使用「上層類別」或「基底類別(Base Class)」來取代常見的「父類別」,以及使用「下層類別」或「衍生類別(Derived Class)」或來取代「子類別」,這樣應該就不會有政治正不正確的問題了。

沒寫就沒有繼承嗎?

Python 的類別繼承是使用小括號寫在類別名稱的後面,像這樣:

class Animal:
pass

class Mammal(Animal):
pass

class Cat(Mammal):
pass

在上面的範例中,貓是一種哺乳類動物,哺乳類動物是一種動物。但最上層的 Animal 後面沒有寫小括號,就代表這個類別沒有繼承任何東西嗎?也不是這樣說,雖然沒有明確寫出繼承自哪個類別,但 Python 在這種情況會在幫我們偷偷繼承一個名為 object 的類別。這要怎麼看出來?有幾個方法,可以透過類別身上的 .mro() 方法或是 __mro__ 屬性看到這個類別的祖宗十八代:

>>> Cat.mro()
(<class '__main__.Cat'>, <class '__main__.Mammal'>, <class '__main__.Animal'>, <class 'object'>)
>>> Cat.__mro__
(<class '__main__.Cat'>, <class '__main__.Mammal'>, <class '__main__.Animal'>, <class 'object'>)
>>> Mammal.__mro__
(<class '__main__.Mammal'>, <class '__main__.Animal'>, <class 'object'>)
>>> Animal.__mro__
(<class '__main__.Animal'>, <class 'object'>)

用「祖宗十八代」來形容比較容易想像但其實不夠精準,MRO 是 Method Resolution Order 的縮寫,字面上的意思是指 Python 在查找方法時候的尋找順序。前面曾經提過,要在某個物件裡找屬性或方法的時候,會先看這顆物件自己有沒有,沒有的話會往它的所屬類別找,再沒有的話會往這個所屬類別的上層類別找,直到找不到為止,所以 MRO 記載著方法查找的流程。

從結果來看,可以看到最末端都是 object 類別,也就是說如果沒有特別標註上層類別的話,上層類別就是內建的 object 類別。所以這兩種寫法在 Python 3 算是等價的:

class Animal:
pass

class Animal(object):
pass

不像其他我們自己寫的類別慣例上會使用駝峰式命名法,這個 object 類別是全部小寫的,別寫錯了。是說為什麼這裡我特別強調 Python 3?因為在 Python 2 的有兩種新舊兩種寫法,而且效果有些不同,例如:

class Fish:
pass

class Dog(object):
pass

以上面這個範例在 Python 2 裡,Fish 類別沒有明確繼承 object 類別,這是比較早期的經典款舊式類別(Old-style Class)寫法,而在 Dog 類別後面加上 object 明確的標記上層類別,這是在 Python 2 的中後期加入的新式類別(New-style Class)寫法。我們可以透過 __bases__ 屬性看一下這個類別的上層類別:

# 注意,這是 Python 2
>>> Fish.__bases__
()

>>> Dog.__bases__
(<type 'object'>,)

可以看的出來還是有一些差異,但在 Python 3 裡所有的類別都是新式類別的寫法,所以這兩種寫法就等價了。

但為什麼要繼承 object 類別呢?這問題也還滿容易理解的,想想看,類別身上總是得內建一些方法吧,不然這個類別沒什麼用途。這些方法是怎麼來的?是你自己寫的,還是你的類別的功能跟孫悟空一樣都是從石頭裡蹦出來的?來巡一下 object 類別身上有哪些屬性:

>>> dir(object)
['__class__', '__delattr__', '__dir__', '__doc__', '__eq__', '__format__', '__ge__', '__getattribute__', '__getstate__', '__gt__', '__hash__', '__init__', '__init_subclass__', '__le__', '__lt__', '__ne__', '__new__', '__reduce__', '__reduce_ex__', '__repr__', '__setattr__', '__sizeof__', '__str__', '__subclasshook__']

這都是 Python 提供的方法,例如像是 __init__()__new__() 這種用來建立實體的方法,這些是所有的類別都有機會用到的功能,所以就算我們沒特別寫,Python 會自動幫我們繼承這個類別,讓我們可以使用這些方法。

我小時候因為好奇為什麼鬧鐘會響所以把它拆來看裡面結構,後來因為裝不回去就被爸媽扁了一頓,即使這樣,我手賤的習慣到現在還是沒改掉。所以我就問,這個 object 上面還有人嗎?讓我們繼續往下挖:

>>> object.__mro__
(<class 'object'>,)
>>> print(object.__base__)
None

啪!沒啦,object 類別就是最上層的類別,它的 __base__ 屬性是 None,表示沒有再更上層的類別了。

覆寫方法?

當繼承自上層類別的時候,如果類別裡面有跟上層類別同名的方法的話,會發生什麼事?例如:

class Animal:
def walk(self):
print("Animal is walking")

class Cat(Animal):
def walk(self):
print("Cat is walking")

kitty = Cat()
kitty.walk() # 會是哪一個 walk?

這樣的行為稱之 Override,中文常會翻譯成「覆寫」,不過大家不要被中文翻譯給誤導了。如果去查字典,Override 有「to be more important than something」的意思,也就是說,這個方法比較重要所以先執行它,上層類別的同名方法並沒有被消滅,只是優先順序上排到比較後面而已。剛剛我們有講到 MRO,也就是 Python 在搜尋屬性或方法的流程,除了物件本身外,Python 會先從物件的所屬類別開始找起,如果找到就執行,找不到才往上層類別找。所以,當我們呼叫 kitty.walk() 的時候,Python 會先找 Cat 類別有沒有 walk() 方法,的確是有這個方法,所以就執行它,就這樣。這並不是覆蓋或覆寫上層 Animal 類別的方法,只是在 MRO 的優先順序上先找到了對應的方法,原本上層的類別還是存在的。

前面介紹用來初始化的 __init__() 方法就是一個常見的例子。當透過 Cat() 類別建立實體的時候,在這顆實體建立之後就會立刻執行 __init__() 方法進行初始化,所以如果在 Cat 類別中有定義 __init__() 方法的話,就會執行它。如果沒有,Python 會去它的上層類別,也許是 Animal 類別找看看,如果還是沒有,最後找到 object 類別,object 類別有定義了最陽春的 __init__() 函數,但是 object 類別的 __init__() 方法也沒做什麼事就是了。也因為 object 類別的陽春版 __init__() 方法並沒有帶參數,所以當我們執行 kitty = Cat("Kitty") 想要帶額外參數給它的時候會出現引數數量不正確的錯誤訊息。

執行上層類別的方法

如果在類別裡想要執行上層類別的方法,有幾種方式可以做到,舉個例子,我定義了一個繼承自 Animal 類別的 Cat 類別:

class Animal:
def walk(self):
print("Animal is walking")

def eat(self, food):
print(f"{food} is yummy!")

class Cat(Animal):
def walk(self):
print("Cat is walking")

我希望在執行 Cat 類別的 .walk() 方法的時候,可以先吃點東西再上路,因為上層類別有定義了 .eat() 方法,我就拿它來用,原本的 .walk() 方法改成這樣寫:

def walk(self):
Animal.eat(self, "罐罐") # <-- 這裡
print("Cat is walking")

這樣就能直接呼叫 Animal 類別的 .eat() 方法來執行,並且主動把 self 傳給它。執行之後的結果應該會變成:

>>> kitty = Cat()
>>> kitty.walk()
罐罐 is yummy!
Cat is walking

這沒什麼問題,不過因為這裡是直接使用 Animal 類別的方法,這樣的寫法其實跟繼承沒什麼關係,這沒有「綁定」到什麼物件上,所以就算 Cat 類別跟 Animal 類別沒有血緣關係這樣寫也不會出錯。不過既然有繼承的關係,可以用更簡單的方式來寫:

def walk(self):
super().eat("罐罐") # <-- 改用 super()
print("Cat is walking")

以這個例子來說,super() 函數看起來好像是呼叫上層類別,但其實並不是。super 指的不是上層類別,這裡也不會建立 AnimalCat 物件,而是建立一個代理物件(Proxy Object)。這可能有點複雜,這顆代理物件會知道目前在哪個類別裡,也知道目前呼叫的 self 是誰。以這個例子來說,這顆代理物件會幫我們找到「Cat 類別的 MRO 的下一個類別」然後找到 Animal 類別,因為這個代理物件裡自帶了 self,所以可以順利的執行在 Animal 類別上的 .eat() 方法。

所以 super() 是上層類別嗎?不是,不過在比較單純的繼承關係下可以暫時先這樣理解沒問題...

你是我的後代嗎?

如果可以知道上層類別是誰,那要怎麼知道某個類別是不是另一個類別的後代呢?在這裡我想先定義一下我這裡指的「後代」是指繼承關係裡的上下層關係,這裡我用下層類別或衍生類別來稱呼這種關係。舉個例子:

class Animal:
pass

class Bird(Animal):
pass

class Mammal(Animal):
pass

class Cat(Mammal):
pass

Python 有個內建函數 issubclass(),從字面上大概就可以猜出它是用來判斷是不是某個類別的衍生類別:

>>> issubclass(Cat, Mammal)
True
>>> issubclass(Cat, Animal)
True
>>> issubclass(Cat, Bird)
False
>>> issubclass(Cat, object)
True

這個 issubclass() 函數可用來判斷「是一種(is kind of)」的關係,例如,Cat 是一種 Mammal,同時也是一種 Animal,不過 Bird 同樣都是一種 Animal 但就跟 Cat 類別就沒有繼承關係。最後一個例子是 object 類別,前面提到只要沒特別標示上層類別的,例如 Animal 類別就是,Python 會讓它的上層類別自動變成 object 類別,所以 issubclass(Cat, object) 會回傳 True,或者也可以廣義的說所有的類別都是 object 的衍生類別。

另一個容易跟「後代」搞混的概念是「實體」,例如:

kitty = Cat()

簡單復習一下,在這裡的 kitty 物件是使用 Cat 類別建立的物件,這時 kitty 物件跟 Cat 類別之間並不是「是一種」的關係,而是「是一個(is a)」的關係。中文的「是一種」跟「是一個」可能不像英文分的那麼清楚,但它們的確是不一樣的關係,再舉個例子,正在讀這本書的各位,應該比較能認同我說「你是一個人」而不會說「你是一種人」。

要判斷某個物件是不是某個類別的實體,有幾種方式:

>>> type(kitty)
<class '__main__.Cat'>
>>> kitty.__class__
<class '__main__.Cat'>

透過物件身上自帶的 __class__ 屬性或是內建函數 type() 都可以查到這顆物件是由哪一個類別所建立的。另外 Python 有個內建函數 isinstance() 也能做到類似的判斷,但範圍更廣一點:

>>> isinstance(kitty, Cat)
True
>>> isinstance(kitty, Bird)
False
>>> isinstance(kitty, Animal)
True
>>> isinstance(kitty, object)
True

type() 函數比較明顯的差異在於 isinstance() 函數在判斷的時候,即使不是直接產生它的類別,例如 Animal 甚至是最上層的 object 類別,判斷的結果都會是 True

所以,哪個比較好用?這要看實際的需求而定,如果你只是要知道物件是不是由某個特定類別所建立的,使用 type() 函數判斷比較精準。相對的,如果你只是想知道物件有沒有某種類別的特定行為,用 isinstance() 函數會比較簡單。舉個例子,假設我想寫出「只要是動物就可以執行它的睡覺功能」,如果我這樣寫:

if type(kitty) is Animal:
kitty.sleep()

你會發現上面這個 if 判斷不會成立,kitty 雖然也是一種 Animal 但它並不是由 Animal 類別建立的。或是判斷的廣泛一點:

if type(kitty) in [Cat, Mammal, Animal]:
kitty.sleep()

雖然這樣可行,但寫起來都有點囉嗦,如果改用 isinstance() 函數就簡單得多了:

if isinstance(kitty, Animal):
kitty.sleep()

多重繼承

大部份程式語言在設計物件導向的繼承的時候選擇單一繼承(Single Inheritance)的設計,也就是說每個類別一次只能繼承一個上層類別,例如 Cat 類別繼承自 Mammal 類別,Mammal 類別繼承自 Animal 類別,這樣的繼承方式就是單一繼承。

如果有天我想讓我的貓除了在地上跑也可以飛上天,最好還可以潛水,對於其他單一繼承的程式語言來說可能會選擇使用「介面(Interface)」或是「混合(Mixin)」的方式來把飛行以及潛水的功能加進貓類別,但 Python 的設計者選擇了另一條看似簡單但一點都不簡單的路 - 多重繼承(Multiple Inheritance)。

Python 的繼承設計比較特別,一個類別可以同時繼承多個類別,舉例來說,假設 Bird 類別有飛行功能,而 Fish 類別有潛水功能。如果 Cat 想要可以上天也可以下海,那只要讓 Cat 類別同時繼承 Bird 以及 Fish 類別就可以啦。多重繼承的設計有好處也有壞處,但我們先來看看怎麼寫:

class Animal:
def sleep(self):
print("Zzzzz")

class Bird(Animal):
def fly(self):
print("I blieve I can fly ♫♪")

class Fish(Animal):
def dive(self):
print("Dive!!")

class Cat(Bird, Fish):
pass

Cat 類別可以同時繼承 Bird 以及 Fish 類別,寫法就是在類別名稱後面加上要繼承的類別名稱,並使用逗號隔開。這樣一來,Cat 類別就同時擁有了 BirdFish 以及 Animal 這三個類別的所有方法:

>>> kitty = Cat()

# 還是可以睡覺
>>> kitty.sleep()
Zzzzz

# 可以下海
>>> kitty.dive()
Dive!!

# 也可以飛天
>>> kitty.fly()
I blieve I can fly ♫♪

多重繼承看起來好像很方便也很直覺,想要某個類別的功能就去繼承它就好啦,其他採用單一繼承設計的程式語言為什麼不跟著抄就好呢?事情有這麼簡單就好了,多重繼承有它的問題要解決...

鑽石問題

多重繼承第一個會遇到的問題,就是如果兩個以上的上層類別有相同的方法的時候,舉例來說,Animal 類別本來就有實作了 .sleep() 方法,但 Bird 以及 Fish 的睡覺方式不太一樣,所以也各自實作自己的 .sleep() 方法:

class Animal:
def sleep(self):
print("Zzzzz")

class Bird(Animal):
def sleep(self):
print("我可以站著睡覺")

class Fish(Animal):
def sleep(self):
print("我睡覺不用閉眼睛")

class Cat(Bird, Fish):
pass

這裡我先簡化其他方法,我們就只看 .sleep() 就好。這時候的繼承狀態大概像這樣:

   A      Animal(A)
/ \
B F Bird(B), Fish(F)
\ /
C Cat(C)

這有個專有名詞叫「鑽石問題(Diamond Problem)」,是指一個類別同時繼承兩個類別,而這兩個類別又同時繼承自同一個類別,這樣的繼承關係就會形成一個鑽石的形狀,但其實更像個菱形,所以有時候又稱之菱形繼承。

回到原本的問題,Cat 類別體內同時流著這三個類別的血液,如果執行 Cat 類別的 .sleep() 方法的時候:

kitty = Cat()
kiity.sleep() # 會執行哪個 .sleep() 方法?

會執行哪一個類別的 .sleep() 方法?

執行下去就會發現,答案是 Bird 類別的 .sleep() 方法會被執行。還記得前面提到的 MRO 嗎?

>>> Cat.mro()
[<class '__main__.Cat'>,
<class '__main__.Bird'>,
<class '__main__.Fish'>,
<class '__main__.Animal'>,
<class 'object'>]

Python 在處理多重繼承的時候,有它自己一套演算法計算出 MRO 的順序。以我們上面這個例子來說,MRO 的順序是由小括號內的類別順序決定的,由左至右,不信的話,你可以把小括號裡的順序調換過來實驗看看。

所以當要執行 kitty.sleep() 的時候,會先詢問 MRO 上的第一個候選人 Cat 類別,不過因為它沒有 .sleep() 方法,所以就會詢問下一個類別 Bird,剛好在這裡就有定義 .sleep() 方法,所以就執行它,打完收工。

接下來我稍微調整一下:

class Animal:
def sleep(self):
print("Zzzzz")

class Bird(Animal):
pass

class Fish(Animal):
def sleep(self):
print("我睡覺不用閉眼睛")

class Cat(Bird, Fish):
def sleep(self):
super().sleep()

kitty = Cat()
kitty.sleep() # 睡!

這次我把 Bird 類別的 .sleep() 方法拿掉,然後在 Cat 類別裡實作自己的 .sleep,但在裡面改為呼叫 super().sleep(),這樣的話,這裡的 super() 指的是誰?是往上層找的 Animal 還是往平行單位找的 Fish

在其他單一繼承的程式語言,如果 Bird 類別裡找不到方法,接下來會往上層找看看 Animal 類別,但 Python 裡,super() 是依照 MRO 的順序來找。上面這個範例中的 MRO 的順序,Bird 的「下一個」是 Fish 類別,所以這裡會執行 Fish 類別的 .sleep() 方法。

不知道有沒有注意到這裡的細節,我先劃一下重點:

super() 函數指的不是「上層類別」,而是在 MRO 裡的「下一個」類別

至於 MRO 的計算過程其實有點複雜,有興趣的可以用關鍵字「C3 線性演算法(C3 Linearization)」來搜尋一下,像我這種普通人,就用 __mro__ 屬性或是 .mro() 方法取得 MRO 列表就行了。

多重繼承用起來挺方便也很直覺的,但如果不是為了教學目的,我不會沒事寫像上面這些範例這種有點極端的狀況。實務上通常不會把繼承關係弄的這麼複雜,多重繼承有好處也有一些小問題,使用的時候稍微留意 MRO 的順序,基本上就沒什麼問題了。

工商服務

想學 Python 嗎?我教你啊 :)

想要成為軟體工程師嗎?這不是條輕鬆的路,除了興趣之外,還需要足夠的決心、設定目標並持續學習,我們的ASTROCamp 軟體工程師培訓營提供專業的前後端課程培訓,幫助你在最短時間內建立正確且扎實的軟體開發技能,有興趣而且不怕吃苦的話不妨來試試看!