第13章 引用传递提升执行效率

第13章 引用传递提升执行效率

13.2 ref 局部变量和 ref return

13.2.1 ref 局部变量

在 C#7 之前,ref 仅用于方法参数;自 C#7 开始,ref 可以创建局部变量!它的用法如下:

int x = 10;
ref int y = ref x;
x++;
y++;
Console.WriteLine(x);

该代码输出结果“ 12 ”。

ref 局部变量的主要作用是避免 不必要的复制 操作。下面是一段更复杂的用例,它通过 ref 局部变量为数组中的元素(元组,为值类型)修改内容:

var array = new (int x, int y)[10];

for (int i = 0; i < array.Length; i++)
{
    array[i] = (i, i);
}

for (int i = 0; i < array.Length; i++)
{
    ref var element = ref array[i];    // 元组为值类型,此处用 ref 避免值拷贝
    element.x++;
    element.y *= 2;
}

ref 局部变量的使用存在一些限制,下面我们依次讲解。

13.2.1.1 初始化:只在声明时初始化一次

  1. ref 局部变量必须在 声明 时完成初始化,例如以下代码 法:
int x = 10;
ref int invalid;
invalid = ref int x;

C#7.3 解除了该限制

  1. 用于初始化 ref 局部变量的变量也必须是 已经赋值 的。以如下代码为例,第 2 行代码非法:
int x;
ref int y = ref x;
x = 10;
Console.WriteLine(y);

该限制尚未解除

Suggest

如果需要在某个方法中使用同一个 ref 变量来指代不同的变量,重构一下方法会更好。

13.2.1.2 没有 ref 字段,也没有超出方法调用范围的 ref 局部变量

出于 变量生命周期 的考量,C# 不允许 ref 字段。

以下 3 个场景都需要关注局部变量的生命周期问题:

  • 迭代器块中不能有 ref 局部变量;

  • async 方法不能有 ref 局部变量;

  • ref 局部变量不能被匿名方法或者局部方法捕获。

    关于局部方法,见14.1 局部方法

Summary

以上几种情况都是局部变量生命周期长于原始方法调用的情况。

13.2.1.3 只读变量不能有引用

ref 局部变量必须是可 的。以如下代码为例,变量 y ​ 的赋值是非法的:

class MixedVariables
{
    private int writableField;
    private readonly int readonlyField;

    public void TryIncrementBoth()
    {
        ref int x = ref writableField;
        ref int y = ref readonlyField;

        x++;
        y++;
    }
}

不过,上述赋值发生在 构造器 中则是合法的。

Summary

简而言之,创建一个变量的 ref 局部变量的前提是:该变量在其他情况下可以 正常写入 。该规则与 C#1.0 中的 ref 参数相同。

13.2.1.4 类型:只允许一致性转换

ref 局部变量的类型,必须和用于初始化它的变量的类型 一致 ,或者这两个类型之间必须存在 一致性 转换。

如下代码演示了基于元组的 一致性 转换:

(int x, int y) tuple1 = (10, 20);
ref (int a, int b) tuple2 = ref tuple1;
tuple2.a = 30;
Console.WriteLine(tuple1.x);

13.2.2 ref return

ref return 的使用有如下限制:

  • 返回 类型 前需要添加 ref 关键字
  • 返回 语句 前需要添加 ref 关键字
  • 调用方需要声明一个 ref 局部 变量接收返回值

下面是一个简单用例:

int x = 10;
ref int y = ref RefReturn(ref x);
y++;
Console.WriteLine(x);

static ref int RefReturn(ref int p)
{
    return ref p;
}

从实现层面分析我们可知,ref return 不能返回在 内存上创建的位置( 内存弹出后该位置就不在有效了)。因此使用 ref return 有诸多限制,下面列出了可用、不可用 ref return 的情况:

可以 不可以
ref 或 out 参数 方法内部声明的局部变量
(包括值类型的参数)
引用类型的字段 在方法中声明的结构体的字段
结构体的字段
(当结构体变量是 ref 或者 out 参数时)
async 方法和迭代器块
数组元素

还有种特殊情况:类型实参。ref 不能用于类型实参,但可以用于接口和委托声明中。例如如下代码 法:

delegate ref int RefRuncInt32();

如下代码却 法:

Func<ref int>

使用技巧

  1. ref return 并非必须和 ref 局部变量搭配使用。如果只需要对返回结果执行简单操作,直接 操作 即可。

例如:

static void Main()
{
    int x = 10;
    RefReturn(ref x)++;
    Console.WriteLine(x);
}

static ref int RefReturn(ref int p)
{
    return ref p;
}
  1. 可以将 ref return 方法用作另一个方法调用的 实参

例如:

RefReturn(ref RefReturn(ref RefReturn(ref x)))++;
  1. ref return 可以用于 索引 器。

例如:

ArrayHolder holder = new ArrayHolder();
ref int x = ref holder[0];
ref int y = ref holder[0];
x = 20;
Console.WriteLine(y);

class ArrayHolder
{
    private readonly int[] array = new int[10];
    public ref int this[int index] => ref array[index];
}

13.2.3 条件运算符 ?:​ 和 ref 值

自 C#7.2 开始,条件运算符支持 ref。条件运算符使用 ref 要求:

  • 条件 需要使用 ref 修饰
  • 两个操作数 需要使用 ref 修饰
  • 接收结果的变量 需要使用 ref 修饰

下面是一个示例,它用于计算序列中奇数和偶数的个数:

static (int even, int odd) CountEvenAndOdd(IEnumerable<int> values)
{
    var result = (even: 0, odd: 0);
    foreach (var value in values)
    {
        ref int counter = ref (value & 1) == 0 ?
            ref result.even : ref result.odd;
        counter++;
    }
    return result;
}

13.2.4 ref readonly

前面提到的 ref 用法都要求原成员“可写”。有时我们只想进行只读访问,使用 ref 是为了避免不必要的拷贝。为此 C#7.2 引入了 ref readonly。

ref readonly 可以用于:

  • 返回值

    • 方法返回值
    • 索引器
  • 局部变量

获得的 ref readonly 数据只能是只 的,就像只读字段一样。如果它是 结构体 类型,则不能修改任何字段或者调用属性的 setter 方法。

接收 ref readonly 数据的变量需要使用 ref readonly 修饰。下面是一个简单的示例:

static readonly int field = DateTime.UtcNow.Second;
static ref readonly int GetFieldAlias() => ref field;

static void Main()
{
    ref readonly int local = ref GetFieldAlias();
    Console.WriteLine(local);
}

13.3 in​ 参数

修饰符 in​ 自 C#7.2 引入,使用方式与 ref、out 相似。被修饰的变量通过引用传递避免 复制 ,同时限制该变量不可被 修改 (行为与 ref readonly 相似)。

与 ref、out 不同,调用方为实参添加 in​ 修饰符 不是 必须的。对此有 4 种情况:

使用 in​ 修饰符 不使用 in​ 修饰符
实参可以按引用传递 法,会隐式创建 局部变量
实参无法按引用传递 法,会隐式创建 局部变量

如下代码演示了这 4 种情况:

DateTime start = DateTime.UtcNow;
PrintDateTime(start);
PrintDateTime(in start);
PrintDateTime(start.AddMinutes(1));
PrintDateTime(in start.AddMinutes(1));  // 编译错误

static void PrintDateTime(in DateTime value)
{
    string text = value.ToString(
    "yyyy-MM-dd'T'HH:mm:ss",
    CultureInfo.InvariantCulture);
    Console.WriteLine(text);
}

13.3.1 兼容性考量

in​ 修饰符的如下两个特点,使其形成了 源代码 兼容、 二进制 不兼容的情形:

  • 调用时可选
  • 生成的 IL 代码中,形参等同于用 IsReadOnlyAttribute​ 特性修饰的 ref 参数

上述特点导致了另一个奇怪的情形:将带有 ref 参数的方法改为 in​ 修饰符,它是 二进制 兼容的,却 源代码 不兼容。

13.3.2 in​ 参数惊人的不可变性:外部修改

in​ 修饰符和 readonly​ 修饰符很相似,它的存在只是告知编译器检查参数是否被尝试修改,它无法 100% 防止参数在当前方法中被修改。以如下代码为例,因涉及到闭包,它将输出 1011

int x = 10;
InParameter(x, () => x++);

