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

  

引言

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

形参和实参

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

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

 

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

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

 

命名实参

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

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一旦升级并改变方法的形参名称,则存有依赖的客户端命名实参代码会报错,以下图所示:
ide

  

可选参数

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

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

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

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


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

 

params,数目可变参数

若是方法签名的参数列表存在多个数量不固定的形参,那么params能够帮你解决这个问题。
在方法声明中的 params 关键字以后不容许任何其余参数,而且在方法声明中只容许一个 params 关键字。ui

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 ,方法重载等功能时,可能会形成同一个方法调用或者实参列表能够适用多个方法签名的状况,那么就须要编译器对其作出方法解析和重载决策。this

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


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


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


参数传递      【重难点】

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

通常按照参数类型和传递方式的不一样,能够分为如下4种参数传递的状况:

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

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

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改变的仅仅只是实例副本的内存块里面的值。

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

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

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

 

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

 

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

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

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改变就是实例所在的内存块里面的值。

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

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

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里面与参数有关的知识进行盘点,可是却牵连其余知识点:
指针、内存分布、地址指向、堆栈、托管堆、泛型类型参数等...
那么问题来了:
一、引用类型加上ref、out修饰符的意义和应用场景?
二、指针和引用是什么关系,或者两者之间有何异同之处?
三、值类型的引用传递,其传的地址是堆栈地址仍是托管堆地址?
四、值类型必定存储在堆栈上面吗?
五、C#的指针传递是怎样的内幕?
六、....C#的语法点滴还有哪些醉美可赏?CLR又究竟还存在多少奇幻魅惑?
这些问题仍是先留给你们本身去思考一下吧,待博主后续博文继续揭晓。

对于问题或者迷惑,咱们必须深刻探讨,揪出其本质,才能在技术的道路上愈加沉淀和累积。
您的支持是我写文的强劲动力,但愿本文可以给您带来帮助和益处,很是感谢您的阅读!

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

相关文章
相关标签/搜索