坚持到第13天,终于有底气写一篇真正算"进阶"的内容了。回头翻我自己列的自学计划,"day 13 进阶3"这行字写在最前面的时候我还不知道要写什么,现在回头看看,这第三个进阶专题刚好卡在一个特别关键的位置上——前12天把基础语法、函数、模块、异常、文件操作、迭代器和生成器过了一遍,代码能跑,但总感觉少了点设计感。这第13天要解决的,就是让代码从"能跑"变成"好用",核心武器是Python里的装饰器和上下文管理器。
这篇文章不是教科书式地列知识点,而是我真实的学习笔记和实操记录。从闭包这个底层机制说起,到装饰器写法的完整演进,再落到日志、缓存、重试这几个实际场景,最后用上下文管理器补齐资源管理的拼图,全程附带我能复现的代码和踩过的坑。适合那些已经把Java基础语法啃完、正往工程化方向走的开发者,也适合像我这样靠自学计划稳步推进的人。
1. 为什么第三个进阶专题要选装饰器
1.1 前12天到底学了什么,以及为什么到第13天才碰装饰器
我在前12天走的是这样一条路径:前3天搞定变量、循环、函数这些语法地基,第4天到第6天啃完列表、字典、集合这些核心数据结构,第7天开始接触模块和包,第8天处理异常,第9天和第10天学了文件的读写和序列化,第11天和第12天把迭代器、生成器、闭包这些概念理了一遍。
这个顺序不是随便排的。装饰器这个东西,听名字很唬人,拆开来看就是"接收函数、返回函数"的普通函数。但如果不懂闭包,你就理解不了为什么函数可以记住外层的变量;如果没写过生成器,你就感受不到惰性求值带来的性能红利。这些前置知识没有,装饰器只能是死记硬背的语法糖,一旦遇到叠加、带参数这种复杂情况就会抓瞎。
第13天把装饰器作为"进阶3"来学,正好是知识链条闭合的关键点。此时函数已经玩得比较熟练,闭包也大致理解了,差的正是把函数当"高级对象"来操作的那一步。这一步跨过去,代码抽象能力会有明显提升。
1.2 装饰器解决的痛点,用一个真实场景来说
假设模拟项目X里有个下单接口,我需要在每次调用时打印一句"开始执行下单",执行完再打印"下单完成,耗时多少秒"。最笨的写法是每个函数体里手写这些打印语句。当时我没多想,直接在三个下单相关函数里复制粘贴了六行日志代码。第二天需求变了,要在所有日志前加上订单ID。那次改动让我改了十几个函数。
这就是装饰器要解决的痛点——横切关注点。日志、鉴权、性能埋点、事务处理这些逻辑,和业务逻辑缠在一起,导致代码变得臃肿且极难维护。装饰器的思路是把同类逻辑抽出来包装成"附加层",业务函数还是干业务,附加逻辑集中一处管理。谁需要就在谁头上挂一个@记号,改一处,处处生效。
1.3 一个12行代码的入门示例,先建立感觉
先看一个最简单但完整的装饰器长什么样:
import time def cost_timer(func): def wrapper(*args, **kwargs): start = time.time() result = func(*args, **kwargs) print(f"耗时: {time.time() - start:.4f}秒") return result return wrapper @cost_timer def multiply(a, b): time.sleep(0.1) return a * b print(multiply(3, 4))这段代码的执行流程是:@cost_timer等价于multiply = cost_timer(multiply)。multiply这个名字不再直接指向原函数,而是指向wrapper。调用multiply(3, 4)时,真正被执行的是wrapper(3, 4),wrapper内部先记录时间,然后调用原始函数,完事打印耗时并返回结果。
这里最关键的一点是wrapper通过闭包记住了func。解释器在创建wrapper时,把外层函数的func参数保留在了自己的作用域里,之后每次调用都能用到。理解了闭包,装个器就只是语法层面的小变体。接下来一节拆闭包。
2. 闭包机制:装饰器的地基
2.1 函数是"一等公民",这决定了装饰器可行
很多初学者没有意识到,Python里函数和整数、字符串一样,是对象。可以被赋值给变量、放进容器、作为参数传给别的函数,也可以作为返回值从函数里跑出来。
def say_hello(name): return f"Hello, {name}" greet = say_hello print(greet("张三")) # Hello, 张三 print(greet.__name__) # say_hellogreet只是一个指向函数对象的引用,后面加不加括号决定了是取对象本身还是执行它。装饰器的一切操作都建立在这种"函数即对象"的基础上,没有这个前提,任何包装函数的方式都无从谈起。
2.2 计数器闭包的完整拆解
闭包这个词听起来玄乎,用一个计数器例子拆开看就明白了:
def make_counter(): count = 0 def counter(): nonlocal count count += 1 return count return counter c1 = make_counter() print(c1()) # 1 print(c1()) # 2 print(c1()) # 3普通函数在每次调用结束后,局部变量就销毁了。但make_counter返回的counter函数还握着一个对count的引用,所以这块内存不会被回收。nonlocal声明告诉Python解释器:这个count不是counter内部的局部变量,而是来自外层的make_counter作用域。
为了加深理解,看一眼底层逻辑。counter()内部用nonlocal锁定的是它定义时所在外层栈帧里的count。c1 = make_counter()执行后,make_counter的栈帧并没有完全销毁,而是作为"闭包单元"被c1携带。每次调用c1()的时候,外部函数make_counter框架里的count已经在内存中等待更新。
装饰器的wrapper里也是这样,它携带的是对原始函数func的引用。这个引用在外界看来是隐藏的,但对wrapper来说,等于是"随身带了一个文件包",什么时候打开都行。
2.3 作用域规则影响闭包行为,一个例子能说明
作用域按内置、全局、闭包外层、局部这个方向查找。闭包能访问外层变量,但默认只能读不能写。想写就必须加nonlocal。很多人卡在"为什么闭包函数里改了外层变量会报错"这个问题上。
def outer(): x = 1 def inner(): x = x + 1 # UnboundLocalError: local variable 'x' referenced before assignment return x return inner因为Python在编译inner函数时,看到了x = x + 1的赋值动作,于是把x标记为inner的局部变量,后面的x + 1引用的就是尚未被赋值的局部变量,自然会报错。解决办法就是加nonlocal x声明。
在写装饰器时,如果需要在wrapper里记录调用次数等状态信息,通常会声明nonlocal或者用一个可变的容器(比如字典、列表)来规避这一限制。理解了这个规则,装饰器里的一切状态管理都清晰了。
2.4 闭包常见的误用场景
闭包有一个经典坑:在循环中创建闭包。
funcs = [] for i in range(3): def f(): return i funcs.append(f) for f in funcs: print(f()) # 2 2 2期望打印 0 1 2,实际全是 2。因为f捕获的是变量i本身,而不是当时的值。每次循环i变为新的值,所有f引用的都是同一个变量。等循环结束后i是 2,所有函数自然都返回 2。
修复办法是使用默认参数绑定当前值:def f(i=i): return i,或者改用生成器。这个坑在装饰器里也有变种,叠加装饰器时每个装饰器捕获相关参数的方式不同,结果就可能出乎意料。后面第6节排坑环节会专门讲。
3. 装饰器的写法演进:从最笨到最好用
3.1 手动版:先理解"包装"这个动作
在最朴素的阶段,可以完全不用@语法,纯粹用赋值的方式来包装。
def raw_say(name): return f"hi {name}" def logging_call(func, *args, **kwargs): print("调用前") result = func(*args, **kwargs) print("调用后") return result raw_say = logging_call(raw_say) print(raw_say("李四"))这个写法完成了一个"装饰"动作:把原函数传入一个包装函数,返回一个新的可调用对象,再把这个新的对象赋值回原名字。这个模式是装饰器的核心实质。但直接这样写有两个毛病:每次包装都要手动赋值,而且raw_say.__name__已经变成了logging_call,函数元信息丢了。
手动版帮助我们理解本质:装饰器就是函数工厂,它接收一个函数,加工后吐出另一个函数。明白了这点,后面所有的语法糖都只是省打字的方式。
3.2 语法糖版:@符号只是赋值替换
用@之后,原来的手动赋值被语法代替:
def logging_call(func): def wrapper(*args, **kwargs): print("调用前") result = func(*args, **kwargs) print("调用后") return result return wrapper @logging_call def raw_say(name): return f"hi {name}"@logging_call放在函数定义正上方,等价于在函数定义结束后执行raw_say = logging_call(raw_say)。好处是书写简洁、代码位置明确、后面接别的装饰器时读起来像画面上的图层叠加。
3.3 元信息丢失与 wraps 修复
前面在手动版里提到__name__会变。这在调试、文档生成、依赖函数签名的框架里是致命的。为了让包装后的函数看起来和原函数完全一致,标准库提供了functools.wraps。
import functools def logging_call(func): @functools.wraps(func) def wrapper(*args, **kwargs): print("调用前") result = func(*args, **kwargs) print("调用后") return result return wrapperfunctools.wraps本身也是一个装饰器,它把原函数的__name__、__doc__、__module__、__qualname__等属性复制到wrapper上,并更新wrapper的__wrapped__属性,方便反查原函数。写装饰器的时候默认加上它是最佳实践。
我一开始天真地以为wraps只是复制属性,直到在调试一个用inspect.getfullargspec做参数校验的框架时,发现装饰后的函数签名全乱了。加上functools.wraps后,签名信息被正确还原,这个坑才算彻底填平。
3.4 带参数装饰器:工厂套工厂
有这样一个需求:日志装饰器需要传入一个"日志级别"参数,不同的被装饰函数记录不同的级别。简单的@logging_call做不到,因为语法在任何对象上做不下去了——如果@logging_call(level='INFO'),Python 会先计算出logging_call(level='INFO')的结果,再拿这个结果去处理下面的函数。
import functools def log_with(level="INFO"): def decorator(func): @functools.wraps(func) def wrapper(*args, **kwargs): print(f"[{level}] 调用 {func.__name__}") return func(*args, **kwargs) return wrapper return decorator @log_with(level="ERROR") def bad_thing(): return "error happened"最外层的log_with是用来接收自定义参数的"工厂",它返回真正的装饰器decorator。decorator接收函数,返回wrapper。整个结构是三层嵌套,对应三个不同的职责:外层收集参数,中层接收函数,内层接收运行时参数。
这个"三层套娃"是初学者写装饰器最容易绕晕的地方。我的记忆诀窍是:把最外层想成"配置器",只管拿配置,中间的才是"装饰器",最里面的wrapper才是真正被调用的那个东西。
4. 实战应用:日志、缓存、重试三种模式
4.1 日志装饰器:一劳永逸的统一埋点
日志是装饰器最经典的应用场景。我在模拟项目X里加工了一个统一日志装饰器,统一记录函数名、参数、耗时和异常信息:
import functools import time import logging logging.basicConfig(level=logging.INFO) def log_call(func): @functools.wraps(func) def wrapper(*args, **kwargs): start = time.time() try: result = func(*args, **kwargs) logging.info(f"[{func.__name__}] args={args}, kwargs={kwargs}, 耗时={time.time()-start:.4f}s, 结果={result}") return result except Exception as e: logging.error(f"[{func.__name__}] 异常: {e!r}") raise return wrapper这里有一点要注意:绝不能在wrapper里把异常吞掉。装饰器只负责记录信息,异常应该继续上抛,交给上层处理。有些封装不到位的人会在装饰器里捕获后不打日志也不抛异常,导致业务层收到None或者直接静默失败,排起错来非常痛苦。
4.2 缓存装饰器:用空间换时间
假设某个函数对相同参数会产生相同结果,并且计算代价很高,那就可以用装饰器做结果缓存。
import functools def cached(func): cache = {} @functools.wraps(func) def wrapper(*args, **kwargs): key = (args, tuple(sorted(kwargs.items()))) if key not in cache: cache[key] = func(*args, **kwargs) return cache[key] return wrapper @cached def expensive_function(x, y): return x ** y这里用字典当缓存,以参数组合为键,计算结果为值。要求被装饰函数的参数都能被 hash,所以不能拿列表做参数,得转成元组。这个装饰器用在频繁调用且结果稳定的场景下效果明显,比如从数据库读取配置的函数,一秒之内被调用几十次。
实际生产环境里更建议直接用标准库提供的functools.lru_cache,它实现了最近最少使用淘汰策略,不会让缓存无限膨胀。自己写的版本更适合学习原理,或者应对特殊缓存策略,比如需要定期失效时自己控制缓存生命周期。
4.3 重试机制:给网络请求函数加韧性
第三个实用模式是重试装饰器。网络请求偶尔失败,不想在业务代码里写一堆重试逻辑,用装饰器集中处理干净利落。
import functools import time def retry(times=3, delay=1): def decorator(func): @functools.wraps(func) def wrapper(*args, **kwargs): for attempt in range(times): try: return func(*args, **kwargs) except Exception as e: if attempt == times - 1: raise time.sleep(delay) return None return wrapper return decorator注意重试的时间间隔,最简单的delay=1是固定等待,更讲究的做法是带随机抖动的delay = base * (2 ** attempt) + random.uniform(0, 0.5)。这样避免多个请求在重试时扎堆同时发起,又引入随机性来错开瞬时故障。
4.4 三种模式组合使用
装饰器可以叠加:
@retry(times=3, delay=0.5) @log_call def fetch_data(): # 模拟网络请求 return "data"执行顺序是从下往上的。fetch_data先被@log_call处理成带日志的版本,然后再被@retry包裹。重试的是"带日志的版本",所以每次重试都会重新记录日志。这个顺序在语义上是合理的:先包装内层,再包装外层。
如果反过来写在上面,日志装饰器只是在重试函数的表面包了一层,重试内部发生多少次失败都不会记日志,容易误判请求次数。设计叠加顺序时要根据实际需求去理解语义。
5. 上下文管理器:与 with 配套的另一块拼图
5.1 with 语句的执行协议
装饰器是给函数加外挂,上下文管理器是给代码块加边界。核心协议是__enter__和__exit__两个方法:
class ManagedResource: def __enter__(self): print("进入资源") return self def __exit__(self, exc_type, exc_val, exc_tb): print("退出资源") return Falsewith ManagedResource() as r:在进入代码块前调用__enter__,在离开代码块时调用__exit__。__exit__的三个参数保存异常信息,返回False表示不拦截异常,让它继续抛出去;返回True表示吞掉异常,阻止它向上传播。
日常最常见的应用是文件操作:
with open("data.txt", "r") as f: content = f.read()即使读取中途触发异常,文件资源也会被正确关闭,因为__exit__里已经包含了关闭逻辑。
5.2 自实现计时上下文管理器
我动手写过一个计时器:
import time class Timer: def __enter__(self): self.start = time.time() return self def __exit__(self, exc_type, exc_val, exc_tb): self.elapsed = time.time() - self.start print(f"代码块耗时 {self.elapsed:.4f} 秒") return False用法:
with Timer(): time.sleep(0.3) sum(range(1000))这个比装饰器更灵活的地方在于,它能精确测量某个代码片段的耗时,而不仅仅是整个函数。在定位性能瓶颈时特别实用。我曾用Timer对比了三种排序方式在小规模数据上的耗时,代码结构一目了然。
5.3 contextmanager 装饰器:用生成器替代类
标准库contextlib.contextmanager允许用生成器函数写上下文管理器,通常比定义两个方法更简洁:
from contextlib import contextmanager @contextmanager def timer(): start = time.time() try: yield finally: elapsed = time.time() - start print(f"耗时 {elapsed:.4f} 秒")@contextmanager把生成器函数包装成一个上下文管理器:yield之前的代码在__enter__时执行,yield之后的代码在__exit__时执行。finally保证即使代码块抛异常也能执行清理逻辑。
这个写法在"临时改变环境变量再自动恢复资源""临时切换数据库连接"这类场景里非常顺手。唯一要注意的是,如果yield后面的finally里没有显式处理异常,异常会被自动抛出,这通常是正确的行为。
5.4 资源管理的高级应用场景
除了文件、数据库连接、网络连接,我还在模拟项目X的做法中用它来管理临时文件目录和线程锁。
class FileLock: def __enter__(self): self.lock = threading.Lock() self.lock.acquire() return self def __exit__(self, exc_type, exc_val, exc_tb): self.lock.release() return False上下文管理器的价值在于它把"获取资源—使用资源—释放资源"这个流程标准化了,任何健忘的释放逻辑都不会有被遗忘的机会。担心内存泄漏的场景,用with包裹比在try/finally里手写更优雅。
6. 踩坑实录:装饰器叠加、顺序与排查思路
6.1 叠加装饰器时执行顺序到底是怎么样的
装饰器叠加时的执行顺序是入门以来最困惑我的一个点。测试代码如下:
def deco_a(func): print("A 包装") def wrapper(): print("A 前置") return func() + " A" return wrapper def deco_b(func): print("B 包装") def wrapper(): print("B 前置") return func() + " B" return wrapper @deco_a @deco_b def foo(): return "foo"回到这一节开头,我原本以为装饰是从上往下执行,但实际打印顺序是:"B 包装" 先出现,然后 "A 包装"。原因是@deco_a装饰的是"已经经过deco_b包装好的函数",所以deco_b优先处理原函数。
调用foo()时,执行的顺序却反过来:先打印 "A 前置",再打印 "B 前置",然后执行原函数返回 "foo B A"。A 在最外层,先被调用,B 在里层,后被执行。这个"先包装后执行、外层先入后出"的顺序,不实际跑一遍很难凭空想明白。
6.2 依赖函数签名导致的诡异 bug:functools.wraps 解决后
某次在调试一个模拟项目X中的参数校验组件时,发现某装饰器让所有函数都报错"参数类型错误"。追查后得知,该组件通过inspect.signature检查被装饰函数的参数是否有动画,结果因为装饰器没加functools.wraps,组件的签名检查拿到了wrapper的签名(*args, **kwargs),与调用时传入的真实参数对不上,于是校验失败。
把functools.wraps加上后,签名恢复原样,校验逻辑不再丢失。这个教训让我在写所有装饰器时都默认遵守:无条件使用functools.wraps。没有例外。
6.3 类装饰器:什么时候值得使用
装饰器不一定是函数,也可以是一个类对象。它要求该类的实例是可以被调用的,也就是实现__call__方法。
class CountCalls: def __init__(self, func): self.func = func self.count = 0 def __call__(self, *args, **kwargs): self.count += 1 print(f"调用第 {self.count} 次") return self.func(*args, **kwargs) @CountCalls def hello(): return "hello"类装饰器的优势在于可以通过实例属性保存状态,不必依赖闭包里的可变类型。但要注意的是,它的实例本身替代了原来的函数,如果忘记实现__call__,调用时会得到一个类型错误。在使用类装饰器时,通常建议同样使用functools.wraps来保留原函数信息,尽管类实例本身也可以提供__name__属性。
这种写法适合状态比较多、需要把多个数据成员和包装逻辑组织在一起的情景,但是过度使用会加重认知负担。我通常只在需要比较多内部状态时才选用类装饰器。
6.4 一次完整的排查链路:不存在的函数返回值
最后一次踩坑是装饰器叠加时返回值被"吞掉"了。业务代码里有一段:
@rerun_on_failure @log_call def fetch_config(): return {"key": "value"}调用fetch_config()总是返回None。先怀疑log_call,把它的日志装饰器注释掉,问题依旧。再去查rerun_on_failure,发现在内部的wrapper循环中漏掉了return语句,只在第一次成功时返回了结果,重试循环结束后没有最终返回,函数落了个隐含的None。
排查思路是:先将可疑装饰器逐一注释测试,缩小范围;然后检查被怀疑装饰器的每一处return,特别是循环内部是否有提前返回值,以及循环结束后是否有兜底返回。装饰器的wrapper必须保证在正常路径下完整返回结果,这个"省略 return"的坑太隐蔽了,常常连人查半天。
经过这次教训,现在我写装饰器时都会直接盯着wrapper的返回值写测试用例,而不是只做功能验证。因为装饰器的错误往往是"悄悄把结果弄丢",这种 bug 靠肉眼看很难发现。
想分享的一点额外心得
做到第13天,我最大的感受是把装饰器和上下文管理器放在一起学是个自然无比的选择。两者虽然实现机制不一样——一个靠函数对象和闭包,一个靠协议和 with 语句,但它们解决的问题是同一件事:把横切逻辑从业务代码里拆出来,让每个函数只干一件事。日志、缓存、重试、清理资源,这些在以前写起来又烦又容易漏的活儿,现在都变成了一个标记符或者一段代码块。
如果你也在学,建议优先跑通这一篇里所有代码,再回头看看前12天写的模拟项目X里哪些地方能改成装饰器或with。哪怕只改造一个函数,这一天的内容就真正沉淀进脑子里了。第14天我打算把装饰器和上下文管理器合并成一个小型中间件系统练手,到时候有新的体会再继续写。