[csharp] ref 的鬼畜用法:与return配合的ref
前言
今天跟大家分享一个我最近发现的 ref 一个非常鬼畜用法:ref return。这个新的语法糖可以让我们直接返回结构体的引用(我的理解是这样),而不是结构体的副本,从而实现了结构体跟引用类型近似的使用方式,保持了用户惯性和开发直觉。听起来很神奇吧?那就跟我一起来看看吧!
一、常识中的 ref
我们都知道,C#中有两种类型:值类型和引用类型。值类型包括基本类型(int, float, bool等)和结构体(struct),引用类型包括类(class)、数组(array)和字符串(string)。值类型和引用类型的区别在于,值类型在内存中存储的是数据本身,而引用类型在内存中存储的是数据的地址。因此,当我们把值类型作为参数传递给一个方法时,实际上是把数据本身复制了一份给方法,这就叫做值传递(pass by value)。而当我们把引用类型作为参数传递给一个方法时,实际上是把数据的地址复制了一份给方法,这就叫做引用传递(pass by reference)。
值传递和引用传递有什么区别呢?区别就在于,如果我们在方法内部修改了参数的值,那么对于值传递来说,只会影响方法内部的局部变量,不会影响方法外部的原始变量;而对于引用传递来说,会影响方法内外的同一个变量。举个例子:
using System;
class Program
{
static void Main(string[] args)
{
int a = 10; // 值类型
string b = "Hello"; // 引用类型
Console.WriteLine($"Before: a = {a}, b = {b}");
Change(a, b);
Console.WriteLine($"After: a = {a}, b = {b}");
}
static void Change(int x, string y)
{
x = 20;
y = "World";
Console.WriteLine($"Inside: x = {x}, y = {y}");
}
}
输出结果是:
Before: a = 10, b = Hello
Inside: x = 20, y = World
After: a = 10, b = Hello
可以看到,在Change方法内部,我们修改了x和y的值,但是在Change方法外部,a和b的值并没有改变。这是因为x和y只是a和b的副本,修改它们并不会影响a和b。
那么有没有办法让我们在方法内部修改值类型参数的值,并且让这个修改反映到方法外部呢?答案是有的,那就是使用ref关键字。ref关键字可以让我们把值类型参数作为引用传递给方法,也就是说,不再复制数据本身,而是复制数据的地址。这样一来,在方法内部修改参数的值,就相当于修改了原始变量的值。例如:
using System;
class Program
{
static void Main(string[] args)
{
int a = 10; // 值类型
Console.WriteLine($"Before: a = {a}");
Change(ref a);
Console.WriteLine($"After: a = {a}");
}
static void Change(ref int x)
{
x = 20;
Console.WriteLine($"Inside: x = {x}");
}
}
输出结果是:
Before: a = 10
Inside: x = 20
After: a = 20
可以看到,在Change方法内部,我们修改了x的值,同时也修改了a的值。这是因为x和a共享了同一个地址,修改其中一个就相当于修改了另一个。
这就是ref参数的常规用法,它可以让我们在方法内部修改值类型参数的值,并且让这个修改反映到方法外部。避免了不必要的数据复制,提高性能和内存效率。特别是当我们处理一些大型的结构体时,使用ref参数可以节省很多开销。
二、ref +return 鬼畜用法
那么,我们刚才说过,ref关键字可以让我们把值类型参数作为引用传递给方法,那么反过来,能不能把值类型作为引用返回给方法呢?答案是可以的,那就是使用 ref return
。ref return
可以让我们直接返回结构体的引用,而不是结构体的副本。
这样一来,我们就可以在方法外部修改结构体的值,并且让这个修改反映到方法内部。听起来很鬼畜吧?那就让我来给大家演示一下吧!
首先,我们定义一个简单的结构体:
struct Point
{
public int x;
public int y;
public Point(int x, int y)
{
this.x = x;
this.y = y;
}
public override string ToString()
{
return $"({x}, {y})";
}
}
然后,我们定义一个方法,它接受一个Point数组作为参数,并且返回数组中第一个元素的引用:
static ref Point GetFirst(Point[] points)
{
return ref points[0];
}
注意,这里我们在返回类型和返回语句前面都加了ref关键字,表示我们要返回Point结构体的引用,而不是副本。
接下来,我们在Main方法中创建一个Point数组,并且调用GetFirst方法:
static void Main(string[] args)
{
Point[] points = new Point[3];
points[0] = new Point(1, 2);
points[1] = new Point(3, 4);
points[2] = new Point(5, 6);
Console.WriteLine($"Before: points[0] = {points[0]}");
ref var p = ref GetFirst(points);
Console.WriteLine($"After: points[0] = {points[0]}");
}
输出结果是:
Before: points[0] = (1, 2)
After: points[0] = (1, 2)
可以看到,在调用GetFirst方法之后,并没有改变points[0]的值。这是因为我们只是把points[0]的引用赋值给了p,并没有修改p的值。
那么如果我们现在修改p的值呢?例如:
static void Main(string[] args)
{
Point[] points = new Point[3];
points[0] = new Point(1, 2);
points[1] = new Point(3, 4);
points[2] = new Point(5, 6);
Console.WriteLine($"Before: points[0] = {points[0]}");
ref var p = ref GetFirst(points);
p.x = 10;
p.y = 20;
Console.WriteLine($"After: points[0] = {points[0]}");
}
输出结果是:
Before: points[0] = (1, 2)
After: points[0] = (10, 20)
可以看到,在修改p的值之后,points[0]的值也跟着改变了。这是因为p和points[0]共享了同一个地址,修改其中一个就相当于修改了另一个。
这就是 ref return
的鬼畜用法,它可以让我们直接返回结构体的引用,而不是结构体的副本。这样做有什么好处呢?好处就是,我们可以避免不必要的数据复制,提高性能和内存效率。特别是当我们处理一些大型的结构体时,使用 ref return
可以节省很多开销。
三、ref return在Unity中的应用
那么,ref return这么鬼畜的特性,在实际开发中有什么应用场景呢?答案是有的,而且还很多。今天我就跟大家分享一个我最近在Unity中遇到的一个例子,它就是使用了ref return来优化结构体的操作。
最近发布一个开源的库:Loom 就用到了 ref return 的新语法糖,看起来就是为了 ref 而 ref。
为了让Loom能够正常工作,我需要把它的Update方法插入到Unity的PlayerLoop中。具体来说,我需要把它插入到UnityEngine.PlayerLoop.Update这个PlayerLoopSystem之后。这样一来,Loom就可以在每帧更新之后执行异步任务,并且在下一帧更新之前回调主线程。
那么问题来了,怎么把Loom的Update方法插入到UnityEngine.PlayerLoop.Update之后呢?最直观的想法就是遍历当前的PlayerLoop中所有的PlayerLoopSystem,找到UnityEngine.PlayerLoop.Update对应的索引(index),然后把Loom对应的PlayerLoopSystem插入到索引之后。例如:
using System;
using UnityEngine;
using UnityEngine.LowLevel;
public class Loom
{
// 省略了Loom类中其他代码,只保留符合本文中心思想的逻辑哈
private void Install()
{
// 获取当前的 Player Loop
var playerloop = PlayerLoop.GetCurrentPlayerLoop();
// 创建一个新的 Player Loop System
var loop = new PlayerLoopSystem
{
type = typeof(Loom),
updateDelegate = Update
};
// 1. 找到 Update Loop System 的索引
int index = Array.FindIndex(playerloop.subSystemList, v => v.type == typeof(UnityEngine.PlayerLoop.Update));
// 2. 将咱们的 loop 插入到 Update loop 中
var updateloop = playerloop.subSystemList[index];
var temp = updateloop.subSystemList.ToList();
temp.Add(loop);
updateloop.subSystemList = temp.ToArray();
playerloop.subSystemList[index] = updateloop;
// 3. 设置自定义的 Loop 到 Unity 引擎
PlayerLoop.SetPlayerLoop(playerloop);
}
}
这段代码看起来很简单,但是有一个问题,就是它涉及了很多的结构体的复制。为什么呢?因为
PlayerLoop
和 PlayerLoopSystem
都是结构体,而且它们都是值传递的。所以当我们从 playerloop.subSystemList 中取出 updateloop 时,实际上是取出了它的副本;当我们把updateloop.subSystemList赋值给 temp 时,实际上是把它的副本赋值给了 temp;当我们把 temp.ToArray() 赋值给 updateloop.subSystemList 时,实际上是把它的副本赋值给了 updateloop.subSystemList;当我们把 updateloop 赋值给 playerloop.subSystemList[index] 时,实际上是把它的副本赋值给了 playerloop.subSystemList[index]。这样一来,我们就做了很多不必要的数据复制,浪费了性能和内存。
那么有没有办法避免这些数据复制呢?答案是有的,那就是使用ref return。我们可以定义一个方法,它接受一个 PlayerLoopSystem
和一个委托作为参数,并且返回 PlayerLoopSystem.subSystemList
中符合委托条件的 PlayerLoopSystem
的引用:
static ref PlayerLoopSystem FindSubSystem(PlayerLoopSystem root, Predicate<PlayerLoopSystem> predicate)
{
for (int j = 0; j < root.subSystemList.Length; j++)
{
if (predicate(root.subSystemList[j]))
{
// 可以关注 ref 配合 return 的用法,这样可以直接修改 sub 的值
return ref root.subSystemList[j];
}
}
throw new Exception("Not Found!");
}
然后,我们就可以使用这个方法来找到 UnityEngine.PlayerLoop.Update
对应的 PlayerLoopSystem
,并且直接修改它的subSystemList
属性,把 Loom 对应的 PlayerLoopSystem
插入到最后:
var rootLoopSystem = PlayerLoop.GetCurrentPlayerLoop();
ref var sub_pls = ref FindSubSystem(rootLoopSystem, v => v.type == typeof(UnityEngine.PlayerLoop.Update));
Array.Resize(ref sub_pls.subSystemList, sub_pls.subSystemList.Length + 1);
sub_pls.subSystemList[^1] = new PlayerLoopSystem { type = typeof(Loom), updateDelegate = Update };
PlayerLoop.SetPlayerLoop(rootLoopSystem);
这段代码看起来更简洁了,而且也避免了很多的数据复制。这是因为我们使用了ref return来直接返回 PlayerLoopSystem
的引用,而不是副本。这样一来,我们就可以在方法外部修改 PlayerLoopSystem
的值,并且让这个修改反映到方法内部。这就实现了结构体跟引用类型近似的使用方式,保持了用户惯性和开发直觉。
四、总结
今天跟大家分享了一个C#语言中的一个非常鬼畜的特性:ref return
。这个特性可以让我们直接返回结构体的引用,而不是结构体的副本。 避免不必要的数据复制,提高性能和内存效率。特别是当我们处理一些大型的结构体时,使用ref return可以节省很多开销。
同时给大家演示了一个在Unity中使用ref return来优化PlayerLoop的操作的例子,希望对大家有所启发。
当然,ref return
也不是万能的,它也有一些限制和注意事项。例如:
- 我们不能返回一个局部变量的引用,因为它会在方法结束后被销毁;
- 我们不能返回一个常量或者字面量的引用,因为它们没有地址;
- 我们不能返回一个表达式或者属性的引用,因为它们不是变量;
- 我们不能把一个ref return赋值给一个普通的变量,因为它会导致数据复制;
- 我们不能把一个ref return作为另一个方法的参数,除非另一个方法也接受ref参数;
- 我们不能把一个ref return作为另一个方法的返回值,除非另一个方法也返回ref值。
总之,使用ref return时要小心谨慎,遵循语法规则,否则可能会出现一些意想不到的错误或者异常。不过也无需过分担心,得益于 IDE 的智能提示,这些都会在开发过程中被指导和修正
最后,感谢 NewBing chat 全程参与到本文的撰写中来!
五、扩展阅读
- https://learn.microsoft.com/zh-cn/dotnet/csharp/language-reference/keywords/ref#ref-returns
- https://docs.microsoft.com/zh-cn/dotnet/csharp/programming-guide/classes-and-structs/ref-returns
- https://docs.unity3d.com/ScriptReference/LowLevel.PlayerLoop.html
- https://github.com/Bian-Sh/Loom
- PlayerLoopSystemSubscription.cs - 在这里第一次遇见 ref return