设计模式(一)- 设计原则很重要

240 阅读14分钟

前言


无论你是新手还是已经写了几年代码的老鸟(菜鸟),有没有感觉自己写的代码充满着一股屎的味道,这种味道导致你没有信心或意愿去看你之前之前写过的代码,这里就把代码当成你的女友,这种情况就如同你女友奇丑无比,在分手之后你再也不想看她一样或跟她再有任何联系。但项目需求是在不断迭代的,你想分又分不掉,还得天天跟她交流亲密,时间越长,你是不是越来觉得恶心?面向对象和面向过程,天天挂在嘴边,手拿面向对象的语言,不知不觉却写成了面向过程。

看着开源框架的代码,感觉大神些为啥就能写出优秀的代码呢?自己写了多年,最大的进步就是api接口用的越来越熟练了,代码设计层面的进步还是很小。其实我觉得这个问题的根源在于写代码的时候缺少一些规范原则来指导和约束,把重心全部集中在了业务层面(并不是说业务不重要,而是相当重要),缺少思考和总结。

这些规范原则其实先辈们已经为我们总结好了即设计原则,大家在平时的工作中,多按照这些设计原则来规范自己的代码,当然并不是说要完全遵守这些设计原则,而是尽可能地去遵守它,你的代码会在现有的基础上有所提高。这些设计原则概念上比较抽象和学术,理解起来不太容易,我会多通过例子来尽量把它阐述的通俗易懂。如果你第一眼看到这些概念觉得抽象或天马行空的话,那就对了,耐着心看下去吧~~

开闭原则


定义:一个软件实体应该对扩展开发,对修改关闭

软件实体:功能模块,类,接口等

其含义是说:当我们的软件实体有需求变化时,不应该在原有的代码基础之上做修改,而是应该通过扩展新代码来满足需求的变化。这里的变化有方法逻辑的变化,也有功能模块的变化等多种方式,对于变化我们不能狭隘地理解成任何功能的变化都要去实现一个新的模块,类或方法,比如一个方法中的逻辑变化,由原有的a + b + c 变为a - b - c,这个变化只限定在此方法中,而且此逻辑以后也不会再变化了,那此时我们就直接修改原来的逻辑不是更好?此时几乎不会带来任何风险,也不会引起上层代码的修改。

理解此原则,更多的是需要站在代码框架设计的角度上来看的。即用抽象来构建框架,用实现来扩展细节,说简单点,就是你得有在顶层建模的能力。举个例子,我们通信网络从3G到现在的4G,以及即将到来的5G,用INetwork接口表示网络

//网络
public interface INetwork {
    //通信
    void communicate();
}

几年前我们用的还是3G通信,此时的通信协议是3G,实现类如下:

public class Network implements INetwork {
    public void communicate() {
        System.out.println("使用3G网络通信...");
    }
}

上层应用类中使用网络进行通信的代码如下:

public class Client  {
    public static void main(String[] args) {
        INetwork network = new Network();
        network.communicate();
    }
}

随着时代的发展,网络通讯技术在不断的发展,3G的速度以不能满足人们的需求,4G出来,但此时3G网络依然可用。那我们怎么来表示呢?第一种方法是修改原有的通信接口,增加一个参数用以表示网络类型,如下:

//网络
public interface INetwork {
    //通信接口,type用以表示不同的通信网络类型,如:1表示3G,2表示4G,3表示5G
    void communicate(int type);
}

实现类如下:

public class Network implements INetwork {
    public void communicate(int type) {
        if(type == 1){
            System.out.println("使用3G网络通信...");
        }else if(type == 2){
            System.out.println("使用4G网络通信...");
        }else {
            System.out.println("使用5G网络通信...");
        }
    }
}

上层应用代码也得跟着修改,这里就不写了。通过上述例子可以看出,需求变更一点,我们的底层接口,实现类和上层的应用代码都得跟着一起变动。而在实际的工作中,业务远比现在的例子复杂,变动就意味着带来风险。所以我们最好的选择是通过继承接口的方式,创建新的实现类来扩展新功能,如下图所示:

