设计模式之禅(一) —— 六大设计原则

1.1 单一职责原则

单一职责原则:Singel Responsibility Principle,SRP
单一职责原则的定义:应该有且仅有一个原因引起类的变更。

书中提到一个例子:对电话的抽象。


电话类图.png

继续细化,向下拆分,向上抽象

电话通话时可以抽象出4个过程:拨号、通话、回应、挂机。
在举着个例子的时候,作者提到大部分人可能都会说这个没什么问题,动作定义比较清晰。其实如果更深层的了解电话的结构,应该可以对电话类进行一个更完整的抽象。
比如:电话的通话过程,是否是自始至终都是这四个阶段,在发展过程中会不会增加或者减少。如果以阶段为维度来进行抽象,是否会出现经常变更的情况。如果换一个维度,如:从更底层职责的角度来进行抽象,会抽象出“协议管理”和“数据传输” 两个角度,无论中间阶段发生什么变化,这两个职责,是一个电话必须拥有的。
所以我们在对业务模型进行抽象定义时,也需要尽量的事无巨细的了解业务的模型,然后再做分析。

回归原则定义

原则定义:应该有且仅有一个原因引起类的变更。
上面的接口并不是“只有一个原因引起变化”的。IPhone 不只是只有一个职责,它包含两个职责:

  • 协议管理:dial() 和hangup() 方法负责拨号接通和挂机
  • 数据传送:chat() 实现数据传送,把话转换成信号在双方之间传递

这两个职责都会引起这个接口或者实现类的变化:

日常习惯

比如一个用户信息接口:


image.png

这个接口有一个“修改用户信息”的方法。这个方法太过于笼统,一个方法承担了多个职责,这样的接口虽然对上层来说只提供了一个接口,但是它的职责并不是单一的。这样做需要在接口文档上做额外的注释,说明这个修改接口都可以修改哪些信息,操作参数什么情况需要传什么样的值。在《代码整洁之道》中建议过:提供好的注释,不如将代码写的别人一看就明白,无需注释。这里也一样,一个好的接口的定义,不需要文档中做长篇大论的调用说明。与其做一堆说明,不如在定义接口的时候,定义的清晰易懂。


image.png

这样定义会对上层更友好一点,将修改用户信息拆解为多个方法,每个方法只负责一件事,别人一看就知道,那个方法改的是什么,这个接口每个方法都能修改什么,清晰完整。

实际开发

虽然一直说要按照SRP的原则去进行设计,但毕竟理论是理论,实践是实践。在实际开发过程中,有很多因素导致最终无法达到按照SRP原则的最终效果,比如开会讨论业务模型中职责的划分;又比如deadline比较紧急,没有足够的时间进行讨论和设计。一个行业的驱动最终还是业务,代码只是实现业务的工具,是轮子。一个功能,最低要求就是先能跑起来,完成功能。只是在一开始实现的时候,尽可能的去往 SRP 上靠,读者的建议是:

对于单一职责原则, 我的建议是接口一定要做到单一职责, 类的设计尽量做到只有一个原因引起变化。

1.2 里氏替换原则

里氏替换原则原则是在继承方面上的一个要求。它是针对继承的弊端而出现的一个原则。
继承的优点:

  • 共享代码,提供代码的重用性
  • 提高代码的扩展性
  • 提高产品或者项目的开放性

继承的缺点:

  • 继承是侵入性的,只要继承,就必须拥有父类的所有属性和方法
  • 增强了耦合性。当父类的内容修改时,需要考虑子类的修改,可能会造成大段代码需要重构

定义

Liskov Substitution Principle, LSP

所有引用父类的地方必须能透明的使用其子类的对象。

通俗的讲,只要父类出现的地方子类就可以出现,而且替换为子类也不会产生任何错误或者异常,使用者可能根本不需要知道是父类还是子类。但是反过来就不行,有子类的地方,无法用父类进行替换。

LSP 对良好的继承定义了一个规范,这个规范包含四层含义:

