专栏首页戴言泛滥通过简单代码示例了解七大软件设计原则

通过简单代码示例了解七大软件设计原则

代码设计原则是我们平时编码时候的代码规范建议

良好的代码设计可以很大程度降低维护和功能扩展的成本代价

但是代码设计原则应该是我们尽量遵循和考虑的规范

并不是一定要求所有代码都遵循设计原则

我们要考虑人力、时间、成本、质量,不是刻意追求完美

要在适当的场景遵循设计原则

帮助我们设计出更加优雅的代码结构

有些原则之间是相辅相成的

有些可能会有一些冲突的地方

就像我们代码的注释或者开发文档

某种程度上是会影响开发的进度和效率的

但是对于代码维护和日后的更新

这简直是上帝的恩赐

本文档设计源码地址:https://gitee.com/daijiyong/SoftwareDesignPrinciples

1. 开闭原则

开闭原则(Open Closed Principle,OCP)由勃兰特·梅耶(Bertrand Meyer)在 1988 年的著作《面向对象软件构造》(Object Oriented Software Construction)中提出

软件实体应当对扩展开放,对修改关闭(Software entities should be open for extension,but closed for modification)

这里的软件实体包括以下几个部分:

  • 项目中划分出的模块
  • 类与接口
  • 方法

简单来说就是尽量实现在新增功能的时候不修改已经写好的代码

举个例子

实现一个商品的对象

可以先定义一个接口

其中实现一个商品的一些属性

编号、名称和价钱

public interface ICommodity {
    Integer getId();
    String getName();
    Double getPrice();
}

具体的商品类则实现这个接口

比如实现一个食品的商品类

/**
 * @author daijiyong
 */
public class FoodCommodity implements ICommodity {
    private Integer id;
    private String name;
    private Double price;
    public FoodCommodity(Integer id, String name, Double price) {
        this.id = id;
        this.name = name;
        this.price = price;
    }
    public Integer getId() {
        return this.id;
    }
    public String getName() {
        return this.name;
    }
    public Double getPrice() {
        return this.price;
    }
    @Override
    public String toString() {
        return "FoodCommodity{" +
                "id=" + id +
                ", name='" + name + '\'' +
                ", price=" + price +
                '}';
    }
}

但是在食物商品有个问题,就是容易过期

在快过期的时候为了促销,会打折

为了能够让商品的价格发生打折

我们可以直接在修改FoodCommodity里边的代码

新增一个成员变量或者直接改返回价格的代码

但是这样其实是有风险的

有可能会导致已经写好的代码出问题

那个更好的办法就是下边这个

新实现一个DiscountFoodCommodity 打折食物的类

并继承FoodCommodity

/**
 * @author daijiyong
 */
public class DiscountFoodCommodity extends FoodCommodity {
    private Double discount;
    public DiscountFoodCommodity(Integer id, String name, Double price, Double disount) {
        super(id, name, price);
        this.discount = disount;
    }
    public Double getDiscountPrice() {
        return super.getPrice() * discount;
    }
    public Double getDisount() {
        return discount;
    }
    @Override
    public String toString() {
        return "DiscountFoodCommodity{" +
                "id=" + getId() +
                ", name='" + getName() + '\'' +
                ", price=" + getDiscountPrice() +
                ", discount=" + getDisount() +
                '}';
    }
}

类图是这样的

这样我们就可以在不更改FoodCommodity任何代码的情况下实现一个打折的商品

/**
 * @author daijiyong
 */
public class OpenCloseTest {
    public static void main(String[] args) {
        // 一个鸡腿商品
        ICommodity commodity = new FoodCommodity(1, "鸡腿", 30D);
        System.out.println(commodity);
        // 打折鸡腿商品
        commodity = new DiscountFoodCommodity(1, "鸡腿", 30D, 0.5D);
        System.out.println(commodity);
        //拿到打折商品的原价
        System.out.println(commodity.getPrice());
    }
}

2. 依赖倒置原则

依赖倒置原则(Dependence Inversion Principle,DIP)是 Object Mentor 公司总裁罗伯特·马丁(Robert C.Martin)于 1996 年在 C++ Report 上发表的文章

依赖倒置原则的原始定义为:高层模块不应该依赖低层模块,两者都应该依赖其抽象

抽象不应该依赖细节,细节应该依赖抽象

其核心思想是:要面向接口编程,不要面向实现编程

依赖倒置原则是实现开闭原则的重要途径之一

它降低了客户与实现模块之间的耦合

由于在软件设计中,细节具有多变性,而抽象层则相对稳定

因此以抽象为基础搭建起来的架构要比以细节为基础搭建起来的架构要稳定得多

这里的抽象指的是接口或者抽象类,而细节是指具体的实现类

使用接口或者抽象类的目的是制定好规范和契约,而不去涉及任何具体的操作,把展现细节的任务交给它们的实现类去完成

