可变对象与不可变对象


172

我试图让我了解可变对象和不可变对象。使用可变对象会带来很多负面影响(例如,从方法中返回字符串数组),但是我很难理解它的负面影响。使用可变对象的最佳实践是什么?您是否应尽可能避免使用它们?


string至少在.NET中是不可变的,而且我认为在许多其他现代语言中也是如此。
多梅尼克(Domenic)

4
这取决于字符串在语言中的真正含义-erlang“字符串”只是一个整数数组,而haskell“字符串”是一个字符数组。
CHII

4
Ruby字符串是可变的字节数组。绝对让我发疯,您可以就地更改它们。
Daniel Spiewak

6
丹尼尔-为什么?您是否发现自己是偶然地这样做?

5
@DanielSpiewak用于从动螺母的解决方案很简单:只需不要这样做。因为您无法在适当位置更改琴弦而被拧紧的解决方案不是那么简单。
卡兹(Kaz)2014年

Answers:


162

好吧,这有几个方面。

  1. 没有参考身份的可变对象可能会在奇数时间导致错误。例如,考虑Person使用基于值的equals方法的bean :

    Map<Person, String> map = ...
    Person p = new Person();
    map.put(p, "Hey, there!");
    
    p.setName("Daniel");
    map.get(p);       // => null
    

    Person实例用作键时,实例在映射中“丢失”,因为实例hashCode和相等性基于可变值。这些值在映射表外部更改,并且所有散列都已过时。理论家喜欢在这一点上竖琴,但是在实践中,我还没有发现这是一个太大的问题。

  2. 另一方面是代码的逻辑“合理性”。这是一个很难定义的术语,涵盖了从可读性到流程的所有内容。通常,您应该能够查看一段代码并轻松了解其功能。但是比这更重要的是,您应该能够使自己相信它可以正确地完成工作。当对象可以跨不同的代码“域”独立更改时,有时会变得难以掌握位置和原因(“ 诡异的动作 ”)。这是一个更难以例证的概念,但是在更大,更复杂的体系结构中经常会遇到这个问题。

  3. 最后,可变对象在并发情况下是致命的。每当您从单独的线程访问可变对象时,都必须处理锁定。这减少了产量,使你的代码大大更难维护。一个足够复杂的系统将这个问题严重地夸大了,以致几乎无法维护(即使对于并发专家而言)。

不变的对象(尤其是不变的集合)避免了所有这些问题。一旦您了解了它们的工作原理,您的代码就会发展为易于阅读,易于维护并且不太可能以奇怪和不可预测的方式失败的代码。不可变对象不仅因为其易于模拟,还因为它们倾向于执行的代码模式甚至更易于测试。简而言之,它们是周围的好习惯!

话虽如此,我在这件事上并不是狂热者。当所有内容都是不可变的时,有些问题只是无法很好地建模。但是我确实认为您应该尝试向该方向尽可能多地推送代码,当然,前提是您所使用的语言使这种观点站得住脚(C / C ++和Java一样使这一点非常困难) 。简而言之:优势在某种程度上取决于您的问题,但是我倾向于不变性。


11
很好的回应。但是,有一个小问题:C ++对不变性没有很好的支持吗?是不是常量,正确性,功能是否足够?
Dimitri C.

1
@DimitriC。:C ++实际上具有一些更基本的功能,最值得注意的是,应该封装非共享状态的存储位置与封装对象标识的存储位置之间有更好的区别。
超级猫

2
如果使用Java编程,约书亚·布洛赫(Joshua Bloch)在他着名的著作《有效的Java》(第15篇)
dMathieuD 2013年

27

不可变对象与不可变集合

关于可变对象与不可变对象的辩论中,最好的观点之一是可以将不可变性的概念扩展到集合中。不变对象是通常代表数据的单个逻辑结构(例如,不变字符串)的对象。当您引用不可变对象时,该对象的内容将不会更改。

不可变集合是一个永不更改的集合。

当我对可变集合执行操作时,我会在适当的位置更改集合,所有引用该集合的实体都将看到更改。

