一个方括号语法统一四种集合创建方式——编译器还帮你做优化
// 数组: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>();
// 数组:
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>()——零堆分配
..这是集合表达式中最有表现力的部分:
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()];
[..a, ..b] 是编译时操作——编译器知道目标类型,可以直接分配精确大小的数组,然后依次拷贝元素。而 a.Concat(b).ToArray() 走迭代器 + 动态扩容。前者更快、分配更少。
.. 而不是 ...?写过 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 切片 Slice | C# 12 展开 Spread | |
|---|---|---|
| 方向 | 取出 ← 拆解集合 | 放入 → 构造集合 |
| 上下文 | is 或 switch 模式匹配 |
= 右侧的集合表达式 |
| 完整示例 | 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 等常用类型已经内置支持。
int[] a = []; 在运行时做了什么?.. 展开运算符,以下哪个说法正确?List<int> list = [1, 2, 3]; 编译器做了什么优化?new[] {、new List、Array.Empty——全部替换成 [ ]。Visual Studio 有自动分析器建议这个替换。[1,2,3] 和 new[] {1,2,3} 创建数组,对比 IL——它们应该完全一致。再对比 List 创建——你会看到补 compiler 生成的优化路径不同。[ ]?想知道编译器对特定类型的优化路径?随时问。