设计模式——面向对象设计原则

面向对象设计原则究其根源是为了保证软件的可维护性和可复用性

知名软件大师Robert C.Martin认为一个可维护性较低的软件设计通常由于如下4个原因造成:过于僵硬,过于脆弱,复用率低,黏度过高。软件工程和建模大师Peter Coad认为,一个好的系统设计应该具备三个性质:可扩展性,灵活性,可插入性

由此看出,可维护性和可复用性在软件的设计中具有举足轻重的地位

  • 面向对象设计复用的目标在于实现支持可维护性的复用
  • 在面向对象的设计里面,可维护性复用都是以面向对象设计原则为基础的,这些设计原则首先都是复用的原则,遵循这些设计原则可以有效地提高系统的复用性和可维护性
  • 重构在不改变软件现有功能的基础上,通过调整程序代码改善软件的质量、性能,使其程序的设计模式和架构更趋合理,提高软件的扩展性和维护性

常用的面向对象设计原则包括7个,这些原则并不是孤立存在的,它们相互依赖,相互补充

  • 开闭原则——OCP
  • 依赖倒置原则——DIP
  • 里式替换原则——LSP
  • 单一职责原则——SRP
  • 组合复用原则——CRP
  • 迪米特原则——LOD
  • 接口隔离原则——ISP

一、开闭原则

1、定义

一个软件实体应当对扩展开放,对修改关闭

在设计一个模块的时候,应当使这个模块可以在不被修改的前提下被扩展,即实现在不修改源代码的情况下改变这个模块的行为

软件实体可以指一个软件模块、一个由多个类组成的局部结构或一个独立的类

2、分析

在我们编码的过程中,需求变化是不断的发生的,当我们需要对代码进行修改时,我们应该尽量做到能不动原来的代码就不动,通过扩展的方式来满足需求

抽象是开闭原则的关键

3、举例

在创业公司里,由于人力成本控制和流程不够规范的原因,往往一个人需要担任N个职责,一个工程师可能不仅要出需求,还要写代码,甚至要面谈客户,代码表示如下

public class Engineer {
    public void makeDemand() {} //出需求
    public void writeCode() {} //写代码
    public void meetClient() {} //见客户
}

为了保证可维护性和可复用性,在满足开闭原则的条件下,把方法设计成接口,例如把写代码的方法抽离成接口的形式,同时在设计之初考虑未来所有可能发生变化的因素,比如未来有可能因为业务需要分成后端和前端的功能,那么设计之初就可以设计成两个接口

public interface BackCode{ //后端
    void writeCode();
}
public interface FrontCode{ //前端
    void writeCode();
}

如果将来前端代码的业务发生变化,只需扩展前端接口的功能,或者修改前端接口的实现类,后台接口以及实现类就不会受到影响

二、依赖倒置原则

1、定义

高层模块低层模块都应该依赖抽象。抽象不应该依赖于细节,细节应该依赖于抽象

或者说,针对接口编程,而不针对实现编程

不可分割的原子逻辑就是底层模块,原子逻辑的再组装就是高层模块

2、分析

代码要依赖于抽象的类,而不要依赖于具体的类;要针对接口或抽象类编程,而不是针对具体类编程

依赖倒转原则的常用实现方式之一是在代码中使用抽象类,而将具体类放在配置文件中

3、举例

以歌手唱歌为例,比如一个歌手要唱国语歌,代码表示:

public class Client {
    public static void main(String[] args) {
        Singer singer = new Singer();
        ChineseSong song = new ChineseSong();
        singer.sing(song);
    }
}

class ChineseSong {
    public String language() {
        return "唱国语歌";
    }
}
class Singer {
    //唱歌的方法
    public void sing(ChineseSong song) {
        System.out.println("歌手" + song.language());
    }
}
//结果输出:歌手唱国语歌

如果现在想让歌手唱英文歌,但是在这个类中很难实现:Singer类依赖于一个具体的实现类 ChineseSong。如果增加方法,就修改了 Singer类,以后需要增加更多的歌种时,歌手类就要一直修改,依赖类已经不稳定了

