技术干货Ns-WUZHIDEWO

深入理解创建类设计模式(Creational Patterns)

2020-03-03  本文已影响0人  煮酒小青梅

导读

本文适合长期困扰于设计模式是什么,有什么用,所有的文章都看懂了但还是没理解设计模式的人群。深入分析了五种创建类设计模式,这些设计模式都是在原书《Design Patterns》中直接指出的。开篇介绍了创建类设计模式主要解决的问题,并围绕这些问题描述了五种设计模式分别是什么,有什么区别,有什么关联,各自又解决了什么问题。在探讨这些问题的过程当中,详细梳理了各个模式的理解误区。在五种模式之外,还额外添加了静态工厂方法作为补充。

创建类设计模式

一个设计模式的根本目的在于维护上层代码结构的稳定性,创建类设计模式通过抽象出一个类实例的创建过程来实现这个目的。即对于上层代码来说,无须了解创建对象的具体细节。具体来说,上层代码只需要了解接口的功能,无须了解实现类型(Java层面的类型细节)或不同场景下大量的不同配置细节。

但要注意,这些细节并没有消失。具体到子类时,无论是类型还是配置数据,都需要明确指定。细节并没有消失,而是对上层代码不可见了。因此,设计模式只是尽可能消除代码结构上的复杂度,但仍然不能消除由现实业务产生的复杂度,即软件工程上所谓的本质复杂度。

创建类的设计模式一共有以下几种。

一、实例创建抽象

Java提供了三种创建对象的方式。即Java 的 new 关键字,Class对象的newInstance方法,以及Constructor<T>类的newInstance方法。无论利用什么样的设计模式创建对象,最终总要落入这三个范畴。

对一个可用对象来说,创建无非要经历几个一般性的过程。首先要使用上面方法之一为对象在虚拟机堆上分配内存,并获得对它的引用。然后,要对象进行初始化,提供对象的依赖,配置对象的参数。删繁就简,我们说一个可用对象要经历创建和初始化两个过程。

理解创建和初始化的关键在于,在java中,这两个过程只有概念上的区分。创建的根本在分配内存空间,而初始化既可以在创建中完成,也可以在创建外完成。例如,初始化可以在一个类的构造器中完成,也可以在实例化中完成。又如在后文的工厂方法中,类可以创建并执行不同的初始化过程,也可以单单只是创建。我们的目的在于要了解这是两个彼此分离又彼此相关的两个过程。

在实际应用中,我们通常只在构造器中初始化那些类的基本私有成员。对于一个类的依赖类,并不推荐在构造器中直接new出来,相反,我们使用一种被称作依赖反转的技术,用构造器参数或setter来配置。

在这里,我们看到了使用依赖反转的一种态度,即类不知道,也不想知道他的依赖类是怎么创建或实例化的,他只需要接受一个完成好的对象。

显然,如果一个可用依赖类不在主类中创建,那么必然要在其它地方完成这件事情。如何完成这件事情构成了创建类设计模式的主要内容。

通常,在学习设计模式时,既要了解这个设计模式使哪些的层次上的类变得稳定,也要了解那些底层的实现类需要遵循哪些协议。

二、工厂方法设计模式

对于很多人来说,理解这个模式的一大谬误在于把重点放在了工厂上,以至于经常与后面要说的抽象工厂模式弄混。但实际来说,工厂只是修饰方法的定语,在整个模式中处于次要地位。

正式定义:

定义一个接口来创建对象, 但让子类型来决定实例化什么类型。工厂方法允许一个类推迟实例化子类型。

又名抽象构造器

有一点要注意,定义中所阐述的接口并不指Java的interface,而是对应着接口方法。实际上,严格追究起来,Java中的interface本质上定义的,就是一个接口集合,或者说一个接口类,而不是一个单个的接口。 为了更方便地阐述它的原理,这里我们定义了一个abstract class,而不是传统阐释工厂方法时常用的interface

我们来逐渐还原工厂方法使用的经典场景。

  interface Product{}
  class ProductA implements Product{}

  abstract class Foo{
    Product product; 
    // 获得一个配置好的默认Product,如果不存在,则创建并初始化它。
    Product getConfiguredProductNotNull(){
        if(product == null){
            this.product =  new ProductA();
        }
        // 在经过一系列对product的配置以后
        return product;
    }
  }

