跳到主要内容
版本:1.0

基础语法

后端、测试、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 是三层组合:

  1. 引用计数为主:每个对象记录被引用次数,归零立刻回收。实时性好,但有致命弱点——循环引用(a.b = b; b.a = a,两者计数永远 ≥ 1)。
  2. 标记清除:定期从根对象出发标记可达对象,清除不可达的,专门治循环引用。
  3. 分代回收提效:对象分 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__,保证资源在异常时也能释放