C# 7.0 中的 Ref returns 限制

Ref returns restrictions in C# 7.0

我试图从官方博客 post 中理解以下摘录,内容涉及 C# 7.0 中与 ref returns.

相关的新功能
  1. You can only return refs that are “safe to return”: Ones that were passed to you, and ones that point into fields in objects.

  2. Ref locals are initialized to a certain storage location, and cannot be mutated to point to another.

遗憾的是,博客post没有给出任何代码示例。如果有人可以通过实际示例和解释进一步阐明以粗体突出显示的限制,将不胜感激。

提前致谢。

您可以在 GitHub - Proposal: Ref Returns and Locals.

找到关于此功能的精彩讨论

1. You can only return refs that are “safe to return”: Ones that were passed to you, and ones that point into fields in objects.

以下示例显示了安全引用的 return,因为它来自调用者:

public static ref TValue Choose<TValue>(ref TValue val)
{
    return ref val;
}

相反,此示例的非安全版本将 returning 对本地的引用(此代码无法编译):

public static ref TValue Choose<TValue>()
{
    TValue val = default(TValue);
    return ref val;
}

2. Ref locals are initialized to a certain storage location, and cannot be mutated to point to another.

该限制意味着您需要始终在声明时初始化局部引用。像

这样的声明
ref double aReference;

无法编译。您也无法将新引用分配给已存在的引用,例如

aReference = ref anOtherValue;

要通过引用传递某些东西,它必须归类为变量。 C# 规范(§5 变量)定义了七类变量:静态变量、实例变量、数组元素、值参数、引用参数、输出参数和局部变量。

class ClassName {
    public static int StaticField;
    public int InstanceField;
}
void Method(ref int i) { }
void Test1(int valueParameter, ref int referenceParameter, out int outParameter) {
    ClassName instance = new ClassName();
    int[] array = new int[1];
    outParameter=0;
    int localVariable = 0;
    Method(ref ClassName.StaticField);  //Static variable
    Method(ref instance.InstanceField); //Instance variable
    Method(ref array[0]);               //Array element
    Method(ref valueParameter);         //Value parameter
    Method(ref referenceParameter);     //Reference parameter
    Method(ref outParameter);           //Output parameter
    Method(ref localVariable);          //Local variable
}

第一点其实是说可以refreturn变量分类为引用参数、输出参数、静态变量实例变量.

ref int Test2(int valueParameter, ref int referenceParameter, out int outParameter) {
    ClassName instance = new ClassName();
    int[] array = new int[1];
    outParameter=0;
    int localVariable = 0;
    return ref ClassName.StaticField;  //OK, "ones that point into fields in objects"
    return ref instance.InstanceField; //OK, "ones that point into fields in objects"
    return ref array[0];               //OK, array elements are also "safe to return" by reference
    return ref valueParameter;         //Error
    return ref referenceParameter;     //OK, "ones that were passed to you"
    return ref outParameter;           //OK, "ones that were passed to you"
    return ref localVariable;          //Error
}

请注意,对于值类型的实例字段,您应该考虑 "safe to return" 封闭变量的状态。并不总是允许的,例如引用类型的字段:

struct StructName {
    public int InstacneField;
}
ref int Test3() {
    StructName[] array = new StructName[1];
    StructName localVariable = new StructName();
    return ref array[0].InstacneField;      //OK, array[0] is "safe to return"
    return ref localVariable.InstacneField; //Error, localVariable is not "safe to return"
}

ref return 方法的结果考虑 "safe to return",如果此方法不采用任何非 "safe to return" 参数:

ref int ReturnFirst(ref int i, ref int ignore) => ref i;
ref int Test4() {
    int[] array = new int[1];
    int localVariable = 0;
    return ref ReturnFirst(ref array[0], ref array[0]);      //OK, array[0] is "safe to return"
    return ref ReturnFirst(ref array[0], ref localVariable); //Error, localVariable is not "safe to return"
}

虽然我们知道ReturnFirst(ref array[0], ref localVariable)会return "safe to return"引用(ref array[0]),但是编译器不能孤立地分析Test4方法来推断它。因此,在那种情况下 ReturnFirst 方法的结果被认为不是 "safe to return".

