Ryuu 的个人博客

一个计算机初学者

简述

Java 选择的泛型实现方式是”类型擦除式泛型”(Type Erasure Generics),而 C# 选择的泛型实现方式是”具体化式泛型”(Reified Generics)。具现化、偏特化这些名词最初都是源于C++ 模板语法中的概念,可以不必纠结其概念定义。C# 里面泛型无论在程序源码、编译后的中间语言(IL,这时泛型是一个占位符),或是运行时期的CLR里面都是切实存在的,List 与 List 就是两个不同的类型,他们由系统在运行期生成,有自己独立的虚方法表和类型数据。而 Java 语言中的泛型则只在程序源码中存在,在编译后的字节码文件中,全部泛型都被替换为原来的裸类型(Raw Type),并且在相应的位置插入了强制转换代码,对于运行期 的 Java 语言而言,ArrayList 和 ArrayList 其实是同一个类型。

如果是 C# 开发者,很难想象以下的 Java 代码是不合法的

1
2
3
T t = new T();
T[] array = new T[10];
List<T>[] listArray = new ArrayList<T>[10];

上述示例仅是 Java 泛型在编码阶段的不良影响,这个阶段的问题还能通过其他方法弥补 (多写几行代码,方法中多加一两个类型参数),然而,在性能上的差距则是难以用编码弥补的。自 C# 2.0 引入了泛型后,带来的显著优势之一便是对比起 Java 在执行性能上的的提高,在使用平台提供的容器类型(例如 List Dictionary<TKey, TValue>)时,无需像 Java 那样不厌其烦的拆装箱,如果在 Java 中想避免这种性能损失,需要构造一个与数据类型相关的容器类(例如 IntFloatHashMap)。显然,这样除了引入了更多的代码,复杂度提高,复用性降低外,丧失了泛型本身的存在价值。

Java 的类型擦除式泛型无论是在使用效果上还是运行效率上,几乎是全面落后于 C# 的具现化式泛型,而它的唯一优势是在于实现这种泛型的影响范围上:擦除式泛型的实现几乎只需要在 Javac 编译器上做出改进即可,不需要改动字节码、不需要改动 Java 虚拟机,也保证了以前没用使用泛型的库,可以直接运行在 Java 5.0 之上。但这种听起来节省工作量甚至可以说是有偷工减料嫌疑的优势就显得非常短视。但这种方法确实在 Java 当年实现泛型的利弊权衡中胜出了。我们必须在当时的泛型历史背景中,考虑不同的实现方式带来的代价。

关于泛型

泛型的思想早在 C++ 语言的模板 (Template) 功能中就开始生根发芽了,而在 Java 语言中加入泛型的首次尝试出现在1996年。Martin Odersky (后来Scala语言的缔造者)当时是德国卡尔斯鲁厄编程理论的教授,他想设计一门能够支持函数式编程的程序语言,又不想从头把编程语言的所有功能都再做一遍。所以就注意到了刚刚发布一年的 Java,并在它上面实现了函数式编程的3大特性;泛型、高阶函数和模式匹配,形成了 Scala 语言的前身 Pizza 语言。后来,Java 的开发团队找到了 Martin Odersky,表示对 Pizza 语言的泛型功能很感兴趣,他们就一起建立了一个叫作 “Generic Java” 的新项目,且标是把 Pizza 语言的泛型单独移植到Java 语言上,其最终成果就是 Java 5.0 中的那个泛型实现,但是移植的过程并不是一开始就朝着类型擦除式泛型去的。事实上 Pizza 语言中的泛型更接近于现在 C# 的泛型,Martin Odersky 自已在采访自述中提到,进行 Generic Java 项目的过程中受到了重重约束,甚至多次让他感到沮丧,最紧、最难的约束来源于被迫要完全向后兼容无泛型 Java,即保证”二进制向后兼容性”(Binary Backwards Compatibility)。二进制向后兼容性是明确写入《Java 语言规范》中的对Java 使用者的严肃承诺,譬如一个在 JDK 1.2 中编译出来的 Class 文件,必须保证能够在 JDK 12 乃至以后的版本中也能够正常运行。

Java 到1.4.2版之前都没有支持过泛型,而到 Java 5.0 突然要支持泛型,还要让以以前编译的程序在新版本的虚拟机还能正常运行,就意味着以前没有的限制不能突然冒出来。

举个例子,在没有泛型的时代,由于 Java 中的数组是支持协变(Covariant)的,对应的集合类也可以存入不同类型的元素。类似于如下代码尽管不提倡,但是是完全可以正常编译成 Class 文件。

1
2
3
4
5
6
7
// 编译通过、运行时报错
Object[] array = new String[10];
array[0] = 10;
// 编译、运行都不会报错
ArrayList list = new ArrayList();
list.add(Integer.valueOf(10));
list.add("hello world");

为了保证这些编译出来的 Class 文件可以在 Java 5.0 引入泛型之后继续运行,设计者大体上有两种选择:

  1. 需要泛型化的类型(主要是容器类型),以前有的就保持不变,然后平行地加一套泛型化版本的新类型
  2. 直接把已有的类型泛型化,即让所有需要泛型化的已有类型都原地泛型化,不添加任何平行于已有类型的泛型版。

C# 选择第一条,添加了一组 System.Collections.Generic 的新容器,以前的 System.Collections 以及 System.Collections.Specialized 依然存在。C# 的开发人员很快就接受了新的容器,唯一的问题大概是许多 .NET 自身的标准库已经把老容器类型当作方法的返回值或者参数使用,这些方法至今还保持者原来的老样子。

但如果相同的选择出现在 Java 中,很有可能不会是相同的结果,当时的 .NET 才问世两年,而 Java 已经快有十年的历史了,再加上各自的流行程度,两者遗留代码的规模根本不在一个数量级上。而且更大的问题是 Java 并不是没有做过第一条那样的技术决策,在 JDK 1.2 时,遗留代码规模尚小,Java 就引入过新的集合类,并且保留了旧集合类不动。这就导致了直到现在,标准库中还有 Vector(老) ArrayList(新)、Hashtable(老) HashMap(新) 等两套容器代码并存,如果再整出像 Vector(老) ArrayList(新)、Vector(老但有泛型) ArrayList(新且有泛型) 这样的容器,可能会被骂的更狠。

如果当时有足够的时间来好好设计和实现,完全有可能做出更好的泛型系统,如今的 Valhalla 项目正在还以前泛型实现偷懒留下的技术债。

Java 类型擦除

由于 Java 选择了第二条,直接把已有的类型泛型化。要让所有需要泛型化的已有类型都原地泛型化。如 ArrayList,原地泛型化后变成了 ArrayList ,需要保证以前直接用 ArrayList 的代码泛型的新版本中还能使用这同一个容器,这就必须让所有泛型化的实例类型,如 ArrayList ArrayList 这些全部自动成为 ArrayList 的子类型才行,否则类型转换将是不安全的。由此引出了裸类型(Raw Type)的概念,**裸类型应是所有该类型泛型化实例的共同父类型(Super Type)**,只有这样,如下的复制才是被系统允许的,从子类到父类的安全转型。