1. 子类必须完全实现父类的方法

书中的例子是“士兵和枪”的例子。当定义“ToyGun”时,由于 ToyGun 无法杀人,程序无法正常运行。原因是 ToyGun 无法完整的实现 shoot 功能。

在具体应用场景中就要考虑下面这个问题了: 子类是否能够完整地实现父类的业务, 否则就会出现像上面的拿枪杀敌人时却发现是把玩具枪的笑话

如果子类不能完整地实现父类的方法, 或者父类的某些方法在子类中已经发
生“畸变”, 则建议断开父子继承关系, 采用依赖、 聚集、 组合等关系代替承。

注意:在类中调用其他类时务必要使用父类或接口, 如果不能使用父类或接口, 则说明类的设计已经违背了LSP原则。

2. 子类可以有自己的个性

书中给出的例子是“狙击手使用狙击枪杀人”,表达的意思是:如果实例类型为子类,则子类无法强转成父类类型进行调用。

3. 覆盖或实现父类的方法是输入参数可以被放大

书中举出了一个例子,这个例子会导致“子类在没有覆写父类的方法的前提下,子类方法被执行了”。
这个例子中子类对于方法的定义就有问题:

public class Father {
    public Collection doSomething(Map map) {
        System.out.println("父类被执行...");
        return map.values();
    }
}

public class Son extends Father {
    //缩小输入参数范围
    public Collection doSomething(HashMap map) {
        System.out.println("子类被执行...");
        return map.values();
    }
}

public class Client {
    public static void invoker() {
        //有父类的地方就有子类
        Father f = new Father();
        HashMap map = new HashMap();
        f.doSomething(map);
    }

    public static void main(String[] args) {
        invoker();
    }
}

子类的doSomething方法,并不是覆盖,而是对从父类继承过来的doSomething方法的重载。在 Client 执行时,如果使用子类去替换,实际执行的将会是子类的 doSomething 方法。从而导致了“子类在没有覆写父类的方法的前提下,子类方法被执行了”。
注意,这里是有一个前提:“子类在没有覆写父类的方法的前提下

如果是子类在复写了父类的方法下,使用子类去替换父类,调用的实际是子类的方法,这样是ok的。但是上面却是没有复写到父类的方法。没有复写,而且输入参数的范围比父类的方法大,就会出现问题。

正确的例子是:

public class Father {
    public Collection doSomething(HashMap map) {
        System.out.println("父类被执行...");
        return map.values();
    }
}

public class Son extends Father {
    //缩小输入参数范围
    public Collection doSomething(Map map) {
        System.out.println("子类被执行...");
        return map.values();
    }
}

public class Client {
    public static void invoker() {
        //有父类的地方就有子类
        Father f = new Father();
        HashMap map = new HashMap();
        f.doSomething(map);
    }

    public static void main(String[] args) {
        invoker();
    }
}

这样执行,子类也没有复写到父类的方法,但是在Client中用子类去替换父类,实际执行的还是父类方法。
最根本的原因,就是 子类在定义同名方法时,输入参数的范围比父类更大

4. 覆盖或实现父类的方法是输出结果可以被缩小

书中分了两种情况:

  • 子类覆盖:返回值范围要小于等于父类的方法
  • 方法重载:这个无所谓,因为不会调用到该方法
    这个也比较好理解,目的是:
    让上层在调用目标方法后,在使用方法的返回值时不会出现不存在方法的现象。如果返回值是父类,而实际返回值类型是子类,这样没什么问题;如果反过来,就可能会出现问题,上层在调用返回值中的方法,有可能是子类独有的方法,而返回值类型是父类,会出现调用失败的现象。

总结:

遵守了这四个规范,也就相当于遵守了 LSP 原则

1.3 依赖倒置原则

依赖倒置原则的表现:

  • 模块间的依赖通过抽象发生,实现类之间不发生直接的依赖关系,其依赖关系是通过接口或抽象类产生的;
  • 接口或抽象类不依赖于实现类;
  • 实现类依赖接口或抽象类。

