集合表达式的多态

Polymorphism of Collection Expressions

今天抽空说一下集合表达式的一个尤其奇怪的设计:集合表达式的目标类型。

因为 C# 的集合表达式还没有全部完成(第二部分将在 C# 13 里完成)。因为设计的内容基本我都了解,所以我会把 C# 13 将会设计出来的集合表达式的特性也一并放在这里面说。

今天侧重要说的点是,集合表达式的多态。看这标题是不是很奇怪?一个表达式怎么还跟面向对象多态扯上关系了?

Part 1 基类的多态

1-1 错误的用法

话不多说。先来看这段代码:

Array arr = [42];

请问,这个赋值想表示什么?你是不是会觉得,42 作为元素的一维数组(等价于 new[] { 42 })然后赋值给 arr 变量?

如果你这么想就错了。这个写法其实是错误的。

让我们先来了解一下自然类型(Natural Type)的概念。什么是自然类型?自然类型指的是一个表达式本身,在没有任何上下文提示下,它能表达出来的具体类型。比如说字面量 42 的自然类型是 int,"Hello" 字符串的自然类型是 string。但是,C# 12 和 C# 13 在设计集合表达式的时候是这样弄的:

  • C# 12 里:集合表达式没有自然类型;

  • C# 13 里:集合表达式的自然类型是 List<T>

先来看 C# 12。由于集合字面量不存在自然类型,因此书写代码的时候写成 var 类型接收变量就会直接产生编译器错误:

var collection = [42]; // 错误

那么对于集合表达式,我们要想使用这种特性,就必须让 var 改写成一个具体的类型。但是前面这个代码的错误点在于,Array 是一个基类。那基类怎么了?集合表达式有这么一个规则:

如果左侧的类型是一个具体的类型(一个结构或类),则这个类型必须满足下面的设计规则,那么才能使用集合表达式:

  • 实现 IEnumerable<T> 泛型接口里规定的成员

    • LengthCount 属性的其一,且返回值必须是 int,不能为负数;

    • Add 方法,要求传入一个参数,且参数必须是 T 类型的,契合 IEnumerable<T> 接口里的泛型参数 T

  • 为类型标记 CollectionBuilderAttribute 特性

    • 实现一个 Create 方法(名字也可以是别的,一般取名 Create),传入一个参数为 ReadOnlySpan<T> 类型,其中的 T 契合集合想要表示的元素类型,并返回一个当前类型的实例;

    • 标记 CollectionBuilderAttribute 特性,传入两个参数。第一个参数是 Create 方法的所在类型(如 typeof(Builder));第二个参数是 Create 方法的名字(如 nameof(Create))。

当且仅当你满足上面两个条件的其中任何一个的时候,才能允许你定义的类型使用集合表达式。使用效果是

MyIntegerValues integers = [1, 2, 3];

既然我们现在了解了满足条件,那么下面我们来说一下前面的这个自然类型。因为集合表达式的返回值要么指定一个具体类型(满足前面的这些条件),要么根据 C# 13 默认规定,将其视为 List<T>。因此别的类型呢?如果一个类型既不是 List<T> 又不去实现前面规定的成员定义的那些个约束条件,那么这个类型就不能被称之为一个集合,也就不能使用集合表达式。

那么现在我们回去看 Array arr = [42]; 这个写法,现在知道这个语句错在哪里了吧:简单来说是因为 Array 是一个抽象类,只有一个实例属性 Length。它只满足了实现 IEnumerable<int> 接口的其中一个条件(LengthCount 属性),而 Add 方法它不满足。因此,Array 类型不能作为集合表达式的目标接收类型。

1-2 基类的多态规则

既然我们说清楚了这一点,那么我们就可以说一下集合表达式的基类多态规则了。

就一点:只有这个类型实现了上述设定的规则时,这个类型才能被允许使用集合表达式。如果它不满足的话,这个类型在使用集合表达式的时候,编译器会直接产生一个错误。而数组类型是唯一一个特例:如果你对一个数组类型使用集合表达式,它即使没有 Add 方法也是可以的。

int[] arr1 = [1, 2, 3]; // 可以
int[] arr2 = [.. arr1, 4, 5, 6]; // 可以
int[][] arr3 = [[1], [2, 3], [4, 5, 6]]; // 可以
int[] arr4 = []; // 可以
int[,] arr5 = [[1, 2], [3, 4]]; // 不可以
int[][,] arr6 = [
    new[,] { { 1, 2 }, { 3, 4 } },
    new[,] { { 5, 6 } },
    new int[,] { } // 空多维数组需要有 int 标识,否则会报错(目标类型不确定)
]; // 可以

