为什么对象应该是不可变的

本文转载自 ImportNew,由 刘 家财 翻译自 javacodegeeks。原文链接: javacodegeeks,译文链接: http://www.importnew.com/14027.html

在面向对象的编程领域中,一个对象如果在创建后,它的状态不能改变,那么我们就认为这个对象是不可变的(Immutable)。

在Java中,String这个不可变对象就是个很好的例子。一旦创建String对象后,我们不能对它的状态进行改变。我们可以创建新的String对象,但是不能改变原有的String对象。

然而,在JDK中有不可变对象只是很少的一部分。类似Date这样的类,我们能够通过调用setTime()方法改变它的状态。

我不清楚为什么JDK的设计者把如此相似的两个对象采取截然相反的实现方式。然而,我认为Date作为一个可变对象有很多缺陷。与此同时,不可变的String更能体现面向对象编程的本质。

更进一步,我认为在一个纯面向对象的世界里,所有的类都应该是不可变的。然而,有时会因为JVM的限制很难实现这一点。但不管怎么说,我们都应该尽全力做到最好。

下面几点是支持对象不可变性的一些理由:

  • 不可变对象更容易构造、测试与使用。
  • 真正的不可变对象都是线程安全的。
  • 不可变对象可以避免耦合。
  • 不可变对象的使用没有副作用(没有保护性拷贝)。
  • 对象变化的问题得到了避免。
  • 不可变对象的失败都是原子性的。
  • 不可变对象更容易缓存。
  • 不可变对象可以避免空值(NULL)引用,这通常是很糟糕的

线程安全

不可变对象最重要的特征是线程安全。这意味着多个线程能够在同时访问同一个对象,而且不需要担心与其他线程产生冲突。

如果对象的方法都不能改变对象的状态,那么不管有多少个对象,不管它们被并行调用的频率——不可变对象运行在自己的堆栈中。

Goetz等人在他们一本非常有名的书Java Concurrency in Pratice中更加细致的讨论了不可变对象的优势,强烈推荐大家去看。

避免时间上的耦合

Request request = new Request("http://example.com");
request.method("POST");
String first = request.fetch();
request.body("text=hello");
String second = request.fetch();

下面给出一个时间上耦合的例子(下面的代码发送两个连续的 HTTP POST请求,第二个有HTTP body):
Request request = new Request("http://example.com");
// request.method("POST");
// String first = request.fetch();
request.body("text=hello");
String second = request.fetch();
这段代码可以工作。但是,第一个方法必须在第二个方法之前调用,如果我们把第一个方法注释掉(也就是去掉第二行与第三行),编译器不会报任何错误:
<code class="java plain">Request request = </code><code class="java keyword">new</code> <code class="java plain">Request(</code><code class="java string">"http://example.com"</code><code class="java plain">);</code><div class="container"><div class="line number2 index1 alt1"><code class="java comments">// request.method("POST");</code></div><div class="line number3 index2 alt2"><code class="java comments">// String first = request.fetch();</code></div><div class="line number4 index3 alt1"><code class="java plain">request.body(</code><code class="java string">"text=hello"</code><code class="java plain">);</code></div><div class="line number5 index4 alt2"><code class="java plain">String second = request.fetch();</code></div></div>现在,这段代码虽然没有编译错误,但仍然失效了。这就是所谓的时间上的耦合——总是有些隐藏信息需要程序员去记住。在这个例子中,我们必须记着在使用第二个方法前,需要调用第一个方法。

我们必须记住第二个方法必须与第一个方法一起使用,并且是在第一个方法之后使用。

如果Request对象不可变,第一个代码片段也是不对的,很有可能是下面这个样子:

final Request request = new Request("");
String first = request.method("POST").fetch();
String second = request.method("POST").body("text=hello").fetch();
这下这两个方法就没有耦合了,我们可以很放心的去掉第一个方法。你也许会说上面的代码有重复,确实是有。但是我们可以改成这样:

