简介:南开大学软件学院Python期末复习资料,面向该校学生及Python基础学习者,系统梳理期末考试涉及的核心知识点。压缩包内为1个docx文档,约5.28MB,文档结构清晰、内容详实,便于按章节查阅与打印。内容覆盖字典、元组、列表、集合等数据结构的操作细节,浅拷贝与深拷贝的差异,函数默认参数陷阱、*args/**kwargs的用法,以及面向对象中的封装、继承、多态和MRO C3算法;同时包含魔法函数、装饰器、生成器、正则表达式等进阶要点,并配有大量代码示例与易错点提示。已有1199人学习,适合考前集中复习、对照查漏补缺,也可作为期末突击和日常回顾的实用手册。
1. 期末复习笔记怎么用:先分清“背概念”和“动手跑”
拿到这份软件学院的 Python 期末复习资料,第一反应是“全”,但真正把它吃下去的人不多。因为 Python 期末复习的最大误区就是把“结论”当“知识”——比如“字典无序、元组不可变、集合不能重复”,这句话背下来能填空,但遇到dict.update()、list.extend()、*args解包这类实操题还是会翻车。这份资料的价值恰恰在于:它把高频考点按“数据结构 → 函数 → 面向对象 → 魔法函数”串成了一条线,每一节都有可以直接抄进解释器里跑的示例代码。适合三类人:准备期末考的软件学院学生、想快速梳理 Python 核心边界的转行者、以及面试前需要系统过一遍语法细节的在职开发。全文按“是什么 → 怎么用 → 坑在哪”展开,每段代码都可以复制到本地跑一遍。
2. 数据结构与引用模型:字典为什么无序、列表切片为什么是左闭右开
这份笔记的前半部分几乎都在讲 Python 四种内置容器:列表、元组、字典、集合。表面上是在背“无序”“不可变”“可重复”这些形容词,实际上这些词决定了一个更底层的问题——你写的代码在内存里到底是怎么被组织的。
2.1 先弄懂“无序”和“不可变”:四种容器的底层差异
先看资料里反复强调的三个结论:
- 字典和集合是无序的,因为底层是哈希表,元素位置由哈希值决定,没法用下标索引。
- 元组、数字、字符串是不可变的,它们能做字典的 key。
- 集合中的元素不允许重复,所以天然适合去重。
这三条不是并列关系,而是因果关系。哈希表要求 key 在生命周期内哈希值不变,所以只有不可变对象才能当 key;而哈希表的存储顺序依赖于哈希值和当前表的容量,所以字典和集合天然无序。列表之所以有序且能索引,是因为它底层是连续内存块上的指针数组,按下标访问是 O(1) 的。
# 验证:可变对象不能做字典的 key d = {} try: d[[1, 2]] = "list" # 列表可变,哈希值会变,直接报错 except TypeError as e: print("TypeError:", e) d[(1, 2)] = "tuple" # 元组不可变,合法 print(d) s = {1, 2, 2, 3} # 集合会自动去重 print(s) # 输出 {1, 2, 3},顺序不保证这里[1, 2]作为列表是可变对象,Python 无法给它一个稳定的哈希值,所以TypeError是预期的。换成元组(1, 2)后就能正常作为 key。集合{1, 2, 2, 3}里写了两个 2,输出只剩一个,这就是“集合元素不允许重复”的直观验证。日常写代码时,如果你需要一个“不重复且之后不再修改”的元素集合,用元组套进集合比用列表再手动去重更安全。
还有一个细节:空集合必须用set()创建,用{}创建的是空字典。这个不算坑,但期末选择题很喜欢拿它出题。
2.2 列表与字典操作:extend、sort、update 的边界行为
资料里的代码实例都很短,但每一句都对应一个考点。列出几个高频的:
list.extend(seq)是在列表尾部逐个追加另一个列表中的元素,和append(seq)把整个列表当成一个元素塞进去不同。list.sort(key=None, reverse=False)默认从小到大排序,reverse=True时从大到小。list.reverse()无返回值,直接原地反转。dict.update(dict1)是用参数里的字典去更新调用者,相当于合并,重复 key 会被覆盖。dict.setdefault(key, default)如果 key 不存在就插入并返回默认值,如果存在就返回原值,不修改。dict.pop(key)必须给 key,否则报错;dict.popitem()无参,删除并返回最后一个键值对。del dict[key]是彻底删除键;dict.clear()是清空内容,字典对象还在。
lst = [3, 1, 2] lst.extend([4, 5]) # 逐个追加,lst 变成 [3, 1, 2, 4, 5] lst.append([6, 7]) # 整个列表作为最后一个元素 print(lst) # [3, 1, 2, 4, 5, [6, 7]] lst = [3, 1, 2] lst.sort(reverse=True) # 原地降序排序 print(lst) # [3, 2, 1] d1 = {"a": 1, "b": 2} d2 = {"b": 3, "c": 4} d1.update(d2) print(d1) # {"a": 1, "b": 3, "c": 4},b 被覆盖extend和append的区别是期末考试最喜欢挖的坑:append是增加一个元素,extend是合并一个序列。update的合并语义同样值得注意,它并不是“如果 key 不存在才写入”,而是无条件覆盖同名字段。如果你只想补缺失的 key,应该用setdefault或dict.get手动判断。
2.3 对象、引用与标识:为什么 a = b 之后改一个等于改两个
资料里有一段很重要的话:“列表不存具体的值在内存中,只存 ID,a、b 的 ID 一样。”这就是 Python 的引用模型。变量名本身不持有对象,持有的是对象在内存中的地址。执行a = [1, 2, 3]时,Python 在堆上创建了一个列表对象,a只是一个指向它的标签。再执行b = a时,b也指向同一个列表,并没有复制数据。
a = [1, 2, 3] b = a b.append(4) print(a) # [1, 2, 3, 4],a 也被改了 print(a is b) # True,两个名字指向同一个对象is比较的是 id,==比较的是 value。这个区分在期末复习里考过不止一次:a is b为 True 不代表两个变量“相等”,而是代表它们是同一个对象。对不可变对象来说,==和is经常产生不同的结果,比如两个内容相同的字符串可能因为驻留机制被缓存到同一内存地址,但两个内容相同的列表一定是不同对象。
实操建议:在理解引用模型时,把“变量名 → 箭头 → 内存块”的画法在纸上画一遍。画过的同学基本不会再把=当成拷贝来用。需要真正复制一份独立的列表时,用切片 lst[:]或list(lst),但要记住这只是浅拷贝,嵌套的可变对象仍然是共享的,这部分的细节放在第 5 章避坑里展开。
3. 函数与参数传递:默认参数陷阱、*args/**kwargs 与装饰器的连锁反应
函数是这份笔记的第二个重头戏。这里面的坑都集中在“参数什么时候被求值”和“参数如何被组装”上。搞懂了这两点,装饰器和闭包就能顺势理解,因为它们本质上也是“函数对象被传参”。
3.1 默认参数陷阱:为什么不能用列表做默认值
资料里给了一个非常经典的错误示例,函数定义时用了可变对象L = []作为默认参数。问题在于:Python 函数默认参数在函数定义时只被求值一次,之后每次调用都复用同一个列表对象。第一次调用往L里加了元素,第二次调用看到的L已经是修改过的,而不是当初定义时的空列表。
# 错误写法:默认参数指向可变对象 def add_item(item, L=[]): L.append(item) return L print(add_item(1)) # [1] print(add_item(2)) # [1, 2],第二次调用拿到了上一次的残留数据 # 正确写法:用 None 占位,函数内部再创建新列表 def add_item(item, L=None): if L is None: L = [] L.append(item) return L print(add_item(1)) # [1] print(add_item(2)) # [2],每次都是新列表第一个函数的两次输出分别是[1]和[1, 2],这个“记忆效应”对新手来说非常反直觉。第二个函数用None作为默认值,因为None是不可变对象,不会被修改,每次调用进入函数体后都重新创建一个空列表,行为完全可控。这条规则可以上升为一个通用的编码习惯:默认参数只指向不可变对象,如果业务上确实需要一个可变的默认容器,用None占位再在函数内部初始化。
3.2 *args 与 **kwargs:定义时组装、调用时解包
资料里把*args和**kwargs分成“函数定义时”和“函数调用时”两种场景,这个区分特别关键。
函数定义时:
*args收集所有未匹配的位置参数,组装成一个元组传给args。**kwargs收集所有未匹配的关键字参数,组装成一个字典传给kwargs。- 二者同时存在时,
*args必须放在**kwargs前面。
def show(*args, **kwargs): print("args:", args) print("kwargs:", kwargs) show(1, 2, 3, name="py", score=98) # args: (1, 2, 3) # kwargs: {'name': 'py', 'score': 98}函数调用时:
*args是解包元组或列表,把每个元素作为独立的位置参数传入。**kwargs是解包字典,把每个键值对作为关键字参数传入。
def calc(a, b, c): return a + b + c nums = (1, 2, 3) print(calc(*nums)) # 6,解包元组为三个位置参数 info = {"a": 10, "b": 20, "c": 30} print(calc(**info)) # 60,解包字典为三个关键字参数注意看第二次调用,字典的 key 恰好是函数参数名a、b、c,如果 key 名字对不上会直接TypeError。这也是**kwargs解包最常见的报错来源。
3.3 装饰器原理:foo = timeit(foo) 到语法糖
资料里用计时器解释了装饰器的本质:写一个timeit函数,接收foo作为参数,内部再定义一个包裹函数wrapper,在调用foo前后计时,然后返回wrapper。调用foo = timeit(foo)后,原来的foo名字指向了一个新函数,这个新函数内部包装了旧函数。
import time def timeit(func): def wrapper(*args, **kwargs): start = time.time() result = func(*args, **kwargs) end = time.time() print(f"cost: {end - start:.4f}s") return result return wrapper # 手动装饰 def foo(x): return x * 2 foo = timeit(foo) print(foo(10)) # 等价语法糖 @timeit def bar(x): return x * 2 print(bar(20))这里的核心是wrapper(*args, **kwargs)这一层透传:wrapper接收任意参数,原封不动地转交给原始函数func,这样装饰后的函数才能保持原来的调用方式。@timeit语法糖只是把foo = timeit(foo)这一步换了个写法,底层逻辑完全一致。复习时只要记住一句话:装饰器就是“传一个函数进去,返回一个新函数”。
需要注意的是,装饰器还涉及一个容易忽略的点:被装饰后函数的__name__会变成wrapper,如果要保留原函数的元信息,需要用functools.wraps。期末复习不会考到这个深度,但实习做项目时如果写 Flask 或 FastAPI 的路由装饰器,这个细节迟早会遇到。
4. 面向对象核心:类属性、私有属性、C3 算法与 super 的钻石继承
面向对象是这份笔记篇幅最大的一块,也是最容易死记硬背的一块。重点其实只有四个:类属性与实例属性的指向关系、私有属性的“假私有”实现、多继承的 MRO 顺序、以及super()在钻石继承里的作用。
4.1 类属性与实例属性:dict和class的指向关系
资料里提到“最后一行的__dict__结果是实例调用__class__属性时会指向该实例对应的类,然后可以再去调用其它类属性”。这句话的信息密度很高,拆开看:
- 每个实例都有一个
__dict__,存放实例自己的属性。 - 每个类也有一个
__dict__,存放类属性。 - 实例可以通过
self.__class__拿到自己的类对象,然后访问类属性。
class Song: title = "default" # 类属性 def __init__(self, name): self.name = name # 实例属性 s = Song("hello") print(s.__dict__) # {'name': 'hello'},实例属性存在实例的字典里 print(Song.__dict__.get("title")) # default,类属性存在类的字典里 print(s.__class__.__name__) # Song,实例通过 __class__ 指向类更重要的是这一段:“创建实例的时候,如果实例中没有初始化实例的变量,用类变量的值赋值给实例变量,从此实例变量与类变量无瓜葛。”意思是:当你通过实例读取一个不存在的属性时,Python 会回溯到类属性;但一旦你给实例的同名属性赋值,相当于在实例的__dict__里新建了一个条目,之后这个实例再也不会引用类属性了。
class Dog: kind = "canine" d = Dog() print(d.kind) # canine,来自类属性 d.kind = "pet" # 实例属性新建,覆盖了读取路径 print(Dog.kind) # canine,类属性不受影响 print(d.__dict__) # {'kind': 'pet'}这个“赋值即隔离”的行为在读过源码前确实像玄学,本质就是 Python 属性查找顺序:实例__dict__→ 类__dict__→ 父类__dict__。记住了这条链路,后面理解继承和多态就是水到渠成的事。
4.2 私有属性:两条下划线只是“改名”而不是“私有”
Python 没有真正意义上的私有属性,双下划线开头的__name会被解释器改名为_ClassName__name。这就是资料里说的“假定没有恶意行为,Python 留出了类外访问私有属性和方法的方式:_类名__属性名”。
class PrivateDemo: def __init__(self): self.__secret = "hidden" def __hidden_method(self): return "method" obj = PrivateDemo() # 直接访问会 AttributeError # print(obj.__secret) # 通过改名规则访问 print(obj._PrivateDemo__secret) # hidden print(obj._PrivateDemo__hidden_method()) # method print(obj.__dict__) # {'_PrivateDemo__secret': 'hidden'}看到obj.__dict__的 key 是_PrivateDemo__secret就真相大白了。为什么说这是“防君子不防小人”?因为改名规则是公开的,任何人只要拼出_类名__属性名就能访问。期末复习只要记住一点:在类内部用双下划线定义“约定私有”的属性,类外访问一律走改名规则,不要试图通过正常属性名直接访问。
4.3 多继承与 MRO:C3 算法的两个原则
Python 3 的多继承查找顺序由 C3 算法决定,资料里给了口诀:从子到父,从左到右,object永远是最后一个。具体执行时有两步剪枝:从最左父类开始,如果某个类是其他父类的子类,就把它推迟到后面;所有不是D直接父类的无关节点直接丢弃。
以一个显式例子来看:
class A1: pass class A2: pass class A3: pass class B1(A1): pass class B2(A2): pass class B3(A3): pass class C1(B1): pass class C2(B2): pass class C3(B3): pass class D(C1, C2, C3): pass print(D.__mro__)输出大致为D -> C1 -> B1 -> A1 -> C2 -> B2 -> A2 -> C3 -> B3 -> A3 -> object。观察这条链,从左到右的每一支都是先子类后父类,同一层级的父类按继承顺序排列。这里最容易犯的错是把继承顺序写反,比如class D(B1, B2, B3)和class D(B2, B1, B3)产生的 MRO 完全不同,方法查找结果也就随之改变。
class Animal: def speak(self): print("Animal speaks") class Dog(Animal): def speak(self): print("Dog barks") class Cat(Animal): def speak(self): print("Cat meows") class Pet(Dog, Cat): pass p = Pet() p.speak() # Dog barks,按 MRO 从左往右先找到 Dog.speak当Pet自己没有定义speak时,Python 会按Pet -> Dog -> Cat -> Animal -> object的顺序查找。这个顺序和括号里的位置完全一致:写在前面的父类有更高的优先级。这就是为什么资料里强调“继承顺序很重要”——同样的父子类关系,只是换了一下括号里的顺序,查到的可能就是另一个父类的方法。
4.4 super() 与钻石继承:避免多次初始化的关键
钻石继承是多继承的经典陷阱:如果D同时继承B和C,而B、C都继承A,用父类名去调用A.__init__()会导致 A 被初始化两次。这是因为直接调用类名方式不会走 MRO,每个父类都会独立地再去初始化它的父类。
class A: def __init__(self): print("A init") class B(A): def __init__(self): print("B init") A.__init__(self) class C(A): def __init__(self): print("C init") A.__init__(self) class D(B, C): def __init__(self): print("D init") B.__init__(self) C.__init__(self) d = D() # 输出顺序: # D init # B init # A init # C init # A init <- A 被初始化了两次改用super()后,调用链变成严格按 MRO 走,每个类在链上只初始化一次。
class A: def __init__(self): print("A init") class B(A): def __init__(self): print("B init") super().__init__() class C(A): def __init__(self): print("C init") super().__init__() class D(B, C): def __init__(self): print("D init") super().__init__() d = D()输出顺序会变成D init -> B init -> C init -> A init。这里的super()并不是“调用父类”,而是“按 MRO 顺序调用下一个类”。super()去掉了Medium1这类显式类名后,代码在继承结构变化时依然能正确工作。期末复习遇到super()相关的题,先画出 MRO,再一条一条对照,正确率会高很多。
5. 避坑自查:五条高频翻车记录与根因复盘
期末复习阶段最常见的状态是“知识点都知道,一写代码就报错”。以下五条是从资料内容里提炼出来的高频翻车点,每条按现象、原因、解决三步拆解,可以直接拿来当自查清单。
5.1 现象:默认参数被“记住”,函数第二次调用结果不对
定义函数def func(item, L=[]),连续两次调用后发现第二次结果包含了第一次的数据,表现和闭包一样“有记忆”。
原因:默认参数在函数定义时求值,L指向同一个列表对象,调用时修改的是同一个对象的内存内容。
解决:默认参数一律指向不可变对象,用None占位,函数内部判断后创建新列表。
5.2 现象:list.copy()之后修改嵌套列表,原列表也跟着变
对二维列表进行copy()后,修改新列表的某个子元素,原列表的对应位置也被改动。
原因:copy()是浅拷贝,只复制外层列表的指针数组,内层列表仍然共享同一个对象引用。
解决:需要完全独立时用copy.deepcopy()。判断标准是“拷贝对象里只要有一个可变类型,就建议用深拷贝”。对于数据量小的日常练习,深拷贝的开销可以忽略不计。
5.3 现象:访问“私有属性”时拼了普通名字却报AttributeError
原因:双下划线开头的属性被 Python 改名为_类名__属性名,普通名字在实例的__dict__里根本不存在。
解决:在类内部用双下划线定义私有属性,类外部访问时用改名规则,或者干脆不访问。这不是为了让你去偷数据,而是让你知道“约定私有”是靠解释器改名实现的,不是真正的权限控制。
5.4 现象:多继承时调用了错误的父类方法,或者在钻石继承中父类被初始化两次
原因:括号中父类顺序决定了 MRO,方法查找从左往右;直接调用父类名.__init__绕过了 MRO,导致钻石结构里同一个祖先被多次初始化。
解决:多继承时先打印ClassName.__mro__确认查找顺序;初始化用super().__init__()而不是显式父类名,让调用链按 MRO 依次执行。
5.5 现象:dict.pop(key)报KeyError,而get(key)又永远不报错
原因:pop(key)需要显式删除指定键,key 不存在时直接抛出异常;get(key)的语义是“查询”,不会修改字典,键不存在时返回None。
解决:删除键时用pop(key),不确定键是否存在时先if key in d判断或使用pop(key, default)提供默认返回值。setdefault和update的区别同理,一个是“缺失才写入”,一个是“无条件覆盖”,按业务场景选。
6. 用魔法函数和深浅拷贝做期末自测:一个可复现的验证流程
复习资料的最后一部分集中在魔法函数、深浅拷贝和动态创建类上。这里的知识点不是背诵题,而是可以通过“写小脚本验证”来巩固的。我复习时习惯把一个结论拆成三步:读笔记里的结论 → 写一段不超过 15 行的代码 → 在解释器里跑通并对比输出。这套流程比反复看笔记高效得多。
魔法函数里最值得手动跑的是__iter__和__getitem__,因为两者存在“降级”关系。资料里说得很清楚:如果一个类没有实现__iter__,for循环会尝试用__getitem__按索引取值,直到抛出IndexError才停止。这个机制可以用一段代码验证:
class NumList: def __init__(self, data): self.data = data # 注释掉 __iter__ 再运行,观察结果差异 def __iter__(self): return iter(self.data) def __getitem__(self, index): return self.data[index] obj = NumList([10, 20, 30]) for item in obj: print(item)把__iter__注释掉后,for循环依然能跑,但走的是__getitem__的 0、1、2 下标路线。这个“先找迭代器,找不到就退到下标”的规则,比死记“实现__iter__才能迭代”更贴近解释器的真实行为。同理,一个类没有__str__时会退到__repr__,这也是可以用print(obj)直接验证的。
深浅拷贝的验证也值得专门跑一遍。资料里的结论是:最外层是可变类型时,copy.copy()会创建新对象;最外层是不可变类型时,copy.copy()返回原对象;但无论哪种浅拷贝,内层可变对象仍然共享引用。想要一眼看清差异,可以打印每层的 id:
import copy nested = [[1, 2], [3, 4]] shallow = copy.copy(nested) deep = copy.deepcopy(nested) print(id(nested[0]) == id(shallow[0])) # True,浅拷贝内层共享 print(id(nested[0]) == id(deep[0])) # False,深拷贝内层独立一句话总结深浅拷贝的判断标准:单看外层,浅拷贝对可变对象创建新引用、对不可变对象直接复用;深拷贝则是不管有几层嵌套,所有可变对象全部重建。期末做题时遇到“修改拷贝后的数据会不会影响原对象”,先看修改发生在第几层,再决定答案是“会”还是“不会”。
把魔法函数和拷贝这两个话题放在最后,是因为它们兼具“高频考点”和“代码验证友好”两个属性。我的期末复习习惯是每天固定抽两小时,把笔记里的结论逐条转化成小脚本跑一遍,跑不通的标记为薄弱点,第二天优先补。这个方法陪我考过了 Python 和算法两门课,从那以后每轮复习我都强制自己走一遍“读结论 → 写验证 → 看输出”的流程,经验证明比单纯翻笔记管用得多。希望帮到你。
本文还有配套的精品资源,点击获取