软件模式与面向对象设计原则

软件模式

在软件开发过程中,开发人员会反复遇到相似的问题。软件模式(Software Pattern)是将模式的一般概念应用于软件开发领域的产物——它是对软件开发中反复出现的特定问题的解决方案的统一表示,是软件开发的总体指导思路或参照样板。

软件模式并不仅限于设计模式,还包括架构模式、分析模式和过程模式等。实际上,在软件生存期的每一个阶段都存在着一些被认同的模式。

软件模式的结构

一个软件模式的基础结构由四个部分构成:

flowchart TD
    P["问题描述"] --> C["前提条件<br>(环境或约束条件)"]
    C --> S["解法"]
    S --> R["关联解法"]
    S --> E["效果/优缺点/已知应用"]
    S --> O["其他相关模式"]

    classDef problem fill:#e3f2fd,stroke:#1565c0,stroke-width:2px
    classDef context fill:#f3e5f5,stroke:#4a148c,stroke-width:2px
    classDef solution fill:#e8f5e8,stroke:#2e7d32,stroke-width:2px
    classDef result fill:#fff3e0,stroke:#ef6c00,stroke-width:2px

    class P problem
    class C context
    class S solution
    class R,E,O result

以做饭为例来理解这个结构:问题描述是「我们饿了,需要吃饭」,前提条件是「家里有鸡肉、胡萝卜、花生等食材,时间充沛」,解法是「鸡肉腌制、调制宫保汁、热锅热油……」,效果是「宫保鸡丁一份,耗时 1 小时,饱食度 +30、san 值 +15、水分值 −5」。关联解法可以是「胡萝卜炖鸡」,其他相关模式可以是「青椒肉片」。

软件模式与具体的应用领域无关。在模式的发现过程中需要遵循大三律(Rule of Three):只有经过三个以上不同类型(或不同领域)的系统的校验,一个解决方案才能从候选模式升格为模式。换言之,一个好的模式不是凭空设计出来的,而是从多个实际系统中归纳提炼而成的。

设计模式

设计模式(Design Pattern)是一套被反复使用、多数人知晓的、经过分类编目的代码设计经验的总结。使用设计模式是为了可重用代码、让代码更容易被他人理解、保证代码可靠性。

基本要素

设计模式一般有如下几个基本要素:模式名称、问题、目的、解决方案、效果、实例代码和相关设计模式。其中关键元素包括四个方面:

要素 含义
模式名称(Pattern Name) 用简洁的词汇标识模式
问题(Problem) 描述何时使用模式及其背景
解决方案(Solution) 描述设计的组成成分及它们之间的关系
效果(Consequences) 模式应用的结果和权衡

以单例模式为例:问题是「系统需要一个类的唯一实例,以保证全局状态的一致性」;解决方案是「私有构造方法,提供一个静态方法,在多线程环境下使用静态内部类优化性能」;效果是「保证数据一致、减少实例,但增加耦合、难扩展、可能成为性能瓶颈」;其他相关模式包括工厂模式。

1
2
3
4
5
6
7
8
9
10
11
12
// 单例模式示例:使用静态内部类实现线程安全的懒加载
public class Singleton {
    private Singleton() {}  // 私有构造方法,防止外部实例化

    private static class Holder {
        static final Singleton INSTANCE = new Singleton();
    }

    public static Singleton getInstance() {
        return Holder.INSTANCE;  // 首次调用时才加载 Holder 类并创建实例
    }
}

设计模式的分类

设计模式可以从两个维度进行分类:

按目的(模式是用来做什么的),可分为三种:

  • 创建型模式(Creational):关注对象的创建过程
  • 结构型模式(Structural):关注类或对象的组合方式
  • 行为型模式(Behavioral):关注类或对象之间的交互与职责分配

按范围(模式处理的是类关系还是对象关系),可分为两种:

  • 类模式:处理类和子类之间通过继承建立的关系,在编译时确定,属于静态的
  • 对象模式:处理对象间的关系,在运行时变化,更具动态性

下表列出了经典的 23 种设计模式的完整分类:

范围 \ 目的 创建型模式 结构型模式 行为型模式
类模式 工厂方法模式 (类)适配器模式 模板方法模式
对象模式 抽象工厂模式、建造者模式、原型模式、单例模式 (对象)适配器模式、桥接模式、组合模式、装饰模式、外观模式、享元模式、代理模式 命令模式、迭代器模式、中介者模式、观察者模式、状态模式、策略模式

GoF