1
2
3
4
5
ArrayList<Integer> iList = new ArrayList<>();
ArrayList<String> sList = new ArrayList<>();
ArrayList list; // Raw use of parameterized class 'ArrayList'
list = iList;
list = sList;

接下来的问题是如何实现裸类型。这又出现了两种选择:

  1. 运行期由 JVM 自动地、真实地构造出 ArrayList 这样的类型,自动实现从 ArrayList 派生自 ArrayList 的继承关系来满足裸类型的定义。
  2. 简单粗暴地直接在编译时把 ArrayList 还原成 ArrayList ,只在元素访问、修改时自动插入一些强制类型转换和检查指令。

当然结果大家都知道了,Java 选择了第二种。将第一段代码编译成 Class 文件,再用字节码反编译工具进行反编译后,将会发现泛型都不见了,程序又变回了 Java 泛型出现以前的代码,类型变为了裸类型,只是在元素访问的时候插入了从 Object 到 String 的强制转型代码,如第二段代码所示。

1
2
3
4
5
6
// 泛型擦除前
Map<String, String> map = new HashMap<>();
map.put("hello", "你好");
map.put("how are you", "你好吗");
System.out.println(map.get("hello"));
System.out.println(map.get("how are you"));
1
2
3
4
5
6
// 泛型擦除后
Map map = new HashMap<>();
map.put("hello", "你好");
map.put("how are you", "你好吗");
System.out.println((String) map.get("hello"));
System.out.println((String) map.get("how are you"));

缺陷

  1. 使用泛型擦除实现导致了对原始类(Primitive Type) 数据的支持成了新麻烦。

    1
    2
    3
    4
    5
    ArrayList<int> iList = new ArrayList<>();
    ArrayList<long> lList = new ArrayList<>();
    ArrayList list;
    list = iList;
    list = lList;

    上述代码是不合法的,因为,这种情况下,一旦把泛型信息擦除后,到要插入强制转型代码的地方就没有办法做下去了,因为不支持 int long 与 Object 之间的强制转换。Java 当时给出的方法一如既往的简单粗暴:没办法做,那就索性不用原生类型的泛型了,都用包装类,反正都做了自动的强制类型转换,遇到原生类型时把装拆箱也做了。这个决定导致了无数构造包装类和装箱、拆箱的开销,成为 Java 泛型慢的重要原因,也成为了如今 Valhalla 项目要重点解决的问题之一。

  2. 运行期无法取到泛型类型信息。使得一些代码变得极其繁琐,例如本文第一段代码的几种 Java 不支持的泛型用法,都是由于运行期 JVM 无法取得泛型类型而导致的。

    1
    2
    3
    4
    public static <T> T[] convert(List<T> list, Class<T> componentType) {
    T[] array = (T[]) Array.newInstance(componentType, list.size());
    ...
    }

    上述代码,写一个从泛型版本的从 List 到数组的转换方法,由于不能从 List 中取得参数化类型 T,所以不得不从另一个额外参数中再传一个数组的组件类型进去,实属无奈。

  3. 通过擦除实现泛型,还丧失了一些面向对象应有的优雅,带来了一些模糊情况。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    // 'method(List<String>)' clashes with 'method(List<Integer>)'
    // both methods have same erasure
    public static void method(List<String> list) {
    System.out.println("invoke method(List<String> list)");
    }

    public static void method(List<Integer> list) {
    System.out.println("invoke method(List<Integer> list)");
    }

    上述代码是不能被编译的,因为 List List 编译之后都被擦除了,变成了同一裸类型 List。类型擦除导致这两个方法的特征签名一模一样。但实际上这仅该方法是无法重载的一部分原因。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    public static String method(List<String> list) {
    System.out.println("invoke method(List<String> list)");
    return "";
    }

    public static int method(List<Integer> list) {
    System.out.println("invoke method(List<Integer> list)");
    return 1;
    }

    上述的代码竟然是可以正常使用的(在一些 JVM 中,例如 JDK 6)。为两个方法指定不同的返回值,方法的重载竟然成功了,简直是打破了我们对于 Java 语言中返回值不参与重载选择的基本认知。

    实际上这当然不是根据返回值来确定的,能编译和执行成功,是因为两个 method() 方法加入了不同的返回值后才能共存在一个 Class 文件中。方法重载要求方法具备不同的特征签名,返回值并不包含在方法的特征签名中,所以返回值不参与重载选择,但是在 Class 文件格式之中,只要描述符不是完全一致的两个方法,就可以共存。

    由于 Java 泛型的引入,各种场景(虚拟机解析、反射等)下的方法调用都可能对原有的基础产生影响并带来新的需求,如在泛型类中如何获取传入的参数化类型等。所以 JCP 组织对《Java 虚拟机规范》做出了相应的修改,引入了诸如 Signature LocalVariableTypeTable 等新的属性用于解决伴随泛型而来的参数类型的识别问题,Signature 是其中最重要的一项属性,它的作用就是存储一个方法在字节码层面的特征整签名,这个属性中保存的参数类型并不是原生类型,而是包括了参数化类型的信息。修改后的虚拟机规范P要求所有能识别 49.0 以上版本的 Class 文件的虚拟机都要能正确地识别 Signature 参数。

    从上面的例子中可以看到擦除法对实际编码带来的不良影响,由于 List 和 List 擦除后是同一个类型,我们只能添加两个并不需要的返回值才能完成重载,这是一种毫无优雅和美感可言的解决方案,并且存在一定语意上的混乱,例如上文中提到的,用 JDK 6的 Javac 才能编译成功,其他版本或者是 ECJ 编译器都有可能拒绝编译。

    另外,从 Signature 属性的出现我们还可以得出结论,擦除法所谓的擦除,仅仅是对方法的 Code 属性中的字节码进行擦除,实际上元数据中还是保留了泛型信息,这也是我们在编码时能通过反射手段取得参数化类型的根本依据。

值类型

目前比较明确的是未来的 Java 应该会提供 “值类型” (Value Type) 的语言层面的支持。

说到值类型,这也是 C# 用户攻讦 Java 语言的常用武器之一

C# 并没有 Java 意义上的原生数据类型,在 C# 中使用的 int、bool、double关键字其实是对应了一系列在 .NET 中来中预定义好的结构体(Struct),如 Int32、 Boolean、 Double 等。在 C# 中开发人员也可以定义自己值类型,只要继承于 ValueType 类型即可,而 ValueType 也是统一基类 Object 的子类,所以并不会遇到 Java 那样 int 不自动装箱就无法转型为 Object 的尴尬。

