# 前言

最近做项目优化,在整理主界面的按钮问题的时候,用到了只读结构体,这个结构体会保存于一个列表中,并采用 in 对列表中的值进行引用传递。

然后突后面然想到,保存于列表中的值类型,真的能直接被引用吗?

毕竟 List 数据结构本身由于存在扩容问题,因此是不允许直接返回其中数据引用的。

那么我们就有理由怀疑:实际类型为 List 容器中,采用下标取出的对象作为引用传递时,它可能依然会在栈上新建一个临时变量,然后再将其作为引用传递过去。那么这里就还是存在一个额外拷贝开销了。

于是考虑了一下,并且也比较好奇,于是就想着用 ILSpy 进行反编译查看生成的 IL 代码,以确定是否真的如猜测所示。

# 列表类型

首先,源代码如下所示:

private readonly struct MainRightButtonItem
{
// 省略......
}
private void RefreshButton(int index, in MainRightButtonItem buttonData)
{
// 省略......
}
/// <summary>
/// 刷新按钮状态数据
/// </summary>
public void RefreshButtons()
{
    for (int i = 0; i < _buttonDataList.Count; i++)
    {
        RefreshButton(i, _buttonDataList[i]);
    }
}

这是一个最简单的 for循环 ,循环中通过下标取 List 并直接传递至 RefreshButton 方法中, RefreshButton 本身接收的则是一个 MainRightButtonItem 的引用地址。

编译为 IL 代码后如下所示:

IL_0000: ldc.i4.0
IL_0001: stloc.0
IL_0002: br.s IL_001e
// loop start (head: IL_001e)
    IL_0004: ldarg.0
    IL_0005: ldloc.0
    IL_0006: ldarg.0
    IL_0007: ldfld class [mscorlib]System.Collections.Generic.List`1<valuetype BottonListHolder/MainRightButtonItem> BottonListHolder::_buttonDataList
    IL_000c: ldloc.0
    IL_000d: callvirt instance !0 class [mscorlib]System.Collections.Generic.List`1<valuetype BottonListHolder/MainRightButtonItem>::get_Item(int32)
    IL_0012: stloc.1
    IL_0013: ldloca.s 1
    IL_0015: call instance void BottonListHolder::RefreshButton(int32, valuetype BottonListHolder/MainRightButtonItem&)
    IL_001a: ldloc.0
    IL_001b: ldc.i4.1
    IL_001c: add
    IL_001d: stloc.0
    IL_001e: ldloc.0
    IL_001f: ldarg.0
    IL_0020: ldfld class [mscorlib]System.Collections.Generic.List`1<valuetype BottonListHolder/MainRightButtonItem> BottonListHolder::_buttonDataList
    IL_0025: callvirt instance int32 class [mscorlib]System.Collections.Generic.List`1<valuetype BottonListHolder/MainRightButtonItem>::get_Count()
    IL_002a: blt.s IL_0004
// end loop

# 分析

loop start 之后就是循环体,在 IL_0015 处的指令可以看出来,确实是传递的一个 MainRightButtonItem 的引用,这个跟想象的一样,没什么问题。

接着就需要确认它之前有没有将值拷贝到栈上的操作:

IL_0007 开始,首先将列表加载至栈上
接着 IL_000d 调用列表的 get_Item 方法 (索引器) 并传入下标
关键就在于 Stloc_1Ldloca_S 指令:

Stloc_1:从计算堆栈顶部弹出当前值,并将其存储在索引 1 处的局部变量列表中。
Ldloca_S:将位于特定索引处的局部变量的地址加载到计算堆栈上(短格式)。

看这指令解释... 貌似还是执行了复制操作?

# 对比非引用传参

与上述唯一不同的是,调用的 RefreshButtonCopy 方法没有标记为引用传参,关键 IL 代码如下:

IL_0007: ldfld class [mscorlib]System.Collections.Generic.List`1<valuetype BottonListHolder/MainRightButtonItem> BottonListHolder::_buttonDataList
IL_000c: ldloc.0
IL_000d: callvirt instance !0 class [mscorlib]System.Collections.Generic.List`1<valuetype BottonListHolder/MainRightButtonItem>::get_Item(int32)
IL_0012: call instance void BottonListHolder::RefreshButtonCopy(int32, valuetype BottonListHolder/MainRightButtonItem)