这 23 种设计模式最初由 Erich Gamma、Richard Helm、Ralph Johnson 和 John Vlissides 四人在 1994 年出版的经典著作 Design Patterns: Elements of Reusable Object-Oriented Software 中系统总结。这四位作者被称为 GoF(Gang of Four,四人组),该书至今仍是设计模式领域的权威参考。

面向对象软件设计

软件开发过程涉及多个抽象层次,每个层次关注不同的问题:

  • 需求定义了系统需要满足的目标
  • 规约定义了系统外部可观察到的行为
  • 架构定义了系统一级的主要组成部分、各部分的交互方法以及使用的技术
  • 设计定义了如何完成任务、需要写的代码——本课程专门关注面向对象设计

面向对象软件设计(Object-Oriented Design, OOD)是将实现的约束条件应用到面向对象分析(OOA)所产生的概念模型的过程。具体而言,OOD 需要用方法和属性来描述用于构成系统的类,添加不明显属于领域的类(比如抽象类和接口),以及描述类是如何构成组件的。

从分析到设计

在软件开发流程中,OOD 处于 OOA(面向对象分析)和 OOP(面向对象编程)之间:

flowchart LR
    OOA["OOA<br>面向对象分析"] --> M["? magic ?"]
    ARCH["Architecture<br>架构"] --> M
    M --> OOD["OOD<br>面向对象设计"]
    OOD --> OOP["OOP<br>面向对象编程"]

    classDef input fill:#fff3e0,stroke:#ef6c00,stroke-width:2px
    classDef magic fill:#f3e5f5,stroke:#4a148c,stroke-width:2px
    classDef design fill:#e3f2fd,stroke:#1565c0,stroke-width:2px
    classDef impl fill:#e8f5e8,stroke:#2e7d32,stroke-width:2px

    class OOA,ARCH input
    class M magic
    class OOD design
    class OOP impl

OOD 的难点在于将一个系统分解成对象。许多对象直接来自于分析模型或实现空间(数据库、文件、用户界面、IPC (Inter-Process Communication,进程间通信) 等),但还有一些类没有这样的直接对应——它们被添加进来是为了使设计更为通用(例如使用策略模式封装可能变化的算法)。从 OOA 到 OOD 没有循序渐进的简单方法,至少 OOA 以相当直接的方式给出了问题域组件,对于其他部分则需要经验。

经验丰富的设计师会发现相似的问题反复出现——每次遇到类似的问题,他们会从之前行之有效的方案入手,同时体会到上一次可以做得更好。虽然技术不断变化,具体的实现经验很快就过时了,但设计原则以及经典的设计经验不会褪色

面向对象设计原则概述

可维护性与可复用性

好的面向对象设计应当同时追求可维护性(Maintainability)和可复用性(Reusability)。Robert C. Martin 指出,可维护性较低的软件设计通常由以下四个原因造成:

  • 过于僵硬(Rigidity):难以修改,牵一发而动全身
  • 过于脆弱(Fragility):一处修改容易引发其他地方的意外故障
  • 复用率低(Immobility):模块难以从系统中分离出来用于其他场景
  • 黏度过高(Viscosity):做正确的事比做不正确的事更困难

Peter Coad 则认为,一个好的系统设计应该具备以下三个性质:

  • 可扩展性(Extensibility):容易添加新功能
  • 灵活性(Flexibility):代码修改平稳地发生
  • 可插入性(Pluggability):容易替换组件

软件复用可以提高开发效率、提高软件质量、节约开发成本,恰当的复用还可以改善系统的可维护性。面向对象设计复用的目标在于实现支持可维护性的复用,而可维护性复用都是以面向对象设计原则为基础的。

面向对象设计原则也是对系统进行合理重构(Refactoring)的指南针。重构是在不改变软件现有功能的基础上,通过调整程序代码改善软件的质量、性能,使其设计模式和架构更趋合理,提高软件的扩展性和维护性。

七大设计原则

常用的面向对象设计原则包括 7 个,它们并不是孤立存在的,而是相互依赖、相互补充:

设计原则 简介 重要性
单一职责原则(SRP) 类的职责要单一 ★★★★☆
开闭原则(OCP) 对扩展开放,对修改关闭 ★★★★★
里氏代换原则(LSP) 基类可被子类透明替换 ★★★★☆
依赖倒转原则(DIP) 针对抽象编程,不针对实现编程 ★★★★★
接口隔离原则(ISP) 使用多个专门接口取代统一接口 ★★☆☆☆
合成复用原则(CRP) 优先使用组合/聚合,少用继承 ★★★★☆
迪米特法则(LoD) 实体间的交互应尽可能少 ★★★☆☆

