Lesson 15: C# 12 — 集合表达式 Collection Expressions

一个方括号语法统一四种集合创建方式——编译器还帮你做优化

前置:已完成 Lesson 14(C# 12 Primary Constructors);理解 Span/ReadOnlySpan 概念。
阅读:Microsoft Learn: 集合表达式 · .NET Blog: Announcing C# 12

一、你每次创建集合都要走一遍决策树

😣 C# 11:四种类型 = 四种语法

// 数组:new + 类型
int[] arr = new int[] { 1, 2, 3 };

// List:new() + 花括号
List<int> list = new() { 1, 2, 3 };

// Span:stackalloc
Span<int> span = stackalloc int[] { 1, 2, 3 };

// 空数组:Array.Empty 或 new int[0]
int[] empty = Array.Empty<int>();

😎 C# 12:一个方括号

// 数组:
int[] arr = [1, 2, 3];

// List:
List<int> list = [1, 2, 3];

// Span:
Span<int> span = [1, 2, 3];

// 空集合:
int[] empty = [];

这不仅是语法的统一——编译器根据目标类型选择最优实现。对数组直接分配精确大小,对 List 预分配 capacity,对空集合直接返回 Array.Empty<T>() 单例。你写的是统一的 [ ],得到的是定制优化。

二、一个语法,支持所有集合类型

// ===== 内置类型——全支持 =====
int[] a1 = [1, 2, 3];                    // T[]
List<string> a2 = ["a", "b", "c"];          // List<T>
Span<char> a3 = ['x', 'y', 'z'];            // Span<T>
ReadOnlySpan<byte> a4 = [0x00, 0x01];     // ReadOnlySpan<T>
ImmutableArray<int> a5 = [1, 2, 3];       // ImmutableArray<T>

// ===== 交错数组——自然嵌套 =====
int[][] matrix = [[1, 2], [3, 4], [5, 6]];

// ===== 方法参数——直接传集合字面量 =====
void Write(ReadOnlySpan<byte> data) { ... }
Write([0x1B, 0x5B, 0x31]);  // 不用先声明变量

// ===== 空集合 =====
int[] none = [];  // 编译为 Array.Empty<int>()——零堆分配

三、展开运算符 Spread Element——..

这是集合表达式中最有表现力的部分:

int[] odds = [1, 3, 5];
int[] evens = [2, 4, 6];

// 内联展开——比 Concat 更直观、更高效:
int[] all = [..odds, 0, ..evens];
// → [1, 3, 5, 0, 2, 4, 6]

// 合并多个集合:
string[] admins = ["alice", "bob"];
string[] mods = ["charlie"];
string[] everyone = [..admins, ..mods, "super"];
// → ["alice", "bob", "charlie", "super"]

// 方法返回值也可以展开:
int[] result = [..GetActiveIds(), ..GetPendingIds()];
展开 vs Concat/LINQ:[..a, ..b]编译时操作——编译器知道目标类型,可以直接分配精确大小的数组,然后依次拷贝元素。而 a.Concat(b).ToArray() 走迭代器 + 动态扩容。前者更快、分配更少。

三-B、设计哲学:为什么是 .. 而不是 ...

写过 JavaScript/TypeScript 的人自然会问:为什么 C# 用两个点,而不是 JS 的三点 ...

// JavaScript / TypeScript
const combined = [...arr, 4, 5];  // 三个点

// C#
int[] combined = [..arr, 4, 5];  // 两个点

答案藏在一条 C# 语言设计的核心原则里:构造应当镜像解构。在集合表达式出现之前,.. 已经在 C# 中存在了——它用于切片模式(C# 11),从集合中"切出"一个子集:

// C# 11 — 切片模式(Slice Pattern):"切出 middle"
if (numbers is [1, .. var middle, 9])
    // middle = [2,3,4,5,6,7,8]

LDM 认为,展开(spread)是切片(slice)的逆操作——切片从集合中"取出"一段,展开把一段"放入"集合。如果切片用 .. 而展开用 ...,两个互逆操作就有了不同语法——这是设计上的不和谐。

C# 11 切片 SliceC# 12 展开 Spread
方向 取出 ← 拆解集合 放入 → 构造集合
上下文 isswitch 模式匹配 = 右侧的集合表达式
完整示例 nums is [1, .. var mid, 9] int[] arr = [1, .. mid, 9]
.. 的语义 "切出中间这段,存到 mid" "把 mid 的元素展开放在这里"

而且 .. 在 C# 中有一条贯穿三代语言的语义演进链:

版本功能示例语义
C# 8范围运算符0..5"从 A 到 B 的范围"
C# 11切片模式[.. var mid]"切出范围内的元素"
C# 12展开运算符[.. arr]"放入范围内的元素"(镜像)

同一个 token ..,三个场景,一条贯穿的语义线索——关于"范围中的元素"。

"We view 'spread' as the inverse of slicing. You slice to pull out a subcollection, and you spread to put a subcollection into a larger one. All other things being equal we might have landed on .... But strong parity in our own language definitely takes precedent."
— C# Language Design Team,GitHub Discussion #7686

核心结论:不是 C# 故意要和 JavaScript 不一样。而是 C# 已经有一条自己的语义线索(Range → Slice → Spread),顺着这条线索走到底,.. 是唯一自洽的答案。多语言开发者多学 5 分钟,换来 C# 概念模型的长期干净统一。

四、编译器生成了什么?

集合表达式不是简单的语法糖——编译器根据目标类型走不同的代码生成路径:

目标类型编译器策略
T[] 直接 new T[N] 分配精确大小,逐元素赋值
List<T> new List<T>(N) 预分配 capacity,通过 CollectionsMarshal.SetCount + Span 写入
Span<T> stackalloc T[N] 栈分配(若元素少),或指向静态数据段(若内容可确定)
ReadOnlySpan<T> 同 Span;若内容为常量,直接指向 PE 数据段的只读区域——零运行时分配
[]T[] Array.Empty<T>() 单例——不分配!
[]Span<T> default——空 Span,零成本
ImmutableArray<T> 空集合返回 ImmutableArray<T>.Empty
// 你写:
List<int> list = [1, 2, 3, 4, 5];

// 编译器生成(SharpLab 可查,简化版):
// List<int> list = new List<int>(5);          // 预分配 capacity = 5
// Span<int> span = CollectionsMarshal.AsSpan(list);
// span[0] = 1; span[1] = 2; ... span[4] = 5;   // 通过 Span 写入
// CollectionsMarshal.SetCount(list, 5);
空集合是亮点:你手写 new int[0] 每次分配一个新数组对象(虽然零长度但对象头存在)。编译器把 int[] x = []; 变成 Array.Empty<int>()——一个全局单例。零分配,零 GC 压力。

五、自定义类型如何接入?

通过 [CollectionBuilder] 属性,你的类型也能使用 [ ] 语法:

// 条件 1:类型实现 IEnumerable<T>
// 条件 2:指定 Builder 类型和 Create 方法

[CollectionBuilder(typeof(MySet), "Create")]
public class MySet<T> : IEnumerable<T>
{
    private readonly HashSet<T> _items;

    // 编译器调用这个方法——传入 ReadOnlySpan<T>
    public static MySet<T> Create(ReadOnlySpan<T> items)
    {
        var set = new MySet<T>();
        foreach (var item in items) set._items.Add(item);
        return set;
    }

    public IEnumerator<T> GetEnumerator() => _items.GetEnumerator();
    // ...
}

// 现在可以用 [ ] 了:
MySet<int> set = [1, 2, 3];  // ✅ 编译器调用 MySet<int>.Create([1,2,3])

大多数时候你不需要写这个——list、array、Span、ImmutableArray 等常用类型已经内置支持。

六、小测验

1/3 · C# 12 中,int[] a = []; 在运行时做了什么?
2/3 · 关于 .. 展开运算符,以下哪个说法正确?
3/3 · List<int> list = [1, 2, 3]; 编译器做了什么优化?

七、下一步

💬 有问题?不确定某个集合类型是否支持 [ ]?想知道编译器对特定类型的优化路径?随时问。