《.Net 的冰与火之歌》寄雁传书,你必须知道的C#参数知识大盘点

  

引言

参数,也叫参变量,是一个变量。在方法签名中随处可见,实现了不同方法间对于数据的寄雁传书,基本上充斥在代码的各个角落里。
在方法签名或者原型中,方法名称后的括号包含方法的参数及其类型的完整列表。参数声明指定参数中存储的值的类型、大小和标识符。
然而小小参数的背后其实也是有着大大的学问,所以本篇博文,您可以和博主一起把C#里面各式各样的参数复习一遍。
我们先简单回顾一下各种各样的参数概念,对不同类型参数的使用场景有一个了解,再慢慢深入探讨参数的传递,内存堆栈分布,抽丝剥茧,步步为营,带着思考由浅入深的去阅读本文。

形参和实参

形参全称为“形式参数”,由于它不是实际存在变量,所以又称虚拟变量。
形参是在定义方法签名的时候使用的参数,目的是用来接收调用该方法时传递的参数(值),它的作用是实现主调方法与被调方法之间的联系。  
形参只在方法内部有效,方法调用结束返回主调用方法后则不能再使用该形参变量。
形参(自身也是变量)和局部变量有所区别,且在方法内部(作用域内)不允许存在一个同名的局部变量,哪怕它们类型是相同的。

//oldValue、parameter1、optionalParam1、optionalParam2就是Change方法的形参
//方法签名如果有多个形参,则多个形参用逗号隔开
private static void Change<T>(T oldValue, object parameter1, object optionalParam1 = null, object optionalParam2 = null)
{
    T newValue = default(T);
    oldValue = newValue;
}

 

实参全称为"实际参数",是在调用时传递给方法的参数,即传递给被调用方法的值,形参实际上就是实参的替身。
实参可以是常量、变量、表达式、方法等,无论实参是何种类型的量或值,在进行方法调用时,它们都必须具有确定的值,以便把这些值传送给形参。 因此应预先用赋值,输入等办法使实参获得确定值。
实参和形参在数量上,类型上、顺序上应严格一致,否则就会发生类型不匹配的错误。

private static void Main()
{
    int a = 5;
    //传进去2个实参,实参1为变量,实参2为值
    //实参初始化了形参的初始值
    Change(a, 99);
}

 

命名实参

C# 4.0 中引入的命名实参,能够为特定形参指定实参,方法的调用者将不再需要记住或查找形参在所调用方法的形参列表中的顺序,可以按形参名称指定每个实参的形参。

private static void Main()
{
    Change(99, 88);

    //如果不记得形参的顺序,但却知道其名称,可以按任意顺序发送实参。
    Change(oldValue: 99, parameter1: 88);
    Change(parameter1: 99, oldValue: 88);

    //命名实参还可以标识每个实参所表示的含义,从而改进代码的可读性。
    //命名实参可以放在位置实参后面,如此处所示。
    Change(99, parameter1: 88);

    //但是,位置实参不能放在命名实参后面。 下面的语句会导致编译器报错。
    //Change(parameter1: 99, 88);
}

如果方法签名的形参比较多,则命名实参技术的使用会使得方法调用变的简便许多。
但是,这种简便性是以牺牲方法签名自由修改的灵活性为代价的。
如果被调用的方法封装在外部dll且不开源,则DLL一旦升级并改变方法的形参名称,则存有依赖的客户端命名实参代码会报错,如下图所示:

  

可选参数

C# 4.0 中还提供了可选参数,任何调用都必须为所有必需的形参提供实参,但可以为可选的形参省略实参。如果没有为该形参发送实参,则使用定义时的默认值。
每个可选形参都必须有一个初始化的默认值作为其定义的一部分。 
可选形参在形参列表的末尾定义,位于任何必需的形参之后。 
如果调用方为一系列可选形参中的任意一个形参提供了实参,则它必须为前面的所有可选形参提供实参。
 