比如我们想实现一个售货员卖商品的功能

首先定义一个售货员类

然后写一个方法,他可以卖香皂

/**
 * @author daijiyong
 */
public class Salesperson {
    /**
     * 卖香皂
     *
     * 不推荐这么设计
     *
     */
    public void saleSoapCommodity() {
        System.out.println("香皂被卖出去了");
    }
}

但是这么设计有一个问题

如果想让他可以卖香蕉

则需要再在上边的类中新加一个售卖香蕉的方法

    /**
     * 卖香蕉
     *
     * 不推荐这么设计
     *
     */
    public void saleBananaCommodity() {
        System.out.println("香蕉被卖出去了");
    }

如果要卖苹果,还要再加一个卖苹果的方法

这样修改代码容易造成意想不到的风险

如果采取依赖倒置的思想

将商品售卖的功能放到商品中

售货员只实现一个售卖东西(任何商品)的方法就可以了

首先定义一个商品接口

/**
 * @author daijiyong
 */
public interface ICommodity {
    void sale();
}

然后实现一个肥皂商品

/**
 * @author daijiyong
 */
public class SoapCommodity implements ICommodity {
    public void sale() {
        System.out.println("香皂被卖出去了");
    }
}
/**
 * @author daijiyong
 */
public class Salesperson {
    /**
     * 售货员售卖商品
     *
     * 推荐这么设计
     *
     * @param commodity 商品
     */
    public void saleCommodity(ICommodity commodity) {
        commodity.sale();
    }

}

这样在新增商品的时候

只需要将商品交给售货员就行

不需要修改任何底层代码

/**
 * @author daijiyong
 */
public class DependencyInversionTest {
    public static void main(String[] args) {
        //定义一个售货员
        Salesperson salesperson = new Salesperson();
        // 卖香皂
        salesperson.saleCommodity(new SoapCommodity());
        // 卖香蕉
        salesperson.saleCommodity(new BananaCommodity());
    }
}

实际上这是一种大家非常熟悉的方式,叫依赖注入

通过方法传参的方式实现的

3. 单一职责原则

单一职责原则(Single Responsibility Principle,SRP)又称单一功能原则

由罗伯特·C.马丁(Robert C. Martin)于《敏捷软件开发:原则、模式和实践》一书中提出的

这里的职责是指类变化的原因

单一职责原则规定一个类应该有且仅有一个引起它变化的原因

否则类应该被拆分

该原则提出对象不应该承担太多职责,如果一个对象承担了太多的职责,至少存在以下两个缺点:

一个职责的变化可能会削弱或者抑制这个类实现其他职责的能力

当客户端需要该对象的某一个职责时,不得不将其他不需要的职责全都包含进来,从而造成冗余代码或代码的浪费。

简单来说就是一个 类/接口/方法只负责一项职责

比如更新用户信息的方法

/**
 * @author daijiyong
 */
public class UserService {
    private String userName;
    private String sex;
    private Integer age;
    private String password;
    /**
     * 更新用户密码
     * <p>
     * 更新用户密码也是使用比较频繁的一个功能
     * 将这种方式单独拿出来
     *
     * @param password 用户密码
     */
    public void updatePassword(String password) {
        // TODO 操作数据库,单独更新用户密码
    }
    /**
     * 更新用户所有信息
     * <p>
     * 如果仅仅有这种方式的话
     * 将来我们如果想单独对密码或者其他信息做处理
     * <p>
     * 可能产生冲突的问题
     * 两种方式结合使用,可以减少维护的难度
     *
     * @param userName 用户名
     * @param sex      用户性别
     * @param age      用户年龄
     * @param password 用户密码
     */
    public void updateUserInfo(String userName, String sex, Integer age, String password) {
        // TODO 操作数据库,将所有信息都更新
    }
}

可以再单独写一个更新密码的方法

一般更新密码需要单独处理,单独解耦更好

而同时更新多个信息的方法也留着

不然单独拆出来多个方法又显得累赘

4. 接口隔离原则

接口隔离原则(Interface Segregation Principle,ISP)要求程序员尽量将臃肿庞大的接口拆分成更小的和更具体的接口,让接口中只包含客户感兴趣的方法

2002 年罗伯特·C.马丁给“接口隔离原则”的定义是

客户端不应该被迫依赖于它不使用的方法(Clients should not be forced to depend on methods they do not use)

该原则还有另外一个定义

一个类对另一个类的依赖应该建立在最小的接口上(The dependency of one class to another one should depend on the smallest possible interface)

以上两个定义的含义是:要为各个类建立它们需要的专用接口,而不要试图去建立一个很庞大的接口供所有依赖它的类去调用

接口隔离原则和单一职责都是为了提高类的内聚性、降低它们之间的耦合性,体现了封装的思想,但两者是不同的:

