面向对象7大设计原则

目录

第1章 单一职责原则(SRP)

1.1 描述说明

1.2 如何使用单一职责原则

第2章 开放-封闭原则(OCP)

2.1 描述说明

2.2 如何使用开闭原则

2.3 开闭原则的实现方法

第3章 里氏替换原则(LSP)

3.1 描述说明

3.2 如何使用里氏替换原则

3.3 里氏替换原则的实现方法

第4章 依赖倒置原则(DIP)

4.1 描述说明

4.2 如何使用依赖倒置原则

第5章 接口隔离原则(ISP)

5.1 描述说明

5.2 如何使用接口隔离原则

第6章 合成/聚合复用原则(CRP/CARP)

6.1 描述说明

6.2 合成复用原则的实现方法

第7章    迪米特法则

7.1 描述说明

7.2 迪米特法则的实现方法


1章 单一职责原则(SRP)

1.1 描述说明

单一职责原则的英文名称是 SingleResponsibilityPrinciple ,简称是 SRP
单一职责原则的定义是:应该有且仅有一个原因引起类的变更。
这里的职责是指类变化的原因,单一职责原则规定一个类应该有且仅有一个引起它变化的原因,否则类应该被拆分。
单一职责原则有什么好处:
●  类的复杂性降低,实现什么职责都有清晰明确的定义;
●  可读性提高,复杂性降低,那当然可读性提高了;
●  可维护性提高,可读性提高,那当然更容易维护了;
●  变更引起的风险降低,变更是必不可少的,如果接口的单一职责做得好,一个接口修改只对相应的实现类有影响,对其他的接口无影响,这对系统的扩展性、维护性都有非常大的帮助。
该原则提出对象不应该承担太多职责,如果一个对象承担了太多的职责,至少存在以下两个缺点:
  1. 一个职责的变化可能会削弱或者抑制这个类实现其他职责的能力;
  2. 当客户端需要该对象的某一个职责时,不得不将其他不需要的职责全都包含进来,从而造成冗余代码或代码的浪费。
注意  单一职责原则提出了一个编写程序的标准,用 职责 变化原因 来衡量接口或类设计得是否优良,但是“ 职责 变化原因 都是不可度量的,因项目而异,因环境而异。

SRP是所有原则中最简单的之一,也是最难正确运用的之一。我们会自然的把职责结合到一起。软件设计真正要做的许多内容,就是发现职责并把那些职责相互分离。事实上,我们要讨论的其余原则都会以这样那样的方式回到这个问题上。

1.2 如何使用单一职责原则

对于接口,我们在设计的时候一定要做到单一,但是对于实现类就需要多方面考虑了。生搬硬套单一职责原则会引起类的剧增,给维护带来非常多的麻烦,而且过分细分类的职责也会人为地增加系统的复杂性。本来一个类可以实现的行为硬要拆成两个类,然后再使用聚合或组合的方式耦合在一起,人为制造了系统的复杂性。所以原则是死的,人是活的,这句话很有道理。
一个方法尽可能做一件事情。
对于单一职责原则,我的建议是接口一定要做到单一职责,类的设计尽量做到只有一个原因引起变化。
单一职责原则是最简单但又最难运用的原则,需要设计人员发现类的不同职责并将其分离,再封装到不同的类或模块中。而发现类的多重职责需要设计人员具有较强的分析设计能力和相关重构经验。下面以大学学生工作管理程序为例介绍单一职责原则的应用。

【例1】大学学生工作管理程序。

分析:大学学生工作主要包括学生生活辅导和学生学业指导两个方面的工作,其中生活辅导主要包括班委建设、出勤统计、心理辅导、费用催缴、班级管理等工作,学业指导主要包括专业引导、学习辅导、科研指导、学习总结等工作。如果将这些工作交给一位老师负责显然不合理,正确的做法是生活辅导由辅导员负责,学业指导由学业导师负责,其类图如图 1 所示。
 

大学学生工作管理程序的类图
图1 大学学生工作管理程序的类图


注意:单一职责同样也适用于方法。一个方法应该尽可能做好一件事情。如果一个方法处理的事情太多,其颗粒度会变得很粗,不利于重用。

第2章 开放-封闭原则(OCP)

2.1 描述说明

软件实体(类、模块、函数等等)应该是可以扩展的,但是不可修改的。

OCP背后的主要机制是抽象和多态。在静态类语言中,比如c++和java,支持抽象和多态的关键机制之一是继承。正是使用了继承,我们才可以创建实现其基类中抽象方法的派生类。

面向对象7大设计原则_第1张图片

