16、SOLID原则(上):单一职责原则(SRP)、开闭原则(OCP)、里氏替换原则(LSP)的Python实现

面向对象设计,说白了就是一套「怎么把代码写得让人省心」的方法论。而SOLID原则,就是这套方法论里最经典的五个原则。

今天我们先聊前三个:单一职责、开闭原则、里氏替换。这三个原则我几乎每天都在用,尤其是重构老代码的时候,简直是救命稻草。

一、先看整体:三个原则的关系

这三个原则其实各有侧重,但目标一致——让你的代码更容易维护、更容易扩展。我画了张图,帮你快速建立整体认知:

SOLID 前三个原则关系图 单一职责原则 (SRP) 一个类只做一件事 只有一个引起变化的原因 → 高内聚,低耦合 开闭原则 (OCP) 对扩展开放 对修改关闭 → 用扩展代替修改 里氏替换原则 (LSP) 子类必须能替换父类 不破坏程序正确性 → 继承要谨慎设计 核心思想 SRP 让每个类职责清晰 → OCP 让系统易于扩展 → LSP 保证继承安全 三者层层递进,共同构建可维护的面向对象系统 违反任何一个原则,代码都会慢慢变成「屎山」

你看,这三个原则其实是层层递进的。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

这样,RectangleSquare 没有继承关系,各自实现 area 方法,互不干扰。用 Shape 的地方,两个都能替换,行为完全正确。

我建议:写继承之前,先问问自己——「这个子类真的能完全替代父类吗?」如果答案有犹豫,就别用继承,用组合或者接口。

五、三个原则的实践总结

原则 核心思想 违反的后果 我的建议
SRP 一个类只做一件事 类变得臃肿,改一处影响多处 写类前先问「它叫什么名字」
OCP 对扩展开放,对修改关闭 加功能要改老代码,容易引入 bug 多用抽象和多态,少用 if-else
LSP 子类必须能替换父类 继承关系脆弱,替换后行为异常 谨慎使用继承,优先考虑组合

这三个原则,说白了就是让你写代码的时候多想想「以后」。SRP 让每个类职责清晰,OCP 让系统容易扩展,LSP 保证继承不出乱子。它们不是教条,而是无数前辈踩坑踩出来的经验。

嗯,今天就聊到这儿。下节课我们继续聊剩下的两个原则——接口隔离原则和依赖倒置原则,到时候你会发现,它们和今天讲的这三个是环环相扣的。


公众号:蓝海资料掘金营,微信 deep3321