跳至主要内容

函數 - 基礎篇

函數

撐了好久,終於來到這個章節了,這個章節應該算是本書我個人最喜歡的章節!幾乎每個程式語言都有函數,我們在前面的章節或多或少都有用過一些函數,用最多的大概是把東西印出來的 print() 函數了。但在開始介紹函數之前,我想先問大家一個很基本的問題:你認為「函數(Function)」是什麼?

函數是什麼?

如果你曾經寫過其他程式語言,不知道你是否曾想過這個最最最基本的問題?到底什麼是函數?因為學校老師或是書上說要寫函數就是用 functiondef 給它寫下去,裡面可能還有什麼參數啦、回傳值什麼的...。

我上課的時候最喜歡問同學們這種「為什麼」的問題,例如「為什麼要寫函數?」,我通常會得到像是「因為函數可以重複使用」之類的答案。是沒錯啦,函數可以重複使用的確是使用函數的好處之一,但它並不是寫函數的目的。想想看,你是否有寫過一些函數,是根本沒有用第二次的?如果有,那你認為的「因為函數可以重複使用」這個說法就打自己臉了。所以,到底什麼是函數?

大家在國中或高中上數學課的時候有沒有看過這種東西:

f(x) = 3x + 2

當時數學老師會說這個叫做「一元一次方程式」,那個 x 叫做「代數」,如果 x 代 2 進去會得到 8,代 3 進去會得到 11,如果再代一次 2,還是會得到 8。這就是函數,前面那個 f 就是函數 function 的意思,只是大家可能沒意識到罷了(還是不願想起來?)

所以如果要我給「函數」一個定義,我會說:

函數是「輸入值」與「輸出值」之間的對應關係,而函數的名稱就是這個關係的名字。

把這些名詞換成程式語言的用語,輸入值就是「參數(parameter)」,而輸出值就是「回傳值(return value)」。一個好的函數,理想狀況是可以做到函數的輸出值只跟輸入值有關,只要是固定的輸入值,不管執行幾次,它的答案不會飄,都是固定的輸出值,不會因為時間、亂數或環境變數之類的「副作用(Side Effect)」而造成輸出值不同。如果可以做到沒有副作用的函數,我們也會稱這樣的函數叫「純函數(Pure Function)」,不純免錢。

給函數一個好的名字很重要,最好做到一眼就看出來這個函數想做什麼事。之所以會撰寫函數,是因為我們可以透過函數把原本比較繁瑣的流程抽象出來,我們的腦細胞就可以把重點放在怎麼使用這個函數,而不需要關注函數本身實作的細節。命名是電腦科學界的兩大難題之一,有好的名字是很難的,光是命名就能寫一整本書了。

想要再深入了解這方面的主題,可用關鍵字「函數式程式設計(Functional Programming)」再找其他資料研究。

所以,為什麼要使用函數?因為函數可以把某部份的程式碼抽象化,讓程式碼更容易閱讀,說的更白話一點寫函數就是幫某一段程式碼邏輯取個名字,讓我們不用閱讀函數的實作細節,只要看函數的名字就知道這個函數的用途。當然,函數可以重複使用也是一個好處,只是這就不太算寫函數的目的。

函數名稱的命名建議跟變數名稱一樣,規則也差不多,有些不該用或不能用的,例如 printinput 這種原本就是 Python 的內建函數,用了之後原本的 print() 就不能用了;或是 TrueFalse 這些保留字,都不能用來當函數名稱。而且,畢竟函數的目的是幫某一段程式碼取名字,所以函數名稱要有意義,能一看就知道這個函數是做什麼的,如果用 abc 這類看不太出來用途的名字,倒不如不要寫函數。

定義函數

在 Python 定義函數,使用的是 def 關鍵字。在 def 後面接著函數的名稱以及一對小括號,最後別忘了加上冒號 :,寫起來大概像這樣:

def say_hello():
print("Hello")

def 底下,就是函數的本體,也就是執行這個函數的時候實際上要做的事。有看到在第二行 print() 前面的留白嗎?那是刻意的,這在程式語言裡叫做「縮排(Indentation)」?如果我故意把縮排拿掉,像這樣:

def say_hello():
print("Hello") # 這會出錯,沒辦法執行

執行之後會產生 IndentationError 的錯誤訊息告訴你縮排不正確,這也算是 Python 的特色之一。Python 不像其他程式語言可以使用大括號 { } 做為程式碼區塊(Block)的識別,僅能透過縮排的方式來做到這件事,縮排表示一個區塊或段落的開始,如果縮排裡面還有縮排就表示這個區塊還有子區塊,以此類推。適當的使用縮排可以讓程式碼看起來更有結構,也更容易閱讀。

如同在布林值與流程控制章節所提到的,對於那些在其他程式語言不太在意縮排習慣的人來說,跟你好好講說要縮排講不聽,現在就交給 Python 來教教你。

如果是比較簡單的函數,不想換行寫,想直接寫成一行也是可以:

def say_hello(): print("Hello")

只是不太推薦這樣寫,這樣會讓程式碼變得比較沒那麼好閱讀,不過畢竟這不是硬性規定,我也是有看過有些人就是喜歡這樣寫。然而,有時候暫時還沒想到要寫什麼,只是想先定義一個函數名稱,該怎麼做?

pass

Python 是強制規定要縮排的,也就是說,函數的本體不能是空的,甚至只放註解也不行:

def say_hello():
# 這裡要寫東西
# 但還沒想到要寫什麼

因為註解最後會被無視,所以這樣寫還是會產生 IndentationError 錯誤訊息。這時候可使用 pass 關鍵字先卡個位:

def say_hello():
pass

在前面介紹 if...else... 語法的時候也看過這個關鍵字,pass 就跟它字面上的意思一樣不做任何事,技術上來說它甚至連編譯的過程都會被忽略。pass 只是卡個位,你可以在任何地方放上這個關鍵字應該都不會出問題,所以你想這樣寫也行:

def say_hello():
print("hi")
pass
print("hey")

在這種地方安插個 pass 沒什麼意義也不需要,不過在暫時還不知道要寫什麼內容的,像是空的 if...else...、空的函數或是空的類別,都可以用先它卡個位。

參數與執行

再回來看一下數學的函數

f(x) = 3x + 2

這裡的 x 在數學我們會說它叫做代數,在程式設計領域我們會說它是個「參數(Parameter)」。函數不一定要有參數,像上面的 say_hello() 函數就是個例子。寫函數的目的是為了把一段邏輯或程式碼抽象化並且可以重複使用,但這抽象化也是有分等級的,如果能再把一些值代進去函數裡,讓這個函數變得更有彈性、更好用,抽象的程度會更高一點,也更容易重複被使用。

在 Python 中,函數的參數是放在小括號 () 裡面,如果有好幾個參數,就用逗號 , 隔開,例如我有一個可以把華氏溫度(Fahrenheit)轉換成攝氏溫度(Celsius)的計算函數:

# 華氏 -> 攝氏
def fahrenheit_to_celsius(temperature):
return (temperature - 32) * 5 / 9

return 的意思我們待會再看,可以先暫時把它當作是函數執行之後的輸出結果就好。而這裡的 temperature 就是這個函數的參數。事實上,參數對這個函數來說不只是參數,也同時是這個函數的區域變數,也就是說只有在這個函數裡面才能用,離開這函數之後在外面是不存在的,就算有同名的變數也不是同一個變數,待會在後面一點介紹「範圍(Scope)」的時候會再詳述。變數的名字可以是任何合規定的變數名稱,只是為了可讀性,我們會盡量讓這個變數的名字看起來更容易被我們理解。

