CLR via C#深解笔记四 - 方法、参数、属性

时间:2022-06-03 06:28:43
实例构造器和类(引用类型)
构造器(constructor)是允许将类型的实例初始化为良好状态的一种特殊方法。构造器方法在“方法定义元数据表”中始终叫.ctor。
创建一个引用类型的实例时:
#1, 首先为实例的数据字段分配内存
#2, 然后初始化对象的附加字段(类型对象指针和同步块索引)
#3, 最后调用类型的实例构造器来设置对象的初始状态
 
构造引用类型的对象时,在调用类型的实例构造器之前,为对象分配的内存总是先被归零。构造器没有显示重写的所有字段保证都有一个0或null值。和其它方法不同,实例构造器永远不能被继承。
如果基类没有提供无参构造器,那么派生类必须显式调用一个基类构造器,否则编译器会报错。如果类的修饰符为static(sealed和abstract - 静态类在元数据中是抽象密封类),编译器根本不会在类的定义中生成一个默认构造器。
 
注意:编译器在调用基类的构造器前,会初始化任何使用了简化语法的字段,以维持源代码给人留下的“这些字段总是一个值”的印象。
 
实例构造器和结构(值类型)
值类型(struct)构造器的工作方式与引用类型(class)的构造器截然不同。CLR总是允许创建值类型的实例,是没有办法阻止值类型的实例化。
类型定义无参构造器的,但是CLR是允许的。为了增强应用程序的运行时性能,C#编译器不会自动地生成这样的代码 (自动调用值类型的无参构造器,即使值类型提供了无参构造器)。
所以,值类型其实并不需要定义构造器。C#编译器也根本不会为值类型生成默认的无参构造器。CLR确实允许为值类型定义构造器,并且必须显式调用,即使是无参构造器 (这样是为了增强应用程序性能)。实际上C#编译器也是不允许值
 
