首页
学习
活动
专区
工具
TVP
发布
精选内容/技术社群/优惠产品,尽在小程序
立即前往

修炼内功:万字长文详解面向对象的设计原则

前言

在我们追逐互联网高并发技术时,应该提前打好基础。面向对象设计原则是成为架构的必由之路,通读此文,反复咀嚼,定会受益无穷。

通常来说,要想构建一个好的软件系统,应该从写整洁的代码开始做起。毕竟,如果建筑所使用的砖头质量不佳,那么架构所能起到的作用也会很有限。反之亦然,如果建筑的架构设计不佳,那么其所用的砖头质量再好也没有用。这就是 SOLID 设计原则所要解决的问题。

SOLID 原则的主要作用就是告诉我们如何将数据和函数组织成为类,以及如何将这些类链接起来成为程序。请注意,这里虽然用到了“类”这个词,但是并不意味着我们将要讨论的这些设计原则仅仅适用于面向对象编程。这里的类仅仅代表了一种数据和函数的分组,每个软件系统都会有自己的分类系统,不管它们各自是不是将其称为“类”,事实上都是 SOLID 原则的适用领域。

一般情况下,我们为软件构建中层结构的主要目标如下:

  • 使软件可容忍被改动。
  • 使软件更容易被理解。
  • 构建可在多个软件系统中复用的组件。

我们在这里之所以会使用“中层”这个词,是因为这些设计原则主要适用于那些进行模块级编程的程序员。SOLID 原则应该直接紧贴于具体的代码逻辑之上,这些原则是用来帮助我们定义软件架构中的组件和模块的。

当然了,正如用好砖也不会盖歪楼一样,采用设计良好的中层组件并不能保证系统的整体架构运作良好。正因为如此,我们在讲完 SOLID 原则之后,还会再继续针对组件的设计原则进行更进一步的讨论,将其推进到高级软件架构部分。

在这一部分中,我们会逐章地详细讨论每个设计原则,下面先来做一个简单摘要。

SRP:单一职责原则。

该设计原则是基于康威定律的一个推论——一个软件系统的最佳结构高度依赖于开发这个系统的组织的内部结构。这样,每个软件模块都有且只有一个需要被改变的理由。

OCP:开闭原则。

该设计原则是由 Bertrand Meyer 在 20 世纪 80 年代大力推广的,其核心要素是:如果软件系统想要更容易被改变,那么其设计就必须允许新增代码来修改系统行为,而非只能靠修改原来的代码。

LSP:里氏替换原则。

该设计原则是 Barbara Liskov 在 1988 年提出的一个著名的子类型定义。简单来说,这项原则的意思是如果想用可替换的组件来构建软件系统,那么这些组件就必须遵守同一个约定,以便让这些组件可以相互替换。

ISP:接口隔离原则。

这项设计原则主要告诫软件设计师应该在设计中避免不必要的依赖。

DIP:依赖反转原则。

该设计原则指出高层策略性的代码不应该依赖实现底层细节的代码,恰恰相反,那些实现底层细节的代码应该依赖高层策略性的代码。

SRP:单一职责原则


SRP 是 SOLID 五大设计原则中最容易被误解的一个。也许是名字的原因,很多程序员根据 SRP 这个名字想当然地认为这个原则就是指:每个模块都应该只做一件事。

没错,后者的确也是一个设计原则,即确保一个函数只完成一个功能。我们在将大型函数重构成小函数时经常会用到这个原则,但这只是一个面向底层实现细节的设计原则,并不是 SRP 的全部。

在历史上,我们曾经这样描述 SRP 这一设计原则:

任何一个软件模块都应该有且仅有一个被修改的原因。

在现实环境中,软件系统为了满足用户和所有者的要求,必然要经常做出这样那样的修改。而该系统的用户或者所有者就是该设计原则中所指的“被修改的原因”。所以,我们也可以这样描述 SRP:

任何一个软件模块都应该只对一个用户(User)或系统利益相关者(Stakeholder)负责。

不过,这里的“用户”和“系统利益相关者”在用词上也并不完全准确,它们很有可能指的是一个或多个用户和利益相关者,只要这些人希望对系统进行的变更是相似的,就可以归为一类——一个或多个有共同需求的人。在这里,我们将其称为行为者(actor)。