值类型可以与引用类型一样,具有构造函数,方法或是属性字段,等等,而它与引用类型的区别在于它在赋值的时候通常是整体复制,而不是像引用类型那样传递引用的。更为关键的是,值类型的实例很容易在方法的调用栈上实现分配,这意味着值类型会随着当前方法的退出而自动释放,不会给垃圾收集于系统带来任何压力。

事务是一组原子性的SQL查询,一个独立的工作单元。如果数据库引擎能够成功对数据库应用该组查询的全部语句,那么就执行该组查询。如果其中任何一条因为崩溃或其他原因无法执行,那么所有的语句都不会被执行。事务内的语句,要么全部执行成功,要么全部执行失败。

  1. 原子性(atomicity)

    一个事务必须被视为一个不可分割的最小单元。

    整个事务中所有的操作要么全部提交(成功),要么全部回滚(失败)。

    对于单一事务而言,不可能只执行其中的一部分操作。

  2. 一致性(consistency)

    总是从一个一致的状态转换到另一个一致的状态。

    若在事务执行过程中系统崩溃,因事务的修改还未提交,事务的修改不会被保存。

  3. 隔离性(isolation)

    一个事务所做的修改在提交前,对于其他的事务是不可见的。

    是否可见与**隔离级别(Isolation level)**相关。

  4. 持久性(durability)

    一旦事务提交,修改将永久保存。即使系统崩溃修改的数据也不会丢失。

    持久性的表现与持久性级别相关。

实现以上特性能提供更高的安全性,但也会需要数据库做更多的额外工作。

将null而不是0长度的数组或集合返回是不合常理的,这使得客户端中必须得有额外代码以处理null的代码。编写客户端程序的程序员有可能忘记写代码来处理null,这样的错误可能很久都不会被发现。返回null也会使得其实现代码更加复杂。

有时候会有人认为: null返回值会比零长度集合或数组更好,因为它避免了分配长度容器所需的开销。这种观点是站不住脚的,原因有二:

  1. 在这个级别上担心性能问题是不明智的,除非分析表明这个方法正是造成性能问题的原因(见67条)。
  2. 不需要分配零长度的集合或者数组,也可以返回他们。

如果切实造存在性2能问题,可以通过重复返回一个不可变的零长度集合(例如 Collections.emptyList() Collections.emptyMap() ),避免即时分配,因为不可变类可以被自由的共享(见17条)。这仅是一个优化,几乎用不上。如果您认为确实有必要,请在行动前后进行性能测试。

简而言之,返回一个零长度的数组或集合,而不是null。如果返回null,那样会使得API更加难用,也更容易出错,且没有任何的性能优势。

  1. 单一职责原则(Single Responsibility Principle)

    单一类承担单一职责。

  2. 开放封闭原则(Open close Principle)

    对扩展开放,对修改封闭。

  3. 里氏替换原则(Liskov Substitution Principle)

    父类具有的功能,子类必须具有。

  4. 接口隔离原则(Interface Segregation Principle)

    依赖应当建立在最小的接口。 (而不是聚合的单一接口)

  5. 依赖倒置原则(Dependency Inversion Principle)

    依赖不基于实体类,而是基于接口 。(依赖不基于具体,而是基于抽象)

国内有设计模式六大原则的说法,多出一个:

  1. 迪米特法则 (Law of Demeter) 又称 最少知道原则 (Least Knowledge Principle)

    模块不应了解所操作对象的内部情况。

本条目将告诉你什么时候应该使用哪种嵌套类,以及这样做的原因。

嵌套类(nested class)是指定义在另一个类内部的类。嵌套类存在的目的应该只是为他的外围类(enclosing class)提供服务。如果嵌套类将来可能会用于其他的某个环境中,它就应该是顶层类(top-level class)。
嵌套类有四种:

  1. 静态成员类 (static member class)

  2. 非静态成员类 (nonstatic member class)

  3. 匿名类 (anonymous class)

  4. 局部类 (local class)

除了第一种之外,其他三种都称为内部类(inner class)

静态成员类是最简单的一种嵌套类。最好把他看作是普通类,只是被声明在另一个类的内部而已,它可以访问外围类的所有成员,包括那些声明为私有的成员。静态成员类是外围类的一个静态成员,与其静态成员一样,也遵守同样的可访问性规则。如果他被声明为私有的,它就只能在外围类的内部才可以被访问,等等。

静态成员类的一种常见用法是作为共有的辅助类,只有与它的外部类一起使用才有意义。例如,以枚举为例,它描述了计算器支持的各种操作(第34条)。Operation枚举应该是Calculator类的公有静态成员类,之后Calculator类的客户端就可以用诸如Calculator.Operation.PLUS 和 Calculator.Operation.MINUS 这样的名称来引用这些操作。

从语法上讲,静态成员类和非静态成员类之间唯一的区别是,静态成员类的声明中包含修饰符static。尽管它们的语法非常相似,但是这两种嵌套类有很大的不同。非静态成员类的每个实例都隐含地与外围类的一个外围实例(enclosing instance)相关联。在非静态成员类的实例方法内部,可以调用外围实例上的方法,或者利用修饰过的this (qualified this) 构造获得外围实例的引用[JLS, 15.8.4]。如果嵌套类的实例可以在它外围类的实例之外独立存在,这个类就必须是静态成员类:在没有外围实例的情况下,要想创建非静态成员类的实例是不可能的。

数组与泛型相比,有两个重要的不同点。首先,数组是协变的(covariant)。这个词听起来有点吓人,其实只是表示,如果Sub为Super的子类型,那么数组类型Sub[]就是Super[]子类型。相反,泛型则是可变的(invariant):对于任意两个不同的类型Type1和Type2,List既不是List的子类型,也不是List的超类型[JLS,4.10; Naftalin07, 2.5]。你可能认为,这意味着泛型是有缺陷的,但实际上可以说数组才是有缺陷的。下面的代码片段是合法的:

1
2
3
4
// Fails at runtime!
Object[] objectArray = new Long[1];
objectArray[0] = "I don't fit in"; // Throws ArrayStoreException
// Ryuu : 这竟然编译期不报错,C# 里编译期肯定报错了

但下面这段代码则不合法

1
2
3
// Won't compile!
List<Object> objectList = new ArrayList<Long>(); // Incompatible types
objectList.add("I don't fit in");

这其中无论哪一种方法,都不能将 String 放进 Long 容器中,但是使用数组,你会在运行时才发现所犯的错误;而使用列表,则可以在编译时就发现错误。

