前言
第三部分的主题内容
Chap7. SRP:单一职责原则
反面案例 1:重复的假象
反面案例 2:代码合井
解决方案
本章小结
Chap8. OCP:开闭原则
一个实验例子
依赖方向的控制
小结
Chap9. LSP:里氏替换原则
正确的例子-继承的使用指导
错误例子-正方形/矩形问题
小结
Chap10. ISP:接口隔离原则
Chap11. DIP:依赖反转原则
稳定的抽象层
抽象工厂
具体实现组件
小结
总结
上次土拨鼠在上一篇已经介绍了三种编程范式的内容总结,接下来第三部分主要是关于编程设计原则的讲述。感兴趣的同学可以看前两篇。[《clean architecture》第一部分笔记] ,[《clean architecture》第二部分编程范式读书笔记] 。
image-20211226232739016
第三部分主要是关于编程设计原则的讲述,简称SOLID设计原则。分别是单一职责原则、开闭原则、里氏替换原则、接口隔离原则、依赖反转原则。
SRP:单一职责原则:一个软件系统的最佳结构高度依赖于这个系统的组织的内部结构,因此每个软件模块都有且只有一个需要被改变的理由。
OCP:开闭原则:如果软件系统想要更容易被改变,那么其设计就必须允许新增代码来修改系统行为,而非只能靠修改原来的代码。
LSP:里氏替换原则:这项原则的意思是如果想用可替换的组件来构建软件系统,那么这些组件就必须遵守同一个约定,以便让这些组件可以相互替换。
ISP:接口隔离原则:软件设计师应该在设计中避免不必要的依赖。
DIP:依赖反转原则:高层策略性的代码不应该依赖实现底层细节的代码。
首先要想构建一个好的软件系统,应该从写整洁的代码开始做起。SOLID 原则的主要作用就是告诉我们如何将数据和函数组织成为类,以及如将这些类链接起来成为程序。 这里的类仅仅代表一种数据和函数的分组,SOLID 原则应该直接紧贴于具体的代码逻辑之上,这些原则是用来帮助我们定义软件架构中的组件和模块的。
Chap7. SRP: THE SINGLE RESPONSIBILITY PRINCIPLE SRP:单一职责原则
SYMPTOM 1: ACCIDENTAL DUPLICATION 反面案例 1:重复的假象
SYMPTOM 2: MERGES 反面案例 2:代码合井
SOLUTIONS 解决方案
CONCLUSION 本章小结
Chap8. OCP: THE OPEN-CLOSED PRINCIPLE OCP:开闭原则
A THOUGHT EXPERIMENT 思想实验
DIRECTIONAL CONTROL 依赖方向的控制
INFORMATION HIDING 信息隐藏
CONCLUSION 本章小结
Chap9. LSP: THE LISKOV SUBSTITUTION PRINCIPLE LSP:里氏替换原则
GUIDING THE USE OF INHERITANCE 继承的使用指导
THE SQUARE/RECTANGLE PROBLEM 正方形/长方形问题
LSP AND ARCHITECTURE LSP 与软件架构
EXAMPLE LSP VIOLATION 违反 LSP 的案例
CONCLUSION 本章小结
Chap10. ISP: THE INTERFACE SEGREGATION PRINCIPLE ISP:接口隔离原则
ISP AND LANGUAGE ISP ISP与编程语言
ISP AND ARCHITECTURE ISP 与软件架构
CONCLUSION 本章小结
Chap11. DIP: THE DEPENDENCY INVERSION PRINCIPLE DIP:依赖反转原则
STABLE ABSTRACTIONS 稳定的抽象层
FACTORIES 工厂模式
CONCRETE COMPONENTS 具体实现组件
CONCLUSION 本章小结
SRP(The Single Responsibility Principle|单一职责原则)是 SOLID 五大设计原则中最容易被误解的一个。也许是名字的原因,很多程序员根据 SRP 这个名字想当然地认为这个原则就是指:每个模块都应该只做一件事。而SRP的真正含义是任何一个软件模块都应该有且仅有一个被修改的原因(A module should have one, and only one reason to change.)。
在现实环境中,软件系统为了满足用户和所有者的要求,必然要经常做出这样那样的修改。所以,我们也可以这样描述 SRP:任何一个软件模块都应该只对某一类行为者负责。
软件模块指什么呢?大部分情况下,其最简单的定义就是指一个源代码文件。在有些情况下编程语言并不是用源代码来存储程序,“软件模块”指的就是一组紧密相关的函数和数据结构。
这里举了一个反面的例子:某个工资管理程序中的 Employee 类有三个方法 calculatePay()、reportHours() 和 save()。三个方法对应三种不同的执行者,被放在同一个类中,显然违反了SRP原则,还会导致CFO的命令影响了COO所依赖的功能。calculatePay() 和 reportHours() 方法使用同样的逻辑来计算工时。假如CFO团队要修改正常工时的计算方法,程序员会注意到calculatePay()调用了regularHours(),导致regularHours也进行了改动。CFO团队验证觉得没问题上线了,COO团队完全不知道这些事,HR仍然使用reportHours()产生的报表数据。这样就导致了数据的出错,甚至会给公司带来很大的损失。
这类问题发生的根源就是因为我们将不同行为的执行者所依赖的代码强凑到了一起。SRP 强调这类代码一定要被分开。
image-20211227122106002
一个拥有很多函数的源代码文件必然会经历很多次的代码合并,该文件中的这些函数分别服务不同行为者的情况就更常见了。它们的一个共同点是,多人为了不同的目的修改了同一份源代码,这很容易造成问题的产生。而避免这种问题产生的方法就是将服务中的不同行为者的代码进行切分。
我们有很多不同的方法可以用来解决上面的问题,每一种方法都需要将相关的函数划分成不同的类。其中最简单直接的办法是将数据与函数分离:设计三个类共同使用一个不包括方法的、十分简单的 EmployeeData 类(见图 7.3);
image-20211229224620440
另一种解决办法是使用 Facade 设计模式(见图 7.4):EmployeeFacade 类所需要的代码量就很少了,它仅仅包含了初始化和调用三个具体实现类的方法。
image-20211227222248717
image-20211227222214244
当然,也有些程序员更倾向于把最重要的业务逻辑与数据放在一起,那么我们也可以选择将最重要的函数保留在 Employee 类中,同时用这个类来调用其他没那么重要的函数(见图 7.5)。
image-20211229234845275
总而言之,上面的每一个类都分别容纳了一组作用于相同作用域的函数,而在该作用域之外,它们各自的私有函数是互相不可见的。
单一职责原则主要讨论的是函数和类之间的关系——但是它在两个讨论层面上会以不同的形式出现。在组件层面,我们可以将其称为共同闭包原则(Common Closure Principle),在软件架构层面,它则是用于奠定架构边界的变更轴心(Axis of Change)。
开闭原则(The Open-Closed Principle|OCP) 是 Bertrand Meyer 在 1988 年提出的,该设计原则认为:设计良好的计算机软件应该易于扩展,同时抗拒修改(A software artifact should be open for extension but closed for modification.)。
换句话说,一个设计良好的计算机系统应该在不需要修改的前提下就可以轻易被扩展。一个好的软件架构设计师会努力将旧代码的修改需求量降至最小,甚至为 0。但该如何实现这一点呢?我们可以先将满足不同需求的代码分组(即 SRP),然后再来调整这些分组之间的依赖关系(即 DIP)。
这里举了一个web报表展示的例子,核心就是将应用生成报表的过程拆成两个不同的操作。即先计算出报表数据,再生成具体的展示报表(分别以网页及纸质的形式展示)。在具体实现上,我们会将整个程序进程划分成一系列的类,然后再将这些类分割成不同的组件。
image-20211227224031606
<I>
标记的类代表接口,用 <DS>
标记的则代表数据结构;开放箭头指代的是使用关系,闭合箭头则指代了实现与继承关系。图中所有组件之间的关系都是单向依赖的,让我们再来复述一下这里的设计原则:如果 A 组件不想被 B 组件上发生的修改所影响,那么就应该让 B 组件依赖于 A 组件。其中,Interactor 组件是整个系统中最符合 OCP 的。发生在 Database、Controller、Presenter 甚至 View 上的修改都不会影响到 Interactor。
以上就是我们在软件架构层次上对 OCP 这一设计原则的应用。软件架构师可以根据相关函数被修改的原因、修改的方式及修改的时间来对其进行分组隔离,并将这些互相隔离的函数分组整理成组件结构,使得高阶组件不会因低阶组件被修改而受到影响。
FinanciaIReportGenerator 和 FinancialDataMapper 之间的 Financial DataGateway 接口是为了反转 Interactor 与 Database 之间的依赖关系而产生的。
OCP 是我们进行系统架构设计的主导原则,其主要目标是让系统易于扩展,同时限制其每次被修改所影响的范围。实现方式是通过将系统划分为一系列组件,并且将这些组件间的依赖关系按层次结构进行组织,使得高阶组件不会因低阶组件被修改而受到影响。
里氏替换原则(Liskov Substitution Principle|LSP) :对于类型是 S 的对象 o1 中存在一个类型为 T 的对象 o2,如果能使操作 T 类型的程序 P 在用 o2 替换 o1 时行为保持不变,我们就可以把 S 称为 T 的子类型。也就是里氏替换原则(LSP)( If for each object o1 of type S there is an object o2 of type T such that for all programs P defined in terms of T, the behavior of P is unchanged when o1 is substituted for o2 then S is a subtype of T.1)。
图9.1这个例子是符合LSP的,License 类的两个“子类型”:PersonalLicense 与 BusinessLicense(都可以替换License),会通过不同的算法来计算费用。
image-20211228000057503
图9.2 正方形/矩形问题是一个著名的违反 LSP 的案例,这里正方形并不是矩形的子类型,操作Rectangle无法按照正方形分别设置宽高。
image-20211228000116987
在面向对象早期我们认为 LSP 只不过是指导如何使用继承关系的一种方法,然而随着时间的推移,LSP 逐渐演变成了一种更广泛的、指导接口与其实现方式的设计原则。
LSP 可以且应该被应用于软件架构层面,因为一旦违背了可替换也该系统架构就不得不为此增添大量复杂的应对机制。
接口隔离原则(Interface Segregation Principle|ISP) :这个名字来自图 10.1 所示的这种软件结构。从图中可以看出有多个用户需要操作 OPS 类。User1 只使用 op1,User2 只使用 op2,User3 只使用 op3。
User1 虽然不需要调用 op2、op3,如果对op2 进行修改,由于源代码层次上的依赖关系也会导致它需要被重新编译和部署。这个问题可以通过将不同的操作隔离成接口来解决,具体如图 10.2 所示。同样,我们也假设这个例子是用 Java 这种静态类型语言来实现的,那么现在 User1 的源代码只会依赖于 U1Ops 和 op1。而不是依赖OPS,这样一来,我们之后对 OPS 做的修改只要不影响到 User1 的功能,就不需要重新编译和部署 User1 了。
image-20211228201659903
本章所讨论的设计原则告诉我们:任何层次的软件设计如果依赖了它并不需要的东西,就会带来意料之外的麻烦。
依赖反转原则(Dependency Inversion Principle|DIP)主要想告诉我们的是:如果想要设计一个灵活的系统,在源代码层次的依赖关系中就应该多引用抽象类型,而不是用具体实现。
源代码依赖方向永远是控制流方向的反转——这就是 DIP 被称为依赖反转原则的原因。
如果想要在软件架构设计上追求稳定,就必须多使用稳定的抽象接口,少依赖多变的具体实现。
我们每次修改抽象接口的时候,一定也会去修改对应的具体实现。但反过来,当我们修改具体实现时,却很少需要去修改相应的抽象接口。所以我们可以认为接口比实现更稳定。如果想要在软件架构设计上追求稳定,就必须多使用稳定的抽象接口,少依赖多变的具体实现。
该设计原则可以归结为以下几条具体的编码守则:
这里展示了抽象工厂模式的例子,如图11.1所示, Application 类是通过 Service 接口来使用 Concretelmpl 类的。Application 类必须要构造 Concretelmpl 类实例,为了避免在源代码层次上引入对 Concretelmpl 类具体实现的依赖,我们现在让 Application 类去调用 ServiceFactory 接口的 makeSvc 方法。让 Application 类去调用 ServiceFactory 接口的 makeSvc 方法。这个方法就由 ServiceFactorylmpl 类来具体提供,它是 ServiceFactory 的一个衍生类。该方法的具体实现就是初始化一个 Concretelmpl 类的实例,并且将其以 Service 类型返回。
中间的曲线代表了软件架构中的抽象层与具体实现层的边界。所有跨越这条边界源代码级别的依赖关系都应该是单向的,即具体实现层依赖抽象层。
image-20211228230550033
这条曲线将整个系统划分为两部分组件:抽象接口与其具体实现。抽象接口组件中包含了应用的所有高阶业务规则,而具体实现组件中则包括了所有这些业务规则所需要做的具体操作及其相关的细节信息。可以看出源代码依赖方向永远是控制流方向的反转——这就是 DIP 被称为依赖反转原则的原因。
在图 11.1 中,具体实现组件的内部仅有一条依赖关系,这条关系其实是违反 DIP 的。这种情况很常见,我们在软件系统中并不可能完全消除违反 DIP 的情况通常只需要把它们集中于少部分的具体实现组件中,将其与系统的其他部分隔离即可。在 图 11.1 中,函数应该负责创建 ServiceFactoryImpl 实例,并将其赋值给类型为 ServiceFactory 的全局变量,以便让 Application 类通过这个全局变量来进行相关调用。
在后续进一步讨论中DIP 出现的频率将会越来越高,在系统架构图中,DIP 通常是最显而易见的组织原则我们在后续章节中会把图 11.1 中的那条曲线称为架构边界,而跨越边界的、朝向抽象层的单向依赖关系则会成为一个设计守则——依赖守则。
名言警句:
首先要想构建一个好的软件系统,应该从写整洁的代码开始做起。
SOLID 原则的主要作用就是告诉我们如何将数据和函数组织成为类,以及如将这些类链接起来成为程序。
SRP:单一职责原则:一个软件系统的最佳结构高度依赖于这个系统的组织的内部结构,因此每个软件模块都有且只有一个需要被改变的理由。
OCP:开闭原则:如果软件系统想要更容易被改变,那么其设计就必须允许新增代码来修改系统行为,而非只能靠修改原来的代码。
LSP:里氏替换原则:这项原则的意思是如果想用可替换的组件来构建软件系统,那么这些组件就必须遵守同一个约定,以便让这些组件可以相互替换。
ISP:接口隔离原则:软件设计师应该在设计中避免不必要的依赖。
DIP:依赖反转原则:高层策略性的代码不应该依赖实现底层细节的代码
软件架构设计上追求稳定,就必须多使用稳定的抽象接口,少依赖多变的具体实现。
如果设计一个灵活的系统,在源代码层次的依赖关系中就应该多引用抽象类型,而不是用具体实现。
源代码依赖方向永远是控制流方向的反转。
抽象设计原则
应在代码中多使用抽象接口,尽量避免使用那些多变的具体实现类
不要在具体实现类上创建衍生类
不要覆盖包含具体实现的函数
应避免在代码中写入与任何具体实现相关和容易变动的名字
关于整洁架构之道的第三部分关于本书的编程设计原则(SOLID)读书笔记土拨鼠今天就介绍到这里了。第四部分从组件构建原则开始,敬请期待。如果有不同见解欢迎留言讨论。
Copyright© 2013-2020
All Rights Reserved 京ICP备2023019179号-8