JavaScript中似乎有一种趋向于将数据结构视为不变的趋势。例如,如果您需要更改一个对象的单个属性,则最好使用新属性创建一个全新的对象,然后从旧对象复制所有其他属性,然后对旧对象进行垃圾回收。 (无论如何,这都是我的理解。)

我最初的反应是,这听起来像是性能上的问题。由比我更聪明的人撰写,并且似乎非常关心性能,所以这使我怀疑我对垃圾的理解(及其对性能的影响)是否错误。

不变性是否有性能优势我很想念,它们是否超过了创建那么多垃圾的弊端?

评论

他们非常关注性能,部分原因是不可变性(有时)会降低性能成本,并且他们希望尽可能降低性能成本。从某种意义上说,不变性本身仅具有性能优势,因为它使得编写多线程代码变得更加容易。

以我的经验,性能仅是以下两种情况的有效关注点:一种是在一秒内执行一次30次以上的操作;另一种是每次执行后其效果都会提高(Windows XP曾经发现一个错误,导致Windows Update花费了很多时间) O(pow(n,2))对其历史记录中的每个更新。)大多数其他代码是对事件的立即响应;例如:单击,API请求或类似请求,并且只要执行时间恒定,那么清除任何数量的对象就几乎没有关系。

另外,请考虑存在不可变数据结构的有效实现。也许这些方法没有可变的方法有效,但可能比幼稚的实现方法还要有效。参见例如Chris Okasaki的纯功能数据结构

@ Katana314:对我来说,超过30次仍然不足以证明担心性能。我将编写的小型CPU仿真器移植到node.js,然后node在20MHz左右(每秒2000万次)执行虚拟CPU。因此,如果我每秒执行1000次以上的操作,我只会担心性能(即使那样,我也不会真正担心,直到每秒执行1000000次操作,因为我知道一次可以轻松执行10次以上) 。

@RobertHarvey“从某种意义上说,不可变性本身仅具有性能上的优势,因为它使得编写多线程代码更加容易。”事实并非完全如此,不变性允许非常普遍的共享,而没有实际后果。在可变环境中这是非常不安全的。这使您认为,就像O(1)数组切片和O(log n)插入二叉树,同时仍然可以自由使用旧的树一样,另一个例子是tails,它使用列表尾部的所有尾部[1, 2] = [[1,2],[2],[]]仅占用O(n)的时间和空间,但元素计数为O(n ^ 2)

#1 楼


例如,如果您需要更改一个对象的单个属性,则最好使用新属性创建一个全新的对象,然后从旧对象复制所有其他属性,然后让旧对象被垃圾收集。


没有不变性,您可能必须在不同范围之间传递对象,并且您事先不知道是否以及何时更改对象。因此,为避免产生不必要的副作用,您可以开始创建对象的完整副本,以防万一,即使事实证明根本不需要更改任何属性。这将比您的情况留下更多的垃圾。

这说明了-如果创建正确的假设方案,则可以证明一切,尤其是在性能方面。但是,我的示例并不像听起来那样假想。上个月,我在一个程序上工作,我们偶然发现了这个问题,因为我们最初决定不使用不可变的数据结构,后来又决定重构它,因为它似乎不值得麻烦。如果您从较早的SO帖子中看到类似的案例,您的问题的答案可能就很清楚了-这要视情况而定。在某些情况下,不变性会影响性能,而在某些情况下,情况可能恰好相反,在许多情况下,这取决于实现的智能程度,在更多情况下,差异可以忽略不计。

A最后说明:您可能会遇到的现实问题是,您需要尽早决定是否支持某些基本数据结构的不变性。然后,您在此基础上构建了大量代码,几周或几个月后,您将看到该决定是好是坏。

对于这种情况,我个人的经验法则是:


如果您设计的数据结构仅具有基于原始类型或其他不可变类型的一些属性,请尝试不变性第一。
如果要设计一种数据类型,其中包含大(或未定义)大小的数组,涉及随机访问和内容更改的情况,请使用可变性。

对于这两个极端之间的情况,请使用您的判断。但是YMMV。

评论


与您的情况相比,这将留下更多的垃圾。更糟糕的是,您的运行时可能将无法检测到毫无意义的重复,因此(与没有人使用的过期不可变对象不同),它甚至没有资格进行收集。

–雅各布·赖勒(Jacob Raihle)
2015年12月8日在16:27



#2 楼

