Effective-Java-50必要时进行保护性拷贝

Java 用起来如此舒适的一个因素在于,它是一门安全的语言 (safe language)。这意味着,它对于缓冲区溢出、数组越界、非法指针以及其他的内存破坏错误都自动免疫,而这些错误却困扰着诸如 C 和 C++ 这样的不安全语言。在一门安全语言中,在设计类时,无论系统的其他部分发生什么问题,类的约束都可以保持为真。对于那些 “把所有内存当作一个巨大的数组来对待” 的语言来说,这是不可能的。

即使在安全的语言中,如果不采取一点措施,还是无法与其它的类隔离开来。建设类的客户端会尽其所能来破坏这个类的约束条件,因此你必须保护性地设计程序。实际上,只有当有人试图破坏系统的安全性时,才可能发生这种情况;更有可能的是,对你的 API 产生误解的程序员,所导致的各种不可预期的行为,只好由类来处理。无论是何种情况,编写面对客户端的不良行为仍保持健壮性的类,这是非常值得投入时间去做的事情。

如果没有对象的帮助,另一个类不可能修改对象的内部状态,但是对象很容易在无意识的情况下提供帮助。例如 下面的类表示一段不可变的时间周期:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// Broken "immutable" time period class
public class Period {
private final Date start;
private final Date end;

public Period(Date start, Date end) {
if (start.compareTo(end) > 0)
throw new IllegalArgumentException(start + " after " + end);
this.start = start;
this.end = end;
}

public Date start() {
return start;
}

public Date end() {
return end;
}
}

乍看之下,此类似乎是不可变的,并且加了周期的起始时间 (start) 不能在结束时间 (end) 之后。然而,因为 Date 类本身是可变的,因此很容易违反这个约束条件:

1
2
3
4
5
// Attack the internals of a Period instance
Date start = new Date();
Date end = new Date();
Period p = new Period(start, end);
end.setYear(78); // Modifies internals of p!

从 Java 8 开始,修正这个问题最明显的方式是使用 Instant (或 LocalDateTime,或者 ZonedDateTime) 代替 Date,因为 Instant (以及另一个 java.time 类) 是不可变类 (见17条)。Date 已经过时了,不应该在新代码中使用

为了保护 Period 实例的内部信息,避免受到此攻击,对于构造器的每个可变参数进行保护性拷贝 (defensive copy) 是必要的,并且使用备份对象作为 Period 实例的组件,而不使用原始的对象:

1
2
3
4
5
6
7
// Repaired constructor - makes defensive copies of parameters
public Period(Date start, Date end) {
this.start = new Date(start.getTime());
this.end = new Date(end.getTime());
if (this.start.compareTo(this.end) > 0)
throw new IllegalArgumentException(this.start + " after " + this.end);
}

用了新的构造器之后,上述的攻击对于 Period 实例不再有效。注意,保护性拷贝是在检查参数的有效性 (见第38条) 之前进行的,并且有效性检查是针对拷贝之后的对象,而不是针对原始的对象。虽然这样做看起来有点不太自然,却是必要的。这样做可以避免在 “危险阶段” 期间从另一个线程改变类的参数,这里的危险阶段是指从检査参数开始,直到拷贝参数之间的时间段。在计算机安全社区中,这被称作Time-Of-Check / Time-Of-Use 或者 TOCTOU 攻击 [ViegaO1]。

同时也请注意,没有用 Date 的 clone 方法来进行保护性拷贝。因为 Date 是非 final 的, 不能保证 clone 方法一定返回类为 java.util.Date 的对象:有可能返回专门出于恶意的目的而设计的不可信子类实例。例如,子类可以在每个实例被创建的时候,把指向该实例的引用记录到一个私有的静态列表中,并且允许攻击者访问这个列表。这将使得攻击者可以 自由地控制所有的实例。为了阻止这种攻击,对于参数类型可以被不可信任方子类化的参数,不使用 clone 方法进行保护性拷贝

虽然替换构造器就可以成功地避免上述的攻击,但是改变 Period 实例仍然是有可能的,因为它的访问方法提供了对其可变内部成员的访问能力:(Ryuu:原始方法返回的是其成员的视图,这样会导致 Period 可被修改)

