Python 八股
Python 八股
基础语法与对象模型
Python 中 is 和 == 有什么区别?
区别在于比较对象身份还是相等关系:
| 运算 | 判断内容 | 比较规则 | 与 Java 类比 |
|---|---|---|---|
is | 是否为同一个对象 | 直接比较身份(内存地址),等价于 id(a) == id(b) | Java == 比较引用地址 |
== | 两个对象是否相等 | 按类型的相等规则比较,可由 __eq__() 定义 | Java equals() 比较内容(需重写) |
什么是可变对象和不可变对象?常见的类型分别有哪些?
- 可变类型:可以在身份不变的情况下修改内容
常见可变类型:list、dict、set、bytearray - 不可变类型:不可变对象创建后,不能修改自身内容
常见不可变类型: int、float、bool、str、bytes、tuple、frozenset
Python 函数传参是值传递还是引用传递
传 对象引用(pass by object reference):传的就是指向对象的指针(地址值),形参引用按值拷贝
def f(x): # a ──→ [1, 2, 3] ←── x
x = [9, 9] # 重新赋值:外部不变 a ──→ [1, 2, 3],x ──→ [9, 9]
def g(x):
x.append(4) # 修改对象:外部可见 a ──→ [1, 2, 3, 4] ←── x
a = [1, 2, 3]
f(a) # a 仍是 [1, 2, 3]
g(a) # a 变成 [1, 2, 3, 4]
为什么是这样的Python 里
=是 给名字重新绑定,不是"修改对象",上述代码在 java 中结果相同
赋值、浅拷贝和深拷贝有什么区别
以嵌套可变对象为例:a = [1, [2, 3]]
- 赋值:不创建新对象,新名字指向同一对象,两边改都互相可见。
- 浅拷贝:只复制外层容器,内层元素仍引用原对象。改外层互不影响,改内层嵌套对象互相可见。
- 深拷贝:递归复制所有层级,完全独立。
import copy
a = [1, [2, 3]]
b = a # 赋值:同一个对象
b.append(4) # a 也变成 [1, [2, 3], 4]
c = a.copy() # 浅拷贝(copy.copy(a)、a[:] 同理)
c.append(5) # a 不变
c[1].append(9) # a 的内层也变成 [2, 3, 9],内层共享
d = copy.deepcopy(a) # 深拷贝
d[1].append(8) # a 完全不变a += b 和 a = a + b 的行为一定相同吗
不一定。a += b 优先调用 a.__iadd__(b)(原地修改,可能复用原对象);没有 __iadd__ 时才退化为 a = a + b。
a = [1, 2]
c = a # c 指向同一对象
b = [3]
a += b # 原地修改:c 也变成 [1, 2, 3]
a is c # True
a = a + b # 新建对象重新绑定 a:c 不受影响
a is c # False,c 仍是 [1, 2]常用数据结构
append() 、 extend() 和 insert() 有什么区别?
| 方法 | 参数 | 作用 | 复杂度 |
|---|---|---|---|
append(x) | 单个元素 | 把 x 作为一个整体加到末尾 | 均摊 O(1) |
extend(iter) | 可迭代对象 | 把其中每个元素逐个加到末尾 | O(k) |
insert(i, x) | 下标 + 元素 | 插入到下标 i 处,后续元素后移 | O(n) |
a = [1, 2]
a.append(3) # [1, 2, 3]
a.append([4, 5]) # [1, 2, 3, [4, 5]],列表会嵌套
a.extend([6, 7]) # [1, 2, 3, [4, 5], 6, 7],逐个展开
a.insert(0, 99) # [99, 1, 2, 3, [4, 5], 6, 7]易错点:append 传列表是"套一层",extend 传列表是"摊平加"。
为什么 [[0] * 3] * 3 创建的二维列表容易出问题?
外层 * 3 是把 **同一个内层列表的引用 **重复三次,三行其实是同一个对象(和浅拷贝共享内层是同一个问题):
a = [[0] * 3] * 3
a[0][0] = 1
a # [[1, 0, 0], [1, 0, 0], [1, 0, 0]],三行全被改
a = [[0] * 3 for _ in range(3)] # 正确写法:每行都是新列表
a[0][0] = 1
a # [[1, 0, 0], [0, 0, 0], [0, 0, 0]]注意 [0] * 3 本身没问题(int 不可变,复制引用安全),出问题的是外层对列表的 *
Python 字典怎么解决哈希冲突与扩容
CPython 3.6+ 用 开放寻址(open addressing),3.6 之前是拉链法。
开放寻址冲突处理:扰动探测(perturbation)
i = (5 * i + perturb + 1) & mask
perturb >>= 5- 探测位置由 hash 和探测次数共同决定,逐步打散,避免聚集
- 删除 key 只打 dummy 标记 不清空,保证其他 key 的 探测链不断
扩容(resize)
- 负载因子达到 2/3 时扩容到约 2 倍(取 2 的幂,
mask = size - 1,定位用hash & mask) - 扩容时所有条目重新 hash、重建索引表,单次插入偶发 O(n),均摊 O(1)
- 反向收缩:dummy 槽超过 1/8 时缩容到 3/4,回收碎片
补充:3.3 起字符串 hash 默认随机化(PYTHONHASHSEED),防 hash 洪水攻击
什么是可哈希对象,字典的 key 可以是列表或元组吗
可哈希(hashable):有 __hash__() 且 hash 值稳定,满足"相等则 hash 相等"(a == b → hash(a) == hash(b))。dict/set 靠 hash 定位桶,key 的 hash 一旦变化就找不到自己了,所以 key 必须不可变
字典先用 __hash__() 定位候选,再用身份或 __eq__() 确认匹配。因此两者必须遵守同一个约定:**a == b 时,必须有 hash(a) == hash(b)**;哈希相同则不一定相等。
| 类型 | 可哈希 |
|---|---|
| int、float、bool、str、bytes、None | 是 |
| tuple | 元素全部可哈希才是 |
| frozenset | 是 |
| list、dict、set、bytearray | 否 |
列表不行,元组看内容:
{[1, 2]: "x"} # TypeError: unhashable type: 'list'
{(1, 2): "x"} # ok
{([1], 2): "x"} # TypeError,元组里套了 list自定义类的规则:
class A: pass
hash(A()) # ok,默认按身份(id)哈希
class B:
def __eq__(self, o): return True
hash(B()) # TypeError:重写了 __eq__ 没重写 __hash__,Python 自动把 __hash__ 设为 None即:重写 __eq__ 必须同时重写 __hash__,且保证相等对象 hash 相同
函数、作用域、闭包、装饰器
Python 的函数为什么可以赋值给变量,也可以作为参数和返回值
函数是 Python 的 一等对象
(first-class object):def 执行时创建一个 function 对象,名字只是对它的绑定,和 int、list 没有本质区别。
def greet(name):
return f"hi {name}"
type(greet) # <class 'function'>
greet.__name__ # 'greet',函数对象有属性
f = greet # 赋值:f 和 greet 指向同一函数对象
f("tam") # 'hi tam'
funcs = [abs, len, str.upper] # 可以放进容器本质:名字绑定机制 + 函数是普通对象。这也是装饰器(本质是"接收函数、返回函数"的高阶函数)能成立的基础
位置参数、关键字参数、*args 和 **kwargs 有什么区别?
| 类型 | 写法 | 匹配方式 | 收集为 |
|---|---|---|---|
| 位置参数 | a | 按位置从左到右 | — |
| 关键字参数 | b=1 | 按名字,可带默认值 | — |
*args | *args | 收集多余的位置参数 | tuple |
**kwargs | **kwargs | 收集多余的关键字参数 | dict |
定义顺序固定:位置参数 → *args → 关键字参数 → **kwargs
def f(a, b=1, *args, c=2, **kwargs):
print(a, b, args, c, kwargs)
f(1, 2, 3, 4, c=5, x=6)
# a=1 b=2 args=(3, 4) c=5 kwargs={'x': 6}*args/**kwargs只是约定俗成的名字,本质是"可变参数收集器"- 调用时反向解包:
f(*[1, 2])、f(**{'x': 6}) - 3.8+ 可用
/强制位置参数、*强制关键字参数:def f(a, /, b, *, c)
常见用途:装饰器透传参数、包装/转发函数、写兼容任意签名的 API。
global 和 nonlocal 有什么区别?
函数内 **赋值 **变量默认创建局部变量;要 修改外层变量 就靠这两个声明。区别在于指向哪一层:
global | nonlocal | |
|---|---|---|
| 指向 | 模块级(全局)变量 | 最近一层 **外层函数 **的变量 |
| 前提 | 变量在模块级 | 变量必须已存在于外层函数作用域 |
| 版本 | Python 2/3 | Python 3.0+ |
count = 0
def f():
global count
count += 1 # 改的是模块级 count
def outer():
x = 10
def inner():
nonlocal x
x += 1 # 改的是 outer 的 x
inner()
return x # 11- 按 LEGB(Local 当前函数 → Enclosing 外层函数 → Global 模块全局 → Built-in 内置名字)查找:只读不需要声明,赋值才需要
nonlocal不能跳过中间层直接指全局,变量不存在于外层函数会SyntaxError- 嵌套函数里写
global仍直指模块级,会跳过中间函数层
什么是闭包?闭包是如何保留外部函数变量的?
闭包 = 内层函数 + 它引用的外层环境。外层函数返回后,内层函数仍能访问外层的局部变量。
三个要素:嵌套函数;内层引用外层变量(free variables);外层已返回、内层仍被使用。
def counter():
n = 0
def inc():
nonlocal n
n += 1
return n
return inc
c = counter()
c() # 1
c() # 2保留机制:cell 对象
- 外层函数返回时局部变量本应被回收,但被内层引用的变量会被编译器标记为 cell 变量,装进一个 cell 对象
- cell 的引用存在内层函数对象的
__closure__元组里,值被 cell 持有,引用计数不归零,所以不被回收 - 外层写
n、内层读写n,操作的都是同一个 cell,因此状态共享
c.__closure__ # (cell at 0x...: int = 2,)
c.__closure__[0].cell_contents # 2字节码上体现为 LOAD_DEREF / STORE_DEREF(经 cell 访问),区别于普通局部变量的 LOAD_FAST。
常见用途:计数器/状态保持、装饰器(wrapper 就是闭包,保留原函数)、工厂函数、避免全局变量。
经典坑——循环变量晚绑定:
fs = [lambda: i for i in range(3)]
fs[0]() # 2,不是 0!所有 lambda 共享同一个 i
fs = [lambda i=i: i for i in range(3)] # 用默认参数捕获当前值
fs[0]() # 0什么是装饰器?@decorator 的执行过程是怎样的?
装饰器是"接收函数、返回函数"的高阶函数,用于不改源码就给函数加功能。本质是 闭包:wrapper 通过闭包保留原函数。
@ 只是语法糖:
@decorator
def f(): ...
# 完全等价于
def f(): ...
f = decorator(f)执行过程:
def f执行,创建函数对象并绑定名字f- 紧接着执行
f = decorator(f)——装饰器在定义时(模块加载时)就运行,不是调用时 decorator(f)返回 wrapper,名字f被重新绑定到 wrapper- 之后调用
f()实际执行的是 wrapper,wrapper 内部再调原函数
标准模板:
from functools import wraps
def decorator(func):
@wraps(func) # 保留原函数的 __name__、__doc__ 等
def wrapper(*args, **kwargs): # *args/**kwargs 透传参数
# 前置逻辑
result = func(*args, **kwargs)
# 后置逻辑
return result
return wrapper带参数的装饰器(三层嵌套,@repeat(3) 等价于 f = repeat(3)(f)):
def repeat(n):
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
for _ in range(n):
result = func(*args, **kwargs)
return result
return wrapper
return decorator要点:
- 定义时跑装饰器,调用时跑 wrapper
- 多个装饰器自下而上应用、自上而下调用
- 类也可以当装饰器:实现
__call__,@C等价于f = C(f)(实例即装饰器)
ef hehe(x):
def inner(*args, **kwargs):
print("log")
return x(*args, **kwargs)
return inner
@hehe
def f(n):
return n * 2
# 等价于:
# f = hehe(f)
# print(f(3))
# f 先变成 inner
# 然后执行 inner(3)
# 最后打印
print(f(3)) # 输出 log 6迭代器和生成器
__iter__() 和 __next__() 分别有什么作用?
迭代器协议的两个方法:for 循环先调用 iter(obj)(即 __iter__)拿到迭代器,再反复调用 next(it)(即 __next__)取元素。
| 方法 | 定义在 | 作用 |
|---|---|---|
__iter__() | 可迭代对象 | 返回一个迭代器(通常返回 self) |
__next__() | 迭代器 | 返回下一个元素;没有更多元素时抛出 StopIteration |
class Counter:
def __init__(self, n):
self.n, self.i = n, 0
def __iter__(self):
return self # 可迭代对象返回迭代器
def __next__(self):
if self.i >= self.n:
raise StopIteration # 迭代结束的信号
self.i += 1
return self.i
for x in Counter(3):
print(x) # 1 2 3yield 和 return 有什么区别?生成器暂停后如何恢复执行?
return | yield | |
|---|---|---|
| 效果 | 结束函数,返回一个值 | 产出一个值并暂停函数,保存全部局部状态 |
| 函数调用时 | 正常执行函数体 | 函数体不执行,直接返回生成器对象 |
| 之后 | 函数结束,局部变量销毁 | 下次 next() 从暂停处继续执行 |
def gen():
print("start")
yield 1 # 暂停:产出 1
print("resumed")
yield 2 # 暂停:产出 2
return "done" # 生成器结束
g = gen() # 此时函数体一行都没执行
next(g) # 1,打印 "start"
next(g) # 2,打印 "resumed"
next(g) # StopIteration(其 .value 是 "done")恢复执行的原理:生成器对象保存着函数的 frame(局部变量、指令指针),每次 next() 都从上次暂停的位置继续运行,直到下一个 yield 再次挂起。
易错点:生成器里 return 不会把值"返回"给外部,它的值只存在 StopIteration.value 里;for 循环会直接忽略它。
多线程、多进程、 GIL、asyncio
进程、线程和协程有什么区别
| 进程 | 线程 | asyncio 协程 | |
|---|---|---|---|
| 调度者 | OS 内核 | OS 内核 | 用户态(asyncio 事件循环) |
| 切换开销 | 大(切换地址空间、刷 TLB) | 中(系统调用陷入内核) | 极小(纯用户态函数调用,无系统调用) |
| 通信方式 | IPC:管道、队列、共享内存 | 共享内存,需加锁保护 | 同一线程直接读写变量,无需锁 |
| 隔离性 | 强,一个进程崩溃不影响其他 | 弱,一个线程崩溃整个进程退出 | 弱,一个协程异常可能影响整个事件循环 |
| 受 GIL 影响 | 否,每个进程有独立的 GIL | 是,同一时刻只有一个线程执行字节码 | 不涉及,单线程运行天然无 GIL 竞争 |
| 适用场景 | CPU 密集、需要隔离 | IO 密集(受 GIL 限制) | 高并发 IO 密集(网络请求、数据库) |
什么是 GIL
GIL(Global Interpreter Lock,全局解释器锁):CPython 解释器里的一把全局锁,同一时刻 只允许一个线程执行 Python 字节码,所以 CPython 的多线程无法真正并行跑 CPU 密集代码。
为什么需要它
- CPython 的内存管理靠 引用计数(refcount),引用计数的增减、对象释放都不是线程安全的
- 给整个解释器加一把全局锁,比给每个对象都加细粒度锁简单得多,是实现上的取舍
关键性质
- GIL 是进程级的:每个进程有独立的 GIL,所以
multiprocessing多进程不受影响 - 线程执行 IO / 阻塞 C 调用(
time.sleep、文件读写、网络请求)时会释放 GIL,所以 IO 密集任务用线程仍然能并发 - 从 3.2 起默认每 5ms(
sys.setswitchinterval)强制切换一次 GIL,保证线程有机会交替执行 - 规避 CPU 密集:
multiprocessing多进程,或用会释放 GIL 的 C 扩展(如 NumPy) - GIL 是 CPython 的实现细节,不是语言规范:PyPy、Jython 等没有 GIL;Python 3.13+ 提供实验性的 free-threaded 构建(PEP 703)
易错点:GIL 只保证"同一时刻只有一个线程执行字节码",不等于多线程代码就是安全的——共享可变状态仍要加锁,比如 x += 1 不是原子操作,可能丢失更新。
Python 的协程是什么?asyncio 是什么?
协程:一种可以"暂停并在之后从暂停处恢复"的用户态任务。它不是被 OS 调度,而是 **协作式 **的——只在遇到 await 时主动让出控制权。
asyncio:标准库提供的事件循环(event loop)框架。单线程内调度所有协程:某个协程 await 一个未完成的 IO 时让出,事件循环就去跑别的协程,IO 完成后再恢复它。
import asyncio
async def fetch(name, delay):
print(f"{name} start")
await asyncio.sleep(delay) # 让出控制权,不阻塞线程
print(f"{name} done")
async def main():
await asyncio.gather(fetch("A", 1), fetch("B", 2)) # 并发
asyncio.run(main())
# A start, B start, A done, B done 总耗时约 2s,而不是 1+2执行模型:
async def定义协程函数,调用它只创建协程对象,函数体不执行,直到被await或交给事件循环await只能在协程内使用:挂起当前协程,等 **右边的结果就绪后 **恢复- 事件循环是调度者:单线程,所有协程在同一个线程里交错运行
协程里执行 阻塞调用(同步 IO、time.sleep、CPU 密集计算)会卡住整个事件循环,必须换成 await 的异步版本,或用 loop.run_in_executor() 丢到线程/进程池。另外 gather 是并发,逐个 await 是串行,别混用。