...

浅谈 C# 可变参数 params

2022-02-12

前言


群里看到群友写了一个基础框架,其中涉及到关于同一个词语可以添加多个近义词的一个场景。当时群友的设计是类似字典的设计,直接添加k-v的操作,本人看到后思考了一下觉得使用c#中的params可以更优雅的实现一个key同时添加一个集合的操作,看起来会更优雅一点,这期间还有群友说道params和数组有啥区别的问题。本篇文章就来大致的说一下。


示例


params是c#的一个关键字,用用汉语来说的话叫可变参数,这里的可变,不是说的类型可变,而是指的个数可变,这是c#的一个基础关键字,相信大家都有一定的了解,今天咱们就来进一步看一下c#的可变参数params。首先来看一下简单的自定义使用,随便定义一个方法

static void ParamtesDemo(string className, params string[] names)
{
    Console.WriteLine($"{className}的学生有:{string.Join(",", names)}");
}

定义可变参数类型的时候需要有几个注意点•params修饰在参数的前面且参数类型得是一维数组类型

params修饰的参数默认是可以不传递的

params参数不能用ref或out修饰且不能手动给默认值调用的时候更简单了,如下所示

ParamtesDemo("小四班""jordan""kobe""james""curry");
// 如果不传递值也不会报错
// ParamtesDemo("小四班");

由上面的示例可知,使用可变参数最大的优势就是你可以传递一个不确定个数的集合类型并且不用声明单独的类型去包装,这种场景特别适合传递参数不确定的场景,比如我们经常使用到的string.Format就是使用的可变参数类型。

探究本质

通过上面我们了解到的params的遍历性,当集合参数个数不确定的时候是使用可变参数的最佳场景,看着很神奇很便捷,本质到底是什么呢?之前楼主也没有在意这个问题,直到前几天怀揣着好奇的心情看了一下。废话不多说,我们直接借助ILSpy工具看一下反编译之后的源码

[CompilerGenerated]
internal class Program
{
    private static void <Main>$(string[] args)
    {
        //声明了一个数组
        ParamtesDemo("小四班"new string[4] { "jordan""kobe""james""curry" });
        Console.ReadKey();

        //已经没有params关键字了,就是一个数组
        static void ParamtesDemo(string className, string[] names)
        {
            Console.WriteLine(className + "的学生有:" + string.Join(",", names));
        }
    }
}

通过ILSpy反编译的源码我们可以看到params是一个语法糖,其实就是增加了编程效率,本质在编译的时候会被具体的声明的数组类型替代,不参与到运行时。这个时候如果你怀疑反编译的代码有问题,可以直接通过ILSpy看生成的IL代码,由于IL代码比较长,首先看一下Main方法

// Methods
.method private hidebysig static 
        void '<Main>$' (
            string[] args
        ) cil managed 
{
    // Method begins at RVA 0x2092
    // Header size: 1
    // Code size: 57 (0x39)
    .maxstack 8
    .entrypoint

    // ParamtesDemo("小四班", new string[4] { "jordan""kobe""james""curry" });
    IL_0000: ldstr "小四班"
    IL_0005: ldc.i4.4
        //通过newarr可知确实是声明了一个数组类型
    IL_0006: newarr [System.Runtime]System.String
    IL_000b: dup
    IL_000c: ldc.i4.0
    IL_000d: ldstr "jordan"
    IL_0012: stelem.ref
    IL_0013: dup
    IL_0014: ldc.i4.1
    IL_0015: ldstr "kobe"
    IL_001a: stelem.ref
    IL_001b: dup
    IL_001c: ldc.i4.2
    IL_001d: ldstr "james"
    IL_0022: stelem.ref
    IL_0023: dup
    IL_0024: ldc.i4.3
    IL_0025: ldstr "curry"
    IL_002a: stelem.ref
    // 这个地方调用了ParamtesDemo,第二个参数确实是一个数组类型
    IL_002b: call void Program::'<<Main>$>g__ParamtesDemo|0_0'(string, string[])
    // Console.ReadKey();
    IL_0030: nop
    IL_0031: call valuetype [System.Console]System.ConsoleKeyInfo [System.Console]System.Console::ReadKey()
    IL_0036: pop
    // }
    IL_0037: nop
    IL_0038: ret
} // end of method Program::'<Main>$' 

通过上面的IL代码可以看到确实是一个语法糖,编译完之后一切尘归尘土归土还是一个数组类型,类型是和params修饰的那个数组类型是一致的。接下来我们再来看一下ParamtesDemo这个方法的IL代码是啥样的