首先,对不可变数据结构的描述不精确。通常,大多数数据结构不会复制,而是共享,并且仅复制更改的部分。它称为持久数据结构。大多数实现大多数时候都能够利用持久性数据结构。该性能非常接近于可变的数据结构,因此功能程序员通常认为它可以忽略不计。

其次,我发现很多人对典型命令式程序中对象的典型生存期有一个相当不准确的想法。也许这是由于内存管理语言的普及。坐下来一段时间,看看与真正的长期数据结构相比,您创建了多少个临时对象和防御性副本。我想您会对这个比率感到惊讶。

我在函数式编程类中提到过很多人,我教过一个算法会产生多少垃圾,然后展示该算法的典型命令式版本。创造了同样多的东西。出于某些原因,人们不再注意到它了。

通过鼓励共享和不鼓励创建变量,直到您拥有有效的价值,不变性往往会鼓励更简洁的编码做法和更长寿的数据结构。根据您的算法,这通常会导致可比的垃圾级别,如果不是更低的话。

评论


“ ...然后,我展示了相同算法的典型命令式版本,它创建了同样多的命令。”这个。同样,对这种风格不熟悉的人,尤其是通常对功能风格不熟悉的人,最初可能会产生次优的功能实现。

–wberry
2015年12月8日在22:31

“区分创建变量”不是仅对默认行为是赋值/隐式构造中进行复制的语言有效吗?在JavaScript中,变量只是标识符。它本身不是对象。它仍然在某处占用空间,但是可以忽略不计(尤其是,据我所知,大多数JavaScript实现仍然使用堆栈进行函数调用,这意味着除非您有大量的递归操作,否则最终大多数情况下将重用相同的堆栈空间临时变量)。不变性与这方面无关。

– JAB
2015年12月9日15:48



#3 楼

后来这个问答已经有了很好的答案了,但是我想成为一个外国人,习惯于从内存中的位和字节的较低层次来看事物。

我很兴奋通过不可变的设计,甚至从C的角度出发,以及从寻找有效地对该野兽硬件进行有效编程的新方法的角度来看,这些日子我们都已经有了。

速度更快/速度更快

对于是否会使事情变慢的问题,机器人的答案应该是yes。在这种非常技术性的概念层面上,不变性只会使事情变慢。硬件在不偶尔分配内存的情况下表现最佳,而只能修改现有的内存(为什么我们有类似时间局部性的概念)。

但实际的答案是maybe。在任何不平凡的代码库中,性能仍然很大程度上是生产力的度量标准。即使我们忽略了这些错误,我们通常也不会发现在竞争条件下绊倒的可怕的可维护代码库是最有效的。效率通常是优雅和简单的功能。微观优化的高峰可能会有些冲突,但通常是保留给最小和最关键的代码部分。

转换不可变的位和字节

级别的观点,如果我们对诸如objectsstrings等概念进行X射线检查,则其核心只是各种形式的具有不同速度/大小特性的内存中的位和字节(内存硬件的速度和大小通常互斥) )。



当我们重复访问相同的内存块时(例如上图),计算机的内存层次结构会喜欢它,因为它将经常访问的内存块保持为最快的内存形式(L1高速缓存,例如,几乎与寄存器一样快)。我们可能会重复访问完全相同的内存(多次重用)或重复访问该块的不同部分(例如:循环访问连续块中的元素,这些元素重复访问该内存块的各个部分)。

如果修改此内存最终想在侧面创建一个全新的内存块,我们最终会在此过程中投入一把扳手,就像这样:



在这种情况下,访问新的内存块可能需要强制执行页面错误和高速缓存未命中,以将其移回到最快的内存形式(一直到寄存器中)。那可能是真正的性能杀手。

有一些方法可以减轻这种情况,但是,可以使用已经接触过的预分配内存的备用池。

大型聚合

从更高级别的视图中出现的另一个概念问题是简单地批量复制非常大的聚合。

为了避免过于复杂的图表,让我们想象一下这个简单的内存块是以某种方式昂贵(也许在难以置信的有限硬件上使用UTF-32字符)。



在这种情况下,如果我们想用“ KILL”替换“ HELP”,并且内存块是不可变的,我们必须完整地创建一个全新的块,以创建一个唯一的新对象,即使只有一部分已更改:



扩展我们的想象力,这种对其他所有内容的深层复制只是为了使一小部分变得独特可能会非常昂贵(在现实情况下,此内存块会非常多更大的问题)。

