前言
今天跟大家分享一個(gè)我最近發(fā)現(xiàn)的 ref 一個(gè)非常鬼畜用法:ref return。這個(gè)新的語法糖可以讓我們直接返回結(jié)構(gòu)體的引用(我的理解是這樣),而不是結(jié)構(gòu)體的副本,從而實(shí)現(xiàn)了結(jié)構(gòu)體跟引用類型近似的使用方式,保持了用戶慣性和開發(fā)直覺。聽起來很神奇吧?那就跟我一起來看看吧!
一、常識中的 ref
我們都知道,C#中有兩種類型:值類型和引用類型。值類型包括基本類型(int, float, bool等)和結(jié)構(gòu)體(struct),引用類型包括類(class)、數(shù)組(array)和字符串(string)。值類型和引用類型的區(qū)別在于,值類型在內(nèi)存中存儲的是數(shù)據(jù)本身,而引用類型在內(nèi)存中存儲的是數(shù)據(jù)的地址。因此,當(dāng)我們把值類型作為參數(shù)傳遞給一個(gè)方法時(shí),實(shí)際上是把數(shù)據(jù)本身復(fù)制了一份給方法,這就叫做值傳遞(pass by value)。而當(dāng)我們把引用類型作為參數(shù)傳遞給一個(gè)方法時(shí),實(shí)際上是把數(shù)據(jù)的地址復(fù)制了一份給方法,這就叫做引用傳遞(pass by reference)。
值傳遞和引用傳遞有什么區(qū)別呢?區(qū)別就在于,如果我們在方法內(nèi)部修改了參數(shù)的值,那么對于值傳遞來說,只會(huì)影響方法內(nèi)部的局部變量,不會(huì)影響方法外部的原始變量;而對于引用傳遞來說,會(huì)影響方法內(nèi)外的同一個(gè)變量。舉個(gè)例子:
using System;
class Program
{
static void Main(string[] args)
{
int a = 10; // 值類型
string b = "Hello"; // 引用類型
Console.WriteLine($"Before: a = {a}, b = ");
Change(a, b);
Console.WriteLine($"After: a = {a}, b = ");
}
static void Change(int x, string y)
{
x = 20;
y = "World";
Console.WriteLine($"Inside: x = {x}, y = {y}");
}
}
輸出結(jié)果是:
Before: a = 10, b = Hello
Inside: x = 20, y = World
After: a = 10, b = Hello
可以看到,在Change方法內(nèi)部,我們修改了x和y的值,但是在Change方法外部,a和b的值并沒有改變。這是因?yàn)閤和y只是a和b的副本,修改它們并不會(huì)影響a和b。
那么有沒有辦法讓我們在方法內(nèi)部修改值類型參數(shù)的值,并且讓這個(gè)修改反映到方法外部呢?答案是有的,那就是使用ref關(guān)鍵字。ref關(guān)鍵字可以讓我們把值類型參數(shù)作為引用傳遞給方法,也就是說,不再復(fù)制數(shù)據(jù)本身,而是復(fù)制數(shù)據(jù)的地址。這樣一來,在方法內(nèi)部修改參數(shù)的值,就相當(dāng)于修改了原始變量的值。例如:
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}");
}
}
輸出結(jié)果是:
Before: a = 10
Inside: x = 20
After: a = 20
可以看到,在Change方法內(nèi)部,我們修改了x的值,同時(shí)也修改了a的值。這是因?yàn)閤和a共享了同一個(gè)地址,修改其中一個(gè)就相當(dāng)于修改了另一個(gè)。
這就是ref參數(shù)的常規(guī)用法,它可以讓我們在方法內(nèi)部修改值類型參數(shù)的值,并且讓這個(gè)修改反映到方法外部。避免了不必要的數(shù)據(jù)復(fù)制,提高性能和內(nèi)存效率。特別是當(dāng)我們處理一些大型的結(jié)構(gòu)體時(shí),使用ref參數(shù)可以節(jié)省很多開銷。
二、ref +return 鬼畜用法
那么,我們剛才說過,ref關(guān)鍵字可以讓我們把值類型參數(shù)作為引用傳遞給方法,那么反過來,能不能把值類型作為引用返回給方法呢?答案是可以的,那就是使用 ref return。ref return 可以讓我們直接返回結(jié)構(gòu)體的引用,而不是結(jié)構(gòu)體的副本。
這樣一來,我們就可以在方法外部修改結(jié)構(gòu)體的值,并且讓這個(gè)修改反映到方法內(nèi)部。聽起來很鬼畜吧?那就讓我來給大家演示一下吧!
首先,我們定義一個(gè)簡單的結(jié)構(gòu)體:
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})";
}
}
然后,我們定義一個(gè)方法,它接受一個(gè)Point數(shù)組作為參數(shù),并且返回?cái)?shù)組中第一個(gè)元素的引用:
static ref Point GetFirst(Point[] points)
{
return ref points[0];
}
注意,這里我們在返回類型和返回語句前面都加了ref關(guān)鍵字,表示我們要返回Point結(jié)構(gòu)體的引用,而不是副本。
接下來,我們在Main方法中創(chuàng)建一個(gè)Point數(shù)組,并且調(diào)用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]}");
}
輸出結(jié)果是:
Before: points[0] = (1, 2)
After: points[0] = (1, 2)
可以看到,在調(diào)用GetFirst方法之后,并沒有改變points[0]的值。這是因?yàn)槲覀冎皇前裵oints[0]的引用賦值給了p,并沒有修改p的值。
那么如果我們現(xiàn)在修改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]}");
}
輸出結(jié)果是:
Before: points[0] = (1, 2)
After: points[0] = (10, 20)
可以看到,在修改p的值之后,points[0]的值也跟著改變了。這是因?yàn)閜和points[0]共享了同一個(gè)地址,修改其中一個(gè)就相當(dāng)于修改了另一個(gè)。
這就是 ref return 的鬼畜用法,它可以讓我們直接返回結(jié)構(gòu)體的引用,而不是結(jié)構(gòu)體的副本。這樣做有什么好處呢?好處就是,我們可以避免不必要的數(shù)據(jù)復(fù)制,提高性能和內(nèi)存效率。特別是當(dāng)我們處理一些大型的結(jié)構(gòu)體時(shí),使用 ref return 可以節(jié)省很多開銷。
三、ref return在Unity中的應(yīng)用
那么,ref return這么鬼畜的特性,在實(shí)際開發(fā)中有什么應(yīng)用場景呢?答案是有的,而且還很多。今天我就跟大家分享一個(gè)我最近在Unity中遇到的一個(gè)例子,它就是使用了ref return來優(yōu)化結(jié)構(gòu)體的操作。
最近發(fā)布一個(gè)開源的庫:Loom 就用到了 ref return 的新語法糖,看起來就是為了 ref 而 ref。
為了讓Loom能夠正常工作,我需要把它的Update方法插入到Unity的PlayerLoop中。具體來說,我需要把它插入到UnityEngine.PlayerLoop.Update這個(gè)PlayerLoopSystem之后。這樣一來,Loom就可以在每幀更新之后執(zhí)行異步任務(wù),并且在下一幀更新之前回調(diào)主線程。
那么問題來了,怎么把Loom的Update方法插入到UnityEngine.PlayerLoop.Update之后呢?最直觀的想法就是遍歷當(dāng)前的PlayerLoop中所有的PlayerLoopSystem,找到UnityEngine.PlayerLoop.Update對應(yīng)的索引(index),然后把Loom對應(yīng)的PlayerLoopSystem插入到索引之后。例如:
using System;
using UnityEngine;
using UnityEngine.LowLevel;
public class Loom
{
// 省略了Loom類中其他代碼,只保留符合本文中心思想的邏輯哈
private void Install()
{
// 獲取當(dāng)前的 Player Loop
var playerloop = PlayerLoop.GetCurrentPlayerLoop();
// 創(chuàng)建一個(gè)新的 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. 設(shè)置自定義的 Loop 到 Unity 引擎
PlayerLoop.SetPlayerLoop(playerloop);
}
}
這段代碼看起來很簡單,但是有一個(gè)問題,就是它涉及了很多的結(jié)構(gòu)體的復(fù)制。為什么呢?因?yàn)?br>
PlayerLoop 和 PlayerLoopSystem 都是結(jié)構(gòu)體,而且它們都是值傳遞的。所以當(dāng)我們從 playerloop.subSystemList 中取出 updateloop 時(shí),實(shí)際上是取出了它的副本;當(dāng)我們把updateloop.subSystemList賦值給 temp 時(shí),實(shí)際上是把它的副本賦值給了 temp;當(dāng)我們把 temp.ToArray() 賦值給 updateloop.subSystemList 時(shí),實(shí)際上是把它的副本賦值給了 updateloop.subSystemList;當(dāng)我們把 updateloop 賦值給 playerloop.subSystemList[index] 時(shí),實(shí)際上是把它的副本賦值給了 playerloop.subSystemList[index]。這樣一來,我們就做了很多不必要的數(shù)據(jù)復(fù)制,浪費(fèi)了性能和內(nèi)存。
那么有沒有辦法避免這些數(shù)據(jù)復(fù)制呢?答案是有的,那就是使用ref return。我們可以定義一個(gè)方法,它接受一個(gè) PlayerLoopSystem 和一個(gè)委托作為參數(shù),并且返回 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]))
{
// 可以關(guān)注 ref 配合 return 的用法,這樣可以直接修改 sub 的值
return ref root.subSystemList[j];
}
}
throw new Exception("Not Found!");
}
然后,我們就可以使用這個(gè)方法來找到 UnityEngine.PlayerLoop.Update對應(yīng)的 PlayerLoopSystem ,并且直接修改它的subSystemList屬性,把 Loom 對應(yīng)的 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);
這段代碼看起來更簡潔了,而且也避免了很多的數(shù)據(jù)復(fù)制。這是因?yàn)槲覀兪褂昧藃ef return來直接返回 PlayerLoopSystem 的引用,而不是副本。這樣一來,我們就可以在方法外部修改 PlayerLoopSystem 的值,并且讓這個(gè)修改反映到方法內(nèi)部。這就實(shí)現(xiàn)了結(jié)構(gòu)體跟引用類型近似的使用方式,保持了用戶慣性和開發(fā)直覺。
四、總結(jié)
今天跟大家分享了一個(gè)C#語言中的一個(gè)非常鬼畜的特性:ref return。這個(gè)特性可以讓我們直接返回結(jié)構(gòu)體的引用,而不是結(jié)構(gòu)體的副本。 避免不必要的數(shù)據(jù)復(fù)制,提高性能和內(nèi)存效率。特別是當(dāng)我們處理一些大型的結(jié)構(gòu)體時(shí),使用ref return可以節(jié)省很多開銷。
同時(shí)給大家演示了一個(gè)在Unity中使用ref return來優(yōu)化PlayerLoop的操作的例子,希望對大家有所啟發(fā)。
當(dāng)然,ref return也不是萬能的,它也有一些限制和注意事項(xiàng)。例如:
- 我們不能返回一個(gè)局部變量的引用,因?yàn)樗鼤?huì)在方法結(jié)束后被銷毀;
- 我們不能返回一個(gè)常量或者字面量的引用,因?yàn)樗鼈儧]有地址;
- 我們不能返回一個(gè)表達(dá)式或者屬性的引用,因?yàn)樗鼈儾皇亲兞浚?/li>
- 我們不能把一個(gè)ref return賦值給一個(gè)普通的變量,因?yàn)樗鼤?huì)導(dǎo)致數(shù)據(jù)復(fù)制;
- 我們不能把一個(gè)ref return作為另一個(gè)方法的參數(shù),除非另一個(gè)方法也接受ref參數(shù);
- 我們不能把一個(gè)ref return作為另一個(gè)方法的返回值,除非另一個(gè)方法也返回ref值。
總之,使用ref return時(shí)要小心謹(jǐn)慎,遵循語法規(guī)則,否則可能會(huì)出現(xiàn)一些意想不到的錯(cuò)誤或者異常。不過也無需過分擔(dān)心,得益于 IDE 的智能提示,這些都會(huì)在開發(fā)過程中被指導(dǎo)和修正
最后,感謝 NewBing chat 全程參與到本文的撰寫中來!
五、擴(kuò)展閱讀
- 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