简介:Python技术面试基础知识整理资料,专为准备Python开发岗位面试的求职者和希望夯实基础的学习者打造。内容按十大模块系统梳理:从变量赋值、数据类型、运算符与流程控制等语法基础,到列表、字典、集合的推导式与增删改查操作;从冒泡、快速、归并排序与二分查找,到递归、动态规划;从类与继承、多态、封装到classmethod/staticmethod;再到异常捕获、标准库中的os/sys/json/re、文件读写、socket通信、sqlite3数据库操作,甚至涵盖项目实践与需求分析。资源为zip压缩包,大小约33.16MB,具体文件明细暂未公布。目前已有508人学习,整理好的知识索引能帮助面试者系统过一遍考点,快速定位薄弱环节,节省自行归纳的时间,提升复习效率,无论是考前突击还是日常巩固都很适用。
1. Python 技术面试基础知识:面试官到底想从你脑子里看到什么
很多人把 Python 技术面试理解成一次“背题考试”:面向对象、异常、装饰器……一个个名词背得滚瓜烂熟,觉得进了考场就能对答如流。可真到了面试现场才发现,面试官从来不按教材顺序提问,他更像个拿着探照灯的修车师傅,上来就问:“你这个变量到底存在哪?这两个东西为什么看起来一样却不相等?”你要是没想过这些问题,很容易被问得当场懵掉。
Python 技术面试要掌握的基础知识,其实就是这一层:不是记住“list 是可变、tuple 是不可变”这种结论,而是理解结论背后的机制——对象怎么存、引用怎么传、函数有哪些隐藏行为、并发和内存到底怎么协同。本文从对象模型开始,一路讲到函数进阶、易混误区、异常与内存,最后落到“什么时候该用、什么时候别用”的判断边界上。把这些基础吃透,你面试不慌,写代码也不容易翻车。
2. 对象与引用:分清“是什么”和“指向谁”
2.1 可变与不可变:Python 里的“改”和“换”是两码事
Python 里每个变量名并不“装”着数据本身,它只是一个指向内存对象的引用。用大白话说,变量名是门牌号,对象才是房子。你看起来在“改变变量”,实际上可能是换了套房,也可能是在同一套房子里敲墙。
不可变对象(int、str、tuple)一旦创建,内容就不能原地修改,只能创建新对象。可变对象(list、dict、set)可以在同一内存地址上增删改。这个差异最常体现在列表上:
a = [1, 2, 3] b = a # b 和 a 指向同一个 list 对象 b.append(4) # append 是原地操作,修改的是那个对象本身 print(a) # [1, 2, 3, 4],a 也跟着变了 x = (1, 2, 3) y = x # y += (4,) 这句会创建出一个新元组,x 保持为 (1, 2, 3)b.append(4)能改到a,是因为b和a引用同一个对象;而元组没有append,你想“加元素”只能拼一个新元组出来,原来的元组纹丝不动。理解了这一层,后面很多题都能解开:比如函数里传入一个列表,函数内部append了数据,外边的列表为什么变了,因为传入的是引用,函数的形参和实参指向同一个对象。
面试里还有个高频追问是“函数默认参数为什么不能用可变对象”。因为默认参数在定义时只创建一次,如果你用def f(x=[]),这个空列表会被所有没传参的调用共享,上次往里面加的东西会保留到下次调用,这属于后文要讲的经典坑。你只需要记住:可变对象意味着“共享有风险,操作需谨慎”。
2.2 深浅拷贝:复制一层,还是复制到底
Python 给 list 提供了copy()方法,给 dict 提供了类似操作,但它们默认都是浅拷贝。浅拷贝只复制了最外层的容器,容器里面的元素还是原来那份引用。
import copy lst1 = [[1, 2], [3, 4]] lst2 = lst1.copy() # 浅拷贝:只复制了外层 list lst2[0].append(99) print(lst1) # [[1, 2, 99], [3, 4]],内层还是同一份 lst3 = copy.deepcopy(lst1) # 深拷贝:递归复制每一层 lst3[0].append(100) print(lst1) # [[1, 2, 99], [3, 4]],不再被影响浅拷贝像你复制了一把抽屉柜,柜子的外观是新的,但每个抽屉里的物品还是原来那些;深拷贝则把每个抽屉连同里面的东西都各做了一份。这个区别在面试里的常见考法是:你的代码里要“基于一份配置做修改,又不影响原配置”,如果配置里套着列表或字典,只用copy()是不够的,必须deepcopy()才能彻底隔离两边的修改。
很多人容易在这件事上踩坑:复制完发现“我改了新的怎么旧的也变了”,其实就是因为内部元素还是共享引用。反过来也要注意,深拷贝不是万能的,复制带文件句柄、线程锁的对象会出问题,那种场景下更合理的做法是重新创建对象,而不是硬拷贝。理解深浅拷贝的核心,是搞清楚这一次复制到底“复制到了哪一层”。
2.3is与==:比身份,还是比内容
==比较的是两个对象的内容是否相等,它背后调用的是__eq__方法;is比较的是内存地址,也就是id()是否相同。一句话:==关心“看起来等不等”,is关心“本来就是同一个吗”。
a = [1, 2, 3] b = [1, 2, 3] print(a == b) # True 内容完全一样 print(a is b) # False 两个不同对象,内存地址不同 c = 256 d = 256 print(c is d) # True 小整数被缓存,c 和 d 指向同一个对象 e = 257 f = 257 print(e is f) # False 超出缓存范围,不同对象上面这个例子是面试经典:CPython 会把 -5 到 256 之间的小整数缓存起来复用,所以256 is 256为 True,而257每次都是新对象。字符串也有类似的驻留机制,但规则更复杂、依赖实现细节,所以面试时标准答法是“不要用is比较数字和字符串内容,is只该用来比None、比单例”。
实际开发里最容易犯的错是把==写成is去判断a is None,这个反而没问题;但如果是a is []或者a is "xx",就是用自己的程序去赌解释器的实现,属于典型的“看起来能跑,换个环境就翻车”。判断内容用==,判断身份用is,这条边界清楚了,相关面试题基本就拿下了。
3. 函数进阶:面试里最爱考的“语法糖”背后是什么
3.1 装饰器:给函数穿上外套,不改里面的肉
日志、鉴权、计时这类逻辑,如果写进每个函数里面,业务代码会被搅得一团乱。装饰器的存在意义,就是把这部分“横切逻辑”抽出来,包一层在函数外面,业务函数自己保持干净。
装饰器的本质是一个可调用对象,接收一个函数,返回一个新函数。语法上的@cost_time等价于slow_add = cost_time(slow_add):
import time def cost_time(func): def wrapper(*args, **kwargs): # wrapper 接收原函数的所有参数 start = time.time() result = func(*args, **kwargs) print(f"{func.__name__} 耗时 {time.time() - start:.4f}s") return result return wrapper @cost_time def slow_add(a, b): time.sleep(0.1) return a + b print(slow_add(1, 2))第一次写装饰器的人容易忽略一个细节:替代函数wrapper会覆盖原函数的__name__、__doc__这些元信息,导致调试时函数名全变成了wrapper。解决办法是加一句@functools.wraps(func),它会把原函数的名字和文档复制到包装函数上。面试时主动提这一句,能明显看出你写过真实项目而不是只背了概念。
带参数的装饰器也不难理解:最外层再包一层函数,接收装饰器的参数,内层照常接收原函数,相当于“工厂函数”。比如做一个只对指定用户生效的权限校验:@require_role("admin")就是require_role先接收"admin"返回一个真正的装饰器,再用这个装饰器去包裹原函数。面试问“装饰器怎么传参”,说的就是这层结构。
装饰器的适用边界也很重要:它适合做行为增强、横切逻辑;如果一段装饰器里塞进了大量业务判断,反而会让调用链变得难追踪。后面第六章会继续讲“什么时候别用”,这里先记住装饰器的核心是“包裹”和“透传”。
3.2 闭包与作用域:为什么需要nonlocal
闭包的定义是“函数 + 捕获到的外部变量”。为什么需要闭包?因为有时候我们希望让函数记住一些上下文状态,又不想为此弄一个全局变量。计数器是最典型的例子:
def make_counter(): count = 0 def inner(): # inner 捕获了外层变量 count nonlocal count count += 1 return count return inner c1 = make_counter() print(c1()) # 1 print(c1()) # 2 c2 = make_counter() print(c2()) # 1,每个计数器独立nonlocal的作用是告诉 Python:“这个变量不是当前作用域的局部变量,它来自外层函数,我要修改它。”如果没有这句,Python 在函数内部看到count += 1会自动把count当作局部变量,于是要么报错、要么行为和预期不符。理解这个机制,你就同时理解了 Python 的作用域查找顺序:Local(局部)→ Enclosing(外层)→ Global(全局)→ Built-in(内置),面试管这个叫 LEGB。
闭包有一个著名陷阱叫“延迟绑定”:循环里创建闭包,闭包内部引用的循环变量,并不会在创建那一刻把值固定下来,而是在调用闭包时才去查找这个变量。
funcs = [lambda: i for i in range(3)] print([f() for f in funcs]) # [2, 2, 2],不是 [0, 1, 2]原因是三个闭包捕获的是同一个i,循环结束后i停在 2,于是三个函数都返回 2。解决办法是用默认参数把当前值“钉住”:lambda i=i: i。这个坑在面试里几乎必考,理解了闭包的“捕获变量”本质,就不会再被问懵。
3.3 生成器与迭代器:一边算一边出,不一口气全加载
迭代器是实现了__iter__和__next__的对象,for 循环能遍历它,靠的就是这个协议。生成器是一种更省事地写出迭代器的方式:函数里出现yield,调用这个函数就不再执行函数体,而是返回一个生成器对象。
def read_large_file(path): with open(path, "r", encoding="utf-8") as f: for line in f: yield line.strip() # 有 yield 就是生成器函数调用read_large_file并不会立刻读取文件内容,它返回一个生成器;for循环每取一次,函数就执行到下一个yield暂停,把当前行的值交出来,下一次再继续。这种“边算边出”的特性,主要用于数据量大的场景:如果先把所有行读进一个列表,内存可能直接爆掉,而生成器里同一时刻只存一行。
生成器和普通函数最大的区别在于“暂停”。普通函数执行完就结束,生成器每次停在一个yield处,状态全部保留。手动调用next(gen)可以让它继续,最后没有值可取时抛StopIteration,for 循环会帮你处理这个异常,所以日常写代码几乎不需要手动next。
面试里经常对比的还有列表推导式和生成器表达式:[x for x in range(10**8)]会一次性生成一个近亿元素的列表,内存直接拉满;(x for x in range(10**8))是生成器表达式,惰性求值,循环到哪就算到哪。判断该用哪个,只有一个标准:你需要的是“全部结果一次性反复访问”,还是“结果流式地挨个消费”。前者用列表,后者用生成器,这条后面还会再展开。
还要注意,迭代器是一次性的:遍历完再遍历,拿不到数据。列表可以来回遍历,因为数据就存在内存里;生成器里的元素被取走就没了,想重新来一遍只能重新创建生成器。这个特性放在“大文件第二轮处理”时尤其关键,很多人第一轮消耗完迭代器,第二轮才发现什么都没了,这时候回头用列表还是生成器,答案立马清晰。
4. 易混概念:五个面试里最容易被问懵的“双胞胎”
4.1 GIL:有了它,多线程是假的吗?
- 易混点:把“Python 多线程”直接等同于“不能并行”,甚至有人把 GIL 当成 Python 语言本身的缺陷。
- 为什么混:因为绝大多数人运行的 CPython 解释器带有 GIL,日常体验就是“多线程跑不满 CPU 多核”,久而久之就把“CPython 的实现细节”误说成“Python 的规则”。
- 怎么区分:GIL 全称是全局解释器锁,它保证同一时刻只有一个线程在执行 Python 字节码。对 IO 密集任务,比如读文件、发请求、查数据库,多线程很有用,因为线程在等待 IO 时会主动释放 GIL,让其他线程继续跑;对 CPU 密集任务,比如纯 Python 写的大规模数值计算,多线程被锁卡住,体现不出并行收益,这时应该用多进程(每个进程独立解释器、各有一把 GIL)或者让计算热点下沉到 C 扩展里。
面试金句可以这样组织:“GIL 是 CPython 的全局解释器锁,它让同一进程内同一时刻最多只有一个线程执行 Python 字节码;IO 密集用多线程能提升吞吐,CPU 密集用多进程绕过 GIL 才能利用多核。”说清楚这三点,面试官就确认你是真明白而不是背结论。
4.2__init__与__new__:房子和装修是两件事
- 易混点:以为创建一个对象只走
__init__,根本没听说过__new__。 - 为什么混:日常写类 90% 都只写
__init__,实例创建和实例初始化被当成了一步。 - 怎么区分:
__new__负责“创建实例”,它是类方法,第一个参数是cls,返回一个实例;__init__负责“初始化实例”,第一个参数是self,不允许有返回值。执行顺序是__new__先跑,返回的实例再传给__init__。
打个比方:__new__是拿到毛坯房的钥匙,__init__是给房子装修。大多数时候你只需要装修,钥匙由系统自动配好;但你如果想控制“这房子一共只允许存在一套”,就必须在__new__里做文章,这就是单例模式:
class Singleton: _instance = None def __new__(cls): if cls._instance is None: cls._instance = super().__new__(cls) # 真正创建这一步只走一次 return cls._instance参数说明:super().__new__(cls)才是真正向系统申请内存并创建对象;如果绕开它直接返回一个已有对象,单例依然成立。注意,就算__new__每次返回同一个实例,__init__还是会每次被调用,所以单例里如果有重初始化逻辑,要加状态判断,不然新调用会把旧状态清掉。面试追问到这一步,基本就是满分区分了。
4.3__str__与__repr__:两种“转字符串”,服务不同的人
- 易混点:以为这两个方法随便实现一个就行,或者不知道交互式解释器显示对象时调用的是哪个。
- 为什么混:从调用形式上看,它们都在把对象变成字符串,区别藏在“给谁看”上。
- 怎么区分:
__str__面向最终用户,print(obj)和str(obj)会走它;__repr__面向开发者,要求无歧义、能表达对象的关键状态,交互式解释器里直接输入对象名回车,或者在容器里打印元素,显示的是__repr__的结果。还有一个规则:没实现__str__时,Python 会退回去用__repr__,反过来却不会。
举个例子,datetime对象:str(datetime.now())输出像2025-01-01 10:00:00,repr(datetime.now())输出则带datetime.datetime(...),方便你复制回去重新构造。面试建议很直接:__repr__永远值得实现,因为日志和调试面板都靠它输出关键信息;__str__则在“需要给人看的展示层”再实现。分清一个给用户、一个给程序员,这两个方法就不再混淆。
4.4*args与**kwargs:它们是收集,还是展开?
- 易混点:以为
*args、**kwargs是特殊语法结构,甚至觉得它们是关键字。 - 为什么混:在函数定义和函数调用两个方向都能看到星号,符号一样、方向相反,很容易绕晕。
- 怎么区分:先看定义侧。
def f(*args, **kwargs)里,多余的“位置参数”会被收集进args这个元组,多余的“关键字参数”会被收集进kwargs这个字典。再看调用侧。f(*items)把可迭代对象展开成多个位置参数,f(**mapping)把字典展开成多个关键字参数。一个收拢、一个摊开,方向完全不同。
def show(a, b, *args, **kwargs): print(a, b, args, kwargs) show(1, 2, 3, 4, x=5, y=6) # 输出:1 2 (3, 4) {'x': 5, 'y': 6} params = (1, 2) kw = {"x": 5} show(*params, **kw) # 等价于 show(1, 2, x=5)参数说明:装饰器里写def wrapper(*args, **kwargs): return func(*args, **kwargs),就是用这两套符号把原函数的参数原样“透传”给被装饰函数——收集进来,再展开出去,一条龙。面试易错点有两个:一是*args必须放在位置参数后面,否则语法错误;二是过度使用会让函数签名变得难读,调用方根本看不出来需要传什么。能用的地方用,不能用的地方老老实实写出具名参数,比炫技稳妥得多。
4.5 可变默认参数:空列表为什么会“记住”上一次的值
- 易混点:以为默认参数每次调用都会重新求值,
def f(x=[])每次进来都是新的空列表。 - 为什么混:因为大多数语言和大多数人的直觉都认为“函数调用到这才创建默认值”。
- 怎么区分:Python 的默认参数在“函数定义时”只求值一次,之后保存在函数对象上。所以不传
x时拿到的永远是同一个列表对象,上次往里面加的数据会一直保留,多次调用结果累积。
def buggy(x=[]): x.append(1) return x print(buggy()) # [1] print(buggy()) # [1, 1] print(buggy()) # [1, 1, 1]正确的写法是用不可变值做默认参数,在函数体内重新创建可变容器:
def fix(x=None): if x is None: x = [] x.append(1) return x参数说明:x=None是不可变对象,安全;每次调用不传参时,函数内部创建一个全新的列表,互不干扰。这条几乎是必考题,面试官问你观察输出,再问为什么,再追问怎么修,能完整答出来的不多。你把它理解成“默认参数是定义时就固定下来的对象,不是每次调用时重新生成的东西”,就不会再踩这个坑。
5. 异常、上下文与内存:面向“真实运行”的 Python 基础
5.1 异常处理:try 下面的顺序,先具体再通用
异常处理的设计目标是:不让错误把程序直接击穿,而是在出错点做分流。面试时长被问的其实是“分流的顺序和边界”。
try: value = input("请输入数字: ") result = 10 / int(value) except ValueError: print("输入不是整数") except ZeroDivisionError: print("不能除以 0") except Exception: print("其他异常,先记录再决定") else: print("没有异常时执行,适合放成功后的逻辑") finally: print("不管是否异常都会执行,适合放清理逻辑")这段代码的顺序是有讲究的:ValueError和ZeroDivisionError在Exception之前,因为 Python 的异常匹配按类型从上往下找,Exception会把前面所有异常都接住,放前面就轮不到更具体的分支了。else是“没出错才做”的后续动作,finally是“无论怎样都要做”的清理动作,两者分工完全不同。
要特别注意一个边界:不要写裸except:,它会连KeyboardInterrupt、SystemExit这类不该被吞掉的异常一起抓住,程序被 Ctrl+C 都终止不了,异常链也被破坏。正确做法是至少写except Exception:,并配合日志把堆栈留下来。在raise时可以用raise ... from ...保留原始异常上下文,排查问题时能顺着链一路看到“底层为什么失败”,这个细节在真实项目里比背十个异常类型都管用。
5.2 上下文管理器:with 保证“就算出错了也收尾”
资源管理是面试里的经典场景:打开文件、获取锁、开启事务,如果中途异常,资源没释放,轻则句柄泄漏,重则锁死。手动try/finally能解决,但容易漏写,于是有了with语法,它背后靠__enter__和__exit__两个方法支撑。
with open(...) as f里,__enter__负责打开资源并返回,__exit__负责关闭资源。关键特性是:即使with块内部抛了异常,__exit__也会被调用,相当于 Python 替你包了一层自动的清理逻辑。想自定义一个上下文管理器,最省事的是用contextlib.contextmanager:
from contextlib import contextmanager import time @contextmanager def log_time(name): start = time.time() try: yield # yield 前面是 enter,后面是 exit finally: print(f"{name}: {time.time() - start:.3f}s") with log_time("计算段"): total = sum(range(100000))yield那行把执行权交给with块内部,yield之后的部分无论有没有异常都会在退出时执行,所以这里的finally保证了“耗时信息一定会被打印”。面试问“自定义上下文管理器有几种写法”,答案就是基于类的__enter__/__exit__和基于装饰器的contextmanager两种,前者适合对象本身参与状态流转,后者适合把一个已有函数快速包装成可with的形式。
5.3 垃圾回收与内存:引用计数不够用了怎么办
Python 的内存回收主要靠引用计数:一个对象被引用的次数归零,立即被回收。这个策略简单高效、无需暂停程序,但有副作用——循环引用。
import gc a = [] b = [] a.append(b) # a 引用 b b.append(a) # b 引用 a del a, b # 两个对象的外部引用都没了 # 但内部互相引用,计数不为 0,普通回收拿它们没办法 gc.collect() # 强制触发一次垃圾回收,可以处理掉这种环上面的代码里,del a, b只是移除两个外部引用,对象互相还指着对方,引用计数都还是 1,内存就一直占着。这类情况需要“标记-清除”,把不可达的对象整个标记出来回收;为了减少全量扫描的开销,CPython 又按对象存活时间分代,越年轻的对象越容易被扫描,这就是分代回收。
面试在这个点的常见追问是:“del 是不是立刻释放内存?”答案是:del只删一个引用,如果引用计数归零会立刻回收,但如果是循环引用或者还有别的引用握着,它并不会真正释放。另一个高频场景是“哪些操作会造成内存泄漏”:模块级缓存、全局列表越长越大、被异常对象意外持有的局部变量,都比循环引用更常见。理解 GC 不是为了写一堆del,而是让你知道:资源能被自动回收,但也可能被长期持有,所以该限制缓存大小的时候就要限制,别把一切都甩给回收器。
6. 什么时候用、什么时候别用:Python 基础知识的边界判断
把这些基础概念串起来的最好方式,是回到一个真实的面试题:“给你一个列表去重并保持原始顺序,你怎么做?”新手会写嵌套循环,把每个元素跟后面的比较一遍,代码长、复杂度高;理解对象模型的人会想到用dict.fromkeys或set加排序,几行搞定。这个差异不是你背了多少 API 决定的,而是你理解了“dict 的键天然唯一,且 Python 3.7 起保持插入顺序”这个机制之后,自然推导出的答案。
同样地,生成器和列表的选择也有清晰的边界:数据量小、需要反复遍历、要做随机访问,直接用列表,代码更直白;数据量大到内存吃紧、只需要顺序消费一遍,或者数据源是无限流,就必须用生成器。很多人在小数据时为了“显得专业”强行上生成器,反而让代码绕了一圈又一圈,这是过度设计。基础知识的正确用法是让代码在合适的场景用合适的工具,而不是在所有地方堆砌高级特性。
装饰器、闭包、上下文管理器也一样。需要给多个函数统一加计时、加日志、加权限校验,装饰器是正解;只有一个函数要加逻辑,直接写在函数里反而更清晰。需要成对取得和释放资源,用with;只有一次打开文件且代码很短,手动close()勉强也能接受,但既然有语法糖,没必要自找麻烦。这些判断的标准都指向同一句话:这个机制有没有帮你把代码变得更简单、更不容易出错,如果没有,别用。
我最早面试时,也踩过不少坑:默认参数写过空列表、深浅拷贝搞混过、GIL 解释得含含糊糊。后来我把每一次面试里答得卡壳的问题记下来,对照 Python 的对象模型、函数机制、异常与内存体系重新梳理,从那以后,我每次面试前都会强制走一遍“对象与引用 → 函数进阶 → 易混误区 → 内存与异常”这条线。这套思路不但帮我稳住了面试表现,也让我写代码时更少遇到“运行起来和想象中不一样”的玄学。希望今天这篇文章,能把你的基础底座也垫实一层。
本文还有配套的精品资源,点击获取