CodeGym /课程 /C# SELF /Span<T> 的新特性 Span<...

Span<T> 的新特性 Span<T>

C# SELF
第 65 级 , 课程 3
可用

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 指向元素 304050——这不是复制,而是对数组“实时”元素的视图。通过 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

类型 可变? 会复制数据? 能在栈上? 类/结构 可以作为类字段?
T[]
-
ArraySegment<T>
结构
Span<T>
结构
ReadOnlySpan<T>
结构

换句话说, Span<T>ArraySegment<T> 的演进:更高效也更安全。

8. 常见错误和坑

试图把 Span<T> 存到类字段里。 不可以:字段存在堆上,而 Span<T> 应该存在栈上。使用 ArraySegment<T> 或索引来定位。

async 方法返回/存储 Span<T> 不行:异步会切断栈。用别的方式传递数据——比如用数组、Memory<T>/ReadOnlyMemory<T>

创建切片时范围错误。 越界会导致运行时异常。在形成切片之前一定要检查长度和边界。

别忘了 span 反映的是原始数据。 通过 Span<T> 的任何修改都会改变原数组。如果需要独立的副本——请显式复制数据。

评论
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION