C#12 集合表达式的幕后揭秘 4 - 探索生成的代码:展开元素

Avatar
不若风吹尘
2024-07-31T09:40:54
1100
0

在之前的帖子中,我们只看了简单的情况,即直接从一组固定的元素创建集合,例如:

List<string> list = [ "1", "2", "3", "4", "5" ];
int[] array = [ 1, 2, 3, 4, 5 ];
Span<int> span = [ 1, 2, 3, 4, 5 ];

在这篇帖子中,我们将探讨当你在集合表达式中使用展开元素时编译器生成的内容以及它如何根据源和目标集合的变化而变化。

注意

按设计来说,编译器产生的代码可能会在未来版本的 .NETC# 中发生变化。这里展示的生成代码代表了某个时间点的情况。如果引入新的 C# 特性、类型或机制,编译器可以在幕后切换到使用它们,而你的编译后的代码会变得更快,无需你做任何改动!事实上,编译器 的变化意味着当我完成这个系列时,最初的示例已经过时了!

我们将从快速回顾展开元素开始,然后我们将看看编译器如何为使用展开的集合表达式生成代码。一如既往,你不需要知道这段代码的样子,并且它可能在未来发生变化。

使用展开元素创建集合

展开元素 .. 是随着集合表达式引入的,它可以让你将多个集合组合成新的集合。

例如,下面的函数创建了一个包含 int[] 中所有相同元素的 List<T>

int[] array = [ 1, 2, 3, 4, 5];
List<int> list = [ ..array ]; // list 包含 1, 2, 3, 4, 5

你可以使用展开元素将多个集合组合成一个,并且可以与固定值混合搭配:

List<int> start = new () { 1, 2, 3 }; // 源列表可以是任何 IEnumerable 集合
IEnumerable<int> end = [ 5, 6, 7 ]; // 不管它是如何创建的

// 你可以在集合表达式中以你喜欢的方式组合单个元素和展开元素
int[] all = [ 0, ..start, 4 ..end, 8 ]; // 0, 1, 2, 3, 4, 5, 6, 7, 8

这就是展开元素在语法方面的全部内容。它非常简单,但是组合集合是一个常见的需求,并且以前的语法非常繁琐,所以这是一个非常有价值的功能。

作为一种额外的好处,它对于最终集合应该是什么样子是 声明式的 ,而不是描述性地说明要调用哪些方法来构建集合。这意味着编译器可以自由地做它认为最好的优化代码,正如我们在本帖中所探讨的那样。

集合表达式和展开元素几乎适用于任何类型的集合,因此在这篇文章中,我根据 目标 类型分解了各个部分,然后研究了各种不同的源集合。我们将从 List<T> 开始,因为它是 .NET 中最常见的集合类型之一。

使用展开元素创建 List<T>

List<T> 是最常见的集合类型之一,是一个很好的通用选择。你可以使用 List<T> 添加任意数量的元素到其中,但如果你 知道 需要多少元素,你可以提高性能。

T[] 创建 List<T>

我们将从源集合是 T[] 并通过展开所有元素到其中创建 List<T> 的示例开始。

为了简单起见,在本文的所有集合中我都使用了 int 类型。与构建原始集合表达式不同,在将集合展开合并到另一个集合时,元素类型的影响较小。

我们可以编写一个简单的程序来演示展开元素的作用:

using System.Collections.Generic;

MyFunc([ 1, 2, 3, 4, 5]);
List<int> MyFunc(int[] source) => [..source]; // T[] source

我已经将 “源集合” 的创建和 “目标集合” 的创建分成了独立的方法,这样可以更方便地跟踪 sharplab.io 生成的代码,并且明确只有在 MyFunc() 方法内的代码与展开元素相关。

为了使本文中生成的代码更容易理解,我只展示了在 MyFunc() 方法中构造返回 List<T> 的实际 “展开” 代码。下面的注释代码基于在 sharplab.io生成的代码 。它显示了当源数组是 T[] 时,列表可以非常高效地构造:

// 创建一个新的 List,这将是最终返回的值。
List<int> list = new List<int>();

// 强制目标 Count 与源长度匹配
CollectionsMarshal.SetCount(list, source.Length);

// 以 Span<T> 形式检索 List<T> 中的 T[] 后台字段
Span<int> span = CollectionsMarshal.AsSpan(list);
// 👆 直到这里的所有内容都是创建具有已知、固定长度的列表的 “正常” 过程。

int num = 0

// 将 _source_ 数组包装为 ReadOnlySpan<T>
ReadOnlySpan<int> readOnlySpan = new ReadOnlySpan<int>(source);

// 将源 Span 复制到目标 Span
readOnlySpan.CopyTo(span.Slice(num, readOnlySpan.Length)); // 这里的切片实际上没有必要
num += readOnlySpan.Length; // 不必要,遗留下来的产物,我们稍后会讨论它

