Python函数式编程09 闭包和装饰器

闭包

闭包的概念

第 3 节中,介绍了高阶函数的概念,一个高阶函数可以返回另一个函数。但实际上,Python 的函数定义还有另一个有意思的地方:一个函数的定义可以嵌套在另一个函数之内,也就是说,如下所示的定义:

def func(...):
    ...
    def inner_func(...):
        ...
    ...

是完全可以的。这种情况下,内部定义的函数会视为在函数内创建了一个类型为函数的局部对象。

嵌套定义的函数有一个特别的地方在于,嵌套函数可以使用函数内定义的局部变量;并且如果嵌套函数被当做结果返回时,如果调用这个返回结果,它可以访问到内部定义的局部变量。

以下展示了一个这样的情形,注意到在调用函数后,就将这个函数给删除了:

def func():
    n = 10
    def inner_n():
        return n
    return inner_n

inner_func = func()
del func
print(inner_func())

结果为:

python -u demo.py 10

可以看到,即便函数被删除后,内部函数依旧记录着函数提供的局部变量的信息。这种返回的函数包含外部函数绑定的变量,称为闭包(closure)。

闭包能够让内部函数记录局部变量的信息,本质上通过在内部函数中使用 __closure__ 属性保存这些数据。

闭包的一个用途就是通过参数批量生成所需的函数。例如,以下函数利用闭包的特性,可以批量生成多项式函数:

from itertools import starmap

def polinomial(*coeffs):
    def inner(x):
        return sum(starmap(lambda i, coeff: coeff * x ** i,
                           enumerate(reversed(coeffs)))
                   )
    return inner

这样可以通过控制几个参数得到不同的函数:

>>> regr = polinomial(2, 1) >>> regr(3) 7 >>> polinomial(4, 3, 7, 2) <function polinomial.<locals>.inner at 0x0000023B89B85A60>

闭包和nonlocal声明

函数内可能会处理全局变量,同理嵌套在其他函数中的函数也可能需要处理不在全局作用域中的外部变量:如果在嵌套函数内对不在全局作用域中的外部变量重新赋值,那么影响范围只会局限于嵌套函数中,这就会出现一些问题。

例如,以下使用闭包创建了一个累积平均值函数的生产函数,并通过绑定外部函数的 history_data 列表记录历史信息:

def cumulative_average():
    history_data = []
    def cumulate(*new_data):
        history_data.extend(new_data)
        return sum(history_data) / len(history_data)
    return cumulate

每次调用 cumulative_average() 函数,它都会生成一个新的 history_data 局部变量并被闭包使用,使得每次调用的结果互不影响:

>>> avg = cumulative_average() >>> avg(15, 10, 12, 18, 13) 13.6 >>> avg(16, 21, 17, 19) 15.66666666 >>> avg2 = cumulative_average() >>> avg2(200, 210, 170, 190) 192.5 >>> avg2(11, 9, 10, 12) 101.5

不过这个函数的实现有一个效率问题:函数将所有历史值都存储了起来,每次增加新的值时都需要重新使用它们来计算总和以及值的总个数,这既浪费了时间又浪费了空间。一种比较好的改进思路是只保存历史值的个数和总和,这样只需要动态更新这两个值就可以了。但是,可能一般人的思路是直接将代码改成这样:

def cumulative_average():
    history_count = 0
    history_sum = 0
    def cumulate(*new_data):
        history_count += len(new_data)
        history_sum += sum(new_data)
        return history_sum / history_count
    return cumulate

这个代码看似没有问题,但在执行时会抛出 UnboundLocalError 错误,错误原因是变量 history_count 未定义。这个问题实际上出在 += 运算符上,因为它是一种赋值操作,所以 Python 解释器会将等号左侧的值 history_count 视为 cumulate 函数的局部变量,使得赋值前无法在当前的局部作用域内找到 history_count 的值,产生了未定义的错误。而之前使用列表的实现恰好利用了可变对象的特点,使得内层函数影响了外部函数的变量。