单一职责原则注重的是职责

而接口隔离原则注重的是对接口依赖的隔离

单一职责原则主要是约束类,它针对的是程序中的实现和细节

接口隔离原则主要约束接口,主要针对抽象和程序整体框架的构建

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

这个原则跟单一设计原则有些类似

比如我们为了在设置动物接口的时候

可以直接将动物的吃、飞、游的属性都定义到接口中

然后具体的鸟和狗都实现这个动物接口

类图如下图所示

但是这样会导致一个问题

就是小鸟一般不会游泳,但是它也会实现这个方法

狗不会飞,但是也会实现飞的方法

这样给代码的维护和使用都会造成一定的障碍

如果我们按照接口隔离原则设计

将游泳、吃、飞的方法分别用三个动物接口实现

狗和鸟分别实现其中对中的多个接口

则不仅代码结构清晰,维护起来也会非常方便

5. 迪米特法则

迪米特法则(Law of Demeter,LoD)又叫作最少知识原则(Least Knowledge Principle,LKP)

产生于 1987 年美国东北大学(Northeastern University)的一个名为迪米特(Demeter)的研究项目

由伊恩·荷兰(Ian Holland)提出,被 UML 创始者之一的布奇(Booch)普及

后来又因为在经典著作《程序员修炼之道》(The Pragmatic Programmer)提及而广为人知

迪米特法则的定义是:只与你的直接朋友交谈,不跟“陌生人”说话(Talk only to your immediate friends and not to strangers)

其含义是:如果两个软件实体无须直接通信,那么就不应当发生直接的相互调用

可以通过第三方转发该调用。其目的是降低类之间的耦合度,提高模块的相对独立性

迪米特法则中的“朋友”是指:当前对象本身、当前对象的成员对象、当前对象所创建的对象、当前对象的方法参数等,这些对象同当前对象存在关联、聚合或组合关系,可以直接访问这些对象的方法

但是,过度使用迪米特法则会使系统产生大量的中介类,从而增加系统的复杂性

使模块之间的通信效率降低

所以,在釆用迪米特法则时需要反复权衡

确保高内聚和低耦合的同时,保证系统的结构清晰

比如校园招聘工作

这里边应该包含学生、HR、用人部门三个角色

但是用人部门不会直接招聘

是通过向HR提交人才需求

通过HR作为中转与学生进行沟通

/**
 * @author daijiyong
 */
public class DemeterTest {
    public static void main(String[] args) {
        HumanResources humanResources = new HumanResources();
        Department departmentA = new Department();

        departmentA.recruit(humanResources);
        //另外一个部门也要招人
        Department departmentB = new Department();
        departmentB.recruit(humanResources);
    }
}

类图如图所示

具体用人的部门根本不需要直接跟学生接触

所有招聘工作都是通过HR完成

6. 里氏替换原则

里氏替换原则(Liskov Substitution Principle,LSP)由麻省理工学院计算机科学实验室的里斯科夫(Liskov)女士在 1987 年的“面向对象技术的高峰会议”(OOPSLA)上发表的一篇文章《数据抽象和层次》(Data Abstraction and Hierarchy)里提出来的

她提出:继承必须确保超类所拥有的性质在子类中仍然成立(Inheritance should ensure that any property proved about supertype objects also holds for subtype objects)

里氏替换原则主要阐述了有关继承的一些原则

也就是什么时候应该使用继承,什么时候不应该使用继承以及其中蕴含的原理

里氏替换原是继承复用的基础,它反映了基类与子类之间的关系

是对开闭原则的补充,是对实现抽象化的具体步骤的规范

通俗来讲就是

子类可以扩展父类的功能,但不能改变父类原有的功能

也就是说:子类继承父类时,除添加新的方法完成新增功能外,尽量不要重写父类的方法

如果程序违背了里氏替换原则,则继承类的对象在基类出现的地方会出现运行错误

这时其修正方法是:取消原来的继承关系,重新设计它们之间的关系

子类可以实现父类的抽象方法,但不能覆盖父类的非抽象方法,子类中可以增加自己特有的方法

当子类的方法重载父类的方法时,方法的前置条件(即方法的输入/入参)要比父类方法的输入参数更宽松

当子类的方法实现父类的方法时(重写/重载或实现抽象方法),方法的后置条件(即方法的输出/返回值)要比父类更严格或相等

比如鸟一般都会飞行

如燕子的飞行速度大概是每小时 120 千米

但是几维鸟由于翅膀退化无法飞行

假如要设计一个实例,计算这两种鸟飞行 300 千米要花费的时间

由于几维鸟不会飞,子类修改了父类的speed方法,写死为0

拿燕子来测试这段代码,结果正确,能计算出所需要的时间

拿几维鸟来测试,结果会发生“除零异常”或是“无穷大”