private static void Main()
{
    //下面对 Change 的调用导致编译器报错,原因是调用者为任意一个形参提供了实参,则它必须为前面的所有可选形参提供实参。 
    //Change(3, 2, , 4);

    //如果你想跳过第三个可选形参,则可以使用命名实参来达到目的。
    Change(3, optionalParam2: 4, parameter1: 99);
}

智能提示使用中括号指示可选形参,如下图所示:


命名实参和可选参数这两种技术都可与方法、索引器、构造函数和委托一起使用。

 

params,数目可变参数

如果方法签名的参数列表存在多个数量不固定的形参,那么params可以帮你解决这个问题。
在方法声明中的 params 关键字之后不允许任何其他参数,并且在方法声明中只允许一个 params 关键字。

private static void Change(params object[] aryParameters)
{
}

private static void Main()
{
    //如果不传实参,则aryParameters长度为0
    Change();

    Change(3);
    Change(3, 4, 8);
    Change(new string[] { "as", "er" });
}

 

方法解析与重载决策

如果同时使用命名实参、可选参数,params ,方法重载等功能时,可能会造成同一个方法调用或者实参列表可以适用多个方法签名的情况,那么就需要编译器对其做出方法解析和重载决策。

多个方法都适用调用的实参列表时,优先选择无可选参数的方法,如下图所示:


多个方法都适用调用的实参列表时,优先选择形参类型更加具体的方法,如下图所示:


多个方法(且方法的形参均为可选)都适用调用的实参列表时,优先选择可选参数更少的方法,如下图所示:


参数传递      【重难点】

概念1:值类型,值类型的变量直接包含其数据,分配在线程堆栈(Thread Stack)上。
概念2:引用类型,引用类型的变量存储对其数据(对象实例)的引用(内存地址),该引用类型的变量(内存地址)会被分配到线程堆栈上,而被引用的数据(对象实例)则会被分配到托管堆(Heap)。
概念3:参数的值传递。
概念4:参数的引用传递。
以上4种概念,一定不能混淆,特别是引用类型和引用传递。
由于本篇博文主要是讲解参数,所以在此就不对值类型和引用类型的基础概念做深入讲解了。

一般按照参数类型和传递方式的不同,可以分为以下4种参数传递的情况:

1、值类型参数的值传递。

在进行参数的值传递时,当传递的参数为值类型时,实际上传递的是该值类型实例的一个拷贝副本。因此方法操作的是实例副本,所以不会对实例本身构成任何影响。

private static void ChangeValue<T>(T oldValue)
{
    T newValue = default(T);
    oldValue = newValue;
}

private static void Main()
{
    int a = 100;
    //传递的是值类型实例的副本,所以针对方法内部的改变丝毫不会影响到实例本身
    ChangeValue(a);
    Console.WriteLine(a);//输出是:100
}

通过指针操作来更进一步的加深理解,注意看下图的实例的指针地址和实例副本的指针地址是不一样的:

由此可以看出这是2个不同的内存块,所以ChangeValue改变的仅仅只是实例副本的内存块里面的值。

2、引用类型参数的值传递。

在进行参数的值传递时,当传递的参数为引用类型时,实际上传递的是该引用类型实例的引用的一个拷贝副本(稍微有点绕),因此方法操作的是引用类型实例的引用的副本。
如果方法不改变引用副本的指向,那么在方法中对参数所做的任何更改都将反映在该变量中。
但是,如果方法改变了引用副本的指向,那么不会对实例本身构成任何影响。
有点绕且拗口,接下来,我们上几个典型的代码例子,并加以讲解,从而帮助我们深层次的理解值传递。

private static void ChangeValue(string value)
{
    //此时传递进来的value副本和value都指向"init"

    value = "update";//value副本做赋值操作,分配一个新的string类型实例对象(new string("update")),value副本已经指向 "update"(地址为0x02),而value仍然还是指向"init"(地址为0x01)
}

