您现在的位置是:首页 >技术交流 >设计模式七大设计原则网站首页技术交流

设计模式七大设计原则

King Gigi. 2023-07-10 04:00:03
简介设计模式七大设计原则


设计模式主要是为了满足一个字 ,这个字,可能是需求变更、可能是场景变更,但是运用好设计模式后我们写出的代码就能很好的应对不断变化的场景。

1、什么是设计模式

设计模式是前辈们不断总结、优化、打磨出来的设计方法,不同设计模式适用于不同的场景

但要明确一点,没有任何一种设计模式,能达到适用于所有场景的效果!

只有运用好设计原则和设计模式,才能让我们写出更加优秀的代码或者设计更好软件架构

设计模式有23种,其中每个设计模式又依赖于七大设计原则中的一个或多个

  • 单一职责原则
  • 开闭原则
  • 接口隔离原则
  • 里氏替换原则
  • 依赖倒置原则
  • 组合优于继承原则
  • 迪米特法则(最少知道原则)

下面我们详细聊聊七大设计原则

2、单一职责原则

单一职责是什么呢?

核心思想:每个方法、每个类、每个框架都只负责一件事情

举个栗子:

  • Math.round() ,只负责完成四舍五入的功能,其他的不管(方法)

  • Reader类,只负责读取文本文件(类)

  • Spring MVC,只负责简化MVC开发(框架)

单一职责讲究一个”“字,将功能尽可能的拆分,然后使用的时候进行组合

优点:

​ 1.代码重用性提高

​ 2.代码可读性提高,此时的代码,就像一个大纲一样

现在需求来了:统计一个文本文件中有多少个单词

我们先来看一个栗子