SOLID 原则

上表中前五个原则(SRP、OCP、LSP、ISP、DIP)即著名的 SOLID 原则,由 Robert C. Martin 在 2000 年代初期整理提出,SOLID 是五个原则英文名首字母的缩写。Michael Feathers 首先提出了这个助记缩写。SOLID 原则是面向对象设计的核心指导方针,被广泛应用于软件工程实践中。

单一职责原则

单一职责原则

单一职责原则(Single Responsibility Principle, SRP):一个对象应该只包含单一的职责,并且该职责被完整地封装在一个类中。

Every object should have a single responsibility, and that responsibility should be entirely encapsulated by the class.

等价表述:就一个类而言,应该仅有一个引起它变化的原因。

There should never be more than one reason for a class to change.

分析

一个类(或者大到模块,小到方法)承担的职责越多,它被复用的可能性越小。如果一个类承担了过多的职责,就相当于将这些职责耦合在一起——当其中一个职责变化时,可能会影响其他职责的运作。

类的职责主要包括两个方面:

  • 数据职责:通过属性来体现
  • 行为职责:通过方法来体现

单一职责原则是实现高内聚、低耦合的指导方针。它是最简单但又最难运用的原则——需要设计人员发现类的不同职责并将其分离,而发现类的多重职责需要较强的分析设计能力和相关重构经验。

实例

某基于 Java 的 C/S 系统的「登录功能」通过一个 Login 类实现,该类包含 init()display()validate()getConnection()findUser()main() 方法。这个类承担了多重职责:界面显示、数据验证、数据库连接、用户查询。

使用单一职责原则重构后,将其拆分为三个各司其职的类:

classDiagram
    direction LR
    class MainClass {
        +main()
    }
    class LoginForm {
        -dao: UserDAO
        +init()
        +display()
        +validate()
    }
    class UserDAO {
        -db: DBUtil
        +findUser()
    }
    class DBUtil {
        +getConnection()
    }
    MainClass ..> LoginForm
    LoginForm --> UserDAO
    UserDAO --> DBUtil
  • LoginForm 负责界面显示和验证
  • UserDAO(Data Access Object,数据访问对象)负责用户数据访问
  • DBUtil 负责数据库连接

每个类只有一个引起它变化的原因:界面需求变化只影响 LoginForm,数据库切换只影响 DBUtil,用户查询逻辑变化只影响 UserDAO

重构前后的代码对比:

1
2
3
4
5
6
7
8
9
// 重构前:一个类承担所有职责
public class Login {
    public void init() { /* 初始化界面 */ }
    public void display() { /* 显示登录窗口 */ }
    public boolean validate(String username, String password) { /* 验证输入 */ }
    public Connection getConnection() { /* 获取数据库连接 */ }
    public User findUser(String username) { /* 查询用户 */ }
    public static void main(String[] args) { /* 启动程序 */ }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// 重构后:每个类只负责一个职责
public class LoginForm {
    private UserDAO dao = new UserDAO();

