面向对象六大原则

1、单一职责原则

英文名称:Single Responsibility Principle(SRP)

基本定义:应该有且仅有一个原因引起类的变更。

好处

  • 类的复杂性降低
  • 代码的可读性提高
  • 代码的可维护性提高
  • 变更风险降低,通过职责划分,将变更导致的修改降低到最小,这也与此原则的定义相符。

示例

对于常见的管理系统来说,一定会涉及到角色管理模块,针对于用户信息维护这一功能,一个糟糕的接口设计如下:
在这里插入图片描述
很显然上面这个接口的定义是不规范的,因为把用户的属性和用户的行为杂糅到一个接口之中。这样做无疑增加了类的复杂性,也引发了类职责不清晰所导致的使用问题。

那么根据单一职责原则,我们对上述类进行修改如下:

在这里插入图片描述

这便是我们在实际项目中常用的接口定义形式,通过将用户属性和行为分别定义在两个接口中来实现职责的划分。

应用

那么在实际场景下,我们怎样去思考何时应用这个原则?

假设我们需要定义一个类,那么此时应该思考类的职责是否单一,也即是否只有一个原因能引起类的变更。如果类的职责并不单一,那么就要考虑将类分成多个职责类,但同时为了避免类的耦合,结合后续所提出的依赖倒置原则(面向接口编程),将职责类定义成职责接口不失为一个更好的方法。

以手机类为例,定义一个接口(类)包含三个方法:拨号、聊天和挂机。按上述方法思考,我们可以发现拨号和挂机是属于协议管理的职责,而聊天则是数据传输的职责,因此该接口包含了两个职责,需要考虑进一步划分。
在这里插入图片描述
划分如下,通过两个接口将两个职责划分开来!

在这里插入图片描述

注意

  • 单一职责并不仅仅针对接口和类,对于方法也同样适用。也就是说,一个方法尽可能做一件事,让方法的职责清晰单一,这种设计的好处不言而喻。
  • 在实际中,但接口的定义一定要做到单一职责,但往往类的单一职责很难办到,因此类的设计需要尽量做到只有一个原因引起变化(这也是情有可原的,类的设计往往需要考虑多个方面,单一职责往往过于受限)。

2、里氏替换原则

英文名称:Liskov Substitution Principle(LSP)

基本定义:所有引用基类的地方必须能透明地使用其子类的对象(简单来说就是父类出现的地方使用子类替换不会出现代码行为的改变)。

该原则为良好的继承定义了一个规范,遵守该规范必须满足以下几个要求:

  • 子类必须完全实现父类的方法
  • 子类可以有自己的个性
  • 覆盖或实现父类的方法时输入参数可以被放大
  • 覆盖或实现父类的方法时输出参数可以被缩小

好处:面向对象里继承可以提高代码重用性,减小创建类的工作量,并且可以提高代码的可扩展性。但是滥用继承也会带来代码耦合性过强,降低代码的灵活性等问题。里氏替换原则定义了良好的继承规则,使继承能够最大限度的发挥作用。

应用

让我们逐一来考虑该原则的几大要求:

1、子类必须完全实现父类的方法

这条规则限制了子类的范围,假设一个类在概念上跟父类上有相似之处,但不能完整的实现父类的方法,或者父类的方法在子类中已经发生了‘异变’(这里是针对于业务逻辑来说的),那么推荐将父子继承关系转变为依赖、聚合和组合等关系。

1)注:依赖、关联、聚合、组合的区别
———————————————————————————————————————————

  • 依赖:表示一个类依赖于另一个类的定义,一般而言,依赖关系在java中体现为局域变量,方法的形参或是对静态方法的调用。
  • 关联:表示类与类之间的联接,可以是单向或是双向。它使一个类知道另一个类的属性和方法,在java中一般使用成员变量来实现。
  • 聚合:一种强关联关系,所涉及的类处于不平等的层次,是整体与部分间的关系。
  • 组合:比聚合还强的关联关系,它要求聚合关系中 代表整体的对象 负责 代表部分的对象 的生命周期。因此,代表部分的对象在每个时刻只能与一个对象发生组合关系,并由后者负责它的生命周期。

———————————————————————————————————————————

2)依赖的三种方式
———————————————————————————————————————————

  • 构造函数传递依赖对象(构造方式注入)
  • Setter方式传入依赖对象(Setter注入)
  • 接口声明依赖对象(接口注入)

———————————————————————————————————————————