所以,对于 SRP 的最终描述就变成了:

任何一个软件模块都应该只对某一类行为者负责。

那么,上文中提到的“软件模块”究竟又是在指什么呢?大部分情况下,其最简单的定义就是指一个源代码文件。然而,有些编程语言和编程环境并不是用源代码文件来存储程序的。在这些情况下,“软件模块”指的就是一组紧密相关的函数和数据结构。

在这里,“相关”这个词实际上就隐含了 SRP 这一原则。代码与数据就是靠着与某一类行为者的相关性被组合在一起的。

或许,理解这个设计原则最好的办法就是让大家来看一些反面案例。反面案例 1:重复的假象

这是我最喜欢举的一个例子:某个工资管理程序中的 Employee 类有三个函数 calculatePay()、reportHours()和 save()。

如你所见,这个类的三个函数分别对应的是三类非常不同的行为者,违反了 SRP 设计原则。

calculatePay()函数是由财务部门制定的,他们负责向 CFO 汇报。

reportHours()函数是由人力资源部门制定并使用的,他们负责向 COO 汇报。

save()函数是由 DBA 制定的,他们负责向 CTO 汇报。

这三个函数被放在同一个源代码文件,即同一个 Employee 类中,程序员这样做实际上就等于使三类行为者的行为耦合在了一起,这有可能会导致 CFO 团队的命令影响到 COO 团队所依赖的功能。

例如,calculatePay()函数和 reportHours()函数使用同样的逻辑来计算正常工作时数。程序员为了避免重复编码,通常会将该算法单独实现为一个名为 regularHours()的函数。

接下来,假设 CFO 团队需要修改正常工作时数的计算方法,而 COO 带领的 HR 团队不需要这个修改,因为他们对数据的用法是不同的。

这时候,负责这项修改的程序员会注意到 calculatePay()函数调用了 regularHours()函数,但可能不会注意到该函数会同时被 reportHours()调用。

于是,该程序员就这样按照要求进行了修改,同时 CFO 团队的成员验证了新算法工作正常。这项修改最终被成功部署上线了。

但是,COO 团队显然完全不知道这些事情的发生,HR 仍然在使用 reportHours()产生的报表,随后就会发现他们的数据出错了!最终这个问题让 COO 十分愤怒,因为这些错误的数据给公司造成了几百万美元的损失。

与此类似的事情我们肯定多多少少都经历过。这类问题发生的根源就是因为我们将不同行为者所依赖的代码强凑到了一起。对此,SRP 强调这类代码一定要被分开。

反面案例 2:代码合并

一个拥有很多函数的源代码文件必然会经历很多次代码合并,该文件中的这些函数分别服务不同行为者的情况就更常见了。

例如,CTO 团队的 DBA 决定要对 Employee 数据库表结构进行简单修改。与此同时,COO 团队的 HR 需要修改工作时数报表的格式。

这样一来,就很可能出现两个来自不同团队的程序员分别对 Employee 类进行修改的情况。不出意外的话,他们各自的修改一定会互相冲突,这就必须要进行代码合并。

在这个例子中,这次代码合并不仅有可能让 CTO 和 COO 要求的功能出错,甚至连 CFO 原本正常的功能也可能受到影响。

事实上,这样的案例还有很多,我们就不一一列举了。它们的一个共同点是,多人为了不同的目的修改了同一份源代码,这很容易造成问题的产生。

而避免这种问题产生的方法就是将服务不同行为者的代码进行切分。

解决方案

我们有很多不同的方法可以用来解决上面的问题,每一种方法都需要将相关的函数划分成不同的类。

其中,最简单直接的办法是将数据与函数分离,设计三个类共同使用一个不包括函数的、十分简单的 EmployeeData 类,每个类只包含与之相关的函数代码,互相不可见,这样就不存在互相依赖的情况了。

这种解决方案的坏处在于:程序员现在需要在程序里处理三个类。另一种解决办法是使用 Facade 设计模式。

这样一来,EmployeeFacade 类所需要的代码量就很少了,它仅仅包含了初始化和调用三个具体实现类的函数。

