Python仙术 第6期 闭包、工厂函数和装饰器

有些人踌躇满志想学Python,
而我劝退他们的方式,就是丢给他们一份介绍装饰器的文档。

注:完整目录、以及以下演示的所有代码,可在此GitHub仓库中找到。
使用Google的Colab运行jupyter notebook或许是个不错的选择,Colab给的CPU时间还是很宽裕的(每天最多12小时?)。Colab支持直接打开GitHub仓库,无需手动拉取。

闭包

闭包,指的是延伸了作用域的函数。其中包含函数定义体中引用、但是不在定义体中定义的非全局变量。
通过闭包,我们可以创建一个特别的函数对象,它不仅有函数本身的定义,还有函数被创建时的环境。

这是一个基本的闭包示例:

def outer_function(x):
  def inner_function(y):
      return x + y
  return inner_function

closure = outer_function(10)
print(closure(5))    # 15
print(closure(10))   # 20

通过这个例子,我们可以看到,x=10 这个值在 closure 被创建后并没有消失,而是被保存在了 closure 中。
因此,“保持状态”是闭包的一个重要特性。利用这个性质可以实现一个简单的计数器:

def counter():
    count = 0
    def inner():
        nonlocal count
        count += 1
        return count
    return inner

c = counter()
print(c())    # 1
print(c())    # 2

这个例子中,count 被保存在了 inner 函数中,每次调用 innercount 都会更新。

Python的垃圾回收机制:Python通过引用计数机制来追踪对象的引用数。如果引用数不为0,说明对象仍然被引用,Python的垃圾回收机制就不会销毁这个对象;否则,垃圾回收机制会认为这是一个垃圾对象,并且在合适的时机将其销毁(不一定立即销毁)。

闭包能够保持状态,也与Python垃圾回收机制中的引用计数机制有关。例如,当我们执行 c = counter() 时,Python解释器会在 counter() 函数的作用域中创建一个 count 变量,然后将 inner 函数绑定到这个 count 变量上。
此时,c 保持着对 inner 函数的引用,而 inner 函数又保持着对 count 变量的引用,因此,这个作用域中的 count 变量的引用计数不会归零,对象不会被销毁。

工厂函数

工厂函数是一种返回函数的函数,它可以动态地创建函数。工厂函数是闭包的一个重要应用场景。

案例1:日志封装
由于不同级别日志的区别仅是保存位置的不同,
因此可以创建一个自定义的方法,来记录不同级别的日志:

def create_logger(log_file):
    def logger(message):
        with open(log_file, 'a') as file:
            file.write(message + '\n')
    return logger

error_logger = create_logger('error.log')
event_logger = create_logger('event.log')

error_logger('Error occurred: Division by zero')
event_logger('User "admin" logged in')

类似的,你可以创建更多个长期生效、互不干扰的 create_logger() 实例,每个实例都维护着自己作用域内的 log_file 变量。

案例2:面积计算器

class Circle:
    def __init__(self, radius):
        self.radius = radius
        
    def area(self):
        return 3.14 * self.radius ** 2
    
class Rectangle:
    def __init__(self, width, height):
        self.width = width
        self.height = height
        
    def area(self):
        return self.width * self.height
    
def create_shape(shape_type, *args, **kwargs):
    if shape_type == 'circle':
        return Circle(*args, **kwargs)
    elif shape_type == 'rectangle':
        return Rectangle(*args, **kwargs)
    else:
        raise ValueError('Invalid shape type')
    
circle = create_shape('circle', 5)
print(circle.area())
rectangle = create_shape('rectangle', 3, 4)
print(rectangle.area())

在这个例子中,create_shape() 是一个工厂函数,它能根据传入的 shape_type 参数动态地调度需要的函数。这种方式可以让我们在尽可能不修改原有代码的情况下,轻松地复用现有的代码,创建更多方法。

装饰器

学Python不会用装饰器,就像读大学不读宁波工程学院。
——沃兹基硕德

指正:Copilot说的

装饰器是Python中的一个极为重要的概念,它是一种高级函数,也是闭包的终极应用之一

装饰器的本质是一个闭包,它把一个函数作为输入并返回经过包装的函数。这种设计使得我们可以简单地将某个功能应用于多个函数上。

装饰器将被装饰的函数作为参数整个传入装饰器函数,然后返回一个新的函数,这个函数通常在原函数前后实现一些操作,而后调用原函数并返回其结果。

例如,可以定义一个装饰器,来记录函数的执行时间:

import time

def benchmark(func):
    def wrapper(*args, **kwargs):
        start = time.perf_counter()
        return_value = func(*args, **kwargs)
        end = time.perf_counter()
        print(f'函数 {func.__name__} 运行耗时 {end - start:.2f} 秒')
        return return_value
    return wrapper

@benchmark
def count_prime_number(n):
    count = 0
    for num in range(2, n + 1):
        prime = True
        for i in range(2, num):
            if num % i == 0:
                prime = False
                break
        if prime:
            count += 1
    return count

count_prime_number(10000)

这个例子中,benchmark 是一个装饰器,它返回的函数 wrapper 调用了原函数 count_prime_number,并在前后分别获取时间,以计算整个函数的运行时间。
如果不使用装饰器,那么上面的代码会变成这样:

import time

def count_prime_number(n):
    count = 0
    for num in range(2, n + 1):
        prime = True
        for i in range(2, num):
            if num % i == 0:
                prime = False
                break
        if prime:
            count += 1
    return count

def benchmark(func, *args, **kwargs):
    start = time.perf_counter()
    return_value = func(*args, **kwargs)
    end = time.perf_counter()
    print(f'函数 {func.__name__} 运行耗时 {end - start:.2f} 秒')
    return return_value

benchmark(count_prime_number, 10000)

显然使用装饰器的代码更加简洁、易读且可拓展。

装饰器还可以带参数(参见Flask神教)。
例如,以下示例代码展示了一个带参数的装饰器,来将不同的命令分配给不同函数:

def cmd_register():
    commands = {}
    def register(name):
        def wrapper(func):
            commands[name] = func
            return func
        return wrapper
    def run(name, *args, **kwargs):
        return commands[name](*args, **kwargs)
    return register, run

command, runner = cmd_register()

@command('add')
def add(a, b):
    return a + b

@command('sub')
def sub(a, b):
    return a - b

print(runner('add', 1, 2))
print(runner('sub', 3, 1))

你应该已经注意到了,上述代码中不仅仅有 register 这个装饰器,cmd_register 函数本身也利用了闭包的特性,它在函数的作用域范围内维护了一个名为 commands 的字典,用于保存命令和函数的映射关系。也就是说,这里的装饰器与上文的例子不同。它并不是直接执行函数,而是注册函数,等待调用。
此外,cmd_register 一次性返回了 registerrun两个函数,分别用于注册和执行命令,它们共享了上级作用域中的 commands 对象。
因此,你可以通过 command 装饰器快捷地注册命令和函数的映射关系,然后通过 runner 函数来执行这些命令。

个人认为上面的例子已经比较复杂了,如果能够完全理解,说明你对Python的函数对象的特性已经有了较深的理解,不再浮于命令式编程的阶段。

但是,这还没有结束,

我相信你已经准备好迎接挑战了,真正的挑战(感谢我吧,我可是写上注释了):

from numbers import Number

def cmd_register():
    commands = {}

    def register(name):
        def wrapper(func):
            commands[name] = func
            return func
        return wrapper

    def run(name, *args, **kwargs):
        return commands[name](*args, **kwargs)
    return register, run

def checker(args_checker, kwargs_checker):
    # 传入的两个检查器是两个函数,分别检查args和kwargs;如果是Bool类型-> True表示始终通过检查,False表示不能有参数
    def wrapper(func):
        def inner(*args, **kwargs):
            for val, check in zip([args, kwargs], [args_checker, kwargs_checker]):
                if check is True:
                    continue
                elif check is False and len(val) > 0:
                    raise ValueError(f"Argument error in function {func.__name__}")
                elif not isinstance(check, bool) and not check(val):
                    raise ValueError(f"Argument check failed in function {func.__name__}")
            return func(*args, **kwargs)
        return inner
    return wrapper

command, runner = cmd_register()

@command('add')
# 这里的匿名函数检查条件:传入参数都是Number类型
@checker(lambda x: all(isinstance(i, Number) for i in x), False)
def add(*args):
    return sum(args)

@command('dev')
# 这里的匿名函数检查条件:传入参数都是Number类型,且只有两个参数,且第二个参数不能为0
@checker(lambda x: all(isinstance(i, Number) for i in x) and len(x) == 2 and x[1]!=0, False)
def dev(*args):
    return args[0] / args[1]

if __name__ == '__main__':
    print(runner('add', 1, 2))
    print(runner('dev', 3, 1))

这个例子包含了:

  • 装饰器套娃
  • 闭包
  • 装饰器
  • 装饰器带参数
  • 闭包加装饰器参数套娃
  • 匿名函数
  • 可变参数
  • 可变参数解包
  • 匿名函数处理可变参数解包
15 个赞

把其它几期也放个链接啊

2 个赞

看字看字

2 个赞

ojbk

2 个赞

来学习咯,慢慢看

2 个赞

好东西。学习了。谢谢佬。mark

2 个赞

抓紧学习,缩小差距

2 个赞

真能水啊

2 个赞

来学习了

1 个赞

github仓库转移到组织里了

2 个赞