面向对象程序设计原则

一、 单一职责原则(SRP)

单一职责面向对象设计原则强调:一个类或模块应仅有一个引起其变化的因素(职责)。

  • 通俗地讲,一个类或模块应只承担一个(或一种类型的)业务职责
  • 当类承担的业务职责较多时,该类中任何职责需求的变化都会引起静态实现的变化,和导致其代码不稳定
  • 无论是向目标类添加新的代码,或修改已有的代码,都是对原有代码设计的破坏,导致程序开发者花费巨大成本进行代码修复

image-20200513100330978.png

图2.1 多个业务行为的Patron类

​ 例如,COS系统中客户角色具有的业务行为有:登录系统,支付订单,查看菜单等,如图2.1。如果将所有的业务行为实现在客户类Patron中,当这些业务行为中的任何一个需求发生变化时,如支付或登录行为需求发生变化,都需要修改Patron类。即,影响Patron类变化的因素多于1个时,会导致Patron类的代码变得不稳定。

image-20200513100701169.png

图2.1中Patron类的代码框架

​ 按照单一职责原则设计Patron类时,可以将登录系统、支付订单等业务职责分别单独地封装在其他类中,从而减少Patron类的业务职责。如图2.2,将登录行为封装在Employee中,支付订单行为封装在PayOrder中。

image-20200513100830202.png

图2.2 遵守“单一职责”原则设计的Patron类

此时,Patron继承Employee和依赖PayOrder,其类的代码框架如下:

image-20200513100920607.png

  • 符合“单一职责”设计原则的图2.2的设计方案减少了影响Patron变化的因素,使其代码更加稳定。
  • 工程师在使用“单一职责”原则设计类时,也会产生如下问题:

    1. 设计类数量的增加。如果一个庞大业务系统的所有类按单一职责设计,则有可能导致设计类数量的“爆炸(Explosion)”。此外,设计类数量的增加也会使设计方案复杂度增大。
    2. 类封装特性的破坏。由于数据域封装在目标(实体)类中,如果从目标类中将含有数据域访问逻辑的业务行为分离出去,则势必造成外部代码访问目标类私有域的问题,而最终破坏目标类的封装特性。
    3. 其他问题。完全教条式地运用“单一职责”设计类时,也可能会降低代码的内聚或增加代码的耦合。同时,类职责没有明确的定义,可以是具体业务功能或行为,也可以是抽象逻辑;因此,其边界是模糊的,难以清晰地划定单一职责。

二、“开/闭”原则(OCP)

“开/闭”原则要求:类或模块的代码“对扩展是开放的”(Open for Extension)、“对修改是关闭的”(Close for Modification)。

  • 当软件需求发生变化时,目标类或模块的代码可以通过代码扩展,很容易地实现新的需求;而不是修改已有类或模块的代码。
  • 因为,软件代码业务逻辑充满了耦合,当一处代码修改时,将会引发已有代码逻辑变化,产生逻辑错误或制造出新的代码缺陷。

image-20200513101128901.png

图2.3 PayOrder类可扩展性设计

​ COS系统需求提到的订单支付方式是工资抵扣或现金;在图2.2中,支付订单行为的实现封装在PayOrder类中。如果只考虑这两种支付行为,工程师一般会在PayOrder类中使用分支结构直接实现支付业务逻辑。这种代码结构存在的问题有:

  1. 如果COS支付需求发生变化,则必须修改PayOrder已有的分支结构才能满足新需求;
  2. 由于Patron依赖于PayOrder,则PayOrder代码的变化会直接影响到依赖者Patron。要解决上面的问题,需要重新设计PayOrder类结构,具体方案见图2.3。

​ 在图2.3中,将PayOrder泛化为一个接口或抽象类,其定义抽象的支付行为check();现金支付订单方式的业务逻辑由子类PayByCash实现,工资抵扣支付订单方式的业务逻辑由子类PayByPRDS实现。代码示例如下。

image-20200513101255795.png

PayOrder类结构

image-20200513101328094.png

PayByCash类结构

image-20200513101400533.png

PayByPRDS类结构
​ 那么,在图2.3的类设计方案中,当支付行为需求发生变化时,可以定义PayOrder新的子类实现新需求,而不需要修改已有的类(或接口)PayOrder、PayByCash、PayByPRDS等。

​ 例如,COS系统升级时,新需求要添加电子银行支付订单方式。那么,工程师可以直接定义PayOrder的子类PayByEBank来实现该需求,如图2.4。

image-20200513101440296.png

图2.4 添加电子银行支付后的PayOrder类结构设计

​ 在图2.4中,PayByEBank子类的添加并不会影响PayByCash、PayByPRDS等已有的类(或接口),且实现了新支付方式的扩展。

