不可变集合

副作用会让代码的易懂性和正确性打折扣。用于转变全局或静态变量的方法就有副作用。用于转变其部分参数的方法也有副作用。必须通读有副作用的所有调用方法的代码,才能理解一段代码。若有多个线程,必须执行线程同步,才能正确执行有副作用的方法。


如果编写的方法没有副作用,情况又如何? 代码什么样?该如何执行? 若要回答这些问题,可以让实例不可变,这样就不会有副作用了。


通常,如果实例为不可变类型,即表示它的值永不改变。与软件工程领域中的其他许多概念一样,不可变性是一项设计选择。并不一定非要使用不可变性,但在一些应用场景中,可以在代码性能或易懂性方面受益于这项设计选择。作为成功的函数编程范例的基本原则之一,不可变性其实往往非常有用。鉴于 F# 是函数优先语言,所有实例都不可变,除非另加明确指定。相比之下,C# 是面向对象的对象优先语言,所有实例都可变,除非另加明确指定。在本文中,我将介绍如何在 C# 中利用不可变性。不过,我将先在本文背景下定义不可变性。


定义不可变性


从技术角度来讲,不可变性分为很多种。对其状态或其实例状态的变化有所限制的任何类型,在某种意义上都可称为不可变。System.String 类型就是不可变类型,从某种意义上说,字符串的大小、字符及其顺序都不能变。System.MulticastDelegate 类型是所有委托类型的父级,与 System.String 一样也不可变。这两种类型都使用数组作为基础数据结构,并复制此数组来实现请求的更改,无论变化幅度如何。若要详细了解各种不可变性,请参阅 bit.ly/2kGVx4Z 上的文章。


虽然 System.Collections.ObjectModel.ReadOnlyCollection<T> 是可变的,但它为给定的可变 IList<T> 对象实现不可变接口。此接口不允许使用者更改集合中的项数及其相对顺序。不过,此接口并没有规定各项的不可变性(具体视 T 的整个类型层次结构而定)。当然,引用基础列表的代码可以随意更改,没有任何约束。


本文中介绍的不可变集合具有的是另一种不可变性。为了激发大家对不可变集合的需求,我要讲讲下面这个示例:


典型文本编辑器提供了多种功能或工具(如拼写检查或代码分析),用于分析或处理用户写入的文本。随着多核计算机的普及,这些工具可以在在用户键入的同时在后台运行。如果稍有不慎,就可能会遇到线程安全性问题。后台分析器会在用户修改文本的同时读取包含文本的缓冲区。现在假设后台进程将按逻辑获取文本快照,而不是缓冲区。


如何才能正确高效地实现此操作呢? 正确使用 String 等类型就可以解决此问题,但效率不高。使用此类类型,用户可以在工具运行的同时更改文本,但每次更改后,都会生成新的文本副本;对于大文档而言,这不仅会拖慢速度,还浪费内存。另一正确解决方案是使用可变类型,如 System.Text.StringBuilder。但此解决方案的效率也不高,因为工具需要在已获得锁定的情况下生成副本,而且只有在副本生成后用户才能进行更改。ReadOnlyCollection<T> 也没什么用,因为基础集合是可变且共享的。


需要另一种不可变性,这样无需使用高昂的线程同步机制,即可安全地进行更改,同时无需复制或只需执行很少量的复制操作,即可在线程间共享尽可能多的数据。不可变集合具有的恰好就是这种不可变性(称为“永久性”)。此类集合不仅适用于上述应用场景,还适用于多线程和单线程应用场景,如本文后面的部分所述。


本文详细介绍了不可变集合的设计、实现和性能,以便你可以有效地使用不可变集合,并能编写自己的不可变集合和类型。可以在支持 .NET Standard 1.0 及更高版本的任意 .NET 平台上使用不可变集合(也就是说,可以在所有的 Windows 平台、Xamarin 和 .NET Core 上使用不可变集合)。不可变集合相对较新,并且作为 NuGet 包进行分发,因此并不为 .NET Framework 本身所用,即使不可变集合会对许多框架 API 有益,也不例外。此类 API 改用可能不是很理想的 ReadOnlyCollection<T>,或可变集合的副本。我将使用的包版本为 1.3.0。源代码可从 CoreFX 中获取。

