用故事学 Python 面向对象编程:从宠物店到代码世界
前言
这是一个关于 Python 面向对象编程的故事。故事的主角叫小王。
小王刚刚从培训班毕业,入职了一家小型软件公司。上班第一天,老板把他叫到办公室,拍着他的肩膀说:“小王啊,我们最近接了一个宠物店管理系统的项目,客户催得急。听说你学过 Python,这个项目就交给你了。”
小王回到工位,信心满满地打开电脑。他心想:一个宠物店管理系统而已,不就是写几个列表、几层循环吗?于是他二话不说,撸起袖子就开干。
七天后,系统终于跑起来了。小王松了一口气。然而,当他把系统交给客户试用时,问题接踵而至:客户说能不能加一个"给宠物洗澡"的功能,小王改了三天,把代码改得千疮百孔;客户说能不能把"狗"的分类再细分成"大型犬"和"小型犬",小王发现自己写了十几处 if animal == 'dog' 的判断,根本不知道从何改起;更可怕的是,系统时不时会莫名其妙地把宠物名字和价格搞混,小王连调试都无从下手。
小王崩溃了。他找到公司的老程序员李哥求助。李哥看了一遍他的代码,只说了一句话:“你用面向过程的方式写 Python 代码,就像用菜刀砍树——能砍,但累死人。”
李哥给小王倒了一杯水,语重心长地说:“面向对象编程,它不是一种语法,而是一种看世界的方式。今天我就用你这个宠物店项目,带你从头到尾走一遍。等你真正理解了它,你会发现自己写的代码就像活了一样,改起来不费劲,扩展起来也不头疼。”
小王点点头,拿出了笔记本。
这篇长文,就是李哥给小王讲课的完整内容。我们将跟随小王的视角,从类与对象的最基础概念开始,一步步走过封装、继承、多态、设计模式、SOLID 原则,最终抵达 Python 现代 OOP 特性的领地。全文以"宠物店"为主线故事,穿插了大量生活化比喻和完整可运行的代码示例。无论你是刚刚接触 OOP 的新手,还是想系统梳理知识的开发者,都欢迎坐下来,听李哥把这门课讲完。
本文阅读路线图:
| 章节 | 核心问题 | 对应现实场景 |
|---|---|---|
| 一、类与对象 | 什么是类?什么是对象? | 宠物档案卡与一只具体的狗 |
| 二、封装 | 为什么数据不能随便改? | 银行账户的余额保护 |
| 三、继承与多态 | 怎样让代码复用又不失灵活? | 动物家族的共同行为 |
| 四、设计模式 | 常见问题有没有标准解法? | 数据库连接、工厂流水线、公众号订阅 |
| 五、SOLID 原则 | 好代码有没有评判标准? | 宠物店系统的三次重构 |
| 六、现代 OOP 特性 | Python 给出了哪些新工具? | 数据类、类型注解、内存优化 |
准备好了吗?让我们正式开始。
一、类与对象:一切从"宠物档案卡"开始
1.1 基本概念
故事:宠物店的第一张档案卡
小王接手的宠物店管理系统中,最核心的需求是管理每一只宠物。最开始,小王用字典来记录一只宠物的信息:
dog_buddy = {
'name': 'Buddy',
'age': 3,
'species': 'Canis familiaris',
'breed': 'Golden Retriever'
}
这个字典看起来很清楚:Buddy 是一只 3 岁的金毛犬。但问题很快就来了。店里新来了第二只狗、第三只狗、甚至一只猫,小王不得不复制粘贴一堆字典:
dog_lucky = {'name': 'Lucky', 'age': 5, 'species': 'Canis familiaris', 'breed': 'Poodle'}
dog_max = {'name': 'Max', 'age': 2, 'species': 'Canis familiaris', 'breed': 'Husky'}
cat_whiskers = {'name': 'Whiskers', 'age': 1, 'species': 'Felis catus', 'breed': 'British Shorthair'}
然后他发现了一个严重的问题:某天他不小心把 dog_max 的 age 写成了字符串:
dog_max['age'] = '2'
系统里所有跟年龄相关的计算都崩了,而且他完全不知道是哪个数据出了问题。李哥看到这里,微笑着说了第一句重要的话:
如果系统里有很多"同类型的数据",与其用一堆孤立的字典,不如定义一个模板,让所有数据都按照这个模板来创建。这个模板,就是类(Class)。
什么是类?什么是对象?
在现实生活中,我们有无数个具体的"狗":Buddy、Lucky、Max……我们的大脑不会为每一只狗都重新建立一套认知,而是会抽象出一个概念——“狗”。这个概念包含了一组特征(有品种、有名字、有年龄、会叫)和一组行为(叫、跑、吃)。当我们看到一只具体的狗时,我们就说:它是"狗"这个概念的一个实例。
Python 的面向对象编程,就是把这个自然思维过程搬进代码:
- 类(Class):就是那个抽象的概念,或者说"模板"。它描述了某类事物共有的属性(数据)和方法(行为)。
- 对象(Object):就是按照模板创造出来的具体实例。每一只具体的狗,都是一个对象。
用一个更直观的比喻:
- 类是一张"宠物档案卡"模板,上面规定:每只宠物必须有名字、年龄、物种这几个栏目。
- 对象就是填好内容的一张张档案卡:Buddy 的卡、Lucky 的卡、Whiskers 的卡。
另一个经典比喻:类是蛋糕模具,对象是用模具烤出来的一个个蛋糕。模具只有一个,但蛋糕可以有很多个,每个蛋糕上的奶油和水果各不相同。
第一个类
现在,我们把"狗"写成一个 Python 类:
class Dog:
"""狗的类,描述所有狗的共同特征和行为"""
# 类变量:所有 Dog 对象共享的属性
species = 'Canis familiaris'
def __init__(self, name, age):
# 实例变量:每个对象独有的数据
self.name = name
self.age = age
# 实例方法:对象的行为
def bark(self):
return f'{self.name} says Woof!'
逐行拆解:
class Dog:——用class关键字定义一个名为Dog的类。Python 约定类名使用大驼峰命名(每个单词首字母大写)。species = 'Canis familiaris'——这是一个类变量。它属于类本身,所有从这个类创建出来的对象共享同一份数据。为什么?因为所有的狗都属于同一个物种,这部分数据没必要在每个对象里各存一份。def __init__(self, name, age):——这是构造方法,在创建对象时自动被调用。它的任务是给每个对象初始化"独一无二的数据"。self.name = name——name是一个实例变量,每个对象都有自己独立的name值。self指代"当前正在被创建(或正在调用方法)的那个对象"。def bark(self):——这是一个实例方法,定义了对象的行为。所有实例方法的第一参数都必须是self,这样方法才能知道"是谁在调用我"。
创建对象与实例化
创建对象的过程叫实例化,语法简洁到不可思议:
dog = Dog('Buddy', 3)
print(dog.name) # Buddy
print(dog.age) # 3
print(dog.species) # Canis familiaris
print(dog.bark()) # Buddy says Woof!
当 Python 执行 dog = Dog('Buddy', 3) 时,背后发生了两件事:
- 创建出一个空白的新对象;
- 自动调用
Dog.__init__(新对象, 'Buddy', 3),把'Buddy'和3分别存进这个新对象的name和age里。
所以 self 不需要你手动传,Python 会自己把它填上。
完整案例:类变量 vs 实例变量
为了彻底搞懂类变量和实例变量的区别,我们来看一个对比实验:
class Dog:
species = 'Canis familiaris' # 类变量:所有狗共享
def __init__(self, name, age):
self.name = name # 实例变量:每只狗独有
self.age = age # 实例变量:每只狗独有
# 创建两只狗
buddy = Dog('Buddy', 3)
lucky = Dog('Lucky', 5)
# 实例变量互不影响
print(buddy.name) # Buddy
print(lucky.name) # Lucky
# 类变量被所有对象共享
print(buddy.species) # Canis familiaris
print(lucky.species) # Canis familiaris
# 修改类变量会影响所有对象
Dog.species = 'Canis lupus familiaris'
print(buddy.species) # Canis lupus familiaris
print(lucky.species) # Canis lupus familiaris
# 但是!如果通过实例去赋值,只会创建一个同名的实例变量
buddy.species = 'I am unique!'
print(buddy.species) # I am unique!(实例变量遮蔽了类变量)
print(lucky.species) # Canis lupus familiaris(其他对象不受影响)
这个实验揭示了一个重要细节:通过 类名.类变量 修改会影响到所有对象;而通过 实例.类变量 赋值,Python 会悄悄给这个实例创建一个同名的实例变量,遮蔽类变量,却不会影响其他实例。这是初学者很容易踩的坑,务必牢记。
实例方法、类方法、静态方法
类的"方法"有三种,它们各自有明确的使用场景。李哥画了一张表:
| 方法类型 | 第一参数 | 装饰器 | 什么时候用 | 能访问实例变量吗 | 能访问类变量吗 |
|---|---|---|---|---|---|
| 实例方法 | self |
无 | 处理单个对象的数据和行为 | ✅ | ✅ |
| 类方法 | cls |
@classmethod |
处理类级别的数据,或提供创建对象的替代方式 | ❌ | ✅ |
| 静态方法 | 无特殊参数 | @staticmethod |
与类相关但不需要访问类或实例的工具函数 | ❌ | ❌ |
回到宠物店场景。小王需要从数据库里读出这样一行字符串:"Buddy,3",然后创建狗对象。他可以用类方法来实现这种"替代构造器":
class Dog:
species = 'Canis familiaris'
def __init__(self, name, age):
self.name = name
self.age = age
def bark(self):
return f'{self.name} says Woof!'
# 类方法:第一个参数是 cls,代表类本身
@classmethod
def from_string(cls, data_str):
name, age = data_str.split(',')
return cls(name, int(age))
# 静态方法:既不依赖类,也不依赖实例
@staticmethod
def is_valid_age(age):
return 0 < age < 30
# 正常方式创建
dog1 = Dog('Buddy', 3)
# 用类方法从字符串创建
dog2 = Dog.from_string('Lucky,5')
print(dog1.bark()) # Buddy says Woof!
print(dog2.bark()) # Lucky says Woof!
print(Dog.is_valid_age(10)) # True
print(Dog.is_valid_age(100)) # False
这里的关键区别:
from_string是类方法,因为它需要调用cls(...)来创建对象。它的第一参数是cls,代表当前类。这样写的好处是:如果以后有了Cat子类,Cat.from_string("Whiskers,2")会自动创建出Cat对象,而不是死板地只能创建Dog对象。is_valid_age是静态方法,它只是"跟狗有关的一个工具函数",既不访问实例数据,也不需要创建对象。所以它既不需要self,也不需要cls。
小结
类是模板、图纸、模具;对象是按模板生产出来的具体产品。
__init__负责在"生产"时初始化每个对象的独有数据,self代表当前对象本身。类变量在所有对象间共享,实例变量各管各的。类方法带cls,静态方法啥也不带,两者都是为了解决"这个方法到底属于谁、需要访问什么数据"这个问题。
1.2 魔术方法:让对象"活"起来
故事:对象开始"说人话"
宠物店系统上线后,小王想在日志里打印一只宠物的信息。他拿出刚学到的知识,写了这样一个类:
class Dog:
def __init__(self, name, age):
self.name = name
self.age = age
buddy = Dog('Buddy', 3)
# 小王想把 dog 打印出来看看
print(buddy) # <__main__.Dog object at 0x000001C2B7F2B4A0>
屏幕上出现的是一串天书:<__main__.Dog object at 0x...>。小王心想:我就想看看这只狗叫什么名字、几岁了,至于给我打印出一串内存地址吗?
李哥说:“Python 里有一类方法,前后都有双下划线,比如 __init__。它们被称为魔术方法,或者叫特殊方法、双下划线方法。Python 解释器会在特定时机自动调用它们。你刚才的输出不好看,是因为 print() 在背后调用了一个叫 __str__ 的魔术方法;默认的 __str__ 只会打印对象类型和内存地址。你要做的,就是重写这个魔术方法。”
常见魔术方法一览
在李哥的指导下,小王把 Dog 类升级了:
class Dog:
def __init__(self, name, age):
self.name = name
self.age = age
# 让 print() 输出友好的信息(面向用户)
def __str__(self):
return f'{self.name} ({self.age} years old)'
# 让交互式终端和列表输出更详细的信息(面向开发者)
def __repr__(self):
return f'Dog(name={self.name!r}, age={self.age!r})'
# 比较两只狗是否"相同"
def __eq__(self, other):
if not isinstance(other, Dog):
return NotImplemented
return self.name == other.name and self.age == other.age
# 让对象支持 len(),返回狗的"年龄"作为长度
def __len__(self):
return self.age
# 让对象支持相加操作:两只狗的年龄相加
def __add__(self, other):
if not isinstance(other, Dog):
return NotImplemented
return self.age + other.age
buddy = Dog('Buddy', 3)
buddy_clone = Dog('Buddy', 3)
lucky = Dog('Lucky', 5)
print(buddy) # Buddy (3 years old) <- __str__
print(repr(buddy)) # Dog(name='Buddy', age=3) <- __repr__
print(buddy == buddy_clone) # True <- __eq__
print(buddy == lucky) # False
print(len(buddy)) # 3 <- __len__
print(buddy + lucky) # 8 <- __add__
一瞬间,Dog 对象活了过来:它可以被友好地打印、可以做比较、可以跟 len() 配合、甚至可以用 + 号相加。这就是魔术方法的魔力。
用 Vector 类深入理解魔术方法
为了更系统地展示魔术方法,我们来看一个数学向量的例子。向量有两个分量 x 和 y,支持加法和比较是再自然不过的事:
class Vector:
def __init__(self, x, y):
self.x = x
self.y = y
# 打印对象时自动调用,让输出更友好
def __repr__(self):
return f'Vector({self.x}, {self.y})'
# 使用 + 运算符时自动调用
def __add__(self, other):
return Vector(self.x + other.x, self.y + other.y)
# 使用 == 比较时自动调用
def __eq__(self, other):
return self.x == other.x and self.y == other.y
# 使用 len() 时自动调用
def __len__(self):
# 向量的"长度"在这里定义为四舍五入后的模长
return int((self.x**2 + self.y**2)**0.5)
# 让对象可迭代:依次产出 x 和 y
def __iter__(self):
yield self.x
yield self.y
# 支持负数运算
def __neg__(self):
return Vector(-self.x, -self.y)
# 支持减法
def __sub__(self, other):
return Vector(self.x - other.x, self.y - other.y)
v1 = Vector(3, 4)
v2 = Vector(1, 2)
print(v1) # Vector(3, 4)
print(v1 + v2) # Vector(4, 6)
print(v1 - v2) # Vector(2, 2)
print(v1 == Vector(3, 4)) # True
print(len(v1)) # 5
print(-v1) # Vector(-3, -4)
print(list(v1)) # [3, 4]
你可能会想:这不就是普通的算数吗?我用函数 add(v1, v2) 也能做加法啊。没错,但关键在于表达力。当你写 v1 + v2 时,代码的意图一目了然;而 add(v1, v2) 还需要读者去查这个 add 函数到底干了什么。魔术方法的作用,就是让自定义类型融入 Python 语言本身,享受跟内建类型(如 int、str、list)完全相同的"待遇"。
魔术方法背后的调用机制
李哥特别强调了一个容易误解的点:
你写
v1 + v2,Python 其实做的是v1.__add__(v2)。你写len(v1),Python 做的是v1.__len__()。你写str(v1),Python 做的是v1.__str__()。魔术方法永远不需要你手动调用,它们由 Python 在适当的语法场景下自动触发。
常用魔术方法速查表:
| 魔术方法 | 触发时机 | 用途 |
|---|---|---|
__init__(self, ...) |
创建对象时 | 初始化实例变量 |
__str__(self) |
print()、str() |
面向用户的友好输出 |
__repr__(self) |
交互式终端、repr()、容器内部展示 |
面向开发者的调试输出 |
__eq__(self, other) |
== 比较 |
判断两个对象是否相等 |
__lt__(self, other) |
<、sorted() |
自定义排序规则 |
__len__(self) |
len() |
返回容器长度 |
__add__(self, other) |
+ |
加法运算 |
__sub__(self, other) |
- |
减法运算 |
__getitem__(self, key) |
obj[key] |
让对象像列表/字典一样索引 |
__setitem__(self, key, value) |
obj[key] = value |
让对象支持索引赋值 |
__call__(self, ...) |
obj() |
让对象可以像函数一样被调用 |
__enter__ / __exit__ |
with 语句 |
上下文管理 |
小结
魔术方法就像是给对象安装的"传感器":当 Python 的语言符号(
+、==、len()、print()……)触碰到对象时,对应的传感器被激活,从而执行你预先定义好的逻辑。用好魔术方法,你写的类就能像内建类型一样自然、流畅、充满 Python 味。
二、封装:给数据装上"安全门"
2.1 属性访问控制
故事:一个"可以随便改"的银行账户
宠物店项目暂时稳定了。小李(客户那边的负责人)又找上了小王:“我们宠物店要上线一个会员储值功能,宠物主人可以先充值,之后消费直接扣余额。”
小王脑子一转,这不简单吗?他写下了第一个版本:
class MemberAccount:
def __init__(self, balance):
self.balance = balance
account = MemberAccount(200)
# 顾客消费 30 元
account.balance -= 30
print(account.balance) # 170
功能确实能用。但第二天,客户发现数据库里出现了好几条余额为负数的记录。原来,某个前端页面在计算折扣时出了问题,直接执行了:
account.balance = -500
一行代码就绕过了所有业务校验。李哥问小王:"你觉得问题出在哪里?"小王想了想:“balance 这个属性太’开放’了,任何人都能绕过业务逻辑直接改它。”
李哥点点头:“这就是**封装(Encapsulation)**要解决的问题。封装的核心理念是:把数据藏起来,只暴露必要的操作接口。就像银行不可能让储户自己打开金库门随便放钱取钱,储户必须通过柜台或者 ATM 操作。放到代码里,余额的修改必须通过存款、消费这些业务方法来完成,而不是直接赋值。”
三条访问级别
Python 没有像 Java、C++ 那样强制性的访问控制关键字(private、protected),而是靠一套约定俗成的命名规则:
| 写法 | 级别 | 访问规则 | 实际效果 |
|---|---|---|---|
self.balance |
公开(public) | 任何人都能读写 | 没有保护 |
self._balance |
受保护(protected) | 约定上不建议外部访问 | 外部仍可访问,但 IDE 会提示 |
self.__balance |
私有(private) | 外部"比较难"访问 | Python 做名称改写,变成 _类名__balance |
李哥提醒小王:“注意我的用词——Python 的私有是’比较难’访问,而不是’无法访问’。Python 哲学里有一句名言:我们都是成年人了(We are all consenting adults)。意思是:开发者被信任能做出正确的选择。下划线只是告诉别人’这个东西是内部实现细节,别碰’,真要碰,技术上也拦不住。”
正确的封装姿势
小王重写了会员账户类:
class MemberAccount:
def __init__(self, balance=0):
self._balance = balance # 受保护属性:约定上不直接访问
self.__transactions = [] # 私有属性:内部流水记录
# property 让外部可以像读取属性一样读取余额
@property
def balance(self):
return self._balance
# 设置余额时自动做合法性校验
@balance.setter
def balance(self, value):
if value < 0:
raise ValueError('Balance cannot be negative')
self._balance = value
# 存款
def deposit(self, amount):
if amount <= 0:
raise ValueError('Deposit amount must be positive')
self._balance += amount
self.__transactions.append(('deposit', amount))
# 消费
def withdraw(self, amount):
if amount <= 0:
raise ValueError('Withdraw amount must be positive')
if amount > self._balance:
raise ValueError('Insufficient balance')
self._balance -= amount
self.__transactions.append(('withdraw', amount))
# 只读访问交易流水
def show_transactions(self):
return list(self.__transactions)
account = MemberAccount(200)
account.deposit(50)
account.withdraw(30)
print(account.balance) # 220
# 尝试非法操作
try:
account.withdraw(9999)
except ValueError as e:
print(e) # Insufficient balance
# 直接赋值非法余额会被拦截
try:
account.balance = -100
except ValueError as e:
print(e) # Balance cannot be negative
现在的 MemberAccount 类就像真正的银行系统:
- 余额只能通过
deposit()和withdraw()两个"业务入口"修改; @property让外部代码依然可以写account.balance来读取,体验不变;- 尝试设置非法余额会立刻抛出异常;
- 交易流水
__transactions被彻底藏了起来,外部只能通过show_transactions()方法拿到副本。
@property 到底做了什么
@property 装饰器把"调用方法"变成了"访问属性"的体验。这在 Python 中是一个重要的设计思想:对外提供稳定的属性访问,对内保留灵活的实现逻辑。
一个经典的好处是:假设你最初把 balance 设计成一个普通属性:
class Account:
def __init__(self):
self.balance = 0
后来产品需求变了:余额必须是"账户总额 - 冻结金额"的计算结果。如果一开始用的是普通属性,所有调用 account.balance 的地方都要改吗?不必——你把 balance 改成 @property,外部调用代码一行都不需要动:
class Account:
def __init__(self, total, frozen):
self._total = total
self._frozen = frozen
@property
def balance(self):
return self._total - self._frozen
这就是封装的另一层价值:在不破坏外部接口的前提下,自由地调整内部实现。
2.2 封装在现实中的影子
封装不只存在于代码里。李哥给小王举了几个生活中的例子:
例子一:咖啡机。 你按下"美式"按钮,咖啡机内部要完成磨豆、加热、注水、冲泡等一系列步骤。但对用户来说,接口只有几个按钮。没有人会打开咖啡机的后盖去手动控制锅炉温度。咖啡机的"内部复杂性"被封装起来了。
例子二:体温计。 体温计测量体温,会直接给你一个数字。它不会告诉你热敏电阻的电压变化,也不需要你了解模数转换器的原理。
例子三:汽车。 你踩下油门,汽车前进。你不需要知道工程师怎么设计变速箱,也不关心喷油嘴怎么喷油。对驾驶员来说,方向盘、油门、刹车就是全部接口。
回到代码世界,封装的收益可以总结为三点:
- 安全性:数据不会被随意篡改。想象一下,任何一个模块都能直接把余额改成负数,系统还能稳定吗?
- 可维护性:内部实现可以自由调整,只要对外接口不变,调用方就无感知。
- 职责清晰:类的用户只需要关心"我能做什么"(方法),不需要理解"它内部怎么做"(数据)。
一个"反例"与"正例"的对决
小王还从李哥那里学到了一个判断封装好坏的实用技巧:看一个类暴露的公开接口是否足够少、足够清晰。下面这段代码是典型的"反例"——把所有东西都堆在外部:
# 反例:没有封装,业务规则散落各处
class Order:
pass
order = Order()
order.items = []
order.total = 0
order.discount = 0
order.tax = 0
# 业务规则全部由外部代码承担
order.items.append(('dog_shampoo', 50))
order.total += 50
order.discount = order.total * 0.1
order.tax = (order.total - order.discount) * 0.06
再对比下面的"正例"——业务规则收进类内部:
# 正例:业务规则封装在类里
class Order:
def __init__(self):
self._items = []
self._total = 0
def add_item(self, name, price):
if price < 0:
raise ValueError('Price cannot be negative')
self._items.append((name, price))
self._total += price
@property
def total(self):
return self._total
@property
def discount(self):
if self._total > 300:
return self._total * 0.1
return 0
@property
def tax(self):
return (self._total - self.discount) * 0.06
@property
def final_amount(self):
return self._total - self.discount + self.tax
order = Order()
order.add_item('dog_shampoo', 50)
order.add_item('cat_food', 120)
order.add_item('dog_toy', 180)
print(order.total) # 350
print(order.discount) # 35.0(满 300 打九折)
print(order.tax) # 18.9
print(order.final_amount) # 333.9
高下立判。正例里,折扣规则、税费计算全部收进了类内部,外部调用者只需要 add_item() 加商品,然后读 final_amount 即可。以后就算折扣规则改为"满 500 减 80",也只需要修改 discount 这一个地方。
小结
封装的核心口号是:数据私有化,接口公开化。内部数据用下划线保护起来,外部只能通过经过设计的公开方法来操作。
@property让"方法"长得像"属性",兼顾了安全性和使用体验。当你在代码里发现某个数据被外部肆意修改、业务规则散落各处时,那就是需要封装的信号。
三、继承与多态:代码复用的利器
3.1 继承
故事:宠物店里的"动物家族"
宠物店管理系统的需求更新了:不仅要管理狗,还要管理猫、兔子、鹦鹉。小王看着自己写的 Dog 类,陷入沉思——难道要把 bark() 换成 meow(),再把 Dog 复制三份改成 Cat、Rabbit、Parrot?每个类都重复写着 name、age、__init__,代码越来越臃肿。
李哥说:“狗、猫、兔子、鹦鹉,它们虽然不同,但都是动物。它们共享一些共同的特征——都有名字、都有年龄、都能发出声音。与其在每个类里重复这些代码,不如提取出一个父类 Animal,把公共代码放进去,再让 Dog、Cat 去继承它。”
这就是继承(Inheritance):子类从父类那里获得属性和方法,实现了代码复用。
class Animal:
"""所有动物的父类"""
def __init__(self, name, age):
self.name = name
self.age = age
def eat(self):
return f'{self.name} is eating.'
def sleep(self):
return f'{self.name} is sleeping.'
def speak(self):
# 父类不实现具体逻辑,要求子类必须重写
raise NotImplementedError('Subclass must implement speak()')
class Dog(Animal):
def speak(self):
return f'{self.name} says Woof!'
def fetch(self):
return f'{self.name} runs to fetch the ball.'
class Cat(Animal):
def speak(self):
return f'{self.name} says Meow!'
def chase_mouse(self):
return f'{self.name} is chasing a mouse.'
class Rabbit(Animal):
def speak(self):
return f'{self.name} says no sound, just hops.'
def hop(self):
return f'{self.name} hops around happily.'
# 使用
buddy = Dog('Buddy', 3)
whiskers = Cat('Whiskers', 1)
fluffy = Rabbit('Fluffy', 2)
print(buddy.eat()) # Buddy is eating.(继承自父类)
print(whiskers.eat()) # Whiskers is eating.(继承自父类)
print(buddy.speak()) # Buddy says Woof!(子类自己的实现)
print(whiskers.speak()) # Whiskers says Meow!(子类自己的实现)
print(fluffy.hop()) # Fluffy hops around happily.(子类自己的方法)
在这个设计里:
Animal定义了所有动物的公共部分:name、age、eat()、sleep();Dog、Cat、Rabbit都继承自Animal,自动获得了这些属性和方法;- 每个子类重写了
speak(),因为每种动物的叫声不同; - 每个子类还可以增加自己独有的方法,如
fetch()、chase_mouse()、hop()。
继承的层级设计:is-a 关系
使用继承前,一定要问自己一个问题:"子类 is a 父类"成立吗?
- 狗 是 动物?成立。 ✅
- 猫 是 动物?成立。 ✅
- 汽车 是 发动机?不成立。 ❌
- 员工 是 公司?不成立。 ❌
继承表达的是**“是一种”**关系,而不是"包含"关系。如果把"发动机"写成"汽车"的父类,那整个设计就错了。
深入理解 super():如何正确地调用父类方法
子类重写 __init__ 时,如果还想使用父类的初始化逻辑,就要用到 super():
class Animal:
def __init__(self, name, age):
self.name = name
self.age = age
print(f'动物 "{name}" 已创建。')
class Dog(Animal):
def __init__(self, name, age, breed):
# 先调用父类的 __init__,完成 name 和 age 的初始化
super().__init__(name, age)
# 再处理子类自己的新属性
self.breed = breed
def speak(self):
return f'{self.name} says Woof!'
buddy = Dog('Buddy', 3, 'Golden Retriever')
# 输出:动物 "Buddy" 已创建。
print(buddy.name) # Buddy
print(buddy.age) # 3
print(buddy.breed) # Golden Retriever
super().__init__(name, age) 就是"调用父类的 __init__ 方法"。这个技巧非常关键:当父类有重要的初始化逻辑时,子类必须通过 super() 把它执行到位,否则父类中定义的属性就不会被设置。
3.2 多态
故事:一个循环遍历所有动物
宠物店系统里有一个"晨间点名"功能,每天早上要播放所有宠物的叫声。小王最初的想法是写一堆 if 判断:
animals = [dog, cat, rabbit]
for animal in animals:
if isinstance(animal, Dog):
print(animal.speak())
elif isinstance(animal, Cat):
print(animal.speak())
elif isinstance(animal, Rabbit):
print(animal.speak())
代码又丑又脆弱。如果以后增加一只鹦鹉,这个 if-elif 链还得继续加。
李哥说:“用多态。多态(Polymorphism) 的意思是:不同的对象对同一个消息做出不同的响应。你不需要知道每个对象具体是哪种动物,只需要统一调用 speak(),剩下的交给对象自己决定。”
animals = [Dog('Buddy', 3), Cat('Whiskers', 1), Rabbit('Fluffy', 2)]
for animal in animals:
print(animal.speak())
# 输出:
# Buddy says Woof!
# Whiskers says Meow!
# Fluffy says no sound, just hops.
这就是多态的精髓:一个接口,多种形态。speak() 这个消息只有一个,但不同的对象接收到后,会执行各自的版本。而且,以后添加新的动物类型(比如 Parrot),旧的循环代码一行都不用改,只需要新写一个继承自 Animal 的类即可。
多态的现实例子
李哥给小王的比喻是:你家电视、空调、音响,各自都有一个"电源开关"。你不需要知道电视内部是液晶面板还是 OLED,也不需要知道空调是变频还是定频,你只需要按"开关",设备自然就执行了正确的开机逻辑。这个"统一的开关接口"就是多态。
再比如,餐馆里顾客说"来一份招牌菜",中餐厨师会炒一盘宫保鸡丁,西餐厨师会煎一块牛扒,日料厨师会捏几个寿司。顾客不需要指定"按中餐做法做",指令一样,响应各不相同。
多态的两种实现方式
Python 的多态有两种常见形态:继承多态和鸭子类型。
继承多态就是上面演示的:子类重写父类方法,通过父类类型的引用统一调用。
鸭子类型是 Python 特有的。正如那句名言:“如果一只鸟走起来像鸭子、游起来像鸭子、叫起来像鸭子,那么它就可以被当作鸭子。”
class Duck:
def speak(self):
return 'Quack! Quack!'
class Person:
def speak(self):
return 'Hello, I am a person!'
class Bell:
def speak(self):
return 'Ding-dong!'
def make_sound(thing):
# 我们根本不管 thing 是什么类型,
# 只要它有 speak 方法,就能被调用
return thing.speak()
print(make_sound(Duck())) # Quack! Quack!
print(make_sound(Person())) # Hello, I am a person!
print(make_sound(Bell())) # Ding-dong!
在这个例子里,make_sound 函数完全没有要求传入对象必须继承哪个父类。它只认一件事:你有没有 speak() 方法。有,就能用。这种灵活性让 Python 代码可以非常简洁地实现多态,但也要求开发者更自律。
3.3 多重继承与 MRO
故事:一只会游泳又会飞的鸭子
宠物店里来了一位特殊顾客,牵着一只鸭子。店主为难了:这只鸭子既会游泳,又会飞,还会叫。用单继承怎么建模?让 Duck 只继承 Animal 显然无法体现它会游泳、会飞的特点。
Python 答案:多重继承。一个类可以同时继承多个父类:
class Swimmer:
def swim(self):
return 'Swimming in the pond.'
class Flyer:
def fly(self):
return 'Flying in the sky.'
class Duck(Swimmer, Flyer):
pass
duck = Duck()
print(duck.swim()) # Swimming in the pond.
print(duck.fly()) # Flying in the sky.
Duck 同时继承了 Swimmer 和 Flyer,所以它天然就会 swim() 和 fly()。这在概念上很美好,但引发了一个严肃的问题:如果两个父类有同名方法,Python 该调用哪个?
MRO:方法解析顺序
假设我们有这样的类结构:
class A:
def who_am_i(self):
return 'I am A'
class B(A):
def who_am_i(self):
return 'I am B'
class C(A):
def who_am_i(self):
return 'I am C'
class D(B, C):
pass
d = D()
print(d.who_am_i()) # I am B
D 继承了 B 和 C,而 B 和 C 都重写了 who_am_i()。Python 到底调用哪个?答案是 B 的版本。为什么?因为 MRO(Method Resolution Order,方法解析顺序) 决定了方法查找的顺序。
你可以用 __mro__ 属性查看任何类的完整查找顺序:
print(D.__mro__)
# (<class '__main__.D'>, <class '__main__.B'>, <class '__main__.C'>, <class '__main__.A'>, <class 'object'>)
解读:当调用 d.who_am_i() 时,Python 按照 D → B → C → A → object 的顺序依次查找,命中第一个定义了 who_am_i 的类就停。B 在最前面,所以 B.who_am_i 被调用。
MRO 背后是一个叫 C3 线性化算法 的复杂机制,对于日常开发,你只需要记住两条直觉规则:
- 子类优先于父类:查找顺序中,子类永远排在父类前面。
- 从左到右:多重继承时,写在括号前面的父类优先级更高。
class D(B, C)中,B优先于C。
菱形继承:多重继承最典型的问题
在上面的例子里,B 和 C 都继承自 A,这就形成了一颗"菱形"。如果 B 和 C 都没有定义 who_am_i,而 A 定义了,调用 d.who_am_i() 时就会一路走到 A。那么问题来了:A 的方法会不会被调用两次?
不会。Python 的 MRO 保证了每个类在查找序列中只出现一次。这就是 C3 线性化的功劳。换句话说,D 的 MRO 是 D → B → C → A → object,A 只出现一次,方法不会被重复执行。
class A:
def who_am_i(self):
return 'I am A'
class B(A):
pass
class C(A):
pass
class D(B, C):
pass
d = D()
print(d.who_am_i()) # I am A
print(D.__mro__)
# (<class '__main__.D'>, B, C, A, object)
再来看一个更贴近业务的例子:
class Swimmer:
def __init__(self):
self.can_swim = True
super().__init__()
class Flyer:
def __init__(self):
self.can_fly = True
super().__init__()
class Duck(Swimmer, Flyer):
def __init__(self):
super().__init__()
self.name = 'Duck'
duck = Duck()
print(duck.can_swim) # True
print(duck.can_fly) # True
print(duck.name) # Duck
这个例子非常神奇:Swimmer.__init__ 和 Flyer.__init__ 都被执行了!靠的就是 super() 配合 MRO 的协作式多继承。Duck.__init__ 里的 super() 顺着 MRO(Duck → Swimmer → Flyer → object)逐级调用,所以两个父类的初始化都被触发了。
多重继承的使用建议
多重继承威力强大,但也容易造成代码难以理解。李哥的建议有三条:
- 能不用就不用。绝大多数场景,单继承加组合就足够了。
- 如果必须用,优先继承 “Mix-in” 风格的轻量类。所谓 Mix-in,就是那些只提供一到两个方法的"能力类",比如
Swimmer、Flyer,它们没有复杂的状态和层级。 - 避免菱形继承中的状态冲突。如果多个父类都定义了同名属性,调试起来会非常痛苦。
小结
继承表达"是一种"关系,让子类共享父类的代码;
super()负责正确调用父类的逻辑。多态是"一个接口、多种形态",让代码在运行时才决定具体执行哪个版本。多重继承是 Python 的强大武器,用 MRO 保证查找顺序可预测;但请克制使用,优先考虑组合。
四、设计模式:常见问题的标准解法
代码写得多了,你就会发现:很多问题虽然业务场景不同,但本质结构是一样的。前辈工程师们总结出了这些反复出现的问题的标准解法,这就是设计模式(Design Patterns)。它就像武术中的套路:经过千锤百炼,能让你在特定情境下快速出拳。
4.1 单例模式
故事:系统里有几个"数据库连接"?
宠物店系统要把数据存到数据库。每个数据库连接都要占用系统资源,如果每个请求都新建一个连接,系统很快就会崩溃。理想的方案是:整个程序运行期间,数据库连接池只有一个实例,所有人都共享它。
这就是单例模式(Singleton Pattern):确保一个类只有一个实例,并且提供一个全局访问点。
class Singleton:
_instance = None
def __new__(cls):
# 如果还没有实例,就创建一个
if cls._instance is None:
cls._instance = super().__new__(cls)
# 否则直接返回已有实例
return cls._instance
s1 = Singleton()
s2 = Singleton()
print(s1 is s2) # True
深入理解 new 与 init 的分工
你可能会有疑问:“平时创建对象不都是靠 __init__ 吗?这里怎么出现了一个 __new__?”
李哥画了一张图来解释:
__new__负责"造出对象",__init__负责"给对象填数据"。__new__在__init__之前被调用,它的任务是创建并返回实例对象本身。只有__new__有能力控制"是否真的创建一个新对象"。
在单例模式里,__new__ 被拦截了:第一次调用时,_instance 是 None,于是创建新对象;第二次调用时,_instance 已经有值了,直接返回这个旧对象。结果就是无论你调用多少次 Singleton(),得到的永远是同一个对象。
一个更实用的单例:配置管理器
class ConfigManager:
_instance = None
_initialized = False
def __new__(cls, *args, **kwargs):
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance
def __init__(self):
# 关键:确保初始化只执行一次
if not self._initialized:
self.config = {}
self._initialized = True
def set(self, key, value):
self.config[key] = value
def get(self, key):
return self.config.get(key)
# 任何地方拿到配置管理器,都是同一个实例
cm1 = ConfigManager()
cm1.set('database_url', 'mysql://localhost:3306')
cm2 = ConfigManager()
print(cm2.get('database_url')) # mysql://localhost:3306
print(cm1 is cm2) # True
这里的 _initialized 标志位很关键:虽然 __new__ 保证了实例唯一,但 __init__ 每次创建时依然会被调用。如果不加防重复初始化标志,cm2 = ConfigManager() 时就会把配置清空。
何时使用单例模式
适用的场景:数据库连接池、配置管理、日志记录器、线程池、缓存等"整个程序只需要一个"的资源。
需要警惕的场景:不要为了用而用。很多人为了追求"高端",到处套单例模式,结果导致全局状态满天飞,程序难以测试和维护。判断标准很简单:这个类如果创建多个实例,会不会产生逻辑错误或资源浪费? 会,就考虑单例;不会,就不要用。
4.2 工厂模式
故事:宠物店的"流水线"上堆满了 if-else
随着业务发展,宠物店引入了更多类型的宠物:狗、猫、兔子、鹦鹉、仓鼠、乌龟……小王发现,创建宠物的代码里出现了大量重复的 if-elif-else:
def create_pet(pet_type, name, age):
if pet_type == 'dog':
return Dog(name, age)
elif pet_type == 'cat':
return Cat(name, age)
elif pet_type == 'rabbit':
return Rabbit(name, age)
elif pet_type == 'parrot':
return Parrot(name, age)
elif pet_type == 'hamster':
return Hamster(name, age)
elif pet_type == 'turtle':
return Turtle(name, age)
else:
raise ValueError(f'Unknown pet type: {pet_type}')
这段代码不仅放在一个函数里,还散落在系统各处。每次新增一种宠物,小王都要满世界找这些判断分支。
李哥说:“把创建对象的逻辑集中到一个’工厂’里,调用方不需要知道具体的创建细节。这就是工厂模式(Factory Pattern)。”
class PetFactory:
@staticmethod
def create(pet_type, name, age):
if pet_type == 'dog':
return Dog(name, age)
elif pet_type == 'cat':
return Cat(name, age)
elif pet_type == 'rabbit':
return Rabbit(name, age)
elif pet_type == 'parrot':
return Parrot(name, age)
else:
raise ValueError(f'Unknown pet type: {pet_type}')
# 调用方只需要跟工厂打交道
pet = PetFactory.create('dog', 'Buddy', 3)
print(type(pet).__name__) # Dog
工厂模式的收益是显而易见的:新增宠物类型时,只需要修改 PetFactory.create 这一个方法;所有调用方不需要做任何改动。
工厂模式的变体:注册式工厂 + 反射
上面的简单工厂还是需要 if-elif。Python 提供了更优雅的字典映射方式:
class Dog:
def __init__(self, name, age):
self.name = name
self.age = age
def speak(self):
return 'Woof!'
class Cat:
def __init__(self, name, age):
self.name = name
self.age = age
def speak(self):
return 'Meow!'
class PetFactory:
# 注册表:类型名 -> 类
_registry = {}
@classmethod
def register(cls, pet_type):
"""类装饰器:注册宠物类型"""
def decorator(pet_class):
cls._registry[pet_type] = pet_class
return pet_class
return decorator
@classmethod
def create(cls, pet_type, name, age):
pet_class = cls._registry.get(pet_type)
if pet_class is None:
raise ValueError(f'Unknown pet type: {pet_type}')
return pet_class(name, age)
@PetFactory.register('dog')
class Dog:
pass
@PetFactory.register('cat')
class Cat:
pass
# 创建宠物
pet = PetFactory.create('cat', 'Whiskers', 1)
print(type(pet).__name__) # Cat
这种"注册式工厂"极其灵活:以后添加新宠物,只需要在类定义上加上 @PetFactory.register('新类型'),工厂就会自动认识它,完全不需要修改工厂内部的任何代码。
工厂模式在现实中的影子
现实生活中,工厂模式的影子无处不在:奶茶店的点单系统就是工厂。顾客说"一杯珍珠奶茶",店员(工厂)根据订单制作出对应的饮品,顾客不需要了解珍珠是怎么煮的、奶茶是怎么泡的。餐厅厨房、汽车总装线、蛋糕店……都是工厂模式的原型。
4.3 观察者模式
故事:宠物店发优惠通知
宠物店运营经理提了一个需求:每当有新宠物到店,系统要自动通知所有订阅了"新宠提醒"的会员(短信、邮件、APP 推送一个都不能少)。
小王最初用了最暴力的方法:
def new_pet_arrived(pet_info):
send_sms(pet_info)
send_email(pet_info)
send_app_push(pet_info)
但需求持续变化:有些会员只订阅短信,有些只要 APP 推送;以后可能还要接入微信通知、站内信……小王改来改去,代码越来越混乱。
李哥说:“这是典型的观察者模式(Observer Pattern)。核心思想是:一个被观察者(Subject)维护一个观察者列表,当它的状态改变时,自动通知列表中的所有观察者。观察者可以随时订阅(attach)或退订(detach),被观察者完全不需要知道谁在关注它。”
class Subject:
"""被观察者:宠物店"""
def __init__(self):
self._observers = [] # 订阅者列表
def attach(self, observer):
"""新增订阅者"""
self._observers.append(observer)
def detach(self, observer):
"""移除订阅者"""
self._observers.remove(observer)
def notify(self, message):
"""通知所有订阅者"""
for observer in self._observers:
observer.update(message)
class SmsObserver:
def update(self, message):
print(f'[短信] {message}')
class EmailObserver:
def update(self, message):
print(f'[邮件] {message}')
class AppPushObserver:
def update(self, message):
print(f'[APP推送] {message}')
# 使用
store = Subject()
store.attach(SmsObserver())
store.attach(EmailObserver())
store.attach(AppPushObserver())
# 新宠到店,一次通知三个渠道
store.notify('新到店:3岁金毛 Buddy,欢迎选购!')
# 输出:
# [短信] 新到店:3岁金毛 Buddy,欢迎选购!
# [邮件] 新到店:3岁金毛 Buddy,欢迎选购!
# [APP推送] 新到店:3岁金毛 Buddy,欢迎选购!
# 某个会员退订了短信
sms_observer = SmsObserver()
store.attach(sms_observer)
store.detach(sms_observer)
这个设计优雅在:添加新的通知渠道不需要改动 Subject 类。你只需要写一个新的观察者类,然后 attach 一下就行了。这完美符合了后面要讲的 SOLID 原则中的"开闭原则"——对扩展开放,对修改关闭。
现实中的观察者模式
微信公众号的"订阅-推送"机制就是最典型的观察者模式:你(观察者)订阅了一个公众号(被观察者),公众号发新文章时,你和其他订阅者都会收到推送;你取关后就不再收到。股票行情软件、RSS 订阅、MQTT 消息队列……背后都有观察者模式的影子。
小结
单例模式解决"只有一个实例"的问题,工厂模式解决"创建对象的复杂性"问题,观察者模式解决"一对多通知"的问题。它们不是教条,而是前人总结的高效套路。学习设计模式最大的收获不是记住代码,而是培养识别问题本质的能力:看到"全局唯一资源"就想到单例,看到"分散创建逻辑"就想到工厂,看到"状态变化通知多个依赖者"就想到观察者。
五、SOLID 原则:面向对象的设计哲学
系统稳定运行了一个阶段。但随着功能不断增加,小王发现自己改一个需求,常常要连带修改多处代码;写一个测试,要花大量的精力去 mock 各种依赖。他问李哥:“到底什么样的面向对象设计才算’好’?有没有一套客观的标准?”
李哥说:“有。SOLID 原则就是面向对象设计的五条黄金准则。SOLID 是五个单词首字母的缩写:S 单一职责、O 开闭原则、L 里氏替换、I 接口隔离、D 依赖倒置。它们不是死命令,而是帮助我们识别’代码坏味道’的指南针。”
5.1 S — 单一职责原则
一句话:一个类只应该有一个引起它变化的原因。
翻译成人话:一个类只负责一件事。如果一个类什么都做,那它就会因为各种原因被反复修改,代码耦合度急剧上升。
反例:宠物店系统里的"上帝类"——它既处理宠物信息,又处理支付逻辑,还处理数据库连接:
class PetStore:
def add_pet(self, pet):
# 业务逻辑:添加宠物
...
def checkout(self, amount):
# 支付逻辑
...
def query_pet_by_name(self, name):
# 数据库查询
...
def send_email(self, to, content):
# 邮件发送
...
这个类有四个职责,任何一个需求变化都要动它,如同一个穿着四种制服的人,什么都要掺和一脚。
正例:
class PetRepository:
"""只负责宠物数据的持久化"""
def add(self, pet):
...
def query_by_name(self, name):
...
class PaymentService:
"""只负责支付"""
def checkout(self, amount):
...
class EmailService:
"""只负责邮件发送"""
def send(self, to, content):
...
修改支付逻辑时,不会波及宠物数据的处理;修改邮件模板时,不影响支付。各有各的"一亩三分地"。
5.2 O — 开闭原则
一句话:软件实体应该对扩展开放,对修改关闭。
意思是:当需求变化时,你应该通过新增代码来扩展功能,而不是修改已有代码。修改已有代码的风险在于,你可能会破坏原本正常工作的功能。
反例:宠物种类每新增一种,都要修改 speak 逻辑:
def make_sound(pet_type):
if pet_type == 'dog':
return 'Woof!'
elif pet_type == 'cat':
return 'Meow!'
# 每次新增宠物都要进来修改这个函数
正例:通过多态实现——新增宠物类型只需新增子类,不必修改已有函数:
class Dog(Animal):
def speak(self):
return 'Woof!'
class Cat(Animal):
def speak(self):
return 'Meow!'
# 新增一种宠物,完全不需要改上面的代码
class Parrot(Animal):
def speak(self):
return 'Squawk!'
make_sound 函数不需要任何改动,因为新增的 Parrot 类自动融入了多态体系。这就是"对扩展开放,对修改关闭"。
5.3 L — 里氏替换原则
一句话:子类必须能够替换它的父类,而程序行为不变。
反例:有一个"鸟"类,子类"企鹅"不会飞,却被迫实现了 fly():
class Bird:
def fly(self):
return 'Flying in the sky.'
class Penguin(Bird):
def fly(self):
# 企鹅根本不会飞!
raise NotImplementedError('Penguin cannot fly!')
如果某段代码拿着 Bird 引用调用 fly(),遇到 Penguin 时程序就崩了。这不是好的继承设计。
正例:将"飞的行为"从 Bird 中移除,换成更细粒度的设计:
class Bird:
def eat(self):
return 'Eating.'
class FlyingBird(Bird):
def fly(self):
return 'Flying.'
class Sparrow(FlyingBird):
pass
class Penguin(Bird):
def swim(self):
return 'Swimming.'
Sparrow 会飞,所以它继承 FlyingBird;Penguin 不会飞,它只继承 Bird,另加一个 swim()。这样,任何接受 FlyingBird 的函数都不会担心拿到一只不会飞的企鹅。这里要特别注意:使用继承前,检验"子类是否真的能替换父类"是必须做的功课。
5.4 I — 接口隔离原则
一句话:不应该强迫一个类依赖它不使用的方法。
简单来说:接口要小而精,不要大而全。
反例:一个庞大的"宠物服务"接口(Python 中用类模拟):
class PetService:
def groom(self):
raise NotImplementedError
def feed(self):
raise NotImplementedError
def breed(self):
raise NotImplementedError
def train(self):
raise NotImplementedError
子类 DogService 继承了这个接口,但"繁殖"和"训练"它根本用不上,却被迫实现了空方法。这是对子类的"债"。
正例:拆分成细粒度接口:
class Groomable:
def groom(self):
raise NotImplementedError
class Feedable:
def feed(self):
raise NotImplementedError
class Trainable:
def train(self):
raise NotImplementedError
class DogService(Groomable, Feedable):
def groom(self):
return 'Grooming the dog.'
def feed(self):
return 'Feeding the dog.'
DogService 只继承它真正需要的接口。干净、清晰、没有包袱。这跟前面讲的"多重继承"呼应了:Python 中接口隔离常常通过 Mix-in 来实现。
5.5 D — 依赖倒置原则
一句话:高层模块不应该依赖低层模块,两者都应该依赖抽象。
说人话就是:代码应该依赖"抽象的接口",而不是"具体的实现"。
反例:宠物店系统直接依赖 MySQL:
class MySQLDatabase:
def save(self, data):
print('Saving to MySQL.')
class PetStore:
def __init__(self):
# 硬编码依赖 MySQL
self.db = MySQLDatabase()
def add_pet(self, pet):
self.db.save(pet)
如果有一天老板说"我们改用 PostgreSQL",PetStore 就要被修改。依赖太具体,灵活性就消失了。
正例:抽象出 Database 接口,让 PetStore 只依赖接口:
class Database:
def save(self, data):
raise NotImplementedError
class MySQLDatabase(Database):
def save(self, data):
print('Saving to MySQL.')
class PostgreSQLDatabase(Database):
def save(self, data):
print('Saving to PostgreSQL.')
class PetStore:
def __init__(self, db: Database):
# 依赖抽象,而不是具体实现
self.db = db
def add_pet(self, pet):
self.db.save(pet)
# 切换数据库时,只需要传入不同的对象
store = PetStore(MySQLDatabase())
store.add_pet('Buddy')
store = PetStore(PostgreSQLDatabase())
store.add_pet('Buddy')
PetStore 只认 Database 这个抽象,具体是 MySQL 还是 PostgreSQL,它不在乎。这就是"依赖倒置"——依赖关系从"指向具体"反转为"指向抽象"。
5.6 SOLID 原则在宠物店系统中的应用
小王听完后,把宠物店系统重新过了一遍:
- 单一职责:把"宠物数据存储"和"支付服务"分离成两个独立的类,以后改支付不会再破坏宠物数据。
- 开闭原则:新增宠物类型时,新写一个
Animal子类即可,不修改现有代码。 - 里氏替换:重新检讨继承关系,确保
Dog真的可以完全替换Animal使用。 - 接口隔离:把"会叫、会游泳、会飞"拆成细粒度 Mix-in,各个宠物按需继承。
- 依赖倒置:把数据库、邮件服务等依赖抽象化,这样更换底层服务不必动业务代码。
以下是一个综合的 SOLID 原则示例——宠物店收款模块:
from abc import ABC, abstractmethod
# 抽象:支付渠道接口(依赖倒置 + 接口隔离)
class PaymentChannel(ABC):
@abstractmethod
def pay(self, amount):
pass
class WeChatPay(PaymentChannel):
def pay(self, amount):
return f'微信支付 {amount} 元'
class Alipay(PaymentChannel):
def pay(self, amount):
return f'支付宝支付 {amount} 元'
# 单一职责:订单类只负责订单信息
class Order:
def __init__(self, total):
self.total = total
self.status = '待支付'
# 单一职责 + 依赖倒置:收银台只负责收款,不关心具体渠道
class Cashier:
def checkout(self, order: Order, channel: PaymentChannel):
result = channel.pay(order.total)
order.status = '已支付'
return result
# 使用(将来增加新支付渠道,只需新增一个 PaymentChannel 子类)
cashier = Cashier()
order = Order(200)
print(cashier.checkout(order, WeChatPay())) # 微信支付 200 元
print(order.status) # 已支付
小结
SOLID 不是教条,是五种"品味好代码"的角度。当你感到某个类越写越大,就是在提醒你"单一职责";当你要修改已经上线了的代码,就是在提醒你"开闭原则";当你担心子类能不能替换父类,就是在应用"里氏替换";当接口庞大臃肿时,就是"接口隔离";当你硬编码依赖某项具体技术时,就是"依赖倒置"。练习久了,这些原则会内化成你的直觉。
六、Python 现代 OOP 特性
Python 从未停止进化。3.7 版本之后,Python 为标准库添加了多项面向对象编程的现代化工具,它们让类定义更加简洁、类型表达更加清晰、内存使用更加高效。作为现代 Python 开发者,这些特性值得你认真掌握。
6.1 dataclass:告别样板代码
故事:定义数据类太枯燥了
宠物店系统中有大量"数据类"——宠物档案、会员信息、订单记录。它们的特点是:主要存放数据,方法很少。传统写法的样板代码多到令小王抓狂:
class Pet:
def __init__(self, name, age, breed):
self.name = name
self.age = age
self.breed = breed
def __repr__(self):
return f'Pet(name={self.name!r}, age={self.age!r}, breed={self.breed!r})'
def __eq__(self, other):
if not isinstance(other, Pet):
return NotImplemented
return (self.name == other.name
and self.age == other.age
and self.breed == other.breed)
仅仅三个属性,就写出了这么多基础设施代码。Pet 的核心其实是 name、age、breed 这三个字段,而 __init__、__repr__、__eq__ 都是"体力活"。
Python 3.7 引入了 dataclass 装饰器,可以自动生成这些样板方法:
from dataclasses import dataclass
@dataclass
class Pet:
name: str
age: int
breed: str
buddy = Pet('Buddy', 3, 'Golden Retriever')
whiskers = Pet('Whiskers', 1, 'British Shorthair')
print(buddy) # Pet(name='Buddy', age=3, breed='Golden Retriever')
print(buddy == Pet('Buddy', 3, 'Golden Retriever')) # True
仅仅五行代码,自动获得了 __init__、__repr__、__eq__ 等方法。代码更聚焦于"业务字段",而不是基础设施。
dataclass 的进阶用法
dataclass 远不止自动生成方法那么简单,它还支持:
from dataclasses import dataclass, field
from typing import List
@dataclass
class MemberAccount:
username: str
balance: float = 0.0 # 默认值
tags: List[str] = field(default_factory=list) # 用工厂函数创建独立列表
frozen_id: int = field(default=0, repr=False) # 不参与 repr
@dataclass(frozen=True)
class Point:
"""不可变数据类:一旦创建就不能修改"""
x: int
y: int
account = MemberAccount('alice')
print(account) # MemberAccount(username='alice', balance=0.0, tags=[])
p1 = Point(3, 4)
# p1.x = 10 # 这行会报错:FrozenInstanceError
# frozen=True 让数据类成为不可变的(类似元组),可以做字典的 key
point_map = {Point(3, 4): 'A点'}
print(point_map[Point(3, 4)]) # A点
关键点解释:
default_factory:当默认值必须是可变对象(如列表、字典)时使用。不要写tags: List[str] = [],那样所有实例都会共享同一个列表!frozen=True:创建不可变的数据类。它让数据类的实例像元组一样,既安全又能做哈希键。
dataclass 与普通 class 的选择
小李曾问李哥:“既然 dataclass 这么好,是不是所有类都该用 dataclass 定义?”
李哥回答:“不是。 dataclass 适合’数据容器’——字段多、逻辑少、主要在存取数据的类。如果你的类有复杂的业务逻辑、生命周期管理、状态机等,普通类更合适。记住一个原则:让工具匹配问题,而不是让问题迁就工具。”
6.2 类型注解与运行时检查
故事:暗藏的类型错误
Python 是动态类型语言,变量可以指向任何类型的对象。这在带来灵活性的同时,也造成了维护上的困扰:
def feed_pet(pet, food_amount):
return pet.name + ' eats ' + food_amount + ' grams.'
如果有人不小心传入了:
result = feed_pet(dog, '很多') # 参数类型完全错误
程序不会立刻报错,但逻辑已经错了。更隐蔽的是,food_amount 本应该是数值,却在某处被粗心大意的开发者传成了字符串,结果下游所有数值计算都产生了偏差。
类型注解:让意图浮出水面
Python 从 3.5 开始支持类型注解(Type Hints):
def feed_pet(pet: str, food_amount: int) -> str:
return f'{pet} eats {food_amount} grams of food.'
参数后面的 : str 和 : int 是类型注解,括号外 -> str 声明了返回值类型。它们不会在运行时强制检查,但意义重大:
- IDE 能提供智能补全和错误提示;
- 配合静态检查工具(如 mypy),在提交代码前捕获类型错误;
- 作为团队协作的无形文档,让类型意图一目了然。
类的类型注解进阶
Python 3.10 及以后,可以用 X | Y 语法表达联合类型,更简洁:
from typing import Optional
class Dog:
def __init__(self, name: str, age: int) -> None:
self.name = name
self.age = age
def get_owner(self) -> Optional[str]:
"""返回主人姓名,可能为 None"""
return self._owner
def find_friend(dog: Dog, friends: list[Dog]) -> Dog | None:
"""在朋友列表中找一个同品种的狗,可能找不到"""
for friend in friends:
if friend.breed == dog.breed:
return friend
return None
Protocol:鸭子类型的静态版本
Protocol 是 Python 3.8 引入的结构化子类型工具。它让你在不强制继承的情况下,定义"接口约束":
from typing import Protocol
class Pet(Protocol):
name: str
def speak(self) -> str:
...
class Dog:
name = 'Buddy'
def speak(self) -> str:
return 'Woof!'
class Car:
def drive(self) -> str:
return 'Vroom!'
def announce(pet: Pet) -> str:
return f'{pet.name}: {pet.speak()}'
print(announce(Dog())) # OK
# announce(Car()) # mypy 会报错:Car 不满足 Pet 协议
注意:Dog 并没有显式继承 Pet,但由于它"长得像 Pet"(有 name 属性和 speak() 方法),就被视为满足 Pet 协议。这就是结构化子类型——Python 鸭子类型的"静态版本"。Car 虽然也有名字,但缺少 speak() 方法,因此 mypy 会拒绝它。
Protocol 的实际价值
当你在设计一个松耦合系统时,Protocol 让你表达"我需要一个会叫的东西",而不是"我需要一个动物子类"。任何满足接口的对象——不管它的继承树长什么样——都可以被接受。这大大提高了代码的灵活性。
6.3 slots:优化内存占用
故事:百万级数据行的挑战
宠物店系统上线一年后,发展成了一个连锁品牌,每天要处理百万级订单。小王的系统突然变得很慢,经分析发现:创建大量对象时,内存占用非常惊人。为什么?
默认情况下,Python 对象使用一个名为 __dict__ 的字典来存储属性。这个字典带来了灵活性——你可以随时随地给对象添加新属性。但灵活性是有代价的:每个对象都背着一个字典,内存开销巨大。
class Pet:
def __init__(self, name, age, breed):
self.name = name
self.age = age
self.breed = breed
# 可以动态添加属性,非常灵活
buddy = Pet('Buddy', 3, 'Golden Retriever')
buddy.favorite_toy = 'ball' # 合法
print(buddy.__dict__) # {'name': 'Buddy', 'age': 3, 'breed': 'Golden Retriever', 'favorite_toy': 'ball'}
但如果你的系统要创建百万个 Pet 对象,每个对象都背着一个 __dict__,内存占用就变得无法接受。
用 slots 定制属性存储
__slots__ 允许你显式声明一个类的属性列表,Python 会改用固定大小的数组来存储属性,而不是字典:
class PetWithSlots:
__slots__ = ('name', 'age', 'breed')
def __init__(self, name, age, breed):
self.name = name
self.age = age
self.breed = breed
buddy = PetWithSlots('Buddy', 3, 'Golden Retriever')
print(buddy.name) # Buddy
# 代价:不能再动态添加新属性
try:
buddy.favorite_toy = 'ball'
except AttributeError as e:
print(e) # 'PetWithSlots' object has no attribute 'favorite_toy'
__slots__ 的收益是显著的:根据官方文档和社区测试,对于简单数据对象,使用 __slots__ 后内存占用通常可以减少 40%~50%。在创建大量实例的场景下(如解析百万级数据行、游戏中的粒子系统、金融领域的订单簿),这是非常关键的内存优化手段。
同时使用 slots 和 dataclass
好消息是:dataclass 也支持 __slots__:
from dataclasses import dataclass
@dataclass(slots=True) # Python 3.10+ 支持
class Pet:
name: str
age: int
breed: str
buddy = Pet('Buddy', 3, 'Golden Retriever')
print(buddy) # Pet(name='Buddy', age=3, breed='Golden Retriever')
# 和普通 dataclass 一样,但不能动态添加属性
# buddy.favorite_toy = 'ball' # AttributeError
@dataclass(slots=True) 结合了两者的优点:既有自动生成的样板方法,又有节省内存的底层存储。这是 Python 3.10+ 为数据处理密集型应用提供的利器。
slots 的使用注意事项
__slots__不能阻止弱引用(__weakref__需要显式加入)——一般用不到,先知道即可。- 子类如果也定义了
__slots__,不会重复父类的槽;但如果不定义__slots__,子类会重新获得__dict__。 __slots__优化的是大量对象的内存场景。如果你的程序只创建几十个对象,用不用__slots__差别不大,不必为了优化而优化。
小结
dataclass帮你自动生成样板代码,Protocol帮你定义结构化接口,__slots__帮你压缩内存占用。它们是 Python 现代化 OOP 的三大武器。在实际项目中,合理搭配使用这些特性,会让你的代码同时具备简洁、安全和高效三张名片。
总结:小王的新起点
一年后,宠物店管理系统已经从单一的宠物管理发展成了一个包含会员储值、智能推荐、库存管理、数据分析的连锁门店平台。更重要的是,小王已经从那个用字典堆砌数据的实习生,成长为团队里的 Python 技术骨干。
回到最初那个问题:面向对象编程到底解决了什么?
它不是魔法,也不会让你写的代码自动变好。它真正的价值,在于提供了一种组织复杂系统的思维框架:
- 用类与对象把数据和操作数据的逻辑打包在一起,让代码结构贴合人类认知;
- 用封装保护数据不被肆意修改,让业务规则落在该在的地方;
- 用继承与多态实现代码复用与行为抽象,让系统在需求变化时依然从容;
- 用设计模式把常见问题的解法沉淀为可复用的套路;
- 用SOLID 原则作为标尺,检视并改善设计质量;
- 用 dataclass、Protocol、
__slots__这些现代化特性,为代码在简洁性、安全性和性能之间找到平衡。
李哥最后送给小王的一句话,也送给此刻读到这里的你:
面向对象编程的精髓不在于你会写
class,而在于你学会了用"对象"的眼光重新观察这个世界。
当你再遇到一个复杂的业务需求时,不妨先停下来问自己三个问题:
- 这个系统里有哪些"东西"?它们就是候选的类。
- 每个"东西"拥有哪些数据和行为?它们就是类的属性和方法。
- 这些"东西"之间是什么关系?是"是一种"(继承)还是"有一个"(组合)?
想清楚这三点,再动手写代码,你会发现很多难题在落笔之前就已经解决了。祝你在 Python OOP 的世界里,写出清晰、优雅、可维护的代码。我们下篇文章见。
更多推荐



所有评论(0)