通俗易懂的讲解常用的设计原则及其实例

目录

概述

单一职责原则

开闭原则

里氏替换原则

依赖倒置原则

接口隔离原则

迪米特法则

合成/聚合复用原则


概述

设计原则可以提高代码的可扩展性,可维护性,可重用性,以及易理解性,易测试性,是大型项目中常常会使用的设计思想,此处介绍几个常用的设计原则及其实例。

  1. 单一职责原则 (Single Responsibility Principle, SRP):一个类或者一个模块只负责完成一个功能或者任务

  2. 开闭原则 (Open/Closed Principle, OCP):软件实体应该对扩展开放,对修改关闭。

  3. 里氏替换原则 (Liskov Substitution Principle, LSP):子类应该可以替换掉父类并且程序能够正确的执行子类继承父类时,除添加新的方法完成新增功能外,尽量不要重写父类的方法。

  4. 依赖倒置原则 (Dependency Inversion Principle, DIP):程序要依赖于抽象接口,不要依赖于具体实现。简单的说就是要求对抽象进行编程,不要对实现进行编程

  5. 接口隔离原则 (Interface Segregation Principle, ISP):一个类不应该依赖它不需要的接口,这要求我们将大接口拆分成多个小接口,从而避免客户端代码依赖于它不需要使用的接口,

  6. 迪米特法则 (Law of Demeter, LoD):一个对象应该对其他对象有尽可能少的了解,只与其直接的朋友(成员变量、方法参数、方法返回值等)通信,而不与陌生的对象发生直接的联系。

  7. 合成/聚合复用原则 (Composite/Aggregation Reuse Principle, CARP):应该尽量使用合成/聚合关系来实现对象之间的复用关系,而不是使用继承关系来实现。

单一职责原则

单一职责原则(Single Responsibility Principle,SRP)是指一个类或者模块只负责完成一个功能或者任务。该原则的核心思想是将一个类或者模块的职责尽可能的分解,以提高代码的可维护性、可扩展性和可重用性。

举个例子,假设我们有一个名为“Product”的类,它负责存储商品的名称、价格和描述信息,并提供方法用于计算折扣价格和保存商品信息到数据库中。这个类承担了太多的职责,因此会导致代码的可维护性和可扩展性变得非常困难

为了解决这个问题,我们可以将“Product”类拆分为两个更小的类:一个类负责存储商品的基本信息(例如名称、价格和描述),另一个类负责计算折扣价格和将商品信息保存到数据库中。这样,每个类只负责完成一个特定的任务,从而提高了代码的可维护性、可扩展性和可重用性。

另外一个例子是,假设我们有一个名为“Logger”的类,它负责记录应用程序中的所有日志信息。然而,随着应用程序变得越来越复杂,该类变得越来越大并且难以维护。为了解决这个问题,我们可以将“Logger”类拆分为多个小的类,每个类负责记录不同类型的日志信息(例如错误日志、警告日志和信息日志等等)。这样,每个类只负责记录一个特定类型的日志信息,从而提高了代码的可维护性和可扩展性。

开闭原则

开闭原则(Open/Closed Principle,OCP)是指软件实体(类、模块、函数等)应该对扩展开放,对修改关闭这个原则的核心思想是通过对软件的扩展而不是修改来实现变化,以提高代码的可维护性、可扩展性和可重用性。

开闭原则的实现方法通常是通过使用抽象化和多态性来实现。通过将软件实体抽象化为一个接口或者抽象类,可以使得实体的行为可以通过多态性来变化,而无需对实体进行修改。

举个例子,假设我们有一个名为“Product”的类,它用于表示商品信息。该类具有一个名为“getPrice”的方法,用于获取商品的价格。现在,我们需要添加一个新的功能,允许用户在购买商品时使用不同的货币。按照开闭原则,我们不应该修改“Product”类来实现这个功能,而是应该扩展该类。