1
2
3
4
5
6
7
8
// Repaired accessors - make defensive copies of internal fields
public Date start() {
return new Date(start.getTime());
}

public Date end() {
return new Date(end.getTime());
}

采用了新的构造器和新的访问方法之后,Period 真正是不可变的了。不管程序员是多么恶意,或者多么不合格,都绝对不会违反 “周期的起始时间不能落后于结束时间” 的约束条件。因为除了 Period 类自身之外,其他任何类都无法访问 Period实例中的任何一个可变域。这些域被真正封装在对象的内部。

访问方法与构造器不同,它们在进行保护性拷贝的时候允许使用 clone 方法。之所以如此, 是因为我们知道,Period 内部的 Date 对象的类是 java.util.Date,而不可能是其他某个潜在的不可信子类。也就是说,基于第11条中所阐述的原因,一般情况下,最好使用构造器或者静态工厂。

参数的保护性拷贝并不仅仅针对不可变类。每当编写方法或者构造器时,如果它要允许客户提供的对象进入到内部数据结构中,则有必要考虑,客户提供的对象是否有可能是可变的。如果是,就考虑你的类是否能够容忍对象进入数据结构之后发生变化。如果答案是否定的,就必须对该对象进行保护性拷贝,并且让拷贝之后的对象,而不是原始对象进入到数据结构中。例如,如果你正在考虑使用由客户提供的对象引用作为内部 Set 实例的元素,或者 作为内部Map实例的键 (key),就应该意识到,如果这个对象在插入之后再被修改,Set 或者 Map 的约束条件就会遭到破坏。

在内部组件被返回给客户端之前,对它们进行保护性拷贝也是同样的道理。不管类是否为不可变的,在把一个指向内部可变组件的引用返回给客户端之前,也应该加倍认真地考虑。 解决方案是,应该返回保护性拷贝。记住长度非零的数组总是可变的。因此,在把内部数组返回给客户端之前,应该总要进行保护性拷贝。另一种解决方案是,给客户端返回该数组的不可变视图(immutableview)。这两种方法在第13条中都已经演示过了。

可以肯定地说,只要有可能,都应该使用不可变的对象作为对象内部的组件,这样就不必再为保护性拷贝(见第15条)操心。在前面的 Period 例子中,有经验的程序员通常使用 Date.getTime() 返回的 long 基本类型作为内部的时间表示法, 而不是使用 Date 对象引用。他们之所以这样做,主要因为 Date 是可变的。

保护性拷贝可能会带来相关的性能损失,这种说法并不总是正确的。如果类信任它的调用者不会修改内部的组件,可能因为类及其客户端都是同一个包的双方,那么不进行保护性拷贝也是可以的。在这种情况下,类的文档中就必须淸楚地说明,调用者绝不能修改可受影响的参数或者返回值

即使跨越包的作用范围,也并不总是适合在将可变参数整合到对象中之前,对它进行保护性拷贝。有一些方法和构造器的调用,要求参数所引用的对象必须有个显式的交接过程。当客户端调用这样的方法时,它承诺以后不再直接修改该对象。如果方法或者构造器 期望接管一个由客户端提供的可变对象,它就必须在文档中明确地指明这一点。

如果类所包含的方法或者构造器的调用需要移交对象的控制权,这个类就无法让自身抵御恶意的客户端。只有当类和它的客户端之间有着互相的信任,或者破坏类的约束条件不会伤害到除了客户端之外的其他对象时,这种类才是可以接受的。后一种情形的例子是包装类模式 (wrapper class pattern) (见第18条)。根据包装类的本质特征,客户端只需在对象被包装 之后直接访问它,就可以破坏包装类的约束条件,但是,这么做往往只会伤害到客户端自己。

总结:如果类具有从客户端得到或者返回到客户端的可变组件,类就必须保护性地拷贝这些组件。如果拷贝的成本受到限制,并且类信任它的客户端不会不恰当地修改组件,就可以在文档中指明客户端的职责是不得修改受到影响的组件,以此来代替保护性拷贝。