image-20200513101526173.png

PayByBank类结构

那么,PayByEBank类的添加对Patron(客户)类是否会产生影响?

  • 这种依赖关系不受具体实现类型的影响。因此,PayByEBank类的添加也没有影响到客户类Patron。
  • 从可扩展性和代码稳定性角度看,图2.3中PayOrder类的设计方案要优于图2.2,符合“开/闭”原则的设计思想。
  • 实施“开/闭”原则设计代码时,工程师可以使用抽象、继承、组合等面向对象技术获得代码灵活性、可重用性、可扩展性等方面的好处。但也应看到,“开/闭”原则对代码还有以下的影响:
  1. 代码可读性降低。由于使用了抽象,代码设计逻辑与业务需求逻辑相比,会产生变化,抽象代码层隐藏了具体业务细节,大大降低了源码的可读性。
  2. 程序测试成本增加。同样地,使用抽象设计会使测试人员无法静态确定具体对象的引用类型,必须等到程序运行时才能确定目标对象的具体类型。因此,代码缺陷可能会滞后到程序运行后才被发现;又或者,程序出现错误后,只有通过动态调试的方法才能有效地定位缺陷。最终,它们都会导致测试成本的增加。

三、接口隔离原则(ISP)

接口隔离原则指出:如果某个接口的行为不是内聚的,就应该按照业务分组,并将分组后的业务行为通过隔离的接口单独定义。

  • 接口的行为要向调用它的客户端提供业务服务;对于不同的业务分组,调用它的客户端是相互独立的;
  • 因此,接口提供的服务(分组)也应该是相互独立的。

image-20200513101804644.png

图2.5 打印配送单和发送配送指令行为定义在IDeliver接口中

​ 例如,在COS系统需求中,餐厅员工(Cafeteria Staff)和配餐员(Meal Deliverer)都有打印配送单(Print Delivery Instructions)的行为;而且,餐厅员工还有发送配送指令(Issue Delivery Request)的行为。如果将打印配送单行为和发送配送指令行为强行定义在接口IDeliver中,如图2.5,将会产生如下问题:

  1. 子类(或实现类)可能会继承(或实现)冗余行为。配餐员MealDeliverer作为IDeliver的实现类,需要实现issueDeliveryQuest()方法;然而,在COS的需求中,配餐员不具有该行为。
  2. 子类(或实现类)的客户端受到不相干的业务行为干扰。假设餐厅员工CafeteriaStaff想要改变issueDeliveryQuest()的行为定义,比如修改方法名称;那么,则要修改接口IDeliver。而IDeliver接口的变化会导致实现类MealDeliverer变化,最终影响到调用MealDeliverer的所有客户端。

​ 要解决上面的问题,工程师可以按照接口隔离原则提供的建议将接口中的方法进行业务分组。由于打印打印配送单和发送配送指令是不同的业务行为,两者之间的内聚度很弱,分离它们可以降低相互影响。

​ 因此,将图2.5中的IDeliver接口行为printDeliveryInstruction()和issueDeliveryQuest()分别定义在的接口IPrintDelivery和IIssueDelivery中,用于实现行为的隔离;如图2.6所示。

image-20200513101923338.png

图2.6 使用“接口隔离”原则设计打印配送单和发送配送指令行为

​ IIssueDelivery接口定义了issueDeliveryQuest()行为,IPrintDelivery接口定义了printDeliveryInstruction()行为,彼此独立,互不影响。CafeteriaStaff类实现IIssueDelivery、IPrintDelivery接口,MealDeliverer实现IPrintDelivery接口。

​ 可以看到,MealDeliverer只实现了自身需要的业务行为printDeliveryInstruction(),不用实现冗余行为issueDeliveryQuest(),保证了代码逻辑与需求的一致性。此外,IIssueDelivery接口定义的变化只对其实现类CafeteriaStaff产生影响,而不会对MealDeliverer造成任何影响。

​ 图2.6中类图代码框架如下。

image-20200513102011752.png

IIssueDelivery接口

image-20200513102023766.png

IPrintDelivery接口

在Java编程语言中,IPrintDelivery接口可以定义printDeliveryInstruction()行为的默认实现,CafeteriaStaff、MealDeliverer根据需求选择重写或使用接口默认。以CafeteriaStaff为例,代码结构如下。

image-20200513102057438.png

CafeteriaStaff实现类

四、依赖倒置原则(DIP)

依赖倒置面向对象设计原则建议:

  1. 高层模块不应依赖于低层模块,二者都应该依赖于抽象;
  2. 抽象不应依赖于细节,细节应依赖于抽象。

在分层的软件架构中,业务逻辑实现的模块被称为高层模块,数据或服务支持模块是底层模块,如图2.7

image-20200513103158318.png

