这段时间我把 MIT 6.0001《计算机科学导论》重新过了一遍,重点看的是第9讲“Python 类和继承”。说实话,这一讲是我当年学 Python 时容易忽略的一节,因为前几讲已经能用循环、函数、字典写程序了,我总觉得类是一种“官方整理过的代码风格”,会写也行,不写也能跑。后来代码量上来,我才发现类解决的不只是代码美观问题,而是“数据和行为怎么绑定、怎么复用、怎么扩展”的核心设计问题。这篇文章按我这边的理解顺序,把第9讲里最实用的部分拆开讲一遍,适合正在学 Python 的初学者,也适合写过脚本但没系统整理过面向对象写法的开发者。
第9讲在整门课里的位置很关键:前面讲完数据类型、控制流、函数、递归、算法复杂度后,程序规模已经从小练习扩大到了可以模拟一个问题、一批数据、一类对象的状态变化。如果继续用零散变量和函数来写,程序很容易变成一张大网:数据存在这里,函数定义在那边,改一个逻辑要翻三个文件。类和继承,就是课程给出的解题工具。
1. 先搞清楚第9讲在解决什么问题:不是语法问题,是代码组织问题
1.1 为什么 MIT 6.0001 要专门用一讲讲类和继承
MIT 6.0001 的课程设计非常讲究层次。前几讲把 Python 的语法、程序结构、计算复杂度讲完之后,学生已经能写几百行的程序。到了第9讲,课程要回答一个问题:当你的程序开始模拟现实世界里的对象时,比如学生、课程、图书、订单,你要怎么用代码描述“一个学生有哪些属性、能干什么事”?
这个问题如果只用字典和函数也能解决。比如:
student = { "name": "Alice", "id": "10001", "grades": [] } def add_grade(student, grade): student["grades"].append(grade)看起来没毛病。但程序一旦复杂起来,问题就出来了:
- 所有操作都要传
student这个字典进去。 - 如果某段代码写错键名,比如
studnet["name"],运行时才报错,不好排查。 - 多个学生对象之间没有类型区分,字典结构靠写代码的人自我约束。
- 想扩展
GraduateStudent、UndergraduateStudent,只能复制粘贴。
类做的事情,就是把“一个学生有什么数据”和“一个学生能执行什么操作”放在同一个结构里。这样代码里就不再是一堆散装的字典和函数,而是一个完整的“模板”。
这一讲真正训练的是抽象能力:你从需求里识别出对象、属性、方法,再通过继承和重用来扩展系统。这比背“class 后面跟冒号”重要得多。
1.2 第9讲的知识点框架
我把这一讲涉及的核心概念整理成了一张表,方便回顾:
| 核心概念 | 作用 | 典型落地场景 |
|---|---|---|
| 类 | 定义一类对象的模板 | 定义Student、Book、Order等 |
| 对象/实例 | 根据类创建出的具体个体 | s = Student("Alice") |
| 属性 | 对象内部保存的数据 | s.name、s.grades |
| 方法 | 对象能够执行的操作 | s.add_grade(90) |
__init__ | 创建对象时的初始化逻辑 | 给初始数据赋值 |
self | 指向当前实例的引用 | 在方法内部访问实例属性 |
| 继承 | 子类复用父类代码 | GraduateStudent(Student) |
| 方法重写 | 子类修改父类的方法实现 | 子类自定义calculate_grade() |
super() | 调用父类的方法 | 初始化父类属性 |
| 多态 | 同一类型调用,不同行为 | 统一调用.speak() |
| 抽象类 | 只定义接口,不直接实例化 | 必须实现指定方法 |
这张表就是这一讲的学习路线图。学完回到这张表,逐个确认自己是否真的理解。
1.3 给不同读者的学习顺序建议
如果你基础偏脚本型,建议先不要碰抽象类,也不要想怎么设计三层继承树。先把“定义一个类、创建一个对象、调用对象方法”这一套跑熟,再进入继承。
如果你已经能熟练写类,建议把重点放在方法解析顺序、重写边界、组合和多继承的取舍上。你真正要解决的问题不是“怎么用语法”,而是“这个继承关系是不是必要的”。
如果你是为了面试或考试,一定要把下面的点全部掌握:__init__、self、实例变量和类变量、方法重写、super()、两类方法的区别、多继承的 MRO 判断。
2. 从零写一个类:init、self、属性的完整拆解
2.1 最简代码:用一个 Product 类起步
我用一个产品类来做例子。这个例子接近第9讲会安排的练习风格,也贴近实际业务:
class Product: def __init__(self, name, price): self.name = name self.price = price def discount_price(self, discount_rate): return self.price * discount_rate实例化:
p1 = Product("机械键盘", 299) print(p1.name) # 机械键盘 print(p1.discount_price(0.8)) # 239.2这里有两个初学者最容易困惑的点。
第一个是__init__。它不是传统意义上的构造函数,准确说是对象的初始化方法。Python 先创建对象,然后执行__init__,把数据绑定到这个对象上。它做的事情是“初始化”,不是“分配内存”。
第二个是self。self指代当前这个实例本身。当你调用p1.discount_price(0.8)时,Python 真正执行的是Product.discount_price(p1, 0.8),所以self接收到的是实例p1。
很多第一次写类的人会问:能不能不写self?不能。如果方法定义里少了第一个参数,调用时会直接报参数数量不匹配的TypeError。
2.2 实例变量和类变量的区别
类里可以定义两种变量,一种是实例变量,一种是类变量。
class Product: category = "数码" # 类变量 def __init__(self, name, price): self.name = name # 实例变量 self.price = pricecategory属于类本身,所有实例共享;name和price属于每个实例,各自独立。
写代码时怎么判断用哪种?
- 如果所有产品都默认属于同一个分类,而且这个分类基本不会因为某个实例改变,那么用类变量。
- 如果每个产品的名字、价格、库存都不同,就必须用实例变量。
- 如果担心类变量被某个实例误改,建议在赋值时使用
Product.category而不是p1.category,避免在实例上创建同名的属性,遮盖类变量。
输出对比:
p1 = Product("键盘", 299) p2 = Product("显示器", 1299) print(p1.category) # 数码 print(Product.category) # 数码2.3 为什么要写 self:从方法调用机制讲起
初学者最崩溃的地方就在这里。定义方法时系统要求第一个参数是self,调用时又不需要显式传self,看起来像套路。
实际上这是 Python 把“普通函数”绑定到对象上的机制。比如:
p1.discount_price(0.8)等价于:
Product.discount_price(p1, 0.8)如果不写self:
class Product: def discount_price(discount_rate): # 错误写法 return 0.8调用时就会报错:
TypeError: discount_price() takes 1 positional argument but 2 were given报错原因就是实例p1自动被当作第一个参数传了进去,但方法只声明了一个参数。
所以看到这种报错,第一反应不是去检查调用点,而是去检查方法签名。多半是少写了self,或者参数个数和小括号里的声明不一致。
2.4 初学阶段最容易踩的三个坑
我总结几个自己踩过、也看到别人反复踩的坑。
第一,不要用可变对象做默认参数。有人想当然写成:
class Product: def __init__(self, tags=[]): self.tags = tags这会导致所有实例共享同一个列表对象。正确写法是:
class Product: def __init__(self, tags=None): self.tags = tags if tags is not None else []第二,__init__里属性名拼错。这个错误特别隐蔽,因为在__init__中定义属性时,你写了self.name = name,后面方法里可能写成self.nmae,等到真正访问时才报AttributeError。
第三,忘记初始化父类属性。这会在进入继承后频繁出现,下面讲。
3. 继承:先复用,再扩展,但别让继承链失控
3.1 最简单的继承写法
继承的意义是让子类直接获得父类已有的属性和方法,然后专心处扩展自己独有的部分。
接着用 Product 做例子:
class ElectronicProduct(Product): def __init__(self, name, price, warranty_months): super().__init__(name, price) self.warranty_months = warranty_months def extra_guarantee(self): return self.price * 0.1创建子类实例:
ep = ElectronicProduct("手机", 3999, 12) print(ep.name) # 手机,属性来自父类初始化 print(ep.discount_price(0.9)) # 3599.1,方法来自父类 print(ep.extra_guarantee()) # 399.9,子类自己扩展的方法这里super().__init__(name, price)的作用是执行父类Product的初始化逻辑,把self.name和self.price设置好。
如果漏掉这一行,子类实例里就没有name和price属性。你调用ep.name时,会得到AttributeError。
这也是继承学习里的一个重要判断点:子类的__init__要不要调用super().__init__?
如果子类只是新增方法,不需要改变父类的初始化逻辑,甚至可以完全不写__init__。Python 会自动去父类找初始化方法。
如果子类在父类基础上增加了属性,那么一定要在子类__init__里调用super().__init__(父类需要的参数),先把父类负责的属性初始化完,再接着初始化自己的属性。
3.2 方法重写:同名方法覆盖父类逻辑
继承不是只能新增方法,也可以重写父类已有的方法。比如不同产品的折扣逻辑不一样:
class DiscountProduct(Product): def discount_price(self, discount_rate): if discount_rate > 0.5: discount_rate = 0.5 return self.price * discount_rate子类里的discount_price会覆盖父类的实现。调用方不用知道自己拿到的是普通产品还是折扣产品,统一调用.discount_price()即可。
重写时有两点要注意。
一是在重写方法里,如果只想去掉父类逻辑的某一部分,或者想在父类结果基础上做补充,可以主动调用super().method(...)。
二是重写时尽量保持方法名、参数个数和调用语义一致。否则调用方虽能正常使用,但整个类体系会变得不可预测:同一个方法名,有的子类要传一个参数,有的要传两个参数,代码写起来会很痛苦。
3.3 多继承与菱形继承
Python 支持一个子类继承多个父类:
class A: def work(self): print("A work") class B: def work(self): print("B work") class C(A, B): pass c = C() c.work()这里C(A, B)里A在前,所以c.work()会先找A的方法。
如果父类之间出现菱形结构:
class Animal: def eat(self): print("Animal eat") class Dog(Animal): pass class Cat(Animal): pass class RobotDog(Dog, Cat): passRobotDog同时继承Dog和Cat,而Dog和Cat又都继承Animal。这就是菱形继承。
Python 通过 C3 线性化算法生成方法解析顺序,也就是 MRO。遇到这种问题,直接查看:
print(RobotDog.__mro__)输出会给出方法搜索的顺序。我的经验是:多继承可以理解原理,但实际项目里尽量少用。大多数时候,用组合来替代多继承会更安全。组合的意思是,把一个类作为另一个类的属性,而不是让一个类同时继承多个类。
3.4 继承设计里的一个常见练习方向
MIT 6.0001 的课堂练习经常用动物或游戏角色来演示继承。如果你曾经看过“人狗大作战”这类 Python 代码,其实思路是一样的:先定义Animal基类,定义name、hp、attack属性和attack()、rest()、is_alive()方法,然后让Dog、Cat继承并重写攻击行为。
这个练习很适合用来练继承,因为它能让你看到“父类承担通用逻辑,子类承担差异化实现”的分工。不是所有类都要设计成复杂的继承树,但通过这样一个小项目,你能理解:基类里写清楚公共属性和公共方法,子类里只写差异,这样代码的重复量会明显下降。
4. 抽象类、封装、多态:从能用类到把类设计好
4.1 抽象类和普通类的区别
这是搜索热词里出现频率很高的问题。抽象类和普通父类的区别,可以从两个角度理解。
第一,抽象类不能直接实例化。你定义了一个Animal(ABC),不能直接写Animal(),必须有一个具体的子类实现抽象方法后才能实例化。
第二,抽象类的价值在于约束。它规定子类必须实现哪些方法,避免不同子类各自为政,漏掉关键行为。
用代码演示:
from abc import ABC, abstractmethod class Animal(ABC): def __init__(self, name): self.name = name @abstractmethod def speak(self): pass class Dog(Animal): def speak(self): return "汪汪" class Cat(Animal): def speak(self): return "喵喵"如果某个子类没实现speak(),实例化时就会报错:
TypeError: Can't instantiate abstract class Cat with abstract method speak这个约束在多人协作或项目模块化时很有用。它能保证每个子类都按接口规范实现方法,不会出现“这个类漏写了某个关键行为”的情况。
4.2 什么时候用抽象类,什么时候用普通父类
这里给出一个简单判断标准:
- 如果你有一个公共方法,所有子类都能直接复用,而且你希望子类直接继承这个实现,用普通父类。
- 如果你要对子类的方法集合做强制约束,要求每个子类必须实现某个方法,用抽象类。
- 如果一个类暂时不需要实例化,只用来作为其他类的基础模板,考虑抽象类。
用表格总结:
| 场景 | 推荐做法 | 优点 |
|---|---|---|
| 多个子类共享相同属性和逻辑 | 普通父类 | 直接复用,减少重复代码 |
| 多个子类行为差异大,但都必须提供统一接口 | 抽象类 | 强制子类实现指定方法 |
| 子类逻辑完全不一致,父类没有可复用逻辑 | 接口协议或普通类,也可考虑不写父类 | 避免没有意义的继承 |
4.3 封装:Python 的下划线约定
很多人听“封装”会觉得是私有属性、不让外部访问。Python 里其实没有严格意义上的 private 关键字,靠的是约定。
单下划线_name表示“内部使用,外部请勿直接访问”。这更多是一种团队规范,不阻止你访问。
双下划线__name会触发名称改写,变成_ClassName__name,相当于加了点阻力,但也不是绝对私有。
我的建议是:日常写代码不要过度依赖双下划线做“安全保护”。封装的核心不是不让别人改属性,而是让对象对外提供稳定的接口,内部细节可以自由变化。比如用property给属性加校验:
class Product: def __init__(self, name, price): self._name = name self._price = price @property def price(self): return self._price @price.setter def price(self, value): if value < 0: raise ValueError("price cannot be negative") self._price = value这样外部可以继续写product.price = 100,但不会再出现负数价格这种奇怪数据。
4.4 多态:让调用方不需要关心具体类型
多态的概念很简单:同一个方法名,不同的对象执行不同行为。
比如一个系统需要让所有动物“叫”,你不需要在函数里写一堆if isinstance(animal, Dog)这样的判断,而是直接调用animal.speak()。具体怎么叫,由对象的实际类型决定。
animals = [Dog("旺财"), Cat("咪咪")] for animal in animals: print(animal.speak())这就是多态的好处:调用方只依赖统一接口,不关心内部实现。这也是为什么继承和重写要一起用。
5. 类相关代码的调试经验:报错和排查顺序
5.1 最常见的报错类型
写类和继承时,最常见的报错集中在下面几类:
| 报错类型 | 常见原因 | 排查顺序 |
|---|---|---|
TypeError: ... takes N positional arguments but M were given | 方法签名少了self,或调用参数个数不匹配 | 先看方法定义,再看调用点 |
AttributeError: 'Product' object has no attribute 'xxx' | 属性拼写错误,或__init__没执行 | 打印对象__dict__,比对属性名 |
NameError: name 'XXX' is not defined | 类名拼错、导错模块、作用域问题 | 查导入语句和拼写 |
TypeError: Can't instantiate abstract class ... | 子类没有实现全部抽象方法 | 逐个子类检查抽象方法列表 |
| 逻辑错误,输出不符合预期 | 继承链中方法被意外覆盖 | 打印 MRO 和对象类型 |
5.2 一套可复用的排查链路
我遇到类相关问题,一般按下面的顺序排查:
第一步,先看报错类型和报错位置。不要急着改代码。
第二步,在调用点打印关键信息:
print(type(obj)) print(obj.__dict__)obj.__dict__会直接显示这个实例当前保存了哪些属性。如果__dict__里没有你期望的属性,说明初始化逻辑有问题。
第三步,确认继承关系和方法实际归属。可以用:
print(ClassName.__mro__)这个方法会输出类查找方法的顺序。如果发现有某个父类的方法意外覆盖了子类的方法,MRO 一眼就能看出来。
第四步,排查环境因素。有时候不是你的类写错了,是 Python 版本不同、依赖版本不一致,或者当前文件里存在同名类。
第五步,把最小复现代码隔离到单独文件里。很多问题在项目里看不出来,单独跑一个 20 行的脚本,现象会清晰很多。
5.3 几个经验性提醒
第一,不要在报错以后直接去搜“某某报错怎么解决”。先确认是环境问题还是代码问题,再决定要不要搜。大部分类相关报错,问题的根源都在方法签名、属性名、构造函数调用顺序上。
第二,多继承报错时,不要硬调__init__的调用顺序。先把继承链简化成组合,或者只继承一个父类,把另一个父类作为对象属性。
第三,super()不是必须出现在每个方法里。它确实能简化父类调用,但如果子类根本没有扩展父类逻辑,就不需要写。
6. 类设计自查清单:什么时候该用类,什么时候别硬写
6.1 要不要用类?先问自己三个问题
看到需求时别急着定义类,先判断当前场景是否真的需要。
第一个问题:代码里是否反复出现“同一组数据 + 同一组操作”的组合?比如你经常拿着name、price、stock三样数据,又反复执行折扣、扣库存、打印价格。这种组合适合封装成类。
第二个问题:是否需要多个实例保持各自独立状态?如果只有一个对象,而且状态简单,用字典和函数可能更方便。
第三个问题:有没有扩展和复用需求?多个类型行为相似但实现有差异,比如普通商品和电子商品,适合用继承。
如果三个问题答案都是“否”,建议用函数或字典,不要为了用类而用类。
6.2 类设计自查清单
每次写完类,我建议按这个清单过一遍:
- 类名是否足够清晰,别人能看懂它代表什么。
__init__是不是只做必要的初始化,没有把复杂业务逻辑塞进去。- 方法是不是都操作了这个类自己的数据。
- 有没有无意义的继承。如果一个子类没有新增属性、没有重写方法、没有扩展逻辑,那它可能没有必要存在。
- 可变对象默认值是否已经处理成
None加判断。 - 对象打印出来是否可读。如果
print(obj)出来是<__main__.Product object at 0x...>,可以考虑加一个__str__方法。 - 属性访问是否合理。有没有外部需要严格保护的字段,是否应该用
property校验。 - 如果涉及抽象约束,子类有没有实现全部抽象方法。
6.3 推荐练习:从一个人物类到一个回合制小模拟
如果你学完这一讲之后不知道练什么,我提供一个简单但完整的方向:
- 先定义
Character类,属性包括名字、生命值、攻击力。 - 实现
attack(target)、rest()、take_damage(amount)、is_alive()方法。 - 再定义一个
Monster子类,重写attack()方法,让攻击行为有差异。 - 再定义一个抽象基类,把角色类必须实现的方法抽象出来。
- 最后写一个主循环,让两个角色按回合攻击,输出每一轮的血量变化。
这个练习能把类定义、实例化、继承、重写、抽象基类、多态和程序结构全部串起来。跑通之后,你对第9讲的理解会明显不一样。
最后说一句个人判断
MIT 6.0001 第9讲给我最大的启发,不是背下了class、__init__、super()这几个关键词,而是终于想清楚一个问题:代码不只是写给人看、让计算机执行的,更是用来模拟真实世界结构的一种工具。数据和操作绑定在一起,子类在父类基础上扩展,调用方只依赖统一接口,这些设计思路在算法题、脚本、后端项目里都能用得上。
真正落地的时候,我更建议先把单个类的写法跑稳,再尝试继承,最后再谈抽象和多继承。不要一上来就把类设计成多层结构,那只会增加调试成本。类用得好,代码是容易读、容易改的;类用得乱,比不用类还要痛苦。学习阶段,花点时间在小练习上,比看十篇博客有效。