csharpunity

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

2023-07-09  本文已影响0人  雨落随风

前言

今天跟大家分享一个我最近发现的 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时要小心谨慎,遵循语法规则,否则可能会出现一些意想不到的错误或者异常。不过也无需过分担心,得益于 IDE 的智能提示,这些都会在开发过程中被指导和修正

最后,感谢 NewBing chat 全程参与到本文的撰写中来!

五、扩展阅读

上一篇 下一篇

猜你喜欢

热点阅读