问题的关键是getConfiguredProductNotNull方法不仅要配置product,而且有可能要创建一个Product实例。上面的代码采用硬编码的形式,将ProductA直接赋值给this.product

然而,这种形式并非对于任何子类都适用。我们注意到Foo是抽象的,它必须经过继承才能够使用。如果有任何一个子类不满意ProductA,我们就要重写Foo类。然而,重新硬编码一个新的Product子类只是拆东墙补西墙罢了,原有的子类可能需要继续依赖ProductA,也就是说ProductA必须存在。

要怎么办?回忆上文的内容——如果一个地方不适合创建对象,那么我们就把它挪到另外一个地方去。

  interface Product{}
  class ProductA implements Product{}

  abstract class Foo{
    Product product; 

    abstract protected Product createNewProduct();

    // 获得一个配置好的默认Product,如果不存在,则创建并初始化它。
    Product getConfiguredProductNotNull(){
        if(product == null){
            // 更改为调用上面的抽象方法。
            this.product =  createNewProduct();
        }
        // 在经过一系列对product的配置以后
        return product;
    }
  }

我们增加了一个createNewProduct方法。注意这里的叫法,一个抽象方法广义上就是一个接口。

现在,子类可以通过覆盖createNewProduct来获得它们想要的Product

  // 增加ProductB的定义
  class ProductB implements Product{}
  // 以及两个Foo类的子类定义
  class FooA extends Foo{
    protected Product createNewProduct(){
        return new ProductA()
    }
  }
  class FooB extends Foo{
     protected Product createNewProduct(){
        return new ProductB()
     }
  }

对设计模式比较熟悉的朋友也许可以联想到,这里似乎与模板方法模式(Template Method)产生了联系。事实上确实如此,这种实现方式是典型的模板方法。区别在于模板方法模式并不要求一个模板方法返回一个新创建的实例。而另一方面,工厂方法模式并不必然要求必须使用模板方法的方式实现,也就是说,工厂方法模式可以实现为模板方法,也可以不实现为模板方法。在接下来的抽象工厂模式中,我们还将看到,工厂方法模式将实现为抽象工厂模式。

此外,我们要关注一下工厂方法模式的稳定结构和非稳定结构。显然,新增一个工厂接口后,我们的Foo类变得更加稳定了。它把创建Product的工作抽象成工厂方法,然后交由子类去实现。这个类的其它方法可以依赖工厂方法去创建一个产品,对于不同的子类会产生不同的产品,自然也就不需要修改Foo类了。相对的,我们的子类依然是不稳定的,所创建具体类的细节必须包含在子类中。

三、抽象工厂模式

在前面对于工厂方法模式的描述中,我们阐述了一个约束,即拥有工厂方法的类的其它方法需要依赖于这个工厂方法来创建实例。然而这不是必要的。事实上,根据定义:

定义一个接口来创建对象, 但让子类型来决定实例化什么类型。工厂方法允许一个类推迟实例化子类型。

拥有工厂方法的类可以只定义一个工厂方法,但不规定任何其它内部方法引用了它。从Java的角度更广义地说,只要一个方法是抽象方法,并且这个方法返回一个新且是可继承类型的对象,那么这个方法就是工厂方法,而不管这个方法到底是protected还是public的。显然,根据定义,这样的说法在正确性上没有任何问题。

  interface Product{}
  interface Foo{
      Product createProduct();
  }

现在,我们发现,Foo这个类仅剩下一个抽象方法了。由于没有其它方法实现,我们可以把abstract class改成interface。进一步考虑这个类,我们发现,在移除了其它方法后,它的职责似乎只剩下创建Product这一件事情。

我们把所有主要对外服务的方法都在创建对象的类叫作工厂,把定义这些方法的抽象类叫作抽象工场。

Foo变成了一个抽象工厂。

