C#12 集合表达式的幕后揭秘 3 - T[]、Span<T> 和不可变集合

Avatar
不若风吹尘
2024-07-31T09:28:14
98
0

在这篇文章中,我们将研究集合表达式为数组、ReadOnlySpan<T> / Span<T> 和不可变集合生成的代码。

为数组优化的集合表达式

当我们开始考虑数组时,可以看到生成的代码根据所使用的类型有很大的不同。对于大多数引用类型,数组的集合表达式代码基本上就是你预期的那样。例如,如果你有这样的代码:

string[] array = [ "1", "2", "3", "4", "5" ];

那么 生成的代码 与使用 传统的数组初始化器生成的代码 相同:

string[] array = new string[5];
array[0] = "1";
array[1] = "2";
array[2] = "3";
array[3] = "4";
array[4] = "5";

如果 T 是像 intdoublebool 这样的简单基本类型,情况就变得更有趣了,例如:

int[] array = [1, 2, 3, 4, 5];

突然之间,生成的代码 看起来显著不同:

RuntimeHelpers.InitializeArray(new int[5], (RuntimeFieldHandle)/*OpCode not supported: LdMemberToken*/);

[CompilerGenerated]
internal sealed class <PrivateImplementationDetails>
{
    [StructLayout(LayoutKind.Explicit, Pack = 1, Size = 20)]
    private struct __StaticArrayInitTypeSize=20
    {
    }
    internal static readonly __StaticArrayInitTypeSize=20 4F6ADDC9659D6FB90FE94B6688A79F2A1FA8D36EC43F8F3E1D9B6528C448A384 /* Not supported: data(01 00 00 00 02 00 00 00 03 00 00 00 04 00 00 00 05 00 00 00) */;
}

你会注意到 “不支持” 的注释 —— 这是因为生成的代码是有效的 IL,但它不是有效的 C#,因此 sharplab 只能尽力处理。不过,你可以推断出发生了什么:

  • 数组的最终表示被转换为一系列字节,并存储在名为 <PrivateImplementationDetails> 类中的静态只读字段里。

  • RuntimeHelpers.InitializeArray() 方法接受一个数组和对字段的引用,并直接用该字段的内容覆盖数组的内容。

这非常强大;不需要遍历数组的每个元素,它实际上是将静态字段(嵌入到程序集中)的内容复制到另一个内存位置(数组)。

如果你仔细看 data() 注释里的 4F6ADDC9659D6... 字段,并记住每个 int 是 4 个字节,你可以看到这看起来像是数组中的 1, 2, 3, 4, 5 。尝试改变集合中的值的数量,你会看到 StructLayout.Size__StaticArrayInitTypeSize 值相应地变化。

你还可以尝试使用其他基本类型,如 doublebool ,并看到你得到相同的行为。你需要使用包含三个或更多非 default 值的数组;无论数组的大小如何,如果只有两个或更少的值是非默认值,编译器会切换到直接分配元素

我觉得如果我不展示直接字段赋值情况实际生成的 IL,会有点失职,所以我添加了之前显示的 5 元素数组集合表达式生成的 IL

IL_0000: ldc.i4.5 // 将'5'压入栈顶作为int32
IL_0001: newarr [System.Runtime]System.Int32 // 创建int[5]
IL_0006: dup // 复制引用(稍后会被调用消耗)
// 👇 将引用的元数据令牌转换为`RuntimeHandle`
IL_0007: ldtoken field valuetype '<PrivateImplementationDetails>`1'/'__StaticArrayInitTypeSize=16' '<PrivateImplementationDetails>`1'::4F6ADDC9659D6FB90FE94B6688A79F2A1FA8D36EC43F8F3E1D9B6528C448A384
// 👇 调用RuntimeHelpers::InitializeArray,传入数组和RuntimeHandle引用
IL_000c: call void [System.Runtime]System.Runtime.CompilerServices.RuntimeHelpers::InitializeArray(class [System.Runtime]System.Array, valuetype [System.Runtime]System.RuntimeFieldHandle)

关于 ldtoken 等还有一些解释,但概念上应该是有意义的:我们正在将固定、编译时的内存块复制到原始数组内存中。所以它很快。😃

IEnumerable<T>IReadOnlyCollection<T>IReadOnlyList<T>

