深入理解python的闭包与装饰器
- 一、闭包与装饰器
-
- [1. 先回顾:函数也是对象](#1. 先回顾:函数也是对象)
-
- [1.1 函数可以赋给变量](#1.1 函数可以赋给变量)
- [1.2 函数可以作为参数](#1.2 函数可以作为参数)
- [1.3 函数可以作为返回值](#1.3 函数可以作为返回值)
- [2. 闭包:函数保留对外层变量的访问](#2. 闭包:函数保留对外层变量的访问)
-
- [2.1 从一个带配置的函数开始](#2.1 从一个带配置的函数开始)
- [2.2 闭包保留的不是整个外层函数](#2.2 闭包保留的不是整个外层函数)
- [2.3 不同调用可以创建独立的闭包](#2.3 不同调用可以创建独立的闭包)
- [3. 闭包中的状态与 nonlocal](#3. 闭包中的状态与 nonlocal)
-
- [3.1 创建一个计数器](#3.1 创建一个计数器)
- [3.2 为什么需要 nonlocal](#3.2 为什么需要 nonlocal)
- [3.3 修改可变对象不一定需要 nonlocal](#3.3 修改可变对象不一定需要 nonlocal)
- [3.4 不同计数器的状态独立](#3.4 不同计数器的状态独立)
- [4. 闭包的延迟绑定](#4. 闭包的延迟绑定)
-
- [4.1 为什么循环创建的函数都返回最后一个值](#4.1 为什么循环创建的函数都返回最后一个值)
- [4.2 使用默认参数绑定当前值](#4.2 使用默认参数绑定当前值)
- [4.3 使用工厂函数创建独立绑定](#4.3 使用工厂函数创建独立绑定)
- [5. 从手动包装到装饰器](#5. 从手动包装到装饰器)
-
- [5.1 一个重复出现的需求](#5.1 一个重复出现的需求)
- [5.2 手动包装函数](#5.2 手动包装函数)
- [5.3 替换原来的名称](#5.3 替换原来的名称)
- [5.4 使用 @ 语法](#5.4 使用 @ 语法)
- [6. 编写通用函数装饰器](#6. 编写通用函数装饰器)
-
- [6.1 转发参数和返回值](#6.1 转发参数和返回值)
- [6.2 使用 functools.wraps 保留元信息](#6.2 使用 functools.wraps 保留元信息)
- [6.3 异常时是否仍需执行收尾逻辑](#6.3 异常时是否仍需执行收尾逻辑)
- [7. 带参数的装饰器](#7. 带参数的装饰器)
-
- [7.1 为什么需要再加一层函数](#7.1 为什么需要再加一层函数)
- [7.2 完整示例](#7.2 完整示例)
- [7.3 近似展开](#7.3 近似展开)
- [8. 装饰时机与多个装饰器的顺序](#8. 装饰时机与多个装饰器的顺序)
-
- [8.1 装饰发生在执行函数定义时](#8.1 装饰发生在执行函数定义时)
- [8.2 多个装饰器由下向上应用](#8.2 多个装饰器由下向上应用)
- [8.3 调用时从外层进入](#8.3 调用时从外层进入)
- [8.4 顺序会改变实际语义](#8.4 顺序会改变实际语义)
- [9. 使用 lru_cache 缓存结果](#9. 使用 lru_cache 缓存结果)
-
- [9.1 为可复用计算增加缓存](#9.1 为可复用计算增加缓存)
- [9.2 参数必须可哈希](#9.2 参数必须可哈希)
- [9.3 清理缓存](#9.3 清理缓存)
- [9.4 不适合缓存哪些函数](#9.4 不适合缓存哪些函数)
- [9.5 缓存实例方法也有边界](#9.5 缓存实例方法也有边界)
- [10. 装饰实例方法](#10. 装饰实例方法)
-
- [10.1 普通包装函数通常可以接收 self](#10.1 普通包装函数通常可以接收 self)
- [10.2 与 classmethod、staticmethod 组合](#10.2 与 classmethod、staticmethod 组合)
如需转载,请附上链接:https://zhenghuisheng.blog.csdn.net/article/details/166900653
一、闭包与装饰器
前面我们已经学习了函数,并知道函数可以赋给变量、作为参数传入,也可以作为另一个函数的返回值。在面向对象章节中,还见过:
@property
@classmethod
@staticmethod
这些带 @ 的写法就是装饰器的应用。装饰器主要用来组织一些重复出现的行为。例如,多个函数都需要:
- 记录执行日志。
- 统计运行时间。
- 检查参数。
- 缓存计算结果。
如果把这些代码分别写进每个函数,就容易重复,也不方便统一修改。本篇从闭包开始,逐步理解装饰器的工作方式,并学习如何实现带参数的装饰器、叠加多个装饰器,以及使用标准库中的缓存装饰器。
1. 先回顾:函数也是对象
1.1 函数可以赋给变量
python
def greet(name):
return f"你好,{name}"
if __name__ == '__main__':
operation = greet
print(operation("小林"))
print(operation is greet)
运行结果:
你好,小林
True
这里:
operation = greet
没有执行函数,只是让 operation 指向同一个函数对象。
result = greet("小林")
1.2 函数可以作为参数
python
def square(number):
return number ** 2
def calculate(number, operation):
return operation(number)
if __name__ == '__main__':
print(calculate(5, square))
运行结果:
25
calculate() 接收一个函数,并在内部调用它。装饰器同样会接收函数,只是它通常还会返回一个新的可调用对象。
1.3 函数可以作为返回值
python
def create_greeting():
def greet(name):
return f"你好,{name}"
return greet
if __name__ == '__main__':
greeting = create_greeting()
print(greeting("小林"))
运行结果:
你好,小林
需要注意:
return greet
返回的是函数对象。
return greet(...)
2. 闭包:函数保留对外层变量的访问
2.1 从一个带配置的函数开始
假设需要创建不同折扣的计算函数:
python
def make_discount(rate):
def calculate(amount):
return amount * rate
return calculate
if __name__ == '__main__':
discount_80 = make_discount(0.8)
discount_90 = make_discount(0.9)
print(discount_80(100))
print(discount_90(100))
运行结果:
80.0
90.0
make_discount() 已经返回,但内部函数 calculate() 仍然能够访问外层的 rate。这种函数与它所引用的外层变量绑定共同存在的结构,称为闭包 。这里的金额只是用于说明函数行为。实际金额计算仍应根据业务选择整数分或 Decimal。
2.2 闭包保留的不是整个外层函数
在上面的例子中:
python
def calculate(amount):
return amount * rate
rate 既不是 calculate() 的参数,也不是它内部定义的局部变量,而是来自外层函数。闭包让这个被引用的外层绑定在需要时继续存在,并不是把外层函数的所有局部变量都无条件保存下来。
也不是所有嵌套函数都会形成闭包。如果内部函数没有引用外层局部变量,就不需要通过闭包保存这些绑定。
2.3 不同调用可以创建独立的闭包
python
def make_prefixer(prefix):
def add_prefix(text):
return f"{prefix}{text}"
return add_prefix
if __name__ == '__main__':
warning = make_prefixer("[警告] ")
info = make_prefixer("[信息] ")
print(warning("库存不足"))
print(info("保存完成"))
运行结果:
[警告] 库存不足
[信息] 保存完成
两次调用 make_prefixer(),分别建立自己的外层绑定,因此可以得到带不同配置的函数。闭包适合这种需求:创建一次配置,之后重复执行同一种操作。
一句话判断就是:这个内部函数里的变量,是不是必须去它的外层函数作用域里找?
3. 闭包中的状态与 nonlocal
3.1 创建一个计数器
python
def make_counter():
count = 0
def increment():
nonlocal count
count += 1
return count
return increment
if __name__ == '__main__':
counter = make_counter()
print(counter())
print(counter())
print(counter())
运行结果:
1
2
3
count 保存在闭包中,每次调用 increment() 都会继续更新它。
3.2 为什么需要 nonlocal
count += 1
会对名称 count 重新赋值。如果没有 nonlocal,Python 会把它视为内部函数的局部名称;但计算右侧时,这个局部名称还没有值,就会产生 UnboundLocalError。
python
nonlocal count
表示使用并重新绑定外层函数中已经存在的 count。
3.3 修改可变对象不一定需要 nonlocal
python
def make_recorder():
records = []
def record(message):
records.append(message)
return records.copy()
return record
if __name__ == '__main__':
recorder = make_recorder()
print(recorder("开始"))
print(recorder("结束"))
运行结果:
['开始']
['开始', '结束']
这里没有重新绑定名称 records,只是修改它引用的列表,因此不需要 nonlocal。
与前面参数和作用域章节的规则一致:
records.append(...):修改对象。records = [...]:重新绑定名称。
3.4 不同计数器的状态独立
python
def make_counter():
count = 0
def increment():
nonlocal count
count += 1
return count
return increment
if __name__ == '__main__':
first = make_counter()
second = make_counter()
print(first())
print(first())
print(second())
运行结果:
1
2
1
但如果只是:
second = first
两个变量就会引用同一个闭包函数,共享同一份计数状态。
4. 闭包的延迟绑定
4.1 为什么循环创建的函数都返回最后一个值
python
def build_functions():
functions = []
for number in range(3):
def get_number():
return number
functions.append(get_number)
return functions
if __name__ == '__main__':
functions = build_functions()
print([function() for function in functions])
运行结果:
[2, 2, 2]
这些函数引用的是同一个外层变量绑定 number。等到真正调用时,循环已经结束,number 的值为 2。闭包通常保留对变量绑定的访问,而不是自动保存定义当时的值快照。
4.2 使用默认参数绑定当前值
python
def build_functions():
functions = []
for number in range(3):
def get_number(value=number):
return value
functions.append(get_number)
return functions
if __name__ == '__main__':
print([function() for function in build_functions()])
运行结果:
[0, 1, 2]
默认参数表达式在执行函数定义时求值,因此每次记录当前的 number。这里使用的是默认参数机制,不是让闭包突然改变了绑定规则。
4.3 使用工厂函数创建独立绑定
另一种方式是每次调用工厂函数:
python
def make_getter(value):
def get_value():
return value
return get_value
def build_functions():
return [make_getter(number) for number in range(3)]
if __name__ == '__main__':
print([function() for function in build_functions()])
运行结果:
[0, 1, 2]
每次调用 make_getter() 都创建独立的 value 绑定,因此不会共享循环变量。
5. 从手动包装到装饰器
5.1 一个重复出现的需求
假设多个函数都需要在执行前后记录日志:
python
def calculate_total(price, quantity):
print("开始执行")
result = price * quantity
print("执行结束")
return result
如果每个业务函数都加入相同日志,就会产生重复代码。可以把公共行为移到一个包装函数中。
5.2 手动包装函数
python
def add_log(func):
def wrapper():
print("开始执行")
result = func()
print("执行结束")
return result
return wrapper
def greet():
print("你好,Python")
if __name__ == '__main__':
decorated_greet = add_log(greet)
decorated_greet()
运行结果:
python
开始执行
你好,Python
执行结束
这里的关系是:
add_log()接收原函数greet。- 创建内部函数
wrapper。 wrapper通过闭包保留对原函数func的访问。- 返回
wrapper。 - 调用
decorated_greet(),实际执行包装函数。
5.3 替换原来的名称
可以直接写:
greet = add_log(greet)
之后:
greet()
调用的就是包装后的函数。这不会原地修改原函数的代码,而是让名称 greet 重新绑定到包装后的函数。
5.4 使用 @ 语法
下面的写法:
python
@add_log
def greet():
print("你好,Python")
可以近似理解为:
python
def greet():
print("你好,Python")
greet = add_log(greet)
完整示例:
python
def add_log(func):
def wrapper():
print("开始执行")
result = func()
print("执行结束")
return result
return wrapper
@add_log
def greet():
print("你好,Python")
if __name__ == '__main__':
greet()
运行结果:
开始执行
你好,Python
执行结束
这样的 add_log 就是一个函数装饰器。
更一般地说,装饰器是接收被装饰对象并返回替代对象的可调用对象。本文主要关注接收函数并返回包装函数的常见形式。
6. 编写通用函数装饰器
6.1 转发参数和返回值
前面的 wrapper() 不接收参数,只适合无参函数。可以使用 *args 和 **kwargs:
python
def add_log(func):
def wrapper(*args, **kwargs):
print(f"开始执行:{func.__name__}")
result = func(*args, **kwargs)
print(f"执行结束:{func.__name__}")
return result
return wrapper
@add_log
def calculate_total(price, quantity=1):
return price * quantity
if __name__ == '__main__':
total = calculate_total(8, quantity=3)
print(f"金额:{total}")
运行结果:
开始执行:calculate_total
执行结束:calculate_total
金额:24
这里的关键是:
func(*args, **kwargs)
将包装函数收到的位置实参和关键字实参交给原函数。以及:
return result
将原函数的返回结果继续交给调用方。如果遗漏 return,调用方得到的就会是 None。
6.2 使用 functools.wraps 保留元信息
包装后,外部名称实际上指向 wrapper:
print(calculate_total.__name__)
如果没有额外处理,会得到:
wrapper
原来的函数名称、文档字符串等信息也可能被遮住。可以使用标准库的 wraps:
python
from functools import wraps
def add_log(func):
@wraps(func)
def wrapper(*args, **kwargs):
print(f"开始执行:{func.__name__}")
result = func(*args, **kwargs)
print(f"执行结束:{func.__name__}")
return result
return wrapper
@add_log
def calculate_total(price, quantity=1):
"""根据单价和数量计算金额。"""
return price * quantity
if __name__ == '__main__':
print(calculate_total.__name__)
print(calculate_total.__doc__)
print(calculate_total(8, 3))
运行结果:
calculate_total
根据单价和数量计算金额。
开始执行:calculate_total
执行结束:calculate_total
24
wraps() 会更新包装函数的相关元信息,并设置 __wrapped__ 指向被包装函数,方便检查工具追踪原函数。它不会自动修复错误的参数转发、返回值或业务行为。
6.3 异常时是否仍需执行收尾逻辑
下面的写法:
result = func(*args, **kwargs)
print("执行结束")
如果原函数抛出异常,第二行不会执行。
如果希望无论成功失败都记录耗时,可以使用 finally:
python
```python
# 从 functools 模块中导入 wraps
# wraps 主要用于装饰器中:
# 它可以让被装饰后的函数保留原函数的名字、文档等元信息
from functools import wraps
# 从 time 模块中导入 perf_counter
# perf_counter() 是一个高精度计时器,
# 很适合用来统计一段代码的执行耗时
from time import perf_counter
# 定义一个装饰器函数 timed
# func 表示将来被装饰的原函数
def timed(func):
# wraps(func) 表示:
# wrapper 虽然替代了原函数执行,
# 但仍然保留原函数 func 的函数名、文档等信息
@wraps(func)
def wrapper(*args, **kwargs):
# 记录函数开始执行时的时间
started = perf_counter()
try:
# 调用原来的函数 func
#
# *args:
# 接收任意数量的位置参数
#
# **kwargs:
# 接收任意数量的关键字参数
#
# return 会把原函数的执行结果继续返回出去
return func(*args, **kwargs)
finally:
# finally 中的代码无论函数是否正常执行完成,
# 甚至发生异常,都会执行
# 再次获取当前时间,并减去开始时间
# 得到函数实际执行耗时
elapsed = perf_counter() - started
# 打印原函数的名称和执行耗时
#
# func.__name__:
# 获取原函数名称
#
# elapsed:.6f:
# 保留 6 位小数
print(f"{func.__name__} 耗时:{elapsed:.6f} 秒")
# 返回内部函数 wrapper
#
# 这里形成了闭包:
# wrapper 内部使用了外层 timed 函数的 func 变量
return wrapper
# 使用 timed 装饰 calculate_sum
#
# 这段代码:
#
# @timed
# def calculate_sum(...):
#
# 本质上等价于:
#
# calculate_sum = timed(calculate_sum)
#
# 所以最终 calculate_sum 实际指向的是 wrapper 函数
@timed
def calculate_sum(limit):
# range(limit):
# 生成 0 到 limit - 1 的整数序列
#
# sum(...):
# 对这些整数进行求和
return sum(range(limit))
# 只有当前 Python 文件被直接运行时,
# 才会执行下面的代码
#
# 如果这个文件是被其他 Python 文件 import,
# 这里的代码不会自动执行
if __name__ == '__main__':
# 调用 calculate_sum
#
# 由于 calculate_sum 被 @timed 装饰,
# 实际调用流程是:
#
# calculate_sum(100_000)
# ↓
# wrapper(100_000)
# ↓
# 记录开始时间
# ↓
# 调用原来的 calculate_sum(100_000)
# ↓
# 得到计算结果
# ↓
# finally 中统计耗时
# ↓
# 返回原来的计算结果
result = calculate_sum(100_000)
# 打印最终求和结果
print(result)
```
输出类似:
calculate_sum 耗时:0.001234 秒
4999950000
这个装饰器没有捕获并吞掉原函数异常。如果原函数失败,记录耗时后,异常仍会继续向外传播。
7. 带参数的装饰器
7.1 为什么需要再加一层函数
假设希望日志装饰器能够配置操作名称:
@log_action("查询商品")
def find_product(...):
...
此时:
log_action("查询商品")
需要先返回一个真正接收函数的装饰器。因此通常会有三层:
python
def 配置函数(配置参数):
def 装饰器(原函数):
def 包装函数(*args, **kwargs):
...
return 包装函数
return 装饰器
7.2 完整示例
python
from functools import wraps
def log_action(action):
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
print(f"[{action}] 开始")
result = func(*args, **kwargs)
print(f"[{action}] 完成")
return result
return wrapper
return decorator
@log_action("查询商品")
def find_product(products, product_id):
for product in products:
if product["id"] == product_id:
return product
return None
if __name__ == '__main__':
products = [
{"id": 1001, "name": "矿泉水"},
{"id": 1002, "name": "可乐"}
]
print(find_product(products, 1002))
运行结果:
[查询商品] 开始
[查询商品] 完成
{'id': 1002, 'name': '可乐'}
三层职责:
| 层级 | 接收什么 | 返回什么 |
|---|---|---|
log_action(action) |
装饰器配置 | decorator |
decorator(func) |
被装饰函数 | wrapper |
wrapper(*args, **kwargs) |
实际调用参数 | 业务调用结果 |
7.3 近似展开
@log_action("查询商品")
def find_product(...):
...
可以近似理解为:
python
def find_product(...):
...
decorator = log_action("查询商品")
find_product = decorator(find_product)
这样看就能分清:配置参数与业务参数是在不同阶段传入的。
8. 装饰时机与多个装饰器的顺序
8.1 装饰发生在执行函数定义时
python
from functools import wraps
def mark(label):
print(f"创建装饰器:{label}")
def decorator(func):
print(f"包装函数:{func.__name__}")
@wraps(func)
def wrapper(*args, **kwargs):
print(f"调用包装函数:{label}")
return func(*args, **kwargs)
return wrapper
return decorator
@mark("演示")
def greet():
print("执行原函数")
if __name__ == '__main__':
print("进入主程序")
greet()
greet()
运行结果:
创建装饰器:演示
包装函数:greet
进入主程序
调用包装函数:演示
执行原函数
调用包装函数:演示
执行原函数
创建和应用装饰器的操作,在函数定义执行时发生。
如果函数定义位于模块顶层,这些操作通常会在模块导入期间发生。因此,装饰器配置阶段不应随意执行耗时操作或业务副作用。
8.2 多个装饰器由下向上应用
@first
@second
def target():
...
相当于:
target = first(second(target))
second 先包装原函数,first 再包装结果。
8.3 调用时从外层进入
python
from functools import wraps
def trace(label):
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
print(f"{label}:进入")
result = func(*args, **kwargs)
print(f"{label}:退出")
return result
return wrapper
return decorator
@trace("外层")
@trace("内层")
def work():
print("执行业务")
if __name__ == '__main__':
work()
运行结果:
外层:进入
内层:进入
执行业务
内层:退出
外层:退出
需要区分两个阶段:
- 装饰器表达式按代码中的顺序求值。
- 得到的装饰器从下向上应用。
- 调用包装后的函数时,从最外层进入,再逐层返回。
8.4 顺序会改变实际语义
例如计时与缓存:
@timed
@lru_cache(maxsize=128)
def calculate(value):
...
计时位于外层,每次调用都会经过计时,包括缓存命中。
反过来:
@lru_cache(maxsize=128)
@timed
def calculate(value):
...
缓存命中时,不会执行内部计时包装函数。
所以装饰器顺序不仅影响日志排列,还可能改变执行次数、性能统计和缓存行为。
9. 使用 lru_cache 缓存结果
9.1 为可复用计算增加缓存
functools.lru_cache 可以根据函数参数缓存返回结果。
python
from functools import lru_cache
@lru_cache(maxsize=128)
def square(number):
print(f"实际计算:{number}")
return number ** 2
if __name__ == '__main__':
print(square(5))
print(square(5))
print(square(6))
print(square.cache_info())
运行结果:
实际计算:5
25
25
实际计算:6
36
CacheInfo(hits=1, misses=2, maxsize=128, currsize=2)
第二次调用 square(5) 时,直接返回缓存结果,没有执行原函数体。maxsize=128 表示最多保留一定数量的最近使用缓存条目,并不是限制为 128 字节。
9.2 参数必须可哈希
下面的调用不适合直接缓存:
python
@lru_cache(maxsize=128)
def total(numbers):
return sum(numbers)
total([1, 2, 3])
因为列表不可哈希,会抛出 TypeError。可以根据业务将输入转换为元组:
python
from functools import lru_cache
@lru_cache(maxsize=128)
def total(numbers):
return sum(numbers)
if __name__ == '__main__':
print(total((1, 2, 3)))
运行结果:
6
不要为了满足缓存要求随意转换数据,仍需保证参数能够准确表达计算所依赖的状态。
9.3 清理缓存
square.cache_clear()
可以清空缓存和命中统计。
还可以通过:
square.cache_info()
查看使用情况。
lru_cache 默认没有按时间过期的 TTL,也不是跨进程共享缓存。
9.4 不适合缓存哪些函数
需要谨慎对待:
- 依赖当前时间的函数。
- 生成随机值的函数。
- 读取持续变化外部状态的函数。
- 带有必须执行的副作用的函数。
- 每次需要返回独立可变对象的函数。
- 生成器函数。
例如:
python
from functools import lru_cache
@lru_cache(maxsize=8)
def get_tags(product_id):
return ["饮料"]
if __name__ == '__main__':
first = get_tags(1001)
first.append("促销")
print(get_tags(1001))
运行结果:
['饮料', '促销']
缓存返回的是同一个结果对象,不会在每次命中时自动复制。
缓存生成器函数也容易出错,因为可能缓存的是同一个会被耗尽的生成器,而不是生成器产出的全部数据。
9.5 缓存实例方法也有边界
如果给实例方法添加 lru_cache:
self会参与缓存键。- 实例需要可哈希。
- 缓存可能保留对实例的引用。
- 实例状态变化后,相同参数的缓存结果可能过期。
因此,不应把它直接作为所有对象方法的通用性能优化。
10. 装饰实例方法
10.1 普通包装函数通常可以接收 self
python
from functools import wraps
def log_call(func):
@wraps(func)
def wrapper(*args, **kwargs):
print(f"调用:{func.__name__}")
return func(*args, **kwargs)
return wrapper
class Inventory:
def __init__(self, stock):
self.stock = stock
@log_call
def restock(self, quantity):
if type(quantity) is not int or quantity <= 0:
raise ValueError("入库数量必须是正整数")
self.stock += quantity
return self.stock
if __name__ == '__main__':
inventory = Inventory(10)
print(inventory.restock(5))
运行结果:
调用:restock
15
当包装后的普通函数放在类中,通过实例访问时,仍然会绑定实例。self 作为位置参数进入 args,再被转发给原方法。
10.2 与 classmethod、staticmethod 组合
对于本文这种接收普通函数的包装装饰器,可以把 classmethod 放在外层:
python
class Product:
def __init__(self, name):
self.name = name
@classmethod
@log_call
def from_name(cls, name):
return cls(name)
应用顺序是:
classmethod(log_call(原函数))
先包装普通函数,再交给 classmethod 建立类方法绑定。静态方法也可以采用:
python
@staticmethod
@log_call
def normalize_name(name):
return name.strip()
不同装饰器接收和返回的对象类型可能不同,不能假设所有装饰器都能任意交换位置。