50. 必要时进行防御性拷贝

      即使在一种安全的语言中,如果不付出一些努力,也不会与其他类隔离。必须防御性地编写程序,假定类的客户端尽力摧毁类其不变量。随着人们更加努力地试图破坏系统的安全性,这种情况变得越来越真实,但更常见的是,你的类将不得不处理由于善意得程序员诚实错误而导致的意外行为。不管怎样,花时间编写在客户端行为不佳的情况下仍然保持健壮的类是值得的。

      如果没有对象的帮助,另一个类是不可能修改对象的内部状态的,但是在无意的情况下提供这样的帮助却非常地容易。例如,考虑以下类,表示一个不可变的时间期间:

      乍一看,这个类似乎是不可变的,并强制执行不变式,即 实例的开始时间并不在结束时间之后。然而,利用 Date 类是可变的这一事实很容易违反这个不变式:

      从 Java 8 开始,解决此问题的显而易见的方法是使用 Instant(或 LocalDateTimeZonedDateTime)代替Date,因为Instant和其他 java.time 包下的类是不可变的(条目 17)。Date 已过时,不应再在新代码中使用。 也就是说,问题仍然存在:有时必须在 API 和内部表示中使用可变值类型,本条目中讨论的技术也适用于这些时间。

      为了保护 Period 实例的内部不受这种攻击,必须将每个可变参数的防御性拷贝应用到构造方法中,并将拷贝用作 实例的组件,以替代原始实例:

      还请注意,我们没有使用 Date 的 clone 方法来创建防御性拷贝。因为 Date 是非 final 的,所以 clone 方法不能保证返回类为 java.util.Date 的对象,它可以返回一个不受信任的子类的实例,这个子类是专门为恶意破坏而设计的。例如,这样的子类可以在创建时在私有静态列表中记录对每个实例的引用,并允许攻击者访问该列表。这将使攻击者可以自由控制所有实例。为了防止这类攻击,不要使用 clone 方法对其类型可由不可信任子类化的参数进行防御性拷贝。

      虽然替换构造方法成功地抵御了先前的攻击,但是仍然可以对 Period 实例进行修改,因为它的访问器提供了对其可变内部结构的访问:

      为了抵御第二次攻击,只需修改访问器以返回可变内部字属性的防御性拷贝:

      使用新的构造方法和新的访问器,Period 是真正不可变的。 无论程序员多么恶意或不称职,根本没有办法违反一个 period 实例的开头不跟随其结束的不变量(不使用诸如本地方法和反射之类的语言外方法)。 这是正确的,因为除了 period 本身之外的任何类都无法访问 实例中的任何可变属性。 这些属性真正封装在对象中。

      在访问器中,与构造方法不同,允许使用 clone 方法来制作防御性拷贝。 这是因为我们知道 Period 的内部 Date 对象的类是 java.util.Date,而不是一些不受信任的子类。 也就是说,由于条目 13 中列出的原因,通常最好使用构造方法或静态工厂来拷贝实例。

      在将内部组件返回给客户端之前进行防御性拷贝也是如此。无论你的类是否是不可变的,在返回对可拜年的内部组件的引用之前,都应该三思。可能的情况是,应该返回一个防御性拷贝。记住,非零长度数组总是可变的。因此,在将内部数组返回给客户端之前,应该始终对其进行防御性拷贝。或者,可以返回数组的不可变视图。这两项技术都记载于条目 15。

      可以说,所有这些的真正教训是,在可能的情况下,应该使用不可变对象作为对象的组件,这样就不必担心防御性拷贝(详见第 17 条)。在我们的 Period 示例中,使用 Instant(或 LocalDateTimeZonedDateTime),除非使用的是 Java 8 之前的版本。如果使用的是较早的版本,则一个选项是存储 返回的基本类型 long 来代替 Date 引用。

      可能存在与防御性拷贝相关的性能损失,并且它并不总是合理的。如果一个类信任它的调用者不修改内部组件,也许是因为这个类和它的客户端都是同一个包的一部分,那么它可能不需要防御性的拷贝。在这些情况下,类文档应该明确指出调用者不能修改受影响的参数或返回值。

      即使跨越包边界,在将可变参数集成到对象之前对其进行防御性拷贝也并不总是合适的。有些方法和构造方法的调用指示参数引用的对象的显式切换。当调用这样的方法时,客户端承诺不再直接修改对象。希望获得客户端提供的可变对象的所有权的方法或构造方法必须在其文档中明确说明这一点。

      包含方法或构造方法的类,这些方法或构造方法的调用指示控制权的转移,这些类无法防御恶意客户端。 只有当一个类和它的客户之间存在相互信任,或者当对类的不变量造成损害时,除了客户之外,任何人都不会受到损害。 后一种情况的一个例子是包装类模式(详见第 18 条)。 根据包装类的性质,客户端可以通过在包装后直接访问对象来破坏类的不变性,但这通常只会损害客户端。