请注意,使用不安全代码或反射可能会规避几乎所有的不可变性保证。通常,若为不可变类型,则暗示要注意这些技术可能会规避任何不可变性保证。这一点适用于本文中介绍的不可变集合。



定义不可变集合


在介绍不可变集合的本质前,我需要先定义是什么不可变集合。不可变集合包含一系列实例,这些实例始终保留自己的结构,并禁止元素级分配,同时仍提供用于执行转变的 API。集合的结构是指元素数量及其相对顺序(取决于元素在数组结构中的索引,以及元素在链接结构中的链接)。


例如,如果将某元素推送到 ImmutableStack<T>,将生成两个独立的不可变堆栈:一个有新元素,一个没有。相比之下,如果将某元素推送到可变 Stack<T>,将有效地改变堆栈,只会生成一个有新元素的堆栈。请注意,不可变和可变集合均不提供关于元素本身的任何保证。如果 T 是 Int32 或 String,那么元素也会是不可变。但如果 T 是 StringBuilder 等,那么元素的可变性就很高。


必须初始化对象,才能构造任意不可变对象。因此,在初始化过程中,对象是可变的。发布对对象的引用(通过非私有方法返回)后,此对象实际上就会在其剩余生存期内变成不可变。


请注意,不可变集合的设计目标有两个。第一个目标是,尽量重用内存、避免执行复制操作、减少垃圾回收器的压力(此类实现通常称为“永久性”)。第二个目标是,支持可变集合提供的相同操作,这些操作具有富有竞争力的时间复杂度。


不可变堆栈


可变 Stack<T> 类型是使用数组实现。不过,数组不适合不可变集合,因为保留当前实例的唯一方法是复制整个数组,然后在新数组上执行更改。这样会导致不可变堆栈的速度慢得令人难以接受。可以精妙地使用链接列表来实现不可变集合。每个元素都包含一个指向其下方元素的指针(如果是最下面的元素,则为 NULL)。不可变堆栈对应着指向最上面元素的指针。这样一来,无需更改给定堆栈即可推送和出栈它的元素,同时还可与生成的堆栈共享其所有元素。这种简单的设计让不可变堆栈成为最简单的不可变集合。在本文中,我将进一步介绍可变堆栈与不可变堆栈的区别。


我们来看一下如何创建和使用不可变堆栈。Immut­ableStack<T> 和其他所有不可变集合都是在 System.Collections.Immutable 命名空间中进行定义。为了最大限度地共享内存,不可变集合不提供任何公共构造函数。若要创建不可变集合的实例,必须使用与不可变集合对应的静态类型中定义的 CreateXxx<T> 方法之一。对于不可变堆栈,这种类型称为 ImmutableStack,并提供以下工厂方法:


public static ImmutableStack<T> Create<T>();public static ImmutableStack<T> Create<T>(T item);public static ImmutableStack<T> Create<T>(params T[] items);public static ImmutableStack<T> CreateRange<T>(IEnumerable<T> items);


所有这些方法都有一个泛型类型参数 T,用于指定集合中存储的项的类型。第一种方法创建空的不可变堆栈,本质上只返回单一实例 ImmutableStack<T>.Empty。第二种方法创建包含向其推送的指定项的堆栈,等同于 ImmutableStack<T>.Empty.Push(item)。第三种和第四种方法创建包含按顺序向其推送的指定项的堆栈。CreateRange<T> 方法的实现方式如下:


var stack = ImmutableStack<T>.Empty;foreach (var item in items)
{
  stack = stack.Push(item);
}return stack;


所有不可变集合的全部工厂方法都只是为了提供方便。所有这些方法本质上都从空集合开始,然后向其中添加指定项。这些项始终进行的是浅表复制。

现在假设从空堆栈开始执行以下顺序操作:


