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的限制,有时技术上不可能实现。尽管如此,我们应该始终追求最好的。

以下是支持不可变性的一些论点(不完全列表):

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

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

  • 它们的使用是无副作用的(没有防御性拷贝)

  • 身份可变性问题被避免

  • 他们总是具有失败的原子性

  • 它们更容易被缓存

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

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

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

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

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

这是一个时间耦合的示例(代码进行了两次连续的HTTP POST请求,其中第二个请求包含HTTP正文)。

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

现在,尽管脚本没有出现错误,但它已经出现了故障。这就是临时耦合的问题所在——代码中总会有一些隐藏的信息需要程序员记住。在这个例子中,我们需要记住第一个请求的配置也会用于第二个请求。

我们需要记住第二个请求应该始终保持在一起,并在第一个请求之后执行。

如果Request类是不可变的,那么第一个代码片段一开始就无法工作,并且会被重写为:

现在,这两个请求是没有关联的。我们可以安全地删除第一个请求,第二个请求仍然可以正常工作。你可能指出存在代码重复。是的,我们应该摆脱它并重新编写代码。

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

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

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

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

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

不用说,这样的副作用会导致错误和可维护性问题。与之相比,使用一个不可变的 Request 会更好。

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

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

很多时候,如果对象的内部状态相同,我们希望它们是相同的。Date 类是一个很好的例子:

有两个不同的对象;然而,它们是相等的,因为它们封装的状态是相同的。这是通过它们自定义重载的equals()hashCode()方法实现的。

使用这种方便的方法与可变对象一起使用的后果是,每次修改对象的状态时,它都会改变其身份。

直到你开始使用可变对象作为映射中的键,这可能看起来很自然。

修改date对象的状态时,我们并不希望它改变自身的身份。我们也不希望由于其键的状态改变而丢失映射中的条目。然而,上述示例中正是发生了这种情况。

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

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

这里是一个简单的例子:

很明显,如果一个Stack类的对象在溢出时抛出运行时异常,它将处于破坏状态。它的size属性将被增加,而items将不会得到一个新元素。

不可变性可以防止这个问题。一个对象永远不会处于破坏状态,因为它的状态只在构造函数中被修改。构造函数要么失败,拒绝对象实例化,要么成功,创建一个有效的固定对象,它永远不会改变其封装的状态。

想要了解更多关于这个主题的信息,请阅读《Effective Java》,作者是Joshua Bloch。

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

  1. “更新现有对象比创建新对象更便宜”。Oracle 认为:“经常会高估对象创建的影响,而不可变对象具有一些与效率相关的优点,可以抵消这种影响。这些优点包括减少垃圾收集的开销以及消除为了保护可变对象免受破坏而需要的代码。”我同意。

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

附言:请查看takes.org,这是一个完全由不可变对象构成的Java网络框架。

Translated by ChatGPT gpt-3.5-turbo/42 on 2023-11-28 at 15:18

sixnines availability badge   GitHub stars