软件修养 -- 依赖倒置原则(DIP :Dependence Inversion Principle

定义

依赖倒置原则(DIP :Dependence Inversion Principle):

  • 高层模块不应该依赖低层模块,二者都应该依赖其抽象
  • 抽象不应该依赖细节
  • 细节应该依赖抽象

也就是说高层模块,低层模块,细节都应该依赖抽象

由来

类 A 直接依赖类 B,假如要将类 B 改为类 C,则必须通过修改类 A 的代码来达成。 类 A 一般是高层模块,负责复杂的业务逻辑。 类 B 和类 C 是低层模块,负责基本的原子操作。 修改类 A,会给程序带来不必要的风险。

由来

解决方案

将类 A 修改为依赖接口 I,类 B 和类 C 各自实现接口 I,类 A 通过接口 I 间接与类 B 或者类 C 发生联系,则会大大降低修改类 A 的几率。

解决方案

优点

依赖倒置原则可以减少类间的耦合性,提高系统的稳定,降低并行开发引起的风险,提高代码的可读性可维护性

思考

  • 依赖倒置原则跟面向接口编程是什么关系?

    依赖倒置原则的核心思想就是面向接口编程

  • 什么是细节?什么是抽象?他们有什么区别?

    所谓细节就是较为具体的东西,比如具体的类,就如上面的类 B 与类 C,有具体的实现。

    所谓抽象就是具有契约性、共同性、规范性的表达,比如上面的接口 I。它表达了一种契约–你需要实现 funcA 和 funcB 才能被当成 I 来对待。

    相对于细节的多变性,抽象的东西要稳定的多。

    以上面的类 ABC 作为例子,B、C 类都属于细节,如果 A 直接依赖 B 或者 C,那么 B 或 C 的改动有可能就会影响到 A 的稳定性。同样的,A 对 B 或者 C 的操作也有可能影响到 B 或 C 的稳定性。这些互相影响,其实来源于直接的依赖,导致 B 或 C 的细节暴露过多。而面对抽象的接口 I,A 只能操作 funA 和 funcB,从而避免了不必要的暴露和风险。

    以抽象为基础搭建起来的架构比以细节为基础搭建起来的架构要稳定的多。

    稳定性表现在规范性、契约性、易修改性、扩展性、可维护性等。

案例

小明(程序员)接到小李(产品经理)的需求:客户端开始的时候打个“开始”的 log。小明心想简单,一下子完成了代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Logger {
  log(text: String) {
    console.log(text);
  }
}

class Client {
  logger?: Logger;

  start() {
    logger?.log("开始");
  }
}

let client = new Client();
let logger = new Logger();
client.logger = logger;
client.start();

这个时候,小李突然说,要把 log 变成存到文件的形式。 小明想了下,有点不情愿地改了代码(因为要改好几个地方)

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
//修改1
class FileLogger {
  log(text: String) {
    console.log("writeToFile"); //修改2
  }

  start() {
    console.log("开始");
  }
}

class Client {
  logger?: FileLogger; //修改3

  start() {
    logger.start();
    logger?.log("开始");
  }
}

let client = new Client();
let logger = new FileLogger(); //修改4
client.logger = logger;

client.start();

小李想了想现在是互联网时代,还是直接将 log 信息传到网络上吧。 这个时候,小明非常不情愿地说了声“你不早说”,但还是改了代码(又是好几处改动)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
//修改1
class WebLogger {
  log(text: String) {
    console.log("writeToWeb"); //修改2
  }
}

class Client {
  logger?: WebLogger; //修改3

  start() {
    logger?.log("开始");
  }
}

let client = new Client();
let logger = new WebLogger(); //修改4
client.logger = logger;

client.start();

这时,小明的老大小华看到小明不开心,便过来帮忙,改了下代码

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
31
32
33
34
35
interface Logger {
  log(text: String): void;
}

class WebLogger implements Logger {
  log(text: String) {
    console.log("writeToWeb");
  }
}

class FileLogger implements Logger {
  log(text: String) {
    console.log("writeToFile");
  }
}

class PrintLogger implements Logger {
  log(text: String) {
    console.log("print");
  }
}

class Client {
  logger?: Logger;

  start() {
    logger?.log("开始");
  }
}

let client = new Client();
let logger = new WebLogger();
client.logger = logger;

client.start();

小华对小明说,现在不用怕了,小李想什么样的 log 你改一下实现类就行了

1
let logger = new WebLogger(); // PrintLogger()  FileLogger()

小华的改动其实就是利用了依赖倒置原则,增强了易修改性、扩展性、可维护性等。

细心的朋友其实还发现了,在改成 FileLogger 的时候,Client 多余地调用了 FileLoggerstart 方法。这就是依赖细节,暴露细节,引起的问题。而使用抽象的接口就能较好地避免这类问题。

注意点

  • 分清细节与抽象

    虽然依赖倒置原则有很大的好处,但也不是所有的类都需要有抽象一个接口去对应,要视情况而定。

  • 变量的声明类型尽量是抽象类或接口

    注意是尽量,而不是全部

  • 尽量不要覆写基类的方法

    如果基类是一个抽象类,而这个方法已经实现了,子类尽量不要覆写。类间依赖的是抽象,覆写了抽象方法,对依赖的稳定性会有一定的影响。

  • 继承要遵循里氏替换原则

    不要破坏继承体系

延伸阅读


CatchZeng
Written by CatchZeng Follow
AI (Machine Learning) and DevOps enthusiast.