//names也是一个数组
.method assembly hidebysig static 
    void '<<Main>$>g__ParamtesDemo|0_0' (
        string className,
        string[] names
    ) cil managed 
{
    .custom instance void System.Runtime.CompilerServices.NullableContextAttribute::.ctor(uint8) = (
        01 00 01 00 00
    )
    .custom instance void [System.Runtime]System.Runtime.CompilerServices.CompilerGeneratedAttribute::.ctor() = (
        01 00 00 00
    )
    // Method begins at RVA 0x20d5
    // Header size: 1
    // Code size: 30 (0x1e)
    .maxstack 8

    // {
    IL_0000: nop
    // Console.WriteLine(className + "的学生有:" + string.Join(",", names));
    IL_0001: ldarg.0
    IL_0002: ldstr "的学生有:"
    IL_0007: ldstr ","
    IL_000c: ldarg.1
    IL_000d: call string [System.Runtime]System.String::Join(stringstring[])
    IL_0012: call string [System.Runtime]System.String::Concat(stringstringstring)
    IL_0017: call void [System.Console]System.Console::WriteLine(string)
    // }
    IL_001c: nop
    IL_001d: ret
// end of method Program::'<<Main>$>g__ParamtesDemo|0_0'

一切了然,本质就是那个数组。我们上面还提到了params修饰的参数默认不传递的话也不会报错,这究竟是为什么呢,我们就用IL代码来看一下究竟进行了何等操作吧

// Methods
.method private hidebysig static 
    void '<Main>$' (
        string[] args
    ) cil managed 
{
    // Method begins at RVA 0x2092
    // Header size: 1
    // Code size: 24 (0x18)
    .maxstack 8
    .entrypoint

    // ParamtesDemo("小四班", Array.Empty<string>());
    IL_0000: ldstr "小四班"
        // 本质是编译的时候帮我们声明了一个空数组Array::Empty<string>
    IL_0005: call !!0[] [System.Runtime]System.Array::Empty<string>()
    IL_000a: call void Program::'<<Main>$>g__ParamtesDemo|0_0'(stringstring[])
    // Console.ReadKey();
    IL_000f: nop
    IL_0010: call valuetype [System.Console]System.ConsoleKeyInfo [System.Console]System.Console::ReadKey()
    IL_0015: pop
    // }
    IL_0016: nop
    IL_0017: ret
// end of method Program::'<Main>$'

原来这得感谢编译器,如果默认不传递params修饰的参数的话,默认它会帮我们生成一个这个类型的空数组,这里需要注意的不是null,所以代码不会报错,只是没有数据。

扩展知识

我们上面提到了string.Format也是基于params实现的,毕竟Format具体的参数依赖于前面声明的字符串的占位符个数。在翻看相关代码的时候还发现了一个ParamsArray这个类,用来包装params可变参数,简单的来说就是便于快速操作params,这个我是在Format方法中发现的,源代码如下

public static string Format(string format, params object?[] args)
{
    if (args == null)
    {
        throw new ArgumentNullException((format == null) ? nameof(format) : nameof(args));
    }
    return FormatHelper(null, format, new ParamsArray(args));
}

params参数也可以为null值,默认不会报错,但是需要进行判断,否则程序处理null可能会报错。在这里我们可以看到把params参数传递给ParamsArray进行包装,我们可以看一下ParamsArray类本身的定义,这个类是一个struct类型的

internal readonly struct ParamsArray
{
    //定义是三个数组分别去承载当传递进来的params不同个数时的数据
    private static readonly object?[] s_oneArgArray = new object?[1];
    private static readonly object?[] s_twoArgArray = new object?[2];
    private static readonly object?[] s_threeArgArray = new object?[3];

    //定义三个值分别存储params的第0、1、2个参数的值
    private readonly object? _arg0;
    private readonly object? _arg1;
    private readonly object? _arg2;

    //承载最原始的params值
    private readonly object?[] _args;

    //params值为1个的时候
    public ParamsArray(object? arg0)
    {
        _arg0 = arg0;
        _arg1 = null;
        _arg2 = null;

        _args = s_oneArgArray;
    }

    //params值为2个的时候
    public ParamsArray(object? arg0, object? arg1)
    {
        _arg0 = arg0;
        _arg1 = arg1;
        _arg2 = null;

        _args = s_twoArgArray;
    }

    //params值为3个的时候
    public ParamsArray(object? arg0, object? arg1, object? arg2)
    {
        _arg0 = arg0;
        _arg1 = arg1;
        _arg2 = arg2;

        _args = s_threeArgArray;
    }

    //直接包装整个params的值
    public ParamsArray(object?[] args)
    {
        //直接取出来值缓存
        int len = args.Length;
        _arg0 = len > 0 ? args[0] : null;
        _arg1 = len > 1 ? args[1] : null;
        _arg2 = len > 2 ? args[2] : null;
        _args = args;
    }

    public int Length => _args.Length;

    public objectthis[int index] => index == 0 ? _arg0 : GetAtSlow(index);

    //判断是否从承载的缓存中取值
    private object? GetAtSlow(int index)
    {
        if (index == 1)
            return _arg1;
        if (index == 2)
            return _arg2;
        return _args[index];
    }
}

ParamsArray是一个值类型,目的就是为了把params参数的值给包装起来提供读相关的操作。根据二八法则来看,params大部分场景的参数个数或者高频访问可能是存在于数组的前几位元素上,所以使用ParamsArray针对热点元素提供了快速访问的方式,略微有一点像Java中的IntegerCache的设计。这个结构体是internal类型的,默认程序集之外是没办法访问的,我当时看到的时候比较好奇,就多看了一眼,感觉设计思路还是考虑的比较周到的。

总结

本文主要简单的聊一下c#可变参数params的本质,了解到了其实就是一个语法糖,编译完成之后本质还是一个数组。它的好处就是当我们不确定集合个数的时候,可以灵活的使用params进行参数传递,不用自行定义一个集合类型。然后微软针对params在内部实现了一个ParamsArray结构体进行对params包装,提升params类型的访问。

新年伊始,聊一点个人针对学习的看法。学习最理想的结果就是把接触到的知识进行一定的抽象,转换为概念或者一种思维方式,然后细化这种思维,让它成为细颗粒度的知识点,然后我们通过不断的接触不断的积累,后者不同领域的接触等,不断吸收壮大这个思维库。然后当看到一个新的问题的时候,或者需要思考的时候,能达到快速的多角度的整合这些思维碎片,得到一个更好的思路或解决问题的办法,这也许是一种更行之有效的状态。类比到我们架构设计上来说,以前的思维方式是一种类似单体应用的方式,灵活性差扩展性更差,后来微服务概念大行其道,更多独立的服务相互协调工作,形成一种更强大的聚合力。


来源:DotNet