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

jopen 10年前

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

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

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

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

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

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

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

线程安全

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

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

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

避免时间上的耦合

下面给出一个时间上耦合的例子(下面的代码发送两个连续的 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();

这段代码可以工作。但是,第一个方法必须在第二个方法之前调用,如果我们把第一个方法注释掉(也就是去掉第二行与第三行),编译器不会报任何错误:

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

现在,这段代码虽然没有编译错误,但仍然失效了。这就是所谓的时间上的耦合——总是有些隐藏信息需要程序员去记住。在这个例子中,我们必须记着在使用第二个方法前,需要调用第一个方法。

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

如果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认为“对象创建的成本往往被高估了,而且,不可变对象带来的便利可以抵消掉创建对象时的开销,因为垃圾回收机制能够减少开销,同时,我们可以不用去写专门防止可变对象出错的代码了。”我同意这种说法。

如果你有其他的想法,请在下面贴出来,我将尽量回复。

原文链接: javacodegeeks 翻译: ImportNew.com - 刘 家财
译文链接: http://www.importnew.com/14027.html