C#12 集合表达式的幕后揭秘 5 - 为自己的类型添加对集合表达式的支持

Avatar
不若风吹尘
2024-08-16T16:57:01
96
0

通过支持集合初始化器添加集合表达式支持

集合表达式会自动与任何支持集合初始化器的具体类型兼容。例如,Roslyn 中没有针对 HashSet 的特殊代码,但您可以使用它与集合表达式一起:

HashSet<int> values = [1, 2, 3, 4];

实际上,集合表达式生成的代码看起来有点像这样:

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

这与使用集合初始化器版本生成的代码相同:

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

这适用于任何支持集合初始化器且具有公共无参数构造函数的类型——该类型也自动支持集合表达式

注意,这里我只讨论了 “列表风格” 的集合初始化器;集合表达式还不支持 “字典风格” 的集合。

要支持集合初始化器所需的最低要求是实现 IEnumerable ,具有公共无参数构造函数,并且有一个公共 Add(T value) 实例方法,其中 T 是正确的类型(或者可以被通用地类型化)。

例如,下面展示了可以与集合初始化器一起使用的自定义集合的最小实现:

public class MyCollection : IEnumerable // 实现非泛型 IEnumerable
{
    // 存储数据的备份集合
    private readonly List<int> _list = new ();

    // 实现 IEnumerable 所需成员
    public IEnumerator<int> GetEnumerator() => _list.GetEnumerator();

    // 集合初始化器所需的 Add() 方法
    public void Add(int val)
    {
        _list.Add(val);
    }
}

注意,我实现了非泛型的 IEnumerable(而不是 IEnumerable<T>IEnumerable<int> 之类的泛型版本),但这仅仅是因为它是最低要求;你当然可以(并且可以说应该)实现泛型版本。

有了上面的代码,现在可以使用集合初始化器语法创建集合:

MyCollection<int> myCollection = new () { 1, 2, 3, 4 };

这意味着现在也可以使用集合表达式:

MyCollection<int> myCollection = [1, 2, 3, 4];

这非常酷,但也有一些权衡。例如,即使使用集合表达式我们事先就知道元素的数量,但编译器(或 MyCollection 类型)无法使用这些信息来优化代码。相反,你必须重复调用 Add()

此外,使用集合初始化器意味着你被迫使用可变类型,因为类型是使用 new() 创建的,然后通过重复调用 Add 进行变异。如果你有选择这样做就好了。

不幸的是,对于集合初始化器来说,你别无选择,没有满足这些要求所需的 API 的方法。但对于集合表达式,我们有机会!

使用 [CollectionBuilder] 创建集合

[CollectionBuilder] 属性是作为 C#12 集合表达式功能的一部分引入的,为您的类型支持集合表达式提供了一种高效机制。最容易的做法是展示一个集合构建器的最小实现,然后讨论其限制、要求和我们可以扩展的方式。

支持 [CollectionBuilder]MyCollection 的最简单实现可能如下所示:

using System;
using System.Linq;
using System.Collections;
using System.Collections.Generic;
using System.Runtime.CompilerServices;

// 使用[CollectionBuilder]装饰类型,指向
// 集合表达式应该调用以创建类型的方法
[CollectionBuilder(typeof(MyCollection), nameof(Create))]
public class MyCollection
{
    // 👇 这是集合表达式调用的方法
    // 它必须接受一个只读跨度值并返回一个集合实例
    public static MyCollection Create(ReadOnlySpan<int> values) => new (values);

    private readonly int[] _values;

    public MyCollection(ReadOnlySpan<int> values)
    {
        // 因为所有值都在构造函数中提供,我们可以
        // 使用数组备份类型而不是列表,这样更高效
        // 创建,并且不需要暴露突变的Add()方法
        _values = values.ToArray();
    }

    // 必须有一个返回IEnumerator实现的GetEnumerator()方法
    public IEnumerator<int> GetEnumerator() => _values.AsEnumerable().GetEnumerator();
}