ImmutableStack<Int32> s1 = ImmutableStack<Int32>.Empty;
ImmutableStack<Int32> s2 = s1.Push(1);
ImmutableStack<Int32> s3 = s2.Push(2);
ImmutableStack<Int32> s4 = s3.Push(3);
ImmutableStack<Int32> s5 = s4.Push(4);
ImmutableStack<Int32> s6 = s4.Pop();
ImmutableStack<Int32> s7 = s6.Pop();


请注意,Push 和 Pop 方法返回对生成的不可变堆栈的引用。相比之下,可变 Stack<T> 的 Push 和 Pop 方法分别返回 void 和 T。这种设计反映了以下事实:更改不可变堆栈会从概念上生成完全不同的堆栈,更改可变堆栈实际上会更改堆栈。如果对可变堆栈执行相同顺序操作,将获得不同的最终结果,如图 1 所示。不可变堆栈的不可变性体现在无法更改节点的指针和值上。


640?

图 1:更改不可变和可变堆栈生成的堆栈不同


请注意,空的不可变堆栈节点存储 T 的默认值(如果 T 是 Int32,默认值为 0)。此外,可变堆栈仅将出栈项的值设置为默认值,而不是收缩数组。未占用的数组部分称为未用空间。


若要获取不可变堆栈最上面的项,可以使用 Peek 方法或再次重载 Push(其中有一个可用于返回此项的输出参数)。


不可变列表


列表数据结构比堆栈更为复杂,主要是由于需要执行索引操作。列表结构支持在指定索引处检索、添加和删除项。使用数组合乎情理(就像在可变 List<T> 中一样),但如上文所述,对常规用途不可变列表使用数组,效率并不高。使用链接列表也不合适,因为可能需要遍历许多元素才能找到指定索引处的相应项。改用均衡二叉树可以实现所有操作和高性能。大多数不可变集合都是使用均衡二叉树进行实现。其余不可变集合使用的都是链接列表,而只有一个不可变集合(即不可变数组)使用的是数组,如下一部分所述。


树中的每个节点都包含列表中的一项,因此都有索引。ImmutableList<T> 类型对树进行整理,这样对树执行深度优先顺序遍历就对应从索引 0 处的项到最后一项遍历列表。


假设程序如下:


ImmutableList<Int32> l1 = ImmutableList.Create<Int32>();
ImmutableList<Int32> l2 = l1.Add(1);
ImmutableList<Int32> l3 = l2.Add(2);
ImmutableList<Int32> l4 = l3.Add(3);
ImmutableList<Int32> l5 = l4.Replace(2, 4);


图 2 展示了从空的不可变列表开始执行顺序操作的同时基础二叉树受到什么影响。每个框代表树中的一个节点。包含字母 E 的框代表空的树单一实例(哪里也不指向的箭头表示指向 E 框)。图右侧的框和箭头不可变,左侧的框和箭头暂时可变。这是由内部布尔型标志(称为“冻结”)所指定。此标志有两种用途,我将在下文中进行介绍。

640?wx_fmt=png

图 2:树的内部状态(左)和完成转变后生成的可公开访问状态(右)


若要向树添加第一项,需要新建一个节点,其两个指针均指向空节点。所有新建节点的冻结标志一开始都设置为 false(表示暂时可变)。此时,无需执行其他任何操作。因此,将冻结标志设置为 true 可使树不可变,如图右侧所示。这样一来,树就会在其剩余生存期内变成不可变。


若要添加第二项,鉴于树的组织方式,它的节点必须是第一个节点的右侧子级。但由于此节点不可变,因此无法更改其指针。添加第二项的唯一方法是为第二项创建节点,并为第一项创建另一节点。正因为此,l2 和 l3 指向完全不同的树。


同样,第三项必须是第二项的右侧子级。添加第三项的唯一方法是同时为第一项和第二项新建节点。不过,这次生成的树不均衡。当根左右两侧的子树高度差大于等于 2 时,就会发生这种情况。为了保持树均衡,必须重新整理树,使其变成图 2 右下角所示的树。可以这样做的原因是,树仍是可变的,Immutable­List<T> 类型外的代码都无法访问它或观测任何转变。返回对树的引用前,将各个节点的冻结标志设置为 true,让它变成不可变,从而冻结树。这就是冻结标志的第一个用途。


