Home
Explore Uedu
Student Console
Register as Member/Login
(2) In future presentations of the research findings, in addition to the course project website and public presentations, your real name and personal information will not appear in this research report. If you are interested in the research results, we can provide you with an executive summary after the study is completed.
問卷中心
Teacher Console
Course Setup
Support & Messages
Uptime Data

UeduGPTs

--

Jupyters

0

Local AI

--

CISOSE26 本地 AI UG26
政治大學 AQI 18 29°C

AI Reply Desktop Notifications

Show a desktop notification when the AI TA finishes replying

Chat Message Notifications

Notify me when classmates post messages in the forum

Sound notification

Play an alert sound whenever there is a new notification

類別與物件

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 類別與物件:把現實世界搬進程式〉的內容回答。可以問我「解釋某段」「舉個例子」「出題考我」,或反白文中段落後點下方「解釋選取段落」。