1. 第67题:Python 变量作用域与 LEGB 规则
1.1 为什么面试喜欢问作用域
Python 的变量作用域几乎是每一轮技术面试里都会出现的考点。相比 Java 和 C++ 那种通过花括号明确划分作用域的方式,Python 用缩进表达代码块,同时又有一套基于“函数”的变量查找规则,这导致很多新手在写嵌套函数、闭包和装饰器时会踩坑。
为了搞清楚 Python 在访问一个变量时到底去哪里找,我们需要掌握 LEGB 规则。这四个字母分别代表:
- L — Local,局部作用域
- E — Enclosing,外层嵌套函数的局部作用域
- G — Global,模块全局作用域
- B — Built-in,Python 内置作用域
当代码执行到某个变量名时,Python 解释器会按 L → E → G → B 的顺序依次查找。如果在局部作用域找到了变量,就直接使用,不会继续向外层查找。
1.2 从一段代码理解查找顺序
先来看一个最简单的例子:
x = "global" def outer(): x = "enclosing" def inner(): x = "local" print(x) inner() outer()输出结果是:
local这里inner()函数内部定义了x,所以 Python 在局部作用域(L)就找到了x,不会再往上层查找。
如果把inner()里的x = "local"删掉呢?
x = "global" def outer(): x = "enclosing" def inner(): print(x) inner() outer()输出结果会变成:
enclosing因为inner()内部没有定义x,Python 会向外层outer()的作用域查找,找到了x = "enclosing",于是直接使用。这里就体现了 E(Enclosing)的作用。
继续修改,如果把outer()里的x也删掉:
x = "global" def outer(): def inner(): print(x) inner() outer()输出结果:
global此时inner()和outer()内部都没有x,Python 继续向上查找,在模块全局作用域找到了x = "global"。
这就是 LEGB 规则的完整链路。
1.3 global 与 nonlocal 关键字
LEGB 规则只解决“读变量”的问题。但如果想在函数内部“修改”一个全局变量或外层变量,直接赋值会出问题。
来看下面的代码:
count = 0 def add(): count = count + 1 add()运行会直接抛出异常:
UnboundLocalError: local variable 'count' referenced before assignment原因在于,Python 在编译函数时,如果发现函数体内部对某个变量有赋值操作,就会认为这个变量是局部变量。上面的count = count + 1中,右侧读取的count被 Python 视为局部变量,但局部count还没定义,所以报错。
正确写法是用global声明:
count = 0 def add(): global count count = count + 1 add() print(count)输出:
1类似地,如果在嵌套函数中想要修改外层函数的局部变量,需要用到nonlocal:
def outer(): x = 10 def inner(): nonlocal x x = 20 inner() print(x) outer()输出:
20nonlocal表示“我要修改的是最近一层非全局作用域中的变量”。如果是全局变量,nonlocal不适用,必须用global。
1.4 面试答题参考建议
如果面试遇到“Python 的作用域规则是什么”,建议分三层作答:
- 先说明 LEGB 四个字母的含义。
- 再补充局部变量和全局变量的区别。
- 最后举一个
global或nonlocal的案例,说明如何修改变量绑定。
这样答案结构完整,也容易引到后续的闭包、装饰器话题。
2. 第68题:Python 类型转换与 isinstance 判断
2.1 类型转换在开发中的位置
Python 作为动态类型语言,变量本身没有固定的类型限制。同一个变量可以先赋整数,再赋字符串。但正因为这种灵活性,实际项目中经常出现类型不匹配的问题。
比如从接口请求中拿到的数据是字符串"12",要参与数学运算就必须转成int;从文本文件中读到的数字是"3.14",要计算就必须转成float。类型转换就是解决这类问题的标准手段。
Python 的类型转换分为两种:
- 隐式类型转换:由解释器自动完成。
- 显式类型转换:开发者主动调用
int()、str()、float()等函数完成。
2.2 显式转换常用函数
Python 提供了一系列内置转换函数,最常用的有:
| 函数 | 目标类型 | 典型使用场景 |
|---|---|---|
int(x) | 整数 | 字符串数字转整数、浮点数取整 |
float(x) | 浮点数 | 字符串数字转浮点 |
str(x) | 字符串 | 拼接输出、日志记录 |
bool(x) | 布尔值 | 条件判断、数据清洗 |
list(x) | 列表 | 元组/集合转列表 |
tuple(x) | 元组 | 列表/集合转元组 |
set(x) | 集合 | 列表去重 |
dict(x) | 字典 | 由键值对序列重建字典 |
看一个综合例子:
s1 = "42" s2 = "3.14" num1 = int(s1) num2 = float(s2) print(num1, type(num1)) print(num2, type(num2)) print(str(num1) + "是字符串") print(bool(num1), bool(""), bool(None))输出:
42 <class 'int'> 3.14 <class 'float'> 42是字符串 True False False值得注意的点是int()可以将浮点数直接截断为整数,例如int(3.99)结果是3,它不是四舍五入,而是向零取整。
2.3 隐式转换的注意事项
隐式转换发生在不同的数值类型参与运算时。例如:
a = 10 # int b = 2.5 # float c = a + b # 结果是 float print(c) # 12.5Python 会把整数自动提升为浮点数再运算,这种提升是安全的。
但字符串和数字之间不会发生隐式转换:
x = "10" + 5运行结果:
TypeError: can only concatenate str (not "int") to str这也是新手最容易遇到的报错之一。解决办法是显式转换:
x = int("10") + 5 print(x) # 15或者把数字转成字符串再做拼接:
y = "10" + str(5) print(y) # 105,注意这是字符串拼接2.4 用 isinstance 判断类型
在某些场景下,我们不仅需要转换类型,还需要先判断当前值的类型。此时推荐使用isinstance()而不是type()。
isinstance()的基本用法:
value = 10 print(isinstance(value, int)) # True print(isinstance(value, (int, float))) # True,元组内任一匹配即可 print(isinstance("text", str)) # Trueisinstance()和type()的一个关键区别在于对继承关系的判断。
class Animal: pass class Dog(Animal): pass dog = Dog() print(type(dog) == Animal) # False print(isinstance(dog, Animal)) # True因为Dog是Animal的子类,所以dog对象也被视为Animal类型。isinstance()会考虑继承关系,更符合面向对象的设计语义。
2.5 类型转换实战:安全地把字符串转数字
在解析接口参数或处理用户输入时,不能假设字符串一定可以直接转换。一个健壮的写法是:
def safe_int(value, default=0): try: return int(value) except (ValueError, TypeError): return default print(safe_int("123")) # 123 print(safe_int("abc")) # 0 print(safe_int(None, -1)) # -1 print(safe_int("3.14")) # 0,int() 无法直接转换带小数点的字符串如果需要处理浮点字符串,可以先用float()再转int():
def safe_int_with_float(value, default=0): try: return int(float(value)) except (ValueError, TypeError): return default print(safe_int_with_float("3.99")) # 3 print(safe_int_with_float("abc")) # 0类型转换看似简单,实际开发中却是空指针、格式错误、解析失败的高发地带。把所有外部输入都当成“不可信数据”处理,是工程化的基本素养。
3. 第69题:Python 装饰器是什么?如何实现和运用?
3.1 装饰器的定义与作用
装饰器是 Python 中一种基于“函数作为一等公民”特性的语法,允许在不修改原函数代码的情况下,给函数动态添加功能。
注意“一等公民”这个说法。在 Python 中,函数不仅可以被调用,还可以被赋值给变量、作为参数传入另一个函数、作为返回值返回。正是这个特性,让装饰器成为可能。
装饰器的典型应用场景包括:
- 日志记录
- 性能计时
- 权限校验
- 参数校验
- 缓存结果
- 重试机制
如果不使用装饰器,要在每个函数内部嵌入日志代码,就会导致大量重复。而装饰器可以把通用逻辑抽离出来,让业务函数保持简洁。
3.2 手工实现一个“函数加工函数”
在真正写装饰器语法之前,先理解它的核心原理。
定义一个函数log,它接收一个函数作为参数,并返回一个新的函数:
def log(func): def wrapper(): print(f"调用函数 {func.__name__}") return func() return wrapper def greet(): print("Hello, CSDN") greet = log(greet) greet()输出:
调用函数 greet Hello, CSDN这段代码的工作过程是:
log(greet)返回一个新函数wrapper。- 变量
greet重新指向wrapper。 - 调用
greet()时,实际执行wrapper()。 wrapper()先打印日志,再调用原来的greet()。
3.3 使用 @ 语法糖
上面的写法虽然可行,但每次都要手动执行greet = log(greet),不够优雅。Python 提供了@语法糖:
def log(func): def wrapper(): print(f"调用函数 {func.__name__}") return func() return wrapper @log def greet(): print("Hello, CSDN") greet()这里的@log等价于greet = log(greet)。
3.4 装饰带参数的函数
上面的wrapper没有接收参数,如果原函数有参数,直接调用会报错。解决方案是让wrapper支持任意位置参数和关键字参数:
def log(func): def wrapper(*args, **kwargs): print(f"调用函数 {func.__name__}") return func(*args, **kwargs) return wrapper @log def add(a, b): return a + b result = add(3, 5) print(result)输出:
调用函数 add 8*args把位置参数收集为元组,**kwargs把关键字参数收集为字典。调用原函数时再解包传回去,这样无论原函数签名如何,装饰器都能正常工作。
3.5 带参数的装饰器
有时候装饰器本身也需要参数。例如希望日志里能指定一个前缀标记:
def log_with_prefix(prefix): def decorator(func): def wrapper(*args, **kwargs): print(f"[{prefix}] 调用函数 {func.__name__}") return func(*args, **kwargs) return wrapper return decorator @log_with_prefix("INFO") def process(): print("处理业务") process()输出:
[INFO] 调用函数 process 处理业务这种“三层嵌套”的结构理解起来稍微复杂一点。关键在于:
- 最外层
log_with_prefix(prefix)接收装饰器的参数,返回一个真正的装饰器。 - 中间的
decorator接收原函数,返回 wrapper。 - 最内层
wrapper实现增强逻辑并调用原函数。
3.6 functools.wraps 的作用
直接使用装饰器会有一个隐藏问题:被装饰函数的元信息会被 wrapper 覆盖。
def log(func): def wrapper(*args, **kwargs): return func(*args, **kwargs) return wrapper @log def add(a, b): return a + b print(add.__name__) # 输出是 wrapper原本函数名add变成了wrapper,这会影响到调试、文档自动生成等场景。
解决办法是使用functools.wraps:
from functools import wraps def log(func): @wraps(func) def wrapper(*args, **kwargs): return func(*args, **kwargs) return wrapper @log def add(a, b): return a + b print(add.__name__) # 输出 add@wraps(func)会将原始函数的__name__、__doc__、__module__等属性复制到 wrapper 上。工程实践中,写装饰器时加上@wraps是好习惯。
3.7 类装饰器
除了函数装饰器,Python 还支持类装饰器。类装饰器通常通过实现__call__方法,让实例对象可以像函数一样被调用。
from functools import wraps class LogDecorator: def __init__(self, func): self.func = func def __call__(self, *args, **kwargs): print(f"调用函数 {self.func.__name__}") return self.func(*args, **kwargs) @LogDecorator def greet(): print("Hello, Class Decorator") greet()类装饰器的优点是可以在__init__中保存更多状态,适合需要维护计数器或配置的复杂场景。缺点是代码量相对更大,可读性不如函数装饰器直观。
4. 第70题:Python 多进程编程基础解析
4.1 多进程与多线程的选择
Python 多线程有一个广为人知的限制,GIL 的存在导致同一时刻只能有一个线程在 CPU 上执行字节码。所以对于 CPU 密集型任务,多线程并不能提升计算效率。
多进程则不同。每个进程拥有独立的解释器和内存空间,可以真正并行利用多核 CPU。对于以下场景,多进程是更合适的选择:
- CPU 密集型计算
- 大量数据并行处理
- 需要隔离执行环境的任务
- 利用多核服务器资源
I/O 密集型任务(网络请求、文件读写)优先考虑多线程或异步 IO,因为等待时间远大于计算时间,GIL 在 IO 等待期间会被释放。
4.2 使用 multiprocessing 创建进程
Python 标准库multiprocessing提供了与threading相似的接口。
import multiprocessing import os def worker(name): print(f"子进程 {name},PID={os.getpid()}") if __name__ == "__main__": processes = [] for i in range(3): p = multiprocessing.Process(target=worker, args=(f"worker-{i}",)) processes.append(p) p.start() for p in processes: p.join() print(f"主进程结束,PID={os.getpid()}")注意一点,使用multiprocessing时必须把创建进程的逻辑放在if __name__ == "__main__":下面,否则在 Windows 平台下会让子进程递归导入主模块,导致无限创建进程。
4.3 进程池 Pool
频繁创建和销毁进程的开销很大。实际项目中更推荐使用进程池,一次性创建固定数量的进程,重复执行任务。
import multiprocessing def square(n): return n * n if __name__ == "__main__": with multiprocessing.Pool(processes=4) as pool: results = pool.map(square, range(1, 10)) print(results)输出:
[1, 4, 9, 16, 25, 36, 49, 64, 81]pool.map()的用法与 Python 内置map()类似,但它会把任务分配给进程池中的多个进程并行执行,最后按顺序收集结果。
Pool还提供了apply_async方法,适合任务参数不同、结果不需要严格排序的场景:
import multiprocessing def add(a, b): return a + b if __name__ == "__main__": with multiprocessing.Pool(processes=2) as pool: results = [pool.apply_async(add, args=(i, i * 10)) for i in range(5)] for r in results: print(r.get())4.4 进程间通信:Queue
多进程之间默认不共享内存,每个进程都有自己的地址空间。如果要在进程间传递数据,需要使用multiprocessing.Queue。
import multiprocessing def producer(q): for i in range(5): q.put(f"消息 {i}") def consumer(q): while True: item = q.get() if item is None: break print(f"收到: {item}") if __name__ == "__main__": q = multiprocessing.Queue() p1 = multiprocessing.Process(target=producer, args=(q,)) p2 = multiprocessing.Process(target=consumer, args=(q,)) p1.start() p2.start() p1.join() q.put(None) # 发送结束标志 p2.join()这里的None是自定义的结束信号,告诉消费者可以退出循环。生产项目中更推荐使用multiprocessing.Pool搭配Queue完成生产消费模型。
4.5 共享内存:Value 与 Array
如果只是需要在多个进程间共享一个简单数值或数组,可以使用multiprocessing.Value和multiprocessing.Array。
import multiprocessing def increment(shared_value): for _ in range(1000): with shared_value.get_lock(): shared_value.value += 1 if __name__ == "__main__": counter = multiprocessing.Value("i", 0) processes = [multiprocessing.Process(target=increment, args=(counter,)) for _ in range(4)] for p in processes: p.start() for p in processes: p.join() print(counter.value)multiprocessing.Value("i", 0)中的"i"表示 C 语言风格的 signed int 类型。get_lock()方法返回一个锁,保证多进程在修改共享变量时不会被其他进程插队,避免数据竞争。因为多个进程对同一个数值执行加法操作,如果不加锁,最后的结果可能小于预期。
4.6 多进程与多线程对比速查
| 特性 | 多进程 | 多线程 |
|---|---|---|
| 并行能力 | 支持多核并行 | 受 GIL 限制 |
| 适用场景 | CPU 密集型 | I/O 密集型 |
| 资源共享 | 需 Queue/Pipe/共享内存 | 天然共享全局变量 |
| 数据隔离 | 强隔离,安全性高 | 共享数据易竞争 |
| 创建开销 | 较大 | 较小 |
| 调试难度 | 较难 | 相对容易 |
5. 综合实战:用四个知识点写一个批量文件处理小工具
把前面四个知识点串起来,做一个真实感强一点的小工具。需求如下:
- 扫描指定目录下所有文本文件。
- 统计每个文件中包含指定关键字的行数。
- 使用多进程加速统计过程。
- 对文件中读取到的数字型字符串做安全类型转换。
- 用装饰器统一记录统计耗时。
5.1 项目结构
keyword_counter/ ├── main.py └── data/ ├── a.txt └── b.txt先在data目录下创建两个样例文件。
a.txt:
python 入门 python 八股文 装饰器很实用 多进程并行b.txt:
类型转换要谨慎 python 基础题库 装饰器示例5.2 使用装饰器记录耗时
import time from functools import wraps def timing(func): @wraps(func) def wrapper(*args, **kwargs): start = time.perf_counter() result = func(*args, **kwargs) cost = time.perf_counter() - start wrapper.cost += cost return result return wrapper def new_timing(func): @wraps(func) def wrapper(*args, **kwargs): start = time.perf_counter() result = func(*args, **kwargs) cost = time.perf_counter() - start print(f"{func.__name__} 耗时 {cost:.4f} 秒") return result return wrapper5.3 多进程统计函数
import multiprocessing from pathlib import Path @new_timing def count_keyword_in_file(file_path, keyword): """统计单个文件中包含关键字的行数""" count = 0 with open(file_path, "r", encoding="utf-8") as f: for line in f: if keyword in line: count += 1 return str(file_path), count def process_file(args): file_path, keyword = args return count_keyword_in_file(file_path, keyword)这里把process_file设计成接收元组参数,方便直接传给进程池。
5.4 主流程:类型转换与安全校验
def safe_count(value): """安全把统计结果转为 int,避免空值或异常字符串""" try: return int(value) except (ValueError, TypeError): return 0 def main(): keyword = "python" data_dir = Path("data") files = list(data_dir.glob("*.txt")) if not files: print("未找到任何 txt 文件") return tasks = [(str(f), keyword) for f in files] with multiprocessing.Pool(processes=2) as pool: results = pool.map(process_file, tasks) total = 0 for file_name, count in results: count_num = safe_count(count) total += count_num print(f"{file_name}: {count_num} 行") print(f"合计: {total} 行包含关键字 {keyword!r}") if __name__ == "__main__": main()5.5 运行结果
运行python main.py,预期输出类似:
data/b.txt: 1 行 data/a.txt: 2 行 process_file 耗时 0.0512 秒 合计: 3 行包含关键字 'python'这个工具规模不大,但把装饰器、类型转换、多进程和文件读取四个知识点完整结合到了一起。初学者可以在此基础上扩展,比如加入Queue实现生产者消费者模式,或者使用Value做全局计数。
6. 常见问题与排查思路
下面整理几个日常开发中容易踩坑的问题,覆盖本文全部四个主题。
| 问题现象 | 常见原因 | 解决思路 |
|---|---|---|
UnboundLocalError | 函数内部对外层变量直接赋值 | 使用global或nonlocal声明,或改用可变对象 |
TypeError: can only concatenate str | 字符串与数字直接拼接 | 调用str()转换数字,或使用f-string |
ValueError: invalid literal for int() | 字符串中不是纯数字 | 先用try-except捕获异常,或先用isinstance校验 |
被装饰函数__name__变成 wrapper | 装饰器没有使用functools.wraps | 在 wrapper 上添加@wraps(func) |
| 多进程代码在 Windows 下报错 | 创建进程的代码未放入if __name__ == "__main__" | 把启动逻辑放到if __name__ == "__main__":中 |
Pool传入不可序列化对象 | multiprocessing依赖 pickle,Lambda 无法序列化 | 使用模块级函数替代 Lambda |
| 多个进程同时修改共享变量结果不正确 | 数据竞争 | 使用Value.get_lock()或Manager提供锁机制 |
TypeError: cannot pickle 'function' object | 尝试把函数自身传入进程池任务 | 传入函数名和参数,不要在任务中携带函数对象 |
排查思路的统一方法论是:先看异常类型,再看触发代码行,最后根据 LEGB 规则、类型信息或并发模型逐步排除。不要只看报错消息的最后一行,前面的调用栈信息往往更有价值。
7. 最佳实践与学习建议
7.1 作用域相关建议
- 尽量避免在函数内部大量使用
global,会让代码的耦合度变高。 - 如果一个函数需要修改外部状态,优先考虑传入参数、返回新值的方式。
- 嵌套函数中修改外层变量时,先想清楚是否真的需要,很多场景可以用返回值替代
nonlocal。 - 变量命名要避免与内置函数重名。比如不要用
list、dict作为变量名,否则会覆盖内置作用域,后续调用内置函数时可能触发 TypeError。
7.2 类型转换相关建议
- 外部输入(用户输入、接口参数、文件内容)一律按不可信数据处理,转换前做校验。
- 优先使用
try-except处理转换异常,而不是在转换前做复杂的字符串正则判断。 - 判断类型时用
isinstance()而不是type(x) == int,因为要兼容继承关系。 - 在批量处理文件时,确认编码格式,避免
UnicodeDecodeError。如果中途遇到编码问题,可以尝试errors="ignore"或逐行识别。
7.3 装饰器相关建议
- 任何需要给多个函数统一增强的行为,比如日志、鉴权、限流,都优先考虑装饰器。
- 装饰器内部逻辑尽量简洁,不要把复杂的业务逻辑塞进去。
- 使用
functools.wraps保留原函数元信息。 - 如果需要嵌套多个装饰器,注意执行顺序。装饰器从下往上应用,从外往里执行。
7.4 多进程相关建议
- 明确任务类型,CPU 密集型用多进程,I/O 密集型用多线程或异步。
- 只传递可序列化的简单对象给子进程。
- 避免频繁创建和销毁进程,使用
Pool管理生命周期。 - 生产环境不要盲目扩大进程数,进程数通常设为 CPU 核心数或核心数加 1 比较合理。
- 大规模并行任务建议使用生产级调度框架(如 Celery、Ray),而不是直接操作
multiprocessing。
7.5 学习路线参考
如果这份八股文的内容已经掌握,下一步可以按顺序学习:
- Python 闭包与装饰器进阶,理解
@property、类装饰器、装饰器工厂。 concurrent.futures模块,掌握ThreadPoolExecutor和ProcessPoolExecutor的统一用法。asyncio异步编程基础,理解协程、事件循环、await 关键字。- 内存管理与垃圾回收机制,理解引用计数、标记清除、分代回收。
- 标准库中
itertools、functools、collections的常用功能。
八股文的作用是帮助快速梳理知识框架,但真正让能力提升的一定是拿真实项目反复练习。建议每周找一个小脚本,刻意使用一次装饰器或多进程,坚持两个月,这些知识点就会变成肌肉记忆。
如果本文对你有帮助,欢迎收藏备用。下一期继续整理 Python 面试中高频出现的函数式编程、迭代器与生成器、上下文管理器等经典问题,到时见。