这时需要用面向接口编程的思想优化方案,修改代码:

public class Client {
    public static void main(String[] args) {
        Singer singer = new Singer();
        EnglishSong englishSong = new EnglishSong();
        // 唱英文歌
        singer.sing(englishSong);
    }
}

interface Song { //声明一个公共接口,interface就是接口
    public String language();
}
class ChineseSong implements Song{
    public String language() {
        return "唱国语歌";
    }
}
class EnglishSong implements Song {
    public String language() {
        return "唱英语歌";
    }
}
class Singer {
    //唱歌的方法
    public void sing(Song song) {
        System.out.println("歌手" + song.language());
    }
}

我们把歌抽成一个接口Song,每个歌种都实现该接口并重写方法,这样歌手的代码就不必改动,如果需要添加歌的种类,只需多写一个实现类继承Song即可

三、里式替换原则

1、定义

如果对每一个类型为 T1的对象 o1,都有类型为 T2的对象 o2,使得以 T1定义的所有程序 P在所有对象 o1都替换成 o2的时候,程序 P的行为都没有发生变化,那么类型 T2是类型 T1的子类型

或者说, 所有引用父类的地方必须能透明地使用其子类的对象

2、分析

在软件中如果能够使用父类对象,那么一定能够使用其子类对象。把父类都替换成它的子类,程序将不会产生任何错误和异常;但是反过来则不成立(子类可以扩展父类没有的功能,同时子类还不能改变父类原有的功能)

由于使用基类对象的地方都可以使用子类对象,因此在程序中尽量使用基类类型来对对象进行定义,而在运行时再确定其子类类型,用子类对象替换父类对象

里氏替换原则为良好的继承定义了一个规范,它包含了4层含义:

  1. 子类可以实现父类的抽象方法,但是不能覆盖父类的非抽象方法
  2. 子类可以有自己的个性,即可以有自己的属性和方法
  3. 子类覆盖或重载父类的方法时输入参数可以被放大
  4. 子类覆盖或重载父类的方法时输出结果可以被缩小,即返回值要小于或等于父类的方法返回值

3、举例

父类有一个方法,参数是HashMap

class Father {
    public void test(HashMap map){
        System.out.println("父类被执行。。。。。");
    }
}

子类的同名方法输入参数的类型可以扩大,例如输入参数为Map

class Son extends Father{
    public void test(Map map){
        System.out.println("子类被执行。。。");
    }
}

写一个场景类测试父类的方法执行效果:

class Client {
    public static void main(String[] args) {
        Father father = new Father();
        HashMap map = new HashMap();
        father.test(map);
    }
}
// 结果输出:父类被执行。。。。。

参考里氏替换原则:只要父类能出现的地方子类就可以出现,而且替换为子类也不会产生任何异常。修改代码调用子类的方法:

public class Client {
    public static void main(String[] args) {
        Son son = new Son();
        HashMap map = new HashMap();
        father.test(map);
    }
}

运行结果一样。这是因为子类方法的输入参数类型范围扩大了,子类代替父类传递到调用者中,子类的方法永远不会被执行。如果想让子类方法执行,可以重写方法体

四、单一职责原则

1、定义

一个对象应该只包含单一的职责,并且该职责被完整地封装在一个类中。或者说,就一个类而言,应该仅有一个引起它变化的原因

2、分析

一个类(或者大到模块,小到方法)承担的职责越多,它被复用的可能性越小。当一个类承担的职责越多,其耦合度越高,当其中一个职责变化时,可能会影响其他职责的运作

类的职责主要包括两个方面:数据职责和行为职责,数据职责通过属性来体现,行为职责通过方法来体现

单一职责原则是实现高内聚、低耦合的指导方针,在很多代码重构手法中都能找到它的存在。它是最简单但又最难运用的原则,需要设计人员发现类的不同职责并将其分离,而发现类的多重职责需要设计人员具有较强的分析设计能力和相关重构经验

3、举例

仍然举开闭原则中的工程师类例子

