这里有一些很好的例子,但是我想加入一些个人的例子,其中不变性帮助了很多人。就我而言,我开始设计一个不变的并发数据结构,主要是希望能够放心地与重叠的读写操作并行运行代码,而不必担心竞争条件。约翰·卡马克(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之类的简单图像应用程序实现通常很简单,并且可能无法从此数据结构中获得太多好处,因为许多操作可能只想转换整个图像的每个像素。
但是,例如,在使用非破坏性网格编辑时,许多操作通常只希望变换一部分网格。一种操作可能只想在此处移动一些顶点。另一个人可能只想在那里细分一些多边形。在这里,不可变的数据结构在很大程度上避免了必须返回整个网格的整个副本,而只需要返回网格的一小部分更改的新版本。
减少副作用
有了这些结构,还可以轻松编写函数,以最大程度地减少副作用,而不会因此而造成巨大的性能损失。我发现自己编写了越来越多的函数,这些函数如今通过按值返回整个不可变数据结构而不会产生副作用,即使这看起来有点浪费。
例如,通常,转换一堆位置的诱惑可能是接受矩阵和对象列表,然后以可变方式转换对象。这些天,我发现自己只是返回了一个新的对象列表。
当您的系统中有更多类似功能而不引起任何副作用时,无疑可以更轻松地推断出其正确性并测试其正确性。
廉价副本的好处
因此,无论如何,这些都是我在不变数据结构(或持久数据结构)中使用最多的领域。一开始我也有点过分热心,做了一个不可变的树,不可变的链表和不可变的哈希表,但是随着时间的流逝,我很少发现它们有太多用处。我主要在上面的图中发现了块状的不可变数组状容器的大部分使用。
我仍然有很多使用可变变量的代码(至少对于低级代码来说,这是实际必需的),但是主要的应用程序状态是不可变的层次结构,从不可变的场景深入到其中的不可变组件。一些较便宜的组件仍会完整复制,但最昂贵的组件(例如网格和图像)使用不可变的结构,以便仅对需要转换的部分进行部分便宜的复制。
ConcurrentModificationException
,通常是由同一线程在同一线程上的foreach
循环体中使同一线程中的集合发生变异而导致的。