callable 物件
April 26, 2022在 Python 中,函式是一級物件,可以作為值傳遞給變數、參數,或作為函式、方法的傳回值,這是個極具威力的特性,Python 標準程式庫或第三方程式庫,有許多 API 也都接受函式或傳回函式,或者是接受函式且傳回函式的高階函式,對吧?
其實這些 API,有可能在定義時的目的,是定位可以接受 callable 物件或傳回 callable 物件,或者是接受 callable 物件且傳回 callable 物件的高階 callable 物件。
函式、方法、類別?
作為一個函式,它是可以呼叫的,這不成問題,Python 有個 callable 函式,可以判斷指定的引數是否可呼叫。例如:
>>> def foo():
... print('foo')
...
>>> callable(foo)
True
在〈屬性與方法〉中談過,可以透過類別的實例取得綁定方法,綁定方法是 method 的實例,行為上可以像函式進行呼叫,傳入 callable 函式的結果會是?
>>> class Some:
... def action(self):
... print(self, 'action')
...
>>> s = Some()
>>> type(s.action)
<class 'method'>
>>> callable(s.action)
True
>>>
你有想過為什麼 Python 中,要建立類別實例,不需要使用 new 嗎?當然,你可以說語法就是如此規範,不過,其實類別在行為上,就是可以像函式進行呼叫:
>>> callable(Some)
True
>>>
__call__ 方法
作為一門動態定型語言,實現鴨子定型是很簡單的事情,既然函式、方法、類別在行為上,可以像函式進行呼叫,表示它們具有相同的行為,具體而言,它們都實現了 __call__ 方法。
若類別定義有 __call__ 方法 ,該類別的實例,就可以像函式一樣地呼叫:
>>> class Some:
... def __call__(self, *args):
... print(args)
...
>>> s = Some()
>>> s(1)
(1,)
>>> s(1, 2)
(1, 2)
>>> s(1, 2, 3)
(1, 2, 3)
>>>
只要實作了 __call__ 方法,就是 callable 物件,這使得函式與各種類型的實例,之間的界線變得模糊,在之前的文件中,你曾經以函式的形式使用的一些 API,其實就是 callable 物件,例如 range、enumerate、zip、type 等,它們其實不是 function 的實例,range、enumerate、zip、type 是類別,是 type 的實例:
>>> range
<class 'range'>
>>> zip
<class 'zip'>
>>> type
<class 'type'>
>>>
類別是 type 的實例,type 定義了 __call__ 方法,因此類別可以使用 range(10) 這類方式呼叫。
知道類別是個 callable 物件,range、zip、type 等其實是類別,並不是要你去區分 len 是函式,range 是類別這種事,而是要你不用去區分 len 是函式,range 是類別這種事,Python 可以定義 callable 物件,本意就是要讓函式與行為上可呼叫的物件之間,界線變得模糊。
因此如果有人說,range 函式,不用去糾正他,Python 就是要你搞不清楚,反正可以呼叫就是了!
有狀態的函式?
有時候會需要一個函式帶有狀態,例如,想要一個 add 函式:
>>> def add(n):
... return lambda a: n + a
...
>>> add10 = add(10)
>>> [add10(i) for i in range(5)]
[10, 11, 12, 13, 14]
>>>
add 函式傳回另一個函式,該函式帶著 n 的值,這形式了 closure,呼叫這個傳回的函式時,都會與當時傳入的 n 進行相加。
有時候攜帶的狀態需要變動,例如,你可能需要一個具有累計加總能力的函式:
>>> def running_total(base):
... total = 0
... def _running_total(a):
... nonlocal total
... total = base + total + a
... return total
... return _running_total
...
>>> partial_sum_from_10 = running_total(10)
>>> [partial_sum_from_10(i) for i in range(5)]
[10, 21, 33, 46, 60]
>>>
這種包含著內部可變動狀態的函式,也常用來模擬一些私有性,例如 JavaScript,可藉由傳回一個形成閉包的函式,來模擬物件的私有屬性。
當事情變得更為複雜之時,例如,你也許需要能直接存取內部狀態,例如運行過程取得 total 或者重置 base,傳回函式的作法不適用之時,可以實作 callable 物件。例如:
>>> class running_total:
... def __init__(self, base):
... self.base = base
... self.total = 0
... def __call__(self, a):
... self.total = self.base + self.total + a
... return self.total
...
>>> partial_sum = running_total(10)
>>> [partial_sum(i) for i in range(5)]
[10, 21, 33, 46, 60]
>>> partial_sum.total
60
>>> partial_sum.base
10
>>>
接受/傳回 callable
Python 可以實作裝飾器(decorator),例如〈屬性與方法〉看過的 @property,就是裝飾器,裝飾器就是 callable 物件,簡單的裝飾器可以使用函式實作,複雜的裝飾器可以使用類別實作,property 就是使用類別定義的裝飾器,而且它是個接受 callable、傳回 callable 的裝飾器。
裝飾器的使用,後續的文件會再介紹,簡單來說,在 Python 中,有些 API 可以接受函式、傳回函式,或者接受函式並傳回函式,實際上,它們可能只是接受 callable 物件、傳回 callable 物件。
有些 API 可能真的會檢查傳入的是不是函式,functools 模組有個 wraps 函式,可以將 callable 物件偽裝為函式。
例如,sorted 函式的 key 可以接受的不只是函式,只要是 callable 物件就可以,對於一組點,你也許想按照它與某個點的距離來排序:
>>> class Point:
... def __init__(self, x, y):
... self.x = x
... self.y = y
... def dist(self, p):
... return ((self.x - p.x) ** 2 + (self.y - p.y) ** 2) ** 0.5
... def __repr__(self):
... return f'Point({self.x}, {self.y})'
...
>>> points = [Point(10, 20), Point(2, 3), Point(50, 98), Point(2, 33)]
>>> pt = Point(3, 2)
>>> sorted(points, key = pt.dist)
[Point(2, 3), Point(10, 20), Point(2, 33), Point(50, 98)]
>>>
pt.dist 是綁定方法,方才談到,綁定方法是 method 的實例,行為上可以像函式進行呼叫,也就是 callable 物件,作為 sorted 的 key 引數,就可以簡單地完成基於與 pt 點的距離進行排序。
像 Point 這種記錄資料用的類別,Python 3.7 新增了 dataclasses.dataclass 裝飾器,可以用來簡化任務。