开闭原则是面向对象 里最基础的设计原则,它指导我们如何建立一个稳定的、灵活的系统。
开闭原则的定义已经非常明确地告诉我们:软件实体应该对扩展开放,对修改关闭,其含义是说一个软件实体应该通过扩展来实现变化,而不是通过修改已有的代码来实现变化。那什么又是软件实体呢?软件实体包括以下几个部分:
项目或软件产品中按照一定的逻辑规则划分的模块。
抽象和类。
方法
一个软件产品只要在生命期内,都会发生变化,既然变化是一个既定的事实,我们就应该在设计时尽量适应这些变化,以提高项目的稳定性和灵活性,真正实现“ 拥抱变化 。开闭原则告诉我们应尽量通过扩展软件实体的行为来实现变化,而不是通过修改已有的代码来完成变化,它是为软件实体的未来事件而制定的对现行开发设计进行约束的一个原则。
注意  开闭原则对扩展开放,对修改关闭,并不意味着不做任何修改,低层模块的变更,必然要有高层模块进行耦合,否则就是一个孤立无意义的代码片段。
我们可以把变化归纳为以下三种类型:
逻辑变化
只变化一个逻辑,而不涉及其他模块,比如原有的一个算法是 a*b+c ,现在需要修改为a*b*c,可以通过修改原有类中的方法的方式来完成,前提条件是所有依赖或关联类都按照相同的逻辑处理。
子模块变化
一个模块变化,会对其他的模块产生影响,特别是一个低层次的模块变化必然引起高层模块的变化,因此在通过扩展完成变化时,高层次的模块修改是必然的。
可见视图变化
可见视图是提供给客户使用的界面,如 JSP 程序、 Swing 界面等,该部分的变化一般会引起连锁反应(特别是在国内做项目,做欧美的外包项目一般不会影响太大)。如果仅仅是界面上按钮、文字的重新排布倒是简单,最司空见惯的是业务耦合变化,什么意思呢?一个展示数据的列表,按照原有的需求是6 列,突然有一天要增加 1 列,而且这一列要跨 N 张表,处理M 个逻辑才能展现出来,这样的变化是比较恐怖的,但还是可以通过扩展来完成变化,这就要看我们原有的设计是否灵活。
开闭原则是非常重要的,可通过以下几个方面来理解其重要性:
1.开闭原则对测试的影响
软件遵守开闭原则的话,软件测试时只需要对扩展的代码进行测试就可以了,因为原有的测试代码仍然能够正常运行。
2.开闭原则可以提高复用性
粒度越小,被复用的可能性就越大;在面向对象的程序设计中,根据原子和抽象编程可以提高代码的可复用性。
3.开闭原则可以提高可维护性
遵守开闭原则的软件,其稳定性高和延续性强,从而易于扩展和维护。
4.面向对象开发的要求

2.2 如何使用开闭原则

1、用抽象来隔离变化

用抽象来隔离变化,在我们认为可能发生变化的地方放置吊钩(hook)。对频繁变化的那部分做出抽象,隔离不同业务的具体的实现。

抽象是对一组事物的通用描述,没有具体的实现,也就表示它可以有非常多的可能性,可以跟随需求的变化而变化。因此,通过接口或抽象类可以约束一组可能变化的行为,并且能够实现对扩展开放,其包含三层含义:第一,通过接口或抽象类约束扩展,对扩展进行边界限定,不允许出现在接口或抽象类中不存在的public 方法;第二,参数类型、引用对象尽量使用接口或者抽象类,而不是实现类;第三,抽象层尽量保持稳定,一旦确定即不允许修改。
所以,要实现对扩展开放,首要的前提条件就是抽象约束。
封装变化。
对变化的封装包含两层含义:第一,将相同的变化封装到一个接口或抽象类中;第二,将不同的变化封装到不同的接口或抽象类中,不应该有两个不同的变化出现在同一个接口或抽象类中。封装变化,也就是受保护的变化(protectedvariations ),找出预计有变化或不稳定的点,我们为这些变化点创建稳定的接口,准确地讲是封装可能发生的变化,一旦预测到或“ 第六感 发觉有变化,就可以进行封装。

2、元数据(metadata)控制模块行为

编程是一个很苦很累的活,那怎么才能减轻我们的压力呢?答案是尽量使用元数据来控制程序的行为,减少重复开发。什么是元数据?用来描述环境和数据的数据,通俗地说就是配置参数,参数可以从文件中获得,也可以从数据库中获得。

例如,可以在数据库中配置什么业务对应什么处理类,根据业务找到处理类名再动态创建处理类进行处理:

BUSI_TYPE RES_KIND_ID CLASS_NAME
LDSTORE rsclM CRsclMLdstoreLogic

3、制定项目章程

在一个团队中,建立项目章程是非常重要的,因为章程中指定了所有人员都必须遵守的约定,对项目来说,约定优于配置。相信大家都做过项目,会发现一个项目会产生非常多的配置文件。举个简单的例子,以SSH 项目开发为例,一个项目中的 Bean 配置文件就非常多,管理非常麻烦。如果需要扩展,就需要增加子类,并修改SpringContext 文件。然而,如果你在项目中指定这样一个章程:所有的Bean 都自动注入,使用 Annotation进行装配,进行扩展时,甚至只用写一个子类,然后由持久层生成对象,其他的都不需要修改,这就需要项目内约束,每个项目成员都必须遵守,该方法需要一个团队有较高的自觉性,需要一个较长时间的磨合,一旦项目成员都熟悉这样的规则,比通过接口或抽象类进行约束效率更高,而且扩展性一点也没有减少。

2.3 开闭原则的实现方法

可以通过“抽象约束、封装变化”来实现开闭原则,即通过接口或者抽象类为软件实体定义一个相对稳定的抽象层,而将相同的可变因素封装在相同的具体实现类中。
因为抽象灵活性好,适应性广,只要抽象的合理,可以基本保持软件架构的稳定。而软件中易变的细节可以从抽象派生来的实现类来进行扩展,当软件需要发生变化时,只需要根据需求重新派生一个实现类来扩展就可以了。

下面以 Windows 的桌面主题为例介绍开闭原则的应用。
【例1】Windows 的桌面主题设计。
分析:Windows 的主题是桌面背景图片、窗口颜色和声音等元素的组合。用户可以根据自己的喜爱更换自己的桌面主题,也可以从网上下载新的主题。这些主题有共同的特点,可以为其定义一个抽象类(Abstract Subject),而每个具体的主题(Specific Subject)是其子类。用户窗体可以根据需要选择或者增加新的主题,而不需要修改原代码,所以它是满足开闭原则的,其类图如图 1 所示。
 

Windows的桌面主题类图
图1 Windows的桌面主题类图

第3章 里氏替换原则(LSP)

3.1 描述说明