    public void init() { /* 初始化界面 */ }
    public void display() { /* 显示登录窗口 */ }
    public boolean validate(String username, String password) {
        if (username == null || username.isEmpty()) return false;
        User user = dao.findUser(username);
        return user != null && user.getPassword().equals(password);
    }
}

public class UserDAO {
    private DBUtil db = new DBUtil();
    public User findUser(String username) {
        Connection conn = db.getConnection();
        // 使用 conn 查询用户...
    }
}

public class DBUtil {
    public Connection getConnection() {
        // 获取数据库连接...
    }
}

开闭原则

开闭原则

开闭原则(Open-Closed Principle, OCP):一个软件实体应当对扩展开放,对修改关闭。即在设计一个模块的时候,应当使这个模块可以在不被修改的前提下被扩展。

Software entities should be open for extension, but closed for modification.

分析

开闭原则由 Bertrand Meyer 于 1988 年在其著作 Object-Oriented Software Construction 中提出,它是面向对象设计中最重要的原则之一。

这里的「软件实体」可以是一个软件模块、一个由多个类组成的局部结构,或一个独立的类。抽象化是开闭原则的关键——通过定义抽象层(抽象类或接口),将系统的不变部分固定下来,而将可变部分作为具体实现来扩展。

开闭原则还可以通过一个更具体的对可变性封装原则(Principle of Encapsulation of Variation, EVP)来描述:找到系统的可变因素并将其封装起来。EVP 是开闭原则的直接体现——通过将可变部分抽象成接口或抽象类并封装在独立的模块中,使系统在面对变化时只需扩展新的实现,而无需修改已有代码。

实例

某图形界面系统提供了各种不同形状的按钮。原始设计中,LoginForm 直接依赖具体的按钮类(如 CircleButtonRectangleButton),每次更换按钮类型都需要修改 LoginForm 的源代码和成员变量类型。

重构后引入抽象类 AbstractButtonLoginForm 只依赖抽象类,具体的按钮类型通过配置文件 config.xml 指定:

classDiagram
    direction LR
    class LoginForm {
        -button: AbstractButton
        +display()
    }
    class AbstractButton {
        <<abstract>>
        +view()*
    }
    class CircleButton {
        +view()
    }
    class RectangleButton {
        +view()
    }
    LoginForm --> AbstractButton
    AbstractButton <|-- CircleButton
    AbstractButton <|-- RectangleButton

新增按钮类型时只需添加新的具体按钮类并修改配置文件,无需修改 LoginForm 的源代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// 抽象按钮类——系统的「不变部分」
public abstract class AbstractButton {
    public abstract void view();
}

// 具体按钮类——系统的「可变部分」,通过扩展新增
public class CircleButton extends AbstractButton {
    public void view() { System.out.println("显示圆形按钮"); }
}

public class RectangleButton extends AbstractButton {
    public void view() { System.out.println("显示矩形按钮"); }
}

// 客户端只依赖抽象,具体类型由配置文件决定
public class LoginForm {
    private AbstractButton button;

    public void display() {
        // 通过反射从 config.xml 读取具体类名并实例化
        String className = XMLUtil.getClassName("config.xml");
        button = (AbstractButton) Class.forName(className).newInstance();
        button.view();
    }
}
1
2
3
4
<!-- config.xml:修改配置即可切换按钮类型,无需改动源代码 -->
<config>
    <className>CircleButton</className>
</config>

里氏代换原则

里氏代换原则

里氏代换原则(Liskov Substitution Principle, LSP)有两种定义方式:

严格定义:如果对每一个类型为 SS 的对象 o1o_1,都有类型为 TT 的对象 o2o_2,使得以 TT 定义的所有程序 PP 在所有的对象 o2o_2 都代换成 o1o_1 时,程序 PP 的行为没有变化,那么类型 SS 是类型 TT 的子类型。

通俗定义:所有引用基类(父类)的地方必须能透明地使用其子类的对象。

Functions that use pointers or references to base classes must be able to use objects of derived classes without knowing it.

分析

里氏代换原则由 2008 年图灵奖得主 Barbara Liskov 和卡内基·梅隆大学 Jeannette Wing 教授于 1994 年提出。其原始表述为:

Let q(x)q(x) be a property provable about objects xx of type TT. Then q(y)q(y) should be true for objects yy of type SS where SS is a subtype of TT.

通俗理解:在软件中如果能够使用基类对象,那么一定能够使用其子类对象。把基类都替换成它的子类,程序将不会产生任何错误和异常;反过来则不成立——如果一个软件实体使用的是一个子类,它不一定能够使用基类。

里氏代换原则是实现开闭原则的重要方式之一。由于使用基类对象的地方都可以使用子类对象,因此在程序中尽量使用基类类型来对对象进行定义,而在运行时再确定其子类类型,用子类对象来替换父类对象。

实例

某系统需要实现对重要数据(如用户密码)的加密处理。原始设计中,DataOperator 同时持有 CipherACipherB 两个具体加密类的引用,新增加密方式需要修改 DataOperator 的源代码。

重构后,将加密算法抽象为基类 CipherA(定义加密接口),CipherB 继承自 CipherA 并覆盖加密方法。DataOperator 仅依赖基类 CipherA,具体的加密类通过配置文件指定。利用里氏代换原则,任何 CipherA 的子类(如 CipherBCipherC)都可以在不修改 DataOperator 的情况下透明替换:

classDiagram
    direction LR
    class Client {
        +main()
    }
    class DataOperator {
        -cipher: CipherA
        +setCipher(CipherA)
        +encrypt(String) String
    }
    class CipherA {
        +encrypt(String) String
    }
    class CipherB {
        +encrypt(String) String
    }
    Client ..> DataOperator
    DataOperator o-- CipherA
    CipherA <|-- CipherB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
public class CipherA {
    public String encrypt(String text) {
        // 加密算法 A:简单的凯撒密码
        StringBuilder sb = new StringBuilder();
        for (char c : text.toCharArray()) sb.append((char)(c + 3));
        return sb.toString();
    }
}

public class CipherB extends CipherA {
    @Override
    public String encrypt(String text) {
        // 加密算法 B:反转 + 凯撒
        StringBuilder sb = new StringBuilder(text).reverse();
        return super.encrypt(sb.toString());
    }
}

public class DataOperator {
    private CipherA cipher;