如你所见, List<T> 被高效地构造,使用 SetCount 来设置底层数组的大小,并以 Span<T> 形式检索后台字段。然后将源 T[] 也封装在 Span<T> 中,并直接复制到目标列表中。就这样!

对于以下所有源类型,你都会得到 大致 相同的代码:

  • T[] — 上面显示的代码。
  • Span<T> — 除了源已经是一个 Span<T> 因此无需包装之外,完全相同。
  • ReadOnlySpan<T> — 与 Span<T> 相同,源可以直接复制到目标
  • List<T> — 如果源是 List<T> ,则直接访问后台数组使用 CollectionsMarshal.AsSpan() (如果可用),然后复制到目标 span

这些示例的行为大致相同,因为我们可以获得源数据的 Span<T> 。但如果不能,甚至不知道集合中有多少元素会发生什么?

IEnumerable<T> 创建 List<T>

展开 Span<T>List<T>T[] 几乎是集合表达式的最佳情况。相比之下,最糟糕的情况是 IEnumerable<T> ,你不知道源集合中有多少元素:

using System.Collections.Generic;

MyFunc([ 1, 2, 3, 4, 5]);

List<int> MyFunc(IEnumerable<int> source) => [..source]; // IEnumerable source

编译器生成的 集合表达式展开代码 如何处理这种情况?

List<int> list = new List<int>();
list.AddRange(source);

在这种情况下,编译器能做的最好的事情是回退到 AddRange() 。这里没有优化,因为我们真的无法做到 —— 我们不知道 IEnumerable<T> 包含多少元素,所以我们无法优化初始列表容量以避免调整大小。

顺便说一下,如果你的目标是在引入 CollectionsMarshal.SetCount() 之前较早的运行时,对于 T[] 等,生成的代码 也会 使用 AddRange() ,但是列表容量被预设以避免调整大小,例如。

List<int> list = new List<int>(source.Length); // 设置已知的容量 list.AddRange(source);

ICollection<T> 及其友元创建 List<T>

我们将查看的最后一个场景是当源是 ICollection<T> 或类似接口时:

using System.Collections.Generic;

MyFunc([ 1, 2, 3, 4, 5]);

List<int> MyFunc(ICollection<int> source) => [..source]; // ICollection source

在这种情况下生成的代码 看起来 相对复杂,但你可以从这个例子中看到,它等同于 foreach 循环生成的相同代码,因此它相当于这样:

// 创建目标列表
List<int> list = new List<int>();

// 将后台数组初始化为集合的大小
CollectionsMarshal.SetCount(list, source.Count);

// 获取后台数组
Span<int> span = CollectionsMarshal.AsSpan(list);

// 在目标 span 中设置源中的每个元素
int num = 0;
foreach(var current in source)
{
  span[num] = current;
  num++;
}

你可能会想知道为什么生成的代码不再次使用 AddRange() (对于较早的运行时,这就是发生的事情),但直接写入 Span<T> 的生成代码(假设)更有效,因为它绕过了 Add()AddRange() 的开销。

多个展开和固定元素

为了简单起见,本文主要展示单一集合源,但你也可以混合展开集合和固定元素,例如:

using System;
using System.Collections.Generic;

MyFunc([ 1, 2, 3, 4, 5]);

List<int> MyFunc(Span<int> source) => [0, ..source, 6, 7];

在这个例子中,附加元素并 没有真正改变任何东西 ,因为编译器可以考虑到附加元素并计算所需的最终容量:

int num = 0;
Span<int> span = source;

int num2 = 3 + span.Length; // 计算总长度

List<int> list = new List<int>(num2); // 创建最终列表
CollectionsMarshal.SetCount(list, num2); // 设置最终大小

Span<int> span2 = CollectionsMarshal.AsSpan(list); // 获取列表后台数组

int index = 0;
span2[index] = num; // 设置第一个固定元素
index++;
span.CopyTo(span2.Slice(index, span.Length)); // 将源复制到目标
index += span.Length;
span2[index] = 6; // 复制剩余的固定元素
index++;
span2[index] = 7;
index++;

对于其他集合类型,代码以类似的方式变化,所以我不会在这里重复。当有 多个 展开元素时,事情变得有趣。例如,下面的例子展开了两个 ICollection<T> 实例:

using System;
using System.Collections.Generic;

MyFunc([ 1, 2, 3, 4, 5], [ 1, 2, 3, 4, 5]);

List<int> MyFunc(ICollection<int> source, ICollection<int> source2) => [0, ..source, 6, 7, ..source2];

鉴于已知所需列表的最终大小,我 预计 生成的代码会考虑到这一点,但它 使用更简单的代码

List<int> list = new List<int>();
list.Add(0);
list.AddRange(source);
list.Add(6);
list.Add(7);
list.AddRange(source2);

