一个类型最多只能有一个基类,但是可以实现多个接口,接口不是基类。接口不能有基类,但是可以有多个父接口。没有实现任何接口,也没有基类的类型实际上隐式的从System.Object继承。
一个非常有趣的地方是,从不同的基类继承在CLR里还有不同的语义。比如值类型从System.ValueType继承,而所有可封送的对象从System.MarshalByRefObject继承,还有一个System.ContextBoundObject,嗯,还有委托。
在上一章讨论静态的相关东西时,已经说过,如果你使用abstract关键字修饰一个类型,那么这个类就不能被实例化,那这样的类天生就是作基类的命,如果你不想让一个类作为基类,那就用sealed修饰它吧。嗯,这样一下sealed和abstract两个冤家倒是不能碰在一起,不然一个天生就是做基类,一个有不允许它做基类,这不就要PK了么,也许你已经看了上一篇文章,其实这两个冤家在IL里还真碰到了一起,那就是为了实现2.0引入的static类型,不过注意,在C#层面这两个是不能放在一起的。
基类里面的非私有的成员,会隐式的作为派生类的成员(告诉都不告诉你一声,就自动的跑到派生类了)。那如果基类与派生类都定义了一个同名的字段,那在基类里不出现两个同名的字段了么,那该怎么访问呢?那就要先看看这个字段是静态的还是实例的,如果是静态字段,好说,我们只需要用类型名引用就OK了:
1: public class Base
2: {
3: public static int _field;
4: }
5: public class Child : Base
6: {
7: public static int _field;
8: public void Test()
9: {
10: Base._field = 5;
11: Child._field = 6;
12: }
13: }
那要是该字段是实例字段该怎么办,嘿嘿,C#已经为我们准备了this和base关键字:
1: public class Base
2: {
3: public int _field;
4: }
5: public class Child : Base
6: {
7: public int _field;
8: public void Test()
9: {
10: //这里默认是加了一个this关键字
11: _field = 5;
12: //实际上和上一句的意义是一样的
13: this._field = 5;
14: //通过base关键字访问基类里的成员
15: base._field = 7;
16: }
17: }
上面是从派生类内部访问字段,那如果是在外部访问呢:
1: Child c = new Child();
2: Base b = c;
3: c._field = 5;
4: b._field = 6;
那么c._field访问的和b._field访问有什么区别呢,它们其实引用的都是同一个对象啊,不过由于b和c两个变量的类型不一样,所以它们看到的契约也不同。通过c访问_field的时候,由于Child隐藏了Base类里面的_field,所以这里毫无疑问,访问的是Child类里面的_field,如果用b变量访问呢,由于b是Base类型的,它并不知道Child的契约(或者公有的接口,就是一些公有成员),所以它访问的是Base类里的_field。实际上,我们使用ILDasm看看内部IL代码:
1: //给Child的_field赋值
2: IL_000a: ldc.i4.5
3: IL_000b: stfld int32 BaseType.Child::_field
4:
5: //给Base的_field赋值
6: IL_0011: ldc.i4.6
7: IL_0012: stfld int32 BaseType.Base::_field
毫无疑问,这里的访问在编译期间就已经确定了。也就是所谓的静态绑定。
在编译上面的程序的时候,实际上我们还发现编译器生成了一个警告:
'BaseType.Child._field' hides inherited member 'BaseType.Base._field'. Use the new keyword if hiding was intended.
这个警告其实可以忽略,意思就是让你在Child上加个关键字new,标识这个_field字段隐藏了基类Base里的同名字段,实际上你加不加new对编译器最后生成的代码、元数据没有任何的影响,影响的是编译器的表现行为:编译器不再给警告了。C#里的这个new关键字负的责任太多了,实际上这里用一个new关键字并不恰当,还是VB.NET更加亲切,VB.NET对于这种情况使用Shadows关键字。
上面说的都是字段,那对于方法呢?方法跟字段可不同,字段就一个名字、一个类型,方法还有参数列表呢。因为要实现方法的重载,所以处理方法名的重用与字段名的重用有些不同。
CLR对于基类和派生类有相同名的方法时有两种策略:hide-by-signature和hide-by-name。这是通过是否在方法的元数据里添加hidebysig元数据实现的。顾名思义,hide-by-signature就是,不仅方法名相同,要签名也相同,派生类才能隐藏基类里的同签名的方法,够狠。那如果用hide-by-name的话,派生类里只要有一个Test方法,那基类里的所有Test方法,不管是有没有参数,多少个参数,都会被派生类里的那个Test方法给隐藏了。
对于这个策略是编译器相关的,对于C#编译器,默认就是hide-by-signature,而对于VB.NET编译器你可以使用Overloads(hide-by-signature)与Shadows(hide-by-name)关键字,对于C++,默认就是hide-by-name,这是由于“古典”C++的遗留问题决定的。好了,我们来看个示例吧:
1: public class Base
2: {
3: public void Test()
4: {}
5: public void Test(object o)
6: {}
7: }
8: public class Child : Base
9: {
10: public new void Test()
11: {}
12: public void Test(int i)
13: {}
14: }
由于这是使用的C#,所以默认的是hide-by-signature。
1: Child c = new Child();
2: Base b = c;
3: //调用的是Child类里的Test(),因为它隐藏了基类里的Test()
4: c.Test();
5: //调用的是Base里的Test()
6: b.Test();
7: //调用的是Child里的Test(int)
8: c.Test(5);
9: //调用的是Base的Test(object)
10: c.Test(“hello”);
实际上,你可以使用ILDasm看看,这里的调用关系在编译时已经确定了,不涉及到任何运行时绑定,CLR还对运行对方法的动态绑定提供支持,这个会在本书后面的相关内容里讨论。
嗯,本章还剩下最后一个问题,在一个继承树中,构造函数是如何调用的?
看下面一段程序(这段程序直接录自.NET本质论):
1: public class Base
2: {
3: public int x = a();
4: public Base()
5: {
6: b();
7: }
8:
9: static int a()
10: {
11: return 2;
12: }
13: private void b() { }
14:
15: }
16: public class D1 : Base
17: {
18: public int y = c();
19: public D1()
20: {
21: d();
22: }
23: static int c()
24: {
25: return 3;
26: }
27: private void d() { }
28: }
29:
30: public class D2 : D1
31: {
32: public int z = e();
33: public D2()
34: {
35: f();
36: }
37: static int e()
38: {
39: return 4;
40: }
41: private void f() { }
42: }
43: public class D3 : D2
44: {
45: public int w = g();
46: public D3()
47: {
48: h();
49: }
50: static int g()
51: {
52: return 5;
53: }
54: private void h() { }
55: }
当调用D3的构造函数,实例化的时候到底发生了什么事情呢?
我们用ILDasm反编译出D3的构造器的IL代码:
1: IL_0001: call int32 BaseType.D3::g()
2: IL_0006: stfld int32 BaseType.D3::w
3: IL_000b: ldarg.0
4: IL_000c: call instance void BaseType.D2::.ctor()
5: IL_0013: ldarg.0
6: IL_0014: call instance void BaseType.D3::h()
我们发现,编译器将public int w = g()这段代码插入到了构造器的第一行,然后又调用D3的基类D2的构造器,然后再到D3构造器本来就有的h方法,实际上,上面的D2,D1,Base的调用规则都是如此,所以简简单单一个实例化,却实际上发生了一连串的事情:
D3.ctor->g()->D2.ctor->e()->D1.ctor->c()->Base.ctor->a()->Object.ctor->b()->d()->f()->h()
最佳实践
由于继承等涉及的东西太多,如果你设计一个类,暂时还不想让它派生新类,那么就应该用sealed关键字修饰它,直到有必要要从它派生新类的时候才去掉。而且,如果一个类,确定只在程序集内部使用,那么就请使用internal关键字修饰这个类。