基础语法
后端、测试、AI 岗都绕不开的地基。这一篇的核心是可变性和引用语义——Python 大部分"灵异现象"都源于此。
可变与不可变
| 类别 | 类型 | 特点 |
|---|---|---|
| 不可变 | int / str / tuple / bool / float | 一旦创建不能原地修改,"修改"其实是新建对象 |
| 可变 | list / dict / set | 可以原地修改,多个变量共享同一对象 |
为什么 dict 的 key 必须可哈希? dict 底层是哈希表,存取时要对 key 算哈希值定位桶。list/dict/set 没有定义 __hash__(内容会变,哈希值不稳定),所以不能做 key;tuple 内容不变就可以。
d = {(1, 2): "ok"} # tuple 可以做 key
d[[1, 2]] = "no" # TypeError: unhashable type: 'list'
is 与 ==
==比较值(调用__eq__)is比较身份(是否同一个对象,比 id)
经典坑——小整数池与字符串驻留:
a = 256; b = 256
a is b # True,CPython 缓存 -5~256
a = 257; b = 257
a is b # 交互式下 False(同一行赋值可能同为 True,不可依赖)
规范:判 None 用 is None;比内容用 ==。永远不要用 is 比整数或字符串。
赋值、浅拷贝、深拷贝
赋值只是绑定名字,不复制对象。浅拷贝只复制外层容器,内层元素仍是引用;深拷贝递归复制整棵对象树。
import copy
a = [[1, 2], [3, 4]]
b = a # 赋值:a、b 是同一个对象
c = copy.copy(a) # 浅拷贝:外层是新 list,内层子 list 共享
d = copy.deepcopy(a) # 深拷贝:完全独立
a[0].append(99)
c # [[1, 2, 99], [3, 4]] ← 浅拷贝的内层被改了,这就是坑
默认参数陷阱
默认值只在函数定义时求值一次,之后所有调用共享同一个对象。
def bad(item, lst=[]): # 危险:所有调用共享一个 list
lst.append(item)
return lst
bad(1) # [1]
bad(2) # [1, 2] ← 不是 [2]!
def good(item, lst=None):
lst = lst if lst is not None else []
lst.append(item)
return lst
装饰器
装饰器 = 闭包 + 一等函数。@d 语法糖等价于 f = d(f)——把原函数包一层换回去。
import functools
def timer(func):
@functools.wraps(func) # 不加这行,func.__name__ 会变成 'wrapper'
def wrapper(*args, **kwargs):
import time
start = time.perf_counter()
result = func(*args, **kwargs)
print(f"{func.__name__} 耗时 {time.perf_counter() - start:.3f}s")
return result
return wrapper
@timer
def train_epoch(n):
return n * 2
要点:
@functools.wraps保留原函数的__name__、__doc__,否则调试和文档工具全废。- 带参数的装饰器是三层嵌套:
@retry(3)→ 先调retry(3)返回真正的装饰器,再装饰函数。
迭代器与生成器
- 迭代器:实现
__iter__和__next__的对象。 - 生成器:带
yield的函数,调用时不执行,每次next()才运行到下一个 yield。
yield 的三个关键性质:挂起函数状态(局部变量、执行位置都保留)、惰性求值(要一个算一个)、省内存(不用把整个序列放进内存)。
def read_big_file(path):
with open(path, encoding="utf-8") as f:
for line in f: # 逐行惰性读,内存占用 O(1)
yield line.strip()
yield vs return:return 终止函数并返回值;yield 暂停函数并让出一个值,下次 next() 从暂停处继续。生成器表达式 (x for x in ...) 同理,区别于列表推导一次性建全量。
import 机制与 __name__
import 做三件事:① 在 sys.modules 缓存里找,有就直接用;② 没有则找到模块并执行其顶层代码;③ 在当前命名空间绑定名字。所以模块顶层代码只在第一次 import 时执行一次。
if __name__ == '__main__':脚本直接运行时 __name__ 是 '__main__',被 import 时是模块名。用它区分"当脚本跑"和"当模块被导入"两种场景。
异常处理
try:
risky()
except ValueError as e: # 具体异常放前面
...
except Exception as e: # 宽泛异常放最后,避免吞掉具体异常
log.error(e)
else:
... # 没抛异常才执行
finally:
... # 无论如何都执行,收尾用
with 语句的本质是上下文管理协议:进入时调 __enter__,离开时(即使抛异常)调 __exit__ 释放资源。这就是为什么文件、锁、数据库连接都应该用 with 管理。
垃圾回收(GC)
CPython 的 GC 是三层组合:
- 引用计数为主:每个对象记录被引用次数,归零立刻回收。实时性好,但有致命弱点——循环引用(
a.b = b; b.a = a,两者计数永远 ≥ 1)。 - 标记清除:定期从根对象出发标记可达对象,清除不可达的,专门治循环引用。
- 分代回收提效:对象分 0/1/2 三代,新对象在 0 代,熬过一次回收就晋升。经验假设"大部分对象朝生暮死",所以频繁扫 0 代、很少扫 2 代,降低整体开销。
另外还有小对象缓存池(pymalloc):小对象从内存池分配,减少系统调用和内存碎片。
面试常问"有了引用计数为什么还要标记清除?"——标准答案就一句:引用计数解决不了循环引用。
高频面试题
| 问题 | 答题要点 |
|---|---|
| 可变与不可变?dict 的 key 有什么要求? | 不可变:int/str/tuple/bool;可变:list/dict/set。dict 用哈希表存取,key 必须可哈希(不可变) |
is 和 == 的区别? | == 比值调 __eq__,is 比地址。注意小整数池 -5~256 的坑;判 None 用 is |
| 浅拷贝深拷贝的区别?举个坑 | 浅拷贝只复制外层,内层引用共享;copy.copy 嵌套 list 后改内层会影响两边 |
def f(x, l=[]) 有什么问题? | 默认值定义时求值一次,所有调用共享同一 list;用 None 哨兵在函数体内新建 |
装饰器的原理?wraps 干什么用? | 闭包 + 一等函数,@d 即 f = d(f);wraps 拷贝元信息,否则 __name__ 丢失 |
yield 和 return 的区别? | return 终止返回;yield 挂起状态、惰性求值、省内存 |
| 说说 GC,为什么有引用计数还要标记清除? | 引用计数为主 + 标记清除治循环引用 + 分代提效 + 小对象池;循环引用计数永不归零 |
with 语句的本质? | 上下文管理协议 __enter__/__exit__,保证资源在异常时也能释放 |