第 1 部分 - 介绍 C#12 中的集合表达式(这篇文章)
本系列深入探讨了随 C#12 引入的集合表达式。本系列主要介绍在应用程序中使用集合表达式时生成的代码,以了解集合表达式在后台的工作方式。
在第一篇文章中,我将介绍 C#12 集合表达式。互联网上已经有很多关于集合表达式的很好的介绍,包括 .NET 博客上的一篇 文章[1],但还有一个也无妨!
经典集合初始值设定项
自 C# 3.0 以来,我们在 C# 中就有了“集合初始值设定项”。它们使用该模式来初始化具有方法的任何实现。例如,这将创建一个新的,并使用 4 个值对其进行初始化{}IEnumerableAdd()List<int>
var values = new List<string> { "1", "2", "3", "4", "5" };
在后台,编译器发出的代码看起来是这样:
List<string> values = new List<string>();
list.Add("1");
list.Add("2");
list.Add("3");
list.Add("4");
list.Add("5");
C# 中的数组初始化很特殊,因为与其他集合相比,你可以用更多的方式初始化它们,尽管它们看起来类似于标准集合初始值设定项:
var values1 = new[] { "1", "2", "3", "4" };
var values2 = new string[4] { "1", "2", "3", "4" };
string[] values3 = { "1", "2", "3", "4" };
它们的工作方式与集合初始值设定项不同;这里没有调用的方法。相反,编译器会生成与必须手动执行所有操作时相同的初始化代码:Add()
string[] array = new string[4];
array[0] = "1";
array[1] = "2";
array[2] = "3";
array[3] = "4";
我故意没有使用上面示例中的原始类型。如果创建并使用数组初始值设定项,编译器会直接从恒定的字节序列中加载数组的数据,这样效率更高。我们将在后续文章中更详细地介绍这种机制。
int
int[]
因此,我们研究了一般集合(如 )和数组的集合初始值设定项,但也有 stackalloc[2] 表达式,它们在 的世界中变得更加有用,因为您不需要使用代码:List<T>
Span<T>
unsafe
Span<int> array = stackalloc []{ 1, 2, 3, 4 };
表达式在堆栈上分配内存,而标准则在堆上分配内存。在方法中创建的堆栈分配的内存块在方法返回时会自动丢弃,因此不会对垃圾回收器施加任何压力。但是,请谨慎使用:如果您使用太多,将导致应用程序崩溃!
stackallocnew
int[]
stacalloc
StackOverflowException
在后台,编译器将此初始值设定项转换为一些代码,如下所示:stacallocunsafe
unsafe
{
byte* num = stackalloc byte[16];
*(int*)num = 1;
*(int*)(num + 4) = 2;
*(int*)(num + (nint)2 * (nint)4) = 3;
*(int*)(num + (nint)3 * (nint)4) = 4;
Span<int> array = new Span<int>(num, 4);
}
上面的代码是在堆栈上创建一个数组,然后遍历每个元素(使用一些指针算术),设置每个值。
使用集合表达式统一语法
因此,我们已经看到,至少有三种不同的场景,我们正在初始化集合:
Array 像这样的集合 List<T>
ReadOnlySpan<T>
跟stackalloc
其中每一种都需要略有不同的语法,例如:
int[] array = new[] { 1, 2, 3, 4 }; // One of several options!
List<int> list = new() { 1, 2, 3, 4 };
HashSet<int> hashset = new() { 1, 2, 3, 4 };
ReadOnlySpan<int> span = stackalloc [] { 1, 2, 3, 4 };
这一切都有点混乱和烦人。C#12 中引入的集合表达式为所有这些不同的集合类型提供了简化、统一的语法。例如:
int[] array = [1, 2, 3, 4]
List<int> list = [1, 2, 3, 4];
HashSet<int> hashset = [1, 2, 3, 4];
ReadOnlySpan<int> span = [ 1, 2, 3, 4 ];
所有集合类型中集合表达式的一致性是一个真正的福音,但这不是唯一的优势。与集合初始值设定项相比,集合表达式可以提供性能优势(我们将在后面的文章中介绍)以及附加功能。
重申一下,集合和数组初始值设定项使用“旧”语法 /,而集合表达式使用“新”语法。
new [] {}
new () {}
[ ]
我们将首先查看集合表达式可以使用集合表达式的区域,而集合初始值设定项则不能使用。
使用集合表达式推断接口类型
想象一下,你想创建一个集合,但你关心的只是它实现了 .您必须自己决定使用哪种背衬类型:
IEnumerable<int>
IEnumerable<int> list1 = new List<int> { 1, 2, 3, 4 };
IEnumerable<int> list2 = new HashSet<int> { 1, 2, 3, 4 };
IEnumerable<int> list3 = new int[] { 1, 2, 3, 4 };
那么你应该使用哪个呢?这有关系吗?如果你需要做的只是枚举列表,那么你选择哪种类型可能并不重要,对吧?那么正确的选择是什么呢?
它也有些烦人的冗长,因为您必须同时编写集合类型和所需的变量类型。
IEnumerable<int>
使用集合表达式,可以改为将选择推迟到编译器。与其显式指定后备类型,不如将其留给编译器。另一个好处是集合表达式的额外简洁:
IEnumerable<int> ienumerable = [ 1, 2, 3, 4 ];
IList<int> ilist = [ 1, 2, 3, 4 ];
IReadOnlyCollection<int> icollection = [ 1, 2, 3, 4 ];
在后台,编译器创建一个集合,该集合完全透明地实现所需的接口,因此您无需考虑它。
当然,你可能想知道这个系列是在幕后创造的,就像我一样。请继续关注该系列的其余部分,因为答案是“视情况而定”!
值得指出的是,虽然编译器会自动为接口集合选择具体类型,但您确实需要指定某种类型。例如,您不能使用:var
var values = [ 1, 2, 3, 4 ]; // ❌ Does not compile, CS9176: There is no target type for the collection expression
Sum(values);
Sum([ 1, 2, 3, 4 ]); // ✅ This is fine
int Sum(IEnumerable<int> v) => v.Sum();
问题在于 C# 编译器的工作方式,它无法推断 for 的类型应该是 ,因此会引发错误。这可能会在未来的 C# 版本中发生变化,但它可能会通过例如,始终在这种情况下进行选择来解决,这不一定是最佳的,所以我不会屏住呼吸。values
IEnumerable
<int>int[]
用于 ReadOnlySpan 的高效自动堆叠
如果您只使用集合初始值设定项,则实例也是如此。Ff 你只需要在 or 中有一些数据,然后使用集合初始值设定项,你需要决定将数据放在哪里,然后从中获取:ReadOnlySpan<T>
Span<T>
Span<T>
ReadOnlySpan<T>
Span<T>
Span<int> spans2 = stackalloc[] { 1, 2, 3, 4 }; // stackalloc an array
Span<int> spans3 = new[] { 1, 2, 3, 4 }; // allocate on the heap
Span<string> spans4 = new[] { "1", "2", "3", "4" }; // can't use stackalloc in this case
在这种情况下,这不是一个重大决定,因为可能只有 2 个明智的选择,但这仍然是需要考虑的额外事情。另外,你不能不跳过一堆箍。stackalloc
string[]
InlineArray
使用集合表达式,您可以再次将决策委托给编译器,编译器将执行正确的操作™。
ReadOnlySpan<int> readonlyspans = [ 1, 2, 3, 4 ];
Span<string> spans = [ "1", "2", "3", "4" ];
在本系列的后面,您将看到这些集合表达式的用例得到了显著优化!
集合表达式使重构更简单
到目前为止,我展示的示例都是将集合表达式分配给变量的,但您也可以直接使用集合表达式作为方法参数,因此您可以执行如下操作:
using System.Linq;
using System.Collections.Generic;
// create a method that takes an IEnumerable
int Sum(IEnumerable<int> values) => values.Sum();
// Call the method using collection expressions
Sum([1, 2, 3, 4]);
这种模式的一个很好的好处是,如果我更改了 的签名,我就不需要更改调用站点。将其与使用集合初始值设定项进行对比:Sum()
// if the method takes an array...
int Sum1(int[] values) => values.Sum();
Sum1(new [] { 1, 2, 3, 4 }); // ...you have to use array syntax (one of several syntaxes!)
// if the method takes an IEnumerable<T>...
int Sum2(IEnumerable<int> values) => values.Sum();
Sum2(new List<int> { 1, 2, 3, 4 }); // ...you have to use an explicit type e.g. List<T> or similar
// if the method takes a ReadOnlySpan<T>...
int Sum3(ReadOnlySpan<int> values)
{
// You can use foreach with IReadOnlySpan<T>
// but it doesn't implement IEnumerable<T>, so can't
// use the Linq convenience methods here!
var total = 0;
foreach (var value in values)
{
total += value;
}
return total;
}
Sum3(new []{ 1, 2, 3, 4 }); // ...you have to choose between a standard array,
Sum3(stackalloc int[] { 1, 2, 3, 4 }); // ... or use a stackalloc'd array (for example)
如果我们改用集合表达式,那么我们可以使用完全相同的语法来调用所有三个实现:Sum()
Sum1([ 1, 2, 3, 4 ]);
Sum2([ 1, 2, 3, 4 ]);
Sum3([ 1, 2, 3, 4 ]);
编译器将使用最有效的实现来创建所需类型的集合。
这似乎是一件小事,在某种程度上确实如此,但正是所有这些小便利方面使集合表达总体上如此简洁!
空集合
集合表达式的另一个特点是编译器显式识别空集合语法,因此不是这样写:
var empty = new int[]{}; // You should generally never do this...
var empty = Array.Empty<int>(); // ...instead, prefer this!
现在,您可以使用该集合生成适当的空版本:[]
int[] empty = [];
同样,集合表达式与显式初始值设定项相比有两个主要优点:
编译器可以选择创建空集合的最有效方法,例如选择示例(或等效方法)。 Array.Empty<int>()
可以对所有集合类型使用一致的语法。 下面显示了一大堆集合类型,以及如何使用它来创建所有这些类型的空版本。每行的注释显示编译器为特定类型生成的代码: []
int[] array = []; // Array.Empty<int>()
HashSet<int> hashset = []; // new HashSet<int>()
List<int> list = []; // new List<int>()
IEnumerable<int> ienumerable = []; // Array.Empty<int>()
ICollection<int> icollection = []; // new List<int>()
IList<int> ilist = []; // new List<int>()
IReadOnlyCollection<int> readonlycollection = []; // Array.Empty<int>()
IReadOnlyList<int> readonlyList = []; // Array.Empty<int>()
Span<int> span = []; // default(Span<int>)
ReadOnlySpan<int> readonlyspan = []; // default(ReadOnlySpan<int>)
ImmutableArray<int> immutablearray = []; // ImmutableCollectionsMarshal.AsImmutableArray(Array.Empty<int>())
ImmutableList<int> immutablelist = []; // ImmutableList.Create(default(ReadOnlySpan<int>));
正如你所看到的,编译器是尽可能高效的;如果类型是可变的,例如 或,那么它别无选择,只能创建该类型的新实例,但如果它可以摆脱使用非分配版本(例如 ),那么它会!HashSet<T>
List<T>
Array.Empty<int>()
使用扩展元素从其他人那里构建集合
到目前为止,我们已经看到了集合表达式的两个好处:
一致的语法 编译器生成的高效实现 集合表达式中的另一大功能是扩展元素[3] ...这使您能够更轻松地从其他集合实例创建集合。
传播功能(有时也称为“splat”)已经在 Python、JavaScript 和 Ruby 等其他语言中存在了很长时间,因此很高兴看到它最终出现在 C# 中。
举个具体的例子,假设你有两个集合,你想把它们连接成一个数组。使用 LINQ 很容易做到这一点,因为有一些扩展方法可以做到这一点:IEnumerable<T>
int[] ConcatAsArray(IEnumerable<int> first, IEnumerable<int> second)
{
return first.Concat(second).ToArray();
}
太好了,但是如果你现在需要想要合作而不是呢?不幸的是,正如我们之前所讨论的,没有实现,所以我们可能会做这样的事情:ReadOnlySpan<T>
IEnumerable<T>
ReadOnlySpan<T>
IEnumerable<T>
int[] ConcatAsArray(ReadOnlySpan<int> first, ReadOnlySpan<int> second)
{
var list = new List<int>(first.Length + second.Length);
list.AddRange(first);
list.AddRange(second);
return list.ToArray();
}
这并不可怕,但必须考虑每种不同的集合类型仍然很烦人。使用集合表达式,我们得到了一个很好的快捷方式,可以通过使用spread运算符将其与所有支持的集合类型一起使用。上述两种重载都可以以相同的方式实现:
int[] ConcatAsArray(IEnumerable<int> first, IEnumerable<int> second)
=> [..first, ..second];
int[] ConcatAsArray(ReadOnlySpan<int> first, ReadOnlySpan<int> second)
=> [..first, ..second];
同样,集合表达式的一致性意味着,如果你更改了 的参数或返回类型,则根本不需要更改集合表达式,它只是工作!ConcatAsArray()
该元素的意思是“写入集合中的所有值”,因此再举一个例子:..
int[] array = [1, 2, 3, 4]
IEnumerable<int> oddValues = array.Where(int.IsOddInteger); // 1, 3
int[] evenValues = [..array.Where(int.IsEvenInteger)]; // 2, 4 in array (using spread)
int[] allValues = [..oddValues, ..evenValues]; // 1, 3, 2, 4
上面的代码多次使用扩展元素,但在每种情况下,它都意味着“写入集合的所有元素”。因此,在最后一步中,包含 的所有元素,后跟 的所有值。allValues
oddValues
evenValues
您还可以在集合表达式中混合单个值并将集合分散在一起,例如:
int[] arr = [1, 2, 3, 4];
int[] myValues = [ 0, ..arr, 5 , 6]; // 0, 1, 2, 3, 4, 5, 6~
最终的结果是组合,就好像你已经遍历并添加了每个值一样。arr
请注意,点差元素与范围运算[4]符不同(例如 in 或 ) 用于索引到数组中。但是,您可以组合它们,使用范围选择元素的子集,然后将它们传播到集合表达式中:..
..
1..3
2..^
int[] primes = [1, 2, 3, 5, 7, 9, 11];
int[] some = [0, ..primes[1..^1]]; // 0, 2, 3, 5, 7, 9
此代码使用 range 运算符获取数组的第 1 到 N-1 个元素(即 ),然后在集合表达式中使用 spread。primes
2, 3, 4, 5, 7, 9
1..^1
..
集合表达式为创建集合增加了一个很好的对称性(这在从一个集合重构到另一个集合时特别有用),并且它们使集合与扩展元素的组合变得更加简单。但集合表达式不仅仅与语法有关。一个重要的部分是集合表达式为编译器提供了优化其生成的代码的选项。在下一篇文章中,我们将看看它是如何工作的。
总结
在这篇文章中,我介绍了集合表达式,并将它们与集合初始值设定项、数组初始值设定项和初始化进行了对比。我展示了使用单个统一的集合表达式语法如何使重构代码变得更加容易,并允许编译器生成优化的专用代码。最后,我展示了如何在集合表达式中使用扩展元素,以便更轻松地从现有集合构建新集合。stackalloc..
在下一篇文章中,我们将了解编译器在代码中使用集合表达式时实际生成的代码的幕后情况。
原文[5] 鸣谢 吉浩然 | 驚鏵
参考资料
文章: https://devblogs.microsoft.com/dotnet/refactor-your-code-with-collection-expressions/
[2]stackalloc: https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/operators/stackalloc#c-language-specification
[3]扩展元素: https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/operators/collection-expressions#spread-element
[4]范围运算: https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/proposals/csharp-8.0/ranges#systemrange
[5]原文: https://andrewlock.net/behind-the-scenes-of-collection-expressions-part-1-introducing-collection-expressions-in-csharp12/?s=09