public class Engineer {
    public void makeDemand() {} //出需求
    public void writeCode() {} //写代码
    public void meetClient() {} //见客户
}

代码貌似没问题,符合我们的写法,但是它不符合单一职责原则:三个方法都可以引起类的变化,比如有天因为业务需要,出需求的方法需要增加需求成本分析的功能,或者见客户需要参数,导致类的变化有多种可能性,其他引用该类的类也需要相应的变化,代码维护的成本随之增加

所以需要把这些方法拆分成独立的职责:让一个类只负责一个方法,每个类只专心处理自己的方法

单一职责原则优点

  • 降低类的复杂性,每个类有明确的职责
  • 逻辑更简单,类的可读性提高,代码的可维护性提高
  • 变更的风险降低,因为只会在单一的类中的修改

五、组合复用原则

1、定义

尽量使用对象组合而不是继承来达到复用的目的

2、分析

在一个新的对象里通过关联关系(组合、聚合关系)使用一些已有的对象, 使之成为新对象的一部分;新对象通过委派调用已有对象的方法来达到复用其已有功能的目的

面向对象中有两种方法可以做到复用已有的设计和实现:通过组合,通过继承

  • 继承复用:实现简单,易于扩展。会破坏系统的封装性;从父类继承而来的实现是静态的,运行时不能改变,灵活性不足;只能在有限的环境中使用
  • 组合复用:耦合度相对较低,选择性地调用成员对象的方法;可在运行时动态进行

组合可以使系统更加灵活,降低类与类之间的耦合度,进而一个类的变化对其他类造成的影响相对较少

使用继承时需要严格遵循里氏代换原则,有效使用继承会有助于对问题的理解,降低复杂度,否则会增加系统构建和维护的难度以及系统的复杂度

因此一般首选使用组合实现复用,其次才考虑继承

3、举例

我们每个人都有一个身份,比如我是老师,你是学生,他是运动员。按照这种情况编写代码

class Person {
    public Person() {
    }
}
 
class Teacher extends Person {
    public Teacher() {
        System.out.println("I am a teacher.");
    }
} 
class Student extends Person {
    public Student() {
        System.out.println("I am a student.");
    }
} 
class Athlete extends Person {
    public Athlete() {
        System.out.println("I am an athlete");
    }
}

但是该设计不符合实际情况,每个人都能有多重身份,可以是学生,也可以是运动员。上述代码只给出了一人一种身份的情况,不合理。重新设计代码

class Person {
    private Role role;
 
    public Person() {
    }
    public void setRole(Role role) {
        this.role = role;
    }
    public Role getRole() {
        return role;
    }
}

interface Role {
}
class Teacher implements Role{
    public Teacher() {
        System.out.println("I am a teacher.");
    }
}
class Student implements Role {
    public Student() {
        System.out.println("I am a student.");
    }
}
class Athlete implements Role {
    public Athlete() {
        System.out.println("I am an athlete");
    }
}

原来的聚合关系变为现在的组合关系。一个人可以有多重角色,在不同的情况下,我们还能给人物设置不同的角色

六、迪米特原则

1、定义

”只与直接朋友通信“

一个对象应该对其他对象有最少的了解

2、分析

一个软件实体应当尽可能少的与其他实体发生相互作用。这样一个模块修改时就会尽量少的影响其他的模块,扩展会相对容易。这是面向设计的核心原则:低耦合,高内聚

对于一个对象,其朋友包括以下几类:

  • 当前对象本身
  • 以参数形式传入到当前对象方法中的对象
  • 当前对象的成员对象
  • 如果当前对象的成员对象是一个集合,那么集合中的元素都是朋友
  • 当前对象所创建的对象

3、举例

上体育课之前,老师让班长先去体务室拿20个篮球上课用。根据这一场景,我们可以设计出三个类 Teacher(老师),Monitor (班长) 和 BasketBall (篮球),以及发布命令的方法command和拿篮球的方法takeBall

