Python技法3:匿名函数、回调函数和高阶函数

1、定义匿名或内联函数

如果我们想提供一个短小的回调函数供sort()这样的函数用,但不想用def这样的语句编写一个单行的函数,我们可以借助lambda表达式来编写“内联”式的函数。如下图所示:

add = lambda x, y: x + y
print(add(2, 3)) # 5
print(add("hello", "world!")) # helloworld

可以看到,这里用到的lambda表达式和普通的函数定义有着相同的功能。
lambda表达式常常做为回调函数使用,有在排序以及对数据进行预处理时有许多用武之地,如下所示:

names = [ 'David Beazley', 'Brian Jones', 'Reymond Hettinger', 'Ned Batchelder']
sorted_names = sorted(names, key=lambda name: name.split()[-1].lower())
print(sorted_names)
# ['Ned Batchelder', 'David Beazley', 'Reymond Hettinger', 'Brian Jones']

lambda虽然灵活易用,但是局限性也大,相当于其函数体中只能定义一条语句,不能执行条件分支、迭代、异常处理等操作。

2、在匿名函数中绑定变量的值

现在我们想在匿名函数定义时完成对特定变量(一般是常量)的绑定,以便后期使用。如果我们这样写:

x = 10
a = lambda y: x + y 
x = 20
b = lambda y: x + y

然后计算a(10)b(10)。你可能希望结果是2030,然而实际程序的运行结果会出人意料:结果是3030
这个问题的关键在于lambda表达式中的x是个*变量(未绑定到本地作用域的变量),在运行时绑定而不是定义的时候绑定(其实普通函数中使用*变量同理),而这里执行a(10)的时候x已经变成了20,故最终a(10)的值为30。如果希望匿名函数在定义的时候绑定变量,而之后绑定值不再变化,那我们可以将想要绑定的变量做为默认参数,如下所示:

x = 10
a = lambda y, x=x: x + y
x = 20
b = lambda y, x=x: x + y
print(a(10)) # 20
print(b(10)) # 30

上面我们提到的这个陷阱常见于一些对lambda函数过于“聪明”的应用中。比如我们想用列表推导式来创建一个列表的lambda函数并期望lambda函数能记住迭代变量。

funcs = [lambda x: x + n for n in range(5)]
for f in funcs:
    print(f(0))
# 4
# 4
# 4
# 4
# 4

可以看到与我们期望的不同,所有lambda函数都认为n是4。如上所述,我们修改成以下代码即可:

funcs = [lambda x, n=n: x + n for n in range(5)]
for f in funcs:
    print(f(0))
# 0
# 1
# 2
# 3
# 4

2、让带有n个参数的可调用对象以较少的参数调用

假设我们现在有个n个参数的函数做为回调函数使用,但这个函数需要的参数过多,而回调函数只能有个参数。如果需要减少函数的参数数量,需要时用functools包。functools这个包内的函数全部为高阶函数。高阶函数即参数或(和)返回值为其他函数的函数。通常来说,此模块的功能适用于所有可调用对象。
比如functools.partial()就是一个高阶函数, 它的原型如下:

functools.partial(func, /, *args, **keywords)

它接受一个func函数做为参数,并且它会返回一个新的newfunc对象,这个新的newfunc对象已经附带了位置参数args和关键字参数keywords,之后在调用newfunc时就可以不用再传已经设定好的参数了。如下所示:

def spam(a, b, c, d):
  print(a, b, c, d)

from functools import partial
s1 = partial(spam, 1) # 设定好a = 1(如果没指定参数名,默认按顺序设定)
s1(2, 3, 4) # 1 2 3 4

s2 = partial(spam, d=42) # 设定好d为42
s2(1, 2, 3) # 1 2 3 42

s3 = partial(spam, 1, 2, d=42) #设定好a = 1, b = 2, d = 42
s3(3) # 1 2 3 42

上面提到的技术常常用于将不兼容的代码“粘”起来,尤其是在你调用别人的*,而别人写好的函数不能修改的时候。比如我们有以下一组元组表示的点的坐标:

points = [(1, 2), (3, 4), (5, 6), (7, 8)]

有已知的一个distance()函数可供使用,假设这是别人造的*不能修改。

import math
def distance(p1, p2):
    x1, y1 = p1
    x2, y2 = p2
    return math.hypot(x2 - x1, y2 - y1)

接下来我们想根据列表中这些点到一个定点pt=(4, 3)的距离来排序。我们知道列表的sort()方法
可以接受一个key参数(传入一个回调函数)来做自定义的排序处理。但传入的回调函数只能有一个参数,这里的distance()函数有两个参数,显然不能直接做为回调函数使用。下面我们用partical()来解决这个问题:

pt = (4, 3)
points.sort(key=partial(distance, pt)) # 先指定好一个参数为pt=(4,3)
print(points)
# [(3, 4), (1, 2), (5, 6), (7, 8)]

可以看到,排序正确运行。还有一种方法要臃肿些,那就是将回调函数distance嵌套进另一个只有一个参数的lambda函数中:

pt = (4, 3)
points.sort(key=lambda p: distance(p, pt))
print(points)
# [(3, 4), (1, 2), (5, 6), (7, 8)]

