1. 函数设计的核心思路:先把职责划分清楚1.1 为什么函数值得认真设计很多人写 Python 函数,第一反应是“把一段重复代码包起来”,这个直觉没错,但如果只停留在这一步,函数很快会退化成一堆纠缠在一起的过程性代码。我有段时间负责维护一个数据处理链路,里面有个函数叫process_data,干了二十多件事:读文件、清洗字段、算指标、画图、写库、发通知。每次需求变更都要动它,改一处崩三处,最后全靠单元测试兜底才没出事故。后来我花了两天重构,把每个步骤拆成独立函数,整个链路才真正变得可读、可测、可替换。函数本质上是对“输入到输出”的一次契约约定。写函数之前,先问三个问题:它接收什么?它返回什么?它产生什么副作用?如果一个函数能清晰回答这三件事,那它就是合格的;如果回答起来模模糊糊,这个函数多半设计得有问题。1.2 单一职责怎么落地单一职责听起来像大道理,落地却需要具体的判断标准。我常用的一个简单判据:如果一个函数需要你用“然后”“接着”这样的词来描述它的行为,那就是职责过多。比如“读取配置文件然后解析成字典再检查必填项”,这其实是三段逻辑,应该拆成三个函数。拆完之后,每个函数的长度自然就短了,参数也变少了,测试时只需要关注一个行为点。我在团队里定过一条规矩:单个函数体超过四十行就必须说明理由。这条规矩不是绝对正确,但它逼着人思考“是不是该拆了”。实际执行下来,大部分超过四十行的函数都能拆出至少两三个子函数,而且拆完之后主调函数的逻辑会变得像读文章一样流畅。另外一个容易被忽略的点:职责划分也要考虑复用边界。拆得太细,一个十行的函数被拆成五个两行的函数,结构反而碎片化。我的经验是,拆分的颗粒度以“是否会被多个地方调用”和“是否包含独立判断逻辑”为标准,而不是为了拆而拆。1.3 输入与输出的契约设计函数契约的核心是明确输入输出的边界。我强烈建议从写第一行函数开始就使用类型提示,不管项目大小。def calc_discount(price: float, rate: float = 0.9) -> float:这种写法,读代码的人一眼就知道该传什么、会得到什么,配合静态检查工具能在运行前拦截大量低级错误。返回值方面,最容易出问题的是“有时返回结果,有时返回 None”。比如一个查找函数,找不到就返回 None,调用方每次都要判空。更好的做法是用异常表达“找不到”这种异常分支,或者用Optional类型明确声明,让调用方知道要处理空值。我见过不少线上事故都是因为调用方忘了处理 None 导致的,契约写清楚能减少这类问题。文档字符串也别偷懒。不需要写成长篇论文,三句话就够:这个函数做什么、参数含义、返回值含义。如果函数有特定的异常行为,也要写清楚。我个人的习惯是 docstring 里写清楚“什么情况下会抛异常”,这比注释里写一百句“这里是处理逻辑”有用得多。2. 参数体系:Python 函数的灵活性与陷阱2.1 位置参数与关键字参数的选择Python 的参数体系是我见过最灵活的语言设计之一,但灵活性也是双刃剑。位置参数适合参数少、顺序有天然逻辑的场景,比如math.ceil(3.2);参数一旦超过两三个,强制使用关键字参数会更安全。在 Python 里可以用*单独分隔参数,强制让后续参数只能以关键字方式传入:def create_user(name: str, *, age: int | None = None, email: str | None = None) -> dict: if age is not None and age < 0: raise ValueError("age 不能为负数") return {"name": name, "age": age, "email": email} # 合法调用 create_user("张三", age=25, email="zhangsan@example.com") # 非法调用:age 的位置参数不合法 # create_user("张三", 25)这种设计在参数多的场景里特别值得用。比如一个初始化工龄较长的函数,如果所有参数都是位置参数,调用时全靠人记顺序,一旦顺序错了,运行期才会暴露,还不如在定义阶段就直接堵住这种可能。2.2 *args 和 **kwargs 的实战取舍*args和**kwargs是 Python 函数的一大特色,但同时也是被滥用最严重的特性。它们最合理的用途有两个:一个是写装饰器和包装器,因为你要转发任意参数;另一个是定义接口层,比如事件处理函数,不同事件可能携带不同参数。我自己写装饰器时的标准写法是:def log_call(func): def wrapper(*args, **kwargs): print(f"调用 {func.__name__}, 参数: {args}, {kwargs}") return func(*args, **kwargs) return wrapper这里如果不收集**kwargs,传关键字的调用就会直接炸掉。但反过来,如果一个业务函数本身签名里全是*args, **kwargs,那代码基本没法维护,因为调用方根本不知道应该传什么。我见过有人把核心业务函数写成完全*args化,美其名曰“灵活”,实际上每次调用都要翻源码看顺序。这种写法一定要避免。2.3 默认参数的两个经典坑默认参数是 Python 入门必踩的坑,最经典的就是可变对象默认参数。函数定义时,默认参数表达式只求值一次,所以def append_item(item, lst=[])中那个空列表会被所有调用共享。我之前给同事排查过一个诡异问题:两个用户调同一个接口,A 用户的数据出现在 B 用户的请求结果里,根源就是某个函数默认参数用了[]。正确的做法是默认值用None,函数体内再初始化:def append_item(item: str, lst: list | None = None) -> list: if lst is None: lst = [] lst.append(item) return lst第二个坑是默认参数值在函数定义时就确定。比如def process(ts=time.time()): ...,这个ts在所有调用中都指向定义那一刻的时间,而不是每次调用时取当前时间。如果需要“调用时取当前值”,默认值必须设为None,函数体内取datetime.now()。这个错误在写日志切分、缓存过期这类场景时尤其容易踩中。3. 作用域与闭包:函数之外的运行环境3.1 LEGB 规则是理解一切的钥匙Python 的名称查找遵循 LEGB 规则:局部作用域(Local)、闭包外函数作用域(Enclosing)、全局作用域(Global)、内建作用域(Built-in)。很多新手困惑“为什么我在函数里改了全局变量没生效”,根源就是没理解赋值操作在局部作用域创建了新名称,而不是修改全局名称。counter = 0 def increment(): # 这里的 counter 会被视为局部变量,因为后面有赋值操作 counter += 1 # UnboundLocalError def increment_ok(): global counter counter += 1如果你需要修改全局变量,用global声明;但更推荐的做法是尽量避免在函数内部修改全局状态,通过返回值把变化带出来。全局变量一旦被多个函数修改,调试时你就会陷入“这变量到底在哪被改的”的泥潭。我在实际项目中会要求所有全局状态收敛到一个状态对象里,函数之间通过参数传递,而不是顺手改全局。3.2 闭包不只是面试题闭包是指内层函数引用了外层函数的变量,并且外层函数已返回,内层函数仍然持有那份引用。这不仅是面试题里的概念,在数据工厂、计数器、延迟计算等场景非常实用。def make_counter(start: int = 0): state = {"value": start} def inc(step: int = 1) -> int: state["value"] += step return state["value"] def reset() -> None: state["value"] = start return inc, reset inc, reset = make_counter(10) print(inc()) # 11 print(inc(5)) # 16 reset() print(inc()) # 11这个例子里用字典作为容器,是因为内层函数要修改state指向的对象内容,而不是重新绑定state这个名字。如果直接写state += step就会触发 UnboundLocalError,因为不可变对象重新赋值会改变名称绑定。理解这一点之后,闭包用起来就不再玄乎,它本质上就是“函数携带着一个小型私有环境”。3.3 从闭包通向装饰器闭包的一个高阶应用就是装饰器。装饰器接收一个函数,返回一个新函数,这个新函数通过闭包“捕获”了原函数。理解闭包之后,装饰器就只剩下“参数是函数”这一层语法糖。def my_decorator(func): def wrapper(*args, **kwargs): print("before") result = func(*args, **kwargs) print("after") return result return wrapper @my_decorator def say_hello(): print("hello")整个机制就是:say_hello = my_decorator(say_hello),新的say_hello是wrapper,但通过闭包能访问原来的函数。一旦想清楚这层等价关系,装饰器的所有行为都变得可推导了。4. 装饰器实战:从计时器到带参数装饰器4.1 写一个可复用的计时装饰器性能分析是装饰器最典型的应用之一。我写过一个轻量的计时装饰器,用在排查接口慢查询和对比不同算法的时间开销上:import time import functools def timed(func): """打印函数执行耗时(毫秒)""" @functools.wraps(func) def wrapper(*args, **kwargs): start = time.perf_counter() try: return func(*args, **kwargs) finally: elapsed = (time.perf_counter() - start) * 1000 print(f"{func.__name__} 耗时 {elapsed:.2f} ms") return wrapper注意这里try/finally的用法,即使函数抛异常也能打印耗时。用perf_counter而不是time.time,是因为time.time受系统时间调整影响,测出来的间隔可能为负或偏差极大。这个装饰器被我贴到了团队公共工具库里,后来在定位一次数据库连接池泄漏时,正是靠它发现某个函数平均耗时从 20ms 涨到了 900 多毫秒。4.2 为什么必须用 functools.wraps如果装饰器直接return wrapper,被装饰函数的元信息会丢失,func.__name__变成wrapper,help(func)显示的也是装饰器的文档。这在调试、日志、自动化生成 API 文档时会带来一堆隐蔽问题。@functools.wraps做的事本质上是把原函数的__name__、__doc__、__module__等属性复制到wrapper上。更重要的是,它还帮wrapper设置了__wrapped__属性,指向原始函数,这样用inspect.signature依然能看到原始签名,对依赖参数自省的工具链非常关键。我见过有人图省事不写wraps,结果在使用inspect.getfullargspec的自动化框架里,所有被装饰函数的参数都变成(*args, **kwargs),导致路由注册全部失效。排查了整整一下午,最后发现是装饰器少了@wraps这一行。这种问题一旦踩中,代价远高于写一行装饰器的时间。4.3 编写带参数的装饰器有些场景需要给装饰器传参数,比如设定超时时间、设置重试次数。带参装饰器的写法是再包一层:def retry(max_times: int = 3, delay: float = 0.5): """失败重试装饰器,max_times 不包含首次执行""" def decorator(func): @functools.wraps(func) def wrapper(*args, **kwargs): for attempt in range(1, max_times + 1): try: return func(*args, **kwargs) except Exception as e: if attempt == max_times: raise print(f"第 {attempt} 次调用失败,{delay}s 后重试: {e}") time.sleep(delay) return wrapper return decorator @retry(max_times=5, delay=1.0) def flaky_request(url: str) -> str: # 模拟不稳定的网络请求 ...很多新手会忘掉装饰器返回的是decorator而不是wrapper,这里有个三层函数结构。第一层接收装饰器参数,第二层接收被装饰函数,第三层是真正的包装逻辑。想清楚以后,任何复杂的装饰器都能一层层搭出来。在真实项目里,我通常不会直接把带参装饰器定义在内联代码里,而是收进公共库模块,比如记日志、缓存、鉴权、重试,这四个是我最常用的自定义装饰器场景。5. lambda 与高阶函数:表达力与可读性的平衡5.1 lambda 的适用边界lambda适合“一次性、逻辑简单、不打算复用”的函数表达式。比如排序时的 key 函数,用 lambda 直接写规则比定义一个具名函数更直白:users.sort(key=lambda u: (u["age"], u["name"]))但如果 lambda 体内要写多行逻辑、包含分支判断,或者同一个函数在多处使用,就应该换成def。lambda 的命名的确麻烦,但匿名不代表它是银弹。一个 lambda 里塞进复杂的嵌套条件,读代码的人至少要看十秒钟才能读懂,而具名函数一眼就知道目的。我的原则是:lambda 只在表达式能在一行内写完的时候用,一旦超过一行,用def重写。5.2 与 map / filter / sorted 组合的正确姿势Python 比很多语言特殊的地方是自带map、filter这类高阶函数,但它们的使用有讲究。map功能可以用生成器表达式替代,比如(func(x) for x in items),而且生成器是惰性的,内存占用更可控。filter可以用(x for x in items if cond(x))替代,也更直观。所以我的建议是:map和filter在列表解析式面前基本可以退场。但sorted的 key 参数是真正的利器,它让排序逻辑非常清晰:records = [ {"name": "alice", "score": 78}, {"name": "bob", "score": 92}, {"name": "carol", "score": 84}, ] ranked = sorted(records, key=lambda r: (-r["score"], r["name"]))这里负号实现降序,如果分数相同再按名字升序。这种组合用法几乎每天都会遇到,比起先排序再反转,key 里写复合条件更省心。5.3 过度使用匿名函数的教训有阵子我写代码特别喜欢把一切能匿名化的函数都写成 lambda,觉得代码“简洁”。直到一次 code review,别人指出一个 lambda 调用里重复计算了昂贵的fetch_data(),我才意识到我被“简洁”蒙蔽了。具名函数除了可读性,还能让你在内部添加局部逻辑、提前算好中间结果、加类型注解,而这些 lambda 都做不到。如果看到一个业务函数里同时出现多个超过一行的 lambda,就该重构了。可以把这些逻辑抽成具名私有函数,清晰程度完全不是一个量级。代码首先是给人读的,其次才是给机器跑的。这个次序搞反了,后续维护成本一定让你后悔。6. 异常处理:让函数的失败路径可以预期6.1 异常不是用来到处捕获的很多人写函数,喜欢在整个函数体包一个try...except Exception,然后打一行日志继续往下走。这种写法会把真正的错误吞掉,导致问题延迟到后续关联系统才暴露。正确的思路是:异常应该在被调函数的边界捕获,在调用方决定如何处理。如果一个函数本身就是可能失败的(比如请求外部接口),应该让异常向上传播,同时在 docstring 里写明哪些异常会抛。你可以在调用层做兜底,但不能在每一层都吞异常、打日志、返回一个奇怪的默认值,否则谁也查不出真正发生了什么。6.2 try / except 的精确布局try块里尽量只放可能会抛异常的代码,不要放无关的逻辑。这样能减少异常误报的可能性,也让排查范围变小。有一个我常用的模式是先校验再执行,把预期内的失败提前拦掉:def safe_divide(a: float, b: float) -> float: if b == 0: raise ValueError("除数不能为 0") return a / b这里先判断再除法,比直接try/except ZeroDivisionError更符合“前置校验”的惯例,而且错误发生原因一目了然。不过如果b是用户输入,可能还需要额外处理类型错误,这时候用try转换成统一的ValueError反而更好,流程上需要结合实际情况取舍。6.3 自定义异常,保证信息完整当异常系统复杂化之后,内置异常类型不够用。比如一个数据校验函数,我希望调用方能够精确知道“哪个字段,为什么失败”,这时自定义异常就非常有用:class ValidationError(Exception): """数据校验失败的异常,包含字段名与错误原因""" def __init__(self, field: str, reason: str): self.field = field self.reason = reason super().__init__(f"字段 {field} 校验失败: {reason}") def validate_user(user: dict) -> None: if "email" not in user: raise ValidationError("email", "缺失必填字段") if "@" not in user["email"]: raise ValidationError("email", "格式非法")自定义异常最重要的好处是用异常类型本身传达语义,调用方可以按异常类型分别处理,而不是靠解析字符串来猜是哪种错误。我现在写库函数时,都会定义一层该领域的异常基类,再派生子类,这样接口给出去,使用者except时就有了清晰的分类维度。7. 常见问题与排查技巧实录7.1 可变默认参数的幽灵前面提过一次,但这里放上一个真实的排查案例做补充。有次系统出现偶发性数据错乱:用户点击“收藏”后,收藏列表里多出了别人的记录。代码里有一处def get_favorites(user_id, cache=[]),缓存列表在所有请求间共享。并发下虽然数据操作有锁,但列表跨请求积累数据,导致越错越多。排查过程用的是二分打日志:先怀疑数据库,再怀疑中间层,最后才看到这个函数签名。经验是,凡是函数签名里出现[]、{}、set()作为默认值的,直接视为 bug 即可,不要心存侥幸。7.2 全局变量修改 UnboundLocalError另一个高频问题是在函数内引用全局变量,但同时又在某个分支对它赋值,Python 解析器看到函数体内有赋值语句,就把整个名字视为局部变量,进而报 UnboundLocalError。这种情况看起来是“我第一次调用了函数为什么报变量未定义”,实际是作用域规则在起作用。我的排查经验是:遇到 UnboundLocalError,先看函数体内是否对该名字有过赋值,如果有,要么加global,要么改个局部变量名。我很推荐后者,因为改局部变量名能避免隐式依赖全局状态的坑。7.3 递归层数上限与尾递归缺失Python 默认递归深度是 1000 左右,递归深度超过就会RecursionError。如果你真的需要深层递归,可以临时调整sys.setrecursionlimit,但那只是治标。Python 没有尾递归优化,真正的方案通常是把递归改写为迭代,或使用显式栈。比如遍历一个深层嵌套的目录结构,递归写法更直观,但目录深度超过一千就可能崩。用显式栈迭代,担心的问题就完全消失,还能自由控制遍历顺序。递归适合“问题本身就是递归定义的”,但遇到深度不可控的场景,不要恋战,换成循环处理更稳。7.4 函数调用的性能微优化Python 函数调用有开销,每次调用要构造栈帧、解析参数。在热点循环里,把函数调用结果缓存到局部变量可以省掉一大截时间:# 不推荐:每次循环都查全局 for item in items: process(item) # 更优:先用局部变量绑定 _process = process for item in items: _process(item)这种优化现在看起来收益不算特别大,但在百万级迭代里也能节省不少时间。更重要的经验是,调优前先profile定位热点,不要凭感觉猜。Python 自带cProfile,配合pstats可以轻松找出哪些函数占了绝大部分执行时间。很多所谓“慢函数”其实慢在下层 I/O 或算法复杂度,并不是函数调用本身。我个人的调试习惯是把函数入口的关键参数通过repr()打日志,格式统一为函数名 参数 -> 返回值。无论是排查线上问题还是在本地调试,这套模式十次里能解决八次问题,比开着调试器一步步跟进要快得多。函数写得短、边界清晰、异常明确,配合这套日志模式,绝大多数问题都能在十分钟内定位。如果你也在系统设计阶段,建议把所有复杂逻辑都切成小函数,然后给每个函数配一个独立测试用例。刚开始会觉得慢,但后续改动和排障时间会大幅缩减。把函数的“契约”想清楚,代码就会像搭积木一样,改一块不影响另外一块。这个习惯值得从第一个函数开始培养。
1. 函数设计的核心思路:先把职责划分清楚1.1 为什么函数值得认真设计很多人写 Python 函数,第一反应是“把一段重复代码包起来”,这个直觉没错,但如果只停留在这一步,函数很快会退化成一堆纠缠在一起的过程性代码。我有段时间负责维护一个数据处理链路,里面有个函数叫process_data,干了二十多件事:读文件、清洗字段、算指标、画图、写库、发通知。每次需求变更都要动它,改一处崩三处,最后全靠单元测试兜底才没出事故。后来我花了两天重构,把每个步骤拆成独立函数,整个链路才真正变得可读、可测、可替换。函数本质上是对“输入到输出”的一次契约约定。写函数之前,先问三个问题:它接收什么?它返回什么?它产生什么副作用?如果一个函数能清晰回答这三件事,那它就是合格的;如果回答起来模模糊糊,这个函数多半设计得有问题。1.2 单一职责怎么落地单一职责听起来像大道理,落地却需要具体的判断标准。我常用的一个简单判据:如果一个函数需要你用“然后”“接着”这样的词来描述它的行为,那就是职责过多。比如“读取配置文件然后解析成字典再检查必填项”,这其实是三段逻辑,应该拆成三个函数。拆完之后,每个函数的长度自然就短了,参数也变少了,测试时只需要关注一个行为点。我在团队里定过一条规矩:单个函数体超过四十行就必须说明理由。这条规矩不是绝对正确,但它逼着人思考“是不是该拆了”。实际执行下来,大部分超过四十行的函数都能拆出至少两三个子函数,而且拆完之后主调函数的逻辑会变得像读文章一样流畅。另外一个容易被忽略的点:职责划分也要考虑复用边界。拆得太细,一个十行的函数被拆成五个两行的函数,结构反而碎片化。我的经验是,拆分的颗粒度以“是否会被多个地方调用”和“是否包含独立判断逻辑”为标准,而不是为了拆而拆。1.3 输入与输出的契约设计函数契约的核心是明确输入输出的边界。我强烈建议从写第一行函数开始就使用类型提示,不管项目大小。def calc_discount(price: float, rate: float = 0.9) -> float:这种写法,读代码的人一眼就知道该传什么、会得到什么,配合静态检查工具能在运行前拦截大量低级错误。返回值方面,最容易出问题的是“有时返回结果,有时返回 None”。比如一个查找函数,找不到就返回 None,调用方每次都要判空。更好的做法是用异常表达“找不到”这种异常分支,或者用Optional类型明确声明,让调用方知道要处理空值。我见过不少线上事故都是因为调用方忘了处理 None 导致的,契约写清楚能减少这类问题。文档字符串也别偷懒。不需要写成长篇论文,三句话就够:这个函数做什么、参数含义、返回值含义。如果函数有特定的异常行为,也要写清楚。我个人的习惯是 docstring 里写清楚“什么情况下会抛异常”,这比注释里写一百句“这里是处理逻辑”有用得多。2. 参数体系:Python 函数的灵活性与陷阱2.1 位置参数与关键字参数的选择Python 的参数体系是我见过最灵活的语言设计之一,但灵活性也是双刃剑。位置参数适合参数少、顺序有天然逻辑的场景,比如math.ceil(3.2);参数一旦超过两三个,强制使用关键字参数会更安全。在 Python 里可以用*单独分隔参数,强制让后续参数只能以关键字方式传入:def create_user(name: str, *, age: int | None = None, email: str | None = None) -> dict: if age is not None and age < 0: raise ValueError("age 不能为负数") return {"name": name, "age": age, "email": email} # 合法调用 create_user("张三", age=25, email="zhangsan@example.com") # 非法调用:age 的位置参数不合法 # create_user("张三", 25)这种设计在参数多的场景里特别值得用。比如一个初始化工龄较长的函数,如果所有参数都是位置参数,调用时全靠人记顺序,一旦顺序错了,运行期才会暴露,还不如在定义阶段就直接堵住这种可能。2.2 *args 和 **kwargs 的实战取舍*args和**kwargs是 Python 函数的一大特色,但同时也是被滥用最严重的特性。它们最合理的用途有两个:一个是写装饰器和包装器,因为你要转发任意参数;另一个是定义接口层,比如事件处理函数,不同事件可能携带不同参数。我自己写装饰器时的标准写法是:def log_call(func): def wrapper(*args, **kwargs): print(f"调用 {func.__name__}, 参数: {args}, {kwargs}") return func(*args, **kwargs) return wrapper这里如果不收集**kwargs,传关键字的调用就会直接炸掉。但反过来,如果一个业务函数本身签名里全是*args, **kwargs,那代码基本没法维护,因为调用方根本不知道应该传什么。我见过有人把核心业务函数写成完全*args化,美其名曰“灵活”,实际上每次调用都要翻源码看顺序。这种写法一定要避免。2.3 默认参数的两个经典坑默认参数是 Python 入门必踩的坑,最经典的就是可变对象默认参数。函数定义时,默认参数表达式只求值一次,所以def append_item(item, lst=[])中那个空列表会被所有调用共享。我之前给同事排查过一个诡异问题:两个用户调同一个接口,A 用户的数据出现在 B 用户的请求结果里,根源就是某个函数默认参数用了[]。正确的做法是默认值用None,函数体内再初始化:def append_item(item: str, lst: list | None = None) -> list: if lst is None: lst = [] lst.append(item) return lst第二个坑是默认参数值在函数定义时就确定。比如def process(ts=time.time()): ...,这个ts在所有调用中都指向定义那一刻的时间,而不是每次调用时取当前时间。如果需要“调用时取当前值”,默认值必须设为None,函数体内取datetime.now()。这个错误在写日志切分、缓存过期这类场景时尤其容易踩中。3. 作用域与闭包:函数之外的运行环境3.1 LEGB 规则是理解一切的钥匙Python 的名称查找遵循 LEGB 规则:局部作用域(Local)、闭包外函数作用域(Enclosing)、全局作用域(Global)、内建作用域(Built-in)。很多新手困惑“为什么我在函数里改了全局变量没生效”,根源就是没理解赋值操作在局部作用域创建了新名称,而不是修改全局名称。counter = 0 def increment(): # 这里的 counter 会被视为局部变量,因为后面有赋值操作 counter += 1 # UnboundLocalError def increment_ok(): global counter counter += 1如果你需要修改全局变量,用global声明;但更推荐的做法是尽量避免在函数内部修改全局状态,通过返回值把变化带出来。全局变量一旦被多个函数修改,调试时你就会陷入“这变量到底在哪被改的”的泥潭。我在实际项目中会要求所有全局状态收敛到一个状态对象里,函数之间通过参数传递,而不是顺手改全局。3.2 闭包不只是面试题闭包是指内层函数引用了外层函数的变量,并且外层函数已返回,内层函数仍然持有那份引用。这不仅是面试题里的概念,在数据工厂、计数器、延迟计算等场景非常实用。def make_counter(start: int = 0): state = {"value": start} def inc(step: int = 1) -> int: state["value"] += step return state["value"] def reset() -> None: state["value"] = start return inc, reset inc, reset = make_counter(10) print(inc()) # 11 print(inc(5)) # 16 reset() print(inc()) # 11这个例子里用字典作为容器,是因为内层函数要修改state指向的对象内容,而不是重新绑定state这个名字。如果直接写state += step就会触发 UnboundLocalError,因为不可变对象重新赋值会改变名称绑定。理解这一点之后,闭包用起来就不再玄乎,它本质上就是“函数携带着一个小型私有环境”。3.3 从闭包通向装饰器闭包的一个高阶应用就是装饰器。装饰器接收一个函数,返回一个新函数,这个新函数通过闭包“捕获”了原函数。理解闭包之后,装饰器就只剩下“参数是函数”这一层语法糖。def my_decorator(func): def wrapper(*args, **kwargs): print("before") result = func(*args, **kwargs) print("after") return result return wrapper @my_decorator def say_hello(): print("hello")整个机制就是:say_hello = my_decorator(say_hello),新的say_hello是wrapper,但通过闭包能访问原来的函数。一旦想清楚这层等价关系,装饰器的所有行为都变得可推导了。4. 装饰器实战:从计时器到带参数装饰器4.1 写一个可复用的计时装饰器性能分析是装饰器最典型的应用之一。我写过一个轻量的计时装饰器,用在排查接口慢查询和对比不同算法的时间开销上:import time import functools def timed(func): """打印函数执行耗时(毫秒)""" @functools.wraps(func) def wrapper(*args, **kwargs): start = time.perf_counter() try: return func(*args, **kwargs) finally: elapsed = (time.perf_counter() - start) * 1000 print(f"{func.__name__} 耗时 {elapsed:.2f} ms") return wrapper注意这里try/finally的用法,即使函数抛异常也能打印耗时。用perf_counter而不是time.time,是因为time.time受系统时间调整影响,测出来的间隔可能为负或偏差极大。这个装饰器被我贴到了团队公共工具库里,后来在定位一次数据库连接池泄漏时,正是靠它发现某个函数平均耗时从 20ms 涨到了 900 多毫秒。4.2 为什么必须用 functools.wraps如果装饰器直接return wrapper,被装饰函数的元信息会丢失,func.__name__变成wrapper,help(func)显示的也是装饰器的文档。这在调试、日志、自动化生成 API 文档时会带来一堆隐蔽问题。@functools.wraps做的事本质上是把原函数的__name__、__doc__、__module__等属性复制到wrapper上。更重要的是,它还帮wrapper设置了__wrapped__属性,指向原始函数,这样用inspect.signature依然能看到原始签名,对依赖参数自省的工具链非常关键。我见过有人图省事不写wraps,结果在使用inspect.getfullargspec的自动化框架里,所有被装饰函数的参数都变成(*args, **kwargs),导致路由注册全部失效。排查了整整一下午,最后发现是装饰器少了@wraps这一行。这种问题一旦踩中,代价远高于写一行装饰器的时间。4.3 编写带参数的装饰器有些场景需要给装饰器传参数,比如设定超时时间、设置重试次数。带参装饰器的写法是再包一层:def retry(max_times: int = 3, delay: float = 0.5): """失败重试装饰器,max_times 不包含首次执行""" def decorator(func): @functools.wraps(func) def wrapper(*args, **kwargs): for attempt in range(1, max_times + 1): try: return func(*args, **kwargs) except Exception as e: if attempt == max_times: raise print(f"第 {attempt} 次调用失败,{delay}s 后重试: {e}") time.sleep(delay) return wrapper return decorator @retry(max_times=5, delay=1.0) def flaky_request(url: str) -> str: # 模拟不稳定的网络请求 ...很多新手会忘掉装饰器返回的是decorator而不是wrapper,这里有个三层函数结构。第一层接收装饰器参数,第二层接收被装饰函数,第三层是真正的包装逻辑。想清楚以后,任何复杂的装饰器都能一层层搭出来。在真实项目里,我通常不会直接把带参装饰器定义在内联代码里,而是收进公共库模块,比如记日志、缓存、鉴权、重试,这四个是我最常用的自定义装饰器场景。5. lambda 与高阶函数:表达力与可读性的平衡5.1 lambda 的适用边界lambda适合“一次性、逻辑简单、不打算复用”的函数表达式。比如排序时的 key 函数,用 lambda 直接写规则比定义一个具名函数更直白:users.sort(key=lambda u: (u["age"], u["name"]))但如果 lambda 体内要写多行逻辑、包含分支判断,或者同一个函数在多处使用,就应该换成def。lambda 的命名的确麻烦,但匿名不代表它是银弹。一个 lambda 里塞进复杂的嵌套条件,读代码的人至少要看十秒钟才能读懂,而具名函数一眼就知道目的。我的原则是:lambda 只在表达式能在一行内写完的时候用,一旦超过一行,用def重写。5.2 与 map / filter / sorted 组合的正确姿势Python 比很多语言特殊的地方是自带map、filter这类高阶函数,但它们的使用有讲究。map功能可以用生成器表达式替代,比如(func(x) for x in items),而且生成器是惰性的,内存占用更可控。filter可以用(x for x in items if cond(x))替代,也更直观。所以我的建议是:map和filter在列表解析式面前基本可以退场。但sorted的 key 参数是真正的利器,它让排序逻辑非常清晰:records = [ {"name": "alice", "score": 78}, {"name": "bob", "score": 92}, {"name": "carol", "score": 84}, ] ranked = sorted(records, key=lambda r: (-r["score"], r["name"]))这里负号实现降序,如果分数相同再按名字升序。这种组合用法几乎每天都会遇到,比起先排序再反转,key 里写复合条件更省心。5.3 过度使用匿名函数的教训有阵子我写代码特别喜欢把一切能匿名化的函数都写成 lambda,觉得代码“简洁”。直到一次 code review,别人指出一个 lambda 调用里重复计算了昂贵的fetch_data(),我才意识到我被“简洁”蒙蔽了。具名函数除了可读性,还能让你在内部添加局部逻辑、提前算好中间结果、加类型注解,而这些 lambda 都做不到。如果看到一个业务函数里同时出现多个超过一行的 lambda,就该重构了。可以把这些逻辑抽成具名私有函数,清晰程度完全不是一个量级。代码首先是给人读的,其次才是给机器跑的。这个次序搞反了,后续维护成本一定让你后悔。6. 异常处理:让函数的失败路径可以预期6.1 异常不是用来到处捕获的很多人写函数,喜欢在整个函数体包一个try...except Exception,然后打一行日志继续往下走。这种写法会把真正的错误吞掉,导致问题延迟到后续关联系统才暴露。正确的思路是:异常应该在被调函数的边界捕获,在调用方决定如何处理。如果一个函数本身就是可能失败的(比如请求外部接口),应该让异常向上传播,同时在 docstring 里写明哪些异常会抛。你可以在调用层做兜底,但不能在每一层都吞异常、打日志、返回一个奇怪的默认值,否则谁也查不出真正发生了什么。6.2 try / except 的精确布局try块里尽量只放可能会抛异常的代码,不要放无关的逻辑。这样能减少异常误报的可能性,也让排查范围变小。有一个我常用的模式是先校验再执行,把预期内的失败提前拦掉:def safe_divide(a: float, b: float) -> float: if b == 0: raise ValueError("除数不能为 0") return a / b这里先判断再除法,比直接try/except ZeroDivisionError更符合“前置校验”的惯例,而且错误发生原因一目了然。不过如果b是用户输入,可能还需要额外处理类型错误,这时候用try转换成统一的ValueError反而更好,流程上需要结合实际情况取舍。6.3 自定义异常,保证信息完整当异常系统复杂化之后,内置异常类型不够用。比如一个数据校验函数,我希望调用方能够精确知道“哪个字段,为什么失败”,这时自定义异常就非常有用:class ValidationError(Exception): """数据校验失败的异常,包含字段名与错误原因""" def __init__(self, field: str, reason: str): self.field = field self.reason = reason super().__init__(f"字段 {field} 校验失败: {reason}") def validate_user(user: dict) -> None: if "email" not in user: raise ValidationError("email", "缺失必填字段") if "@" not in user["email"]: raise ValidationError("email", "格式非法")自定义异常最重要的好处是用异常类型本身传达语义,调用方可以按异常类型分别处理,而不是靠解析字符串来猜是哪种错误。我现在写库函数时,都会定义一层该领域的异常基类,再派生子类,这样接口给出去,使用者except时就有了清晰的分类维度。7. 常见问题与排查技巧实录7.1 可变默认参数的幽灵前面提过一次,但这里放上一个真实的排查案例做补充。有次系统出现偶发性数据错乱:用户点击“收藏”后,收藏列表里多出了别人的记录。代码里有一处def get_favorites(user_id, cache=[]),缓存列表在所有请求间共享。并发下虽然数据操作有锁,但列表跨请求积累数据,导致越错越多。排查过程用的是二分打日志:先怀疑数据库,再怀疑中间层,最后才看到这个函数签名。经验是,凡是函数签名里出现[]、{}、set()作为默认值的,直接视为 bug 即可,不要心存侥幸。7.2 全局变量修改 UnboundLocalError另一个高频问题是在函数内引用全局变量,但同时又在某个分支对它赋值,Python 解析器看到函数体内有赋值语句,就把整个名字视为局部变量,进而报 UnboundLocalError。这种情况看起来是“我第一次调用了函数为什么报变量未定义”,实际是作用域规则在起作用。我的排查经验是:遇到 UnboundLocalError,先看函数体内是否对该名字有过赋值,如果有,要么加global,要么改个局部变量名。我很推荐后者,因为改局部变量名能避免隐式依赖全局状态的坑。7.3 递归层数上限与尾递归缺失Python 默认递归深度是 1000 左右,递归深度超过就会RecursionError。如果你真的需要深层递归,可以临时调整sys.setrecursionlimit,但那只是治标。Python 没有尾递归优化,真正的方案通常是把递归改写为迭代,或使用显式栈。比如遍历一个深层嵌套的目录结构,递归写法更直观,但目录深度超过一千就可能崩。用显式栈迭代,担心的问题就完全消失,还能自由控制遍历顺序。递归适合“问题本身就是递归定义的”,但遇到深度不可控的场景,不要恋战,换成循环处理更稳。7.4 函数调用的性能微优化Python 函数调用有开销,每次调用要构造栈帧、解析参数。在热点循环里,把函数调用结果缓存到局部变量可以省掉一大截时间:# 不推荐:每次循环都查全局 for item in items: process(item) # 更优:先用局部变量绑定 _process = process for item in items: _process(item)这种优化现在看起来收益不算特别大,但在百万级迭代里也能节省不少时间。更重要的经验是,调优前先profile定位热点,不要凭感觉猜。Python 自带cProfile,配合pstats可以轻松找出哪些函数占了绝大部分执行时间。很多所谓“慢函数”其实慢在下层 I/O 或算法复杂度,并不是函数调用本身。我个人的调试习惯是把函数入口的关键参数通过repr()打日志,格式统一为函数名 参数 -> 返回值。无论是排查线上问题还是在本地调试,这套模式十次里能解决八次问题,比开着调试器一步步跟进要快得多。函数写得短、边界清晰、异常明确,配合这套日志模式,绝大多数问题都能在十分钟内定位。如果你也在系统设计阶段,建议把所有复杂逻辑都切成小函数,然后给每个函数配一个独立测试用例。刚开始会觉得慢,但后续改动和排障时间会大幅缩减。把函数的“契约”想清楚,代码就会像搭积木一样,改一块不影响另外一块。这个习惯值得从第一个函数开始培养。