C#12 集合表达式的幕后揭秘 2 - 探索生成的代码:List<T> 和备选方案

Avatar
不若风吹尘
2024-07-26T17:37:33
101
0

本文中,我们将关注当你使用集合表达式与一些内置类型时,编译器会生成什么样的代码。本文主要分析那些生成代码简单易懂的情况。

  • 集合初始化器:HashSet<T>ConcurrentBag<T>SortedSet<T>
  • 使用集合表达式自定义类型
  • 优化 List<T>
  • 面向早期版本的目标时的 List<T>
  • List<T> 支持的接口:IList<T>ICollection<T>

集合初始化器:HashSet、ConcurrentBag 和 SortedSet

HashSet<T> 开始可能显得有些奇怪,但我选择它是因为编译器生成的代码非常简单直观。如果你编写如下代码:

using System.Collections.Generic;
HashSet<int> hashSet = [1, 2, 3, 4];

编译器会生成类似这样的代码:

HashSet<int> hashSet = new HashSet<int>();
hashSet.Add(1);
hashSet.Add(2);
hashSet.Add(3);
hashSet.Add(4);

这与你使用旧式集合初始化器而非集合表达式完全相同:

HashSet<int> list = new() { 1, 2, 3, 4 };

同样地,HashSet<T> 的空集合初始化器 [] 只是调用 new HashSet<T>()

为什么这里的代码如此基础且未经优化?简单的答案是编译器并没有为 HashSet<T> 包含任何特殊处理,所以它使用了与自定义集合相同的回退路径:即集合初始化语法。

ConcurrentBag<T>SortedSet<T> 等其他可以使用集合初始化器的集合也是同样的道理。

如果你使用集合表达式创建集合:

using System.Collections.Concurrent;
ConcurrentBag<int> bag = [1, 2, 3, 4, 5];

生成的代码与使用集合初始化器版本相同,为每个条目调用 Add() 方法。

ConcurrentBag<int> bag = new ConcurrentBag<int>();
concurrentBag.Add(1);
concurrentBag.Add(2);
concurrentBag.Add(3);
concurrentBag.Add(4);
concurrentBag.Add(5);

对于 SortedSet<T> ,生成的代码模式也一样,与使用集合初始化器的版本看起来一致。

SortedSet<int> sortedSet = new SortedSet<int>();
sortedSet.Add(1);
sortedSet.Add(2);
sortedSet.Add(3);

我保证我们很快会研究比集合初始化器更有趣的内容,但在那之前,值得指出的是这个概念不仅仅适用于内置的集合类型,你也可以在自己的自定义类型中使用集合表达式。

使用集合表达式自定义类型

你可能不知道,实际上,你可以对实现了 IEnumerable<T> 并公开了 Add() 方法(或等效的扩展方法)的任何类型使用集合初始化器语法。

例如,你可以创建一个这样的集合:

class MyCollection : IEnumerable<int> {
    private readonly List<int> _items = new();
    public IEnumerator<int> GetEnumerator() => _items.GetEnumerator();
    IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
    public void Add(int i) { _items.Add(i); }
}

然后,你可以像这样在集合初始化器中使用它:

MyCollection myCollection = new() { 1, 2, 3, 4, 5 };

在这种情况下,我实现了 IEnumerable<T>,但你也可以实现 IEnumerable,或者仅仅暴露一个 GetEnumerator() 方法而不公开接口。

集合初始化器生成的代码简单地对每个元素调用 Add() 方法。

MyCollection myCollection = new MyCollection();
myCollection.Add(1);
myCollection.Add(2);
myCollection.Add(3);
myCollection.Add(4);
myCollection.Add(5);

对于集合表达式,类型的要求与集合初始化器基本相同 —— 如果可以在集合初始化器中使用它,那么很可能也能使用集合表达式。

MyCollection mycollection = [ 1, 2, 3, 4, 5 ];

正如你可能猜到的,生成的代码是完全一样的:

MyCollection myCollection = new MyCollection();
myCollection.Add(1);
myCollection.Add(2);
myCollection.Add(3);
myCollection.Add(4);
myCollection.Add(5);

有一点需要注意的是,集合表达式要求有一个公共的无参构造函数,因为在生成的代码中构造函数是被隐式调用的。这与集合初始化器形成对比,在集合初始化器中你可以使用任何公共构造函数,因为你直接调用了它。

优化 List<T>