最后一行代码调用 Replace 函数,该函数会找到指定项并将其替换为其他项。在这种情况下,新建节点来保留新项,同一树的其他节点会在新树中重用。


鉴于树的组织方式,对不可变列表执行的任何一项操作(无论是添加、插入、删除还是搜索)的时间复杂度都为 O(log N),其中 N 表示列表当前的项数。相比之下,对可变列表 List<T> 执行的操作的时间复杂度为 O(1)(可以就地执行操作)或 O(N)(需要复制基础数组)。


可以对不可变列表快速执行一项操作。但如果要连续执行 M 项大量操作,时间复杂度将为 O(M log N)。使用冻结标志并将所有转变汇总到一起,效果会更好。生成器可以实现这种优化。


大多数不可变集合(包括 ImmutableList<T>)都定义名为生成器的类型,这种类型使用相同的基础数据结构并提供相同的 API。区别在于,生成器在每次操作后不设置冻结标志。它让新建的所有节点都一直处于可变状态,这样就可以更高效地执行许多操作了。不可变列表的生成器类型是 ImmutableList<T>.Builder。


若要创建生成器实例,需要使用不可变集合实例。可以从空集合开始,然后使用 ImmutableList.CreateBuilder<T> 静态方法,或在给定不可变集合上使用 ToBuilder 实例方法。在后一种情况中,正如所承诺的,现有的全部节点都会保持不可变状态。只有新节点才是可变的。执行所有操作后,可以调用 ToImmutable 实例方法冻结所有节点,实际上是让集合不可变。ImmutableList<T> 提供多个实例方法(如 AddRange 和 RemoveRange),这些方法可引用 IEnumerable<T>,并在内部使用生成器对指定项执行操作。


一些操作并不受益于生成器,且费用本来就很高昂。Reverse 实例方法必须复制树中的所有非叶节点才能颠倒项的顺序。Sort 实例方法的实现方式为,将所有项复制到数组,对数组进行排序,然后根据已排序的数组创建不可变列表。


可变 List<T> 本质上使用数组。在数组中间插入或删除项需要新建数组并复制其他所有项。此外,可能也无法在零碎的地址空间中分配非常大的数组。可变 LinkedList<T> 解决了这两个问题。虽然 ImmutableList<T> 在这两方面有优势,但却降低了其他操作的效率。ImmutableList<T> 是同时对应 List<T> 和 LinkedList<T> 的不可变集合。请注意,StringBuilder 本质上是 List<Char>。


不可变数组


ImmutableArray<T> 类型与 ImmutableList<T> 一样实现的都是不可变列表,区别在于实现方式不同。ImmutableArray<T> 只是类型为 T[ ] 的数组的薄包装器。它很“薄”,因为它是包含一个引用类型字段的值类型。数组本身是通过托管堆进行分配。若要执行任何转变操作,需要生成整个数组的副本,然后对副本执行操作。就此而论,ImmutableArray<T> 是 String 的泛化,因为它可以代表任意类型项的字符串,而不只是 Char 类型。


使用 Immutable­Array<T> 时,所有转变操作的时间复杂度为 O(N),而使用 ImmutableList<T> 时的时间复杂度为 O(log N)。不过,ImmutableArray<T> 在以下三个方面更胜一筹。首先,使用 ImmutableArray<T> 时,如果访问给定索引处的项,时间复杂度为 O(1),而使用 ImmutableList<T> 时的时间复杂度为 O(log N)。其次,虽然这两种实现均提供线性时间迭代,但 ImmutableArray<T> 更适合缓存,因为所有项都是连续存储的。通过 ImmutableArray<T> 迭代可能要比通过 ImmutableList<T> 迭代快许多倍。最后,Immutable­Array<T> 占用的内存较少,因为它不使用指针。通常需要进行衡量才能确定在特定情况下使用哪一种类型。两种类型均可实现 IImmutableList<T> 接口。可以跨代码使用此接口,从而轻松切换这两种类型。


