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 物件,例如 rangeenumerateziptype 等,它們其實不是 function 的實例,rangeenumerateziptype 是類別,是 type 的實例:

>>> range 
<class 'range'>
>>> zip   
<class 'zip'>
>>> type
<class 'type'>
>>> 

類別是 type 的實例,type 定義了 __call__ 方法,因此類別可以使用 range(10) 這類方式呼叫。

知道類別是個 callable 物件,rangeziptype 等其實是類別,並不是要你去區分 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 物件,作為 sortedkey 引數,就可以簡單地完成基於與 pt 點的距離進行排序。

Point 這種記錄資料用的類別,Python 3.7 新增了 dataclasses.dataclass 裝飾器,可以用來簡化任務。

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