上层应用代码根据需求来改变对应的实现类即可。如下:

public class Client  {
    public static void main(String[] args) {
        //需要什么网络,就用什么网络
        //INetwork network = new Network3G(); //3G通信
        //INetwork network = new Network4G(); //4G通信
        INetwork network = new Network5G(); //5G通信
        network.communicate();
    }
}

通过这种方式,我们就达到了对扩展开放,对修改关闭的效果。有人会说你的上层应用代码也有变动啊,这TM还是在有修改啊?其实这是你的业务逻辑在改变,必然会有代码的变动。假设我把INetwork相关的实现类,封装到一个jar包里,供其他客户调用,这样就可以达到此原则的目的了。

实践中针对此原则,通过继承的方式或配置文件来扩展功能都是比较好的方式。

单一原则


单一原则大家应该是最熟悉的了,它的定义为:不要存在多余一个导致类变更的原因,一个类,接口,方法只负责一项职责。此原则理解起来很简单,但是实现起来就不那么简单了。

就那职责这个话题来说,我们怎么来划分职责?站在不同的角度有不同的划分方法,足球大家都懂吧,场上11个人,按照位置的不同我们简单的划分为前锋,中场,后卫和守门员的角色。前锋的职责就是射门,取得进球。中场球员的职责就是负责调度和传球,后卫的职责就是抢断防守,守门员的职责就是守住球门不失球。但是你说后卫能不能去充当前锋的角色,去射门进球?球队在进攻的时候,后卫是可以插上去参与进攻的。前锋需不需充当防守的角色?当然也需要,如果球队的防守压力比较大时,需要前锋回撤来帮助后卫防守,此时他的主要职责就是防守。

所以说很多时候职责这个东西很难说清楚,特别是对于一个对象来说的话,职责过于单一的话会造成系统中类的急剧膨胀,反而不利于代码的维护。那么实践中怎么比较好的运用这个原则呢?通常的做法是不要求类的职责单一,而是接口的职责要做到单一

我们以上面的例子为例,在不划分职责的情况下将足球运动员抽象为IPlayer接口,该接口具有射门,调度,拦截,守门的能力。

public interface IPlayer {

    /**
     * 射门
     */
    void shot();

    /**
     * 调度
     */
    void dispatch();

    /**
     * 拦截
     */
    void intercept();

    /**
     * 守门
     */
    void keepGoal();
}

在没有对接口进行职责划分前,无论是前锋,中场,后卫或守门员都要实现这几个方法,只是根据具体位置的球员,实现他具有的功能方法即可,其他方法作为一个空实现。如守门员就只具有守门的能力,那么他就只需实现keepGoal()方法即可,其他几个方法空着,如下:

public class GoalKeeper implements IPlayer {

    public void shot() {

    }

    public void dispatch() {

    }

    public void intercept() {

    }
    
    public void keepGoal() {
        System.out.println("守门员守住球门...");
    }
}

其他对象前锋,中场,后卫类似。将多个职能功能的方法揉到一个接口里,会导致实现类膨胀,也会加大维护难度和不便于客服端的使用。我们将此接口按照职责重新分解成多个接口

public interface IShot {
    /**
     * 射门
     */
    void shot();
}
public interface IDispatch {
    /**
     * 调度
     */
    void dispatch();
}
public interface IIntercept {
    /**
     * 拦截
     */
    void intercept();
}
public interface IKeepGoal {
    /**
     * 守门
     */
    void keepGoal();
}

对于前锋,中场,后卫和守门员,按照其自己能力属性,实现相应的接口即可

/**
 * 前锋
 */
public class Forwarder implements IShot,IDispatch {

    public void dispatch() {
        System.out.println("前锋调度...");
    }

