前几日,读了刀刀的一篇装箱拆箱 深度理解,刀刀认为由于ValueType中重写了ToString等方法,因此,在调用这些方法时,不会导致装箱,而我的观点正好相反,ValueType中重写的这些方法如果没有在值类型中重写,那么依然会被装箱。
既然两个人都表达了自己的论点,那么,必须要拿出相应的证据,来证明各自的观点。
如何证明
刀刀在回复中指出因为IL中没有使用box指令,因此不会发生装箱,不过这个论据并不能让我信服,原因很简单,IL中除了显式的box指令会导致装箱外,还有Constrained+虚方法调用形式(2.0为了支持泛型而加出来的Op),这种方式会导致隐式的装箱。
既然IL不能证明,那么什么方式可以证明?
大家还记得平时说的要避免频繁装箱不?为什么要尽量避免哪?
原因有两个:
- 装箱分配内存,影响性能
- 导致很多临时性的对象,加重GC负担
我们正可以利用第一点来证明到底有没有发生装箱。
我的证明
首先,准备5个不同的类型:
2 {
3 public override string ToString()
4 {
5 return GetType().ToString();
6 }
7 }
8
9 public class ReferenceTypeByTypeOf___
10 {
11 public override string ToString()
12 {
13 return typeof(ReferenceTypeByTypeOf___).ToString();
14 }
15 }
16
17 public struct ValueTypeWithoutOverride { }
18
19 public struct ValueTypeWithOverride___
20 {
21 public override string ToString()
22 {
23 return this.GetType().ToString();
24 }
25 }
26
27 public struct ValueTypeWithOverrideFix
28 {
29 public override string ToString()
30 {
31 return typeof(ValueTypeWithOverrideFix).ToString();
32 }
33
这5个类型分别为,2个引用类型(用于对比GetType与typeof的性能差异),1个没有重写的值类型(没有重写的话,实际实现就是GetType().ToString()),2个重写了的值类型,并且已经将类名整理成相同的长度,避免不必要的误差。
再加一个计时器:
MeasureCost1 static void MeasureCost<T>(string title, Action<T> action, T args)
2 {
3 var begin = Stopwatch.GetTimestamp();
4 action(args);
5 var end = Stopwatch.GetTimestamp();
6 Console.WriteLine(title + " Cost: " + ((double)(end - begin) / Stopwatch.Frequency).ToString("f6") + "s");
7
然后,就可以写比较代码了:
Measures1 private static void MeasureToString()
2 {
3 MeasureCost("ReferenceTypeByGetType", x =>
4 {
5 string s = null;
6 for (int i = 0; i < LoopCount; i++)
7 s = x.ToString();
8 Console.WriteLine(s);
9 }, new ReferenceTypeByGetType__());
10 MeasureCost("ReferenceTypeByTypeOf", x =>
11 {
12 string s = null;
13 for (int i = 0; i < LoopCount; i++)
14 s = x.ToString();
15 Console.WriteLine(s);
16 }, new ReferenceTypeByTypeOf___());
17 MeasureCost("ValueTypeWithoutOverride", x =>
18 {
19 string s = null;
20 for (int i = 0; i < LoopCount; i++)
21 s = x.ToString();
22 Console.WriteLine(s);
23 }, new ValueTypeWithoutOverride());
24 MeasureCost("ValueTypeWithOverride", x =>
25 {
26 string s = null;
27 for (int i = 0; i < LoopCount; i++)
28 s = x.ToString();
29 Console.WriteLine(s);
30 }, new ValueTypeWithOverride___());
31 MeasureCost("ValueTypeWithOverrideFix", x =>
32 {
33 string s = null;
34 for (int i = 0; i < LoopCount; i++)
35 s = x.ToString();
36 Console.WriteLine(s);
37 }, new ValueTypeWithOverrideFix());
38 MeasureCost("ValueTypeWithOverrideFix (boxing manual)", x =>
39 {
40 string s = null;
41 for (int i = 0; i < LoopCount; i++)
42 s = ((object)x).ToString();
43 Console.WriteLine(s);
44 }, new ValueTypeWithOverrideFix());
45 MeasureCost("Just a string", x =>
46 {
47 string s = null;
48 for (int i = 0; i < LoopCount; i++)
49 s = x.ToString();
50 Console.WriteLine(s);
51 }, "Just a string.");
52
最后,添加一个字符串,字符串的ToString就是返回自身,用于比较循环本身的代价,对了,差点忘了这个:
const int LoopCount = 10000000;
这样,测试代码就准备好了,来看看Release下的执行结果吧:
starting MeasureToString...
ConsoleApplication10.ReferenceTypeByGetType__
ReferenceTypeByGetType Cost: 0.154173s
ConsoleApplication10.ReferenceTypeByTypeOf___
ReferenceTypeByTypeOf Cost: 0.154043s
ConsoleApplication10.ValueTypeWithoutOverride
ValueTypeWithoutOverride Cost: 0.223557s
ConsoleApplication10.ValueTypeWithOverride___
ValueTypeWithOverride Cost: 0.217400s
ConsoleApplication10.ValueTypeWithOverrideFix
ValueTypeWithOverrideFix Cost: 0.149262s
ConsoleApplication10.ValueTypeWithOverrideFix
ValueTypeWithOverrideFix (boxing manual) Cost: 0.199377s
Just a string.
Just a string Cost: 0.024276s
分析
跑出结果并不难,问题是要正确分析。
首先,看第一个和第二个,比较GetType().ToString()和typeof(xxx).ToString()的性能差异,,当然试验结果是非常接近的,因此,我们可以认为对于引用类型而言GetType()和typeof(xxx)的性能是非常接近的。
然后,看第三个和第四个,一个是ValueType的默认实现,另一个是override调用GetType().ToString()的实现,两者的性能也基本一致,别急着下结论说因此证明确实没有装箱。
接着,看第四个和第五个,从第一个和第二个的比较中,已经可以证明,GetType()与typeof(xxx)的性能基本一致,但是在第四个和第五个的比较中,却看到了巨大的差异,为什么?很简单,GetType()是object的方法,因此每次调用都会被装箱,因此,第三个和第四个的性能才会如此接近,同时也证明第4个类的重写方式其实是错误的(至少在提高性能方面)。
然后,将看一下第三、四个和第六个,第六个强制装箱后,性能与不重写,和错误的重写的性能基本一致,从而,证明不重写也会装箱。
最后,Just a string.是用于计算循环本身和调用的方法的代价。
抛出个问题:为什么说第四个类的重写方式是错误的?
不知道,大家有没有想过这个问题,值类型的某个方法里面调用了另一个导致自身装箱的方法。
在前面的测试中,已经可以看出这个方式和不重写的性能基本一样
不妨在做个试验:
1 MeasureCost("ValueTypeWithOverride (boxing manual)", x =>
2 {
3 string s = null;
4 for (int i = 0; i < LoopCount; i++)
5 s = ((object)x).ToString();
6 Console.WriteLine(s);
7 }, new ValueTypeWithOverride___());
再看看执行结果:
ConsoleApplication10.Program
starting MeasureToString...
ConsoleApplication10.ReferenceTypeByGetType__
ReferenceTypeByGetType Cost: 0.157408s
ConsoleApplication10.ReferenceTypeByTypeOf___
ReferenceTypeByTypeOf Cost: 0.148192s
ConsoleApplication10.ValueTypeWithoutOverride
ValueTypeWithoutOverride Cost: 0.216962s
ConsoleApplication10.ValueTypeWithOverride___
ValueTypeWithOverride Cost: 0.213888s
ConsoleApplication10.ValueTypeWithOverrideFix
ValueTypeWithOverrideFix Cost: 0.149800s
ConsoleApplication10.ValueTypeWithOverrideFix
ValueTypeWithOverrideFix (boxing manual) Cost: 0.202390s
Just a string.
Just a string Cost: 0.020379s
ConsoleApplication10.ValueTypeWithOverride___
ValueTypeWithOverride (boxing manual) Cost: 0.271406s
这个错误的重载方式,在手工装箱的情况下,跑出来的成绩进一步落后了0.06s(基本就是装箱需要的时间),也就是说,这样的错误重载,在极端情况下可能导致两次装箱。
梳理结论
重新梳理一下整个过程,可以得出下列结论:
- 使用引用类型类证明GetType()和typeof(xxx)的性能基本相当(当然会有些误差,不过这些误差原没有装箱的代价大),
- 证明不覆盖的情况下会导致装箱,
- 证明在不正确覆盖的前提下,并不见得能提高性能
- 证明在正确的覆盖的前提下,可以提高性能
- 证明在不正确覆盖的前提下,某些条件下反而会降低性能