厘清泛型参数的协变与逆变
Posted
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了厘清泛型参数的协变与逆变相关的知识,希望对你有一定的参考价值。
协变与逆变(CoVariant and ContraVariant),很多人是糊涂的,我也一直糊涂。其实,对协变与逆变概念糊涂,甚至完全不知道,对一般程序员也没有很大影响。不过,如果你想提高水平,想大概看懂.Net Framework类库中那些泛型接口与泛型类,想大概弄清楚Linq,这个概念还是需要搞清楚。
话又说回来,想弄清楚,其实还是挺费劲的。
如果你还糊涂着这两个概念,相信我,认真看完下面的文字,你会对泛型参数的协变与逆变有一个清晰的理解。
想透彻掌握协变、逆变的概念,首先需要对接口、委托以及泛型等概念有一个相对深入的理解和掌握,否则想理解CoVariant、ContraVariant是不容易的。这些基础知识的掌握,不是这篇随笔的责任,请自己看书。
首先明确几个知识点
(1) 在.Net Framework 4.0中,协变与逆变的概念,应用于泛型的可变类型参数时,只能用于泛型接口和泛型委托(请参考https://msdn.microsoft.com/EN-US/library/dd799517(v=VS.110,d=hv.2).aspx中明确了这一点)。明白这一点,可以将我们的思考范围圈定清楚,使理解和学习更加简单清晰。当然,除了泛型类型参数以外,协变与逆变的概念在其它地方也有应用。
(2) 任何时候,子类向父类转换是安全的,而父类向子类转换,则是不安全的。因为,子类中包含了父类的全部信息,转换为父类,当然没问题;而父类中不包含子类所特有的那些信息,所以,将父类转换为子类是不安全的。当然,如果父类类型的变量中存放的实际上是子类的对象,那么强制类型转换是可以的,这要另当别论。
(3) 再看看msdn中关于协变、逆变以及不变的说法(你可以跳过英文,看我的翻译。):
Covariance and contravariance are terms that refer to the ability to use a less derived (less specific) or more derived type (more specific) than originally specified. Generic type parameters support covariance and contravariance to provide greater flexibility in assigning and using generic types. When you are referring to a type system, covariance, contravariance, and invariance have the following definitions. The examples assume a base class named Base and a derived class named Derived.
-
Covariance
Enables you to use a more derived type than originally specified.
You can assign an instance of IEnumerable<Derived> (IEnumerable(Of Derived) in Visual Basic) to a variable of type IEnumerable<Base>.
-
Contravariance
Enables you to use a more generic (less derived) type than originally specified.
You can assign an instance of IEnumerable<Base> (IEnumerable(Of Base) in Visual Basic) to a variable of type IEnumerable<Derived>.
-
Invariance
Means that you can use only the type originally specified; so an invariant generic type parameter is neither covariant nor contravariant.
You cannot assign an instance of IEnumerable<Base> (IEnumerable(Of Base) in Visual Basic) to a variable of type IEnumerable<Derived> or vice versa.
简单翻一下:
术语协变与逆变是指可以用更泛化(更朝向父类型)或者更具体化(更朝向子类型)的类型替代原有指定类型的能力。泛型参数支持协变与逆变,则为泛型类型的赋值及其使用带来更大的灵活性。当我们讨论的是类型系统时(协变与逆变的概念不止应用于泛型类型系统),协变、逆变与不变分别定义如下。在下面的例子中,Base是父类的类名,Derived是子类的类名。
- 协变
协变使你可以使用一个更具体的类型替代原始类型。例如,你可以将一个IEnumerable<Derived>的实例赋值给一个IEnumerable<Base>类型的变量。
本人补充说明:用一个更具体的类型替代原始类型了,那就是说,子类型可以向父类型转换。考虑到子类型对象向父类型对象转换是正常的,就叫协变吧。
- 逆变
逆变使你可以使用一个更泛化的类型替代原始类型。例如,你可以将一个IEnumerable<Base>的实例赋值给一个IEnumerable<Derived>类型的变量。
本人补充说明:用一个更泛化的类型替代原始类型了,那就是说,父类型向子类型转换。这和上面的协变相反,叫做逆变。
- 不变
不变是指你只能使用原始指定的类型;因此,一个“不变”的泛型类型参数既不是协变的也不是逆变的。你不能将一个IEnumerable<Derived>类型的实例赋值给IEnumerable<Base>类型的变量。反过来也不行。
(4) 协变,在C#中使用out关键字表示,逆变使用in关键字表示。
请注意,本篇文章仅限于讨论泛型类型参数的协变与逆变问题,这也是这两个概念用的最多的地方。搞清楚这些,也就基本搞定协变与逆变了。
好了,让我们揭开协变与逆变的神秘面纱
我们前面说了,泛型类型参数的协变与逆变,只用于泛型委托和泛型接口。所以,这很容易枚举穷尽,别想的太复杂,只要我们把泛型委托和泛型接口中的协变与逆变搞清楚了,就等于把协变与逆变搞清楚了。
让我们先看一下委托吧。
泛型委托中的协变与逆变
委托,和C++中的函数指针比较类似。委托是一个引用类型。因此,你可以声明一个委托类型的变量,也可以对这个变量赋值。委托类型的变量存放的是一个方法,包括这个方法的参数和返回值。
先看一段代码:
1 public class program 2 { 3 delegate TResult Fn<in T, out TResult>(T arg); 4 static void Main() 5 { 6 Fn<Object, ArgumentException> fn1 = obj => obj == null ? new ArgumentException() : null; 7 Fn<string, Exception> fn2; 8 fn2 = fn1; 9 Exception e = fn2("123"); 10 } 11 }
第3行,首先定义一个委托类型Fn,该委托类型有两个泛型参数,一个是T,用in修饰,是逆变量,另一个是TResult,用out修饰,是协变量。
第6行,声明一个Fn类型的对象(变量)fn1,该委托对象的两个泛型参数的类型分别为object和ArgumentException。fn1使用lambda表达式实现,方法对Object类型的输入参数obj进行检验,若obj为null,则返回一个ArgumentException异常,否则,返回null。该Lambda表达式等价于如下方法:
1 ArgumentException function(object obj) 2 { 3 if(obj == null) 4 { 5 return ArgumentException; 6 } 7 else 8 { 9 return null; 10 } 11 }
第7行,再声明一个Fn类型的对象fn2,该委托对象的两个泛型参数的类型分别为string和Exception。
在第8行,我们直接将fn1赋值给fn2,换句话说,就是将Fn<Object, ArgumentException>类型的变量隐式赋值给了Fn<string, Exception>。
第9行,我们以一个字符串“123”为参数调用fn2方法,该方法返回一个null,指示“123”对象是合法参数对象,不需要抛出异常。
我们来分析一下这里面的协变与逆变。
首先,要清晰地记在心里的一点是,所谓的协变与逆变,都是指“类型”而言,而不是指对象。前面说过了,类型的对象,只能从子类向父类转换,一个实际的父类对象,永远不可能安全转换到子类。所以,别指望说“逆变”能实现父类对象转子类对象的梦想。
fn2=fn1这一句代码里面的协变与逆变,就是类型的协变与逆变,而不是类型的对象的。fn2与fn1都是TResult Fn<in T, out TResult>(T arg)这一委托类型的实例对象。将fn1赋值给fn2,是从fn1向fn2转换。
拿掉泛型之后,fn1及fn2的方法原型分别如下:
fn1: ArgumentException fn1(object); fn2: Exception fn2(string);
从泛型委托Fn的定义我们可以知道,其返回值为协变,参数值为逆变。这也就是说,Fn的一个对象实例能够向另一个Fn的对象实例转换的条件是:被转换的那个对象实例的返回值的类型必须是更具体化的,而参数值的类型必须是更泛化的。就fn2及fn1而言,ArgumentException类型较Exception类型更具体化,object类型较string类型更泛化。
上述规则为什么存在?更进一步,这些规则为什么是一种合理的存在?不合理的规则,即使存在,也无法执行。
我们先看看赋值语句fn2=fn1的实质是什么。其实质可以从上面的Exception e = fn2("123")语句中看出来。
其实质是:
(1) 当将fn1赋值给fn2以后,若调用fn2方法,则传递给它的参数及其回值,都是必须要符合fn2的定义的,也就是说,调用时,参数是string,而要求的返回值是Exception;
(2) 因为fn1被赋值给fn2了,所以,当调用fn2时,被执行的实际上是fn1。因此,实际上被执行的方法要求接收的参数是Object,而返回值是ArgumentException。
综上两点,被执行体要求Object,而传递过来的是string,没问题;被执行体返回了一个ArgumentException类型的返回值,而fn2要求的是Exception类型的值,没问题。因此,上述关于协变与逆变的规则,是合理的,可行的。
看清楚了吧,无论是类型的逆变还是类型的协变,都是要求实例对象(不是类型参数)从子类向父类转换。如果反过来,肯定是编译不通过。
这里插两句闲话,希望不会影响到大家对协变与逆变概念的理解。
(1)在上面例子中,若将delegate Fn定义中的in和out关键字拿掉,则fn2=fn1语句就无法通过编译。为什么呢?我们上面不是已经分析过了,fn1到fn2的转换,是顺理成章的事情,合理且合法,为什么会发生编译错误呢?我的理解是,这就是所谓的“不变”规则在起作用,MSDN明确说了,若泛型委托中的类型变量既不是协变也不是逆变,则其是不可变的,既然不可变,那么这种转换就是要被编译器禁止的。反过来,你若想让他们可变,对不起,请明确使用in或者(和)out关键字把你的想法显式告诉编译器。
(2)in和out关键字在泛型委托中不会有负面作用,只能使你定义的委托适用范围更广。因此,CLR via C#书中明确建议大家尽可能使用in和out关键字来修饰委托的泛型参数。书中原文如下:When using delegates that take generic arguments and return values, it is recommended to always specify the in and out keywords for contravariance and covariance whenever possible, as doing this has no ill effects and enables your delegate to be used in more scenarios.
(3)协变与逆变的概念,不仅应用于泛型类型参数,如下例所示。
1 delegate Object MyCallback(Circle cir); 2 3 string SomeMethod(Shape s) 4 { 5 return s.Area.ToString(); 6 } 7 8 void TestFn() 9 { 10 MyCallback callbackFn = SomeMethod; 11 }
例子中,Shape是基类,Circle是其派生类。
第1行定义了一个委托MyCallback,第3行至第6行定义了一个方法SomeMethod,其签名与MyCallback要求的并不一致。但是,从第10行可以看的,我们可以将SomeMethod赋值给MyCallback类型的委托变量。这里,也叫作协变和逆变。
由此可知,协变与逆变的概念,并不仅应用于泛型类型参数。这一对概念在其它地方也有出现。不过,泛型类型参数中的协变与逆变,是这对概念最主要的应用场所。
泛型接口应用中的协变与逆变
如果看明白了上面与泛型委托相关的协变与逆变,那么,泛型接口应用中的协变与逆变就相对容易理解了。
接口到底是什么?
限于本人的水平以及篇幅,这里不能对接口进行系统叙述。仅把本人认为重要的且与协变与逆变相关的一些知识点写在这里。
接口是.Net Framework为了规避多继承所带来的复杂性,又想享受多继承的好处而采取的一种措施。在接口中,只是规定了一些方法(其返回值类型、参数的类型及数量)。接口和抽象基类有类似的地方,但又有很多不同。接口也是一种类型,你可以声明接口类型的变量,也可以对这些变量赋值。一个实现了某一接口的实际类的实例对象,可以被赋值给一个接口变量,隐式。这就好像是说,接口是实现了接口的类的基类一样,将一个子类对象赋值给基类对象,当然是不需要显示转换的。
当我们讨论协变与逆变的时候,可以这样认为:接口,实际上就是一些方法的集合。
如下代码所示:
1 //先定义一个接口 2 interface ITest 3 { 4 string someMethod(object arg); 5 } 6 //再定义一个实现了接口的类
7 class ClassTest : ITest 8 { 9 public string someMethod(object arg) 10 { 11 return arg.ToString(); 12 }
13 public void otherMethod(){...}
14 }
有了上面的定义,可以进行如下变量声明:
ITest objInterface = new ClassTest();
因为ClassTest继承了ITest接口,所以new出来的ClassTest对象,可以隐式赋值给ITest类型的变量。
当我们声明了一个接口类型的变量时,这个变量能调用接口规定的方法,但是,不能调用这个接口变量所代表的实际对象所属的类中,不属于上述接口的其它方法。在上面的例子中,objInterface可以调用someMethod,却不可以调用otherMethod。
再说说泛型接口
泛型接口,就是泛型化后的接口,:)。
泛型接口可以定义泛型类型参数,如:ITestInterface<T>。
与泛型委托类似,也可以对接口的类型参数限定协变与逆变。例如,.Net框架类库中的一个协变泛型接口:
public interface IEnumerable<out T> : IEnumerable
这个泛型接口有一个类型参数T,这个T是用out修饰的,说明类型T是协变的。为此,我们可以书写如下两行代码:
IEnumerable<String> strings = new List<String>();
IEnumerable<Object> objects = strings;
其中,第一行,声明了一个IEnumerable<string>类型的一个变量strings,用实现了IEnumerable<T>的List<string>类的对象来赋值。第二行,将strings赋值给一个IEnumerable<Object>类型的变量objects。
这看起来好像顺理成章,因为string类型向object类型是可以无忧转换的。所以,IEnumerable<String>向IEnumerable<Object>也是无忧转换?
但是,等一下!
string==>object没问题,是不能说明IEnumerable<String>==>IEnumerable<Object>没问题吧!因为,string派生自object,但是,IEnumerable<String>却并非派生自IEnumerable<Object>,对不啦?一定要搞清楚,无论是Enumerable<String>,还是IEnumerable<Object>,都是派生自Object。他两位之间,是没有派生关系的。
既然没有派生关系,那么,这种转换为什么是可以的呢?
我们前面说过了,接口,实际上就是一些方法的集合。而委托呢?代表的是一个方法。这其中是不是有些联系?对,有联系,甚至,在讨论协变与逆变的概念的时候,接口和委托,实际上可以采用相同的方式去理解。
定义了可变的类型参数的接口,实际上是把这些可变的类型参数传递到接口里面的一个个方法而已,而这些单个的方法,在讨论协变与逆变概念的时候,可以看成委托,没毛病。
说到这里,聪明的你应该不需要我再继续啰嗦了,静下心来想一下,就会明白委托的协变与逆变到底是咋回事了。
泛型接口的类型参数,如上面的T,是传递给接口定义的方法使用的。
定义一个协变的类型参数T,就是告诉接口的方法,T这个类型是协变的,你可以用更朝向父类的类型替代原有类型。在语句IEnumerable<Object> objects = strings中,就是用object类型替换了string类型。一般而言,由于协变的类型参数是朝向父类类型转换,所以可用于接口方法的返回值。你返回一个string给object是没问题的。
同理,定义一个逆变的类型参数,则告诉接口的方法,这个类型是逆变的,你可以用更朝向子类的类型替代原有类型。一般而言,逆变的类型参数,可用于定义方法的参数,而不能用于返回值。这个地方分析起来挺绕人的,就不展开了,参考上面泛型委托中的协变与逆变相关内容的讨论就明白了。
总结
总的来讲,协变的泛型类型参数可用于委托的返回值类型,或者接口中方法的返回值类型。而逆变的泛型类型参数,则可用于委托的参数类型,或者接口的方法的参数类型。注意了,协变和逆变,指的都是类型,而不是对象,这是问题的关键,很多人迷惑,就是迷惑在这里。
就说这么多吧,文中有不对的地方,请各位达人批评指正。
以上是关于厘清泛型参数的协变与逆变的主要内容,如果未能解决你的问题,请参考以下文章