数组与泛型的第二大区别:数组是具象化的 (reified)[JLS, 4.7]。因此数组会在运行时知道和强化它们的元素类型。如上所述,将 String 保存到 Long 数组中,就会得到一个 ArrayStoreException 异常。泛型则是通过擦除(erasure)[JLS, 4.6]来实现的。泛型只会在编译时强化它们的类型信息,运行时丢弃(或者擦除)它们的元素信息。擦除就是使泛型可以与没有使用泛型的代码随意进行互用(见26条),以确保在Java5中平滑过渡到泛型。

因为以上这些根本的区别,数组和泛型不能很好地混合使用。例如,创建泛型、参数化类型或者类型参数的数组是非法的。

以下数组创建表达式没有一个是合法的

这些在编译期会产生一个泛型数组创建(generic array creation)错误。

1
2
3
4
new List<String>[];
new List<E>[1];
new E[1];
// Ryuu : 这竟然编译期报错,C# 里肯定没有错

从技术角度上来说,E、List和List这样的型应称作**不可具体化(nonreifiable)类型[JLS, 4.7]**。不可具体化类型是指其运行时表示法包含的信息比它编译时表示包含的信息比它的编译时表示法包含的信息更少的类型。唯一可具体化的(reifiable)参数化类型是无限制的通配符类型如List和Map(见26条)。创建无限制通配类型的数组是合法的。

如前文所说的,若对象包含非托管资源,那么一定要正确的清理。对于非托管型资源来说,.NET Framework 会采用一套标准的模式来完成清理工作,如果你所变写的类里也用到了非托管资源,那么该类的使用者就会认为这个类同样遵循这套模式。标准的 dispose (释放/处置) 模式会实现 IDisposable 接口,又会提供 finalizer (终结器/终止化器),以便在客户端忘记调用 IDisposable.Dispose() 的情况下也可以释放资源。这样做虽然有可能令程序的性能因执行 finalizer 而下降,但毕竟可以保证垃圾回收器能够把资源回收掉。这是处理非托管资源的正确方式,开发者应该透彻地理解该方式。实际上,.NET 中的非托管资源还可以通过System.Runtime.Interop.SafeHandle 的派生类来访问,那个类也正确地实现了这套标准的 dispose 模式。

在类的继承体系中,位于根部的那个基类应该做到如下几点

  • 实现 IDisposable 接口,以便释放资源。

  • 若本身含有非托管资源,添加 finalizer,以防客户端忘记调用 Dispose() 方法。若是没有非托管资源,则不添加 finalizer。

  • Dispose 方法与 finalizer (如果有) 都把释放资源的工作委派给虚方法,使得子类能够重写该方法,以释放它们自己的资源。

继承体系中的子类应该做到以下几点:

  • 若子类有自己的资源需要释放,那就重写由基类所定义的那个虚方法,若没有,则不重写该方法。
  • 若子类自身的某个成员字段表示非托管资源,实现 finalizer,若没有这样的字段,则不用实现 finalizer。
  • 记得调用基类的同名函数。

若类包含非托管资源,必须提供 finalizer,因为开发者不能保证使用者总是会调用 Dispose()。如果他们忘了,则会造成资源泄漏,尽管这是使用者的错误,但是受责备的是你 (因为你没有提前防范这种情况)。**(Ryuu:当然,作为使用者,应使用 Dispose(),而不是全部依赖于 finalizer (特别是在使用外部资源时)。finalizer 仅是保险手段,其确切的执行时间是不可知的 (作者下文有述)。Java 中也是如此。)**

垃圾收集器每次运行时,都会把不带 finalizer 的垃圾对象立刻从内存中移除。而带有 finalizer 的对象则会继续留在内存中,并添加到队列中。GC 会安排线程在这些对象上运行其 finalizer,运行完毕后,通常可以像不带 finalizer 的垃圾对象一样被移除。但与那些对象相比,他们属于老一代的对象,因为只有当其 finalizer 执行过一次后, GC 才会将其视为可以直接释放的对象,他们需要在内存中停留更长的时间。这也是没有办法,因为必须通过 finalizer 来保证非托管资源得到释放。尽管程序的性能可能因此有所下降,但只要客户端记得调用 Dispose(),就不会有此问题。

如果所编写的类使用了某些必须及时释放的资源,那么应按照广利实现 IDisposable 接口,以提醒此类使用者与运行系统注意。该接口只包含一个方法:

1
2
3
public interface IDisposable {
void Dispose();
}

实现 IDisposable.Dispose() 时需要注意:

  1. 释放所有的非托管资源
  2. 释放所有的托管资源 (其中包括取消事件订阅)
  3. 设定相关状态标志,表示该对象已被清理。若清理后还有对其成员的访问,可通过状态标志得知该情况,令这些操作抛出 ObjectDisposedException。
  4. 阻止垃圾回收器对该对象的重复清除 (可以通过 GC.SuppressFinalize(this) 来完成)。

正确实现 IDisposable 接口是一举两得的,因为它既提供了适当的机制使得托管资源能够及时释放,又令客户端可以通过标准的 Dispose() 来释放非托管类型的资源。如果你编写的类实现了 IDisposable 接口,并且客户端又能够记得调用其 Dispose(),那么程序将不必执行 finalizer,其性能也得到了保证,这将使得此类能顺利融入 .NET 环境中。

但此机制依然有漏洞,因为子类在清理自身的资源时必须保证基类的资源也能得到清理。若子类要重写 finalizer 或是想根据自己的需要给 IDisposable.Dispose() 添加新的逻辑,那么必须调用基类的版本。否则,基类的资源无法正确释放。此外,由于 finalizer 和 Dispose() 都有类似的任务。因此,这两个方法几乎总是包含重复的代码。直接重写接口中的函数可能无法达到预期效果,因为这些函数默认的情况下是非虚的。为此,需要再做一点工作来解决问题:把 finalizer 和 Dispose() 中重复的代码提取到 protected 级别的虚函数中,使得子类能够重写该函数,以释放他们分配的资源,而基类则应在接口方法中把核心的逻辑实现好。该辅助函数可以声明为此以供子类重写,使得其能在 Dispose() 方法或 finalizer 得以执行时把相关的资源清理干净:

1
protected virtual void Dispose(bool isDisposing)

IDisposable.Dispose() 和 finalizer 都可以调用此方法以清理相关资源。这个方法与 IDisposable.Dispose() 相互重载。由于其是虚方法,子类可以重写该方法,以便用适当的代码来清理自身的资源并调用基类版本。

  • isDisposing:true

    清理托管资源与非托管资源 (这表明该方法是在 IDisposable.Dispose() 中调用的)

  • isDisposing:false

    仅清理非托管资源 (这表明该方法是在 finalizer 中调用的)

无论是哪种情况,都要调用基类的 Dispose(bool),使得基类有机会清除其资源。

如下示例演示了该模式所用的代码框架,其中,MyResourceHog 实现了 IDisposable 接口,并创建了 Dispose(bool) 的虚方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
public class MyResourceHog : IDisposable
{
// Flag for already disposed
private bool alreadyDisposed = false;

// Implementation of IDisposable
// Call the virtual Dispose method
// Suppress Finalization
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}