private static void Main()
{
    string value = "init";

    //value变量存的是对"init"的引用(内存地址:0x01)
    //那么传递的是value变量的副本(本质也就是对"init"的引用的副本,也是0x01)
    //这2个0x01都存储在线程堆栈上,并且都指向"init"的托管堆内存块
    ChangeValue(value);

    //ChangeValue方法只是改变了value副本的指向,value本身的指向不受任何影响,所以结果可想而知输出“init”
    Console.WriteLine(value);
}

 

public string TestProperty { get; set; }
private static void ChangeValue(Program p)
{
    //此时传递进来的p副本和p都指向new Program { TestProperty = "init" }

    //对p副本所指向内存块的TestProperty属性做赋值操作,
    //而不是对p副本做赋值操作,那么也就是说p副本的指向并未改变
    //实际上操作的内存块依然是new Program { TestProperty = "init" }
    //所以p副本所指向内存块的TestProperty属性已经指向"update"了
    p.TestProperty = "update";

    //这里只是改变了一个地址,就是p副本和p共同指向的内存块里面的TestProperty属性的地址
}

private static void Main()
{
    Program p = new Program { TestProperty = "init" };

    //p变量存的是对new Program { TestProperty = "init" }的引用(内存地址:0x001)
    //那么传递的是p变量的副本(本质也就是对new Program { TestProperty = "init" }的引用的副本,也是0x001)
    //这2个0x001都存储在线程堆栈上,并且都指向new Program { TestProperty = "init" }的托管堆内存块
    ChangeValue(p);

    //ChangeValue既没有改变p的指向,也没有改变p副本的指向
    //p和p副本依然指向同一个内存块
    //由于ChangeValue修改了该内存块TestProperty属性的值,所以输出结果“update”
    Console.WriteLine(p.TestProperty);
}

 

public string TestProperty { get; set; }
private static void ChangeValue(Program p)
{
    //此时传递进来的p副本和p都指向new Program { TestProperty = "init" }

    p = new Program();//对p副本做赋值操作,这个时候,p副本已经指向另外一个内存块0x002了,而p依然还是指向0x001

    //对p副本所指向内存块0x002的TestProperty属性做赋值操作
    //p副本所指向内存块0x002的TestProperty属性已经指向"update"了
    p.TestProperty = "update";

    //这里改变了二个地址,就是p副本的地址和0x002里面的的TestProperty属性的地址
}

private static void Main()
{
    Program p = new Program { TestProperty = "init" };

    //p变量存的是对new Program { TestProperty = "init" }的引用(内存地址:0x001)
    //那么传递的是p变量的副本(本质也就是对new Program { TestProperty = "init" }的引用的副本,也是0x001)
    //这2个0x001都存储在线程堆栈上,并且都指向new Program { TestProperty = "init" }的托管堆内存块
    ChangeValue(p);

    //ChangeValue并没有改变p的指向,而是将p副本的指向由0x001改成0x002,然后又将0x002的TestProperty改成“update”了
    //所以p指向的0x001内存块没有受到任何影响
    //那么p指向的0x001内存块里面的TestProperty值并未改变,所以输出结果“init”
    Console.WriteLine(p.TestProperty);
}

 

通过以上代码的讲解和分析,我想我们可以窥探出其本质:
值传递的本质是传“值”的副本,只不过值类型变量的“值”是实例本身,而引用类型变量的“值”是实例的引用,这么一说就清晰很多了是吧!

 

3、值类型参数的引用传递。

在进行参数的引用传递时,当传递的参数为值类型时,实际上传递的是该值类型实例的引用(不是实例副本)。
因此方法操作的是值类型实例的引用,在方法中对参数所做的任何更改都将反映在该变量中。
值类型通过引用传递时,不会对值类型进行装箱。

private static void ChangeValue(ref int value)
{
    value = 0;
}

