聊聊设计模式中的那些坑 —— 桥接模式

聊聊设计模式中的那些坑 —— 桥接模式

先简单地介绍一下“桥接模式”: 用一个(抽象化的)接口将实体中的属性独立出来,以保持这个属性的变化独立于宿主,因为这个接口在两个定义(宿主和属性)之间起到桥接的作用,故名“桥接模式”,比如下文中的 Name 接口就是起到桥接 Party 类和 Name 属性之间的“桥”。 桥接模式给对象的灵活性带来了一些变化,它使得对象的某些部分的实现不再完全依赖父类,相反它可以从非继承关系中获的某些好处。就以文中的例子,我们先来看看当它以正面形象运行的时候是什么样的: 很显然我们会得到预期的输出结果: Organization name is “ABC Company” Individual name is “Bill, Gates” 但是需要注意的是“桥接模式”同样是恶魔的制造者,我们只需要对主代码做非常小的一点改动你就会发现问题: 我们“不小心”交换了传入参数的类型,原本应该传入 OrganizationName的地方实际上传入了IndividuaName,相反也是。而编译器居然完全没有发现这个错误,结果我们的到了完全相反的输出结果: Organization name is “Gates, Bill” Individual name is “ABC Company” 为什么会这样?编译器为什么无法发现这个问题呢?难道是编译器开发者的疏忽吗?并不是的,其实这就是编译器做出的正确选择,虽然它不符合我们的预期。要理解这个“错误”,我们还是要从OO的最基础原理中去寻找答案。 OO 允许我们适应不同类型的最根本依据是“多态”,多态是 OO 三大特征中的一个,多态的主要体现是“重载”和“重写”,它们解决的核心问题是对象如何正确地寻找到目标。以 Java 语言规范为例,当一个对象被初始化的时候,它会对对象做“重载解析”(overload resolution)或称 “静态分派”,所谓 “静态” 指的是一个对象在声明的时候的类型,也被称为“静态类型”,在这个例子中也就是 “Name”类型,而等号后面的实例类型,OrganizationName 或 IndividualName,被称为“运行时类型”(runtime type)或“实际类型”(Actual type),顾名思义,实际类型在运行时决定,两者最大的不同是运行时类型可能会在运行时发生变化,但是这种变化不会影响静态类型,也就是说,你可以先给 organization.name 赋予 OrganizationName 类型值,然后再改变成 IndividualName 类型也不会导致程序运行的任何不舒服(而你舒服不舒服就不好说了)。 因为静态类型的判定属于编译期的事,而实际类型是属于运行期的事,编译器对OO 的多态依据是由静态类型决定的,也就是说是静态类型,而不是实际类型决定了重载解析时的变量类型!并且,在 Java 语言中,因为属性是不支持重写的,也就是说如果这个问题发生在属性 Name 上,我们希望通过重写来解决问题也是不可能的,也就是说指望经典多态理论来解决这个问题基本上不可行。 以下重写无法通过编译: 综上所述,桥接模式其实是一个很危险的模式,这种危险性在于它的类型不安全性,那么有没有办法解决这个问题呢?答案当然是有的,就是“泛型”,泛型允许我们在编译期指定运行期类型的范围: 如此一来就避免了类型的不确定性,可以放心地使用 Organization 和 Individual了。 泛型并不排斥多态,甚至泛型弥补了经典多态的缺憾,产生了泛型多态的概念,丰富了多态的领域,它让语言变得更加安全,但是这种加强也导致了语言发展过程中的一次强烈的地震,这场地震的结果是将编程语言的世界一分为二,各自走上不同的道路:在一方面尽可能利用泛型来加强静态类型的约束力,让语言逐渐趋向静态类型,静态类型语言一个最明显的标志是逐渐淘汰了 cast(特指 downgrade cast)指令的使用,而对cast 指令的支持最初被认为是OO多态很重要的一个特征(自行Google cast 的四种模式中的 dynamic cast相关内容),Scala语言算的上是静态类型语言的巅峰之作;而另一方面有些语言则希望极大化地强调运行时侧的类型变化特征,这产生了语言的另一个分支:“动态类型语言”的出现,典型的动态类型语言包括 Python、JavaScript 等。当然也有语言尝试兼取两者优点,比如typescript 等。有关动态类型语言,以及它和泛函编程的渊源的内容有很多,基于时间和篇幅不在这里啰嗦,以后再单独介绍。但是可以提一下今天的 Java 也实现了对动态类型语言的支持,只不过这个支持出现的很晚,一直到 JVM7 才从虚拟机层面支持,而语言层面的支持则一直到 Java 8 发布以后。这个过程罕见地经过两个主版本的迭代,前后历时8年,影响深远。