IEnumerable<T>IReadOnlyCollection<T>IReadOnlyList<T> 类型的集合表达式由 T[] 支持,并且大部分行为类似于数组等价物。因此,对于像 int 这样的基本可复制类型,你获得优化的 InitializeArray() 代码,而对于其他类型,则获得每个元素的 “单纯” 初始化。

注意

"Blittable" 在技术上指的是一个类型在托管内存和非托管内存中是否具有相同的表示形式,并且包括(但不限于)像 intlongdouble 这样的基本类型。像 stringbool(或许令人惊讶的是)并不是 blittable 类型。

然而,在这篇帖子的目的下,我稍微滥用了一下 "blittable" 这个术语,将其定义为编译器能够将值数组存储在一个连续的内存块中的任何类型。

然而,有趣的是,接口实现并不像 List<T>/IList<T> 案例那么简单,在那里创建 IList<T> 集合表达式会在幕后返回一个 List<> 。这一点从使用数组接口的集合表达式变得明显:

using System;
using System.Collections.Generic;

IEnumerable<int> enumerable = [1, 2, 3, 4, 5];
IReadOnlyCollection<int> collection = [1, 2, 3, 4, 5];
IReadOnlyList<int> list = [1, 2, 3, 4, 5];

Console.WriteLine(enumerable is int[]); // False
Console.WriteLine(collection is int[]); // False
Console.WriteLine(list is int[]); // False

如你所见,IEnumerable<T> 等并不是简单地由 int[] 实现的。让我们看看生成的代码,以非可复制版本为例(因为生成的代码更容易阅读):

using System.Collections.Generic;

IEnumerable<string> array = [ "1", "2", "3", "4", "5" ];

这个示例的 生成代码 表明我们正在用另一种类型包装数组 。

string[] array = new string[5];
array[0] = "1";
array[1] = "2";
array[2] = "3";
array[3] = "4";
array[4] = "5";
return new <>.z__ReadOnlyArray<string>>(array);

难以发音的类型 <>z__ReadOnlyArray<T> 是另一个编译器生成的类型,它的行为几乎正如你所期望的那样。它是 T[] 的简单包装,实现了许多与数组相同的接口,但作为一个真正只读的版本:

internal sealed class <>.z__ReadOnlyArray<T> : IEnumerable, IEnumerable<T>, IReadOnlyCollection<T>, IReadOnlyList<T>, ICollection<T>, IList<T>
{
    private readonly T[] _items;
    public <>.z__ReadOnlyArray(T[] items) => _items = items;

    int IReadOnlyCollection<T>.Count => _items.Length;
    T IReadOnlyList<T>.this[int index] => _items[index];
    int ICollection<T>.Count => _items.Length;
    bool ICollection<T>.IsReadOnly => true;

    T IList<T>.this[int index] {
        get => _items[index];
        set => throw new NotSupportedException();
    }

    IEnumerator IEnumerable.GetEnumerator() => ((IEnumerable)_items).GetEnumerator();
    IEnumerator<T> IEnumerable<T>.GetEnumerator() => ((IEnumerable<T>)_items).GetEnumerator();

    bool ICollection<T>.Contains(T item) => ((ICollection<T>)_items).Contains(item);
    void ICollection<T>.CopyTo(T[] array, int arrayIndex) => ((ICollection<T>)_items).CopyTo(array, arrayIndex);

    int IList<T>.IndexOf(T item) => ((IList<T>)_items).IndexOf(item);

    void ICollection<T>.Add(T item) => throw new NotSupportedException();
    void ICollection<T>.Clear() => throw new NotSupportedException();
    bool ICollection<T>.Remove(T item) => throw new NotSupportedException();
    void IList<T>.Insert(int index, T item) => throw new NotSupportedException();
    void IList<T>.RemoveAt(int index) => throw new NotSupportedException();
}

有趣的是,<>z__ReadOnlyArray<T>部分实现了非只读集合,并在尝试调用任何变异方法时抛出异常。我不完全确定这种方法的逻辑,这似乎对我来说有些危险,但是 🤷‍♂️ 我的假设是它通过存在 ICollection<T>.CopyTo() 等方法使编译器能够实现其他优化。

