💡 先搞懂問題
虛構的「晴空咖啡」想用 Python 算每筆訂單的應付金額:單價乘上杯數,再加 5% 的服務費(四捨五入到整數)。第一筆訂單寫三行,第二筆訂單把同樣三行複製一份、改兩個數字,第三筆再複製一次。程式能動,但下個月服務費改成 8%,你得找出每一個複製出來的地方逐一修改,只要漏掉一處,那一筆就算錯,而且電腦不會提醒你。
解法是把這三行包起來,取一個名字,例如 final_price。之後要算錢,只要寫 final_price(120, 2) 這樣一行,電腦就會照包好的步驟做一次。這個「包起來、有名字、可以重複使用的一段步驟」叫函式(function)。你其實從第 1 課就在用函式了:print()、len()、input() 都是 Python 內建、別人寫好的函式;這一課要學的是自己寫。
def 寫成函式,右邊三行各「呼叫」它一次,括號裡的數字是這一次要用的單價和杯數。規則只存在一個地方,改一次就全部生效。問題是,剛開始寫函式的人幾乎都會在同樣幾個地方卡住:寫好 def 按下執行,畫面卻什麼也沒印;函式裡算出來的 subtotal,出了函式就說找不到這個名字;函式裡改了 total,外面的 total 卻沒變;明明看到函式印出 10,接住結果的變數卻是 None。這些現象背後是同一件事:每次呼叫函式,Python 都會另外開一個小空間放這次呼叫用的名字,函式結束就把它收掉。只要把這個小空間看清楚,上面四個疑問都有答案。
生活比喻:咖啡店的食譜卡與工作便條
晴空咖啡的店長把「拿鐵怎麼做」寫成一張食譜卡,貼在吧台牆上。卡片貼上去,並不會有任何一杯咖啡被做出來;要等客人點單,店員才會照著卡片做。店員每接一張單,就撕一張新的工作便條,寫上這一單的杯數和甜度,中間量了多少牛奶、算了幾份濃縮,也都寫在這張便條上。咖啡做好交到櫃台,便條就揉掉丟進垃圾桶。
兩位店員同時做兩單,各自的便條上都寫著「杯數」,數字不同也互不干擾;吧台黑板上寫的「今日總杯數」是另一回事,店員在自己便條上寫「杯數:2」,黑板上的數字不會跟著變。做拿鐵的步驟裡有一步是「先做一份濃縮咖啡」,濃縮咖啡有自己的食譜卡,店員就再撕一張便條疊在拿鐵那張上面,做完濃縮、丟掉那張,再回到拿鐵的便條繼續。
def 定義的函式,def 只是把步驟存起來、貼上名字,不會執行裡面的內容。客人點單是呼叫(call),寫成 final_price(120, 2)。每次呼叫,Python 都會開一個新的呼叫框(frame),就是那張工作便條:括號裡傳進來的 120 和 2 叫引數(argument),函式定義裡用來接它們的名字 price、qty 叫參數(parameter);在函式裡新建的名字叫區域變數(local variable),只存在這個呼叫框裡。把咖啡交到櫃台是 return,它把結果交回呼叫的那一行;函式結束,呼叫框就被拿掉。黑板上的今日總杯數,是寫在最外面「全域(主程式)」那一框的全域變數(global variable)。
這個比喻有三個地方和 Python 不一樣,後面的播放器會讓你親眼看到。第一,店員是把杯數「抄」到便條上,Python 的參數卻不是抄一份:它是把名字標籤貼到傳進來的同一個物件上(第 2 課說過,變數是貼在資料上的標籤)。數字和字串不能修改,所以看不出差別;傳的是串列時,函式裡就地修改,外面就會看到,就像客人自帶的環保杯,店員在杯裡加了東西,客人拿回去的就是那個杯子。第二,便條揉掉不代表上面的東西全部消失:回傳出去的那個物件,只要外面有名字貼著,就會留下來。第三,print() 比較像店員在吧台喊一聲「拿鐵好了」,聲音傳到輸出畫面,卻沒有把任何東西交到櫃台;真正交出結果的只有 return。
def 時,它只把第 2、3 行存起來,不執行;等到第 5 行呼叫 total_price,才跳進函式裡執行第 2、3 行,return 之後再跳回第 5 行把結果貼上 latte。第 2、3 行總共被執行兩次,第 1 行只執行一次。🎮 互動實驗室一:呼叫框打開、又被拿掉
這是上面那張執行順序圖的程式,Python 3.13 真的執行一次、逐步記錄下來的結果。按「下一步」,綠色箭頭是下一個要執行的行,黃色是剛執行完的行。右下方的記憶體圖分成兩欄:左邊是名字,一個方框就是一個呼叫框;右邊是物件。請特別盯著左欄:呼叫函式時,「全域」底下會多出一個新的框,函式結束時,它又整個不見。停在 🤔 的地方,先猜再按下一步。
total_price 的呼叫框出現,price、qty 兩個參數的箭頭分別指向 120 和 2,這時全域還沒有 latte,因為等號右邊還沒算完。第 7 步看「回傳值」那一列,它和 subtotal 指向同一個 240;到第 8 步,整個呼叫框被拿掉,subtotal 這個名字跟著消失,240 改由全域的 latte 貼著,所以它留了下來。第二次呼叫(第 9 步)開的是一個全新的框,裡面看不到上一次的 subtotal。🎮 互動實驗室二:同一個名字,不同的呼叫框
右上角的選單有三段程式,每段各示範一個新手最常誤會的地方:函式裡外都有一個叫 total 的名字、只用 print 沒有 return 的函式、把串列傳進函式。每段都先猜再看,記憶體圖會告訴你每個名字到底貼在哪裡。
total:一個在全域、指向 0,一個在 checkout 的呼叫框、指向 100。它們名字相同,卻是兩張貼在不同框裡的標籤。切換到「print 與 return」,看 a 最後指向哪個物件;切換到「把串列傳進函式」,看 my_cart 和 cart 兩支箭頭是不是指向同一個方塊。🎮 互動實驗室三:猜猜印出什麼
12 張卡片,每張是一小段用到函式的程式。先自己在腦中「逐行執行」:哪幾行會被執行、每次呼叫開了什麼框、名字貼在哪裡,再選出輸出畫面會出現什麼。選完會看到解析,答案都是用 Python 3.13 實際執行的結果。輸出有好幾行時,選項裡用 ⏎ 表示換行。
📘 原理補完
1. 正式名稱一次對照
看官方文件或別人的教學時,會碰到下面這些名詞。左邊兩欄是正式說法,右邊兩欄對應到這一課的程式和播放器畫面。
| 用語 | 英文 | 在程式裡長怎樣 | 白話意思 |
|---|---|---|---|
| 定義函式 | define(關鍵字 def) | def total_price(price, qty): | 把底下縮排的步驟存起來、貼上名字,這時不執行 |
| 函式本體 | function body | def 底下縮排的那幾行 | 被呼叫時才執行的步驟;縮排規則和第 7 課的 if 一樣 |
| 呼叫 | call | total_price(120, 2) | 名字後面加括號,就是「現在執行一次」 |
| 參數 | parameter | price、qty | 定義裡等著接值的名字 |
| 引數 | argument | 120、2 | 呼叫時實際傳進去的值 |
| 回傳值 | return value | return subtotal | 交回給呼叫那一行的結果;沒寫 return 就是 None |
| 區域變數 | local variable | 函式裡的 subtotal | 只存在這一次呼叫的呼叫框裡 |
| 全域變數 | global variable | 主程式的 latte | 寫在最外層,從指派那一刻起一直存在 |
| 呼叫框 | frame | 播放器左欄的方框 | 每呼叫一次開一個,函式結束就拿掉 |
| 呼叫堆疊 | call stack | 疊在一起的好幾個框 | 函式裡又呼叫函式時,框一層一層疊上去 |
參數和引數常被混著講,意思通常也聽得懂;真正要記的是方向:引數是「從外面傳進去的值」,參數是「函式裡用來接它的名字」。呼叫時,Python 依照位置一對一配對,第一個引數貼給第一個參數,第二個貼給第二個,數量對不上就會出錯(後面第 7 小節有錯誤訊息)。
2. 呼叫框的一生
每次呼叫函式,Python 都做同樣幾件事:先把呼叫它的那一行暫停在半路(等號右邊還沒算完,所以左邊的名字還不會出現);開一個新的呼叫框;把每個參數貼到對應的引數物件上;然後從函式本體第一行開始執行。執行到 return,就把後面那個值當成回傳值交出去,拿掉整個呼叫框,回到剛才暫停的那一行繼續,回傳值就像那一行「算出來的結果」。如果函式一路執行到最後一行都沒有碰到 return,效果等於最後寫了 return None。
subtotal 指向同一個 240;④框拿掉後,120 和 2 已沒有名字指向,240 卻因為 latte 貼著而留下。return 還有一個作用:它會立刻結束函式。寫在 return 後面、同一層縮排的程式永遠不會執行。函式裡也可以有好幾個 return,例如放在 if 和 else 裡(第 7 課),執行到哪一個就在哪裡結束;但如果某條路線走完都沒碰到 return,那條路線就會回傳 None,這是很常見的漏洞。
3. return 與 print 不是同一件事
剛開始最容易混淆的是:函式裡 print 了一個數字,畫面上看得到,感覺函式「給出了答案」。可是 print 只是把文字送到輸出畫面,給人看的;return 才是把物件交回給程式,讓等號左邊的名字貼上、或拿去繼續計算。實驗室二第二段就是這個對照。
show_double 把 10 印到畫面上,但沒有 return,a 只拿到 None;get_double 沒有印任何東西,卻把 10 交了回來。之後如果寫 a + 1,就會得到下面第 7 小節的 TypeError。| print(值) | return 值 | |
|---|---|---|
| 做了什麼 | 把值轉成文字,顯示在輸出畫面 | 把物件交回呼叫的那一行,並結束函式 |
| 給誰用 | 給看畫面的人 | 給程式:可以貼上名字、繼續運算、傳給別的函式 |
| 函式之後還會繼續嗎 | 會,繼續執行下一行 | 不會,函式到此結束 |
| 只寫它、不寫另一個 | 函式回傳 None | 畫面上什麼都不會出現,除非呼叫的人自己 print |
實務上的習慣是:計算用的函式負責 return,印不印由呼叫的人決定。這樣同一個函式可以拿去算總和、存進串列、交給另一個函式,用途比「只會印」的函式多得多。
4. 區域變數與全域變數
在函式裡用等號指派一個名字(total = …),這個名字就會建立在目前這個呼叫框裡,成為區域變數,不管全域有沒有同名的東西。所以實驗室二第一段裡,函式裡的 total 和外面的 total 是兩張貼在不同框裡的標籤,改其中一張完全不影響另一張;參數也是區域變數,函式結束就跟著呼叫框一起消失。這正是函式好用的原因:寫函式的人可以放心取名字,不必擔心和程式其他地方撞名。
total,各自在不同的框裡;印出外面的 total 時,Python 找的是全域那一張,所以是 0。反過來,函式裡如果只是「讀」一個自己框裡沒有的名字,Python 會往外到全域去找,所以函式讀得到全域變數。麻煩出在「同一個函式裡,先讀、又指派同一個名字」:只要函式裡有任何一行對 count 指派,Python 就把整個函式裡的 count 都當成區域變數,讀的時候區域的 count 還沒貼上任何東西,就會出現下面的錯誤。
count = 0
def add_one():
count = count + 1
add_one()
Traceback (most recent call last):
File "/tmp/b0_func/main.py", line 4, in <module>
add_one()
~~~~~~~^^
File "/tmp/b0_func/main.py", line 3, in add_one
count = count + 1
^^^^^
UnboundLocalError: cannot access local variable 'count' where it is not associated with a value
Python 有 global 關鍵字可以讓函式改全域變數,但初學階段建議換個寫法:把需要的值當參數傳進去,把新的值 return 出來,由呼叫的人自己貼回去,例如 count = add_one(count)。資料怎麼進出函式,看參數和 return 就一清二楚。
5. 函式呼叫函式:呼叫框疊起來
函式本體裡可以再呼叫別的函式。這時原本的函式不會結束,而是暫停在呼叫的那一行,新的呼叫框疊在它上面;新的函式 return 之後,它的框拿掉,原本的函式從暫停的地方繼續。這一疊框叫呼叫堆疊(call stack),永遠只有最新開的那一框在執行,其他框都在等。第 10 課讀錯誤訊息時,Traceback 列出的就是出錯當下這一疊框。
tax 裡面時的樣子。三個框同時存在:全域在等 final_price,final_price 在第 5 行等 tax。tax 的參數 amount 貼在傳進來的同一個 360 上,不是複製一份。下面這段程式還用到預設參數(default parameter):def tax(amount, rate=0.05) 的意思是,呼叫時沒傳第二個引數,rate 就貼到 0.05;有傳就用傳進來的值,例如 tax(1000, 0.1) 會得到 100。有預設值的參數要寫在沒有預設值的參數後面。也可以在呼叫時寫明名字,例如 tax(1000, rate=0.1),這叫關鍵字引數(keyword argument),參數一多時比較不容易傳錯位置。
tax 回傳 18 之後,程式不是回到全域,而是回到 final_price 的第 5 行,把 fee 貼到 18,接著才執行第 6 行。6. 把串列傳進函式
參數是貼到傳進來的同一個物件上(第 2 課的標籤),這件事在傳數字、字串時看不出影響,因為它們不能被修改。傳的是串列或字典時就不一樣了:函式裡對參數做就地修改(append、cart[0] = …),改的就是外面那一個物件,函式結束後外面看得到,這和第 5 課 b = a 之後改 a、b 也跟著變是同一件事。但函式裡如果寫 cart = [],那是把區域的標籤 cart 撕下來改貼到新的串列,外面的名字不受影響。
my_cart 和 cart 指向同一個串列,append 改的就是它。右邊是預測卡裡的 reset:cart = [] 只讓 cart 改貼,basket 還是原本的 ['蘋果']。這個行為有時是你要的(例如「把訂單加進購物車」),有時是意外(你只想讀資料,卻不小心改到呼叫者的串列)。寫函式時如果會修改傳進來的串列,最好在函式名稱或註解裡講清楚;不想影響外面,就在函式開頭先複製一份(第 5 課的 copy())。
7. 常見錯誤與錯誤訊息的長相
下面每一段都是用 Python 3.13 實際執行的畫面(舊版本的外觀可能略有不同;File 後面的路徑是這支檔案在電腦上的位置,你的電腦會顯示自己的資料夾)。先看最後一行:冒號前是錯誤類型,冒號後是說明。第 10 課會完整教怎麼讀。
少傳了引數。函式有兩個參數,呼叫時只給一個,Python 會告訴你缺的是哪一個參數:
def total_price(price, qty):
return price * qty
print(total_price(120))
Traceback (most recent call last):
File "/tmp/b0_func/main.py", line 3, in <module>
print(total_price(120))
~~~~~~~~~~~^^^^^
TypeError: total_price() missing 1 required positional argument: 'qty'
多傳了引數。「takes 2 positional arguments but 3 were given」是「需要 2 個,卻給了 3 個」:
def total_price(price, qty):
return price * qty
print(total_price(120, 2, 3))
Traceback (most recent call last):
File "/tmp/b0_func/main.py", line 3, in <module>
print(total_price(120, 2, 3))
~~~~~~~~~~~^^^^^^^^^^^
TypeError: total_price() takes 2 positional arguments but 3 were given
拿 None 去運算。函式只有 print 沒有 return,回傳的 None 不能加 1。注意第一行的 10 是函式自己印的,錯誤發生在它結束之後:
def show_double(n):
print(n * 2)
answer = show_double(5) + 1
10
Traceback (most recent call last):
File "/tmp/b0_func/main.py", line 3, in <module>
answer = show_double(5) + 1
~~~~~~~~~~~~~~~^~~
TypeError: unsupported operand type(s) for +: 'NoneType' and 'int'
在函式外面用區域變數。subtotal 只存在呼叫框裡,函式結束就不見了,外面要用結果,請接住回傳值:
def total_price(price, qty):
subtotal = price * qty
return subtotal
total_price(120, 2)
print(subtotal)
Traceback (most recent call last):
File "/tmp/b0_func/main.py", line 5, in <module>
print(subtotal)
^^^^^^^^
NameError: name 'subtotal' is not defined
還有兩個「不會出現錯誤訊息」的錯:一是在 def 之前就呼叫函式(那時名字還沒貼上,會得到 NameError,自我檢測有一題);二是忘了加括號,只寫 greet 不寫 greet("小美"),這一行只是提到函式物件本身,不會執行它,畫面上什麼都不會出現,也不會有錯誤訊息。
8. 兩段完整範例
第一段把服務費規則包成函式,加上預設參數,並示範把一個函式的結果直接當另一個函式的引數:
# 晴空咖啡:計算一筆訂單的應付金額與會員點數
def final_price(price, qty, fee_rate=0.05): # fee_rate 沒傳時用 0.05
subtotal = price * qty # 區域變數,只存在這次呼叫
fee = round(subtotal * fee_rate) # 服務費四捨五入到整數
return subtotal + fee # 把結果交回呼叫的那一行
def points(amount):
return amount // 100 # 每滿 100 元給 1 點(// 是整除)
pay = final_price(120, 2) # 240 + 12,pay 貼到 252
print("應付", pay, "元,點數", points(pay)) # 函式的結果直接當另一個函式的引數
print("外帶免服務費:", final_price(120, 2, 0)) # 第三個引數 0 取代預設的 0.05
應付 252 元,點數 2 外帶免服務費: 240
第二段把串列傳進函式:add_order 會就地修改傳進來的串列,total_of 只讀不改,裡面的 total 是區域變數,和外面的任何名字無關:
# 把訂單金額存進串列,再交給函式統計
def add_order(orders, amount):
orders.append(amount) # 就地修改:外面傳進來的那個串列也會變
return len(orders) # 回傳目前有幾筆
def total_of(orders):
total = 0 # 區域變數,每次呼叫都從 0 開始
for a in orders: # 第 8 課的 for 迴圈
total = total + a
return total # 只讀不改,外面的串列不受影響
today = [] # 全域的空串列
add_order(today, 252) # 回傳值 1 沒有人接,就被丟掉了
n = add_order(today, 189)
print(n, "筆,合計", total_of(today))
print(today) # append 的結果留在外面的串列裡
2 筆,合計 441 [252, 189]
以後讀 AI 或資料分析的範例程式,幾乎每一段都是由函式組成的:資料前處理寫成 def clean(text):,模型訓練一回合寫成 def train_one_epoch(model, loader):,呼叫大型語言模型的程式也常包成 def ask(question):。看到這種程式時,先找 def 那一行看它需要哪些參數、再找 return 看它交出什麼,就能抓到這段程式在做什麼。
✅ 自我檢測
6 題原創的程式閱讀題,每題讀一小段程式、選出結果。所有答案都用 Python 3.13 實際執行確認過;輸出有好幾行時,選項裡用 ⏎ 表示換行。選完會立即顯示對錯與解析,全部作答後出現總分。目前得分:0 / 6