要執行函數的話,如果參數本身沒有額外定義參數,直接在函數名稱後面加上小括號 () 就行了;如果函數有需要參數的時候,需要把值放在小括號裡一併帶進來給它:

# 沒有參數的
>>> say_hello()

# 有參數的
>>> fahrenheit_to_celsius(95)
35.0
>>> fahrenheit_to_celsius(27)
-2.7777777777777777

在上面看到的 9527 就是這個函數的參數,我們會把帶進去的值稱做「引數(Argument)」。在業界參數跟引數很多時候會被混著講,但其實是有區別的,在定義函數的時候,放在小括號裡的是參數,在執行函數的時候,放在小括號裡的是引數。

原本的函數如果設定了參數,不管定義幾個,在執行函數的時候就要給足夠數量的引數,不能多也不能少的,否則會產生錯誤,例如:

# 不需要帶參數但硬給它一個
>>> say_hello("悟空")
TypeError: say_hello() takes 0 positional arguments but 1 was given

# 說好只要一個參數,但給了兩個
>>> fahrenheit_to_celsius(180, 200)
TypeError: fahrenheit_to_celsius() takes 1 positional argument but 2 were given

從錯誤訊息都可以看的出來,不管引數是多給還是少給,Python 會跟你抱怨引數數量不對,我覺得這很棒。另外,有注意到上面的錯誤訊息嗎?在錯誤訊息裡用的是 arguments 這個字,而不是 parameters 喔。

是執行還是呼叫?

我們現在知道怎麼定義函數了,如果想要讓這個函數「動起來」,有時候會聽到別人說「執行(Execute)」這個函數,有時候會說「呼叫(Call)」函數。這兩個動詞想要表達的意思其實差不多,只是立場有些不太一樣。

呼叫函數是指請求函數運行的動作或行為。例如,temperature = fahrenheit_to_celsius(95),就是「呼叫」函數 fahrenheit_to_celsius();而執行函數,是指函數實際進行運算的過程,也就是說,函數應該是被「呼叫」之後才會「執行」。

因此,這兩個動詞分別表示函數使用過程的不同階段,也反映了看待函數的不同角度。「執行」強調函數的運行過程,「呼叫」強調從外部使用函數的行為,都是有效和常用的表述方式,我也常混著用。

關鍵字引數

另一個問題是,假設我寫了一個計算身體質量指數(Body Mass Index, BMI)的函數,並定義了 heightweight 兩個參數,像這樣:

def calc_bmi(height, weight):
# 請想像在這裡做了很複雜的計算
print(height, weight)

我知道呼叫這個函數的時候要帶身高跟體重給它,但到底是哪個參數是身高哪個是體重?萬一不小心寫錯的話,算出來的答案可能是錯的。當然我也可以再查看原本的函數定義,確認參數的數量跟位置,但如果參數數量比較多的情況,用這種「一個蘿蔔一個坑」的方式來記憶參數的位置就有點麻煩。在 Python 中我們可以這樣寫:

>>> calc_bmi(170, 60)
170 60
>>> calc_bmi(weight=60, height=170)
170 60
>>> calc_bmi(height=170, weight=60)
170 60

執行函數的時候可以直接指定參數的名稱,甚至跟定義的參數位置不一樣也沒關係,執行的結果都是一樣的。這種引數的寫法叫做「關鍵字引數(Keyword Argument)」。相比之下原本需要按照順序、逐個傳入引數的方式又稱做「位置引數(Positional Argument)」。

因為我們是直接指定參數的名稱,這樣就不用煩惱去記參數的位置,只要記得參數叫什麼名字就行了。關鍵字引數看起來好像比較清楚,這樣以後都應該使用關鍵字引數來傳參數嗎?也不是這樣,如果參數的個數只有一個,或是參數的位置容易記,用一般的位置引數反而比較簡單;相對的,但如果參數比較多,或者參數的位置不太容易記憶,那麼使用關鍵字引數會比較方便一點,不用特別去記參數的順序。

這兩種方式可以混著用,只是有一些遊戲規則要遵守。

1. 位置引數必須在關鍵字引數之前

# 這可以
>>> calc_bmi(170, weight=60)
170 60

# 這不行!
>>> calc_bmi(height=170, 60)

第二種寫法會造成語法錯誤:

SyntaxError: positional argument follows keyword argument

2. 一個參數不能重複傳兩次

>>> calc_bmi(170, height=60)

想想看,在這個例子裡 170 是放在 height 參數的位置,但後面同時又 height=60 指定 height 參數,這樣會不知道到底該聽誰的,Python 會給我們這個錯誤訊息:

TypeError: calc_bmi() got multiple values for argument 'height'

有沒有辦法限定函數只能用位置引數或是關鍵字引數嗎?可以的,舉個例子:

def print_something(a, b, c, d, e):
print(a, b, c, d, e)

print_something(1, 2, 3, 4, 5)
print_something(a=1, b=2, c=3, d=4, e=5)

沒特別註明的話,這兩種參數寫法都可以正常運作,只要照剛剛講的遊戲規則,你想兩個混著用也沒問題。如果在定義參數的時候加上 / 標記,像這樣:

def print_something(a, b, c, /, d, e):
print(a, b, c, d, e)

這裡的 / 並不是真的參數,這個寫法只是個標記,意思是在 / 之前「只能」用位置引數,在 / 之後就隨你開心:

print_something(1, 2, 3, 4, 5)        # 全部都是位置引數,可以
print_something(1, 2, 3, e=5, d=4) # 後面兩個是關鍵字引數,也行
print_something(1, 2, c=3, e=5, d=4) # 這樣不行!

前兩種寫法沒問題,但第三種就會出現錯誤:

TypeError: print_something() got some positional-only arguments passed as keyword arguments: 'c'

Python 給的錯誤訊息還滿明顯的。另一個跟 / 有點像但效果不一樣的寫法是 *,寫起來像這樣:

def print_something(a, b, c, *, d, e):
print(a, b, c, d, e)

/ 一樣,* 本身也不是參數,它的意思是在這之後的參數只能使用關鍵字引數,不能用位置引數:

print_something(1, 2, 3, e=5, d=4)        # 後面兩個是關鍵字引數
print_something(a=1, b=2, c=3, e=5, d=4) # 全部都是關鍵字引數
print_something(1, 2, 3, 4, 5) # 全部都是位置引數,不行!

前兩個都有符合規定,但最後一個會出錯:

TypeError: print_something() takes 3 positional arguments but 5 were given