static void InParameter(in int p, Action action)
{
    Console.WriteLine($"p = {p}");
    action();
    Console.WriteLine($"p = {p}");
}

Info

关于 readonly​ 值可以被修改,见二者的本质

13.3.3 使用 in​ 参数进行方法重载

在 IL 代码中,in​ 参数与 ref 参数无异。对于 CLR 也是一样,它只知道这是一个 ref 参数。而 ref 可以作为方法重载的依据。以如下两段代码为例,第一段代码 法,第二段 法:

void Method(int x) { ... }
void Method(in int x) { ... }
void Method(in int x) { }
void Method(ref int x) { }

重载决议时,根据是否带有 in​ 参数决定调用的方法。以如下代码为例,第 4 行代码的调用对应了 in​ 参数方法:

int x = 5;
Method(5);
Method(x);
Method(in x);

Suggest

  • DON'T:禁止 通过 ref、out 和 in 修饰符对成员进行重载。

    public class SomeType {
        public void SomeMethod(string name) { ... }
        // 编译不会报错
        public void SomeMethod(out string name) { ... }
    }
    

13.3.4 in​ 参数的使用指导

in​ 参数的使用有如下建议:

  • 只有确定性能提升可观时,才使用 in​ 参数;

    例如使用 大型结构体 时。

  • 公共 API 中尽量避免使用 in​ 参数,除非即便参数值发生变化,方法也能正确执行。

  • 可以考虑通过 公共 方法作为防止参数被修改的外部屏障,然后在 内部(internal) / 私有(private) 方法中使用 in​ 参数来减少复制。

    下面是一个简单的示例:

    public static double PublicMethod(          // 使用值参数的
        LargeStruct first, LargeStruct second)  // 公共方法
    {
        double firstResult = PrivateMethod(in first);
        double secondResult = PrivateMethod(in second);
        return firstResult + secondResult;
    }
    
    // 使用 in 参数的私有方法 1
    private static double PrivateMethod(in LargeStruct input)
    {
        double scale = GetScale(in input);
        return (input.X + input.Y + input.Z) * scale;
    }
    
    // 使用 in 参数的私有方法 2
    private static double GetScale(in LargeStruct input) =>
        input.Weight * input.Score;
    
  • 对于采用 in​ 参数的方法,在调用时考虑 式给出 in​ 修饰符

    除非有意利用编译器来通过引用传递隐藏的局部变量

13.4 将结构体声明为只读

13.4.1 背景:只读变量的隐式复制

C# 对结构体进行隐式复制的特点人尽皆知,但是它也有不少陷阱。以如下代码为例,ImplicitFieldCopy​ 类声明了两个结构体字段,其中一个被 readonly​ 修饰:

public struct YearMonthDay
{
    public int Year { get; }
    public int Month { get; }
    public int Day { get; }

    public YearMonthDay(int year, int month, int day) =>
        (Year, Month, Day) = (year, month, day);
}

class ImplicitFieldCopy
{
    private readonly YearMonthDay readOnlyField = new YearMonthDay(2018, 3, 1);
    private YearMonthDay readWriteField = new YearMonthDay(2018, 3, 1);

    public void CheckYear()
    {
        int readOnlyFieldYear = readOnlyField.Year;
        int readWriteFieldYear = readWriteField.Year;
    }
}

在访问这两个字段的属性时,它们的 IL 代码并不相同!其中对 readOnlyFieldYear ​ 的访问发生了隐式复制,性能有所降低!二者对应的 IL 代码如下:

// ldfld 指令将值加载到栈内存
ldfld valuetype YearMonthDay ImplicitFieldCopy::readOnlyField
stloc.0
ldloca.s V_0
call instance int32 YearMonthDay::get_Year()
// ldfld 指令将字段地址加载到栈内存
ldflda valuetype YearMonthDay ImplicitFieldCopy::readWriteField
call instance int32 YearMonthDay::get_Year()

编译器之所以复制字段,是为了防止只读字段在属性(或者方法)中被修改。

Tips

虽然 YearMonthDay​ 的属性都是只读的,但是 getter 也有可能修改属性值,因此编译器还是进行了复制。

类似的, in ​ 参数、 ref readonly ​ 局部变量也有同样的问题:

private void PrintYearMonthDay(in YearMonthDay input) =>
    Console.WriteLine($"{input.Year} {input.Month} {input.Day}");