聊聊设计模式中的那些坑 —— 单例模式(1)

Java设计范式23式,单例模式(singleton)是第一式,但是也是最复杂的一式。它的复杂性体现在多方面,首先我们先要了解为什么存在单例。其次,在产生单例的过程中,我们要避免哪些风险。最后,我们还要对 JVM 如何载入一个类有一定的认识。可以说一个单例模式综合考验了我们对 Java 在各方面的理解。 首先,为什么需要单例?这个问题比较简单,在任何可以用一个实例解决问题的地方,我们就应该考虑使用单例,前提是它是否同样保持高并发下执行效率不变?也就是说如果系统共享这一个实例,那么是否会出现资源竞争而产生瓶颈?如果不会,那么就应该使用单例。比如所有的只读量、静态量、纯函数对象等,都应该是单例。这种对象在系统中可以有很多,但是我们很少注意到它们,是因为在使用面向对象编程时,我们几乎会为所有的对象都产生一个实例,而不管是否真的需要这么做。但是在网络编程中,“对象“一般是来自用户的请求(值对象),在这种情况下,controller 作为存粹的处理节点(函数),它本身不包含也不寄存对象特例,那么将它单例化就很合理。另外,对于排它性资源的使用,也应该是单例,比如日志文件。我们要保证任何时候只能有一个任务在写入日志,那么单例也是比较合适的。 确定了单例的使用后,就是如何生成一个单例。首先我们必须知道生成单例有两种基本模式,在这两种模式上又演化出多种形式。这两种模式分别是“饥饿”模式和“懒加载”模式。 先来看一个错误的单例模式例子: 这个例子在单线程下不会有问题,但是如果运行在多线程下就不能保证是单例,因为 getInstance() 不能防止多线程重复进入,当第一个线程判断 INSTANCE 为空后,进而准备生成实例之前,如果恰好此时第二个线程也进入了 if 块,它同样会认为实例是空的,然后越过判断保护,执行到和第一个线程相同的地方,于是,你有可能执行了多余一次的构建。我们只需要放大这个执行过程不难看出错误: 现在让我们在 if 代码块的开头加入一段随机的停顿,然后用两个线程分别去获取实例之后比较它们是否相同: 这几乎一定会得到 false 的结果。所以我们必须考虑对 getInstance() 方法加装同步锁,以防止多线程共享: 但是这种将 synchronized 关键词直接加在方法上的做法未免使得加锁的范围太大,synchronized 在大并发条件下可能导致重度加锁,对性能产生较大影响,因此应该尽可能缩小加锁的范围,因此诞生了以下优化的写法: 以上这种模式在大并发条件下效率比前一种要高很多,尤其在Java6之前。它的第一次 if 判断的作用是尽可能避免触发锁机制,只是用来避免由此产生的效率问题的,并不保证此时访问单例一定是安全的。第二次比较才真正地确保单例的安全性。其中的逻辑相信大家不难看出来。因为它在进入临界区之前和之后需要反复确认单例对象是否为空,因此有个名称叫 Double check lock (DCL)模式。 以上模式最大的坑其实是在单例变量的声明上,它必须被声明成“volatile static”。这是因为现代 CPU 大多数有乱序执行的能力。什么是“乱序执行”呢?我们以“将大象装入冰箱”为例,如果我们要将一只大象装进冰箱,那么需要分三个步骤:1)打开冰箱门。2)将大象推进去。3)关上冰箱门。这三个步骤必须先后执行,如果我们“乱”了这个顺序,在执行第二步之前就先关上冰箱的门,那么大象就无法被装进冰箱里。同理,虚拟机生成一个对象也分为三个步骤:1)开辟内存空间。2)初始化对象。3)将变量指向对象。但是 CPU 不保证后两个步骤按顺序执行,如果第三步先于第二部执行,那么这时候我们拿到的将是一个未初始化的空指针(请参考《Java虚拟机规范》中有关对象加载与初始化机制的章节)。而关键词 volatile 的作用之一就是保证指令执行的有序性,它告诉CPU在对该对象的任何写入或读取之前,必须先完成之前的动作,所以必须在声明对象的时候加上该关键词,才能避免其它线程拿到空指针的风险。 以上介绍的这种模式的特点是,当且仅当我们调用 getInstance() 方法的时候,单例才会被建立,因此这种模式被称为“懒加载”模式。而这种懒加载只有最后一个才是完全正确且高效的,但是它需要我们对锁机制和 CPU 指令执行顺序有一定的了解。下一章将介绍一种不需要(显式)加锁的单例生成机制:静态初始化模式。

