美文网首页
设计模式之禅(一) —— 六大设计原则

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

作者: 若琳丶 | 来源:发表于2020-12-09 22:29 被阅读0次

    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<IBook> bookList = new ArrayList<IBook>();
        //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<IBook> bookList = new ArrayList<IBook>();
        //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 方法中也是使用的是接口类型对象进行操作。

    开闭原则的意义

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

    相关文章

      网友评论

          本文标题:设计模式之禅(一) —— 六大设计原则

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