Lesson 19: C# 13 — ref struct 三部曲

.NET 9(2024.11)· 实现接口 · allows ref struct · 进入 async 方法

前置:已完成 Lesson 18(params + Lock)Lesson 16(ref struct)。本课三个特性围绕同一个主题:ref struct 的可用性大幅提升
阅读:Microsoft Learn: C# 13 新增功能 · NDepend: ref struct interfaces · dev.to: ref in async

背景:ref struct 的三个历史限制

C# 7.2 引入 ref struct 以来(回顾 L16),三个困扰开发者的问题始终存在:

  1. 不能实现接口——Span<T> 无法参与 IEnumerable<T>IDisposable 等泛型抽象
  2. 不能作为泛型参数——泛型天然拒绝 ref struct
  3. 不能出现在 async / 迭代器方法中——哪怕不跨越 await/yield 边界也不行

C# 13 一次性解决了这三个问题。三个特性共享同一个设计动机:让 ref struct 不再是一等公民之外的二等类型

一、ref struct 实现接口 ref struct Interfaces 核心

1.1 痛点:Span<T> 进不了泛型方法

// C# 12:同样的逻辑要写三份重载——只因为 Span 不实现接口
void Process(int[] data)      => ProcessCore(data);
void Process(List<int> data)  => ProcessCore(data);
void Process(Span<int> data)  => ProcessCore(data);  // 重载 × 3

// 理想:对实现了 IEnumerable<int> 的类型只写一份
// 现实:Span<int> 不实现任何接口 → 做不到

1.2 C# 13:ref struct 可以声明接口继承

// 自定义 ref struct 实现 IDisposable
ref struct Buffer : IDisposable {
    public Span<byte> Data;
    public void Dispose() => Data.Clear();
}

// 可以用 using 语句了!
using (var buf = new Buffer { Data = stackalloc byte[32] }) {
    // ... 使用 buf ...  
}  // 自动调用 Dispose()——零分配

// .NET 9 BCL:Span<T> / ReadOnlySpan<T> 已实现 IEnumerable<T> 等接口
两个硬限制:
不能装箱——IDisposable d = myRefStruct; 编译错误(装箱会让 ref struct 逃逸到堆)
必须实现所有接口成员——不能依赖默认接口方法(DIM 的 this 调用会触发装箱)

1.3 真正用法:配合泛型约束

// 泛型方法——通过约束来消费接口
void UseBuffer<T>(T buffer) where T : IDisposable {
    using (buffer) { /* ... */ }
}

Buffer buf = new();   // ref struct
UseBuffer(buf);        // ✅ C# 13——泛型在调用点被特化,不装箱

你很少需要自己写 ref struct 实现接口。但整个 BCL 已经为 Span 做了这件事——你直接受益。

二、allows ref struct 反约束 Generic Anti-Constraint 核心

2.1 问题:泛型默认拒绝 ref struct

即使 ref struct 实现了接口,泛型参数仍然不接受它——因为编译器要确保 T 可以装箱/堆分配:

// C# 12:即使 Span 实现了 IEnumerable<int>,这里还是传不进去
void ForEach<T>(T collection) where T : IEnumerable<int> { ... }

ForEach(new[] { 1, 2, 3 });    // ✅ int[]
ForEach(new List<int> { 1, 2 }); // ✅ List<int>
// ForEach((ReadOnlySpan<int>)(...)); // ❌ Span 不能作为 T

2.2 C# 首个"反约束"

allows ref struct 不是限制 T 必须是什么,而是额外允许 T 是 ref struct——这是 C# 历史上第一个"反约束":

// 加一行 allows ref struct
void ForEach<T>(T collection)
    where T : IEnumerable<int>
    where T : allows ref struct      // ← 反约束:扩张——放行 ref struct
{
    foreach (var item in collection)
        Console.WriteLine(item);
}

// 全都可以传!
ForEach(new[] { 1, 2, 3 });
ForEach(new List<int> { 1, 2 });
ForEach((ReadOnlySpan<int>)stackalloc int[] { 1, 2 });  // ✅ C# 13
理解"反约束":普通约束(where T : class)是收缩——只允许某类类型。反约束(allows ref struct)是扩张——在原有约束基础上多放行一种类型。两者可以叠加:where T : IEnumerable<int>, allows ref struct

2.3 编译器行为

标记了 allows ref struct 的泛型方法,编译器在调用点对 ref struct 做特化(specialization)——不装箱、不逃逸。和其他类型走同一套泛型代码路径,但 IL 层面有专门处理。

// 反约束与普通约束可以组合
void Work<T>(T item)
    where T : struct               // 必须是值类型
    where T : allows ref struct      // 额外放行 ref struct
{ }

