Python类和继承核心解析:从对象封装到抽象类与多态
2026/8/31 1:29:07 网站建设 项目流程

这段时间我把 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"],运行时才报错,不好排查。
  • 多个学生对象之间没有类型区分,字典结构靠写代码的人自我约束。
  • 想扩展GraduateStudentUndergraduateStudent,只能复制粘贴。

类做的事情,就是把“一个学生有什么数据”和“一个学生能执行什么操作”放在同一个结构里。这样代码里就不再是一堆散装的字典和函数,而是一个完整的“模板”。

这一讲真正训练的是抽象能力:你从需求里识别出对象、属性、方法,再通过继承和重用来扩展系统。这比背“class 后面跟冒号”重要得多。

1.2 第9讲的知识点框架

我把这一讲涉及的核心概念整理成了一张表,方便回顾:

核心概念作用典型落地场景
定义一类对象的模板定义StudentBookOrder
对象/实例根据类创建出的具体个体s = Student("Alice")
属性对象内部保存的数据s.names.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__,把数据绑定到这个对象上。它做的事情是“初始化”,不是“分配内存”。

第二个是selfself指代当前这个实例本身。当你调用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 = price

category属于类本身,所有实例共享;nameprice属于每个实例,各自独立。

写代码时怎么判断用哪种?

  • 如果所有产品都默认属于同一个分类,而且这个分类基本不会因为某个实例改变,那么用类变量。
  • 如果每个产品的名字、价格、库存都不同,就必须用实例变量。
  • 如果担心类变量被某个实例误改,建议在赋值时使用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.nameself.price设置好。

如果漏掉这一行,子类实例里就没有nameprice属性。你调用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): pass

RobotDog同时继承DogCat,而DogCat又都继承Animal。这就是菱形继承。

Python 通过 C3 线性化算法生成方法解析顺序,也就是 MRO。遇到这种问题,直接查看:

print(RobotDog.__mro__)

输出会给出方法搜索的顺序。我的经验是:多继承可以理解原理,但实际项目里尽量少用。大多数时候,用组合来替代多继承会更安全。组合的意思是,把一个类作为另一个类的属性,而不是让一个类同时继承多个类。

3.4 继承设计里的一个常见练习方向

MIT 6.0001 的课堂练习经常用动物或游戏角色来演示继承。如果你曾经看过“人狗大作战”这类 Python 代码,其实思路是一样的:先定义Animal基类,定义namehpattack属性和attack()rest()is_alive()方法,然后让DogCat继承并重写攻击行为。

这个练习很适合用来练继承,因为它能让你看到“父类承担通用逻辑,子类承担差异化实现”的分工。不是所有类都要设计成复杂的继承树,但通过这样一个小项目,你能理解:基类里写清楚公共属性和公共方法,子类里只写差异,这样代码的重复量会明显下降。

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 要不要用类?先问自己三个问题

看到需求时别急着定义类,先判断当前场景是否真的需要。

第一个问题:代码里是否反复出现“同一组数据 + 同一组操作”的组合?比如你经常拿着namepricestock三样数据,又反复执行折扣、扣库存、打印价格。这种组合适合封装成类。

第二个问题:是否需要多个实例保持各自独立状态?如果只有一个对象,而且状态简单,用字典和函数可能更方便。

第三个问题:有没有扩展和复用需求?多个类型行为相似但实现有差异,比如普通商品和电子商品,适合用继承。

如果三个问题答案都是“否”,建议用函数或字典,不要为了用类而用类。

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()这几个关键词,而是终于想清楚一个问题:代码不只是写给人看、让计算机执行的,更是用来模拟真实世界结构的一种工具。数据和操作绑定在一起,子类在父类基础上扩展,调用方只依赖统一接口,这些设计思路在算法题、脚本、后端项目里都能用得上。

真正落地的时候,我更建议先把单个类的写法跑稳,再尝试继承,最后再谈抽象和多继承。不要一上来就把类设计成多层结构,那只会增加调试成本。类用得好,代码是容易读、容易改的;类用得乱,比不用类还要痛苦。学习阶段,花点时间在小练习上,比看十篇博客有效。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询