protected virtual void Dispose(bool isDisposing)
{
// Don't dispose more than once.
if (alreadyDisposed) return;

if (isDisposing)
{
// elided: free managed resources here.
}

// elided: free managed resources here.
// Set disposed flag:
alreadyDisposed = true;
}

public void ExampleMethod()
{
if (alreadyDisposed)
throw new ObjectDisposedException("MyResourceHog", "Called Example Method on Disposed object");
// remainder elided.
}
}

DerivedResourceHog 继承了 MyResourceHog,并重写了基类中的 protected Dispose(bool):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class DerivedResourceHog : MyResourceHog
{
// Have its own disposed flag.
private bool disposed = false;

protected override void Dispose(bool isDisposing)
{
// Don't dispose more than once.
if (disposed) return;
if (isDisposing)
{
// TODO: free managed resources here.
}
// TODO: free unmanaged resources here.
// Let the base class free its resource.
// Base class is responsible for calling
// GC.SuppressFinalize()
base.Dispose(isDisposing);

// Set derived class disposed flag:
disposed = true;
}
}

请注意,基类和子类对象采用各自的 disposed 标志来表示其资源是否得到释放。若公用一个标志,那么子类可能率先将其设置为 true,而等到基类运行 Dispose(bool) 时,则会误认为其资源已释放。

Dispose(bool) 与 finalizer 需要具备 幂等性 (idempotent),多次调用 Dispose(bool) 的效果应与调用一次相同。由于各对象的 dispose 操作之间可能没有明确的顺序,因此在执行自身的 Dispose(bool) 时,或许其中某些成员已经被释放 (dispose) 了。这并不表示程序出了问题,因为 Dispose() 本身就可能多次被调用。对于该方法以外的其他 public 方法而言,如果在此对象已被释放后还有人要调用,那么应抛出 ObjectDisposedException ( Dispose() 是个例外)。在对象释放后调用该方法应该没有任何效果。当系统执行某个对象的 finalizer 时,该对象所引用的某些资源可能已经释放过,或是没有得到初始化。对于前者来说,不用检查其是否为 null,因为他所引用的资源还可以继续引用,只是有可能被释放,甚至其 finalizer 有可能已经执行过了。

上文示例的两个类都没有 finalizer,示例代码根本不会以 false 为参数调用 Dispose(bool)。只有当该类型直接包含非托管资源时,才应实现 finalizer。否则不调用也会给该类带来负担,因为这有着较大的开销。若有,则必须添加 finalizer 才能正确的实现 dispose 模式,此时的 finalizer 应与 Dispose(bool) 相同,都可以适当地将非托管资源释放掉。

在编写 Dispose 或 finalizer 等资源清理方法时,最重要的一点是:仅释放资源,不进行其他处理。否则就会产生一些涉及对象生存期的严重问题。一般的,对象在构造时诞生,在变成垃圾并回收时死亡。若程序不在访问某个对象,可以认为该对象已 *昏迷 (comatose)*,对象中的方法也不会得到调用,实际上等于已经消亡了,然而如果他包含 finalizer,那么系统在正式宣告其死亡前,会给他机会,使其能够将非托管资源清理。此时,如果 finalizer 令该对象可以重新为程序引用,那么他将复活,但是这种从昏迷中醒来的对象有其问题:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// DON'T DO THIS!
public class BadClass
{
// Store a reference to a global object:
private static readonly List<BadClass> FinalizedList = new List<BadClass>();
private string msg;

public BadClass(string msg)
{
this.msg = msg;
}

~BadClass()
{
// Add this object to the list.
// This object is reachable, no longer garbage.
// It's back!
FinalizedList.Add(this);
}
}

BadClass 对象执行其 finalizer 时,会将指向自身的引用添加到全局变量表中,使得程序能够再度访问该对象,使得此对象复活。这会造成很大问题。由于 finalizer 已经执行过了,因此垃圾回收器不会再执行其 finalizer ,于是这个复活的对象将不会被系统做终结 (finalize)。其次,该对象引用的资源可能无效了。对于那些只通过 finalizer 队列中对象访问的资源来说,GC 将不会把他们从内存中移除,但这些资源的 finalizer 可能已经执行过了,这些资源基本上不能再使用了。请不要采用此写法。

应该不会有人在终结对象时故意将其复活。但此例说明,若想在 Dispose 和 finalizer 中调用其他的函数以执行一些工作,请仔细考虑,这些操作可能会导致 bug,最好是将其删除,使得 Dispose 和 finalizer 只用来释放资源。

对于运行在托管环境的程序来说,开发者不需要给自己的每一个类都编写 finalizer。只有当其中包含了非托管资源或是带有实现了 IDisposable 接口的成员,才需要添加 finalizer。注意,在只需 IDisposable 接口但不需要 finalizer 的场合下,还是应该把整套模式写出,使得子类可轻松的实现标准的 dispose 方案。

