写 Python 久了,总会遇到一类代码:语法挑不出错,跑起来也正常,但阅读起来磕磕绊绊,动其中一处,另一处就跟着坏掉。真正的原因往往与关键字无关,而在于对 Python 名字与对象这两层机制掌握得不够透彻。

变量是名字,不是装东西的盒子

Python 的变量属于「名字」,名字绑定在对象上,而不是像一个容器那样把值装进来。执行 a = [1, 2] 实际上完成了两步:先新建一个列表对象,再把名字 a 与它关联起来。接着写 b = a 并不会生成新列表,两个名字指向的是同一个对象,因此对 b.append(3) 之后,通过 a 看到的内容同样发生了变化——这就是名字绑定的直接结果。若希望拿到副本,可以调用 a.copy();但若列表中嵌套了列表,copy 只处理最外层,深层复制必须用 copy.deepcopy。

默认参数在函数定义时就已求值

像 def add(item, bucket=[]) 这样的写法隐藏着一个时机问题:bucket 所指向的列表在函数定义那一刻就已创建,之后每次调用只要不显式传入 bucket,取到的都是同一个列表,上一次调用塞进去的元素在下一次仍然可见。正确做法是写成 def add(item, bucket=None),在函数体内判断 bucket is None,成立时再新建列表。这比原来多出一行,但行为更清楚。

边遍历边删元素会漏项,原地改要用切片赋值

当循环写成 for i in range(len(items)) 时,删除元素会让长度发生变化,而索引继续向前推进,于是部分元素被跳过。可行的替代方案是倒序遍历,或者先生成一个新列表;如果确实需要原地修改,可以写 items[:] = [x for x in items if keep(x)],切片赋值保留了原列表对象本身,其他仍指向它的名字看到的也是更新后的内容。

遍历时若要同时拿到下标,用 enumerate 即可:for i, value in enumerate(items),免去 range(len(items)) 的写法。zip 能把两个序列并排遍历,但长度以较短的一方为准;需要按较长的一方推进时,改用 itertools.zip_longest 并设置默认填充值。

生成器只能走一遍,迭代器耗尽即停

生成器表达式与列表推导在写法上只差方括号与圆括号,行为却明显不同:列表推导会一次性把全部元素建好,生成器表达式则按需逐个产出。数据量大时,生成器更省内存;代价是它只能被遍历一次,走完就空了,若要重复使用,必须重新构造或转回列表。

迭代器同样有用完即停的特性。iter([1,2,3]) 返回迭代器后,连续调用 next 三次可以取出值,第四次就会抛出 StopIteration。for 循环自身会处理这个异常,而手动调用 next 时需要自行留意。文件对象也是迭代器,读到底之后位置停在末尾,再对它做 for 循环取不到内容,想重读需要调用 seek(0) 或者重新打开文件。

资源交给 with,异常捕获要收窄范围

文件、锁、数据库连接都实现了上下文协议,可以用 with 统一管理。以 with open(...) as f 为例,离开代码块时文件会自动关闭,即使中途抛出异常也会走关闭流程。自行编写类时实现 __enter__ 与 __exit__ 即可,需要注意 __exit__ 返回 True 会吞掉异常,返回 False 或 None 则异常继续向外传播,这个返回值的取舍必须提前想清楚。

捕获异常时要写明具体类型。except Exception 覆盖面过大,会把程序里的小错误一并截获,调试线索也就丢了。应当分别捕获 ValueError、KeyError 这类明确的异常,无法处理的则让它继续向上抛。finally 适合放置清理动作,else 则适合放在没有异常发生时才会执行的代码。

路径、数据类与装饰器的省样板写法

拼接路径尽量别用字符串手工加斜杠,Windows 与 Linux 的分隔符并不一致,pathlib 的 Path 对象正是为这类差异而生:Path('data') / 'a.txt' 得到新路径,.exists() 判断是否存在,.read_text() 读取文本内容,.glob('*.py') 用于查找文件。采用 Path 之后,路径就是一个对象,各类方法直接挂在它上面。