和以往一样,可以执行批量操作并池化生成器对象,从而提升性能并减少垃圾回收 (GC) 压力。可以使用批量操作方法 XxxRange 或 ImmutableArray<T>.Builder,实现方式与 List<T> 相似。


请注意,由于 LINQ 运算符的标准设计支持 IEnumerable<T> 引用,因此需要进行装箱,才能将其应用于 ImmutableArray<T> 值类型。不可变集合 NuGet 包实现了一些专为 ImmutableArray<T> 而设计的 LINQ 运算符,可以避免装箱。可以从 System.Linq.ImmutableArrayExtensions 中获取。


不可变字典


ImmutableDictionary<TKey, TValue> 类型使用均衡二叉树来表示字典。树中的每个节点都包含一个 ImmutableList<KeyValuePair<TKey, TValue>>(也是均衡二叉树),其中保留经哈希处理成同一个值的所有项。相比之下,可变 Dictionary<TKey, TValue> 使用键值对数组,键值对采用开放地址法解决冲突。总体来说,ImmutableDictionary<TKey, TValue> 的运行速度要比 Dictionary<TKey, TValue> 慢许多倍,并且占用的内存也更多。使用字典生成器的帮助不大,因为基础结构仍是由树组成的树。使用 ImmutableDictionary<TKey, TValue> 时,绝对应该衡量性能,以确定性能是否可接受。如果不能接受,可能需要编写你自己的自定义不可变字典。


不可变集合的性能和内存占用率


现在,即使使用不可变集合是理想首选,但性能可能令人无法接受。正因为此,请务必了解不可变集合的实现方式及其对性能的影响。


我们来比较一下不可变列表与对应的可变列表的性能。有关对不可变集合执行的常见操作的时间复杂度,请访问 bit.ly/2ko07HS。本文并未详细介绍,最好查阅一些更为切实的资料。我已编写了三个程序,它们分别直接向可变列表、不可变列表和不可变列表生成器追加或前置 1,000 万个 8 字节整数。图 3 为结果。(显示的数字为近似值。时间以秒为单位。内存以 MB 为单位。已启用 JIT 优化。默认的构造函数用于创建可变列表。)


图 3:可变列表、不可变列表和不可变数组花费的时间和占用的内存


Mutable ImmutableList ILBuilder ImmutableArray IABuilder
附加 0.2 12 8 很长! 0.2
前置 很长! 13.3 7.4 很长! 很长!
32 位大小 128 320 320 128 128
64 位大小 128 480 480 128 128


追加到可变列表的成本较低,因为它是基于数组。数组的规模有时会扩大一倍,在此之后,添加项操作起来就会非常快速。相比之下,向不可变列表添加项的成本就要昂贵得多,因为它是基于树。即使使用生成器,速度仍要慢 39 倍左右。这相差甚远。不过,这样比较并不真正公平。公平的做法是比较不可变列表与使用线程同步提供相似快照语义的可变列表。不过,在单线程应用场景中,这样比较仍会让不可变集合的吸引力大打折扣。尽管时间复杂度仅为 O(log N),隐藏的常数因子仍相当庞大。我很快就会解释具体原因。


前置操作就完全不是这么回事。List<T> 需要花费许多小时才能完成,因为它必须分配并复制 1,000 万个越来越大的短期数组。不可变列表及其生成器的性能大致相同。


图 4 展示了向不可变列表追加项时使用 Visual Studio 2015 诊断工具生成的托管内存分配图的一部分。此图最上面的标记表示每次生成时记录的 GC。此图表明,GC 频繁发生,大约每几十毫秒发生一次。我使用 PerfView 确定了执行所有这些小型 GC 所花费的总时间。如果不使用生成器,GC 时间大约为 4 秒。这就是直接使用不可变列表和使用生成器的区别所在。为了确定此差异是确实由 GC 造成,还是只是巧合,我还对使用生成器的程序使用了 PerfView。结果证明,情况确实如此。看看两者的运作方式,这种现象就很容易解释。不可变列表会创建大量短期和中期对象,而生成器则会转变现有对象的指针。使用不可变列表直接触发了 100 多次 GC,而使用生成器和可变列表则触发了少于 10 次。

