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

这段代码看起来很简单,但是有一个问题,就是它涉及了很多的结构体的复制。为什么呢?因为
PlayerLoopPlayerLoopSystem 都是结构体,而且它们都是值传递的。所以当我们从 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 全程参与到本文的撰写中来!

五、扩展阅读

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 213,047评论 6 492
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,807评论 3 386
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 158,501评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,839评论 1 285
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,951评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,117评论 1 291
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,188评论 3 412
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,929评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,372评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,679评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,837评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,536评论 4 335
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,168评论 3 317
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,886评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,129评论 1 267
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,665评论 2 362
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,739评论 2 351

推荐阅读更多精彩内容