我们可以创建一个新的名为“CurrencyConverter”的类,该类负责将商品价格转换为指定货币的价格。我们还可以创建一个名为“ProductWithCurrency”的子类,该子类继承“Product”类(这样就实现了保留原有的功能并拓展新功能),并且增加了一个名为“getPriceWithCurrency”的方法,该方法使用“CurrencyConverter”类来计算指定货币的商品价格。现在,我们可以通过使用“ProductWithCurrency”类来实现新的功能,而无需对原来的“Product”类进行修改。

里氏替换原则

里氏替换原则(Liskov Substitution Principle,LSP)是指子类对象应该能够替换掉它们的父类对象,并且程序仍然可以正确地执行。这也是核心思想。通俗的讲就是:子类继承父类时,除添加新的方法完成新增功能外,尽量不要重写父类的方法。

  1. 里氏替换原则是实现开闭原则的重要方式之一。
  2. 它克服了继承中重写父类造成的可复用性变差的缺点。
  3. 它是动作正确性的保证。即类的扩展不会给已有的系统引入新的错误,降低了代码出错的可能性。

举个例子,假设我们有一个名为“Rectangle”的类,它有两个属性:宽度和高度,并且具有一个名为“getArea”的方法,用于计算矩形的面积。现在,我们想要创建一个名为“Square”的子类,该子类继承自“Rectangle”类,并且重写了“setWidth”和“setHeight”方法,使得宽度和高度总是相等。根据里氏替换原则,我们可以将“Square”对象替换成“Rectangle”对象,并且程序仍然可以正确执行。

然而,在这个例子中,“Square”类违反了里氏替换原则。因为“Square”类的行为与“Rectangle”类的行为不完全一致,这会导致程序中的一些操作不能正确执行。例如,如果我们调用“setWidth”方法来设置矩形的宽度,那么在“Square”对象中,高度也会被改变,这与“Rectangle”对象的行为不同。

为了遵循里氏替换原则,我们可以通过重新设计“Rectangle”和“Square”类来避免这个问题。具体来说,我们可以将“Rectangle”类改为一个名为“Shape”的抽象类,并且将“getArea”方法定义为抽象方法。然后,我们可以创建“Rectangle”和“Square”类,它们分别继承“Shape”类并且实现“getArea”方法,从而可以遵循里氏替换原则。

依赖倒置原则

 依赖倒置原则(Dependence Inversion Principle)是程序要依赖于抽象接口,不要依赖于具体实现。简单的说就是要求对抽象进行编程,不要对实现进行编程,这样就降低了客户与实现模块间的耦合。

  面向过程的开发,上层调用下层,上层依赖于下层,当下层剧烈变动时上层也要跟着变动,这就会导致模块的复用性降低而且大大提高了开发的成本。

  面向对象的开发很好的解决了这个问题,一般情况下抽象的变化概率很小,让用户程序依赖于抽象,实现的细节也依赖于抽象。即使实现细节不断变动,只要抽象不变,客户程序就不需要变化。这大大降低了客户程序与实现细节的耦合度。

通过依赖倒置,可以减少类与类之间的耦合性,提高系统的稳定性,提高代码的可读性和可维护性,并且能够降低修改程序所造成的风险。

先来看一个例子

可是依赖倒置原则是怎么做到的呢?我们先来看一个例子:一个爱学习的「我没有三颗心脏」同学现在正在学习「设计模式」和「Java」的课程,伪代码如下:

public class Wmyskxz {

    public void studyJavaCourse() {
        System.out.println("「我没有三颗心脏」同学正在学习「Java」课程");
    }

    public void studyDesignPatternCourse() {
        System.out.println("「我没有三颗心脏」同学正在学习「设计模式」课程");
    }
}

我们来模拟上层调用一下:

public static void main(String[] args) {
    Wmyskxz wmyskxz = new Wmyskxz();
    wmyskxz.studyJavaCourse();
    wmyskxz.studyDesignPatternCourse();
}

原因一:有效控制影响范围

