关于 Span 的一切:探索新的 .NET 明星
https://docs.microsoft.com/en-us/archive/msdn-magazine/2018/january/csharp-all-about-span-exploring-a-new-net-mainstay
想象一下你正在发布一个特别的排序算法程序,它可以在内存中就地处理数据。你会希望发布一个获得一个数组参数,并提供在数组之上操作 T[] 的实现。如果调用方可以获得这个数组,并且是希望对整个数组进行排序,那么这种方式特别棒。但是,如果调用方仅仅希望对数组的部分进行排序呢,你可能又会出提供一个重载的实现,通过 offset 和 count 参数来支持。不过,如果你又希望支持内存中不是数组的数据,比如说,而是来自原生代码呢?或者是在堆栈上的数据,你只有指向它的指针和长度呢?你又如何开发你的排序方法来操作此类任意的内存区域,而且仍然与处理整个数组,或者数组的子集一样好呢?还要考虑到处理托管数组与非托管数组一样好呢?
或者,我们看另一个例子。你正在实现一个对 System.String 的处理。例如是一个特别的解析方法。你希望获得一个字符串参数,然后提供提供处理字符串的实现。但是,如果你希望还要支持处理该字符串的子集呢?String.Substring() 方法可以用来抽取出感兴趣的一部分字符串,但是这会牵涉到昂贵的操作,导致字符串分配和内存复制。你也可以这样做,如在数组示例中那样,通过一个 offset 和 count 参数来处理。不过,如果调用方并没有得到这个字符串,而是得到了一个 char[] 数组呢?或者调用方得到的是指针 char* 呢?或者是通过调用 stackalloc 使用栈空间呢?或者是调用原生代码得到的结果呢?你又如何开发你的解析方法,不需要强制调用者做任何内存分配或者复制的一种方式呢?并且仍然一致良好地处理各种输入类型,比如字符串、char[] 和 char* 呢?
在这两种场景下,你可能可以使用 unsafe 代码和指针来完成,提供接受指针和长度的输入。不过,这样丢掉了 .NET 的核心的安全保证,打开了问题之门,比如缓冲区溢出,访问冲突等等,这些对大多数 .NET 开发者已经过去的问题。它还引入了额外的性能惩罚,比如需要在处理过程中钉住托管对象,以便你获得的指针保持有效。基于底层不同的数据类型,并不总是可以获得指针。
对于这个谜题的答案就是,Span <T>
什么是 Span<T>?
System.Span<T> 是来自 .NET 核心的新的值类型。它支持表示内存中任意一段连续的区间,不管这段内存属于一个托管对象,还是通过原生代码进行互操作得到,或者是被分配在堆栈上。尽管这样还提供了类似数组操作的高性能的安全访问。
例如,你可以从数组来创建 Span<T>:
var arr = new byte[10];
Span<byte> bytes = arr; // Implicit cast from T[] to Span<T>
从这里开始,你可以简单且高效地利用 Span 的 Slice() 重载方法,创建一个 Span 来表示/指向该数组的一个子集。通过它,你可以通过下标来读、写源数组相关的部分。
Span<byte> slicedBytes = bytes.Slice(start: 5, length: 2);
slicedBytes[0] = 42;
slicedBytes[1] = 43;
Assert.Equal(42, slicedBytes[0]);
Assert.Equal(43, slicedBytes[1]);
Assert.Equal(arr[5], slicedBytes[0]);
Assert.Equal(arr[6], slicedBytes[1]);
slicedBytes[2] = 44; // Throws IndexOutOfRangeException
bytes[2] = 45; // OK
Assert.Equal(arr[2], bytes[2]);
Assert.Equal(45, arr[2]);
如前所述,Span 还提供了访问数组子集的一种方式。它也可以用来指向堆栈上的数据。例如:
Span<byte> bytes = stackalloc byte[2]; // Using C# 7.2 stackalloc support for spans
bytes[0] = 42;
bytes[1] = 43;
Assert.Equal(42, bytes[0]);
Assert.Equal(43, bytes[1]);
bytes[2] = 44; // throws IndexOutOfRangeException
更为方便的是,它可以用来指向任意的指针和长度,例如通过本地堆分配的内存,例如:
IntPtr ptr = Marshal.AllocHGlobal(1);
try
{
Span<byte> bytes;
unsafe { bytes = new Span<byte>((byte*)ptr, 1); }
bytes[0] = 42;
Assert.Equal(42, bytes[0]);
Assert.Equal(Marshal.ReadByte(ptr), bytes[0]);
bytes[1] = 43; // Throws IndexOutOfRangeException
}
finally { Marshal.FreeHGlobal(ptr); }
Span<T> 的索引器借助于被称为 ref return 的从 C# 7.0 引入的 C# 特性。该索引器使用 ref T
返回类型定义。它提供了类似于索引数组的语法,返回实际存储位置的引用,而不是在该位置内存的复制品。
public ref T this[int index] { get { ... } }
通过该示例,该 ref-returning 索引器的影响显而易见,例如与 List 索引器相比,它不是 ref returning 的。下面是一个示例:
struct MutableStruct { public int Value; }
...
Span<MutableStruct> spanOfStructs = new MutableStruct[1];
spanOfStructs[0].Value = 42;
Assert.Equal(42, spanOfStructs[0].Value);
var listOfStructs = new List<MutableStruct> { new MutableStruct() };
listOfStructs[0].Value = 42; // Error CS1612: the return value is not a variable
Span<T> 的一种变体,被称为 System.ReadOnlySpan<T>,支持只读访问。该类型与 Span<T> 类似,除了借助于 C#7.2 中引入的 ref readonly T
特性,而不是 ref T
。使得它可以处理不变的数据类型,比如 System.String。ReadOnlySpan<T> 使得可以非常高效地处理字符串切片,而不需要分配或者复制内存,例如:
string str = "hello, world";
string worldString = str.Substring(startIndex: 7, length: 5); // Allocates
ReadOnlySpan<char> worldSpan =
str.AsSpan().Slice(start: 7, length: 5); // No allocation
Assert.Equal('w', worldSpan[0]);
worldSpan[0] = 'a'; // Error CS0200: indexer cannot be assigned to
除了这些已经介绍的特性,Span 还提供了多种优点。例如,Span 支持类型转换符号,意味着你可以强制一个 Span<byte> 到 Span<int> ( 这里 Span<int> 的 0 下标映射到 Span<byte> 第一个 4 字节 )。这样如果你读取 bytes 缓冲区,你可以安全且高效地将它传递给操作一组字节,例如 int 类型。