CHARLIE SAYS

查理如是说
DATE 2026-08-24
THEME
SERIES / DESIGN_PATTERNS / P-096 · 设计模式

设计模式 002:SOLID 设计原则

在软件开发中,前人对软件系统的设计和开发总结了一些原则和模式,不管用什么语言做开发,都将对系统设计和开发提供指导意义。面向对象的基本原则(SOLID)是五个,但经常被提到的还有迪米特法则合成复用原则等,所以常见文章中也有写六大或七大原则的。

首字母原则一句话
S单一职责原则(SRP)一个类只做一件事,只有一个引起它变化的原因
O开闭原则(OCP)对扩展开放,对修改关闭
L里氏替换原则(LSP)任何基类可以出现的地方,子类也可以出现
I接口隔离原则(ISP)客户端不应该依赖那些它不需要的接口
D依赖倒置原则(DIP)要依赖抽象,而不要依赖具体的实现

S:单一职责原则(SRP)

Single-Responsibility Principle:一个类最好只做一件事,只有一个引起它变化的原因。单一职责原则可以看做是低耦合、高内聚在面向对象原则的引申,将职责定义为引起变化的原因,以提高内聚性、减少引起变化的原因。

定义:一个对象应该只包含单一的职责,并且该职责被完整地封装在一个类中(Every object should have a single responsibility, and that responsibility should be entirely encapsulated by the class),即有且仅有一个原因使类变更。

原则分析

  • 一个类(或者大到模块、小到方法)承担的职责越多,它被复用的可能性越小;如果一个类承担的职责过多,就相当于将这些职责耦合在一起,当其中一个职责变化时,可能会影响其他职责的运作
  • 类的职责主要包括两个方面:数据职责通过其属性来体现,行为职责通过其方法来体现
  • 单一职责原则是实现高内聚、低耦合的指导方针,在很多代码重构手法中都能找到它的存在。它是最简单但又最难运用的原则——需要设计人员发现类的不同职责并将其分离,而发现类的多重职责需要较强的分析设计能力和重构经验

优点

  • 降低类的复杂性,类的职责清晰明确(数据职责和行为职责清晰明确)
  • 提高类的可读性和维护性
  • 变更引起的风险降低:如果接口的单一职责做得好,一个接口修改只对相应的类有影响,对其他接口无影响

注意:“职责”和”变化原因”都没有具体标准——一个类到底要负责哪些职责?这些职责怎么细化?细化后是否都要有一个接口或类?这些需从实际情况考虑,因项目而异、因环境而异。

例子:SpringMVC 中 Entity、DAO、Service、Controller、Util 等的分离。

O:开闭原则(OCP)

Open-Closed Principle:对扩展开放,对修改关闭(设计模式的核心原则)。

定义:一个软件实体(如类、模块和函数)应该对扩展开放,对修改关闭。一个好的系统是在不修改源代码的情况下,可以扩展你的功能,而实现开闭原则的关键就是抽象化

原则分析

  • 当软件实体因需求变化时,尽量通过扩展已有软件实体来提供新的行为,满足新的需求,而不是修改已有的代码,使变化中的软件有一定的适应性和灵活性
  • 已有软件模块(特别是最重要的抽象层模块)不能再修改,这使变化中的软件系统有一定的稳定性和延续性
  • 不允许修改的是抽象的类或者接口,允许扩展的是具体的实现类——抽象类和接口在开闭原则中扮演着极其重要的角色。既要预知可能变化的需求,又要预见所有已知的扩展,所以”抽象化”是关键
  • 可变性的封闭原则:找到系统的可变因素,将它封装起来,这是对开闭原则最好的实现。不要把可变因素放在多个类中或散落在程序各个角落;也不要把所有可变因素封装在一起——最好的办法是分块封装可变因素,避免超大类、超长类、超长方法的出现

例子:设计模式中模板方法模式和观察者模式都是开闭原则的极好体现(见本系列模板方法观察者)。

L:里氏替换原则(LSP)

Liskov Substitution Principle:任何基类可以出现的地方,子类也可以出现。这一思想表现为对继承机制的约束规范,只有子类能够替换其基类时,才能够保证系统在运行期内识别子类,这是保证继承复用的基础。

定义(两种表述):

  • 严格定义:如果对每一个类型为 S 的对象 o1,都有类型为 T 的对象 o2,使得以 T 定义的所有程序 P 在所有的对象 o1 都代换成 o2 时,程序 P 的行为没有变化,那么类型 S 是类型 T 的子类型
  • 通俗定义:所有引用基类(父类)的地方必须能透明地使用其子类的对象;子类在基类的基础上也能新增行为

(里氏替换原则由 2008 年图灵奖得主、美国第一位计算机科学女博士、MIT 教授 Barbara Liskov 和卡内基·梅隆大学 Jeannette Wing 教授于 1994 年提出。)

原则分析

  • 讲的是基类和子类的关系,只有这种关系存在时,里氏替换原则才存在。“正方形是长方形”是理解里氏替换原则的经典例子
  • 把基类都替换成它的子类,程序将不会产生任何错误和异常;反过来则不成立——如果一个软件实体使用的是一个子类,那么它不一定能够使用基类
  • 里氏替换原则是实现开闭原则的重要方式之一:在程序中尽量使用基类类型来对对象进行定义,而在运行时再确定其子类类型,用子类对象来替换父类对象

I:接口隔离原则(ISP)

Interface Segregation Principle:客户端不应该依赖那些它不需要的接口(这个法则与迪米特法则是相通的)。

定义:一旦一个接口太大,则需要将它分割成一些更细小的接口,使用该接口的客户端仅需知道与之相关的方法即可。注意这里的”接口”指的是所定义的方法——例如外部调用某个类的 public 方法,这个方法对外就是接口。

原则分析

  • 使用多个专门的接口,而不使用单一的总接口;每一个接口应该承担一种相对独立的角色,不多不少,不干不该干的事,该干的事都要干
  • 一个接口只代表一个角色,每个角色都有它特定的一个接口,此时该原则可以叫做”角色隔离原则”
  • 接口仅仅提供客户端需要的行为,应当为客户端提供尽可能小的单独接口,而不要提供大的总接口
  • 拆分接口时首先必须满足单一职责原则:将一组相关的操作定义在一个接口中,且在满足高内聚的前提下,接口中的方法越少越好
  • 可以采用定制服务的方式:为不同的客户端提供宽窄不同的接口,只提供用户需要的行为,隐藏不需要的行为

D:依赖倒置原则(DIP)

Dependency-Inversion Principle:要依赖抽象,而不要依赖具体的实现。具体而言就是高层模块不依赖于底层模块,二者共同依赖于抽象;抽象不依赖于具体,具体依赖于抽象。

定义

  • 高层模块不应该依赖低层模块,它们都应该依赖抽象。抽象不应该依赖于细节,细节应该依赖于抽象
  • 简单地说:要针对接口编程,不针对实现编程

原则分析

  • 如果说开闭原则是面向对象设计的目标,依赖倒置原则就是到达”开闭”原则的手段;依赖倒置原则是对”抽象化”的最好规范,也可以说是里氏替换原则的补充
  • 常用实现方式之一:在代码中使用抽象类,而将具体类放在配置文件中
  • 类之间的耦合有三种:零耦合、具体耦合、抽象耦合。依赖倒置原则要求客户端以抽象方式耦合,这是该原则的关键

例子:理解依赖倒置,先明白面向对象设计中的依赖(Dependency)——一种使用关系,特定事物的改变有可能会影响到使用该事物的其他事物。大多数情况下,依赖关系体现在某个类的方法使用另一个类的对象作为参数。

某系统提供一个数据转换模块,可以将来自不同数据源的数据转换成多种格式:数据源可以是数据库(DatabaseSource)、文本文件(TextSource);转换格式可以是 XML(XMLTransformer)、XLS(XLSTransformer)等。每增加一种新的数据源或文件格式,客户类 MainClass 都需要修改源代码——这违背了开闭原则,现使用依赖倒置原则重构:

/**
 * 依赖注入是依赖 AbstractSource 抽象注入的,而不是具体 DatabaseSource
 */
abstract class AbstractTransformer {
    private AbstractSource source;

    /**
     * 构造注入(Constructor Injection):通过构造函数注入实例变量
     */
    public AbstractTransformer(AbstractSource source) {
        this.source = source;
    }

    /**
     * 设值注入(Setter Injection):通过 Setter 方法注入实例变量
     */
    public void setSource(AbstractSource source) {
        this.source = source;
    }

    /**
     * 接口注入(Interface Injection):通过接口方法注入实例变量
     */
    public void transform(AbstractSource source) {
        source.getSource();
        System.out.println("Transforming ...");
    }
}

依赖注入有以下三种方式:构造注入设值注入接口注入(见上方代码注释)。

其它原则

合成/聚合复用原则(CARP)

Composite/Aggregate Reuse Principle:要尽量使用对象组合,而不是继承关系达到软件复用的目的。经常又叫做合成复用原则(Composite Reuse Principle 或 CRP)。

定义:在一个新的对象里面使用一些已有的对象,使之成为新对象的一部分;新对象通过向这些对象的委派达到复用已有功能的目的。简而言之:要尽量使用合成/聚合,尽量不要使用继承

原则分析:在面向对象设计中,可以通过两种基本方法在不同环境中复用已有的设计和实现:

方式特点别称
继承复用实现简单,易于扩展;但破坏系统的封装性,从基类继承而来的实现是静态的,不可能在运行时发生改变,没有足够的灵活性,只能在有限的环境中使用“白箱”复用
组合/聚合复用聚合度相对较低,可选择性调用成员对象的操作,可以在运行时动态进行“黑箱”复用

组合/聚合可以使系统更加灵活,类与类之间的耦合度降低,一个类的变化对其他类造成的影响相对较少,因此一般首选组合/聚合来实现复用;其次才考虑继承,且使用继承时需严格遵循里氏替换原则。

聚合和组合的区别

  • 组合(合成):表示一个整体与部分的关系,指一个依托整体而存在的关系(整体与部分不可以分开)。比如眼睛和嘴对于头来说就是组合关系——没有了头就没有眼睛和嘴。UML 中用带实心菱形的直线表示
  • 聚合:比组合弱的一种依赖关系,也表示整体与部分的关系(整体与部分可以分开)。比如螺丝和汽车玩具的关系——螺丝脱离玩具依然可以用在其他设备上。UML 中用带空心菱形的直线表示

(UML 类图的关系表示可参考 Java 基础 - 面向对象。)

迪米特法则(LoD)

Law of Demeter:系统中的类尽量不要与其他类互相作用,减少类之间的耦合度。又叫最少知识原则(Least Knowledge Principle,LKP)。

定义(几种形式):

  • 不要和”陌生人”说话(Don’t talk to strangers)
  • 只与你的直接朋友通信(Talk only to your immediate friends)
  • 每一个软件单位对其他的单位都只有最少的知识,而且局限于那些与本单位密切相关的软件单位

简单地说:一个对象应当对其他对象有尽可能少的了解。一个类应该对自己需要耦合或调用的类知道得最少——你的内部如何复杂都和我没关系,我只知道你提供的 public 方法,我就调用这么多,其他的一概不关心。

朋友类:对于一个对象,其”朋友”包括以下几类,满足之一即是朋友,否则是”陌生人”:

  1. 当前对象本身(this)
  2. 以参数形式传入到当前对象方法中的对象
  3. 当前对象的成员对象
  4. 如果当前对象的成员对象是一个集合,那么集合中的元素也都是朋友
  5. 当前对象所创建的对象