只承载数据的类可以用 dataclass 减少样板代码:from dataclasses import dataclass 之后,在类前加上 @dataclass,字段以类型注解声明,__init__、__repr__、__eq__ 都会自动生成。需要可变默认值时写 field(default_factory=list),直接写 list 会报错。这种写法适合字段不多的类,业务逻辑较多的类还是手写构造函数更合适。

编写装饰器时别忘了 functools.wraps,它会把被装饰函数的名字、文档字符串和注解复制到包装函数上,调试工具和文档工具才能看到真实信息。缺少 wraps 时,函数名会变成 wrapper,排查问题时极容易认错对象。

闭包、身份比较与字典默认值

在循环里生成 lambda 要格外小心。fs = [lambda: i for i in range(3)] 得到的三个函数共享同一个 i,真正调用时 i 已经变成 2。若希望每个函数记住当时的值,可以借助默认参数:lambda i=i: i,默认参数在定义阶段求值,每个函数便各自拿到了属于自己的 i。

is 判断的是对象身份,== 比较的是值。判空应写 is None,而不是 == None。小整数与短字符串有时会被解释器复用,导致 is 的结果与预期不符,代码中不应依赖这种复用行为;需要比较值是否相等时,一律用 ==。

从字典取值时用 get 提供默认值,例如 d.get(key, 0),可以省掉一次判断。若还想顺带写入默认值,则用 setdefault:d.setdefault(key, []).append(x),这一行先把列表放进字典,再对它追加元素,避免在循环里反复创建新列表。

字符串、排序与耗时测量

拼接多个字符串时应该用 join。例如 parts = ['a', 'b', 'c'],再执行 result = ''.join(parts)。若在循环里不断用加号,会反复创建新的字符串对象,数据量大时性能差距明显;join 的做法是先算出总长度,再一次性填充内容。

排序优先使用 key 函数,例如 sorted(users, key=lambda u: u.age),不要把复杂逻辑塞进比较函数里。key 对每个元素只计算一次,排序依据的就是这个结果。需要按多个字段排序时,让 key 返回元组,例如 (u.age, u.name)。

测量一小段代码的耗时用 timeit,它会多次运行并给出统计结果。自己用 time.time() 则要额外处理循环与预热,且结果容易受机器状态干扰。比较两种写法时,应当保证同一台机器、同一组输入,关注数量级上的差异即可,不必纠结小数点后的抖动。

调试、注解与模块入口

调试可以用 breakpoint(),它是 Python 3.7 起内置的功能,执行到这一行就会进入 pdb,可以查看变量、单步执行、打印调用栈。需要强调的是,线上代码里不应留下 breakpoint,提交前记得全局搜一遍。

类型注解既方便阅读代码,也能帮助编辑器补全,但运行时并不会强制校验。写注解时必须与实际数据保持一致,错误的注解比不写更具误导性。公共函数、数据结构与返回值都应当写清楚。

当同一个文件既要作为脚本运行又要被当作模块导入时,加上 if __name__ == '__main__': 即可:只有直接运行时才执行这段测试代码,被 import 时不会触发。这个判断通常放在文件靠下的位置,函数与类的定义则应排在它前面。

import 同样要克制。from module import * 会把名字一股脑导入当前命名空间,读代码时难以分辨来源,发生冲突也不好排查。建议写成 import module,或者用 from module import 明确列出所需名字;即使名字较短,写全路径也不算吃亏。

小结

这些写法指向的是同一套底层设计:Python 把名字与对象分为两层,把求值时机直接摆到明处,把资源清理交给协议、把数据流动交给迭代器。写代码时多留意一次绑定关系、一次返回值、一次作用域,踩坑的概率就会明显下降。依据原文作者「张姐」在文中给出的整理,这些点覆盖的都是日常最容易出问题的细节,值得逐条对照自己的代码检查一遍。