1. 介绍
想象一下你有一个很大的数据数组,比如从文件读到的字节。你想处理其中一段——把一部分当成字符串读出,或者把这段数据传给某个方法。在“老”办法里,你会把这些字节复制到一个新数组——这既慢又浪费内存。如果这样的片段有几十上百个怎么办?
这时候英雄出现了—— Span<T>。它是一种特殊的结构,描述了对数组(或者任何连续内存片段)的切片(view),不复制数据,只是指向需要的那块区域。重要: Span<T> 不是新的集合,而是对现有数组的一个 安全的 "窗口"!
Span<T> 的主要特性和限制
- Span<T> 是一个结构(value type),表示内存的“窗口”,不会复制元素。
- 仅引用连续的内存区域:数组、数组的一部分、stackalloc 分配的块、通过专用方法获取的字符串内存,或者 unsafe 代码里的内存。
- Span<T> 存活在栈上。不能把它存到类字段里,也不能直接从 async 方法返回。
- 保证类型安全:访问内存时不用手动使用指针(除非你在用 unsafe)。
关于 C# 14 的简要新特性
C# 14 扩展了切片的用法:更方便的范围运算符 .. 和从末尾的索引 ^1,对数组和切片改进了匹配模式,还有其他一些语法糖。我们在最后会回到这些内容。
2. 如何创建 Span<T>?
先从一个简单的数组开始,创建一个 span:
int[] numbers = { 10, 20, 30, 40, 50, 60 };
Span<int> midSpan = new Span<int>(numbers, 2, 3); // 从第3个元素(索引2)开始取3个元素
现在 midSpan 指向元素 30、40、50——这不是复制,而是对数组“实时”元素的视图。通过 span 修改它们会改变原数组。
midSpan[0] = 100;
Console.WriteLine(numbers[2]); // 将输出 100
为什么不直接使用数组的切片?
经典的 LINQ 切片会创建一个 新数组:
int[] slice = numbers.Skip(2).Take(3).ToArray(); // <-- 这里会创建一个副本!
这会耗时又耗内存。 Span 解决了多余复制的问题——而且不只适用于数字。
3. 还有更多创建 Span<T> 的方式
已有数组
Span<int> mySpan = numbers; // 从数组到 Span 的隐式转换
数组的一部分
int[] numbers = {10, 20, 30, 40, 50, 60};
Span<int> part = numbers.AsSpan(1, 4); // 从索引1开始的4个元素:{20, 30, 40, 50}
栈内存 (stackalloc)
Span<T> 允许在栈上分配数组:
Span<byte> buffer = stackalloc byte[128];
for (int i = 0; i < buffer.Length; i++)
buffer[i] = (byte)i;
栈内存很快,并在方法结束时自动释放。但大小要合理——别把兆字节级别的数据放到栈上。
字符串和 ReadOnlySpan<char>
在 .NET 中字符串是不可变的,所以用 ReadOnlySpan<char>:
string greeting = "Hello, C# world!";
ReadOnlySpan<char> span = greeting.AsSpan(7, 8); // "C# world"
Console.WriteLine(span.ToString()); // C# world
4. 来组个例子!
using System;
class Program
{
static void Main()
{
int[] orderTotals = { 100, 200, 300, 400, 500, 600, 700 };
Console.WriteLine("所有订单历史: ");
foreach (int total in orderTotals)
Console.Write(total + " ");
Console.WriteLine();
Console.WriteLine("输出第2到第4的订单(索引1-3):");
Span<int> recentOrders = orderTotals.AsSpan(1, 3);
foreach (int t in recentOrders)
Console.Write(t + " ");
Console.WriteLine();
// 通过 Span 修改数据
recentOrders[1] = 999;
Console.WriteLine("通过 Span 修改后:");
foreach (int total in orderTotals)
Console.Write(total + " ");
Console.WriteLine();
}
}
切片 recentOrders 真正直接操作数组——这不是副本。
5. 安全性、性能和检查
- 节省内存:没有多余复制。
- 边界检查防止越界。
- JIT 优化能提供非常快的访问(无需 unsafe)。
与方法的交互
方法可以接受 Span<T> 或 ReadOnlySpan<T>。如果不打算修改数据,就用 ReadOnlySpan<T>。
static int Sum(Span<int> slice)
{
int sum = 0;
foreach (var item in slice)
sum += item;
return sum;
}
int[] data = { 1, 2, 3, 4, 5, 6, 7 };
Console.WriteLine(Sum(data.AsSpan(2, 3))); // 3+4+5=12
6. 使用范围(range)
从 C# 8 开始,可用范围运算符 .. 和从末尾的索引 ^ —— 它们和切片配合得很好。
int[] arr = { 10, 20, 30, 40, 50, 60 };
Span<int> span = arr[2..5]; // 索引 2、3、4 —— 即 30、40、50
范围里起始索引包含,结束索引不包含。
从末尾的索引
int lastElement = arr[^1]; // 最后一个元素 (60)
Span<int> lastTwo = arr[^2..]; // 最后两个元素 (50, 60)
范围和字符串
string code = "SpanMagic!";
var mid = code[4..9]; // "Magic"
这个切片是 ReadOnlySpan<char>;要得到字符串,调用 ToString()。
7. 关于切片的现代模式(C# 14)
新的模式让解析数组和切片更简单。
if (arr is [10, 20, .. var rest]) // .. 捕获数组的 "尾部"
{
Console.WriteLine("开头匹配,尾部:");
foreach (var x in rest)
Console.WriteLine(x);
}
if (arr is [.., 50, 60])
Console.WriteLine("数组以 50、60 结尾");
比较:数组、ArraySegment 和 Span
| 类型 | 可变? | 会复制数据? | 能在栈上? | 类/结构 | 可以作为类字段? |
|---|---|---|---|---|---|
|
是 | - | 否 | 类 | 是 |
|
是 | 否 | 否 | 结构 | 是 |
|
是 | 否 | 是 | 结构 | 否 |
|
否 | 否 | 是 | 结构 | 否 |
换句话说, Span<T> 是 ArraySegment<T> 的演进:更高效也更安全。
8. 常见错误和坑
试图把 Span<T> 存到类字段里。 不可以:字段存在堆上,而 Span<T> 应该存在栈上。使用 ArraySegment<T> 或索引来定位。
从 async 方法返回/存储 Span<T>。 不行:异步会切断栈。用别的方式传递数据——比如用数组、Memory<T>/ReadOnlyMemory<T>。
创建切片时范围错误。 越界会导致运行时异常。在形成切片之前一定要检查长度和边界。
别忘了 span 反映的是原始数据。 通过 Span<T> 的任何修改都会改变原数组。如果需要独立的副本——请显式复制数据。
GO TO FULL VERSION