当然,也有些程序员更倾向于把最重要的业务逻辑与数据放在一起,那么我们也可以选择将最重要的函数保留在 Employee 类中,同时用这个类来调用其他没那么重要的函数。

上图,将最重要的函数保留在 Employee 类中,同时调用其他两个没那么重要的类。

读者也许会反对上面这些解决方案,因为看上去这里的每个类中都只有一个函数。事实上并非如此,因为无论是计算工资、生成报表还是保存数据都是一个很复杂的过程,每个类都可能包含了许多私有函数。

总而言之,上面的每一个类都分别容纳了一组作用于相同作用域的函数,而在该作用域之外,它们各自的私有函数是互相不可见的。

单一职责原则主要讨论的是函数和类之间的关系——但是它在两个讨论层面上会以不同的形式出现。在组件层面,我们可以将其称为共同闭包原则。在软件架构层面,它则是用于奠定架构边界的变更轴心。

OCP:开闭原则


开闭原则(OCP)是 Bertrand Meyer 在 1988 年提出[3]的,该设计原则认为:

设计良好的计算机软件应该易于扩展,同时抗拒修改。

换句话说,一个设计良好的计算机系统应该在不需要修改的前提下就可以轻易被扩展。

其实这也是我们研究软件架构的根本目的。如果对原始需求的小小延伸就需要对原有的软件系统进行大幅修改,那么这个系统的架构设计显然是失败的。

尽管大部分软件设计师都已经认可了 OCP 是设计类与模块时的重要原则,但是在软件架构层面,这项原则的意义则更为重大。

下面,让我们用一个思想实验来做一些说明。

假设我们现在要设计一个在 Web 页面上展示财务数据的系统,页面上的数据要可以滚动显示,其中负值应显示为红色。

接下来,该系统的所有者又要求同样的数据需要形成一个报表,该报表要能用黑白打印机打印,并且其报表格式要得到合理分页,每页都要包含页头、页尾及栏目名。同时,负值应该以括号表示。

显然,我们需要增加一些代码来完成这个要求。但在这里我们更关注的问题是,满足新的要求需要更改多少旧代码。

一个好的软件架构设计师会努力将旧代码的修改需求量降至最小,甚至为 0。

但该如何实现这一点呢?我们可以先将满足不同需求的代码分组(即 SRP),然后再来调整这些分组之间的依赖关系(即 DIP)。

利用 SRP,我们可以按下图中所展示的方式来处理数据流。即先用一段分析程序处理原始的财务数据,以形成报表的数据结构,最后再用两个不同的报表生成器来产生报表。

SRP 的应用

这里的核心就是将应用生成报表的过程拆成两个不同的操作。即先计算出报表数据,再生成具体的展示报表(分别以网页及纸质的形式展示)。

接下来,我们就该修改其源代码之间的依赖关系了。这样做的目的是保证其中一个操作被修改之后不会影响到另外一个操作。同时,我们所构建的新的组织形式应该保证该程序后续在行为上的扩展都无须修改现有代码。

在具体实现上,我们会将整个程序进程划分成一系列的类,然后再将这些类分割成不同的组件。下面,我们用下图中的那些双线框来具体描述一下整个实现。在这个图中,左上角的组件是 Controller,右上角是 Interactor,右下角是 Database,左下角则有四个组件分别用于代表不同的 Presenter 和 View。

在下图中,用<I>标记的类代表接口,用<DS>标记的则代表数据结构;开放箭头指代的是使用关系,闭合箭头则指代了实现与继承关系。

首先,我们在下图中看到的所有依赖关系都是其源代码中存在的依赖关系。这里,从类 A 指向类 B 的箭头意味着 A 的源代码中涉及了 B,但是 B 的源代码中并不涉及 A。因此在下图中,FinancialDataMapper 在实现接口时需要知道 FinancialDataGateway 的实现,而 FinancialDataGateway 则完全不必知道 FinancialDataMapper 的实现。

其次,这里很重要的一点是这些双线框的边界都是单向跨越的。也就是说,上图中所有组件之间的关系都是单向依赖的,如下图所示,图中的箭头都指向那些我们不想经常更改的组件。

让我们再来复述一下这里的设计原则:如果 A 组件不想被 B 组件上发生的修改所影响,那么就应该让 B 组件依赖于 A 组件。