由于「我没有三颗心脏」同学热爱学习,随着学习兴趣的 “暴增”,可能会继续学习 AI(人工智能)的课程。这个时候,因为「业务的扩展」,要从底层实现到高层调用依次地修改代码。

我们需要在 Wmyskxz 类中新增 studyAICourse() 方法,也需要在高层调用中增加调用,这样一来,系统发布后,其实是非常不稳定的。显然在这个简单的例子中,我们还可以自信地认为,我们能 Hold 住这一次的修改带来的影响,因为都是新增的代码,我们回归的时候也可以很好地 cover 住,但实际的情况和实际的软件环境要复杂得多。

最理想的情况就是,我们已经编写好的代码可以 “万年不变”,这就意味着已经覆盖的单元测试可以不用修改,已经存在的行为可以保证保持不变,这就意味着「稳定」。任何代码上的修改带来的影响都是有未知风险的,不论看上去多么简单。

原因二:增强代码可读性和可维护性

另外一点,你有没有发现其实加上新增的 AI 课程的学习,他们三节课本质上行为都是一样的,如果我们任由这样行为近乎一样的代码在我们的类里面肆意扩展的话,很快我们的类就会变得臃肿不堪,等到我们意识到不得不重构这个类以缓解这样的情况的时候,或许成本已经变得高得可怕了。

原因三:降低耦合

《资本论》中有这样一段描述:

在商品经济的萌芽时期,出现了物物交换。假设你要买一个 iPhone,卖 iPhone 的老板让你拿一头猪跟他换,可是你并没有养猪,你只会编程。所以你找到一位养猪户,说给他做一个养猪的 APP 来换他一头猪,他说换猪可以,但是得用一条金项链来换…

所以这里就出现了一连串的对象依赖,从而造成了严重的耦合灾难。解决这个问题的最好的办法就是,买卖双发都依赖于抽象——也就是货币——来进行交换,这样一来耦合度就大为降低了。

三、怎么做


我们现在的代码是上层直接依赖低层实现,现在我们需要定义一个抽象的 ICourse 接口,来对这种强依赖进行解耦(就像上面《资本论》中的例子那样):

通俗易懂的讲解常用的设计原则及其实例_第1张图片

接下来我们可以参考一下伪代码,先定一个课程的抽象 ICourse 接口:

public interface ICourse {
    void study();
}

然后编写分别为 JavaCourse 和 DesignPatternCourse 编写一个类:

public class JavaCourse implements ICourse {

    @Override
    public void study() {
        System.out.println("「我没有三颗心脏」同学正在学习「Java」课程");
    }
}

public class DesignPatternCourse implements ICourse {

    @Override
    public void study() {
        System.out.println("「我没有三颗心脏」同学正在学习「设计模式」课程");
    }
}

然后把 Wmyskxz 类改造成如下的样子:

public class Wmyskxz {

    public void study(ICourse course) {
        course.study();
    }
}

再来是我们的调用:

public static void main(String[] args) {
    Wmyskxz wmyskxz = new Wmyskxz();
    wmyskxz.study(new JavaCourse());
    wmyskxz.study(new DesignPatternCourse());
}

 

这时候我们再来看代码,无论「我没有三颗心脏」的兴趣怎么暴涨,对于新的课程,都只需要新建一个类,通过参数传递的方式告诉它,而不需要修改底层的代码。实际上这有点像大家熟悉的依赖注入的方式了。

总之,切记:以抽象为基准比以细节为基准搭建起来的架构要稳定得多,因此在拿到需求后,要面相接口编程,先顶层设计再细节地设计代码结构。

接口隔离原则

接口隔离原则(Interface Segregation Principle,ISP)是SOLID设计原则中的一条,它指出一个类不应该依赖于它不需要使用的接口。具体来说,ISP要求我们将大接口拆分成多个小接口,从而避免客户端代码依赖于它不需要使用的接口,减少接口之间的耦合性。