定义一:如果对每一个类型为 S 的对象 o1 ,都有类型为 T 的对象o2 ,使得以 T 定义的所有程序 P 在所有的对象 o1 都代换成 o2 时,程序 P 的行为没有发生变化,那么类型S 是类型 T 的子类型。
定义二:所有引用基类的地方必须能透明地使用其子类的对象。
第二个定义是最清晰明确的,通俗点讲,只要父类能出现的地方子类就可以出现,而且替换为子类也不会产生任何错误或异常,使用者可能根本就不需要知道是父类还是子类。但是,反过来就不行了,有子类出现的地方,父类未必就能适应。
里氏替换原则主要阐述了有关继承的一些原则,也就是什么时候应该使用继承,什么时候不应该使用继承,以及其中蕴含的原理。 里氏替换原是继承复用的基础,它反映了基类与子类之间的关系,是对开闭原则的补充,是对实现抽象化的具体步骤的规范。
里氏替换原则的主要作用如下。
  1. 里氏替换原则是实现开闭原则的重要方式之一。
  2. 它克服了继承中重写父类造成的可复用性变差的缺点。
  3. 它是动作正确性的保证。即类的扩展不会给已有的系统引入新的错误,降低了代码出错的可能性。

3.2 如何使用里氏替换原则

在面向对象的语言中,继承是必不可少的、非常优秀的语言机制,它有如下优点:
代码共享,减少创建类的工作量,每个子类都拥有父类的方法和属性;
提高代码的重用性;
子类可以形似父类,但又异于父类, 龙生龙,凤生凤,老鼠生来会打洞 是说子拥有父的“ 世界上没有两片完全相同的叶子 是指明子与父的不同;
提高代码的可扩展性,实现父类的方法就可以 为所欲为 了,君不见很多开源框架的扩展接口都是通过继承父类来完成的;
提高产品或项目的开放性。
自然界的所有事物都是优点和缺点并存的,即使是鸡蛋,有时候也能挑出骨头来,继承的缺点如下:
继承是侵入性的。只要继承,就必须拥有父类的所有属性和方法;
降低代码的灵活性。子类必须拥有父类的属性和方法,让子类自由的世界中多了些约束;
增强了耦合性。当父类的常量、变量和方法被修改时,需要考虑子类的修改,而且在缺乏规范的环境下,这种修改可能带来非常糟糕的结果—— 大段的代码需要重构。
里氏替换原则为良好的继承定义了一个规范,一句简单的定义包含了 4 层含义:
1.子类必须完全实现父类的方法
注意 在类中调用其他类时务必要使用父类或接口,如果不能使用父类或接口,则说明类的设计已经违背了LSP原则。
注意 如果子类不能完整地实现父类的方法,或者父类的某些方法在子类中已经发生“畸变”,则建议断开父子继承关系,采用依赖、聚集、组合等关系代替继承。
2.子类可以有自己的个性
子类当然可以有自己的行为和外观了,也就是方法和属性,那这里为什么要再提呢?是因为里氏替换原则可以正着用,但是不能反过来用。在子类出现的地方,父类未必就可以胜任。
3.覆盖或实现父类的方法时输入参数可以被放大
class FatherParam
{};
class SonParam : public FatherParam
{};
class Father
{
public:
    virtual void doSomething(const FatherParam& in) {}
}
class Son : public Father
{
public:
    virtual void doSomething(const SonParam& in) {}
}
4.覆写或实现父类的方法时输出结果可以被缩小
class FatherParam
{};
class SonParam : public FatherParam
{};
class Father
{
public:
    virtual SonParam doSomething(const FatherParam& in) {}
}
class Son : public Father
{
public:
    virtual FatherParam doSomething(const SonParam& in) {}
}
采用里氏替换原则的目的就是增强程序的健壮性,版本升级时也可以保持非常好的兼容性。即使增加子类,原有的子类还可以继续运行。在实际项目中,每个子类对应不同的业务含义,使用父类作为参数,传递不同的子类完成不同的业务逻辑,非常完美!

3.3 里氏替换原则的实现方法

里氏替换原则通俗来讲就是:子类可以扩展父类的功能,但不能改变父类原有的功能。也就是说:子类继承父类时,除添加新的方法完成新增功能外,尽量不要重写父类的方法。

如果通过重写父类的方法来完成新的功能,这样写起来虽然简单,但是整个继承体系的可复用性会比较差,特别是运用多态比较频繁时,程序运行出错的概率会非常大。

如果程序违背了里氏替换原则,则继承类的对象在基类出现的地方会出现运行错误。这时其修正方法是:取消原来的继承关系,重新设计它们之间的关系。

关于里氏替换原则的例子,最有名的是“正方形不是长方形”。当然,生活中也有很多类似的例子,例如,企鹅、鸵鸟和几维鸟从生物学的角度来划分,它们属于鸟类;但从类的继承关系来看,由于它们不能继承“鸟”会飞的功能,所以它们不能定义成“鸟”的子类。同样,由于“气球鱼”不会游泳,所以不能定义成“鱼”的子类;“玩具炮”炸不了敌人,所以不能定义成“炮”的子类等。

下面以“几维鸟不是鸟”为例来说明里氏替换原则。
【例2】里氏替换原则在“几维鸟不是鸟”实例中的应用。
分析:鸟一般都会飞行,如燕子的飞行速度大概是每小时 120 千米。但是新西兰的几维鸟由于翅膀退化无法飞行。假如要设计一个实例,计算这两种鸟飞行 300 千米要花费的时间。显然,拿燕子来测试这段代码,结果正确,能计算出所需要的时间;但拿几维鸟来测试,结果会发生“除零异常”或是“无穷大”,明显不符合预期,其类图如图 1 所示。
 

“几维鸟不是鸟”实例的类图
图1 “几维鸟不是鸟”实例的类图

第4章 依赖倒置原则(DIP)

4.1 描述说明

依赖倒置原则的原始定义为:高层模块不应该依赖低层模块,两者都应该依赖其抽象;抽象不应该依赖细节,细节应该依赖抽象(High level modules shouldnot depend upon low level modules.Both should depend upon abstractions.Abstractions should not depend upon details. Details should depend upon abstractions)。其核心思想是:要面向接口编程,不要面向实现编程。
依赖倒置原则是实现开闭原则的重要途径之一,它降低了客户与实现模块之间的耦合。
由于在软件设计中,细节具有多变性,而抽象层则相对稳定,因此以抽象为基础搭建起来的架构要比以细节为基础搭建起来的架构要稳定得多。这里的抽象指的是接口或者抽象类,而细节是指具体的实现类。
使用接口或者抽象类的目的是制定好规范和契约,而不去涉及任何具体的操作,把展现细节的任务交给它们的实现类去完成。

