C#基础之方法和参数
实例方法、静态方法
C#中的方法分为两类,一种是属于对象(类型的实例)的,称之为实例方法,另一种是属于类型的,称之为静态方法(用static关键字定义)。大家都是做开发的,这两个也没啥好说的。
唯一的建议就是:你的静态方法最好是线程安全的(这点是说起容易做起难啊……)。
构造器(构造函数)
构造器是一种特殊的方法,CLR中的构造器分为两种:一种是实例构造器;另一种是类型构造器。和其他方法不同,构造器不能被继承,所以在构造器前应用virtual/new/override/sealed和abstract是没有意义的,同时构造器也不能有返回值。
实例构造器用来初始化类型的实例(也就是对象)的初始状态。
对于引用类型,如果我们没有显式定义实例构造器,C#编译器默认会生成一个无参实例构造器,这个构造器什么也不做,只是简单调用一下父类的无参实例构造器。这里应该意识到,如果我们定义的类的基类没有定义无参构造器,那么我们的派生类就必须显式调用一个基类构造器。
class MyBase { public MyBase(string name) { } } class MyClass : MyBase { }
上面的代码会报“MyBase不包含采用0个参数的构造函数”的错误,必须显式调用一个基类的构造器:
class MyBase { public MyBase(string name) { } } class MyClass : MyBase { public MyClass(string name) : base(name) { } }
一个类型可以定义多个实例构造器,只要这些构造器有不同的方法签名即可。如下MyBase类,我定义了三个构造器:
class MyBase { public MyBase() //无参构造器 : this(string.Empty) { } public MyBase(string name) //一个参数的构造器 : this(name, 0) { } public MyBase(string name, int age) //两个参数的构造器 { } }
除了实例构造器,C#语言还提供了一种初始化字段的简便语法,称为“内联初始化”:
从编译后生成的IL代码可以看出,内联初始化本质是在所有实例构造器中,生成一段字段初始化代码的方式来实现的。注意这里一个潜在的代码膨胀问题,如果我们定义了多个实例构造器,那么在每个实例构造器开头处,都会生成这样的初始化代码。在有多个实例构造器的类型定义中,应尽量减少这种内联初始化,可以通过创建一个构造器来初始化这些字段,然后让其他构造器通过this关键字来调用这个构造器。
对于值类型,C#不会对值类型生成默认的无参构造器,但CLR总是允许值类型的实例化。即对于以下的值类型定义,虽然我们没有定义任何构造器,C#也没有为我们生成默认无参构造器,但它总是可以通过new实例化的(值类型的字段被初始化为0或null)。
struct MyStruct { public int x, y; } MyStruct ms = new MyStruct(); //总是可以实例化
我们可以为值类型定义有参构造器(C#不允许值类型定义无参构造器),但在内部必须初始化值类型的所有字段。
struct MyStruct { public int x, y; public string z; public MyStruct(int a) //异常:在控制返回到调用之前,字段y、z必须完全赋值。 { x = a; } }
像上面这样为值类型定义一个有参构造器时,编译器会报“在控制返回到调用之前,字段y、z必须完全赋值”的错误。为了修正这个问题,可以采用下面的语法为值类型字段初始化。
struct MyStruct { public int x, y; public string z; public MyStruct(int a) //在控制返回到调用之前,字段y、z必须完全赋值。 { this = new MyStruct(); //this代表值类型实例本身,用new初始化值类型所有字段为0或null。 //this = default(MyStruct); //这种方式书上没提,但我认为这样也可以。 x = a; } }
类型构造器(静态构造器)用来初始化类型的初始状态,并且有且只能定义一个,且没有参数。类型构造器总是私有的,C#会自动把它标记为private,事实上C#禁止开发人员对类型构造器应用任何访问修饰符。
CLR在第一次使用一类型时,如果该类型定义了类型构造器,CLR便会以线程安全的方式调用它。这里应该意识到对类型构造器的调用,由于CLR要做大量检查与判断和线程同步,所以性能上会有所损失。
类型构造器的通常用来初始化类型中的静态字段,C#同样提供了一种内联初始化的语法:
从编译器生成的IL可知,静态字段的内联初始化实际上是在类型构造器生成初始化代码完成的,而且首先生成的是内联初始化代码,然后才是类型构造器方法内部显式包含的代码。
注意:虽然值类型能定义类型构造器,但永远都不要那么做。因为CLR有时不会调用值类型的类型构造器。
抽象方法、虚方法
这两个概念都是针对于类型的继承层次结构中来说的,如果没有了继承,它们是毫无意义的。这也意味着它们的可访问性至少是protected,即对派生类是可见的。
抽象方法是只定义了方法名称、签名和返回值类型,而没有定义任何方法实现的一种方法。C#中用abstract定义,抽象方法所在的类肯定是抽象类。由于抽象方法没有定义方法实现,所以它是没有意义的,必须在派生类中提供方法的实现(如果派生类没有提供,那么它必须仍然定义成抽象类)。
abstract class MyBase { //静态方法 public static void Test0() { /*方法实现*/ } //实例方法 public void Test1() { /*方法实现*/ } //抽象方法 public abstract void Test2(); //虚方法 protected virtual void Test3() { /*方法实现*/} }
在C#中用virtual定义的方法是虚方法,它看上去只是比定义一个普通实例方法多了一个virtual关键字。虚方法总是允许在派生类中重写,但不强求,这正在它和抽象方法的区别。也可以逻辑上把虚方法想象成提供了默认实现的抽象方法,因为提供了默认实现,所以不强求派生类中重写。
抽象方法编译后被标记为abstract virtual instance(抽象虚实例方法),虚方法编译后被标记为virtual instance(虚实例方法)。
抽象方法和虚方法共同的特点都是可以在派生类中重写,在C#中用override关键字来重写一个方法。在VS中,如果我们在类中输入override关键字加空格,便会显示出所有基类中的虚成员(方法、属性、事件等)。因为抽象方法编译后是抽象和虚的,所以也会显示在列表中。
重写后的方法仍然是virtual的(但不再是抽象的)
virtual方法是可以被派生类重写的,如果不希望重写后的方法被接下来的派生类(即派生自MyClass的类)重写,可以在override前应用sealed关键字,将方法标记为封闭的。
如下图中,我将MyClass中的Test3标记为sealed后,MyClass的派生类中,VS列出的可重写的成员中便没有Test3了。
当然,还可以对类应用sealed关键字,这样整个类都不能被继承了!类都不能被继承了,类里包含的所有虚方法更不谈重写了。
分部方法(partial关键字)
主要是partial关键字(也可以应用于类、结构和接口),可以将一个方法定义到多个文件中。
通常有这么一种场情:我们往往利用代码生成工具生成一些模板化的代码,但又需要对某些细节进行定制,虽然可以通过虚方法重写来实现,但这样做存在两点问题:
- 工具生成的类必须是非密封的,因为要继承重写虚方法。
- 调用虚方法存在潜在的效率问题。
这时候,就可以利用分部方法来实现。让代码生成器生成一个分部类(注意这个类可以是密封的),把实现细节抽象成一个方法定义。像下面这样:
//工具生成部分 sealed partial class XXOO { //声明一个分部方法 partial void PrepareSomething(string boy, string girl); public void DoSomething() { //调用分部方法(如果没有提供实现,编译后这句会被优化掉) PrepareSomething("", ""); /*其他逻辑*/ } }
如果我们没有提供分部方法的实现,那么编译后,整个方法的定义和所有对此方法的调用都会被优化(删除)掉,这样可以让代码更少更快!也正因为这一点(编译后分部方法可能不存在),所以分部方法不能定义任何修改符,也不能定义返回值!
当然用分部方法主要还是为了提供实现细节,我们甚至可以在不同的文件中来定义这个类(在VS中输入partial加空格,便会列出当前分部类中的还未提供实现的分部方法):
//自定义的部分 sealed partial class XXOO { //提供具体的实现细节 partial void PrepareSomething(string boy, string girl) { /*提供实现细节的代码*/ } }
我们再来看看提供分部方法的实现代码后,编译器生成了什么:
关于分部方法有几点要小注意一下:
- 只能在分部类或结构中声明。
- 返回类型始终为void,并且参数不能有out修改符,因为编译后这个方法可能不存在。
- 分部方法总被视为private的,C#编译器禁止手工指定任何修改符。
Finalize方法与Dispose方法
这两个方法涉及CLR的垃圾回收部分,这里只是从方法层面上谈谈这两个方法。我们知道,C#是托管语言,我们写的程序最终托管给CLR,CLR有强大的自动垃圾回收机制来帮助我们回收内存资源。但注意CLR自动回收的仅是内存资源,有些类除了要利用内存资源外,还需要利用一些其他的系统资源(比如文件、网络连接、套接字、互斥体等),所以CLR提供了一种机制来释放这些资源,这便是Finalize方法(终结器)。
这里的Finalilze方法并不是指直接在类中定义一个Finalize方法(虽然可以定义,但永远不要这么做!),而是指用析构语法来定义的一种方法,即“~类名()”的方式定义的方法,该方法编译后,会生成名为Finalize的方法。CLR会在决定回收包含Finalize方法的对象之前用一个特殊的线程调用Finalize方法来释放一些资源(这个具体的过程待日后写到CLR垃圾回收部分慢慢聊)。
下图简单演示了一下如何定义一个终结器,我们用定义析构函数的语法来定义了一个方法(注意这个方法没有参数和任何修饰符),编译后,编译器为我们生成一个名为Finalize的protected virtual方法。且在方法内部生成一个try块包装原方法内的代码,生成一个finally块来调用基类的Finalize方法。
虽然定义Finalize方法的语法和C++的析构函数语法一样,但CLR书上说两者原理还是完全不同,所以不能称为析构器(我的理解C++中的析构函数应该是释放对象所用的资源包括内存资源,调用后对象便被清理干净了;而C#中的Finalize方法只是释放对象所用的系统资源,调用后对象仍然存活,直到CLR将其回收,不知道这么理解对不对啊,请指点!)。
虽然Finalize方法很有用,能确释放一些资源。但有一点要注意,就是它的调用是由CLR决定的,所以调用时间我们无法保证。所以我们需要一种机制来显式地释放资源,这便是Dispose模式。.Net里提供了IDisposable接口(包含唯一一个Dispose方法),我们只要实现该接口即代表我们的类实现了Dispose模式。在Dispose方法内部,我们关闭对象所用到的系统资源。这样我们在代码中,就可以显式调用Dispose方法来释放资源,而不是被动地交给CLR去释放,《CLR Via C#》书中建议所有实现终结器的类都同时实现Dispose模式。如下面的类,实现终结器的同时还实现Dispose模式(先不管实现细节是否合理):
class MyResource: IDisposable { private Mutex mutex; //构造器 public MyFinalization() { mutex = new Mutex(); } //终结器 ~MyFinalization() { mutex = null; } //实现IDisposable接口 public void Dispose() { mutex = null; } }
这样在我们使用完MyResource对象后,就可以通过调用Dispose方法释放资源。
MyResource resource = new MyResource(); // //…使用资源… // resource.Dispose(); //调用Disopse释放对象所用的资源
对于实现Dispose模式的类型,C#还提供了using语句来简化我们的编码。
using (MyResource resource = new MyResource()) { // //…使用资源… // }
上面的代码等价于
MyResource resource = new MyResource(); try { // //…使用资源… // } finally { if (resource != null) (resource as IDisposable).Dispose(); }
扩展方法
扩展方法使我们能够向现有类型“添加”方法,而无需创建新的派生类型、重新编译或以其他方式修改原始类型。 扩展方法是一种特殊的静态方法,但可以像被扩展类型上的实例方法一样进行调用,同时它可以得到VS智能提示的良好支持(我们可以像使用对象实例方法一样,点出扩展方法)。
定义一个扩展方法,有以下几点要求:
- 必须定义在非泛型静态类中(类名无所谓)。并且这个类必须顶级类,即不能嵌套在其他类中。
- 扩展方法必须是静态方法。
- 第一个参数必须指明被扩展的类型,并且用this关键字标识。
static class ExtensionMethods //静态类名 无所谓 { public static bool IsNullOrEmpty(this string s) //扩展方法 { return string.IsNullOrWhiteSpace(s); } }
上面示例为string类型对象定义了一个名为IsNullOrEmpty的方法,只要我们的代码中引入扩展方法所有静态类ExtensionMethods 的命名空间,就可以直接在代码中,像使用string类型原生方法一样使用它了。
string name = "heku"; name.IsNormalized(); //Sytem.String类型原生方法 name.IsNullOrEmpty(); //扩展方法
关于扩展方法,还有以下几点要注意:
- C#只支持扩展方法,不支持扩展属性、扩展事件、扩展操作符等(虽然它们本质都是方法)。
- 使用扩展方法之前,必须要先引入扩展方法所在类的命名空间。
- 多个静态类可以定义相同的扩展方法。如果发生冲突时,只能使用调用静态方法的语法来调用扩展方法。
- 扩展方法是可以被继承的,如果我们扩展了一个类型,那么它的所有派生类型都能调用此扩展方法。
- 潜在版本问题,如果未来微软在类型中加入了和你扩展方法同名的方法,那么所有对扩展方法的调用都将变成调用微软的方法。
扩展方法延伸阅读:鹤冲天 http://www.cnblogs.com/ldp615/archive/2009/08/07/1541404.html
值类型参数和引用类型参数
传递参数就是赋值操作,我们可以把方法参数看成方法定义的一些变量,传参就是对这些变量进行赋值的过程。赋值过程就是拷贝线程栈内容的过程,值类型的栈内容保存的就是值实例本身,而引用类型栈内容保存的是引用实例在堆上的地址。所以这里的区别主要是值类型与引用类型内存分配上的区别,具体可参考《C#基础之基本类型》。所以在传参后,方法的值类型参数拥有原始值的复制(一个副本),对其的更改不影响原始值,因为它们根本就不是一块内存!方法的引用类型参数拥有与原始值相同的地址,它们指向同一块堆内存,所以对引用类型参数的更改会影响原始值。如下示例,分别定义了一个值类型val和引用类型refObj,在调用Work方法后,值类型val未被修改,引用类型refObj被修改了。
class Program { static void Main(string[] args) { DoWork dw = new DoWork(); int val = 555; RefType refObj = new RefType { Id = 1, Name = "Heku" }; Console.WriteLine(val); Console.WriteLine("Id={0},Name={1}\n", refObj.Id, refObj.Name); //********输出******** //555 //Id=1,Name=Heku dw.Work(val, refObj); Console.WriteLine(val); Console.WriteLine("Id={0},Name={1}\n", refObj.Id, refObj.Name); //********输出******** //555 //Id=2,Name=Heku修改后 Console.ReadKey(); } } //一个引用类型 class RefType { public int Id { get; set; } public string Name { get; set; } } class DoWork { //修改值类型a 和 引用类型 b public void Work(int a, RefType b) { a++; b.Id++; b.Name = b.Name + "修改后"; } }
可选参数、命名参数
我们定义一个有很多参数的方法后,那么所有调用处都要准备好这些参数才能调用此方法。但往往有些时候,我们调用时只关心其中的部分参数,通常我们是通过重载来定义几个参数比较少的方法,内部补全其他参数再调用参数最多的那个方法。但这是纯体力活,而且也不能重载出所有可能的参数组合情况。因此C#提供一种机制,可以在定义方法的同时,给参数指定默认值,这样在方法调用处,如果没有给参数提供值,就会采用默认值,拥有默认值的参数就称为可选参数。
//参数isToUpper因为有了默认值true, //参数other因为有了默认值0, //所以参数isToUpper和other在调用时可以不提供,故称为 可选参数 public string ToUpperOrLower(string message, bool isToUpper = true, int other = 0) { if (isToUpper) return message.ToUpper(); else return message.ToLower(); }
参数isToUpper和other因为提供了默认值,所以我们可以仅提供message的值,来调用方法:
//调用 没有传可选参数 string result = dw.ToUpperOrLower("HeKu");
如果我们想为第二个可选参数other显式提供一个值,那么按参数只能一对一按顺序匹配的规则,我们不得不指定isToUpper的值,这很不爽,所以命名参数的登场了!我们可以在调用时,用“参数名:参数值”的语法给参数提供值,这种语法的作用是要求参数的匹配方式不要按参数顺序,而是根据提供的名称。像下面这样(没有用命名参数语法的参数还是按参数顺序匹配,如第一个参数”Heku”):
//为第二个可选参数 显示提供一个值 string result = dw.ToUpperOrLower("HeKu", other: 25);
在定义方法参数时,还有几点要小注意一下:
- 可以为方法、构造器、有参属性的参数指定默认值,还可以为委托定义一部分的参数指定默认值。
- 有默认值的参数必须放在没有默认值参数的后面。
- 默认值必须是编译时能确定的常量值。
- 不要重命名参数变量。如上面ToUpperOrLower方法中,如果重命名了第三个参数,那么在调用处就因找不到other参数而报错。
- 如果参数要用ref或out关键字标识,就不能设置默认值。
可变数量的参数(params关键字)
如果我们要设计一个方法,来计算所有输入数字的总和。按以往我们会这么实现(不要关注方法内部实现是否合理):
//计算任意个数字和 public int sum(int[] numbers) { int sum = 0; foreach (int item in numbers) { sum += item; } return sum; }
因为输入的数字个数是未知的,这里用一个数组来接收这些数字。在调用时,我们不得不先初始化一个数组,然后再调用方法。为了简化这种编程方式,可以在numbers参数定义前,应用params关键字。
//计算任意个数字和 public int sum(params int[] numbers) { int sum = 0; foreach (int item in numbers) { sum += item; } return sum; }
现在就可以直接用这种直观的方式调用sum了:
sum(1, 2, 3);
当然也可以用传统的方式来调用:
int[] numbers = new int[] { 1, 2, 3 }; sum(numbers);
可变数量的参数,有几点要小注意一下:
- Params关键字只能应用于方法签名中的最后一个参数。
- 这个参数必须且只能是一维数组!
- 调用带params关键字的方法时,会有额外的性能损失(除非显式传null)。因为数组对象必须在堆上分配,数组元素必须初始化,数组内存最终也必须垃圾回收。为了降低性能损失,通常可定义几个没有作用params关键字的重载版本。如System.String类型中Format方法的定义:
以传引用的方式传参数(ref和out关键字)
默认情况下,CLR中的所有方法参数都是传值(线程栈的内容)的,但可以通过在参数应用ref或out关键字来改变这一默认方式。这两个关键字唯一的区别就是,使用ref标识的参数要在传递之前初始化,而使用out标识的参数不需要。
应用了ref或out关键字的参数在传递时,传递的是线程栈内容的引用(地址),注意这里不是堆的地址(以引用方式传参并不是说将参数转换成引用类型来传递)。下面看一个例子:
public void Update(ref int a,ref object b) { a++; b = null; }
上面定义了一个方法,接收一个值类型参数,一个引用类型参数。并要求参数以引用的方式传递(加了ref关键字)。下面开始调用:
int a = 100; object o = new object(); object c = o;
Update(ref a,ref c);
Console.WriteLine(a); Console.WriteLine(o == null); Console.WriteLine(c == null);
会输出什么?你想到了吗?答案是:
![](https://images.cnblogs.com/OutliningIndicators/ContractedBlock.gif)
101 False True
上面我们讲过,以引用的方式传参传递的是栈的地址。值类型本身的值就是分配在栈上,所以以引用方式传参的值类型就像以传值方式传递的引用类型(比较绕,好好想一下),最终的效果就是Update的第一个参数指向了变量a的栈,所以在方法内部的更改也直接影响到了变量a。对第二个参数,我特别事先定义了两个变量o和c,让它们都指向堆上同一块内存空间,然后把变量c的栈地址传给了方法的第二个参数,在方法内部将第二个参数设为null,实际上就是把c的栈内容设为了null(这点我是根据现象推出来的,到底是不是这样?请大牛指点!),但这丝毫没有影响到堆上的对象和变量o!所以最终的结果就是a被修改成101,o没变,c被修改为null。如果把第二个参数的ref去掉,结果会是什么样呢?这个请大家自己think一下吧~本丝又敲了两天键盘,眼睛好累啊~
结束的话
又一个周末,终于敲完了这篇读书笔记性质的总结。给自己列的提纲中“操作符重载方法、转换操作符方法”这一部分由于自己未做过多了解,故未写进来(待日后有机会再补进来吧)。
各位园友同行,本丝也是学习中的菜鸟一枚,如果某些知识点我理解有误,请大家指出!感谢!