当我对不可变集合执行操作时,引用会返回到反映该更改的新集合。引用了集合的早期版本的所有实体都不会看到更改。

聪明的实现不一定需要复制(克隆)整个集合以提供不变性。最简单的示例是将堆栈实现为单链接列表和推入/弹出操作。您可以重用新集合中先前集合中的所有节点,仅为推送添加一个节点,而为弹出窗口不克隆任何节点。另一方面,单链接列表上的push_tail操作不是那么简单或有效。

不可变与可变变量/引用

某些功能语言采用对象引用本身不变的概念,仅允许单个引用分配。

  • 在Erlang中,所有“变量”都是如此。我只能将对象分配给引用一次。如果要操作集合,则无法将新集合重新分配给旧引用(变量名)。
  • Scala还将其内置到语言中,所有引用都使用varval声明,vals仅是单个赋值并提升了功能样式,但是vars允许使用更类似于C或类似Java的程序结构。
  • var / val声明是必需的,而许多传统语言使用可选的修饰符,例如java中的final和C中的const

易于开发与性能

几乎总是使用不可变对象的原因是为了促进无副作用编程和有关代码的简单推理(尤其是在高度并发/并行的环境中)。如果对象是不可变的,则不必担心基础数据会被另一个实体更改。

主要缺点是性能。这是我在Java中所做的一个简单测试的文章该测试比较了玩具问题中的某些不可变对象与可变对象。

性能问题在许多应用程序中都存在,但不是全部,因此这就是为什么许多大型数值程序包(例如Python中的Numpy Array类)允许就地更新大型数组的原因。这对于使用大型矩阵和矢量运算的应用领域将非常重要。通过在适当位置进行操作,可以解决大量并行数据和计算量大的问题。


11

检查此博客文章:http : //www.yegor256.com/2014/06/09/objects-should-be-immutable.html。它解释了为什么不变的对象比可变的对象更好。简而言之:

  • 不变的对象更易于构造,测试和使用
  • 真正不可变的对象始终是线程安全的
  • 它们有助于避免时间耦合
  • 它们的使用无副作用(无防御性副本)
  • 避免了身份变异性问题
  • 他们总是有失败原子性
  • 它们更容易缓存

10

不变对象是一个非常强大的概念。它们消除了试图使所有客户端的对象/变量保持一致的许多负担。

您可以将它们用于低级,非多态的对象(例如CPoint类),这些对象通常与值语义一起使用。

或者,您可以将它们用于高级的多态接口(例如表示数学函数的IFunction),该接口专用于对象语义。

最大的优点:不变性+对象语义+智能指针使对象所有权成为非问题,默认情况下,该对象的所有客户端都有其自己的私有副本。隐式地,这也意味着在并发存在时的确定性行为。

缺点:当与包含大量数据的对象一起使用时,内存消耗可能成为一个问题。解决此问题的方法可能是保持对符号对象的操作并进行惰性计算。但是,这可能会导致符号计算链,如果接口未设计为容纳符号操作,则可能会对性能产生负面影响。在这种情况下,一定要避免的事情是从方法中返回大量的内存。与链接的符号操作结合使用,可能导致大量内存消耗和性能下降。

因此,不变的对象绝对是我思考面向对象设计的主要方式,但它们不是教条。它们为对象的客户解决了许多问题,但也创建了许多问题,尤其是对于实现者。


我想我误解了第4节的最大优势:不变性+对象语义+智能指针使对象所有权成为“争论”点。所以值得商?吗?我认为您在错误地使用“拟议” ...好像下一句话是该对象从其“拟议”(可辩论)行为中暗示了“确定性行为”。
本杰明

没错,我没有正确使用“模拟”。认为它已更改:)
QBziZ 2014年

6

您应该指定您在说什么语言。对于C或C ++等低级语言,我更喜欢使用可变对象来节省空间并减少内存流失。在高级语言中,不可变对象使代码(尤其是多线程代码)的行为的推理变得容易,因为没有“遥远的鬼动作”。


您是在暗示线程是量子纠缠的吗?这是一个很大的延伸:)如果实际上考虑的话,线程实际上几乎是纠缠在一起的。一个线程进行的更改会影响另一个线程。+1
ndrewxie