看起来竟然还比引用传递少了两条指令!

# 数组类型

首先,数组结构的模式。

原代码:

public void Test()
{
    MainRightButtonItem[] items = new MainRightButtonItem[10];
    for (int i = 0; i < items.Length; i++)
    {
        RefreshButton(i, items[i]);
    }
}

编译为 IL 代码如下:

IL_0000: ldc.i4.s 10
IL_0002: newarr BottonListHolder/MainRightButtonItem
IL_0007: stloc.0
IL_0008: ldc.i4.0
IL_0009: stloc.1
IL_000a: br.s IL_001e
// loop start (head: IL_001e)
    IL_000c: ldarg.0
    IL_000d: ldloc.1
    IL_000e: ldloc.0
    IL_000f: ldloc.1
    IL_0010: ldelema BottonListHolder/MainRightButtonItem
    IL_0015: call instance void BottonListHolder::RefreshButton(int32, valuetype BottonListHolder/MainRightButtonItem&)
    IL_001a: ldloc.1
    IL_001b: ldc.i4.1
    IL_001c: add
    IL_001d: stloc.1
    IL_001e: ldloc.1
    IL_001f: ldloc.0
    IL_0020: ldlen
    IL_0021: conv.i4
    IL_0022: blt.s IL_000c
// end loop

对于 ldelema 指令文档解释为:

ldelema:将位于指定数组索引的数组元素的地址作为 & 类型(托管指针)加载到计算堆栈的顶部。

  1. 对象引用 array 被推送到堆栈上。
  2. 索引值 index 被推送到堆栈上。
  3. index 并从 array 堆栈中弹出;将查找存储在位置 index array 的地址。
  4. 地址被推送到堆栈上。

可知这是一个对数组特异化的引用取值指令,表明此处确定是引用传递,与预想一致。

# 性能测试

对于 List 的结构体容器引用传参及非引用传参的性能,目前就有点拿不准了,于是还是走测试流程,实际比较一下吧。

这里直接使用 Unity3DTestRunner ,代码如下:

public class RefStruckTest
{
    private List<Data> testList = new List<Data>(10000000);
    private Data[] testArray = new Data[10000000];
    [Test]
    public void TestListRef()
    {
        for (int i = 0; i < testList.Count; i++)
        {
            InvokeRef(testList[i]);
        }
    }
    [Test]
    public void TestListCopy()
    {
        for (int i = 0; i < testList.Count; i++)
        {
            InvokeCopy(testList[i]);
        }
    }
    [Test]
    public void TestArrayRef()
    {
        for (int i = 0; i < testArray.Length; i++)
        {
            InvokeRef(testArray[i]);
        }
    }
    [Test]
    public void TestArrayCopy()
    {
        for (int i = 0; i < testArray.Length; i++)
        {
            InvokeCopy(testArray[i]);
        }
    }
    private void InvokeRef(in Data data)
    {
    }
    private void InvokeCopy(Data data)
    {
    }
    private readonly struct Data
    {
        readonly long x, y, z, w, k, n;
    }
}

结果:

TestArrayCopy (0.073s)
TestArrayRef (0.061s)
TestListCopy (0.000s)
TestListRef (0.000s)

# 总结

IL 代码分析中,对于 List 容器来说,可以看出直接传递的方式反而比引用传递少两条指令,但实际性能测试结果显示,引用传递的的方式性能还是更好。

另外,数组的性能更好 —— 至少在仅取值和传递方面不是一个级别的,这估计跟 CLR 对数组本身的优化支持、以及 List 索引实际还走了一道属性取值也有关系。

后续估计还得再研究一下 Stloc_1Ldloca_S 两条指令深一点的功用。

例如,我们是否有理由怀疑:

  1. 引用传递只执行了一次复制,调用者的方法栈上,然后传递引用
  2. 复制传递方式则至少执行两次复制,即调用者的方法栈上,然后再复制一份传递至调用方法

因此虽然上述两条指令执行了赋值,依然造成了上述实际的性能差异,也就是说,引用传递怎么都还是比直接复制传参省一些。