16、SOLID原则(上):单一职责原则(SRP)、开闭原则(OCP)、里氏替换原则(LSP)的Python实现
面向对象设计,说白了就是一套「怎么把代码写得让人省心」的方法论。而SOLID原则,就是这套方法论里最经典的五个原则。
今天我们先聊前三个:单一职责、开闭原则、里氏替换。这三个原则我几乎每天都在用,尤其是重构老代码的时候,简直是救命稻草。
一、先看整体:三个原则的关系
这三个原则其实各有侧重,但目标一致——让你的代码更容易维护、更容易扩展。我画了张图,帮你快速建立整体认知:
你看,这三个原则其实是层层递进的。SRP 把基础打牢,OCP 让系统有弹性,LSP 保证继承不出乱子。咱们一个一个来看。
二、单一职责原则(SRP)
定义:一个类应该只有一个引起它变化的原因。
说白了,就是一个类只干一件事。如果一个类既管数据存储,又管业务逻辑,还管界面展示——那它就是个「万能类」,改起来谁都怕。
核心判断标准:如果你能说出「这个类有两个不同的职责」,那就违反了 SRP。
2.1 违反 SRP 的代码长什么样?
我见过太多这样的代码了。比如一个 Order 类,既处理订单数据,又负责保存到数据库,还要发邮件通知用户。
# ❌ 违反 SRP:一个类干了三件事
class Order:
def __init__(self, order_id, items):
self.order_id = order_id
self.items = items
self.total = sum(item['price'] for item in items)
def calculate_total(self):
# 职责1:计算订单总额
return sum(item['price'] for item in self.items)
def save_to_database(self):
# 职责2:保存到数据库
print(f"保存订单 {self.order_id} 到数据库")
def send_confirmation_email(self):
# 职责3:发送确认邮件
print(f"发送订单确认邮件给用户")
你想想看,如果数据库从 MySQL 换成 PostgreSQL,你得改 Order 类。如果邮件模板变了,你还得改 Order 类。一个类被改了三次,每次改都可能引入 bug。
我曾经接手过一个项目:一个 3000 行的类,里面混着业务逻辑、数据库操作、日志记录、缓存处理、甚至还有发短信的代码。每次改需求,测试都要跑一周。后来花了两个月重构,拆成 12 个类,世界清净了。
2.2 符合 SRP 的写法
把职责拆开,每个类只做一件事:
# ✅ 符合 SRP:每个类只负责一件事
class Order:
"""只负责订单数据本身"""
def __init__(self, order_id, items):
self.order_id = order_id
self.items = items
def calculate_total(self):
return sum(item['price'] for item in self.items)
class OrderRepository:
"""只负责订单的持久化"""
def save(self, order):
print(f"保存订单 {order.order_id} 到数据库")
class EmailService:
"""只负责发送邮件"""
def send_order_confirmation(self, order):
print(f"发送订单确认邮件给用户,订单号:{order.order_id}")
这样改完之后,每个类只有一个修改原因。数据库变了改 OrderRepository,邮件变了改 EmailService,互不干扰。
我个人习惯:写类之前先问自己三个问题——这个类叫什么名字?它负责什么?如果明天需求变了,我改哪里?如果答案不清晰,说明职责没分好。
三、开闭原则(OCP)
定义:软件实体(类、模块、函数等)应该对扩展开放,对修改关闭。
什么意思呢?就是你想加新功能的时候,不要改已有的代码,而是通过加新代码来实现。这就像给房子加个阳台——你不会把承重墙拆了重砌,而是在外面搭个新结构。
3.1 违反 OCP 的典型例子
假设我们有个图形绘制系统,一开始只支持圆形和矩形:
# ❌ 违反 OCP:每次加新图形都要修改已有代码
class GraphicEditor:
def draw_shape(self, shape):
if shape.type == 'circle':
self.draw_circle(shape)
elif shape.type == 'rectangle':
self.draw_rectangle(shape)
# 如果加三角形,还得加 elif
def draw_circle(self, circle):
print(f"绘制圆形,半径:{circle.radius}")
def draw_rectangle(self, rect):
print(f"绘制矩形,宽:{rect.width},高:{rect.height}")
问题在哪?每次加新图形(比如三角形、五边形),你都得改 GraphicEditor 类。改多了,这个类就变成了一锅粥。
3.2 符合 OCP 的写法
用多态来解决。让每个图形自己知道怎么画:
# ✅ 符合 OCP:通过扩展新类来增加功能
from abc import ABC, abstractmethod
class Shape(ABC):
@abstractmethod
def draw(self):
pass
class Circle(Shape):
def __init__(self, radius):
self.radius = radius
def draw(self):
print(f"绘制圆形,半径:{self.radius}")
class Rectangle(Shape):
def __init__(self, width, height):
self.width = width
self.height = height
def draw(self):
print(f"绘制矩形,宽:{self.width},高:{self.height}")
class GraphicEditor:
def draw_shape(self, shape: Shape):
shape.draw() # 不用判断类型,直接调用
现在加三角形?直接新建一个 Triangle 类,实现 draw 方法就行。GraphicEditor 一行代码都不用改。
关键点:OCP 的核心是「抽象化」。用抽象基类或接口定义行为,让具体实现去扩展。这样系统就像搭积木,加一块新的,不用动旧的。
四、里氏替换原则(LSP)
定义:子类对象必须能够替换父类对象,并且程序的行为不会发生变化。
这个原则听起来有点绕,其实就一句话:子类不能「坑」父类。如果你用父类的地方,换成子类就出错了,那这个继承就有问题。
4.1 经典的反面案例:正方形继承矩形
这是教科书级别的反面教材。矩形有宽和高,正方形是特殊的矩形——看起来没毛病吧?
# ❌ 违反 LSP:正方形继承矩形
class Rectangle:
def __init__(self, width, height):
self.width = width
self.height = height
def set_width(self, width):
self.width = width
def set_height(self, height):
self.height = height
def area(self):
return self.width * self.height
class Square(Rectangle):
def __init__(self, side):
super().__init__(side, side)
def set_width(self, width):
self.width = width
self.height = width # 正方形宽高必须相等
def set_height(self, height):
self.width = height
self.height = height
看起来挺合理?但问题来了:
def resize_rectangle(rect: Rectangle):
rect.set_width(5)
rect.set_height(10)
# 期望面积是 50
assert rect.area() == 50, f"面积应该是50,实际是{rect.area()}"
# 用矩形没问题
rect = Rectangle(2, 3)
resize_rectangle(rect) # ✅ 面积 = 50
# 用正方形就出事了
square = Square(4)
resize_rectangle(square) # ❌ 面积 = 100,因为 set_width 把 height 也改了
你看,用父类的地方换成子类,结果就不对了。这就是违反了 LSP。
我曾经踩过这个坑:在一个支付系统里,让「信用卡支付」继承了「支付」基类,结果信用卡有额外的验证逻辑,导致替换后有些流程走不通。后来改成组合模式,才彻底解决。
4.2 如何避免违反 LSP?
记住几个判断标准:
- 子类不能削弱父类的前置条件——父类要求参数非空,子类不能改成允许空值
- 子类不能增强父类的后置条件——父类返回结果,子类不能额外抛出异常
- 子类不能改变父类的「约定」——父类说 set_width 只改宽度,子类不能偷偷改高度
对于正方形和矩形的例子,正确的做法是:不要用继承,用组合,或者让它们都实现一个共同的抽象接口:
# ✅ 符合 LSP:用抽象接口,各自实现
from abc import ABC, abstractmethod
class Shape(ABC):
@abstractmethod
def area(self):
pass
class Rectangle(Shape):
def __init__(self, width, height):
self.width = width
self.height = height
def area(self):
return self.width * self.height
class Square(Shape):
def __init__(self, side):
self.side = side
def area(self):
return self.side * self.side
这样,Rectangle 和 Square 没有继承关系,各自实现 area 方法,互不干扰。用 Shape 的地方,两个都能替换,行为完全正确。
我建议:写继承之前,先问问自己——「这个子类真的能完全替代父类吗?」如果答案有犹豫,就别用继承,用组合或者接口。
五、三个原则的实践总结
| 原则 | 核心思想 | 违反的后果 | 我的建议 |
|---|---|---|---|
| SRP | 一个类只做一件事 | 类变得臃肿,改一处影响多处 | 写类前先问「它叫什么名字」 |
| OCP | 对扩展开放,对修改关闭 | 加功能要改老代码,容易引入 bug | 多用抽象和多态,少用 if-else |
| LSP | 子类必须能替换父类 | 继承关系脆弱,替换后行为异常 | 谨慎使用继承,优先考虑组合 |
这三个原则,说白了就是让你写代码的时候多想想「以后」。SRP 让每个类职责清晰,OCP 让系统容易扩展,LSP 保证继承不出乱子。它们不是教条,而是无数前辈踩坑踩出来的经验。
嗯,今天就聊到这儿。下节课我们继续聊剩下的两个原则——接口隔离原则和依赖倒置原则,到时候你会发现,它们和今天讲的这三个是环环相扣的。
公众号:蓝海资料掘金营,微信 deep3321