使用 def語句定義函數(shù)是所有程序的基礎(chǔ)。 本章的目標(biāo)是講解一些更加高級和不常見的函數(shù)定義與使用模式。 涉及到的內(nèi)容包括默認(rèn)參數(shù)、任意數(shù)量參數(shù)、強制關(guān)鍵字參數(shù)、注解和閉包。 另外,一些高級的控制流和利用回調(diào)函數(shù)傳遞數(shù)據(jù)的技術(shù)在這里也會講解到。
你想構(gòu)造一個可接受任意數(shù)量參數(shù)的函數(shù)。
為了能讓一個函數(shù)接受任意數(shù)量的位置參數(shù),可以使用一個*參數(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
在這個例子中,rest 是由所有其他位置參數(shù)組成的元組。然后我們在代碼中把它當(dāng)成了一個序列來進行后續(xù)的計算。
為了接受任意數(shù)量的關(guān)鍵字參數(shù),使用一個以**開頭的參數(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 是一個包含所有被傳入進來的關(guān)鍵字參數(shù)的字典。
如果你還希望某個函數(shù)能同時接受任意數(shù)量的位置參數(shù)和關(guān)鍵字參數(shù),可以同時使用*和**。比如:
def anyargs(*args, **kwargs):
print(args) # A tuple
print(kwargs) # A dict
使用這個函數(shù)時,所有位置參數(shù)會被放到 args 元組中,所有關(guān)鍵字參數(shù)會被放到字典 kwargs 中。
一個*參數(shù)只能出現(xiàn)在函數(shù)定義中最后一個位置參數(shù)后面,而 *參數(shù)只能出現(xiàn)在最后一個參數(shù)。 有一點要注意的是,在參數(shù)后面仍然可以定義其他參數(shù)。
def a(x, *args, y):
pass
def b(x, *args, y, **kwargs):
pass
這種參數(shù)就是我們所說的強制關(guān)鍵字參數(shù),在后面7.2小節(jié)還會詳細(xì)講解到。
你希望函數(shù)的某些參數(shù)強制使用關(guān)鍵字參數(shù)傳遞
將強制關(guān)鍵字參數(shù)放到某個參數(shù)或者當(dāng)個后面就能達(dá)到這種效果。比如:
def recv(maxsize, *, block):
'Receives a message'
pass
recv(1024, True) # TypeError
recv(1024, block=True) # Ok
利用這種技術(shù),我們還能在接受任意多個位置參數(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
很多情況下,使用強制關(guān)鍵字參數(shù)會比使用位置參數(shù)表意更加清晰,程序也更加具有可讀性。 例如,考慮下如下一個函數(shù)調(diào)用:
msg = recv(1024, False)
如果調(diào)用者對 recv 函數(shù)并不是很熟悉,那他肯定不明白那個 False 參數(shù)到底來干嘛用的。 但是,如果代碼變成下面這樣子的話就清楚多了:
msg = recv(1024, block=False)
另外,使用強制關(guān)鍵字參數(shù)也會比使用 **kwargs 參數(shù)更好,因為在使用函數(shù) help 的時候輸出也會更容易理解:
>>> help(recv)
Help on function recv in module __main__:
recv(maxsize, *, block)
Receives a message
強制關(guān)鍵字參數(shù)在一些更高級場合同樣也很有用。 例如,它們可以被用來在使用 *args 和 **kwargs 參數(shù)作為輸入的函數(shù)中插入?yún)?shù),9.11小節(jié)有一個這樣的例子。
你寫好了一個函數(shù),然后想為這個函數(shù)的參數(shù)增加一些額外的信息,這樣的話其他使用者就能清楚的知道這個函數(shù)應(yīng)該怎么使用。
使用函數(shù)參數(shù)注解是一個很好的辦法,它能提示程序員應(yīng)該怎樣正確使用這個函數(shù)。 例如,下面有一個被注解了的函數(shù):
def add(x:int, y:int) -> int:
return x + y
python 解釋器不會對這些注解添加任何的語義。它們不會被類型檢查,運行時跟沒有加注解之前的效果也沒有任何差距。 然而,對于那些閱讀源碼的人來講就很有幫助啦。第三方工具和框架可能會對這些注解添加語義。同時它們也會出現(xiàn)在文檔中。
>>> help(add)
Help on function add in module __main__:
add(x: int, y: int) -> int
>>>
盡管你可以使用任意類型的對象給函數(shù)添加注解(例如數(shù)字,字符串,對象實例等等),不過通常來講使用類或著字符串會比較好點。
函數(shù)注解只存儲在函數(shù)的 __annotations__ 屬性中。例如:
>>> add.__annotations__
{'y': <class 'int'>, 'return': <class 'int'>, 'x': <class 'int'>}
盡管注解的使用方法可能有很多種,但是它們的主要用途還是文檔。 因為 python 并沒有類型聲明,通常來講僅僅通過閱讀源碼很難知道應(yīng)該傳遞什么樣的參數(shù)給這個函數(shù)。 這時候使用注解就能給程序員更多的提示,讓他們可以爭取的使用函數(shù)。
參考9.20小節(jié)的一個更加高級的例子,演示了如何利用注解來實現(xiàn)多分派(比如重載函數(shù))。
你希望構(gòu)造一個可以返回多個值的函數(shù)
為了能返回多個值,函數(shù)直接 return 一個元組就行了。例如:
>>> def myfun():
... return 1, 2, 3
...
>>> a, b, c = myfun()
>>> a
1
>>> b
2
>>> c
3
盡管 myfun() 看上去返回了多個值,實際上是先創(chuàng)建了一個元組然后返回的。 這個語法看上去比較奇怪,實際上我們使用的是逗號來生成一個元組,而不是用括號。比如下面的:
>>> a = (1, 2) # With parentheses
>>> a
(1, 2)
>>> b = 1, 2 # Without parentheses
>>> b
(1, 2)
>>>
當(dāng)我們調(diào)用返回一個元組的函數(shù)的時候 ,通常我們會將結(jié)果賦值給多個變量,就像上面的那樣。 其實這就是1.1小節(jié)中我們所說的元組解包。返回結(jié)果也可以賦值給單個變量, 這時候這個變量值就是函數(shù)返回的那個元組本身了:
>>> x = myfun()
>>> x
(1, 2, 3)
>>>
你想定義一個函數(shù)或者方法,它的一個或多個參數(shù)是可選的并且有一個默認(rèn)值。
定義一個有可選參數(shù)的函數(shù)是非常簡單的,直接在函數(shù)定義中給參數(shù)指定一個默認(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ù)是一個可修改的容器比如一個列表、集合或者字典,可以使用 None 作為默認(rèn)值,就像下面這樣:
# Using a list as a default value
def spam(a, b=None):
if b is None:
b = []
...
如果你并不想提供一個默認(rèn)值,而是想僅僅測試下某個默認(rèn)參數(shù)是不是有傳遞進來,可以像下面這樣寫:
_no_value = object()
def spam(a, b=_no_value):
if b is _no_value:
print('No b value supplied')
...
我們測試下這個函數(shù):
>>> spam(1)
No b value supplied
>>> spam(1, 2) # b = 2
>>> spam(1, None) # b = None
>>>
仔細(xì)觀察可以發(fā)現(xiàn)到傳遞一個 None 值和不傳值兩種情況是有差別的。
定義帶默認(rèn)值參數(shù)的函數(shù)是很簡單的,但絕不僅僅只是這個,還有一些東西在這里也深入討論下。
首先,默認(rèn)參數(shù)的值僅僅在函數(shù)定義的時候賦值一次。試著運行下面這個例子:
>>> 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 的值的時候?qū)δJ(rèn)參數(shù)值并沒有影響,這是因為在函數(shù)定義的時候就已經(jīng)確定了它的默認(rèn)值了。
其次,默認(rèn)參數(shù)的值應(yīng)該是不可變的對象,比如 None、True、False、數(shù)字或字符串。 特別的,千萬不要像下面這樣寫代碼:
def spam(a, b=[]): # NO!
...
如果你這么做了,當(dāng)默認(rèn)值在其他地方被修改后你將會遇到各種麻煩。這些修改會影響到下次調(diào)用這個函數(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 值時使用 is 操作符是很重要的,也是這種方案的關(guān)鍵點。 有時候大家會犯下下面這樣的錯誤:
def spam(a, b=None):
if not b: # NO! Use 'b is None' instead
b = []
...
這么寫的問題在于盡管 None 值確實是被當(dāng)成 False, 但是還有其他的對象(比如長度為0的字符串、列表、元組、字典等)都會被當(dāng)做 False。 因此,上面的代碼會誤將一些其他輸入也當(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
>>>
最后一個問題比較微妙,那就是一個函數(shù)需要測試某個可選參數(shù)是否被使用者傳遞進來。 這時候需要小心的是你不能用某個默認(rèn)值比如 None、 0或者 False值來測試用戶提供的值(因為這些值都是合法的值,是可能被用戶傳遞進來的)。 因此,你需要其他的解決方案了。
為了解決這個問題,你可以創(chuàng)建一個獨一無二的私有對象實例,就像上面的 _no_value 變量那樣。 在函數(shù)里面,你可以通過檢查被傳遞參數(shù)值跟這個實例是否一樣來判斷。 這里的思路是用戶不可能去傳遞這個 _no_value 實例作為輸入。 因此,這里通過檢查這個值就能確定某個參數(shù)是否被傳遞進來了。
這里對 object() 的使用看上去有點不太常見。object 是 python 中所有類的基類。 你可以創(chuàng)建object類的實例,但是這些實例沒什么實際用處,因為它并沒有任何有用的方法, 也沒有哦任何實例數(shù)據(jù)(因為它沒有任何的實例字典,你甚至都不能設(shè)置任何屬性值)。 你唯一能做的就是測試同一性。這個剛好符合我的要求,因為我在函數(shù)中就只是需要一個同一性的測試而已。
你想為 sort()操作創(chuàng)建一個很短的回調(diào)函數(shù),但又不想用def去寫一個單行函數(shù), 而是希望通過某個快捷方式以內(nèi)聯(lián)方式來創(chuàng)建這個函數(shù)。
當(dāng)一些函數(shù)很簡單,僅僅只是計算一個表達(dá)式的值的時候,就可以使用 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ù),但是它的使用是有限制的。 你只能指定單個表達(dá)式,它的值就是最后的返回值。也就是說不能包含其他的語言特性了, 包括多個語句、條件表達(dá)式、迭代以及異常處理等等。
你可以不使用 lambda 表達(dá)式就能編寫大部分 python 代碼。 但是,當(dāng)有人編寫大量計算表達(dá)式值的短小函數(shù)或者需要用戶提供回調(diào)函數(shù)的程序的時候, 你就會看到 lambda 表達(dá)式的身影了。
你用 lambda 定義了一個匿名函數(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,那么你就錯了:
>>> a(10)
30
>>> b(10)
30
>>>
這其中的奧妙在于 lambda 表達(dá)式中的 x 是一個自由變量, 在運行時綁定值,而不是定義時就綁定,這跟函數(shù)的默認(rèn)值參數(shù)定義是不同的。 因此,在調(diào)用這個 lambda 表達(dá)式的時候,x 的值是執(zhí)行時的值。例如:
>>> x = 15
>>> a(10)
25
>>> x = 3
>>> a(10)
13
>>>
如果你想讓某個匿名函數(shù)在定義時就捕獲到值,可以將那個參數(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
>>>
在這里列出來的問題是新手很容易犯的錯誤,有些新手可能會不恰當(dāng)?shù)?lambda 表達(dá)式。 比如,通過在一個循環(huán)或列表推導(dǎo)中創(chuàng)建一個 lambda 表達(dá)式列表,并期望函數(shù)能在定義時就記住每次的迭代值。例如:
>>> funcs = [lambda x: x+n for n in range(5)]
>>> for f in funcs:
... print(f(0))
...
4
4
4
4
4
>>>
但是實際效果是運行是 n 的值為迭代的最后一個值。現(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ù)在定義時就能綁定到值。
你有一個被其他 python 代碼使用的 callable 對象,可能是一個回調(diào)函數(shù)或者是一個處理器, 但是它的參數(shù)太多了,導(dǎo)致調(diào)用時出錯。
如果需要減少某個函數(shù)的參數(shù)個數(shù),你可以使用functools.partial()。 partial() 函數(shù)允許你給一個或多個參數(shù)設(shè)置固定的值,減少接下來被調(diào)用時的參數(shù)個數(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ù)并返回一個新的 callable 對象。這個新的 callable 接受未賦值的參數(shù), 然后跟之前已經(jīng)賦值過的參數(shù)合并起來,最后將所有參數(shù)傳遞給原始函數(shù)。
本節(jié)要解決的問題是讓原本不兼容的代碼可以一起工作。下面我會列舉一系列的例子。
第一個例子是,假設(shè)你有一個點的列表來表示(x,y)坐標(biāo)元組。 你可以使用下面的函數(shù)來計算兩點之間的距離:
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è)你想以某個點為基點,根據(jù)點和基點之間的距離來排序所有的這些點。 列表的 sort() 方法接受一個關(guān)鍵字參數(shù)來自定義排序邏輯, 但是它只能接受一個單個參數(shù)的函數(shù)(distance()很明顯是不符合條件的)。 現(xiàn)在我們可以通過使用 partial()來解決這個問題:
>>> pt = (4, 3)
>>> points.sort(key=partial(distance,pt))
>>> points
[(3, 4), (1, 2), (5, 6), (7, 8)]
>>>
更進一步,partial()通常被用來微調(diào)其他庫函數(shù)所使用的回調(diào)函數(shù)的參數(shù)。 例如,下面是一段代碼,使用 multiprocessing 來異步計算一個結(jié)果值, 然后這個值被傳遞給一個接受一個 result 值和一個可選 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ù)時,通過使用partial()傳遞額外的 logging參數(shù)。 而multiprocessing對這些一無所知——它僅僅只是使用單個值來調(diào)用回調(diào)函數(shù)。
作為一個類似的例子,考慮下編寫網(wǎng)絡(luò)服務(wù)器的問題,socketserver模塊讓它變得很容易。 下面是個簡單的 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 增加一個可以接受其他配置選項的 __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 類中添加前綴了。 但是你再次運行程序后會報類似下面的錯誤:
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'
初看起來好像很難修正這個錯誤,除了修改 socketserver 模塊源代碼或者使用某些奇怪的方法之外。 但是,如果使用 partial()就能很輕松的解決——給它傳遞 ack參數(shù)的值來初始化即可,如下:
from functools import partial
serv = TCPServer(('', 15000), partial(EchoHandler, ack=b'RECEIVED:'))
serv.serve_forever()
在這個例子中,__init__() 方法中的 ack 參數(shù)聲明方式看上去很有趣,其實就是聲明 ack 為一個強制關(guān)鍵字參數(shù)。 關(guān)于強制關(guān)鍵字參數(shù)問題我們在7.2小節(jié)我們已經(jīng)討論過了,讀者可以再去回顧一下。
很多時候 partial() 能實現(xiàn)的效果,lambda 表達(dá)式也能實現(xiàn)。比如,之前的幾個例子可以使用下面這樣的表達(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))
這樣寫也能實現(xiàn)同樣的效果,不過相比而已會顯得比較臃腫,對于閱讀代碼的人來講也更加難懂。 這時候使用partial() 可以更加直觀的表達(dá)你的意圖(給某些參數(shù)預(yù)先賦值)。
你有一個除 __init__()方法外只定義了一個方法的類。為了簡化代碼,你想將它轉(zhuǎn)換成一個函數(shù)。
大多數(shù)情況下,可以使用閉包來將單個方法的類轉(zhuǎn)換成函數(shù)。 舉個例子,下面示例中的類允許使用者根據(jù)某個模板方案來獲取到 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'))
這個類可以被一個更簡單的函數(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'))
大部分情況下,你擁有一個單方法類的原因是需要存儲某些額外的狀態(tài)來給方法使用。 比如,定義 UrlTemplate 類的唯一目的就是先在某個地方存儲模板值,以便將來可以在 open()方法中使用。
使用一個內(nèi)部函數(shù)或者閉包的方案通常會更優(yōu)雅一些。簡單來講,一個閉包就是一個函數(shù), 只不過在函數(shù)內(nèi)部帶上了一個額外的變量環(huán)境。閉包關(guān)鍵特點就是它會記住自己被定義時的環(huán)境。 因此,在我們的解決方案中,opener() 函數(shù)記住了 template 參數(shù)的值,并在接下來的調(diào)用中使用它。
任何時候只要你碰到需要給某個函數(shù)增加額外的狀態(tài)信息的問題,都可以考慮使用閉包。 相比將你的函數(shù)轉(zhuǎn)換成一個類而言,閉包通常是一種更加簡潔和優(yōu)雅的方案。
你的代碼中需要依賴到回調(diào)函數(shù)的使用(比如事件處理器、等待后臺任務(wù)完成后的回調(diào)等), 并且你還需要讓回調(diào)函數(shù)擁有額外的狀態(tài)值,以便在它的內(nèi)部使用到。
這一小節(jié)主要討論的是那些出現(xiàn)在很多函數(shù)庫和框架中的回調(diào)函數(shù)的使用——特別是跟異步處理有關(guān)的。 為了演示與測試,我們先定義如下一個需要調(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)
實際上,這段代碼可以做任何更高級的處理,包括線程、進程和定時器,但是這些都不是我們要關(guān)心的。 我們僅僅只需要關(guān)注回調(diào)函數(shù)的調(diào)用。下面是一個演示怎樣使用上述代碼的例子:
>>> 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ù)僅僅只接受一個參數(shù) result 。不能再傳入其他信息。 而當(dāng)你想讓回調(diào)函數(shù)訪問其他變量或者特定環(huán)境的變量值的時候就會遇到麻煩。
為了讓回調(diào)函數(shù)訪問外部信息,一種方法是使用一個綁定方法來代替一個簡單函數(shù)。 比如,下面這個類會保存一個內(nèi)部序列號,每次接收到一個result的時候序列號加1:
class ResultHandler:
def __init__(self):
self.sequence = 0
def handler(self, result):
self.sequence += 1
print('[{}] Got: {}'.format(self.sequence, result))
使用這個類的時候,你先創(chuàng)建一個類的實例,然后用它的 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
>>>
第二種方式,作為類的替代,可以使用一個閉包捕獲狀態(tài)值,例如:
def make_handler():
sequence = 0
def handler(result):
nonlocal sequence
sequence += 1
print('[{}] Got: {}'.format(sequence, result))
return handler
下面是使用閉包方式的一個例子:
>>> handler = make_handler()
>>> apply_async(add, (2, 3), callback=handler)
[1] Got: 5
>>> apply_async(add, ('hello', 'world'), callback=handler)
[2] Got: helloworld
>>>
還有另外一個更高級的方法,可以使用協(xié)程來完成同樣的事情:
def make_handler():
sequence = 0
while True:
result = yield
sequence += 1
print('[{}] Got: {}'.format(sequence, result))
對于協(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ù)通常會跟請求執(zhí)行代碼斷開。 因此,請求執(zhí)行和處理結(jié)果之間的執(zhí)行環(huán)境實際上已經(jīng)丟失了。如果你想讓回調(diào)函數(shù)連續(xù)執(zhí)行多步操作, 那你就必須去解決如何保存和恢復(fù)相關(guān)的狀態(tài)信息了。
至少有兩種主要方式來捕獲和保存狀態(tài)信息,你可以在一個對象實例(通過一個綁定方法)或者在一個閉包中保存它。 兩種方式相比,閉包或許是更加輕量級和自然一點,因為它們可以很簡單的通過函數(shù)來構(gòu)造。 它們還能自動捕獲所有被使用到的變量。因此,你無需去擔(dān)心如何去存儲額外的狀態(tài)信息(代碼中自動判定)。
如果使用閉包,你需要注意對那些可修改變量的操作。在上面的方案中, nonlocal聲明語句用來指示接下來的變量會在回調(diào)函數(shù)中被修改。如果沒有這個聲明,代碼會報錯。
而使用一個協(xié)程來作為一個回調(diào)函數(shù)就更有趣了,它跟閉包方法密切相關(guān)。 某種意義上來講,它顯得更加簡潔,因為總共就一個函數(shù)而已。 并且,你可以很自由的修改變量而無需去使用nonlocal 聲明。 這種方式唯一缺點就是相對于其他 Python 技術(shù)而已或許比較難以理解。 另外還有一些比較難懂的部分,比如使用之前需要調(diào)用next() ,實際使用時這個步驟很容易被忘記。 盡管如此,協(xié)程還有其他用處,比如作為一個內(nèi)聯(lián)回調(diào)函數(shù)的定義(下一節(jié)會講到)。
如果你僅僅只需要給回調(diào)函數(shù)傳遞額外的值的話,還有一種使用 partial() 的方式也很有用。 在沒有使用 partial() 的時候,你可能經(jīng)??吹较旅孢@種使用 lambda 表達(dá)式的復(fù)雜代碼:
>>> apply_async(add, (2, 3), callback=lambda r: handler(r, seq))
[1] Got: 5
>>>
可以參考7.8小節(jié)的幾個示例,教你如何使用partial()來更改參數(shù)簽名來簡化上述代碼。
當(dāng)你編寫使用回調(diào)函數(shù)的代碼的時候,擔(dān)心很多小函數(shù)的擴張可能會弄亂程序控制流。 你希望找到某個方法來讓代碼看上去更像是一個普通的執(zhí)行序列。
通過使用生成器和協(xié)程可以使得回調(diào)函數(shù)內(nèi)聯(lián)在某個函數(shù)中。 為了演示說明,假設(shè)你有如下所示的一個執(zhí)行某種計算任務(wù)然后調(diào)用一個回調(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)
接下來讓我們看一下下面的代碼,它包含了一個 Async 類和一個 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
這兩個代碼片段允許你使用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() ,你會得到類似如下的輸出:
5
helloworld
0
2
4
6
8
10
12
14
16
18
Goodbye
你會發(fā)現(xiàn),除了那個特別的裝飾器和yield語句外,其他地方并沒有出現(xiàn)任何的回調(diào)函數(shù)(其實是在后臺定義的)。
本小節(jié)會實實在在的測試你關(guān)于回調(diào)函數(shù)、生成器和控制流的知識。
首先,在需要使用到回調(diào)的代碼中,關(guān)鍵點在于當(dāng)前計算工作會掛起并在將來的某個時候重啟(比如異步執(zhí)行)。 當(dāng)計算重啟時,回調(diào)函數(shù)被調(diào)用來繼續(xù)處理結(jié)果。apply_async() 函數(shù)演示了執(zhí)行回調(diào)的實際邏輯, 盡管實際情況中它可能會更加復(fù)雜(包括線程、進程、事件處理器等等)。
計算的暫停與重啟思路跟生成器函數(shù)的執(zhí)行模型不謀而合。 具體來講,yield 操作會使一個生成器函數(shù)產(chǎn)生一個值并暫停。 接下來調(diào)用生成器的 __next__() 或send() 方法又會讓它從暫停處繼續(xù)執(zhí)行。
根據(jù)這個思路,這一小節(jié)的核心就在 inline_async() 裝飾器函數(shù)中了。 關(guān)鍵點就是,裝飾器會逐步遍歷生成器函數(shù)的所有 yield語句,每一次一個。 為了這樣做,剛開始的時候創(chuàng)建了一個result 隊列并向里面放入一個 None 值。 然后開始一個循環(huán)操作,從隊列中取出結(jié)果值并發(fā)送給生成器,它會持續(xù)到下一個 yield語句, 在這里一個 Async 的實例被接受到。然后循環(huán)開始檢查函數(shù)和參數(shù),并開始進行異步計算 apply_async() 。 然而,這個計算有個最詭異部分是它并沒有使用一個普通的回調(diào)函數(shù),而是用隊列的put() 方法來回調(diào)。
這時候,是時候詳細(xì)解釋下到底發(fā)生了什么了。主循環(huán)立即返回頂部并在隊列上執(zhí)行 get()操作。 如果數(shù)據(jù)存在,它一定是put() 回調(diào)存放的結(jié)果。如果沒有數(shù)據(jù),那么先暫停操作并等待結(jié)果的到來。 這個具體怎樣實現(xiàn)是由 apply_async() 函數(shù)來決定的。 如果你不相信會有這么神奇的事情,你可以使用 multiprocessing 庫來試一下, 在單獨的進程中執(zhí)行異步計算操作,如下所示:
if __name__ == '__main__':
import multiprocessing
pool = multiprocessing.Pool()
apply_async = pool.apply_async
# Run the test function
test()
實際上你會發(fā)現(xiàn)這個真的就是這樣的,但是要解釋清楚具體的控制流得需要點時間了。
將復(fù)雜的控制流隱藏到生成器函數(shù)背后的例子在標(biāo)準(zhǔn)庫和第三方包中都能看到。 比如,在 contextlib中的 @contextmanager 裝飾器使用了一個令人費解的技巧, 通過一個yield 語句將進入和離開上下文管理器粘合在一起。 另外非常流行的 Twisted包中也包含了非常類似的內(nèi)聯(lián)回調(diào)。
你想要擴展函數(shù)中的某個閉包,允許它能訪問和修改函數(shù)的內(nèi)部變量。
通常來講,閉包的內(nèi)部變量對于外界來講是完全隱藏的。 但是,你可以通過編寫訪問函數(shù)并將其作為函數(shù)屬性綁定到閉包上來實現(xiàn)這個目的。例如:
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
>>>
為了說明清楚它如何工作的,有兩點需要解釋一下。首先,nonlocal 聲明可以讓我們編寫函數(shù)來修改內(nèi)部變量的值。 其次,函數(shù)屬性允許我們用一種很簡單的方式將訪問方法綁定到閉包函數(shù)上,這個跟實例方法很像(盡管并沒有定義任何類)。
還可以進一步的擴展,讓閉包模擬類的實例。你要做的僅僅是復(fù)制上面的內(nèi)部函數(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()
下面是一個交互式會話來演示它是如何工作的:
>>> 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
>>>
有趣的是,這個代碼運行起來會比一個普通的類定義要快很多。你可能會像下面這樣測試它跟一個類的性能對比:
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)
如果這樣做,你會得到類似如下的結(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é)果顯示,閉包的方案運行起來要快大概8%,大部分原因是因為對實例變量的簡化訪問, 閉包更快是因為不會涉及到額外的 self 變量。
Raymond Hettinger 對于這個問題設(shè)計出了更加難以理解的改進方案。不過,你得考慮下是否真的需要在你代碼中這樣做, 而且它只是真實類的一個奇怪的替換而已,例如,類的主要特性如繼承、屬性、描述器或類方法都是不能用的。 并且你要做一些其他的工作才能讓一些特殊方法生效(比如上面 ClosureInstance中重寫過的 __len__()實現(xiàn)。)
最后,你可能還會讓其他閱讀你代碼的人感到疑惑,為什么它看起來不像一個普通的類定義呢? (當(dāng)然,他們也想知道為什么它運行起來會更快)。盡管如此,這對于怎樣訪問閉包的內(nèi)部變量也不失為一個有趣的例子。
總體上講,在配置的時候給閉包添加方法會有更多的實用功能, 比如你需要重置內(nèi)部狀態(tài)、刷新緩沖區(qū)、清除緩存或其他的反饋機制的時候。