后来这个问答已经有了不错的答案,但是我想作为一个外国人习惯于从较低级别的内存中查看位和字节的观点。
我对不变的设计感到非常兴奋,即使是从C的角度来看,也从寻找新方法有效地编程我们如今拥有的这种野兽硬件的角度都感到兴奋。
较慢/较快
关于它是否会使事情变慢的问题,将是一个机器人的答案yes
。在这种非常技术性的概念水平上,不变性只会使事情变慢。硬件在不偶尔分配内存的情况下表现最佳,而只能修改现有的内存(为什么我们有类似时间局部性的概念)。
一个实际的答案是maybe
。在任何非平凡的代码库中,性能仍然基本上是生产力指标。即使我们忽略了这些错误,我们通常也不会发现在竞争条件下绊倒的可怕的可维护代码库是最有效的。效率通常是优雅和简单的功能。微观优化的高峰可能会有些冲突,但通常是为最小和最关键的代码部分保留的。
转换不可变的位和字节
从低级别的观点来看今后,如果我们x射线的概念,如objects
和strings
等等,在它的心脏只是在具有不同的速度/大小特性的各种形式的存储器(速度和存储器的硬件的尺寸通常为位和字节互斥)。
当我们重复访问相同的内存块时(例如上图),计算机的内存层次结构会喜欢它,因为它将经常访问的内存块保持为最快的内存形式(L1高速缓存,例如,几乎与寄存器一样快)。我们可能会重复访问完全相同的内存(多次重用)或重复访问该块的不同部分(例如:循环访问一个连续块中的元素,这些元素重复访问该存储块的各个部分)。
如果修改该内存最终想要在侧面创建一个全新的内存块,我们最终会在该过程中投入一把扳手,如下所示:
在这种情况下,访问新的内存块可能需要强制执行页面错误和高速缓存未命中,以将其移回到最快的内存形式(一直到寄存器中)。那可能是真正的性能杀手。
但是,有一些方法可以缓解这种情况,即使用已经涉及的预分配内存的保留池。
大骨料
从更高层次的观点引起的另一个概念性问题是简单地批量复制非常大的聚合。
为了避免过于复杂的图表,让我们想象一下这个简单的内存块在某种程度上是昂贵的(在难以置信的有限硬件上可能是UTF-32字符)。
在这种情况下,如果我们想将“ HELP”替换为“ KILL”并且该存储块是不可变的,则我们将必须完整地创建一个全新的块以创建唯一的新对象,即使其中只有一部分已更改:
扩展我们的想象力,仅仅为了使一小部分变得独特,对其他所有内容的这种深层复制可能会非常昂贵(在现实情况下,此存储块会大得多,从而造成问题)。
然而,尽管有这样的花费,但这种设计将趋向于不易发生人为错误。任何使用过具有纯函数功能语言的人都可能会对此感到赞赏,尤其是在多线程情况下,我们可以在无需关心的情况下对此类代码进行多线程处理。通常,人类程序员倾向于跳过状态变化,尤其是那些导致外部副作用导致当前函数范围之外的状态变化的程序员。在这种情况下,即使混合中的外部状态发生可变的变化,即使从外部错误(异常)中恢复也非常困难。
减轻这种冗余复制工作的一种方法是使这些存储块成为字符的指针(或引用)的集合,如下所示:
不好意思,我没意识到L
在制作图表时我们不需要做得唯一。
蓝色表示复制的数据浅。
...不幸的是,这将使每个字符的指针/参考成本变得非常昂贵。此外,我们可能会将字符的内容分散到整个地址空间中,并最终以大量页面错误和缓存未命中的形式为它付出代价,这很容易使此解决方案比复制整个内容更糟糕。
即使我们谨慎地连续分配这些字符,也可以说机器可以将8个字符和8个指向某个字符的指针加载到高速缓存行中。我们最终像这样加载内存以遍历新字符串:
在这种情况下,我们最终需要加载价值7个不同的高速缓存行的连续内存来遍历此字符串,理想情况下,我们只需要3个。
整理数据
为了缓解上述问题,我们可以应用相同的基本策略,但使用8个字符的粗略级别,例如
结果需要加载4个高速缓存行的数据(1个用于3个指针,3个用于字符)以遍历该字符串,这仅比理论上的最佳值短1个。
因此,这还算不错。有一些内存浪费,但是内存充足,如果多余的内存只是不经常访问的冷数据,那么消耗更多内存并不会减慢速度。它仅适用于热的连续数据,减少的内存使用和速度通常是并行的,我们希望将更多的内存放入单个页面或缓存行中,并在逐出之前对其进行访问。这种表示非常易于缓存。
速度
因此,利用上述表示形式可以实现相当不错的性能平衡。不可变数据结构的最关键性能用途可能是修改大块数据并使它们在此过程中唯一,同时浅层复制未修改的数据。这也确实暗示了在多线程上下文中安全引用浅表复制段的原子操作的一些开销(可能正在进行一些原子引用计数)。
但是,只要这些粗略的数据片段以足够粗糙的水平表示,那么许多此类开销就会减少甚至可能变得微不足道,同时仍然为我们提供安全性和便捷性,并且无需编写外部代码就能以纯净的形式编码和多线程处理更多函数效果。
保留新旧数据
从性能的角度(从实际意义上来说),不变性可能是最有帮助的地方,当我们可以尝试制作大数据的完整副本,以使其在可变的环境中变得唯一时,目的是从中产生新的东西。当我们可以通过精心设计的不可变设计使其中的一点点与众不同时,就已经存在了一种我们想保留新旧的方式。
示例:撤消系统
一个示例是撤消系统。我们可能会更改数据结构的一小部分,并希望保留我们可以撤消的原始格式和新格式。通过这种不变的设计,仅使数据结构的较小的,经过修改的部分成为唯一的,我们可以简单地将旧数据的副本存储在撤消条目中,而只需支付添加的唯一部分数据的存储成本。这在生产率(使撤消系统的实施成为小菜一碟)和性能之间提供了非常有效的平衡。
高级界面
上述情况仍然有些尴尬。在局部函数上下文中,可变数据通常是最简单,最直接的修改方法。毕竟,修改数组的最简单方法通常是遍历数组并一次修改一个元素。如果我们有大量的高级算法可以选择来转换数组,并且必须选择合适的算法以确保在修改了部分的同时制作所有这些大块浅副本,那么我们最终可能会增加知识开销。变得独一无二。
在这些情况下,最简单的方法可能是在函数上下文内本地使用可变缓冲区(它们通常不会使我们绊倒),该可变缓冲区原子地将更改提交给数据结构以获取新的不可变副本(我相信某些语言会调用这些“瞬变”)...
...或者我们可以简单地对数据上越来越高级的转换函数建模,以便我们可以隐藏修改可变缓冲区并将其提交给结构的过程,而无需涉及可变逻辑。无论如何,这还不是一个被广泛研究的领域,如果我们更多地拥抱不可变的设计,并为如何转换这些数据结构提供有意义的接口,那么我们的工作就被淘汰了。
数据结构
这里出现的另一件事是,在性能至关重要的上下文中使用的不变性可能会希望将数据结构分解为块状数据,在这些数据块中,块的大小不要太小,也不要太大。
链接列表可能需要进行一些更改以适应此情况,并转变为展开列表。大的连续数组可能会变成一个指针数组,这些指针变成具有用于随机访问的模索引的连续块。
它可能会以一种有趣的方式改变我们看待数据结构的方式,同时推动这些数据结构的修改功能看起来更笨重,从而掩盖了在此处浅层复制某些位并使其他位唯一的额外复杂性。
性能
无论如何,这是我对该主题的较低层次的看法。从理论上讲,不变性的代价可能从非常大到很小。但是,非常理论上的方法并不总是使应用程序快速运行。它可能使它们具有可伸缩性,但现实世界中的速度通常需要拥抱更实际的思维方式。
从实践的角度来看,诸如性能,可维护性和安全性之类的质量往往会变得一团糟,尤其是对于非常大的代码库。虽然从某种意义上说,性能会因不变性而降低,但很难说它对生产率和安全性(包括线程安全性)的好处。随着这些的增加,通常可以提高实际性能,这仅仅是因为开发人员有更多的时间来调整和优化他们的代码而不会被错误所困扰。
因此,我认为从这种实际意义上讲,不变的数据结构实际上可能在很多情况下有助于提高性能,听起来似乎很奇怪。理想的世界可能会寻求两者的混合体:不可变的数据结构和可变的数据结构,可变的数据结构通常在非常局部的范围内(例如:函数的局部区域)非常安全地使用,而可变的结构可以避免外在的影响直接执行,并将对数据结构的所有更改转换为原子操作,从而生成新版本,而不会出现竞争情况。