Objects Should Be Immutable

The following text is a partial translation of the original English article, performed by ChatGPT (gpt-3.5-turbo) and this Jekyll plugin:

在面向对象编程中,如果一个对象在创建后其状态不能被修改,那么它就是不可变对象。在Java中,不可变对象的一个很好的例子是String。一旦创建,我们就无法修改它的状态。我们可以请求它创建新的字符串,但它自己的状态永远不会改变。

然而,在JDK中,不太有不可变类。以Date类为例,您可以使用setTime()方法修改其状态。

我不知道JDK设计者为什么决定以不同的方式创建这两个非常相似的类。然而,我相信可变的Date的设计存在许多缺陷,而不可变的String更符合面向对象的范式。

此外,我认为在一个完美的面向对象的世界中,所有的类都应该是不可变的。不幸的是,有时由于JVM的限制,从技术上来说这是不可能的。尽管如此,我们应该始终追求最好的结果。

这是一个支持不可变性的论点不完整列表:

  • 不可变对象更容易构建、测试和使用。

  • 真正的不可变对象始终是线程安全的。

  • 它们有助于避免时间耦合。

  • 它们的使用是无副作用的(无防御性复制)。

  • 避免了身份可变性问题

  • 他们总是拥有失败原子性

  • 它们更容易进行缓存。

  • 它们防止空引用,空引用是不好的。

让我们逐个讨论最重要的论点。

Thread Safety

第一个也是最明显的论点是不可变对象是线程安全的。这意味着多个线程可以同时访问同一个对象,而不会与另一个线程发生冲突。

如果没有对象方法可以修改其状态,无论调用多少次和多频繁的并行调用,它们将在自己的内存空间中在堆栈中工作。

Goetz等人在他们非常著名的书籍《Java并发实践》中更详细地解释了不可变对象的优势(强烈推荐)。

Avoiding Temporal Coupling

这是一个时间耦合的例子(代码连续进行两个HTTP POST请求,其中第二个请求包含HTTP主体)。

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

这段代码是有效的。然而,你必须记住,在第二个请求发生之前,第一个请求应该被配置好。如果我们决定从脚本中移除第一个请求,我们将会移除第二行和第三行,并且不会从编译器中得到任何错误。

Request request = new Request("http://localhost");
// 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();

看,重构没有破坏任何东西,我们仍然没有时间上的耦合。可以安全地从代码中删除第一个请求,而不影响第二个请求。

我希望这个例子能够说明操作不可变对象的代码更易读和易于维护,因为它没有时间耦合。

Avoiding Side Effects

让我们尝试在一个新方法中使用我们的 Request 类(现在它是可变的):

public String post(Request request) {
  request.method("POST");
  return request.fetch();
}

让我们尝试发起两个请求—第一个使用GET方法,第二个使用POST。

Request request = new Request("http://localhost");
request.method("GET");
String first = this.post(request);
String second = request.fetch();

方法post()有一个“副作用”—它对可变对象request进行了更改。在这种情况下,这些更改实际上是不被期望的。我们期望它只是发起一个POST请求并返回其内容。我们不想阅读其文档,只是为了发现它在幕后还修改了我们作为参数传递给它的请求。

毋庸置疑,这些副作用会导致错误和可维护性问题。与不可变的“Request”一起工作会更好。

public String post(Request request) {
  return request.method("POST").fetch();
}

在这种情况下,我们可能没有任何副作用。无论我们的 request 对象在哪里被使用,以及它通过方法调用在调用堆栈中穿越多深,都没有人能修改它。

Request request = new Request("http://localhost").method("GET");
String first = this.post(request);
String second = request.fetch();

这段代码是完全安全且没有副作用的。

Avoiding 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<Date, String> map = new HashMap<>();
Date date = new Date();
map.put(date, "hello, world!");
date.setTime(12345L);
assert map.containsKey(date); // false

当修改date对象的状态时,我们不希望它改变其身份。我们不希望仅仅因为其键的状态改变而丢失映射中的条目。然而,这正是上面的示例中发生的事情。

当我们将一个对象添加到地图中时,它的hashCode()方法会返回一个值。这个值被HashMap用来将条目放入内部哈希表中。当我们调用containsKey()方法时,对象的哈希码是不同的(因为它是基于其内部状态的),所以HashMap无法在内部哈希表中找到它。

这是可变对象的一个非常恼人和难以调试的副作用。不可变对象完全避免了这个问题。

Failure Atomicity

这是一个简单的例子:

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》(链接:http://amzn.to/2cs4aiR)。

Arguments Against Immutability

有许多反对不可变性的论点。

  1. “不可变性不适用于企业系统”。很多时候,我听到人们说不可变性是一种花哨的特性,在实际的企业系统中绝对不实用。作为反驳,我只能展示一些只包含不可变Java对象的真实应用的示例:jcabi-httpjcabi-xmljcabi-githubjcabi-s3jcabi-dynamojcabi-w3cjcabi-jdbcjcabi-simpledbjcabi-ssh。以上都是仅与不可变类/对象一起工作的Java库。netbout.com和stateful.co是仅使用不可变对象工作的网络应用程序。

  2. “更新现有对象比创建新对象更便宜”。Oracle认为“对象创建的影响经常被高估,并且可以通过与不可变对象相关的一些效率来抵消。这些效率包括减少了垃圾收集的开销以及无需保护可变对象免受破坏所需的代码。”我同意。

如果你有其他的论点,请在下方发表,我会尽力进行评论。

P.S. 请查看 takes.org,这是一个完全由不可变对象组成的Java Web框架。

Translated by ChatGPT gpt-3.5-turbo/35 on 2023-09-09 at 05:52

sixnines availability badge   GitHub stars