640?

图 4:向不可变列表追加项时 GC 运行更为频繁


生成器的速度比可变列表要慢得多。之所以会这样,有四个相互关联的原因。首先,生成器使用链接结构,从而导致许多缓存失效。其次,追加任意两项后,树就变得不均衡,需要进行旋转使其再次均衡。第三,追加项需要遍历 (log N) 个节点。第四,每次添加项后,都会触发托管节点的单独内存分配。


这并不意味着 GC 也是需要解决的问题。相反,自动内存管理实际上更加简化了不可变集合的编写和使用。可以自动清理无人使用的所有不可变对象。


我们也来比较一下不可变堆栈和可变堆栈。通过此类比较,可以量化使用链接数据结构产生的对象分配及相关的缓存失效(只是后面可能会发生的总缓存失效中的一小部分)成本。不可变堆栈更适合 GC,因为它仅分配属于生成的堆栈的对象。它的效率非常高,甚至都没有生成器。将 1,000 万个 8 字节整数推送到可变堆栈大约需要 0.17 秒,而将相同的整数推送到不可变堆栈则大约需要 1 秒。速度慢了约 5 倍,不算糟糕。通过大型不可变堆栈或任意链接结构迭代比通过相应的可变集合迭代要慢许多倍,主要是因为存在缓存失效和页面错误(及因共享而需跨 NUMA 体系结构上的 NUMA 节点进行数据传输)。


不过,基于数组的可变集合存在未用空间浪费问题。如果从大型集合中删除大多数项,基础数组不会收缩,而是会继续不必要地占用内存。基于链接的不可变集合始终都是一项对应一个对象。不过,鉴于典型用例,这对链接集合来说几乎不能称之为优势。


在什么情况下使用不可变集合


不可变性的根本优势在于,大大简化了如何推论代码的工作方式,让你可以快速编写正确精妙的代码。假设为单线程程序。在给定代码行,你可能想知道不可变集合的状态。通过在创建集合的代码中定位这些位置,即可轻松确定它的状态。此类位置通常只有少数几个。继续执行此过程,要么是获得可变源,要么是清空集合。不可变集合是否已传递到方法,并不要紧,因为它的结构保证一定会予以保留,所以无需考虑这些方法中发生了什么。如果项也是不可变类型,可以推论集合的全部状态。如果是多线程程序,也同样简单;但如果使用共享可变集合,难度将大增。因此,不可变性可以作为常规设计原则,因为它适用于函数编程。


现在,假设方法签名如下:


void Foo<T>(Stack<T> s);


此方法包含可变堆栈参数,因此可以通过此方法修改堆栈。这确实也是此方法的用途所在。但当它真正修改堆栈时,旧状态会丢失,除非调用方生成了堆栈副本。请注意,此方法无需返回任何内容,即使它修改了堆栈,也不例外。此签名传达出的另一点信息是它不提供任何线程安全性保证。


如果线程安全性不成问题,且此方法旨在修改堆栈,而你也对这些修改感兴趣,那就没有关系。如果此方法仅用于读取或检查堆栈(或可能会修改堆栈,但调用方绝不会关注这些修改),那么以下签名可能更为合适:


void Foo<T>(ReadOnlyCollection<T> s);


这里面存在两个问题。首先,ReadOnlyCollection<T> 需要构造 List<T>,因此调用方必须将堆栈复制到列表。让参数成为接口类型 IReadOnly­Collection<T> 可以避免这个问题,因为 Stack<T> 会实现它,然后此方法可以同样轻松地将其转换回 Stack<T>。其次,如果此方法通常用于更改堆栈,首先需要将堆栈复制到可变集合。此签名也不提供任何线程安全性保证。只有在原始可变集合是 List<T>,且此方法不会更改它时,才会比较方便。


如果可能有多个可变线程访问集合,那么以下签名可能更为合适:


void Foo<T>(ConcurrentStack<T> s);