书中用“司机开车”的例子来说明。

image.png
public class Benz {
    //汽车肯定会跑
    public void run() {
        System.out.println("奔驰汽车开始运行...");
    }
}
public class Client {
    public static void main (String[] args)  {
        Driver zhangSan = new Driver();
        Benz benz = new Benz();
        //张三开奔驰车
        zhangSan.drive(benz);
    }
}

Driver 和 Benz 类都是实现类,Driver 强依赖 Benz 类。
如果将来需要司机去开 BMW,程序将无法完成。此处进行结构优化,对实现类进行抽象,解除强依赖关系。


image.png
public interface IDriver {
    //是司机就应该会驾驶汽车
    public void drive(ICar car);
}

public class Driver implements IDriver{
    //司机的主要职责就是驾驶汽车
    public void drive(ICar car){
        car.run();
    }
}

public interface ICar {
    //是汽车就应该能跑
    public void run();
}

public class Benz implements ICar{
    //汽车肯定会跑
    public void run(){
        System.out.println("奔驰汽车开始运行...");
    }
}

public class BMW implements ICar{
    //宝马车当然也可以开动了
    public void run(){
        System.out.println("宝马汽车开始运行...");
    }
}

public class Client {
    public static void main (String[] args)  {
        IDriver zhangSan = new Driver();
        ICar benz = new Benz();
        //张三开奔驰车
        zhangSan.drive(benz);

        //IDriver zhangSan = new Driver();
        //ICar bmw = new BMW();
        //张三开奔驰车
        //zhangSan.drive(bmw);
    }
}

总结一下依赖倒置的好处:

  • 在新增加低层模块时, 只修改了业务场景类, 也就是高层模块, 对其他低层模块如Driver类不需要做任何修改, 业务就可以运行, 把“变更”引起的风险扩散降到最低
  • 两个类之间有依赖关系, 只要制定出两者之间的接口( 或抽象类) 就可以独立开发了, 而且项目之间的单元测试也可以独立地运行

最佳实践

我们怎么在项目中使用这个规则呢:

  • 每个类尽量都有接口或抽象类, 或者抽象类和接口两者都具备
  • 变量的表面类型尽量是接口或者是抽象类
  • 任何类都不应该从具体类派生
  • 尽量不要覆写基类的方法
  • 结合里氏替换原则使用

1.4 接口隔离原则

定义

接口:

  • 类接口
  • 实例接口

隔离:

  • 客户端不应该依赖它不需要的接口
  • 类之间的依赖关系应该建立在最小的接口之上

接口隔离原则概括为一句话:

建立单一接口,接口尽量细化,同时接口中方法尽量少。

例子

书中给出的例子是“美女类”的例子


image.png

接口定义了一个美女:

public interface IPettyGirl {
    //要有姣好的面孔
    public void goodLooking();
    //要有好身材
    public void niceFigure();
    //要有气质
    public void greatTemperament();
}

接口存在的问题是:审美会随着时间的改变而改变。

接口IPettyGirl的设计是有缺陷的, 过于庞大了, 容纳了一些可变的因素。而我们却把这些特质都封装了起来, 放到了一个接口中, 封装过度了。

把原IPettyGirl接口拆分为两个接口, 一种是外形美的美女IGoodBodyGirl, 这类美女的特点就是脸蛋和身材极棒, 超一流, 但是没有审美素质, 比如随地吐痰, 文化程度比较低; 另外一种是气质美的美女IGreatTemperamentGirl, 谈吐和修养都非常高。把一个比较臃肿的接口拆分成了两个专门的接口, 灵活性提高了, 可维护性也增加了, 不管以后是要外形美的美女还是气质美的美女都可以轻松地通过PettyGirl定义。


修改后的星探寻找美女类图
/** 外表型美女 **/
public interface IGoodBodyGirl {
    //要有姣好的面孔
    public void goodLooking();
    //要有好身材
    public void niceFigure();
}

