使用 def
語句定義函數(shù)是所有程序的基礎(chǔ)。 本章的目標(biāo)是講解一些更加高級(jí)和不常見的函數(shù)定義與使用模式。 涉及到的內(nèi)容包括默認(rèn)參數(shù)、任意數(shù)量參數(shù)、強(qiáng)制關(guān)鍵字參數(shù)、注解和閉包。 另外,一些高級(jí)的控制流和利用回調(diào)函數(shù)傳遞數(shù)據(jù)的技術(shù)在這里也會(huì)講解到。
你想構(gòu)造一個(gè)可接受任意數(shù)量參數(shù)的函數(shù)。
為了能讓一個(gè)函數(shù)接受任意數(shù)量的位置參數(shù),可以使用一個(gè)*參數(shù)。例如:
def avg(first, *rest):
return (first + sum(rest)) / (1 + len(rest))
# Sample use
avg(1, 2) # 1.5
avg(1, 2, 3, 4) # 2.5
在這個(gè)例子中,rest 是由所有其他位置參數(shù)組成的元組。然后我們在代碼中把它當(dāng)成了一個(gè)序列來進(jìn)行后續(xù)的計(jì)算。
為了接受任意數(shù)量的關(guān)鍵字參數(shù),使用一個(gè)以**開頭的參數(shù)。比如:
import html
def make_element(name, value, **attrs):
keyvals = [' %s="%s"' % item for item in attrs.items()]
attr_str = ''.join(keyvals)
element = '<{name}{attrs}>{value}</{name}>'.format(
name=name,
attrs=attr_str,
value=html.escape(value))
return element
# Example
# Creates '<item size="large" quantity="6">Albatross</item>'
make_element('item', 'Albatross', size='large', quantity=6)
# Creates '<p><spam></p>'
make_element('p', '<spam>')
在這里,attrs 是一個(gè)包含所有被傳入進(jìn)來的關(guān)鍵字參數(shù)的字典。
如果你還希望某個(gè)函數(shù)能同時(shí)接受任意數(shù)量的位置參數(shù)和關(guān)鍵字參數(shù),可以同時(shí)使用*和**。比如:
def anyargs(*args, **kwargs):
print(args) # A tuple
print(kwargs) # A dict
使用這個(gè)函數(shù)時(shí),所有位置參數(shù)會(huì)被放到 args 元組中,所有關(guān)鍵字參數(shù)會(huì)被放到字典 kwargs 中。
一個(gè)*參數(shù)只能出現(xiàn)在函數(shù)定義中最后一個(gè)位置參數(shù)后面,而 *參數(shù)只能出現(xiàn)在最后一個(gè)參數(shù)。 有一點(diǎn)要注意的是,在參數(shù)后面仍然可以定義其他參數(shù)。
def a(x, *args, y):
pass
def b(x, *args, y, **kwargs):
pass
這種參數(shù)就是我們所說的強(qiáng)制關(guān)鍵字參數(shù),在后面7.2小節(jié)還會(huì)詳細(xì)講解到。
你希望函數(shù)的某些參數(shù)強(qiáng)制使用關(guān)鍵字參數(shù)傳遞
將強(qiáng)制關(guān)鍵字參數(shù)放到某個(gè)參數(shù)或者當(dāng)個(gè)后面就能達(dá)到這種效果。比如:
def recv(maxsize, *, block):
'Receives a message'
pass
recv(1024, True) # TypeError
recv(1024, block=True) # Ok
利用這種技術(shù),我們還能在接受任意多個(gè)位置參數(shù)的函數(shù)中指定關(guān)鍵字參數(shù)。比如:
def mininum(*values, clip=None):
m = min(values)
if clip is not None:
m = clip if clip > m else m
return m
minimum(1, 5, 2, -5, 10) # Returns -5
minimum(1, 5, 2, -5, 10, clip=0) # Returns 0
很多情況下,使用強(qiáng)制關(guān)鍵字參數(shù)會(huì)比使用位置參數(shù)表意更加清晰,程序也更加具有可讀性。 例如,考慮下如下一個(gè)函數(shù)調(diào)用:
msg = recv(1024, False)
如果調(diào)用者對(duì) recv 函數(shù)并不是很熟悉,那他肯定不明白那個(gè) False 參數(shù)到底來干嘛用的。 但是,如果代碼變成下面這樣子的話就清楚多了:
msg = recv(1024, block=False)
另外,使用強(qiáng)制關(guān)鍵字參數(shù)也會(huì)比使用 **kwargs 參數(shù)更好,因?yàn)樵谑褂煤瘮?shù) help 的時(shí)候輸出也會(huì)更容易理解:
>>> help(recv)
Help on function recv in module __main__:
recv(maxsize, *, block)
Receives a message
強(qiáng)制關(guān)鍵字參數(shù)在一些更高級(jí)場合同樣也很有用。 例如,它們可以被用來在使用 *args 和 **kwargs 參數(shù)作為輸入的函數(shù)中插入?yún)?shù),9.11小節(jié)有一個(gè)這樣的例子。
你寫好了一個(gè)函數(shù),然后想為這個(gè)函數(shù)的參數(shù)增加一些額外的信息,這樣的話其他使用者就能清楚的知道這個(gè)函數(shù)應(yīng)該怎么使用。
使用函數(shù)參數(shù)注解是一個(gè)很好的辦法,它能提示程序員應(yīng)該怎樣正確使用這個(gè)函數(shù)。 例如,下面有一個(gè)被注解了的函數(shù):
def add(x:int, y:int) -> int:
return x + y
python 解釋器不會(huì)對(duì)這些注解添加任何的語義。它們不會(huì)被類型檢查,運(yùn)行時(shí)跟沒有加注解之前的效果也沒有任何差距。 然而,對(duì)于那些閱讀源碼的人來講就很有幫助啦。第三方工具和框架可能會(huì)對(duì)這些注解添加語義。同時(shí)它們也會(huì)出現(xiàn)在文檔中。
>>> help(add)
Help on function add in module __main__:
add(x: int, y: int) -> int
>>>
盡管你可以使用任意類型的對(duì)象給函數(shù)添加注解(例如數(shù)字,字符串,對(duì)象實(shí)例等等),不過通常來講使用類或著字符串會(huì)比較好點(diǎn)。
函數(shù)注解只存儲(chǔ)在函數(shù)的 __annotations__
屬性中。例如:
>>> add.__annotations__
{'y': <class 'int'>, 'return': <class 'int'>, 'x': <class 'int'>}
盡管注解的使用方法可能有很多種,但是它們的主要用途還是文檔。 因?yàn)?python 并沒有類型聲明,通常來講僅僅通過閱讀源碼很難知道應(yīng)該傳遞什么樣的參數(shù)給這個(gè)函數(shù)。 這時(shí)候使用注解就能給程序員更多的提示,讓他們可以爭取的使用函數(shù)。
參考9.20小節(jié)的一個(gè)更加高級(jí)的例子,演示了如何利用注解來實(shí)現(xiàn)多分派(比如重載函數(shù))。
你希望構(gòu)造一個(gè)可以返回多個(gè)值的函數(shù)
為了能返回多個(gè)值,函數(shù)直接 return 一個(gè)元組就行了。例如:
>>> def myfun():
... return 1, 2, 3
...
>>> a, b, c = myfun()
>>> a
1
>>> b
2
>>> c
3
盡管 myfun() 看上去返回了多個(gè)值,實(shí)際上是先創(chuàng)建了一個(gè)元組然后返回的。 這個(gè)語法看上去比較奇怪,實(shí)際上我們使用的是逗號(hào)來生成一個(gè)元組,而不是用括號(hào)。比如下面的:
>>> a = (1, 2) # With parentheses
>>> a
(1, 2)
>>> b = 1, 2 # Without parentheses
>>> b
(1, 2)
>>>
當(dāng)我們調(diào)用返回一個(gè)元組的函數(shù)的時(shí)候 ,通常我們會(huì)將結(jié)果賦值給多個(gè)變量,就像上面的那樣。 其實(shí)這就是1.1小節(jié)中我們所說的元組解包。返回結(jié)果也可以賦值給單個(gè)變量, 這時(shí)候這個(gè)變量值就是函數(shù)返回的那個(gè)元組本身了:
>>> x = myfun()
>>> x
(1, 2, 3)
>>>
你想定義一個(gè)函數(shù)或者方法,它的一個(gè)或多個(gè)參數(shù)是可選的并且有一個(gè)默認(rèn)值。
定義一個(gè)有可選參數(shù)的函數(shù)是非常簡單的,直接在函數(shù)定義中給參數(shù)指定一個(gè)默認(rèn)值,并放到參數(shù)列表最后就行了。例如:
def spam(a, b=42):
print(a, b)
spam(1) # Ok. a=1, b=42
spam(1, 2) # Ok. a=1, b=2
如果默認(rèn)參數(shù)是一個(gè)可修改的容器比如一個(gè)列表、集合或者字典,可以使用 None 作為默認(rèn)值,就像下面這樣:
# Using a list as a default value
def spam(a, b=None):
if b is None:
b = []
...
如果你并不想提供一個(gè)默認(rèn)值,而是想僅僅測試下某個(gè)默認(rèn)參數(shù)是不是有傳遞進(jìn)來,可以像下面這樣寫:
_no_value = object()
def spam(a, b=_no_value):
if b is _no_value:
print('No b value supplied')
...
我們測試下這個(gè)函數(shù):
>>> spam(1)
No b value supplied
>>> spam(1, 2) # b = 2
>>> spam(1, None) # b = None
>>>
仔細(xì)觀察可以發(fā)現(xiàn)到傳遞一個(gè) None 值和不傳值兩種情況是有差別的。
定義帶默認(rèn)值參數(shù)的函數(shù)是很簡單的,但絕不僅僅只是這個(gè),還有一些東西在這里也深入討論下。
首先,默認(rèn)參數(shù)的值僅僅在函數(shù)定義的時(shí)候賦值一次。試著運(yùn)行下面這個(gè)例子:
>>> x = 42
>>> def spam(a, b=x):
... print(a, b)
...
>>> spam(1)
1 42
>>> x = 23 # Has no effect
>>> spam(1)
1 42
>>>
注意到當(dāng)我們改變 x 的值的時(shí)候?qū)δJ(rèn)參數(shù)值并沒有影響,這是因?yàn)樵诤瘮?shù)定義的時(shí)候就已經(jīng)確定了它的默認(rèn)值了。
其次,默認(rèn)參數(shù)的值應(yīng)該是不可變的對(duì)象,比如 None、True、False、數(shù)字或字符串。 特別的,千萬不要像下面這樣寫代碼:
def spam(a, b=[]): # NO!
...
如果你這么做了,當(dāng)默認(rèn)值在其他地方被修改后你將會(huì)遇到各種麻煩。這些修改會(huì)影響到下次調(diào)用這個(gè)函數(shù)時(shí)的默認(rèn)值。比如:
>>> def spam(a, b=[]):
... print(b)
... return b
...
>>> x = spam(1)
>>> x
[]
>>> x.append(99)
>>> x.append('Yow!')
>>> x
[99, 'Yow!']
>>> spam(1) # Modified list gets returned!
[99, 'Yow!']
>>>
這種結(jié)果應(yīng)該不是你想要的。為了避免這種情況的發(fā)生,最好是將默認(rèn)值設(shè)為 None, 然后在函數(shù)里面檢查它,前面的例子就是這樣做的。
在測試 None 值時(shí)使用 is
操作符是很重要的,也是這種方案的關(guān)鍵點(diǎn)。 有時(shí)候大家會(huì)犯下下面這樣的錯(cuò)誤:
def spam(a, b=None):
if not b: # NO! Use 'b is None' instead
b = []
...
這么寫的問題在于盡管 None 值確實(shí)是被當(dāng)成 False, 但是還有其他的對(duì)象(比如長度為0的字符串、列表、元組、字典等)都會(huì)被當(dāng)做 False。 因此,上面的代碼會(huì)誤將一些其他輸入也當(dāng)成是沒有輸入。比如:
>>> spam(1) # OK
>>> x = []
>>> spam(1, x) # Silent error. x value overwritten by default
>>> spam(1, 0) # Silent error. 0 ignored
>>> spam(1, '') # Silent error. '' ignored
>>>
最后一個(gè)問題比較微妙,那就是一個(gè)函數(shù)需要測試某個(gè)可選參數(shù)是否被使用者傳遞進(jìn)來。 這時(shí)候需要小心的是你不能用某個(gè)默認(rèn)值比如 None、 0或者 False值來測試用戶提供的值(因?yàn)檫@些值都是合法的值,是可能被用戶傳遞進(jìn)來的)。 因此,你需要其他的解決方案了。
為了解決這個(gè)問題,你可以創(chuàng)建一個(gè)獨(dú)一無二的私有對(duì)象實(shí)例,就像上面的 _no_value 變量那樣。 在函數(shù)里面,你可以通過檢查被傳遞參數(shù)值跟這個(gè)實(shí)例是否一樣來判斷。 這里的思路是用戶不可能去傳遞這個(gè) _no_value 實(shí)例作為輸入。 因此,這里通過檢查這個(gè)值就能確定某個(gè)參數(shù)是否被傳遞進(jìn)來了。
這里對(duì) object()
的使用看上去有點(diǎn)不太常見。object
是 python 中所有類的基類。 你可以創(chuàng)建object
類的實(shí)例,但是這些實(shí)例沒什么實(shí)際用處,因?yàn)樗]有任何有用的方法, 也沒有哦任何實(shí)例數(shù)據(jù)(因?yàn)樗鼪]有任何的實(shí)例字典,你甚至都不能設(shè)置任何屬性值)。 你唯一能做的就是測試同一性。這個(gè)剛好符合我的要求,因?yàn)槲以诤瘮?shù)中就只是需要一個(gè)同一性的測試而已。
你想為 sort()
操作創(chuàng)建一個(gè)很短的回調(diào)函數(shù),但又不想用def
去寫一個(gè)單行函數(shù), 而是希望通過某個(gè)快捷方式以內(nèi)聯(lián)方式來創(chuàng)建這個(gè)函數(shù)。
當(dāng)一些函數(shù)很簡單,僅僅只是計(jì)算一個(gè)表達(dá)式的值的時(shí)候,就可以使用 lambda 表達(dá)式來代替了。比如:
>>> add = lambda x, y: x + y
>>> add(2,3)
5
>>> add('hello', 'world')
'helloworld'
>>>
這里使用的 lambda 表達(dá)式跟下面的效果是一樣的:
>>> def add(x, y):
... return x + y
...
>>> add(2,3)
5
>>>
lambda 表達(dá)式典型的使用場景是排序或數(shù)據(jù) reduce 等:
>>> names = ['David Beazley', 'Brian Jones',
... 'Raymond Hettinger', 'Ned Batchelder']
>>> sorted(names, key=lambda name: name.split()[-1].lower())
['Ned Batchelder', 'David Beazley', 'Raymond Hettinger', 'Brian Jones']
>>>
盡管 lambda 表達(dá)式允許你定義簡單函數(shù),但是它的使用是有限制的。 你只能指定單個(gè)表達(dá)式,它的值就是最后的返回值。也就是說不能包含其他的語言特性了, 包括多個(gè)語句、條件表達(dá)式、迭代以及異常處理等等。
你可以不使用 lambda 表達(dá)式就能編寫大部分 python 代碼。 但是,當(dāng)有人編寫大量計(jì)算表達(dá)式值的短小函數(shù)或者需要用戶提供回調(diào)函數(shù)的程序的時(shí)候, 你就會(huì)看到 lambda 表達(dá)式的身影了。
你用 lambda 定義了一個(gè)匿名函數(shù),并想在定義時(shí)捕獲到某些變量的值。
先看下下面代碼的效果:
>>> x = 10
>>> a = lambda y: x + y
>>> x = 20
>>> b = lambda y: x + y
>>>
現(xiàn)在我問你,a(10)和 b(10)返回的結(jié)果是什么?如果你認(rèn)為結(jié)果是20和30,那么你就錯(cuò)了:
>>> a(10)
30
>>> b(10)
30
>>>
這其中的奧妙在于 lambda 表達(dá)式中的 x 是一個(gè)自由變量, 在運(yùn)行時(shí)綁定值,而不是定義時(shí)就綁定,這跟函數(shù)的默認(rèn)值參數(shù)定義是不同的。 因此,在調(diào)用這個(gè) lambda 表達(dá)式的時(shí)候,x 的值是執(zhí)行時(shí)的值。例如:
>>> x = 15
>>> a(10)
25
>>> x = 3
>>> a(10)
13
>>>
如果你想讓某個(gè)匿名函數(shù)在定義時(shí)就捕獲到值,可以將那個(gè)參數(shù)值定義成默認(rèn)參數(shù)即可,就像下面這樣:
>>> x = 10
>>> a = lambda y, x=x: x + y
>>> x = 20
>>> b = lambda y, x=x: x + y
>>> a(10)
20
>>> b(10)
30
>>>
在這里列出來的問題是新手很容易犯的錯(cuò)誤,有些新手可能會(huì)不恰當(dāng)?shù)?lambda 表達(dá)式。 比如,通過在一個(gè)循環(huán)或列表推導(dǎo)中創(chuàng)建一個(gè) lambda 表達(dá)式列表,并期望函數(shù)能在定義時(shí)就記住每次的迭代值。例如:
>>> funcs = [lambda x: x+n for n in range(5)]
>>> for f in funcs:
... print(f(0))
...
4
4
4
4
4
>>>
但是實(shí)際效果是運(yùn)行是 n 的值為迭代的最后一個(gè)值。現(xiàn)在我們用另一種方式修改一下:
>>> funcs = [lambda x, n=n: x+n for n in range(5)]
>>> for f in funcs:
... print(f(0))
...
0
1
2
3
4
>>>
通過使用函數(shù)默認(rèn)值參數(shù)形式,lambda 函數(shù)在定義時(shí)就能綁定到值。
你有一個(gè)被其他 python 代碼使用的 callable 對(duì)象,可能是一個(gè)回調(diào)函數(shù)或者是一個(gè)處理器, 但是它的參數(shù)太多了,導(dǎo)致調(diào)用時(shí)出錯(cuò)。
如果需要減少某個(gè)函數(shù)的參數(shù)個(gè)數(shù),你可以使用functools.partial()
。 partial()
函數(shù)允許你給一個(gè)或多個(gè)參數(shù)設(shè)置固定的值,減少接下來被調(diào)用時(shí)的參數(shù)個(gè)數(shù)。 為了演示清楚,假設(shè)你有下面這樣的函數(shù):
def spam(a, b, c, d):
print(a, b, c, d)
現(xiàn)在我們使用partial()
函數(shù)來固定某些參數(shù)值:
>>> from functools import partial
>>> s1 = partial(spam, 1) # a = 1
>>> s1(2, 3, 4)
1 2 3 4
>>> s1(4, 5, 6)
1 4 5 6
>>> s2 = partial(spam, d=42) # d = 42
>>> s2(1, 2, 3)
1 2 3 42
>>> s2(4, 5, 5)
4 5 5 42
>>> s3 = partial(spam, 1, 2, d=42) # a = 1, b = 2, d = 42
>>> s3(3)
1 2 3 42
>>> s3(4)
1 2 4 42
>>> s3(5)
1 2 5 42
>>>
可以看出 partial()
固定某些參數(shù)并返回一個(gè)新的 callable 對(duì)象。這個(gè)新的 callable 接受未賦值的參數(shù), 然后跟之前已經(jīng)賦值過的參數(shù)合并起來,最后將所有參數(shù)傳遞給原始函數(shù)。
本節(jié)要解決的問題是讓原本不兼容的代碼可以一起工作。下面我會(huì)列舉一系列的例子。
第一個(gè)例子是,假設(shè)你有一個(gè)點(diǎn)的列表來表示(x,y)坐標(biāo)元組。 你可以使用下面的函數(shù)來計(jì)算兩點(diǎn)之間的距離:
points = [ (1, 2), (3, 4), (5, 6), (7, 8) ]
import math
def distance(p1, p2):
x1, y1 = p1
x2, y2 = p2
return math.hypot(x2 - x1, y2 - y1)
現(xiàn)在假設(shè)你想以某個(gè)點(diǎn)為基點(diǎn),根據(jù)點(diǎn)和基點(diǎn)之間的距離來排序所有的這些點(diǎn)。 列表的 sort()
方法接受一個(gè)關(guān)鍵字參數(shù)來自定義排序邏輯, 但是它只能接受一個(gè)單個(gè)參數(shù)的函數(shù)(distance()很明顯是不符合條件的)。 現(xiàn)在我們可以通過使用 partial()
來解決這個(gè)問題:
>>> pt = (4, 3)
>>> points.sort(key=partial(distance,pt))
>>> points
[(3, 4), (1, 2), (5, 6), (7, 8)]
>>>
更進(jìn)一步,partial()
通常被用來微調(diào)其他庫函數(shù)所使用的回調(diào)函數(shù)的參數(shù)。 例如,下面是一段代碼,使用 multiprocessing
來異步計(jì)算一個(gè)結(jié)果值, 然后這個(gè)值被傳遞給一個(gè)接受一個(gè) result 值和一個(gè)可選 logging 參數(shù)的回調(diào)函數(shù):
def output_result(result, log=None):
if log is not None:
log.debug('Got: %r', result)
# A sample function
def add(x, y):
return x + y
if __name__ == '__main__':
import logging
from multiprocessing import Pool
from functools import partial
logging.basicConfig(level=logging.DEBUG)
log = logging.getLogger('test')
p = Pool()
p.apply_async(add, (3, 4), callback=partial(output_result, log=log))
p.close()
p.join()
當(dāng)給 apply_async()
提供回調(diào)函數(shù)時(shí),通過使用partial()
傳遞額外的 logging
參數(shù)。 而multiprocessing
對(duì)這些一無所知——它僅僅只是使用單個(gè)值來調(diào)用回調(diào)函數(shù)。
作為一個(gè)類似的例子,考慮下編寫網(wǎng)絡(luò)服務(wù)器的問題,socketserver
模塊讓它變得很容易。 下面是個(gè)簡單的 echo 服務(wù)器:
from socketserver import StreamRequestHandler, TCPServer
class EchoHandler(StreamRequestHandler):
def handle(self):
for line in self.rfile:
self.wfile.write(b'GOT:' + line)
serv = TCPServer(('', 15000), EchoHandler)
serv.serve_forever()
不過,假設(shè)你想給 EchoHandler 增加一個(gè)可以接受其他配置選項(xiàng)的 __init__
方法。比如:
class EchoHandler(StreamRequestHandler):
# ack is added keyword-only argument. *args, **kwargs are
# any normal parameters supplied (which are passed on)
def __init__(self, *args, ack, **kwargs):
self.ack = ack
super().__init__(*args, **kwargs)
def handle(self):
for line in self.rfile:
self.wfile.write(self.ack + line)
這么修改后,我們就不需要顯式地在 TCPServer 類中添加前綴了。 但是你再次運(yùn)行程序后會(huì)報(bào)類似下面的錯(cuò)誤:
Exception happened during processing of request from ('127.0.0.1', 59834)
Traceback (most recent call last):
...
TypeError: __init__() missing 1 required keyword-only argument: 'ack'
初看起來好像很難修正這個(gè)錯(cuò)誤,除了修改 socketserver
模塊源代碼或者使用某些奇怪的方法之外。 但是,如果使用 partial()
就能很輕松的解決——給它傳遞 ack
參數(shù)的值來初始化即可,如下:
from functools import partial
serv = TCPServer(('', 15000), partial(EchoHandler, ack=b'RECEIVED:'))
serv.serve_forever()
在這個(gè)例子中,__init__()
方法中的 ack 參數(shù)聲明方式看上去很有趣,其實(shí)就是聲明 ack 為一個(gè)強(qiáng)制關(guān)鍵字參數(shù)。 關(guān)于強(qiáng)制關(guān)鍵字參數(shù)問題我們在7.2小節(jié)我們已經(jīng)討論過了,讀者可以再去回顧一下。
很多時(shí)候 partial()
能實(shí)現(xiàn)的效果,lambda 表達(dá)式也能實(shí)現(xiàn)。比如,之前的幾個(gè)例子可以使用下面這樣的表達(dá)式:
points.sort(key=lambda p: distance(pt, p))
p.apply_async(add, (3, 4), callback=lambda result: output_result(result,log))
serv = TCPServer(('', 15000),
lambda *args, **kwargs: EchoHandler(*args, ack=b'RECEIVED:', **kwargs))
這樣寫也能實(shí)現(xiàn)同樣的效果,不過相比而已會(huì)顯得比較臃腫,對(duì)于閱讀代碼的人來講也更加難懂。 這時(shí)候使用partial()
可以更加直觀的表達(dá)你的意圖(給某些參數(shù)預(yù)先賦值)。
你有一個(gè)除 __init__()
方法外只定義了一個(gè)方法的類。為了簡化代碼,你想將它轉(zhuǎn)換成一個(gè)函數(shù)。
大多數(shù)情況下,可以使用閉包來將單個(gè)方法的類轉(zhuǎn)換成函數(shù)。 舉個(gè)例子,下面示例中的類允許使用者根據(jù)某個(gè)模板方案來獲取到 URL 鏈接地址。
from urllib.request import urlopen
class UrlTemplate:
def __init__(self, template):
self.template = template
def open(self, **kwargs):
return urlopen(self.template.format_map(kwargs))
# Example use. Download stock data from yahoo
yahoo = UrlTemplate('http://finance.yahoo.com/d/quotes.csv?s={names}&f={fields}')
for line in yahoo.open(names='IBM,AAPL,FB', fields='sl1c1v'):
print(line.decode('utf-8'))
這個(gè)類可以被一個(gè)更簡單的函數(shù)來代替:
def urltemplate(template):
def opener(**kwargs):
return urlopen(template.format_map(kwargs))
return opener
# Example use
yahoo = urltemplate('http://finance.yahoo.com/d/quotes.csv?s={names}&f={fields}')
for line in yahoo(names='IBM,AAPL,FB', fields='sl1c1v'):
print(line.decode('utf-8'))
大部分情況下,你擁有一個(gè)單方法類的原因是需要存儲(chǔ)某些額外的狀態(tài)來給方法使用。 比如,定義 UrlTemplate 類的唯一目的就是先在某個(gè)地方存儲(chǔ)模板值,以便將來可以在 open()方法中使用。
使用一個(gè)內(nèi)部函數(shù)或者閉包的方案通常會(huì)更優(yōu)雅一些。簡單來講,一個(gè)閉包就是一個(gè)函數(shù), 只不過在函數(shù)內(nèi)部帶上了一個(gè)額外的變量環(huán)境。閉包關(guān)鍵特點(diǎn)就是它會(huì)記住自己被定義時(shí)的環(huán)境。 因此,在我們的解決方案中,opener()
函數(shù)記住了 template
參數(shù)的值,并在接下來的調(diào)用中使用它。
任何時(shí)候只要你碰到需要給某個(gè)函數(shù)增加額外的狀態(tài)信息的問題,都可以考慮使用閉包。 相比將你的函數(shù)轉(zhuǎn)換成一個(gè)類而言,閉包通常是一種更加簡潔和優(yōu)雅的方案。
你的代碼中需要依賴到回調(diào)函數(shù)的使用(比如事件處理器、等待后臺(tái)任務(wù)完成后的回調(diào)等), 并且你還需要讓回調(diào)函數(shù)擁有額外的狀態(tài)值,以便在它的內(nèi)部使用到。
這一小節(jié)主要討論的是那些出現(xiàn)在很多函數(shù)庫和框架中的回調(diào)函數(shù)的使用——特別是跟異步處理有關(guān)的。 為了演示與測試,我們先定義如下一個(gè)需要調(diào)用回調(diào)函數(shù)的函數(shù):
def apply_async(func, args, *, callback):
# Compute the result
result = func(*args)
# Invoke the callback with the result
callback(result)
實(shí)際上,這段代碼可以做任何更高級(jí)的處理,包括線程、進(jìn)程和定時(shí)器,但是這些都不是我們要關(guān)心的。 我們僅僅只需要關(guān)注回調(diào)函數(shù)的調(diào)用。下面是一個(gè)演示怎樣使用上述代碼的例子:
>>> def print_result(result):
... print('Got:', result)
...
>>> def add(x, y):
... return x + y
...
>>> apply_async(add, (2, 3), callback=print_result)
Got: 5
>>> apply_async(add, ('hello', 'world'), callback=print_result)
Got: helloworld
>>>
注意到 print_result()
函數(shù)僅僅只接受一個(gè)參數(shù) result
。不能再傳入其他信息。 而當(dāng)你想讓回調(diào)函數(shù)訪問其他變量或者特定環(huán)境的變量值的時(shí)候就會(huì)遇到麻煩。
為了讓回調(diào)函數(shù)訪問外部信息,一種方法是使用一個(gè)綁定方法來代替一個(gè)簡單函數(shù)。 比如,下面這個(gè)類會(huì)保存一個(gè)內(nèi)部序列號(hào),每次接收到一個(gè)result
的時(shí)候序列號(hào)加1:
class ResultHandler:
def __init__(self):
self.sequence = 0
def handler(self, result):
self.sequence += 1
print('[{}] Got: {}'.format(self.sequence, result))
使用這個(gè)類的時(shí)候,你先創(chuàng)建一個(gè)類的實(shí)例,然后用它的 handler()
綁定方法來做為回調(diào)函數(shù):
>>> r = ResultHandler()
>>> apply_async(add, (2, 3), callback=r.handler)
[1] Got: 5
>>> apply_async(add, ('hello', 'world'), callback=r.handler)
[2] Got: helloworld
>>>
第二種方式,作為類的替代,可以使用一個(gè)閉包捕獲狀態(tài)值,例如:
def make_handler():
sequence = 0
def handler(result):
nonlocal sequence
sequence += 1
print('[{}] Got: {}'.format(sequence, result))
return handler
下面是使用閉包方式的一個(gè)例子:
>>> handler = make_handler()
>>> apply_async(add, (2, 3), callback=handler)
[1] Got: 5
>>> apply_async(add, ('hello', 'world'), callback=handler)
[2] Got: helloworld
>>>
還有另外一個(gè)更高級(jí)的方法,可以使用協(xié)程來完成同樣的事情:
def make_handler():
sequence = 0
while True:
result = yield
sequence += 1
print('[{}] Got: {}'.format(sequence, result))
對(duì)于協(xié)程,你需要使用它的 send()
方法作為回調(diào)函數(shù),如下所示:
>>> handler = make_handler()
>>> next(handler) # Advance to the yield
>>> apply_async(add, (2, 3), callback=handler.send)
[1] Got: 5
>>> apply_async(add, ('hello', 'world'), callback=handler.send)
[2] Got: helloworld
>>>
基于回調(diào)函數(shù)的軟件通常都有可能變得非常復(fù)雜。一部分原因是回調(diào)函數(shù)通常會(huì)跟請(qǐng)求執(zhí)行代碼斷開。 因此,請(qǐng)求執(zhí)行和處理結(jié)果之間的執(zhí)行環(huán)境實(shí)際上已經(jīng)丟失了。如果你想讓回調(diào)函數(shù)連續(xù)執(zhí)行多步操作, 那你就必須去解決如何保存和恢復(fù)相關(guān)的狀態(tài)信息了。
至少有兩種主要方式來捕獲和保存狀態(tài)信息,你可以在一個(gè)對(duì)象實(shí)例(通過一個(gè)綁定方法)或者在一個(gè)閉包中保存它。 兩種方式相比,閉包或許是更加輕量級(jí)和自然一點(diǎn),因?yàn)樗鼈兛梢院芎唵蔚耐ㄟ^函數(shù)來構(gòu)造。 它們還能自動(dòng)捕獲所有被使用到的變量。因此,你無需去擔(dān)心如何去存儲(chǔ)額外的狀態(tài)信息(代碼中自動(dòng)判定)。
如果使用閉包,你需要注意對(duì)那些可修改變量的操作。在上面的方案中, nonlocal
聲明語句用來指示接下來的變量會(huì)在回調(diào)函數(shù)中被修改。如果沒有這個(gè)聲明,代碼會(huì)報(bào)錯(cuò)。
而使用一個(gè)協(xié)程來作為一個(gè)回調(diào)函數(shù)就更有趣了,它跟閉包方法密切相關(guān)。 某種意義上來講,它顯得更加簡潔,因?yàn)榭偣簿鸵粋€(gè)函數(shù)而已。 并且,你可以很自由的修改變量而無需去使用nonlocal
聲明。 這種方式唯一缺點(diǎn)就是相對(duì)于其他 Python 技術(shù)而已或許比較難以理解。 另外還有一些比較難懂的部分,比如使用之前需要調(diào)用next()
,實(shí)際使用時(shí)這個(gè)步驟很容易被忘記。 盡管如此,協(xié)程還有其他用處,比如作為一個(gè)內(nèi)聯(lián)回調(diào)函數(shù)的定義(下一節(jié)會(huì)講到)。
如果你僅僅只需要給回調(diào)函數(shù)傳遞額外的值的話,還有一種使用 partial()
的方式也很有用。 在沒有使用 partial()
的時(shí)候,你可能經(jīng)??吹较旅孢@種使用 lambda 表達(dá)式的復(fù)雜代碼:
>>> apply_async(add, (2, 3), callback=lambda r: handler(r, seq))
[1] Got: 5
>>>
可以參考7.8小節(jié)的幾個(gè)示例,教你如何使用partial()
來更改參數(shù)簽名來簡化上述代碼。
當(dāng)你編寫使用回調(diào)函數(shù)的代碼的時(shí)候,擔(dān)心很多小函數(shù)的擴(kuò)張可能會(huì)弄亂程序控制流。 你希望找到某個(gè)方法來讓代碼看上去更像是一個(gè)普通的執(zhí)行序列。
通過使用生成器和協(xié)程可以使得回調(diào)函數(shù)內(nèi)聯(lián)在某個(gè)函數(shù)中。 為了演示說明,假設(shè)你有如下所示的一個(gè)執(zhí)行某種計(jì)算任務(wù)然后調(diào)用一個(gè)回調(diào)函數(shù)的函數(shù)(參考7.10小節(jié)):
def apply_async(func, args, *, callback):
# Compute the result
result = func(*args)
# Invoke the callback with the result
callback(result)
接下來讓我們看一下下面的代碼,它包含了一個(gè) Async
類和一個(gè) inlined_async
裝飾器:
from queue import Queue
from functools import wraps
class Async:
def __init__(self, func, args):
self.func = func
self.args = args
def inlined_async(func):
@wraps(func)
def wrapper(*args):
f = func(*args)
result_queue = Queue()
result_queue.put(None)
while True:
result = result_queue.get()
try:
a = f.send(result)
apply_async(a.func, a.args, callback=result_queue.put)
except StopIteration:
break
return wrapper
這兩個(gè)代碼片段允許你使用yield
語句內(nèi)聯(lián)回調(diào)步驟。比如:
def add(x, y):
return x + y
@inlined_async
def test():
r = yield Async(add, (2, 3))
print(r)
r = yield Async(add, ('hello', 'world'))
print(r)
for n in range(10):
r = yield Async(add, (n, n))
print(r)
print('Goodbye')
如果你調(diào)用 test()
,你會(huì)得到類似如下的輸出:
5
helloworld
0
2
4
6
8
10
12
14
16
18
Goodbye
你會(huì)發(fā)現(xiàn),除了那個(gè)特別的裝飾器和yield
語句外,其他地方并沒有出現(xiàn)任何的回調(diào)函數(shù)(其實(shí)是在后臺(tái)定義的)。
本小節(jié)會(huì)實(shí)實(shí)在在的測試你關(guān)于回調(diào)函數(shù)、生成器和控制流的知識(shí)。
首先,在需要使用到回調(diào)的代碼中,關(guān)鍵點(diǎn)在于當(dāng)前計(jì)算工作會(huì)掛起并在將來的某個(gè)時(shí)候重啟(比如異步執(zhí)行)。 當(dāng)計(jì)算重啟時(shí),回調(diào)函數(shù)被調(diào)用來繼續(xù)處理結(jié)果。apply_async()
函數(shù)演示了執(zhí)行回調(diào)的實(shí)際邏輯, 盡管實(shí)際情況中它可能會(huì)更加復(fù)雜(包括線程、進(jìn)程、事件處理器等等)。
計(jì)算的暫停與重啟思路跟生成器函數(shù)的執(zhí)行模型不謀而合。 具體來講,yield
操作會(huì)使一個(gè)生成器函數(shù)產(chǎn)生一個(gè)值并暫停。 接下來調(diào)用生成器的 __next__()
或send()
方法又會(huì)讓它從暫停處繼續(xù)執(zhí)行。
根據(jù)這個(gè)思路,這一小節(jié)的核心就在 inline_async()
裝飾器函數(shù)中了。 關(guān)鍵點(diǎn)就是,裝飾器會(huì)逐步遍歷生成器函數(shù)的所有 yield
語句,每一次一個(gè)。 為了這樣做,剛開始的時(shí)候創(chuàng)建了一個(gè)result
隊(duì)列并向里面放入一個(gè) None
值。 然后開始一個(gè)循環(huán)操作,從隊(duì)列中取出結(jié)果值并發(fā)送給生成器,它會(huì)持續(xù)到下一個(gè) yield
語句, 在這里一個(gè) Async
的實(shí)例被接受到。然后循環(huán)開始檢查函數(shù)和參數(shù),并開始進(jìn)行異步計(jì)算 apply_async()
。 然而,這個(gè)計(jì)算有個(gè)最詭異部分是它并沒有使用一個(gè)普通的回調(diào)函數(shù),而是用隊(duì)列的put()
方法來回調(diào)。
這時(shí)候,是時(shí)候詳細(xì)解釋下到底發(fā)生了什么了。主循環(huán)立即返回頂部并在隊(duì)列上執(zhí)行 get()
操作。 如果數(shù)據(jù)存在,它一定是put()
回調(diào)存放的結(jié)果。如果沒有數(shù)據(jù),那么先暫停操作并等待結(jié)果的到來。 這個(gè)具體怎樣實(shí)現(xiàn)是由 apply_async()
函數(shù)來決定的。 如果你不相信會(huì)有這么神奇的事情,你可以使用 multiprocessing
庫來試一下, 在單獨(dú)的進(jìn)程中執(zhí)行異步計(jì)算操作,如下所示:
if __name__ == '__main__':
import multiprocessing
pool = multiprocessing.Pool()
apply_async = pool.apply_async
# Run the test function
test()
實(shí)際上你會(huì)發(fā)現(xiàn)這個(gè)真的就是這樣的,但是要解釋清楚具體的控制流得需要點(diǎn)時(shí)間了。
將復(fù)雜的控制流隱藏到生成器函數(shù)背后的例子在標(biāo)準(zhǔn)庫和第三方包中都能看到。 比如,在 contextlib
中的 @contextmanager
裝飾器使用了一個(gè)令人費(fèi)解的技巧, 通過一個(gè)yield
語句將進(jìn)入和離開上下文管理器粘合在一起。 另外非常流行的 Twisted
包中也包含了非常類似的內(nèi)聯(lián)回調(diào)。
你想要擴(kuò)展函數(shù)中的某個(gè)閉包,允許它能訪問和修改函數(shù)的內(nèi)部變量。
通常來講,閉包的內(nèi)部變量對(duì)于外界來講是完全隱藏的。 但是,你可以通過編寫訪問函數(shù)并將其作為函數(shù)屬性綁定到閉包上來實(shí)現(xiàn)這個(gè)目的。例如:
def sample():
n = 0
# Closure function
def func():
print('n=', n)
# Accessor methods for n
def get_n():
return n
def set_n(value):
nonlocal n
n = value
# Attach as function attributes
func.get_n = get_n
func.set_n = set_n
return func
下面是使用的例子:
>>> f = sample()
>>> f()
n= 0
>>> f.set_n(10)
>>> f()
n= 10
>>> f.get_n()
10
>>>
為了說明清楚它如何工作的,有兩點(diǎn)需要解釋一下。首先,nonlocal
聲明可以讓我們編寫函數(shù)來修改內(nèi)部變量的值。 其次,函數(shù)屬性允許我們用一種很簡單的方式將訪問方法綁定到閉包函數(shù)上,這個(gè)跟實(shí)例方法很像(盡管并沒有定義任何類)。
還可以進(jìn)一步的擴(kuò)展,讓閉包模擬類的實(shí)例。你要做的僅僅是復(fù)制上面的內(nèi)部函數(shù)到一個(gè)字典實(shí)例中并返回它即可。例如:
import sys
class ClosureInstance:
def __init__(self, locals=None):
if locals is None:
locals = sys._getframe(1).f_locals
# Update instance dictionary with callables
self.__dict__.update((key,value) for key, value in locals.items()
if callable(value) )
# Redirect special methods
def __len__(self):
return self.__dict__['__len__']()
# Example use
def Stack():
items = []
def push(item):
items.append(item)
def pop():
return items.pop()
def __len__():
return len(items)
return ClosureInstance()
下面是一個(gè)交互式會(huì)話來演示它是如何工作的:
>>> s = Stack()
>>> s
<__main__.ClosureInstance object at 0x10069ed10>
>>> s.push(10)
>>> s.push(20)
>>> s.push('Hello')
>>> len(s)
3
>>> s.pop()
'Hello'
>>> s.pop()
20
>>> s.pop()
10
>>>
有趣的是,這個(gè)代碼運(yùn)行起來會(huì)比一個(gè)普通的類定義要快很多。你可能會(huì)像下面這樣測試它跟一個(gè)類的性能對(duì)比:
class Stack2:
def __init__(self):
self.items = []
def push(self, item):
self.items.append(item)
def pop(self):
return self.items.pop()
def __len__(self):
return len(self.items)
如果這樣做,你會(huì)得到類似如下的結(jié)果:
>>> from timeit import timeit
>>> # Test involving closures
>>> s = Stack()
>>> timeit('s.push(1);s.pop()', 'from __main__ import s')
0.9874754269840196
>>> # Test involving a class
>>> s = Stack2()
>>> timeit('s.push(1);s.pop()', 'from __main__ import s')
1.0707052160287276
>>>
結(jié)果顯示,閉包的方案運(yùn)行起來要快大概8%,大部分原因是因?yàn)閷?duì)實(shí)例變量的簡化訪問, 閉包更快是因?yàn)椴粫?huì)涉及到額外的 self 變量。
Raymond Hettinger 對(duì)于這個(gè)問題設(shè)計(jì)出了更加難以理解的改進(jìn)方案。不過,你得考慮下是否真的需要在你代碼中這樣做, 而且它只是真實(shí)類的一個(gè)奇怪的替換而已,例如,類的主要特性如繼承、屬性、描述器或類方法都是不能用的。 并且你要做一些其他的工作才能讓一些特殊方法生效(比如上面 ClosureInstance
中重寫過的 __len__()
實(shí)現(xiàn)。)
最后,你可能還會(huì)讓其他閱讀你代碼的人感到疑惑,為什么它看起來不像一個(gè)普通的類定義呢? (當(dāng)然,他們也想知道為什么它運(yùn)行起來會(huì)更快)。盡管如此,這對(duì)于怎樣訪問閉包的內(nèi)部變量也不失為一個(gè)有趣的例子。
總體上講,在配置的時(shí)候給閉包添加方法會(huì)有更多的實(shí)用功能, 比如你需要重置內(nèi)部狀態(tài)、刷新緩沖區(qū)、清除緩存或其他的反饋機(jī)制的時(shí)候。