举一个示例,在游戏场景(Client)中,用户(Soldier)可以持有枪支,枪支由抽象类(AbstractGun)指代,并实例化出手枪、步枪和机关枪类,各类包含的方法如UML所示。

在这里插入图片描述

现在要新增一个玩具枪(不可射击)类,那么继承AbstractGun是不是一个好的选择?

显然,玩具枪无法实现抽象类中的shoot方法,如果继承此抽象类,那么new Soilder(new ToyGun())后调用杀敌(killEnemy())方法会出现打不出子弹的现象,这显然与业务逻辑相违背。因此基于该原则,我们应该将玩具枪单独抽象出来成为一个类,并将其与AbstractGun建立关联关系,委托其处理共性业务。

在这里插入图片描述

2、子类可以有自己的个性

里氏替换原则可以正着用,但不能反过来(父类透明替换子类)。这也表明了子类可以有自己的‘个性’,即可以定义属于自己的方法或属性。

3、覆盖或实现父类的方法时输入参数可以被放大

这个规则规定了子类覆写或实现方法时参数的选择的下确界(父类的参数类型),为什么要保证输入参数大于等于父类的参数类型?

实际上,这条规则还是保障了子类替换父类时不影响代码的行为,例如下面子类实现的方法参数类型小于父类,导致了替换后行为的改变。

public class Test {
    public static void main(String[] args) {
        Father f = new Father();
        //Son f = new Son(); //Son替换
        f.method(new HashMap());
    }

}

class Father {
    public void method(Map map){
        System.out.println("father class");
    }
}

class Son extends Father {
    public void method(HashMap map){
        System.out.println("son class");
    }
}

替换前(保持注释部分):
在这里插入图片描述
替换后:
在这里插入图片描述
因此,子类方法的参数类型必须跟超类覆写的方法中的相同或更宽松。

4、覆盖或实现父类的方法时输出参数可以被缩小

这个规则规定了子类覆写或实现方法时输出的上确界(父类的输出类型),原因跟上面类似,都是为了保证子类替换后原代码行为不改变。

注意

在实际项目中,应用该原则时,尽量避免子类的‘个性’,因为如果把子类当成父类使用,会抹杀子类的个性,而如果把子类单独作为一个业务使用,会使得代码间的耦合关系变得复杂—缺乏类替换的标准。

3、依赖倒置原则

英文名称:Dependence Inversion Principle(DIP)

基本定义

  • 高层模块不应该依赖低层模块,两者都应该依赖其抽象
  • 抽象(接口)不应该依赖细节(实现)
  • 细节应该依赖抽象

实际上,上述几个要求正是面向对象的精髓之一:面向接口编程

好处

  • 减少类的耦合性
  • 降低并行开发的风险
  • 提高代码可读性和可维护性

应用

下面用一个示例展示此原则的用法。

给定一个场景(Client),司机(Driver)驾驶汽车(Benz),类定义如下:

在这里插入图片描述
显然,上述类的定义耦合性太强了,一旦业务变更,比如现在增加一辆新车(BMW),那么就必须修改司机类;并且在实际业务中,这并不利于并行开发—由于类的耦合关系过强,难以单独对单元类进行单元测试。因此,基于依赖倒置原则,类图修改如下:

在这里插入图片描述
通过建立两个接口(IDriver和ICar),把司机和汽车抽象出来,并且在IDriver中依赖了汽车抽象ICar,这样在业务变更时,例如增加汽车类,增加不同的司机类,底层模块不用做任何修改,可以把变更的风险降到最低。

从上面可以看出依赖倒置原则的优良之处,其本质就是通过抽象使得各个类或模块的实现彼此独立,实现模块间的松耦合,在实际项目中一般遵循下面几个规则来使用此原则:

  • 每个类尽量都有接口或者抽象类(或皆有)
  • 任何类都不应该从具体类派生
  • 尽量不要覆写基类的方法

这条针对父类是抽象类来说的,抽象父类已经实现的方法子类尽量不要覆写,类间依赖的是抽象,覆写了抽象方法对依赖的稳定性会产生影响。

  • 结合里氏替换原则使用

结合里氏替换原则可以得出一个适用的规则:接口负责定义public属性和方法,并且声明与其他对象的依赖关系,抽象类负责公共构造部分的实现,实现类准确的实现业务逻辑,同时在适当时候对父类进行细化。

注意:依赖倒置原则是后面开闭原则的基础,但是使用该原则时要审时度势,不要一味地抓住原则不放,有时候我们往往会为了大局牺牲一定的原则。

