面向对象设计的七大原则

作者: 移动的红烧肉 | 来源:发表于2018-10-15 15:24 被阅读155次

    1.开闭原则 - Open Close Principle(OCP)

    1)定义
    • 一个软件实体如类、模块和函数应该对扩展开放,对修改关闭
    • Software entities like classes,modules and functions should be open for extension but closed for modifications.
    2)基本概念
    • 开:对扩展开放,支持方便的扩展
    • 闭:对修修改关闭,严格限制对已有的内容修改
    • 说明:一个软件产品只要在生命周期内,都会发生变化,即然变化是一个事实,我们就应该在设计时尽量适应这些变化,以提高项目的稳定性和灵活性,真正实现“拥抱变化”。开闭原则告诉我们应尽量通过扩展软件实体的行为来实现变化,而不是通过修改现有代码来完成变化,它是为软件实体的未来事件而制定的对现行开发设计进行约束的一个原则
    3)优点
    • 提高系统的灵活性、可复用性和可维护性
    4)示例:现在,以课程为例说明什么是开闭原则
    /**
    * 定义课程接口
    */
    public interface ICourse {
        String getName();  // 获取课程名称
        Double getPrice(); // 获取课程价格
        Integer getType(); // 获取课程类型
    }
    
    /**
     * 英语课程接口实现
     */
    public class EnglishCourse implements ICourse {
    
        private String name;
        private Double price;
        private Integer type;
    
        public EnglishCourse(String name, Double price, Integer type) {
            this.name = name;
            this.price = price;
            this.type = type;
        }
    
        @Override
        public String getName() {
            return null;
        }
    
        @Override
        public Double getPrice() {
            return null;
        }
    
        @Override
        public Integer getType() {
            return null;
        }
    }
    
    // 测试
    public class Main {
        public static void main(String[] args) {
            ICourse course = new EnglishCourse("小学英语", 199D, "Mr.Zhang");
            System.out.println(
                    "课程名字:"+course.getName() + " " +
                    "课程价格:"+course.getPrice() + " " +
                    "课程作者:"+course.getAuthor()
            );
        }
    }
    

    项目上线,课程正常销售,但是我们产品需要做些活动来促进销售,比如:打折。那么问题来了:打折这一动作就是一个变化,而我们要做的就是拥抱变化,现在开始考虑如何解决这个问题,可以考虑下面三种方案:

    1)修改接口

    • 在之前的课程接口中添加一个方法 getSalePrice() 专门用来获取打折后的价格;
    • 如果这样修改就会产生两个问题,所以此方案否定
        (1) ICourse 接口不应该被经常修改,否则接口作为契约的作用就失去了
        (2) 并不是所有的课程都需要打折,加入还有语文课,数学课等都实现了这一接口,但是只有英语课打折,与实际业务不符
    public interface ICourse {
    
        // 获取课程名称
        String getName();
    
        // 获取课程价格
        Double getPrice();
    
        // 获取课程类型
        String getAuthor();
        
        // 新增:打折接口
        Double getSalePrice();
    }
    

    2)修改实现类

    • 在接口实现里直接修改 getPrice()方法,此方法会导致获取原价出问题;或添加获取打折的接口 getSalePrice(),这样就会导致获取价格的方法存在两个,所以这个方案也否定,此方案不贴代码了。

    3)通过扩展实现变化

    • 直接添加一个子类 SaleEnglishCourse ,重写 getPrice()方法,这个方案对源代码没有影响,符合开闭原则,所以是可执行的方案,代码如下,代码如下:
    public class SaleEnglishCourse extends EnglishCourse {
    
        public SaleEnglishCourse(String name, Double price, String author) {
            super(name, price, author);
        }
    
        @Override
        public Double getPrice() {
            return super.getPrice() * 0.85;
        }
    }
    

    综上所述,如果采用第三种,即开闭原则,以后再来个语文课程,数学课程等等的价格变动都可以采用此方案,维护性极高而且也很灵活

    2.单一职责原则 - Single Responsibility Principle(SRP)

    1)定义
    • 不要存在多于一个导致类变更的原因
    • There should never be more than one reason for a class to change.
    2)基本概念
    • 单一职责是高内聚低耦合的一个体现
    • 说明:通俗的讲就是一个类只能负责一个职责,修改一个类不能影响到别的功能,也就是说只有一个导致该类被修改的原因
    3)优点
    • 低耦合性,影响范围小
    • 降低类的复杂度,职责分明,提高了可读性
    • 变更引起的风险低,利于维护
    4)示例:现在,以动物为例说明什么是单一原则

    假如说,类 A 负责两个不同的职责,T1 和 T2,当由于职责 T1 需求发生改变而需要修改类 A 时,有可能会导致原本运行正常的职责 T2 功能发生改变或出现异常。为什么会出现这种问题呢?代码耦合度太高,实现复杂,简单一句话就是:不够单一。那么现在提出解决方案:分别建立两个类 A 和 B ,使 A 完成职责 T1 功能,B 完成职责 T2 功能,这样在修改 T1 时就不会影响 T2 了,反之亦然。

    说到单一职责原则,很多人都会不屑一顾。因为它太简单了。稍有经验的程序员即使从来没有读过设计模式、从来没有听说过单一职责原则,在设计软件时也会自觉的遵守这一重要原则,因为这是常识。在软件编程中,谁也不希望因为修改了一个功能导致其他的功能发生故障。而避免出现这一问题的方法便是遵循单一职责原则。虽然单一职责原则如此简单,并且被认为是常识,但是即便是经验丰富的程序员写出的程序,也会有违背这一原则的代码存在。为什么会出现这种现象呢?因为有职责扩散。所谓职责扩散,就是因为某种原因,职责 T 被分化为粒度更细的职责 T1 和 T2

    /**
    * 定义动物类
    */
    public class Animal {
        public void move(String animal){
            System.out.println(animal + "用翅膀飞");
        }
    }
    
    /**
     * 第一次测试
     */
    public class Main {
        public static void main(String[] args) {
           Animal animal = new Animal();
           animal.move("麻雀");
           animal.move("老鹰");
           animal.move("鲸鱼");
        }
    }
    

    经过上面代码示例发现:麻雀和老鹰会飞是可以理解的,但是鲸鱼就有点不合常理了,那么我们遵循单一对代码进行第(一)次修改,代码如下:

    /**
     * 会飞的动物
     */
    public class FlyAnimal {
        public void move(String animal){
            System.out.println(animal + "用翅膀飞");
        }
    }
    
    /**
     * 在水里的动物
     */
    public class WaterAnimal {
        public void move(String animal){
            System.out.println(animal + "在水里游泳");
        }
    }
    
    /**
     * 测试
     */
    public class Main {
        public static void main(String[] args) {
           FlyAnimal flyAnimal = new FlyAnimal();
           flyAnimal.move("麻雀");
           flyAnimal.move("老鹰");
    
           WaterAnimal waterAnimal = new WaterAnimal();
            waterAnimal.move("鲸鱼");
        } 
    }
    

    遵循单一原则发现确实是职责单一了,但是我们会发现如果这样修改花销是很大的,除了将原来的类分解之外还需要修改客户端代码。如果我们不去遵循单一原则,而是直接在原有代码进行第(二)次修改,代码如下:

    /**
     * 直接修改源代码
     */
    public class Animal {
        public void move(String animal){
            if ("鲸鱼".equals(animal)) {
                System.out.println(animal + "在水里游泳");
            } else {
                System.out.println(animal + "用翅膀飞");
            }
        }
    }
    
    /**
     * 第三次测试
     */
    public class Main {
        public static void main(String[] args) {
           Animal animal = new Animal();
           animal.move("麻雀");
           animal.move("老鹰");
           animal.move("鲸鱼");
        }
    }
    

    直接修改源码确实简单很多,但是却存在巨大的隐患,加入需求变了:将在水里的动物分为淡水的和海水的,那么又要继续修改 Animal 类的 move() 方法,这也就对会飞的动物造成了一定的风险,所以我们继续摒弃单一原则对代码进行第(三)次修改,代码如下:

    public class Animal {
        public void move(String animal){
            System.out.println(animal + "在水里游泳");
        }
        public void moveA(String animal){
            System.out.println(animal + "用翅膀飞");
        }
    }
    
    public class Main {
        public static void main(String[] args) {
           Animal animal = new Animal();
           animal.move("麻雀");
           animal.move("老鹰");
           animal.moveA("鲸鱼");
        }
    }
    

    在最后一种修改中可以看到,这种修改方式没有改动原来的方法,而是在类中新加了一个方法,这样虽然也违背了单一职责原则,但在方法级别上却是符合单一职责原则的,因为它并没有动原来方法的代码。综上所述,这三种方式各有优缺点,那么在实际编程中,采用哪一中呢?结论:只有逻辑足够简单,才可以在代码级别上违反单一职责原则;只有类中方法数量足够少,才可以在方法级别上违反单一职责原则;

    3.里士替换原则 - Liskov Substitution Principle(LSP)

    1)定义
    • 定义一:所有引用基类的地方必须能透明地使用其子类的对象。
    • 定义二:如果对每一个类型为 T1的对象 o1,都有类型为 T2 的对象o2,使得以 T1定义的所有程序 P 在所有的对象 o1 都代换成 o2 时,程序 P 的行为没有发生变化,那么类型 T2 是类型 T1 的子类型。
    • Functions that use pointers or references to base classes must be able to use objects of derived classes without knowing it.
    2)基本概念
    • 强调的是设计和实现要依赖于抽象而非具体;子类只能去扩展基类,而不是隐藏或者覆盖基类它包含以下4层含义

    1)子类可以实现父类的抽象方法,但不能覆盖父类的非抽象方法。
    2)子类中可以增加自己特有的方法。
    3)当子类的方法重载父类的方法时,方法的前置条件(即方法的形参)要比父类方法的输入参数更宽松。
    4)当子类的方法实现父类的抽象方法时,方法的后置条件(即方法的返回值)要比父类更严格。

    3)优点
    • 开闭原则的体现,约束继承泛滥
    • 提高系统的健壮性、扩展性和兼容性
    4)示例:

    代码讲解第三个概念 :
    当子类的方法重载父类的方法时,方法的前置条件(即方法的形参)要比父类方法的输入参数更宽松

    public class ParentClazz {
        public void say(CharSequence str) {
            System.out.println("parent execute say " + str);
        }
    }
    
    public class ChildClazz extends ParentClazz {
        public void say(String str) {
            System.out.println("child execute say " + str);
        }
    }
    
    /**
     * 测试
     */
    public class Main {
        public static void main(String[] args) {
            ArrayList list = new ArrayList();
            ParentClazz parent = new ParentClazz();
            parent.say("hello");
            ChildClazz child = new ChildClazz();
            child.say("hello");
    
        }
    }
    
    执行结果:
    parent execute say hello
    child execute say hello
    

    以上代码中我们并没有重写父类的方法,只是重载了同名方法,具体的区别是:子类的参数 String 实现了父类的参数 CharSequence。此时执行了子类方法,在实际开发中,通常这不是我们希望的,父类一般是抽象类,子类才是具体的实现类,如果在方法调用时传递一个实现的子类可能就会产生非预期的结果,引起逻辑错误,根据里士替换原则的子类的输入参数要宽于或者等于父类的输入参数,我们可以修改父类参数为String,子类采用更宽松的 CharSequence,如果你想让子类的方法运行,就必须覆写父类的方法。代码如下:

    public class ParentClazz {
        public void say(String str) {
            System.out.println("parent execute say " + str);
        }
    }
    
    public class ChildClazz extends ParentClazz {
        public void say(CharSequence str) {
            System.out.println("child execute say " + str);
        }
    }
    
    public class Main {
        public static void main(String[] args) {
            ParentClazz parent = new ParentClazz();
            parent.say("hello");
            ChildClazz child = new ChildClazz();
            child.say("hello");
        }
    }
    
    执行结果:
    parent execute say hello
    parent execute say hello
    

    代码讲解第四个概念 :

    public abstract class Father {
        public abstract Map hello();
    }
    
    public class Son extends Father {
        @Override
        public Map hello() {
            HashMap map = new HashMap();
            System.out.println("son execute");
            return map;
        }
    }
    
    public class Main {
        public static void main(String[] args) {
            Father father = new Son();
            father.hello();
        }
    }
    
    执行结果:
    son execute
    

    4.依赖倒置原则 - Dependence Inversion Principle(DIP)

    1)定义
    • 高层模块不应该依赖低层模块,二者都应该依赖其抽象;抽象不应该依赖细节,细节应该依赖抽象。
    • High level modules should not depend upon low level modules,Both should depend upon abstractions.Abstractions should not depend upon details.Details should depend upon abstracts.
    2)基本概念
    • 依赖倒置原则的核心就是要我们面向接口编程,理解了面向接口编程,也就理解了依赖倒置
    • 低层模块尽量都要有抽象类或接口,或者两者都有
    • 变量的声明类型尽量是抽象类或接口
    • 使用继承时遵循里氏替换原则
    • 设计和实现要依赖于抽象而非具体。一方面抽象化更符合人的思维习惯;另一方面,根据里氏替换原则,可以很容易将原来的抽象替换为扩展后的具体,这样可以很好的支持开-闭原则
    3)优点
    • 减少类间的耦合性,提高系统的稳定性
    • 降低并行开发引起的风险
    • 提高代码的可读性和可维护性
    4)示例:
    public class MrZhang {
        public void study(ChineseCourse course) {
            course.content();
        }
    }
    
    public class ChineseCourse {
        public void content() {
            System.out.println("开始学习语文课程");
        }
    }
    
    public class Main {
        public static void main(String[] args) {
            MrZhang zhang = new MrZhang();
            zhang.study(new ChineseCourse());
        }
    }
    
    执行结果:
    开始学习语文课程
    

    执行之后,结果正常。那么,考虑一个问题假如此时要学习数学课程呢? 数学课程代码如下:

    public class MathCourse {
        public void content() {
            System.out.println("开始学习数学课程");
        }
    }
    

    很显然,MrZhang 无法学习,因为他只能接受 ChineseCourse ,学习语文课程。当然如果我们修改接受参数为 MathCourse 的话就可以学习了,但是不能学习语文,英语,化学等等。造成此现象的具体原因是:MrZhang 和 ChineseCourse 耦合度太高了,必须降低耦合度才可以。代码如下:

    public interface ICourse {
        void content();
    }
    
    public class MrZhang {
        public void study(ICourse course) {
            course.content();
        }
    }
    
    public class ChineseCourse implements ICourse {
        @Override
        public void content() {
            System.out.println("开始学习语文课程");
        }
    }
    
    public class MathCourse implements ICourse {
        @Override
        public void content() {
            System.out.println("开始学习数学课程");
        }
    }
    
    public class Main {
        public static void main(String[] args) {
            MrZhang zhang = new MrZhang();
            zhang.study(new ChineseCourse());
            zhang.study(new MathCourse());
        }
    }
    

    MrZhang 与 ICourse 具有依赖关系,ChineseCourse 和 MathCourse 属于课程范畴,并且各自实现了 ICourse 接口,这样就符合了依赖倒置原则。这样修改后无论再怎么扩展 Main 类,都不用继续修改 MrZhang 了,MrZhang.java 作为高层模块就不会依赖低层模块的修改而引起变化,减少了修改程序造成的风险。

    5.接口隔离原则 - Interface Segration Principle(ISP)

    1)定义
    • 定义一:客户端不应该依赖它不需要的接口
    • Clients should not be forced to depend upon interfaces that they don’t use.
    • 定义二:类间的依赖关系应该建立在最小的接口上
    • The dependency of one class to another one should depend on the
      smallest possible interface.
    2)基本概念
    • 一个类对另一个类的依赖应该建立在最小的接口上,通俗的讲就是需要什么就提供什么,不需要的就不要提供
    • 接口中的方法应该尽量少,不要使接口过于臃肿,不要有很多不相关的逻辑方法
    3)优点
    • 高内聚,低耦合
    • 可读性高,易于维护
    4)代码示例:
    public interface IAnimal {
        void eat();
        void talk();
        void fly();
    }
    
    public class BirdAnimal implements IAnimal {
        @Override
        public void eat() {
            System.out.println("鸟吃虫子");
        }
    
        @Override
        public void talk() {
            //并不是所有的鸟都会说话
        }
    
        @Override
        public void fly() {
            //并不是所有的鸟都会飞
        }
    }
    
    public class DogAnimal implements IAnimal {
        @Override
        public void eat() {
            System.out.println("狗狗吃饭");
        }
    
        @Override
        public void talk() {
            //狗不会说话
        }
    
        @Override
        public void fly() {
            //狗不会飞
        }
    }
    

    通过上面的代码发现:狗实现动物接口,必须实现三个接口,根据常识我们得知,第二个和第三个接口不一定会有实际意义,换句话说也就是这个方法有可能一直不会被调用。但是就是这样我们还必须实现这个方法,尽管方法体可以为空,但是这就违反了接口隔离的定义。我们知道 由于Java类支持实现多个接口,可以很容易的让类具有多种接口的特征,同时每个类可以选择性地只实现目标接口,基于此特点我们可以对功能进一步的细化,编写一个或多个接口,代码如下:

    public interface IEat {
        void eat();
    }
    
    public interface IFly {
        void fly();
    }
    
    public interface ITalk {
        void talk();
    }
    
    public class DogAnimal implements IEat {
        @Override
        public void eat() {
            System.out.println("狗狗吃饭");
        }
    }
    
    public class ParrotAnimal implements IEat, IFly, ITalk {
    
        @Override
        public void eat() {
            System.out.println("鹦鹉吃东西");
        }
    
        @Override
        public void fly() {
            System.out.println("鹦鹉吃飞翔");
        }
    
        @Override
        public void talk() {
            System.out.println("鹦鹉说话");
        }
    }
    

    说到这里,很多人会觉的接口隔离原则跟之前的单一职责原则很相似,其实不然。
    其一,单一职责原则原注重的是职责;而接口隔离原则注重对接口依赖的隔离。
    其二,单一职责原则主要是约束类,其次才是接口和方法,它针对的是程序中的实现和细节;而接口隔离原则主要约束接口接口,主要针对抽象,针对程序整体框架的构建

    接口隔离原则一定要适度使用,接口设计的过大或过小都不好,过分的细粒度可能造成接口数量庞大不易于管理

    6.迪米特法则/最少知道原则 - Law of Demeter or Least Knowledge Principle(LoD or LKP)

    1)定义
    • 一个对象应该对其他对象保持最少的了解
    • 这个原理的名称来源于希腊神话中的农业女神,孤独的得墨忒耳。
    2)基本概念
    • 每个单元对于其他的单元只能拥有有限的知识:只是与当前单元紧密联系的单元;
    • 每个单元只能和它的朋友交谈:不能和陌生单元交谈;
    • 只和自己直接的朋友交谈。
    3)优点
    • 使得软件更好的可维护性与适应性
    • 对象较少依赖其它对象的内部结构,可以改变对象容器(container)而不用改变它的调用者(caller)
    4)详细讲解:

    迪米特法则通俗的来讲,就是一个类对自己依赖的类知道的越少越好。也就是说,对于被依赖的类来说,无论逻辑多么复杂,都尽量地的将逻辑封装在类的内部,对外除了提供的public方法,不对外泄漏任何信息。迪米特法则还有一个更简单的定义:只与直接的朋友通信。首先来解释一下什么是直接的朋友:每个对象都会与其他对象有耦合关系,只要两个对象之间有耦合关系,我们就说这两个对象之间是朋友关系。耦合的方式很多,依赖、关联、组合、聚合等。其中,我们称出现成员变量、方法参数、方法返回值中的类为直接的朋友,而出现在局部变量中的类则不是直接的朋友。也就是说,陌生的类最好不要作为局部变量的形式出现在类的内部。

    代码举例:通过老师要求班长告知班级人数为例,讲解迪米特法则。先来看一下违反迪米特法则的设计,代码如下

    public class Student {
        private Integer id;
        private String name;
    
        public Student(Integer id, String name) {
            this.id = id;
            this.name = name;
        }
    }
    
    public class Teacher {
        public void call(Monitor monitor) {
            List<Student> sts = new ArrayList<>();
            for (int i = 0; i < 10; i++) {
                sts.add(new Student(i + 1, "name" + i));
            }
            monitor.getSize(sts);
        }
    }
    
    public class Monitor {
        public void getSize(List list) {
            System.out.println("班级人数:" + list.size());
        }
    }
    

    现在这个设计的主要问题出在 Teacher 中,根据迪米特法则,只与直接的朋友发生通信,而 Student 类并不是 Teacher 类的直接朋友(以局部变量出现的耦合不属于直接朋友),从逻辑上讲 Teacher 只与 Monitor 耦合就行了,与 Student 并没有任何联系,这样设计显然是增加了不必要的耦合。按照迪米特法则,应该避免类中出现这样非直接朋友关系的耦合。修改后的代码如下:

    public class Student {
        private Integer id;
        private String name;
    
        public Student(Integer id, String name) {
            this.id = id;
            this.name = name;
        }
    }
    
    public class Teacher {
        public void call(Monitor monitor) {
            monitor.getSize();
        }
    }
    
    public class Monitor {
        public void getSize() {
            List<Student> sts = new ArrayList<>();
            for (int i = 0; i < 10; i++) {
                sts.add(new Student(i + 1, "name" + i));
            }
            System.out.println("班级人数" + sts.size());
        }
    }
    
    

    将Student 从 Teacher 抽掉,也就达到了 Student 和 Teacher 的解耦,从而符合了迪米特原则。

    迪米特法则的初衷是降低类之间的耦合,由于每个类都减少了不必要的依赖,因此的确可以降低耦合关系。但是凡事都有度,虽然可以避免与非直接的类通信,但是要通信,必然会通过一个“中介”来发生联系,例如本例中,老师(Teacher)就是通过班长(Monitor)这个“中介”来与 学生(Student)发生联系的。过分的使用迪米特原则,会产生大量这样的中介和传递类,导致系统复杂度变大。所以在采用迪米特法则时要反复权衡,既做到结构清晰,又要高内聚低耦合。

    7.合成/聚合复用原则 - Composite/Aggregate Reuse Principle(CARP / CRP)

    1) 定义
    • 尽量采用组合(contains-a)、聚合(has-a)的方式而不是继承(is-a)的关系来达到软件的复用目的
    2)基本概念
    • 如果新对象的某些功能在别的已经创建好的对象里面已经实现,那么应当尽量使用别的对象提供的功能,使之成为新对象的一部分,而不要再重新创建

    组合/聚合的优缺点:类之间的耦合比较低,一个类的变化对其他类造成的影响比较少,缺点:类的数量增多实现起来比较麻烦

    继承的优点:由于很多方法父类已经实现,子类的实现会相对比较简单,缺点:将父类暴露给了子类,一定程度上破坏了封装性,父类的改变对子类影响比较大

    3)优点
    • 可以降低类与类之间的耦合程度
    • 提高了系统的灵活性
    4)讲解
    public class Person {
        public void talk(String name) {
            System.out.println(name + " say hello");
        }
        public void walk(String name) {
            System.out.println(name + " move");
        }
    }
    
    public class Manager extends Person { 
    }
    
    public class Employee extends Person {
    }
    

    按照组合复用原则我们应该首选组合,然后才是继承,使用继承时应该严格的遵守里氏替换原则,必须满足“Is-A”的关系是才能使用继承,而组合却是一种“Has-A”的关系。导致错误的使用继承而不是使用组合的一个重要原因可能就是错误的把“Has-A”当成了“Is-A”。

    由上没看的代码可以看出,经理和员工继承了人,但实际中每个不同的职位拥有不同的角色,如果我们添加了角色这个类,那么继续使用继承的话只能使每个人只能具有一种角色,这显然是不合理的。

    备注:Java设计模式的桥接模式很好的体现了这一原则

    相关文章

      网友评论

        本文标题:面向对象设计的七大原则

        本文链接:https://www.haomeiwen.com/subject/bxnxzftx.html