然而,事情可以进一步拓展。我们看到,Foo只拥有一个createProduct方法。事实上,并没有这种奇怪的限制,Foo可以拥有无穷多个工厂方法。

  interface ProductA{}
  interface ProductB{}
  interface ProductC{}
  interface Foo{
     ProductA createProductA();
     ProductB createProductB();
     ProductC createProductC();
  }

注意,这里ProductAProductBProductC都变成了接口而不是具体的类。我们当然还可以继续定义这样的产品接口和对应的工厂方法,只是没必要罢了。

吊轨的事情在于,如果这里只有一个Product,那么Foo类就是一个抽象工厂,但如果它分成了ProductAProductBProductC,事情就变得不一样了。在阐述这个问题之前,我们首先要看一下抽象工厂的定义。

提供一个创建一系列相关联或依赖对象的接口。

也称作 “工具箱”

我们已经习惯了设计模式作者在接口一词上滥用的习惯,无论是在工厂方法还是在抽象工厂中,他用的都一个(an)接口,而不是一组接口。此问题暂且不谈,读者理解即可。

多个工厂方法组成了一个interface,这个接口类允许创建多个类的实例。根据定义,如果这些类实例是相互关联或互相依赖的,则它是抽象工厂;反之,如果它们没有任何关联,则不是抽象工厂。

如此严格定义的原因在于,抽象工厂本身应用于风格相关的程序中,例如工厂A提供一组田园风格的UI组件,而工厂B提供一组宫廷风格的UI组件。滥用的结果是,抽象工厂这个名词将产生极大的外延,这与设计模式产生的初衷不符——它本来就是为了程序员更好的交流而产生的,但最后大家聊起来的结果却是,“你的抽象工厂不是我的抽象工厂”。

同时,注意到抽象工厂模式和工厂方法模式粒度大小不同之处。工厂方法模式是作用在方法上的约束,它说的是,如果我的类里有一个方法,这个方法是抽象的,且返回一个类型(通常是子类型)的实例,那么这个方法是工厂方法。而抽象工厂模式描述的是,当整个类的主要对外方法都是工厂方法时,它是一个抽象工厂。前者是方法级别的设计模式(或者说,对象级别的设计模式),后者是类级别的设计模式。

附 静态工厂方法

静态工厂方法不属于现有的设计模式之列,但由于它与工厂方法模式非常之相似,我们把它列在这里,用以与工厂方法相区别。静态工厂方法通常用来替代一个类的构造方法,它是静态的,因而也是具体的。

  class Person{

      private Person(){};

      public static Person newInstance(){
         return new Person();
      }

  }

静态工厂方法作用于一个类上,用以获得它的一个实例。我们看到,在上面的Foo类型中,构造函数被设置为私有的,然后我们提供了一个newInstance静态方法返回这个类的实例。这样做的效果是,我们把构造器转换成了一个可具名的方法。有什么好处呢?最直观的方面是,代码的自说明性增强了。尤其是,我们可以提供多个静态工厂方法的时候,例如——

  class Person{
      // 职业
      private String career;

      private Person(){};

      private Person(String career){this.career = career;}

      public static Person newTeacher(){
         return new Person("teacher");
      }

      public static Person newDoctor(){
         return new Person("doctor");
      }
  }

另一方法,被方法封装起来的构造方法具有更大的灵活性,客户端不需要知道这个对象是怎么创建出来的,它只是调用这个方法,告诉目标类,我需要一个什么的对象,然后这个对象就被创建出来了。我们在文章的倒数第二节将会用这种方法实现一个单例模式。

四、建造器模式

前面提到了工厂方法模式和抽象工厂模式。回忆一下,我们曾经说过,一个可用类型的创建过程不仅仅包括创建本身,还有对创建好实例的初始化过程。无论是工厂方法模式,还是抽象工厂模式,它们只对实例如何创建进行了说明约束,而不对实例的初始化过程进行约束。所以,应用这两种设计模式的时候,你可以在工厂方法里做任意的初始化动作。

问题也出现在这里,我们说工厂方法模式维持了调用它的类的稳定性,而对具体实现类而言,混乱程度并没有多少降低。实现的工厂方法可以对类进行大量的初始化,配置大量的参数,也可以只做一点点,或者仅选用它需要的实例类型,除此之外什么也不做。这个空白由建造器(Builder)模式来填补。