所以现在的情况是,我们不想让发生在 Presenter 上的修改影响到 Controller,也不想让发生在 View 上的修改影响到 Presenter。而最关键的是,我们不想让任何修改影响到 Interactor。

其中,Interactor 组件是整个系统中最符合 OCP 的。发生在 Database、Controller、Presenter 甚至 View 上的修改都不会影响到 Interactor。

为什么 Interactor 会被放在这么重要的位置上呢?因为它是该程序的业务逻辑所在之处,Interactor 中包含了其最高层次的应用策略。其他组件都只是负责处理周边的辅助逻辑,只有 Interactor 才是核心组件。

虽然 Controller 组件只是 Interactor 的附属品,但它却是 Presenter 和 View 所服务的核心。同样的,虽然 Presenter 组件是 Controller 的附属品,但它却是 View 所服务的核心。

另外需要注意的是,这里利用“层级”这个概念创造了一系列不同的保护层级。譬如,Interactor 是最高层的抽象,所以它被保护得最严密,而 Presenter 比 View 的层级高,但比 Controller 和 Interactor 的层级低。

以上就是我们在软件架构层次上对 OCP 这一设计原则的应用。软件架构师可以根据相关函数被修改的原因、修改的方式及修改的时间来对其进行分组隔离,并将这些互相隔离的函分组整理成组件结构,使得高阶组件不会因低阶组件被修改而受到影响。

依赖方向的控制

如果刚刚的类设计把你吓着了,别害怕!你刚刚在图表中所看到的复杂度是我们想要对组件之间的依赖方向进行控制而产生的。

例如,FinancialReportGenerator 和 FinancialDataMapper 之间的 FinancialDataGateway 接口是为了反转 Interactor 与 Database 之间的依赖关系而产生的。同样的,FinancialReportPresenter 接口与两个 View 接口之间也类似于这种情况。

当然,FinancialReportRequester 接口的作用则完全不同,它的作用是保护 FinancialReportController 不过度依赖于 Interactor 的内部细节。如果没有这个接口,则 Controller 将会传递性地依赖于 FinancialEntities。

这种传递性依赖违反了“软件系统不应该依赖其不直接使用的组件”这一基本原则。之后,我们会在讨论接口隔离原则和共同复用原则的时候再次提到这一点。

所以,虽然我们的首要目的是为了让 Interactor 屏蔽掉发生在 Controller 上的修改,但也需要通过隐藏 Interactor 内部细节的方法来让其屏蔽掉来自 Controller 的依赖。

OCP 是我们进行系统架构设计的主导原则,其主要目标是让系统易于扩展,同时限制其每次被修改所影响的范围。实现方式是通过将系统划分为一系列组件,并且将这些组件间的依赖关系按层次结构进行组织,使得高阶组件不会因低阶组件被修改而受到影响。

LSP:里氏替换原则


1988 年,Barbara Liskov 在描述如何定义子类型时写下了这样一段话:

这里需要的是一种可替换性:如果对于每个类型是 S 的对象 o1 都存在一个类型为 T 的对象 o2,能使操作 T 类型的程序 P 在用 o2 替换 o1 时行为保持不变,我们就可以将 S 称为 T 的子类型。

为了让读者理解这段话中所体现的设计理念,也就是里氏替换原则(LSP),我们可以来看几个例子。

继承的使用指导

假设我们有一个 License 类,其结构如下图所示。该类中有一个名为 calcFee()的方法,该方法将由 Billing 应用程序来调用。而 License 类有两个“子类型”:PersonalLicense 与 BusinessLicense,这两个类会用不同的算法来计算授权费用。

上述设计是符合 LSP 原则的,因为 Billing 应用程序的行为并不依赖于其使用的任何一个衍生类。也就是说,这两个衍生类的对象都是可以用来替换 License 类对象的。

反例:正方形/长方形问题

正方形/长方形问题是一个著名(或者说臭名远扬)的违反 LSP 的设计案例

在这个案例中,Square 类并不是 Rectangle 类的子类型,因为 Rectangle 类的高和宽可以分别修改,而 Square 类的高和宽则必须一同修改。由于 User 类始终认为自己在操作 Rectangle 类,因此会带来一些混淆。例如在下面的代码中:

Rectangle r = …

r.setW(5);

r.setH(2);

assert(r.area()== 10);

很显然,如果上述代码在…除返回的是 Square 类,则最后的这个 assert 是不会成立的。

如果想要防范这种违反 LSP 的行为,唯一的办法就是在 User 类中增加用于区分 Rectangle 和 Square 的检测逻辑(例如增加 if 语句)。但这样一来,User 类的行为又将依赖于它所使用的类,这两个类就不能互相替换了。

LSP 与软件架构

在面向对象这场编程革命兴起的早期,我们的普遍认知正如上文所说,认为 LSP 只不过是指导如何使用继承关系的一种方法,然而随着时间的推移,LSP 逐渐演变成了一种更广泛的、指导接口与其实现方式的设计原则。

这里提到的接口可以有多种形式——可以是 Java 风格的接口,具有多个实现类;也可以像 Ruby 一样,几个类共用一样的方法签名,甚至可以是几个服务响应同一个 REST 接口。

LSP 适用于上述所有的应用场景,因为这些场景中的用户都依赖于一种接口,并且都期待实现该接口的类之间能具有可替换性。

想要从软件架构的角度来理解 LSP 的意义,最好的办法还是来看几个反面案例

违反 LSP 的案例

假设我们现在正在构建一个提供出租车调度服务的系统。在该系统中,用户可以通过访问我们的网站,从多个出租车公司内寻找最适合自己的出租车。当用户选定车子时,该系统会通过调用 restful 服务接口来调度这辆车。

接下来,我们再假设该 restful 调度服务接口的 URI 被存储在司机数据库中。一旦该系统选中了最合适的出租车司机,它就会从司机数据库的记录中读取相应的 URI 信息,并通过调用这个 URI 来调度汽车。

也就是说,如果司机 Bob 的记录中包含如下调度 URI:

purplecab.com/driver/Bob

那么,我们的系统就会将调度信息附加在这个 URI 上,并发送这样一个 PUT 请求:

purplecab.com/driver/Bob

/pickupAddress/24 Maple St.

/pickupTime/153

/destination/ORD

很显然,这意味着所有参与该调度服务的公司都必须遵守同样的 REST 接口,它们必须用同样的方式处理 pickupAddress、pickupTime 和 destination 字段。

接下来,我们再假设 Acme 出租车公司现在招聘的程序员由于没有仔细阅读上述接口定义,结果将 destination 字段缩写成了 dest。而 Acme 又是本地最大的出租车公司,另外,Acme CEO 的前妻不巧还是我们 CEO 的新欢……你懂的!这会对系统的架构造成什么影响呢?

显然,我们需要为系统增加一类特殊用例,以应对 Acme 司机的调度请求。而这必须要用另外一套规则来构建。

最简单的做法当然是增加一条 if 语句:

if(driver.getDispatchUri().startsWith("acme.com"))…

然而很明显,任何一个称职的软件架构师都不会允许这样一条语句出现在自己的系统中。因为直接将“acme”这样的字串写入代码会留下各种各样神奇又可怕的错误隐患,甚至会导致安全问题。

例如,Acme 也许会变得更加成功,最终收购了 Purple 出租车公司。然后,它们在保留了各自名字的同时却统一了彼此的计算机系统。在这种情况下,系统中难道还要再增加一条“purple”的特例吗?

软件架构师应该创建一个调度请求创建组件,并让该组件使用一个配置数据库来保存 URI 组装格式,这样的方式可以保护系统不受外界因素变化的影响。例如其配置信息可以如下:

但这样一来,软件架构师就需要通过增加一个复杂的组件来应对并不完全能实现互相替换的 restful 服务接口。

LSP 可以且应该被应用于软件架构层面,因为一旦违背了可替换性,该系统架构就不得不为此增添大量复杂的应对机制。

ISP:接口隔离原则


在上图所描绘的应用中,有多个用户需要操作 OPS 类。现在,我们假设这里的 User1 只需要使用 op1,User2 只需要使用 op2,User3 只需要使用 op3。

