☰
Python-100-Days 第17天:装饰器与递归调用——函数高级应用的进阶之路
2026/10/14 2:55:59 网站建设 项目流程
  • 教程
  • 文档

【免费下载链接】Python-100-Days

Python - 100天从新手到大师

项目地址:https://gitcode.com/capable-ai/Python-100-Days
点击查看免费下载

装饰器与递归调用是 Python 函数进阶的两大核心主题:前者是 Python 独有的语法糖,能以声明式的方式为既有函数注入计时、日志、缓存等横切关注能力,实现"增强而不侵入";后者则让函数可以调用自身,将阶乘、斐波那契数列、回溯搜索等递归定义的问题用代码直接表达。本文以《Python-100-Days》第 17 天教程为骨架,结合仓库中的源码实例(如 Day31-35/code/example09.py 的参数化装饰器、Day31-35/code/example10.py 的单例装饰器),完整讲解装饰器的实现原理、语法糖、functools.wraps的取消机制,以及递归的收敛条件、栈溢出风险与lru_cache性能优化,读完后你将能够独立编写可复用的装饰器,并安全、高效地使用递归解决问题。

装饰器:用一个函数"装饰"另一个函数

在函数使用进阶中我们已经了解到,Python 中的函数是"一等函数":函数可以赋值给变量,可以作为参数传入,也可以作为返回值返回,把一个函数作为其他函数的参数或返回值的用法称为"高阶函数"。装饰器正是基于高阶函数的能力实现的——装饰器本身是一个函数,它的参数是被装饰的函数,它的返回值是一个带有装饰功能的函数。也就是说,装饰器是一个参数和返回值都是函数的高阶函数。

从实际需求出发:记录函数的执行时间

假设有名为download和upload的两个函数,分别用于模拟文件的下载和上传,内部用随机休眠来模拟耗时操作:

import random import time def download(filename): """下载文件""" print(f'开始下载{filename}.') time.sleep(random.random() * 6) print(f'{filename}下载完成.') def upload(filename): """上传文件""" print(f'开始上传{filename}.') time.sleep(random.random() * 8) print(f'{filename}上传完成.') download('MySQL从删库到跑路.avi') upload('Python从入门到住院.pdf')

说明:上面的代码用休眠一段随机时间的方式模拟了下载和上传文件需要花费一定的时间,并没有真正的联网上传下载文件。

现在有一个新需求:希望知道调用download和upload函数到底用了多少时间。最直接的做法是在函数开始执行时记录一个时间,调用结束后再记录一个时间,两个时间相减即可:

start = time.time() download('MySQL从删库到跑路.avi') end = time.time() print(f'花费时间: {end - start:.2f}秒') start = time.time() upload('Python从入门到住院.pdf') end = time.time() print(f'花费时间: {end - start:.2f}秒')

问题显而易见:记录时间、计算和显示执行时间的代码全部是重复代码。有编程经验的人都知道,重复的代码是万恶之源。装饰器就是解决这类问题的最佳选择:把与业务(上传和下载)无关的计时功能封装到一个函数中,当upload和download需要记录时间时,直接把装饰器作用到这两个函数上即可。

装饰器的核心结构:高阶函数 + 函数嵌套

将记录时间的装饰器命名为record_time,它的整体结构如下:

def record_time(func): def wrapper(*args, **kwargs): result = func(*args, **kwargs) return result return wrapper

理解这个结构要注意几个关键点:

  1. record_time的参数func是被装饰的函数;
  2. wrapper函数是带有装饰功能的函数,它负责执行被装饰的函数func并返回func的返回值;
  3. 上面的代码在wrapper内部第 4 行和第 6 行各留了一个空行,这意味着可以在这些位置添加代码来实现额外功能;
  4. record_time最终返回wrapper并用wrapper替代原函数func——被装饰后调用原函数,实际调用的是wrapper,因此才获得了额外能力;
  5. wrapper使用*args, **kwargs(可变参数和关键字参数)照单全收原函数的所有参数,然后在调用func时原封不动地传回,这样装饰器就能适配任意签名的函数;
  6. Python 支持函数的嵌套定义(在record_time中定义wrapper),这个操作在很多编程语言中并不被支持,它是闭包和装饰器能够成立的语言基础。

把记录时间的功能写入装饰器:

import time def record_time(func): def wrapper(*args, **kwargs): # 在执行被装饰的函数之前记录开始时间 start = time.time() # 执行被装饰的函数并获取返回值 result = func(*args, **kwargs) # 在执行被装饰的函数之后记录结束时间 end = time.time() # 计算和显示被装饰函数的执行时间 print(f'{func.__name__}执行时间: {end - start:.2f}秒') # 返回被装饰函数的返回值 return result return wrapper

写装饰器虽然颇费周折,但这是一劳永逸的操作:将来再有记录函数执行时间的需求时,只需在目标函数上添加该装饰器即可。

两种使用方式:函数式调用与语法糖

方式一:直接调用装饰器函数,把返回值替代原来的函数:

download = record_time(download) upload = record_time(upload) download('MySQL从删库到跑路.avi') upload('Python从入门到住院.pdf')

方式二:使用@装饰器函数语法糖,将装饰器直接放在被装饰的函数定义之上,效果与方式一完全相同。所谓"语法糖"是指编程语言中添加的某种语法,它对语言的功能没有影响,但使用更加便捷、可读性更强。完整代码如下:

import random import time def record_time(func): def wrapper(*args, **kwargs): start = time.time() result = func(*args, **kwargs) end = time.time() print(f'{func.__name__}执行时间: {end - start:.2f}秒') return result return wrapper @record_time def download(filename): print(f'开始下载{filename}.') time.sleep(random.random() * 6) print(f'{filename}下载完成.') @record_time def upload(filename): print(f'开始上传{filename}.') time.sleep(random.random() * 8) print(f'{filename}上传完成.') download('MySQL从删库到跑路.avi') upload('Python从入门到住院.pdf')

被装饰后的download和upload其实就是装饰器中返回的wrapper函数,调用它们等同于调用wrapper,因此自动获得了记录执行时间的能力。

functools.wraps:保留原函数以便取消装饰器

如果代码的某些地方想去掉装饰器的作用、直接执行原函数,定义装饰器时需要做一点点额外的工作:使用 Python 标准库functools模块的wraps函数(它本身也是一个装饰器)放在wrapper函数上,它会帮我们保留被装饰之前的函数;需要取消装饰时,可以通过被装饰函数的__wrapped__属性获得被装饰之前的函数。

import random import time from functools import wraps def record_time(func): @wraps(func) def wrapper(*args, **kwargs): start = time.time() result = func(*args, **kwargs) end = time.time() print(f'{func.__name__}执行时间: {end - start:.2f}秒') return result return wrapper @record_time def download(filename): print(f'开始下载{filename}.') time.sleep(random.random() * 6) print(f'{filename}下载完成.') @record_time def upload(filename): print(f'开始上传{filename}.') time.sleep(random.random() * 8) print(f'{filename}上传完成.') # 调用装饰后的函数会记录执行时间 download('MySQL从删库到跑路.avi') upload('Python从入门到住院.pdf') # 取消装饰器的作用不记录执行时间 download.__wrapped__('MySQL必知必会.pdf') upload.__wrapped__('Python从新手到大师.pdf')

此外,@wraps(func)还会把原函数func的__name__、__doc__等元信息复制到wrapper上,避免装饰后函数"改名换姓"(例如print(download.__name__)时仍显示download而不是wrapper),这对调试和文档生成都很有价值。

参数化装饰器:从源码看"输出方式"的解耦

装饰器函数本身也可以参数化——装饰器可以由调用者传入的参数来定制。仓库源码 Day31-35/code/example09.py 给出了一个教科书级别的参数化装饰器record,它把"记录耗时之后输出到哪里"彻底解耦,文件头部的注释点明了它的设计意图:

装饰器 - 装饰器中放置的通常都是横切关注(cross-concern)功能 所谓横切关注功能就是很多地方都会用到但跟正常业务又逻辑没有必然联系的功能 装饰器实际上是实现了设计模式中的代理模式 - AOP(面向切面编程)

其核心实现为三层嵌套函数(record→decorate→wrapper),wrapper执行被装饰函数后,把函数名和耗时交给外部传入的output回调处理:

from functools import wraps from time import time def record(output): def decorate(func): @wraps(func) def wrapper(*args, **kwargs): start = time() ret_value = func(*args, **kwargs) output(func.__name__, time() - start) return ret_value return wrapper return decorate

这样,使用者可以自由组合"计时逻辑"与"输出逻辑"——源码中分别定义了输出到控制台、输出到文件、输出到 MySQL 数据库三种回调,只需一行@record(output_to_console)即可切换记录方式:

def output_to_console(fname, duration): print('%s: %.3f秒' % (fname, duration)) def output_to_file(fname, duration): with open('log.txt', 'a') as file_stream: file_stream.write('%s: %.3f秒\n' % (fname, duration))

从源码结构看,这正是**面向切面编程(AOP)**思想的体现:装饰器中放置的多是与业务逻辑无关的横切关注功能(计时、日志、鉴权、缓存等),通过装饰器把它们"编织"进业务函数,既复用了代码,又没有侵入业务逻辑。在Day31-35/31.Python语言进阶.md中,还展示了用类定义装饰器(实现__call__方法)的等价写法,感兴趣的读者可以对照阅读。

装饰器的另一重身份:装饰类(单例模式)

装饰器的参数可以是函数,也可以是类。仓库源码 Day31-35/code/example10.py 用装饰器实现了单例模式——确保一个类只能创建出唯一的对象,并且给出了线程安全的版本:

import threading from functools import wraps def singleton(cls): """单例装饰器""" instances = {} lock = threading.Lock() @wraps(cls) def wrapper(*args, **kwargs): if cls not in instances: with lock: if cls not in instances: instances[cls] = cls(*args, **kwargs) return instances[cls] return wrapper @singleton class President(): def __init__(self, name, country): self.name = name self.country = country def __str__(self): return f'{self.country}: {self.name}' def main(): p1 = President('特朗普', '美国') p2 = President('奥巴马', '美国') print(p1 == p2) # True,无论创建多少次都只得到一个实例 print(p1) print(p2)

这里有几个值得深挖的细节:

  • instances字典是singleton函数内的局部变量,wrapper通过闭包捕获并复用它,因此所有对President()的调用共享同一个实例缓存;
  • 判空与创建之间用threading.Lock加锁,并采用了"双重检查"(先不加锁判一次,加锁后再判一次)的写法,既保证线程安全又避免每次调用都加锁的性能开销;
  • 由于使用了@wraps(cls),同样可以通过President.__wrapped__拿到原始的类,绕过单例限制直接创建新对象。

President类的装饰器也是"装饰器可以作用于类"的明证——装饰器并不局限于函数,任何可调用对象都可以作为被装饰对象。

递归调用:函数调用自己

Python 允许函数嵌套定义,也允许函数之间相互调用,而且一个函数还可以直接或间接地调用自身。函数自己调用自己称为递归调用。

递归的定义与两大要素:收敛条件与递归公式

现实中很多问题的定义本身就是递归定义。例如阶乘:非负整数N的阶乘是N乘以N-1的阶乘,即 $N! = N \times (N-1)!$,定义的左边和右边都出现了阶乘的概念,因此这是一个递归定义。可以用递归调用的方式写出求阶乘的函数:

def fac(num): if num in (0, 1): return 1 return num * fac(num - 1)

写递归函数有两个要点(这一总结在仓库的公开课《算法入门系列2 - 在水一方》中同样被强调):

  1. 收敛条件——什么时候结束递归。上述代码第 2 行的if num in (0, 1)就是收敛条件:计算到0或1的阶乘时停止递归,直接返回1;
  2. 递归公式——每一项与前一项(或前 N 项)的关系。上述代码第 4 行的num * fac(num - 1)就是递归公式,即阶乘的递归定义。

递归与函数调用栈:先进后出与栈溢出

用fac(5)计算 5 的阶乘,整个调用过程如下:

# 递归调用函数入栈 # 5 * fac(4) # 5 * (4 * fac(3)) # 5 * (4 * (3 * fac(2))) # 5 * (4 * (3 * (2 * fac(1)))) # 停止递归函数出栈 # 5 * (4 * (3 * (2 * 1))) # 5 * (4 * (3 * 2)) # 5 * (4 * 6) # 5 * 24 # 120 print(fac(5)) # 120

函数调用会通过内存中称为栈(stack)的数据结构保存当前代码的执行现场,函数调用结束后再通过栈恢复之前的执行现场。栈是先进后出的数据结构,这意味着最早入栈的函数最后才返回,最后入栈的函数最先返回——例如函数a调用b、b调用c,则最先入栈的是a,最先出栈的是c。

每进入一个函数调用,栈就增加一层栈帧(stack frame),栈帧正是保存当前代码执行现场的结构;每当函数调用结束,栈就减少一层栈帧。通常内存中的栈空间很小,因此递归调用次数如果太多,会导致栈溢出(stack overflow)。尝试执行fac(5000)会看到RecursionError错误,错误消息为maximum recursion depth exceeded in comparison(超出最大递归深度),其实就是发生了栈溢出。