我们先引用一下建造器模式的用途定义。

分离一个复杂对象的构建过程与它的表现形式,使得一个相同的构建过程能创建出不同的表现对象。

顾句思义,一个建造器抽象出了创建对象的流程。那什么又是构造器的抽象流程呢? 考虑一个铁匠打铁的过程。对于这个过程,我们首先要输入一块铁,然后锻造,有时候还要加上其它工序,最后才能获得成品。但无论你是打造镰刀还是锤子,这些工序都是被预先设定好的。

如果我们有一个铁匠A,一个铁匠B。

  Blacksmith a = getBlacksmithA();
  Blacksmith b = getBlacksmithB();
  Iron iron = new Iron();
  a.put(iron).forging().get(); //  产生一个锤子
  b.put(iron).forging().get(); // 产生一个镰刀

这就是所谓的通过相同的构建流程产生不同的表现对象。而所谓的构建流程,即是对象的初始化指令,它指示铁匠如何去创建并初始化一个工具对象。

一个建造器类型上可以拥有两大类型的方法,一个是配置方法,另一个是最后的结束方法。对于配置方法,建造器要求所有的子类都实现它,但并不要求构建对象的时候真的使用它。也就是说,任何一个建造器子类具体实现必须要接受所有通用的配置,但可以视情况选用它想要的配置。在这类方法中,我们通常使用return this;来返回这个建造器本身以实现链式调用。在调用的最后要求使用一个结束方法,每个结束方法都会返回一个构建好的目标对象。

在上面的例子中,put是参数配置类型的配置方法,forging是行为类型的配置方法,而get是最后的结束方法,它返回一个配置好的目标实例。

在建造器这项技术之上,我们还可以应用一些常见且高效的编程方法。通常情况下,我们会一边配置一边构建目标对象,即配置方法调用时,目标对象已经产生,调用配置方法的本质是逐步配置这个已创建好的对象。但另一方面,我们完全可以先不创建对象,而仅仅是保存配置本身的数据,在最后的结束方法中才把这个对象创建出来。这对于处理有相互依赖的配置是非常有效的手段。而且我们会发现,最后建造者的表现形式会与原型模式非常相像。事实上,原型模式的本质就是利用预定义的配置来构建一个对象。

  Blacksmith a = getBlacksmithA();
  Iron iron = new Iron();
  a.put(iron).forging(); // 此时建造器已经配置完毕。
  // 要四个锤子,仅需要调用四次。
  a.get();
  a.get();
  a.get();
  a.get();

此外,我们还要看到,从设计模式作者给出的实例来看,建造器的结束方法具有某些工厂方法的气质——都是抽象方法且都返回一个具体的产品实例。就建造器言,大部分情况下确实是用工厂方法来实现的,但并非必然。我们可以将结束方法定义为抽象的,也可以定义为非抽象的。例如,在边配置边构建的实现中,结束方法做的事情通常仅仅是返回一个对象引用而已。

五、 原型模式

最能说明原型模式的例子莫过于制作图片的软件,例如大名鼎鼎的微软公司开发的Visio。当你使用Visio的时候,最常做的事情莫过于用鼠标点击一下图形列表中的小圆圈或小方块,然后这个小圆圈和小方块就会神奇地出现在屏幕中央的绘画板上。

我们把每个小圆或小方块视作一个对象。那么,图形列表要怎样管理这些对象呢?在工具板上,无论是小圆还是小方块,或是其它的什么图样,我们把它称作一个“绘制图形按钮”。我们要求,每个绘制图形按钮必须对应一个图形对象,并且要求这个图形对象拥有一个克隆自己的方法。这样一来,每当你点击一个绘制图形按钮时,都会调用这个克隆方法,创建一个新的图形对象,并绘制在面板上。

我们把被克隆的那个对象称作原型。

然而我们可以来观察这件事情与建造器模式有什么相同之处。在使用原型模式之前,我们需要获得一个原型,我们必须手动(或者利用上面的某种创建类设计模式)构建一个对象,配置好它,然后把它注册到使用类中。每当一个请求到来的时候,我们调用它的克隆方法。