这里有几个重要的点:

  • [CollectionBuilder] 属性指向一个 Type 和一个用于创建装饰集合的命名方法。
  • 集合必须是一个 “迭代类型”,即它应该有一个 GetEnumerator() 方法,返回一个 IEnumerator(或 IEnumerator<T> )。
    • 通常你会通过实现 IEnumerable 来满足这一点,但我选择了上面的最小示例!
    • GetEnumerator() 返回的类型 T 定义了集合类型的 “元素类型” 。
    • 如果你返回一个非泛型 IEnumerator,则 “元素类型” 为 object
  • [CollectionBuilder] 属性指向的方法必须是 static,可访问(例如 publicinternal),并且接受一个类型为 ReadOnlySpan<T> 的单个参数,其中 T 是集合的 “元素类型” 。

遵循这些规则并添加 [CollectionBuilder] 后,集合很可能会创建得更加高效。当创建 ReadOnlySpan 时,编译器能够显著优化集合表达式,而这正是这里发生的情况。

为了演示它的实际效果,如果我们创建新实现的集合的新实例:

MyCollection myCollection = [1, 2, 3, 4];

那么 生成的代码 比使用集合初始化器版本时要高效得多。

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

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

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

除此之外,我们不需要暴露 Add() 突变方法(如果我们不想的话),而是在类型构造时直接给出所有元素。

使用 [CollectionBuilder] 处理泛型集合

