我试图了解可变对象与不可变对象。使用可变对象会带来很多负面影响(例如,从方法中返回字符串数组),但我无法理解这样做的负面影响。使用可变对象的最佳实践是什么?你应该尽可能避免它们吗?
12 回答
嗯,这有几个方面。
没有引用标识的可变对象有时会导致错误。例如,考虑一个
Person
具有基于值的方法的 beanequals
: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 也是如此) . 简而言之:优势在某种程度上取决于您的问题,但我倾向于更喜欢不变性。
不可变对象与不可变集合
关于可变对象与不可变对象的争论中的一个更好的观点是将不可变性的概念扩展到集合的可能性。不可变对象是通常表示数据的单个逻辑结构的对象(例如不可变字符串)。当你有一个不可变对象的引用时,对象的内容不会改变。
不可变集合是永远不会改变的集合。
当我对可变集合执行操作时,我会更改集合,所有引用该集合的实体都会看到更改。
当我对不可变集合执行操作时,会返回对反映更改的新集合的引用。引用集合的先前版本的所有实体都不会看到更改。
聪明的实现不一定需要复制(克隆)整个集合来提供这种不变性。最简单的例子是实现为单链表的堆栈和推入/弹出操作。您可以在新集合中重用上一个集合中的所有节点,只为推送添加一个节点,而为弹出不克隆任何节点。另一方面,单链表上的 push_tail 操作并不那么简单或高效。
不可变与可变变量/引用
一些函数式语言将不变性的概念用于对象引用本身,只允许单个引用分配。
- 在 Erlang 中,所有“变量”都是如此。我只能将对象分配给一个引用一次。如果我要对集合进行操作,我将无法将新集合重新分配给旧引用(变量名)。
- Scala 也将这一点构建到语言中,所有引用都用var或val声明,vals 只是单一赋值并促进函数式风格,但 vars 允许更像 C 或 Java 的程序结构。
- var/val 声明是必需的,而许多传统语言使用可选修饰符,例如java中的final和C中的const 。
易于开发与性能
几乎总是使用不可变对象的原因是为了促进无副作用的编程和对代码的简单推理(尤其是在高度并发/并行的环境中)。如果对象是不可变的,您不必担心底层数据会被另一个实体更改。
主要缺点是性能。这是我在 Java 中进行的一个简单测试的文章,比较了玩具问题中的一些不可变对象和可变对象。
性能问题在许多应用程序中没有实际意义,但并非全部,这就是为什么许多大型数值包(例如 Python 中的 Numpy Array 类)允许对大型数组进行就地更新。这对于使用大型矩阵和向量运算的应用领域非常重要。这种大型数据并行和计算密集型问题通过就地操作实现了极大的加速。
不可变对象是一个非常强大的概念。它们消除了试图为所有客户端保持对象/变量一致的大量负担。
您可以将它们用于低级、非多态对象(例如 CPoint 类),这些对象主要用于值语义。
或者,您可以将它们用于高级、多态接口——例如表示数学函数的 IFunction——专门用于对象语义。
最大优势:不变性 + 对象语义 + 智能指针使对象所有权成为非问题,默认情况下,对象的所有客户端都有自己的私有副本。隐含地,这也意味着存在并发时的确定性行为。
缺点:当与包含大量数据的对象一起使用时,内存消耗可能会成为一个问题。对此的解决方案可能是保持对对象符号的操作并进行惰性求值。但是,这可能会导致符号计算链,如果接口不是为适应符号操作而设计的,则可能会对性能产生负面影响。在这种情况下绝对要避免的事情是从方法中返回大量内存。结合链式符号操作,这可能会导致大量内存消耗和性能下降。
所以不可变对象绝对是我思考面向对象设计的主要方式,但它们不是教条。它们为对象的客户解决了很多问题,但也创造了很多问题,尤其是对于实现者。
查看这篇博文:http ://www.yegor256.com/2014/06/09/objects-should-be-immutable.html 。它解释了为什么不可变对象比可变对象更好。简而言之:
- 不可变对象更易于构造、测试和使用
- 真正不可变的对象总是线程安全的
- 它们有助于避免时间耦合
- 它们的使用没有副作用(没有防御性副本)
- 避免了身份可变性问题
- 他们总是有失败的原子性
- 它们更容易缓存
您应该指定您正在谈论的语言。对于 C 或 C++ 等低级语言,我更喜欢使用可变对象来节省空间并减少内存流失。在高级语言中,不可变对象可以更容易地推断代码(尤其是多线程代码)的行为,因为没有“远处的诡异动作”。
可变对象只是一个可以在创建/实例化后修改的对象,而不是一个不能修改的不可变对象(请参阅该主题的 Wikipedia 页面)。编程语言中的一个例子是 Python 的列表和元组。列表可以修改(例如,可以在创建后添加新项目)而元组不能。
对于哪种情况更适合所有情况,我真的不认为有一个明确的答案。他们都有自己的位置。
不久:
可变实例通过引用传递。
不可变实例按值传递。
抽象的例子。假设我的硬盘上存在一个名为txtfile的文件。现在,当你要求我给你txtfile文件时,我可以通过以下两种方式进行:
- 我可以创建txtfile的快捷方式并将快捷方式传递给您,或者
- 我可以制作txtfile文件的完整副本并将复制的文件传递给您。
在第一种模式下,返回的文件代表一个可变文件,因为对快捷方式文件的任何更改也会反映到原始文件中,反之亦然。
在第二种模式下,返回的文件代表一个不可变文件,因为对复制文件的任何更改都不会反映到原始文件中,反之亦然。
如果类类型是可变的,则该类类型的变量可以具有多种不同的含义。例如,假设一个对象foo
有一个 field int[] arr
,并且它持有一个对int[3]
持有数字 {5, 7, 9} 的 a 的引用。即使该字段的类型已知,它也至少可以表示四种不同的事物:
一个潜在共享的引用,所有的持有者只关心它封装了值 5、7 和 9。如果
foo
想要arr
封装不同的值,它必须用包含所需值的不同数组替换它。如果想要制作 的副本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
)的引用通常只有一个含义。不可变对象的大部分“力量”都源于这一事实。
当用于就地操作时,可变集合通常比不可变集合更快。
然而,可变性是有代价的:您需要更加小心地在程序的不同部分之间共享它们。
在共享可变集合意外更新的情况下很容易创建错误,迫使您寻找大型代码库中的哪一行正在执行不需要的更新。
一种常见的方法是在函数内本地使用可变集合或在存在性能瓶颈的类中使用私有集合,但在速度不太受关注的其他地方使用不可变集合。
这在最重要的地方为您提供了可变集合的高性能,同时不会牺牲不可变集合在整个应用程序逻辑中为您提供的安全性。
如果您返回数组或字符串的引用,那么外部世界可以修改该对象中的内容,从而使其成为可变(可修改)对象。
不可变意味着不能改变,可变意味着你可以改变。
对象与 Java 中的基元不同。基元内置于类型(布尔、整数等)中,对象(类)是用户创建的类型。
当在类的实现中定义为成员变量时,基元和对象可以是可变的或不可变的。
很多人认为原语和对象变量前面有一个 final 修饰符是不可变的,然而,这并不完全正确。所以 final 几乎并不意味着变量不可变。请参阅此处的示例
http://www.siteconsortium.com/h/D0000F.php。
通用可变 vs 不可变
Unmodifiable
- 是可修改的包装器。它保证它不能直接更改(但它可能使用支持对象)
Immutable
- 创建后无法更改的状态。当对象的所有字段都不可变时,对象就是不可变的。这是不可修改对象的下一步
线程安全
不可变对象的主要优点是它是一个自然的并发环境。并发的最大问题是shared resource
可以更改任何线程。但是如果一个对象是不可变的,那么read-only
它就是线程安全的操作。对原始不可变对象的任何修改都会返回一个副本
真实来源,无副作用
作为开发人员,您完全确定不可变对象的状态不能从任何地方(有意或无意)更改。例如,如果消费者使用不可变对象,他可以使用原始的不可变对象
编译优化
提高性能
坏处:
复制对象比更改可变对象更繁重,这就是为什么它有一些性能足迹
要创建一个immutable
对象,您应该使用:
1.语言水平
每种语言都包含可以帮助您的工具。例如:
- Java 有
final
和primitives
- 斯威夫特有
let
和struct
[关于]。
语言定义了一种变量。例如:
- Java 有
primitive
和reference
类型, - Swift 有
value
并reference
输入[About]。
对于immutable
object 更方便的是默认复制的 type primitives
。value
至于reference
类型,它更难(因为你可以改变对象的状态)但可能。例如,您可以clone
在开发人员级别使用模式来制作deep
(而不是shallow
)副本。
2. 开发者级别
作为开发人员,您不应提供用于更改状态的接口