🗺️ 程式互動式地圖
零基礎起步・第 9 課

函式:把步驟包起來重複使用

寫了 def 卻什麼都沒發生?函式裡的變數跑去哪了?明明印出了數字,拿到的卻是 None?這一課一步一步看電腦怎麼執行函式,把這些疑問一次弄清楚。

呼叫框開啟與拿掉 函式內外的同名變數 return 與 print

💡 先搞懂問題

虛構的「晴空咖啡」想用 Python 算每筆訂單的應付金額:單價乘上杯數,再加 5% 的服務費(四捨五入到整數)。第一筆訂單寫三行,第二筆訂單把同樣三行複製一份、改兩個數字,第三筆再複製一次。程式能動,但下個月服務費改成 8%,你得找出每一個複製出來的地方逐一修改,只要漏掉一處,那一筆就算錯,而且電腦不會提醒你。

解法是把這三行包起來,取一個名字,例如 final_price。之後要算錢,只要寫 final_price(120, 2) 這樣一行,電腦就會照包好的步驟做一次。這個「包起來、有名字、可以重複使用的一段步驟」叫函式(function)。你其實從第 1 課就在用函式了:print()、len()、input() 都是 Python 內建、別人寫好的函式;這一課要學的是自己寫。

沒有函式:同樣的步驟寫三次 sub = 120 * 2fee = round(sub*0.05)print(sub + fee) sub = 60 * 3fee = round(sub*0.05)print(sub + fee) sub = 85 * 4fee = round(sub*0.05)print(sub + fee) 服務費改成 8%:紅色那行有三份,要改三個地方,漏改一處就算錯 有函式:步驟只寫一次,要用時呼叫 def final_price(price, qty): sub = price * qty fee = round(sub*0.05) return sub + fee final_price(120, 2) final_price(60, 3) final_price(85, 4) 三筆訂單各呼叫一次 服務費改成 8%:只改函式裡紅色那一行,三筆訂單同時生效
上半是複製貼上的寫法:算錢的規則散落在三個地方。下半把同樣的步驟用 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 的對應 貼在牆上的食譜卡 def 定義函式(還沒執行) 客人點一單 呼叫 final_price(120, 2) 撕一張新的工作便條 開一個新的呼叫框 frame 便條上寫這單的杯數 參數 qty 貼上引數 2 便條上的計算過程 區域變數 subtotal 咖啡交到櫃台 return 交回回傳值 便條揉掉丟進垃圾桶 呼叫框被拿掉 黑板上的今日總杯數 全域(主程式)的變數
左欄是咖啡店的流程,右欄是 Python 的正式說法。中間六列描述的是「一次呼叫」從開始到結束的過程;最後一列提醒你:便條(呼叫框)之外,還有一個從程式開始就存在的最外層,叫全域。

這個比喻有三個地方和 Python 不一樣,後面的播放器會讓你親眼看到。第一,店員是把杯數「抄」到便條上,Python 的參數卻不是抄一份:它是把名字標籤貼到傳進來的同一個物件上(第 2 課說過,變數是貼在資料上的標籤)。數字和字串不能修改,所以看不出差別;傳的是串列時,函式裡就地修改,外面就會看到,就像客人自帶的環保杯,店員在杯裡加了東西,客人拿回去的就是那個杯子。第二,便條揉掉不代表上面的東西全部消失:回傳出去的那個物件,只要外面有名字貼著,就會留下來。第三,print() 比較像店員在吧台喊一聲「拿鐵好了」,聲音傳到輸出畫面,卻沒有把任何東西交到櫃台;真正交出結果的只有 return。

1234567 def total_price(price, qty): subtotal = price * qty return subtotal print("晴空咖啡結帳") latte = total_price(120, 2) tea = total_price(60, 3) print("合計", latte + tea) 黃框:只有被呼叫時才會執行 藍線=呼叫跳進去 橘虛線=return 跳回來 實際執行的順序 ① 第 1 行:建立函式 ② 第 4 行:印出標題 ③ 第 5 行呼叫 → 第 2、3 行 ④ 回到第 5 行:latte 貼上 240 ⑤ 第 6 行呼叫 → 第 2、3 行 ⑥ 回到第 6 行:tea 貼上 180 ⑦ 第 7 行:印出 合計 420
這是實驗室一要逐行執行的程式。Python 還是由上往下一行一行執行(第 1 課),只是碰到 def 時,它只把第 2、3 行存起來,不執行;等到第 5 行呼叫 total_price,才跳進函式裡執行第 2、3 行,return 之後再跳回第 5 行把結果貼上 latte。第 2、3 行總共被執行兩次,第 1 行只執行一次。

🎮 互動實驗室一:呼叫框打開、又被拿掉

這是上面那張執行順序圖的程式,Python 3.13 真的執行一次、逐步記錄下來的結果。按「下一步」,綠色箭頭是下一個要執行的行,黃色是剛執行完的行。右下方的記憶體圖分成兩欄:左邊是名字,一個方框就是一個呼叫框;右邊是物件。請特別盯著左欄:呼叫函式時,「全域」底下會多出一個新的框,函式結束時,它又整個不見。停在 🤔 的地方,先猜再按下一步。

