跳至主要内容

函數 - 進階篇

函數

函數能介紹的內容實在太多,有的主題我自己覺得很有趣但可能稍微有點複雜,而且平常工作可能不一定會用的上,所以另外再開一個章節來介紹,也就是說,這個章節的內容先跳過也沒關係,如果晚上睡不著再翻開來看就好。

在開始介紹函數相關的內容之前,我們先來認識兩個名詞,一個叫「Expression(表達式)」,另一個叫「Statement(陳述句)」。

表達式 vs 陳述句

我們一般人類用的語言,不管是英文、日文還是中文都差不多,都有很多的詞組或片語(Phrase),而一個完整的句子是由這些片語組合而成。舉例來說,「我喜歡吃火鍋」這句話其中的「喜歡」、「吃」跟「火鍋」雖然你都知道這些代表什麼意思,但都不能算是一個完整的句子,只能算是單字片語(Phrase)。如果對比到電腦程式語言來說,這些單字片語就是「表達式(Expression)」。雖然有些單字片語本身就能夠表達意思,但通常要把整個句字從頭到尾講完,才算的上是個完整句子;以電腦程式語言來說,完整的一句話就是「陳述句(Statement)」。

如果這樣還是有些抽象,來些例子吧:

18
"hello kitty"
1450 > 9527

從最簡單的數字、字串,到四則運算,或是變數本身或函數呼叫,這些都是表達式,例如上例中數字 18、字串 "hello kitty",以及 1450 > 9527 的計算結果,都是一個表達式,執行表達式之後都會得到一個結果或一個值,我們直接進 Python 的 REPL 看看就知道:

>>> 18
18

>>> "hello kitty"
'hello kitty'

>>> 1450 > 9527
False

如果只是一般的值,它的結果就是它本身,但如果是一個運算式,則會得到運算之後的結果,例如 1450 > 9527 的結果是 False

我們再看看下面這個例子:

cats = 5

if cats > 0:
print("有好多貓 🐈")

第一行宣告了一個 cat 變數並且指定值等於數字 5,這是一個陳述句;接下來的 if 判斷句,也是一個陳述句。

如果用人類的語言來比喻,「鮪魚壽司 🍣」就是一個表達式,它就是代表「鮪魚壽司 🍣」這個東西,它可能沒辦法完整呈現你想要表達的意思;相對的「我要吃鮪魚壽司 🍣」就是一個陳述句,它可以完整的表示你想說的話。

就像我們講一句話,通常一個句子裡是由許多單字組合而成一樣,例如「我想要吃鮪魚壽司 🍣」這句話是一句完整的陳述句,但裡面有「我」、「想要」、「吃」、「鮪魚壽司 🍣」這些表達式,也就是說,一個陳述句通常會包括一個或多個表達式。

因為陳述句可以只包含一個表達式,所以就算只有單個數字 1450,像這樣:

>>> 1450
1450

這是一個表達式,也是一個有效的陳述句,但反過來就不能這樣講了,陳述句並不是一個表達式,而是包含一個或多個表達式。

在上面的例子中,cats = 5 這行,等號右手邊的數字 5 是一個表達式,這個表達式的結果就是數字 5,而 cats = 5 是指把等號右手邊的結果,也就是 5,指定給一個變數 cats,這個指定的行為是一個陳述句。

同樣的,if cats > 0: 這行,cats > 0 是一個表達式,這會得到一個布林值的結果,而 if cats > 0: 這整行或這整段的 if 判斷句則是一個陳述句。

不知道到這裡會不會看的有點眼花了,對程式新手來說,表達式跟陳述句一開始可能不是那麼容易分辨,別擔心,這也不影響程式學習,暫時可以不用太揪結這兩者在定義上有什麼不同。如果要說這兩者比較明顯的差異,在於表達式會有「結果」,但陳述句不會。這裡我很想用「回傳值(Return Value)」來替代「結果」,但用回傳值來表示表達式的結果又不夠精準,所以我這裡就還是使用「結果」代替大家比較常聽到的回傳值。

我們再回到 REPL 看個例子:

>>> char_count = len("hello")
>>> char_count = char_count + 1
>>> char_count
6

在第一行等號右手邊的 len() 函數呼叫是一個表達式,它會計算 "hello" 字串的字數並且回傳 5,所以這個表達式的結果就是 5,而整個句子 char_count = len("hello") 是一個陳述句,它不會有結果。同樣的,接下來的 char_count = char_count + 1 這行,等號右手邊的 char_count + 1 是一個表達式,這個表達式的結果是 6,但整行的 char_count = char_count + 1 是個陳述句,也同樣沒有結果,就只是一個行為而已。所以在 REPL 裡會看到前兩行都沒有印出結果。

但第三行的 char_count 是個表達式,它的結果就是 6,所以在 REPL 裡會印出 6

表達式跟陳述句這兩個名詞並不是 Python 發明的,在很多程式語言裡面都有這個概念,只是你可能不知道你寫的就是表達式或陳述句而已。在 Python 裡常寫到的 ifforwhiledefclass 這些關鍵字都是陳述句,也就是說像是「定義函數」、「定義類別」這些行為本身都沒有結果,就只是個行為而已。

海象運算子(Walrus Operator)

如前面所說,age = 18 這在 Python 是一個 Statement,只是一個行為,也就是說在 Python 宣告變數或賦值這件事是不會有結果的。然而在其它程式語言不一定是這樣,像 Ruby 或 JavaScript 的賦值是有結果的,我用 JavaScript 舉個例子:

$ node
Welcome to Node.js v22.0.0.
Type ".help" for more information.
> let a
undefined
> a = 1
1

在最後一行可以看到 a = 1 的結果就是 1 本身。

在 Python 3.8 之後新增了一個看起來滿好玩的運算子 :=,它有個可愛的名字叫做「海象運算子」(Walrus Operator),先不管它的功能是什麼,你可以想想看為什麼叫這個名字?想不出來的話,把你的頭歪個 90 度再加點想像力看看,: 就像海象的鼻孔,而 = 就像是海象的牙齒,這樣是不是有點像海象了?

這個運算子的功能是把 := 右邊的值指定給左邊的變數,並且同時把右邊的值當作整個運算式的結果,有點像其它程式程式語言的賦值會有結果一樣。不過這個 := 不能直接這樣寫:

>>> age := 18
File "<stdin>", line 1
age := 18
^^
SyntaxError: invalid syntax

這會造成語法錯誤。不過外面加個小括號讓它變成一個 expression 就不會出錯了:

>>> (age := 18)
18
>>> age
18

如果只是單純的宣告或賦值的話別這樣寫,用一般的 = 就好,這並不是這個運算子設計主要的用途。那麼這個可愛的運算子可以用在什麼地方?舉個例子,我這裡有個要運算比較久的函數:

import time

def slow_query(value):
time.sleep(1) # 睡一下
return value > 0

其實也不是什麼複雜的演算法,我就只是讓它每次執行的時候都先睡個 1 秒鐘再執行,目的是模擬可能比較複雜的計算。再看看這個例子:

result = slow_query(1450) if slow_query(1450) else None
print(result)

這是個簡單的 if..else.. 判斷,但這段程式碼執行需要花 2 秒鐘。為什麼?因為後面的 if 先花 1 秒鐘執行 slow_query() 函數做一次判斷,最後的結果又要再花 1 秒再執行一次。所以聰明如你可能會改成這樣寫:

user_data = slow_query(1450)
result = user_data if user_data else None
print(result)

這樣就只會執行一次 slow_query()

Python 社群對海象運算子有一些爭議,主要是:

海象這個名稱不夠明白、直覺,無法讓人直接從名稱了解其用途 無法向下相容,如果你是套件開發者,用了海象運算子就會有 3.8 以前的相容問題要解決 := 與 = 符號太相似,難以快速識別

Lambda 表達式

我們在 Python 裡定義函數是使用 def 關鍵字,像這樣:

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

我們會說這是定義一個 add() 函數,因為在 Python 裡的函數也是物件,也是我們在前個章節介紹過的「一等公民」,所以你也能把上面這兩行解讀成「建立一個函數物件,並且把它指定給 add 變數」。

事實上,如果你用 Python 內建的 dis 模組來檢視編譯出來的 Bytecode 的話,你會看到這個:

$ python -m dis demo.py
0 0 RESUME 0
1 2 LOAD_CONST 0 (<code object add ...略...>)
4 MAKE_FUNCTION 0
6 STORE_NAME 0 (add)
8 RETURN_CONST 1 (None)

雖然我們應該看不懂每個指令,但大概從名字可以猜是怎麼回事。首先,Python 先使用 LOAD_CONST 指令把要執行的程式碼變成一顆程式碼物件(Code Object),接著使用 MAKE_FUNCTION 指令把這個程式碼物件建立成函數,然後再用 STORE_NAME 指令把它指定給 add 變數。附帶一提,Python 自帶的這個 dis 模組是個很酷的工具,對想要了解 Python 是怎麼運作的人來說是個很好的入門工具。

講到函數是一等公民這件事,在 JavaScript 的世界裡函數也是物件,所以常會看到這樣寫:

// 使用 function 定義函數
const add = function (a, b) {
return a + b
}

// 或是使用箭頭函數
const add = (a, b) => a + b

所以不管是用 function 關鍵字或是使用箭頭函數(Arrow Function)的寫法,都能做到把函數指定給某個變數或常數。在 Python 裡函數雖然也是物件,但卻沒辦法像 JavaScript 一樣直接用 def 關鍵字把函數指定給一個變數。Python 有「Lambda 表達式(Lambda Expression)」可以做到一樣的事,使用的是關鍵字 lambda,語法寫起來滿簡單的:

lambda 參數列表: 表達式

lambda 關鍵字後面接的是參數,跟一般函數一樣想放幾個參數都可以,參數超過一個的話就用逗號分開即可,然後再接一個冒號 :,冒號後面就是實際要執行的內容。你應該有發現使用 Lambda 表達式的時候不需要像 def 定義函數一樣先幫函數想個名字,所以我們也會稱這種函數為「匿名函數」(Anonymous Function)。因為它沒有名字,lambda 只是個表達式而已,它的結果就是這個函數本身,所以通常會把這個結果指定給某個變數,例如:

>>> add = lambda a, b: a + b
>>> add(1, 2)
3

使用的時候就像一般的函數一樣搭配小括號 () 來呼叫就行了。如果試著印出它的型態,會發現 Lambda 其實就只是個函數物件:

>>> type(add)
<class 'function'>

甚至如果我們使用內建的 dis 模組來檢視編譯出來的 Bytecode 的話,像這樣:

from dis import dis

add1 = lambda a, b: a + b

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

dis(add1)
dis(add2)

會發現 Lambda 表達式寫出來的函數跟一般 def 寫出來的行為是一樣的:

3           0 RESUME                   0
2 LOAD_FAST 0 (a)
4 LOAD_FAST 1 (b)
6 BINARY_OP 0 (+)
10 RETURN_VALUE
6 0 RESUME 0

7 2 LOAD_FAST 0 (a)
4 LOAD_FAST 1 (b)
6 BINARY_OP 0 (+)
10 RETURN_VALUE

lambda 是另一種在 Python 定義函數的方式,只是它沒有名字而已:

add1 = lambda a, b: a + b

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

把函數的名字印出來看看:

>>> add1.__name__
'<lambda>'
>>> add2.__name__
'add2'

Lambda 表達式產生的函數因為沒有自己的名字所以只會印出 <lambda>,而一般函數則是看的出來名字的 add2

另外,在 Lambda 表達式的冒號 : 右手邊也是一個表達式,不用寫 return 關鍵字就會自動會回傳這個表達式的結果,如果硬是加上 return 反而會造成語法錯誤。

如果不想要帶參數的話,可以這樣寫:

>>> say_hello_to_kitty = lambda: 'Hello, Kitty!'
>>> say_hello_to_kitty()
'Hello, Kitty!'

使用限制

Lambda 表達式雖然跟一般函數差不多,但它有一些限制,例如它只能有一個表達式,講的更白話一點就是它只能做比較簡單的運算,如果要做一些較複雜的事情,就不太適合用 Lambda 表達式了。別誤會,Lambda 表達式不是不能寫複雜的東西,但要把比較複雜的流程判斷硬是寫成一個表達式會讓程式碼變得不好閱讀,這也不是 Lambda 表達式的設計初衷。

其次,在 Lambda 表達式裡不能做賦值或宣告變數,像這樣寫:

lambda: n = 1

這會造成語法錯誤 SyntaxError: cannot assign to lambda,也就是說,像底下這樣看起來似乎沒什麼問題的 Lambda 表達式:

add_one = lambda n: n += 1

這同樣也會造成語法錯誤,正確的寫法應該是:

add_one = lambda n: n + 1

另外,Lambda 表達式也不能使用型別註記,像這樣:

add_one = lambda n:int: n + 1

這一堆冒號看不懂啦!Python 直接就賞了我們一個語法錯誤。

參數

Lambda 表達式用的參數用起來跟一般函數差不多,例如設定預設值:

add = lambda a, b=1: a + b

print(add(1, 2)) # 3

# 使用關鍵字引數
print(add(a=1, b=2)) # 3

# 少給一個引數
print(add(100)) # 101

Lambda 表達式的參數也可以是用之前學過的 *args**kwargs,例如:

add = lambda *args: sum(args)

print(add(1, 2, 3, 4, 5)) # 15
print(add(1, 2, 3)) # 6

用途

Lambda 表達式通常可以用在一些簡單的運算,或是直接當作參數傳遞給其他函數使用。簡單的運算就算了,這用 def 也能做到,但是可以直接當參數傳給其他函數這就可以做一些方便的事了。舉個例子,我們之前用過的排序 sorted() 函數,sorted() 函數有一個 key 參數可以傳入一個函數來進行排序,但如果這個函數只會用到一次,用 Lambda 表達式就可以不用另外定義函數了。例如原本可能是這樣寫:

people = [
{"name": "弗里沙", "power": 530000},
{"name": "地球人", "power": 5},
{"name": "張無忌", "power": 10000},
]

def get_power(hero):
return hero["power"]

# 按照戰鬥力高低反向排序
print(sorted(people, key=get_power, reverse=True))

不過這個 get_power() 如果只用一次,還得幫它想個好名字就有點累,這時候可考慮使用 Lambda 表達式:

print(sorted(people, key=lambda h: h["power"], reverse=True))

這樣就不用另外定義一個函數,程式碼看起來有沒有比較簡潔就看你自己的個人觀感了,對我來說的確是有。

另外還有一些內建函數,像是 mapfilterreduce 之類的,其實在其他程式語言也挺常見,舉個例子,如果我有一個串列 [1, 2, 3, 4, 5],我想要對串列裡的每個元素都乘以 2,可以這樣寫:

numbers = [1, 2, 3, 4, 5]
double_numbers = map(lambda x: x * 2, numbers)
print(list(double_numbers))

map() 函數會得到一個 map 物件,所以我們可以用 for...in... 把它一個一個拿出來用,或是直接使用 list() 函數將它轉換成串列,這樣就可以得到 [2, 4, 6, 8, 10] 了。

filter() 函數則是用來過濾元素,例如我想要過濾出串列裡的偶數:

numbers = [1, 2, 3, 4, 5]
even_numbers = filter(lambda x: x % 2 == 0, numbers)
print(list(even_numbers))

執行之後可得到 [2, 4]

雖然 map()filter() 在其他程式語的使用頻率可能還算高,但在 Python 倒是比較少,主要是 Python 有更好用的串列推導式可以用,以上面的需求完全可以用串列推導式寫出來,而且語法更短、更直覺。不過 map()filter() 函數也不是沒好處,因為它們執行之後會得到一個迭代器(Iterator)而不是直接產生一個新的串列,當遇到串列元素比較多的時候,用這種寫法可以先產生迭代器,再依實際需求一個一個把元素拿出來用,比較不會一口氣吃掉一大塊記憶體。

Closure

Closure,在電腦程式世界通常翻譯成「閉包」,這並不是什麼套件或函式庫,而是一種程式設計的手法,而且不少程式語言都有這個特性,Python 也有。在介紹什麼是閉包之前,我們先來看一個有點酷的名詞 - 「自由變數(Free Variable)」。