public class nagtive {
    public static void main(String[] args) {
        try{
            //统计一个文本文件中有多少个单词
            //Reader默认查询的码表是与操作系统一致的码表,我们的操作系统是中文的,所以Reader就会使用GBK码表
            //GBK码表一个汉字占2个字节 ,且汉字的两个字节都是以1开头,utf8码表一个汉字占3个字节
            //读取到记事本中的数字45489---> GBK --->北 --->unicode ---> 21271
            //总之一句话:字符流读取文件会查询码表,字节流不会查询码表
            Reader in = new FileReader("E:\1.txt");
            BufferedReader bufferedReader = new BufferedReader(in);

            String line = null;
            StringBuilder sb = new StringBuilder("");

            while((line =bufferedReader.readLine()) != null){
                sb.append(line);
                sb.append(" ");
            }

            //对内容进行分割
            String[] words = sb.toString().split("[^a-zA-Z]+");
            System.out.println(words.length);

            bufferedReader.close();

        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

相信很多同学拿到需求上来就是梭哈搞定,同一个方法将所有的事做完了(让它去做文件读取,还让它去做内容分割)。这就违背了单一职责原则。

之前聊到设计模式讲究一个”“字,那现在需求变了,我们需要统计文本文件中有多少个句子,那我们的做法是什么呢?重新写一个方法,将读取文件的内容部分的代码复制粘贴过去?然后改一下分割条件?这样虽然能解决问题,但是你有没有发现代码变得很臃肿呀,这显然是不合理的。

那正确的做法是什么呢?应该是将读取文件内容部分封装成一个方法,将内容分割也封装成一个方法,然后根据需求进行组合

看下面这个栗子:

public class demo {
    //读取文件的内容
    public static StringBuilder loadFile(String path) throws IOException {

        Reader in = new FileReader(path);
        BufferedReader bufferedReader = new BufferedReader(in);

        String line = null;
        StringBuilder sb = new StringBuilder("");

        while ((line = bufferedReader.readLine()) != null) {
            sb.append(line);
            sb.append(" ");
        }

        bufferedReader.close();
        return sb;
    }
    //对内容进行分割
    public static String[] getSplit(String regex, StringBuilder sb){
        return  sb.toString().split(regex);
    }
    //--------------------------------------------------------------------------
    //需求:
    //统计一个文本文件中有多少个单词
    public static Integer getWords() throws IOException {
        //读取文件的内容
        StringBuilder sb = loadFile("E:\1.txt");
        //对内容进行分割
        String[] words = getSplit("[^a-zA-Z]+", sb);

        return words.length;
    }
    //统计一个文本文件中有多少个句子
    public static Integer getSentence() throws IOException {
        //读取文件的内容
        StringBuilder sb = loadFile("E:\1.txt");
        //对内容进行分割
        String[] words = getSplit("[.,!?]", sb);

        return words.length;
    }
    public static void main(String[] args) throws IOException {
        System.out.println(getWords());
        System.out.println(getSentence());

    }
}

遵守单一原则,可以给我们带来的好处是,提高了代码的可重用性,同时还让得到的数据不再有耦合,完成我们的需求。

3、开闭原则

简单来说就是

对扩展开放,对修改关闭

在程序需要进行拓展的时候,不能去修改原有的代码。

举个栗子,我现在有一个刮胡刀,刮胡刀的功能应该就是刮胡子,但是我现在想要它拥有吹风机的能力

  • 违法开闭原则的做法是,把吹风机的功能加上了,可能就不能刮胡子了
  • 符合开闭原则的做法是,把吹风功能加上,且没有影响之前刮胡子的功能

例如我现在有一个商品类Goods,这个类之前有一个方法是获取它的价格,例如:

public class Goods {
    private BigDecimal price;
    
    public void setPrice(BigDecimal price) {
        this.price = price;
    }
    public BigDecimal getPrice() {
        return this.price;
    }
}

现在变化来了,当前商品需要打8折进行销售,不符合开闭原则的做法就是直接在原来的代码中进行修改

public BigDecimal getPrice() {
    // BigDecimal可以防止精度丢失
    return this.price.multiply(new BigDecimal("0.8"));
}

这样显然是不合理的,因为我们对源代码进行了修改,如果下次是打七折,那是不是又要去改源代码呢

正确的做法应该是写一个子类DiscountGoods来拓展父类的功能,再在子类上进行修改,这样就不会破坏父类的功能,又能满足需求

public class DiscountGoods extends Goods{
    @Override
    public BigDecimal getPrice() {
        return super.getPrice().multiply(new BigDecimal("0.8"));
    }
}

这就叫对扩展开发,对修改关闭。我们在用设计模式编码时应该时刻注意的是,改源码是一件非常危险的事情,因为一个功能并不是只有你在使用,很容易造成牵一发而动全身的效果

但是如果我们因为要遵守开闭原则,每次对功能进行修改的时候,都去新写一个类,这样的会很繁琐,所以我们的准则是:

  • 如果这个类是自己写的,自己修改不会影响该类在其他地方的效果(不会牵一发而动全身),那就可以随意修改
  • 如果这个类不是自己写的,自己不清楚修改后会带来什么样的影响,那就不要修改,要符合开闭原则

4、接口隔离原则

接口隔离原则也是满足一个字 ”“,将接口的功能尽可能的拆分

应该使用多个专门的接口,而不是使用单一的总接口

即客户端不应该依赖于那些它不需要的接口

举个栗子:现在设计一个动物的接口,统一动物的行为,可能会这样写

public interface Animal {
    void eat();
    void fiy(); 
    void swim(); 
}

这三个行为分别是 吃、飞和游泳,似乎并没有什么问题,但是动物这个接口太广了,并不是所有的动物都有着这三种行为

例如小狗的栗子:

public class Dog implements Animal {
    @Override
    public void eat() {
        System.out.println("小狗啃骨头");
    }
    @Override
    public void swim() {
        System.out.println("小狗会狗刨");
    }
    @Override
    public void fly() {
        throw new UnsupportedOperationException("小狗不会飞,你行你来");
    }
}

小狗并不具备飞的属性

正确的做法是将动物这个总接口拆分成多个单独的小接口

interface Eatable{
    void eat();
}

interface Swimable{
    void swim();
}

interface Flyable{
    void fly();
}

再不断的组合,实现不同的接口

核心思想还是高内聚,低耦合,通过不断组合不可分割的功能完成最终需要的功能

我们改进一下小狗的栗子

public class Dog implements Eatable, Swimable {
    @Override
    public void eat() {
        System.out.println("小狗啃骨头");
    }
    @Override
    public void swim() {
        System.out.println("小狗会狗刨");
    }
}

客户端依赖的接口中不应该存在他所不需要的方法。

如果某一接口太大导致这一情况发生,应该拆分这一接口,使用接口的客户端只需要知道它需要使用的接口及该接口中的方法即可。

5、依赖倒置原则

面向接口编程,依赖于抽象而不依赖于具体

  • 上层不应该依赖于下层
  • 它们都应该依赖于抽象

区分上下层的方法为:调用别的方法的就是上层,被调用的就是下层

举个栗子:人喂养动物

class Person {
    public void feed(Dog dog) {
        System.out.println("开始喂dog...");
    }
}

class Dog {
    public void eat() {
        System.out.println("狗啃骨头");
    }
}

------------------------------------------------------------
public class AppTest {
    public static void main(String[] args) {
        Person person = new Person();
        Dog dog = new Dog();
        person.feed(dog);
    }
}

上述代码好像并没有什么问题,但是设计模式是为了应对变化,现在变化来了,现在客户端Person不仅需要喂狗,还需要喂猫。

直接添加一个Cat

class Cat {
    public void eat() {
        System.out.println("小猫吃鱼");
    }
}
public class AppTest {
    public static void main(String[] args) {
        Person person = new Person();
        Dog dog = new Dog();
        Cat cat = new Cat();
        // 喂狗
        person.feed(dog);
        // 喂猫
        person.feed(cat);
    }
}

这样明显会报错,因为之前的代码中只能喂狗,不能喂猫!
在这里插入图片描述

那怎么办呢?我直接重载一个方法,让Person类可以喂猫不就好了?

class Person {
    public void feed(Dog dog) {
        System.out.println("开始喂dog...");
    }
    public void feed(Cat dog) {
        System.out.println("开始喂Cat...");
    }
}

好家伙,这是不是为了应对变化直接改源码了?首当其冲的就是破坏了开闭原则,其次如果每次要多喂养一种动物就要去重载一个方法,似乎并不合理。

每当一个新的类需要依赖时,就要重载一个方法,这里就违反了依赖倒置原则,每当下层发生改变时,上层要一起改变(下层多个猫,上层要重载喂猫),这样的设计没有拓展性我们不应该依赖于具体的类,而应该依赖于抽象的接口!

我们聊回来,猫和狗都属于什么?是动物,狗和猫只是动物的实现,人应该去喂养动物,而不是具体的实现,所以我们应该进行依赖倒置,依赖抽象不依赖实现,这里我们只需要依赖一个抽象的动物类或者接口即可

class Person {
    public void feed(Animal animal) {
        System.out.println("开始喂动物...");
    }
}

interface Animal {
    void eat();
}

class Dog implements Animal{
    @Override
    public void eat() {
        System.out.println("狗啃骨头");
    }
}

class Cat implements Animal{
    @Override
    public void eat() {
        System.out.println("小猫吃鱼");
    }
}

---------------------------------------------------
public class AppTest {
    public static void main(String[] args) {
        Person person = new Person();
        Dog dog = new Dog();
        Cat cat = new Cat();
        // 喂狗
        person.feed(dog);
        // 喂猫
        person.feed(cat);
    }
}

看一下类图的变化

image-20221003003659847

这里有读者可能有疑问了?为什么是依赖倒置呢?

看类图,之前箭头是向下的,依赖于具体实现;之后大家都指向抽象,面向抽象编程,这就是依赖倒置。

6、迪米特法则(最少知道原则)

一个实体应当尽量少的与其他实体之间发生相互作用,使得系统功能模块相对独立

一个类,对于其他类,要知道的越少越好,封装的思想,封装内部细节,向外暴露提供功能的接口

只和朋友通讯,朋友是指:

  • 类中的字段
  • 方法的参数
  • 方法的返回值
  • 方法中实例化出来的对象
  • 对象本身
  • 集合中的泛型

我们来看一个栗子:现在有一个电脑,需要关闭它

class Compute {
    public void saveData() {
        System.out.println("正在保存数据");
    }

    public void killProcess() {
        System.out.println("正在关闭程序");
    }

    public void closeScreen() {
        System.out.println("正在关闭屏幕");
    }

    public void powerOff() {
        System.out.println("正在断电");
    }
}

class Person {
    Compute compute = new Compute();
    public void shutDownCompute() {
        compute.saveData();
        compute.killProcess();
        compute.closeScreen();
        compute.powerOff();
    }
}

好像没有什么问题,

但对于用户来说,知道的细节太多了,要是不小心搞错了步骤,那岂不是玩完?所以他不想知道关闭电脑的具体步骤,只想按一下按钮(封装)就好了

我们改一下上面的代码:

class Compute {
    private void saveData() {
        System.out.println("正在保存数据");
    }

    private void killProcess() {
        System.out.println("正在关闭程序");
    }

    private void closeScreen() {
        System.out.println("正在关闭屏幕");
    }

    private void powerOff() {
        System.out.println("正在断电");
    }
	//封装细节
    public void shutDownCompute() {
        this.saveData();
        this.killProcess();
        this.closeScreen();
        this.powerOff();
    }
}

class Person {
    Compute compute = new Compute();

    public void shutDown() {
        compute.shutDownCompute();
    }
}

那么对于朋友而言的最少知道原则是什么呢?

  • 如果对于作为返回类型、方法参数、成员属性、局部变量的类,不需要过多的封装,应该提供应有的细节,由调用者自己弄清楚细节并承担异常的后果,这样由我们直接创造的对象,我们就能把它称为我们的朋友

  • 但是如果这个对象不是我们自己获得的,而是由被人提供的,就不是朋友,即朋友的朋友并不是自己的朋友

public class AppTest {
    public void func() {
        AppBean appBean = BeanFactory.getAppBean();
        // 朋友的朋友就不是朋友了
        appBean.getStr();
    }

}

class BeanFactory {
    public static AppBean getAppBean() {
        return new AppBean();
    }
}

class AppBean {
    public String getStr() {
        return "";
    }
}

那么想要和这个AppBean做朋友该怎么办呢?比如给它转换成方法参数

public class AppTest {
    public void func() {
        AppBean appBean = BeanFactory.getAppBean();
        // 朋友的朋友就不是朋友了
        this.getStr(appBean);
    }
    /* 将朋友的朋友的细节转换为自己熟悉的方法 */
    public String getStr(AppBean appBean){
        return appBean.getStr();
    }
}

相信很多同学看到这里很谜,这不是制造了很多小方法吗?确实迪米特法则的缺点就是如此,在系统里造出大量的小方法,这些方法仅仅是传递间接的调用,与系统的业务逻辑无关。所以在开发中适当的违反一下也是可以的。

因此,前人总结出一些方法论以供我们参考:

  1. 优先考虑将一个类设置成不变类。
  2. 尽量降低一个类的访问权限。
  3. 谨慎使用Serializable
  4. 尽量降低成员的访问权限。

虽然规矩很多,但是理论需要深刻理解,实战需要经验积累。路还很长。

7、里式替换原则

任何能够使用父类对象的地方,都应该能透明的替换为子类

也就是说:子类对象能够随时随地替换父类对象,并且替换完之后,语法不会报错,业务逻辑也不会出现问题

我们先聊一下方法重写的定义:

  • 在子类和父类中,出现了返回类型相同、方法名相同、方法参数相同的方法时,构成了方法重写。

方法重写的两个限制:

  1. 子类重写父类的方法时,子类方法的访问修饰符不能比父类更严格
  2. 子类重写父类的方法时,子类方法不能抛出比父类更多的异常

为什么要有这两个限制呢?

就是为了保证代码符合里氏替换原则

举个栗子:
在这里插入图片描述

正常情况下,如果子类抛出的异常比父类少,父类在执行方法时就会进行catch,并且能够捕获子类中的异常,所以这样进行替换时,就不会影响代码的结构,做到透明、无感知

有很多的例子都可以用里式替换进行解释,著名的例子有:长方形正方形问题

接下来我们具体看看长方形正方形的问题,先来回顾下继承方面的知识

继承的作用:

  • 提高代码重用性
  • 多态的前提

两个类能发生继承关系的依据是什么?

  • 先看两个类有咩有” is a “ 关系
  • 在两个类有了 is a 关系之后,还要考虑子类对象在替换了父类对象之后,业务逻辑是否发生变化。如果变化,就不能发生继承关系

正方形和长方形是 is a 关系,那么我们能不能让正方形类直接去继承长方形类呢?

答案是不能,为什么呢?因为还要考虑具体的业务场景,看看在具体的业务场景下,正方形替换了长方形之后,业务逻辑是否变化

举个栗子:

public class AppTest {
    //长方形
    @Getter
    @Setter
    static class Rectangular {
        private Integer width;
        private Integer length;
    }
    //正方形
    static class Square extends Rectangular {
        private Integer sideWidth;

        @Override
        public Integer getWidth() {
            return sideWidth;
        }

        @Override
        public void setWidth(Integer width) {
            this.sideWidth = width;
        }

        @Override
        public Integer getLength() {
            return sideWidth;
        }

        @Override
        public void setLength(Integer length) {
            this.sideWidth = length;
        }
    }

    static class Utils{
        public static void transform(Rectangular graph){
            while ( graph.getWidth() <= graph.getLength() ){
                graph.setWidth(graph.getWidth() + 1);
                System.out.println("长:"+graph.getLength()+" : " +
                        "宽:"+graph.getWidth());
            }
        }
    }

    public static void main(String[] args) {
        // Rectangular graph = new Rectangular();
        Rectangular graph = new Square();
        graph.setWidth(20);
        graph.setLength(30);
        Utils.transform(graph);
    }
}

替换后运行将是无限死循环。

要知道,在向上转型的时候,方法的调用只和new的对象有关,才会造成不同的结果。在使用场景下,需要考虑替换后业务逻辑是否受影响。

由此引出里氏替换原则的使用需要考虑的条件:

  • 是否有is-a关系
  • 子类可以扩展父类的功能,但是不能改变父类原有的功能。

鸵鸟非鸟问题

  • 在我们看来,鸵鸟属于鸟科,但是现在有个需求是送信(飞鸽传书),这个业务场景能将鸵鸟(子类)替换为鸟(父类)吗?鸵鸟不会飞,所以这显然是不可以的。

8、组合优于继承

复用别人的代码时,不宜使用继承,应该使用组合。

  • 组合,是一种强关联关系,整体对象和局部对象的生命周期是一样的,类似于大雁和翅膀的关系
    • 整体对象负责局部对象的生命周期
    • 局部对象不能被其他对象共享;
    • 如果整体对象被销毁或破坏,那么局部对象也一定会被销毁或破坏
  • 聚和,它是一种弱关联,是 【整体和局部】之间的关系,且局部可以脱离整体独立存在,类似于雁群和其中一只大雁的关系
    • 代表局部的对象有可能会被多个代表整体的对象所共享,而且不一定会随着某个代表整体的对象被销毁或破坏而被销毁或破坏,甚至代表局部的对象的生命周期可以超越整体

总而言之,组合是值的关联(Aggregation by Value),而聚合是引用的关联(Aggregation by Reference)

实心菱形的是组合空心菱形的是聚和,如果不区分就用虚线指向,组合是作为成员变量作为另一个类的引用,聚和是作为形参或者局部变量作为另一个类的引用

image-20221003000126551

组合大家在平时编码的时候一定经常使用,举一个简单的例子,如果我们现在要有链表实现队列应该怎么做呢?队列的特点就是先进先出,完全可以用链表实现,我们可以用继承关系来做:

public class Queue <E> extends LinkedList<E> {
    /**
     * 入队
     */
    public void enQueue(E element){
        this.add(element);
    }

    /**
     * 出队
     */
    public E deQueue(){
        return this.remove(0);
    }

}

似乎并没有什么问题,队列类继承自链表类,并暴露自己提供给外界的方法,但是当我们调用这个Queue时就会发现问题:

image-20221003154018680

好家伙,我的Queue本来只需要入队和出队两个方法,但是居然有这么多细节的方法供我使用,这就违背了迪米特法则,一个类的内部实现应该不要提供给外界,只暴露该提供的方法,这就是继承的问题,继承复用破坏包装,因为继承将基类的实现都暴露给派生类

如果我们换成组合该怎么做呢?

public class Queue<E> {
    // 成员变量 -> 组合关系
    LinkedList<E> list = new LinkedList<>();
    /**
     * 入队
     */
    public void enQueue(E element) {
        list.add(element);
    }

    /**
     * 出队
     */
    public E deQueue() {
        return list.remove(0);
    }
}

所以如果我们仅仅只是为了复用代码,可以优先考虑组合,如果是为了实现多态,可以优先继承

我们也来看一个反例叭,其实在Java中有很多不合理的设计,例如Serializable接口,Date类等等,这里就讲一个java.util.Stack的糟糕设计

image-20221003160116593

点进源码中看我们发现,原来是继承了Vector类,让其拥有了链表的能力,看着这个兄弟设计模式也没学好

image-20221003160243437

官方也注意到了这个设计不合理的地方,推荐我们使用Deque来实现栈

image-20221003160720506

其实我们看完了这些设计原则,就会发现其实都是为了应对不断变化的,在看一些源码中,例如Spring的源码、dubbo的源码、netty的源码中也是非常严谨的遵守这些开发规范的。

本文部分内容参考了我老大的博文:设计模式学习(汇总版

大佬的文章写的太好了

关于设计模式,我们后面接着聊…

风语者!平时喜欢研究各种技术,目前在从事后端开发工作,热爱生活、热爱工作。