狭义法则和广义法则

  • 狭义迪米特法则:如果两个类之间不必彼此直接通信,那么这两个类就不应当发生直接的相互作用;如果其中一个类需要调用另一个类的某一个方法,可以通过第三者转发这个调用。它可以降低类之间的耦合,但会在系统中增加大量的小方法并散落在各个角落;它可以使系统的局部设计简化,但也会造成不同模块之间的通信效率降低、不容易协调
  • 广义迪米特法则:指对对象之间的信息流量、流向以及信息影响的控制,主要是对信息隐藏的控制。信息隐藏可以使各个子系统之间脱耦,从而允许它们独立地被开发、优化、使用和修改,同时促进软件的复用。系统的规模越大,信息隐藏就越重要

主要用途在于控制信息的过载:

  • 在类的划分上,应当尽量创建松耦合的类,类之间的耦合度越低,就越有利于复用
  • 在类的结构设计上,每一个类都应当尽量降低其成员变量和成员函数的访问权限
  • 在类的设计上,只要有可能,一个类型应当设计成不变类
  • 在对其他类的引用上,一个对象对其他对象的引用应当降到最低

例子外观模式 Facade中介者模式 Mediator 都是迪米特法则的应用。类与类之间相互作用越多,修改难度越大:A 类依赖 B 类、B 类依赖 C 类、C 类再依赖 D 类——修改 A 类时你要考虑 B 类是否受影响,B 类的影响是否又会波及 C 类。

面向对象设计的其他原则

  • 封装变化
  • 少用继承,多用组合
  • 针对接口编程,不针对实现编程
  • 为交互对象之间的松耦合设计而努力
  • 类应该对扩展开放,对修改封闭(开闭 OCP 原则)
  • 依赖抽象,不要依赖于具体类(依赖倒置 DIP 原则)
  • 密友原则:只和朋友交谈(最少知识原则,迪米特法则)——将方法调用保持在界限内,只调用属于以下范围的方法:该对象本身(本地方法)、对象的组件、被当作方法参数传进来的对象、此方法创建或实例化的任何对象
  • 别找我(调用我),我会找你(调用你)(好莱坞原则)
  • 一个类只有一个引起它变化的原因(单一职责 SRP 原则)

Q&A

你能解释一下里氏替换原则吗?

严格定义见上文。通俗表述:所有引用基类的地方必须能透明地使用其子类的对象,即子类可以扩展父类的功能,但不能改变父类原有的功能。它包含以下 4 层含义:

  1. 子类可以实现父类的抽象方法,但不能覆盖父类的非抽象方法
  2. 子类中可以增加自己特有的方法
  3. 当子类的方法重载父类的方法时,方法的前置条件(即方法的形参)要比父类方法的输入参数更宽松
  4. 当子类的方法实现父类的抽象方法时,方法的后置条件(即方法的返回值)要比父类更严格

什么情况下会违反迪米特法则?为什么会有这个问题?

迪米特法则建议”只和朋友说话,不要和陌生人说话”,以此来减少类之间的耦合——直接调用”陌生人”(朋友类之外的对象)的方法即违反该法则。

给我一个符合开闭原则的设计模式的例子?

开闭原则要求代码对扩展开放、对修改关闭:想增加新功能,可以在不改变已测试过的代码的前提下增加新代码。有好几个设计模式基于开闭原则,如策略模式——如果需要一个新的策略,只需实现接口、增加配置,不需要改变核心逻辑。一个正在工作的例子是 Collections.sort() 方法:它基于策略模式、遵循开闭原则,你不需为新的对象修改 sort() 方法,需要做的仅仅是实现自己的 Comparator 接口。

什么时候使用享元模式(蝇量模式)?

享元模式通过共享对象来避免创建太多的对象。使用享元模式需要确保你的对象是不可变的,这样才能安全地共享。JDK 中 String 池、Integer 池以及 Long 池都是很好地使用享元模式的例子。

系列导航

参考文章

← 开源合规 002:常见软件开源协议详解 目录 软件工程 002:事务理论 - ACID →
← 返回文章列表