由于并发堆栈是可变的,因此所有线程都能看到全部更改。这仅在至少满足以下两个条件之一时适合: 此方法希望即时考虑其他线程可能做出的更改,或其他线程希望即时看到此方法做出的更改。请注意,所有线程都可以单独看到任何特定更改。否则,如果一些线程只应看到一批更改或不能看到任何更改,必须获得全局锁,并生成堆栈的私有副本。这两种应用场景被称为“快照语义”。


请注意如何在各种应用场景下使用不同的集合类型。另外,用于介绍此方法的工作方式或使用方式的优质文档是可取的。不可变集合让这一切得到简化。以下两个签名可满足大多数应用场景的需求:


void Foo1<T>(ImmutableStack<T> s);
ImmutableStack<T> Foo2<T>(ImmutableStack<T> s);


瞧瞧这些 API 是多么地实用。当没有人关注方法做出的更改时,使用第一个签名。否则,可以使用第二个签名。不适合使用不可变集合的应用场景只有两个:吞吐量(项的处理速率)和生产者-使用者语义。如上所述,常规用途不可变集合的吞吐量较低,尤其是在单线程应用场景中。在生产者-使用者语义中,并发可变集合更为精妙,可提升性能。生产者-使用者语义和快照语义的区别在于,读取代理或使用代理的行为方式。在前者中,元素会得到使用(永久性删除);而在后者中,元素会得到处理,并且只能由写入代理或生产代理删除。我要将快照语义称为变换器-处理器语义,因为有更改代理和处理代理。处理代理可以做出更改,但前提是这些更改保留在一个单独副本(与其他副本一样,均为所需副本)中。如果要在全局做出这些更改,则为生产者-使用者语义范围。


方便使用的接口和 API


有许多 ToXxx 扩展方法可以将可变集合或与集合相关的接口转换成不可变集合。这些方法是通过复制可变集合,而不是重用集合来维持不可变性。许多不可变集合都提供方便使用的方法,如用于排序和搜索的方法,与可变集合提供的方法相似。这有助于混合同时使用两种集合的代码。


为了提高不可变集合在现有代码库中的可用性,一些不可变集合实现适当的通用接口,如 IEnumerable<T>、IList<T> 和 ICollection<T>。一些已声明的方法(如 IList<T>.Insert)专门用于转变集合。这些方法通过引发 NotSupported­Exception 进行实现。不可变集合还实现 NuGet 包中定义的相应不可变接口。


System.Collections.Immutable.ImmutableInterlocked 类型提供了大量方法来实现互锁交换机制,以正确更新不可变集合中的项或对不可变集合的引用。例如,以下方法引用某项,并根据指定转换器更新此项:


public static bool Update<T>(ref T location, Func<T, T> transformer) 
            where T : class;


虽然所有线程都可以观测此类操作的影响,但有保证的是所有线程始终可以观测到同一项。


总结


本文介绍了不可变集合的优势,并详细介绍了不可变堆栈、列表、数组和字典的设计和实现。NuGet 包还随附其他大量不可变集合。几乎每个可变集合都有对应的不可变集合,可以在此包中找到。我希望大家可以在自己的代码中有效利用这些类型。如果你青睐不可变性模式,并且想要编写自己的不可变类型,可以试试 Andrew Arnott 编写的基于 Roslyn 的工具。使用此工具,可以像编写可变类型一样轻松地编写不可变类型。有关详细信息,请访问 bit.ly/2ko2s5O



Hadi Brais 从德里印度理工学院获得了博士学位,主要研究下一代内存系统的编译器优化。他将大部分精力用于编写 C/C++/C# 代码上,并深入研究了运行时、编译器框架和计算机体系结构。他的博客地址为 hadibrais.wordpress.com,还可以通过 [email protected] 联系他。


衷心感谢以下技术专家对本文的审阅: Andrew Arnott 和 Immo Landwerth


 
 

原文地址:https://msdn.microsoft.com/en-us/magazine/mt795189


.NET社区新闻,深度好文,微信中搜索dotNET跨平台或扫描二维码关注

640?wx_fmt=jpeg