咱们平时写代码,尤其是写业务系统的时候,一定会遇到一个坎,叫“面向对象”。这名字听着玄乎,其实说白了,就是一套组织代码的思路。而它最核心的骨架,就是三大特征:封装、继承、多态。不管你是刚学Python、Java还是C++,不管你在刷面试题还是写毕业设计,这三大特征都是绕不开的主干道,理解了它,你看别人项目源码的能力能提升一大截,自己动手设计类的时候也不会再东一榔头西一棒子。
这篇文章我打算用Python为主来展开,因为Python的语法足够简单,屏蔽了太多底层细节,能把三大特征最本质的思想暴露出来。当然,Java和C++的读者也能看懂,语言不同,背后那套逻辑是通用的。文章会从设计思路、核心代码、完整案例、常见问题几个维度来讲,中间穿插一些我这些年写代码、做项目评审时踩过的坑和积累下来的心得,希望能对你有实际帮助,而不是又多看了一篇理论科普。
1. 面向对象的整体设计思路:三大特征到底在解决什么问题
很多人学封装、继承、多态是一个一个分开学的,学的时候觉得听懂了,合上书本遇到需求还是一团乱麻。问题在哪呢?在于不知道它们彼此之间是什么关系,也不知道它们联合起来是为了解决哪一类问题。
1.1 为什么需要面向对象
我先说一个最朴素的感觉:代码是给人看的,顺便给机器执行。当项目规模小的时候,函数一个个往下写没问题,但一旦到了几千行、几万行以后,如果所有变量都是全局的,所有逻辑都平铺直叙,那你改一处功能就要提心吊胆半天,生怕别的功能被牵连。
面向对象做的事情,本质上就是给代码“划地盘、定规矩”。
它把人认知世界的方式搬进了代码里。人怎么理解一辆汽车?我们知道车有颜色、有速度、有品牌,这是它的属性;我们知道车能跑、能刹车、能按喇叭,这是它的行为。面向对象就是把这套认知模型直接落到程序里:属性对应变量,行为对应函数,再把它们捆成一个独立单元,这个单元叫对象,对象所属的分类叫类。
但仅仅把属性和行为捆起来还不够。一个系统里几十个类,它们之间怎么协作?哪些东西可以共用?哪些地方可以留出扩展的余地?这就是三大特征出场的场合了。
1.2 三大特征的分工与协作
我的理解里,三大特征不是三个并列的语法点,而是三条各管一摊的纪律:
- 封装管的是“安全与边界”:一个对象内部的数据不能让别人随便改,它对外能做什么、不能做什么,必须明确说清楚。
- 继承管的是“复用与关系”:多个类如果有共同的属性和行为,把它们提炼到父类里,子类只写自己独有的部分,同时表达出“子类是一种特殊的父类”这层关系。
- 多态管的是“扩展与统一”:调用方只依赖一个统一的接口,至于这个接口背后到底是哪个具体实现,运行时再决定。这样系统加新功能的时候,老代码不需要动。
可以把它们放一起做个简单对比。
| 特征 | 核心问题 | 核心手段 | 带来的收益 |
|---|---|---|---|
| 封装 | 数据安全与边界 | 私有属性、公开接口 | 降低误操作,隔离变化 |
| 继承 | 代码复用与层级关系 | 父类抽取、子类扩展 | 避免重复,表达is-a关系 |
| 多态 | 运行时动态决策 | 方法重写、统一接口 | 开闭原则,低成本扩展 |
这张表我建议你多看几遍。很多人在设计类的时候,不是不会写代码,而是不知道“当前这个阶段用哪个特征合适”。最典型的错误就是见面就继承,有点公共代码就往上抽父类,结果最后父类变成了一个大杂烩。这说明对三者的边界理解还不够清楚。
1.3 语言差异带来的理解偏差
同一个三大特征,在不同语言里的实现方式差别很大,如果不提前说清楚,容易把一门语言里的经验硬套到另一门语言上,产生不少误解。
Java里封装靠private和public关键字强制约束,你不写接口就访问不了;Python里没有真正的私有变量,双下划线开头的变量其实只是做了一次名字重整(name mangling),从根上讲,Python默认大家都是成年人,靠约定而非强制。
Java默认单继承,一个类只能有一个父类,这样可以规避很多复杂的继承冲突;C++允许一个类有多个父类,灵活归灵活,但一旦两个父类有同名字段,问题就来了;Python走的是多继承路线,但它有自己的策略,后面我会讲到MRO算法。
多态这块差别更大。Java的多态必须建立在继承或接口实现之上,调用的是父类类型,实际跑的是子类方法;Python里细节更轻巧,它讲究鸭子类型——只要一个对象有调用的那个方法,不管你的类是从哪里继承来的,就能直接当作目标类型使用。
所以你在看下面代码示例的时候,要刻意做一步“思维去语言化”:重点看思路,不要死记语法。
2. 封装:不是简单的“私有化”
封装听起来像是一个语法功能,但实际上它是一种设计态度。我给团队做代码评审时,判断一个人有没有真正理解封装,不看它有没有用私有变量,而看他设计的接口能不能有效防止别人“瞎折腾”。
2.1 封装的本质是“隐藏细节,暴露接口”
举一个有带入感的例子。你设计了一个银行账户类,里面有一个字段表示余额。如果你把余额设计成公开字段,外部代码就可以随便写account.balance = 9999999,这显然是不可接受的。哪怕你把字段名写清楚,外部依然可能在毫不知情的情况下改掉这个值,连带产生各种连锁问题。
封装的核心思想就是:字段是你的内部细节,不对外开放;对外只开放行为,也就是方法。所有对余额的修改,都必须经过你规定的方法来操作。
新手最容易犯的误区,是把“封装”等同于“加私有修饰符”。其实私有只是一个工具,真正的意图是保证对象的状态在任何时刻都是合法的、可控的。比如存款方法里你可以检查金额必须大于0,取款方法里你可以检查余额不能为负,这些业务规则如果放在外部代码里,没法保证别人会遵守,只有放进方法内部,才成为强制约束。
我试着用类比来解释一下。你开了一家店,仓库里的货是内部资产,顾客不能直接进仓库搬东西,这就是封装。顾客想买商品,必须通过收银台(对外接口),收银台会检查钱够不够、商品是否有货,还会记录流水(业务规则)。你作为店主,把仓库门锁上,不是为了防贼,而是为了保证每一笔货物进出都合规、都有记录。
2.2 用Python代码理解封装的三个层次
Python里的封装在语法层面可以做成三个层次,对应不同的暴露程度。我们先写一个最普通的版本。
class Account: def __init__(self, owner, balance): self.owner = owner self.balance = balance acct = Account("张三", 1000) acct.balance = -200 # 完全裸露,谁都能改,甚至可以改成负数这种写法做小Demo没问题,但做真实业务是完全不够的。接下来我们加上第一层保护:单下划线约定。
class Account: def __init__(self, owner, balance): self.owner = owner self._balance = balance # 约定:这个变量是受保护的,外部不要直接碰 def get_balance(self): return self._balance def deposit(self, amount): if amount <= 0: raise ValueError("存款金额必须大于0") self._balance += amount def withdraw(self, amount): if amount <= 0: raise ValueError("取款金额必须大于0") if amount > self._balance: raise ValueError("余额不足") self._balance -= amount单下划线是Python社区里的一种约定俗成,表示“这是内部变量,外部别动,但我不强制阻止你”。紧接着我们还能做第二层:改用双下划线,触发名称改写。
class Account: def __init__(self, owner, balance): self.owner = owner self.__balance = balance # 双下划线:名字会被改写,外部直接访问会报错 def get_balance(self): return self.__balance def deposit(self, amount): if amount <= 0: raise ValueError("存款金额必须大于0") self.__balance += amount def withdraw(self, amount): if amount <= 0: raise ValueError("取款金额必须大于0") if amount > self.__balance: raise ValueError("余额不足") self.__balance -= amount此时在外面执行acct.__balance,会得到一个AttributeError,因为Python把属性名字改成了_Account__balance。你可以用这个技巧防止别人“顺手”动内部属性,但要清楚一点,它防不了真正的恶意,它防的是不小心和随意。
到了Python代码写多了以后,第三个层次会更顺手:用@property把方法包装成属性来使用。这样既能保留校验逻辑,外部调用又和访问普通属性一样自然。
class Account: def __init__(self, owner, balance): self.owner = owner self.__balance = balance @property def balance(self): return self.__balance @balance.setter def balance(self, value): if value < 0: raise ValueError("余额不能为负") self.__balance = value def deposit(self, amount): if amount <= 0: raise ValueError("存款金额必须大于0") self.__balance += amount acct = Account("李四", 800) print(acct.balance) # 读的时候就像在访问属性 acct.balance = 1000 # 写的时候自动走到setter校验我个人在日常项目里更推荐@property的写法,因为它能把“内部数据”和“外部接口”的边界处理得最干净,调用方的体验也最好。
2.3 封装中的三个实操心得
先讲一个我踩过的坑:不要在@property的getter里写耗时计算。Property本身是拿来模拟属性的,可读性要好,一旦你在里面做了网络请求或大循环,外部代码访问一个属性的时候突然卡住半天,排查起来非常难受。真正的计算逻辑可以拆成普通方法,或者缓存起来,不要在读取路径上做太多文章。
再讲一个设计习惯:不要在setter里轻易抛异常。抛异常属于一种“强校验”,如果业务上允许一定范围内的调整,可以用截断、取默认值的方式处理。强校验要用在真正会产生脏数据的地方,比如余额为负、年龄为负数这种。
还要提醒一点:别为了封装而封装。如果一个类只有一堆私有变量和getter/setter,没有任何业务规则,那它和被切成两半的普通变量没多大区别。一个有意义的封装,至少要保证内部状态的任何变化都经过某种规则检查,否则这个封装就只是形式主义。
3. 继承:代码复用的同时也要接住复杂度
继承可能是三大特征里面最容易上手、也最容易出问题的。上手容易是因为语法太简单了,class Dog(Animal),一写就完事。但设计一个合理的继承关系,难度远比你想象的大。
3.1 继承解决的问题与使用前提
继承解决的核心问题是“公共部分只写一遍”。比如你有一个Person类,之后又要写Student类、Teacher类,它们都有name、age、gender这些公共属性,都有introduce()这种公共方法。如果每个类各写各的,代码重复会很严重。把这部分提到父类里,是继承最基本的使用场景。
但还有一个更重要的使用前提,就是“is-a关系”。学生是一个人,老师也是一个人,所以Student和Teacher继承Person,这个关系成立。而如果某天你想让Dog继承Person,仅仅因为人和狗都有name和age,那就不对了。这个时候正确的做法是组合:在狗的类里面持有一个人的字段,或者把它提升为更上层的类。
“组合优先于继承”这句话在一些有经验的开发者之间经常出现。它的意思是:能用组合表达的关系,就不要用继承硬套。组合表达的是“有”一个东西,继承表达的是“是”一个东西。两者不是一个维度。滥用继承的后果是:父类的任何改动都会波纹状影响到所有子类,一层改错,全线崩盘。我在实际评审里见过最揪心的系统,父类里一个字段被加了默认值,三个子系统的表现全部发生变化,然后大家开会排查了一下午。
3.2 Python的继承写法:初始化、方法重写与super()调用
先看一个最典型的三层继承结构,覆盖了继承中最常见的两个问题:参数如何向上传递,方法如何重写。
class Person: def __init__(self, name, age): self.name = name self.age = age def introduce(self): return f"我叫{self.name},今年{self.age}岁" class Student(Person): def __init__(self, name, age, student_id): super().__init__(name, age) self.student_id = student_id def introduce(self): base = super().introduce() return f"{base},我的学号是{self.student_id}" class GraduateStudent(Student): def __init__(self, name, age, student_id, research_field): super().__init__(name, age, student_id) self.research_field = research_field def introduce(self): base = super().introduce() return f"{base},我研究的方向是{self.research_field}" me = GraduateStudent("小王", 24, "2023001", "计算机视觉") print(me.introduce())这里有一个细节非常关键:为什么Student.__init__里要写super().__init__(name, age)?
我解释一下,Python的super()不是凭空把父类的__init__跑一遍,而是去当前实例的继承链上找下一个类,把调用传给它。如果你不写,那么父类里的name和age永远不会被初始化。写出来的效果是:Student的实例既拥有自己定义的student_id,也拥有父类定义的name和age。
很多初学者刚写继承时,会直接在子类里重新赋值一遍:
class Student(Person): def __init__(self, name, age, student_id): self.name = name self.age = age self.student_id = student_id这样也能运行,但问题很明显:父类初始化逻辑如果加了默认值、校验、格式处理,子类这里全都绕过了,逻辑重复了一份,后续维护就是灾难。正确的套路永远是:子类只处理自己新引入的字段,公共字段交给父类处理。
方法重写这块,我用代码演示了一个固定节奏:先用super().xxx()拿到父类的默认行为,再在这个基础上拼接扩展内容。这样做的好处是,子类不需要复制父类的实现,父类改了逻辑,子类自动跟着变,只是多了自己那一层输出。这种“先走后扩”的写法,比把父类代码复制进来改一改要稳健得多。
3.3 多继承与MRO方法解析顺序
Python允许多继承,也就是说一个类可以同时继承多个父类。听着功能强大,但如果不了解解析顺序,很容易栽跟头。我先看一个经典的菱形继承问题。
class Base: def say(self): print("Base say") class Left(Base): def say(self): print("Left say") super().say() class Right(Base): def say(self): print("Right say") super().say() class Child(Left, Right): def say(self): print("Child say") super().say() c = Child() c.say()运行这段代码,输出顺序会是什么呢?你可能会想,Child继承Left和Right,Left里调super()应该去找Right吗?还是找Base?Python的答案来自C3线性化算法,它保证了两个原则:单调性和局部优先序。在Child这个位置,查找顺序是Child -> Left -> Right -> Base -> object。所以输出是:
Child say Left say Right say Base say也就是说,Left里的super()确实去调用了Right的方法,形成了一个协作式的调用链。这听起来抽象,但实际上这是Python多继承能够运行的前提。你在项目里排查多继承问题的时候,最实用的工具就是打印MRO顺序:
print(Child.mro())一次就能清楚地看到类的方法解析顺序。
我自己对多继承的态度是:业务代码里尽量少用,或者干脆不用。多继承适合做“混入(Mixin)”,把一个小能力切出来让别的类“顺便带上”,比如说LogMixin、JsonMixin这种。一旦多个父类里都定义了大量业务字段,你去推“这个属性到底来自谁”的时候,痛苦程度会直线上升。
如果实在要过菱形继承这种关,就记住一个自我检查的准则:写super()调用时,要当它不是“父类”而是“继承链上的下一个类”,按MRO顺序去理解,这样你的代码逻辑才是安全协作的。
4. 多态:让代码面向未来地扩展
如果说封装是防守、继承是复用,那么多态就是系统里最闪亮的那一颗,它直接决定了你的程序能不能低成本地接入新功能。
4.1 多态的本质:同一套调用,多个版本的行为
先看一个生活化的例子:你有一个“遥控器”,它只认一个“按下”按钮。按下时,接在电视上,电视开机;接在空调上,空调启动;接在音箱上,音箱播放。遥控器不需要知道末端到底是什么设备,它只管发指令。结果由设备自己决定。
映射到代码里,就是调用方只依赖一个统一的方法名,不同的对象各自提供不同的实现,程序运行时根据实际对象的类型自动选择对应的行为。这种方式让系统在增加新设备时,不需要修改遥控器的代码,只需要让新设备实现同一个方法的约定。
这就是多态带来的最大好处:开闭原则——对扩展开放,对修改关闭。
多态有几种实现路径。一种是基于继承:父类定义抽象方法,子类各自重写,调用方使用父类类型接收子类对象。另一种是在Python里特别重要的“鸭子类型”:不要求类型上有继承关系,只要求对象拥有该方法。
4.2 Python里最直观的多态示例
我写一个模拟动物园叫声的小例子,代码短,但把多态的核心逻辑都涵盖到了。
class Animal: def speak(self): raise NotImplementedError("子类需要实现speak方法") class Dog(Animal): def speak(self): return "汪汪汪" class Cat(Animal): def speak(self): return "喵喵喵" class Duck: # 注意:Duck没有继承Animal def speak(self): return "嘎嘎嘎" def make_sound(animal): print(animal.speak()) animals = [Dog(), Cat(), Duck()] for a in animals: make_sound(a)这个例子里有三个关键点。
第一个,make_sound(animal)函数根本不关心传入对象的具体类型,它只看这个对象有没有speak()方法。这就叫以接口为导向的编程。
第二个,Duck没有继承Animal,但它依然能被传进make_sound正常调用。这就是Python的鸭子类型:走起来像鸭子、叫起来像鸭子,那它就是鸭子。Java里要做到类似效果必须定义接口,Python的语法约束弱很多,这是优势也是坑,因为一旦你传进来一个没有speak()方法的对象,程序就报错。
第三个,当我需要新增一种动物时,比如再加一个Chicken,我只需写好新的类,实现speak(),然后在列表里添加实例,make_sound函数一行都不用改。这就是多态带来的增量扩展。想象一下,如果系统里到处是if animal_type == "dog": ... elif animal_type == "cat": ...这种分支,每加一种动物,就要去改这一坨逻辑,改的次数越多,漏掉分支的概率就越大。
4.3 多态误区的澄清:重载不等于多态
市面上不少教程把“重载”和“重写”混在一起讲,这里必须分开。
重载(Overload)指的是在同一个类里面,定义多个同名方法,靠参数个数或类型来区分。Java支持重载,Python默认不支持,因为Python的函数就是名字对应一个调用,后来的定义会覆盖前面的。
重写(Override)指的是子类和父类有同名方法,子类覆盖父类的实现。
多态的关键在运行时,它依赖的是“重写”,不是“重载”。重载只是编译期静态选择,它在增加参数组合的时候才有点用,但并不会让代码获得动态扩展能力。可以认为,多态是“动态的”、基于运行时对象的真实类型做决策;重载是“静态的”,基于编译期的参数类型做决策。
还有一个常见的误解:“多态就是子类方法里写了好多东西”。其实多态关注的是调用方与实现方的解耦。一个类里有十个方法各自实现自己的逻辑,那不叫多态。只有当调用方写的是统一接口,而不同对象用不同方式响应时,才称得上多态。
5. 三大特征综合实战:模拟一个简单的员工薪资系统
理论讲了很多,如果没有一个完整案例把它们串起来,知识很容易变成空中楼阁。这一节我构建一个最小的员工薪资系统,代码量不大,但封装、继承、多态三个特征都会自然体现出来。
5.1 需求描述与类设计
假设我们有这样几种员工:
- 普通员工:只拿固定月薪。
- 经理:在固定月薪基础上,额外拿一笔固定的岗位津贴。
- 销售员:拿底薪加销售提成,提成按销售额的10%计算。
系统要求可以用统一的方式计算所有人的薪资,打印工资单时不需要判断员工类型。将来如果出现新的员工类型,比如工程师加项目奖金、外包按小时计费,系统要能做到“只新增类,不改旧逻辑”。
基于这个需求,我建议的类结构是这样的:
- 有一个基类
Employee,定义公共字段(姓名、薪资基数)和统一的get_salary()接口,基类方法可以抛出“子类实现”的提示,也可以给一个默认值。 - 三个子类
Manager、Salesman、Engineer分别继承基类,各自重写get_salary()。 - 测试部分构造一个员工列表,统一调用
get_salary()。
5.2 完整代码与运行过程
下面就是完整的可运行代码。
class Employee: """所有员工种类的基类""" def __init__(self, name, base_pay): self.name = name self._base_pay = base_pay # 受保护字段,子类可以直接访问 def get_salary(self): raise NotImplementedError("子类必须实现get_salary方法") class Manager(Employee): def __init__(self, name, base_pay, allowance): super().__init__(name, base_pay) self._allowance = allowance def get_salary(self): return self._base_pay + self._allowance class Salesman(Employee): def __init__(self, name, base_pay, sales_amount): super().__init__(name, base_pay) self._sales_amount = sales_amount def get_salary(self): return self._base_pay + self._sales_amount * 0.10 class Intern(Employee): def __init__(self, name, daily_pay, work_days): super().__init__(name, 0) self._daily_pay = daily_pay self._work_days = work_days def get_salary(self): return self._daily_pay * self._work_days # 统一的薪资计算入口 def print_salary(employee): print(f"员工{employee.name}的本月薪资为:{employee.get_salary():.2f}") employees = [ Manager("张伟", 15000, 3000), Salesman("李明", 5000, 120000), Intern("王芳", 120, 22), ] for emp in employees: print_salary(emp)运行这段代码,输出如下:
员工张伟的本月薪资为:18000.00 员工李明的本月薪资为:17000.00 员工王芳的本月薪资为:2640.00回到刚才的需求,如果产品经理第二天说,我们要新增一个“外包工程师”类型,按小时计费。你怎么做?很简单,新增一个类继承Employee,实现get_salary(),然后添加到员工列表里。print_salary函数一行不改,员工列表的类型约束也几乎为零。这就是多态给你的底气。
5.3 这个例子为什么能说明问题
我逐个拆一下三大特征在这个例子里的体现。
封装体现在哪里呢?看基类里,_base_pay是受保护字段,外部代码不应该直接读取或修改,而子类里的_allowance、_sales_amount也同样是内部实现细节。外部想知道薪资,唯一的入口是get_salary()。这里面虽然没有复杂的校验逻辑,但“内部数据通过方法接口暴露”的原则已经很明确了。如果你要把这个系统变得更强壮,完全可以在__init__里增加参数合法性校验,比如薪资不能为负、销售额不能为负。这些校验放在类的内部,才叫封装落地。
继承体现在哪里呢?三个子类都复用了Employee的name字段和_base_pay字段,初始化时通过super().__init__()把公共部分交给父类处理,自己没有重复声明公共字段。这就是“公共部分只写一遍”。
多态体现在哪里呢?employees列表里放的是三种不同的子类对象,print_salary函数接收的虽然是同一种“Employee”类型,但实际执行时各对象调用的都是自己重写过的get_salary()。后续新增类型也不用改动print_salary,类型本身就可以自动扩展。
从这个案例里,你可以看到三大特征不是三个孤立的知识点,而是相互配合的:封装划清边界,继承抽公共部分,多态保证扩展弹性。把这三个特征用在一套代码里,代码的稳定性、可读性和可维护性都会上一个台阶。
6. 常见问题与排查技巧实录
三大特征的概念不难理解,但真正在项目里写起来,经常会遇到一些很具体的问题。这一节我整理出出现频率最高的几个场景,每个场景都附上我的排查思路和解决方案。
6.1 高频问题速查表
| 问题表现 | 可能原因 | 排查思路 | 解决方法 |
|---|---|---|---|
外部直接访问__balance报错 | 双下划线触发了名称改写 | 确认是故意如此还是误用 | 用get_balance()或@property接口 |
| 子类对象创建时缺少父类字段 | 子类__init__没调super().__init__() | 检查子类初始化方法 | 子类只处理新字段,公共参数交给父类 |
| 多重继承时方法调用顺序不符合预期 | 不理解MRO | 打印类名.mro()查看顺序 | 避免复杂的菱形继承,或按MRO顺序调整父类声明顺序 |
| 重写方法后父类逻辑丢失 | 子类中没有调用super().xxx() | 确认是否需要保留父类默认行为 | 先调父类方法,再拼接扩展逻辑 |
| 统一入口传入了没有目标方法的对象 | 鸭子类型导致运行时AttributeError | 检查对象类型和是否实现方法 | 用hasattr()做防御,或者统一继承基类 |
这里面我想重点展开两个问题。
第一个,super().__init__漏调的问题。漏调表面上是多了一行代码的事,但可能只会在运行到某个特定方法时才爆炸。排查的时候,如果发现子类实例既没有name也没有age,第一时间就要去检查子类初始化方法里是否调了父类初始化。这个问题的本质是,继承关系不一定保证父类方法自动执行,方法和属性都是“继承”的,但初始化动作不会自动挨个做,需要显式调用。
第二个,多重继承的方法解析顺序问题。很多人以为super()就是简单调用父类,但多重继承情况下,它去的可能是继承链上的“旁边”类。我建议任何用了多重继承的类结构,都主动打印一次mro()看一眼,确认调用顺序符合预期。这招在排查魔幻方法调用时能节省大量时间。
6.2 从一次代码评审看三大特征的陷阱
去年我参与过一个老项目的重构评审,细节不展开,但三大特征使用不当导致的典型问题都撞上了。
那个系统里有一个大的Animal基类,里面有基础信息、有喂食、有健康状态、还有一些展示方法,然后猫、狗、鸟都继承它。问题在于,产品希望鸟能飞,开发同学就试图在Animal基类里加一个fly()方法,然后让狗也空实现,用于占位。这种做法其实就是滥用继承和抽象方法:基类越来越膨胀,最终成为“上帝类”,所有东西都能做,等于所有东西都耦合在一起。
正确的做法应该考虑用组合或者混入:让鸟额外拥有一个Flyable的能力,狗不需要。三大特征不是让你把所有公共逻辑都堆到一个大块头里面,而是让每个类保持自己小而清晰的内聚。
评审到后面,还发现一个多态使用不当的问题。有一个统一显示的模块,本应通过多态让每个动物展示自己的卡片,结果代码到处是if isinstance(animal, Dog): ... elif isinstance(animal, Bird): ...的分支判断。每次加一个动物类型,就要去改这个分支。我给出的建议是:把“每个动物如何展示自己”的方法下沉到动物类里,统一入口只调用display(),把所有isinstance分支消灭掉。
这里也让我想到一句话:模块之间如果能用多态解耦,就尽量不要用条件判断去区分类型。一次分支是简单直接,分支多了以后,它就是系统里最脆弱的定时炸弹。
6.3 使用三大特征时的几条编码规范
结合这些踩坑经历,我总结出一些相对稳定的编码规范和习惯,可以作为团队评审的参考。
- 类名用名词,方法名用动词,属性名用名词。命名对了,代码读起来就像在阅读业务领域的说明书。
- 优先组合,慎用继承。继承表达is-a,组合表达has-a。没有清晰is-a关系时,不要硬凑父类。
- 继承层级建议控制在三层以内。超过三层以后,底层改动波及的范围就很难评估,排查难度也指数级上升。
- 基类里的方法不要让过半数子类用不到。如果一个方法大部分子类都要重写,那就应该考虑它是否适合放在基类,或者应该设计成抽象方法。
- 对外暴露的方法数量越少越好。封装不是把接口全部铺在外面,而是永远只提供真正必要的操作。
super()调用要养成“必须调用一遍”的习惯,不需要时也要明确不写的原因,并在代码注释里说明。
这些规范看起来是限制,但实际上是给未来的自己减少麻烦。代码写出来是给人读的,给机器跑只是附带功能,这句话越到后期体会越深。
7. 最后的实操心得:如何真正学以致用
我自己的体会是,学三大特征不能停留在“看懂教程”的层面,必须要落到真实的代码结构里,哪怕是自己写一个超小的玩具项目,只要能运用上封装、继承、多态,效果远大于刷十遍概念题。
一个实用的方法是“代码考古”:把你电脑里某一个旧项目打开,找出所有类,分析一遍每个类用了哪几种特征,如果某个地方用了继承,就问自己,这个继承关系合理吗?能不能用组合替代?有没有父类的字段在子类里根本没有被用到?这样的复盘做三次,你设计类结构的手感会有质的飞跃。
还有一个训练做法是“重写提取”:故意把一个有大量if-elif分支的旧函数拿出来,改成用多态来实现。刚开始会觉得很别扭,觉得还是分支简单。等你写完再看,会发现主函数瞬间变得干净,新增一种类型只需要加新类,几乎没有机会影响老逻辑。这种重构的爽快感,会在很大程度上改变你对面向对象的看法。
最后分享一个小技巧,Python里调试继承关系的利器就是三行代码:print(类名.__mro__)或者print(类名.mro())。当你对“这个方法到底会调用谁”产生疑问时,不要靠猜,直接打印执行顺序,一切都会清晰起来。我在好几个项目的联调阶段,都是靠这个方法快速定位到继承链上某个中间层多了不该有的实现,节省了大量排查时间。