    public void setCipher(CipherA cipher) {
        this.cipher = cipher;  // 接受 CipherA 及其任何子类
    }

    public String encrypt(String data) {
        return cipher.encrypt(data);
    }
}

由于 CipherBCipherA 的子类,DataOperator.setCipher() 可以接受 CipherB 的实例而无需任何修改——这正是里氏代换原则的体现。

课件示例中 CipherA 既作为基类又作为一种具体加密实现,这在实际工程中并不理想。更好的做法是定义一个 Cipher 接口,让 CipherACipherB 分别实现该接口。但本例旨在说明:即使基类本身也是具体类,只要子类正确覆盖了方法且行为兼容,里氏代换原则仍然成立。

依赖倒转原则

依赖倒转原则

依赖倒转原则(Dependency Inversion Principle, DIP):

  • 高层模块不应该依赖低层模块,它们都应该依赖抽象。
  • 抽象不应该依赖于细节,细节应该依赖于抽象。

High level modules should not depend upon low level modules, both should depend upon abstractions. Abstractions should not depend upon details, details should depend upon abstractions.

等价表述:要针对接口编程,不要针对实现编程。

Program to an interface, not an implementation.

分析

依赖倒转原则是 Robert C. Martin 在 1996 年为 C++ Reporter 所写的专栏中提出的,后来收录于其 2002 年出版的经典著作 Agile Software Development, Principles, Patterns, and Practices 中。

简单来说,依赖倒转原则就是指:代码要依赖于抽象的类,而不要依赖于具体的类;要针对接口或抽象类编程,而不是针对具体类编程。如果说开闭原则是面向对象设计的目标,那么依赖倒转原则就是面向对象设计的主要手段

依赖倒转原则的常用实现方式之一是在代码中使用抽象类,而将具体类放在配置文件中——「将抽象放进代码,将细节放进元数据」(Put Abstractions in Code, Details in Metadata)。

类之间的耦合

类之间的耦合关系分为三种:

  • 零耦合:两个类之间没有任何关系
  • 具体耦合:一个类直接引用另一个具体类
  • 抽象耦合:一个类引用另一个类的抽象(接口或抽象类)

依赖倒转原则要求客户端依赖于抽象耦合,以抽象方式耦合是依赖倒转原则的关键

实例

某系统提供一个数据转换模块,可以将来自不同数据源(DatabaseSourceTextSource)的数据转换成多种格式(XMLTransformerXLSTransformer)。原始设计中,客户类 MainClass 直接依赖所有具体类,每增加一种新的数据源或文件格式都需要修改源代码,违背开闭原则。

重构后引入抽象层 AbstractSourceAbstractTransformerMainClass 仅依赖抽象类,具体类型通过配置文件指定:

classDiagram
    class MainClass {
        +main()
    }
    class AbstractSource {
        <<abstract>>
        +getData()* String
    }
    class AbstractTransformer {
        <<abstract>>
        +transform(String)*
    }
    class DatabaseSource {
        +getData() String
    }
    class TextSource {
        +getData() String
    }
    class XMLTransformer {
        +transform(String)
    }
    class XLSTransformer {
        +transform(String)
    }
    MainClass ..> AbstractSource
    MainClass ..> AbstractTransformer
    AbstractSource <|-- DatabaseSource
    AbstractSource <|-- TextSource
    AbstractTransformer <|-- XMLTransformer
    AbstractTransformer <|-- XLSTransformer

新增数据源或格式转换器时,只需创建新的具体类并修改配置文件,MainClass 的源代码无需任何改动。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 针对抽象编程
public abstract class AbstractSource {
    public abstract String getData();
}

public abstract class AbstractTransformer {
    public abstract void transform(String data);
}

// 客户端仅依赖抽象
public class MainClass {
    public static void main(String[] args) {
        // 从配置文件读取具体类名
        AbstractSource source =
            (AbstractSource) XMLUtil.getBean("source");
        AbstractTransformer transformer =
            (AbstractTransformer) XMLUtil.getBean("transformer");

        String data = source.getData();
        transformer.transform(data);
    }
}

接口隔离原则

接口隔离原则

接口隔离原则(Interface Segregation Principle, ISP):客户端不应该依赖那些它不需要的接口。

Clients should not be forced to depend upon interfaces that they do not use.

等价表述:一旦一个接口太大,则需要将它分割成一些更细小的接口,使用该接口的客户端仅需知道与之相关的方法即可。

Once an interface has gotten too 'fat' it needs to be split into smaller and more specific interfaces so that any clients of the interface will only know about the methods that pertain to them.

注意这里的「接口」指的是所定义的方法,而不仅仅是 Java 等语言中的 interface 关键字。

分析

接口隔离原则的核心是使用多个专门的接口,而不使用单一的总接口。每一个接口应该承担一种相对独立的角色,不多不少:

  1. 一个接口就只代表一个角色,每个角色都有它特定的一个接口——此时这个原则可以叫做「角色隔离原则」
  2. 接口仅仅提供客户端需要的行为(即所需的方法),客户端不需要的行为则隐藏起来,应当为客户端提供尽可能小的单独的接口

使用接口隔离原则拆分接口时,首先必须满足单一职责原则,将一组相关的操作定义在一个接口中,且在满足高内聚的前提下,接口中的方法越少越好。可以采用定制服务的方式,即为不同的客户端提供宽窄不同的接口,只提供用户需要的行为,而隐藏用户不需要的行为。

实例

某系统中定义了一个巨大的接口(胖接口)AbstractService,包含 operatorA()operatorB()operatorC() 三个方法,同时服务 ClientAClientBClientC 三个客户类。但每个客户类实际上只需要其中一个方法。

重构后将胖接口拆分为三个专门的接口,ConcreteService 同时实现这三个接口:

classDiagram
    class AbstractServiceA {
        <<interface>>
        +operatorA()
    }
    class AbstractServiceB {
        <<interface>>
        +operatorB()
    }
    class AbstractServiceC {
        <<interface>>
        +operatorC()
    }
    class ConcreteService {
        +operatorA()
        +operatorB()
        +operatorC()
    }
    class ClientA
    class ClientB
    class ClientC
    ClientA ..> AbstractServiceA
    ClientB ..> AbstractServiceB
    ClientC ..> AbstractServiceC
    AbstractServiceA <|.. ConcreteService
    AbstractServiceB <|.. ConcreteService
    AbstractServiceC <|.. ConcreteService

每个客户端只依赖它实际需要的接口方法,新增或修改某个操作不会影响不相关的客户端。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// 重构前:胖接口——所有客户端被迫依赖不需要的方法
public interface AbstractService {
    void operatorA();
    void operatorB();
    void operatorC();
}

// 重构后:按职责拆分为细粒度接口
public interface AbstractServiceA { void operatorA(); }
public interface AbstractServiceB { void operatorB(); }
public interface AbstractServiceC { void operatorC(); }

// 实现类可以实现多个接口
public class ConcreteService
        implements AbstractServiceA, AbstractServiceB, AbstractServiceC {
    public void operatorA() { /* ... */ }
    public void operatorB() { /* ... */ }
    public void operatorC() { /* ... */ }
}

// 每个客户端只依赖自己需要的接口
public class ClientA {
    private AbstractServiceA service;
    public void doWork() { service.operatorA(); }
}

合成复用原则

合成复用原则

合成复用原则(Composite Reuse Principle, CRP),又称组合/聚合复用原则(Composition/Aggregate Reuse Principle, CARP):尽量使用对象组合,而不是继承来达到复用的目的。

Favor composition of objects over inheritance as a reuse mechanism.

分析

合成复用原则就是指在一个新的对象里通过关联关系(包括组合关系和聚合关系)来使用一些已有的对象,使之成为新对象的一部分;新对象通过委派调用已有对象的方法达到复用其已有功能的目的。

在面向对象设计中,可以通过两种基本方法复用已有的设计和实现:

继承复用(「白箱」复用) 组合/聚合复用(「黑箱」复用)
实现难度 简单,易于扩展 相对复杂
封装性 破坏系统封装性,基类实现细节暴露给子类 不破坏封装性,只能通过公开接口访问
灵活性 静态的,不可能在运行时改变 可以在运行时动态选择和组合
耦合度 较高 较低,选择性地调用成员对象的操作
适用范围 有限 广泛

为什么叫「白箱」和「黑箱」

继承关系中,基类的内部实现对子类是可见的(如同打开的白箱),子类可以直接访问父类的 protected 成员。而组合/聚合关系中,被复用对象的内部实现对外部不可见(如同封闭的黑箱),只能通过其公开接口进行交互。

组合/聚合可以使系统更加灵活,类与类之间的耦合度降低,因此一般首选使用组合/聚合来实现复用;其次才考虑继承。在使用继承时,需要严格遵循里氏代换原则——有效使用继承会有助于对问题的理解、降低复杂度,而滥用继承反而会增加系统构建和维护的难度。

实例

某教学管理系统中,StudentDAOTeacherDAO 通过继承 DBUtil 获取数据库连接。如果需要更换连接方式(如从 JDBC 改为连接池),必须修改 DBUtil 的源代码。如果不同的 DAO 需要不同的连接方式,则需要增加新的 DBUtil 子类并修改 DAO 的继承关系,违背开闭原则。

重构后将继承关系改为组合关系:DAO 类持有一个 DBUtil 类型的成员变量,通过 setDbOperator() 方法注入具体的连接实现:

classDiagram
    direction LR
    class DBUtil {
        +getConnection()
    }
    class NewDBUtil {
        +getConnection()
    }
    class StudentDAO {
        -dbOperator: DBUtil
        +setDbOperator(DBUtil)
        +findStudentById()
        +save()
    }
    class TeacherDAO {
        -dbOperator: DBUtil
        +setDbOperator(DBUtil)
        +findTeacherById()
        +save()
    }
    DBUtil <|-- NewDBUtil
    StudentDAO o-- DBUtil
    TeacherDAO o-- DBUtil

改为组合关系后,不同的 DAO 可以在运行时选择不同的数据库连接方式(通过注入不同的 DBUtil 子类实例),无需修改源代码即可切换。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// 重构前:通过继承复用(白箱复用)——DAO 与 DBUtil 紧耦合
public class DBUtil {
    public Connection getConnection() { /* JDBC 连接 */ }
}

public class StudentDAO extends DBUtil {  // 继承获取连接能力
    public Student findStudentById(int id) {
        Connection conn = getConnection();  // 直接调用父类方法
        // ...
    }
}

// 重构后:通过组合复用(黑箱复用)——DAO 与 DBUtil 松耦合
public class StudentDAO {
    private DBUtil dbOperator;  // 持有 DBUtil 引用(组合)