HashSet<T> 并不太吸引人,但到了 List<T> ,事情开始变得复杂起来。首先考虑旧式集合初始化器语法:

using System.Collections.Generic;

List<int> list = new () {1, 2, 3, 4, 5};

那么生成的代码将会看起来非常类似于 HashSet<T> 的代码:

List<int> list = new List<int>();
list.Add(1);
list.Add(2);
list.Add(3);
list.Add(4);
list.Add(5);

这段代码虽然没问题,但它做的工作比必要的要多。每次调用 Add() 都必须检查底层的 int[](该数组存储 List<int> 中的实际值)是否需要调整大小。即使只有 5 个元素,在上面的代码中我们最终也需要执行调整大小的操作,因为默认容量是 4 个元素。

因此,我们应该使用集合表达式而不是集合初始化器:

using System.Collections.Generic;

List<int> list = [1, 2, 3, 4, 5];

使用集合表达式,编译器有更多的自由利用它知道将会有 5 个元素这一事实,从而直接创建具有正确大小的底层数组。结合 CollectionsMarshal 中的一些不安全方法,这使得初始化过程更加高效:

List<int> list = new List<int>();
// 强制列表支持最终的条目数量
CollectionsMarshal.SetCount(list, 5);
// 获取对底层数组作为 Span<T> 的访问权限,以便你可以修改其中的值
Span<int> span = CollectionsMarshal.AsSpan(list);
int num = 0;
span[num] = 1; // 设置每个值
num++;
span[num] = 2;
num++;
span[num] = 3;
num++;
span[num] = 4;
num++;
span[num] = 5;
num++;

生成的代码使用了在 .NET 8 中引入的 CollectionsMarshal.SetCount() 方法来扩展列表底层数组的大小以容纳最终的元素数量。然后,它使用了在 .NET 5 中引入的 CollectionsMarshal.AsSpan() 方法来直接更新数组元素。

注意

这种方法具有一些 “不安全” 的行为,所以在一般情况下使用时需要谨慎。当然,当该方法由编译器作为集合表达式的一部分使用时,上面所示的例子是完全安全的。

生成的代码比调用五次 Add() 方法的工作量少得多,但最终结果是相同的。这是集合表达式的一大卖点:编译器可以利用对语言或运行时的更新来使你的代码更快,而无需你进行任何更改!

面向早期版本的目标时的 List<T>

虽然集合表达式是在 C#12.NET 8 中引入的,但你仍然可以同时使用 C#12 并面向 .NET 的早期版本。对于一些 C# 特性(如默认接口方法)在 .NET 早期版本中不可用,官方支持情况可能有些模糊,但一般来说,你可以假设除非编译器告诉你不行,否则可以安全地使用更新的 C# 特性,比如集合表达式。

然而,优化 List<T> 的代码使用了一个 .NET 8 中引入的 API:CollectionsMarshal.SetCount()。如果你面向的是 .NET 的早期版本,该 API 不可用,编译器就必须采取其他措施。在这种情况下,它会回退到简单的集合初始化器代码。

List<int> list = new List<int>();
list.Add(1);
list.Add(2);
list.Add(3);
list.Add(4);
list.Add(5);

因此在这个特定的例子中,早期的目标框架版本(TFMs)使用集合表达式并不能从中获得性能提升的好处,不过这并不意味着所有的集合表达式使用场景或者所有的集合类型都会是这样。

List<T> 支持的接口:IList<T>ICollection<T>

到目前为止,重点讨论的是具体类型,但集合表达式也适用于某些接口类型。特别是 IList<T>ICollection<T> ,它们生成的实例背后是 List<T> 类型。

对于这样的代码:

using System;
using System.Collections.Generic;

IList<int> ilist = [1, 2, 3 ];
ICollection<int> collection = [2, 4, 6];

Console.WriteLine(ilist is List<int>); // True
Console.WriteLine(collection is List<int>); // True

编译器生成的代码与你期望的如果每个变量被声明为 List<int> 时的 List 初始化代码完全相同。

List<int> list = new List<int>();
CollectionsMarshal.SetCount(list, 3);
Span<int> span = CollectionsMarshal.AsSpan(list);
int num = 0;
span[num] = 1;
num++;
span[num] = 2;
num++;
span[num] = 3;
num++;

在下一篇文章中,我们将继续探讨更多集合类型,如 T[]ReadOnlySpan<T>,看看它们如何在使用集合表达式时得到高度优化。

英文原文

Last Modification : 9/18/2024 11:36:18 PM


In This Document