(Ryuu:作者所述许多要点,文档中都有具体实现,推荐查看 (本书作者也是 dotnet docs 的作者,Bill wagner’s github overview)。终结器 - C# 编程指南非托管类型 - C# 参考IDisposable 接口 (System)实现 Dispose 方法)

垃圾回收器可以帮你把内存管理好,并高效地移除那些用不到的对象,但这并不是在鼓励你毫无节制地创建对象,因为创建并摧毁一个基于堆 (heap-based) 的对象无论如何都要比根本不生成这个对象耗费更多的处理器时间。在方法中创建很多局部的引用对象可能会大幅降低程序的性能。

因此,开发者不应该给垃圾回收器 (GC) 带来太多的负担,而是应该利用一些简单的技巧,尽量降低 GC 的工作量。所有引用类型的对象都需要先分配内存,然后才能使用,即使是局部变量也不例外。如果跟对象与这些对象之间没有路径可通,那么他们就变成了垃圾。具体到局部变量来看,如果声明这些变量的那个方法不再活跃于程序中,那么很可能导致这些变量成为垃圾。

例如很多人喜欢在窗口的paint handler 里面分配 GDI 对象,这样做容易出现这个问题:

1
2
3
4
5
6
7
8
9
protected override void OnPaint(PaintEventArgs e)
{
// Bad. Created the same font every paint event.
using (Font MyFont = new Font("Arial", 10.0f))
{
e.Graphics.DrawString(DataTime.Now.ToStirng(), MyFont, Brushes.Black, new PointF(0, 0));
}
base.OnPaint(e);
}

系统会频繁调用 OnPaint(),而每次调用时,都会创建新的 Font 对象,但是这并没有必要,因为实际上这些对象都是一样的,因此垃圾回收器总是得回收旧的 Font。GC 的执行时机与程序所分配的内存数量以及分配的频率有关,如果总是分配内存,那么 GC 的工作压力就比较大,这自然会降低程序效率。

反之,将 Font 对象从局部变量改为成员变量,那么就可以复用同一个 Font:

1
2
3
4
5
6
7
private readonly Font myFont = new Font("Arial", 10.0f);

protected override void OnPaint(PaintEventArgs e)
{
e.Graphics.DrawString(DataTime.Now.ToStirng(), MyFont, Brushes.Black, new PointF(0, 0));
base.OnPaint(e);
}

改版之后就不会进行频繁的垃圾回收了,这将使得程序运行的稍快一点。对于像本例的 Font 这样实现了 IDisposable 接口的类型来说,把该类型的局部变量提升为成员之后,需要在类中实现这个接口(见17条)。

如果局部变量是引用类型而非值类型,并且出现在需要频繁运行的例程 (routine) 中,那就应该将其提升为成员变量。上文的 OnPaint 例程中的 myFont 就是如此。请注意,只有当例程调用得较为频繁时材质的这样做,如果不太频繁,那么可以不用考虑这个问题。要避免频繁的创建相同的对象,而不是说把每个局部变量都转化为成员变量。

上文的代码用到了 Brushes。Black 这个静态属性,该属性采用另一种技术来避免频繁创建相似的对象。如果程序中有很多地方都要用到某个引用类型的实例,那么可以把它创建成静态的成员变量。每次用黑色绘制窗口时,都要使用这样的画笔,但如果每次绘制时都去重新分配,那么程序在执行过程中要创建并销毁大量的 Brush 对象。即便按照刚才那条技巧将这个对象从局部提升为成员变量,也无法避免该问题。由于程序会创建很多窗口与控件,而且在绘制时会用到大量的黑色画笔,因此.NET框架的设计者决定,只创建一支黑色的画笔给程序中的各个地方公用。Brushes 类里面有大量的 Brush 对象,每个对象对应于一种颜色,这种颜色的画笔是程序中的每个例程都可以使用的。Brushes 类在其内部采用惰性求值算法 (lazy evaluation algorithm) 来创建画笔,这种算法的逻辑可以表示成下面这样:

1
2
3
4
5
6
7
8
9
10
11
private static Brush blackBrush;
public static Brush Black
{
get
{
if (blackBrush == null)
blackBrush = new SolidBrush(Color.Black);
return blackBrush;
}
}
}

首次请求获取黑色画笔时,Brushes 类会创建该画笔,并把指向它的引用保存起来。以后如果还要获取这种颜色的画笔,那么 Brushes 类久把早前保存的引用直接返回给你,而不用再去重新创建。并且还有一个好处,如果某种画笔从始至终根本没有用到,那么 Brushes 类就根本不会创建该画笔。在编程工作中使用该技术会有正反两方面的效果, 正面效果是可以令程序少创建一些对象,而负面效果则是有可能导致对象在内存中待的比较久,这还意味着开发者无法释放非托管资源,因为你不知道什么时候调用 Dispose() 方法才好。

前面讲的这两项技巧可以令程序在运行过程中尽量少分配一些对象,第一项技巧是把经常使用的局部变量提升为成员变量,第二项技巧是采用依赖注入 (dependency injection) 的办法创建并复用那些经常使用的实例。此外还有一项针对不可变类型 (immutable type) 的技巧,该技巧可以把这种类型对象最终所应具备的取值分步骤地构建好。比方说,System.String 类就是不可变的,这种字符串创建好之后,其内容无法修改。某些代码看上去好像是修改了字符串内容,但其实还是创建了新的string对象,并用它来替换原有的string,从而导致后者变为垃圾。下面这种写法看起来似乎没有问题:

1
2
3
4
string msg = "Hello, ";
msg += thisUser.name;
msg += ". Today is ";
msg += System.DateTime.Now.ToString();

但是这样写很没有效率,因为它相当于:

1
2
3
4
5
6
7
8
// Not legal, for illustration only:
string msg = "Hello, ";
string msg1 = new string(msg + thisUser.Name);
msg = msg1; // "Hello, " is garbage
string msg2 = new string(msg + ". Today is ");
msg = msg2; // "Hello, <user>" is garbage
string msg3 = new string(msg + System.DateTime.Now.ToString());
msg = msg3; // "Hello, <user>. Today is " is garbage

tmp1、tmp2、tmp3 以及最初的 msg 全都成了垃圾,因为在 string 类的对象上面运用 += 运算符会导致程序创建出新的字符串对象,并且指令向原字符串的引用指向这个新的对象。程序并不会把这两个字符串中的字符连接起来并将其保存在原来那个字符串的存储空间中。如果想用效率较高的办法完成刚才那个例子所执行的操作,那么可以考虑内插字符串实现:

1
string msg = string.Format("Hello, {0}. Today is {1}",thisUser.Name, DateTime.Now.ToString());

相较于 string.Format(),字符串内插避免了因写入太多参数而对错位置的情况:(见第4条)

1
string msg = $"Hello, {thisUser.Name}. Today is {DateTime.Now.ToString()}";

如果要执行更为复杂的操作,那么可以使用 StringBuilder 类:

1
2
3
4
5
StringBuilder msg = new StringBuilder("Hello, ");
msg.Append(thisUser.Name);
msg.Append(". Today is ");
msg.Append(DateTime.Now.ToString());
string finalMsg = msg.ToString();

由于这个例子很简单,因此用内插字符串来做就足够了(内插字符串的用法见第4条)。如果最终要构建的字符串很复杂,不方便用内插字符串实现,那么可以考虑改用 StringBuilder 处理,这是一种可变的字符串,提供了修改其内容的机制,使得开发者能够以此来构建不可变 string 对象。与 StringBuilder 类本身的功能相比,更值得学习的是它所体现的设计思路,也就是说,如果要设计不可变的类型,那就应该考虑提供相应的 *builder(构建器)*,令开发者能够以分阶段的形式来指定不可变的对象最终所应具备的取值。这既可以保证构建出来的对象不会遭到修改,又能够给开发者提供较大的余地,使其可以将整个构建过程划分为多个步骤。

垃圾回收器能够有效地管理应用程序使用的内存,但需注意,在堆上创建并销毁对象需要耗费一定的时间,因此,不要过多地创建对象,不要创建那些根本不用去重新构建的对象。此外,在函数中以局部变量的形式频繁创建引用类型的对象也是不合适的,应该把这些变量提升为成员变量,或是考虑把常用的那几个实例设置成相关类型中的静态对象。最后还有一条技巧,就是要考虑给不可变的类型设计相应的 builder 类,以供用户通过可变 builder 对象来构建不可变的对象。

若使用 C#,那么就必须适应静态类型检查机制,该机制在很多情况下都会起到良好的作用。