依赖倒置原则(DependenceInversionPrinciple,DIP),原始定义包含三层含义:

高层模块不应该依赖低层模块,两者都应该依赖其抽象;
抽象不应该依赖细节;
细节应该依赖抽象。
更加精简的定义就是 面向接口编程 ”——OOD Object-OrientedDesign ,面向对象设计)的精髓之一。
高层模块和低层模块容易理解,每一个逻辑的实现都是由原子逻辑组成的,不可分割的原子逻辑就是低层模块,原子逻辑的再组装就是高层模块。那什么是抽象?什么又是细节呢?在Java/c++ 语言中,抽象就是指接口或抽象类,两者都是不能直接被实例化的;细节就是实现类,实现接口或继承抽象类而产生的类就是细节,其特点就是可以直接被实例化,也就是可以加上一个关键字new 产生一个对象。依赖倒置原则在Java/c++语言中的表现就是:
模块间的依赖通过抽象发生,实现类之间不发生直接的依赖关系,其依赖关系是通过接口或抽象类产生的;
接口或抽象类不依赖于实现类;
实现类依赖接口或抽象类。

面向对象7大设计原则_第2张图片

依赖倒置原则的主要作用如下:
  • 依赖倒置原则可以降低类间的耦合性。
  • 依赖倒置原则可以提高系统的稳定性。
  • 依赖倒置原则可以减少并行开发引起的风险。
  • 依赖倒置原则可以提高代码的可读性和可维护性。
抽象是对实现的约束,对依赖者而言,也是一种契约,不仅仅约束自己,还同时约束自己与外部的关系,其目的是保证所有的细节不脱离契约的范畴,确保约束双方按照既定的契约(抽象)共同发展,只要抽象这根基线在,细节就脱离不了这个圈圈,始终让你的对象做到“ 言必信,行必果
对象的依赖关系有三种方式来传递,如下
1. 构造函数传递依赖对象
在类中通过构造函数声明依赖对象,按照依赖注入的说法,这种方式叫做构造函数注入。
2.Setter 方法传递依赖对象
在抽象中设置 Setter 方法声明依赖关系,依照依赖注入的说法,这是 Setter 依赖注入。
3. 接口声明依赖对象
在接口的方法中声明依赖对象,该方法也叫做接口注入。
到底什么是 倒置 呢?我们先说“ 正置 是什么意思,依赖正置就是类间的依赖是实实在在的实现类间的依赖,也就是面向实现编程,这也是正常人的思维方式,我要开奔驰车就依赖奔驰车,我要使用笔记本电脑就直接依赖笔记本电脑,而编写程序需要的是对现实世界的事物进行抽象,抽象的结果就是有了抽象类和接口,然后我们根据系统设计的需要产生了抽象间的依赖,代替了人们传统思维中的事物间的依赖,“ 倒置 就是从这里产生的。
依赖倒置原则的优点在小型项目中很难体现出来,例如小于10个人月的项目,使用简单的SSH架构,基本上不费太大力气就可以完成,是否采用依赖倒置原则影响不大。但是,在一个大中型项目中,采用依赖倒置原则有非常多的优点,特别是规避一些非技术因素引起的问题。项目越大,需求变化的概率也越大,通过采用依赖倒置原则设计的接口或抽象类对实现类进行约束,可以减少需求变化引起的工作量剧增的情况。人员的变动在大中型项目中也是时常存在的,如果设计优良、代码结构清晰,人员变化对项目的影响基本为零。大中型项目的维护周期一般都很长,采用依赖倒置原则可以让维护人员轻松地扩展和维护。
依赖倒置原则是 7 个设计原则中最难以实现的原则,它是实现开闭原则的重要途径,依赖倒置原则没有实现,就别想实现对扩展开放,对修改关闭。在项目中,大家只要记住是“ 面向接口编程 就基本上抓住了依赖倒置原则的核心。

4.2 如何使用依赖倒置原则

依赖倒置原则的本质就是通过抽象(接口或抽象类)使各个类或模块的实现彼此独立,不互相影响,实现模块间的松耦合,我们怎么在项目中使用这个规则呢?只要遵循以下的几个规则就可以:
●  每个类尽量都有接口或抽象类,或者抽象类和接口两者都具备
这是依赖倒置的基本要求,接口和抽象类都是属于抽象的,有了抽象才可能依赖倒置。
●  变量的表面类型尽量是接口或者是抽象类
● 尽量 任何类都不应该从具体类派生
●  尽量不要覆写基类的方法
如果基类是一个抽象类,而且这个方法已经实现了,子类尽量不要覆写。类间依赖的是抽象,覆写了抽象方法,对依赖的稳定性会产生一定的影响。可以在我们认为可能发生变化的地方放置吊钩(hook)空方法,再在子类中复写这个空方法。
●  结合里氏替换原则使用
在第 3 章中我们讲解了里氏替换原则,父类出现的地方子类就能出现,再结合本章的讲解,我们可以得出这样一个通俗的规则:接口负责定义public 属性和方法,并且声明与其他对象的依赖关系,抽象类负责公共构造部分的实现,实现类准确的实现业务逻辑,同时在适当的时候对父类进行细化。

面向对象7大设计原则_第3张图片

下面以“顾客购物程序”为例来说明依赖倒置原则的应用。
【例1】依赖倒置原则在“顾客购物程序”中的应用。
分析:本程序反映了 “顾客类”与“商店类”的关系。商店类中有 sell() 方法,顾客类通过该方法购物以下代码定义了顾客类通过韶关网店 ShaoguanShop 购物:

  1. class Customer
  2. {
  3. public void shopping(ShaoguanShop shop)
  4. {
  5. //购物
  6. System.out.println(shop.sell());
  7. }
  8. }
但是,这种设计存在缺点,如果该顾客想从另外一家商店(如婺源网店 WuyuanShop)购物,就要将该顾客的代码修改如下:
  1. class Customer
  2. {
  3. public void shopping(WuyuanShop shop)
  4. {
  5. //购物
  6. System.out.println(shop.sell());
  7. }
  8. }
顾客每更换一家商店,都要修改一次代码,这明显违背了开闭原则。存在以上缺点的原因是:顾客类设计时同具体的商店类绑定了,这违背了依赖倒置原则。解决方法是:定义“婺源网店”和“韶关网店”的共同接口 Shop,顾客类面向该接口编程,其代码修改如下:
  1. class Customer
  2. {
  3. public void shopping(Shop shop)
  4. {
  5. //购物
  6. System.out.println(shop.sell());
  7. }
  8. }

这样,不管顾客类 Customer 访问什么商店,或者增加新的商店,都不需要修改原有代码了,其类图如图 1 所示。
 

顾客购物程序的类图
图1 顾客购物程序的类图

程序代码如下:

package principle;
public class DIPtest
{
    public static void main(String[] args)
    {
        Customer wang=new Customer();
        System.out.println("顾客购买以下商品:"); 
        wang.shopping(new ShaoguanShop()); 
        wang.shopping(new WuyuanShop());
    }
}
//商店
interface Shop
{
    public String sell(); //卖
}
//韶关网店
class ShaoguanShop implements Shop
{
    public String sell()
    {
        return "韶关土特产:香菇、木耳……"; 
    } 
}
//婺源网店
class WuyuanShop implements Shop
{
    public String sell()
    {
        return "婺源土特产:绿茶、酒糟鱼……"; 
    }
} 
//顾客
class Customer
{
    public void shopping(Shop shop)
    {
        //购物
        System.out.println(shop.sell()); 
    }
}

第5章 接口隔离原则(ISP)

5.1 描述说明

什么是隔离呢?它有两种定义,如下所示:

客户端不应该依赖它不需要的接口。
类间的依赖关系应该建立在最小的接口上。
把这两个定义剖析一下,先说第一种定义:“ 客户端不应该依赖它不需要的接口 ,那依赖什么?依赖它需要的接口,客户端需要什么接口就提供什么接口,把不需要的接口剔除掉,那就需要对接口进行细化,保证其纯洁性;再看第二种定义:“ 类间的依赖关系应该建立在最小的接口上 ,它要求是最小的接口,也是要求接口细化,接口纯洁,与第一个定义如出一辙,只是一个事物的两种不同描述。
我们可以把这两个定义概括为一句话:建立单一接口,不要建立臃肿庞大的接口。再通俗一点讲:接口尽量细化,同时接口中的方法尽量少。看到这里大家有可能要疑惑了,这与单一职责原则不是相同的吗?错,接口隔离原则与单一职责的审视角度是不相同的,单一职责要求的是类和接口职责单一,注重的是职责,这是业务逻辑上的划分,而接口隔离原则要求接口的方法尽量少。例如一个接口的职责可能包含10 个方法,这 10 个方法都放在一个接口中,并且提供给多个模块访问,各个模块按照规定的权限来访问,在系统外通过文档约束“ 不使用的方法不要访问 ,按照单一职责原则是允许的,按照接口隔离原则是不允许的,因为它要求“ 尽量使用多个专门的接口 。专门的接口指什么?就是指提供给每个模块的都应该是单一接口,提供给几个模块就应该有几个接口,而不是建立一个庞大的臃肿的接口,容纳所有的客户端访问。
以上两个定义的含义是:要为各个类建立它们需要的专用接口,而不要试图去建立一个很庞大的接口供所有依赖它的类去调用。

接口隔离原则和单一职责都是为了提高类的内聚性、降低它们之间的耦合性,体现了封装的思想,但两者是不同的:
  • 单一职责原则注重的是职责,而接口隔离原则注重的是对接口依赖的隔离。
  • 单一职责原则主要是约束类,它针对的是程序中的实现和细节;接口隔离原则主要约束接口,主要针对抽象和程序整体框架的构建。
把一个臃肿的接口变更为两个独立的接口所依赖的原则就是接口隔离原则。
接口隔离原则是为了约束接口、降低类对接口的依赖性,遵循接口隔离原则有以下 5 个优点。
  1. 将臃肿庞大的接口分解为多个粒度小的接口,可以预防外来变更的扩散,提高系统的灵活性和可维护性。
  2. 接口隔离提高了系统的内聚性,减少了对外交互,降低了系统的耦合性。
  3. 如果接口的粒度大小定义合理,能够保证系统的稳定性;但是,如果定义过小,则会造成接口数量过多,使设计复杂化;如果定义太大,灵活性降低,无法提供定制服务,给整体项目带来无法预料的风险。
  4. 使用多个专门的接口还能够体现对象的层次,因为可以通过接口的继承,实现对总接口的定义。
  5. 能减少项目工程中的代码冗余。过大的大接口里面通常放置许多不用的方法,当实现这个接口的时候,被迫设计冗余的代码。
接口隔离原则是对接口进行规范约束,其包含以下 4 层含义:
●接口要尽量小
这是接口隔离原则的核心定义,不出现臃肿的接口( FatInterface ),但是 是有限度的,首先就是不能违反单一职责原则。
接口要高内聚
什么是高内聚?高内聚就是提高接口、类、模块的处理能力,减少对外的交互。具体到接口隔离原则就是,要求在接口中尽量少公布public方法,接口是对外的承诺,承诺越少对系统的开发越有利,变更的风险也就越少,同时也有利于降低成本。
定制服务
什么是定制服务?定制服务就是单独为一个个体提供优良的服务。我们在做系统设计时也需要考虑对系统之间或模块之间的接口采用定制服务。采用定制服务就必然有一个要求:只提供访问者需要的方法。
接口设计是有限度的
接口的设计粒度越小,系统越灵活,这是不争的事实。但是,灵活的同时也带来了结构的复杂化,开发难度增加,可维护性降低,这不是一个项目或产品所期望看到的,所以接口设计一定要注意适度,这个“ 如何来判断呢?根据经验和常识判断,没有一个固化或可测量的标准。
(c++中对应的应该就是接口类提供的接口要高内聚,同类接口(提供给同一个调用方或接口内容是相关的)放在同一个文件中,不相干的接口分到其它接口文件中,不要将大堆接口都堆积到一个接口文件中。一个接口(接口类或接口文件)只服务于一个子模块或业务逻辑)

5.2 如何使用接口隔离原则

接口隔离原则是对接口的定义,同时也是对类的定义,接口和类尽量使用原子接口或原子类来组装。但是,这个原子该怎么划分是设计模式中的一大难题,在实践中可以根据以下几个规则来衡量:
接口尽量小,但是要有限度。 一个接口只服务于一个子模块或业务逻辑;
提高内聚,减少对外交互。使接口用最少的方法去完成最多的事情。 通过业务逻辑压缩接口中的 public 方法,接口时常去回顾,尽量让接口达到 满身筋骨肉” ,而不是 肥嘟嘟 的一大堆方法;
已经被污染了的接口,尽量去修改,若变更的风险较大,则采用适配器模式进行转化处理;
●使用委托分离接口
●使用多重继承分离接口
了解环境,拒绝盲从。每个项目或产品都有特定的环境因素,别看到大师是这样做的你就照抄。千万别,环境不同,接口拆分的标准就不同。深入了解业务逻辑,最好的接口设计就出自你的手中!
下面以学生成绩管理程序为例介绍接口隔离原则的应用。
【例1】学生成绩管理程序。
分析:学生成绩管理程序一般包含插入成绩、删除成绩、修改成绩、计算总分、计算均分、打印成绩信息、査询成绩信息等功能,如果将这些功能全部放到一个接口中显然不太合理,正确的做法是将它们分别放在输入模块、统计模块和打印模块等 3 个模块中,其类图如图 1 所示。

学生成绩管理程序的类图
图1 学生成绩管理程序的类图

第6章 合成/聚合复用原则(CRP/CARP)

6.1 描述说明

合成复用原则(Composite Reuse Principle,CRP)又叫组合/聚合复用原则(Composition/Aggregate Reuse Principle,CARP)。它要求在软件复用时,在一个新的对象中引入/注入现有的对象以达到功能复用和扩展的目的,要尽量先使用组合或者聚合等关联关系来实现,其次才考虑使用继承关系来实现。

如果要使用继承关系,则必须严格遵循里氏替换原则。合成复用原则同里氏替换原则相辅相成的,两者都是开闭原则的具体实现规范。

通常类的复用分为继承复用和合成复用两种,继承复用虽然有简单和易实现的优点,但它也存在以下缺点。

  1. 继承复用破坏了类的封装性。因为继承会将父类的实现细节暴露给子类,父类对子类是透明的,所以这种复用又称为“白箱”复用。
  2. 子类与父类的耦合度高。父类的实现的任何改变都会导致子类的实现发生变化,这不利于类的扩展与维护。
  3. 它限制了复用的灵活性。从父类继承而来的实现是静态的,在编译时已经定义,所以在运行时不可能发生变化。


采用组合或聚合复用时,可以将已有对象纳入新对象中,使之成为新对象的一部分,新对象可以调用已有对象的功能,它有以下优点。

  1. 它维持了类的封装性。因为成分对象的内部细节是新对象看不见的,所以这种复用又称为“黑箱”复用。
  2. 新旧类之间的耦合度低。这种复用所需的依赖较少,新对象存取成分对象的唯一方法是通过成分对象的接口。
  3. 复用的灵活性高。这种复用可以在运行时动态进行,新对象可以动态地引用与成分对象类型相同的对象。

组合/聚合复用原则来源:

       在面向对象的设计中,如果直接继承基类,会破坏封装,因为继承将基类的实现细节暴露给子类;如果基类的实现发生改变,则子类的实现也不得不发生改变;从基类继承而来的实现是静态的,不可能在运行时发生改变,没有足够的灵活性。于是就提出了组合/聚合复用原则,也就是在实际开发设计中,尽量使用合成/聚合,不要使用类继承。即在一个新的对象里面使用一些已有的对象,使之成为新对象的一部分,新对象通过向这些对象的委派达到复用已有功能的目的。就是说要尽量的使用合成和聚合,而不是继承关系达到复用的目的。

组合/聚合复用原则定义:

        合成/聚合复用原则经常又叫做合成复用原则。该原则就是在一个新的对象里面使用一些已有的对象,使之成为新对象的一部分:新的对象通过向这些对象的委派达到复用已有功能的目的。

组合/聚合复用原则作用:

        组合/聚合可以使系统更加灵活,降低类与类之间的耦合度,一个类的变化对其他类造成的影响相对较少;其次才考虑继承,在使用继承时,需要严格遵循里氏代换原则,有效使用继承会有助于对问题的理解,降低复杂度,而滥用继承反而会增加系统构建和维护的难度以及系统的复杂度,因此需要慎重使用继承复用。 由于组合或聚合关系可以将已有的对象(也可称为成员对象)纳入到新对象中,使之成为新对象的一部分,因此新对象可以调用已有对象的功能,这样做可以使得成员对象的内部实现细节对于新对象不可见,所以这种复用又称为“黑箱”复用,相对继承关系而言,其耦合度相对较低,成员对象的变化对新对象的影响不大,可以在新对象中根据实际需要有选择性地调用成员对象的操作;合成复用可以在运行时动态进行,新对象可以动态地引用与成员对象类型相同的其他对象。

组合/聚合复用原则示例:

        合成(Composition,也有翻译成组合)和聚合(Aggregation),都是关联的特殊种类。聚合表示一种弱的“拥有”关系,体现的是A对象可以包含B对象,但B对象不是A对象的一部分;合成(组合)是一种强的“拥有”关系,体现了严格的部分与整体的关系,部分和整体的生命周期是一样的。聚合/组合UML示意图如图1所示:

                        


       在我们的日常开发中,经常遇到新的数据库或股市行情源替换问题,如下面的情况。产品初始开发采用A公司的行情源,与行情源操作有关的类如CustomerData类等都需要连接行情源,连接行情源的方法getConnection()封装在DataUtil类中,由于需要重用DataUtil类的getConnection()方法,设计人员将CustomerData作为DataUtil类的子类,初始设计方案结构如图2所示:

                                                   


         随着产品的正式上线,需要更加稳定快速的行情源,于是决定使用B公司行情源,但是B公司行情源数据解析模式和A公司不一样。因此需要增加一个新的DataUtilB类来连接B行情源,由于在初始设计方案中CustomerData和DataUtil之间是继承关系,因此在更换行情连接方式时需要修改CustomerData类的源代码,将CustomerData作为DataUtilB的子类,这将违反开闭原则。当然也可以修改DataUtil类的源代码,同样会违反开闭原则。

         现使用合成复用原则对其进行重构。重构后的方案如图3所示:

                                     

        重构后的CustomerData和DataUtil之间的关系由继承关系变为关联关系,采用依赖注入的方式将DataUtil对象注入到CustomerData中,可以使用构造注入,也可以使用Setter注入。如果需要对DataUtil的功能进行扩展,可以通过其子类来实现,如通过子类DataUtilB来连接B行情源。由于CustomerData针对DataUtil编程,根据里氏代换原则,DataUtil子类的对象可以覆盖DataUtil对象,只需在CustomerData中注入子类对象即可使用子类所扩展的方法。例如在CustomerData中注入DataUtilB对象,即可实现接入B行情源,原有代码不需要修改,且还可以很方便地增加新的行情接入。

合成/聚合复用优缺点和继承复用优缺点总结:
合成/聚合复用:

(1).优点:
        新对象存取成分对象的唯一方法是通过成分对象的接口; 这种复用是黑箱复用,因为成分对象的内部细节是新对象所看不见的;这种复用支持包装;这种复用所需的依赖较少; 每一个新的类可以将焦点集中在一个任务上; 这种复用可以在运行时动态进行,新对象可以使用合成/聚合关系将新的责任委派到合适的对象。
(2).缺点:
         通过这种方式复用建造的系统会有较多的对象需要管理。

继承复用:
(1).优点:
        新的实现较为容易,因为基类的大部分功能可以通过继承关系自动进入派生类;修改或扩展继承而来的实现较为容易。
(2).缺点:
        继承复用破坏包装,因为继承将基类的实现细节暴露给派生类,这种复用也称为白箱复用; 如果基类的实现发生改变,那么派生类的实现也不得不发生改变;从基类继承而来的实现是静态的,不可能在运行时发生改变,不够灵活。

组合/聚合复用原则使用总结:

        合成和聚合均是关联的特殊情况。聚合用来表示“拥有”关系或者整体与部分的关系;而合成则用来表示一种强得多的“拥有”关系。在一个合成关系里面,部分和整体的生命周期是一样的。一个合成的新的对象完全拥有对其组成部分的支配权,包括它们的创建和销毁等。使用程序语言的术语来说,组合而成的新对象对组成部分的内存分配、内存释放有绝对的责任。要正确的选择合成/复用和继承,必须透彻地理解里氏替换原则和Coad法则。(Coad法则由Peter Coad提出,总结了一些什么时候使用继承作为复用工具的条件。Coad法则:只有当以下Coad条件全部被满足时,才应当使用继承关系)

(1).子类是基类的一个特殊种类,而不是基类的一个角色。区分“Has-A”和“Is-A”。只有“Is-A”关系才符合继承关系,“Has-A”关系应当用聚合来描述。 

(2).永远不会出现需要将子类换成另外一个类的子类的情况。如果不能肯定将来是否会变成另外一个子类的话,就不要使用继承。 

(3).子类具有扩展基类的责任,而不是具有置换掉(override)或注销掉(Nullify)基类的责任。如果一个子类需要大量的置换掉基类的行为,那么这个类就不应该是这个基类的子类。 

(4).只有在分类学角度上有意义时,才可以使用继承。不要从工具类继承。

6.2 合成复用原则的实现方法

合成复用原则是通过将已有的对象纳入新对象中,作为新对象的成员对象来实现的,新对象可以调用已有对象的功能,从而达到复用。

下面以汽车分类管理程序为例来介绍合成复用原则的应用。
【例1】汽车分类管理程序。
分析:汽车按“动力源”划分可分为汽油汽车、电动汽车等;按“颜色”划分可分为白色汽车、黑色汽车和红色汽车等。如果同时考虑这两种分类,其组合就很多。图 1 所示是用继淨:关系实现的汽车分类的类图。

用继承关系实现的汽车分类的类图
图1 用继承关系实现的汽车分类的类图
从图 1 可以看出用继承关系实现会产生很多子类,而且增加新的“动力源”或者增加新的“颜色”都要修改源代码,这违背了开闭原则,显然不可取。但如果改用组合关系实现就能很好地解决以上问题,其类图如图 2 所示。

用组合关系实现的汽车分类的类图
图2 用组合关系实现的汽车分类的类图

7章    迪米特法则

7.1 描述说明

迪米特法则( LawofDemeter LoD )也称为最少知识原则( LeastKnowledgePrinciple, LKP ),虽然名字不同,但描述的是同一个规则:一个对象应该对其他对象有最少的了解。通俗地讲,一个类应该对自己需要耦合或调用的类知道得最少,你(被耦合或调用的类)的内部是如何复杂都和我没关系,那是你的事情,我就知道你提供的这么多public方法,我就调用这么多,其他的我一概不关心。

迪米特法则的定义是:只与你的直接朋友交谈,不跟“陌生人”说话(Talk only to your immediate friends and not to strangers)。其含义是:如果两个软件实体无须直接通信,那么就不应当发生直接的相互调用,可以通过第三方转发该调用。其目的是降低类之间的耦合度,提高模块的相对独立性。
迪米特法则中的“朋友”是指:当前对象本身、当前对象的成员对象、当前对象所创建的对象、当前对象的方法参数等,这些对象同当前对象存在关联、聚合或组合关系,可以直接访问这些对象的方法。

迪米特法则对类的低耦合提出了明确的要求,其包含以下 4 层含义。
1. 只和朋友交流
迪米特法则还有一个英文解释是: Onlytalktoyourimmediatefriends (只与直接的朋友通信。)什么叫做直接的朋友呢?每个对象都必然会与其他对象有耦合关系,两个对象之间的耦合就成为朋友关系,这种关系的类型有很多,例如组合、聚合、依赖等。
2. 朋友间也是有距离的
注意  迪米特法则要求类 羞涩 一点,尽量不要对外公布太多的 public 方法和非静态的public变量,尽量内敛,多使用 private package-private protected 等访问权限。
3. 是自己的就是自己的
在实际应用中经常会出现这样一个方法:放在本类中也可以,放在其他类中也没有错,那怎么去衡量呢?你可以坚持这样一个原则: 如果一个方法放在本类中,既不增加类间关系,也对本类不产生负面影响,那就放置在本类中。
4. 谨慎使用 Serializable

迪米特法则的核心观念就是类间解耦,弱耦合,只有弱耦合了以后,类的复用率才可以提高。其要求的结果就是产生了大量的中转或跳转类,导致系统的复杂性提高,同时也为维护带来了难度。读者在采用迪米特法则时需要反复权衡,既做到让结构清晰,又做到高内聚低耦合。
迪米特法则要求类间解耦,但解耦是有限度的,除非是计算机的最小单元 —— 二进制的0和 1 。那才是完全解耦,在实际的项目中,需要适度地考虑这个原则,别为了套用原则而做项目。原则只是供参考,如果违背了这个原则,项目也未必会失败,这就需要大家在采用原则时反复度量,不遵循是不对的,严格执行就是“ 过犹不及
迪米特法则要求限制软件实体之间通信的宽度和深度,正确使用迪米特法则将有以下两个优点。
  1. 降低了类之间的耦合度,提高了模块的相对独立性。
  2. 由于亲合度降低,从而提高了类的可复用率和系统的扩展性。

但是,过度使用迪米特法则会使系统产生大量的中介类,从而增加系统的复杂性,使模块之间的通信效率降低。所以,在釆用迪米特法则时需要反复权衡,确保高内聚和低耦合的同时,保证系统的结构清晰。

7.2 迪米特法则的实现方法

从迪米特法则的定义和特点可知,它强调以下两点:
  1. 从依赖者的角度来说,只依赖应该依赖的对象。
  2. 从被依赖者的角度说,只暴露应该暴露的方法。
所以,在运用迪米特法则时要注意以下 6 点。
  1. 在类的划分上,应该创建弱耦合的类。类与类之间的耦合越弱,就越有利于实现可复用的目标。
  2. 在类的结构设计上,尽量降低类成员的访问权限。
  3. 在类的设计上,优先考虑将一个类设置成不变类。
  4. 在对其他类的引用上,将引用其他对象的次数降到最低。
  5. 不暴露类的属性成员,而应该提供相应的访问器(set 和 get 方法)。
  6. 谨慎使用序列化(Serializable)功能。
【例1】明星与经纪人的关系实例。

分析:明星由于全身心投入艺术,所以许多日常事务由经纪人负责处理,如与粉丝的见面会,与媒体公司的业务洽淡等。这里的经纪人是明星的朋友,而粉丝和媒体公司是陌生人,所以适合使用迪米特法则,其类图如图 1 所示。
 

面向对象7大设计原则_第4张图片
图1 明星与经纪人的关系图

程序代码如下:
package principle;
public class LoDtest
{
    public static void main(String[] args)
    {
        Agent agent=new Agent();
        agent.setStar(new Star("林心如"));
        agent.setFans(new Fans("粉丝韩丞"));
        agent.setCompany(new Company("中国传媒有限公司"));
        agent.meeting();
        agent.business();
    }
}
//经纪人
class Agent
{
    private Star myStar;
    private Fans myFans;
    private Company myCompany;
    public void setStar(Star myStar)
    {
        this.myStar=myStar;
    }
    public void setFans(Fans myFans)
    {
        this.myFans=myFans;
    }
    public void setCompany(Company myCompany)
    {
        this.myCompany=myCompany;
    }
    public void meeting()
    {
        System.out.println(myFans.getName()+"与明星"+myStar.getName()+"见面了。");
    }
    public void business()
    {
        System.out.println(myCompany.getName()+"与明星"+myStar.getName()+"洽淡业务。");
    }
}
//明星
class Star
{
    private String name;
    Star(String name)
    {
        this.name=name;
    }
    public String getName()
    {
        return name;
    }
}
//粉丝
class Fans
{
    private String name;
    Fans(String name)
    {
        this.name=name;
    }
    public String getName()
    {
        return name;
    }
}
//媒体公司
class Company
{
    private String name;
    Company(String name)
    {
        this.name=name;
    }
    public String getName()
    {
        return name;
    }
}

参考资料:

《敏捷软件开发:原则、模式与实践》

《设计模式之禅(第2版)》

Java设计模式:23种设计模式全面解析(超级详细)》:Java设计模式:23种设计模式全面解析(超级详细)

你可能感兴趣的:(架构-模式设计,设计模式)