    public void setDbOperator(DBUtil dbOperator) {
        this.dbOperator = dbOperator;  // 可注入 DBUtil 或其子类
    }

    public Student findStudentById(int id) {
        Connection conn = dbOperator.getConnection();  // 委派调用
        // ...
    }
}

// 使用时可以灵活切换连接方式
StudentDAO dao = new StudentDAO();
dao.setDbOperator(new DBUtil());     // 使用 JDBC
dao.setDbOperator(new NewDBUtil());  // 切换为连接池——无需改动 DAO 代码

迪米特法则

迪米特法则

迪米特法则(Law of Demeter, LoD),又称最少知识原则(Least Knowledge Principle, LKP),有多种表述:

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

迪米特法则得名于 1987 年美国东北大学(Northeastern University)一个名为「Demeter」的研究项目。

分析

简单地说,迪米特法则就是指一个软件实体应当尽可能少地与其他实体发生相互作用。这样,当一个模块修改时,就会尽量少地影响其他模块,扩展会相对容易。这是对软件实体之间通信的宽度和深度的限制。

对于一个对象,其朋友包括以下五类:

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

任何不满足上述条件的对象都是「陌生人」。

1
2
3
4
5
6
7
8
9
public class A {
    private B b;               // 朋友:成员对象

    public void method(C c) {  // 朋友:方法参数
        D d = new D();         // 朋友:自己创建的对象
        E e = c.getE();        // 陌生人!E 不是 A 的直接朋友
        e.doSomething();       // 违反迪米特法则:与陌生人通信
    }
}

狭义与广义

狭义迪米特法则:如果两个类之间不必彼此直接通信,那么这两个类就不应当发生直接的相互作用。如果其中一个类需要调用另一个类的某一个方法,可以通过第三者转发这个调用。

  • 优点:降低类之间的耦合,简化局部设计
  • 缺点:会在系统中增加大量小方法(中转方法),造成不同模块之间通信效率降低

广义迪米特法则:指对对象之间的信息流量、流向以及信息的影响的控制,主要是对信息隐藏的控制。信息隐藏使各个子系统之间脱耦,允许它们独立地被开发、优化、使用和修改,同时促进软件的复用。系统规模越大,信息隐藏就越重要。

实践建议

迪米特法则的主要用途在于控制信息的过载:

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

实例

某系统界面类(Form1Form5)与数据访问类(DAO1DAO4)之间的调用关系较为复杂,每个 Form 可能直接调用多个 DAO。

重构后引入 Controller 作为中介,界面类只与 Controller 通信,Controller 再与 DAO 交互:

flowchart TD
    subgraph 界面层
        F1["Form1"]
        F2["Form2"]
        F3["Form3"]
        F4["Form4"]
        F5["Form5"]
    end