聊聊设计模式中的那些坑 —— 单例模式(2)

静态初始化模式 ,直接看例子: 这是最简单的静态初始化模式,可以看到类 EagerStaticSingleton 的内部具有一个静态(单例)常量 “INSTANCE”,它随着类在初始化阶段时就完成了实例的生成。 这种方法的优点是简单,你不必考虑多线程问题,因为虚拟机的类初始化机制本身就是线程安全的,因此无需为生成过程加锁。为了保证这个单例不被意外修改,它应该被声明为 final。但是它的缺点是可能造成内存泄漏,因为我们知道一个类被实例化之前需要先经过加载和初始化。通常虚拟机的加载器为了节约内存,提高执行的效率,仅在必要的时候才会执行相应的步骤(请参考《Java虚拟机规范》)。也就是说一个类被加载,不等于就会被初始化,被初始化不等就会被构建,同样被构建不等于会被使用。所以这个实现等于无论是否使用都强制执行了一次构建,这可能在内存资源紧张的系统中,或对于构建时间较长的类会遇到问题。 与之前的“懒加载”模式相对,这种不管用不用都生成实例的模式称为“饥饿”模式。顾名思义就是单例在静态初始化的同时就生成,而不管它是否真的被使用到。这个例子我们也可以写成以下形式: 相比上面的例子我们仅仅是把对象的初始化过程改到了静态初始化块中。通常情况下(单例对象的类型是一个类)这两者没有什么区别,但是如果单例对象是 Java 的内置类型或者 String,那么这两种写法就有区别了。我们知道 Java 在编译的时候,内置对象和String 类型的静态常量会被直接编译到静态常量池中,而静态常量池中的量在加载的同时就被直接赋予初始值,而不是等到初始化阶段才执行这个过程,它的执行效率会更高,并且可以越过初始化直接使用,不会产生效率或内存瓶颈问题。 为了避免饥饿模式的静态初始化单例的浪费和低效率,我们又有了以下懒加载模式的静态初始化单例: 和饥饿模式相比,懒加载模式下单例的不会在类初始化过程中被强制实例化,我们可以通过以下测试比较以下: 我们会看到控制台只输出了“EagerStaticSingleton”而懒加载类不会输出任何信息,因为它的构造函数不会被触发,一直延迟到 getInstance() 方法被调用的时候,LazyStaticSingleton 才会尝试构造单例实例。 这种模式最有意思的地方是它不仅是懒惰的,而且也是线程安全的。因为内部静态类 Holder 取代了单例原本应该的位置,我们之前说过,类加载过程保证了静态成员的线程安全,而单例又是 Holder 的静态成员,因此 Holder 的初始化过程(被延迟到 getInstance() 被调用的那一刻)反过来又保证了单例的线程安全。 最后,在介绍完利用类静态初始化来完成单例之前,再介绍它的一个变种,枚举单例: 枚举类在 JVM 中上被当作 final class 来看待,它也可以有构造函数和其它功能函数。并且它的枚举成员的类型就是它自己,也就是说以上相当于: 毫无疑问,这是一个饥饿模式的静态初始化单例。 以上所有单例(包括第一章的内容)都是非序列化条件下的单例,如果我们的单例模型需要支持序列化,那么它们都还存在缺陷,因为当一个类被反序列化的时候JVM会生成一个全新的对象。 对于这样一个单例,我们测试以下它反序列化之后是否依然相等呢? 所以,如果我们要得到一个支持反序列化的单例,以上方法还需要改进,我们需要给类加上一个特殊的方法: readResolve() 方法在反序列化方法 readObject() 中被调用。如果一个类实现了这个方法,那么 readObject() 将优先执行这个方法来获得实例,否则执行内建的机制来生成新的实例。而在这个方法调用 getInstance() 来返回当前系统中已经存在的那个实例,因此避免了生成新的实例,就保证了系统中始终存在唯一的单例。

BACK TO TOP