静态类型检查意味着编译器会把类型不符的用法找出来,这也令应用程序在运行期能够少做一些类型检查。然而有的时候还必须在运行期检查对象的类型,比如,如果所使用的框架已经在方法签名里把参数写成了 Object,那么可能就得先将该参数转成其他类型(例如其他的类或接口),然后才能继续编写代码。有两种办法能实现转换,一是使用 as 运算符,二是通过强制类型转换 (cast) 来绕过编译器的类型检查。在这之前,可以先通过 is 判断该操作是否合理,然后再使用 as 运算符 或执行强制类型转换。

在这两种方法中,应该优先考虑第一种办法,这样做要比盲目地进行类型转换更加安全,且在运行的时候更有效率。as 及 is 运算符不会考虑由用户所定义的转换。只有当运行期的类型与要转换到的类型相符合时,该操作才能顺利地执行。这种类型转换操作很少会为了类型转换而构建新的对象(但若用 as 运算符把装箱的值类型转换成未装箱且可以为 null 的值类型,则会创建新的对象)。

下面来看一个例子。如果需要把 object 对象转换为 MyType 实例,那么可以这样写:

1
2
3
4
5
6
7
8
9
10
11
12
object o = Factory.GetObject();
// Version one:
MyType t = 0 as MyType;

if(t!= null)
{
// work with t, it's a MyType
}
else
{
// report the failure
}

此外,也可以这样写:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
object o = Factory.GetObject();
// Version one:
try
{
MyType t;
t = (MyType) o;
if (t != null)
{
// work with t, it's a MyType
}
}
catch(InvalidCastException)
{
// report the conversion failure
}

大家应该会觉得第一种写法比第二种更简单,而且更好理解。由于第一种写法不需要使用 try/catch 结构,因此程序的开销与代码量都比较低。如果采取第二种写法,那么不仅要捕获异常,而且还得判断t是不是 null。强制类型转换在遇到 null 的时候并不抛出异常,这导致开发者必须处理两种情况:一种是 o 本来就为 null,因此强制转换后所得的 t 也是 null;另一种是程序因 o 无法类型转换为 MyType 而抛出异常。如果采用第一种写法,那么由于 as 操作在这两种特殊情况下的结果都是 null,因此只需要用 if (t!= null) 来概括处理就可以了。

as 运算符与强制类型转换之间的最大区别在于如何对待由用户所定义的转换逻辑。as 与 is 运算符只会判断待转换的那个对象在运行期是何种类型,并据此做出相应的处理,除了必要的装箱与取消装箱操作,它们不会执行其他操作。如果待转换的对象既不属于目标类型,也不属于由目标类型所派生出来的类型,那么 as 操作就会失败。反之,强制类型转换操作则有可能使用某些类型转换逻辑来实现类型转换,这不仅包含由用户所定义的类型转换逻辑,而且还包括内置的数值类型之间的转换。例如可能发生从 long 至 short 转换,这种转换可能导致信息丢失。

继承(inheritance)是实现代码重用的有力手段,但它并非永远是完成这项工作的最佳工具。使用不当会导致软件变得很脆弱。在包的内部使用继承是非常安全的,在那里子类和超类的实现都处在同一个程序员的控制下。对于专门为了继承而设计并且具有很好的文档说明的类来说(见19条),使用继承也是非常安全的。然而,对于普通的具体类(concrete class)进行跨越包边界的继承,则是非常危险的。提示一下,本条目使用”继承”一词,含义是实现继承(当一个类扩展另一个类的时候)。本条目中讨论的问题并不指接口继承(类实现接口或接口扩展接口)。

与方法调用不同的是,继承打破了封装性[Snyder86]。子类依赖于其超类中特定功能的实现细节。超类的实现有可能会随着发行版本的不同而有所变化,如果发生了变化,子类可能会遭到破坏,即时是子类代码完全没有改变。因而,子类必须要跟着其超类的更新而演变,除非超类是专门为了扩展而设计的,并且具有很好的文档说明。

为了说明得更加具体,我们建设有一个程序使用了 HashSet。为了调优该程序的性能需要查询 HashSet,看一看自从它被创建以来添加了多少个元素(不要与它当前的元素数目混淆起来,它会随着元素的删除而递减)。为了提供这种功能,需要基于 HashSet 编写一个变体,定义记录视图插入的元素的数量 addCount,并针对该计数值导出一个访问方法。HashSet 类包含两个可以郑家元素的方法:add 和 addAll,因此这两个方法都要被覆盖:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// Broken - Inappropriate use of inheritance!
public class InstrumentedHashSet<E> extends HashSet<E> {
// The number of attempted element insertions
private int addCount = 0;

public int getAddCount() {
return addCount;
}

@Override
public boolean add(E e) {
addCount++;
return super.add(e);
}

@Override
public boolean addAll(Collection<? extends E> c) {
addCount += c.size();
return super.addAll(c);
}
}

这个类看起来非常合理,但是它并不能正常工作。假设我们创建一个实例,并利用addAll方法添加了三个元素。

1
2
3
InstrumentedHashSet<String> s = new InstrumentedHashSet<>();
s.addAll(List.of("Snap", "Crackle", "Pop"));
System.out.println(s.getAddCount()); // 6

此时我我们期望 getAddCount 方法能返回3,但实际上它返回的是6。原因是,在 HashSet 的内部 addAll 方法是基于它的add 方法来实现的,即使 HashSet 的文档中并没有说明这样的实现细节。所以实际上在 addAll() 中的所有元素都使得 addCount 加了2。只需要去掉被覆盖的 addAll 方法。虽然这样可以正常工作,但其功能正确性依赖于这样的事实:HashSet 的 addAll 方法是在其 add 方法上实现的。 这种自用性(self-use)是实现细节,而不是承诺,不能保证在 java 平台的所有实现中都保持不变,不能保证随着上发行版本的不同而不发生变化。因此,InstrumentedHashSet 类将会是非常脆弱的。

导致子类脆弱的一个原因是,它们的超类在后续的发行版本中可获得新方法。假设程序的安全性依赖于这一事实:所有被插入至某个集合的元素都满足某个先决条件。下面的做法将能保证这一点:对集合进行子类化,并覆盖掉所有的能够添加元素的方法。如果在后续的发行版本中,超类中没有增加能插入元素的新方法,那么这种做法能够正常工作。然而,一旦超类增加了这样的新方法,则很有可能因为调用了这个没有被覆写的新方法,而将”非法”的元素添加到子类的实例中。这不是一个纯粹的理论问题。在把 Hashtable 和 Vector 加入到 Collections Framework 中的时候,就修正了几个这类性质的安全漏洞。