数组的实现里,只有锯齿数组(T[][])和普通的一维数组(T[])可以使用集合表达式;多维数组(T[,])是不能使用集合表达式的,请注意。

正是因为它必须满足这些条件,所以集合表达式在基类里不存在多态一说:你不能使用集合表达式往一个抽象类型(或者它的普通的基类型)赋值,除非追加转换运算符:

Array arr = (int[])[42]; // 可以
object obj = (List<string>)["Hello", ", ", "world", "!"]; // 可以

这样才能告知编译器,[42] 是一个 int[]。而 int[]Array 类型的一个派生类型,这种多态赋值才是成功的;后面这个例子也是一样的理解方式。

Part 2 接口类型的多态

比起基类的集合表达式的多态,接口就更复杂了。

C# 里的集合非常多,也有非常多的接口类型来表示一个集合。常见的接口有如下这几个:

  • IList<T>

  • IReadOnlyList<T>

  • IEnumerable<T>

  • ICollection<T>

  • IReadOnlyCollection<T>

  • IDictionary<TKey, TValue>

  • IReadOnlyDictionary<TKey, TValue>

  • ISet<T>

  • IReadOnlySet<T>

好。下面请听题。

2-1 一个过于复杂的设计

请问,如果我定义若干方法,里面啥事不做,就一个返回语句,只是这些返回语句对应的返回值类型是这些接口,那么你还知道哪些成功哪些不成功吗?如果是成功的,那么你知道这些集合表达式在底层都是什么具体的类型吗?

IEnumerable<int> EmptyArray() => [];
IEnumerable NongenericIEnumerable() => [42];
IEnumerable<int> IEnumerable() => [42];
IList NongenericIList() => [42];
IList<int> IList() => [42];
IReadOnlyList<int> IReadOnlyList() => [42];
ICollection NongenericICollection() => [42];
ICollection<int> ICollection() => [42];
IReadOnlyCollection<int> IReadOnlyCollection() => [42];
ISet<int> ISet() => [42];
IReadOnlySet<int> IReadOnlySet() => [42];
IDictionary<int, int> IDictionary() => [new KeyValuePair<int, int>(42, 42)];
IReadOnlyDictionary<int, int> IReadOnlyDictionary() => [new KeyValuePair<int, int>(42, 42)];
IDictionary<int, int> IDictionary2() => [42: 42];
IReadOnlyDictionary<int, int> IReadOnlyDictionary2() => [42: 42];
List<int> RealType() => [42];
ReadOnlySpan<int> ReadOnlySpan() => [42];

如果你答不上来,没有关系。我个人觉得,这个多态设计得确实有些复杂了。实际上答案是这样的(我稍微调整下前面题目的顺序,方便分组):

// 正确:直接取目标类型
List<int> RealType() => [42];
ReadOnlySpan<int> ReadOnlySpan() => [42];
// 正确:int[]
IEnumerable<int> EmptyArray() => [];
// 正确:ReadOnlyArray<int>
IEnumerable<int> IEnumerable() => [42];
IReadOnlyList<int> IReadOnlyList() => [42];
IReadOnlyCollection<int> IReadOnlyCollection() => [42];
// 正确:List<int>
IList<int> IList() => [42];
ICollection<int> ICollection() => [42];
// 正确:C# 13 的字典集合表达式,就是 Dictionary<int, int>
IDictionary<int, int> IDictionary2() => [42: 42];
IReadOnlyDictionary<int, int> IReadOnlyDictionary2() => [42: 42];

// 错误:不支持非泛型
IList NongenericIList() => [42];
IEnumerable NongenericIEnumerable() => [42];
ICollection NongenericICollection() => [42];
// 错误:不支持 ISet 相关接口
ISet<int> ISet() => [42];
IReadOnlySet<int> IReadOnlySet() => [42];
// 错误:不支持 IDictionary 相关接口
IDictionary<int, int> IDictionary() => [new KeyValuePair<int, int>(42, 42)];
IReadOnlyDictionary<int, int> IReadOnlyDictionary() => [new KeyValuePair<int, int>(42, 42)];

