01 · 设计原则(Design Principles)

📅 预计 60 分钟 | ⭐ = 重要知识点 | 📌 中英术语见文末


1.0 先给直觉:设计原则是"装修的规矩"

把写代码比作装修房子。房子能不能住得舒心,硬装和软装都有讲究:电线怎么走、插座装在哪、承重墙能不能砸、以后想加个房间要不要大动干戈。设计原则就是软件世界里的装修规矩——它不是某件具体的家具(那是设计模式),而是一套判断"这样改、这样拆、这样加,会不会出事"的准则。

学完这一篇,你会拥有一种"嗅到坏味道"的能力:看到某个类又长又杂、加个功能要改一堆文件、子类替换父类就出 bug,你能立刻指出来——它违反了哪条原则。

本篇核心是 SOLID 五原则,加上两个贯穿始终的"组合优于继承"和"高内聚低耦合"。

1.1 单一职责原则(SRP)⭐

一个类只负责一件事

单一职责原则(Single Responsibility Principle)说的是:一个类只有一个让它改变的理由。就像一把菜刀只负责切菜、一把钥匙只开一把锁——职责专一,才不容易互相连累。

反例很好找:一个 Student 类,既保存学生信息,又负责打印成绩单,还去连数据库。哪天数据库字段变了,打印功能没碰却被迫跟着改;哪天打印格式变了,数据库代码也可能被牵连。把这三件事拆开:

class Student:                      # 只保存数据
    def __init__(self, name, score):
        self.name = name
        self.score = score

class ScorePrinter:                 # 只负责"打印"
    def print_score(self, s: Student):
        print(f"{s.name}: {s.score}")

class ScoreDB:                      # 只负责"存取"
    def save(self, s: Student): ...

判断标准很朴素:要改这个类时,你的理由有几个? 如果一个理由(比如"成绩展示改版了")就足够,说明职责单一;如果不同方向的改动都会落到同一个类上,说明它们被硬塞在一起了。

⚠️ 常见错误

  1. 把"类的方法少"当成"职责单一"——一个类功能可以丰富,关键是这些功能是否都围绕同一件事。
  2. 一个方法里塞满 if-elif,每个分支各干各的职责,这种"上帝方法"同样违反 SRP,要拆。

1.2 开闭原则(OCP)⭐

对扩展开放,对修改封闭

开闭原则(Open-Closed Principle)要求:在不修改已有代码的前提下,就能为系统增加新功能。听起来很矛盾——不加代码怎么加功能?答案是靠抽象多态:让变化的部分被"新增"而不是被"修改"覆盖。

看一个绘图程序。如果靠类型判断逐个分支处理,每加一种图形都要改主函数:

# 不好的做法:加新图形就要改 draw_all
def draw_all(shapes):
    for s in shapes:
        if s.kind == "circle":
            draw_circle(s)
        elif s.kind == "rect":      # 每加一种图形,这里就要改
            draw_rect(s)

更好的做法:让每种图形自己知道怎么画,主程序只面向统一接口,从此不用再改:

class Shape:
    def draw(self): ...

class Circle(Shape):
    def draw(self): print("画圆")

class Rect(Shape):
    def draw(self): print("画矩形")

def draw_all(shapes):               # 这个函数以后不需要动
    for s in shapes:
        s.draw()

将来新增 Triangle,只需新建一个类,draw_all 一行不动。这就是"对扩展开放、对修改封闭":扩展体现在新增类,封闭体现在旧代码不被改动。

⚠️ 常见错误

  1. 把"不修改"理解成"代码永远不能改"——OCP 针对的是"加新功能时不该被迫改旧功能",日常重构、修 bug 不算违反。
  2. 过度设计:还没出现新需求,就为可能的扩展提前抽象成接口,凭空增加理解成本。

1.3 里氏替换原则(LSP)⭐

子类要能替换父类

里氏替换原则(Liskov Substitution Principle)说的是:凡是能放父类对象的地方,换成子类对象,程序行为依然正确。子类不能悄悄打破父类的承诺。

最经典的反例是"正方形继承长方形"。长方形允许分别设置宽和高,正方形却要求两者永远相等。一旦有人把正方形当长方形用,调用 set_width 时高度也被悄悄改了,行为就乱了:

class Rectangle:
    def set_width(self, w):  self.w = w
    def set_height(self, h): self.h = h

class Square(Rectangle):          # 违背 LSP 的经典反例
    def set_width(self, w):
        self.w = w
        self.h = w                # 偷偷改高度,破坏父类约定

"是"的关系在语义上不成立,强行用继承就会破坏替换性。判断方法:把子类放进所有使用父类的场景,逐一验证行为是否仍然合理。行为不合理的继承,多半应该改成组合或重新建模。

⚠️ 常见错误

  1. 只因为"语法上能继承"就继承,不看语义是否真的符合 is-a 关系。
  2. 子类重写父类方法时抛出父类没声明的异常、返回不同类型的值、悄悄改前置条件,这些都是破坏替换性的表现。

1.4 接口隔离原则(ISP)⭐

别让调用者依赖它用不上的方法

接口隔离原则(Interface Segregation Principle):不要把一大堆方法塞进一个大接口,应当拆成多个小接口,让每个调用者只依赖它真正用到的部分。

把接口想象成餐厅菜单。如果菜单厚厚一大本,一家小吃店也必须"实现"做意面、做寿司,那纯属负担。用 Python 抽象基类示意:

from abc import ABC, abstractmethod

# 大而全的接口:实现它的类被迫实现一堆用不到的方法
class Worker(ABC):
    @abstractmethod
    def code(self): ...
    @abstractmethod
    def test(self): ...
    @abstractmethod
    def cook(self): ...        # 程序员不需要会做饭

# 按职责拆开,谁需要谁实现
class Coder(ABC):
    @abstractmethod
    def code(self): ...

class Tester(ABC):
    @abstractmethod
    def test(self): ...

接口隔离让系统更稳:接口变小时,改动影响的范围也跟着变小,调用者不会被无关方法的变化波及。

⚠️ 常见错误

  1. 为了"接口统一"强行合并接口,最后调用者被迫依赖一堆空实现(pass 占位)。
  2. 接口拆得过于细碎,一个类要实现十几个接口,反而更难用——适度拆分即可。

1.5 依赖倒置原则(DIP)⭐

高层不依赖低层的具体实现

依赖倒置原则(Dependency Inversion Principle)有两条:

  1. 高层模块不应该依赖低层模块,两者都应依赖抽象
  2. 抽象不应该依赖细节,细节应该依赖抽象

用"开关"打比方:一个开关不该认识具体的"灯泡""风扇""空调",它只认一个抽象概念"可开关的东西"。这样加任何新电器,开关都不用改:

class Switchable:                  # 抽象:能开能关
    def on(self): ...
    def off(self): ...

class Lamp(Switchable):            # 具体设备实现抽象
    def on(self):  print("灯亮")
    def off(self): print("灯灭")

class Switch:                      # 高层只依赖抽象
    def __init__(self, device: Switchable):
        self.device = device
    def turn_on(self):
        self.device.on()

好处非常实际:业务层依赖"数据库接口",而不是某个具体的数据库类。从 MySQL 换成 PostgreSQL,业务代码一行不用动,只要换一个实现。

⚠️ 常见错误

  1. 把"依赖抽象"理解成"绕来绕去"——抽象是为了隔离变化,不是为了让调用路径更曲折。
  2. 高层直接 new 低层具体对象(如 new MySQLDB()),把选择权攥在自己手里,想换实现就要改代码。

1.6 组合优于继承 ⭐

优先"有一个",其次才是"是一个"

继承建立的是 is-a(是一个)关系,组合建立的是 has-a(有一个)关系。现实里"是"关系常被滥用:鸟会飞,但企鹅不会飞,硬让企鹅继承"会飞的鸟"立刻出事。

更稳的做法:把"飞行能力"做成一个组件,谁需要谁就组合进去:

class FlyBehavior:
    def fly(self): print("展翅飞")

class Bird:                          # 组合:鸟"有一个"飞行能力
    def __init__(self, fly_behavior):
        self.fly_behavior = fly_behavior
    def do_fly(self):
        self.fly_behavior.fly()

penguin = Bird(FlyBehavior())        # 示意:换一个不会飞的组件即可

为什么组合更灵活:

  • 继承关系在编译期就写死了,运行时很难改变行为;
  • 继承会把父类的所有能力(哪怕用不到的)一并带给子类,容易破坏 LSP;
  • 多层继承容易撞上"菱形问题"(一个类同时继承两个有共同祖先的类)。

组合则可以在运行时更换组件,且不强制父子语义。

⚠️ 常见错误

  1. 为了"复用几行代码"就继承,结果引入一堆用不到的方法,还破坏了替换性。
  2. 反过来也一样:关系稳定、确实是 is-a 的场景(如"猫是动物")也硬用组合,反而绕。

1.7 高内聚、低耦合 ⭐

内部紧抱一团,对外少打扰

高内聚(high cohesion):一个模块内部的元素联系紧密,都围绕同一个目的。低耦合(low coupling):模块之间的依赖尽量少、尽量简单。

想象一个班级做项目:高内聚是"每个小组负责一个明确任务,组内配合默契";低耦合是"组与组之间只需要一个简单交接,不互相打探内部细节"。

  • 高内聚让模块好改:改动集中在内部,不扩散到外面。
  • 低耦合让模块好换:换掉一个模块,别的模块几乎无感。

这两个指标常常相伴出现——内聚高,通常耦合也低。它们是用直觉衡量可维护性最直接的两个标尺:一个模块既好改又好换,维护成本自然低。

⚠️ 常见错误

  1. 只看"类名短、文件少"就以为高内聚——关键看元素是否围绕同一职责运转。
  2. 为了"完全解耦"而层层转发,把简单的 A→B 调用绕成 A→X→Y→B 的消息接力,耦合没降反升。

📌 术语对照(中英)

中文 English
单一职责原则 Single Responsibility Principle (SRP)
开闭原则 Open-Closed Principle (OCP)
里氏替换原则 Liskov Substitution Principle (LSP)
接口隔离原则 Interface Segregation Principle (ISP)
依赖倒置原则 Dependency Inversion Principle (DIP)
组合优于继承 Composition over Inheritance
高内聚低耦合 High Cohesion, Low Coupling

🎯 复习清单

  • SOLID 五个原则分别管什么问题,用一句话说清各自要点。
  • 开闭原则为什么依赖"抽象 + 多态",新增功能为什么不该改旧代码。
  • "正方形继承长方形"违反哪条原则(LSP),根因是什么。
  • 组合与继承的取舍标准,组合在何时比继承更合适。
  • 高内聚、低耦合各自怎么判断,为什么它们能衡量可维护性。