类 ref 结构体(C# 7.2)

C# 7.2引入了类ref结构体的概念:只存在于栈内存上的结构体。与自定义task类型相似,很可能我们永远不需要自行声明类ref结构体,但我们所使用的framework中很可能内建类ref结构体。

首先介绍类ref结构体的基本规则,然后介绍其使用方式以及framework的支持方式。这里介绍的使用规则都是简化后的,具体规则参见语言规范。尽管很少有开发人员需要知道编译器是如何保证类ref结构体在栈内存上的安全的,但是应该了解该特性的主要实现目标:

ref结构体的值必须永远保存在栈内存中。

首先创建一个类ref结构体,其声明方式只比声明普通结构体多了一个ref修饰符:

public ref struct RefLikeStruct
{
             <------ 和普通结构体一样的成员
}

先不介绍RefLikeStruct的用途,而是列出它不能用来做什么,并给出相应解释。

  • 对于任何不是类ref结构体的类型,RefLikeStruct不能用作其字段。即便是普通的结构体,也可以通过装箱或者成为某个类字段的方式最终存储在堆内存中。即使在其他类ref结构体中,RefLikeStruct也只能用作实例字段的类型,不能是静态字段的类型。
  • 不能对RefLikeStruct执行装箱操作。装箱旨在在堆内存中创建一个对象,这绝对不是我们想要的结果。
  • 不能把RefLikeStruct用作类型实参(不管是显式方式还是类型推断方式),任何泛型方法或者类型都不可以,也不能用作某个泛型类ref结构体的类型实参。泛型代码可以以多种方式将泛型实参存放于堆内存中,例如创建List<T>
  • 不可以创建RefLikeStruct[],类似的数组类型也不能用作typeof运算符的操作数。
  • RefLikeStruct类型的局部变量不能用于编译器可能需要在某个生成类型中进行堆内存中捕获的情况,包括如下几类。
    • async方法。这个要求不是那么严格,例如变量可以在await表达式之间进行声明和使用(在await之前声明,在其之后使用)。async方法的参数不能是类ref结构体类型。
    • 迭代器块,它的规则大致是“只能在两个yield表达式之间使用RefLikeStruct”。迭代器块的形参不能是类ref结构体。
    • 任何被局部方法、LINQ查询表达式、匿名方法或者lambda表达式捕获的局部变量,都不可以是类ref结构体。

此外,关于类ref类型的ref局部变量,还有很多复杂的使用规则,建议遵从编译器的指示。如果代码因为类ref结构体而编译失败,那么很有可能是代码试图获取已经不存在于栈内存中的数据。有了这些将值锁定在栈内存的规则之后,下面介绍类ref结构体的衍生类型Span<T>

在.NET世界中,访问一块区域的内存有多种方法,常用的有数组,有时也可以使用ArraySegment<T>和指针。直接使用数组有一个巨大的缺陷:数组不单是一块大内存,它掌握着自己的全部内存。这听起来似乎没什么不妥,但是如下所示的方法签名会有问题:

int ReadData(byte[] buffer, int offset, int length)

这种bufferoffsetlength的参数组合广泛存在于.NET中,其实是不良代码的迹象,昭示着这里缺少合理的抽象。Span<T>正是为解决这一问题而生的。

说明 使用Span<T>时,有时只需添加对NuGet包System.Memory的引用即可,有时还需要framework的支持。本节给出的代码都是基于.NET Core 2.1构建的。其中一部分也可以在更早的framework版本中构建。

Span<T>是类ref结构体,具有读写属性,可以像数组那样通过索引访问内存,但它并不拥有这块内存。span总是从别处创建而来(可能是指针、数组甚至是从栈内存直接创建)。使用Span<T>时,无须关注所分配内存的位置。另外,span也可以进行切分:可以在无须复制的情况下,切分出一块span作为另一个span的子分区。在新版framework中,JIT编译器可以识别Span<T>并将其高度优化。

Span<T>的名称看起来与类ref结构体的本质不太相关,但它有两大优势。

  • span可以指向一个生命周期轻度受限的内存,因为span不可能离开栈内存。负责分配内存的代码可以把span传递给其他代码,然后放心地释放内存,因为不会有残余的span指向未释放的内存。
  • span中的数据可以实现自定义一次性初始化,不需要任何复制,也不存在之后数据被其他代码篡改的风险。

下面编写一个创建随机字符串的例子,这个例子可以展示上述两大优势。虽然Guid.NewGuid也可以用于创建随机字符串,但有时需要使用不同的字符集和长度来创建一些定制化程度更高的随机字符串。代码清单13-20是传统的实现方式。

代码清单13-20 使用char[]生成一个随机字符串

static string Generate(string alphabet, Random random, int length)
{
char[] chars = new char[length];
for (int i = 0; i < length; i++)
{
    chars[i] = alphabet[random.Next(alphabet.Length)];
}
return new string(chars);
}

该方法的调用代码如下:

string alphabet = "abcdefghijklmnopqrstuvwxyz";
Random random = new Random();
Console.WriteLine(Generate(alphabet, random, 10));

代码清单13-20需要两块堆内存的分配:一块给char数组,一块给字符串。在创建字符串时,这段数据会从一处复制到另一处。如果可以使用非安全代码,并且知道所创建的字符串不会太大,还可以使用stackalloc对代码做一些小的改进,见代码清单13-21。

代码清单13-21 使用stackalloc和指针来实现生成随机字符串

unsafe static string Generate(string alphabet, Random random, int length)
{
char* chars = stackalloc char[length];
for (int i = 0; i < length; i++)
{
    chars[i] = alphabet[random.Next(alphabet.Length)];
}
return new string(chars);
}

这段代码只有一次堆内存分配:为字符串分配内存。之前的临时缓冲区使用了栈内存分配,但是需要在方法前添加unsafe修饰符,因为这里使用了指针。非安全的代码令人不适,虽然我自己确信这段代码没有问题,但是我不想使用指针实现太多更复杂的功能,而且这段代码仍存在从栈内存到字符串的数据复制。

好在Span<T>支持stackalloc,而不需要unsafe修饰符,见代码清单13-22。之所以Span<T>不需要unsafe修饰符,是因为类ref结构体可以保证一切安全。

代码清单13-22 使用stackallocSpan<char>创建随机字符串

static string Generate(string alphabet, Random random, int length)
{
Span<char> chars = stackalloc char[length];
for (int i = 0; i < length; i++)
{
    chars[i] = alphabet[random.Next(alphabet.Length)];
}
return new string(chars);
}

不过光有这些还不够。代码中依然存在一处多余的复制操作。使用System.String的一个工厂方法可以解决这一问题,如下所示:

public static string Create<TState>(
int length, TState state, SpanAction<char, TState> action)

该方法用到了SpanAction<T, TArg>,这是下面方法签名的一个新委托:

delegate void SpanAction<T, in TArg>(Span<T> span, TArg arg);

这两个签名乍看有些奇怪,接下来对其进行详细剖析,看看Create的实现。它完成了以下几个步骤:

(1) 根据要求的长度分配一个字符串;

(2) 创建一个指向该字符串的span;

(3) 调用action委托,向委托传递span和方法的状态;

(4) 返回字符串。

首先需要注意:该委托可以向字符串进行写入。这一点似乎和字符串的不可变性相违背,但这里由Create方法主宰,因此可以向字符串写入任何内容,就像创建并初始化新字符串一样。当字符串返回时,其内容就确定下来了。我们也不能保留传递给委托的Span<char>,因为编译器会确保其不会脱离栈内存。

还有一个关于参数state的疑问:为什么需要传入state,然后回传给委托呢?示例如下,代码清单13-23用Create方法实现随机字符串生成器。

代码清单13-23 使用string.Create创建随机字符串

static string Generate(string alphabet, Random random, int length) =>
string.Create(length, (alphabet, random), (span, state) =>
{
    var alphabet2 = state.alphabet;
    var random2 = state.random;
    for (int i = 0; i < span.Length; i++)
    {
        span[i] = alphabet2[random2.Next(alphabet2.Length)];
    }
});

起初,我们会觉得其中有太多无意义的重复代码。string.Create的第2个实参是(alphabet, random),它把alphabetrandom放到一个元组中作为state,然后在lambda表达式中又把这个元组拆解开了:

var alphabet2 = state.alphabet;
var random2 = state.random;

为什么不能在lambda表达式中直接捕获这两个参数呢?直接在lambda表达式中使用alphabetrandom既能通过编译,又能正常运行,为什么需要一个额外的state参数呢?

请记住使用span的目的:减少复制和堆内存的分配。当lambda表达式捕获参数或者局部变量时,它必须创建一个生成类的实例,这样委托才能访问这些变量。代码清单13-23中的lambda表达式无须捕获任何东西,编译器就能生成一个静态方法,然后缓存一个委托实例,以供每次Generate调用。所有state都是通过参数传递给string.Create的,因为C# 7的元组是值类型,所以对于state不需要内存分配。

至此,字符串生成器终于可堪大用了:只需要一次堆内存分配,而且没有任何数据复制。代码直接向字符串中写入数据。

这只是Span<T>所能实现的一个小例子。相关的ReadOnlySpan<T>Memory<T>以及ReadOnlyMemory<T>几个类型更重要,不过本书不做深入探讨。

重要的是,优化之后的Generate方法根本不需要改变它的方法签名。这是一个纯粹实现层面的改动,将实现变化与外部代码隔离开来,值得称道。虽然通过引用传递结构体也能避免大量复制操作,但这属于侵入性的改动,我更喜欢零散的、有针对性的优化。

string类型已经增加利用span的新方法,其他类型也会紧随其后。对于任何基于I/O的操作,在framework中都会有相应的异步方法,随着时间的推移,span应该也会如此。span能发挥作用的地方,都应当提供相应的方法。第三方库也会提供接收span的重载方法。

  1. 在初始化器中使用stackalloc(C# 7.3)

    关于栈内存分配,C# 7.3也为此新增了一个变动:初始化器。在以前的版本中,使用stackalloc时必须为其提供一个分配内存大小的值;到了C# 7.3,可以为这块内存指定内容了。对于指针和span,下面这两种方式都是合法的:

    Span<int> span = stackalloc int[] { 1, 2, 3 };
    int* pointer = stackalloc int[] { 4, 5, 6 };

    虽然与先分配内存然后填充数据相比,新写法的效率提升并不明显,但可读性的增强是毋庸置疑的。
     

  2. 基于模式的fixed语句(C# 7.3)

    要点回顾:fixed语句用于获取指向某块内存的指针,可以暂时阻止垃圾回收器回收这部分数据。在C# 7.3之前,它只能用于数组、字符串以及获取变量的地址。C# 7.3则将其扩展到了所有类型,只需要该类型有一个名为GetPinnableReference的方法用于返回一个非托管类型的引用即可。如果有一个返回ref int的方法,那么它可以使用fixed语句:

    fixed (int* ptr = value) <------ 调用value.GetPinnableReference
    {
               <------ 使用指针的代码
    }

    即便是那些经常和非安全代码打交道的少数程序员,通常也不需要自己实现。Span<T>ReadOnlySpan<T>类型更常用,通过它们足以和已经使用了指针的代码进行交互。

ref结构体会由[IsRefLikeAttribute]修饰,该attribute来自System.Runtime.CompilerServices命名空间。如果目标framework没有提供该attribute,那么程序集中会创建。

in参数不同,编译器不会使用modreq修饰符来要求使用该类型的工具识别它,而是添加一个[ObsoleteAttribute]到该类型中,并且提供一条固定的消息。任何能够识别[IsRefLikeAttribute]的编译器,在消息正确的情况下,都可以忽略[ObsoleteAttribute]。如果该类型的作者想废除该类型,只需要使用[ObsoleteAttribute]即可,而编译器会把它当作过期类型。

(完)

相关阅读:


C# 回顾:ref 知多少

C# ref 局部变量和 ref return

in 参数(C# 7.2)

将结构体声明为只读(C# 7.2)

使用 ref 参数或者in参数的扩展方法(C# 7.2)

类 ref 结构体(C# 7.2)

评论

此博客中的热门博文

in 参数(C# 7.2)

C# ref 局部变量和 ref return