4、接口隔离原则

Tips:这里所说的接口不单只我们通常意义上的类接口,也包括了类。(可以把类也当成一种实例接口)

基本定义:有两种正式定义

  • 客户端不应该依赖它不需要的接口(保证类的纯洁性,把不需要的接口剔除)
  • 类间的依赖关系应该建立在最小的接口上(与上面定义类似,同样要求接口细化,保持纯洁性)

简单来说,接口隔离原则的理念就是建议单一接口而不是臃肿复杂的接口,应该把接口尽量细化。

注:这里要把接口隔离原则和单一职责区别开,单一职责是针对类和接口的职责,是业务逻辑的划分,因此如果一个接口定义了许多方法提供多个模块访问。但这些方法都针对于一个职责,那么这种设计满足单一职责原则,但不满足接口隔离原则,因为它要求类间依赖关系建立在最小的接口上,因此每个模块所依赖的都应该是单一接口,而不是一个杂糅的接口。

好处

  • 降低变更风险
  • 提高系统的灵活性和可维护性

示例

假设现在给定一个场景:星探(AbstractSearcher)寻找美女(IPettyGirl-要求脾气、身材、长相俱佳),一个较好的类设计如下:
在这里插入图片描述
虽然上面美女的三个特质都刻画了美女的标准,但上面的类还是不能完全符合接口隔离原则。因为每个人的看法不一样,可能有些人觉得长相好,身材好就是美女;又有些人可能觉得脾气好就是美女,针对这种情况,显然上面的划分还过于粗粒度,应当把IPettyGirl接口进一步细化。
在这里插入图片描述

上面就是我们最终得出的类设计方案,通过将原来的单个接口进一步细化为两个专门的接口,满足了我们的接口隔离原则。

应用

接口隔离原则实质是对接口进行规范约束,在实际应用此原则时,要遵守下面几条规则:

1、接口要尽量小

这是接口隔离原则的核心要义,即接口不能过于臃肿,应当尽量细化。但千万要注意的是,接口的细化是有限度的,不能为了满足该原则进行过于细致的划分,从而陷入设计的泥潭。

那么这个限度怎么去度量呢?答案就是单一职责原则,也就是说拆分接口时要先考虑拆分出的接口能不能满足单一职责原则(从这点上来看,单一职责原则与接口隔离原则存在一定的共通性,结合之前的内容,可以把单一职责看成是接口隔离的超集),如果划分出来的接口没办法支撑起一个单独的职责,那么便不能进行进一步拆分。

注:实际上,这里的职责也必须明确,一般来说,我们根据业务逻辑确定一个接口到底满不满足一个职责。以上面美女为例,根据现有的业务逻辑,美女分为两大类—外形好和脾气好,那么基于该业务逻辑下,所得出的两个接口便是最小的职责划分。如果把外形好再划分为外貌好和身材好两类,那么这两类便不足以支撑起一个单独的职责,不符合我们的要求(当然,如果业务逻辑变更导致职责可以进一步划分那么支持继续修改)。

2、接口要高内聚

高内聚就是指提高接口、类和模块的处理能力,减少对外的交互。具体到接口隔离原则就是要求在接口中尽量少公布public方法,接口是对外的承诺,承诺越少对系统的开发越有利,变更风险和成本也会随之降低。

3、定制服务

系统间的模块之间必然会存在耦合,有耦合就会有相互访问的接口,因此在设计时就需要为各个访问者提供定制服务。定制服务就是指单独为各个个体提供优良的服务,这种方式必须满足一个要求:只提供访问者需要的方法。简单来说就是将一个臃肿的接口,分解成几个简单的接口以自由组合的方式为不同的访问者提供不同的定制服务。一个简单的例子就是权限管理,将一个操作接口按权限细化成不同的接口,然后为不同权限者提供不同的定制服务操作。

4、接口设计是有限度的

接口的设计虽然越小越灵活,但代价是结构的复杂性和开发的难度增加,所以要 ‘有度’,而这个度就要靠你的经验来判断。

注意

虽然接口的划分难以权衡,但可以根据以下几个方法来衡量:

  • 一个接口只服务于一个子模块或业务逻辑
  • 通过业务逻辑压缩接口中的public方法,让接口变得精悍有力
  • 已经被污染的接口,尽量去修改,如果代价太大,建议采用适配器模式进行转化处理
  • 一个好的接口划分往往是基于业务的,因此接口设计的前提就是对业务的深入理解