第二点说,ref local variables declaration must include initializer:

int localVariable = 0;
ref int refLocal1;                     //Error, no initializer
ref int refLocal2 = ref localVariable; //OK

此外,ref 局部变量不能重新分配以指向其他存储位置:

int localVariable1 = 0;
int localVariable2 = 0;
ref int refLocal = ref localVariable1;
ref refLocal = ref localVariable2;     //Error
refLocal = ref localVariable2;         //Error

实际上没有有效的语法来重新分配 ref 局部变量。

您得到了一些阐明限制的答案,但没有说明限制背后的原因。

限制背后的原因是我们绝不能允许死变量的别名。如果你在一个普通方法中有一个普通的局部变量,并且你 return 一个引用到它,那么在使用引用时局部变量已经死了。

现在,有人可能会指出由 ref return 编辑的局部变量可以提升到闭包 class 的字段。是的,那会解决问题。但该特性的重点是让开发者编写高性能的接近机器的低成本机制,并自动提升到闭包——然后承担收集压力等负担——工作反对那个目标。

事情可能会变得有些棘手。考虑:

ref int X(ref int y) { return ref y; }
ref int Z( )
{
  int z = 123;
  return ref X(ref z);
}

我们在这里 return 以偷偷摸摸的方式引用本地 z!这也必须是非法的。但现在考虑一下:

ref double X(ref int y) { return ref whatever; }
ref double Z( )
{
  int z = 123;
  return ref X(ref z);
}

现在我们可以知道 returned ref 不是 z 的 ref。那么如果传入的 refs 类型与 returned 的 refs 类型完全不同,我们能说这是合法的吗?

这个呢?

struct S { public int s; }
ref int X(ref S y) { return ref y.s; }
ref int Z( )
{
  S z = default(S);
  return ref X(ref z);
}

现在我们又一次return引用了一个死变量。

当我们第一次(2010 年 IIRC)设计此功能时,有许多复杂的提案来处理这些情况,但我最喜欢的提案只是 "make all of them illegal"。你不会得到 return 你通过 ref-returning 方法得到 return 的引用,即使它不可能死掉。

我不知道 C# 7 团队最终实施了什么规则。

这个页面的其他答案很完整,也很有用,但我想补充一点,就是 out 个参数,你的函数需要完全初始化,算作 "safe to return" 用于 ref return.

有趣的是,将这一事实与另一个新的 C# 7 特性 inline declaration of 'out' variables 相结合,可以模拟通用 局部变量的内联声明能力:

辅助函数:

public static class _myglobals
{
    /// <summary> Helper function for declaring local variables inline. </summary>
    public static ref T local<T>(out T t)
    {
        t = default(T);
        return ref t;
    }
};

有了这个助手,调用者通过赋值给ref-[=161]来指定"inline local variable"的初始化 =] 帮手.

为了演示帮助程序,接下来是一个简单的两级比较函数的示例,这对于(例如)MyObj.IComparable<MyObj>.Compare 实现来说是典型的。尽管非常简单,但这种类型的表达式无法绕过需要单个局部变量的情况——也就是说,无需重复工作。现在通常情况下,需要本地会使用 expression-bodied member 阻塞,这是我们想在这里做的,但问题很容易用上面的助手解决:

public int CompareTo(MyObj x) =>
                       (local(out int d) = offs - x.offs) == 0 ? size - x.size : d;

演练:局部变量d是"inline-declared,"并用第一个计算结果初始化-级别比较,基于 offs 字段。如果此结果不确定,我们将退回到 return 二级排序(基于 size 字段)。但在另一种情况下,我们仍然有第一级结果可供 return 使用,因为它保存在本地 d.

请注意,上面的操作也可以在没有辅助函数的情况下完成,通过 C# 7 pattern matching:

public int CompareTo(MyObj other) => 
                       (offs - x.offs) is int d && d == 0 ? size - x.size : d;

在源文件的顶部包含:

using System;
using /* etc... */
using System.Xml;
using Microsoft.Win32;

using static _myglobals;    //  <-- puts function 'local(...)' into global name scope

