第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 初始化:只在声明时初始化一次
- ref 局部变量必须在 声明 时完成初始化,例如以下代码 非 法:
int x = 10;
ref int invalid;
invalid = ref int x;
C#7.3 解除了该限制
- 用于初始化 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>
使用技巧
- 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;
}
- 可以将 ref return 方法用作另一个方法调用的 实参 。
例如:
RefReturn(ref RefReturn(ref RefReturn(ref x)))++;
- 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% 防止参数在当前方法中被修改。以如下代码为例,因涉及到闭包,它将输出 10 、 11 :
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 特性。

浙公网安备 33010602011771号