☰
Python 函数:参数、作用域、装饰器,还有几个我踩过的坑
2026/10/24 2:28:42 网站建设 项目流程

一、参数到底怎么传:别再说"传值还是传引用"

面试标准答案里最常听到的是"Python 传引用",这话错得很隐蔽——因为在函数里重新赋值,外部对象并不会变。

def rebind(x): print("进函数时 id:", id(x)) x = [1, 2, 3] print("赋值后 id:", id(x)) data = [0] print("调用前 id:", id(data)) rebind(data) print("调用后:", id(data), data)

跑一遍你会看到三个 id:前两个相同,第三个变了。也就是说函数拿到的是同一个对象引用的一个副本(call by sharing),x = [1,2,3] 只是让局部名字 x 指向了新对象,跟外面的 data 从此没关系。

但换成"在对象上动刀"就是另一回事:

def mutate(x): x.append(3) data = [0] mutate(data) print(data) # [0, 3]

所以判断标准只有一句话:你是让名字重新指向,还是动这个对象本身。不是"可变不可变"决定的。

顺带一个容易搞混的:下面这段报错,很多人会解释成"元组不可变所以不能改",其实不是。

t = (1, 2) mutate(t)
AttributeError: 'tuple' object has no attribute 'append'

报错说的是"没有 append 这个方法",不是"不允许修改"。这个区别在排查时很有用——看到 AttributeError 先怀疑方法名/对象类型,别往可变性上想。

二、默认值只在定义时算一次(这个坑最常见)

回到开头那个 list 的问题:

def add(item, bucket=[]): bucket.append(item) return bucket print(add(1)) # [1] print(add(2)) # [1, 2] ← 不是 [2] print(add(3)) # [1, 2, 3]

很多人第一反应是"每次调用都会重新创建一个空列表吧"——不会。默认值在 def 执行的那一瞬间求值,然后存在函数的 __defaults__ 里,之后每次调用都复用同一个对象。你可以直接把它挖出来看:

print(add.__defaults__) # ([1, 2, 3],)

这个机制最阴的地方是它不报错,只是悄悄累积数据。我遇到过一次线上事故,一个 def parse(req, ctx={}) 在 Flask 里当请求上下文用,跑了两周,最后一个请求带上了前面所有请求的字段。

正确写法是老一套,但没有更好的替代:

def add(item, bucket=None): if bucket is None: bucket = [] bucket.append(item) return bucket

为什么必须用 is None 而不是 if not bucket?因为调用方完全可能传一个空列表进来表达"就用这个",not [] 会把它换掉。同理别用 {} 以外的假值做判断。

三、参数列表其实有五个区段,多数人只用了两个

完整签名长这样:

def fetch(url, /, timeout=10, *extra, retries=3, **kwargs): ...

从左到右:仅位置、普通参数、可变位置、仅关键字、可变关键字。其中 / 是 3.8 才加的(PEP 570),用它之前的写法只能靠文档约定"这个参数不要按关键字传"。

实践中我几乎不用 / 和 *extra,但**只用 * 这一条我建议你养成习惯**:

def request(url, *, timeout=10, retries=3): ...

加了 * 之后,所有带默认值的参数必须按关键字传。好处很直接:调用处写成 request(url, 5),半年后回来看代码,你不知道 5 是超时还是重试次数;写成 request(url, timeout=5) 就没人会误解。这个约束是加在定义端的,调用方想偷懒也偷不了。

对照表,省得你翻文档:

写法位置传参关键字传参说明
def f(a)可以可以最常见
def f(a=1)可以可以默认值定义时求值
def f(a, /)可以报错3.8+,参数名无法按关键字传
def f(*, a)报错必须强制关键字
def f(*args)可以不行收到的是 tuple
def f(**kw)不行可以收到的是 dict

*args 拿到的永远是 tuple,**kwargs 拿到的是 dict,这点确定无疑;但你如果对 args 做修改,改的是新建的 tuple,不影响调用方——它本来就是打包出来的新对象。

四、返回值:Python 最安静的一个默认行为

没写 return 的函数返回 None。人人都知道,但下面这三种写法我年年都能见到:

items = [3, 1, 2] result = items.sort() # None!sort 是原地排序 items_sorted = sorted(items) # 这才返回新列表
def save(data): if not data: return # 隐式 None return db.insert(data) # 这也可能是 None

然后调用方写 save(d).id,报 AttributeError: 'NoneType' object has no attribute 'id'。这个报错位置离真正的错误源头可能隔了三层调用,排查起来很费劲。

我的做法是一个函数只选一种风格:要么它是纯副作用型(明确 return None,调用方永远不接返回值),要么它一定返回有效对象,出错就抛异常。最怕的是"有时候返回数据、有时候返回 None、有时候返回 False"这种三段式,调用方除了写 if x is None 还得写 if x is False。

另外提一句 list.sort() 和 sorted() 的区别不该靠记:sort 改自己、返回 None,sorted 不动原对象、返回新列表。凡是"原地操作"的函数,Python 里基本都返回 None(list.append、dict.update 同理),这是一条能推出来的规律。

五、作用域与闭包:那个经典的晚绑定

LEGB 顺序(Local → Enclosing → Global → Builtin)背下来就够用了,但有两个具体的坑值得单说。

第一个,在函数里给全局变量赋值:

counter = 0 def inc(): counter += 1 inc()
UnboundLocalError: cannot access local variable 'counter' where it is not associated with a value

3.10 及以前的报错是 local variable 'counter' referenced before assignment,3.11 改得更直白了。原因:函数体内只要出现对 counter 的赋值,Python 就把它当局部变量,+= 又要先读后写,于是"读的时候它还没赋值"。修法是 nonlocal(嵌套函数)或 global(模块级)。不过我更建议直接别这么写,用一个类或传参进来,可测性好很多。

第二个坑,闭包的晚绑定,这个坑杀过无数人:

funcs = [lambda: i for i in range(3)] print([f() for f in funcs]) # [2, 2, 2],不是 [0, 1, 2]

原因不复杂:lambda 体里捕获的是名字 i,不是当时的值。列表推导跑完时 i 是 2,三个 lambda 调用时都去那个作用域里找 i,找到的都是 2。

两种修法,我都用过:

# 一:用默认参数把当前值焊死在定义时 funcs = [lambda i=i: i for i in range(3)] # 二:functools.partial from functools import partial funcs = [partial(lambda i: i, i) for i in range(3)]

日常我选第一种,短。但如果这段代码要留给别人接手,我会加一行注释说明"这里的 i=i 不是手滑"——不加的话,下一个人的第一反应就是把它"优化"掉。

六、装饰器:先知道它会怎么坏,再谈会用

写一个计时装饰器,大部分人第一次都能写出来:

import time def timing(func): def wrapper(*args, **kwargs): start = time.perf_counter() try: return func(*args, **kwargs) finally: print(f"{func.__name__} 耗时 {time.perf_counter() - start:.4f}s") return wrapper

注意那个 finally。不加的话,被装饰的函数一旦抛异常,计时永远不会打印——而异常路径恰恰是你最需要知道耗时的时候。我第一次写这个装饰器就没加,结果一个接口超时的时候我在日志里啥也没看到,白排查了半天。

然后是更常见的失误:忘了 functools.wraps。

@timing def handle(req): """处理请求""" print(handle.__name__) # wrapper,不是 handle print(handle.__doc__) # None

__name__、__doc__、__qualname__、__module__ 全被 wrapper 顶掉了。后果不是"看着别扭":日志里所有函数名都是 wrapper,pytest 参数化用例 id 全变成 test_x[wrapper0],inspect.signature() 拿到的是 (*args, **kwargs),Sphinx 生成的文档里所有被装饰函数都长得一样。加一行就行:

import functools def timing(func): @functools.wraps(func) def wrapper(*args, **kwargs): ... return wrapper

带参数的装饰器要三层嵌套,第一次写基本都会转晕:

def retry(times=3, delay=1.0): def decorator(func): @functools.wraps(func) def wrapper(*args, **kwargs): for attempt in range(1, times + 1): try: return func(*args, **kwargs) except Exception: if attempt == times: raise time.sleep(delay) return wrapper return decorator

记法就一句:最外层收装饰器的参数,第二层收函数,第三层收调用参数。想不明白的时候拿 @retry(times=5) 套进去念一遍——retry(times=5) 先返回 decorator,decorator 再吃函数,就顺了。

七、两个关于性能的实话

第一个,lru_cache 不是万能加速器:

import functools @functools.lru_cache(maxsize=128) def total(prices): return sum(prices) total([1, 2, 3])
TypeError: unhashable type: 'list'

lru_cache 拿参数做缓存 key,所以参数必须可哈希。这里还藏着个更隐蔽的:total([1,2,3]) 和 total((1,2,3)) 虽然结果一样,但因为是不同的 key,会各占一条缓存。我见过有人一半调用传 list 一半传 tuple,缓存命中率直接掉一半,还以为是自己 maxsize 设小了。

第二个,关于"函数调用太慢要不要内联"。这在 C 里是个真问题,在 Python 里基本不值得纠结:3.11 引入自适应解释器(PEP 659)之后,热点函数调用会被特化,官方给的 pyperformance 数据是整体比 3.10 快 10%–60%,函数调用正属于主要受益项。为了省一次调用把逻辑摊平,换来的是可读性崩掉——这笔账不划算。真到了性能瓶颈,该换的是算法或者上 C 扩展,而不是删函数。

八、我这边的几条规矩

写了不少年,最后沉淀下来的就这几条,都是我 review 时会打回去的:

  • 参数超过 5 个,别再往上堆默认值了,改用 dataclass 或者 TypedDict,调用方写起来也不难受
  • 默认值永远不用可变对象([]、{}、set()),一律 None 哨兵 + 函数体内创建
  • 对外的函数写类型注解,一次性的脚本不写。注解的价值在于"被别人调用",自己调自己的脚本,注释可能更管用
  • 装饰器必须带 functools.wraps,这条我当红线,没有例外
  • 不靠 sys.setrecursionlimit 解决问题。默认 1000 层不是随便定的,硬提到几十万在 Linux 上容易直接段错误(C 栈撑爆),而且崩的时候没有 Python 栈可以看。要深递归就改成显式栈或者循环

最后

上面这些坑里,可变默认值那个我见得最多,也最难在新人代码里发现——因为它不报错,只是数据慢慢不对。如果这篇只能记住一句话,记"默认值只在定义时算一次"。

至于类型注解,我一直好奇别人的落地情况:我们这边是新代码强制、老代码不动,mypy 只跑 strict 模式的文件。你们团队是什么要求?真在维护的项目里,注解带来的收益和额外的维护成本,各家体感差别挺大的,评论区聊聊,我挺想听反例。

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

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

立即咨询