private static void Main()
{
    int value = 100;

    //实际上传递的是value实例的引用(类似于指针)
    ChangeValue(ref value);

    //由于ChangeValue方法修改了value的引用所指向的内存块里面的值(实例本身),所以输出是:0
    Console.WriteLine(value);
}


通过指针操作来更进一步的加深理解,注意看下图的实例的指针地址和实例引用的指针地址是一样的:


由此可以看出这是同一个内存块,所以ChangeValue改变就是实例所在的内存块里面的值。

4、引用类型参数的引用传递。

在进行参数的引用传递时,当传递的参数为引用类型时,实际上传递的是该引用类型实例的引用的引用(不是实例引用的副本)。
因此方法操作的是引用类型实例的引用的引用,在方法中对参数所做的任何更改都将反映在该变量中。

private static void ChangeValue(ref string value)
{
    /* value的引用
     * value的引用的引用
     * value的引用指向对象实例,而[value的引用的引用]指向[value的引用]
     * 所以其本质就是修改value的引用所指向的托管堆里的内存块里面的值
     */

    value = "update";
}

private static void Main()
{
    string value = "init";

    //实际上传递的是value实例的引用的引用(类似于指针的指针)
    ChangeValue(ref value);

    //由于ChangeValue方法修改了value的引用的引用所指向的内存块里面的值,所以输出是:“update”
    Console.WriteLine(value);
}

由于C#指针只能用来操作值类型,所以上面只有值类型对象的指针操作的屏幕演示gif动画,就当锦上添花加深理解吧。
既然指针已经过来凑热闹了,我就多说一句,C#指针和引用的确类似,但是还是有所区别,是两个不同的概念。

讲解到这里的时候,我们基本上在脑海中已经开始有一些结论了:
值类型的值传递:  实际传的就是实例的 副本。
引用类型的值传递: 实际传的就是实例的 引用的副本。
值类型的引用传递: 实际传的就是实例的 引用。
引用类型的引用传递:实际传的就是实例的 引用的引用。
在C#中,必须使用ref或者out参数修饰符显式声明的情况下,参数才会按照引用传递,否则默认就是按照值传递。
不管使用了什么障眼法,或者代码绕了什么弯,只要抓住其本质,都可以把赋值操作和内存分布及其地址指向说的清清楚楚。 

 

ref引用参数/out输出参数   参数修饰符

如果方法签名中的参数使用了ref、out修饰符,那么就是显式的告诉编译器,该参数是按照引用传递。我们不妨看看IL代码,就一目了然了,如下图所示:

通过上图可以看出,在调用带有ref、out参数修饰符的方法时,传递参数时都会加上&运算符。

看看IL代码,顺便对比一下加了ref之后是什么样子:

private static void ChangeValue(int value)
{
    value = 0;
}

private static void ChangeValue(ref int value)
{
    value = 0;
}

private static void Main()
{
    int a = 1;
    ChangeValue(a);
    ChangeValue(ref a);
}

//以下是IL代码
.method private hidebysig static 
    void Main () cil managed 
{
    // Method begins at RVA 0x2350
    // Code size 16 (0x10)
    .maxstack 1
    .entrypoint
    .locals init (
        [0] int32 a
    )

    IL_0000: ldc.i4.1 //把1推送到堆栈上。
    IL_0001: stloc.0  //从堆栈的顶部弹出1并存到索引 0 处的局部变量列表中。
    IL_0002: ldloc.0  //将索引 0 处的局部变量a加载到堆栈上。【这个地方走ChangeValue(a),直接就是进行堆栈数据拷贝。】
    IL_0003: call void ConsoleTest.Program::ChangeValue(int32)
    IL_0008: ldloca.s a //将局部变量a的地址加载到堆栈上。   【这个地方走ChangeValue(ref a),则是把堆栈上的地址拿过来。由此可见,加上ref修饰符之后,传的就是实例的引用地址了】
    IL_000a: call void ConsoleTest.Program::ChangeValue(int32&) //注意看这地方多了&运算符
    IL_000f: ret
} // end of method Program::Main

 