5、迪米特法则

英文名称:Law of Demeter(LoD)

基本定义:一个对象应该对其他对象有最少的了解

上述定义实际包含四大含义:

  • 只和朋友交流
  • 朋友也是有距离的
  • 是自己的就是自己的
  • 谨慎使用Serializable

好处:保证类的低耦合性

应用

让我们逐一来考虑该原则的几大要求:

1、只和朋友交流

朋友关系是指对象之间的耦合关系,依赖、聚合、组合等都属于这种关系。那么什么叫只和朋友交流?下面看一个例子。

/**
 * 老师类:通知班长清点女生人数
 */
public class Teacher {
    public void command(GroupLeader groupLeader){
        List listGirls = new ArrayList();
        for(int i = 0; i < 10; ++i){
            listGirls.add(new Girl());
        }
        groupLeader.countGirls(listGirls);
    }
}

/**
 * 班长类:负责清点女生人数
 */
class GroupLeader {
    public void countGirls(List<Girl> listGirls){
        System.out.println("the number of girls is " + listGirls.size());
    }
}

class Girl {
    
}

可以看出,老师类依赖了班长类(即班长类为朋友),但却与非朋友关系(在方法体内不属于依赖关系)的Girl类有了交流,因此这种设计并不满足迪米特法则,修改如下:

/**
 * 测试场景类
 */
public class Client{
    public static void main(String[] args) {
        List<Girl> listGirls = new ArrayList();
        for(int i = 0; i < 10; ++i){
            listGirls.add(new Girl());
        }
        Teacher teacher = new Teacher();
        teacher.command(new GroupLeader(listGirls));
    }
}

/**
 * 老师类:通知班长清点女生人数
 */
class Teacher {
    public void command(GroupLeader groupLeader){
        groupLeader.countGirls();
    }
}

/**
 * 班长类:负责清点女生人数
 */
class GroupLeader {

    private List<Girl> listGirls;

    public GroupLeader(List<Girl> listGirls){
        this.listGirls = listGirls;
    }

    public void countGirls(){
        System.out.println("the number of girls is " + listGirls.size());
    }
}

class Girl {

}

修改后Teacher类只与GroupLeader类发生了交流,避开了对陌生类Girl的访问,降低了系统间的耦合性。

2、朋友间也是有距离的

这个规则要求类要 ‘羞涩’一点,不能对外公布太多的public方法和非静态的public变量,因为一个类公开的public属性或方法越多,修改所涉及的面也就越大,变更的风险也因此增加,所以类尽量多实用private、package-private、protected等访问权限。

3、是自己的就是自己的

在实际应用中可能会出现某个方法的放置位置不太确定,似乎放在本类中可以,放在其他类中也没错,那么只要方法放在本类中既没有增加类间关系,也对本类没有负面影响,那么就放置在本类中。

4、谨慎使用Serializable

Serializable用于远程方法调用,如果调用两端对某属性或方法进行了变更,而另一端没有同步进行变更,便会出现业务错误,因此使用Serializable时要小心。实际上,也属于项目管理的内容,只要项目管理得当,这种问题便不会出现。

注意

迪米特法则的核心观念就是类间解耦,只有达到弱耦合,类的复用率才能提高,但这样带来的后果是产生了大量的中转类,提高了系统的复杂性,因此在应用该原则时,需要反复权衡,既做到让结构清晰,有做到高内聚低耦合,要找到一个 ‘度’。

6、开闭原则

开闭原则是java世界里最基础的设计原则,它指导我们如何构建一个稳定灵活的系统。

基本定义:一个软件实体如类、模块和函数应该对扩展开放,对修改关闭。简单来说就是一个软件实体应该通过扩展来实现变化,而不是通过修改已有的代码。

注:这里的变化一般有下面几种类型

  • 逻辑变化:只变化逻辑而不涉及其他模块,可以通过修改原有类中的方法来完成。
  • 子模块变化:模块发生变化,例如通过实现低层次接口扩展模块。
  • 可见视图变化:提供给用户的内容发生改变,还是可以通过扩展来完成,但要求原来的设计比较灵活。

示例

给定一个书店 (BookStore) 销售书籍IBook(目前只销售小说NovelBook)的业务需求,类设计如下
在这里插入图片描述
那么假设现在业务需求变更,需要增加打折销售这一功能,应该如何应对?

第一种方式:修改接口;显然该方式会导致该接口的所有实现类发生修改,不符合我们的原则。