然而,尽管有这样的花费,但这种设计将趋向于不易发生人为错误。任何使用过具有纯函数的功能语言的人都可能会喜欢上它,尤其是在多线程的情况下,我们可以在无需关心的情况下对此类代码进行多线程处理。通常,人类程序员倾向于跳过状态更改,特别是那些导致外部副作用导致当前函数范围之外的状态变化的程序员。在这种情况下,即使从外部错误(异常)中恢复,也可能由于混合中可变的外部状态变化而变得异常困难。

减轻这种冗余复制工作的一种方法是将这些内存块变成一个内存块。指向字符的指针(或引用)的集合,例如:

抱歉,我没意识到我们在制作图表时不需要使L唯一。

蓝色表示复制的数据浅。



...不幸的是,要为每个字符支付指针/引用成本,这将变得非常昂贵。此外,我们可能会将字符的内容分散到整个地址空间中,并最终以大量页面错误和缓存未命中的形式为它付出代价,这很容易使该解决方案比复制整个内容更糟糕。

即使我们谨慎地连续分配这些字符,也可以说机器可以将8个字符和8个指向该字符的指针加载到高速缓存行中。我们最终像这样加载内存以遍历新字符串:



在这种情况下,我们最终需要将7个不同的缓存行值的连续内存加载到遍历此字符串,理想情况下,我们只需要3个字符串。 8个字符,例如



结果需要加载4个高速缓存行数据(1个用于3个指针,3个用于字符)以遍历此字符串,这仅比理论上的最佳值短1个。

所以这不是不好。有一些内存浪费,但是内存充足,如果多余的内存只是不经常访问的冷数据,那么消耗更多内存并不会减慢速度。它仅适用于热的,连续的数据,在这些数据中,减少的内存使用和速度通常是并行的,我们希望将更多的内存放入单个页面或高速缓存行中,并在逐出之前对其进行全部访问。这种表示形式非常易于缓存。

速度

因此,利用上述表示形式可以在性能上取得相当不错的平衡。不变数据结构的最关键性能用途可能是具有以下性质:修改大块数据并使它们在过程中唯一,同时浅层复制未修改的数据。它还确实暗示了原子操作的一些开销,以便在多线程上下文中安全地引用浅表复制的片段(可能正在进行一些原子引用计数)。

,只要这些大块数据以足够粗糙的层次表示,许多开销减少了,甚至可能微不足道,同时仍使我们安全且易于编码,并且以纯形式对更多函数进行多线程处理,而没有外部副作用。保留新数据和旧数据

从性能的角度(从实践的角度来看),不变性可能是最有用的,这是当我们很想制作大数据的完整副本以使其独特时在可变的环境中,目标是从已经存在的事物中产生新事物,以我们希望保留新事物和旧事物的方式,当我们可以通过仔细不变的设计使其中的一点点独特时,

示例:撤消系统
一个示例是撤消系统。我们可能会更改数据结构的一小部分,并希望保留我们可以撤消的原始格式和新格式。通过这种不可变的设计,仅使数据结构的较小的,经过修改的部分变得唯一,我们可以简单地将旧数据的副本存储在撤消条目中,而只需支付添加的唯一部分数据的存储成本。这样可以在生产率(使撤消系统的实现成为小菜一碟)和性能之间实现非常有效的平衡。

高级接口

然而,这种情况出现了一些尴尬以上情况。在局部函数上下文中,可变数据通常是最容易修改,最直接的修改。毕竟,修改数组的最简单方法通常是遍历数组并一次修改一个元素。如果我们有大量的高级算法可以选择来转换数组,并且必须选择合适的算法以确保在修改了部分的同时制作所有这些大块浅表副本,那么我们最终可能会增加知识开销。

在这种情况下,最简单的方法可能是在函数上下文内局部使用可变缓冲区(它们通常不会使我们绊倒),该可变缓冲区原子地将更改提交给数据结构以得到一个新的不可变副本(我相信某些语言将其称为“瞬变”)...

...或者我们可以简单地对数据上越来越高级的转换函数建模,以便我们可以隐藏修改可变缓冲区并将其提交到结构而不涉及可变逻辑的过程。无论如何,这还不是一个被广泛研究的领域,如果我们更多地拥抱不可变的设计,并为如何转换这些数据结构提供有意义的接口,那么我们的工作就无法进行。

数据结构

这里出现的另一件事是,在性能至关重要的上下文中使用的不变性可能会希望将数据结构分解为块状数据,在这些数据块中,块的大小不要太小,也不要太大。链接列表可能要作一些改动以适应这种情况,然后变成展开列表。大的连续数组可能会变成指针数组,并通过模索引为连续块进行连续访问。