传递到ref形参的实参必须先经过初始化,然后才能传递,out则不需要。
属性或索引器不能作为 out 或 ref 参数传递,因为它们的本质是方法,而不是变量。
ref或者out可以实现方法重载,但是不能同时实现方法重载。例如void Test(ref int a)和void Test(out int a)同时出现并实现方法重载则会编译报错。

 

泛型类型参数

在泛型类型或方法定义中,类型参数是客户端在实例化泛型类型的变量时指定的特定类型的占位符。
客户端代码必须通过指定尖括号中的类型参数来声明和实例化构造类型。
此特定类的类型参数可以是编译器识别的任何类型。
可以创建任意数目的构造类型实例,每个实例使用不同的类型参数。
尖括号中出现的每个 T 都会在运行时替换为相应的类型参数。通过这种替换方式,我们可以使用一个泛型定义并创建多个独立的类型安全的有效对象。

泛型类型参数的约束

 

约束

说明

where T: struct

类型参数必须是值类型。可以指定除 Nullable 以外的任何值类型。

where T: class

类型参数必须是引用类型;这一点也适用于任何类、接口、委托或数组类型。

where T:new()

类型参数必须具有无参数的公共构造函数。当与其他约束一起使用时,new() 约束必须最后指定。

where T:<基类名>

类型参数必须是指定的基类或派生自指定的基类。

where T:<接口名称>

类型参数必须是指定的接口或实现指定的接口。可以指定多个接口约束。约束接口也可以是泛型的。

where T:U

为 T 提供的类型参数必须是为 U 提供的参数或派生自为 U 提供的参数。

 

in/out 泛型类型参数修饰符

in/out关键字均可以在泛型接口和委托中使用。
用in修饰的泛型类型参数,表示该类型参数是逆变的。
用out修饰的泛型类型参数,表示该类型参数是协变的。
关于泛型的逆变和协变需要大篇幅来讲解,且与本文的侧重点有所偏离,故不作深入探讨。

本文回顾与总结

本文先是对形参/实参、命名实参、可选参数、params数目可变参数等基础知识概念作了一翻讲解,顺便蜻蜓点水了值类型与引用类型,然后以此为铺垫,开始循序渐进、由浅入深的揭秘参数传递的神秘面纱。
参数传递才是本文的重难点,比较绕,而且拗口,理解起来需要静下心细细品味与琢磨。为了更好的理解参数传递的本质,本文也引入了IL代码和指针来加以辅助。
然而不甘寂寞的泛型类型参数也作为特邀嘉宾到场助兴,为本文增色不少。

本篇文章主要是对C#.NET里面与参数有关的知识进行盘点,但是却牵连其他知识点:
指针、内存分布、地址指向、堆栈、托管堆、泛型类型参数等...
那么问题来了:
1、引用类型加上ref、out修饰符的意义和应用场景?
2、指针和引用是什么关系,或者二者之间有何异同之处?
3、值类型的引用传递,其传的地址是堆栈地址还是托管堆地址?
4、值类型一定存储在堆栈上面吗?
5、C#的指针传递是怎样的内幕?
6、....C#的语法点滴还有哪些醉美可赏?CLR又究竟还存在多少奇幻魅惑?
这些问题还是先留给大家自己去思考一下吧,待博主后续博文继续揭晓。

对于问题或者迷惑,我们必须深入探讨,揪出其本质,才能在技术的道路上越发沉淀和累积。
您的支持是我写文的强劲动力,希望本文能够给您带来帮助和益处,非常感谢您的阅读!

看完文章,来一首好听的音乐:《Five Hundred Miles》—Justin Timberlake

posted @ 2016-09-01 16:28  陌城&心灵捕手  阅读(4309)  评论(22编辑  收藏  举报