/** 气质型美女 **/
public interface IGreatTemperamentGirl {
    //要有气质
    public void greatTemperament();
}

/** 最标准的美女,拥有所有优点 **/
public class PettyGirl implements IGoodBodyGirl,IGreatTemperamentGirl {

    private String name;
    //美女都有名字
    public PettyGirl(String _name){
        this.name=_name;
    }

    //脸蛋漂亮
    public void goodLooking() {
        System.out.println(this.name + "---脸蛋很漂亮!");
    }

    //气质要好
    public void greatTemperament () {
        System.out.println(this.name + "---气质非常好!");
    }
    //身材要好
    public void niceFigure () {
        System.out.println(this.name + "---身材非常棒!");
    }
}

让客户端去依赖两个专用的接口,比去依赖一个综合的接口要更加灵活。

接口隔离原则的目的

接口隔离原则是对接口进行规范的约束

接口要尽量的小

这一点上面的例子已经体现了,如果一个接口已经存在了臃肿现象,它会影响一个正常的代码结构,一些不需要实现的方法强制去实现。要对接口进行细化。

接口要高内聚

高内聚 就是提高接口、 类、 模块的处理能力, 减少对外的交互。
具体到接口隔离原则就是, 要求在接口中尽量少公布public方法, 接口是对外的承诺, 承诺越少对系统的开发越有利, 变更的风险也就越少, 同时也有利于降低成本

定制服务

只提供访问者需要的方法

接口设计是有限度的:

  • 对接口的拆分也需要有度,根据接口隔离原则拆分接口时,首先必须满足单一职责原则
  • 接口的设计粒度越小, 系统越灵活, 这是不争的事实。 但是, 灵活的同时也带来了结构的复杂化, 开发难度增加, 可维护性降低。

如何衡量原子接口或原子类的划分:

  • 一个接口只服务于一个子模块或业务逻辑
  • 通过业务逻辑压缩接口中的public方法, 接口时常去回顾, 尽量让接口达到“满身筋骨肉”, 而不是“肥嘟嘟”的一大堆方法
  • 已经被污染了的接口, 尽量去修改, 若变更的风险较大, 则采用适配器模式进行转化处理
  • 了解环境, 拒绝盲从。 每个项目或产品都有特定的环境因素,深入了解业务逻辑

与单一职责原则的区别

接口隔离原则与单一职责的审视角度是不相同的, 单一职责要求的是类和接口职责单一, 注重的是职责, 这是业务逻辑上的划分, 而接口隔离原则要求接口的方法尽量少。

1.5 迪米特法则

定义

原则

对外公开的范围

一个类公开的public属性或方法越多, 修改时涉及的面也就越大, 变更引起的风险扩散也就越大,因此, 在设计时需要反复衡量:

  • 是否还可以再减少 public方法和属性
  • 是否可以修改为private、 package-private(包类型, 在类、 方法、 变量前不加访问权限, 则默认为包类型) 、 protected等访问权限
  • 是否可以加上final关键字等

成员的归属

如果一个方法或者属性放在本类中, 既不增加类间关系, 也对本类不产生负面影响, 那就放置在本类中

最佳实践

迪米特法则的核心观念就是类间解耦, 弱耦合,既做到让结构清晰, 又做到高内聚低耦合。

1.6 开闭原则

定义

对扩展开放, 对修改关闭, 其含义是说一个软件实体应该通过扩展来实现变化, 而不是通过修改已有的代码来实现变化

例子

书中用 “书店买书” 的例子来进行说明


image.png
/* 书籍接口 */
public interface IBook {
      //书籍有名称
      public String getName();
      //书籍有售价
      public int getPrice();
      //书籍有作者
      public String getAuthor();
}