畫面說明:第 4 步是呼叫的瞬間:total_price 的呼叫框出現,price、qty 兩個參數的箭頭分別指向 120 和 2,這時全域還沒有 latte,因為等號右邊還沒算完。第 7 步看「回傳值」那一列,它和 subtotal 指向同一個 240;到第 8 步,整個呼叫框被拿掉,subtotal 這個名字跟著消失,240 改由全域的 latte 貼著,所以它留了下來。第二次呼叫(第 9 步)開的是一個全新的框,裡面看不到上一次的 subtotal。

🎮 互動實驗室二:同一個名字,不同的呼叫框

右上角的選單有三段程式,每段各示範一個新手最常誤會的地方:函式裡外都有一個叫 total 的名字、只用 print 沒有 return 的函式、把串列傳進函式。每段都先猜再看,記憶體圖會告訴你每個名字到底貼在哪裡。

畫面說明:「函式裡外的 total」這段,第 6 步之後左欄會同時出現兩個 total:一個在全域、指向 0,一個在 checkout 的呼叫框、指向 100。它們名字相同,卻是兩張貼在不同框裡的標籤。切換到「print 與 return」,看 a 最後指向哪個物件;切換到「把串列傳進函式」,看 my_cart 和 cart 兩支箭頭是不是指向同一個方塊。

🎮 互動實驗室三:猜猜印出什麼

12 張卡片,每張是一小段用到函式的程式。先自己在腦中「逐行執行」:哪幾行會被執行、每次呼叫開了什麼框、名字貼在哪裡,再選出輸出畫面會出現什麼。選完會看到解析,答案都是用 Python 3.13 實際執行的結果。輸出有好幾行時,選項裡用 ⏎ 表示換行。

得分 0 / 0
連續答對 0
畫面說明:每張卡先問自己三件事:函式有沒有被呼叫?它是 return 還是 print?這個名字是在哪一個框裡被指派的?

📘 原理補完

1. 正式名稱一次對照

看官方文件或別人的教學時,會碰到下面這些名詞。左邊兩欄是正式說法,右邊兩欄對應到這一課的程式和播放器畫面。

用語英文在程式裡長怎樣白話意思
定義函式define(關鍵字 def)def total_price(price, qty):把底下縮排的步驟存起來、貼上名字,這時不執行
函式本體function bodydef 底下縮排的那幾行被呼叫時才執行的步驟;縮排規則和第 7 課的 if 一樣
呼叫calltotal_price(120, 2)名字後面加括號,就是「現在執行一次」
參數parameterprice、qty定義裡等著接值的名字
引數argument120、2呼叫時實際傳進去的值
回傳值return valuereturn subtotal交回給呼叫那一行的結果;沒寫 return 就是 None
區域變數local variable函式裡的 subtotal只存在這一次呼叫的呼叫框裡
全域變數global variable主程式的 latte寫在最外層,從指派那一刻起一直存在
呼叫框frame播放器左欄的方框每呼叫一次開一個,函式結束就拿掉
呼叫堆疊call stack疊在一起的好幾個框函式裡又呼叫函式時,框一層一層疊上去

參數和引數常被混著講,意思通常也聽得懂;真正要記的是方向:引數是「從外面傳進去的值」,參數是「函式裡用來接它的名字」。呼叫時,Python 依照位置一對一配對,第一個引數貼給第一個參數,第二個貼給第二個,數量對不上就會出錯(後面第 7 小節有錯誤訊息)。

2. 呼叫框的一生

每次呼叫函式,Python 都做同樣幾件事:先把呼叫它的那一行暫停在半路(等號右邊還沒算完,所以左邊的名字還不會出現);開一個新的呼叫框;把每個參數貼到對應的引數物件上;然後從函式本體第一行開始執行。執行到 return,就把後面那個值當成回傳值交出去,拿掉整個呼叫框,回到剛才暫停的那一行繼續,回傳值就像那一行「算出來的結果」。如果函式一路執行到最後一行都沒有碰到 return,效果等於最後寫了 return None。

① 第 5 行,呼叫之前② 呼叫:開新框、參數貼上引數③ 第 3 行 return:交出 240④ 回到第 5 行:框被拿掉全域(主程式)total_price函式total_price()等號右邊要先算完,latte 還不存在全域(主程式)total_pricetotal_price 的框priceqty函式total_price()整數 int120整數 int2全域(主程式)total_pricetotal_price 的框priceqtysubtotal回傳值函式total_price()整數 int120整數 int2整數 int240全域(主程式)total_pricelatte函式total_price()整數 int240整數 int120沒有名字指向它了整數 int2沒有名字指向它了框拿掉了,price、qty、subtotal 三個名字消失
實驗室一程式第一次呼叫的四個瞬間(畫法和播放器相同:左邊名字、右邊物件)。②的藍色箭頭是剛貼上的參數;③的「回傳值」和 subtotal 指向同一個 240;④框拿掉後,120 和 2 已沒有名字指向,240 卻因為 latte 貼著而留下。