以上问题都来源于覆盖 (overriding) 方法。在扩展一个类时,增加新的方法,而不覆盖现有的方法只是相对的安全,并不是没有风险。如果超类在后续的发行版本中获得了一个新方法,并且和子类中的某一方法只是返回类型不同,这样的子类将无法通过编译 [JLS,8.4.8.3]。如果给子类提供的方法带有与新的超类方法完全相同的签名及返回类型,这就覆盖了超类中的方法。你的方法是否能遵守新超类方法,也是个问题。因为在编写子类方法时,超类新方法还并存在

幸运的是,有一种方法可以避免前文所述的所有问题。不扩展现有类,而是在新的类中增加一个私有域,引用现有类的一个实例。这种设计被称为 “复合” (composition)。因为现有类变成了新类的一个组件。新类中的每个实例方法都能调用被包含的现有实例中对应的方法,并返回其结果。这被称为转发 (forwarding),新类中的方法被称为转发方法 (forwarding method)。这样的类将会非常的稳固,它不依赖于现有类的实现细节,即使是现有类增加了新的方法,也不会影响到新的类。

如下示例用复合 / 转发方法来代替 InstrumentedHashSet 类。注意这个实现分为两部分:类本身和可重用的转发类 (forwarding class),其中包含了所有转发方法,没有任何的其他方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// Wrapper class - uses composition in place of inheritance
public class InstrumentedSet<E> extends ForwardingSet<E> {
private int addCount = 0;

public InstrumentedSet(Set<E> s) { super(s); }

public int getAddCount() { return addCount; }

@Override
public boolean add(E e) {
addCount++;
return super.add(e);
}

@Override
public boolean addAll(Collection<? extends E> c) {
addCount += c.size();
return super.addAll(c);
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
// Reusable forwarding class
public class ForwardingSet<E> implements Set<E> {
private final Set<E> s;

public ForwardingSet(Set<E> s) { this.s = s; }

public int size() { return s.size(); }

public boolean isEmpty() { return s.isEmpty(); }

public boolean contains(Object o) { return s.contains(o); }

public Iterator<E> iterator() { return s.iterator(); }

public Object[] toArray() { return s.toArray(); }

public <T> T[] toArray(T[] a) { return s.toArray(a); }

public boolean add(E e) { return s.add(e); }

public boolean remove(Object o) { return s.remove(o); }

public boolean containsAll(Collection<?> c) { return s.containsAll(c); }

public boolean addAll(Collection<? extends E> c) { return s.addAll(c); }

public boolean retainAll(Collection<?> c) {
return s.retainAll(c);
}

public boolean removeAll(Collection<?> c) { return s.retainAll(c); }

public void clear() { s.clear(); }

public boolean equals(Object o) { return s.equals(o); }

public int hashCode() { return s.hashCode(); }

public String toString() { return s.toString(); }
}

Set 接口的存在使得 InstrumentedSet 类的设计成为可能,因为 Set 接口保存了 HashSet 类的功能特性。前文的基于继承的方法只适用于单个具体类,并且对于超类中所支持的每个构造器都要求有一个单独的构造器,与此不同的是,这里的包装类 (wrapper class) 可以被用来包装任何 Set 实现,并且可以结合任何先前存在的构造器一起工作:

1
2
Set<Instant> times = new InstrumentedSet<>(new TreeSet<>(cmp));
Set<E> s = new InstrumentedSet<>(new HashSet<>(INIT_CAPACITY));

因为每一个 InstrumentedSet 实例都把另一个 Set 实例包装,所以 InstrumentedSet 类被称为包装类 (wrapper class)。这也正是 Decorator (修饰者) 模式 [Gamma95] (InstrumentedSet 类对集合进行修饰,增加计数特性)。有时复合和转发的结合也被宽松的称为 “委托” (delegation)。从技术的角度而言,这不是委托,除非包装对象把自身传递给被包装的对象 [Liebermen86; Gamma95] (Ryuu:确实是有种委托的味道)。

包装类几乎没有什么缺点。需要注意的是,**包装类不适合用于回调框架 (callback framework)**;在回调框架中,对象需要把自身的引用传递给其他对象,用于后续的调用 (“回调”)。因为被包装的类并不直到它外面的包装对象,它传递一个指向自身的引用 (this),回调时避开了外面的包装对象。这被称为 SELF 问题 [Lieberman86]。有些人担心转发方法调用所带来的性能影响,或者包装对象导致的内存使用。在实践中,这两者都不会造成太大的影响。编写转发方法倒是有点繁琐,但只需给每个接口编写一次构造器,转发类则可以通过包含接口的包提供。如 Guava 就为所有的集合接口提供了转发类 [Guava]。

只有当子类真正是超类的子类型 (subtype) 时,才适合用继承。对于 A、B 两类,只有两个类有 “is-a” 的关系时,B 才应该扩展 A。若想用 B 扩展 A,就应该问问自己:每个 B 是否 is an A?如果不能肯定,那么就不应该进行扩展。如果没有 is-a 的关系,通常情况下,B 包含 A 的一个私有实例,并暴露一个较小的、较简单的 API:A 本质上不是 B 的一部分,只是它的实现细节。

在 Java 平台类库中,有许多明显违反这条原则的地方。例如,栈 (stack) 并不是向量 (vector),所以 Stack 不应扩展 Vector。同样的,属性列表也不是散列表,所以 Properties 不应扩展 Hashtable。这种情况下,复合模式才是恰当的。

若在适用复合的地方使用了继承,则会不必要地暴露实现细节。这样得到的 API 会把你限制在原始的实现上,不必要的局限该类。更为严重的是,由于暴露了内部的细节,客户端可能直接访问这些内部细节。至少会造成语义上的混淆。例如,如果 p 指向 Properties 实例,那么 p.getProperty(key) 就有可能产生与 p.get(key) 不同的结果:getProperty 考虑了默认的属性表,get 继承自 HashTable,未考虑默认的属性列表。最为严重的是,客户可能直接修改超类,从而破坏子类的约束条件。在 Properties 的情况中,设计者的目标是只允许字符串作为键 (key) 和值 (value),但直接访问底层的 HashTable 就允许违反这种约束条件。一旦违反了约束,就不能再使用 Properties API 的其他部分了。等到发现这个问题的时,已经太迟,因为客户已经依赖于使用非字符串的键和值了。

在使用继承而非复合前,考虑被扩展类的 API 是否有缺陷。是否愿意这些缺陷传播到类中 API?继承机制会把超类 API 的所有缺陷传播到子类中,而复合则允许设计新的 API 以隐藏这些缺陷。

继承违背了封装原则。只有当子类和超类有 “is-a” 的关系时,使用继承才是恰当的。并且,若子类与超类处于不同的包,并且超类并不是为继承而设计,那么继承将会导致脆弱性 (fragility)。为了避免这种脆弱性,可用复合转发机制来代替继承,尤其是当存在适当的接口可以实现包装类的时候。包装类不仅比子类更健壮,功能也更强大。

0%