5.3.1 使用接口更改已装箱值类型中的字段(以及为什么不应该这样做)
2012-01-05 10:21 iRead 阅读(405) 评论(0) 编辑 收藏 举报下面让我们通过一些例子来验证自己对值类型、装箱和拆箱的理解程度。请研究以下代码,判断它会在控制台上显示什么:
using System;
//Point是一个值类型
internal struct Point{
private Int32 m_x,m_y;
public Point(Int32 x,Int32 y){
m_x = x;
m_y = y;
}
public void Change(Int32 x,Int32 y){
m_x = x;
m_y = y;
}
public override String ToString(){
return String.Format(“({0},{1})”,m_x,m_y);
}
}
public sealed class Program{
public static void Main(){
Point p = new Point(1,1);
Console.WriteLine(p);
p.Change(2,2);
Console.WriteLine(p);
Object o = p;
Console.WriteLine(o);
((Point)o).Change(3,3);
Console.WriteLine(o);
}
}
这段程序其实很简单,Main在栈上创建Point值类型的一个实例(p),并将它的m_x和m_y字段设为1。然后,在第一次调用WriteLine之前,p要进行装箱。WriteLine会在已装箱的Point上调用ToString,并像预期的那样显示(1,1)。然后,p用于调用Change方法,该方法将p在栈上的m_x和m_y字段的值都更改成2。第二次调用WriteLine时,要求再次对p进行装箱,并像预期的那样显示(2,2)。
现在,p要进行第三次装箱,o将引用已装箱的Point对象。第三次调用WriteLine会再次显示(2,2),这同样是我们预期的。最后,我们希望调用Change方法来更新已装箱的Point对象中的字段。然而,Object(变量o的类型)对Change方法一无所知,所以首先必须将o转型为一个Point。将o转型为Point要求对o进行拆箱,并将已装箱Point中的字段复制到线程栈上的一个临时Point中!这个临时Point的m_x和m_y字段会变成3和3,但已装箱的Point不会受这个Change调用的影响。第四次调用WriteLine方法,会再次显示(2,2)。这不是许多开发人员所预期的。
有的语言(比如C++/CLI)允许更改已装箱值类型中的字段,但C#不允许。不过,我们可以使用一个接口来欺骗C#,让他允许这个操作。以下代码是上例的修改版本:
using System;
//接口定义了一个Change方法
internal interface IChangeBoxedPoint{
void Change(Int32 x,Int32 y);
}
//Point是一个值类型
internal struct Point : IChangeBoxedPoint{
private Int32 m_x,m_y;
public Point(Int32 x, Int32 y){
m_x = x;
m_y = y;
}
public void Change(Int32 x, Int32 y){
m_x = x;
m_y = y;
}
public override String ToString(){
return String.Format(“({0},{1})”,m_x, m_y);
}
}
public sealed class Program{
public static void Main(){
Point p = new Point(1,1);
Console.WriteLine(p);
p.Change(2,2);
Console.WriteLine(p);
object o = p;
Console.WriteLine(o);
((Point)o).Change(3,3);
Console.WriteLine(o);
//对p进行装箱,更改已装箱的对象,然后丢弃它
((IChangeBoxedPoint)p).Change(4,4);
Console.WriteLine(p);
//更改已装箱的对象,并显示它
((IChangeBoxedPoint)o).Change(5,5);
Console.WriteLine(o);
}
}
上述代码与上一个版本几乎完全一致,主要区别在于Change方法是由IChangeBoxedPoint接口定义,Point类型现在实现了这个接口。在Main中,前4个WriteLine调用和前面的例子是相同的,生成的也是和前面的例子一样的结果(这是我们预期的)。然而,Main的末尾新增了两个例子。
在第一个例子中,未装箱的Point p转型为一个IChangeBoxedPoint。这个转型造成对p中的值进行装箱。然后,我们在已装箱的值上调用Change,这确实会将其m_x和m_y字段分别变成4和4。但是,在Change返回之后,已装箱的对象立即准备好进行垃圾回收。所以,对WriteLine的第5个调用会显示(2,2)。许多开发人员预期的并不是这个结果。
在最后一个例子中,o所引用的已装箱Point要转型为一个IChangeBoxedPoint。这里不需要进行装箱,因为o当前已经是一个装箱的Point。然后调用Change,它能正确修改已装箱的Point的m_x和m_y字段。接口方法Change允许我更改一个已装箱Point对象中的字段!现在,当调用WriteLine时,它会像预期的那样显示(5,5)。本例的宗旨是演示接口方法如何修改一个已装箱的值类型中的字段。在C#中,不用接口方法是无法达到这个目的的。
重要提示:
本章前面讲过,在值类型中定义的成员不应修改类型的任何实例字段。也就是说,值类型应该是不可变(immutable)的。事实上,我建议将值类型的字段都标记为readonly。这样一来,如果不慎写了一个方法企图更改一个字段,编译时就会报错。前面的例子非常清楚地揭示了这背后的原因。假如一个方法企图修改值类型的实例字段,调用这个方法就会产生非预期的行为。构造好一个值类型之后,如果不去调用任何会修改其状态的方法(或者如果根本不存这样的方法),就不用再为什么时候会发生装箱和拆箱/字段复制而担心。如果一个值类型是不可变的,只需简单地复制相同的状态就可以了(不用担心有任何方法会修改这些状态),代码的任何行为都将在你的掌控之中。
有许多开发人员审阅了本书的章节。在阅读了我的部分实例代码之后(比如前面的代码),他们告诉我他们再也不敢使用值类型了。这里我必须指出的是,值类型这些细微之处花了我几天的调试时间,这正是我为什么要在书中把它们指出来的原因。希望记住我在这里描述的一些问题。这样一来,当代码真正出现这些问题的时候,就会心中有数。不过,虽然如此,但有一点是肯定的,不应害怕值类型。它们是有用的类型,有自己的适用场合。毕竟,程序偶尔还是需要Int32的。只是要注意,取决于值类型和引用类型的使用方式,它们的行为也会出现显著的区别。事实上,在前面的例子中,将Point声明为一个class,而不是一个struct,即可获得令人满意的结果。最后还要告诉你一个好消息,FCL的核心值类型(Byte,Int32,UInt32,Int64,UInt64,Single,Double,Decimal,BigInteger,Complex以及所有enums)都是“不可变”的,所以在使用这些类型时,不会发生任何稀奇古怪的事情。