这次我们直接进入 MIT 6.0001 计算机科学导论的第 9 讲:Python 的类与继承。这一讲是整门课程从“写脚本”转向“写系统”的分水岭。前面几讲教你怎么用变量、循环、函数组织逻辑,到这一讲开始讨论如何把数据和操作数据的方法绑在一起,用类去表达真实世界中的对象关系。如果你前几讲已经能写出函数式风格的程序,但面对“一个对象有自己的状态和行为”这种建模需求时不知道怎么落地,这一讲就是你要补的那块拼图。
这篇文章我会按 MIT 6.0001 的教学节奏,把类定义、实例化、继承、方法覆盖、多重继承、特殊方法这些核心知识点拆开讲,每一块都给可运行的示例代码,再补充实际工程里高频踩坑的排错方向。无论你是准备跟完这门课,还是已经在用 Python 写项目想回头补面向对象基础,都可以直接照这篇文章跑一遍。先看一下这一讲覆盖的知识点全貌,再逐个击破。
1. 核心知识速览
| 知识点 | 核心作用 | Python 关键字 / 语法 | 常见误区 |
|---|---|---|---|
| 类与对象 | 把数据和操作数据的方法封装在一起 | class、__init__、self | 忘记self参数 |
| 属性与方法 | 描述对象状态和行为 | self.xxx = xxx、实例方法 | 实例变量和类变量混淆 |
| 继承 | 子类复用父类代码并扩展 | class A(B): | 继承层级过深 |
| 方法覆盖 | 子类重新实现父类方法 | 同名方法重定义 | 忘记调用super() |
super() | 调用父类方法,协作式多重继承 | super().__init__() | 多重继承时只追求语法通过 |
| MRO | 方法解析顺序 | ClassName.__mro__ | 不理解菱形继承 |
| 特殊方法 | 让对象支持len()、str()、==等内置行为 | __str__、__len__、__eq__ | 不知道这些方法是由解释器调用的 |
从表格能看到,类和继承本身的知识密度并不低,但掌握它们不需要背概念,核心是理解 Python 在创建对象、查找方法时到底做了什么。下面从最基本的“为什么需要类”开始。
2. 类和对象的基本概念
在 MIT 6.0001 前面的课程里,程序基本由函数和数据组成。你定义一个列表、写几个函数处理它,数据和函数是分开的。这种方式在程序规模小的时候没问题,但一旦一个数据结构的形态变复杂,比如要同时保存一个动物的名字、位置、饥饿程度、移动方式,再用“全局变量 + 一组函数”去维护,函数之间就会到处传递参数,代码很快就会失控。
类提供了一种更自然的组织方式:把数据和操作数据的方法放在同一个结构里。比如描述平面上一个点,直接定义一个Coordinate类,让每个坐标对象自带x和y属性,再提供一个计算距离的方法。这样调用方不需要关心坐标存储的细节,只需要调用对象的方法。
用课程里的经典表达方式来说,类是模板,对象是根据模板创建的实例。类里写好的属性是所有实例共有的定义,但每个实例的属性值可以完全不同。理解这一点,后面的继承和特殊方法才能顺利展开。
class Coordinate: def __init__(self, x, y): self.x = x self.y = y def distance(self, other): dx = self.x - other.x dy = self.y - other.y return (dx ** 2 + dy ** 2) ** 0.5 point_a = Coordinate(3, 4) point_b = Coordinate(0, 0) print(point_a.distance(point_b)) # 5.0这段代码里,__init__是初始化方法,在实例创建时自动执行。self代表当前实例,所有实例方法的第一个参数都是它。调用point_a.distance(point_b)时,Python 会自动把point_a作为self传入,把point_b作为other传入。
3. Python 类的定义与实例化
3.1__init__方法
__init__是 Python 类里最常用的特殊方法。它不能有返回值,作用是在对象创建后完成初始化。这里要注意:Java 或 C++ 中的构造器负责分配内存和初始化,而 Python 中真正分配内存的是__new__,__init__只负责设置初始状态,所以它本质上是“初始化器”。
多数时候你只需要写__init__,不用动__new__。课程里考察的重点是:__init__的参数能不能覆盖所有必要的初始属性。如果某些属性在对象生命周期内可变,也应该在__init__中显式初始化,避免不同实例之间共享同一个可变默认值。
class Animal: def __init__(self, name): self.name = name self.hunger = 50 def eat(self, food_amount): self.hunger = max(0, self.hunger - food_amount)3.2 实例属性与类属性
写在__init__里、通过self.xxx = xxx赋值的是实例属性,每个对象各有一份。直接写在class代码块顶层的是类属性,所有实例共享一份。这是一个非常容易出错的细节。
很多初学 Python 的人会在类顶层写一个空列表,然后在某个实例里修改它,结果所有实例的数据都变了。原因是类属性被所有实例共享,在类外部使用instance.attr = value只是给该实例新建了一个同名属性,不会影响类属性。
class Student: courses = [] # 类属性,所有实例共享 def __init__(self, name): self.name = name # 实例属性,每个实例独立 s1 = Student("Alice") s2 = Student("Bob") s1.courses.append("Python") print(s2.courses) # ['Python'],因为共享同一个列表如果你希望每个学生都有自己的课程列表,就应该在__init__里写self.courses = []。这是 MIT 6.0001 编程作业里一个高频坑点。
3.3 实例方法
实例方法是类中默认的方法类型。它的第一个参数是self,只能通过实例调用。实例方法可以读取和修改实例属性,也可以调用其他实例方法。课程中用的例子通常是“模拟动物移动”“模拟账户取款”,核心都是通过方法操作对象内部状态。
class BankAccount: def __init__(self, owner, balance=0): self.owner = owner self.balance = balance def deposit(self, amount): if amount > 0: self.balance += amount def withdraw(self, amount): if 0 < amount <= self.balance: self.balance -= amount return amount return 0 account = BankAccount("Alice", 1000) account.deposit(500) print(account.balance) # 1500实例方法的设计原则是“高内聚”。一个方法只负责一种职责,这样继承时,子类只需要覆盖特定行为,不需要复制粘贴整套逻辑。
4. 继承:让子类复用父类的能力
4.1 继承语法
继承是第 9 讲的核心,也是最容易体现面向对象设计能力的地方。语法非常直接:class 子类名(父类名):。子类会自动拥有父类的属性和方法,同时可以新增属性和方法,也可以覆盖父类已有方法。
MIT 6.0001 课程常用“动物体系”讲继承。先有一个Animal父类定义共性,再派生Dog、Cat等子类表现差异。这样做的好处是:公共逻辑只写一次,每个子类只需要保留自己的独特部分。
class Animal: def __init__(self, name): self.name = name def speak(self): raise NotImplementedError("子类必须实现 speak 方法") class Dog(Animal): def speak(self): return "Woof!" class Cat(Animal): def speak(self): return "Meow" animals = [Dog("Rex"), Cat("Luna")] for animal in animals: print(animal.name, animal.speak())这里父类speak方法刻意抛异常,目的是用“抽象接口”约束子类实现。这是一种很常见的父类设计方式,虽然没有强制语法,但能让你在开发时尽早发现漏实现的方法。
4.2 方法覆盖 override
子类里定义和父类同名的方法就是方法覆盖。调用子类实例的方法时,Python 会优先查找子类的方法,找不到再沿着继承链向上查找。
需要注意:覆盖方法时,参数签名最好保持一致。虽然 Python 不像 Java 那样强制要求,但如果调用方以相同方式调用,参数不一致很容易埋下运行时错误。
class Animal: def __init__(self, name): self.name = name self.age = 0 def birthday(self): self.age += 1 class Dog(Animal): def __init__(self, name, breed): super().__init__(name) self.breed = breed def birthday(self): super().birthday() print(f"{self.name} is now {self.age} years old")4.3super()的作用
super()是第 9 讲里必须掌握的方法。它返回一个代理对象,用于调用父类的方法。最常见的用法是在子类的__init__中调用父类的__init__,这样父类中定义的属性会自动初始化,子类不用重复写一遍。
如果不调用super().__init__(),父类__init__里的代码不会自动执行,子类就会缺少父类初始化过的属性,后续访问时直接AttributeError。这是一个很典型的初学问题。
class Dog(Animal): def __init__(self, name, breed): super().__init__(name) self.breed = breed d = Dog("Rex", "Labrador") print(d.name, d.breed) # Rex Labradorsuper()不只是简单指向父类,在多重继承时它的行为更复杂,下一节专门展开。
4.4isinstance与issubclass
课程里还会讲到两个内置函数:
isinstance(obj, ClassName):判断对象是不是某个类或其子类的实例。issubclass(ClassA, ClassB):判断一个类是不是另一个类的子类。
这两个函数在做类型检查、断言时非常有用。但实际开发中要避免滥用,能用多态解决的场景就不要到处写类型判断。
print(isinstance(d, Dog)) # True print(isinstance(d, Animal)) # True,子类实例也是父类实例 print(issubclass(Dog, Animal)) # True5. 多重继承与 MRO
5.1 为什么会遇到多重继承
Python 和 Java 不同,一个类可以同时继承多个父类,语法是class C(A, B):。这个特性在混入类、工具类组合的场景中很有用,但也会带来方法查找顺序的问题。
如果只是两个毫无关系的父类,多重继承不会造成困惑。真正的难点出现在菱形继承里:D同时继承B和C,而B和C又都继承A。此时调用一个在A中定义的方法,D的实例最终应该调用哪个版本?
5.2 MRO 规则
Python 采用 C3 线性化算法计算 MRO,也就是方法解析顺序。你可以用ClassName.__mro__查看具体的解析链。
class A: def who(self): print("A") class B(A): def who(self): print("B") super().who() class C(A): def who(self): print("C") super().who() class D(B, C): pass d = D() d.who() # 输出: # B # C # A很多人好奇为什么D.who()会依次调用B -> C -> A。原因就是 MRO 把每个父类都放进了一条线性链,super()在多重继承中不是“只找父类”,而是沿着 MRO 找下一个符合条件的类。
5.3 使用建议
多重继承学习阶段能跑通示例即可,生产项目里优先保持继承链简单。如果发现要用很复杂的多重继承才能复用代码,更稳的方案是改用组合:把一个工具类实例作为属性传入,而不是硬塞进继承体系。MRO 能解释运行结果,但设计上能避免则避免。
6. Python 特殊方法与运算符重载
6.1__str__与__repr__
MIT 6.0001 第 9 讲会特别强调一个体验问题:直接打印一个对象,输出的是<__main__.Coordinate object at 0x...>,根本看不出内容。想让对象在打印和调试时可读,就需要实现__str__或__repr__。
__str__由str(obj)或print(obj)调用,面向最终用户,要求可读性好。__repr__由repr(obj)调用,面向开发者,最好是能重建对象的表达式。
class Coordinate: def __init__(self, x, y): self.x = x self.y = y def __repr__(self): return f"Coordinate({self.x}, {self.y})" def __str__(self): return f"({self.x}, {self.y})" point = Coordinate(3, 4) print(point) # (3, 4) print(repr(point)) # Coordinate(3, 4)没有__repr__时,调试信息里只有内存地址,排查问题效率很低。这个习惯越早养成越好。
6.2__eq__与__hash__
两个对象用==比较时,默认比较的是内存地址,也就是说即使内容完全相同,两个实例也不相等。要让对象支持按内容比较,需要实现__eq__。
class Coordinate: def __init__(self, x, y): self.x = x self.y = y def __eq__(self, other): if not isinstance(other, Coordinate): return NotImplemented return self.x == other.x and self.y == other.y def __hash__(self): return hash((self.x, self.y)) p1 = Coordinate(1, 2) p2 = Coordinate(1, 2) print(p1 == p2) # True一个容易忽略的约束是:实现了__eq__后,__hash__往往也要同步处理。把对象放进集合或作为字典键时,Python 依赖哈希值;如果两个相等的对象哈希不同,就会出现非常难排查的 bug。
6.3 迭代与长度协议
想让自定义对象支持len(obj)、obj[i]、for x in obj这些操作,分别要实现__len__、__getitem__或__iter__。这是让自定义类表现得更像内置类型的关键。
class Playlist: def __init__(self, songs): self._songs = songs def __len__(self): return len(self._songs) def __getitem__(self, index): return self._songs[index] playlist = Playlist(["A", "B", "C"]) print(len(playlist)) # 3 print(playlist[1]) # B for song in playlist: print(song)如果实现了__getitem__且能正确处理整数索引,Python 会自动用递增索引调用它来实现迭代,所以连__iter__都可以先不写。当然,显式实现__iter__在性能和语义上更清晰。
6.4 运算符重载
Python 通过__add__、__sub__、__mul__、__lt__、__gt__等特殊方法支持运算符重载。课程里的经典应用是定义一个向量类,让两个向量可以直接相加,而不是调用方法。
class Vector: def __init__(self, x, y): self.x = x self.y = y def __add__(self, other): return Vector(self.x + other.x, self.y + other.y) def __repr__(self): return f"Vector({self.x}, {self.y})" v1 = Vector(1, 2) v2 = Vector(3, 4) print(v1 + v2) # Vector(4, 6)合理重载运算符可以让代码更简洁,但不要为了“炫技”把所有运算符都写一遍。只有在语义明确、调用者不会误解的情况下才值得重载。
7. 类方法、静态方法与实例方法的区别
7.1 三种方法的定义
在类里面定义方法时,默认使用self作为第一个参数,这是实例方法。除了实例方法,Python 还提供了@classmethod修饰的类方法和@staticmethod修饰的静态方法。
- 实例方法:操作实例属性,需要实例才能调用。
- 类方法:第一个参数是
cls,操作类属性或提供备选构造器,不依赖具体实例。 - 静态方法:参数与类、实例都无关,只是放在类命名空间里的普通函数。
class Student: major = "Computer Science" def __init__(self, name): self.name = name def introduce(self): return f"My name is {self.name}, major in {self.major}" @classmethod def change_major(cls, new_major): cls.major = new_major @staticmethod def is_valid_name(name): return bool(name) and len(name) > 17.2 类方法在工程中的用途
类方法最常见的用途是提供多个构造器。比如Date.from_string("2024-05-20")就能创建一个日期对象,用类方法把字符串解析逻辑封装进类内部,调用方不用关心解析细节。
class Date: def __init__(self, year, month, day): self.year = year self.month = month self.day = day @classmethod def from_string(cls, text): year, month, day = map(int, text.split("-")) return cls(year, month, day) def __repr__(self): return f"Date({self.year}, {self.month}, {self.day})" d = Date.from_string("2024-05-20") print(d) # Date(2024, 5, 20)7.3 三者的选择原则
课程阶段可以简单记:默认用实例方法;需要操作类状态或提供备选构造器用类方法;方法逻辑与类和实例都没有绑定关系时用静态方法。不要把所有方法都定义成静态方法,否则类就退化成了一组普通函数。
8. 学习类与继承时的常见误区
| 误区 | 后果 | 正确做法 |
|---|---|---|
方法定义忘记self参数 | 调用时报参数数量错误 | 实例方法第一个参数固定写self |
子类__init__不调用super().__init__() | 父类属性未初始化,访问报AttributeError | 在子类__init__第一行调用super().__init__() |
| 在类顶层定义可变对象并实例间共享 | 一个实例修改导致所有实例受影响 | 在__init__里用self.xxx = []初始化 |
用==比较两个对象却发现总是 False | 默认比较的是内存地址 | 实现__eq__和__hash__ |
| 继承层级过深,改父类方法影响所有子类 | 代码维护成本陡增 | 优先用组合代替深度继承 |
| 多重继承中忽略 MRO | 方法调用结果和预期完全不一致 | 用ClassName.__mro__查看解析顺序 |
这六类问题基本覆盖了初学者到进阶阶段的主要报错点。遇到问题时,优先看报错堆栈,再对照排查表定位,比盲目改代码更有效率。
9. 从 MIT 6.0001 到实际工程的扩展方向
第 9 讲的内容在课程体系里只是一个起点,但它的价值不在语法本身,而在于让你建立面向对象的建模思维。真正进入工程后,还会遇到几个紧密相关的主题,建议按顺序掌握。
第一个是抽象基类,Python 标准库的abc模块提ABC和abstractmethod,可以显式声明哪些方法是子类必须实现的。把这个和本讲里“父类方法抛NotImplementedError”的做法相对比,你会体会到从约定到强制约束的演进。
第二个是property装饰器。很多 Python 程序员已经习惯直接用self.attribute访问属性,但有时我们需要在读取或赋值时执行校验逻辑。property可以让你在保持访问方式不变的情况下加入控制逻辑,这是面向对象封装原则在实际项目中最常见的落地方式。
第三个是dataclass。标准库中的@dataclass能够自动生成__init__、__repr__、__eq__等方法,非常适合用来保存数据的类。虽然它简化了大量模板代码,但理解它生成的方法,仍然要以本讲的特殊方法知识为基础。
第四个是设计模式。比如工厂模式、策略模式、观察者模式,它们大多数建立在类和继承之上。学完类和继承后,用设计模式去重构一个之前写过的程序,会明显感受到代码结构的变化。
10. 总结与下一步
MIT 6.0001 第 9 讲把 Python 面向对象的核心机制都过了一遍:类定义、实例化、实例属性与类属性、方法、继承、方法覆盖、super()、多重继承与 MRO、特殊方法、类方法与静态方法。这些知识点单独看都不难,难的是组合在一起解决问题。建议你现在就打开终端,把文中所有示例按顺序跑一遍,特别是super()多重继承那段,手动打印D.__mro__,理解为什么输出顺序是B -> C -> A。
下一步可以给自己布置一个练习:定义一个Shape父类,派生Circle和Rectangle子类,给它们实现计算面积的方法,并实现__str__和__eq__。这个练习能覆盖本讲 80% 的核心内容,跑完再进入下一讲,你会觉得后面的内容轻松很多。