自由變數

來看看以下這段程式碼:

def hi():
message = "Hello, Kitty!"

def hey():
print(message)

hey()

在內層的 hey() 函數沒有定義 message 變數,所以如我們在前面介紹過的 LEGB 順序,它會往外找到在外層 hi() 函數的區域變數 message。像這樣在內層函數本身沒有定義而是使用外層函數的區域變數的情況,我們就會稱這樣的變數就叫做「自由變數」。但如果是這樣的話:

message = "Hello, Kitty!"

def hi():
def hey():
print(message)

hey()

雖然這段程式碼執行的結果跟上面的一樣,但在內層的 hey() 函數要找外層函數的區域變數的時候找不到,所以 Python 就會接著往外找到全域變數 message,在這種情況下我們就不會稱這個變數為自由變數。

Python 有提供一些方法讓我們可以檢視某個函數的自由變數,例如:

def hi():
a = 1
b = 2

def hey():
print(a)

hi()

每個函數都有個 __code__ 屬性,它會回傳一個 Code Object,這個我們在前面介紹 Lambda 的時候也出現過,這是每個函數都有的屬性。這個 Code Object 裡記載了這個函數的相關資訊,例如它有專門記錄自由變數的 co_freevars 屬性,開頭的 co_ 就是 Code Object 的縮寫。如果試著印出 co_freevars 屬性就會得到一個 Tuple ('a',),表示變數 ahey() 函數來說就是個「自由變數」。相對的,在 hey() 函數裡並沒有用到變數 b,所以它就不會在 co_freevars 屬性裡。

我們先不管自由變數的的自由是在自由什麼,現在大概可以理解的是如果在自己的範圍裡找不到就會往外面找,接下來我讓事情變的更複雜一點...

變數帶著走?

現在大家應該都已經知道在函數裡宣告的區域變數,如果沒有特別使用 global 關鍵字處理的話,只要函數執行結束,裡面的區域變數就會跟著消失而無法再被使用,例如:

def hi():
a = 1

hi()
print(a) # 這樣不行!

這應該沒問題,再看一下剛剛的例子:

def hi():
message = "Hello, Kitty!"

def hey():
print(message)

hey()

hi()

執行之後會印出 "Hello, Kitty!",這也沒問題,但如果這裡我不執行,而是把裡面的 hey() 函數回傳出來呢?像這樣:

def hi():
message = "Hello, Kitty!"

def hey():
print(message)

return hey

greeting = hi()
greeting()

依照我們目前對函數以及變數 Scope 的認知,在外層的 hi() 函數執行結束之後,沒意外的話 message 變數就會跟著消失。但我們這裡並不是執行函數,而是把函數本身回傳回來,晚點才要執行它,這一樣來待會執行函數的時候,因為變數 message 消失了所以執行應該會出錯。結果執行之後竟然不會出錯,這表示區域變數 messagehi() 執行結束之後還活著?這就是這個小節要介紹的「閉包」概念了。而之所以會稱之自由變數,「自由」兩字表示這些變數不會受到 Scope 的約束,它是「自由」的存在於程式碼中,可以在不同的地方被引用和賦值。

閉包是指一個函數裡面參考了外部函數的區域變數,這個函數就是閉包。在上面的例子中,hey() 函數參考了 hi() 函數裡面的區域變數 a,所以當 hi() 函數執行結束之後 a 變數還會繼續活著,它最終變成了 hey() 函數的一部分,這就是閉包的概念。

運作原理

Python 是怎麼做到這件事的?在這裡我要介紹一個大家可能比較少聽過的東西叫做 Cell Object,我不知道這中文怎麼翻譯比較貼切,就讓我繼續使用原文。根據 Python 的官方文件記載:

Cell Objects

“Cell” objects are used to implement variables referenced by multiple scopes.

等等,一個變數被多個 Scope 參照?這什麼意思?我們來看個簡單的例子:

def hi():
a = 1
b = 2

def hey():
print(a)

hi() 函數裡有兩個區域變數,啊...不對,應該是三個,因為裡面的 hey 雖然是函數,但也算是在這個函數裡的區域變數,我們暫時先把重點放在一般的變數就好。

變數 a 在內層的 hey() 函數有被使用到,所以我們就可以說變數 a 就被兩個 Scope 參考到了,分別是 hi() 函數跟 hey() 函數這兩個 Scope。當遇到一個變數被多個 Scope 參照的時候,Python 就會在編譯階段偷偷幫我們另外建立一個 Cell 物件,這個 Cell 物件會指向變數 a 原本應該要指向的數字 1,然後原本的變數 ahey() 函數裡的變數 a 都是指向這個 Cell 物件,有點像這樣:

  a ------> < Cell 物件 > ----> 1
|
hey.a -------|

這就是 Python 內部在做的事,不管有沒有發生閉包,只要變數在多個 Scope 被參考到,Python 就會做這件事。這個 Cell 物件同樣可以透過函數的 Code Object 的 co_cellvars 屬性看到:

>>> hi.__code__.co_cellvars
('a',)

執行之後會印出一個 Tuple ('a',),表示在 hi() 函數裡的變數 a 其實是指向 Cell 物件,再由 Cell 物件指向實際的值。這裡會發現同樣是區域變數的 b 變數並沒有被列在 co_cellvars 裡面,這是因為 b 變數只有在 hi() 函數裡面被使用,所以 Python 不需要另外建立 Cell 物件。如果再繼續往下挖,在 Code Object 有一個 co_varnames 屬性是用來存放區域變數的:

>>> hi.__code__.co_varnames
('b', 'hey')

從顯示結果可以看出來,在 hi() 函數裡面只有一個區域變數 b 跟函數 hey,也就是說變數 a 在 Code Object 的角度來看已經不算是區域變數,而是被歸類在 Cell 變數裡了。這些都是 Python 內部的實作細節,我們實際在寫程式碼的時候可以宏觀的來看就好,甚至就把它們都當區域變數來看待也沒問題。

現在知道 Python 內部會使用 Cell 物件來做這件事之後,我們再回頭看剛才的範例:

def hi():
message = "Hello, Kitty!" # 指向 Cell 物件

def hey():
print(message) # 指向 Cell 物件

return hey

greeting = hi()
greeting()

因為在 hi() 以及 hey() 這兩個函數的 Scope 裡都有參照到 message 變數,所以在這兩個 Scope 裡都是指向 Python 偷偷幫我們另外建立的 Cell 物件,因此在 hi() 外要執行 greeting() 函數試著想要印出 message 變數的時候,其實就是去找那個 Cell 物件,然後再找到這個物件對應到的值。這就是 Python 實作閉包的手法,Python 使用了 Cell 物件來做到這個行為,這也是為什麼明明函數執行結束之後本該消失的區域變數竟然還能使用的原因。有興趣的話,可以透過函數的 __closure__ 屬性可以看到這些 Cell 物件:

>>> greeting.__closure__
(<cell at 0x104b553c0: str object at 0x104ba4c70>,)

__closure__ 屬性是一個 Tuple,裡面放了一個 Cell 物件,再繼續往下挖就可以找到這個 Cell 物件對應到的值:

>>> greeting.__closure__[0].cell_contents
'Hello, Kitty!'

透過 .cell_contents 屬性就能取得這個 Cell 物件的內容物,也就是原本 message 變數的值 "Hello, Kitty!"

Closure 的應用

所以,閉包有什麼用途?因為宣告在函數裡的變數在外面是無法直接取用的,也就是說可以透過閉包用來建立一個私有的變數,就不會像全域變數那樣不小心被改到。

閉包可以記住函數被定義時環境中的自由變數,利用這個特性可以用來實現一些有趣的功能,例如可以寫個簡單的計數器:

def create_counter():
count = 0

def inner():
nonlocal count
count += 1
return count

return inner

我在 create_counter() 函數裡設定了一個 count 變數,它會變成 inner() 函數的自由變數,這裡也會發生閉包的行為。透過這個函數可以建立獨立的計數器,而且它們有各別的狀態:

counter1 = create_counter()
counter2 = create_counter()

print(counter1()) # 印出 1
print(counter1()) # 印出 2
print(counter1()) # 印出 3

print(counter2()) # 印出 1
print(counter2()) # 印出 2

print(counter1()) # 印出 4

這樣就可以建立多個獨立的計數器,裡面的數值不會互相影響。而且接下來要介紹的酷東西「函數裝飾器」,就是利用閉包的特性來實現的。

函數裝飾器

接下來我們要介紹的叫做「裝飾器(Decorator)」,這在 Python 裡算是很常使用的手法,特別是在一些套件或網站開發框架,例如後續會介紹的 Flask 以及 Django 都有用上。不過這東西一開始接觸的時候可能容易有暈炫感,沒關係,我們先從簡單的範例開始。

舉個例子,有些套件的函數可能會因為改版然後就會被棄用,但也不能突然間說砍就砍,通常都會預告一陣子之後再砍,所以我想來寫一個 deprecated() 函數來做這件事:

def deprecated(fn):
return fn

def hi():
print("Hello Kitty")

hi = deprecated(hi)
hi()

在這裡我把原本的 hi() 函數丟給 deprecated() 函數,可以做到這件事的原因就是上個章節提到的「函數是一等公民」所以可以被當參數傳來傳去。不過現在這個 deprecated() 函數沒什麼用,它就只是把傳進來的函數直接回傳回去,簡單過一下水而已。接下來我讓這個 deprecated() 函數再複雜一點點。同樣是回傳函數,但不是回傳原本帶進來的 fn,而是另外在裡面寫一個函數,然後把 fn 包在這個函數裡再回傳回來:

def deprecated(fn):
def wrapper():
print(f"Warning: {fn.__name__} function is deprecated.")
return fn()

return wrapper

def hi():
print("Hello Kitty")

hi = deprecated(hi)
hi()

這回 deprecated() 函數會回傳一個函數,而且把原本傳進去的 fn 給包在裡面,所以最後要執行 hi() 函數的時候,實際上就等於是在執行回傳回來的 wrapper() 函數,所以會先印出提醒該函數即將被棄用的訊息,再執行原本 hi() 函數該做的事情。也就是說經過 deprecated() 函數的「包裝」之後得到的函數 hi() 還是會做原本該做的事情,但我們可以在它前後加上額外的行為,當然也可以讓原本的函數完全不執行。這種在原本的函數外面再包一層函數的手法,就是函數裝飾器(Function Decorator)的概念。

@語法糖

hi = deprecated(hi)
hi()

雖然上面這面的寫法還算直觀,但是當要寫好幾次的時候,這樣的寫法可能就會變得有點囉嗦。Python 有幫函數裝飾器提供了更簡單的語法糖,讓我們更方便使用它,就是使用 @ 符號:

@deprecated
def hi():
print("Hello Kitty")

hi()

為什麼叫做「語法糖(Syntactic Sugar)」?也許程式原本的寫法邏輯有點複雜或囉嗦,但為了要讓大家簡單使用,設計者有時候會另外設計比較容易使用或理解的寫法,就像讓原本苦苦的藥外面包一層糖衣一樣,這樣的設計在電腦程式領域很常見。Python 身為一個被形容為一個簡潔容易上手的程式語言,自然可以想像它包的糖衣可一點都不少。

在定義 hi() 函數的前面加上一行 @deprecated 的寫法,等同於 hi = deprecated(hi) 的寫法,但這樣寫起來更簡單。如果你試著印出現在的 hi() 函數的話就會發現它已經不是原本的 hi() 函數了:

print(hi)
# 印出 <function deprecated.<locals>.wrapper at 0x10433ccc0>

它已經是個被 deprecated() 函數包裝過的函數了。

原本的參數?

剛剛寫的這個裝飾器只適用於 hi() 這種沒有參數的函數,如果 hi() 函數有帶參數的話,像這樣:

@deprecated
def hi(someone):
print(f"Hello {someone}")

hi("Kitty")

這會出現引數錯誤。為什麼?因為在我們的 wrapper() 函數裡是直接呼叫帶進來的 fn,這樣當然會出錯。問題是我怎麼知道原本的 fn 要帶幾個參數呢?我們在上個章節學到的 *args**kwargs 現在就可以派上用場了:

def deprecated(fn):
def wrapper(*args, **kwargs):
print(f"Warning: {fn.__name__} function is deprecated.")
return fn(*args, **kwargs)

return wrapper

雖然我們不知道原本帶進來的 fn 會需要幾個參數,但我用這種方式把所有的引數都接收進來,然後再把它們整包傳給原本的 fn,這樣就可以適用於任何帶引數的函數,就算沒有引數也行。這樣一來,我們的 deprecated() 函數就可以適用於任何函數了。

帶參數的裝飾器

如果我希望原本的 @deprecated 裝飾器還可以加上要棄用的理由,例如:

@deprecated(reason="我就是不想用了")
def hi(someone):
print(f"Hello {someone}")

這該怎麼寫?這會比剛才那個版本再複雜一點點。有看過電影「全面啟動(Inception)」嗎?一層一層的夢境,那種醒過來結果還在夢裡面,這種帶參數的裝飾器就有點像是這樣的感覺。簡單的說,這種可以帶參數的裝飾器,就是一個會回傳裝飾器的裝飾器。

def deprecated(reason=None):
def decorator(fn):
message = f"Warning: {fn.__name__} function is deprecated."
if reason:
message = f"{message}, reason: {reason}"

def wrapper(*args, **kwargs):
print(message)
return fn(*args, **kwargs)

return wrapper

return decorator

這有點複雜,但你可以這樣想,deprecated() 函數會回傳一個裝飾器 decorator(),這個裝飾器才是真正要把函數包進去的裝飾器。這樣一來,我們就可以在 @deprecated 後面加上參數了:

@deprecated(reason="我就是不想用了")
def hi(someone):
print(f"Hello {someone}")

hi("Kitty")

這會印出:

Warning: hi function is deprecated. reason: 我就是不想用了
Hello Kitty

像這種帶有參數的裝飾器,就是一個會回傳裝飾器的裝飾器,這在很多 Python 的套件或框架中都會看到,例如在後面章節介紹到 Flask 的 @app.route("/") 就是這樣的寫法。

不過這還有個小問題,就是現在的寫法在使用的時候就算不帶理由給它,也是得寫成 @deprecated(),這樣看起來有點醜,如果我希望它可以帶參數,也可以不帶參數,就得再調整一下寫法:

def deprecated(reason=None):
def decorator(fn):
message = f"Warning: {fn.__name__} function is deprecated."
if reason:
message = f"{message}, reason: {reason}"

def wrapper(*args, **kwargs):
print(message)
return fn(*args, **kwargs)

return wrapper

if callable(reason):
decorator = deprecated()
return decorator(reason)

return decorator

因為如果是不帶參數的裝飾器,等同是把底下準備裝飾的函數傳進來,所以這裡我利用了內建函數 callable() 來檢查傳進來的參數是不是可以呼叫的,如果不能呼叫表示就是一般的參數,就會走原本的流程,這樣一來這個 @deprecated 裝飾器就可以帶參數也可以不帶參數了。

遞迴

遞迴(Recursion)這個名字聽起來有點像迴圈的感覺,事實上寫起來也有一點點像迴圈,這是一種在函數中呼叫自己的有趣技巧,不過一不小心可能也會寫出錯誤的程式碼。先來看個錯誤的例子:

def call_me():
call_me()

call_me()

這裡我定義了一個 call_me() 函數,然後在這個函數裡呼叫自己,猜猜看,這樣寫執行之後會得到什麼結果?你可能會猜這樣會造成無窮迴圈,但其實並不會。在大部份的程式語言的設計,當執行一個函數的時候,你可以想像這個函數會被擺到一個叫做「呼叫堆疊(Call Stack)」的地方,當函數執行完畢之後就會從堆疊中移除。如果函數還沒執行完就繼續呼叫別的函數,被呼叫的函數就會疊在原本的函數上面;以上面的範例來說,call_me() 函數會呼叫 call_me() 函數,然後 call_me() 函數又繼續呼叫 call_me() 函數,這樣一直下去堆疊就會不斷的增加,直到堆疊滿出來而造成錯誤,這又稱 Stack Overflow,現在大家應該知道那個知名的程式問答網站的名字是怎麼來的了。在 Python 的堆疊極限值預設是 1,000 層,也就是當堆疊堆超過 1,000 層的時候會出現錯誤,所以剛才的程式碼執行之後會看到這個結果:

[Previous line repeated 996 more times]
RecursionError: maximum recursion depth exceeded

雖然可以透過內建 sys 模組的 setrecursionlimit() 函數來調整上限,但這並不會解決問題。所謂的「遞迴」就是自己呼叫自己,為了不造成堆疊滿出來的錯誤,就必須在適當的地方「結束」,這個結束或是停下來的地方通常稱之「基底情況(Base Case)」,它也就是遞迴的出口,如果沒有這個出口,就會不斷呼叫自己直到堆疊滿出來為止。講到遞迴,最經典的例子大概就是計算費氏數列(Fibonacci Sequence)了,根據維基百科的定義,費氏數列的第 n 項是由以下公式定義的:

  1. 第 0 項為 0
  2. 第 1 項為 1
  3. 第 n 項為第 n - 1 項加上第 n - 2 項

算出來的費氏數列應該是這樣:

0、1、1、2、3、5、8、13、21、34、55、89、144、233、377、610、987...

這在 Python 可以怎麼寫?有兩種方式,先看看使用迴圈的寫法:

def fibonacci(n):
a, b = 0, 1
for _ in range(n):
a, b = b, a + b

return a

這邏輯不算太複雜,就是用兩個變數 ab 來記錄費氏數列的前兩個數字,然後透過迴圈不斷的累加、交換,來更新這兩個變數的值,最後回傳的 a 就是第 n 項的值。

另一個方式是使用遞迴的方式:

def fibonacci(n):
if n == 0:
return 0

if n == 1:
return 1

return fibonacci(n - 1) + fibonacci(n - 2) # <-- 呼叫自己

在最後一行可以看到我把 n 的值變小一點後再傳入原來的函數,直到達到基底情況會得到 0 或 1,這樣就可以算出費氏數列的第 n 項了。有沒有發現上面這種遞迴的寫法,跟維基百科上費氏數列的定義相比,我們幾乎就是照著定義寫出來的而已。

不知道大家在看遞迴寫法的時候腦袋會不會打結,新手在看這個寫法的時候有時會想為什麼這樣不會無窮迴圈。遞迴的本質是「拆解問題」,把原本的大問題拆成中問題,再把中問題拆成小問題,再一層一層的把小問題解開最終得到答案。遞迴的寫法有些時候會比迴圈的寫法來的直覺,不過如果大家看不懂遞迴的寫法也不用太擔心,有一句話是這樣說的:

「遞迴只應天上有 凡人應當用迴圈」

我是凡人,所以我在寫程式的時候用迴圈的頻率比使用遞迴高很多。

效能?

我自己是比較喜歡遞迴的寫法,對我來說相對比較直覺可讀性也較好,但以這個費式數列的例子來說,數字小可能沒感覺,但數字大一點的時候,這個遞迴寫法的效能可能會比迴圈的寫法來的差,這是因為每次遞迴都會產生新的堆疊上,如果 n 比較大的時候,這個堆疊堆得很高導致效能差很多,甚至不小心還有堆到滿出來而出現錯誤。這個 n 也不用多大,你可以試試看在自己的電腦上把 n 代入 30,執行的時候可能會有一點卡頓感,如果改成 40 的時候就要跑好一陣子了。

所以只要是遞迴的效能就會比較差嗎?倒也不一定,不過為了避免戰線拉太長,這裡我留一個專有名詞給有興趣的讀者自行研究,叫「尾遞迴呼叫」(Tail Call)」,這是一個關於遞迴效能的調整技巧,如果遞迴的方式是尾遞迴方式處理的話,效能就會跟迴圈的方式差不多,這是因為尾遞迴的特性,讓程式在呼叫自己的時候不會有新的函數呼叫被放到堆疊中,這樣就不會有一直往上堆東西的問題。嗯...不過因為 Python 並沒有也不打算支援尾遞迴呼叫的最佳化(Tail Call Optimization)的設計,所以這個技巧在 Python 中並不適用,感覺講了有點白講,大家可當個科普知識看看就好。

網站連結

產生器

在前面介紹 Tuple 的時候曾經有提過產生器表達式會建立一個產生器物件,這裡再來看看產生器的概念。一般我們呼叫函數時,通常會使用 return 關鍵字回傳執行的結果,就算沒寫 return 也會得到一個 None,Python 有個滿特別的設計,可以讓程式執行到一半的時候先暫停一下,待會再執行,這個手法叫做產生器(Generator)。產生器(Generator)可以回傳一個產生器物件,產生器物件可以讓我們一次取出一個值,而不是一次回傳所有值。

產生器有幾種寫法,其中一種是在函數裡使用 yield 關鍵字:

def even_numbers(n):
i = 1
while i <= n:
if i % 2 == 0:
yield i # <- 在這裡
i += 1

yield 關鍵字會讓函數回傳一個產生器(Generator)物件,要注意的是這不需要加上 return 關鍵字,如果硬加了 return 反而會出錯。這也可以理解,因為執行到 yield 的時候函數還不算結束,比較像是影片看到突然尿急,先按了暫停鍵,等待會上完廁所再按下播放鍵繼續播放。我們來試用看看:

>>> numbers = even_numbers(10)

# 可以看的出來是個產生器物件
>>> numbers
<generator object even_numbers at 0x10021b340>

執行 even_numbers() 函數所得到的這顆產生器物件,我們可對它使用內建函數 next() 來拿下一個值:

# 使用 next() 函數
>>> next(numbers)
2
>>> next(numbers)
4
>>> next(numbers)
6
>>> next(numbers)
8
>>> next(numbers)
10
>>> next(numbers)
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
StopIteration

next() 函數一次取出一個值,當沒有東西可以拿的時候就會得到 StopIteration 錯誤。

產生器物件同時也是一種可迭代物件,所以我們可以用 for 迴圈來取得產生器的值,例如:

>>> for n in even_numbers(10):
... print(n)
...
2
4
6
8
10

或是放在串列推導式裡:

>>> [n for n in even_numbers(10)]
[2, 4, 6, 8, 10]

這裡不用擔心會出現 StopIteration 錯誤,因為在 for 迴圈或推導式的時候 Python 會自動幫我們搞定這個錯誤,當沒有值可以拿的時候就會自動停止。

如果看懂了這個概念,我們可以用產生器來實作剛才的費氏數列:

def fibonacci(n):
i = 0
a, b = 0, 1

while i <= n:
yield a
a, b = b, a + b
i += 1

邏輯是一樣的,只是把 return 換成 yield,這樣就可以一次取出一個值,而不是一次回傳所有值:

>>> [n for n in fibonacci(10)]
[0, 1, 1, 2, 3, 5, 8, 13, 21, 34, 55]

產生器的特性在處理大量資料的時候挺好用的,它不需要一口氣就把所有資料展開來放在記憶體中,而是以擠牙膏的方式一個一個慢慢拿,可避免迴圈都還沒開始跑起來就先吃掉一大塊記憶體的問題。

偏函數與柯里化

接下來這個小節的內容可能在數學或計算機科學領域中還算常見,但在日常的程式開發中可能不會用到,特別如果只是拿 Python 來寫寫爬蟲或網站的話應該更沒機會用到,但多學一些跟函數有關的概念也不是壞事。

在 Python 如果我們定義了一個函數,像這樣:

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

這裡說好了要帶四個參數,在沒有設定參數預設值的情況下,呼叫這個函數就是要給四個值,沒得商量,多了少了都會出錯。不過透過一些特別的手法,我們可以先給部份的參數,剩下的參數可以晚點再給,有點像買東西可以先付訂金,等之後再付尾款一樣的概念,這個手法稱之「偏函數(Partial Function)」。這可以用我們前面學過的閉包的手法來實現。

def partial(fn, *args, **kwargs):
def wrapper(*wrapper_args, **wrapper_kwargs):
nonlocal args, kwargs
args += wrapper_args
kwargs = kwargs | wrapper_kwargs

return fn(*args, **kwargs)

return wrapper

這乍看之下有點複雜,但其實這裡的 argskwargs 就是自由變數,等 wrapper() 函數被呼叫之後再把它們跟新的參數合併一下再傳進去。這樣一來我們就可以這樣用:

>>> add_one = partial(add, 1)
>>> add_one
<function partial.<locals>.wrapper at 0x10294b420>
>>> add_one(2, 3, 4)
10

把原來的 add 函數用 partial 函數產生新的函數,在這個時候我先給一個參數 1,後面要執行的時候再把欠的三個參數傳進去就能正常執行。如果覺得我們自己寫的這個不好用,Python 內建的 functools 模組裡面有個 partial 函數可以用,用起來更簡單:

>>> from functools import partial
>>> add_night = partial(add, 9)
>>> add_night(5, 2, 7)
23

functools 模組裡面還有很多可以做一些神奇事情的有趣函數,有興趣的話可以翻一下文件。另一個跟偏函數有點類似的概念是「柯里化函數(Currying Function)」,同樣也可以不用一次給剛所有的參數。偏函數跟柯里化函數有點像,但在設計上又不太一樣,偏函數是先付訂金再付尾款,而且尾款要一次付清;相對的柯里化函數比較像是分期付款,而且規定一次只能付一期的款項,等到最後一期的款項結清後就會得到函數執行的結果。我來寫個裝飾器來實現柯里化函數:

from inspect import signature
from functools import partial

def curry(fn):
def wrap_fn(arg):
if len(signature(fn).parameters) == 1:
return fn(arg)
return curry(partial(fn, arg))

return wrap_fn


@curry
def add(a, b, c, d):
return a + b + c + d

稍微解釋一下,在 curry() 函數裡,我先檢查函數的參數個數,如果參數個數只剩一個就表示這是分期付款的最後一期,就直接執行函數,否則就再呼叫自己繼續拆解,這裡我使用了遞迴的寫法並且搭配 functools 模組的 partial 函數。最後把 curry 裝飾器掛到 add 函數後,就可以這樣用:

>>> aa = add(1)
>>> bb = aa(2)
>>> cc = bb(3)
>>> dd = cc(4)
>>> dd
10

一次只傳一個值給它都會得到一個新的偏函數,等到最後一個值傳進去後就會執行函數,或是直接一口氣傳完所有的值也可以:

>>> add(1)(2)(3)(4)
10

覺得有趣嗎?這種偏函數或柯里化的技巧在日常的程式開發中大概真的用不上,就當做科普知識看看也不錯。

工商服務

想學 Python 嗎?我教你啊 :)

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