貌似这几个关键字一直很热,我也凑个热闹,谈一谈,加深一下理解。
先看这两个class
class A
{
public void T()
{
Console.WriteLine("A");
}
}
class B : A
{
public new void T()
{
Console.WriteLine("B");
}
}
A a1 = new A();
A a2 = new B();
B b1 = new B();
b b2 = (B)a2;
a1.T();
a2.T();
b1.T();
b2.T();
执行上面的代码,结果如下:
A
A
B
B
第一个A好解释,因为就是调用的A的T方法。第一个B也好解释,是调用的B的T方法。
第二个A呢?为了看清楚事情的本质,我们只好去看下汇编代码了。为了方便,我这里就写一些伪汇编代码了,其中只摘录了主要的部分,省略了一些不必要的细节。
a1.T():
mov ecx,XXX --XXX代表某个地址
call FCFA8618 --A的T方法的具体地址
a2.T():
mov ecx,XXX --XXX代表某个地址
call FCFA8618 --A的T方法的具体地址
可以看到,调用T方法的时候,指向的是同一个地址。也就是说,虽然a2在内存里面指向的对象,本质上是B,但是因为a2是A类型的,所以调用的就是A::T()。当名字相同的时候(有两个T方法),类型起到了决定作用。
继续看:
b1.T():
mov ecx,XXX --XXX代表某个地址
call FCFA86A0 --B的T方法的具体地址,注意这个地址已经和上面的不一样了。
b2.T():
mov ecx,XXX --XXX代表某个地址
call FCFA86A0 --B的T方法的具体地址。
可以看到,b1和b2调用的都是B类的T方法。也就是说,对于实方法(就是没有用virtual修饰的方法),在编译之后,都会有一个固定的位置。调用的时候,直接去调用就可以了。这样实现也不难理解,毕竟,一切都是确定的,类型是确定的,方法是确定的,直接调用,当然是最合适的了。同时,b2也再次说明了,当方法名字一样的时候,类型起作用。
由此,我们也可以反向推出A和B的方法表。
A的方法表:
名字T,地址FCFA8618
名字X,地址XXX
…………
B的方法表:
名字T,地址FCFA86A0
名字A::T,地址FCFA8618
…………
注意B里面肯定是有A的T方法的,否则当我们用一个A类型去引用B的实例的时候,就没有办法调用A的T方法了。只是A的T方法,不能用B去调用而已。
我再补充一点,如果B里面不写new的话,编译器会为我们自动加上,同时会给出一个警告。
上面说完了new关键字,下面说virtual和override。
class C
{
public virtual void T()
{
Console.WriteLine("C");
}
}
class D : C
{
public override void T()
{
Console.WriteLine("D");
}
}
C c1 = new C();
C c2 = new D();
D d1 = new D();
D d2 = (D)c2;
c1.T();
c2.T();
d1.T();
d2.T();
执行上面的代码,结果如下:
C
D
D
D
第一个C和第二个D,就不解释了。为了解释清楚另外两个D,还是要看下汇编。
c1.T():
mov ecx,XXX --XXX代表某个地址
call XXX+38h --C的T方法的具体地址,是计算出来的
c2.T():
mov ecx,XXX --XXX代表某个地址
call XXX+38h --D的T方法的具体地址,是计算出来的,而且可以看到,偏移量一样,都是38h
d1.T():
mov ecx,XXX --XXX代表某个地址
call XXX+38h --D的T方法的具体地址,偏移量也是38h
d2.T():
mov ecx,XXX --XXX代表某个地址
call XXX+38h --D的T方法的具体地址,偏移量也是38h
可以看出来,无论是调用C还是D的T方法,都是在某个地址的位置上加上了一个相同的偏移量。所以,当调用c2的T方法时,虽然c2是C类型的,但是指向的还是一个D类型的实例,而且在调用T方法的时候,不是call了一个具体的地址,而是算出来了一个地址。因为是D类型,计算的时候是以D为基准的,所以算出来的就是D的T方法,而不是C的。
也就是说,通过virtual和override,我们定义了一个契约,用virtual修饰的方法,都会放在某个相对位置,并且后面的子类如果使用了override关键字,那子类的方法也会放在同样的相对位置。这样,我们就可以在子类里面实现各自的方法,只要遵守这个约定,方法就会放到同一个相对位置。这样,将来被调用的时候,就能够被灵活的调用到不同的方法了。
我再习惯性的补充一下:如果D里面没用使用override,那就跟B一样了。大家可以试试看。
下面,我来归纳一下由此反推出来的C和D的方法表:
C的方法表:
名字T,地址 某个位置偏移38h
名字X,地址XXX
…………
D的方法表:
名字T,地址 某个位置偏移38h
名字X,地址XXX
…………
当然了,得到了灵活性,必然要损失一些性能,就损失在了call的时候地址的计算上面了。那这个损失到底有多大呢?试试看吧。
我把T方法改一下,如下:
T()
{
i++;//因为打印太浪费时间了,不适合测试用
}
下面我分别调用a1.T、c1.T和d1.T各一千万次,并且重复三遍,看看时间上面有多少区别。其中a1代表实方法,c1代表父类的虚方法,d1代表子类的虚方法。从理论上来说,应该是a1最快,c1和d1一样的。(不应该认为基类会比子类快,因为上面的汇编已经表明,基类和子类都是同样的寻址方式。)
下面是我的测试结果,数字的单位是ticks数:
a:1406250
a:1250000
a:1250000
c:1250000
c:1406250
c:1406250
d:1406250
d:1406250
d:1250000
平均来看,确实a最快,c和d一样。不过,a也快得有限。所以,为了灵活性,我们还是可以放心使用虚函数的,只要不滥用就可以了。
上面主要讲了三个关键字。其实,这里面还有不少细节。就这样错过很可惜。在下一篇里面,我会谈谈这些细节的问题,里面会说到堆和栈的调用,详细了解一下.net运行时(CLR)到底是如何执行我们写的代码的。