根据前面的结果,我们可以总结出如下的一些规则:

  • 如果返回空集合表达式,则无论什么接口类型接收,这个空集合表达式的底层类型都是一维数组 T[]

  • 如果返回的是具体类型,只要它支持集合的前文条件定义,则这个具体类型就是集合表达式的类型;

  • 如果接口类型从概念上表示的是只读的序列,则集合表达式的类型是 ReadOnlyArray<T>

  • 如果接口类型从概念上可以增删元素,则集合表达式的类型是 List<T>

  • 字典集合表达式的自然类型是 Dictionary<TKey, TValue>

  • 非泛型接口不支持集合表达式;

  • ISet<T>IReadOnlySet<T> 接口不支持集合表达式(实际上它的派生类型如 HashSet<T> 都是支持集合表达式的);

  • IDictionary<TKey, TValue>IReadOnlyDictionary<TKey, TValue> 接口类型不支持直接使用集合表达式(如 [new KeyValuePair<TKey, TValue>(42, 42))构造,只能使用字典集合表达式 [k: v](如 [42: 42])。

我实在是不想讲,因为这种可能性也太多了点。我不奢求各位记住这些条条框框,我也不建议各位使用模棱两可的规则(如图上的这些自己记不住都能绕进去的规则)。

多态是好东西,但同时也会影响性能;在必要的时候使用多态,会起到良好的封装效果,防止用户在使用的时候出现不必要的类型拆解(从抽象类型到具体类型的直接转换使用)。

这里我要补充一个点。可能很多人会反对我说多态赋值的这种封装会产生性能损失的这个话。可能你学过 C# 的底层,C# 因为是强类型的,也意味着编译器层面就可以完全掌控类型的使用。因此在运行时就不需要检测变量的具体类型。因此,强制类型转换在 C# 的底层是不消耗性能和时间的。当然,这里肯定要把自定义的转换运算符给排除掉(那个 explicit operatorimplicit operator),它是一个例外,因为有自定义转换规则。我比较想强调的点是,在具体类型转换为抽象类型的时候,转换虽然不会丢失性能,但是它在使用抽象类型定义的一些成员的时候,因为系统不能良好判断类型的具体情况,就必然会存在使用默认处理规则去处理的情况。举个例子。List<T>IEnumerable<T> 都是集合,但显然对前者这个类型的实例进行元素迭代,就比后者的元素迭代,性能就会好一些。这是因为 IEnumerable<T> 为了照顾所有可枚举元素的类型,所以采用了一个比较通用的规则,这必然会丢失信息;而 List<T> 快在,它的底层是一个数组。我完全可以使用数组的迭代规则进行迭代。我甚至可以使用指针来遍历它。这就是我为什么说具体类型和抽象类型有性能差距的原因。

2-2 ReadOnlyArray<T> 是个啥?

下面我们来说一下 ReadOnlyArray<T>。各位可能已经看到了,这个玩意儿在前面的例子里会用到,但这个类型你可能从来就没见过。

没见过没毛病,因为这个类型是编译器生成的一个类型。是的,这个类型本来就不存在于 .NET 的 API 之中,而是编译器“想当然”为了封装集合使其只读的一个临时类型。这个类型的全名叫 <>z__ReadOnlyArray<T>。有个 <>z__ 可能很多人就看不懂了。这个是编译器生成的标识符。我们都知道,C# 定义的标识符规则是不能使用大小于符号的,因为它会和泛型参数的尖括号搞混。而实际上,标识符只是一个类型的“名称化”体现,在底层里,标识符任何符号都可以使用,包括尖括号 <>,包括美元符号 $,甚至是各种运算符操作用得到的符号。

这种标识符的作用是防止你取这种名字。因为这些写法是编译器保留的,它为了方便编译器判断成员是否是编译器生成,才会用到的一种机制。C# 是充满语法糖的一门编程语言,而语法糖的背后,就是一个一个的复杂操作,被编译器简化的操作。数不胜数的这种标识符被编译器使用过:

  • 匿名类型 new { }:底层是一个类型 <>f__AnonymousType0<<属性名>j__TPar>

  • 迭代器语句 yield return:底层是一个类型 <母方法名>d__0

  • 本地函数:底层是一个实际的方法 <母方法名>g__本地函数名|0_0

  • 顶级语句里的主函数:实际上还是 main 方法,但是名字改成了 <Main>$

  • file 类型:底层是一个类型 <项目名>文件哈希码__类型名

  • 自动属性:底层会生成一个字段 <属性名>k__BackingField

等等。

一句话总结 ReadOnlyArray<T>:就是个编译器生成的集合类型,底层是一个数组,但类型保证了集合元素只读。

最后更新于