Lesson 27: C# 15 — 集合表达式参数 Collection Expression Arguments

.NET 11 Preview · with(...) 向集合构造器传参 · 一行指定容量/比较器

前置:已完成 Lesson 26(Closed Hierarchies);熟悉 Lesson 15(C# 12 集合表达式)的基础语法。
状态:.NET 11 Preview 5 Preview 已合并。
阅读:集合表达式参数语言参考 · 功能规范 · 设计讨论 #8886

〇、「[] 很好,但我还想传个参数」 The Missing Piece

C# 12 的集合表达式 [1, 2, 3] 统一了四种集合创建语法——一个方括号打天下。但它有一个盲区:

场景C# 12 能做吗?你被迫回退到……
创建 List 并预分配容量[with(capacity: 100)] 不存在new List<int>(100) { 1, 2, 3 }——告别方括号
创建忽略大小写的 HashSet❌ 方括号内传不了 comparernew HashSet<string>(StringComparer.OrdinalIgnoreCase) { "a", "b" }
创建自定义 comparer 的字典❌ 无解不用集合表达式,回到构造函数 + Add

核心张力:集合表达式让创建集合变得简洁——但一旦你需要指定容量、比较器、或任何构造函数参数,你就被踢出方括号世界,回到 new T() { } 的旧语法。C# 15 的 with(...) 元素填上了这个缺口。

一、基本语法——方括号的第一个元素 with(...) as First Element

1.1 看代码——一眼懂

// ──── C# 12:创建 List,想预分配容量?对不起,方括号做不到 ────
List<string> old = new List<string>(100) { "a", "b", "c" };  // new + 花括号——旧时代语法

// ──── C# 15:with(capacity: ...) 作为方括号的第一个元素 ────
List<string> names = [with(capacity: 100), "a", "b", "c"];
// ↑ with(...) 必须是第一个元素——语义上先构造、再填充

// ──── HashSet 指定比较器——在此之前做不到 ────
HashSet<string> set = [with(StringComparer.OrdinalIgnoreCase), "Hello", "HELLO", "hello"];
// set.Count == 1——OrdinalIgnoreCase 下三个字符串相等

// ──── 结合 spread——容量预分配 + 批量复制 ────
string[] source = ["one", "two", "three"];
List<string> result = [with(capacity: source.Length * 2), .. source];
// 先分配 source.Length*2 的容量,再 spread 复制——一步到位
位置规则:with(...) 必须是集合表达式的第一个元素。写 [1, 2, with(capacity: 3)] 会编译错误。这对应集合的构造语义:先建壳、再装东西。位置规则也让读者一眼看到关键配置(如比较器),不用翻到表达式末尾。

1.2 只改了一个词——和 C# 12 的兼容性

// with(...) 出现前,这些全部合法:
int[] a = [1, 2, 3];         // 数组——with() 不适用,不支持
Span<int> b = [1, 2, 3];      // Span——with() 不适用,不支持
List<int> c = [1, 2, 3];    // List——现在可以加 with(),也可以不加

// 如果有一个叫 with 的方法:
object with(int x, int y) => ...;
object[] d = [with(1, 2), 3];  // C# 14: 调用 with(1,2),数组含两个元素
                                    // C# 15: 编译错误——object[] 不支持 with()

C# 15 中,只要 token 序列以 with( 开头,编译器就把它解析为 with_element。如果目标类型不支持参数(数组、Span),即使空 with() 也会报错。这和 C# 14 是 breaking change——仅在编译时指定 C# 15 语言版本时生效。

二、三条通路——编译器如何分发 with() 的参数 Three Construction Pathways

with(...) 的参数去哪了?取决于目标类型:

目标类型参数去往……示例
① 普通 class/struct
(有构造函数)
构造函数重载决议——和 new T(args) 一样 List<T>(int capacity)
HashSet<T>(IEqualityComparer<T>)
② [CollectionBuilder] 类型
(有工厂方法)
Create 方法的非 span 参数——
span 参数始终是最后一个,接收元素
ImmutableArray.Create<T>(ReadOnlySpan<T>)
或自定义 MyBuilder.Create(comparer, elements)
③ 接口类型
(IList、IDictionary 等)
编译器预设的 curated 构造函数签名——
不支持任意参数
IList<int>new List<int>(capacity)
IDictionary<K,V>new Dictionary<K,V>(comparer)

2.1 通路①:构造函数——最常用

// List<T> 有三个构造函数。编译器根据 with() 参数做重载决议:
List<int> l;

l = [with(capacity: 3), 1, 2];   // → new List<int>(capacity: 3)
l = [with([1, 2]), 3];            // → new List<int>(IEnumerable<int> collection)
l = [with(default)];                   // ❌ 歧义——default 匹配多个构造函数
关键:转换存在性只看 with()有无,不看具体参数。有 with() → 找至少一个可访问的构造函数 → 转换成立。无 with() → 必须有无参构造函数。这意味着没有无参构造函数的集合类型,必须用 with() 来创建——[] 不行,[with(capacity: 1)] 可以。

2.2 通路②:CollectionBuilder——工厂方法也能接参数

// 自定义集合类型——通过 [CollectionBuilder] 指定工厂方法
[CollectionBuilder(typeof(MyBuilder), "Create")]
class MySet<T> : IEnumerable<T> { ... }

class MyBuilder
{
    public static MySet<T> Create<T>(ReadOnlySpan<T> elements);
    public static MySet<T> Create<T>(IEqualityComparer<T> comparer, ReadOnlySpan<T> elements);
    // ↑ 注意:span 参数必须是最后一个——with() 参数在它之前
}

// 使用——with() 的参数传给 comparer,元素传给 span
MySet<string> set = [with(StringComparer.OrdinalIgnoreCase), "a", "b"];
// → MyBuilder.Create<string>(StringComparer.OrdinalIgnoreCase, ["a", "b"])

编译器按"去掉 span 参数后的投影方法"做重载决议。Create 方法可以有多个重载——每个重载的 span 参数之前可以有不同数量和类型的额外参数。

2.3 通路③:接口类型——编译器替你选实现

// IList<T> / ICollection<T> → 背后用 List<T>,with() 参数映射到 List<T> 构造函数
IList<int> list = [with(capacity: 4), 1, 2, 3];
// → new List<int>(capacity: 4) { 1, 2, 3 }

// IDictionary<K,V> → 背后用 Dictionary<K,V>
IDictionary<string, int> d = [with(StringComparer.Ordinal), "a": 1];
// → new Dictionary<string, int>(StringComparer.Ordinal) { ["a"] = 1 }

// IReadOnlyDictionary<K,V> → 只能用 comparer 参数
IReadOnlyDictionary<string, int> r = [with(StringComparer.Ordinal)];
// ✅ comparer 参数——控制 key 比较语义
r = [with(capacity: 2)];
// ❌ 编译错误——IReadOnlyDictionary 不支持 capacity 参数
接口支持的 with() 签名
IEnumerable<E> / IReadOnlyCollection<E> / IReadOnlyList<E>()(等效于没有 with())
IList<E> / ICollection<E>List<E>() · List<E>(int)
IDictionary<K,V>Dictionary<K,V>() · (int) · (IEqualityComparer<K>) · (int, IEqualityComparer<K>)
IReadOnlyDictionary<K,V>() · (IEqualityComparer<K>?)

三、性能——容量预分配的价值 Performance: Capacity Pre-allocation

3.1 之前:写不出,只好多花钱

// C# 12:想把 spread 的元素放进 List,但没法预分配容量
List<string> names = [.. values];
// 等价于:new List<string>()(默认容量 4),然后逐个 Add
// values 有 100 个元素 → List 内部数组扩容 log₂(100/4) ≈ 5 次
// 每次扩容:分配新数组 + 拷贝所有已存在的元素

3.2 之后:一行搞定

// C# 15:with(capacity:) 直接传给构造函数——编译器不做额外工作
List<string> names = [with(capacity: values.Length), .. values];
// 等价于:new List<string>(values.Length),内部数组一次性分配够
// 零次扩容——values.Length 个元素直接塞进预分配的空间
不是编译器优化,是语法让你能做正确的事。with(capacity:) 只是把参数转发给 List<T>(int capacity) 构造函数——和直接写 new List<T>(n) 一样。区别是:现在你能在方括号语法里做到这件事了,不用回退到 new T() { }

四、正确性——比较器指定 Correctness: Comparer Specification

// 常见 bug:忘了给 HashSet 传比较器——默认区分大小写
HashSet<string> names = ["Admin", "admin"];  // Count == 2——两个值不等

// C# 15:比较器放在 with() 里,和元素写在一起——不容易忘
HashSet<string> names = [with(StringComparer.OrdinalIgnoreCase), "Admin", "admin"];
// Count == 1——OrdinalIgnoreCase 下 Admin == admin

为什么 with() 放第一个位置能减少 bug:如果一个 100 行的字典表达式在末尾才标注比较器,你很可能看不到它——或者更糟:加了 99 个元素之后才发现比较器写错了。放在开头强迫读者(和写者)先想清楚"这个集合用什么样的相等/排序?"再往里塞东西。

五、边界与限制 Restrictions & Edge Cases

限制详情原因
必须是第一个元素[1, with(capacity: 3), 2] → ❌语义上先构造再填充——和 new T(args) { } 的直觉一致
数组 / Span 不支持int[] a = [with(), 1, 2] → ❌数组和 Span 没有构造函数——长度由元素数量决定
dynamic 参数禁用[with((dynamic)comparer), "a"] → ❌dynamic 需要运行时 binder 做重载决议——集合表达式不支持
不影响类型推断with() 的参数不参与泛型推断和重载决议避免 [with(comparer)] 的歧义——ambiguous 错误交给 target-typed new 处理
__arglist 不支持[with(__arglist(x, y))] → ❌除非免费实现——设计团队决定不为此增加复杂度
旧语言版本保持兼容C# 14 中 [with(x,y)] 仍被解析为方法调用breaking change 只在 LangVersion ≥ 15 时生效

六、为什么是 with?——设计考量 Why "with"?

设计团队考虑了多种语法——最终 pick with(...) 有明确的取舍:

候选语法为什么被否决
[args(capacity: 10), 1, 2]技术上 equivalent——但 with 读起来更自然:"a collection with capacity 10"
new(...)[1, 2]new([1, 2], capacity: 10)暗示调用构造函数——但 [CollectionBuilder] 类型不走构造函数。且语法扩散到 [...] 外部,读者可能以为是"创建后再拷贝"
[comparer; v1, v2](分号分隔)容易被忽略——[1; 2] 看起来太像 [1, 2](一个逗号的差别)。写错时:对 List 把 1 当 capacity、只存一个元素 2,悄无声息
[comparer, v1](元素复用)当元素类型和比较器类型有继承关系时歧义——HashSet<object> 无法区分"第一个元素是比较器"还是"第一个元素就是放进集合的值"
设计哲学总结:一个只有 6 个字符的 with()——语法着色下 with 关键字高亮、() 内的参数正常着色——视觉上不容忽视,语义上"先构造再填充"的直觉对应,扩展性上不限制未来新增参数类型。

七、测验 Quiz

1/5 · 基本语法

with(...) 元素在集合表达式中的位置要求是?

2/5 · 通路识别

ImmutableArray<int> arr = [with(), 1, 2, 3]; —— with() 的参数传给谁?

3/5 · 数组和 Span

以下哪个表达式产生编译错误?

4/5 · 设计选择

为什么设计团队否决了 [comparer; element1, element2](分号分隔)方案?

5/5 · 综合判断

关于集合表达式参数,以下哪个说法正确

八、总结 Summary

要点一句话
语法[with(...), elements] —— with() 必须是第一个元素
构造函数通路参数传给目标类型的构造函数——编译器做重载决议
CollectionBuilder 通路参数传给 Create 方法的非 span 参数——span 始终是最后一个
接口通路IList → List 构造函数;IDictionary → Dictionary 构造函数;不支持任意参数
数组 / Span不支持 with()——无构造函数,也无此必要
性能场景[with(capacity: n), .. values] —— 一行消除扩容开销
正确性场景[with(comparer), .. values] —— 比较器写在集合旁边,不易忘
运行时开销零——纯编译期转发,等价于手动调用构造函数或工厂方法
状态.NET 11 Preview 5 已合并——GA 预计 2026.11
最值得记住的一件事:with(...) 让方括号语法不再有"盲区"。需要传容量?[with(capacity: 100), .. data]。需要比较器?[with(StringComparer.OrdinalIgnoreCase), "a", "b"]。你不需要在"简洁方括号"和"完整构造函数"之间做选择了——两者合一。

九、下一步 Next: Memory Safety Evolution

C# 15 集合表达式参数填上了 C# 12 方括号语法的最后一块拼图。下节课转向 C# 15 的内存安全演进:

🔒 Memory Safety Phase 1

声明指针、取地址 &fixedsizeof 不再需要 unsafe 块——但解引用仍需 unsafe。这是多版本演进的第一步。

🗂️ Dictionary Expressions

["key": value] 语法——字典字面量。仍在开发中,runtime 团队已介入,预计后续 Preview 发布。

📋 C# 15 系列回顾

Union Types → Closed Hierarchies → Collection Expression Args → Memory Safety。四大特性形成 C# 15 核心能力矩阵。

← L26: Closed Hierarchies · L28: Memory Safety → · 🎯 回顾 Mission