ldarg.1
// ldobj 指令把值复制到栈内存
ldobj Chapter13.YearMonthDay
stloc.0
ldloca.s V_0
call instance int32 YearMonthDay::get_Year()

为了避免此问题,C#7.2 支持了只读结构体

13.4.2 结构体的只读修饰符

只有当目标结构体本身是 只读 的时,才能为其添加 readonly​ 修饰符,因此必须满足以下条件:

  • 每个实例字段和自动实现的实例属性必须是 只读 的。

    静态字段和属性可以不做要求。

  • 只能在 构造器 中为 this 赋值。

    用语言规范中的术语来说:this 在构造器中按照 out 参数来处理,在普通结构体成员中按照 ref 参数来处理,在只读结构体成员中按照 in 参数来处理。

添加 readonly​ 修饰符后就可以让编译器帮忙检查是否存在修改结构体的代码。

下面是只读结构体的一个示例:

public readonly struct YearMonthDay
{
    public int Year { get; }
    public int Month { get; }
    public int Day { get; }

    public YearMonthDay(int year, int month, int day) =>
        (Year, Month, Day) = (year, month, day);
}

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

13.5.1 在扩展方法中使用 ref​/in​ 参数来规避复制

当我们为结构体添加扩展方法时也会发生隐式复制。为了改善扩展方法的性能,C#7.2 做出了如下改进:

  • 编写扩展方法时,第一个参数前可以添加 ref ​ 或者 in ​ 修饰符。

    修饰符位于 this 前后皆可。

使用哪个修饰符的依据如下:

  • 使用 ref ​:需要修改原始内存位置上的值,又不想创建并复制一个新值
  • 使用 in ​:只需要计算出一个新值

下面是一个扩展方法的简单示例:

var vector = new Vector3D(1.5, 2.0, 3.0);
var offset = new Vector3D(5.0, 2.5, -1.0);
vector.OffsetBy(offset);
Console.WriteLine($"({vector.X}, {vector.Y}, {vector.Z})");
Console.WriteLine(vector.Magnitude());

public readonly struct Vector3D
{
    public double X { get; }
    public double Y { get; }
    public double Z { get; }

    public Vector3D(double x, double y, double z)
    {
        X = x;
        Y = y;
        Z = z;
    }
}

static class Extension
{
    public static double Magnitude(this in Vector3D vec) =>
        Math.Sqrt(vec.X * vec.X + vec.Y * vec.Y + vec.Z * vec.Z);

    public static void OffsetBy(this ref Vector3D orig, in Vector3D off) =>
        orig = new Vector3D(orig.X + off.X, orig.Y + off.Y, orig.Z + off.Z);
}

其中扩展方法 OffsetBy()​ 对 orig​ 的引用进行了修改。从调用者的角度看它修改了只读结构体实例的成员值,实际上它返回了 一个新的结构体实例

此外,带有 in ​ 参数的扩展方法,可以在只读变量(ref readonly​ 修饰)上调用,而带有 ref ​ 参数的扩展方法无法在只读变量上调用。以如下代码为例,第 3 行代码非法。

ref readonly var alias = ref vector;
_ = alias.Magnitude();
alias.OffsetBy(offset);

13.5.2 ref​ 和 in​ 扩展方法的使用限制

ref​ 和 in​ 扩展方法只能扩展值类型,主要有两个原因:

  • 该特性是用于避免 复制值 所导致的性能消耗的,而引用类型不存在这样的性能消耗。

  • 如果 ref​ 参数是引用类型,那么它可能是 null 引用。

    这样会违背了目前 C# 开发人员和工具的一条假定:x.Method()​(x​ 如果是一个引用类型变量)的调用中,x​ 不能为 null。

    Question

    这条准则我没搞懂

in ​ 扩展方法中,对应的值类型也不能是类型形参(未来或许会解除这一限制)。下面分别列举了合法声明和非法声明:

static void Method(this ref int target)
static void Method<T>(this ref T target) where T : struct
static void Method<T>(this ref T target) where T : struct, IComparable<T>
static void Method<T>(this ref int target, T other)
static void Method(this in int target)
static void Method(this in Guid target)
static void Method<T>(this in Guid target, T other)
static void Method(this ref string target)
static void Method<T>(this ref T target) where T : IComparable<T>
static void Method<T>(this in string target)
static void Method<T>(this in T target) where T : struct