一般的函数在定义时也存在这个问题,解决方法是使用 global 关键字在函数内将一个变量声明为全局变量;但使用闭包时不能声明为全局变量。为此,Python3 引入了关键字 nonlocal ,它的作用效果类似:nonlocal 关键字可以声明一个变量为一个不在全局作用域中的外部变量声明,使得对该变量的重新赋值或修改的影响范围不局限于嵌套函数体内部。因此,以上闭包只需要添加上 nonlocal 声明就能正常工作了:

def cumulative_average():
    history_count = 0
    history_sum = 0
    def cumulate(*new_data):
        nonlocal history_count, history_sum
        history_count += len(new_data)
        history_sum += sum(new_data)
        return history_sum / history_count
    return cumulate

nonlocal 声明的变量的赋值操作会直接更新变量的值,而不是创建一个新的变量。

装饰器

装饰器的基本概念

装饰器(decorator)是 Python 的语法糖,其作用是增强函数的功能。在编写大型项目时,需要给很多函数加上某个相似功能,或是一些函数的某个基本功能需要重复使用,又或者需要将函数内某部分内容独立出来便于维护和更新,这些便可以使用函数的装饰器来实现。

装饰器可以在不改变函数原有的结构下,增强函数的功能。装饰器适用于以下两个场景:

  1. 增强被装饰函数的功能
  2. 代码复用

装饰器的思路可以从高阶函数开始。假设有函数:

def demo01():
    print('hello')

def demo02():
    return 'hello'

在某个比较大的项目内,可能不知道这两个函数是在哪里执行的。现在需要增加这两个函数的功能,能让函数在调用时,显示函数名、调用时间、保存的局部变量等信息,但又希望不要逐个修改函数结构,否则这样会让工程量大大增加,而且容易出现疏忽。

这个时候,很自然地就会想到利用高阶函数。即新定义一个函数,它能够利用传入的函数对象输出其基本信息,然后再执行该函数。也就是说,对于这个高阶函数的需求有:

  • 它需要接受一个函数作为参数,这样在函数体内输出该函数的函数名
  • 考虑到函数是要被调用的,所以高阶函数不能影响函数正常的调用功能,这就要求高阶函数返回结果必须要是传入的函数

那么关于该提供调试信息的高阶函数便可以是这样:

def log(func):
    result = func()
    print(f'[{strftime("%H:%M:%S", localtime())}]'
          f' {func.__name__} returns {result}')
    return result

测试运行的效果为:

>>> log(demo01) hello [21:31:49] demo01 returns None >>> type(log(demo02)) [21:31:50] demo02 returns hello <class 'str'>

该函数基本实现了要求的功能,即不修改原函数的定义、不影响原函数的调用,同时又可以输出原函数名。

不过以上定义的高阶函数存在一定问题,例如对于以下这些函数:

double = polinomial(2, 0)
triple = polinomial(3, 0)

这些函数是含有参数的,如果使用原先的高阶函数,则会因为高阶函数传入的参数不再是一个函数名了,而是一个返回值而出错。因此,这样一来对高阶函数的需求又增加了:高阶函数应该能支持函数的传入参数功能。

这就可以使用之前介绍闭包时提到过的批量生产函数的用法:可以让函数在生产时不改变其用法,只输出基本信息,也就是说在高阶函数内部定义一个嵌套函数,让高阶函数传入的参数来供嵌套函数使用,再由返回的嵌套函数来执行需要的传参功能。因此,该高阶函数便可以是:

def log(func):
    def inner_func(x):
        result = func()
        print(f'[{strftime("%H:%M:%S", localtime())}]'
            f' {func.__name__} returns {result}')
        return result
    return inner_func

注意到定义装饰器函数时,其传参是依靠内层函数来完成的,因此嵌套函数的参数应该与装饰器函数的内层函数的参数数量相一致。这里使用可变参数 *args**kwargs 组合,就可以表示任意的传参情况。接下来的一个技巧就是使用覆盖原有的函数定义,使用添加功能后的同名函数:

double = log(double)
triple = log(triple)

这样不但可以正常地进行传参功能,还可以利用生产得到的具有更多功能的函数替换原有函数,来执行更高级的效果:

>>> triple(3) * 8 [21:41:34] inner returns 9 72

如果不需要这些功能了,就把生成与覆盖的过程去掉即可。