上面展示的 MyCollection 示例有些牵强,因为它仅支持创建 int 类型的集合。我们现在使用的大多数集合都是泛型的,带有一个类型参数 T。如果你想要装饰的集合是泛型的,需要做一些更改。

  • Create() 方法移到单独的非泛型类型中。[CollectionBuilder] 引用的类型不能是泛型的。
  • 更新 Create() 方法使其成为泛型方法,接受 ReadOnlySpan<T>
  • 更新 GetEnumerator() 调用以返回 IEnumerator<T>(或者更好的做法是实现 IEnumerable<T>

更新后的最小代码可能如下所示:

public static class MyCollectionBuilder
{
    // 构建方法必须是泛型的,
    // 但不能位于泛型类型中
    public static MyCollection<T> Create<T>(ReadOnlySpan<T> values) => new (values);
}

// 更新[CollectionBuilder]以指向其他类型
[CollectionBuilder(typeof(MyCollectionBuilder), nameof(MyCollectionBuilder.Create))]
public class MyCollection<T>(ReadOnlySpan<T> values) // 现在它是一个泛型类型
{
    // 使用主构造函数简化初始化泛型T[]
    private readonly T[] _values = values.ToArray();

    // 返回IEnumerator
    public IEnumerator<T> GetEnumerator() => _values.AsEnumerable().GetEnumerator();
}

从使用角度来看,代码看起来大致相同,我们只是改变了类型使之成为泛型的,因此我们从 MyCollection 移动到了 MyCollection<T>

MyCollection<int> myCollection = [1, 2, 3, 4];

生成的代码 基本上与以前的非泛型实现相同,但现在该类型可以与任何元素类型一起工作!

[CollectionBuilder] 添加到接口

到目前为止,我们一直在具体类型上添加 [CollectionBuilder],但集合表达式的一个很好的特性是你也可以用它们与接口一起使用,例如:

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

如果你在自己的接口上应用 [CollectionBuilder] 属性,你也可以做同样的事情!让我们扩展前面的例子,同时使用一个接口。首先,我们将让我们的 MyCollection 类型实现接口 IMyCollection

[CollectionBuilder(typeof(MyCollection), nameof(Create))]
public class MyCollection<T> : IMyCollection<T> // 实现接口
{
    // ... 如前
}

在这里使接口成为泛型的,但这不是必需的。接口的定义如下所示。它目前只是一个标记接口,唯一的要求是:

  • 它装饰有 [CollectionBuilder] 属性,该属性指向有效构造方法(其中方法的返回类型为 IMyCollection<T> )。
  • 它实现了 IEnumerable<T>(给出元素类型为 T )或 IEnumerable(给出元素类型为 object )。

因此,向 IMyCollection 类型添加支持就像这样简单:

[CollectionBuilder(typeof(MyCollectionBuilder), nameof(MyCollectionBuilder.Create))]
public interface IMyCollection<T> : IEnumerable<T>
{
}

现在你可以像使用具体类型一样使用集合表达式与接口类型!

IMyCollection<int> myCollection = [1, 2, 3, 4];

在早期框架版本中使用 [CollectionBuilder]

CollectionBuilderAttribute 类型是随着 C#12.NET 8 一起添加的,但如果您的目标是早期版本的 .NET,或者您正在为多个运行时进行多目标开发,该怎么办?好消息是,您只需在项目中创建自己的 CollectionBuilderAttribute 版本即可满足编译器的要求!

只需将以下文件添加到您的项目中,突然之间您的集合表达式就可以在早期版本的 .NET 中工作,甚至可以在 .NET Framework 中工作!

#if !NET8_OR_GREATER
namespace System.Runtime.CompilerServices;

sealed class CollectionBuilderAttribute : Attribute
{
    public CollectionBuilderAttribute(Type builderType, string methodName)
    {
        BuilderType = builderType;
        MethodName = methodName;
    }

    public Type BuilderType { get; }
    public string MethodName { get; }
}
#endif

注意,如果你的目标是旧版本的 .NET Framework,你还需添加对 System.Memory 的引用,以便能够使用 ReadOnlySpan<T>

这几乎涵盖了关于创建自己的集合表达式的所有内容,但在结束之前,我们将简要回顾一下在尝试实现集合构建器时可能遇到的错误。

当事情并不完全如预期那样工作...

当我最初探索集合表达式的最小要求时,我遇到了一些麻烦。我正在探索最小(非泛型)版本,但我无法让我的代码编译。

这是我开始时的代码:

using System;
using System.Linq;
using System.Collections;
using System.Collections.Generic;
using System.Runtime.CompilerServices;

// 创建集合
MyCollection myCollection = [1, 2, 3, 4];

// 定义
[CollectionBuilder(typeof(MyCollection), nameof(Create))]
public class MyCollection(ReadOnlySpan<int> values)
{
    // 构建方法
    public static MyCollection Create(ReadOnlySpan<int> values) => new (values);

    private readonly int[] _values = values.ToArray();
}

但我收到了错误

error CS9188: 'MyCollection' has a CollectionBuilderAttribute but no element type.

我开始围绕 CollectionBuilderAttribute 查找,试图找到一个我可以传递的 element 参数或类似的东西,但并没有这样的东西 🤷‍♂️ 希望阅读了前面的部分后,答案就会显而易见。我在搜索错误时找到了一条提示:

集合类型必须有一种迭代类型。换句话说,你可以将该类型作为集合来进行 foreach 遍历。

我知道 foreach 使用鸭子类型来寻找 GetEnumerator() 方法(而不是要求实现特定的方法),所以我尝试向类型添加GetEnumerator()(我现在将省略 using 和调用以避免冗余):

[CollectionBuilder(typeof(MyCollection), nameof(Create))]
public class MyCollection(ReadOnlySpan<int> values)
{
    public static MyCollection Create(ReadOnlySpan<int> values) => new (values);

    private readonly int[] _values = values.ToArray();

    // 使类型成为“迭代类型”,以便可以在foreach中使用
    public IEnumerator GetEnumerator() => _values.GetEnumerator();
}

这解决了 CS9188 类型的问题,但现在我收到了新的错误:

error CS9187: Could not find an accessible 'Create' method with the expected signature: a static method with a single parameter of type 'ReadOnlySpan<int>' and return type 'MyCollection'.

这让我一开始感到困惑,为什么它坚持 Create() 接受 ReadOnlySpan<int> 而不是 ReadOnlySpan<object>

我最终明白了——这是因为我在返回非泛型 IEnumerator 而不是 IEnumerator<int> ,所以编译器将 “元素类型” 视为 object 而不是 int

为了解决这个问题,我决定实现 IEnumerable<int> ,以免出错:

[CollectionBuilder(typeof(MyCollection), nameof(Create))]
public class MyCollection(ReadOnlySpan<int> values) : IEnumerable<int> // 实现IEnumerable<T>
{
    public static MyCollection Create(ReadOnlySpan<int> values) => new (values);

    private readonly int[] _values = values.ToArray();

    // 实现IEnumerable
    IEnumerator<int> IEnumerable<int>.GetEnumerator() => ((IEnumerable<int>)_values).GetEnumerator();

    public IEnumerator GetEnumerator() => _values.GetEnumerator();
}

但这仍然不起作用 🤦‍♂️ 我仍然收到要求 ReadOnlySpan<int> 的相同 CS9187 错误 😕

经过长时间的盯着看,我终于意识到我做了什么。IEnumerable 也实现了 IEnumerable,所以你需要实现两个 GetEnumerator() 方法:

  • IEnumerator GetEnumerator()IEnumerable 接口所必需的
  • IEnumerator<T> GetEnumerator()IEnumerable<T> 接口所必需的

由于这两个方法具有相同的签名,因此您需要在类型中 明确地 实现其中至少一个。在我的代码中,我已经明确实现了 IEnumerable<int> 接口,这与通常的做法 相反。通过这样做,集合表达式只发现了 IEnumerable 版本,并将元素类型设置为 object。为了修复它,我只需要翻转哪一个被明确实现:

[CollectionBuilder(typeof(MyCollection), nameof(Create))]
public class MyCollection(ReadOnlySpan<int> values): IEnumerable<int> // 实现 IEnumerable<T>
{
    public static MyCollection Create(ReadOnlySpan<int> values) => new(values);
    private readonly int[] _values = values.ToArray();
    // 👇 需要隐式实现 IEnumerable 实现...
    public IEnumerator<int> GetEnumerator() => ((IEnumerable<int>)_values).GetEnumerator();
    // 👇 ... 并且明确实现 IEnumerable 实现
    IEnumerator IEnumerable.GetEnumerator() => _values.GetEnumerator();
}

在做出更改后,我的代码最终编译成功了!🎉 还值得一提的是,以下两种选项 同样 有效:

  • 明确实现 IEnumerableIEnumerable<T>两个 方法。在这种情况下,集合表达式会自动选择更具体的 IEnumerable<T> 版本来确定元素类型。
  • 实现 IEnumerator<int> GetEnumerator() 方法 实现 IEnumerable<T>,如我在上一节的最小示例中所示。

您可能感兴趣的是,[CollectionBuilder] 属性也是 运行时 为某些 Immutable* 类型(例如 ImmutableArrayImmutableList)添加集合表达式支持的方式。

但并非 所有 集合都得到了这种支持。

不支持集合表达式的内置类型

最明显的不支持集合表达式的集合是字典,尽管它们支持集合初始化器。好消息是这些将在未来的 C# 版本中出现,主要问题是如何决定语法。

因此,目前所有的字典类型 Dictionary<,>ImmutableDictionary<,>SortedDictionary<,> 等都不支持集合表达式。但是,还有一些其他类型你 可能会 认为应该支持集合表达式,但无法编译。

  • ISet<T> — 许多接口类型如 IList<T>IReadOnlyCollection<T> 可以直接使用集合表达式,但你不能使用 ISet<T>。我没有深入探究是否有特定原因!
  • FrozenSet<T>ImmutableSet<T> 支持集合表达式,那么为什么 FrozenSet<T> 不支持?嗯,在 .NET 9 中,它是支持的!
  • SortedList<TKey, TValue> — 这个有点奇怪,因为它 一个列表,但它 实际上 像字典一样初始化,有两个泛型参数,并且有一个 Add(key, value) 而不是 value。所以当我们在 .NET 9 中获得字典表达式时,我们可能会得到这个。
  • PriorityQueue<TElement, TPriority> — 我在最近的一个系列中讨论了优先队列,并展示了它们与 “标准” 列表的行为相当不同。它们也不支持集合初始化器,所以我对它们不支持集合表达式并不感到惊讶。
  • ConcurrentQueue<T> — 我不得不说,我 期望 它可以使用集合表达式,特别是因为 ConcurrentBag<T> 支持它们。但是 ConcurrentQueue<T>不支持 集合初始化器语法,因为它没有 Add() 方法(只有 Enqueue() 方法)。因此,“默认” 的集合表达式模式是回退到集合初始化器,这意味着代码无法编译。
  • ConcurrentStack<T> — 就像 ConcurrentQueue<T> 一样,ConcurrentStack<T> 没有 Add() 方法(它有 Push()),所以它不支持集合初始化器,因此也不支持集合表达式。

最后三种类型 PriorityQueue<>ConcurrentQueue<>ConcurrentStack<> 不支持集合初始化器,但如果它们使用了 [CollectionBuilder],它们肯定 可以 支持。所以谁知道呢,如果需求足够高,也许将来它们会得到支持!

英文原文

Last Modification : 9/18/2024 11:26:54 PM


In This Document