资讯动态

Python 函数:参数、作用域、装饰器,还有几个我踩过的坑

发布时间:2026/10/24 2:28:47 来源:尧图企业网站定制
一、参数到底怎么传别再说传值还是传引用面试标准答案里最常听到的是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 sharingx [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, bucketNone): if bucket is None: bucket [] bucket.append(item) return bucket为什么必须用 is None 而不是 if not bucket因为调用方完全可能传一个空列表进来表达就用这个not [] 会把它换掉。同理别用 {} 以外的假值做判断。三、参数列表其实有五个区段多数人只用了两个完整签名长这样def fetch(url, /, timeout10, *extra, retries3, **kwargs): ...从左到右仅位置、普通参数、可变位置、仅关键字、可变关键字。其中 / 是 3.8 才加的PEP 570用它之前的写法只能靠文档约定这个参数不要按关键字传。实践中我几乎不用 / 和 *extra但**只用 * 这一条我建议你养成习惯**def request(url, *, timeout10, retries3): ...加了 * 之后所有带默认值的参数必须按关键字传。好处很直接调用处写成 request(url, 5)半年后回来看代码你不知道 5 是超时还是重试次数写成 request(url, timeout5) 就没人会误解。这个约束是加在定义端的调用方想偷懒也偷不了。对照表省得你翻文档写法位置传参关键字传参说明def f(a)可以可以最常见def f(a1)可以可以默认值定义时求值def f(a, /)可以报错3.8参数名无法按关键字传def f(*, a)报错必须强制关键字def f(*args)可以不行收到的是 tupledef f(**kw)不行可以收到的是 dict*args 拿到的永远是 tuple**kwargs 拿到的是 dict这点确定无疑但你如果对 args 做修改改的是新建的 tuple不影响调用方——它本来就是打包出来的新对象。四、返回值Python 最安静的一个默认行为没写 return 的函数返回 None。人人都知道但下面这三种写法我年年都能见到items [3, 1, 2] result items.sort() # Nonesort 是原地排序 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 改自己、返回 Nonesorted 不动原对象、返回新列表。凡是原地操作的函数Python 里基本都返回 Nonelist.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 value3.10 及以前的报错是 local variable counter referenced before assignment3.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 ii: i for i in range(3)] # 二functools.partial from functools import partial funcs [partial(lambda i: i, i) for i in range(3)]日常我选第一种短。但如果这段代码要留给别人接手我会加一行注释说明这里的 ii 不是手滑——不加的话下一个人的第一反应就是把它优化掉。六、装饰器先知道它会怎么坏再谈会用写一个计时装饰器大部分人第一次都能写出来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 顶掉了。后果不是看着别扭日志里所有函数名都是 wrapperpytest 参数化用例 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(times3, delay1.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(times5) 套进去念一遍——retry(times5) 先返回 decoratordecorator 再吃函数就顺了。七、两个关于性能的实话第一个lru_cache 不是万能加速器import functools functools.lru_cache(maxsize128) def total(prices): return sum(prices) total([1, 2, 3])TypeError: unhashable type: listlru_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 模式的文件。你们团队是什么要求真在维护的项目里注解带来的收益和额外的维护成本各家体感差别挺大的评论区聊聊我挺想听反例。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