在这种情况下,如果 OPS 类是用 Java 编程语言编写的,那么很明显,User1 虽然不需要调用 op2、op3,但在源代码层次上也与它们形成依赖关系。这种依赖意味着我们对 OPS 代码中 op2 所做的任何修改,即使不会影响到 User1 的功能,也会导致它需要被重新编译和部署。

这个问题可以通过将不同的操作隔离成接口来解决,具体如下图所示。

同样,我们也假设这个例子是用 Java 这种静态类型语言来实现的,那么现在 User1 的源代码会依赖于 U1Ops 和 op1,但不会依赖于 OPS。这样一来,我们之后对 OPS 做的修改只要不影响到 User1 的功能,就不需要重新编译和部署 User1 了。

ISP 与编程语言

很明显,上述例子很大程度上也依赖于我们所采用的编程语言。对于 Java 这样的静态类型语言来说,它们需要程序员显式地 import、use 或者 include 其实现功能所需要的源代码。而正是这些语句带来了源代码之间的依赖关系,这也就导致了某些模块需要被重新编译和重新部署。

而对于 Ruby 和 Python 这样的动态类型语言来说,源代码中就不存在这样的声明,它们所用对象的类型会在运行时被推演出来,所以也就不存在强制重新编译和重新部署的必要性。这就是动态类型语言要比静态类型语言更灵活、耦合度更松的原因。

当然,如果仅仅就这样说的话,读者可能会误以为 ISP 只是一个与编程语言的选择紧密相关的设计原则,而非软件架构问题,这就错了。

ISP 与软件架构

回顾一下 ISP 最初的成因:在一般情况下,任何层次的软件设计如果依赖于不需要的东西,都会是有害的。从源代码层次来说,这样的依赖关系会导致不必要的重新编译和重新部署,对更高层次的软件架构设计来说,问题也是类似的。

例如,我们假设某位软件架构师在设计系统 S 时,想要在该系统中引入某个框架 F。这时候,假设框架 F 的作者又将其捆绑在一个特定的数据库 D 上,那么就形成了 S 依赖于 F,F 又依赖于 D 的关系。

在这种情况下,如果 D 中包含了 F 不需要的功能,那么这些功能同样也会是 S 不需要的。而我们对 D 中这些功能的修改将会导致 F 需要被重新部署,后者又会导致 S 的重新部署。更糟糕的是,D 中一个无关功能的错误也可能会导致 F 和 S 运行出错。

DIP:依赖反转原则


依赖反转原则(DIP)主要想告诉我们的是,如果想要设计一个灵活的系统,在源代码层次的依赖关系中就应该多引用抽象类型,而非具体实现。

也就是说,在 Java 这类静态类型的编程语言中,在使用 use、import、include 这些语句时应该只引用那些包含接口、抽象类或者其他抽象类型声明的源文件,不应该引用任何具体实现。

同样的,在 Ruby、Python 这类动态类型的编程语言中,我们也不应该在源代码层次上引用包含具体实现的模块。当然,在这类语言中,事实上很难清晰界定某个模块是否属于“具体实现”。

显而易见,把这条设计原则当成金科玉律来加以严格执行是不现实的,因为软件系统在实际构造中不可避免地需要依赖到一些具体实现。例如,Java 中的 String 类就是这样一个具体实现,我们将其强迫转化为抽象类是不现实的,而在源代码层次上也无法避免对 java.lang.String 的依赖,并且也不应该尝试去避免。

但 String 类本身是非常稳定的,因为这个类被修改的情况是非常罕见的,而且可修改的内容也受到严格的控制,所以程序员和软件架构师完全不必担心 String 类上会发生经常性的或意料之外的修改。

同理,在应用 DIP 时,我们也不必考虑稳定的操作系统或者平台设施,因为这些系统接口很少会有变动。

我们主要应该关注的是软件系统内部那些会经常变动的(volatile)具体实现模块,这些模块是不停开发的,也就会经常出现变更。

稳定的抽象层

我们每次修改抽象接口的时候,一定也会去修改对应的具体实现。但反过来,当我们修改具体实现时,却很少需要去修改相应的抽象接口。所以我们可以认为接口比实现更稳定。

的确,优秀的软件设计师和架构师会花费很大精力来设计接口,以减少未来对其进行改动。毕竟争取在不修改接口的情况下为软件增加新的功能是软件设计的基础常识。