    public void shot() {
        System.out.println("前锋射门...");
    }
}
/**
 * 中场球员
 */
public class Midfielder implements IShot,IDispatch,IIntercept{
    public void dispatch() {
        System.out.println("中场球员调度...");
    }

    public void intercept() {
        System.out.println("中场球员拦截...");
    }

    public void shot() {
        System.out.println("中场球员射门...");
    }
}
/**
 * 后卫
 */
public class Guarder implements IIntercept{

    public void intercept() {
        System.out.println("后卫拦截...");
    }
}

/**
 * 守门员
 */
public class GoalKeeper implements IKeepGoal {

    public void keepGoal() {
        System.out.println("守门员守住球门...");
    }
}

通过接口的职责分解,使实现类更容易维护了,同时通过接口的组合可以更加灵活的创建新的实现类。实践中,怎么划分职责需要经验的积累,需要平时多思考和总结才能拿捏得当。

接口隔离原则


说完了单一原则,紧接着说接口隔离原则,因为这两个概念比较相似容易弄混。先来看看定义:定义多个专门的接口,而不使用单一的总接口,客户端不应该依赖它不需要的接口,即一个类对另一个类的依赖应该建立在最小的接口上。如上面的例子所示,如果我们不拆分接口,用一个大而全的IPlayer接口提供给客服端的话,无论客户端需要一个前锋还是后卫或是守门员我们提供的都是一套接口,会连带提供许多无用的接口给客户端,这在开发中是比较危险的。

接口隔离原则要求接口尽量单一,通俗点说就是接口中的方法越少越好。那有人要说一个接口一个方法那不是更好?如果单从此原则的角度来看的话确实是这样的,但别忘了还有一个单一原则,此原则是站在业务逻辑的角度上来看的,有时一个单一的业务逻辑是由几个方法共同组成的,在实践中,我们要在满足单一职责的基础之上才去讨论接口隔离

依赖倒置原则


先解释下“倒置”的意思,传统的编程思维中,我们都是面向具体的实现类来编程,比如以顾客买书这个例子来说明,代码如下:

public class Client  {

    public static void main(String[] args) {
        Customer customer = new Customer();
        ComputerBook computerBook = new ComputerBook("操作系统");
        customer.buy(computerBook);
    }
}

按照正常的思维我们有一个顾客类Customer和表示计算机书的类ComputerBook,顾客类中有个购买的buy方法,此时Customer类直接依赖ComputerBook这个类。假如有天我们想购买另外一本书,如建筑类的书怎么办,只能在新增一个方法,如此每新增一个种类的书就要新增一个方法,而且方法里的逻辑都是一样的,会造成类很难维护。所以此时我们不要依赖于具体的类,而是写一个IBook的抽象接口,让所有关于Book的类都成为这个接口的实现类,Customer依赖这个IBook的抽象接口即可,“倒置”的名称由此而来。

public class Client  {

    public static void main(String[] args) {
        Customer customer = new Customer();
        IBook book = new ComputerBook("操作系统");
        customer.buy(book);
    }
}

依赖倒置定义:

  • 高层模块不应该依赖低层模块,二者都该依赖其抽象
  • 抽象不应该依赖细节;细节应该依赖抽象
  • 针对接口编程,不要针对实现编程

其实这个定义就是实践的指导。说下定义中的几个概览:

低层模块:可以理解为不可分割的原子逻辑。例如:数据库的操作

高层模块:原子逻辑的组装。很多业务逻辑往往都是需要多个原子逻辑的组合才能完成的

抽象:接口或抽象类,不能实例化的对象

细节:抽象的实现类

说了那么多,这个原则在实践中怎么用呢?很简单,凡是依赖尽量都用抽象

里是替换原则


