python实习面试基础题
面试日期:2026-07-19
考察方向:Python 基础(数据结构、语言特性)
难度等级:⭐ 基础必问
一、列表(list)和元组(tuple)的区别
核心答案
| 对比维度 | 列表(list) | 元组(tuple) |
|---|---|---|
| 可变性 | ✅ 可变(mutable)——可增删改元素 | ❌ 不可变(immutable)——创建后不可修改 |
| 语法 | [1, 2, 3]方括号 | (1, 2, 3)圆括号 |
| 性能 | 相对较慢(动态扩容,内存分配更复杂) | 相对较快(内存连续紧凑,Python 可缓存复用) |
| 哈希性 | ❌ 不可哈希,不能做字典 key | ✅ 可哈希(元素也需可哈希),可做字典 key |
| 内存占用 | 更大(预留额外空间供 append) | 更小(固定大小) |
| 使用场景 | 同类型元素的集合,需要增删改 | 异构数据(如坐标、数据库一条记录),不该变的 |
深入追问点
Q: 元组不可变,为什么还能t = (1, 2); t += (3,)这种操作?
那不是修改原元组,而是创建一个新元组重新赋值给变量。原元组并没有变,可以用
id()验证前后变化。
Q: 元组里有可变对象(如列表)会怎样?
元组的"不可变"是指引用的不可变——你不能让元组里的某个元素指向另一个对象。但如果元素本身是可变对象(如列表),你仍然可以修改那个列表的内容。
t = ([1, 2], 3) t[0].append(99) # ✅ 可以,t 变成 ([1, 2, 99], 3) t[0] = [] # ❌ TypeError,不能重新赋值
Q: 创建单元素元组要注意什么?
a = (1) # 这是 int,不是元组! b = (1,) # 加逗号才是元组逗号是关键,不是括号。
源码级理解(加分项)
列表底层是PyListObject(C 数组 + 动态扩容策略 over-allocate ~1/8),元组底层是PyTupleObject(固定数组,Python 解释器对小元组有缓存池)。这也是元组创建比列表快的原因之一。
💡 面试回答技巧
- 先旗帜鲜明地摆出核心区别:可变 vs 不可变
- 再从可变性推导出衍生区别(可哈希性、性能、使用场景)
- 如果面试官追问底层,可以提到
PyListObject的 over-allocate 机制
二、装饰器(Decorator)
核心答案
装饰器是一种高阶函数,它接收一个函数作为参数,返回一个新的函数(或可调用对象),在不修改原函数代码的前提下为其附加功能。
最简示例
import functools import time def timer(func): """装饰器:打印函数执行时间""" @functools.wraps(func) # 保留原函数的元信息(name、doc 等) def wrapper(*args, **kwargs): start = time.perf_counter() result = func(*args, **kwargs) elapsed = time.perf_counter() - start print(f"{func.__name__} 耗时 {elapsed:.4f}s") return result return wrapper @timer def slow_add(a, b): """计算两数之和(模拟耗时)""" time.sleep(0.1) return a + b # 调用 slow_add(1, 2) # 输出: slow_add 耗时 0.1002s@timer等价于slow_add = timer(slow_add),这就是语法糖的本质。
带参数的装饰器
def repeat(n: int): """装饰器工厂:让函数重复执行 n 次""" def decorator(func): @functools.wraps(func) def wrapper(*args, **kwargs): for _ in range(n - 1): func(*args, **kwargs) return func(*args, **kwargs) # 最后一次返回结果 return wrapper return decorator @repeat(n=3) def greet(name): print(f"Hello, {name}!")@repeat(n=3)等价于greet = repeat(n=3)(greet)——外层先调用工厂拿到真正的装饰器,再把函数传进去。
类装饰器
class CountCalls: def __init__(self, func): self.func = func self.count = 0 def __call__(self, *args, **kwargs): self.count += 1 return self.func(*args, **kwargs) @CountCalls def say_hi(): print("Hi!") say_hi() # count = 1 say_hi() # count = 2
如果类实现了__call__,它就是可调用对象,可以当作装饰器用。适合需要维护状态的场景。
多个装饰器的执行顺序
@decorator_a @decorator_b @decorator_c def f(): pass # 等价于 f = decorator_a(decorator_b(decorator_c(f))) # 执行顺序:离函数最近的最先装饰,最远的最先执行
常用内置装饰器
| 装饰器 | 用途 |
|---|---|
@staticmethod | 静态方法,不传 self/cls |
@classmethod | 类方法,自动传 cls |
@property | 把方法变成属性访问 |
@functools.wraps | 保留被装饰函数的元信息,写装饰器时务必加上 |
@functools.lru_cache | 缓存函数结果(LRU 策略) |
@functools.singledispatch | 单分派泛函数(根据第一个参数类型重载) |
真实项目中的应用
- Flask/FastAPI 路由注册:
@app.get("/api/users") - Django 权限校验:
@login_required、@permission_required - Django 缓存:
@cache_page(60 * 15) - 日志/监控:自动记录函数入参、出参、耗时
- 事务管理:
@transaction.atomic - 重试机制:函数执行失败时自动重试
💡 面试回答技巧
- 三步法:先说是"高阶函数"→ 再说语法糖
@→ 再给一个最简例子(用functools.wraps展示专业度) - 面试官追问"带参数装饰器"时,自然地引出三层嵌套结构(外层工厂 → 中层装饰器 → 内层 wrapper)
- 加分点:提到
functools.wraps的重要性(不写的话原函数的__name__、__doc__都会被 wrapper 覆盖)
三、生成器(Generator)
核心答案
生成器是一个可迭代对象,但它不一次性把所有值都算出来,而是按需逐个产出(yield),因此特别节省内存。本质上是惰性求值(lazy evaluation)。
个人总结:生成器,简单来说就是一种不会一次性算出所有结果,而是用到时才计算一个值返回的特殊迭代器。它最大的好处就是省内存,特别适合处理大数据流或无限序列以及进行惰性计算。
🎯 用一个生活例子彻底搞懂"惰性求值"
普通列表(list)像去自助餐厅——厨师一次性把所有菜都做好摆出来。如果餐厅有 100 道菜,你哪怕只吃 3 道,厨房也得先做完 100 道、占用 100 个餐盘的位置。
# 列表:一次性算完所有值,全部存在内存里 nums = [x for x in range(1_000_000)] # ✅ 马上生成 100 万个数字,占用约 28MB 内存 print(nums[0]) # 1 —— 但其实你已经把 100 万个都算完了
生成器(generator)像去日式 omakase 餐厅——厨师等你吃完一道,才做下一道。不管菜单上有多少道菜,任何时候厨房里只有一道菜在准备。
# 生成器:用到的时候才算,算完就丢 nums = (x for x in range(1_000_000)) # ✅ 瞬间返回,几乎不占内存 print(next(nums)) # 1 —— 到现在为止只算了 1 个数字 print(next(nums)) # 2 —— 继续算下一个
验证内存差异:
import sys # 列表——所有值已在内存 list_nums = [x for x in range(100_000)] print(sys.getsizeof(list_nums)) # ~824KB # 生成器——只有代码和状态,没有值 gen_nums = (x for x in range(100_000)) print(sys.getsizeof(gen_nums)) # ~112 字节(固定!不管 range 多大)
列表每多一个元素就多占一份内存;生成器无论迭代 100 个还是 10 亿个,本身的大小几乎不变,因为它不存值,只存"怎么算下一个值"的规则。
🪜 打个比方:两本书的区别
| 普通列表 | 生成器 |
|---|---|
| 像一本已经印好的书 | 像一本读一页印一页的书 |
| 你可以翻到任意页码 | 你只能一直往前读,不能回翻 |
| 书已经完整存在,占书架位置 | 只保留了"怎么印下一页"的配方 |
| 可以反复读开头 | 读完了就没了,不能回头 |
所以生成器的两个核心特征就很好记了:
- 省内存— 不存结果,只存配方
- 一次性的— 不能后退,不能随机访问,用完即弃
三种创建方式
# 1. 生成器函数(用 yield) def count_up_to(n): i = 1 while i <= n: yield i i += 1 gen = count_up_to(5) for num in gen: print(num) # 1 2 3 4 5 # 2. 生成器表达式 squares = (x * x for x in range(10)) # 注意是 () 不是 [] print(list(squares)) # [0, 1, 4, 9, 16, 25, 36, 49, 64, 81] # 3. 第三方/内置函数返回生成器 lines = (line.strip() for line in open("file.txt"))
yield 的执行机制(关键)
def demo(): print("→ 开始") yield 1 print("→ 继续") yield 2 print("→ 结束") g = demo() result = next(g) # 输出 "→ 开始",result = 1 result = next(g) # 输出 "→ 继续",result = 2 next(g) # 输出 "→ 结束",抛出 StopIteration
每次调用next(),函数从上次yield的地方继续执行,直到下一个yield。这就是"协程"的雏形。
yield 和 return 的对比
return | yield | |
|---|---|---|
| 作用 | 返回值并终止函数 | 产出值并暂停函数 |
| 执行状态 | 丢弃局部变量 | 保留局部变量和指令指针 |
| 再次调用 | 从头执行 | 从暂停处恢复 |
| 是否可多次产出 | ❌ 一次 | ✅ 多次 |
生成器的常见应用
- 处理大文件(这是最经典的面试场景)
def read_large_file(file_path): """逐行读取,任何时候内存只有一行""" with open(file_path, 'r', encoding='utf-8') as f: for line in f: yield line.strip() # 即使文件 20GB,内存也只占用一行的大小 for line in read_large_file("huge_log.txt"): process(line)
- 无限序列
def fibonacci(): a, b = 0, 1 while True: yield a a, b = b, a + b fib = fibonacci() for _ in range(10): print(next(fib), end=" ") # 0 1 1 2 3 5 8 13 21 34
- 管道式数据处理(多生成器串联)
# 从日志文件读 → 过滤 ERROR → 提取 IP → 统计 lines = (line for line in open("app.log")) errors = (line for line in lines if "ERROR" in line) ips = (line.split()[0] for line in errors) seen = set() unique_ips = (ip for ip in ips if ip not in seen and not seen.add(ip)) for ip in unique_ips: print(ip) # 整个处理过程是流式的,内存恒定
与迭代器的关系
# 生成器自动实现了迭代器协议(__iter__ 和 __next__) g = (x for x in range(3)) print(iter(g) is g) # True —— 生成器本身就是自己的迭代器
新增特性:yield from
def chain(*iterables): """串联多个可迭代对象""" for it in iterables: yield from it # 等价于 for item in it: yield item list(chain([1, 2], "ab")) # [1, 2, 'a', 'b']
生成器的"血条"(面试引导技巧)
生成器是一次性的,遍历完就空了,不能回头。需要重复用就转成 list(但那样就失去内存优势了)。
💡 面试回答技巧
- 用类比:普通函数是"餐馆一次性把菜全上齐",生成器是"吃一道上一道"
- 最加分的回答路径:先说生成器是惰性求值 → 对比列表推倒式的内存差异 → 举大文件读取的例子 → 提一下
yield from和协程方向 - 面试官如果问"生成器和协程的关系",可以说
yield是 Python 实现协程的基础(在async/await出现前的原始协程),展示你知识面的纵深
📌 横向对比总结表
| 问题 | 关键词 | 一句话记忆 |
|---|---|---|
| 列表 vs 元组 | 可变 vs 不可变→ 可哈希性、性能、用途 | 列表变,元组不变 |
| 装饰器 | 高阶函数 + 语法糖→ AOP、闭包 | 给函数套个壳 |
| 生成器 | 惰性求值 + yield→ 节省内存、流式处理 | 用的时候才算 |
📚 延伸阅读建议
- 列表 vs 元组:Python 官方文档 Data Structures
- 装饰器:查阅
functools模块源码;读《Python Cookbook》第 9 章 - 生成器:PEP 255(生成器起源),PEP 380(
yield from语法) - 实战吃透这三个概念,推荐做一下 Python 官方教程的 Generator Tricks for System Programmers(David Beazley 的经典教程)
📝面试复盘心得
这三个问题是 Python 面试的"入门三件套",看似基础但层层深入可以考察到源码级别。回答时注意:
- 先给最清晰的结论,再展开细节
- 每个解释都配代码片段(面试官很看重你能不能把抽象概念讲清楚)
- 如果你能自然地提到底层实现(C 源码层面)或者库级应用(Flask/Django),会是很大的加分项