我怀疑这是一种不费力去优化不太常见的情况的情况,所以也许以后会有所改进?

我想我们已经看了足够多的创建 List<T> ,现在我们将看看另一端的情况,从其他集合创建 IEnumerable<T>

使用展开元素创建 IEnumerable<T>

从多个现有集合创建 IEnumerable<T> 再次是 可能的 不使用集合表达式,但它要么效率不高,要么非常笨拙。

例如,想象你试图结合两个集合与一些固定元素,就像前面的例子一样:

using System;
using System.Collections.Generic;

IEnumerable<int> MyFunc(IEnumerable<int> source,
IEnumerable<int> source2) => [0, ..source, 6, 7, ..source2];

没有 集合表达式,你可以使用 yield 关键字产生相同的结果:

IEnumerable<int> B(IEnumerable<int> source, IEnumerable<int> source2)
{
  yield return 0;
  foreach(var val in source)
  {
    yield return val;
  }

  yield return 6;
  yield return 7;
  foreach(var val in source2)
  {
    yield return val;
  }
}

但这 需要 你在单独的函数中进行串联,并且非常冗长。或者你可以回退到使用 Linq

IEnumerable<int> B(IEnumerable<int> source, IEnumerable<int> source2)
  => Enumerable.Repeat(0, 1)
    .Concat(source)
    .Concat(Enumerable.Repeat(6, 1))
    .Concat(Enumerable.Repeat(7, 1))
    .Concat(source2);

这几乎是好的,但是添加固定值既丑陋又相对低效。集合表达式在这里只是如此美好!但是编译器实际生成了什么?

List<T>Span<T>T[] 创建 IEnumerable<T>

我们将回到单一集合以简化,我们将从 List<T> 源开始:

using System.Collections.Generic;

MyFunc([ 1, 2, 3, 4, 5]);

IEnumerable<int> MyFunc(List<int> source) => [..source];

在这种情况下,生成的代码 非常简单,它调用 List.ToArray() 来获取后台数组的副本,然后将结果封装在生成的 ReadOnlyArray 类型中。

new <>z__ReadOnlyArray<int>(source.ToArray());

有趣的是,用 Span<T>ReadOnlySpan<T> 替换 List<T>生成完全相同的代码 ,因为它们也提供 ToArray() 方法。并且 T[] 几乎是相同的:

new <>z__ReadOnlyArray<int>(new ReadOnlySpan<int>(source).ToArray());

这里唯一的区别是生成的代码首先将 T[] 包装在 ReadOnlySpan<T> 中,然后再调用 ToArray() 。有趣的是,这可能是比直接 Array.Copy() 操作成本更低,或者是更好的 API 使用方式!

从另一个 IEnumerable<T> 创建 IEnumerable<T>

远离像 List<T>T[] 这样的已知长度类型,我们可以转向另一端,从另一个 IEnumerable<T> 创建 IEnumerable<T>

using System.Collections.Generic;

MyFunc([ 1, 2, 3, 4, 5]);

IEnumerable<int> MyFunc(IEnumerable<int> source) => [..source];

在这种情况下,生成的代码 使用 List<T> 作为返回对象的 “后台” 类型,使用 List.AddRange() 将源添加到列表中,然后将其封装在另一个生成类型 <>z__ReadOnlyList 中:

List<int> list = new List<int>();
list.AddRange(source);
new <>z__ReadOnlyList<int>(list);

就像 <>z__ReadOnlyArray 一样, <>z__ReadOnlyList 类型是编译器生成的类型。它看起来像下面这样,所有接口都明确实现:

internal sealed class <>z__ReadOnlyList<T> : IEnumerable, ICollection, IList, IEnumerable<T>, IReadOnlyCollection<T>, IReadOnlyList<T>, ICollection<T>, IList<T>
{
  private readonly List<T> _items; // 包含项目的后台列表

  // 明确实现了接口
  int ICollection.Count => _items.Count;
  void IList.Clear() => throw new NotSupportedException(); // 修改方法抛出异常

  // ... 等等
}

所有实现的成员都将委托给底层 List<T> _items ,所有会修改列表的成员都会抛出 NotSupportedException()

从另一个 ICollection<T> 和类似的接口创建 IEnumerable<T>

我们将查看的最后一种情况是从 ICollection<T> 和其他类似的接口创建 IEnumerable<T>

using System.Collections.Generic;

MyFunc([ 1, 2, 3, 4, 5]);

IEnumerable<int> MyFunc(ICollection<int> source) => [..source];

像往常一样,这种情况介于 int[]IEnumerable<T> 之间;我们不能保证拥有连续内存,但我们 确实 知道集合的大小,因此我们可以 预先分配正确大小的集合

int num = 0;
int[] array = new int[source.Count];
foreach(var current in source)
{
  array[num] = current; num++;
}
return new <>z__ReadOnlyArray<int>(array);