图2.7 高层模块与低层模块的依赖关系
​ 图2.7中,业务层依赖于数据或服务层;即,高层模块依赖低层模块。当低层模块发生变化时,高层模块则不可避免地受到影响。特别地,当目标系统分层较多时,最低层模块代码的变化,会影响到所有的(含有直接依赖或间接依赖)高层模块。

​ 为了减少依赖或依赖传递对高层模块的影响,要使高层模块依赖于稳定的抽象。那么,将低层模块向高层模块提供服务定义为抽象,高层模块依赖于抽象;抽象是稳定的,抽象具体实现的变化不会影响到高层模块,如图2.8。

image-20200513103249839.png

图2.8 按照“依赖倒置”原则设计模块依赖关系示意

​ 相较于图2.7中的高层模块直接依赖于低层模块实现,图2.8中的高层模块依赖于底层模块的抽象;低层模块实现也同时依赖于抽象的定义(接口实现或类继承是一种强约束依赖关系,实现类或子类均依赖接口或父类的方法定义)。当低层实现变化时,由于低层模块的抽象隔离了变化,高层模块感知不到这种变化,也就不会受到该变化的影响。

​ 举个例子:在COS系统中,客户(Patron)支付订单的业务依赖于支付类(PayOrder);如果PayOrder是支付服务的实现类,则形成了高层代码Patron直接依赖于低层实现PayOrder的逻辑关系,如图2.9。

image-20200513103325290.png

图2.9 Patron直接依赖于PayOrder实现

​ 由于PayOrder是支付服务的实现,当支付服务需求发生变化时,则需要修改PayOrder已有代码,或重新定义实现新需求的子类。无论哪种方式,客户端Patron都会感知到支付服务的变化,并受到影响。假设,图2.9中的PayOrder类的check()方法实现了工资抵扣支付订单行为。那么,要实现电子银行支付订单行为的新需求,则解决方案会是以下两种中的一个:

  1. 修改PayOrder类,添加电子银行支付订单服务行为;修改客户端Patron支付逻辑,使得Patron可以使用PayOrder新添加的电子银行支付行为;
  2. 继承PayOrder,添加电子银行支付订单行为子类;修改Patron支付逻辑,使得Patron可以依赖新添加的电子银行支付行为子类。

以上两种解决方案都无法避免对客户端Patron代码的修改。即,低层模块实现的变化直接影响了高层模块。

​ 按照“依赖倒置”原则的建议重新设计Patron与PayOrder之间的依赖关系,将低层模块提供的服务进行抽象,封装为抽象类或接口;使高层模块依赖于低层模块的抽象类(或接口);低层模块的实现类继承(或实现)抽象类(或接口);如图2.10所示。

image-20200513103419518.png

图2.10按照“依赖倒置”原则重新设计Patron与PayOrder之间的依赖关系

图2.10的类图结构与图2.3、图2.4的类图结构一致,示例代码可参见2.2节。

​ 由于将图2.10中的PayOrder设计为抽象类(或接口),Patron对PayOrder的依赖关系就变得稳定了。子类PayByPRDS实现了工资抵扣支付订单服务,PayByEBank实现了电子银行支付订单服务;实现类的变化不会影响到客户端Patron。因此,图2.10的设计类图不仅能保证代码稳定性,也具有良好的可扩展性。

“依赖倒置”原则提供的代码设计建议可以很好地实现代码解耦。“依赖倒置”原则所体现的设计思维,在不同材料中有不同的名称,如“好莱坞”原则(Hollywood Principle)、回调机制(Callback Mechnism)等。

在使用依赖倒置原则时,工程师需要注意以下问题:

  1. 增加了代码复杂度。依赖倒置的设计方式无疑会增加接口或抽象类的数量,使得软件代码结构变得抽象或复杂。
  2. 更适合应对需求变化。由于将依赖关系指向抽象,抽象能够隔离调用者与被调用者的实现,使得二者的变化不会相互影响,在一定程度上保证了代码稳定性。依赖倒置的代码能够通过稳定的依赖关系将新需求加入,或将原需求变化所带来的影响降低;因此,其更适合应对有需求变化的代码设计。

五、Liskov替换原则(LSP)

对于静态类型的面对象编程语言,如Java、C#,继承是多态技术实现的主要方式。通过继承,子类可以重写父类定义的方法,使得父类(或接口)定义的对象引用可以指向不同的子类实例,形成同一个行为的调用表现出多态特征。

Liskov替换原则(有的资料翻译成“里氏”替换原则)建议:子类型对象必须能够完全替换掉它们的父类型对象,而不需要改变父类型的任何属性。

