第50项:必要时进行保护性拷贝

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

  即使在安全的语言中,如果不采取一点措施,还是无法与其他的类隔离开来。假设类的客户端会尽其所能来破坏这个类的约束条件,因此你必须保护性地设计程序。 随着人们为了破坏系统的安全性而付出了更多的努力,这种做法越来越正确,但更常见的是,你的类不得不处理那些善意的程序猿意外导致的错误。不管怎么说,编写一些面对客户的不良行为时仍能保持健壮性的类,这是非常值得投入时间去做的事情。

  没有对象的帮助时,虽然另一个类不可能修改对象的内部状态,但是对象很容易在无意识的情况下提供这种帮助。例如,考虑下面的类,它声称可以代表一段不可变的时间周期:

// Broken "immutable" time period class
public final class Period {
    private final Date start;
    private final Date end;
    /**
    * @param start the beginning of the period
    * @param end the end of the period; must not precede start
    * @throws IllegalArgumentException if start is after end
    * @throws NullPointerException if start or end is null
    */
    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;
    }
    ... // Remainder omitted
}

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

// 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已过时,不应再在新代码中使用。 也就是说,问题仍然存在:有时你必须在API和内部表示中使用可变值类型,并且此项中讨论的技术适用于那些时候【必须在API和内部表示中使用可变值类型】。

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

// 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实例没有影响。注意,保护性拷贝是在检查参数的有效性(第49项)之前进行的,并且有效性检查是针对拷贝之后的对象,而不是针对原始的对象。 虽然这样做看起来有点不自然,却是必要的。这样做可以避免在危险阶段(window of vulnerability)期间从另一个线程改变类的参数,这里的危险阶段是指从检查参数开始直到拷贝参数之间的时间段。在计算机安全社区中,这被称作time-of-check/time-of-use或者TOCTOU攻击[Viega01]。

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

  虽然替换构造函数就可以成功避免上述的攻击,但是改变Period实例仍然是有可能的,因为它的访问方法提供了对其可变内部成员的访问能力:

// Second attack on the internals of a Period instance
Date start = new Date();
Date end = new Date();
Period p = new Period(start, end);
p.end().setYear(78); // Modifies internals of p!

  为了防御这第二种攻击,只要修改这两个访问方法,使它返回可变内部域的保护性拷贝即可

// 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真正是不可变的了。不管程序猿是多么恶意,或者多么不合格,都绝对不会违反周期的起始时间不能落后于结束时间这个约束条件(不采用诸如native方法和反射之类的语言手段)。确实如此,因为除了Period类自身之外,其他任何类都无法访问Period实例中的任何一个可变域,这些域被真正封装在对象的内部。

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

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

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

  可以肯定地说,上述的真正启示在于,只要有可能,都应该使用不可变的对象作为对象内部的组件,这样就不必再为保护性拷贝(第17项)操心。在前面的Period例子中,使用Instant (或 LocalDateTime 或 ZonedDateTime), 除非你使用的是Java 8之前的版本。如果你使用的是更早的版本,一个选择是存储Date.getTime()返回的基本类型long代替Date引用。

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

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

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

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

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值