创建 ReadonlySpan<T> 集合表达式

对于 ReadOnlySpan<T>,我们必须再次考虑两种不同的实现,即像 int 这样的可复制值和其他类型;我们先从 int 版本开始。代码示例必须以某种方式“使用”集合,否则编译器可能会完全省略它,因为创建 ReadOnlySpan<T> 没有副作用:

using System;

ReadOnlySpan<int> A()
{
    ReadOnlySpan<int> array = [1, 2, 3, 4, 5];
    return array;
}

这生成看起来像下面这样的 代码

return RuntimeHelpers.CreateSpan<int>((RuntimeFieldHandle)/*OpCode not supported: LdMemberToken*/);

[CompilerGenerated]
internal sealed class <PrivateImplementationDetails>
{
    [StructLayout(LayoutKind.Explicit, Pack = 4, Size = 20)]
    private struct __StaticArrayInitTypeSize=20_Align=4
    {
    }

    internal static readonly __StaticArrayInitTypeSize=20_Align=4 4F6ADDC9659D6FB90FE94B6688A79F2A1FA8D36EC43F8F3E1D9B6528C448A3844/* Not supported: data(01 00 00 00 02 00 00 00 03 00 00 00 04 00 00 00 05 00 00 00) */;
}

这看起来非常熟悉!它几乎与 int[] 代码相同,但是不是调用 RuntimeHelpers.InitializeArray() ,而是调用 RuntimeHelpers.CreateSpan() ,后者又调用 RuntimeHelpers.GetSpanDataFrom() 。正如 Stephen Toub 在他的 .NET 8 性能改进的史诗级帖子 中描述的那样:

提示

它将数组的数据直接复制到程序集中,然后构建 span 不是通过分配数组,而是直接用一个指向程序集数据的指针来包裹 span。这不仅避免了启动时的开销和堆上的额外对象,还更好地支持了各种即时编译器(JIT)优化,特别是当 JIT 能够看到被访问的偏移量时。

这样就涵盖了 int 的情况。如果你还记得的话,int[] 有类似的优化,但对于 string[] ,编译器退回到每个元素的基本初始化。那么对于 ReadOnlySpan<T> 会发生什么呢?

using System;

ReadOnlySpan<string> array = [ "1", "2", "3", "4", "5" ];

生成的代码 肯定不是朴素的实现!

<>.y__InlineArray5<string> buffer = default(<>.y__InlineArray5<string>);
<PrivateImplementationDetails>.InlineArrayElementRef<<>.y__InlineArray5<string>, string>(ref buffer, 0) = "1";
<PrivateImplementationDetails>.InlineArrayElementRef<<>.y__InlineArray5<string>, string>(ref buffer, 1) = "2";
<PrivateImplementationDetails>.InlineArrayElementRef<<>.y__InlineArray5<string>, string>(ref buffer, 2) = "3";
<PrivateImplementationDetails>.InlineArrayElementRef<<>.y__InlineArray5<string>, string>(ref buffer, 3) = "4";
<PrivateImplementationDetails>.InlineArrayElementRef<<>.y__InlineArray5<string>, string>(ref buffer, 4) = "5";
<PrivateImplementationDetails>.InlineArrayAsReadOnlySpan<<>.y__InlineArray5<string>, string>(ref buffer, 5);

[CompilerGenerated]
internal sealed class <PrivateImplementationDetails>
{
    internal static ReadOnlySpan<TElement> InlineArrayAsReadOnlySpan<TBuffer, TElement>([In][IsReadOnly] ref TBuffer buffer, int length)
    {
        return MemoryMarshal.CreateReadOnlySpan(ref Unsafe.As<TBuffer, TElement>(ref Unsafe.AsRef(ref buffer)), length);
    }
    internal static ref TElement InlineArrayElementRef<TBuffer, TElement>(ref TBuffer buffer, int index)
    {
        return ref Unsafe.Add(ref Unsafe.As<TBuffer, TElement>(ref buffer), index);
    }
}

[StructLayout(LayoutKind.Auto)]
[InlineArray(5)]
internal struct <>.y__InlineArray5<T>
{
    [CompilerGenerated]
    private T _element0;
}

上面示例中的所有难以理解的名字很难跟随,所以我们逐个分解。我们从<>y__InlineArray5<T>开始:

[StructLayout(LayoutKind.Auto)]
[InlineArray(5)]
internal struct <>.y__InlineArray5<T>
{
    [CompilerGenerated]
    private T _element0;
}

这定义了一个新的 struct 类型作为 内联数组 。内联数组的语法相当反直觉,但你可以将其视为创建 T[] 的一种方式,但无需在堆上分配新数组;相反,元素直接嵌入在 <>y__InlineArray5<T> 结构实例中。

这是一个非常模糊的描述,也许我会在将来专门写一篇关于它们的文章。在此期间,我推荐你阅读 Lazlo 写的 这篇优秀的解释文章

接下来是我们有 <PrivateImplementationDetails>.InlineArrayElementRef<TBuffer, TElement>() 实现。这个方法接收对内联数组的引用,并返回对该数组指定索引处元素的引用:

[CompilerGenerated]
internal sealed class <PrivateImplementationDetails>
{
    internal static ref TElement InlineArrayElementRef<TBuffer, TElement>(ref TBuffer buffer, int index)
    {
        return ref Unsafe.Add(ref Unsafe.As<TBuffer, TElement>(ref buffer), index);
    }
}

最后,我们有 <PrivateImplementationDetails>.InlineArrayAsReadOnlySpan<TBuffer, TElement>() 方法,顾名思义:它接受内联数组作为参数,并返回指向内联数组内容的 ReadOnlySpan<T> 引用:

[CompilerGenerated]
internal sealed class <PrivateImplementationDetails>
{
    internal static ReadOnlySpan<TElement> InlineArrayAsReadOnlySpan<TBuffer, TElement>([In][IsReadOnly] ref TBuffer buffer, int length)
    {
        return MemoryMarshal.CreateReadOnlySpan(ref Unsafe.As<TBuffer, TElement>(ref Unsafe.AsRef(ref buffer)), length);
    }
}

现在我们可以将所有这些放在一起,来理解集合表达式生成的代码。为了使其稍微容易阅读一点,我通过基本上提供类型别名简化了难以理解的名字

// 简化一些名字
using Private = <PrivateImplementationDetails>;
using StringInlineArray = <>y__InlineArray5<string>;

// 创建内联数组实例
StringInlineArray buffer = default(StringInlineArray);

// 设置内联数组的每个元素
Private.InlineArrayElementRef<StringInlineArray, MyType>(ref buffer, 0) = "1";
Private.InlineArrayElementRef<StringInlineArray, MyType>(ref buffer, 1) = "2";
Private.InlineArrayElementRef<StringInlineArray, MyType>(ref buffer, 2) = "3";
Private.InlineArrayElementRef<StringInlineArray, MyType>(ref buffer, 3) = "4";
Private.InlineArrayElementRef<StringInlineArray, MyType>(ref buffer, 4) = "5";

// 将内联数组的内容包装在ReadOnlySpan中
Private.InlineArrayAsReadOnlySpan<StringInlineArray, string>(ref buffer, 5);

通过使用内联数组,编译器能够避免分配整个新数组( string[] ),这总是会分配在堆上。内联数组是一个 struct ,因此可以分配在堆栈上或直接嵌入到其他类型中,这可以减少分配(当然,取决于细节!)

注意

请注意,只有数组本身是 “内联” 的并被嵌入。每个字符串实例都会像平常一样被分配,而每个数组元素包含对该实例的一个引用。

ReadOnlySpan<T> 之后,下一个明显的选项是考虑 Span<T>

Span<T>: 内联数组

在上一节中,我们看到当 T 是可直接复制的(例如 int )时,ReadOnlySpan<T> 的集合表达式实现经过了高度优化,通过在程序集中存储一个 static readonly 字段,然后在这个内存区域 “包裹” 一个 ReadOnlySpan<T>

不幸的是,Span<T> 要求能够修改它所包裹的元素,因此上述优化方法在这里不适用。但是,使用内联数组的非可直接复制的方法是可以使用的。实际上,Span<T> 集合表达式无论 T 是什么都使用相同的内联数组实现。这段代码同时使用了可直接复制和非可直接复制的数组:

using System;
Span<string> array = [ "1", "2", "3", "4", "5" ];
Span<int> array2 = [ 1, 2, 3, 4, 5 ];

