1. 介绍
当你在处理数组、字符串和字节缓冲区时,经常需要“查看”这些数据的一部分。比如,取出子字符串、对数组做切片、处理输入流的一段。在旧版 .NET 里,这通常要么复制数据(创建新数组/子字符串),要么写代码从某索引遍历到另一个索引。无论是性能还是可读性,这都不太理想。
下面是旧方法的例子:我们需要把大数组的一部分传给方法:
// 旧方法 — 复制数组的一部分(低效!)
int[] source = new int[] { 1, 2, 3, 4, 5, 6, 7, 8 };
int[] subArray = source.Skip(2).Take(4).ToArray(); // 创建了新数组
因此,如果你想高效地传递数组的“切片”(甚至字符串的一段),不创建多余对象,旧的 C# 手段明显不利,尤其当数据量很大时。
这时候出场的就是今日主角 — Span<T>!
2. 什么是 Span<T>?核心思想
Span<T> 是一种类型,表示同一类型 T 的连续内存区间。它的目的是给你一个快速、安全且高效的方式来处理数组、字符串片段、结构体,甚至非托管内存(比如在托管环境外分配的内存)。
Span<T> 的主要特点是“切片但不创建新数组”。想象一把尺子,你可以用它测量同一数组的任意区段,不复制数据,而且减少索引出错的风险。
简要:
- Span<T> — 一个“窗口”或者对内存片段的“视图”,可以方便且安全地操作这段内存。
- 不分配新内存 — 节省资源,减少 GC 的工作。
- 不仅能操作数组,还能操作字符串片段、stackalloc 块,甚至非托管内存。
- 不能作为普通类的字段保存:它是栈上的类型(stack-only struct)。
为什么这很重要?
在高性能场景(文件解析、大缓冲区处理、加密、序列化)中,哪怕省去几次数组复制也能显著提升速度并降低垃圾回收负载(GC)。而且你还能向同事展示你熟悉现代 C# 和 .NET 的最佳实践!
3. Span<T> 的基本使用:第一个切片
int[] numbers = { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 };
// 在数组的一部分上创建 Span(比如从第2个到第5个元素,含端点)
Span<int> middle = new Span<int>(numbers, 2, 4); // 索引: 2, 3, 4, 5
// 我们看到子数组: 3, 4, 5, 6
Console.WriteLine(string.Join(", ", middle.ToArray())); // 3, 4, 5, 6
// 修改 Span 会修改原数组!
middle[1] = 999;
Console.WriteLine(numbers[3]); // 999
注意! Span<T> 不会复制数据,它只是指向数组的一个“片段”。所有修改在原数组和 Span 中都可见。
4. 创建 Span<T> 的主要方式
基于数组:
int[] arr = { 10, 20, 30, 40, 50 };
Span<int> span = arr; // 整个数组
Span<int> slice = arr.AsSpan(1, 3); // 元素 20, 30, 40
基于数组的一部分:
Span<int> part = new Span<int>(arr, 2, 2); // 元素 30, 40
stackalloc:在栈上分配内存(极快且不进“堆”):
Span<byte> buffer = stackalloc byte[128];
buffer[0] = 42;
使用 .Slice() 方法:
Span<int> subSpan = span.Slice(1, 2); // 元素 20, 30
“切片”可视化示意
原始数组: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10
<--- Span: 3, 4, 5, 6 --->
5. Span<T> 的限制和特性
- 仅限栈(Stack-only)! 不能存为普通类的字段,也不能作为闭包的一部分 — 它是栈上的类型。
- 不能作为类字段使用,也不能从 async 方法返回(编译器会报错)。
- 不能在 lambda/匿名方法中捕获 — 要“即用即丢”。
- 不能直接序列化或在线程间传递。
这是因为 Span 可能指向任意内存区域,如果它被移动到堆上(比如通过闭包或 async),会导致不安全的状态。
6. 不可变版本:ReadOnlySpan<T>
有时候你只想“看”内存的一部分但不想修改它。为此有不可变版本 — ReadOnlySpan<T>。
string text = "Hello, Span!";
ReadOnlySpan<char> letters = text.AsSpan(7, 4); // 'S', 'p', 'a', 'n'
Console.WriteLine(string.Join(", ", letters.ToArray())); // S, p, a, n
// letters[0] = 'Z'; // 错误: 索引器只读!
典型场景 — 安全地传递字符串或数组的“片段”,在接收方不允许修改它们的情况下使用。
7. 实战示例:对数组和字符串做切片
假设这是一个数据解析器,从长字符串中取出子串,查找其中的数字并返回它们的和(在初始切片时不做不必要的复制):
using System;
class Program
{
static void Main()
{
// 假设用户输入了一串用空格分隔的长数字
string input = "12 34 56 78 90 123 456 789";
// 我们只需要计算中间那些数字的和,比如 56 78 90
// 取得子字符串(但不复制!)
ReadOnlySpan<char> center = input.AsSpan(6, 8); // 索引可以动态计算
// 通过 Split 解析数字(会创建临时数组)
string[] numbers = center.ToString().Split(' ');
int sum = 0;
foreach (var str in numbers)
{
if (int.TryParse(str, out int num))
sum += num;
}
Console.WriteLine($"中央数字的和: {sum}");
}
}
现代的 CSV/JSON 解析库都用 Span 来提升大文本的处理速度——现在你知道它们的“魔法”原理了。
8. 有用的细节
Span 与数组复制对比
// 旧方法:复制数组的一部分
int[] arr = Enumerable.Range(0, 1000000).ToArray();
int[] firstThousand = arr.Take(1000).ToArray(); // 创建了一个 1000 元素的新数组
// 新方法:Span
Span<int> bestThousand = arr.AsSpan(0, 1000); // 完全不复制!
bestThousand[0] = 42; // arr 中也会改变
在密集的文件解析、网络缓冲处理、二进制数据操作时,这种差别尤其明显。
实际应用:为什么要懂 Span
- 高性能解析和处理字符串/二进制数据。现代序列化库(比如 System.Text.Json,以及 Span 在 Microsoft 文档)用 Span 加速处理。
- 缓冲与文件读取(切分大缓冲而不复制)。
- 内存受限场景(embedded、IoT)— 参考 Memory/Spans 官方文档。
- 图像和音频算法,要求速度并避免多余分配。
- 使用 Span 加速 CSV、JSON、XML 解析——在 .NET 8/9 中尤其明显。
在面试中,关于 Span 的问题自从它在 .NET Core 2.1+ 出现后就越来越常见,在 .NET 9 中更是常被期待熟悉。
可视化:哪里是 Span,哪里是数组
+--------------------+
| int[] 数组 |
| 1 2 3 4 5 6 7 8 |
+--------------------+
^ ^
| |
[ 2, 3, 4, 5 ] <-- Span<int> 的 "内存窗口" (slice)
Span<T> 不是单独的数组,而是对数据片段的“透明透镜”。
与其他集合的区别:比较表
| 类型 | 储存数据? | 能修改元素? | 能改变大小? | 切片时复制? | 存放位置? |
|---|---|---|---|---|---|
|
是 | 是 | 否 | 是(通过 .Take) | Heap |
|
是 | 是 | 是 | 是 | Heap |
|
否 | 是 | 否 | 否 | Stack |
|
否 | 否 | 否 | 否 | Stack |
9. 使用 Span/ReadOnlySpan 时的常见错误
错误 №1: 试图把 Span 保存为类字段。编译器会报错 “Span type may not be used in this context”。这是刻意为之:把 Span 存在字段中是不安全的。
错误 №2: 从 async 方法返回 Span。不能这么做,因为 async 方法可能会“逃离”到堆上。改用数组或其他类型代替。
错误 №3: 忘了通过 Span 做的修改会反映到原数组上。这可能无意中改变外部数据,导致不可预期的行为。
GO TO FULL VERSION