定義函式

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 是函式名稱,num1num2 是參數名稱,如果要傳回值可以使用 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

想要避免這樣的問題,可以將 prependlt 參數預設值設為 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 函式中,在 ltNone 時,使用 [] 建立新的 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'))

引數拆解

如果有個函式擁有固定參數,而你有個序列,像是 listtuple,只要在傳入時加上 *,則 listtuple 中各元素就會自動拆解給各參數。例如:

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
>>>

在以上的範例可以看到,cd 必須使用關鍵字參數形式指定,否則就會引發錯誤。

在定義參數列時,/* 可以併並存,/ 之後 * 之前的參數,可以使用位置參數或關鍵字參數形式指定,例如:

def foo(a, b, /, c, d, *, e, f):
    pass

若如上定義,ab 只能作為位置參數,cd 可以作為位置參數或關鍵字參數,ef 只能作為關鍵字參數。

分享到 LinkedIn 分享到 Facebook 分享到 Twitter