尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

Python类和继承核心解析:从对象封装到抽象类与多态

Python类和继承核心解析:从对象封装到抽象类与多态 这段时间我把 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)如果不写selfclass 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, tagsNone): 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: Cant 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: Cant 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()这几个关键词而是终于想清楚一个问题代码不只是写给人看、让计算机执行的更是用来模拟真实世界结构的一种工具。数据和操作绑定在一起子类在父类基础上扩展调用方只依赖统一接口这些设计思路在算法题、脚本、后端项目里都能用得上。真正落地的时候我更建议先把单个类的写法跑稳再尝试继承最后再谈抽象和多继承。不要一上来就把类设计成多层结构那只会增加调试成本。类用得好代码是容易读、容易改的类用得乱比不用类还要痛苦。学习阶段花点时间在小练习上比看十篇博客有效。
返回列表