Liskov替换原则是美国计算机科学家Barbara Liskov(女,曾于2008年获得图灵奖)于1987年在期刊ACM SIGPLAN Notices发表的标题为“Data Abstraction and Hierarchy”的文章中所提出的形式化原则。其原文中的表述为:

对于类型S的对象o1,存在类型T的对象o2,如果能使T编写的程序P的行为在o1替换o2后保持不变,则S是T的子类型(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)。

​ Liskov替换原则提出了如何规范地使用继承。一旦违反该规则,则有可能会导致程序表达错误。例如,在COS系统中,工程师需要实现统计图表的绘制功能,图表组件类型有:柱状图(Bar)、线状图(Line)等。工程师设计了图表组件父类ChartComponent,并定义组件绘制行为draw();子类Bar继承ChartComponent和实现绘制柱状图行为,Line继承ChartComponent和实现绘制线状图行为;柱状图Bar额外定义了方法fill(),用于图形填充行为的实现;使用组件对象进行图表绘制的客户类是ChartDrawer;如图2.11。

image-20200513103700208.png

图2.11 COS图表绘制类结构

​ 由于不同的图表组件绘制方式不同;如,绘制柱状图不仅绘制柱形,还需对图形进行填充,而绘制线状图则不需要填充;ChartDrawer代码结构可以是:

image-20200513103722228.png

ChartComponent抽象类代码结构如下:

image-20200513103733026.png

Bar和Line作为ChartComponent的子类,分别实现柱状图、线状图绘制,以Bar为例,其代码结构如下:

image-20200513103741842.png

​ 从上面的代码中可以看到,ChartDrawer在绘制不同类型图表组件时,需要知道组件的子类型,然后才能对该组件进行绘制操作;这违反了Liskov替换原则。因为,Liskov替换原则建议:对于客户端程序ChartDrawer来说,drawChart()绘制行为使用的父类ChartComponent定义的对象是可以被其子类Bar或Line定义的对象完全替换,而不需要提供额外的子类信息。

  • 违反Liskov替换原则的代码具有以下缺陷:

    1. 可扩展性差。如果需要在图2.11中添加新的图表类型,比如饼状图Pie,则必须修改ChartDrawer类代码,才能完成组件子类型的扩展。而修改已有代码将会导致一系列后果,详细见本章2.2“开/闭”原则。
    2. 引入程序逻辑错误。ChartDrawer类的行为drawChart()绘制的是ChartComponent类型的图表组件;增加的新组件仍然是ChartComponent类型,但是drawChart()原有代码却无法完成新组件对象的绘制,其逻辑是错误的。

​ 仔细观察上面的代码,学习者会发现违反Liskov替换原则的主要原因是:Bar子类在实现父类ChartComponent的draw()行为时,更改了原有的业务定义。父类ChartComponent定义的抽象行为draw()是统计图表组件的完整绘制逻辑,而Bar子类将统计图表组件的绘制分成了两个行为逻辑:绘制图形draw()和填充图形fill()。即,Bar子类更改了父类ChartComponent定义的绘制行为逻辑。

​ 要解决Bar子类违反Liskov替换原则的问题,只需要保证该子类业务行为逻辑与父类保持一致即可。也就是,Bar子类的绘制行为draw()应包含图形填充行为fill()的逻辑。同样是图2.11类结构,Bar子类绘制行为的实现代码可以更改为:

image-20200513103902635.png

那么,客户类ChartDrawer的代码结构则修改为:

image-20200513103910045.png

​ ChartComponent所有子类draw()行为的业务逻辑都是实现目标组件完整绘制,而不是只绘制组件的一部分。因此,客户类ChartDrawer在绘制目标组件时,就不需要关心或了解具体的组件子类型。修改后的代码没有违反Liskov替换原则。

​ 此外,在图2.11中添加新的图表类型饼状图Pie,也不会对ChartDrawer带来任何影响。当ChartDrawer类的drawChart()行为收到的方法参数为Pie子类型的实例时,也能正确地实现绘制逻辑。

​ 虽然遵循Liskov替换原则设计的代码方案能够给代码稳定性、可扩展性等带来好处;工程师在实际使用该规则时,仍需注意:

  1. 在继承关系中,父类与子类之间是强约束关联。即,子类只能实现(或继承)父类的定义(域或行为),而不能更改;父类定义(域或行为)的变化会迫使所有子类跟随变化。
  2. Liskov替换原则限制了子类重写父类行为的逻辑,降低了代码灵活性。

六、总结

  • 任何一个面向对象设计原则都是从局部提高代码质量,而不是从软件架构的全局
  • 独立运用任何一个面向对象设计原则,都无法保证整体代码方案的优化
  • 本质上,软件设计活动是不同技术方案分析、比较和折中的过程
Last modification:October 24th, 2020 at 02:51 pm
如果觉得我的文章对你有用,请随意赞赏