    subgraph 控制层
        C1["Controller1"]
        C2["Controller2"]
    end

    subgraph 数据层
        D1["DAO1"]
        D2["DAO2"]
        D3["DAO3"]
        D4["DAO4"]
    end

    F1 --> C1
    F2 --> C1
    F3 --> C2
    F4 --> C2
    F5 --> C2
    C1 --> D1
    C1 --> D2
    C2 --> D3
    C2 --> D4

    classDef form fill:#e3f2fd,stroke:#1565c0,stroke-width:2px
    classDef ctrl fill:#fff3e0,stroke:#ef6c00,stroke-width:2px
    classDef dao fill:#e8f5e8,stroke:#2e7d32,stroke-width:2px

    class F1,F2,F3,F4,F5 form
    class C1,C2 ctrl
    class D1,D2,D3,D4 dao

引入 Controller 中介后,Form 与 DAO 之间不再直接通信,系统的耦合度大大降低。这正是 MVC 架构中 Controller 层的职责所在。

java.util.Stack 继承 java.util.Vector 合理吗?

这是一个违反多项设计原则的经典案例。Stack 是一个后进先出(LIFO)的数据结构,只应提供 pushpoppeek 等操作。但因为继承了 Vector,它同时拥有了 get(int)add(int, Object)remove(int) 等随机访问方法,允许用户绕过栈的 LIFO 约束从任意位置插入或删除元素。

1
2
3
4
5
6
7
Stack<String> stack = new Stack<>();
stack.push("A");
stack.push("B");
stack.push("C");
stack.add(0, "X");        // 在栈底插入——破坏了 LIFO 语义!
stack.remove(1);          // 按索引删除——栈不应支持此操作
String s = stack.get(0);  // 随机访问——违反栈的抽象

这违反了:

  • 里氏代换原则Stack 在行为语义上不是 Vector——它不应支持随机访问,无法在所有场景透明替换
  • 接口隔离原则Stack 暴露了大量用户不需要的 Vector 方法(胖接口)
  • 合成复用原则:应当用组合(Stack 内部持有一个 Vector 或数组作为存储)来复用 Vector 的功能,而非通过继承暴露其全部接口

Java 官方文档也建议使用 Deque 接口(如 ArrayDeque)替代 Stack 类。

原则间的关系

七大设计原则并非各自独立,它们之间存在明确的层次关系:

flowchart TD
    OCP["开闭原则<br>(目标)"]

    LOD["最少知识原则<br>(指导)"]
    SRP["单一职责原则<br>(基础)"]
    EVP["可变性封装原则<br>(基础)"]

    DIP["依赖倒转原则<br>(实现)"]
    CRP["合成复用原则<br>(实现)"]
    LSP["里氏代换原则<br>(实现)"]
    ISP["接口隔离原则<br>(实现)"]

    LOD -.->|指导| OCP
    SRP -->|支撑| OCP
    EVP -->|支撑| OCP
    DIP -->|实现| OCP
    CRP -->|实现| OCP
    LSP -->|实现| OCP
    ISP -->|实现| OCP

    classDef goal fill:#ffebee,stroke:#c62828,stroke-width:2px
    classDef guide fill:#fff3e0,stroke:#ef6c00,stroke-width:2px
    classDef base fill:#e3f2fd,stroke:#1565c0,stroke-width:2px
    classDef impl fill:#e8f5e8,stroke:#2e7d32,stroke-width:2px

    class OCP goal
    class LOD guide
    class SRP,EVP base
    class DIP,CRP,LSP,ISP impl
  • 目标:开闭原则——所有原则最终服务于「对扩展开放、对修改关闭」这一核心目标
  • 指导:最少知识原则——通过限制实体间的交互来降低系统复杂度
  • 基础:单一职责原则和可变性封装原则(EVP)——为后续原则提供基本的设计约束。EVP 是开闭原则的直接推论:通过将可变部分封装在抽象接口之后,系统对外表现为「对修改关闭」,对内则「对扩展开放」
  • 实现:依赖倒转原则、合成复用原则、里氏代换原则、接口隔离原则——提供达成开闭原则的具体手段