namespace MyNamespace
{
   // ...

以下示例显示 C# 7 中声明局部变量内联 并进行初始化。如果未提供初始化,它将获得 default(T),由 local<T>(out T t) 辅助函数分配。现在只有 ref return 功能才有可能,因为 ref return 方法是唯一可以用作 ℓ-value.

的方法

示例 1:

var s = "abc" + (local(out int i) = 2) + "xyz";   //   <-- inline declaration of local 'i'
i++;
Console.WriteLine(s + i);   //   -->  abc2xyz3

示例 2:

if ((local(out OpenFileDialog dlg) = new OpenFileDialog       // <--- inline local 'dlg'
    {
        InitialDirectory = Environment.CurrentDirectory,
        Title = "Pick a file",
    }).ShowDialog() == true)
{
    MessageBox.Show(dlg.FileName);
}

第一个示例简单地从整数文字进行分配。在第二个示例中,内联局部 dlg 从构造函数(new 表达式)赋值,然后整个赋值表达式用于其解析值以调用实例方法(ShowDialog ) 在新创建的实例上。作为一个独立的示例,为了精确清楚,它最后显示 dlg 的引用实例确实需要命名为变量,以便获取其属性之一。


[编辑:] 关于...

2. Ref locals are initialized to a certain storage location, and cannot be mutated to point to another.

...如果有一个带有可变引用的 ref 变量肯定会很好,因为这将有助于避免在循环体内进行昂贵的索引边界检查。当然,这也正是不允许这样做的原因。你可能无法解决这个问题(即 ref 到索引包含 ref 的数组访问表达式将不起作用;它在初始化时永久解析为引用位置的元素)但是如果它有帮助,注意你 canref 指向一个指针,这包括 ref local:

int i = 5, j = 6;

int* pi = &i;
ref int* rpi = ref pi;

Console.WriteLine(i + " " + *pi + " " + *rpi);      //   "5 5 5"

pi = &j;

Console.WriteLine(i + " " + *pi + " " + *rpi);      //   "5 6 6"

这个毫无意义的示例代码的要点是,尽管我们没有以任何方式改变 ref local 变量 rpi 本身(因为“你可以” t),它确实现在有不同的(最终)指称。


更严重的是,ref local does 现在允许,只要加强数组索引循环体中的 IL,是一种我称之为 值类型标记的技术。 这允许在需要访问数组中每个元素的多个字段的循环体中实现高效的 IL值类型。通常,这是在外部初始化 (newobj / initobj) 之后进行单个索引访问与 原位 非初始化之间的权衡,但冗余多运行时索引的开销。

使用 值类型标记 但是,现在我们可以完全避免每个元素的 initobj / newobj IL 指令并且只有一个索引在运行时计算。我将首先展示示例,然后在下面描述一般的技术。

/// <summary>
/// Returns a new array of (int,T) where each element of 'src' is paired with its index.
/// </summary>
public static (int Index, T Item)[] TagWithIndex<T>(this T[] src)
{
    if (src.Length == 0)
        return new (int, T)[0];

    var dst = new (int Index, T Item)[src.Length];     // i.e, ValueTuple<int,T>[]
    ref var p = ref dst[0];      //  <--  co-opt element 0 of target for 'T' staging

    ref int i = ref p.Index;  //  <-- index field in target will also control loop
    i = src.Length;    

    while (true)
    {
        p.Item = src[--i];
        if (i == 0)
            return dst;
        dst[i] = p;
    }
}

这个例子展示了值类型冲压技术的简洁而极端的使用;如果您有兴趣,可以自己辨别它的曲折(在评论中给出)。在下文中,我将以更一般的术语来讨论值类型标记技术。

首先,准备ref locals,直接引用值类型的临时实例中的相关字段。这可以在堆栈上,或者如示例中所示,从目标数组本身的最后一个要处理的元素中增选。对整个登台实例也有一个 ref 可能很有价值,尤其是在使用增选技术时。

循环体的每次迭代都可以非常有效地准备暂存实例,并且作为准备好的最后一步,"stamp"它只需要一次索引操作就可以批量放入数组中。当然,如果数组的最后一个元素被增选为暂存实例,那么你也可以稍微提前离开循环。