/**
 * @author daijiyong
 */
public class Bird {
    double flySpeed;
    public void setSpeed(double speed) {
        flySpeed = speed;
    }
    public double getFlyTime(double distance) {
        return (distance / flySpeed);
    }
}
/**
 * @author daijiyong
 */
public class BrownKiwi extends Bird {
    /**
     *  重写设置速度的方法
     *  设置速度的时候,默认设置为0
     * @param speed
     */
    @Override
    public void setSpeed(double speed) {
        flySpeed = 0;
    }
}
/**
 * @author daijiyong
 */
public class LSPTest {
    public static void main(String[] args) {
        Bird bird1 = new Swallow();
        Bird bird2 = new BrownKiwi();
        bird1.setSpeed(120);
        bird2.setSpeed(120);
        System.out.println("假设飞了120公里:");
        System.out.println("燕子将飞行" + bird1.getFlyTime(120) + "小时.");
        System.out.println("几维鸟将飞行" + bird2.getFlyTime(120) + "小时。");
    }
}

这段代码的问题就在于直接修改了父类的方法

怎么解决这个问题呢,最好的办法的就是取消这种集成关系的设计

可以这么设计

7. 合成复用原则

合成复用原则(Composite Reuse Principle,CRP)又叫组合/聚合复用原则(Composition/Aggregate Reuse Principle,CARP)

它要求在软件复用时,要尽量先使用组合或者聚合等关联关系来实现

其次才考虑使用继承关系来实现

继承我们叫做白箱复用,相当于把所有的实现细节暴露给子类

组合/聚合也称之为黑箱复用,对类以外的对象是无法获取到实现细节的

如果要使用继承关系,则必须严格遵循里氏替换原则

合成复用原则同里氏替换原则相辅相成的,两者都是开闭原则的具体实现规范

这个比较好理解,如果我们在实现service层功能时

不会直接继承dao层的实现类

/**
 * @author daijiyong
 */
public class Service extends Dao {
    public void updateInfo() {
        super.update();
    }
}

而是通过组合的方式,在Service中定义个dao的对象

/**
 * @author daijiyong
 */
public class Service2 {
    Dao dao = new Dao();


    public void updateInfo() {
        dao.update();
    }
}

文/戴先生@2020年7月5日

---end---

本文分享自微信公众号 - 你好戴先生(Hello_MrDai),作者:你好戴先生

原文出处及转载信息见文内详细说明,如有侵权,请联系 yunjia_community@tencent.com 删除。

原始发表时间:2020-07-05

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

我来说两句

0 条评论
登录 后参与评论

相关文章

  • 【设计模式系列(三)】彻底搞懂原型模式

    原型模式(Prototype Pattern)是用于创建重复的对象,同时又能保证性能

    你好戴先生
  • 各位相加,直到得到一个一位的整数

    从1开始,结果依次是1,2,3,4,5,6,7,8,9,1,2,3,4,5,6,7,8,9····

    你好戴先生
  • 【设计模式系列(二)】彻底搞懂单例模式

    概念:单例模式(Singleton Pattern)是指确保一个类在任何情况下都绝对只有一个实例,并提供一个全局访问点

    你好戴先生
  • Java面向对象之抽象类,接口

    抽象类: 含有抽象方法的类被声明为抽象类 抽象方法由子类去实现 含有抽象方法的类必须被声明为抽象类 抽象类被子类继承,子类(如果不是抽象类)必须重写抽象类中...

    二十三年蝉
  • 依赖注入容器-- Autofac

    Autofac---Autofac是一款IOC框架,比较于其他的IOC框架,如Spring.NET,Unity,Castle等等所包含的,它很轻量级性能上非常高...

    小世界的野孩子
  • 自定义注解与常用设计模式

    注解分为:内置注解,自定义注解。内置注解就是JDK 自带的,而自定义注解则是自己定义的比如许多框架(spring) 用到的

    斯文的程序
  • 设计模式学习 - 工厂模式

    根据不同的对象,提供不同的工厂,然后由客户端来选择对应的工厂。这也是与简单工厂模式的不同的地方。

    许杨淼淼
  • PHP反射类export方法详细解析

    CrazyCodes
  • 初探设计模式六大原则

    我想用贴近生活的语句描述一下自己对六种原则的理解。也就是不做专业性的阐述,而是描述一种自己学习后的理解和感受,因为能力一般而且水平有限,也许举的例子不尽妥当,还...

    外婆的彭湖湾
  • 转载 Java设计模式

    设计模式; 一个程序员对设计模式的理解: “不懂”为什么要把很简单的东西搞得那么复杂。后来随着软件开发经验的增加才开始明白我所看到的“复杂”恰恰就是设计模式的精...

    用户1518699

扫码关注云+社区

领取腾讯云代金券