定義函式
April 19, 2022當開始為了重用某個流程,而複製、貼上、修改變數名稱時,或者發現到兩個或多個程式片段極為類似,只有當中幾個計算用到的數值或變數不同時,就可以考慮將那些片段定義函式。例如發現到程式中…
# 其他程式片段...
max1 = a if a > b else b
# 其他程式片段...
max2 = x if x > y else y
# 其他程式片段...
def 函式
這時可以定義函式來封裝程式片段,將流程中引用不同數值或變數的部份設計為參數,例如:
def max(num1, num2):
return num1 if num1 > num2 else num2
定義函式時要使用 def
關鍵字,max
是函式名稱,num1
、num2
是參數名稱,如果要傳回值可以使用 return
,如果函式執行完畢但沒有使用 return
傳回值,或者使用了 return
結束函式但沒有指定傳回值,預設會傳回 None
。
這麼一來,原先的程式片段就可以修改為:
max1 = max(a, b)
# 其他程式片段...
max2 = max(x, y)
# 其他程式片段...
函式是一種抽象,對流程的抽象,在定義了 max
函式之後,客戶端對求最大值的流程,被抽象為 max(x, y)
這樣的函式呼叫,求值流程實作被隱藏了起來。
在 Python 中,函式中還可以定義函式,稱為區域函式(Local function),可以使用區域函式將某函式中的演算,組織為更小單元,例如,在選擇排序的實作時,每次會從未排序部份,選擇一個最小值放到已排序部份之後,在底下的範例中,尋找最小值的索引時,就以區域函式的方式實作:
import sys
def sele_sort(number):
# 找出未排序中最小值
def min_index(left, right):
if right == len(number):
return left
elif number[right] < number[left]:
return min_index(right, right + 1)
else:
return min_index(left, right + 1)
for i in range(len(number)):
selected = min_index(i, i + 1)
if i != selected:
number[i], number[selected] = number[selected], number[i]
number = [int(arg) for arg in sys.argv[1:]]
sele_sort(number)
print(number)
可以看到,區域函式的好處之一,就是能直接存取外部函式之參數,或者先前宣告之區域變數,如此可減少呼叫函式時引數的傳遞。
在 Python 中,語法上不直接支援函式重載(Overload),也就是在同一個名稱空間中,不能有相同的函式名稱。如果定義了兩個函式具有相同名稱,但擁有不同參數個數,之後定義的函式會覆蓋先前定義的函式。例如:
>>> def sum(a, b):
... return a + b
...
>>> def sum(a, b, c):
... return a + b + c
...
>>> sum(1, 2)
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: sum() missing 1 required positional argument: 'c'
>>>
參數預設值
雖然不支援函式重載的實作,不過 Python 可以使用預設引數,有限度地模仿函式重載。例如:
def account(name, number, balance = 100):
return {'name' : name, 'number' : number, 'balance' : balance}
# 顯示 {'name': 'Justin', 'balance': 100, 'number': '123-4567'}
print(account('Justin', '123-4567'))
# 顯示 {'name': 'Monica', 'balance': 1000, 'number': '765-4321'}
print(account('Monica', '765-4321', 1000))
使用參數預設值時,必須小心指定了可變動物件時的一個陷阱,Python 在執行到 def
時,就會依定義建立了相關的資源。來看看下面會有什麼問題?
>>> def prepend(elem, lt = []):
... lt.insert(0, elem)
... return lt
...
>>> prepend(10)
[10]
>>> prepend(10, [20, 30, 40])
[10, 20, 30, 40]
>>> prepend(20)
[20, 10]
>>>
在上例中,lt
預設值設定為 []
,由於 def
是個陳述,執行到 def
的函式定義時,就建立了 []
,而這個 list
物件會一直存在,如果沒有指定 lt
時,使用的就會一直是一開始指定的 list
物件,也因此,隨著每次呼叫都不指定 lt
的值,你前置的目標 list
,都是同一個 list
。
想要避免這樣的問題,可以將 prepend
的 lt
參數預設值設為 None
,並在函式中指定真正的預設值。例如:
>>> def prepend(elem, lt = None):
... rlt = lt if lt else []
... rlt.insert(0, elem)
... return rlt
...
>>> prepend(10)
[10]
>>> prepend(10, [20, 30, 40])
[10, 20, 30, 40]
>>> prepend(20)
[20]
>>>
在上面的 prepend
函式中,在 lt
為 None
時,使用 []
建立新的 list
實例,這樣就不會有之前的問題。
預設引數在執行到 def
的函式定義時就固定了,因此不只是指定 []
為預設引數會有問題,如果你希望每次函式呼叫時,都要新預設值的需求,就別使用預設引數。例如:
>>> from datetime import datetime
>>> def log(msg, time = datetime.now()):
... pring(time, msg)
... print(time, msg)
...
>>> log('msg1')
2022-04-21 11:15:35.536105 msg1
>>> log('msg2')
2022-04-21 11:15:35.536105 msg2
>>>
在上例中可以看到,time
的值始終是第一次呼叫時產生的值,如下撰寫才能避免這個問題:
>>> def log(msg, time = None):
... t = time if time else datetime.now()
... print(t, msg)
...
>>> log('msg1')
2022-04-21 11:18:36.814947 msg1
>>> log('msg2')
2022-04-21 11:18:39.496919 msg2
>>>
關鍵字參數
事實上,在呼叫函式時,並不一定要依參數宣告順序來傳入引數,而可以指定參數名稱來設定其引數值,稱為關鍵字參數。例如:
def account(name, number, balance):
return {'name' : name, 'number' : number, 'balance' : balance}
# 顯示 {'name': 'Monica', 'balance': 1000, 'number': '765-4321'}
print(account(balance = 1000, name = 'Monica', number = '765-4321'))
引數拆解
如果有個函式擁有固定參數,而你有個序列,像是 list
、tuple
,只要在傳入時加上 *
,則 list
或 tuple
中各元素就會自動拆解給各參數。例如:
def account(name, number, balance):
return {'name' : name, 'number' : number, 'balance' : balance}
# 顯示 {'name': 'Justin', 'balance': 1000, 'number': '123-4567'}
print(account(*('Justin', '123-4567', 1000)))
像 sum
這種加總數字的函式,事先無法預期要傳入的引數個數,可以在定義函式的參數時使用 *
,表示該參數接受不定長度引數。例如:
def sum(*numbers):
total = 0
for number in numbers:
total += number
return total
print(sum(1, 2)) # 顯示 3
print(sum(1, 2, 3)) # 顯示 6
print(sum(1, 2, 3, 4)) # 顯示 10
傳入函式的引數,會被收集在一個 tuple
中,再設定給 numbers
參數,這適用於參數個數不固定,而且會循序迭代處理參數的場合。
如果有個 dict
,打算依鍵名稱,指定給對應的參數名稱,可以在 dict
前加上 **
,這樣 dict
中各對鍵值,就會自動拆解給各參數。例如:
def account(name, number, balance):
return {'name' : name, 'number' : number, 'balance' : balance}
params = {'name' : 'Justin', 'number' : '123-4567', 'balance' : 1000}
# 顯示 {'name': 'Justin', 'balance': 1000, 'number': '123-4567'}
print(account(**params))
如果參數個數越來越多,而且每個參數名稱皆有其意義,像是 def ajax(url, method, contents, datatype, accept, headers, username, password)
,這樣的函式定義不但醜陋,呼叫時也很麻煩,單純只搭配關鍵字參數或預設引數,也不見得能改善多少,將來若因需求而必須增減參數,也會影響函式的呼叫者,因為改變參數個數,就是在改變函式簽署(Signature),也就是函式的外觀,這勢必得逐一修改影響到的程式,造成未來程式擴充時的麻煩。
這個時候,可以試著使用 **
來定義參數,讓指定的關鍵字參數收集為一個 dict
。例如:
def ajax(url, **user_settings):
settings = {
'method' : user_settings.get('method', 'GET'),
'contents' : user_settings.get('contents', ''),
'datatype' : user_settings.get('datatype', 'text/plain'),
# 其他設定 ...
}
print('請求 {}'.format(url))
print('設定 {}'.format(settings))
ajax('https://openhome.cc', method = 'POST', contents = 'book=python')
my_settings = {'method' : 'POST', 'contents' : 'book=python'}
ajax('https://openhome.cc', **my_settings)
像這樣定義函式就顯得優雅許多,呼叫函式時可使用關鍵字參數,在函式內部也可實現預設引數的效果,這樣的設計在未來程式擴充時比較有利,因為若需增減參數,只需修改函式的內部實作,不用變動函式簽署,函式的呼叫者不會受到影響。
在上面的函式定義中是假設,url
為每次呼叫時必須指定的參數,而其他參數可由使用者自行決定是否指定,如果已經有個 dict
想作為引數,也可以 ajax('https://openhome.cc', **my_settings)
這樣使用 **
進行拆解。
可以在一個函式中,同時使用 *
與 **
設計參數,如果想設計一個函式接受任意引數,就可以加以運用。例如:
>>> def some(*arg1, **arg2):
... print(arg1)
... print(arg2)
...
>>> some(1, 2, 3)
(1, 2, 3)
{}
>>> some(a = 1, b = 22, c = 3)
()
{'a': 1, 'c': 3, 'b': 22}
>>> some(2, a = 1, b = 22, c = 3)
(2,)
{'a': 1, 'c': 3, 'b': 22}
>>>
限定位置參數、關鍵字參數
方才的 ajax
函式設計,url
為每次呼叫時必須指定的參數,也許你想要限定呼叫函式時,網址必須作為第一個引數,而且不得使用關鍵字參數的形式來指定,然而目前的 ajax
函式,無法達到這個需求,例如 ajax(method = 'POST', url = 'https://openhome.cc')
這樣的呼叫方式,也是可以的。
Python 3.8 新增了 Positional-Only Parameters 特性 ,在定義參數列時可以使用 /
標示,/
前的參數必須依定義的位置呼叫,而且不能採用關鍵字參數的形式來指定,例如:
def ajax(url, /, **user_settings):
settings = {
'method' : user_settings.get('method', 'GET'),
'contents' : user_settings.get('contents', ''),
'datatype' : user_settings.get('datatype', 'text/plain'),
# 其他設定 ...
}
print('請求 {}'.format(url))
print('設定 {}'.format(settings))
若如上定義函式,可以使用 ajax('https://openhome.cc', method = 'POST')
呼叫,但是不能用 ajax(url = 'https://openhome.cc', method = 'POST')
或者 ajax(method = 'POST', url = 'https://openhome.cc')
等方式呼叫。
因為 /
前的參數,不能採用關鍵字參數的形式來指定,對維護會有些幫助,因為你或許會有些參數名稱,不想成為API的一部份,就可以定義在 /
之前。
相對地,某些參數的值,也許想限定為只能以關鍵字參數形式指定,這時可以在參數列使用 *
來標示,例如:
>>> def foo(a, b, *, c, d):
... print(a, b, c, d)
...
>>> foo(10, 20, c = 30, d = 40)
10 20 30 40
>>> foo(10, 20, 30, 40)
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: foo() takes 2 positional arguments but 4 were given
>>>
在以上的範例可以看到,c
與 d
必須使用關鍵字參數形式指定,否則就會引發錯誤。
在定義參數列時,/
與 *
可以併並存,/
之後 *
之前的參數,可以使用位置參數或關鍵字參數形式指定,例如:
def foo(a, b, /, c, d, *, e, f):
pass
若如上定義,a
、b
只能作為位置參數,c
、d
可以作為位置參數或關鍵字參數,e
、f
只能作為關鍵字參數。