让我来换一种描述方式。我们手动(用建造器模式也行,那不过是建造器的建造器而已)创建了一个建造器,并配置好它。我们把这个建造器注册在使用类上,每当一个请求到来的时候,我们调用它的结束方法来创建一个对象。

这是一个多么令人惊讶的巧合!我们发现,所谓的原型模式,只不过是一个建造目标对象是建造器自己的特例。然而无论是在软件开发,还是业务软件的使用领域,原型模式的升级应用得远比建造器模式来得广泛。它的思想甚至远远超出了面象对象软件开发的这一范畴,“约定大于配置”这一想法逐渐深入人心。

六、单例模式

单例模式是最简单的对象构建模式,前面我们在拓展静态工厂方法时曾经提到过,静态工厂方法技术可以用来实现单例模式。一个类型在全局范围内有且仅有一个实例,

  class Singleton{
      public static Singleton singleton = new Singleton();
      private Singleton(){}
      public static Singleton getSingleton(){ return this.singleton;}
  }

关于这个模式的主要细节大概有三个,一是所谓的全局唯一中的全局,到底范围有多大?二是单例模式获得实例的入口必须要在它的主类吗?最后,我们将拓展性地讨论单例模式的安全性问题。

首先,所谓全局的单例中的全局,这个词在最开始仅仅指的是整个JVM运行时范围。但到今天为止,这个全局的含义已经在不停地向外扩展,我们说,现在这个单例模式中的“单例”,已经越来越趋向于“x对一关系”的意思。例如,一个应用只能拥有一个application对象,一个http请求只能拥有一个会话对象等等。在Spring中,bean对象可以是单例的,然而同一个类型的单例可以出现在两个IOC容器中。因此,在描述单例模式时,必须首先要说明它的单例范围。

此外,单例模式获得实例的入口必须在主类吗? 根据定义——

确保一个对象仅有一个实例,而且提供一个全局入口来获得这个实例。

也就是说,单例模式的入口未必需要在它的主类上。然而,在主类上定义一个静态工厂方法无疑是十分贴切的,“如果你需要一个类的实例,那就去这个类上的方法找找看”,这种想法实在是太过于自然了。

最后,我们需要探索一下单例的安全性问题。 我们看到,单例的构造器被声明为私有的,以防止它被意外地在类外被创建。然而这只能确保编译期的安全,在运行期,我们可以轻松通过反射机制绕开私有,实例化一个新的单例对象。这显然与我们的初衷相违背。
此外,需要单例的通常是一个大对象,而众所周知的是,一个大对象常用懒加载模式创建,也就是说下面这种模式才是常见的模式。

  class Singleton{
      public static Singleton singleton = null;
      private Singleton(){}
      public static Singleton getSingleton(){ 
         if(singleton == null){
             singleton = new Singleton();
         }
         return this.singleton;
     }
  }

这个时候,由于访问的是一个全局单例对象,并发也需要纳入考虑范围。我们可以加锁来解决这个问题,因为单例对象的创建只有一次(或者说在某个范围内只有一次),锁带来的开销通常是可以承受的,否则,就需要考虑是否需要使用CAS同步机制。如何选择同步机制超出了本文的讨论范围。回到上面构造器私有的问题,为了确保安全,在这种模式下,我们可以在私有构造器中判断singleton引用是否为null,如果不为null则抛出一个异常,用来警告客户端执行了不应该执行的操作。

但通常而言,技术是不完美的。我们可以不停地增加各种各样的约束,但最后总有一些例外来打破这些约束,这种情况下需要的是计算机技术之外的一些支持,当需要实现一个单例模式时,如果你的命名非常契合单例的命名习惯,并且详细描述了使用这个单例需要注意的情况,就能大幅度减少客户端程序员犯错的概率。

结语

全文完。

以下是关于设计模式的更多内容。

了解更多关于结构性设计模式的内容:

  1. 从设计模式看面向对象的程序结构组织(Structural Patterns)
  2. 对象的有序行为(Behavioral Patterns)
上一篇下一篇

猜你喜欢

热点阅读