它可能以有趣的方式改变我们看待数据结构的方式,同时推动这些数据结构看上去很笨重,以掩盖在此处浅浅复制一些位并使其他位在其中唯一的额外复杂性。

性能

无论如何,这是我的低一点主题的高层视图。从理论上讲,不变性的代价可能从非常大到很小。但是,非常理论上的方法并不总是使应用程序快速运行。它可能使它们具有可伸缩性,但是现实世界中的速度通常需要拥抱更实际的思维方式。非常大的代码库。虽然从某种意义上说,性能会因不变性而降低,但很难说它对生产率和安全性(包括线程安全性)的好处。增加这些功能通常可以提高实际性能,这仅仅是因为开发人员有更多的时间来调整和优化他们的代码而不会被错误淹没。

因此,我认为从这种实际意义上讲,不变的数据结构实际上可能在很多情况下有助于提高性能,听起来似乎很奇怪。理想的世界可能会寻求这两者的混合:不可变的数据结构和可变的数据结构,可变的数据结构通常在非常局部的范围内(例如:函数的局部区域)非常安全地使用,而可变的结构可以避免外部的影响直接影响,并将对数据结构的所有更改转换为原子操作,从而生成新版本,而不会出现竞争状况。

#4 楼

ImmutableJS实际上非常有效。如果我们举一个例子:

var x = {
    Foo: 1,
    Bar: { Baz: 2 }
    Qux: { AnotherVal: 3 }
}


如果将上述对象设置为不可变的,则可以修改'Baz'属性的值:

var y = x.setIn('/Bar/Baz', 3);
y !== x; // Different object instance
y.Bar !== x.Bar // As the Baz property was changed, the Bar object is a diff instance
y.Qux === y.Qux // Qux is the same object instance


这为深层对象模型带来了一些非常酷的性能改进,您只需要将值类型复制到根路径上的对象上即可。对象模型越大,所做的更改就越小,因为不可变数据结构最终共享许多对象,因此它们的内存和CPU性能会更好。

如其他答案所说,如果您将其与试图通过在将x传递给可以操纵它的函数之前进行防御性复制来提供相同的保证相反,则性能会明显提高。

#5 楼

要补充这个(已经很好回答的)问题:

简短的回答是“是”。这将损害性能,因为您仅创建对象而不是对现有对象进行突变,这会导致更多的对象创建开销。


但是,长的答案并不是真的。

从实际运行时的角度来看,在JavaScript中您已经创建了很多运行时对象-函数和对象文字在JavaScript中无处不在,似乎没有人考虑使用它们。我认为对象创建实际上是很便宜的,尽管我对此没有引用,所以我不会将其用作独立的参数。

对我来说,最大的“性能”提高是不是在运行时性能上,而是在开发人员性能上。在开发Real World(tm)应用程序时,我学到的第一件事是,可变性确实很危险且令人困惑。我花了很多时间来追逐执行线程(而不是并发类型),试图找出导致该死的错误的原因,而这却是该死的应用程序另一端的变种!

使用不变性使事情容易推断。您可以立即知道X对象在其生存期内不会更改,并且更改的唯一方法是克隆它。我更重视这一点(尤其是在团队环境中),而不是可变性可能带来的任何微优化。

有例外,最值得注意的是如上所述的数据结构。我很少遇到这样的情况:我想在创建后更改一个映射(尽管我承认我是在谈论伪对象文字映射而不是ES6映射),与数组相同。当您处理更大的数据结构时,可变性可能会有所回报。请记住,JavaScript中的每个对象都是作为引用而不是值传递的。


就是说,上面提到的一点是GC及其无法检测到重复项。这是一个合理的问题,但是在我看来,只有在内存是问题时,这才是一个问题,并且有很多更简单的方法可以将自己编码到一个角落–例如,闭包中的循环引用。


最终,我希望拥有一个不变的代码库,该代码库具有很少(如果有的话)可变部分,并且性能要比在任何地方都具有可变性小。如果由于某种原因不变性成为性能问题,您以后可以随时进行优化。

#6 楼

直线上,不可变的代码具有创建对象的开销,这比较慢。但是,在许多情况下,可变代码变得很难有效管理(导致大量防御性复制,这也很昂贵),并且有许多巧妙的策略可减轻“复制”对象的成本,就像其他人提到的那样。

如果您有一个对象(例如计数器),并且它每秒增加许多次,则使该计数器不变是不值得的。如果您的对象被应用程序的许多不同部分读取,并且每个对象都希望拥有该对象的稍微不同的克隆,则可以通过使用良好的性能以更加高效的方式来安排对象不变对象的实现。