没有并发性时,不变性是否值得?


53

似乎总是/经常提到线程安全是使用不可变类型(尤其是集合)的主要好处。

我有一种情况,我想确保方法不会修改字符串字典(在C#中是不可变的)。我想尽可能地约束事物。

但是,我不确定将依赖项添加到新程序包(Microsoft不可变集合)是否值得。性能也不是大问题。

因此,我想我的问题是,在没有硬性能要求且没有线程安全问题的情况下,是否强烈建议使用不可变集合?考虑一下值语义(在我的示例中)可能是也可能不是要求。


1
并发修改不必意味着线程。只需查看恰当地命名的名称ConcurrentModificationException,通常是由同一线程在同一线程上的foreach循环体中使同一线程中的集合发生变异而导致的。

1
我的意思是您没错,但这与OP的要求不同。抛出该异常是因为不允许在枚举期间进行修改。例如,使用ConcurrentDictionary仍然会出现此错误。
edthethird 2014年

13
也许您还应该问自己一个相反的问题:可变性何时值得?
乔治,

2
在Java中,如果可变性影响hashCode()equals(Object)导致更改,则在使用时可能会导致错误Collections(例如,在一个HashSet对象中存储了一个“存储桶”,并且在更改后应将其移到另一个对象)。
SJuan76

2
@DavorŽdralo就高级语言的抽象而言,普遍的不变性是相当温和的。它只是创建和静默丢弃“临时值”的非常常见的抽象(甚至存在于C中)的自然扩展。也许您的意思是说这是使用CPU的一种低效方法,但是这种说法也有缺陷:易变性但动态语言的性能通常比仅不可变但静态的语言差,部分原因是有些聪明(但最终很简单)技巧,优化处理不可变数据的程序:线性类型,砍伐森林等

Answers:


101

不变性简化了以后阅读代码时需要进行心理跟踪的信息量。对于可变变量,尤其是可变的类成员,很难在不使用调试器运行代码的情况下,知道它们在您正在阅读的特定行处的状态。不可变的数据很容易推论 -它将始终是相同的。如果要更改它,则需要设置一个新值。

老实说,我希望默认情况下使事物不可变,然后将其更改为可变的,以证明它们是必需的,无论这意味着您需要性能,还是拥有的算法对于不变性都没有意义。


23
+1并发是同时发生的突变,但是随着时间的流逝而传播的突变可能同样难以推理
guillaume31 2014年

20
对此进行扩展:可以将依赖于可变变量的函数视为带有额外的隐藏参数,这是可变变量的当前值。任何更改可变变量的函数都可以视为产生了额外的返回值,即可变状态的新值。在查看一段代码时,您不知道它是否依赖于或更改了可变状态,因此您必须从心中找出并跟踪更改。这还会在共享可变状态的任何两段代码之间引入耦合,耦合不好。
Doval

29
@Mehrdad People也设法在汇编中提出大型程序数十年。然后我们做了几十年的C.
Doval 2014年

11
@Mehrdad当对象很大时,复制整个对象不是一个好的选择。我不知道为什么改进涉及的数量级很重要。您是否会仅仅因为生产率不是三位数的增长而拒绝提高20%的生产率(请注意:任意数字)?不变性是理智的默认设置;您可以偏离它,但是您需要一个理由。
Doval 2014年

9
@Giorgio Scala让我意识到,甚至很少需要使价值变得可变。每当使用该语言时,我都会将所有内容都val设为a ,并且只有在非常非常罕见的情况下,我才发现需要将某些内容更改为a var。我在任何给定语言中定义的许多“变量”都只是用来保存一个存储一些计算结果的值,不需要更新。
KChaloux 2014年

22

您的代码应表达您的意图。如果您不希望对象一旦创建就被修改,则使其无法修改。

不变性有几个好处:

  • 原作者的意图得到了更好的表达。

    您如何才能知道在下面的代码中修改名称将导致应用程序稍后在某个地方生成异常?

    public class Product
    {
        public string Name { get; set; }
    
        ...
    }
    
  • 确保对象不会出现在无效状态下比较容易。

    您必须在构造函数中进行控制,并且只能在其中进行控制。另一方面,如果您有一堆修改器和对象的方法,则此类控件可能会变得特别困难,尤其是在例如两个字段应同时更改以使对象生效时。

    例如,如果地址不是null GPS坐标不是null,则对象是有效的,但是如果同时指定了地址和GPS坐标,则该对象无效。您能想象,如果地址和GPS坐标都有一个setter或两者都是可变的,可以验证这一点吗?

  • 并发。

顺便说一句,在您的情况下,您不需要任何第三方软件包。.NET Framework已经包含一个ReadOnlyDictionary<TKey, TValue>类。


1
+1,特别是对于“您必须在构造函数中且仅在此位置进行控制”。海事组织这是一个巨大的优势。
Giorgio 2014年

10
另一个好处:复制对象是免费的。只是一个指针。
罗伯特·格兰特

1
@MainMa谢谢您的回答,但据我了解,ReadOnlyDictionary不能保证其他人不会更改基础字典(即使没有并发,我可能也想将对原始字典的引用保存在该方法所属的对象中以便以后使用)。ReadOnlyDictionary甚至在一个奇怪的命名空间中声明:System.Collections.ObjectModel。
2014年

2
@Den:这与我的宠物激怒之一有关:人们将“只读”和“不可变”视为同义词。如果将对象封装在只读包装器中,并且在Universe中不存在任何其他引用或将其保留在Universe中的任何位置,则包装该对象将使其不可变,并且对包装器的引用可以用作封装对象状态的简写。其中包含的对象。但是,没有代码可以确定是否是这种情况的机制。相反,因为包装器隐藏了包装对象的类型,所以包装了一个不可变的对象...
supercat

2
...将使代码无法知道是否可以安全地将结果包装器视为不可变的。
超级猫2014年

13

使用不变性的原因有很多。例如

对象A包含对象B。

外部代码查询您的对象B,然后将其返回。

现在您有三种可能的情况:

  1. B是不变的,没问题。
  2. B是易变的,您制作一份防御性副本并将其退还。性能下降但没有风险。
  3. B是可变的,您将其返回。

在第三种情况下,用户代码可能无法意识到您所做的事情,并且可能会更改对象,从而在您无法控制或看不到发生的情况下更改对象的内部数据。


9

不变性也可以大大简化垃圾收集器的实现。从GHC的Wiki

数据不可变性迫使我们产生大量临时数据,但它也有助于迅速收集这些垃圾。诀窍是,不变的数据永远不会指向更年轻的值。实际上,在创建旧值时还不存在较年轻的值,因此无法从头开始指出它。而且,由于永远不会修改值,因此以后也无法指向它们。这是不可变数据的关键属性。

这极大地简化了垃圾收集(GC)。在任何时候,我们都可以扫描最后创建的值,并释放同一集合中未指向的值(当然,实时值层次结构的真正根位于堆栈中)。[...]因此,它具有违反直觉的行为:较大的百分比值是垃圾,它的运行速度更快。[...]


5

扩展KChaloux总结得非常好...

理想情况下,您有两种类型的字段,因此有两种使用它们的代码。这两个字段都是不可变的,并且代码不必考虑可变性。或字段是可变的,我们需要编写代码来拍摄快照(int x = p.x)或妥善处理此类更改。

根据我的经验,大多数代码都属于乐观代码,介于两者之间:假定第一个调用p.x与第二个调用具有相同的结果,它自由地引用可变数据。在大多数情况下,这是事实,但事实证明事实并非如此。哎呀。

所以,真的,将这个问题转过来:使我变得可变的原因是什么

  • 减少内存分配/释放?
  • 天生可变吗?(例如柜台)
  • 保存修改器,水平噪声?(常量/最终)
  • 使一些代码更短/更容易?(初始默认值,之后可能会覆盖)

您是否编写防御性代码?不变性将为您节省一些复制空间。您是否编写乐观代码?不变性将使您免于那个奇怪的,不可能的错误的疯狂。


3

不变性的另一个好处是,这是将这些不变性对象汇总到池中的第一步。然后,您可以对其进行管理,以免创建多个在概念和语义上表示同一事物的对象。一个很好的例子是Java的String。

在语言学中一个众所周知的现象是几个单词出现很多,也可能出现在其他上下文中。因此String,可以创建一个不可变对象,而不是创建多个对象。但是随后您需要保留一个池管理器来照顾这些不可变的对象。

这样可以节省大量内存。这也是一篇有趣的文章:http : //en.wikipedia.org/wiki/Zipf%27s_law


1

在Java,C#和其他类似语言中,类类型字段可用于标识对象或封装这些对象中的值或状态,但是这些语言在这些用法之间没有区别。假设一个类对象George具有一个type字段char[] chars;。该字段可以封装以下任一字符序列:

  1. 永远不会被修改的数组,也不会暴露于任何可能修改它的代码,但是可能存在外部引用。

  2. 一个数组,不存在外部引用,但George可以自由修改。

  3. 数组归乔治所有,但可能会存在外部视图,这些视图可以代表乔治的当前状态。

此外,该变量可以代替封装字符序列,而将实时视图封装为某个其他对象拥有的字符序列

如果chars当前封装字符序列[wind],而George希望chars封装字符序列[wand],则George可以做很多事情:

答:构造一个包含字符[wand]的新数组,然后更改chars以标识该数组,而不是原来的数组。

B.以某种方式标识一个预先存在的字符数组,该数组将始终容纳字符[wand],并进行更改chars以标识该数组,而不是原来的数组。

C.改变由所识别的阵列的第二个字符chars到一个a

在情况1中,(A)和(B)是获得所需结果的安全方法。在情况(2)中,(A)和(C)是安全的,但(B)不会[不会引起直接问题,但是由于George会假定它拥有该数组的所有权,因此会假定它可以随意更改数组]。在情况(3)中,选项(A)和(B)会破坏任何外部视图,因此只有选项(C)是正确的。因此,要知道如何修改字段封装的字符序列,就需要知道字段是哪种语义类型。

如果代码不是使用type字段char[]封装了可能可变的字符序列,而是使用type字段String封装了不可变的字符序列,那么上述所有问题都将消失。所有类型的字段都String使用永不改变的可共享对象封装字符序列。因此,如果类型为String封装“ wind”,使其成为“ wand”的唯一方法是使它标识不同的对象-持有“ wand”的对象。在代码仅保留对对象的引用的情况下,对对象进行更改可能比创建新对象更有效,但是,只要一类是可变的,就必须区分其封装值的不同方式。我个人认为应该使用Apps Hungarian(我认为这四种用法char[]在语义上是不同的类型,即使类型系统认为它们是相同的-正是Apps Hungarian光芒四射的情况),但是由于它避免这种歧义的最简单方法不是设计只封装值一种方式的不可变类型。


这看起来是一个合理的答案,但有点难以理解。
2014年

1

这里有一些很好的例子,但是我想加入一些个人的例子,其中不变性帮助了很多人。就我而言,我开始设计一个不变的并发数据结构,主要是希望能够放心地与重叠的读写操作并行运行代码,而不必担心竞争条件。约翰·卡马克(John Carmack)在谈到这样的想法时,曾有过这样的演讲启发了我去做。这是一个非常基本的结构,实现起来很简单:

在此处输入图片说明

当然,它还有更多的花哨之处,例如能够在恒定时间内删除元素并留下可回收的孔,并且如果块变空并可能在给定的不可变实例中释放,则块将被取消引用。但是基本上是要修改结构,您可以修改“临时”版本并原子地提交对它所做的更改,以获得不会触及旧版本的新不可变副本,而新版本只会创建块的新副本,在浅层复制和引用其他计数时,必须使其唯一。

但是,我没有发现它对于多线程目的很有用。毕竟,仍然存在概念上的问题,例如,物理系统在玩家尝试在世界各地移动元素时同时应用物理。您要使用哪一不变的转换数据副本,是播放器转换的副本还是物理系统转换的副本?因此,除了具有可变的数据结构以可变的数据结构以更智能的方式锁定并阻止对缓冲区相同部分的重复读取和写入以避免线程阻塞之外,我还没有真正找到解决此基本概念问题的简单方法。约翰·卡马克(John Carmack)似乎已经想出了在游戏中解决问题的方法。至少他在谈论它,就像他几乎不用打开蠕虫车就能看到一个解决方案一样。在这方面,我还没有得到他的帮助。如果我尝试并行化不可变​​对象周围的所有内容,那么我所看到的只是无休止的设计问题。我希望我可以花一天的时间来思考他的想法,因为我的大部分努力都是从他提出的那些想法开始的。

但是,我发现这种不变的数据结构在其他领域具有巨大的价值。我什至现在都用它来存储图像,这确实很奇怪,并且确实使随机访问需要更多指令(右移和按位and以及指针间接层),但是我将在下面介绍这些好处。

撤消系统

我发现受益于此的最直接的地方之一是撤消系统。撤消系统代码曾经是我所在领域(视觉效果行业)中最容易出错的事情之一,不仅在我工作过的产品中,而且在竞争产品中(它们的撤消系统也很脆弱),因为存在很多不同的东西担心正确撤消和重做的数据类型(属性系统,网格数据更改,着色器更改等不基于属性的更改(例如彼此交换),场景层次结构更改(例如更改孩子的父母,图像/纹理更改,等等等等等等)。

因此,所需的撤消代码量很大,通常可以与实现撤消系统必须记录其状态变化的系统的代码量相媲美。通过依靠这种数据结构,我能够将撤消系统简化为:

on user operation:
    copy entire application state to undo entry
    perform operation

on undo/redo:
    swap application state with undo entry

通常,当您的场景数据跨千兆字节完整复制时,上面的代码效率极低。但是,这种数据结构只是浅表复制了未更改的内容,实际上,它便宜到足以存储整个应用程序状态的不变副本。因此,现在我可以像上面的代码一样轻松地实现撤消系统,而只是专注于使用这种不可变的数据结构,使复制应用程序状态的未更改部分变得越来越便宜。自从我开始使用这种数据结构以来,我所有的个人项目都仅使用此简单模式就具有撤消系统。

现在这里仍然有一些开销。上次我测量它大约10 KB只是为了浅复制整个应用程序状态而不对其进行任何更改(这与场景的复杂性无关,因为场景是按层次结构排列的,所以如果根目录下没有任何更改,则仅根目录被复制而不必下降到子级中)。与仅存储增量的撤消系统所需的字节数相比,这远非0字节。但是,如果每次操作的撤消开销为10 KB,那么每100个用户操作仍然只有1 MB。另外,如果需要的话,将来我仍然可以将其进一步压缩。

例外安全

复杂应用程序的异常安全性不是小事。但是,当您的应用程序状态是不可变的,而您仅使用瞬态对象来尝试执行原子更改事务时,它本质上是异常安全的,因为如果代码的任何部分抛出,则在提供新的不可变副本之前,将瞬态丢弃。因此,这琐碎了我在复杂的C ++代码库中发现的最困难的事情之一。

太多的人经常只使用C ++中符合RAII的资源,并认为这足以保证异常安全。通常情况并非如此,因为函数通常会导致超出其作用域局部状态的副作用。在这些情况下,通常需要开始处理范围保护和复杂的回滚逻辑。这种数据结构使它成为可能,所以我通常不需要理会这些功能,因为这些函数不会引起副作用。他们返回的是应用程序状态的转换后的不变副本,而不是转换应用程序的状态。

无损编辑

在此处输入图片说明

无损编辑基本上是在不触摸原始用户数据的情况下将操作分层/堆叠/连接在一起(仅输入数据而输出数据而无需触摸输入)。使用诸如Photoshop之类的简单图像应用程序实现通常很简单,并且可能无法从此数据结构中获得太多好处,因为许多操作可能只想转换整个图像的每个像素。

但是,例如,在使用非破坏性网格编辑时,许多操作通常只希望变换一部分网格。一种操作可能只想在此处移动一些顶点。另一个人可能只想在那里细分一些多边形。在这里,不可变的数据结构在很大程度上避免了必须返回整个网格的整个副本,而只需要返回网格的一小部分更改的新版本。

减少副作用

有了这些结构,还可以轻松编写函数,以最大程度地减少副作用,而不会因此而造成巨大的性能损失。我发现自己编写了越来越多的函数,这些函数如今通过按值返回整个不可变数据结构而不会产生副作用,即使这看起来有点浪费。

例如,通常,转换一堆位置的诱惑可能是接受矩阵和对象列表,然后以可变方式转换对象。这些天,我发现自己只是返回了一个新的对象列表。

当您的系统中有更多类似功能而不引起任何副作用时,无疑可以更轻松地推断出其正确性并测试其正确性。

廉价副本的好处

因此,无论如何,这些都是我在不变数据结构(或持久数据结构)中使用最多的领域。一开始我也有点过分热心,做了一个不可变的树,不可变的链表和不可变的哈希表,但是随着时间的流逝,我很少发现它们有太多用处。我主要在上面的图中发现了块状的不可变数组状容器的大部分使用。

我仍然有很多使用可变变量的代码(至少对于低级代码来说,这是实际必需的),但是主要的应用程序状态是不可变的层次结构,从不可变的场景深入到其中的不可变组件。一些较便宜的组件仍会完整复制,但最昂贵的组件(例如网格和图像)使用不可变的结构,以便仅对需要转换的部分进行部分便宜的复制。


0

已经有很多好的答案。这只是.NET特有的额外信息。我浏览了旧的.NET博客文章,并从Microsoft不可变集合开发人员的角度发现了一个不错的总结:

  1. 快照语义,使您可以以接收者可以依赖的方式共享您的集合,而这些需求永远不会改变。

  2. 多线程应用程序中的隐式线程安全性(访问集合不需要锁)。

  3. 每当您有一个接受或返回集合类型的类成员,并且希望在合同中包含只读语义时。

  4. 功能编程友好。

  5. 允许在枚举期间修改集合,同时确保原始集合不变。

  6. 它们实现了代码已处理的相同IReadOnly *接口,因此迁移很容易。

如果有人将您交给ReadOnlyCollection,IReadOnlyList或IEnumerable,则唯一的保证就是您无法更改数据–无法保证递给您集合的人不会更改它。但是,您通常需要一定的信心,它不会改变。这些类型不提供事件以在其内容更改时通知您,如果它们确实发生更改,是否可能在其他线程上发生,可能是在枚举其内容时发生?这种行为将导致数据损坏和/或应用程序中的随机异常。

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.