/* 小说类 */
public class NovelBook implements IBook {
    //书籍名称
    private String name;
    //书籍的价格
    private int price;
    //书籍的作者
    private String author;
    //通过构造函数传递书籍数据
    public NovelBook(String _name,int _price,String _author){
        this.name = _name;
        this.price = _price;
        this.author = _author;
    }
    //作者是谁
    public String getAuthor() {
        return this.author;
    }
    //书籍叫什么名字
    public String getName() {
        return this.name;
    }
    //获得书籍的价格
    public int getPrice() {
        return this.price;
    }
}

/* 模拟业务流程类 */
public class BookStore {
    private final static ArrayList bookList = new ArrayList();
    //static静态模块初始化数据, 实际项目中一般是由持久层完成
    static{
        bookList.add(new NovelBook("天龙八部",3200,"金庸"));
        bookList.add(new NovelBook("巴黎圣母院",5600,"雨果"));
        bookList.add(new NovelBook("悲惨世界",3500,"雨果"));
        bookList.add(new NovelBook("金瓶梅",4300,"兰陵笑笑生"));
    }
    //模拟书店买书
    public static void main(String[] args) {
        NumberFormat formatter = NumberFormat.getCurrencyInstance();
        formatter.setMaximumFractionDigits(2);
        System.out.println("-----------书店卖出去的书籍记录如下: -----------");
        for(IBook book:bookList){
            System.out.println("书籍名称: " + book.getName()+"\t书籍作者: "
            book.getAuthor()+"\t书籍价格: "+ formatter.format (book.getPrice()/100.0)+"元");
        }
    }
}

此时需求增加,需要对打折的书籍的价格进行特殊调整。

  • 打折行为只会出现在打折书籍中,并不存在于所有书籍。所以不能改动 IBook 接口;
  • 例如采购书籍人员也是要看价格的, 由于该方法已经实现了打折处理价格, 因此采购人员看到的也是打折后的价格, 会因信息不对称而出现决策失误的情况。 因此, 该方案也不是一个最优的方案。(说来惭愧,书上的这一段我没咋明白作者想表达的意思...)

此时需要构造一个新的类作为 NovelBook 的子类


image.png
public class OffNovelBook extends NovelBook {
    public OffNovelBook(String _name,int _price,String _author){
        super(_name,_price,_author);
    }
    //覆写销售价格
    @Override
    public int getPrice () {
        //原价
        int selfPrice = super.getPrice();
        int offPrice = 0;
        if (selfPrice > 4000) { //原价大于40元, 则打9折
            offPrice = selfPrice * 90 /100;
        } else {
            offPrice = selfPrice * 80 /100;
        }
        return offPrice;
    }
}

/* 业务流程类 */
public class BookStore {
    private final static ArrayList bookList = new ArrayList();
    //static静态模块初始化数据, 实际项目中一般是由持久层完成
    static{
        bookList.add(new OffNovelBook("天龙八部",3200,"金庸"));
        bookList.add(new OffNovelBook("巴黎圣母院",5600,"雨果"));
        bookList.add(new OffNovelBook("悲惨世界",3500,"雨果"));
        bookList.add(new OffNovelBook("金瓶梅",4300,"兰陵笑笑生"));
    }
    //模拟书店买书
    public static void main(String[] args) {
        NumberFormat formatter = NumberFormat.getCurrencyInstance();
        formatter.setMaximumFractionDigits(2);
        System.out.println("-----------书店卖出去的书籍记录如下: -----------");
        for(IBook book:bookList){
                System.out.println("书籍名称: " + book.getName()+"\t书籍作者: "
        }
    }
}

在定义了新的子类之后,输入的图书列表对象可能存在正常的 NovelBook,也会有 OffNovelBook,无论存在什么,业务主流程还是无需改动的。关键点在于

在 BookStore 类中,也可以将 bookList 看做是一种外界输入,参数的类型为接口类型,main 方法中也是使用的是接口类型对象进行操作。

开闭原则的意义

  • 主业务流程不会改动的太频繁
  • 单测用例不需要频繁改动
  • 提高复用性
  • 提高可维护性

你可能感兴趣的:(设计模式之禅(一) —— 六大设计原则)