// 常见组合:
// where T : IDisposable, allows ref struct
// where T : IEnumerable<TElement>, allows ref struct

三、ref / unsafe 进 async 和迭代器 ref in async & iterators 性能

3.1 过去的限制:全有或全无

C# 12:async 方法和迭代器(yield return)中完全禁止使用 ref 局部变量和 unsafe 块。哪怕你在 await 之前用完就扔了也不行。

// C# 12:编译错误 CS8344
async Task<int> ReadHeaderAsync(Stream stream) {
    Span<byte> buffer = stackalloc byte[16];  // ❌ async 方法不能有 ref struct
    await stream.ReadAsync(buffer);
    return BitConverter.ToInt32(buffer);
}

这是最令人沮丧的限制之一:你知道 buffer 不跨越 await——你只是想在读 I/O 之前临时用一下栈上的 buffer——但编译器不让你这么做。

3.2 C# 13:有条件放行

规则很简单——ref 变量不能跨越 await / yield 边界。必须在 await 之前"死亡"(最后一次引用之后),await 之后不能再访问:

😣 C# 12 — 完全禁止

async Task<int> ReadAsync(Stream s) {
    Span<byte> buf = stackalloc byte[16];
    // ❌ CS8344
}

😎 C# 13 — buf 在 await 前死亡

async Task<int> ReadAsync(Stream s) {
    Span<byte> buf = stackalloc byte[16];
    s.Read(buf);                         // 同步读取——没有 await
    var r = BitConverter.ToInt32(buf);    // buf 在这里"死亡"
    // 之后不再引用 buf——安全
    await s.FlushAsync();                 // ✅ await 在 buf 死亡之后
    return r;
}
// ❌ 但仍然不能跨越 await——编译器会在 await 后将 ref 变量标记为"未赋值"
async Task Bad(Stream s) {
    Span<byte> buf = stackalloc byte[16];
    await Task.Delay(1);
    Console.WriteLine(buf[0]);  // ❌ CS4015: ref local 在 await 后已失效
}

3.3 规则速查

场景允许不允许
Span<T> 在 await 之前
Span<T> 跨越 await❌ 编译器视为已失效
unsafe { } 含 await❌ await 不能在 unsafe 中
unsafe { } 在迭代器中✅(不含 yield 的部分)❌ yield return 不能在 unsafe 中
实用价值:网络/文件 I/O 场景非常普遍——读 header、解析协议帧、验证签名。模式是"await 之前用 Span 做高性能同步解析,await 之后交给堆对象"。C# 13 之前你必须用 byte[](堆分配),现在可以用 stackalloc(零分配)。核心思想:同步解析部分用 Span(零分配栈内存),跨越 await 时才用堆对象。

四、日常开发中你会用到几个?

三个特性对普通开发者的影响程度不同:

特性你直接写吗好处怎么到你身上
ref struct 实现接口几乎不BCL 替你做完了——Span<T> 现在实现 IEnumerable<T>,你能直接把 Span 传给 string.Joinforeach
allows ref struct偶尔你写的泛型工具方法加一行约束,就能接受 Span 参数。不写泛型方法就用不到
ref 进 async经常任何 async I/O 方法里,想用 stackalloc 替代 new byte[] 省一次堆分配,直接写就行

真正每天受益的是第三个。比如之前你要在 async 方法里读 header:

😣 C# 12 — 被迫堆分配

async Task<int> ReadHeaderAsync(Stream s) {
    byte[] buf = new byte[16];   // 堆分配——只为了 16 字节
    await s.ReadAsync(buf, 0, 16);
    return BitConverter.ToInt32(buf, 0);
}

😎 C# 13 — 栈上搞定

async Task<int> ReadHeaderAsync(Stream s) {
    Span<byte> buf = stackalloc byte[16];  // 零分配
    s.Read(buf);                            // 同步读完
    var result = BitConverter.ToInt32(buf); // buf 死亡
    await s.FlushAsync();                   // 可以 await
    return result;
}

五、小测验

1/4 · ref struct 实现 IDisposable 后,以下哪项不能做?
2/4 · allows ref struct——哪项描述最准确?
3/4 · ref/unsafe 进 async——以下哪项在 C# 13 中仍然不合法
4/4 · 三个特性串联——它们的共同设计目标是什么?

六、小结

特性解决的问题使用场景
ref struct 实现接口Span 不能参与泛型抽象消除 Span 的冗余重载;泛型约束消费
allows ref struct泛型天然拒绝 ref struct让泛型方法接受 Span 等 ref struct 参数
ref/unsafe 进 asyncasync 方法完全不能用 SpanI/O 场景——await 前用 stackalloc 零分配

七、下一步

💬 有问题?ref struct 接口的装箱限制?allows ref struct 和 where T : struct 的交互?async 中 Span 的边界规则?随时问。