在这种情况下,编译器创建了一个正确最终大小的 T[] 。然后枚举 ICollection<T> 中的所有元素并将它们分配给数组元素。最后,它将数组封装在编译器生成的 <>z__ReadOnlyArray 类型中。

使用展开元素创建 T[]

这篇文章变得非常长,所以我们现在要加快速度!😄

从展开集合创建 T[] 是最简单的选项之一。正如我们反复看到的那样, T[] 经常是可能的情况下类型选择的 “后台” 集合,所以在大多数情况下,解决方案很简单。例如,将列表展开到数组

int[] MyFunc(List<int> source) => [..source];

仅仅是

source.ToArray();

同样地,将数组展开到另一个数组中:

int[] MyFunc(int[] source) => [..source];

使用了我们之前看到的 ReadOnlySpan.ToArray() 技巧

new ReadOnlySpan<int>(source).ToArray();

同时,将 IEnumerable<T> 展开到 T[]

int[] MyFunc(IEnumerable<int> source) => [..source];

使用了一个 List<T> ,使用 AddRange() 添加元素,然后调用 ToArray() 将内容作为数组返回:

List<int> list = new List<int>();
list.AddRange(source);
return list.ToArray();

最后,对于 ICollection<T> 和类似的接口,其中集合长度是已知的,使用一个 foreach 循环来写入数组元素,正如在返回 IEnumerable<T> 时前面部分所示。

创建使用展开元素的 ReadOnlySpan<int>

大部分情况下,从展开的集合创建 ReadOnlySpan<T>T[] 情况相同,因此我们会略过它们。

在以下示例中,你会注意到 sharplab.io 的源代码中有多个方法。这是为了避免编译器完全省略 ReadOnlySpan<T> 的创建!

再次回顾简单的案例,List<T> 创建 ReadOnlySpan<T> 会先从 List<T> 创建一个数组,然后直接将其包装起来:

List<int> source = //...
new ReadOnlySpan<int>(source.ToArray())

同样地,从 T[] 创建 ReadOnlySpan<T> 会通过将其包装在 ReadOnlySpan<T> 中并调用 ToArray() 来复制源数组:

int[] source = //...
new ReadOnlySpan<int>(new ReadOnlySpan<int>(source).ToArray())

使用 IEnumerable<T> 作为源时,会 采用 List<T> 的技巧来创建数组 ,然后用 ReadOnlySpan<T> 包装该数组。

IEnumerable<int> source = //...

List<int> list = new List<int>();
list.AddRange(source);
new ReadOnlySpan<int>(list.ToArray());

最后,ICollection<T> 使用带有 foreach 的数组

ICollection<int> source = // ...
int num = 0;
int[] array = new int[source.Count];
foreach(var current in source)
{
    array[num] = current;
    num++;
}
new ReadOnlySpan<int>(array);

作为最后的总结,我们将简要讨论从固定元素和展开数组的混合体创建 ReadOnlySpan<T> 的情况,类似于这样:

int[] source = //
ReadOnlySpan<int> = [1, ..source, 6, 7];

编译器仍然知道最终所需的 ReadOnlySpan<T> 长度,因此它可以预先分配适当大小的数组,但 生成的代码 对于展示 Span<T> 如何使移动数据块变得更加容易来说相当有趣:

int element0 = 1; // 要存储在元素 0 的值。
int index = 0; // 元素索引
int[] array = new int[3 + source.Length]; // 计算最终数组大小

// 设置第一个元素并递增索引
array[index] = element0;
index++;

// 将源数组包装为 ReadOnlySpan
ReadOnlySpan<int> readOnlySpan = new ReadOnlySpan<int>(source);

// 👇 这是展开的核心。它将目的地数组包装为 Span<T>
// - 切片数组以正确的大小,返回正确大小的 Span<T>
// - 将源 Span<T> 复制到目的切片 Span<T> 中,将数据写入底层数组
// - 通过已写入元素的数量递增索引
span.CopyTo(new Span<int>(array).Slice(index, span.Length));
index += span.Length;

// 设置剩余的固定元素
array[index] = 6;
index++;
array[index] = 7;
index++;

// 将目的数组包装为 ReadOnlySpan<T>
new ReadOnlySpan<int>(array);

只要你花时间理解 Span<T> ,这没有什么特别复杂的,但很高兴想到这是尽可能高效的方式 而你不必编写它 。这并不总是如此,特别是如果你正在展开多个集合,但随着新版本的 .NET 发布,编译器可以继续改进,你的代码只会变得更快。

我们已经深入探讨了集合表达式背后的内容。在本系列的最后一篇文章中,我将展示如何为自己的类型添加对集合表达式的支持,即使它们通常不支持集合初始化器。

英文原文

Last Modification : 4/30/2025 5:22:07 PM


In This Document