类型构造器
CLR还支持类型构造器(Type constructor), 也称为静态构造器(static constructor)、类构造器(class constructor)或者类型初始化器(type initializer)。类型构造器可应用于接口(C#编译器不允许)、引用类型和值类型。
#1, 实例构造器的作用是设置类型的实例的初始状态。对应地,类型构造器的作用是设置类型的初始状态。类型默认是没有定义类型构造器的。若是定义,也只能定义一个,并且永远没有参数的。
#2, 类型构造器不允许出现访问修饰符,事实上它总是私有的,C#编译器会自动标记为private。之所以私有,是为了阻止任何由开发人员写的代码调用它,对它的调用总是由CLR负责的。
#3, 类型构造器的调用比较麻烦。JIT编译器在编译一个方法时,会查看代码中都引用了那些类型。任何一个类型定义了类型构造器,JIT编译器都会检查 - 针对当前AppDomain, 是否已经执行了这个类型构造器。如果构造器从未执行,JIT编译器就会在它生成的本地(native)代码中添加对类型构造器的一个调用。如果类型构造器已经执行,JIT编译器就不添加对它的调用,因为他知道类型已经初始化了。
#4, 当方法被JIT编译器编译完毕之后,线程开始执行它,最终会执行到调用类型构造器的代码。多个线程可能同时执行相同的方法。CLR希望确保在每个AppDomain中,一个类型构造器只能执行一次。为了保证这一点,在调用类型构造器时,调用线程要获取一个互斥线程同步锁。这样一来,如果多个线程视图同时调用某个类型的静态类型构造器,只有一个线程才可以获得锁,其他线程会被阻塞(blocked)。第一个线程会执行静态构造器中的代码。当第一个线程离开构造器后,正在等待的线程将被唤醒,然后发现构造器的代码已经被执行过。
#5, 虽然能在值类型中定义一个类型构造器,但永远都不要真的那么做,因为CLR有时不会调用值类型的静态类型构造器。
#6, CLR保证一个类型构造器在每个AppDomain中只执行一次,而且(这种执行)是线程安全的,所以非常适合在类型构造器中初始化类型需要的任何单实例(singleton)对象。
 
最后,如果类型构造器抛出一个未处理的异常, CLR会认为这个类型不可用。试图访问该类型的任何字段或方法,都将导致抛出一个System.TypeInitializationException 异常。类型构造器中的代码只能访问类型的静态字段,并且它的常规用途就是初始这些字段。和实例字段一样,C#提供了一个简单的语法来初始化类型的静态字段。
 
操作符重载方法
有些编程语言是允许一个类型定义操作符应该如何操作类型的实例。如,许多类型(System.String)都重载了相等(==)和不等(!=)操作符。CLR对操作符重载一无所知,它们甚至不知道什么事操作符。是编程语言定义了每个操作符的含义,以及当这些特殊符号出现时,应该生成什么样的代码。
 
public sealed class Complex {
     public static Complex operator+(Complex c1, Complex c2) { ... }
}
操作符和编程语言的互操作性:如果一个类型定义了操作符重载方法,Microsoft还建议类型定义更友好的公共静态方法,并在这种方法的内部调用操作符重载方法。FCL的System.Decimal类型很好地演示了如何重载操作符并按照Microsoft的知道原则定义友好的方法名。
 
转换操作符方法
有时需要将对象从一个类型转换成一个不同的类型。例如,有时不得不将Byte类型转换成为Int32类型。其实,当源类型和目标类型都是编译器的基元类型时,编译器自己就知道如何生成转换对象所需的代码。
有些编程语言(如C#)就有提供转换操作符的重载。转换操作符是将对象从一个类型转换成另一个类型的方法。可以使用特殊的语法来定义转换操作符的方法。CLR规范要求转换操作符重载方法必须是public和static方法。
除此之外,C#要求参数类型和返回类型二者必有其一与定义转换方法的类型相同。
 
CLR via C#深解笔记四 - 方法、参数、属性
 
相同在C#中,implicit关键字告诉编译器为了生成代码来调用方法,不需要在源代码中进行显式转型。相反,explicit关键字告诉编译器只有在发现了显式转型时,才调用方法。
在implicit或explicit关键字之后,要指定operator关键字告诉编译器该方法是一个转换操作符。在operator之后,指定对象需要转换成什么类型。在圆括号之内,则指定要从什么类型转换。
 
扩展方法
 
CLR via C#深解笔记四 - 方法、参数、属性
 
应用扩展方法:
 
CLR via C#深解笔记四 - 方法、参数、属性
 
C#只支持扩展方法,不支持扩展属性、扩展事件、扩展操作等 
扩展方法(第一个参数前面有this的方法)必须在非泛型的静态类中声明,然而类名没有限制,可以随便什么名字。当然,扩展方法至少要有一个参数,而且只有第一个参数能用this关键字标记。
C#编译器查找静态类中定义的扩展方法时,要求这些静态类本身必须具有文件作用域。
扩展方法扩展类型时,同时也扩展了派生类型。所以,不应该将System.Object用作扩展方法的第一个参数,否则这个方法在所有表达式类型上都能调用,造成Visual Studio的“ 智能感知“ 窗口被填充太多的垃圾信息。
扩展方法有潜在的版本控制问题。
扩展方法,还可以为接口类型定义扩展方法。
 
扩展方法是微软的LINQ(Language Integrated Query, 语言集成查询)技术的基础。
C#编译器允许创建一个委托,让它引用一个对象上的扩展方法。
 
Action a = "Jeff".ShowItems;
a();
 
分部方法
 
只能在分部类或者结构中声明
分部方法的返回类型始终是void,任何参数都不能用out修饰符来标记。因为,方法在运行时可能不存在,所以将一个变量初始化为方法也许会返回的东西。可以有ref参数,可以是泛型方法,可以是实例或者静态方法。
若是没有对应的实现部分,便不能在代码中创建一个委托来引用这个分部方法。
分部方法总是被视为private方法。
 
CLR via C#深解笔记四 - 方法、参数、属性
 
----------------------------------------------------------------------------------------------------
 
参数
 
可选参数和命名参数
设计一个方法的参数时,可为部分或者全部参数分配默认值。
 
以传引用的方式向方法传递参数
默认情况下,CLR假定所有方法参数都是传值的。
传递引用类型的对象时,对一个对象的引用(或者说指向对象的一个指针)会传给方法。注意这个引用(或者指针)本身是以传值方式传给方法的。这就意味着方法可以修改对象,而调用者可以看到这些修改。
传递值类型的实例时,传给方法的是实例的一个副本,这意味着方法获得它专用的一个值类型实例的副本,调用者的实例并不受影响。
 
关键字out或ref
C#中,允许以传引用而非传值的方式传递参数。这是用关键字out或ref来做到的,告诉C#编译器生成元数据来指明该参数是传引用的。编译器也将生成代码来传递参数的地址,而不是传递参数本身。调用者必须为实例分配内存,被调用者则操纵该内存(中的内容)。
CLR角度来看,关键字out和ref完全一致。这就是说,无论用哪个关键字,都会生成相同的IL代码。元数据也几乎一致,只有一个bit除外,它用于记录声明方法时指定的是out还是ref。
C#编译器是将这两个关键字区别对待的,而且这个区别决定了由哪个方法负责初始化所引用的对象。如果方法的参数用out来标记,表明不指望调用者在调用方法之前初始化好对象,返回前必须向这个值写入。如果是ref来标记,调用者就必须在调用该方法前初始化参数的值,被调用的方法可以读取值以及/或者向值写入。
综上所述,从IL和CLR角度看,out和ref是同一码事:都导致传递指向实例的一个指针。但从编译器角度看,两者有区别的,编译器会按照不同的标准(要求)来验证你写的代码是否正确。
重要提示:
如果两个重载方法只有out和ref的区别,那么是不合法的,因为两个签名的元数据表示是完全相同的。
对于以传引用的方式传给方法的变量(实参),它的类型必须与方法签名中声明的类型(形参) 相同。
 
参数和返回类型的知道原则
 
#1,声明方法的参数类型时,应尽量指定最弱的类型,最好是接口而不是基类。
例如,如果要写一个方法来处理一组数据项,最好是用接口(比如IEnumerable<T>来声明方法的参数),而不要用强数据类型(如List<T>)或者更强的接口类型(如ICollection<T> 或 IList<T>).
 
// 好
public void AddItems<T>(IEnumerable<T> collection) { ... }  
 
// 不好
public void AddItems<T>(List<T> collection) { ... }  
 
如果需要是一个列表(而非仅仅是可枚举的对象),就应该将参数类型声明为IList<T>。但是,仍然要避免将参数类型声明为List<T>。
这里的例子讨论的是集合,是用一个接口体系结构来设计的。如果要讨论使用基类体系结构设计的类,概念同样适用。如:
 
// 好
public void ProcessBytes(Stream someStream) { ... }
 
// 不好
public void ProcessBytes(FileStream fileStream) { ... }
 
第一个方法能处理任何一种流,包括FileStream、NetworkStream和MemoryStream等。
第二种方法则只能处理FileStream流,这限制了它的应用。
 
#2,一般将方法的返回类型声明为最强的类型(以免受限于特定的类型)。例如,最好声明方法返回一个FileStream对象,而不是返回一个Stream对象。
 
// 好
public FileStream OpenFile() { ... }
 
// 不好
public Stream OpenFile() { ... }
 
如果某个方法返回一个List<String> 对象,就可能想在未来的某个时候修改它的内部实现,以返回一个String[]。如果希望保持一定的灵活性,以便将来更改方法返回的东西,请选择一个较弱的返回类型。
 
----------------------------------------------------------------------------------------------------
 
属性
属性允许源代码用一个简化的语法来调用一个方法。CLR支持两种属性:无参属性(parameterless property), 简称为属性。有参属性(parameterful property),即索引器(indexer)。
 
无参属性
许多类型都定义了可以被获取或者更改的状态信息。这种状态信息一般作为类型的字段成员实现。
 
CLR via C#深解笔记四 - 方法、参数、属性
CLR via C#深解笔记四 - 方法、参数、属性
 
需要争辩的是永远都不应该像这样来实现。面向对象的设计和编程的重要原则之一就是数据封装(data encapsulation)。它意味着类型的字段永远不应该公开,因为这样很容易写出不恰当使用字段的代码,从而破坏对象的状态。
e.Age = -5; // 代码被破坏
还有其他原因促使我们封装对类型中的数据字段的访问:
其一,你可能希望访问字段来执行一些side effect、缓存某些值或者推迟创建一些内部对象。
其二,你可能希望以线程安全的方式访问字段。
其三,字段可能是一个逻辑字段,它的值不由内存中的字节表示,而是通过某个算法来计算获得。
基于上述原因,强烈建议将所有字段都设为private。要允许用户或类型获取或设置状态信息,就公开一个针对该用途的方法。封装了字段访问的方法通常称为访问器(accessor)方法。访问器方法可选择对数据的合理性进行检查,确保对象的状态永远不被破坏。
CLR via C#深解笔记四 - 方法、参数、属性
 
CLR via C#深解笔记四 - 方法、参数、属性
 
这样有两个缺点。首先,因为不得不实现额外的方法,所以必须写更多的代码;其次,类型的用户必须调用方法,而不能直接饮用一个字段名。
 
编程语言和CLR提供了一种称为属性(property)的机制。它缓解了第一个缺点所造成的影响,同时完全消除了第二个缺点。
CLR via C#深解笔记四 - 方法、参数、属性
 
CLR via C#深解笔记四 - 方法、参数、属性
 
可以将属性想象成智能字段(smart field),即背后有额外逻辑的字段。CLR支持静态、实例、抽象和虚属性。另外,属性可用任意”可访问性“修饰符来标记,而且可以在接口中定义。
某个属性都有一个名称和一个类型(类型不能是void)。通过属性的get和set方法操作类型内定义的私有字段,这种做法十分常见。私有字段通常称为支持字段(backing field)。但是,get和set方法并不是一定要访问支持字段。
C#内建了对属性的支持,当C#编译器发现代码视图获取或者设置一个属性时,它实际上会生成对上述某个方法的一个调用。除了生成对应的访问器方法,针对源代码中定义的每一个属性,编译器还会在托管程序集的元数据中生成一个属性定义项。在这个记录项中,包含了一些标志(flags)以及属性的类型。另外,它还引用了get和set访问器方法。这些信息唯一的作用就是在”属性“这种抽象概念与它的访问器方法之间建立起一个联系。CLR并不使用这些元数据信息,在运行时只需要访问器方法。
 
合理定义属性
属性看起来与字段相似,但本质上是方法。这一点引起了很多误解。
 
对象和集合初始化器
常需要构造一个对象,然后设置对象的一些公共属性(或字段)。下面的初始化方法简化了对象初始化编程模式:
 
CLR via C#深解笔记四 - 方法、参数、属性
CLR via C#深解笔记四 - 方法、参数、属性
 
匿名类型
C#的匿名类型功能,可以使用非常简洁的语法来声明一个不可变的元组类型。元组类型是含有一组属性的类型,这些属性通常以某种形式相互关联。
 
CLR via C#深解笔记四 - 方法、参数、属性
CLR via C#深解笔记四 - 方法、参数、属性
 
创建匿名类型,没有在new关键字后执行类型名称,编译器会为其自动创建一个类型名称,而且不会告诉我这个名称具体是什么(这正是匿名一词的来历)。可以利用C#的”隐式类型局部变量“功能(var)。
编译器定义匿名类型非常”善解人意“,如果它看到你在源代码中定义了多个匿名类型,而且这些类型具有相同的结构,那么它只会创建一个匿名类型定义,但创建该类型的多个实例。所谓”相同结构“,是指在这些匿名类型中,每个属性都有相同的类型和名称,而且这些属性的指定顺序相同。正是类型的同一性,可以创建一个隐式类型的数组,在其中包含一组匿名类型的对象。
匿名类型经常与LINQ(Language Intergrated Query, 语言集成查询)技术配合使用。可用LINQ执行查询,从而生成由一组对象构成的集合,这些对象都是相同的匿名类型。然后,可以对结果集中的对象进行处理。所有这些都是在同一个方法中发生。匿名类型的实例不能泄露到一个方法的外部。方法原型中,无法要求它接受一个匿名类型的参数,因为没有办法执行匿名类型。也无法指定它返回对一个匿名类型的引用。
 
除了匿名类型和Tuple类型,还以注意下System.Dynamic.ExpandoObject 类(System.Core.dll程序集中定义)。这个类和C#的dynamic类型配合使用,就可以用另一种方式将一系列属性(键值对)组合到一起,这样做的结果在编译时不时类型安全的,但语法看起来不错。
 
CLR via C#深解笔记四 - 方法、参数、属性
CLR via C#深解笔记四 - 方法、参数、属性
 
有参属性
无参属性因为get访问器方法不接收参数,又与字段的访问有些相似,所以这些属性很容易理解。除此之外,编译器还支持所谓的有参属性(parameterful property),它的get访问器方法接受一个或者多个参数,set 访问器接受两个或多个参数。不同的编码语言以不同的形式公开有参属性,称呼也有所不同。C#语言把他们称为索引器。Visual Basic称为默认属性。
 
C#使用数组风格的语言来公开有参属性(索引器)。换句话说,可将索引看做C#开发人员重载" []"操作符的一种方式。
 
CLR via C#深解笔记四 - 方法、参数、属性
CLR via C#深解笔记四 - 方法、参数、属性
 
CLR本身并不区分无参属性和有参属性。对CLR来说,每个属性都只是类型中定义的一对方法和一些元数据。如前所述,不同的编程语言要求用不同的语法来创建和使用有参属性。将this[...] 作为表达一个索引器的语法,纯粹是C#团队自己的选择。所以,C#也只是允许在对象的实例上定义索引器,而不提供定义静态索引器属性的语法,虽然CLR是支持静态有参属性的。
 
调用属性访问器方法时的性能
对于简单get和set访问器方法,JIT编译器会将代码内联(inline)。这样一来,使用属性(而不是使用字段)就没有性能上的损失。内联是将一个方法(或者当前情况下的访问器方法)的代码直接编译到调用它的方法中。这避免了在运行时发出调用所产生的开销,代价是编译好的方法的代码会变得更大。注意,JIT编译器在调试代码时不会内联属性方法,因为内联的代码会变得难以调试。