4

可变对象只是创建/实例化后可以修改的对象,而不是不能修改的不可变对象(请参阅该主题的Wikipedia页面)。Python列表和元组是编程语言中的一个示例。列表可以修改(例如,新项目可以在创建后添加),而元组则不能。

我真的不认为有一个明确的答案,即哪种方法更适合所有情况。他们俩都有自己的位置。


1

如果类类型是可变的,则该类类型的变量可以具有许多不同的含义。例如,假设一个对象foo有一个field int[] arr,并且它拥有对int[3]持有数字{5,7,9} 的引用。即使字段的类型是已知的,它也至少可以表示四种不同的内容:

  • 潜在共享的引用,所有引用的所有人都只关心它封装了值5、7和9。如果fooarr封装不同的值,则必须用包含所需值的另一个数组替换它。如果要复制foo,则可以给该副本引用arr或使用值{1,2,3}的新数组,以较方便的一种为准。

  • 在宇宙中的任何地方,唯一引用一个封装了值5、7和9的数组。这三个存储位置的集合目前保存着值5、7和9;如果foo要封装值5、8和9,则可以更改该数组中的第二项,也可以创建一个保存值5、8和9的新数组,然后放弃旧的值。请注意,如果要复制,则该副本中的foo一个必须替换arr为对新数组的引用,以便foo.arr保留为对Universe的唯一引用。

  • 对某个数组的引用,该数组归某个其他对象foo(由于某种原因而暴露给该对象)(例如,可能要foo在此存储一些数据)。在这种情况下,arr不封装数组的内容,而是封装其标识。因为用arr对新数组的引用替换将完全改变其含义,所以的副本foo应包含对同一数组的引用。

  • 对数组的引用foo是唯一所有者,但是由于某种原因(例如,它想让其他对象在此处存储数据,这是前一种情况的另一面)而被其他对象保留。在这种情况下,arr封装了数组的标识及其内容。arr用对新数组的引用替换将完全改变其含义,但是使用克隆的arr引用foo.arr将违反foo唯一所有者的假设。因此,没有办法复制foo

从理论上讲,int[]应该是一个很好的简单的定义明确的类型,但是它具有四个非常不同的含义。相比之下,对不可变对象(例如String)的引用通常仅具有一种含义。不可变对象的“力量”大部分源于这一事实。


1

可变实例通过引用传递。

不可变的实例按值传递。

抽象的例子。假设我的硬盘中存在一个名为txtfile的文件。现在,当您向我询问txtfile时,我可以以两种方式返回它:

  1. 创建txtfile的快捷方式和pas快捷方式,或者
  2. txtfile和一份pas复制一份副本。

在第一种模式下,返回的txtfile是一个可变文件,因为在快捷方式文件中进行更改时,也会在原始文件中进行更改。此模式的优点是,每个返回的快捷方式都需要较少的内存(在RAM或HDD上),缺点是每个人(不仅是我,所有者)都具有修改文件内容的权限。

在第二种模式下,返回的txtfile是不可变的文件,因为接收到的文件中的所有更改均不引用原始文件。此模式的优点是只有我(所有者)可以修改原始文件,而缺点是每个返回的副本都需要内存(在RAM或HDD中)。


严格来说,这不是真的。不变实例确实可以通过引用传递,并且通常是这样。主要在需要更新对象或数据结构时进行复制。
Jamon Holmgren

0

如果返回数组或字符串的引用,那么外界可以修改该对象中的内容,从而使它成为可变的(可修改的)对象。


0

不变意味着不能改变,而可变意味着可以改变。

对象与Java中的原语不同。基元内置于类型(布尔值,整数等)中,而对象(类)则是用户创建的类型。

当在类的实现中将基元和对象定义为成员变量时,它们可以是可变的或不可变的。

许多人认为原始的和具有最终修饰符的对象变量是不可变的,但是,事实并非如此。所以final几乎并不意味着变量是不变的。在此处查看示例
http://www.siteconsortium.com/h/D0000F.php

By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.