提问人:Alex Angas 提问时间:10/18/2008 最后编辑:MachavityAlex Angas 更新时间:5/14/2022 访问量:103676
可变对象与不可变对象
Mutable vs immutable objects
问:
我试图弄清楚可变对象与不可变对象。使用可变对象会得到很多不好的报道(例如,从方法返回字符串数组),但我很难理解这样做的负面影响是什么。使用可变对象的最佳做法是什么?你应该尽可能避免它们吗?
答:
您应该指定您正在谈论的语言。对于像C或C++这样的低级语言,我更喜欢使用可变对象来节省空间并减少内存流失。在高级语言中,不可变对象可以更轻松地推理代码(尤其是多线程代码)的行为,因为没有“远距离的幽灵般的动作”。
评论
嗯,这有几个方面。
没有 reference-identity 的可变对象可能会在奇怪的时间导致错误。例如,考虑一个具有基于值的方法的 Bean:
Person
equals
Map<Person, String> map = ... Person p = new Person(); map.put(p, "Hey, there!"); p.setName("Daniel"); map.get(p); // => null
当用作键时,该实例会在映射中“丢失”,因为它和相等性基于可变值。这些值在映射之外发生了变化,所有哈希都已过时。理论家喜欢在这一点上喋喋不休,但在实践中,我并没有发现这是一个太大的问题。
Person
hashCode
另一个方面是代码的逻辑“合理性”。这是一个很难定义的术语,涵盖了从可读性到流畅性的所有内容。通常,您应该能够查看一段代码并轻松理解它的作用。但更重要的是,你应该能够说服自己,它正确地做了它所做的事情。当对象可以在不同的代码“域”中独立更改时,有时很难跟踪什么在哪里以及为什么(“远处的幽灵般的动作”)。这是一个更难举例说明的概念,但它在更大、更复杂的架构中经常面临。
最后,可变对象在并发情况下是杀手锏。每当您从单独的线程访问可变对象时,您都必须处理锁定问题。这会降低吞吐量,并使代码更难维护。一个足够复杂的系统将这个问题吹得不成比例,以至于几乎不可能维护(即使对于并发专家来说也是如此)。
不可变对象(更具体地说,不可变集合)避免了所有这些问题。一旦你了解了它们是如何工作的,你的代码就会发展成更易于阅读、更易于维护、更不可能以奇怪和不可预测的方式失败的东西。不可变对象甚至更容易测试,这不仅是因为它们易于模拟,而且因为它们倾向于强制执行的代码模式。简而言之,它们到处都是好的做法!
话虽如此,我在这件事上几乎不是一个狂热者。当一切都是不可变的时,有些问题就不能很好地建模。但我确实认为你应该尝试尽可能多地朝这个方向推动你的代码,当然假设你正在使用一种语言,使这成为一个站得住脚的观点(C/C++ 使这变得非常困难,Java也是如此)。简而言之:优点在某种程度上取决于你的问题,但我倾向于不变性。
评论
可变对象只是一个在创建/实例化后可以修改的对象,而不是一个无法修改的不可变对象(请参阅有关该主题的维基百科页面)。编程语言中的一个例子是 Python 列表和元组。列表可以修改(例如,可以在创建后添加新项目),而元组则不能。
我真的不认为有一个明确的答案来说明哪一种更适合所有情况。他们都有自己的位置。
不可变对象是一个非常强大的概念。它们消除了试图保持所有客户端的对象/变量一致的很多负担。
可以将它们用于低级别的非多态对象(如 CPoint 类),这些对象主要与值语义一起使用。
或者,您可以将它们用于高级多态接口(如表示数学函数的 IFunction),该接口专门用于对象语义。
最大优点:不可变性 + 对象语义 + 智能指针使对象所有权成为问题,默认对象的所有客户端都有自己的私有副本。隐含地,这也意味着存在并发时的确定性行为。
缺点:当与包含大量数据的对象一起使用时,内存消耗可能会成为一个问题。解决此问题的方法可能是保持对对象的操作是符号式的,并执行延迟计算。但是,这可能会导致符号计算链,如果接口的设计不适应符号操作,则可能会对性能产生负面影响。在这种情况下,绝对要避免的事情是从方法返回大量内存。结合链式符号操作,这可能会导致大量内存消耗和性能下降。
因此,不可变对象绝对是我思考面向对象设计的主要方式,但它们不是教条。 它们为对象的客户端解决了很多问题,但也创造了许多问题,尤其是对于实现者。
评论
不可变对象与不可变集合
在关于可变对象与不可变对象的争论中,一个更精细的点是将不可变性的概念扩展到集合的可能性。不可变对象是通常表示数据的单个逻辑结构(例如不可变字符串)的对象。当您引用不可变对象时,该对象的内容不会更改。
不可变集合是永不更改的集合。
当我对可变集合执行操作时,我就地更改该集合,并且所有引用该集合的实体都将看到更改。
当我对不可变集合执行操作时,将返回到反映更改的新集合的引用。引用该集合的早期版本的所有实体都不会看到更改。
聪明的实现不一定需要复制(克隆)整个集合来提供这种不可变性。最简单的示例是作为单向链表实现的堆栈和推送/弹出操作。您可以在新集合中重用上一个集合中的所有节点,只为推送添加一个节点,而不为 pop 克隆任何节点。另一方面,单向链表上的push_tail操作并不那么简单或高效。
不可变变量与可变变量/引用
一些函数式语言将不可变性的概念应用于对象引用本身,只允许单个引用赋值。
- 在 Erlang 中,所有“变量”都是如此。我只能将对象分配给引用一次。如果我要对集合进行操作,我将无法将新集合重新分配给旧引用(变量名称)。
- Scala 还将其构建到语言中,所有引用都使用 var 或 val 声明,vals 只是单个赋值并促进函数式风格,但 var 允许更像 C 或类似 Java 的程序结构。
- var/val 声明是必需的,而许多传统语言使用可选修饰符,例如 java 中的 final,C 中的 const。
易于开发与性能
使用不可变对象的原因几乎总是为了促进无副作用的编程和对代码的简单推理(尤其是在高度并发/并行的环境中)。如果对象是不可变的,则不必担心基础数据被另一个实体更改。
主要缺点是性能。这是我在 Java 中所做的一个简单的测试的文章,该测试比较了玩具问题中的一些不可变对象和可变对象。
性能问题在许多应用程序中都没有实际意义,但不是全部,这就是为什么许多大型数值包(例如 Python 中的 Numpy Array 类)允许大型数组的就地更新。这对于使用大型矩阵和向量运算的应用领域非常重要。这种大型数据并行和计算密集型问题通过就地操作实现了极大的加速。
如果类类型是可变的,则该类类型的变量可以具有许多不同的含义。例如,假设一个对象有一个字段,并且它包含对包含数字 {5, 7, 9} 的引用。即使字段的类型是已知的,它至少可以表示四种不同的事物:foo
int[] arr
int[3]
一个潜在共享的引用,其所有持有者只关心它封装了值 5、7 和 9。如果要封装不同的值,则必须将其替换为包含所需值的其他数组。如果要复制 ,可以给副本一个引用或一个新数组,其中包含值 {1,2,3},以更方便者为准。
foo
arr
foo
arr
在宇宙的任何地方,对封装值 5、7 和 9 的数组的唯一引用。集合三个存储位置,目前保存值 5、7 和 9;如果希望它封装值 5、8 和 9,它可以更改该数组中的第二项,或者创建一个包含值 5、8 和 9 的新数组并放弃旧数组。请注意,如果想要复制 ,则必须在副本中替换为对新数组的引用,以便作为该数组在宇宙中任何地方的唯一引用。
foo
foo
arr
foo.arr
对数组的引用,该数组由其他对象拥有,该对象出于某种原因将其暴露(例如,它可能希望在那里存储一些数据)。在此方案中,不封装数组的内容,而是封装其标识。因为替换为对新数组的引用会完全改变其含义,所以 的副本应该包含对同一数组的引用。
foo
foo
arr
arr
foo
对数组的引用,该数组是唯一所有者,但由于某种原因,该数组的引用由其他对象持有(例如,它希望让另一个对象在那里存储数据 - 前一种情况的另一面)。在此方案中,封装数组的标识及其内容。用对新数组的引用替换会完全改变其含义,但使用克隆的引用会违反唯一所有者的假设。因此,无法复制 .
foo
arr
arr
arr
foo.arr
foo
foo
从理论上讲,应该是一个简单定义的类型,但它有四个截然不同的含义。相比之下,对不可变对象(例如)的引用通常只有一个含义。不变物体的大部分“力量”都源于这一事实。int[]
String
如果返回数组或字符串的引用,则外部世界可以修改该对象中的内容,从而使其成为可变(可修改)对象。
不可变意味着不能改变,而可变意味着你可以改变。
对象与 Java 中的基元不同。基元是内置的类型(布尔值、int 等),对象(类)是用户创建的类型。
当基元和对象定义为类实现中的成员变量时,基元和对象可以是可变的,也可以是不可变的。
很多人认为基元和对象变量前面有一个最终修饰符是不可变的,然而,这并不完全正确。因此,final 几乎并不意味着变量是不可变的。请参阅此处
的示例 http://www.siteconsortium.com/h/D0000F.php。
查看这篇博文:http://www.yegor256.com/2014/06/09/objects-should-be-immutable.html。它解释了为什么不可变对象比可变对象更好。总之:
- 不可变对象更易于构造、测试和使用
- 真正不可变的对象始终是线程安全的
- 它们有助于避免时间耦合
- 它们的使用是无副作用的(没有防御副本)
- 避免了标识可变性问题
- 它们总是具有失败原子性
- 它们更容易缓存
不久:
可变实例通过引用传递。
不可变实例按值传递。
抽象示例。假设我的硬盘上存在一个名为 txtfile 的文件。现在,当您要求我为您提供 txtfile 文件时,我可以通过以下两种模式进行:
- 我可以创建 txtfile 的快捷方式并将快捷方式传递给您,或者
- 我可以做一个完整的txtfile文件,并将复制的文件传递给你。
在第一种模式下,返回的文件表示可变文件,因为对快捷方式文件的任何更改也将反映到原始文件中,反之亦然。
在第二种模式下,返回的文件表示不可变文件,因为对复制文件的任何更改都不会反映到原始文件中,反之亦然。
评论
a new read-only copy of the original file
a read-only wrapper file on top of original one
一般可变与不可变
Unmodifiable
- 是一个可修改的包装器。它保证它不能直接更改(但它可能使用后备对象)
Immutable
- 创建后无法更改的状态。当对象的所有字段都是不可变的时,对象是不可变的。这是不可修改对象的下一步
线程安全
Immutable object 的主要优点是它自然是并发环境。并发性最大的问题是可以更改任何线程。但是,如果一个对象是不可变的,那就是线程安全操作。对原始不可变对象的任何修改都会返回一个副本shared resource
read-only
真相来源,无副作用
作为开发人员,您完全可以确定不可变对象的状态不能从任何地方更改(有意或无意)。例如,如果消费者使用不可变对象,他可以使用原始的不可变对象
编译优化
提高性能
缺点:
复制对象比更改可变对象更繁重,这就是为什么它有一些性能占用空间的原因
要创建对象,应使用:immutable
1. 语言水平
每种语言都包含帮助您的工具。例如:
- Java 有 和
final
primitives
- Swift 有 和 [关于]。
let
struct
语言定义变量的类型。例如:
- Java 有 和 类型,
primitive
reference
- Swift 有 和 type[About]。
value
reference
对于对象更方便的是和类型,默认情况下会进行复制。至于类型,它更困难(因为您可以更改对象的状态),但可能。例如,您可以在开发人员级别上使用 pattern 来制作(而不是 )副本。immutable
primitives
value
reference
clone
deep
shallow
2. 开发人员级别
作为开发人员,您不应该提供用于更改状态的接口
当用于就地时,可变集合通常比不可变的集合更快 操作。
然而,可变性是有代价的:你需要更加小心地在两者之间共享它们 程序的不同部分。
在更新共享可变集合时很容易创建错误 出乎意料的是,迫使您寻找大型代码库中的哪一行正在执行不需要的更新。
一种常见的方法是在函数中本地使用可变集合,或者对类使用私有集合,其中 是一个性能瓶颈,但在其他地方使用不可变的集合,速度不那么重要。
这为您提供了最重要的可变集合的高性能,同时又不牺牲 不可变集合在整个应用程序逻辑中为您提供的安全性。
评论
string
是不可变的,至少在 .NET 中是这样,我认为在许多其他现代语言中也是如此。