第五十条:必要时进行保护性拷贝

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

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

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

// 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)之后。然而,以为Data类本身是可变的,因此很容易违反这个约束条件:

// 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!

从Java8开始,修正这个问题最明显的方式是使用Instant(或LocalDateTime,或者ZonedDatetime)代替Date,因为Instant(以及另一个java.time类)是不可变的(详见第17条)。Date已经过时了,不应该在新代码中使用。也就是说,问题依然存在:有时候,还需要在API和内部表达式中使用可变的值类型,本条目中讨论的方法正适用于这些情况。

为了保护Period实例的内部信息避免受到这种攻击,对于构造器的每个可变参数进行保护性拷贝是必要的,并且适用备份对像作为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条)之前进行的,并且有效性检查是针对拷贝之后的对象,而不是针对原始对象。虽然这样做看起来有点不太自然,确是必要的。这样做可以避免在危险阶段期间从另一个线程改变类的参数,这里的危险阶段是指从检查参数开始,知道拷贝参数之间的时间段。在计算机安全社区中,这被称作Time-Of-Check/Time-Of-Use或者TOCTOU攻击。

同时也请注意,我们没有用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真正是不可变的了。不管程序员是多么恶意,或者多么不合格,都绝对不会违反周期的起使时间不能晚于结束时间这个约束条件。确实如此,因为除了Period类自身之外,其他任何类都无法访问Period实例中的任何一个可变域。这些域被真正封装在对象的内部。

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

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

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

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

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

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

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

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

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,294评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,493评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,790评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,595评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,718评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,906评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,053评论 3 410
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,797评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,250评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,570评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,711评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,388评论 4 332
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,018评论 3 316
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,796评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,023评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,461评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,595评论 2 350

推荐阅读更多精彩内容