这个原则比较简单,这里我们只说其比较容易理解的定义:所有引用基类的地方必须能透明地使用其子类对象,这个原则不打算详细地展开来说,因为从其定义就很好理解。这里主要从实践的角度来说下在平时的代码编写过程中怎么来遵守这个原则。

  • 子类可以扩展父类的功能,但不要改变父类的原有功能
  • 子类可以有自己的个性,即实现自己特有的一些方法功能
  • 覆盖或实现父类的方法时,方法的输入参数可以被放大,输出结果可以被缩小

前两点都比较好理解,这里解释下第三点。如定义Father和Son来个类,Father类中有个doSomething的方法,改方法依赖一个HashMap类型的参数

class Father {
    public List doSomething(HashMap map){
        System.out.println("do something ...");
    }
}
class Son {
    public ArrayList doSomething(Map map){
        System.out.println("do something ...");
    }
}

如下代码所示,我们在客户端使用类的代码,此时我们编译时类型是Father,而实际类型为Son,这里父类限制了参数类型为HashMap,该参数也可被子类接收。如果反过来的话就不行,假如父类参数为Map类型,子类为HashMap,如果在使用时我们传入了一个TreeMap类型,在编译时不会有问题,但在运行时就会出问题。

public class Client  {
    public static void main(String[] args) {
        Father father = new Son();
        father.doSomething(new HashMap());
    }
}

返回类型就不举例说了,大家想想就会明白的。

迪米特原则


该原则又叫最少知识原则,其定义为:一个对象应该对其它对象有最少的了解。强调只和朋友交流。什么是朋友呢?出现在类的成员变量方法输入参数或输出参数中的对象,在实践中就是在你的方法中最好不要出现陌生对象,比如你new一个对象这种方式就是不符合该原则的。

在我们很多的重构工作中,很多是跟关于该原则相关的。如一个类中的某个方法太长,我们会把它的代码重新组织下,把一些代码提取到其他类中等,这些手段都是迪米特原则的体现。举个例子,公司有boss,部门leader和基层员工worker。假设现在boss要统计下某个部门的员工人数,代码如下

    class Boss {
        public void command(Leader leader){
            List<Worker> workers = new ArrayList();
            for(int i = 0;i <= 10;i++){
                workers.add(new Worker);
            }
            leader.countWorkers(workers);
        }
    }
    class Leader {
        public Integer countWorkers(List<Worker> workers) {
            return workers.size();
        }
    }

上述代码command中就出现了陌生人Worker,公司中你一个小小的基层员工想跟Boss做朋友?你觉得现实么?Boss有问题肯定找部门leader,他跟部门leader的关系才够亲密,底层的苦逼员工最多就是和部门leader打下交道。我们把上面的结构变下

class Boss {
        public void command(Leader leader){
            leader.countWorkers();
        }
    }
    
class Leader {
    List<Worker> workers = new ArrayList();
           
    public Integer countWorkers() {
        for(int i = 0;i <= 10;i++){
            workers.add(new Worker);
        }
        return workers.size();
    }
}

上面这个代码有没有感觉到对朋友,陌生人这些概念有点感觉了,修改后的代码逻辑是不是更清晰了呢,而且也更贴近现实了呢?

合成复用原则


有些书上并没有把此原则作为设计的原则,此处我们还是把它当成一个设计原则。此原则的定义为:尽量使用对象组合/聚合,而不是继承关系达到软件复用的目的。继承关系对代码是具有侵入性的,因为你的子类会无条件的拥有父类的行为方法,而子类也可以复写父类的方法,改变父类的行为,有时候这些都是比较危险的。

所以此原则建议我们尽量通过类的组合来创建新对象,这样系统会更加灵活,类与类间的耦合性也会降低。

总结


上面介绍了7种设计模式的设计原则,其中开闭原则是最重要的,所有的其他原则都是为了达到此原则的目的。这些原则都是思想的提炼总结,在学习的过程中要静下心来好好理解领悟。而我们熟悉的设计模式也是这些思想在实践中的总结出来的一些固定模式,掌握好这些设计原则,有助于更深刻地理解设计模式