类的实例构造器
构造器是将类型的实例初始化为良好状态的特殊方法。构造器方法在“方法定义元数据表”中始终叫做.ctor(constructor的简称)。创建引用类型的实例时,首先为实例的数据字段分配内存,然后初始化对象的附加字段(类型对象指针和同步块索引),最后调用类型的实例构造器来设置对象的初始状态。
没有被构造器显式重写的所有字段的都保证获得0或null值。
和其他方法不同,实例构造器永远不能被继承。所以实例构造器不能使用以下修饰符:virtual,new,override,sealed,abstract。如果类没有显式定义任何构造器,C#编译器将定义一个默认(无参)构造器。在它的实现中,只是简单的调用了基类的无参构造器。例如:
public class MainClass
{
}
等价于
public class MainClass
{
public MainClass() : base() { }
}
如果类的修饰符为abstract,那么编译器生成的默认构造器的可访问性就为protected。否则,构造器会被赋予public可访问性。如果基类没有提供无参构造器,那么派生类必须显式调用一个基类构造器,否则编译器会报错。如果类的修饰符为static,编译器根本不会在类的定义中生成默认构造器。
一个类型可以定义多个实例构造器。每个构造器都必须有不同的签名,而且每个都可以由不同的可访问性。为了使代码“可验证”(verifiable),类的实例构造器在访问从基类继承的任何字段之前,都必须先调用基类的构造器。如果派生类的构造器没有显式调用一个基类构造器,C#编译器会自动生成对默认的基类构造器的调用。最终,System.Object的公共无参构造器会得到调用。该构造器什么都不做,直接返回。
极少数情况下可以在不调用实例构造器的前提下创建类型的实例。一个典型的例子是Object的MemberwiseClone方法。该方法的作用是分配内存,初始化对象的附加字段(类型对象指针和同步块索引),然后将源对象的字节数据复制到新对象中,另外,用运行时序列化器(runtime serializer)反序列化对象时,通常也不需要调用构造器。反序列化代码使用 System.Runtime.Serialization.FormatterServices 类型的 GetUninitializedObject 或者GetSafeUninitializedObject 方法为对象分配内存,期间也不会调用一个构造器。
内联语法
C#编译器提供了一个简化的语法,允许以“内联”方式初始化实例字段。但在幕后,它会将这种语法转换成构造器方法中的代码来执行初始化,例如:
public class MainClass
{
public int mainVal = 10;
}
相当于
public class MainClass
{
public int mainVal;
public MainClass()
{
mainVal = 10;
}
}
看看.ctor的IL会更清晰(以第一个MainClass为例):
从这段IL也能看出,是先初始化内联的语法,再调用基类的无参构造函数,最后才是执行构造函数中本来的代码。
除此之外,使用“内联”方式初始化实例字段,也要注意代码的膨胀效应,假如上例的MainClass中,假如有多个构造器方法,编译器在为这三个构造器方法生成IL代码时,在每个方法的开始位置,都会包含用于初始化mainVal的代码。如果不希望代码过于膨胀,不要使用“内联”方式初始化实例字段,而是创建单个构造器来执行这些公共的初始化,例如:
public class MainClass
{
public int mainVal;
public MainClass()
{
mainVal = 5;
}
public MainClass(int val) : this() { }
public MainClass(string val) : this() { }
}
来看看生成的IL(以MainClass(string val)为例):
可以看到,不仅没有mainVal的内联生成,也不会调用基类的无参构造函数,而是先调用了自己类的无参构造函数:
结构的实例构造器
值类型(struct)构造器的工作方式和引用类型(class)的构造器截然不同。CLR总是允许创建值类型的实例,并且没有办法阻止值类型的实例化。所以,值类型其实并不需要定义构造器,C#编译器根本不会为值类型内联默认的无参构造函数。例如如下代码:
internal struct Point
{
public int x, y;
}
internal sealed class Rectangle
{
public Point tl, br;
}
构建Rectangle,必须使用new操作符,为Rectangle分配内存时,内存中包含Point值类型2个实例。假如我们调用代码:
Rectangle rectangle = new Rectangle();
CLR会自动将rectangle中的tl,br2个值类型字段自动初始化,此时我们调用rectangle.tl.x是能正常读到数据,数据为0。
除此之外,CLR也允许为值类型定义构造器。但有如下几点要求:
- 不能定义无参构造器。因为事实上,即使你能定义无参构造器,如果不能显示调用无参构造器,编译器是不会生成代码来自动调用它。
- 值类型的实例构造器只有显示调用才会执行。
- 定义值类型的实例构造器时,需要对全部字段进行赋值,例如上例Point需要在构造器中完成x,y字段的初始化,不然编译器会报错。
当然,有时偷懒,也可以使用个小伎俩绕过为全部字段进行赋值的处理,例如,Point的实例构造器我们改成如下写法就不会报错了:
internal struct Point
{
public int x, y;
public Point(int x, int y)
{
this = new Point();
}
}
在值类型的构造器中,this代表值类型本身的一个实例,用new创建的值类型的一个实例可以被赋给this。在new的过程中,会将所有字段置为0.而在引用类型的构造器中,this被认为是只读的,所以不能对它进行赋值。
类型构造器
类型构造器(type constructor),也称为静态构造器(static constructor)、类构造器(class constructor),或者类型初始化器(type initializer)。
类型构造器可以用于引用类型和值类型。类型构造器的作用是设置类型的初始状态。类型默认没有定义类型构造器。如果定义,也只能定义一个,且类型构造器永远没有参数,也不能定义修饰符(public,internal...),它总是私有的。
类型构造器的调用比较麻烦。JIT编译器在编译一个方法时,会查看代码中都引用了哪些类型。任何一个类型定义了类型构造器,JIT编译器都会检查针对当前AppDomain,是否已经执行了这个类型构造器。如果构造器从未执行,JIT编译器会在它生成的本机代码中添加对类型构造器的调用。如果类型构造器已经执行,JIT编译器就不添加对它的调用,因为它知道类型已经初始化好了。
现在,当方法被JIT编译完毕后,线程开始执行它,最终会执行到调用类型构造器的代码。事实上,多个线程可能同时执行相同的方法。CLR希望确保在每个AppDomain中,一个类型构造器只执行一次。为了保证这一点,在调用类型构造器时,调用线程要获取一个互斥同步锁。这样一来,如果多个线程同时调用某个类型的静态构造器,只有一个线程能获得锁,其他线程会被阻塞。第一个线程执行完毕静态构造器的代码后,其他线程会被唤醒,然后发现构造器的代码已被执行过。因此,这些线程不会再次执行代码,将直接从构造器方法返回。
值类型中也可以定义类型构造器,不过只有首次访问值类型的静态成员时,CLR才会自动调用静态构造函数,例如以下例子:
internal struct Point
{
public int x;
public static int y;
static Point()
{
Console.WriteLine("Point值类型构造器调用");
}
}
public class Program
{
static void Main(string[] args)
{
Point point = new Point();
Console.WriteLine(point.x);
Console.WriteLine(Point.y);
}
}
------ 输出 ------
0
Point值类型构造器调用
0
使用ILDasm.exe查看类型构造器的IL,可以发现,类型构造器方法总是.cctor(class constructor)。与实例构造器相同,它同样支持字段内联的方式在构造器中初始化,同样是执行内联的初始化代码,在执行构造函数内的代码。不过,类型构造函数不会调用基类型的静态构造器。
类型只有在AppDomain卸载时才会卸载。AppDomain卸载时,用于标识类型的对象(类型对象)将成为“不可达”的对象(不存在对它的引用),垃圾回收器会回收类型对象的内存。这个行为导致许多开发人员为类型添加一个静态Finalize方法。当类型卸载时,就自动调用这个方法。遗憾的是,CLR并不支持静态Finalize方法。但也不是完全没有办法,要在AppDomain卸载时执行一些代码,可向System.AppDomain类型的DomainUnload事件登记一个回调方法。
操作符重载方法
CLR对操作符重载一无所知,是编程语言定义了每个操作符的含义,以及当这些特殊符号出现时,应该生成什么样的代码。例如在C#中,向基元类型的数字应用+符号时,编译器生成将两个数加一起的代码。将+应用于String对象,C#编译器生成将两个字符串连接到一起的代码。
虽然CLR对操作符一无所知,但它确实规定了语言应如何公开操作符重载,以便由另一种语言的代码使用。每种编程语言都要自行决定是否支持操作符重载。如果决定支持,还要决定用什么语法来表示和使用它们。至于CLR,操作符重载只是方法而已。
CLR要求操作符重载必须是public static方法,另外C#要求重载方法至少有一个参数的类型与当前定义这个方法的类型相同,例如这个实现+操作符的例子:
public class Vector2
{
public int x, y;
public Vector2(int x, int y)
{
this.x = x;
this.y = y;
}
public static Vector2 operator+(Vector2 a, Vector2 b)
{
return new Vector2(a.x + b.x, a.y + b.y);
}
}
public class Program
{
static void Main(string[] args)
{
Vector2 a = new Vector2(1, 1);
Vector2 b = new Vector2(2, 2);
Vector2 c = a + b;
Console.WriteLine($"x:{c.x} y:{c.y}");
}
}
下面整理了下C#允许重载的一元和二元操作符,以及由编译器生成的对应的CLS方法名。
一元C#操作符 | 特殊方法名 | 推荐的相容于CLS的方法名 |
---|---|---|
+ | op_UnaryPlus | Plus |
- | op_UnaryNegation | Negate |
! | op_LogicalNot | Not |
~ | op_OnesComplement | OnesComplement |
++ | op_Increment | Increment |
-- | op_Decrement | Decrement |
(无) | op_True | IsTrue |
(无) | op_False | IsFalse |
二元C#操作符 | 特殊方法名 | 推荐的相容于CLS的方法名 |
---|---|---|
+ | op_Addition | Add |
- | op_Subtraction | Subtract |
* | op_Multiply | Multiply |
/ | op_Division | Divide |
% | op_Moddulus | Mod |
& | op_BitwiseAnd | BitwiseAnd |
| | op_BitwiseOr | BitwiseOr |
^ | op_ExclusiveOr | Xor |
<< | op_LeftShift | LeftShift |
>> | op_RightShift | RightShift |
== | op_Equality | Equals |
!= | op_Inequality | Equals |
< | op_LessThan | Compare |
> | op_GreaterThan | Compare |
<= | op_LessThanOrEqual | Compare |
>= | op_GreaterThanOrEqual | Compare |
不过检查FCL类库会发现,它们的核心数值类型(Int32,Int64等),没有定义任何操作符重载方法。之所以不定义,是因为编译器会专门查找针对这些基元类型执行的操作,并生成直接操作这些类型的实例的IL指令。
通过ILDasm.exe查看上例例子操作符+的元数据:
我们可以发现这个方法被命名为op_Addition,且flag打上了specialName的标记。
转换操作符
有时需要将对象从一个类型转换为另一种类型(例如将byte转为int),这由几种情况需要讨论:
- 当源类型和目标类型都是编译器识别的基元类型时,编译器自己就知道如何生成转换对象所需的代码。
- 如果源类型或目标类型不是基元类型,编译器会生成代码,要求CLR执行转换(强制转型),这种情况下,CLR只是检查源对象的类型和目标类型(或从目标类型派生出的其他类型)是不是相同。
- 有时会希望一个类型转换为一个跟它毫无关联的类型,这个时候就需要利用到转换操作符。
CLR规范要求转换操作符必须是public static方法。此外,C#要求参数类型和返回类型二者必要其一与定义转换类型的方法相同,例如下例:
public class Vector2
{
public int x, y;
public Vector2(int x, int y)
{
this.x = x;
this.y = y;
}
// 由Vector2显式转为Vector3
public static explicit operator Vector2(Vector3 v)
{
return new Vector2(v.x, v.y);
}
}
public class Vector3
{
public int x, y, z;
public Vector3(int x, int y, int z)
{
this.x = x;
this.y = y;
this.z = z;
}
// 由Vector2隐式转为Vector3
public static implicit operator Vector3(Vector2 v)
{
return new Vector3(v.x, v.y, 0);
}
}
public class Program
{
static void Main(string[] args)
{
Vector3 vector3 = new Vector3(1, 1, 1);
Vector2 vector2 = (Vector2)vector3;
vector3 = vector2;
}
}
在C#中,implicit 关键字告诉编译器在不需要额外写转换代码的情况下自动进行类型转换。换句话说,如果有一个转换是安全的,编译器就会自动进行转换,不需要我们手动指定。例如:
public class Celsius
{
public double Degrees { get; set; }
// 允许从 double 到 Celsius 的隐式转换
public static implicit operator Celsius(double d)
{
return new Celsius { Degrees = d };
}
}
Celsius temp = 36.5; // 这里自动转换成了 Celsius 对象
在上面的代码中,你直接把一个 double 值赋给 Celsius 类型,编译器知道这个转换是安全的,就自动帮你完成了。
explicit 关键字则是告诉编译器,只有当开发者明确写出转换操作时,才进行类型转换。也就是说,这个转换可能不是完全安全的,需要你明确告诉编译器“我要进行这个转换”。例如:
public class Fahrenheit
{
public double Degrees { get; set; }
// 需要从 Fahrenheit 到 double 的显式转换
public static explicit operator double(Fahrenheit f)
{
return f.Degrees;
}
}
Fahrenheit temp = new Fahrenheit { Degrees = 98.6 };
double degrees = (double)temp; // 必须加上 (double),显式转换
在上面的代码中,编译器不会自动进行 Fahrenheit 到 double 的转换,除非你明确告诉它 (double)。
扩展方法
直接上例子:
public static class TextHelper
{
public static int GetLength(this string str)
{
return str.Length;
}
}
public class Program
{
static void Main(string[] args)
{
string abc = "Hello World!";
Console.WriteLine(abc.GetLength());
}
}
挺常用的,就不详细摘录了,列一下书中提到要注意的点:
- C#只支持扩展方法,不支持扩展属性、扩展事件、扩展操作符等。
- 扩展方法必须在非泛型的静态类中声明。然后,类名没有限制,可以随便叫什么名字。当然,扩展方法至少要有一个参数,且只有第一个参数能用this关键字标记。
- C#编译器在静态类中查找扩展方法时,要求静态类本身必须具有文件作用域(如果静态类嵌套在另一个类中,会报错,error CS1109:扩展方法必须在顶级静态类中定义)。
用扩展方法扩展接口
public static class MiscHelper
{
public static void PrintItems<T>(this IEnumerable<T> collection)
{
foreach(var item in collection)
{
Console.WriteLine(item);
}
}
}
public class Program
{
static void Main(string[] args)
{
string abc = "Hello World!";
abc.PrintItems();
}
}
用扩展方法扩展委托
public static class MiscHelper
{
public static void SimpleInvoke(this Action action)
{
action?.Invoke();
}
}
public class Program
{
static void Main(string[] args)
{
Action action = () => Console.WriteLine("Hello World!");
action.SimpleInvoke();
}
}
ExtensionAttribute
在C#中,一旦用this关键字标记了某个静态方法的第一个参数,编译器就会在内部向该方法应用一个定制特性。该特性会在最终生成的文件的元数据中持久性地存储下来。
除此之外,任何静态类只要包含至少一个扩展方法,它的元数据中也会应用这个特性。类似地,任何程序集只要包含了至少一个符合上述特点的静态类,它的元数据中也会应用这个特性。这样一来,如果代码调用了一个不存在的实例方法,编译器就能快速扫描引用的所有程序集,判断它们哪些包含了扩展方法。然后,在这些程序集中,可以只扫描包含了扩展方法的静态类。在每个这样的静态类中,可以只扫描扩展方法来查找匹配。利用这个技术,代码就能以最快速度编译完成。
分部方法
public partial class Program
{
partial void DoSomething()
{
Console.WriteLine("Hello World");
}
}
public partial class Program
{
partial void DoSomething();
static void Main(string[] args)
{
new Program().DoSomething();
}
}
分部方法有一些附加的原则和规则需要注意:
- 如果没有实现分部方法,编译器不会生成任何代表分部方法的元数据。此外,编译器不会生成任何调用分部方法的IL指令。而且,编译器不会生成对本该传给分部方法的实参进行求值的IL指令,运行时的性能会得到提升。
- 它们只能在分部类或结构中声明。
- 分部方法的返回类型始终是void,任何参数都不能用out修饰符标记。之所以有这种限制,是因为方法在运行时可能不存在,所以不能将变量初始化为方法也许会返回的东西。类似地,不允许out参数是因为方法必须初始化它,而方法可能不存在。分部方法可以有ref参数,可以是泛型方法,可以是实例或静态方法,而且可标记为unsafe。
- 分部方法的声明和实现必须具有完全一致的签名。
- 如果没有对应的实现部分,不能在代码中创建一个委托来引用这个分部方法。
- 分部方法总是被视为private方法,但C#编译器禁止在分部方法声明之前添加private关键字。