这就是装饰器的基本思路,使用嵌套函数实现的生产函数效果来替换原有函数,实现更丰富的功能。不过这种使用高阶函数的方式略显复杂,而且还需要定义很多新函数来执行传入参数功能。实际上,Python 中可以使用装饰器来很好地实现以上操作。相对于赋值创建一个新函数的方法去改变原有函数的功能,可以在函数定义的前一行,通过 @ 符号加上高阶函数名,来作为一个函数的装饰器。例如,以上修改后的高阶函数便可以作为一个装饰器来装饰函数:

@log
def four_times(x):
    return x * 4

是 Python 提供的一种更简洁的写法。也就是说,函数的装饰器使用下列形式来装饰一个函数:

@decorator
def function():
    ...

等价于下面这种普通形式:

def function():
    ...
function = decorator(function)

装饰器实际上就是一个函数,它将一个函数装饰成一个它修改之后的新的函数。需要注意的是,装饰器的函数是一个高阶函数,且必须要有一个位置参数。

可以看出,装饰器可以在不影响函数结果的同时,为函数增加一些新的功能,也就是“装饰”的字面意义。

下图表达了这种装饰的思路,可以看到装饰器就像一张图片的花边,让图片内容变得更丰富,但又不改变图片原有的含义:

图片素材来源于网络

例如,以下实现了一个装饰器 @timer ,可以在调用函数时同时检查函数运行的时间,从而统计出是哪一个函数拖慢了程序的运行:

def timer(func):
    def wrapper(*args, **kwargs):
        start = perf_counter()
        result = func(*args, **kwargs)
        print(f'{func.__name__} execs {perf_counter() - start} second(s)')
        return result
    return wrapper

一个函数的定义可以使用多个装饰器,结果与装饰器的位置顺序有关。例如:

@outer_decor
@inner_decor
def func(..):
    ...

它等价于:

def func(...):
    ...
func = outer_decor(inner_decor(func))

每一次添加装饰器,函数的功能就又丰富了一层。

带参数的装饰器

由于装饰器的本质就是高阶函数,当然可以在装饰函数时就加上参数。让装饰器加上参数可以进一步丰富装饰器的功能。

前文说过,装饰器

@decorator
def function():
    ...

等价于下面这种普通形式:

def function():
    ...
function = decorator(function)

如果此时让装饰器加上一个参数,例如 @decorator(arg) 。那么此时,真正作为高阶函数的表达式应该是 @decorator(arg) ,这个表达式可以接收一个函数作为参数,并且生成一个新的函数。也就是说,这个高阶函数再次嵌套了一个函数。

例如,以下修改之前定义的 log 函数,使它能够在装饰函数时可以通过参数自由控制显示的内容。这样,该函数便可以再嵌套一次,以完成二次传参作用:

def log(time=True, check_arg=False):
    def outer_wrapper(func):
        def inner_wrapper(*args, **kwargs):
            result = func(*args, **kwargs)
            if time:
                print(f'[{strftime("%H:%M:%S", localtime())}]', end=' ')
            print(func.__name__, end='')
            if check_arg:
                print('(', *args, *[f'{k}={v}' for k, v in kwargs.items()], ')', end='')
            print(f' returns {result}')

            return result
        return inner_wrapper
    return outer_wrapper

先分析一下该函数,当该函数作为装饰器装饰 function 函数时,等价于这种形式:

function = log(time, check_arg)(function)

该函数自左向右执行,首先将 log(time, check_arg) 返回 outer_wrapper ,变成 outer_wrapper(function) ,进一步执行返回 inner_wrapper ,赋值给变量 function ,因此传参和执行都由 inner_wrapper 来完成,该函数在执行时,会调用 log(time, check_arg) 接收的参数,并给函数增加更为复杂的功能。

以下编写了两个函数:isprime() 用于判断一个数是否为质数;prime_factorization() 则根据结果将一个大数分解为它的质因数。如果给第一个函数加上 @log 装饰器,那么在调用函数时就可以很清楚地看到程序执行的过程:

@log(check_arg=True)
def isprime(number: int):
    for i in range(2, number // 2):
        if number % i == 0:
            return False
    return True

def prime_factorization(number: int):
    factors = []
    while not isprime(number):
        for i in range(2, number // 2):
            if number % i == 0:
                factors.append(i)
                number //= i
                break
    factors.append(number)
    return factors

print(prime_factorization(6108984))

通过结果可以观察到质因数的分解过程:

python -u demo.py [11:05:32] isprime( 6108984 ) returns False [11:05:32] isprime( 3054492 ) returns False [11:05:32] isprime( 1527246 ) returns False [11:05:32] isprime( 763623 ) returns False [11:05:32] isprime( 254541 ) returns False [11:05:32] isprime( 84847 ) returns False [11:05:32] isprime( 12121 ) returns False [11:05:32] isprime( 713 ) returns False [11:05:32] isprime( 31 ) returns True [2, 2, 2, 3, 3, 7, 17, 23, 31]

当然,以上编写的程序运行效率还有许多优化空间,不过对于较小的数字还算够用。

完善装饰器

装饰器的基本结构已经大致完成,但是仍然有一些小缺陷。例如,以下装饰器可以将任意函数的返回值包装成 HTML 标签:

def html_tag(tag: str, **attrs):
    def outer_func(func):
        def inner_func(contain: str):
            attr = ''.join([f' {k}="{v}"' for k, v in attrs.items()])
            return '<{0}{1}>{2}</{0}>'.format(tag, attr, func(contain))
        return inner_func
    return outer_func

这个装饰器本身没有任何问题,以下是一个装饰示例:

@html_tag('p')
@html_tag('a', href='javascript: upload(this);')
def legalize(string):
    """Make the string a valid filename"""
    return sub(r'[\\\/:*?"<>|]', ' ', string)

print(legalize('<User>: name'))

这里为函数提供了帮助文档。运行结果为:

python -u demo.py <p><a href="javascript: upload(this);"> User name</a></p>

但是如果查看它的帮助文档,结果就变得奇怪了:

>>> help(legalize) Help on function inner_func in module __main__: inner_func(contain: str)

这个帮助文档看起来根本不是之前编写的函数。这是因为,当装饰器装饰函数后,被装饰函数被当做一个参数传递给了装饰器,然后就被同名变量取代了,实际上执行的是装饰器提供的 inner_func() 函数,因此获取的是这个函数的帮助文档。

要想完善该问题,可以在装饰器函数内部返回 inner_func 前进行一些改动,手动将原有函数的属性拷贝给返回的函数:

def html_tag(tag: str, **attrs):
    def outer_func(func):
        def inner_func(contain: str):
            ...
        inner_func.__name__ = func.__name__
        inner_func.__doc__ = func.__doc__
        # too many attributes need to update
        return inner_func
    return outer_func

这样的做法稍显复杂,因为函数的属性实际上是非常多的(例如,__dict__ 包含了各种额外属性,如果不更新会导致这些属性丢失)。实际上,Python 提供了一种方法,可以快速拷贝函数对象的所有属性给另一个函数,通过使用 functools 模块的 @wraps() 装饰器来完成该操作:

from functools import wraps

def html_tag(tag: str, **attrs):
    def outer_func(func):
        @wraps(func)
        def inner_func(contain: str):
            ...
        return inner_func
    return outer_func

这样再次检查帮助文档的结果就正确了:

>>> help(legalize) Help on function legalize in module __main__: legalize(string) Make the string a valid filename

至此,装饰器便已经非常完善了。装饰器作为在不修改函数本义的情况下,可以增加函数的功能,有效增加了代码的复用性,并使写出的代码结构更清晰。在一些大型框架中,通过编写强大的装饰器,只需要非常简短的函数就可以装饰出非常丰富的功能,大大降低了编程的复杂程度。

例如,以下是第三方框架 Flask 的一个非常简单的示例:

from flask import Flask, render_template

app = Flask(__name__)

@app.route('/', methods=['GET'])
def main():
    return render_template('index.html')

if __name__ == '__main__':
    app.run(debug=True)

只需要一个装饰器,便可以将一个只有两行的函数变成一个网络应用,当在浏览器中访问本地端口时就会将 index.html 中的内容显示在浏览器上,但在代码中几乎完全不用涉及这方面的内容,这就是装饰器的强大之处。

京ICP备2021034974号
contact me by hello@frozencandles.fun