生成的代码看起来大致如下(我再次添加了类型别名以提高可读性),并且表明两种情况下的实现本质上是相同的:

// 简化一些名称
using Private = <PrivateImplementationDetails>;
using StringInlineArray = <>y__InlineArray5<string>;
using IntInlineArray = <>y__InlineArray5<int>;

// Span<string> array = [ "1", "2", "3", "4", "5" ];
StringInlineArray buffer = default(StringInlineArray);
PrivateInlineArrayElementRef<StringInlineArray, string>(ref buffer, 0) = "1";
PrivateInlineArrayElementRef<StringInlineArray, string>(ref buffer, 1) = "2";
PrivateInlineArrayElementRef<StringInlineArray, string>(ref buffer, 2) = "3";
PrivateInlineArrayElementRef<StringInlineArray, string>(ref buffer, 3) = "4";
PrivateInlineArrayElementRef<StringInlineArray, string>(ref buffer, 4) = "5";
PrivateInlineArrayAsSpan<StringInlineArray, string>(ref buffer, 5);

// Span<int> array2 = [1, 2, 3, 4, 5];
IntInlineArray buffer2 = default(IntInlineArray);
PrivateInlineArrayElementRef<IntInlineArray, int>(ref buffer2, 0) = 1;
PrivateInlineArrayElementRef<IntInlineArray, int>(ref buffer2, 1) = 2;
PrivateInlineArrayElementRef<IntInlineArray, int>(ref buffer2, 2) = 3;
PrivateInlineArrayElementRef<IntInlineArray, int>(ref buffer2, 3) = 4;
PrivateInlineArrayElementRef<IntInlineArray, int>(ref buffer2, 4) = 5;
PrivateInlineArrayAsSpan<IntInlineArray, int>(ref buffer2, 5);

[CompilerGenerated]
internal sealed class <PrivateImplementationDetails>
{
    internal static Span<TElement> InlineArrayAsSpan<TBuffer, TElement>(ref TBuffer buffer, int length)
    {
        return MemoryMarshal.CreateSpan(ref Unsafe.As<TBuffer, TElement>(ref buffer), length);
    }

    internal static ref TElement InlineArrayElementRef<TBuffer, TElement>(ref TBuffer buffer, int index)
    {
        return ref Unsafe.Add(ref Unsafe.As<TBuffer, TElement>(ref buffer), index);
    }
}

[StructLayout(LayoutKind.Auto)]
[InlineArray(5)]
internal struct <>y__InlineArray5<T>
{
    [CompilerGenerated]
    private T _element0;
}

注意这里是在两种情况下重用了相同的 <>y__InlineArray5<T> ,只是用不同的 T 。如果我们有一个 5 个元素的 ReadOnlySpan<T> ,它也会重用这个实现。同样地,InlineArrayElementRef() 的实现与ReadOnlySpan<T> 相同,而 InlineArrayAsSpan() 则直接类似于 InlineArrayAsReadOnlySpan 版本。

ImmutableList<T>, ImmutableArray<T>, Immutable

我们几乎完成了对所有可以与集合表达式一起使用的类型的全面研究。我们最后的一组类型是:

  • ImmutableArray<T>
  • ImmutableList<T>/IImmutableList<T>
  • ImmutableQueue<T>/IImmutableQueue<T>
  • ImmutableStack<T>/IImmutableStack<T>
  • ImmutableHashSet<T>/IImmutableSet<T>
  • ImmutableSortedSet<T>

我们将从 ImmutableArray<T> 开始,因为在那里生成的代码与其他不可变集合略有不同。以一个简单的 int 示例为例:

using System.Collections.Immutable;
ImmutableArray<int> array = [1, 2, 3, 4, 5];

生成的代码分为两步:

  • 初始化一个数组
  • 调用 ImmutableCollectionsMarshal.AsImmutableArray(array) 来创建 ImmutableArray
int[] array = new int[5];
RuntimeHelpers.InitializeArray(array, (RuntimeFieldHandle)/*OpCode not supported: LdMemberToken*/);

ImmutableCollectionsMarshal.AsImmutableArray(array);

如果你还记得之前的 int[] 分析,你会看到这实际上是使用相同的集合表达式生成的代码来创建数组,然后将数组转换为不可变数组类型。

