[C#.Tips]也来谈谈接口欺诈

不得不先说明一下,这又是一篇跟值类型的装箱拆箱有关的文章,尽管我之前已近写了两篇随笔来阐述这个很基础的问题了。它们分别在:这里这里。本文中的代码示例出自后者,稍作了修改。
我们知道C#是一门“安全”的的语言,以至于它不让我们修改已装箱值类型实例中的字段。因为这种尝试会带来出乎意料的效果。下面就来解释一下为什么会有这种让很多程序员“意外”的情况发生以及如何“欺骗”C#来实现程序员真正的意图,尽管那样做不是合理的方式。
首先还是把我以前在这里提到的那段老代码翻出来:
 1    /// <summary>
 2    /// 重新订票的接口
 3    /// </summary>

 4    internal interface IReBook
 5    {
 6        Ticket ReBook(String newTerminal, Int32 newDistance);
 7    }

 8
 9    internal struct Ticket:IReBook
10    {
11        private String _start, _terminal;//起点和终点
12        private Int32 _distance;//距离
13
14        public Ticket(string start, string terminal, Int32 distance)
15        {
16            _start = start;
17            _terminal = terminal;
18            _distance = distance;
19        }

20
21
22        /// <summary>
23        /// 重写System.ValueType的ToString方法
24        /// </summary>

25        public override String ToString()
26        {
27            return String.Format("From {0} To {1} , {2} km",
28                _start,
29                _terminal,
30                _distance);//在方法的内部,_distance被装箱
31        }

32
33        IReBook Members
47    }

48
49    public sealed class Program
50    {
51        public static void Main()
52        {
53            Ticket t = new Ticket("北京""汉口"1225);
54            //值类型实例t在这里第一次被装箱:Ticket-->Object-->override ToString
55            Console.WriteLine(t);
56
57            //显示的装箱
58            // Console.WriteLine(((Object)t).ToString());
59
60            t.ReBook("上海"1400);
61            Console.WriteLine(t);
62
63            Object o = t;
64            Console.WriteLine(o);
65
66            Ticket t2 = ((Ticket)o).ReBook("广州"2000);
67            Console.WriteLine(o);
68            Console.WriteLine(t2);
69
70            //t-->IReBook,被装箱
71            ((IReBook)t).ReBook("广州"2000);
72            Console.WriteLine(t);
73
74            //o-->IReBook,无须装箱
75            ((IReBook)o).ReBook("广州"2000);
76            Console.WriteLine(o);
77        }

78    }

跟之前的代码相比,仅仅多了一个接口IReBook,然后在Ticket中实现了这个接口。输出方面,前5个输出都跟原来的代码一样,显示的结果也一样。我在后面增加了两个输出,您可以先猜猜第72行的输出结果会是怎样?
似乎我们原本的意图是修改车票为到广州,2000km。但是这里的输出却仍然是"From 北京 To 上海,1400 km"。这是违背了我们的初衷的(其实原本定义这样的方法就是不合理的)。

是什么原因会让我们的修改“失败”了呢?看了这篇文章的朋友应该能看出来,因为值类型实例t装箱成为引用类型IReBook,我们调用ReBook时,只是在CLR生成的已装箱的值类型实例(姑且称做tII)上进行了修改,由于没有任何引用指向tII,tII会被GC探知并回收。

那么如何强行让这种对值类型实例字段的改变变得合理呢?这就是引入接口的原因,来看第75行,我们把引用类型o(其指向的是已装箱的值类型tII)转型为IReBook,这是两个引用类型之间的转换,不存在装箱拆箱,不创建额外的副本,所以当我们在IReBook上调用ReBook方法时,会理所当然的显示改变后的结果"From 北京 To 广州,2000 km"。
这就是所谓的接口欺诈,间接地修改已装箱值类型的实例字段。
程序完整的输出结果如下:
显然,一个可变(mutable)的值类型,如这里的Ticket ,一般来讲都是不合理的设计,因为这会给我们带来像上述的出乎预料的结果,而且会产生额外的“垃圾”,这在我们定义任何一个值类型的时候都是应当注意的。就这个例子来说,明显定义Ticket为一个class是较好的设计,因为这样避免了产生"失控的对象"以及对它的操作。

总之,"一个值类型成员不应该修改任何实例字段" --《CLR via C#》。
顺便再多说一句关于接口的,一般来说不要尝试把未装箱的值类型转化为接口类型,因为那样做实际上是让CLR在背后为你“悄悄地”创建一个已装箱的值类型,而你却无法控制。

就写到这吧,我一直希望用最简单的话把问题说清楚,同时欢迎大家批评指正 :-)

posted on 2008-02-14 03:44  J.D Huang  阅读(845)  评论(2编辑  收藏  举报