[转]关于struct的一些解释与class对比
近日看到的一个文章,搜索的来,不知哪位前辈,感谢了~
有关构造函数,调用这两个方面的内容
最近才知道struct和class的静态构造函数的触发规则是不同的,不像class在第一次使用类的时候触发静态构造函数。如果只访问struct实例的字段是不会触发静态构造函数调用的。通过测试发现当访问静态字段,struct本身的函数(静态和实例)和带参数的构造函数就会引起静态构造函数的执行。而调用默认构造和未覆写的基类虚函数是不会的。为什么呢?
让我们先来看看class和struct在调用构造函数时的区别。class使用newobj指令而struct使用initobj指令来构造对象。newobj在堆上申请一块内存并调用相应的构造函数进行初始化,然后将对象地址返回给计算栈。initobj则是从本地变量表中载入已经分配出来的struct实例然后初始化struct的各字段。这个初始化过程是CLR内部执行的,而不像class编译器会给class添加一个默认构造函数(这就是为什么struct不能给字段添加默认值的原因。但在类中如果给字段添加了默认值编译器就会自动在构造函数中添加字段赋值操作)。如果给struct中定义了一个有参数的构造函数,那么系统就不会使用initobj指令,而是直接用call指令调用带参数的构造函数。
我们最常见最常用的调用函数的指令是call和callvirt。对于静态函数使用call指令,对于class使用callvirt指令(不论class中的函数是不是虚的)。只有子类调用父类的函数的时候(避免递归调用)以及构造函数中(由编译器添加保证父类字段被初始化)使用call指令。而对于struct我们发现只要调用的函数是struct本身定义的都是使用call指令。call和callvirt指令的差别在于,call会把调用的函数当作静态函数看待,而不会关心调用当前函数时实例指针(this)是否为空。这就是struct调用函数时为什么都是call因为struct实例是不可能被置为null的。实际上class在调用非虚函数时实际上也是使用call的只是多做了一步验证——this是否为空,让我们来验证一下。
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
namespace CLR {
class Class_Test {
public void Test1() { Console.WriteLine("Test 1"); }
public virtual void Test2() { Console.WriteLine("Test 2"); }
public static void Test3() { Console.WriteLine("Static Method"); }
public override string ToString() {
return base.ToString();
}
}
class Program {
static void Main(string[] args) {
Class_Test c=new Class_Test();
c.Test1();
c.Test2();
Class_Test.Test3();
string str=c.ToString();
Console.ReadLine();
}
}
}
对应的汇编如下:
c.Test1(); //实例非虚函数 0000006b mov ecx,esi //将this放到ecx中,ecx在.net函数调用规则中保存第一个参数 0000006d cmp dword ptr [ecx],ecx //验证this是否为空,空指针的话dword ptr [ecx]就会报错 0000006f call FFEEC130 //调用函数 00000074 nop c.Test2(); //实例虚函数 00000075 mov ecx,esi 00000077 mov eax,dword ptr [ecx] //得到方法表地址,引用类型在堆上开始4个字节是方法表地址 00000079 call dword ptr [eax+38h] //因为是虚函数每次调用的时候都要计算要调用的函数地址 0000007c nop Class_Test.Test3(); //静态函数 00000083 call FFEEC140 //调用函数 00000088 nop public override string ToString() //子类调用父类函数 { //省略前面的汇编 return base.ToString(); //如果使用callvirt就会死循环 00000026 mov ecx,edi //从ecx中得到this 00000028 call 77A00F68 //调用函数 0000002d mov esi,eax //.net函数调用规则中eax保存返回值 0000002f mov ebx,esi 00000031 nop 00000032 jmp 00000034 }
通过上边的汇编我们可以看出class调用非虚函数时本质上使用了call指令,而调用父类函数时就是直接使用call,并且因为在实例函数中所以不需要验证this是否为空。这里说点题外话,在IL中我们经常会看到执行函数时将本地变量加载到计算栈中或者将计算栈中的结果保存到本地变量中这不是很慢的操作吗?实际上在大多数情况下是通过esi,edi这些寄存器来当缓存的,如果局部变量比较多才会保存到相应的栈上。从这里我们又印证了事实,.net的线程栈在每次执行函数时所创建的栈帧包含参数表,本地变量表,返回地址和计算栈。
继续说call指令的问题,我前面说了struct本身定义的都是使用call指令调用的如果你亲自动手实验的就会发现我说不对。如果struct覆写了基类的函数(GetHashCode,ToString)在调用是IL会使用callvirt来调用,我真的错了吗?
using System; using System.Collections.Generic; using System.Linq; using System.Text; namespace CLR { struct Struct_Test { bool _a; int _b; int _c; public Struct_Test(bool a,int c,int b) { this._a=a; this._b=b; this._c=c; } public void Test() { } public override string ToString() { return string.Format("{0}, {1}, {2}",this._a,this._b,this._c); } } class Program { static void Main(string[] args) { Struct_Test s=new Struct_Test(true,15,20); string str=s.ToString(); Console.ReadLine(); } } }
对应的IL代码
IL_0001: ldloca.s s IL_0003: ldc.i4.1 IL_0004: ldc.i4.s 15 IL_0006: ldc.i4.s 20 IL_0008: call instance void Test_Console.Struct_Test::.ctor(bool, int32, int32) IL_000d: nop IL_000e: ldloca.s s IL_0010: constrained. Test_Console.Struct_Test IL_0016: callvirt instance string [mscorlib]System.Object::ToString() IL_001b: stloc.1
如果你仔细观察会发现在callvirt调用的上面有这么一条指令constrained。让我们看看msdn里让人头晕的解释:
- 如果 callvirtmethod 指令前面带有前缀 constrainedthisType,该指令将按照以下步骤执行:
- 如果 thisType 为引用类型(相对于值类型),则 ptr 被取消引用,并作为“this”指针传递到 method 的callvirt。
- 如果 thisType 为值类型,而且 thisType 实现 method,则 ptr 作为“this”指针在不作任何修改的状态下传递到 callmethod 指令,以便 thisType 实现 method。
- 如果 thisType 为值类型,而且 thisType 不实现 method,则将取消对 ptr 的引用,对它进行装箱,然后将它作为“this”指针传递到 callvirtmethod 指令。
说白了就是:如果值类型在调用一个虚函数时如果改虚函数是该值类型实现的那么就以call形式调用,如果没有实现就以callvirt形式调用,并且要对值类型装箱。关于constrained更详细的分析请看这里。下面使用简易的方法来验证这个结论:
Struct_Test s=new Struct_Test(true,15,20); Console.WriteLine(GC.GetTotalMemory(false)); int hash=0; for(int i=0;i<10000000;++i) { hash=s.GetHashCode(); } Console.WriteLine(GC.GetTotalMemory(false)); Console.WriteLine(GC.CollectionCount(0));
运行结果为:141200 399104 127
从上面的结果可以看到如果没有覆写虚函数确实引起了装箱。让我在对比一下与调用ToString()时的不同,s.ToString()请看反汇编;
s.ToString(); 0000003d lea ecx,[ebp-44h] 00000040 call FFE4C0B0 00000045 nop s.GetHashCode(); 00000046 mov ecx,7C3810h //Struct_Test方法表地址 0000004b call FFE31FAC //在堆上分配空间 00000050 mov ebx,eax 00000052 lea edi,[ebx+4] 00000055 cmp ecx,dword ptr [edi] 00000057 lea esi,[ebp-44h] //将栈上数据拷贝到堆上 0000005a movq xmm0,mmword ptr [esi] 0000005e movq mmword ptr [edi],xmm0 00000062 add esi,8 00000065 add edi,8 00000068 movs dword ptr es:[edi],dword ptr [esi] 00000069 mov ecx,ebx 0000006b mov eax,dword ptr [ecx] //虚函数调用 0000006d call dword ptr [eax+30h]
所以我们使用struct要小心不要因为忘记了覆写虚函数而造成不必要的性能损失。而且在这里因为没有调用Struct_Test本身的函数所以不会触发静态构造的执行。最后说一下struct在调用函数的时候首先要得到this指针,比如IL_000e: ldloca.s s。大家注意看这里不是ldloc所以对于Struct_Test的函数调用来说第一个参数是ref Struct_Test,感觉ref的这个参数修饰用在这里才是最能体现价值的。
- 博客是我学习和思考的输出,愿你有所收获。
- 有想法请留言,共同探讨学习。
- 由于博主能力有限,文中可能存在描述不当,恳请指正!
- 你也可以关注我的公众号:ProgramLife042,名称:风之程序人生,方便接收最新内容。