ImmutableCollectionsMarshal.AsImmutableArray() 的实现仅仅返回一个包装提供的实例的 ImmutableArray。这比调用 ImmutableArray.Create(array) 更快(并且分配更少),后者通常是标准做法,但为了安全起见会复制数组。编译器可以安全地使用 ImmutableCollectionsMarshal ,因为它知道没有人能引用生成的 array

生成的代码基本上是相同的,即使你使用的是非 blittable 类型如 string。它仍然创建一个 T[] 并调用 ImmutableCollectionsMarshal.AsImmutableArray()。唯一的不同之处在于,对于非 blittable 类型,生成的代码使用了 T[] 的 “简单” 初始化代码。

其余的不可变实现基本上使用相同的实现。它们各自创建一个 ReadOnlySpan<T>(使用针对给定 T 的适当生成代码),然后调用各自的 Create() 方法。对于以下代码:

using System.Collections.Immutable;

ImmutableList<int> list = [1, 2, 3, 4, 5];
IImmutableList<int> ilist = [1, 2, 3, 4, 5];

ImmutableQueue<int> queue = [1, 2, 3, 4, 5];
IImmutableQueue<int> iqueue = [1, 2, 3, 4, 5];

ImmutableStack<int> stack = [1, 2, 3, 4, 5];
IImmutableStack<int> istack = [1, 2, 3, 4, 5];

ImmutableHashSet<int> set = [1, 2, 3, 4, 5];
IImmutableSet<int> iset = [1, 2, 3, 4, 5];

ImmutableSortedSet<int> sortedSet = [1, 2, 3, 4, 5];

我们最终得到以下生成的代码。我在这里包括了 <PrivateImplementationDetails> 代码,因为它显示了每个 RuntimeHelpers.CreateSpan() 调用都可以包装相同的常量数据,因为它创建了一个 ReadOnlySpan<T>

ImmutableList.Create(RuntimeHelpers.CreateSpan<int>((RuntimeFieldHandle)/*OpCode not supported: LdMemberToken*/));
ImmutableList.Create(RuntimeHelpers.CreateSpan<int>((RuntimeFieldHandle)/*OpCode not supported: LdMemberToken*/));

ImmutableQueue.Create(RuntimeHelpers.CreateSpan<int>((RuntimeFieldHandle)/*OpCode not supported: LdMemberToken*/));
ImmutableQueue.Create(RuntimeHelpers.CreateSpan<int>((RuntimeFieldHandle)/*OpCode not supported: LdMemberToken*/));

ImmutableStack.Create(RuntimeHelpers.CreateSpan<int>((RuntimeFieldHandle)/*OpCode not supported: LdMemberToken*/));
ImmutableStack.Create(RuntimeHelpers.CreateSpan<int>((RuntimeFieldHandle)/*OpCode not supported: LdMemberToken*/));

ImmutableHashSet.Create(RuntimeHelpers.CreateSpan<int>((RuntimeFieldHandle)/*OpCode not supported: LdMemberToken*/));
ImmutableHashSet.Create(RuntimeHelpers.CreateSpan<int>((RuntimeFieldHandle)/*OpCode not supported: LdMemberToken*/));

ImmutableSortedSet.Create(RuntimeHelpers.CreateSpan<int>((RuntimeFieldHandle)/*OpCode not supported: LdMemberToken*/));

internal sealed class <PrivateImplementationDetails>
{
    [StructLayout(LayoutKind.Explicit, Pack = 4, Size = 20)]
    private struct __StaticArrayInitTypeSize=20_Align=4
    {
    }

    internal static readonly __StaticArrayInitTypeSize=20_Align=4 4F6ADDC9659D6FB90FE94B6688A79F2A1FA8D36EC43F8F3E1D9B6528C448A3844/* Not supported: data(01 00 00 00 02 00 00 00 03 00 00 00 04 00 00 00 05 00 00 00) */;
}

至此,我们已经涵盖了本系列中我要研究的所有内置类型。在下一篇文章中,我们将探讨当你在集合表达式中使用扩展元素 .. 时生成的代码如何变化。

英文原文

Last Modification : 9/18/2024 11:24:57 PM


In This Document