C#夯实基础之接口(《CLR via C#》读书笔记)
一. 接口的类型
接口是引用类型.因此从值类型赋值给接口是需要装箱的.如下所示:
1 class Program 2 { 3 static void Main(string[] args) 4 { 5 ISay catSay = new Cat(); 6 catSay.Say(); 7 Console.Read(); 8 } 9 } 10 11 interface ISay 12 { 13 void Say(); 14 } 15 struct Cat : ISay 16 { 17 public void Say() 18 { 19 Console.WriteLine("Cat Say!"); 20 } 21 }
IL代码:
我们看到,这里有一个装箱的操作,而只有把值类型转到引用类型才会产生装箱操作,因而接口是引用类型.
二. 接口的定义
我们知道,在定义一个方法的时候,我们需要定义:
1.方法的签名:也就是这个方法的名称,参数与返回值
2.方法的实现:也就是这个方法具体的内容
接口只是定义了一系列方法的签名,不包含方法的实现,且签名不能有任何修饰符.这意味着:
1.属性 事件 索引器也可定义入接口中,因为它们本质上都是方法,不能定义字段;
2.接口不可以定义任何构造器方法
下面是IConvertible的接口定义,它定义了很多类型转换的方法,但是并没有任何实现.
三. 接口的实现(单继承与多继承)
一个类可以同时继承m个基类(m=0,1)和n个接口(n≥0),同时类必须显式实现所有接口,接口不能继承基类,但是可以继承接口.
我们定义如下:
1 interface ITest1 2 { 3 void Test1(); 4 } 5 interface ITtest2 6 { 7 void Test2(); 8 } 9 class Base 10 { 11 }
单继承:我们先类或接口来看只继承一个基类或接口的情况.如下所示:
1 class Test : Base//类继承类 2 class Test : ITest1//类继承接口 3 interface ITest1: ITtest2//接口继承接口 4 interface ITest1: Base//(错误)接口继承类
其实第4种情况我们也能理解:假设接口能继承类,那么我们接口是不是就有了方法的实现,前面我们说过,接口只能有方法的签名不能有方法的实现,两者就相悖了.
多继承:我们再来看类或接口继承一个类型和多个接口的情况
1 class Test : Base,ITest1,ITtest2//类继承1个基类和多个接口 2 interface ITtest3:ITest1,ITtest2//接口继承多个接口
使用接口的多继承,可以为一个类同时约束多个方法的实现,规定一个方法必须实现所有的的方法. 这既有好处,也不坏处.
1:接口有什么好处?
拿我们最简单的例子来说,我们都有电脑,如果我们想为这台电脑升级一块硬盘,我们只需要更新一块符合这个电脑主板上硬盘的接口的新硬盘就可以了,我们不用更换整台电脑,也不用只更新特定厂商的硬盘. 因为所有的硬盘厂商都遵循着公共的接口标准,这样耦合就降低了.
其实,这样的例子生活中随处可见,如我们常用的USB接口,电源插座等等.
2:接口继承的问题1
接口的坏处也显而易见,最明显的就是我们可能只需要实现一个接口中的一个方法,但被迫实现了接口中的其他方法,哪怕实现的方法体中没有任何代码.
3:接口继承的问题2—重名问题
还有一个问题,就是多接口继承带过来的问题.
我们定义如下接口:
interface ITest1 { void TestMethod (); } interface ITtest2 { void TestMethod (); } class Test : ITest1, ITtest2 { }
我们看到,ITest1和ITest2中有两个相同的方法,都是TestMethod (),Test类同时继承两个接口,那么问题来了,我们前面说过”必须显式实现所有接口方法”,Test类该如何实现这两个同名方法?
C#提供了答案,即是它们可以共用一个实现,我姑且称之为共用实现,也可以分别各自实现,我们称之为显式实现.
在讲共用实现和显式实现之前,我们需要加一个基础知识的铺垫.
在CLR中,当一个类型加载进来时,会为这个类型建立一个方法列表,它包括以下内容:
1.这个类本身的方法记录
2.从基类继承过来的虚方法记录
3.从接口接口过来的虚方法记录
那么,在前面Test的例子中,Test的方法列表是什么?
1.TestMethod//Test类本身的方法
2.Ojbect(隐式继承的基类)的方法,如下
3.TestMethod// 继承自ITest1接口的虚方法
4.TestMethod// 继承自ITest2接口的虚方法
共用实现
C#编译器一比较,发现TestMethod这个方法的签名相同,也是显式public的,因为它认为ITest1和ITest2的接口方法和Test类本身的TestMethod方法完全一致.在生成元数据的时候,标明Test的TestMethod方法和ITest1的TestMethod方法和Test2的TestMethod方法三者都应该引用同一个实现.因而,下面这段代码的输出是:Hello
1 class Test : ITest1, ITtest2 2 { 3 public void TestMethod() 4 { 5 Console.WriteLine("Hello"); 6 } 7 } 8 static void Main(string[] args) 9 { 10 new Test().TestMethod(); 11 Console.Read(); 12 }
可不可以指定接口自己特定的实现?也就是说ITest1有自己的接口实现, ITest2有自己的接口实现, Test也有自己的同名实现方法.答案是可行的,这就是下面要说的显式实现.
显式实现:在方法的名称前带上接口的名称,同时接口方法就默认为private,并且不能改变它的访问级别,调用时只能通过接口来调用,如下所示
1 class Test : ITest1, ITtest2 2 { 3 public void TestMethod() 4 { 5 Console.WriteLine("Test"); 6 } 7 void ITest1.TestMethod() 8 { 9 Console.WriteLine("Test1"); 10 } 11 12 void ITtest2.TestMethod() 13 { 14 Console.WriteLine("Test2"); 15 } 16 }
如果要调用Test类本身的TestMethod()方法,我们可以直接调用,如下所示:
new Test().TestMethod();
如果要调用Test1接口的TestMethod()方法,我们就需要通过接口调用了,如下所示:
((ITest1)new Test()).TestMethod();
这里需要说明的是,显式接口实现方法并不是类型的对象模型的一部分,而只是将方法与接口对应起来,同时避免公开行为.《CLR via C#》(P266)
另一种重名问题是接口继承接口.
如下所示, ITtest2与ITtest1都有TestMethod()方法签名,同时ITtest2继承自ITtest1,那么ITtest2中只能存在一个方法,会默认隐藏掉ITtest1的方法,这个时候,我们可以使用new关键字.
interface ITtest2: ITest1 { new void TestMethod(); }
4:接口继承的问题3—派生问题
当没有重名问题的时候,C#编译器要求将接口方法标记为public,同时CLR会将方法默认标记为virtual和sealed,这样可以公开调用但是不能被派生类重写.如下所示:
class Base : ITest1 { public void TestMethod () { } } class Test : Base { }
也就是说Test类不能重写TestMethod方法.但是,如果我们把TestMethod加上virtual,那么CLR会默认去掉sealed标记,这样Test类就可以重写TestMethod方法了.如下所示
class Base : ITest1 { public virtual void TestMethod() { } } class Test : Base { public override void TestMethod() { Console.WriteLine("Hello"); } }
当使用显式接口实现(重名和不重名的时候都可以用)的时候,这样的方法不能加virtural关键字,所以这个时候它不能被Test类所重写.如下所示:
class Base : ITest1 { void ITest1.TestMethod() { } } class Test : Base { //这里不能overvide TestMethod方法 }
四. 接口的调用
接口的调用有两种方式,其实文章的前面已经写出来了,这里再总结一下:
类调用:使用实现这个接口的类来调用接口的方法,它能调用类所有的方法,如下所示:
interface ITest1 { void TestMethod(); } class Test : ITest1 { public void TestSelf() { } public void TestMethod() { } }
接口调用:使用接口来调用接口的方法,它只能调用接口的方法
需要说明的是,类继承的一个重要特点是:凡能使用基类实例的地方,都能使用派生类的实例.
与此相似,接口继承的一个重点特点是:凡能使用接口实例的地方,都能使用实现了接口的一个类型的实例.前面的接口调用依据的就是这样的原理.摘自《CLR via C#》
五. 范型接口
这里直接给出《CLR via C#》中总结的3点.
1.提供编译时类型安全
当我们使用如下代码时,编译是没有问题的:
1 int i = 3; 2 int j= i.CompareTo("2");
但在调用的时候,因为字符串与整形不能直接比较,因而会报错:
这个时候我们就一脸懵逼了,你要是早点告诉我就好了,ok我们使用范型版本:
2.减少装箱的次数
1 int i = 3; 2 int m=2; 3 int j= i.CompareTo(m);
别看这里比较的都是值类型,但是这里m装箱了,因为CompareTo期待的是object类型.使用范型可以有效地规避这一问题,因为它会直接让你传入相应的类型数据.
3.可以实现同一个接口若干次
class Test : IComparable<string>, IComparable<int> { public int CompareTo(int other) { throw new NotImplementedException(); } public int CompareTo(string other) { throw new NotImplementedException(); } }
六. 范型接口的约束
使用范型接口时,我们可以限定,参数T必须实现了哪几个类和接口,才可以作为参数.以便更精细地控制我们的类.
如下所示:
为什么传入Guid类型的不行呢?
因为我们要求参数,同时实现了IComparable, IConvertible两个接口(where T: IComparable, IConvertible),但Guid类只实现了一个.
同时,C#编译器会为接口约束生成特殊的IL指令,以减少装箱操作,这里向M传入x参数不会造成装箱操作.如下所示:
需要说明的是,下面的例子,在实例化t时不会发生装箱,在调用CompareTo方法传递参数3的时候会发生装箱,但此时实例t仍然不会发生装箱,用Jeffrey Richter的话就是:如果值类型实现了一个接口方法,在值类型的实例上调用接口方法不会造成值类型的实例装箱.
七. 使用非范型接口的问题
前面我们探讨过使用接口遇到的几个问题,比如,一旦继承了一个接口,即使子类不需要全部的方法,也需要一一实现接口方法.这些是使用接口(范型接口和非范型接口)共同的问题,.NetFramework在非范型接口之后又提供了范型接口,肯定是范型接口解决了非范型接口的某些问题,为我们编程提供了更好的编程体验和性能.
举一例,在使用IComparable接口时,它的Compare方法接收一个object参数,使用这个版本的非范型版本最大的问题是会遇到编译时安全性问题.如下所示
从上面的代码,我们可以看出,在转换的时候,因为不能控制传入的类型,所以,在类型转换时会出错.
我们可以利用范型接口来解决这个问题,使用显式实现接口,使用类实例的时候,这个接口就可以不被外界访问了,同时用一个同名方法来实现我们的方法.
这里我们在编译时就可以查出这个问题,增强了编译时的安全性.如下所示:
但有一个问题,如果我们使用接口实例来访问接口方法,那么前面所述的问题同样会出现.
八. 使用显式实现接口的问题
在第7点,我们利用了显式实现接口时,接口方法就是private了.但这个特性有时也会带来新的问题,如下所示:
在派生类中无法访问基类的接口方法.对于这样的问题,与第7点中的例子相似,我们提供了一个同名方法:
1 public class Base : IComparable 2 { 3 public int CompareTo(object obj) 4 { 5 return 0; 6 } 7 int IComparable.CompareTo(object obj) 8 { 9 return 0; 10 } 11 } 12 public class Drived : Base 13 { 14 public void Test() 15 { 16 base.CompareTo(3); 17 } 18 }
这样就可以避免无法访问的问题.
九. 接口与基类的设计原则
既然接口与基类都在继承方面表现出了自己的特色.那有一个问题摆在我们面前:什么时候选择接口,什么时候选择基类?
要回答这个问题,我们要知道两者在使用时的一些特点:
接口:只是定义规范,每个继承接口的类都要实现一套自己的方法,彼此不能共享代码.如果接口有修正的时候,所有的子类都需要进行相应的变更.
基类:多个子类可以共享代码,对基类进行方法的添加时,子类可以直接用,而不需要进行相应的修改.
至于怎么用,因人因地而异.不过,我比较赞同Jeffrey Richter的建议:当父类与子类的关系是IS-A的关系,可以使用基类,如哺乳动物和牛;当父类与子类的关系是CAN-DO的关系时,可以使用接口,如牛和耕田
十. 总结
1.接口只定义方法签名与实现
2.必须显式实现接口的方法
3.可以显式接口方法实现,但会改变方法的访问级别
4.C#编译器为范型接口提供了约束,并进行了优化
十一. 参考文档
《CLR via C#(第4版)》