为什么我们需要不可变类?

95

我无法理解何时需要不可变类的情况。
您是否有遇到过此类要求?或者可以给我们任何一个需要使用此模式的实际示例吗?


14
我很惊讶没有人指出这是重复的。看起来你们只是想轻松地积累分数... :) (1) https://dev59.com/MEjSa4cB1Zd3GeqPJeq0 (2) https://dev59.com/mHA75IYBdhLWcg3wrrJS (3) https://dev59.com/A3RB5IYBdhLWcg3wAjNH - Javid Jamae
20个回答

97
其他答案似乎过于关注解释为什么不可变性很好。虽然它非常好,我尽可能地使用它。然而,这不是你的问题。我将逐点回答你的问题,以确保你得到所需的答案和示例。
“需要”在这里是一个相对的术语。不可变类是一种设计模式,就像任何范例/模式/工具一样,旨在使构建软件更容易。同样,大量代码是在面向对象范例出现之前编写的,但是我算是那些程序员中的一员,“需要”面向对象。不可变类,就像面向对象一样,并不严格必需,但我会像需要它们一样行事。
如果您没有正确的视角查看问题域中的对象,可能无法看到需要不可变对象的要求。如果您不知道何时可以有利地使用它们,那么可能很容易认为问题域不需要任何不可变类。
我经常在将给定对象视为值或固定实例的情况下使用不可变类。这个想法有时取决于观点或视角,但理想情况下,很容易转换到正确的视角来识别好的候选对象。
你可以通过阅读各种书籍/在线文章来发展对不可变类思考方式的好感,从而更好地理解不可变对象在哪些情况下真正有用(即使不是绝对必要)。其中一个很好的起点文章是Java theory and practice: To mutate or not to mutate?
我将尝试举几个例子,以不同的视角(可变 vs 不可变)来看待对象,以澄清我的意思。
... 你能否给我们任何一个使用此模式的实际示例。
既然你要求实际示例,那么我会给你一些,但首先,让我们从一些经典示例开始。
经典值对象
字符串和整数通常被视为值。因此,在Java中,String类和Integer包装类(以及其他包装类)都是不可变的。颜色通常被认为是一个值,因此是不可变的Color类。
反例
相比之下,汽车通常不被视为值对象。建模汽车通常意味着创建一个具有可变状态(里程表、速度、燃油水平等)的类。然而,在某些领域,汽车可能是一个值对象。例如,在查找给定车辆的适当机油的应用程序中,汽车(或特定的汽车型号)可能被视为值对象。
纸牌游戏

你曾经写过一张纸牌程序吗?我写过。我可以将一张纸牌表示为一个可变对象,具有可变的花色和等级。五张抽扑克牌手牌可以是5个固定实例,其中替换我的手中第五张牌意味着将第五张纸牌实例变异为新牌,通过改变其花色和等级ivars。

然而,我倾向于将一张纸牌视为一个不可变的对象,一旦创建就有一个固定的不变花色和等级。我的五张抽扑克牌手牌将是5个实例,并且替换手中的一张牌将涉及丢弃其中之一实例并将新的随机实例添加到我的手牌中。

地图投影

最后一个例子是我在处理一些地图代码时,地图可以以各种投影方式显示。原始代码使用了一个固定但可变的投影实例(类似于上面可变的纸牌)。更改地图投影意味着突变地图投影实例的ivars(投影类型、中心点、缩放等)。

然而,我认为如果将投影视为一个不可变值或固定实例,则设计更简单。更改地图投影意味着使地图引用不同的投影实例,而不是突变地图的固定投影实例。这也使得捕获命名投影(如MERCATOR_WORLD_VIEW)更加简单。


44

不可变类通常更加容易设计、实现和正确使用。例如,字符串类型是一个不可变类:java.lang.String的实现显著简单于C++中的std::string,主要原因在于其不可变性。

不可变性对并发编程尤为重要:不可变对象可以安全地在多个线程之间共享,而可变对象必须通过仔细的设计和实现来确保线程安全 - 这通常远非一个琐碎的任务。

更新:《Effective Java》第二版详细阐述了这个问题——请参见“第15项:最小化可变性”。

还可参考以下相关帖子:


41

