资讯动态

Python继承与多态:用is-a关系做对类设计决策

发布时间:2026/9/9 6:51:31 来源:尧图企业网站定制
写完项目代码后我习惯把常用的类继承方式整理成一个速查表单一继承用 class A(B)多继承用 class A(B, C)抽象基类用 class A(ABC)Mixin 类名用 Mixin 后缀。这个表贴在手边面试和重构时都方便。如果你已经能熟练使用列表、字典、函数和模块觉得自己离 Python 高手只差一步然后端端正正翻开面向对象那一章大概率会在“继承”和“多态”这两个词上发呆三分钟。网上的教程不是没有而是太多了光“什么是继承”就能搜出八百种说法可大部分都停留在形状、动物、汽车这种例子上看完你会点头但一写自己的代码还是懵。这一章我用一个很直接的切入点来聊所有关于继承与多态的疑问最后都能落到 is-a 关系上。你只需要反复问一句话——“子类真的是父类的一种吗”是就用继承不是就别硬蹭继承。把这层关系想清楚了继承和多态基本就不会再用错。这段内容适合两种人一是正在学 Python 面向对象、看完基础语法但不知道怎么用的人二是写过一些类、但总觉得代码里继承关系别扭、改一处崩三处的初学者。我会用一段可运行的代码对比、几个真实业务场景和一堆我在项目里踩过的坑把这一章讲透。1. 这一章到底要解决什么问题1.1 面向对象三大特征不是并列关系先梳理一下封装、继承、多态是面向对象的三个核心特征但在我的经验里它们并不是三件并列的事。封装是基础它把数据和操作绑在一起继承是结构它设计出类与类之间的等级关系多态则是继承这个结构在运行期的表现。没有继承多态基本无从谈起没有多态继承就只有代码复用这个浅层意义。我们常听一句话“继承是为了代码复用。”这话对不对对但对得很容易误导人。很多初学者听到“复用”就以为只要能复用父类的方法就应该继承。于是写了个 UserService又写了个 AdminService 继承它理由是登录逻辑是一样的。这其实是典型的“为复用而继承”它忽视了最重要的语义校验管理员服务真的是一种用户服务吗在业务语义上可能成立但在设计上往往不是 is-a而是 has-a一个管理员服务包含了一个通用用户服务。1.2 is-a 关系的判断标准is-a 是什么英文原句是“A is a B”A 是一种 B。狗是一种动物微信支付是一种支付渠道圆形是一种形状。这种关系放到代码里意味着任何需要 B 类型对象的地方都能放一个 A 对象进去而且行为依然正确。这个规则的学名是里氏替换原则英文 Liskov Substitution Principle简称 LSP。里氏替换原则其实就给 is-a 关系下了明确的判断标准父类能做的子类也能做并且不破坏调用方的预期。如果子类重写父类方法时把输入要求提高了、把输出结果变窄了、甚至抛出了一个父类从没抛过的异常那这个子类虽然语法上继承了父类语义上已经背叛了父类is-a 关系也就碎了。我给新手测试 is-a 关系时习惯用一句大白话“子类对象敢不敢直接塞给父类用”敢就是 is-a不敢就得考虑组合。1.3 典型判断失误为复用代码而继承再展开一个非常容易犯的错。比如有个人写了五个类里面都有 get_connection 方法为了省事他把这个方法抽到一个 BaseModel 里然后让其他类都继承 BaseModel。从语法上看没毛病方法也复用了。可问题是用户类、订单类、日志类它们都是统一的“BaseModel”吗如果这几个类除了一个公共方法之外毫无共同点那这种继承就是硬凑关系哪天 BaseModel 里改了个私有字段所有后代一起遭殃。组合的好处是你把一个方法或组件放到自己的类里通过 self.xxx 调用关系是显式的改动的影响范围也是可控的。所以我在项目里有一条不成文的规矩能组合就不继承必须继承就先证明 is-a 成立。对比维度继承组合语义关系is-a是一种has-a有一个代码耦合强耦合子类依赖父类内部实现弱耦合通过接口调用组件改动影响父类改动可能影响所有子类组件改动一般只影响调用方推荐场景真正的类型层级、策略模式、接口抽象功能复用、依赖注入、可替换组件典型反例为了省几个重复方法强行继承把本应是父子类型的对象硬塞进组件2. Python 中的继承实操从单继承到多继承2.1 单继承的基础写法与 super() 的作用先看一段最常见的单继承代码。假设我们有一个 Person 基类然后让 Student 继承它。class Person: def __init__(self, name, age): self.name name self.age age def intro(self): return f我是{self.name}今年{self.age}岁 class Student(Person): def __init__(self, name, age, school): super().__init__(name, age) self.school school def intro(self): base super().intro() return f{base}我在{school}上学这段代码里有三个关键点。第一class Student(Person) 的括号就是在声明“Student 是一种 Person”。第二子类init里的 super().init(name, age) 是必须在最前面完成的先让父类把自己该初始化的属性初始化好子类再补充自己的属性。第三子类里的 intro 方法重写了父类同名方法但通过 super().intro() 把父类的行为保留了下来然后在此基础上追加内容。这就是“重写不是覆盖”的核心重写是扩展不是抹杀。我见过不少初学者直接在子类里复制粘贴父类的方法体再追加两行结果父类一改子类全忘了同步。正确的做法是先调 super()再用父类已有的逻辑子类只写差异部分。2.2 方法重写不是覆盖是协作用一个稍微复杂点的例子看超级链。下面这段代码会打印出什么初学者十有八九答错。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() print(D.__mro__)输出结果是B C A (class __main__.D, class __main__.B, class __main__.C, class __main__.A, class object)第一次看到这个结果的人都会愣一下为什么 B 之后不是直接到 A而是先跑到 C 去了因为 Python 的多继承是按 MRO方法解析顺序来决定 super() 接下来交给谁的。D 继承了 B 和 C而 C 也继承了 APython 用 C3 线性化算法把继承链拉平形成 D → B → C → A → object 的顺序。这条链说明白一点就理解 super() 不是简单的“调用父类”而是“调用 MRO 里的下一个类”。一旦涉及到多继承super() 就变成一种协作机制B 调用 super() 时它不知道也不关心下一个是谁反正 Python 会按 MRO 顺序把请求交下去。这种设计让每个类都可以在自己的方法里做“一部分事情”然后把剩余工作交给链条里的下一个类。2.3 多继承与 MRO钻石问题为什么存在多继承为什么会带来所谓的“钻石问题”因为出现了菱形结构D 继承 B 和 CB 和 C 又都继承 A。搞不好A 的init会被调用两次。Python 的解决思路很聪明既然调用顺序已经由 MRO 固定了那么每个类在继承链里只会被初始化一次。我把这句话翻译成人话多继承里super().init() 不是要把所有祖先的init都执行一遍而是只沿着 MRO 把链条真正走一遍重复出现的类只执行一次。这就避免了 C 里那种钻石继承导致基类被构造两次的问题。但注意这不代表多继承就可以乱用。我的建议是如果只是想让一个类同时具备多个能力优先考虑 Mixin。Mixin 是一种轻量级的父类它一般不单独实例化也不保存重要状态只负责提供一组方法。比如写一个 JSONMixin里面提供 to_json 和 from_json然后让模型类继承它。这样做的好处是职责清晰、冲突少。2.4 抽象基类用接口思维限制继承如果父类本身不打算创建实例只想定一套子类必须实现的方法那就要用抽象基类英文是 ABC全称 Abstract Base ClassPython 里对应的是 abc 模块。from abc import ABC, abstractmethod class Payment(ABC): abstractmethod def pay(self, amount): 子类必须实现否则不能实例化 class Alipay(Payment): def pay(self, amount): return f支付宝支付了{amount}元 p Payment(10) # 直接报错Cant instantiate abstract class Payment a Alipay(10) # 正常抽象基类的价值在于它把“子类必须有什么”从口头约定变成了代码强制。你忘了实现 pay 方法Python 直接不让这个类创建实例编译期就帮你拦截错误。这种写法的本质是面向接口编程调用方不关心具体是支付宝还是微信支付只关心它能不能 pay。这也是多态的底层支撑。3. 多态继承存在的最大意义3.1 多态的本质与“面向接口编程”讲完继承终于可以说多态了。我用一句话概括多态同一个方法名在不同对象上有不同的行为调用方不需要知道对象的真实类型。这句话念起来简单但拆开看有三个条件第一这些对象必须满足同一个接口要么继承同一个父类要么都有同名方法第二调用方只依赖这个接口不依赖具体类型第三运行时 Python 自己决定实际执行哪种行为。打个比方你去饭店点一份“番茄炒蛋”你不用管后厨是王师傅做还是李师傅做你只对接菜单这个接口。王师傅和李师傅虽然做法不同但他们提供的服务都符合“番茄炒蛋”的约定。这就是多态。在 Python 里实现多态最常见有两种方式一种是通过继承和重写另一种是鸭子类型。前者是我在上一节代码里演示过的子类重写父类方法父类类型的地方可以放子类对象。后者强调的则是对象有没有这个“行为”而不是对象“是不是”某个类型。两者并不冲突继承是语法层面帮你建立关系鸭子类型是运行时帮你省掉类型检查。3.2 鸭子类型Python 对多态的独特支持“鸭子类型”这个名字来自一句英文谚语如果它走起来像鸭子、叫起来像鸭子那它就是鸭子。放到代码里就是我不管你是鸭子还是鹅只要你有 quack 方法我就可以把你当鸭子用。class Dog: def sound(self): return 汪汪 class Cat: def sound(self): return 喵喵 def listen(animal): print(animal.sound()) listen(Dog()) # 汪汪 listen(Cat()) # 喵喵Dog 和 Cat 完全没有继承关系但 listen 函数照样能调用 sound 方法。放在 Java 或 C 里这两个类没有公共父类或接口这种写法根本编译不过但 Python 不管这些只要对象有这个方法我就直接调。这就是动态语言赋予多态的另一种形式。有读者会问那继承和多态不就没关系了吗也不是。鸭子类型能成立靠的是“对象之间存在共同的行为约定”而继承是显式声明这种约定的一种方式。用继承时约定是写在父类里的用鸭子类型时约定是写在实际调用处的。理解这一个区别你就把动态语言的多态机制吃透了。3.3 一个真实的实战改造案例光讲理论不行我拿一个支付渠道的例子走一遍完整改造。最开始业务代码长这样def pay(amount, channel): if channel alipay: print(支付宝支付, amount) elif channel wechat: print(微信支付, amount) elif channel card: print(银行卡支付, amount) else: raise ValueError(不支持的支付渠道)这叫“面向过程判断”每加一个渠道就要在 pay 函数里加一个 elif。加三个五个没问题加到二十个的时候pay 函数又臭又长还特别容易被改错。这时候就该引入继承和多态了。改造后class Payment: def pay(self, amount): raise NotImplementedError class Alipay(Payment): def pay(self, amount): print(f支付宝支付{amount}元) class WechatPay(Payment): def pay(self, amount): print(f微信支付{amount}元) class CardPay(Payment): def pay(self, amount): print(f银行卡支付{amount}元) def pay(payment: Payment, amount: int): payment.pay(amount)调用方变成了 pay(Alipay(), 100)以后要新增渠道直接建一个新类就行pay 函数完全不用动。这就是开闭原则对扩展开放对修改关闭。这种改造看起来增加了类数量但实际上把每个渠道的差异隔离在各自的类里代码的稳定性高了一个量级。我在真实项目里更倾向于由工厂函数来创建支付对象但这不影响你对多态本身的理解核心逻辑是一样的。4. 常见错误与排查技巧实录4.1 super().init() 没调用属性全没了这是我带项目时看到新手频率最高的错误。子类重写了init但忘了调用 super().init()结果父类里定义的自带属性一个都没初始化。比如刚才的 Person 例子Student 类里不写 super().init(name, age)直接去访问 self.name马上就会报 AttributeError。排查方法也很直观遇到“AttributeError: Student object has no attribute name”这类错误时第一反应不是去看 name 在哪定义而是先去子类init里找有没有 super()。还有一个坏习惯要戒掉不要在子类里重复定义父类已有的属性那样会掩盖问题让代码越来越啰嗦。正确做法是先调 super()把父类逻辑跑完再补子类自己的东西。4.2 isinstance 用得太早把自己圈死了很多初学者在练习多态时不放心总想在调用前先 isinstance 判断一下对象类型。结果代码越写越硬一旦来了个新类型isinstance 的判断条件又得改。这其实是把多态的活抢过来自己干了。我的建议是如果方法名一致直接用鸭子类型如果需要约束类型优先用抽象基类做 isinstance 的判定目标而不是具体类。比如用 isinstance(obj, Payment)这样新增的支付渠道只要继承 Payment就天然通过检查。判断抽象关系而不是判断具体类型代码才有扩展空间。4.3 多继承的顺序问题与 mixed-in 设计多继承用不好最典型的问题就是 MRO 混乱。有些人图省事把一堆 Mixin 类都往父类括号里塞最后方法到底走谁的 super()查半天才能查明白。我在同事代码里见过一个类括号里排了六个父类运行结果完全不可预测。只要你打开了这种代码第一件事是打印mro属性。Python 很贴心每个类都自带mro你 print(ClassName.mro) 就能看到方法解析顺序再来判断到底是谁覆盖了谁。设计上我通常只允许一个主要父类其余都做成 Mixin 风格无状态、无继承、只提供方法。这样多继承冲突的概率会降到最低。4.4 继承层次过深后改一处崩一片最后一个让我特别想吐槽的坑就是继承层次过深。新人喜欢这样写A 继承 BC 继承 AD 继承 CE 又继承 D。看起来层级清晰其实改一句顶层的代码底下的类全都受影响。有一次我改了一个基类的初始化逻辑结果线上十几个类同时出问题排查了整整半天。我现在给自己立了一条规矩继承层级尽量不超过三层。超过三层就开始考虑有没有可能拆成组合。如果你发现修改父类时心里发虚不知道会波及哪些子类那这个继承结构就已经失控了。重构的时候不要恋战先把公共逻辑抽成独立的类再通过组合引入比硬撑一个深层继承树要安全得多。5. 实用设计原则与项目应用建议5.1 什么时候用继承什么时候用组合我经常被问组合优于继承是不是继承就不该用了不是。这句原则的完整说法是“优先组合而不是继承”但前提是两者都可行时组合通常更灵活。对于真正满足 is-a 关系的场景继承仍然是最自然、最合适的表达。我把决策流程总结成四步。第一步问自己是不是 is-a 关系不是直接选组合。第二步如果是 is-a 关系再问父类有没有需要子类强制实现的方法有就考虑抽象基类。第三步问父类是否会被大量实例化如果父类像一个模板那不如抽象基类加组合。第四步问子类是否需要和父类共享私有属性如果需要那就继承否则组合可以做得更干净。5.2 用 collections.abc 理解真实世界的抽象Python 标准库里最好的继承范例我推荐 collections.abc也就是标准库里跟容器有关的抽象基类。比如你自定义一个列表一样的类可以直接继承 collections.abc.MutableSequence然后只需要实现getitem、setitem、delitem、len、insert 这五个核心方法Python 就能自动帮你补全 append、pop、extend 等一系列派生方法。这个例子特别适合理解抽象基类的价值父类把“框架”搭好子类只需填写最核心的方法剩下的行为由父类用核心方法组合出来。你写的代码获得了继承的便利性同时又能保证子类之间的接口一致。标准库已经帮你验证了这套设计模式的可靠性照着抄就行。5.3 练习用继承与多态写一个小型插件系统说了这么多最后留一道动手题。你可以尝试写一个插件系统定义一个 Plugin 抽象基类里面声明 run 和 name 两个抽象方法然后写两个插件类比如 HelloPlugin 和 TimePlugin 继承它最后写一个 PluginManager接收一组插件实例循环调用 run 方法。写完之后试着回答三个问题第一如果再加一个插件需要改动哪些文件第二如果有个插件忘了实现 run会不会在集成时立刻暴露第三如果把 Plugin 抽象基类去掉改成鸭子类型代码有什么不同这三种写法的对比比背一百个概念都管用。最后再分享一个我在项目里的习惯给所有需要继承的父类命名时尽量带上 Base、Interface 或 Abstract 这样的前缀比如 BasePayment、AbstractPlugin。这样看代码的第一眼团队就能知道哪些类是拿来当父类的哪些类是拿来实例化的后续维护的人也不会误用。这个细节听起来小实际能省掉很多沟通成本。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价