Python 装饰器和闭包

2018/06/01 11:17 上午 posted in  Python

装饰器是 Python 中常见的语法糖,这篇文章讲了闭包和装饰器的原理,并且分析了函数中变量的作用域,以及尝试总结了常见的坑。

装饰器基础

首先来看看装饰器的定义:装饰器本身是一个可调用对象,接收一个函数作为参数,然后将其返回或替换成另一个函数或可调用对象。可以理解成,原来那是一堵白墙,经过了一层装饰,这层装饰可能是漆也可能把墙掏空镶进一层黄金变得金光闪闪,最后呈现在你眼前的就是装饰后的样子。

严格来说,装饰器只是语法糖,可以把它完全当成常规函数来调用,其参数是另一个函数。装饰器有两大特征,一是能把被装饰的函数替换成其他函数,二是装饰器在加载函数时立即执行。用一个🌰来看看这两个特性:

def red_oil(func):
    print("ready to paint!")

    def red_wall_func():
        print("red wall!")

    return red_wall_func


@red_oil
def wall():
    print("wall!")


if __name__ == "__main__":
    print("start painting!!!")
    wall()

运行结果:

ready to paint!
start painting!!!
red wall!

可以看出,装饰器 red_oil 将函数 wall 替换成了另一个函数,就好比在原来的墙上刷了一层红漆。而装饰器在被装饰的函数被定义时立即执行,而被装饰的函数在运行的时候才执行,这也是导入时和运行时的区别。

闭包

通常函数里声明的局部变量的作用范围是函数内,而未声明的变量视为全局变量,如果连全局变量都没有声明,就是 bug 了。但是 Python 的设计是不要求声明变量,那么函数中变量的作用范围就不一样了,为了延长作用范围,引进了闭包。下面一一做解析。

函数中变量的作用范围

Python 中不要求声明变量,但是假定在函数定义体中赋值的变量是局部变量。如果在函数中赋值时想把某变量当成全局变量,需要使用 global 声明:

red_paint = "red oil"


def wall(paint):
    print(paint)
    global red_paint
    print(red_paint)


if __name__ == "__main__":
    wall("yellow oil")

闭包的定义

闭包是指延伸了作用域的函数,其中包含函数定义体中引用、但不在定义体中定义的非全局变量。来看个🌰:

def wall():
    paints = []

    def paint_wall(color):
        paints.append(color)
        return paints

    return paint_wall


if __name__ == "__main__":
    white_wall = wall()
    white_wall("red")
    result = white_wall("yellow")
    print(result)

运行结果: ['red', 'yellow'] 。可以看出,变量 paints 是在函数 paint_wall 外定义的,这称作自由变量,指未在本地作用域中绑定的变量。而 paint_wall 的闭包衍生到函数的作用域之外,包含自由变量 paints 的绑定。在之前的文章《Python 一等函数》中讲到,函数的 __code__ 属性指“编译成字节码的函数元数据和函数定义体”,意思是指编译后的函数定义体,保存了局部变量和自由变量的名称。我们可以用上面的🌰查看。

In [9]: white_wall.__code__.co_varnames
Out[9]: ('color',)

In [10]: white_wall.__code__.co_freevars
Out[10]: ('paints',)

闭包实际上也是一种函数,但是会保留自由变量的绑定,这样在调用函数时,虽然定义作用域不可用了,但是这些绑定仍然能够使用。

闭包中的坑

上面刷墙的🌰中,自由变量是个可变类型的变量。但是当这个自由变量是个不可变类型的时候,比如数字、字符串、元组等,就掉进坑里了。我们把上个🌰稍作修改:

def wall():
    nums = 0
    oils = []

    def paint_wall(color):
        nums += 1
        oils.append(color)
        print("Paint wall by color: " + color)
        print("Paint wall {} times".format(nums))
        return oils

    return paint_wall


if __name__ == "__main__":
    white_wall = wall()
    white_wall("red")
    white_wall("yellow")

运行后出错:

Traceback (most recent call last):
  File "/Users/zww/.../decorator.py", line 28, in <module>
    white_wall("red")
  File "/Users/zww/.../decorator.py", line 17, in paint_wall
    nums += 1
UnboundLocalError: local variable 'nums' referenced before assignment

可以看到报错提示是赋值前引用了局部变量 nums 。这是因为闭包中定义的自由变量 nums 被赋值为 0, 这是不可变量,只能读取不能更新。而函数 paint_wallnums += 1 其实是重新绑定,会隐式创建了一个局部变量 nums,也就意味着这个时候 nums 不再是之前那个自由变量,自然不会保存在闭包里。

nonlocal 声明

为了从上面这种坑中跳出来,Python3 引入了 nonlocal 声明,作用是将变量标记为自由变量,也就意味着我们可以对做了 nonlocal 声明的变量进行修改。上面的🌰修改后如下:

def wall():
    nums = 0
    oils = []

    def paint_wall(color):
        nonlocal nums
        nums += 1
        oils.append(color)
        print("Paint wall by color: " + color)
        print("Paint wall {} times".format(nums))
        return oils

    return paint_wall


if __name__ == "__main__":
    white_wall = wall()
    white_wall("red")
    white_wall("yellow")

运行结果:

Paint wall by color: red
Paint wall 1 times
Paint wall by color: yellow
Paint wall 2 times

functools.wraps

理论上讲,被装饰后的函数在被调用后成了另一个函数。但是往往我们需要装饰器实现的作用是:在增强一部分逻辑的基础上,不改变原函数的属性和方法。而 Python 标准库中 functools.wraps 就是实现这个功能的。 wraps 把相关属性从原函数复制到装饰器中,并且能够正确处理关键字参数。

from functools import wraps


def oil(func):
    @wraps(func)
    def wall_func(*args, **kwargs):
        func(*args, **kwargs)

    return wall_func


@oil
def wall(**kwargs):
    color = kwargs["color"]
    print("paint wall to " + color)
    return color


if __name__ == "__main__":
    wall(color="red")
    print(wall.__name__)

运行结果:

paint wall to red
wall

带参数的装饰器

既然装饰器本质上就是一个函数,那装饰器也可以传参。那么具体应该怎么做呢?在 Python 中需要建立一个装饰器工厂函数,把参数传给它,再返回一个装饰器,然后应用到要装饰的函数上。

from functools import wraps


def oil_box(box_name):
    def oil(func):
        @wraps(func)
        def wall_func(*args, **kwargs):
            print("from box: " + box_name)
            func(*args, **kwargs)

        return wall_func

    return oil


@oil_box("iron box")
def wall(**kwargs):
    color = kwargs["color"]
    print("paint wall to: " + color)
    return color


if __name__ == "__main__":
    wall(color="red")

运行结果:

from box: iron box
paint wall to: red

虽然看上去 oil_box 是作为装饰器使用,但是它却不是真正意义上的装饰器,而是一个装饰器工厂函数,其返回的是装饰器。所以带参数的装饰器需要两层嵌套的结构才能实现。

单分派泛函数

在程序中,一种很常见的场景就是需要根据一个变量取不同值时,分别调用不同的函数或对象来适应不同的应用场景。比如我们上面刷墙的例子中,我需要根据输入变量的类型来做不同的事情,而 Python 不支持重载函数,所以 Python 中常用的方式将 oil 函数变成分派函数,用 if/elif/else 调用不同的函数,这样十分不利于代码管理。

Python 3.4 中新增的装饰器 functools.singledispatch 为这种情况提供了很便利的方案:把整体方案拆分成多个模块,甚至可以为你无法修改的类提供专门的函数,而被 singledispatch 装饰的函数就成了泛函数,即根据第一个参数的类型,以不同方式执行相同操作的一组函数。我们使用这一方案来改装上面的🌰:

from functools import singledispatch


@singledispatch
def oil(obj):
    print("start painting !")
    return ""


@oil.register(str)
def _(color):
    print("paint wall to: " + color)
    return color


@oil.register(int)
def _(n):
    print("paint {} walls".format(n))
    return n


@oil.register(tuple)
def _(seq):
    print("paint walls in different directions: {}".format(seq))
    return seq


if __name__ == "__main__":
    oil("red")
    oil(2)
    oil(("north", "east"))

运行结果:

paint wall to: red
paint 2 walls
paint walls in different directions: ('north', 'east')

@singledispath 的优点是支持模块化扩展:各个模块可以为它支持的各个类型注册一个专门函数,也就变相地实现了函数重载。

类实现装饰器

Python 中一切皆对象,那么装饰器也可以通过类来实现。而当我们需要在装饰器中实现一些比较复杂的逻辑的时候,函数明显不够用,用类实现是最好的选择。

用类来实现装饰器做法跟函数一样,同样是需要语法糖 @ ,然而 @ 是直接调用后面的对象,所以这里跟函数不同的是,需要实现类的 __init__ 方法来接受参数, __call__ 方法来实现调用。我们用类来重构上面刷墙的🌰。

from functools import wraps


class Oil_box:
    def __init__(self, box_name):
        self.box_name = box_name

    def oil(self):
        print("from box: " + self.box_name)

    def __call__(self, func):
        @wraps(func)
        def wall_func(*args, **kwargs):
            self.oil()
            func(*args, **kwargs)

        return wall_func


@Oil_box("iron box")
def wall(**kwargs):
    color = kwargs["color"]
    print("paint wall to: " + color)
    return color


if __name__ == "__main__":
    wall(color="red")

甚至还可以将类作为装饰器参数,只要实现不同的类,共用一套装饰器的逻辑,就能有更多的玩法。

from functools import wraps


class IronBox:
    box_name = "iron_box"

    @classmethod
    def paint(cls):
        print("from box: " + cls.box_name)


def oil_box(cls):
    def oil(func):
        @wraps(func)
        def wall_func(*args, **kwargs):
            cls.paint()
            func(*args, **kwargs)

        return wall_func

    return oil


@oil_box(IronBox)
def wall(**kwargs):
    color = kwargs["color"]
    print("paint wall to: " + color)
    return color


if __name__ == "__main__":
    wall(color="red")

上面这个🌰中需要注意的就是每个装饰器的类参数,都需要实现一个类函数 paint()

总结

到这里本文关于装饰器的内容就介绍完了。主要从装饰器、函数的变量作用域、闭包以及不同的方法实现装饰器等方面,对装饰器进行了介绍。灵活运用装饰器的不同实现方法,可以实现很多好玩的功能。