[csharp] ref 的鬼畜用法:與return配合的ref

前言

今天跟大家分享一個(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> PlayerLoopPlayerLoopSystem 都是結(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ò)展閱讀

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容