Home
探索 Uedu
學生控制台
註冊會員/登入
研究知情同意中心
教師控制台
課程設定
支援與訊息
Uptime 數據

UeduGPTs

--

Jupyters

4

UG26 CISOSE26
臺北 AQI 46 · 臺中 AQI 28 · 臺南 AQI 24 · 高雄 AQI 33

AI 回覆桌面通知

AI 助教回覆完成時顯示桌面通知

聊天訊息通知

同學在討論區發送訊息時通知

聲音通知

每當有新通知時播放提示音

類別與物件

Python 類別與物件:把現實世界搬進程式

從一隻會叫的狗開始,動手寫出 class、繼承、多型與 dunder 方法,並一窺鴨子型別與 MRO 的語言機制

從「一隻會叫的狗」開始:把現實世界搬進程式

假設你要寫一個寵物管理系統。第一隻狗叫小白、三歲、會「汪汪」叫;第二隻狗叫小黑、五歲,一樣會叫。如果用變數硬刻,你會寫出 dog1_namedog1_agedog2_namedog2_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 會:

  1. 先在記憶體裡造出一個空的 Dog 物件。
  2. 把這個物件當作 self 傳進 __init__
  3. 接著把 "小白" 對應到 name3 對應到 age

self.name = name 這行的意思是「把參數 name 的值,記在這個物件身上、取名叫 name」。self 永遠代表「目前正在操作的這個物件」。初學者最常見的疑惑是「我呼叫時只給了兩個參數,為什麼定義時有三個?」——因為 self 由 Python 自動填入,你不需要、也不可以手動傳。

實例屬性 vs 類別屬性:誰屬於誰

剛剛的 nameage 寫在 __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 + yx[i]for ... in xx == 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、最後 objectd.who()B 就找到答案,所以回 "B"。C3 演算法保證兩件事:子類別永遠排在父類別之前,且每個父類別的相對順序被尊重(這裡 BC 之前,因為 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 的思路會在你親手敲程式的過程裡,慢慢變成直覺。

AI 共讀助教正在陪你讀:Python 類別與物件:把現實世界搬進程式
嗨!我是這篇文章的共讀助教,只根據〈Python 類別與物件:把現實世界搬進程式〉的內容回答。可以問我「解釋某段」「舉個例子」「出題考我」,或反白文中段落後點下方「解釋選取段落」。