class Teacher {
    // 命令班长去拿球
    public void command(Monitor monitor) {
        List<BasketBall> ballList = new ArrayList<BasketBall>();
        // 初始化篮球数目
        for (int i = 0;i<20;i++){
            ballList.add(new BasketBall());
        }
        // 通知班长开始去拿球
        monitor.takeBall(ballList);
    }
}
class BasketBall {
}
class Monitor {
    // 拿球
    public void takeBall(List<BasketBall> balls) {
        System.out.println("篮球数目:" + balls.size());
    }
}

写一个情景类进行测试:

public class Client {
    public static void main(String[] args) {
        Teacher teacher = new Teacher();
        teacher.command(new Monitor());
    }
}
//结果输出:20

结果是正确的,但程序存在一些问题:从场景来说,老师只需命令班长拿篮球即可,Teacher只需要一个朋友 Monitor,但程序中 Teacher的方法却依赖了 BasketBall类,也就是说 Teacher类与一个陌生类有了交流,这样 Teacher的健壮性就被破坏了:一旦 BasketBall类做了修改,Teacher也需要修改,明显违背了迪米特法则

因此我们要在 Teacher的方法中去掉对 BasketBall类的依赖,只让 Teacher类与 Monitor类产生依赖,修改后的代码如下:

class Teacher {
    // 命令班长去拿球
    public void command(Monitor monitor) {
        // 通知班长开始去拿球
        monitor.takeBall();
    }
}
class Monitor {
    // 拿球
    public void takeBall() {
        List<BasketBall> ballList = new ArrayList<BasketBall>();
        // 初始化篮球数目
        for (int i = 0;i<20;i++){
            ballList.add(new BasketBall());
        }
        System.out.println("篮球数目:" + ballList.size());
    }
}

这样 Teacher类就不会与 BasketBall类产生依赖了,日后因为业务需要修改 BasketBall类时也不会影响 Teacher类

七、接口隔离原则

1、定义

客户端不应该依赖它不需要的接口

2、分析

客户端需要什么接口就提供什么接口,把不需要的接口剔除掉,即对接口进行细化,保证接口的纯洁性。换一种说法就是,类间的依赖关系应该建立在最小的接口上,也就是建立单一的接口

使用接口隔离原则拆分接口时,首先必须满足单一职 责原则,将一组相关的操作定义在一个接口中,且在满足高内聚的前提下,接口中的方法越少越好

3、举例

在我们年轻人的观念里,好的智能手机应该是价格便宜,外观好看,功能丰富的,由此可以定义一个智能手机的抽象接口 ISmartPhone,代码如下:

public interface ISmartPhone {
    public void cheapPrice();
    public void goodLooking();
    public void richFunction();
}

接着定义一个手机接口的实现类,实现这三个抽象方法

public class SmartPhone implements ISmartPhone{
    public void cheapPrice() {
        System.out.println("这手机便宜~~~~~");
    }

    public void goodLooking() {
        System.out.println("这手机外观好看~~~~~");
    }

    public void richFunction() {
        System.out.println("这手机功能真多~~~~~");
    }
}

然后定义一个用户的实体类 User,并定义一个构造方法,以 ISmartPhone 作为参数传入,最后定义一个使用的方法 usePhone调用接口的方法

public class User {
    private ISmartPhone phone;
    
    public User(ISmartPhone phone){
        this.phone = phone;
    }
    public void usePhone(){
        phone.cheapPrice();
        phone.goodLooking();
        phone.richFunction();
    }
}

当我们实例化User类并调用其方法usePhone后,控制台上就会显示手机接口三个方法的方法体信息,这种设计看上去没什么大毛病,但是 ISmartPhone这个接口的设计是否已经达到最优了呢

某些消费群体对手机功能要求很简单,比如成功人士,外观大气,功能简单即可。这样 ISmartPhone接口就不适用了,我们的接口定义了智能手机必须满足三个特性,如果实现该接口就必须三个方法都实现

这时就能看出,ISmartPhone过于臃肿了,按照接口隔离原则,我们可以根据不同的特性把智能手机的接口进行拆分,这样就保证了接口的单一性和纯洁性

posted @ 2019-10-09 13:53 W❤L 阅读(...) 评论(...) 编辑 收藏