Java 理论和实践:变还是不变?

不变对象具备许多能更方便地使用他们的特性,包括不严格的同步需求和不必考虑数据讹误就能自由地共享和高速缓存对象引用。尽管不变性可能未必对于任何类都 有意义,但大多数程式中至少有一些类将受益于不可变。在本月的 Java 理论和实践中,Brian Goetz 说明了不变性的一些长处和构造不变类的一些准则。请在附带的论坛中和作者和其他读者分享您关于本文的心得。(也能够单击文章顶部或底部的“讨论”来访问论 坛。)
不变对象是指在实例化后其外部可见状态无法更改的对象。Java 类库中的 String、Integer 和 BigDecimal 类就是不变对象的示例 — 他们表示在对象的生命期内无法更改的单个值。

不变性的长处
假如正确使用不变类,他们会极大地简化编程。因为他们只能处于一种状态,所以只要正确构造了他们,就决不会陷入不一致的状态。您不必复制或克隆不 变对象,就能自由地共享和高速缓存对他们的引用;您能够高速缓存他们的字段或其方法的结果,而不用担心值会不会变成失效的或和对象的其他状态不一致。不变 类通常产生最好的映射键。而且,他们本来就是线程安全的,所以不必在线程间同步对他们的访问。

自由高速缓存
因为不变对象的值没有更改的危险,所以能够自由地高速缓存对他们的引用,而且能够肯定以后的引用仍将引用同一个值。同样地,因为他们的特性无法更改,所以您能够高速缓存他们的字段和其方法的结果。

假如对象是可变的,就必须在存储对其的引用时引起注意。请考虑清单 1 中的代码,其中排列了两个由调度程式执行的任务。目的是:现在启动第一个任务,而在某一天启动第二个任务。

清单 1. 可变的 Date 对象的潜在问题 Date d = new Date();
Scheduler.scheduleTask(task1, d);
d.setTime(d.getTime() + ONE_DAY);
scheduler.scheduleTask(task2, d);




因为 Date 是可变的,所以 scheduleTask 方法必须小心地用防范措施将日期参数复制(可能通过 clone())到他的内部数据结构中。不然,task1 和 task2 可能都在明天执行,这可不是所期望的。更糟的是,任务调度程式所用的内部数据结构会变成讹误。在编写象 scheduleTask() 这样的方法时,极其容易忘记用防范措施复制日期参数。假如忘记这样做,您就制造了一个难以捕获的错误,这个错误不会马上显现出来,而且当他暴露时人们要花 较长的时间才会捕获到。不变的 Date 类不可能发生这类错误。

固有的线程安全
大多数的线程安全问题发生在当多个线程正在试图并发地修改一个对象的状态(写-写冲突)时,或当一个线程正试图访问一个对象的状态,而另一个线程 正在修改他(读-写冲突)时。要防止这样的冲突,必须同步对共享对象的访问,以便在对象处于不一致状态时其他线程不能访问他们。正确地做到这一点会很难, 需要大量文档来确保正确地扩展程式,还可能对性能产生不利后果。只要正确构造了不变对象(这意味着不让对象引用从构造函数中转义),就使他们免除了同步访 问的需要,因为无法更改他们的状态,从而就不可能存在写-写冲突或读-写冲突。

不用同步就能自由地在线程间共享对不变对象的引用,能够极大地简化编写并发程式的过程,并减少程式可能存在的潜在并发错误的数量。

在恶意运行的代码面前是安全的
把对象当作参数的方法不应变更那些对象的状态,除非文档明确说明能够这样做,或实际上这些方法具备该对象的任何权。当我们将一个对象传递给普通方 法时,通常不希望对象返回时已被更改。但是,使用可变对象时,完全会是这样的。假如将 java.awt.Point 传递给诸如 Component.setLocation() 的方法,根本不会阻止 setLocation 修改我们传入的 Point 的位置,也不会阻止 setLocation 存储对该点的引用并稍后在另一个方法中更改他。(当然,Component 不这样做,因为他不鲁莽,但是并不是任何类都那么客气。)现在,Point 的状态已在我们不知道的情况下更改了,其结果具备潜在危险 — 当点实际上在另一个位置时,我们仍认为他在原来的位置。然而,假如 Point 是不变的,那么这种恶意的代码就不能以如此令人混乱而危险的方法修改我们的程式状态了。