第二种方式:修改实现类;这个方法通过修改Novelbook类中方法,在getPrice()中实现打折处理,这是一个可行的方法,通过替换实现类来实现业务变更。但这种方式带来的问题就是可能会导致某些信息的不一致,比如虽然对前台展示模块需要获取打折后的价格,但对后台某些模块,比如采购模块,需要获取真实的价格,但上述设计方式无法满足这一目的。

第三种方式:通过扩展实现变化;增加一个子类OffNovelBook,通过覆写getPrice()来完成业务需求,修改后类图如下:
在这里插入图片描述
这便是一个优秀的应对方案,通过扩展实现来完成相应的业务变更,当然,在高层次模块,比如业务实现模块,也需要作相应的修改,因为引入了新的业务规则,但这是无法避免的。

注:开闭原则所说的对修改关闭并不意味着不做任何修改,底层模块的扩展变化,必然会与高层模块进行耦合,否则变更毫无意义。

好处

开闭原则是最基础的一个原则,相对于前面的五大原则,开闭原则相当于一个抽象类,而五大原则则是具体的实现类。 它的重要意义有以下几点:

1、开闭原则对测试的影响

保证开闭原则,能够有效保证业务变更后,代码的修改不会引起原先的测试结果,即只需对增加的类进增加新的测试用例进行测试。以上面销售书籍的例子来说就是业务变更后只需对增加的OffNovelBook子类进行单独测试。

2、开闭原则可以提高复用性

在面向对象的设计中,所有逻辑都是由原子逻辑组合而来的,而不是由一个类去实现所有的业务逻辑,只有这样代码才能有效的被复用,代码量也可以大大减少,提高代码的清晰性、可维护性和可扩展性,这也是开闭原则所追求的目标。

3、开闭原则可以提高可维护性

开闭原则能有效降低业务变更所带来的的代价,这同样体现了系统的可维护性大大提高。

4、面向对象开发的要求

面向对象中万物皆对象,业务就是对象在一个特定场景下的交互,当场景发生了变动,那么对象之间的交互也必然会发生改变,那么如何应对好这种变化,便需要好好掌握开闭原则这把钥匙,为改变留下操作的余地,等待可能转变为现实。

应用

那么在实践中如何有效的去运用开闭原则呢?这里给出几条规则:

1、使用抽象约束

通过抽象类或接口去约束一组可能变化的行为,并且能够实现对扩展开放,具体要求:

  • 通过接口或抽象类约束扩展,对扩展进行边界限定,不允许出现 在接口中不存在的public方法
  • 参数类型、引用对象尽量使用抽象类或接口,而不是实现类
  • 抽象层尽量保持稳定,一旦确定就不允许修改

2、元数据控制模块行为

通过尽量使用元数据控制程序的行为来减轻我们的编程负担。这里的元数据是指用来描述环境和数据的数据,通俗的说就是配置参数,如Spring等框架中的配置文件。采用这种方式地好处就可以通过修改配置文件来完成对应的业务变更,实现有效的扩展而不对程序做大的修改。

3、制定项目章程

这也是许多框架中采用的一个思想:约定大于配置,通过遵守约定来达到项目开发的最优化。实际上,这也是软件项目管理的要求之一,一个好的开发团队只有在某种标准下才能进行有效的开发。

4、封装变化

这是对变化点所做出的应对,当发觉某处可能发生变化,就可以创建稳定的接口对变化进行封装。要求满足以下两点:

  • 将相同的变化封装到一个接口或抽象类中
  • 将不同的变化封装到不同的接口或抽象类中,不应该有两个不同的变化出现在同一个抽象类或接口中。

注:实际上,23种设计模式便是从不同的角度对变化进行了封装。

注意

使用开闭原则要注意一下几点:

  • 开闭原则也只是一个原则

虽然开闭原则很有效,但是这并不是一个规定,在适当的时候也可以未必开闭原则,例如在紧急情况下通过替换class文件来弥补缺陷。

  • 项目规章十分重要:一个好的项目章程会给团队带来很多好处。
  • 学会预知变化

小结

本篇文章总结了面向对象的六大原则,这六个原则实际上都是为了应对未知的变化。软件开发最大的难题就是应对需求的变化,以开闭原则为核心要义,灵活使用其他五大原则,以此建立一个稳定、灵活、健壮的系统,这便是软件设计的最佳实践。

注:本系列文章是对设计模式之禅一书的整理总结,如有疑问建议读一读原本。

  • 3
    点赞
  • 16
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Leo木

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值