/* 標記可以混著一起用,例如:

def print_something(a, b, /, c, *, d, e):
print(a, b, c, d, e)

這表示前兩個參數只能用位置引數,最後面兩個只能用關鍵字引數,而中間的參數 c 就隨便你:

print_something(1, 2, 3, e=5, d=4)    # 變數 c 使用位置引數,沒問題
print_something(1, 2, c=3, e=5, d=4) # 使用關鍵字引數也沒問題

我們前面在介紹排序的時候曾經用過一個 Python 的內建函數 sorted(),如果你去翻它的手冊,會發現是它是這樣定義的:

sorted(iterable, /, *, key=None, reverse=False)
Return a new sorted list from the items in iterable.

也就是說,sorted() 函數的第一個引數是位置引數,後面的 keyreverse 是關鍵字引數:

>>> sorted([9, 5, 2, 7], reverse=True)
[9, 7, 5, 2]

使用 / 以及 * 標記可以讓使用者更容易理解這個函數的用法,也可以避免一些不必要的錯誤。

參數預設值

有些程式語言,像 JavaScript 對函數定義的參數沒有強制一定每個都要傳給它,多了不會錯,少了也只是得到 undefined 而已。不過 Python 可沒這麼好說話,講好要傳幾個參數就得照規矩給幾個,只要引數數量不對就會得到引數錯誤的錯誤訊息。如果參數有設定預設值的話,有些參數不給也沒關係,例如:

def hello(name, message="哈囉"):
return f"{message}!我是{name}!"

雖然有設定參數預設值,但如果我們自己有帶自己的值進去的話,就會用我們給它的值:

# 少一個參數
>>> hello("流川楓")
'哈囉!我是流川楓!'

# 全部都有
>>> hello("悟空", "オッス")
'オッス!我是悟空!'

# 同上,但使用關鍵字引數
>>> hello("悟空", message="オッス")
'オッス!我是悟空!'

不過幫函數的參數設定預設值的目的並不是讓我們偷懶用的,而是一般情況呼叫的時候可以少帶幾個參數或少打一些字,但需要的時候也可以傳參數去修改預設值。像我們一直在用的 print() 函數就是個很好的例子:

print(*objects, sep=' ', end='\n', file=None, flush=False)
Print objects to the text stream file, separated by sep and followed by end. sep, end, file, and flush, if present, must be given as keyword arguments.

平常我們就 print(1, 2, 3, 4, 5) 就能印出這幾個數字,但如果我們想要改變分隔符號,或是結尾加上句點,就可以用關鍵字引數來改變預設值:

>>> print(1, 2, 3, 4, 5)
1 2 3 4 5
>>> print(1, 2, 3, 4, 5, sep="、", end="。\n")
1、2、3、4、5。

可以觀察一下印出來的結果有什麼不同。同時,我也想請大家思考一下以下這個寫法:

def hello(name, message="哈囉", age):
return f"{message}!我是{name}!我今年{age}歲"

我把預設值放在中間,看起來沒問題,但我們要怎麼執行它?

# 只帶兩個參數
>>> hello("芙莉蓮", 1000))

我只帶頭尾兩個參數,想讓中間的就讓它用參數預設值,但這其實是做不到的,因為 Python 並不知道這裡的 1000 是要給 message 還是 age,不要說會印出什麼結果了,光是執行本身就會出現語法錯誤:

SyntaxError: parameter without a default follows parameter with a default

意思是有設定預設值的參數得要在沒有預設值的參數之後,這樣 Python 才能知道你是要給哪個參數值。

參數的預設值介紹的差不多了,是時候看點有趣的了。大家先看一下這個例子:

from random import random

def add_random_value(number, value=random()):
print(f"{number=} {value=}")

因為這裡我沒寫什麼實作內容,只是單純把參數印出來而已,比較特別的是參數的預設值我借用了 random 模組的 random() 函數,用來隨機產生數字。我們來看看執行結果:

print(add_random_value(10))  # number=10 value=0.8444218515250481
print(add_random_value(20)) # number=20 value=0.8444218515250481
print(add_random_value(30)) # number=30 value=0.8444218515250481

也許在你電腦上執行的時候隨機亂數跟我的不一樣(也不應該一樣),你會發現明明是三次獨立的執行,它的隨機數值卻都是一樣的,這一點都不隨機吧!這是因為 Python 在定義函數的時候,預設值是在定義的時候就計算好的,而不是在執行的時候才計算。如果我們想要每次執行的時候都有不同的隨機數值,應該這樣寫:

def add_random_value(number, value=None):
if value is None:
value = random()
print(f"{number=} {value=}")

預設值給個 None,然後在函數裡面進行檢查,如果是 None 表示這個函數在呼叫的時候可能沒有帶 value 參數,所以這時候才用 random() 函數產生隨機數值。因為隨機函數是在每次呼叫 add_random_value() 函數的時候才被執行,所以每次執行的時候就會有不同的隨機數值了。如果覺得 if 有點囉嗦,也可以利用我們曾經介紹過的邏輯短路讓程式碼更短一點: 或是更短一點:

def add_random_value(number, value=None):
value = value or random()
print(f"{number=} {value=}")

類似的概念,我們再看個例子:

def add_to_box(a, b, box=[]):
box.append(a)
box.append(b)
return box
>>> add_to_box(1, 4)
[1, 4]

# 猜猜看印出什麼?
>>> add_to_box(5, 0)

這裡我使用串列當做其中一個參數的預設值,乍看之下好像沒什麼問題,如果只執行一次也不會發現問題在哪裡,但當執行第二次的時候就會發現那個預設值怪怪的,是忘了喝孟婆湯嗎?這個 box 參數怎麼還會記得之前的結果?這是因為函數在定義的階段就先幫我們建立了一個空串列,再強調一次,預設值是定義階段就建立的而不是執行階段才決定的,執行階段只會判斷是不是有參數進來,有的話就用你的,沒有的話就是用預設的。所以在上例中沒有帶 box 參數進來的時候,在函數裡面進行修改串列的時候其實都是修改同一個串列,而不是全新的串列,所以第二次執行會印出 [1, 4, 5, 0]

如果我們自己帶 box 進來,就不會有這個問題:

>>> add_to_box(55, 66, box=[])
[55, 66]

>>> add_to_box(7, 8)
[1, 4, 5, 0, 7, 8]

但在這之後再執行一次,會發現又是一樣的狀況。該怎麼解決?跟剛剛的 random() 範例一樣,使用 None 來當預設值,然後在函數裡面判斷是否有帶 box 參數進來:

def add_to_box(a, b, box=None):
box = box or []
box.append(a)
box.append(b)
return box

簡單的說,預設值方便歸方便,但如果要把可變動的資料結構或是函數執行結果當作預設值的時候要特別注意,因為參數的預設值是在函數定義階段就決定的。覺得這個設計很雷嗎?也許吧,但你也可以利用這個特性來達到某些特殊的需求,例如可以把函數執行的結果暫存在函數裡之類的,所以這到底是 Bug 還是 Feature,端看你怎麼用它了。

不定數量參數

Python 的函數定義了幾個參數,執行的時候就應該要照規定帶幾個引數進來,不然會出現引數不足或是引數過多的錯誤。不過如果我們不知道使用者會帶幾個引數進來,或是想要讓你的函數用起來更有彈性,像我們很常用的內建函數 print() 就是個例子:

print(1, 2, 3)       # 印出 1 2 3
print(1, 2, 3, 4, 5) # 印出 1 2 3 4 5

這就是個標準的不定數量參數的例子,這要怎麼做?在 Python 可以使用 * 來做到這件事,我們先用最簡單的型態看看怎麼回事:

def hi(*a):
print(a)

看一下執行結果:

>>> hi(1, 2, 3, 4, 5)
(1, 2, 3, 4, 5)

>>> hi(1, 2, 3)
(1, 2, 3)

>>> hi()
()

我在定義函數的時候,刻意在參數前面加上 *,這表示會吃下所有的「位置引數(Positional Arguments)」,執行 hi() 函數之後就會發現,不管我們帶幾個引數給它,Python 都會把它們收集成一個 Tuple,甚至一個參數都不給,還是會得到一個空的 Tuple。為什麼是 Tuple 而不是串列?在前面 Tuple 章節有介紹到 Tuple 是不可變的,而且效能比串列好一點,這個參數列表應該不會也不應該被亂改,Python 把它包成 Tuple 也算合理。

知道進來的這包是一個 Tuple,接下來就可以在函數裡面用迴圈來處理這一包引數了,例如:

def say_hello_to(*names):
for name in names:
print(f"哈囉!{name}!")

這麼一來不管來多少人,這個 say_hello_to() 函數都能一次就搞定:

# 跟一個人打招呼
>>> say_hello_to("悟空")
哈囉!悟空!

# 跟很多人打招呼
>>> say_hello_to("芙莉蓮", "欣梅爾", "海塔")
哈囉!芙莉蓮!
哈囉!欣梅爾!
哈囉!海塔!

是說,如果 * 會吃下所有的位置引數,如果我這樣寫:

def hi(a, *b):
print(a, b)

先看看執行結果:

>>> hi(1, 2, 3)
1 (2, 3)
>>> hi(1)
1 ()

在這個函數裡我設定了兩個參數,參數 a 是必要的,而參數 b 加上 * 表示會接收剩下所有的位置引數,所以如果執行 hi(1, 2, 3) 的話,數字 1 會分配給 a,而 2 和 3 會被包成 Tuple 丟給 b;如果是 hi(1) 的話,數字 1 還是分配給 a,但因為沒有剩下的引數,所以 b 會是空的 Tuple。

好,我再讓這個函數複雜一點點:

def hi(a, *b, c):
print(a, b, c)

這裡我多加了一個參數 c 在最後面,你覺得這樣執行會得到什麼結果?

>>> hi(1, 2, 3, 4)

執行會得到錯誤訊息:

TypeError: hi() missing 1 required keyword-only argument: 'c'

參數定義本身沒問題,但因為中間的 *b 參數會吃光剩下的位置引數,不會留下任何位置引數給後面的參數 c,所以如果是這樣定義方式,雖然這裡沒有 / 或是 * 標記規定要用什麼方式傳參數,但參數 c 只能被迫使用關鍵字引數了:

>>> hi(1, 2, 3, c=4)
1 (2, 3) 4

這樣一來才能正常執行。

是說大家不知道有沒注意到我特別強調 * 會把剩下的位置引數吃光光,這是因為如果引數裡有關鍵字引數會出錯:

def hi(*a):
print(a)

hi(1, 2, 3, x=4, y=5) # 這行會出錯

如果想要像 * 抓到所有的位置引數一樣的抓下所有關鍵字引數的話,是使用 ** 的寫法。*** 都可以單獨使用,不過我這裡把它們放在一起:

def hi(*a, **b):
print(a, b)

這樣一來,前面 3 個位置引數同樣會被包成 Tuple 丟給參數 a,而後面的 x=4 以及 y=5 會被包成一個字典給參數 b

>>> hi(1, 2, 3, x=4, y=5)
(1, 2, 3) {'x': 4, 'y': 5}

也就是說,把 *** 像這樣組合在一起使用,就能讓函數抓到所有的引數。不過大部份你可能會看到這樣的寫法:

def func(*args, **kwargs):
print(args, kwargs)

argskwargs 其實就是 argumentskeyword arguments 的意思,我在上面的例子故意用 *a**b 只是想讓大家知道 args 或是 kwargs 並不是內建或固定的寫法,你可以用任何你喜歡的名字。

引數開箱

不管是串列、字典、Tuple 或集合,我們都看過可以用 * 可以來進行「開箱(Unpacking)」這件事,如果你想把它翻譯成「解包」也可以,總之這個 * 做的事就是把一包東西展開來的意思。這在 Python 的引數也能這樣做。先來看這個函數:

def hi(a, b, c):
print(a, b, c)

hi() 函數定義了 3 個參數而且都沒有預設值,所以如果你要呼叫這個函數,你就必須要給它剛好 3 個引數,不管是用位置引數或關鍵字引數都可以,參數寫了幾個引數就得傳幾個,不能多也不能少。如果我手上有個串列,裡面剛好有 3 個元素,我想把串列裡的元素一個一個餵給 hi() 函數,可以這樣寫:

>>> heroes = ["悟空", "魯夫", "櫻木花道"]
>>> hi(heroes[0], heroes[1], heroes[2])
悟空 魯夫 櫻木花道

但這樣有點太辛苦了,使用 * 可以讓我們更簡單的做到這件事:

>>> hi(*heroes)
悟空 魯夫 櫻木花道

這個 * 可以把串列展開變成位置引數,剛好一個蘿蔔一個坑,這樣就不用透過索引值一個一個寫了。方便是方便,但如果串列裡的元素數量跟參數數量不一樣,還是一樣會出現引數過多或過少的錯誤訊息。

串列、Tuple 都能這樣做,但用在集合的話要小心,因為在前面我們介紹集合的時候也有提過,集合是沒有順序的,所以展開出來的引數也不一定會依照你以為的順序。

類似的概念,如果我們有個字典,可以用 ** 把它展開變成關鍵字引數傳給函數:

>>> heroes = {"a": "悟空", "b": "魯夫", "c": "櫻木花道"}
>>> hi(**heroes)
悟空 魯夫 櫻木花道

這樣就等於是把字典裡的 Key 對應到參數名稱,所以如果字典的 Key 跟參數的名字沒對上,也是會出現錯誤。

Docstring

有時候你會看到函數的定義裡面會有一小段看起來像註解的文字,像這樣:

def say_hello_to(someone):
"""
Say hello to someone.

Parameters:
someone (str): The name of the person you want to say hello to.

Returns:
str: A greeting message.
"""

return f"Hello, {someone}!"

這段文字通常是用來說明這個函數的用途,例如要傳什麼參數,以及這個函數執行之後的結果,這樣的寫法稱之 Docstring。根據 PEP257 的定義:

What is a Docstring?

A docstring is a string literal that occurs as the first statement in a module, function, class, or method definition. Such a docstring becomes the __doc__ special attribute of that object.

簡單來說,Docstring 就是一個字串,要用單引號、雙引號都可以,當它出現在函數、類別或模組裡面的第一行的時候,這個字串會變成這個物件的 __doc__ 特殊屬性。Docstring 用一般的字串或是用三個單引號或雙引號包起來都可以,不過三個引號的字串寫法可以在字串裡換行,比較適合用來寫比較長的說明。

我們在前面曾經介紹過 Python 的註解是使用 # 符號,雖然用三個單引號或雙引號也有人說這在 Python 是多行註解,但它並不是註解,它就只是一般的多行字串而已。因為並沒有把這個字串指定給任何變數,Python 會在編譯的過程會被無視這段文字,不會被編譯在 Bytecode 裡。 好啦,就算你要把它當註解看也無所謂,當我們試著印出這個函數的 __doc__ 屬性的時候:

print(say_hello_to.__doc__)

你會發現剛剛那段看起來像註解的文字被印出來:

Say hello to someone.

Parameters:
someone (str): The name of the person you want to say hello to.

Returns:
str: A greeting message.

或是使用內建函數 help() 來查看某個函數的使用方法:

help(say_hello_to)

同樣也會看到這段文字:

say_hello_to(someone)
Say hello to someone.

Parameters:
someone (str): The name of the person you want to say hello to.

Returns:
str: A greeting message.

這看起來就像這個函數的說明文件一樣,這也是為什麼它被稱之 Docstring 的原因。不過需要注意的是,Docstring 必須是函數或類別的第一個陳述句(Statement),這樣到時候 help() 函數或 .__doc__ 屬性才會抓的到這段文字,如果寫在第二行或之後就沒這個效果了。關於「陳述句」在下個章節會有更詳細的說明。

回傳值

接下來這部份是函數的重點之一,可能也是新手比較容易搞混的地方,就是函數的「回傳值(Return Value)」。什麼是回傳值?再次讓我們回想一下數學的函數:

f(x) = 3x + 2

x 代入 3,會得到 11,代入 4,會得到 14,這裡的 11 跟 14 就是這個函數計算之後的「結果」。對比到程式語言,函數計算的結果就是函數的回傳值。先看看這個例子:

def add(a, b):
print(a + b)

add(3, 4)

這個函數應該不難理解,執行 add(3, 4) 之後會在畫面上印出 7。但這並不是這個函數執行之後的「結果」喔,因為我們在畫面上看到的 7 只是這個函數執行的「過程」,在執行的過程中把參數 ab 相加印出來。如果要有「結果」的話,在 Python 一定要使用 return 這個關鍵字:

def add(a, b):
return a + b

如果想要加上型別註記,也可以這樣寫:

def add(a: int, b: int) -> int:
return a + b

加上型別註記的好處是就算不看完整的函數內容,也可以知道這個函數應該要傳什麼值給它,以及它最後的回傳值是什型態。不過我們在前面也提過,型別註記在 Python 並沒有強制性,以目前的版本來說,就算標記了型別但執行的時候不遵守也不會造成錯誤。

有了 return 回傳結果,接下來一樣也是執行它,只是稍稍有些不同:

answer = add(3, 4)
print(answer)

add() 函數本身只有做計算,並且把計算完的結果交回給呼叫它的地方(也就是回傳)。正因為如此,單獨執行這個函數並不會在畫面上得到任何回應,也不會印出任何東西。所以這裡我先把結果存到一個變數裡,再呼叫 print() 函數把它印出來。

這裡就是新手容易困惑的地方了,原本的寫法不是直接就印出計算之後的結果嗎?對,但那個就不是回傳值,再說一次,原本的寫法會看到答案被印出來這是因為在函數執行過程中呼叫了 print() 函數造成的,但這並不是這個函數的「結果」。

沒有回傳值又怎樣?的確,沒有硬性規定函數都要有回傳值,有時候只是單純的做一些事情就好,例如印出一些東西,或是修改一些變數的值,這都沒問題,但如果你想要把這個函數的結果拿去做其他事情,那就需要回傳值。再次強調一次,只要沒有明確的寫出 return 關鍵字,這個函數就是沒有回傳的結果。

為什麼這麼麻煩?每個函數本來就應該做好自己的事就好,例如 add() 就做好自己的加法運算,要印東西請交給專業的 print() 函數來吧。

早一點回傳

先看一下以下這個例子:

def add(a, b):
return a + b
print("Hello World")

我故意在 return 之後加印了一行 "Hello World",這樣寫並不會出錯,但最後一行的 print() 函數永遠不會被執行。因為 return 關鍵字不是只有把回傳結果,當函數遇到 return 關鍵字,就會立刻結束這個函數的執行並回傳結果,所以在 return 後面的程式碼永遠沒機會被執行。利用這個特性,我們可能可以寫出更簡潔的程式碼,例如有個 BMI 的計算公式原本會這樣寫:

def calc_bmi(height, weight):
if height <= 0 or weight <= 0:
return None
else:
height = height / 100
return round(weight / (height**2), 2)

print(calc_bmi(170, -65)) # None
print(calc_bmi(170, 65)) # 22.49

這個函數很簡單,就是判斷身高體重如果小於等於 0 就回傳 None,否則就開始計算,因為如果發生 return 之後函數就會直接結束,所以可以改寫成這樣:

def calc_bmi(height, weight):
if height <= 0 or weight <= 0:
return None

height = height / 100
return round(weight / (height**2), 2)

print(calc_bmi(170, -65)) # None
print(calc_bmi(170, 65)) # 22.49

因為如果 if 條件成立的話函數就會直接結束,所以這裡我把 else 拿掉了,這個手法叫做「Early Return」,在函數的一開始先判斷條件,如果符合或不符合就直接 return 回傳結果,結束這個函數。雖然看起來感覺沒什麼差別,結果看起來好像也就只是少寫了一個 else 而已,但這樣寫的目的,是讓我們把注意力放在實際上要做的事情上,而不是在流程判斷上。

如果你是剛剛開始學習程式,你可能不會覺得這種寫法有什麼好處,而且我也希望不是因為我在這裡吹捧這種寫法好棒棒你就要認同我,不管這樣的寫法是好還是壞得自己感受一下。下回遇到類似情況的時候,不妨試試看不要寫 else,用 Early Return 的手法來寫看看,慢慢感受一下這種寫法的好處(或壞處)。

所有函數都有回傳值?

當我們呼叫函數,函數會做一些事,然後可能會也可能不會給你回傳值,所以,如果我說「所有的函數都有回傳值」,你認為這句話對還是不對?

嗯...這就得看你是怎麼看待 None 這個值了。任何只要沒有明確寫出 return 的函數,不管這個函數做了多厲害的事,還是只放了一個什麼事都不做的 pass,它的回傳值就是 None。所以,如果你認為 None 是一個存在的值,那這句話就是對的;但如果你認為 None 是「不存在」,那這句話就是不對的。

不過這就跟我們前面曾經介紹過的「Not A Number」nan 差不多的概念,它本身是個數字,而且還是個浮點數,只是被用來表示不是數字。一樣的概念,None 代表的是「沒有值」,但它本身就是一個實際存在的值,只是被用來表示沒有值而已。

再想個跟程式無關的哲學問題,在這個世界上你要怎麼知道某個東西不存在?我猜你可能沒想過這個問題,想想看,如果你知道某個東西不存在,是不是表示你就已經知道它是什麼,那麼它就存在了不是嗎?

函數是一等公民

在 Python 中,函數是「一等公民(First-Class Citizen)」,有時也稱之「一級物件(First-Class Object)」,意思是在 Python 裡的函數跟其他的值,像是數字、字串、串列等等資料型態,都是相同的地位,函數可以當作變數的值,或是當做其他函數的參數:

def add(a, b):
return a + b

# 變數指定
a = 123
b = add

# 像一般的值一樣放在一起
data = [1, 4, 5, 0, add, "hello"]

# 當做其他函數的參數
print(123)
print(add)

你可以看到函數 add 就跟數字 123 一樣,可以當變數的值,也跟一般的值一樣放在串列裡,還能當作參數傳給其他的函數。因為函數就跟一般的物件一樣,所以它還能被當做回傳值:

def create_adder(n):
def adder(m):
return n + m

return adder

add3 = create_adder(3)
print(add3(4)) # 7
print(add3(10)) # 13

在函數裡面定義另一個函數,這樣的寫法在 Python 沒什麼問題而且很常見,執行 create_adder() 函數之後會回傳一個函數,接著就可以使用這個剛剛回傳的函數。函數可以被當做參數傳進另一個函數,也能被函數當做回傳值傳回來,剛好符合這樣的設計函數有個很酷的外號叫做「高階函數」(Higher-Order Function, HOF)」,聽起來有點厲害但其實也沒那麼厲害。因為這個函數可以使用別的函數當參數,所以我比你「高階」;因為這個函數可以建立並回傳另一個函數,所以我比你「高階」,就這樣而已,高階函數並不是什麼複雜的技術。Python 本身就有很多內建的高階函數,例如前面介紹過拿來排序的 sorted() 函數就是一種高階函數,關於高階函數的細節在下個章節會再來跟大家聊聊到底是怎麼運作的。

也就是說,我們平常怎麼看待一般的值,就可以怎麼看待函數,這也是它被稱做一等公民或一級物件的原因。不過我個人對這個名詞有一些些意見,如果說函數跟其他的值的地位一樣,那它不就一般普通公民而已嗎?加上個「一等」或「一級」特別強調,是指有其他的「次等」、「二等」公民的意思嗎?

但我也可以理解為什麼特別這樣講,因為並不是所有的程式語言的函數都是物件、都是一等公民,例如我最喜歡的程式語言 Ruby,它的函數就不是物件。我們再接著看個例子:

def greeting(someone: str) -> None:
"""
Print a greeting to someone.
要當個有禮貌的人
"""

print(f"Hello, {someone}!")

在這個函數裡我放了一段 Docstring 以及刻意在定義的時候加上了型別註記,這些內容都能透過函數本身兩個底線開頭的魔術屬性能夠取得:

>>> print(greeting.__doc__)

Print a greeting to someone.
要當個有禮貌的人

>>> greeting.__annotations__
{'someone': <class 'str'>, 'return': None}

透過函數身上的 __name__ 屬性可以取得函數的名稱:

>>> greeting.__name__
'greeting'

為什麼可以對函數做這樣的事?就是因為在 Python 裡函數跟其他的值一樣都是物件,所以它也可以像其他物件一樣有屬性可以用。另外,雖然不知道什麼時候會用到,但除了函數內建的屬性外,我們也可以自己隨意的在函數上面安插屬性,例如:

greeting.answer_to_life = 42
greeting.WAT = "WAT"

print(greeting.answer_to_life) # 印出 42
print(greeting.WAT) # 印出 WAT

看起來是有點怪,但執行起來不會出錯,這就是把函數當作一般的物件看待的概念。

作用域

沒意外的話,print("Hello World") 應該是大家在學習 Python 程式的時候第一個會寫的語法,但你有沒有想過為什麼在 Python 程式裡,我們可以不需要定義或宣告,就能直接呼叫 print() 函數?你可能會認為這是 Python 內建的函數所以本來就能這樣用。就算是這樣,在程式語言中,當我們定義變數或函數之後,並不是定義了就能在程式的任何地方都可以使用,但為什麼 print() 這個內建的函數在任何地方都能使用?

在程式語言的世界裡,每個變數或函數都有它們的活動範圍或生存空間,在這個範圍或空間裡我們可以存取它們,這個範圍或空間就是所謂的「作用域(Scope)」。

有些程式語言會把作用域大致分成全域跟區域兩種,但在 Python 的作用域除了全域跟區域之外還有加了幾種,而且這些作用域有優先順序或是遊戲規則,我們一條一條來看。

LEGB 規則

Python 的作用域分成四種,分別是「區域(Local, L)」、「封閉(Enclosing, E)」、「全域(Global, G)」以及「內建(Built-in, B)」,合在一起簡稱 LEGB:

我們先從範圍最小的「區域(Local, L)」開始看:

def say_hello_to(someone):
greeting = "Hello"
print(f"{greeting} {someone}")

say_hello_to("Kitty")

print(someone) # 這會印出什麼?
print(greeting) # 這會印出什麼?

say_hello() 函數裡定義了參數 someone,同時也定義了變數 greeting,這兩個傢伙的作用範圍都只有在這個函數裡面,也就是只有在這個函數裡才能使用它,如果在函數外面想要存取它們,Python 會出現沒有定義的錯誤訊息。

再看另一個例子,如果在函數裡沒有定義,Python 會試著往外面找:

name = "kitty"

def say_hello():
print(f"hello {name}") # 這會印出什麼?

say_hello()

當 Python 發現在自己函數裡面沒有這個變數的時候,不會馬上就放棄,Python 會試著往外面找看看,在這裡剛好找到外面的 name 變數,所以可以正常執行。當然,如果外面也找不到同樣也會出現變數沒有定義的錯誤訊息。

好,我再讓程式碼複雜一點點,如果我們在函數裡再定義一個函數,像這樣:

name = "kitty"

def say_hello():
name = "nancy" # 這裡也有 name

def inner_hello():
print(f"hello {name}")

inner_hello()

say_hello()

在最外層以及 say_hello() 函數裡分別都定義了變數 name,正因為在 say_hello() 函數裡有自己的 name,所以執行 inner_hello() 的時候想要印出 name 變數,Python 就會先找到它而印出 hello nancy 字樣。所以,找變數的遊戲規則是先找看看目前的範圍有沒有,如果沒有,會試著往外一層找,如果外層還是沒有會再試著往更外面一層找,如果到最後還是找不到就會出錯。

這種函數裡還有函數的巢狀結構的時候,會一層一層往外找的設計,就是 LEGB 裡的 「封閉(Enclosing, E)」。

這樣的設計在我們現在來看可能覺得很理所當然,但在早期的 Python 並不是這樣設計的,Enclosing 的設計是在 Python 2.2 版本才加入的,所以就以剛才的範例來說,在 2.2 版本之前也能正常執行,只是會得到的結果不是中間層的 "nancy",而是最外層的 "kitty"

在上面範例中,我在最外層定義了變數 name,在這個檔案裡或是對 Python 來說也可以說是這個模組裡,定義在最外層的就是 LEGB 的 「全域(Global, G)」變數,在任何地方都可以使用。

但這感覺好像跟 Enclosing 有點重疊?的確是有一點,也同樣是在自己範圍裡找不到所以往外找,不同的地方在於 Enclosing 只有在函數裡面有函數的時候才會發生,會一層一層往外層函數找,當各層函數都找不到的時候,最後才會找到最外層,也就是全域變數。所以:

name = "kitty"  # 全域變數

def say_hello():
print(f"Hello {name}")

say_hello()

say_hello() 函數裡使用的 name 變數,就是全域變數,在這裡沒有函數裡的函數,所以並不會有 Enclosing 的情況發生。

在 LEGB 的最後一個是「內建(Built-in, B)」,這指的就是 Python 內建的函數或變數,像是函數 print()len()range() 或是 __name__ 變數這些都是。Built-in 在 LEGB 這四種範圍的優先順序是最低的,也就是前面三個都找不到的時候才會找它,就是因為這樣,我們前面曾經提過當我們試著把變數命名成 printlist 這些內建函數或變數的時候,可能會造成一些麻煩:

print = 123
print("hello") # 這裡會出錯

因為定義在最外層,所以 print 變成一個全域變數,接著執行 print() 函數的時候會出錯,原因並不是內建的 print() 函數被覆寫或消失了,只是因為在找 print 的時候會先找到全域變數,所以原本的內建函數 print() 就暫時看不見了。

簡單整理一下 LEGB 的優先順序:

  • 如果在自己函數有定義的話,會優先使用,這就是 L(Local)
  • 如果在函數裡面還有函數的話,在自己這一層沒有定義,會往外層函數找,這就是 E(Enclosing)
  • 如果各層函數都找不到,或是函數只有一層的時候,會往全域變數找,這就是 G(Global)
  • 最後,如果全域變數也沒定義,才會找內建的函數或變數,這就是 B(Built-in)

如果看懂 LEGB 的順序的話,就能知道為什麼不管在哪裡都能使用 print() 這種內建函數來印東西了。

Python 有內建兩個函數,可以用來檢視目前的全域變數以及區域變數有哪些,想要看全域變數的話,可以透過 globals() 函數,假設我先隨便宣告幾個變數跟函數:

a = 1
b = 2

def hello():
pass

print(globals())

執行之後會印出一個字典,我稍微排版整理一下:

{
'__name__': '__main__',
'__doc__': None,
'__package__': None,
# ... 略 ...
'__annotations__': {},
'__builtins__': <module 'builtins' (built-in)>,
'__file__': '/tmp/demo.py',
'__cached__': None,
'a': 1,
'b': 2,
'hello': <function hello at 0x104c522a0>
}

在這個字典裡裡可以看到我們剛才宣告的變數 ab 以及函數 hello,其他的有些之前有看過,例如型別註記的 __annotations__、Docstring 的 __doc__、這個檔案的名稱 __file__ 等資訊。

另一個函數是 locals(),從名字就能猜的出來這個函數會回傳區域變數,例如:

a = 1
b = 2

def hello(n, m):
x = 3
y = 4
print(locals()) # 印出區域變數

執行之後同樣也是得到一個字典:

>>> hello(100, 200)
{'n': 100, 'm': 200, 'x': 3, 'y': 4}

可以看到在函數裡宣告的變數以及函數本身的參數都是區域變數,而在外面的變數 ab 就不算在這個區域變數裡了。

如果在最外層執行 locals() 函數的話,會得到跟 globals() 一樣的結果:

>>> globals() is locals()
True

這也合理,在最外層的區域變數,不就是整個檔案或模組的全域變數嗎 :)

Lexical Scope

是說,如果說在自己的範圍裡找不到的話就會「往外面找」,這個外面是指哪裡的外面?來看看這個例子:

language = "Python"

def hi():
print(language)

def hey():
language = "Ruby"
hi()

hey()

在上面這個例子中,執行 hi() 函數會印出 language 變數的值,但因為 hi() 函數裡沒有,所以它會往外找。再看看 hey() 函數,我刻意在這裡宣告一個同名的區域變數,這時候如果執行 hi() 函數,因為它自己沒有 language 變數,我們現在知道它會往外找,但它會找到它旁邊的 "Ruby" 還是全域 Scope 的 "Python" 呢?

這就得看程式語言的 Scope 是怎麼設計的了,Python 採用的是 Lexical Scope 的設計,這個 Lexical 翻成中文是「詞彙的」,這個字其實有點抽象,Lexical Scope 的意思是指當在找東西如果找不到會往外面找,這個「外面」指的是函數定義的地方的外面。也就是說,Python 的 Scope 只跟函數定義在什麼地方有關,跟它在哪裡被執行無關。

所以在上面這個範例中,雖然在呼叫 hi() 函數的前一行宣告了 language 變數,執行到 hi() 函數的時候因為自己沒有這個變數所以往外找,但 Python 會找當時定義 hi() 函數的地方的外面,所以執行之後會找到全域 Scope 的 language 變數而印出 "Python" 字樣,而不是 "Ruby"

a += 1

大家現在學到這個章節,接下來這段範例應該不陌生:

a = 1
a += 1
print(a)

這應該不需要實際執行,光用肉眼看就知道結果是 2+= 這個運算子是 Python 提供的簡寫語法,等同於 a = a + 1。這沒什麼問題,那接下來這個呢:

a = 1

def hi():
a += 1
print(a)

hi()

hi() 函數裡面執行 a += 1,而在最外層有個全域變數 a,就我們現在的認知執行應該會印出 2,但執行卻會發生錯誤:

UnboundLocalError: cannot access local variable 'a' where it is not associated with a value

這個錯誤訊息有點怪,如果是變數沒定義的話,錯誤訊息應該是:

NameError: name 'a' is not defined

事情是這樣的,Python 並沒有像其他程式語言一樣有 varlet 這樣明確的「宣告變數」關鍵字,當 Python 遇到 a = 1 的時候,它得從作用域來判斷是不是需要宣告一個新的變數 a,還是試著去找既有的變數 a 然後把它的值設定成 1。

在 Python 的官方文件 FAQ 裡有提到 Python 是怎麼決定某個變數是全域還是區域變數:

Q: What are the rules for local and global variables in Python?

A: In Python, variables that are only referenced inside a function are implicitly global. If a variable is assigned a value anywhere within the function’s body, it’s assumed to be a local unless explicitly declared as global.

Python 透過一個很簡單的規則:如果在函數裡做賦值這件事,像是 a = 1,這個行為會被視為宣告一個區域變數 a,並且把值設定成 1,而不是取用或修改函數外層或全域變數。為什麼這樣設計?就是剛剛說的,Python 的語法並沒有明確的「宣告動作」的設計,如果不加這條規則,以後在函數裡要宣告變數的時候,都得擔心外層是不是已經有同名的變數了,所以 Python 才定了這條規則。

因此在 hi() 函數裡面的 a += 1,也就是 a = a + 1,等號左邊的 a = 表示會有一個區域變數 a,而等號右邊的 a + 1 會試著問「嘿,請問在我們這個函數裡有沒有變數 a 啊?」,這時 Python 會跟它說有,但這個變數還沒有被賦值,所以無法使用,因此出現 UnboundLocalError 的錯誤訊息。

如果改成這樣呢?

a = 1

def hi():
print(a) # 這會印出什麼?
a = 2

hi()

執行之後還是一樣得到 UnboundLocalError,這是因為 Python 在編譯的過程發現我們打算在函數裡進行變數賦值,不管我們把它寫在哪一行,Python 都會把 a = 2 解讀成區域變數,所以在它的前一行執行 print(a) 的時候,Python 會認為在這個 Scope 裡有個區域變數 a 而想要取用它,但因為這時變數還沒有賦值,所以產生一樣的錯誤訊息。

如果你曾經寫過 JavaScript,這個行為跟 ES6 的 let 宣告的變數行為有一點像,也就是大家說的「暫時死區(TDZ, Temporal Dead Zone)」。

請記得,在 Python 的函數裡只要進行變數的賦值,這個行為會被當做宣告區域變數,不管寫在哪一行都是,甚至像這樣:

a = 1

def hi():
if False:
a = 2
print(a)

hi()

雖然裡面的 if 流程並不會執行,但 Python 在編譯過程仍會把裡面的 a = 2 先解讀成區域變數,所以同樣也產生 UnboundLocalError 的錯誤訊息。如果你大概了解怎麼回事,最後再來個例子練練手:

counter = 0

def set_counter():
counter = 2

set_counter()
print(counter) # 這會印出什麼?

你覺得在執行 set_counter() 函數後,最後一行的變數 counter 最後會變成什麼?如果你有看懂前面的內容,應該能猜到答案是 0 而不是 2。

但如果真的想要取用全域變數呢?那得明確的使用關鍵字 global

a = 1

def hi():
global a # <- 這裡!
a += 1

hi()
print(a)

global a 的意思是「嘿,待會我要用的變數 a 是全域變數的 a 喔!」,這樣一來就可以正確的取用全域變數 a 了。

使用 global 關鍵字的時候要注意,以上面這個例子來說,如果原本在全域 Scope 就有定義 a 的話是會拿來用沒錯,但如果沒有定義,就會在全域 Scope 建立一個新的變數 a

def hi():
global a # 我要使用全域的 a!
a = "Hello Kitty"

hi()
print(a) # 這會印出什麼?

執行之後會發現會印出 "Hello Kitty" 字串,也就是說即使在 hi() 函數執行之後,裡面的 global a 這行會在外面的全域 Scope 建立一個新的變數 a,也就是即使 hi() 函數執行結束之後,變數 a 還是會繼續存活在全域 Scope 裡,這種寫法並不是什麼好事。

好,現在我們知道使用 global 關鍵字可以存取全域 Scope,接下來看看這個再複雜一點的例子:

a = 0

def hi():
a = 10

def hey():
global a # 全域
a += 1

hey()
print(f"1 - {a}") # A. 這會印出什麼?

hi()
print(f"2 - {a}") # B. 這裡又會印出什麼?

想想看,這兩個 print() 函數分別會印出什麼?在 hey() 函數裡面使用了 global a 的寫法,所以這裡的 a 指的是最外層的全域變數,也就是 0。執行內層的 hey() 函數之後,並不會改變在 hi() 函數裡的變數 a,所以執行之後的答案是:

1 - 10
2 - 1

不知道你有沒有猜對。不過,使用 global 關鍵字會指向最外層的全域變數,不用 global 又會出現 UnboundLocalError 的錯誤訊息,如果只是想指到上一層的變數該怎麼做?這時要使用另一個關鍵字 nonlocal

def hey():
nonlocal a
a += 1

nonlocal a 表示這個變數 a 會找上一層函數裡的區域變數,也就是找 LEGB 的 Enclosing 作用域,所以執行之後的結果是:

1 - 11
2 - 0

注意,Enclosing 作用域並不是全域作用域,所以如果改成這樣:

a = 0

def hey():
nonlocal a # <-- 這裡!
a += 1

hey()

這樣還是會出錯的,因為 Enclosing 作用域只會找上層函數裡的區域變數,不會找全域變數。

老實說 Python 的這個設計跟其他程式語言的作用域不太一樣,我說不上是好設計還是糟糕的設計,畢竟 Python 沒有明確的「宣告」關鍵字可以用,也只能先用這樣的設計來解決問題,不管如何,一定要知道這個特性,避免在寫程式的時候出現一些奇怪的問題。

函數 vs 方法

不知道大家從一開始看到現在,是否有發現我在講到函數的時候,有時候會叫它「方法(Method)」,有時候會叫它「函數(Function)」,它們本質上其實都是函數,但會因為使用的方式不同而有一點不同。例如很常用到的 print() 是個函數,這沒什麼問題,但如果是在操作串列的時候有使用過 .append(),雖然它也是函數,只是在使用這個函數的時候是透過一個物件來呼叫的,這樣的函數就叫做「方法」。所以,所謂的「方法」,其實就是綁在物件上的函數。

因為方法是透過物件來呼叫的,所以有些方法在執行的時候,也可能可以改變物件本身的狀態,像內建函數 sorted() 函數跟串列的 .sort() 方法就是一個例子,同樣都是排序的功能,sorted() 函數會回傳一個新的串列,而 .sort() 會直接改變呼叫這個方法的串列。

所以,函數跟方法的差別在於,函數是直接呼叫,而方法是透過物件呼叫的,其他就沒什麼太大的差別了。也因為方法通常是跟著物件,所以方法通常都是定義在類別裡,這在後面的物件導向章節會有更詳細的介紹。

《冷知識》是函數還是方法?

Python 有一套自己分辨是函數還是方法的方式,Python 有個內建模組叫 inspect,這個模組裡有很多有趣的函數,有空可以翻手冊看看,其中有兩個函數,一個叫 isfunction(),一個叫 ismethod(),光看函數的名字就知道用途了,使用起來也很簡單:

from inspect import ismethod, isfunction

# 函數
def say_something():
print("Hello, World!")

print(isfunction(say_something)) # True
print(ismethod(say_something)) # False

# 方法
class Cat:
def say_something(self):
print("Meow!")

kitty = Cat()

print(isfunction(kitty.say_something)) # False
print(ismethod(kitty.say_something)) # True

但這兩個函數是怎麼分辨哪個是函數哪個是方法?追一下原始碼就知道答案了:

def isfunction(object):
"""Return true if the object is a user-defined function."""
return isinstance(object, types.FunctionType)

def ismethod(object):
"""Return true if the object is an instance method."""
return isinstance(object, types.MethodType)

isfunction()ismethod() 這兩個函數的實作都滿簡單的,分別檢查該物件是不是一種 FunctionTypeMethodType,再繼續往下追就會知道這兩個東西是怎麼定義的:

# FunctionType
def _f(): pass
FunctionType = type(_f)

# MethodType
class _C:
def _m(self): pass
MethodType = type(_C()._m)

也就是說,FunctionType 指的就是一般的函數,我們自己使用 def 寫的函數,以及下一章節會介紹的 Lambda 表達式寫出來的匿名函數都算是 FunctionType。而 MethodType 是透過類別建立的實體身上的方法取得的,所以這兩個函數就是透過這兩個型別來判斷是函數或是方法。那如果像是內建的 print() 函數呢?

print(isfunction(print)) # 會印出什麼?

print() 是個函數對吧,但用 isfunction() 檢查的答案卻是 False,這是因為 print() 是 Python 內建的函數,這種內建函數在 Python 有另外的型別叫做 BuiltinFunctionType 而不是 FunctionType,根據前面 isfunction() 函數的原始碼的定義,不難推敲出答案會得到 False。如果你好奇 BuiltinFunctionType 是怎麼定義的,同樣追一下原始碼就會知道:

BuiltinFunctionType = type(len)

其實就是隨便抓一個內建函數 len() 來定義型別而已啦。

追原始碼的過程很有趣,當你覺得好奇或是不知道某個函數是怎麼運作的就去追原始碼,不只可以趁這個機會熟悉 Python 的語法,也能真正了解故事的來龍去脈。網路上的教學文章可能會騙人,但原始碼不會騙人,不要再相信沒有根據的說法了。

函數還有很多有趣的特性跟玩法,但這個章節大概先介紹到這裡,如果還想知道更深入關於函數的介紹,下個章節就會跟大家介紹到更進階的函數特性,例如遞迴、匿名函數、裝飾器等等,但因為內容不見得實用,日常工作也不一定用的上,而且可能還有點燒腦,特別是函數的裝飾器,所以可把它當做選讀的內容,要跳過它也沒問題的。

工商服務

想學 Python 嗎?我教你啊 :)

想要成為軟體工程師嗎?這不是條輕鬆的路,除了興趣之外,還需要足夠的決心、設定目標並持續學習,我們的ASTROCamp 軟體工程師培訓營提供專業的前後端課程培訓,幫助你在最短時間內建立正確且扎實的軟體開發技能,有興趣而且不怕吃苦的話不妨來試試看!