良好的键
不变对象产生最好的 HashMap 或 HashSet 键。有些可变对象根据其状态会更改他们的 hashCode() 值(如清单 2 中的 StringHolder 示例类)。假如使用这种可变对象作为 HashSet 键,然后对象更改了其状态,那么就会对 HashSet 实现引起混乱 — 假如枚举集合,该对象仍将出现,但假如用 contains() 查询集合,他就可能不出现。无需多说,这会引起某些混乱的行为。说明这一情况的清单 2 中的代码将打印“false”、“1”和“moo”。

清单 2. 可变 StringHolder 类,不适合用作键 public class StringHolder {
private String string;
public StringHolder(String s) {
this.string = s;
}
public String getString() {
return string;
}
public void setString(String string) {
this.string = string;
}
public boolean equals(Object o) {
if (this == o)
return true;
else if (o == null || !(o instanceof StringHolder))
return false;
else {
final StringHolder other = (StringHolder) o;
if (string == null)
return (other.string == null);
else
return string.equals(other.string);
}
}
public int hashCode() {
return (string != null ? string.hashCode() : 0);
}
public String toString() {
return string;
}
...
StringHolder sh = new StringHolder("blert");
HashSet h = new HashSet();
h.add(sh);
sh.setString("moo");
System.out.println(h.contains(sh));
System.out.println(h.size());
System.out.println(h.iterator().next());
}




何时使用不变类
不变类最适合表示抽象数据类型(如数字、枚举类型或颜色)的值。Java 类库中的基本数字类(如 Integer、Long 和 Float)都是不变的,其他标准数字类型(如 BigInteger 和 BigDecimal)也是不变的。表示复数或精度任意的有理数的类将比较适合于不变性。甚至包含许多离散值的抽象类型(如向量或矩阵)也很适合实现为不 变类,这取决于您的应用程式。

Flyweight 模式
不变性启用了 Flyweight 模式,该模式利用共享使得用对象有效地表示大量细颗粒度的对象变得容易。例如,您可能希望用一个对象来表示字处理文档中的每个字符或图像中的每个像素,但 这一策略的幼稚实现将会对内存使用和内存管理开销产生高得惊人的花费。Flyweight 模式采用工厂方法来分配对不变的细颗粒度对象的引用,并通过仅使一个对象实例和字母“a”对应来利用共享缩减对象数。有关 Flyweight 模式的更多信息,请参阅经典书籍 Design Patterns(Gamma 等著;请参阅参考资料)。
Java 类库中不变性的另一个不错的示例是 java.awt.Color。在某些颜色表示法(如 RGB、HSB 或 CMYK)中,颜色通常表示为一组有序的数字值,但把一种颜色当作颜色空间中的一个特异值,而不是一组有序的单独可寻址的值更有意义,因此将 Color 作为不变类实现是有道理的。

假如要表示的对象是多个基本值的容器(如:点、向量、矩阵或 RGB 颜色),是用可变对象还是用不变对象表示?答案是……要看情况而定。要如何使用他们?他们主要用来表示多维值(如像素的颜色),还是仅仅用作其他对象的一 组相关特性集合(如窗口的高度和宽度)的容器?这些特性多久更改一次?假如更改他们,那么各个组件值在应用程式中是否有其自己的含义呢?

事件是另一个适合用不变类实现的好示例。事件的生命期较短,而且常常会在创建他们的线程以外的线程中消耗,所以使他们成为不变的是利大于弊。大多 数 AWT 事件类都没有作为严格的不变类来实现,而是能够有小小的修改。同样地,在使用一定形式的消息传递以在组件间通信的系统中,使消息对象成为不变的或许是明智 的。

编写不变类的准则
编写不变类很容易。假如以下几点都为真,那么类就是不变的:

他的任何字段都是 final
该类声明为 final
不允许 this 引用在构造期间转义
任何包含对可变对象(如数组、集合或类似 Date 的可变类)引用的字段:
是私有的
从不被返回,也不以其他方式公开给调用程式
是对他们所引用对象的唯一引用
构造后不会更改被引用对象的状态
最后一组需要似乎挺复杂的,但其基本上意味着假如要存储对数组或其他可变对象的引用,就必须确保您的类对该可变对象拥有独占访问权(因为不然的 话,其他类能够更改其状态),而且在构造后您不修改其状态。为允许不变对象存储对数组的引用,这种复杂性是必要的,因为 Java 语言没有办法强制不对 final 数组的元素进行修改。注:假如从传递给构造函数的参数中初始化数组引用或其他可变字段,您必须用防范措施将调用程式提供的参数或您无法确保具备独占访问权 的其他信息复制到数组。否则,调用程式会在调用构造函数之后,修改数组的状态。清单 3 显示了编写一个存储调用程式提供的数组的不变对象的构造函数的正确方法(和错误方法)。

清单 3. 对不变对象编码的正确和错误方法 class ImmutableArrayHolder {
private final int[] theArray;
// Right way to write a constructor -- copy the array
public ImmutableArrayHolder(int[] anArray) {
this.theArray = (int[]) anArray.clone();
}
// Wrong way to write a constructor -- copy the reference
// The caller could change the array after the call to the constructor
public ImmutableArrayHolder(int[] anArray) {
this.theArray = anArray;
}
// Right way to write an accessor -- don't expose the array reference
public int getArrayLength() { return theArray.length }
public int getArray(int n) { return theArray[n]; }
// Right way to write an accessor -- use clone()
public int[] getArray() { return (int[]) theArray.clone(); }
// Wrong way to write an accessor -- expose the array reference
// A caller could get the array reference and then change the contents
public int[] getArray() { return theArray }
}




通过一些其他工作,能够编写使用一些非 final 字段的不变类(例如,String 的标准实现使用 hashCode 值的惰性计算),这样可能比严格的 final 类执行得更好。假如类表示抽象类型(如数字类型或颜色)的值,那么您还会想实现 hashCode() 和 equals() 方法,这样对象将作为 HashMap 或 HashSet 中的一个键工作良好。要保持线程安全,不允许 this 引用从构造函数中转义是很重要的。

偶尔更改的数据
有些数据项在程式生命期中一直保持常量,而有些会频繁更改。常量数据显然符合不变性,而状态复杂且频繁更改的对象通常不适合用不变类来实现。那么有时会更改,但更改又不太频繁的数据呢?有什么方法能让有时更改的数据获得不变性的便利和线程安全的长处呢?

util.concurrent 包中的 CopyOnWriteArrayList 类是如何既利用不变性的能力,又仍允许偶尔修改的一个良好示例。他最适合于支持事件监听程式的类(如用户界面组件)使用。虽然事件监听程式的列表能够更 改,但通常他更改的频繁性要比事件的生成少得多。

除了在修改列表时,CopyOnWriteArrayList 并不变更基本数组,而是创建新数组且废弃旧数组之外,他的行为和 ArrayList 类很相似。这意味着当调用程式获得迭代器(迭代器在内部保存对基本数组的引用)时,迭代器引用的数组实际上是不变的,从而能够无需同步或冒并发修改的风险 进行遍历。这消除了在遍历前克隆列表或在遍历期间对列表进行同步的需要,这两个操作都很麻烦、易于出错,而且完全使性能恶化。假如遍历比插入或除去更加频 繁(这在某些情况下是常有的事),CopyOnWriteArrayList 会提供更佳的性能和更方便的访问。

结束语
使用不变对象比使用可变对象要容易得多。他们只能处于一种状态,所以始终是一致的,他们本来就是线程安全的,能够被自由地共享。使用不变对象能够 完全消除许多容易发生但难以检测的编程错误,如无法在线程间同步访问或在存储对数组或对象的引用前无法克隆该数组或对象。在编写类时,问问自己这个类是否 能够作为不变类有效地实现,总是值得的。您可能会对回答常常是肯定的而感到吃惊。

阅读更多

没有更多推荐了,返回首页