举个例子,假设我们正在设计一个文件上传的接口,这个接口包括上传文件、删除文件和获取文件列表等操作。如果我们将这些操作都放在同一个接口中,那么它可能会造成以下几个问题:

  1. 客户端代码需要实现接口中的所有方法,包括它不需要使用的方法,这会导致代码冗余和浪费。

  2. 如果接口中的某些方法发生变化,那么所有实现这个接口的类都需要进行相应的修改,这会带来不必要的麻烦和工作量。

为了解决这个问题,我们可以使用接口隔离原则。具体来说,我们可以将上传、删除和获取文件列表等操作分别抽象成独立的小接口,例如“Uploadable”、“Deletable”和“Listable”等。然后,我们可以让实现类根据自己的需要去实现这些小接口,而不需要强制实现不需要使用的接口。

这样一来,客户端代码就可以只依赖于它需要使用的接口,而不会受到其他不需要使用的接口的影响。同时,如果某个小接口的方法发生变化,那么只需要修改这个小接口的实现类即可,不需要修改其他接口的实现类,从而减少了代码的修改量和维护成本。

迪米特法则

迪米特法则(Law of Demeter,LoD),又称为最少知识原则(Principle of Least Knowledge,PoLK),是SOLID设计原则中的一条。它指出,一个对象应该对其他对象有尽可能少的了解,只与其直接的朋友(成员变量、方法参数、方法返回值等)通信,而不与陌生的对象发生直接的联系。简单来说,这个原则的核心思想是要尽量减少对象之间的耦合性

举个例子,假设我们正在设计一个订单管理系统。这个系统包含订单类、商品类、用户类等。现在,我们需要编写一个方法来查询某个订单的商品列表,并返回商品的名称、价格等信息。如果我们采用传统的面向实现编程方式,那么查询订单的方法可能会像这样:

public class Order {
    private List items;
    // ...
    public List getItems() {
        return items;
    }
}

public class Item {
    private Product product;
    private int quantity;
    // ...
    public Product getProduct() {
        return product;
    }
}

public class Product {
    private String name;
    private double price;
    // ...
    public String getName() {
        return name;
    }
    public double getPrice() {
        return price;
    }
}

public class OrderService {
    public List> getOrderItemList(Order order) {
        List> itemList = new ArrayList<>();
        List items = order.getItems();
        for (Item item : items) {
            Map itemMap = new HashMap<>();
            Product product = item.getProduct();
            itemMap.put("name", product.getName());
            itemMap.put("price", product.getPrice());
            itemMap.put("quantity", item.getQuantity());
            itemList.add(itemMap);
        }
        return itemList;
    }
}

上面的代码中,我们的OrderService类直接依赖于Order、Item、Product这些陌生的对象,并访问了这些对象的方法和属性。这样一来,如果这些对象的方法和属性发生变化,那么就需要修改OrderService类的代码,这会带来很多不必要的麻烦。

为了解决这个问题,我们可以采用迪米特法则。具体来说,我们可以在Order类中添加一个方法,用于返回商品的信息,例如:

public class Order {
    private List items;
    // ...
    public List> getItemInfoList() {
        List> itemList = new ArrayList<>();
        for (Item item : items) {
            Map itemMap = new HashMap<>();
            Product product = item.getProduct();
            itemMap.put("name", product.getName());
            itemMap.put("price", product.getPrice());
            itemMap.put("quantity", item.getQuantity());
            itemList.add(itemMap);
        }
        return itemList;
    }
}

然后,在OrderService类中直接调用Order的getItemInfoList方法,而不需要直接访问Item和Product的方法和属性,例如:

public class OrderService {
    public List> getOrderItemList(Order order) {
        return order.getItemInfoList;
    }
}

这样一来,OrderService类就不再直接依赖于Item和Product这些陌生的对象,而是通过Order类来访问这些对象的信息。这样一来,如果Item和Product类的方法和属性发生变化,那么只需要修改Order类的代码即可,不需要修改OrderService类的代码,从而减少了代码的修改量和维护成本。

合成/聚合复用原则

