💡 先搞懂問題
第 9 課幫虛構的「晴空咖啡」寫過算錢的函式。現在店長想把整個點單流程寫成程式:每一杯飲料都有品名、尺寸、加料三項資料,也有「加料」「算價錢」這些動作。用前面學過的東西來寫,最直覺的做法是每一杯取一組名字:name1、size1、tops1 是第一杯,name2、size2、tops2 是第二杯,再寫一個函式 add_topping(tops, item) 負責加料。
兩杯還應付得來,十杯就亂了。同一杯的三項資料,只靠名字結尾的數字湊在一起;加料時要自己記得把「這一杯」的清單傳進函式,傳錯了,珍珠就加到別人的飲料上,而且 Python 完全不會提醒你,因為對它來說那只是一個普通的串列。第 6 課的字典可以把一杯的資料收在一起,可是「加料」「算價錢」這些動作仍然是另外寫的函式,資料和動作還是分開的。
Drink,照它做出的每一杯都自己帶著品名、尺寸、加料;b.add("珍珠") 的意思是「對 b 這一杯加料」,不可能加錯杯。Python 的解法是讓你自己定義一種新的東西,叫類別(class)。類別像一張設計圖,規定「一杯飲料」要帶著哪些資料、能做哪些動作;照著設計圖做出來的每一杯,叫物件(object),也常叫實例(instance)。物件身上帶著的資料叫屬性(attribute),寫在類別裡、讓物件能做的動作叫方法(method)。
其實你從第 4 課就在用物件了。"latte".upper()、第 5 課的 orders.append("雞腿飯"),都是「物件、點號、方法名稱、括號」的寫法:字串和串列是 Python 內建好的類別(str、list),每一個字串、每一個串列都是照它們做出來的物件。這一課要學的,是自己畫一張設計圖。
生活比喻:櫃台的點單本
晴空咖啡櫃台有一本點單本,每一張都照同一個格式印著三個欄位:「品名__ 尺寸__ 加料__」,最下面還印著一行作業說明:「加料:在本單的加料欄追加品項」。格式是印刷廠事先設計好的,整本只有這一種。客人來點餐,店員就撕下一張新的,填上這一杯的品名和尺寸;第二位客人來,再撕一張,兩張各寫各的,在第二張寫上珍珠,第一張不會跟著多出珍珠。
吧台的店員要替某一杯加料,會拿起那一張單,照底下的說明在「本單」的加料欄寫上品項。說明只寫「本單」,沒有寫是哪一位客人,因為它指的永遠是「手上正拿著的這一張」。另外,櫃台習慣叫這張單「3 號單」,吧台叫它「拿鐵那張」,兩個稱呼說的是同一張紙:吧台在上面加了燕麥奶,櫃台拿起 3 號單也會看到燕麥奶。
Drink;撕下一張、填上內容,是呼叫類別建立物件 Drink("拿鐵", "M"),負責「填上內容」的是一個特別的方法 __init__(init 是 initialize 初始化的縮寫,前後各兩條底線);一張填好的單是一個物件,上面的欄位是屬性 self.name、self.size、self.toppings;單底的作業說明是方法 add,說明裡的「本單」就是 self,代表「這一次被操作的那一個物件」。兩個稱呼指同一張單,就是兩個名字(第 2 課的標籤)貼在同一個物件上。
這個比喻有三個地方和 Python 不一樣,播放器會讓你親眼看到。第一,紙本的每一張單底下都印了一份作業說明,Python 的方法卻只存在類別裡一份,物件身上只有屬性;呼叫 a.add(…) 時,Python 是回到類別去找 add。第二,點單的欄位是事先印好的空格,Python 物件剛建立時身上什麼屬性都沒有,是 __init__ 執行時一個一個「貼」上去的,你會在播放器裡看到一個寫著「還沒有屬性」的新物件。第三,欄位裡是用筆「寫」上去的字,Python 的屬性和第 2 課的變數一樣是名字標籤:self.toppings = [] 是把物件身上的名字 toppings 貼到一個新串列上,所以加料時 append 會就地修改那個串列,這正是第 5 課的觀念。
Drink 只有一個,方法列在它身上;照圖做出的兩個物件只帶著屬性,各有自己的 toppings 串列(o5 和 o10),所以替 b 加珍珠,a 那杯不受影響。🎮 互動實驗室一:照設計圖做出兩杯
這段程式先畫一張設計圖 Drink,再照著它做出拿鐵和紅茶兩杯,最後只替紅茶加珍珠。它是 Python 3.13 真的執行一次、逐步記錄下來的結果。按「下一步」,綠色箭頭是下一個要執行的行,黃色是剛執行完的行;右下方左欄是名字和呼叫框(第 9 課),右欄是物件。黃色底的方塊是類別和照它做出的物件,物件裡 .name 這種一列一列的表格就是屬性。停在 🤔 的地方,先猜再按下一步。
Drink 物件,__init__ 呼叫框裡的 self 指著它,這時全域還沒有 a。接著每執行一行 self.… = …,物件的表格就多一列。第 8 步呼叫框拿掉、a 貼上這個物件。第 9 步做第二杯時,同樣的第 3~5 行再執行一次,但 self 指向的是另一個新物件。第 15 步呼叫 b.add,看 self 的箭頭指向哪一杯。🎮 互動實驗室二:同一杯,還是兩杯?
右上角的選單有三段程式。前兩段只差第 8 行:c = a 和 c = Drink("拿鐵")。兩段的 c 都「是一杯拿鐵」,記憶體裡卻完全不同:先用第一段走一遍,再切到第二段,比較右欄有幾個 Drink 物件、a 和 c 的箭頭指向哪裡。第三段是初學者最常犯的錯:在 __init__ 裡忘了寫 self.。
Drink 物件,兩支箭頭指著它;呼叫 c.add 時,self 也指著同一個物件,所以 a 看得到燕麥奶。切到「② c = Drink("拿鐵")」,第 8 行會再走一次 __init__,右欄多出第二個物件,之後的改動都只發生在 c 那杯。切到「③ 忘了寫 self.」,盯著第 3 行執行前後的物件:它從頭到尾都沒有 .name 這一列。🎮 互動實驗室三:物件工廠,自己寫程式
這一次換你寫程式。上面的設計圖 Drink 已經寫好了,用下面幾排按鈕組出一行程式、按「執行」,這一行就會加到程式最後面,下方的記憶體圖立刻更新(為了專心看結果,這裡每按一次只前進一整行,不展開 __init__ 和 add 內部的步驟,那些在實驗室一、二已經看過)。播放器的「上一步」可以回放你寫的整段程式。試著完成下面的四個任務;寫錯了可以按「撤銷上一行」。
a = Drink("拿鐵", "M")。- 任務 1:讓 a、b 各自指向一杯不同的飲料,而且只有 b 加了料(印出 a 確認它的加料清單是空的)。
- 任務 2:讓 c 和 a 指向同一杯,再透過 c 加料,讓 a 的加料清單也出現那一項。
- 任務 3:用
print(… is …)印出一個True和一個False。 - 任務 4:讓某一杯飲料變成「沒有任何名字指向它」(提示:用 ① 或 ④ 把唯一貼著它的名字改貼到別處)。
Drink 物件,④ 卻只會多一支箭頭;一杯飲料失去所有名字時,會以虛線顯示一次「沒有名字指向它了」。📘 原理補完
1. 正式名稱一次對照
看官方文件或別人的教學時,會碰到下面這些名詞。左邊兩欄是正式說法,右邊兩欄對應到這一課的程式和播放器畫面。
| 用語 | 英文 | 在程式裡長怎樣 | 白話意思 |
|---|---|---|---|
| 類別 | class | class Drink: | 設計圖:規定這種東西有哪些屬性、能做哪些動作 |
| 物件/實例 | object/instance | Drink("拿鐵", "M") 做出來的東西 | 照設計圖做出的一個實體,可以做很多個 |
| 建立物件 | instantiation | a = Drink("拿鐵", "M") | 呼叫類別,就像呼叫函式,結果是一個新物件 |
| 初始化方法 | __init__ | def __init__(self, name, size): | 建立物件時自動執行,負責替新物件貼上屬性 |
| self | self | 每個方法的第一個參數 | 這一次被操作的那一個物件 |
| 屬性 | attribute | self.name、a.toppings | 貼在物件身上的名字,指向這個物件自己的資料 |
| 方法 | method | def add(self, item):、a.add("珍珠") | 寫在類別裡的函式,透過物件呼叫 |
| 點號 | dot notation | a.name、a.add(…) | 「a 身上的……」:取屬性或呼叫方法 |
類別名稱習慣用大寫字母開頭、每個英文字的字首大寫,例如 Drink、StampCard;變數、屬性和方法名稱則用小寫加底線,例如 toppings、add_topping。這不是規定,寫錯也能執行,但照這個習慣,別人一看就知道 Drink(…) 是在建立物件,不是呼叫普通函式。內建的 str、list 是早期留下來的例外。
2. 建立一個物件時發生的三件事
a = Drink("拿鐵", "M") 這一行看起來很短,Python 其實做了三件事:先做出一個空的 Drink 物件;接著自動呼叫 __init__,開一個呼叫框,把 self 貼到這個新物件、name 和 size 貼到傳進來的兩個字串,執行裡面的 self.name = name 等等,替物件貼上屬性;最後 __init__ 結束、呼叫框拿掉,「呼叫類別」這件事的結果就是那個新物件,等號左邊的 a 才貼上去。實驗室一的第 3 到第 8 步,就是這三件事。
self:它和最後的 a 指向同一個物件 o2,只是 self 先到,a 要等 __init__ 做完才貼上。③呼叫框拿掉後,參數 name、size 跟著消失,屬性卻留在物件身上,因為屬性是貼在物件上的名字,不是呼叫框裡的名字。兩個細節值得記住。第一,__init__ 不寫 return,它的回傳值是 None;a 拿到的新物件不是 __init__ 交回來的,而是「呼叫類別」這整件事的結果。第二,__init__ 的參數就是建立物件時要給的東西:定義成 __init__(self, name, size),建立時就要寫 Drink("拿鐵", "M") 兩個引數,少給一個會出錯(下面第 7 小節);也可以像第 9 課那樣給預設值,例如 size="M"。
3. self:方法怎麼知道是哪一杯
方法 add 只在類別裡寫了一次,卻要能替任何一杯加料,所以它需要一個參數來接「這一次是哪一杯」,這個參數就是 self。寫 b.add("珍珠") 時,Python 先看點號前面的 b,到它的類別 Drink 裡找到 add,然後把 b 自動放在第一個位置傳進去,效果和 Drink.add(b, "珍珠") 完全一樣。這就是為什麼定義時要寫 self,呼叫時卻不用傳:那個位置 Python 已經幫你填好了。
a.add("珍珠") 被 Python 改寫成 Drink.add(a, "珍珠"),於是 a 落在 self 的位置、"珍珠" 落在 item。右邊說明 add 從哪裡來:物件身上只有屬性,方法存在類別裡,所有物件共用同一份。self 只是習慣的名字,換成別的也能執行,但整個 Python 社群都用 self,請不要改。真正要注意的是兩個「忘了」:定義方法時忘了寫 self 參數,呼叫時 Python 照樣會塞一個物件進去,參數數量就對不上;方法裡忘了寫 self.,例如 name = name,那只是呼叫框裡的區域變數(第 9 課),方法結束就消失,物件身上什麼也沒留下,實驗室二第三段就是這種情況。
4. 方法讀取、修改自己的屬性
方法需要的資料如果都在物件身上,就不必另外傳參數,從 self 讀就好。下面的 price 只有一個參數 self,卻能依「這一杯」的尺寸和加料數量算出價錢(價格都是示意)。而 add 裡的 self.toppings.append(item) 是對物件身上那個串列做就地修改:和第 5 課一樣,串列還是同一個物件,只是內容變了,所以方法結束後,從外面看 order.toppings 也是新的內容。
add,self 都貼在 order 指向的同一個物件上,串列 o5 被就地修改,編號一直沒變。第 17 步呼叫 order.price():括號裡什麼都沒傳,呼叫框裡卻有 self。第 21 步 base 從 60 改貼到 75,它是 price 的區域變數,方法結束就消失;self.size、self.toppings 這些屬性則一直留在物件上。屬性的「改」也分兩種,和第 5 課完全一樣:self.toppings.append(…) 是改物件(就地修改那個串列);self.size = "L" 是把物件身上的名字 size 撕下來改貼到新的字串,字串本身沒有被修改。不論哪一種,改的都是這個物件自己的屬性,別的物件不受影響。
5. 同一杯,還是兩杯:is 與 ==
每呼叫一次類別,就做出一個新物件;等號只會讓名字貼上去,不會複製。所以 c = a 之後只有一杯、兩個名字,透過哪個名字改,另一個都看得到;c = Drink("拿鐵") 則是第二杯,就算內容和 a 一模一樣,也是兩個獨立的物件。要確認兩個名字是不是貼在同一個物件上,用第 5 課學過的 is。
Drink 物件,兩支箭頭指著它;右邊有兩個物件,各有自己的屬性。兩段程式之間只差第 8 行。那 == 呢?自己寫的類別如果沒有另外規定「怎樣算相等」,== 的結果和 is 一樣,只有同一個物件才算相等:Drink("拿鐵", "M") == Drink("拿鐵", "M") 是 False。想比內容,就比屬性,例如 p.name == q.name。讓 == 改成比內容的寫法(定義 __eq__,或用標準函式庫的 dataclass)在地圖上的「類別與物件」進階節點。
6. 為什麼不要直接 print(物件)
把自己做的物件直接交給 print,Python 不知道你想看哪些屬性,只好印出「這是哪個類別的物件、放在記憶體的哪裡」:
class Drink:
def __init__(self, name):
self.name = name
a = Drink("拿鐵")
print(a)
print(a.name)
<__main__.Drink object at 0x7fbcdb8a97f0> 拿鐵
第一行的 0x7fbcdb8a97f0 是記憶體位置,每次執行、每台電腦都不一樣,對讀程式的人沒什麼幫助;__main__ 代表這個類別定義在你直接執行的那支檔案裡。想看內容,就印屬性。如果希望 print(a) 直接印出好讀的文字,可以在類別裡定義一個特別的方法 __str__,回傳一個字串,print 就會改用它:
class Drink:
def __init__(self, name):
self.name = name
def __str__(self): # print(物件) 時會呼叫它,必須回傳字串
return "一杯" + self.name
a = Drink("拿鐵")
print(a) # 一杯拿鐵
像 __init__、__str__ 這種前後各兩條底線的方法,叫特殊方法(special method,口語常說 dunder method,double underscore 的縮寫):你不會直接呼叫它們,而是 Python 在特定時機自動呼叫,例如建立物件時呼叫 __init__、印出時呼叫 __str__。
7. 常見錯誤與錯誤訊息的長相
下面每一段都是用 Python 3.13 實際執行的畫面(File 後面的路徑是檔案在電腦上的位置,你的電腦會顯示自己的資料夾)。和第 10 課一樣,先看最後一行:冒號前是錯誤類型,冒號後是說明。
定義方法時忘了寫 self。呼叫 a.add("珍珠") 時,Python 會自動把 a 放進第一個位置,加上 "珍珠" 一共傳了 2 個,可是 add 只準備了 1 個參數:
class Drink:
def __init__(self, name, size):
self.name = name
self.size = size
def add(item):
print("加料", item)
a = Drink("拿鐵", "M")
a.add("珍珠")
Traceback (most recent call last):
File "/tmp/b0_class/main.py", line 8, in <module>
a.add("珍珠")
~~~~~^^^^^^^^
TypeError: Drink.add() takes 1 positional argument but 2 were given
在 __init__ 裡忘了寫 self.。錯誤不會發生在 __init__ 那一行,而是到後面真的去讀屬性時才出現,所以要回頭找「當初有沒有貼上這個屬性」:
class Drink:
def __init__(self, name, size):
name = name
size = size
a = Drink("拿鐵", "M")
print(a.name)
Traceback (most recent call last):
File "/tmp/b0_class/main.py", line 6, in <module>
print(a.name)
^^^^^^
AttributeError: 'Drink' object has no attribute 'name'
屬性名稱拼錯。Python 3.13 會在拼法很接近時提示「Did you mean」(意思是「你是不是要寫 name」);差太多就不會提示:
class Drink:
def __init__(self, name):
self.name = name
a = Drink("拿鐵")
print(a.nme)
Traceback (most recent call last):
File "/tmp/b0_class/main.py", line 5, in <module>
print(a.nme)
^^^^^
AttributeError: 'Drink' object has no attribute 'nme'. Did you mean: 'name'?
建立物件時少給引數。訊息裡寫的是 __init__,因為那些參數是它要的;self 不算在內,Python 會自動給:
class Drink:
def __init__(self, name, size):
self.name = name
self.size = size
a = Drink("拿鐵")
Traceback (most recent call last):
File "/tmp/b0_class/main.py", line 5, in <module>
a = Drink("拿鐵")
~~~~~^^^^^^^^
TypeError: Drink.__init__() missing 1 required positional argument: 'size'
忘了加括號。a = Drink 沒有建立物件,只是讓 a 也貼到類別本身。透過類別呼叫方法時沒有「點號前面的物件」可以自動塞進 self,於是 "珍珠" 被拿去當 self,item 反而沒有東西:
class Drink:
def __init__(self, name):
self.name = name
self.toppings = []
def add(self, item):
self.toppings.append(item)
a = Drink
a.add("珍珠")
Traceback (most recent call last):
File "/tmp/b0_class/main.py", line 8, in <module>
a.add("珍珠")
~~~~~^^^^^^^^
TypeError: Drink.add() missing 1 required positional argument: 'item'
__init__ 拼錯。把 __init__ 打成 __int__(少了一個 i)或底線只寫一條,Python 就不認得它是初始化方法,類別等於沒有 __init__,建立物件時不能傳任何引數。看到「takes no arguments」,先檢查 __init__ 的拼法:
class Drink:
def __int__(self, name):
self.name = name
a = Drink("拿鐵")
Traceback (most recent call last):
File "/tmp/b0_class/main.py", line 4, in <module>
a = Drink("拿鐵")
~~~~~^^^^^^^^
TypeError: Drink() takes no arguments
8. 兩段完整範例
第一段把這一課的 Drink 補完整:加上預設參數、算價錢的方法和 __str__,再把好幾杯放進一個串列,用第 8 課的 for 迴圈逐杯處理。串列的每一格都指向一個物件,就像第 11 課的巢狀資料:
# 晴空咖啡:用類別表示「一杯飲料」
class Drink:
def __init__(self, name, size="M"): # size 沒傳時用 "M"(第 9 課的預設參數)
self.name = name # 屬性:貼在這杯飲料身上的名字
self.size = size
self.toppings = [] # 每杯各自做一個新的空串列
def add(self, item):
self.toppings.append(item) # 就地修改這杯自己的加料清單
def price(self):
total = 60 # 中杯基本價(示意)
if self.size == "L":
total = total + 15 # 大杯加 15 元
return total + 10 * len(self.toppings) # 每項加料 10 元
def __str__(self): # print(物件) 時要顯示的文字
return f"{self.name}({self.size})加料 {len(self.toppings)} 項"
orders = [Drink("拿鐵"), Drink("紅茶", "L")] # 串列裡放兩個 Drink 物件
orders[1].add("珍珠") # orders[1] 是第二杯,只替它加料
orders[1].add("椰果")
total = 0
for d in orders: # 第 8 課的 for:d 每圈改貼到下一杯
print(d, d.price(), "元") # print(d) 會用 __str__ 回傳的文字
total = total + d.price()
print("合計", total, "元")
拿鐵(M)加料 0 項 60 元 紅茶(L)加料 2 項 95 元 合計 155 元
第二段是集點卡,重點放在「同一張卡還是兩張卡」。注意 stamps 是整數,整數不能修改,所以 self.stamps = self.stamps + n 是把屬性改貼到新的整數;不管是改貼還是就地修改,改的都是那一個物件身上的屬性:
# 晴空咖啡的集點卡:同一張卡,還是兩張卡?
class StampCard:
def __init__(self, owner):
self.owner = owner
self.stamps = 0 # 每張新卡都從 0 點開始
def stamp(self, n=1):
self.stamps = self.stamps + n # 整數不能修改:屬性改貼到新的整數
def redeem(self):
if self.stamps >= 10: # 滿 10 點可以換一杯
self.stamps = self.stamps - 10
return True
return False
mei = StampCard("小美")
zhe = StampCard("阿哲") # 兩張不同的卡
family = mei # 沒有做新卡:family 和 mei 貼在同一張卡上
mei.stamp(6)
family.stamp(5) # 透過 family 蓋章,蓋在小美那張卡上
zhe.stamp(3)
print(mei.owner, mei.stamps, zhe.owner, zhe.stamps)
print(family.redeem(), mei.stamps) # 換了一杯,剩 1 點
print(zhe.redeem(), zhe.stamps) # 點數不夠,不能換
print(family is mei, zhe is mei)
小美 11 阿哲 3 True 1 False 3 True False
類別還有一個常聽到的功能叫繼承(inheritance):寫 class Tea(Drink):,Tea 就自動擁有 Drink 的所有方法,再加上自己的。入門階段先會用本課的內容就夠了,繼承留到地圖上的「類別與物件」進階節點。
9. 以後在 AI 程式裡會看到它
學完這一課,你已經能看懂 AI 與資料分析範例程式最常見的句型。scikit-learn(機器學習套件)的模型全部都是類別:model = LinearRegression() 就是呼叫類別、建立一個線性迴歸模型物件;model.fit(X, y) 是呼叫它的方法,用資料訓練,學到的結果會存成物件身上的屬性,例如 model.coef_;model.predict(…) 再用這些屬性做預測。fit 改的是 model 這個物件自己,所以不用寫成 model = model.fit(…),這和 a.add("珍珠") 改的是 a 那杯是同一件事。
# 套件要先匯入(import),地圖上的「模組、套件與 import」節點會講
X = [[1], [2], [3]] # 3 筆資料,每筆一個特徵(示意)
y = [3, 5, 7]
model = LinearRegression() # 呼叫類別、建立物件:這時還沒學到任何東西
print(hasattr(model, "coef_")) # False:還沒有 coef_ 這個屬性
model.fit(X, y) # 呼叫方法:用資料訓練,結果貼成物件的屬性
print(model.coef_) # [2.]:學到的斜率
print(model.predict([[4]])) # [9.]:用學到的屬性預測
model 這個名字從頭到尾貼著同一個物件,fit 只是讓物件身上多了學到的屬性(黃底那幾列)。scikit-learn 的慣例是「學到的屬性名稱結尾有一條底線」,看到 coef_ 就知道它要 fit 之後才會有。PyTorch(深度學習套件)也一樣:自己定義的神經網路是一個類別,__init__ 裡用 self.layer = … 把每一層存成屬性。更深入的用法在地圖上的 fit/predict 介面、線性與邏輯迴歸、nn.Module 建模 節點。
10. 學完 14 課之後,可以往哪裡走
到這裡,零基礎系列就全部結束了。你已經能看著一段 Python 程式,在腦中畫出名字、物件和箭頭,判斷每一行會讓什麼東西改變。接下來不必照固定順序,依你的目標挑一條路:
開始寫自己的程式之前,也建議看一下地圖上的 Python 安裝、編輯器與 IDE 和 Jupyter Notebook 與 Colab 節點,挑一個順手的地方練習。
✅ 自我檢測
6 題原創的程式閱讀題,每題讀一小段程式、選出結果。所有答案都用 Python 3.13 實際執行確認過;輸出有好幾行時,選項裡用 ⏎ 表示換行。選完會立即顯示對錯與解析,全部作答後出現總分。目前得分:0 / 6