return 還有一個作用:它會立刻結束函式。寫在 return 後面、同一層縮排的程式永遠不會執行。函式裡也可以有好幾個 return,例如放在 if 和 else 裡(第 7 課),執行到哪一個就在哪裡結束;但如果某條路線走完都沒碰到 return,那條路線就會回傳 None,這是很常見的漏洞。

3. return 與 print 不是同一件事

剛開始最容易混淆的是:函式裡 print 了一個數字,畫面上看得到,感覺函式「給出了答案」。可是 print 只是把文字送到輸出畫面,給人看的;return 才是把物件交回給程式,讓等號左邊的名字貼上、或拿去繼續計算。實驗室二第二段就是這個對照。

執行完 a = show_double(5) 與 b = get_double(5) 之後全域(主程式)show_doubleget_doubleab函式show_double(n)函式get_double(n)NoneNone整數 int10🖥️ 輸出畫面10← show_double 印的print:把字送到輸出畫面,函式還是回傳 Nonereturn:把物件交回呼叫那一行,b 才貼得到 10
兩個函式都「算出了 10」,結果完全不同。show_double 把 10 印到畫面上,但沒有 return,a 只拿到 None;get_double 沒有印任何東西,卻把 10 交了回來。之後如果寫 a + 1,就會得到下面第 7 小節的 TypeError。
print(值)return 值
做了什麼把值轉成文字,顯示在輸出畫面把物件交回呼叫的那一行,並結束函式
給誰用給看畫面的人給程式:可以貼上名字、繼續運算、傳給別的函式
函式之後還會繼續嗎會,繼續執行下一行不會,函式到此結束
只寫它、不寫另一個函式回傳 None畫面上什麼都不會出現,除非呼叫的人自己 print

實務上的習慣是:計算用的函式負責 return,印不印由呼叫的人決定。這樣同一個函式可以拿去算總和、存進串列、交給另一個函式,用途比「只會印」的函式多得多。

4. 區域變數與全域變數

在函式裡用等號指派一個名字(total = …),這個名字就會建立在目前這個呼叫框裡,成為區域變數,不管全域有沒有同名的東西。所以實驗室二第一段裡,函式裡的 total 和外面的 total 是兩張貼在不同框裡的標籤,改其中一張完全不影響另一張;參數也是區域變數,函式結束就跟著呼叫框一起消失。這正是函式好用的原因:寫函式的人可以放心取名字,不必擔心和程式其他地方撞名。

第 4 行執行前(播放器第 6 步)全域(主程式)totalcheckoutcheckout 的框pricetotal整數 int0函式checkout(price)整數 int50整數 int100兩個 total,兩張標籤全域那張貼在 0checkout 框裡那張貼在 100第 3 行的 total = … 只會在「目前的框」裡新增名字
實驗室二第一段執行到第 4 行之前的記憶體。左欄有兩個 total,各自在不同的框裡;印出外面的 total 時,Python 找的是全域那一張,所以是 0。

反過來,函式裡如果只是「讀」一個自己框裡沒有的名字,Python 會往外到全域去找,所以函式讀得到全域變數。麻煩出在「同一個函式裡,先讀、又指派同一個名字」:只要函式裡有任何一行對 count 指派,Python 就把整個函式裡的 count 都當成區域變數,讀的時候區域的 count 還沒貼上任何東西,就會出現下面的錯誤。

count = 0
def add_one():
    count = count + 1
add_one()
Python 3.13 的輸出畫面
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 的第 2 行時(實驗室程式,示意)全域(主程式)taxfinal_pricefinal_price 的框priceqtysubtotaltax 的框amountrate函式tax(amount, rate)函式final_price(price, qty)整數 int120整數 int3整數 int360浮點數 float0.05越下面越晚開amount 和 subtotal貼在同一個 360 上rate 沒傳,用預設值 0.05藍框(最新的框)正在執行;上面的框暫停、等它回傳
下面播放器執行到 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),參數一多時比較不容易傳錯位置。

畫面說明:第 7 步左欄同時有三個框,這就是上面那張圖。注意第 9 步到第 10 步:tax 回傳 18 之後,程式不是回到全域,而是回到 final_price 的第 5 行,把 fee 貼到 18,接著才執行第 6 行。

6. 把串列傳進函式

參數是貼到傳進來的同一個物件上(第 2 課的標籤),這件事在傳數字、字串時看不出影響,因為它們不能被修改。傳的是串列或字典時就不一樣了:函式裡對參數做就地修改(append、cart[0] = …),改的就是外面那一個物件,函式結束後外面看得到,這和第 5 課 b = a 之後改 a、b 也跟著變是同一件事。但函式裡如果寫 cart = [],那是把區域的標籤 cart 撕下來改貼到新的串列,外面的名字不受影響。

就地修改:cart.append(item)改貼:cart = []全域my_cartadd_item 的框cartitem串列 list'牛奶'0'麵包'1字串 str'麵包'兩支箭頭指向同一個串列,函式結束後 my_cart 也看得到麵包全域basketreset 的框cart串列 list'蘋果'0串列 list[]cart 改貼到新的空串列,basket 還是指向原本的 ['蘋果']
左邊是實驗室二第三段: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