这种方法以来臃肿,二来仍然存在我们上面提到过的一个毛病,如果我们定义回调函数后对pt有所修改,就会发生我们上面所说的不愉快的事情:

pt = (4, 3)
func_key = lambda p: distance(p ,pt) 
pt = (0, 0) # 像这样,后面pt变了就GG
points.sort(key=func_key)
print(points)
# [(1, 2), (3, 4), (5, 6), (7, 8)]

可以看到,最终排序的结果由于后面pt的改变而变得完全不同了。所以我们还是建议大家采用使用functools.partial()函数来达成目的。
下面这段代码也是用partial()函数来调整函数签名的例子。这段代码利用multiprocessing模块以异步方式计算某个结果,然后用一个回调函数来打印该结果,该回调函数可接受这个结果和一个事先指定好的日志参数。

# result:回调函数本身该接受的参数, log是我想使其扩展的参数
def output_result(result, log=None):
    if log is not None:
        log.debug('Got: %r', result)

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

# DEBUG:test:Got: 7

下面这个例子则源于一个在编写网络服务器中所面对的问题。比如我们在socketServer模块的基础上,编写了下面这个简单的echo服务程序:

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

现在,我们想在EchoHandler类中增加一个__init__()方法,它接受额外的一个配置参数,用于事先指定ack。即:

class EchoHandler(StreamRequestHandler):
    def __init__(self, *args, ack, **kwargs):
        self.ack = ack
        super().__init__(*args, **kwargs) 
    def handle(self) -> None:
        for line in self.rfile:
            self.wfile.write(self.ack + line)

假如我们就这样直接改动,就会发现后面会提示__init__()函数缺少keyword-only参数ack(这里调用EchoHandler()初始化对象的时候会隐式调用__init__()函数)。 我们用partical()也能轻松解决这个问题,即为EchoHandler()事先提供好ack参数。

from functools import partial
serv = TCPServer(('', 15000), partial(EchoHandler, ack=b'RECEIVED'))
serv.serve_forever()

3、在回调函数中携带额外的状态

我们知道,我们调用回调函数后,就会跳转到一个全新的环境,此时会丢失我们原本的环境状态。接下来我们讨论如何在回调函数中携带额外的状态以便在回调函数内部使用。
因为对回调函数的应用在与异步处理相关的库和框架中比较常见,我们下面的例子也多和异步处理相关。现在我们定义了一个异步处理函数,它会调用一个回调函数。

def apply_async(func, args, *, callback):
    # 计算结果
    result = func(*args)
    # 将结果传给回调函数
    callback(result)

下面展示上述代码如何使用:

# 要回调的函数
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_reuslt()能够接受更多的参数,比如其他变量或者环境状态信息。比如我们想让print_result()函数每次的打印信息都包括一个序列号,以表示这是第几次被调用,如[1] ...[2] ...这样。首先我们想到,可以用额外的参数在回调函数中携带状态,然后用partial()来处理参数个数问题:

class SequenceNo:
    def __init__(self) -> None:
        self.sequence = 0

def handler(result, seq):
    seq.sequence += 1
    print("[{}] Got: {}".format(seq.sequence, result))

seq = SequenceNo()
from functools import partial
apply_async(add, (2, 3), callback=partial(handler, seq=seq)) 
# [1] Got: 5
apply_async(add, ('hello', 'world'), callback=partial(handler, seq=seq))
# [2] Got: helloworld

看起来整个代码有点松散繁琐,我们有没有什么更简洁紧凑的方法能够处理这个问题呢?答案是直接使用和其他类绑定的方法(bound-method)。比如面这段代码就将print_result做为一个类的方法,这个类保存了计数用的ack序列号,每当调用print_reuslt()打印一个结果时就递增1:

class ResultHandler:
    def __init__(self) -> None:
        self.sequence = 0
    def handler(self, result):
        self.sequence += 1
        print("[{}] Got: {}".format(self.sequence, result))

apply_async(add, (2, 3), callback=r.handler) 
# [1] Got: 5
apply_async(add, ('hello', 'world'), callback=r.handler) 
# [2] Got: helloworld

还有一种实现方法是使用闭包,这种方法和使用类绑定方法相似。但闭包更简洁优雅,运行速度也更快:

def make_handler():
    sequence = 0
    def handler(result):
        nonlocal sequence # 在闭包中编写函数来修改内层变量,需要用nonlocal声明
        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

最后一种方法,则是利用协程(coroutine)来完成同样的任务:

def make_handler_cor():
    sequence = 0
    while True:
        result = yield
        sequence += 1
        print("[{}] Got: {}".format(sequence, result))

handler = make_handler_cor()
next(handler) # 切记在yield之前一定要加这一句
apply_async(add, (2, 3), callback=handler.send) #对于协程来说,可以使用它的send()方法来做为回调函数
# [1] Got: 5
apply_async(add, ('hello', 'world'), callback=handler.send)
# [2] Got: helloworld
上一篇:SpringBoot核心技术-Web开发-请求参数解析原理


下一篇:【死磕 NIO】— Reactor 模式就一定意味着高性能吗?