13.6 类 ref 结构体

C#7.2 引入了类 ref 结构体的概念,该特性的主要目标是:类 ref 结构体的值必须永远保存在 内存中。

它的语法并不复杂,其声明方式只比声明普通结构体多了一个 ref ​ 修饰符:

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

13.6.1 类 ref 结构体的规则

类 ref 结构体(以下称为 RefLikeStruct)有如下规则:

  • RefLikeStruct 只能作为 RefLikeStruct 的实例字段;

    普通结构体可以通过装箱、成为类字段的方式最终存储在 内存中,RefLikeStruct 不接受该操作

  • RefLikeStruct 不能 执行装箱;

  • RefLikeStruct 不能 作为类型实参;

    泛型代码可以以多种方式将泛型实参存放于堆内存中,例如创建 List<T>

  • 不可以创建 RefLikeStruct[]

  • RefLikeStruct 类型的局部变量 不能 作为被捕获的变量

此外,关于类 ref​ 类型的 ref​ 局部变量,还有很多复杂的使用规则,建议遵从编译器的指示。

13.6.2 Span<T>​ 和栈内存分配

在 .NET 中,为了访问一块区域的内存,如下签名形式的方法比比皆是。这其实是不良代码的迹象,昭示着这里缺少合理的抽象。Span<T>​ 为解决此问题应运而生。

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

Span<T>​ 是类 ref 结构体,它有如下特点:

  • 具有读写属性;

  • 可以像数组那样通过 索引 访问内存,但不拥有这块内存;

    它总是从别处创建而来,可能是指针、数组甚至是从栈内存直接创建。

  • 使用时无须关注 所分配内存的位置

  • 可以进行 切分

    可以在无须复制的情况下,切分出一块 span 作为另一个 span 的子分区。

Span<T>​ 作为类 ref 结构体有如下优势:

  • span 可以指向一个 生命周期 轻度受限的内存,因为 span 不可能离开栈内存。
  • span 中的数据可以实现自定义一次性初始化,不需要任何复制(直接操作 内存 ),也不存在之后数据被其他代码篡改的风险( 生命周期 有限)。

我们以如下代码为例:

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

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);
}

这段代码需要两块堆内存的分配(char​ 数组和 string​ 的创建),一次数据复制(创建字符串时 char​ 数组的值传递给 string​)

改为使用 Span<T>​,代码将变为如下形式。借助 Span<T>​ 开发者可以直接操作 string​ 内部的内存,省去多余的 内存 分配和数据 复制

static string Generate(string alphabet, Random random, int length)
{
    return 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)];
        }
    });
}

Info

上述例子原书花了很大功夫讲解,这里作为笔记仅记录优化前后的代码。

13.6.2.1 在初始化器中使用 stackalloc​(C#7.3)

C#7.3 为栈内存分配新增了初始化器,它显著增强了代码的可读性。该初始化器可以用于指针和 Span<T>​,以下代码合法:

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

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

fixed 语句用于获取指向某块内存的指针,可以暂时阻止垃圾回收器回收这部分数据(参见25.6 将结构体映射到非托管内存中)。

在 C#7.3 之前,它只能用于数组、字符串以及获取变量的地址。C#7.3 则将其扩展到了所有类型,只要该类型具有一个名为 GetPinnableReference()​ 的方法,且它的返回值是非托管类型的引用。

以如下代码为例,value​ 的 GetPinnableReference()​ 方法需要返回 ref int ​ 类型的值:

fixed (int* ptr = value)
{
    // 使用指针的代码
}

13.6.3 类 ref 结构体的 IL 表示

类 ref 结构体会由 IsRefLikeAttribute​、ObsoleteAttribute​ 修饰。对于能够识别 IsRefLikeAttribute​ 的编译器,会自动忽略 ObsoleteAttribute​。

若开发者手动为自定义的“类 ref 结构体”添加了 ObsoleteAttribute​ 特性,编译器将忽略上述自动添加的 ObsoleteAttribute​ 特性。

posted @ 2025-04-16 23:35  hihaojie  阅读(39)  评论(0)    收藏  举报