Python 類別與物件:把現實世界搬進程式
從一隻會叫的狗開始,動手寫出 class、繼承、多型與 dunder 方法,並一窺鴨子型別與 MRO 的語言機制
從「一隻會叫的狗」開始:把現實世界搬進程式
假設你要寫一個寵物管理系統。第一隻狗叫小白、三歲、會「汪汪」叫;第二隻狗叫小黑、五歲,一樣會叫。如果用變數硬刻,你會寫出 dog1_name、dog1_age、dog2_name、dog2_age⋯⋯到了第十隻狗,整個程式就會變成一團糾結的義大利麵。
問題的本質是:現實世界的「狗」是一種東西,它同時擁有資料(名字、年齡)與行為(叫、跑)。物件導向程式設計(Object-Oriented Programming, OOP)就是把資料與行為綁在一起的思考方式。在計算機概論裡你學過的「抽象化(abstraction)」、「封裝(encapsulation)」、「繼承(inheritance)」、「多型(polymorphism)」,在 Python 裡都有對應的具體寫法。這篇文章帶你親手把這些概念敲出來。

類別是藍圖,物件是成品:class 與 init
類別(class) 是一張藍圖,描述「一隻狗應該長什麼樣、能做什麼」;物件(object)(也叫實例 instance)則是照著藍圖打造出來的具體成品。一張藍圖可以蓋出無數棟房子,一個類別也可以產生無數個物件。
我們先定義最簡單的 Dog 類別:
class Dog:
def __init__(self, name, age):
self.name = name
self.age = age
# 用類別「蓋出」兩個物件
xiaobai = Dog("小白", 3)
xiaohei = Dog("小黑", 5)
print(xiaobai.name) # 輸出:小白
print(xiaohei.age) # 輸出:5
這裡的 __init__ 是一個特殊方法(前後各兩個底線,常唸作 dunder,dunder init),它在你建立物件的當下自動被呼叫,負責「初始化」這個物件的資料。當你寫 Dog("小白", 3) 時,Python 會:
- 先在記憶體裡造出一個空的
Dog物件。 - 把這個物件當作
self傳進__init__。 - 接著把
"小白"對應到name、3對應到age。
self.name = name 這行的意思是「把參數 name 的值,記在這個物件身上、取名叫 name」。self 永遠代表「目前正在操作的這個物件」。初學者最常見的疑惑是「我呼叫時只給了兩個參數,為什麼定義時有三個?」——因為 self 由 Python 自動填入,你不需要、也不可以手動傳。
實例屬性 vs 類別屬性:誰屬於誰
剛剛的 name、age 寫在 __init__ 裡、掛在 self 上,這叫實例屬性(instance attribute):每個物件各自擁有一份,互不干擾。小白改名不會影響小黑。
但有些資料是「所有狗共用」的,例如「狗的生物學分類是哺乳類」。這種寫在 class 區塊裡、不掛在 self 上的,叫類別屬性(class attribute):所有物件共享同一份。
class Dog:
species = "哺乳類" # 類別屬性:所有狗共享
def __init__(self, name, age):
self.name = name # 實例屬性:每隻狗各自一份
self.age = age
xiaobai = Dog("小白", 3)
xiaohei = Dog("小黑", 5)
print(xiaobai.species) # 輸出:哺乳類
print(xiaohei.species) # 輸出:哺乳類
print(Dog.species) # 輸出:哺乳類(透過類別直接存取)
這裡有一個經典的雷區:當你讀取 xiaobai.species 時,Python 先找實例屬性,找不到才往類別屬性找;但當你寫入 xiaobai.species = "犬科" 時,Python 會在 xiaobai 身上新建一個同名的實例屬性,遮蔽掉類別屬性,而不會動到其他物件:
xiaobai.species = "犬科" # 只在 xiaobai 身上新建實例屬性
print(xiaobai.species) # 輸出:犬科
print(xiaohei.species) # 輸出:哺乳類(不受影響)
print(Dog.species) # 輸出:哺乳類(類別屬性原封不動)
因此,可變物件(如串列(list)、字典(dict))絕對不要當類別屬性用來存「各物件自己的資料」,否則所有物件會共用同一個串列,改一個全部跟著變。這是 Python OOP 初學者最容易製造的 bug:
# 反模式:不要這樣寫!
class BadDog:
tricks = [] # 共享的串列,災難的開始
def add_trick(self, trick):
self.tricks.append(trick)
a, b = BadDog(), BadDog()
a.add_trick("坐下")
print(b.tricks) # 輸出:['坐下'] ← b 也被影響了,這通常不是你要的
# 正確:每隻狗各自一份串列,放在 __init__ 裡
class GoodDog:
def __init__(self):
self.tricks = [] # 每個物件初始化時各自建立
def add_trick(self, trick):
self.tricks.append(trick)
方法與 self:讓物件動起來
方法(method) 就是「定義在類別裡的函式」,代表物件的行為。每個方法的第一個參數都是 self,讓你能在方法內存取、修改這個物件自己的資料。
class Dog:
def __init__(self, name, age):
self.name = name
self.age = age
def bark(self):
return f"{self.name}:汪汪!"
def have_birthday(self):
self.age += 1 # 透過 self 修改自己的屬性
xiaobai = Dog("小白", 3)
print(xiaobai.bark()) # 輸出:小白:汪汪!
xiaobai.have_birthday()
print(xiaobai.age) # 輸出:4
當你寫 xiaobai.bark() 時,Python 會自動把 xiaobai 當作 self 傳進去——這就是為什麼定義時有 self、呼叫時卻不用寫。你也可以理解成:xiaobai.bark() 等價於 Dog.bark(xiaobai)。
繼承與 super:站在前人的肩膀上
「貓」和「狗」都是「動物」,都有名字、年齡,都會叫、都會吃。如果每種動物都從零寫起,重複的程式碼會多到讓人崩潰。繼承(inheritance) 讓你把共通的部分抽到一個父類別(parent class / base class),再由子類別(child class / subclass) 繼承並擴充。
class Animal:
def __init__(self, name, age):
self.name = name
self.age = age
def eat(self):
return f"{self.name} 正在吃東西"
def speak(self):
return f"{self.name} 發出了聲音"
class Dog(Animal): # Dog 繼承 Animal
def __init__(self, name, age, breed):
super().__init__(name, age) # 呼叫父類別的 __init__ 處理共通屬性
self.breed = breed # 再補上狗特有的屬性
def fetch(self):
return f"{self.name} 把球叼回來了"
lucky = Dog("Lucky", 2, "柴犬")
print(lucky.eat()) # 輸出:Lucky 正在吃東西 ← 繼承自 Animal
print(lucky.fetch()) # 輸出:Lucky 把球叼回來了 ← Dog 自己的方法
print(lucky.breed) # 輸出:柴犬
關鍵在 super().__init__(name, age):super() 代表「父類別」,這行的意思是「先讓 Animal 完成它那部分的初始化(設定 name、age),我再補做狗特有的事」。請務必用 super() 而不要寫死 Animal.__init__(self, ...)——前者在多層繼承與多重繼承時才能正確運作(稍後深入段會解釋為什麼)。
多型與覆寫:同一句話,不同回應
子類別可以覆寫(override) 父類別的方法——名字一樣,內容不同。這帶來多型(polymorphism):同樣呼叫 .speak(),狗回「汪汪」,貓回「喵喵」。
class Animal:
def __init__(self, name):
self.name = name
def speak(self):
return f"{self.name} 發出了聲音"
class Dog(Animal):
def speak(self): # 覆寫父類別的 speak
return f"{self.name}:汪汪!"
class Cat(Animal):
def speak(self): # 覆寫父類別的 speak
return f"{self.name}:喵喵~"
# 多型的威力:用同一段邏輯處理不同型別的物件
animals = [Dog("小白"), Cat("咪咪"), Animal("某生物")]
for a in animals:
print(a.speak())
# 輸出:
# 小白:汪汪!
# 咪咪:喵喵~
# 某生物 發出了聲音
注意那個迴圈:它完全不需要知道清單裡每個元素到底是狗還是貓,只要它「會 speak」就好。Python 在執行 a.speak() 的當下,才依照物件的實際型別決定呼叫哪個版本,這叫動態分派(dynamic dispatch)。多型讓你寫出對未來開放的程式:日後新增一個 Bird 類別,這段迴圈一個字都不用改。
str:讓物件「自我介紹」
直接 print 一個物件,預設會印出像 <__main__.Dog object at 0x7f...> 這種對人類毫無意義的東西。實作 __str__ 這個 dunder 方法,就能定義「物件被轉成字串時長什麼樣」:
class Dog:
def __init__(self, name, age):
self.name = name
self.age = age
def __str__(self):
return f"狗狗 {self.name}({self.age} 歲)"
def __repr__(self):
# 給開發者看的、最好能還原物件的字串
return f"Dog(name={self.name!r}, age={self.age})"
d = Dog("小白", 3)
print(d) # 輸出:狗狗 小白(3 歲) ← print 會呼叫 __str__
print(str(d)) # 輸出:狗狗 小白(3 歲)
print(repr(d)) # 輸出:Dog(name='小白', age=3)
慣例上,__str__ 給「終端使用者」看(友善、好讀),__repr__ 給「開發者」看(精確、最好能用來重建物件)。當你只想實作一個時,優先實作 __repr__,因為在 __str__ 缺席時 Python 會退而求其次拿 __repr__ 來頂替。
動手寫一段:一個迷你銀行帳戶系統
把上面的概念全部串起來。下面是一個完整、可直接執行的小程式,模擬銀行帳戶與「儲蓄帳戶」(會生利息):
class Account:
"""銀行帳戶基底類別"""
bank_name = "Uedu 銀行" # 類別屬性:所有帳戶共享
def __init__(self, owner, balance=0):
self.owner = owner # 實例屬性
self.balance = balance
def deposit(self, amount):
if amount <= 0:
raise ValueError("存款金額必須大於 0")
self.balance += amount
def withdraw(self, amount):
if amount > self.balance:
raise ValueError("餘額不足")
self.balance -= amount
def __str__(self):
return f"[{self.bank_name}] {self.owner} 的帳戶,餘額 {self.balance} 元"
class SavingsAccount(Account):
"""儲蓄帳戶:繼承自 Account,多了利率"""
def __init__(self, owner, balance=0, rate=0.02):
super().__init__(owner, balance) # 重用父類別初始化
self.rate = rate
def add_interest(self):
"""加上一期利息"""
self.balance += int(self.balance * self.rate)
def __str__(self): # 覆寫,顯示利率
base = super().__str__()
return f"{base}(年利率 {self.rate:.0%})"
# 多型:用同一份程式處理不同帳戶
accounts = [
Account("小明", 1000),
SavingsAccount("小華", 1000, rate=0.05),
]
for acc in accounts:
acc.deposit(500)
if isinstance(acc, SavingsAccount):
acc.add_interest()
print(acc)
# 輸出:
# [Uedu 銀行] 小明 的帳戶,餘額 1500 元
# [Uedu 銀行] 小華 的帳戶,餘額 1575 元(年利率 5%)
跑跑看,再試著自己加一個 CheckingAccount(支票帳戶,可透支到 -5000)。你會發現:只要它也繼承 Account、實作該有的方法,那個 for 迴圈一樣不用改——這就是 OOP 帶來的可擴充性。
常見錯誤與重點回顧
- 忘記
self:在方法定義裡漏寫第一個參數self,或在方法內存取屬性時忘了加self.(寫成name而非self.name,那只是個區域變數,存不進物件)。 - 可變物件當類別屬性:把
[]或{}寫成類別屬性來存各物件自己的資料,會導致所有物件共用,改一個全部變。各物件專屬的可變資料一律放進__init__。 __init__不是建構子的全部:__init__負責「初始化已造好的物件」,真正「造出物件」的是__new__。日常開發改__init__就夠,但別誤以為它會「回傳」物件——__init__不該有return值。- 覆寫時忘了
super():子類別寫了__init__卻沒呼叫super().__init__(...),父類別的屬性就不會被設定,之後存取會直接AttributeError。 - 混淆
is與==:is比較「是不是同一個物件(記憶體位址相同)」,==比較「值是否相等」(可由__eq__自訂)。判斷兩個物件內容相同,要用==,不是is。
深入探討(研究所視角)
鴨子型別:Python 不問「你是誰」,只問「你會什麼」
在 Java、C++ 這類靜態型別語言裡,多型通常綁定繼承——你得是 Animal 的子類別才能被當成 Animal 用。Python 走的是另一條路:鴨子型別(duck typing),名稱來自「如果牠走路像鴨子、叫聲像鴨子,那就把牠當鴨子」。
class Duck:
def speak(self):
return "嘎嘎"
class Robot: # 注意:Robot 完全沒有繼承任何動物類別
def speak(self):
return "嗶嗶(模擬叫聲)"
def make_it_speak(thing):
# 不檢查 thing 的型別,只要它有 speak() 就能用
return thing.speak()
print(make_it_speak(Duck())) # 輸出:嘎嘎
print(make_it_speak(Robot())) # 輸出:嗶嗶(模擬叫聲)
make_it_speak 從不過問參數的型別,它只在執行 thing.speak() 的瞬間嘗試呼叫,呼叫得通就成。這帶來極高的彈性,但代價是「型別不相容」的錯誤要到執行期才會爆。現代 Python 用 typing.Protocol(結構型別 structural typing)在保留鴨子精神的同時,補上靜態檢查的能力,讓 mypy 這類工具能在執行前就抓出「這東西沒有 speak 方法」。
特殊方法(dunder):Python 物件協定的接口
__init__、__str__ 只是冰山一角。Python 的內建語法——len(x)、x + y、x[i]、for ... in x、x == y——背後全都委派給對應的 dunder 方法。換句話說,這些運算子不是「型別寫死的特權」,而是任何類別都能接管的協定(protocol):
class Vector:
def __init__(self, x, y):
self.x, self.y = x, y
def __add__(self, other): # 定義 + 的行為
return Vector(self.x + other.x, self.y + other.y)
def __eq__(self, other): # 定義 == 的行為
return self.x == other.x and self.y == other.y
def __len__(self): # 定義 len() 的行為(這裡回傳維度數)
return 2
def __repr__(self):
return f"Vector({self.x}, {self.y})"
v = Vector(1, 2) + Vector(3, 4)
print(v) # 輸出:Vector(4, 6)
print(Vector(1, 2) == Vector(1, 2)) # 輸出:True
print(len(v)) # 輸出:2
這就是 Python「協定優先於繼承」的設計哲學:你不需要繼承某個 Addable 介面才能支援 +,只要實作 __add__ 即可。NumPy 的陣列、pandas 的 DataFrame 能那麼順手地支援數學運算與索引,靠的正是大量實作這些 dunder 方法。
MRO:多重繼承時,方法到底從哪裡找?
當一個類別同時繼承多個父類別,而它們又有同名方法時,Python 該呼叫哪一個?答案由方法解析順序(Method Resolution Order, MRO) 決定。Python 3 採用 C3 線性化(C3 linearization) 演算法,產生一個確定、無歧義的查找順序。
class A:
def who(self):
return "A"
class B(A):
def who(self):
return "B"
class C(A):
def who(self):
return "C"
class D(B, C): # 經典的菱形繼承(diamond inheritance)
pass
d = D()
print(d.who()) # 輸出:B
print([cls.__name__ for cls in D.__mro__])
# 輸出:['D', 'B', 'C', 'A', 'object']
D.__mro__ 印出的就是查找鏈:先找 D、再 B、再 C、再 A、最後 object。d.who() 在 B 就找到答案,所以回 "B"。C3 演算法保證兩件事:子類別永遠排在父類別之前,且每個父類別的相對順序被尊重(這裡 B 在 C 之前,因為 D(B, C) 是這個順序)。
現在你能理解前面為什麼堅持用 super() 而非 Animal.__init__() 了:super() 並不是「呼叫父類別」這麼簡單,它其實是「沿著 MRO 走到下一個類別」。在菱形繼承中,super() 能確保 A 的 __init__ 只被執行一次(協作式多重繼承 cooperative multiple inheritance),而寫死類別名會讓共同祖先被重複初始化,埋下難以察覺的 bug。
class Base:
def __init__(self):
print("Base.__init__")
class Left(Base):
def __init__(self):
super().__init__() # 走 MRO,不是寫死 Base
print("Left.__init__")
class Right(Base):
def __init__(self):
super().__init__()
print("Right.__init__")
class Child(Left, Right):
def __init__(self):
super().__init__()
print("Child.__init__")
Child()
# 輸出:
# Base.__init__
# Right.__init__
# Left.__init__
# Child.__init__
看見了嗎?Base.__init__ 只跑了一次,順序完全依照 MRO(Child → Left → Right → Base)反向收束。這正是 super() 在大型框架(如 Django 的 mixin 體系)中不可或缺的原因。理解了 MRO 與協作式 super(),你對 Python 物件模型的掌握,就從「會用」邁向了「懂為什麼」。
動手把這些範例改一改、跑一跑——把狗換成你正在做的專案裡的概念(使用者、訂單、貼文都行),OOP 的思路會在你親手敲程式的過程裡,慢慢變成直覺。