final Request request = new Request("");
final Request post = request.method("POST");
String first = post.fetch();
String second = post.body("text=hello").fetch();
这样一来,我们 重构后的代码也是正确的,而且没有了时间上的耦合。第一个请求可以在不影响第一个请求的情况下取消掉。

我希望这个例子能够向你展示操作不可变对象是更可读且可维护的,因为它没有时间上的耦合。

避免副作用

让我们在一个新方法中使用Request对象现在它是可变的了):

public String post(Request request) {
  request.method("POST");
  return request.fetch();
}
下面让我们发送两个请求——第一个用GET方法,第二个用POST方法:

Request request = new Request("http://example.com");
request.method("GET");
String first = this.post(request);
String second = request.fetch();
这样代码就安全了,而且没有副作用。

避免身份可变性(Identity Mutability)

通常而言,对于内部状态相同的对象,我们认为它们是相同的。Date 类就是这方面一个很好的例子:

Date first = new Date(1L);
Date second = new Date(1L);
assert first.equals(second); // true
这里有两个对象,但是由于它们的内部状态是一样的,所以我们认为它们是相同的。可以通过重写它们的equals()与hashCode()方法实现。

这种便捷的方式的后果是:当我们在处理可变对象时,一旦我们改变了它们的内部状态,那么也就改变了它们的身份。

Date first = new Date(1L);
Date second = new Date(1L);
first.setTime(2L);
assert first.equals(second); // false
这也许看起来很自然,但是如果我们把可变对象作为Map的key时,情况就不一样了:
Map<Date, String> map = new HashMap<>();
Date date = new Date();
map.put(date, "hello, world!");
date.setTime(12345L);
assert map.containsKey(date); // false
当我们改变date的状态时,我们不希望改变它的身份。我们不想仅仅因为改变了key的状态就失去了这个条目。但是上面的例子确实会发生丢失条目的问题。

当我们向map中添加一个对象时,这个对象的hashCode()会返回一个值。HashMap根据这个值来决定当前条目在内部哈希表的位置。当我们调用containsKey()方法时,由于对象的hashcode不一样了(因为 hashcode 依赖于内部状态),所以HashMap在内部的哈希表中找不到相应条目了。

这是个非常烦人的问题,而且很难去调试可变对象的副作用而产生的问题。不可变对象就能从根本上避免这个问题了。

原子性失败

下面是个简单的例子:

public class Stack {
  private int size;
  private String[] items;
  public void push(String item) {
    size++;
    if (size > items.length) {
      throw new RuntimeException("stack overflow");
    }
    items[size] = item;
  }
}
很明显,如果程序因为溢出而导致抛出异常时,Stack 对象就会处于一种不健康的状态。它的size属性会增加,但是items中并不包含新元素。

不可变性可以避免这个问题,因为一个不可变对象只能在构造时改变状态。构造函数要么失败,这样就不会初始化这个对象;要么成功,这时才会构造一个合法可靠且的对象。因为这时对象的内部属性不会再发生改变了。

如果想了解更多关于这方面的内容,可以参考Joshua Bloch写的Effective Java, 2nd Edition

反对不可变性的论据

下面是一些反对不可变性的争论:

  1. “不可变性不适合企业级项目”。通常,我会听人说到不可变性是个假想的特征,在真正的企业级项目中并不适用。作为一个反对这个争论的人,我可以仅仅列举出下面一些例子,它们都是真实的应用,并且使用到了不可变的Java对象:jcabi-http,jcabi-xml,jcabi-github,jcabi-s3,jcabi-dynamo,jcabi-simpledb。 上面的这些Java库都使用了不可变对象。netbout.comstateful.co是两个使用了不可变对象实现的Web 应用程序。
  2. “更新一个已有对象的状态比创建一个新对象的成本要低”。Oracle认为“对象创建的成本往往被高估了,而且,不可变对象带来的便利可以抵消掉创建对象时的开销,因为垃圾回收机制能够减少开销,同时,我们可以不用去写专门防止可变对象出错的代码了。”我同意这种说法。
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值