也就是说,如果想要在软件架构设计上追求稳定,就必须多使用稳定的抽象接口,少依赖多变的具体实现。下面,我们将该设计原则归结为以下几条具体的编码守则:

应在代码中多使用抽象接口,尽量避免使用那些多变的具体实现类。这条守则适用于所有编程语言,无论静态类型语言还是动态类型语言。同时,对象的创建过程也应该受到严格限制,对此,我们通常会选择用抽象工厂(abstract factory)这个设计模式。

不要在具体实现类上创建衍生类。上一条守则虽然也隐含了这层意思,但它还是值得被单独拿出来做一次详细声明。在静态类型的编程语言中,继承关系是所有一切源代码依赖关系中最强的、最难被修改的,所以我们对继承的使用应该格外小心。即使是在稍微便于修改的动态类型语言中,这条守则也应该被认真考虑。

不要覆盖(override)包含具体实现的函数。调用包含具体实现的函数通常就意味着引入了源代码级别的依赖。即使覆盖了这些函数,我们也无法消除这其中的依赖——这些函数继承了那些依赖关系。在这里,控制依赖关系的唯一办法,就是创建一个抽象函数,然后再为该函数提供多种具体实现。

应避免在代码中写入与任何具体实现相关的名字,或者是其他容易变动的事物的名字。这基本上是 DIP 原则的另外一个表达方式。

工厂模式

如果想要遵守上述编码守则,我们就必须要对那些易变对象的创建过程做一些特殊处理,这样的谨慎是很有必要的,因为基本在所有的编程语言中,创建对象的操作都免不了需要在源代码层次上依赖对象的具体实现。

在大部分面向对象编程语言中,人们都会选择用抽象工厂模式来解决这个源代码依赖的问题。

下面,我们通过下图来描述一下该设计模式的结构。如你所见,Application 类是通过 Service 接口来使用 ConcreteImpl 类的。然而,Application 类还是必须要构造 ConcreteImpl 类实例。于是,为了避免在源代码层次上引入对 ConcreteImpl 类具体实现的依赖,我们现在让 Application 类去调用 ServiceFactory 接口的 makeSvc 方法。这个方法就由 ServiceFactoryImpl 类来具体提供,它是 ServiceFactory 的一个衍生类。该方法的具体实现就是初始化一个 ConcreteImpl 类的实例,并且将其以 Service 类型返回。

上图中间的那条曲线代表了软件架构中的抽象层与具体实现层的边界。在这里,所有跨越这条边界源代码级别的依赖关系都应该是单向的,即具体实现层依赖抽象层。

这条曲线将整个系统划分为两部分组件:抽象接口与其具体实现。抽象接口组件中包含了应用的所有高阶业务规则,而具体实现组件中则包括了所有这些业务规则所需要做的具体操作及其相关的细节信息。

请注意,这里的控制流跨越架构边界的方向与源代码依赖关系跨越该边界的方向正好相反,源代码依赖方向永远是控制流方向的反转——这就是 DIP 被称为依赖反转原则的原因。

具体实现组件

在上图中,具体实现组件的内部仅有一条(红线)依赖关系,这条关系其实是违反 DIP 的。这种情况很常见,我们在软件系统中并不可能完全消除违反 DIP 的情况。通常只需要把它们集中于少部分的具体实现组件中,将其与系统的其他部分隔离即可。

绝大部分系统中都至少存在一个具体实现组件——我们一般称之为 main 组件,因为它们通常是 main 函数所在之处。在上图中,main 函数应该负责创建 ServiceFactoryImpl 实例,并将其赋值给类型为 ServiceFactory 的全局变量,以便让 Application 类通过这个全局变量来进行相关调用。

作者:Robert 原文链接:https://mp.weixin.qq.com/s/X6TGkwe-mmtZsS7K4t2_Eg

  • 发表于:
  • 本文为 InfoQ 中文站特供稿件
  • 首发地址https://www.infoq.cn/article/06d0d59fc7c7894bc41087a95
  • 如有侵权,请联系 cloudcommunity@tencent.com 删除。

扫码

添加站长 进交流群

领取专属 10元无门槛券

私享最新 技术干货

扫码加入开发者社群
领券