使用官方解释器 CPython 时,默认将函数调用的栈结构最大深度设置为1000 层,超出即触发RecursionError。虽然可以用sys模块的setrecursionlimit函数改变递归调用的最大深度(如sys.setrecursionlimit(10000),该用法可见于仓库公开课《算法入门系列2 - 在水一方》),但不建议这样做——让递归快速收敛才是正确方向,否则就应该考虑使用循环递推而不是递归。

斐波那契数列:递归写法与性能陷阱

斐波那契数列前两个数都是1,从第三个数开始每个数是前两个数相加的和,记为f(n) = f(n - 1) + f(n - 2),又是一个递归定义,可以写出如下递归函数:

def fib1(n): if n in (1, 2): return 1 return fib1(n - 1) + fib1(n - 2) for i in range(1, 21): print(fib1(i))

需要提醒的是,这段代码虽然看起来简单明了,但执行性能非常糟糕:把range的第二个参数修改为51(输出前 50 个斐波那契数)时耗时将显著增加。原因在于递归展开时存在大量重复计算——fib1(n)会分别递归计算fib1(n-1)和fib1(n-2),而这两棵递归树之间存在海量的重叠子问题,时间复杂度呈指数级增长(在公开课中对此的评价是"时间复杂度是几何级数级的")。

显然,直接用循环递推是更好的选择:

def fib2(n): a, b = 0, 1 for _ in range(n): a, b = b, a + b return a

lru_cache:用缓存装饰器"飞一般"地优化递归

除此以外,还可以使用functools模块的lru_cache函数优化上面的递归代码。lru_cache本身是一个带参数的装饰器函数,将其置于fib1之上,它会缓存该函数的执行结果,从而避免递归调用过程中产生的大量重复运算,执行性能因此有"飞一般"的提升:

from functools import lru_cache @lru_cache() def fib1(n): if n in (1, 2): return 1 return fib1(n - 1) + fib1(n - 2) for i in range(1, 51): print(i, fib1(i))

提示:lru_cache函数是一个带参数的装饰器,所以使用装饰器语法糖时,lru_cache后面要跟上圆括号。lru_cache有一个非常重要的参数maxsize,用来定义缓存空间的大小,默认值是128。

这个优化思路在仓库中被反复使用:公开课第05次公开课-算法入门系列1-周而复始/code/example04.py 和年薪50W+的Python程序员如何写代码/code/Python/opencourse/part01/example07.py 中,都用@lru_cache()装饰fib函数,后者甚至直接输出了前 120 个斐波那契数,性能依然可接受。公开课中还用「默认参数缓存」的等价技巧优化过递归,其核心思想与lru_cache一致:用缓存消灭重复子问题的计算。

顺带一提,递归在仓库中还有更广阔的应用场景——公开课《算法入门系列2 - 在水一方》中的迷宫寻路、八皇后、骑士巡逻等回溯算法,都以递归为基本手段,回溯法本质上是暴力搜索法的一种,可看作"带悔棋的递归搜索"。

总结

  • 装饰器是 Python 语言中的特色语法,本质是一个参数和返回值都是函数的高阶函数。通过装饰器可以增强现有的函数(记录执行时间、输出日志、实现单例、缓存结果等),这是一种非常有用的编程技巧。装饰器内放置的多是与业务无关的横切关注功能,体现了面向切面编程(AOP)和代理模式的设计思想;结合functools.wraps可以保留原函数并通过__wrapped__随时取消装饰器的作用,结合参数化设计则可以进一步解耦装饰器内部的可变行为。
  • 函数递归调用可以在代码层面将一些复杂的问题简单化,但必须注意收敛条件和递归公式:找到递归公式才有机会使用递归调用,收敛条件则确保递归能停下来。函数调用通过内存中的栈空间保存和恢复执行现场,栈空间通常很小,递归如果不能迅速收敛,很可能引发栈溢出错误,导致程序崩溃。遇到指数级复杂度的递归时,优先考虑循环递推,或用lru_cache等缓存机制消除重复子问题计算。
  • 教程
  • 文档

【免费下载链接】Python-100-Days

Python - 100天从新手到大师

项目地址:https://gitcode.com/capable-ai/Python-100-Days
点击查看免费下载
上一篇:终极指南:3分钟完成GitHub下载加速10倍提升
下一篇:如何把闲置电视盒子改造成Linux服务器:一条可回退的完整Armbian刷机路线

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询