合成/聚合复用原则(Composition/Aggregation Reuse Principle,CARP)是指在设计和实现软件系统时,应该尽量使用合成/聚合关系来实现对象之间的复用关系,而不是使用继承关系来实现。该原则是SOLID设计原则中的一个重要原则,它的目的是提高代码的可维护性、灵活性和可扩展性。

举例:

假设我们正在设计一个学校管理系统,其中包含学生、老师和课程三个类,我们需要实现一个获取学生选课情况的功能。我们可以通过传统的继承思想来实现学生和课程之间的关系,也可以使用合成/聚合复用原则来实现。

首先,我们来看一下使用传统的继承思想来实现的代码:

public class Person {
    private String name;
    private int age;
    // ...
}

public class Student extends Person {
    private List courses;

    // 构造函数、getters和setters方法
    // ...

    public void showCourses() {
        for (Course course : courses) {
            System.out.println(course.getName());
        }
    }
}

public class Teacher extends Person {
    private List courses;

    // 构造函数、getters和setters方法
    // ...

    public void showCourses() {
        for (Course course : courses) {
            System.out.println(course.getName());
        }
    }
}

public class Course {
    private String name;
    private String teacher;

    // 构造函数、getters和setters方法
    // ...
}

public class School {
    private List students;
    private List teachers;
    private List courses;

    // 构造函数、getters和setters方法
    // ...

    public void showStudentCourses(String studentName) {
        for (Student student : students) {
            if (student.getName().equals(studentName)) {
                student.showCourses();
                break;
            }
        }
    }
}

在上面的代码中,我们定义了一个Person类,表示一个人。Student类和Teacher类分别继承了Person类,并实现了showCourses方法,分别表示学生和老师。Course类表示一个课程。School类包含学生、老师和课程的列表,通过showStudentCourses方法来展示学生的选课情况。

接下来,我们来看一下使用合成/聚合复用原则来实现的代码:

public class Person {
    private String name;
    private int age;
    // ...
}

public class Student {
    private Person person;
    private List courses;

    public Student(Person person) {
        this.person = person;
        courses = new ArrayList<>();
    }

    // getters和setters方法
    // ...

    public void showCourses() {
        for (Course course : courses) {
            System.out.println(course.getName());
        }
    }
}

public class Teacher {
    private Person person;
    private List courses;

    public Teacher(Person person) {
        this.person = person;
        courses = new ArrayList<>();
    }

    // getters和setters方法
    // ...

    public void showCourses() {
        for (Course course : courses) {
            System.out.println(course.getName());
        }
    }
}

public class Course {
    private String name;
    private String teacher;

    // 构造函数、getters和setters方法
    // ...
}

public class School {
    private List students;
    private List teachers;
    private List courses;

    // 构造函数、getters和setters方法
    // ...

    public void showStudentCourses(String studentName) {
        for (Student student : students) {
            if (student.getPerson().getName().equals(studentName)) {
                student.showCourses();
                break;
            }
        }
    }
}

在上面的代码中,我们将Person类作为Student类和Teacher类的组成部分,通过构造函数来传入。在Student类和Teacher类中,我们维护了一个Person对象和一个Course对象的列表。在School类中,我们通过调用getPerson方法来获取学生的名字,并通过调用showCourses方法来展示学生的选课情况。

相比于传统的继承思想,合成/聚合复用原则具有以下优点:

  1. 更加灵活:使用合成/聚合复用原则可以避免继承层次结构的过度复杂,从而提高代码的灵活性和可维护性。

  2. 更加可扩展:使用合成/聚合复用原则可以将类的实现细节封装在类内部,从而提高代码的可扩展性和可重用性。

  3. 更加易于测试:使用合成/聚合复用原则可以将类的依赖关系通过接口明确地表达出来,从而更加易于测试和调试。

  4. 更加符合面向对象设计原则:合成/聚合复用原则遵循了“面向对象设计的基本原则之一:组合/聚合优于继承”的思想,从而使代码更加符合面向对象设计的原则和理念。

你可能感兴趣的:(数据库,java,服务器)