《Effective Java》是由Joshua Bloch所著,其中提出了写不可变类的几个原因:

  • 简单性 - 每个类只有一个状态
  • 线程安全 - 因为状态不能被更改,所以不需要同步
  • 以不可变风格编写代码可以使代码更加健壮。想象一下如果String类型不是不可变的;任何返回String类型值的getter方法都需要在返回String之前创建一个防御性副本,否则客户端可能会无意或者恶意地破坏对象的状态。

通常情况下,除非由于严重性能问题而不得不这样做,否则将对象设计为不可变的是很好的实践方法。在这种情况下,可变的构建器对象可以用于构建不可变的对象,例如StringBuilder。


1
每个类是一个状态,还是每个对象? - Tushar Thakur
@TusharThakur,每个对象。 - joker

19

哈希表是一个经典的例子。关键是保证地图的键是不可变的。如果键不是不可变的,并且您更改了键上的一个值,以使hashCode()得出一个新值,则映射现在已经损坏(一个键现在位于哈希表中错误的位置)。


3
我认为关键是不能更改,这是必须的,虽然官方没有要求它不可变。 - Péter Török
不确定您所说的“官方”是什么意思。 - Kirk Woll
1
我认为他的意思是,唯一真正的要求是键不应该被改变...并不是它们完全不能通过不可变性被改变。当然,防止键被改变的简单方法就是在一开始就使它们成为不可变的! :-) - Platinum Azure
2
例如:http://download.oracle.com/javase/6/docs/api/java/util/Map.html:“注意:如果使用可变对象作为映射键,则必须非常小心。”也就是说,可变对象可以用作键。 - Péter Török

8

让我们以整数常量为极端例子。如果我写出这样的语句:“x=x+1”,我希望能百分百确定数字“1”不会在程序中的任何地方因某种原因变成2。

现在,好吧,整数常量并不是一个类,但概念相同。假设我写了:

String customerId=getCustomerId();
String customerName=getCustomerName(customerId);
String customerBalance=getCustomerBalance(customerid);

看起来很简单。但如果字符串是可变的,那么我必须考虑getCustomerName可能会更改customerId的可能性,这样当我调用getCustomerBalance时,我获取的就是另一个客户的余额。现在你可能会说,“为什么有人写一个getCustomerName函数会改变id?那完全没有意义。”但这正是你可能会遇到问题的地方。上面代码的编写者可能认为函数不会更改参数很明显,然后有人需要修改该函数的另一个用法,以处理同名客户拥有多个账户的情况。他说:“哦,这里有一个方便的getCustomer name函数已经查找名字。我只需将其自动更改为下一个具有相同姓名的帐户的ID,并将其放入循环中…”然后您的程序开始神秘地停止工作。那样的编码风格好吗?可能不好。但这确实是在副作用不明显的情况下出现问题的情况。
不可变性仅意味着某些对象的类是常量,并且我们可以将它们视为常量。
(当然,用户可以将不同的“常量对象”分配给变量。有人可以写 String s="hello"; 然后稍后写 s="goodbye"; 除非我把变量声明为final,否则我无法确定它是否在我的代码块中被更改。就像整数常量向我保证“1”始终是相同的数字,但不能保证“x=1”永远不会被写为“x=2”。但是,如果我拥有一个不可变对象的句柄,我可以确信传递给它的任何函数都不能更改它,或者如果我制作两份副本,则持有一份副本的变量的更改将不会影响其他副本。等等。)

8

Java几乎是所有引用的基础。有时一个实例被多次引用。如果更改这样的实例,它将反映在所有引用中。有时您只是不希望这样做以提高鲁棒性和线程安全性。然后,不可变类就很有用了,这样就强制创建一个实例并将其重新分配给当前引用。这样其他引用的原始实例保持不变。

想象一下如果String是可变的,Java会变成什么样子。


13
如果DateCalendar是可变的,哦等等,它们是可变的,OH SH。 - gustafc
1
@gustafc:日历是可变的,考虑到它的工作是进行日期计算(可以通过返回副本来完成,但考虑到日历的重量级,这样做更好)。但日期-是的,那很糟糕。 - Michael Borgwardt
在某些JRE实现中,String是可变的!(提示:一些旧版本的JRockit)。调用string.trim()会导致原始字符串被修剪。 - Salandur
6
那么它就不是一个“JRE实现”。它是类似于JRE的某种实现,但并不是。 - Mark Peters
@Mark Peters:非常正确的陈述。 - Salandur

6

对于未来的访问者,我的建议如下:


不可变对象是一个很好的选择的两种情况是:

在多线程中

在多线程环境中的并发问题可以通过同步来解决,但同步是一项昂贵的任务(不会深入探讨“为什么”),因此如果您使用的是不可变对象,则没有同步来解决并发问题,因为不可变对象的状态无法更改,如果状态不能更改,则所有线程都可以无缝地访问该对象。 因此,在多线程环境中,不可变对象是共享对象的绝佳选择。


作为基于哈希的集合的键

在使用基于哈希的集合时,最重要的一点是键应该是这样的,即其hashCode()应始终返回对象的生命周期内相同的值,因为如果该值更改,则使用该对象进行的旧条目无法检索到哈希基础集合,因此会导致内存泄漏。 由于不可变对象的状态无法更改,因此它们成为哈希基础集合中键的绝佳选择。 因此,如果您将不可变对象用作基于哈希的集合的键,则可以确保不会因此造成任何内存泄漏(当然,如果用作键的对象没有被其他地方引用,仍然可能会发生内存泄漏,但这不是本文的重点)。


6

我们不是非要使用不可变类,但它们可以使某些编程任务更加容易,特别是涉及多个线程时。访问不可变对象无需执行任何锁定操作,并且您已经建立的有关该对象的任何事实将在未来继续保持真实。


6

不可变性有各种原因:

  • 线程安全: 不可变对象既不能被更改,也不能其内部状态发生变化,因此无需对其进行同步。
  • 它还保证了我通过网络发送的任何内容都必须与先前发送的相同。这意味着没有人(窃听者)可以在我的不可变集合中添加随机数据。
  • 它也更容易开发。如果一个对象是不可变的,你就可以保证不会存在子类。例如,String类。

因此,如果你想通过网络服务发送数据,并且希望有一种保证,即你将得到与所发送内容完全相同的结果,请将其设置为不可变的。


我不理解关于通过网络发送的部分,也不理解你说一个不可变类无法被扩展的部分。 - aioobe
@aioobe,发送数据通过网络,例如Web服务,RMI等。并且对于扩展,您不能扩展不可变的String类或ImmutableSet、ImmutableList等。 - Buhake Sindi
无法扩展String、ImmutableSet、ImmutableList等类与不可变性是完全不相关的问题。在Java中,不是所有标记为“final”的类都是不可变的,也不是所有不可变的类都被标记为“final”。 - JUST MY correct OPINION

5
我将从不同的角度来解释这个问题。我发现当阅读代码时,不可变对象可以让我的生活更加轻松。
如果我有一个可变对象,那么当它在我的直接作用域之外被使用时,我永远不确定它的值是什么。假设我在一个方法的局部变量中创建了MyMutableObject,并填充了一些值,然后将其传递给另外五个方法。这五个方法中的任何一个都可以改变我的对象状态,因此必须发生以下两种情况之一:
1. 我必须在思考我的代码逻辑时跟踪五个额外方法的主体。 2. 我必须制作五个无用的防御性副本,以确保正确的值传递给每个方法。
第一种方法使得推理我的代码变得困难。第二种方法使我的代码在性能上非常糟糕——我基本上在模仿具有写入时复制语义的不可变对象,但无论被调用的方法是否实际修改了我的对象状态,我都会一直这样做。
如果我使用MyImmutableObject,我可以确保我设置的就是我的方法生命周期内的值。没有“神秘的行动”会在我的对象下面对其进行更改,我也不需要在调用五个其他方法之前制作我的对象的防御性副本。如果其他方法想要为其目的更改事物,他们必须进行复制——但只有在他们真正需要复制时才会这样做(而不是在每次外部方法调用之前都这样做)。我节省了跟踪可能甚至不在当前源文件中的方法所需的精神资源,并且我避免了系统无休止地制作不必要的防御性副本的开销。
(如果我走出Java世界,进入C++等其他领域,我可以变得更加巧妙。我可以使对象看起来像是可变的,但在幕后,它们会在任何状态更改时透明地克隆——这就是写入时复制,没有人能发现。)

在支持可变值类型且不允许对其进行持久引用的语言中,可变值类型的一个优点是您可以获得您所描述的优点(如果将可变引用类型按引用传递给例程,则该例程可以在运行时修改它,但不能在返回后导致修改),而不会出现不可变对象常常具有的笨拙。 - supercat

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接