有如下四个类。
public class Animal { } public class Mammal : Animal { } public class Dog : Mammal { public void EatBone() { } } public class Panda : Mammal { public void EatBamboo() { } }
Animal animal = new Dog();
这样的赋值肯定是没问题的,但这只是多态。
变体的大概意思是:有T和U两个类型,并且T = U (此处的等号为赋值)成立,如果T和U经过某种操作之后分别得到T’和U’,并且T’ = U’也成立,则称此操作为协变;如果U’ = T’,则称此操作为逆变。
//以下代码能通过,则说明Operation是协变。
T = U; //=表示赋值 ↓ Operation(T) = Operation(U);
//类似的,以下操作为逆变。
T = U; ↓ Operation2(U) = Operation2(T);
一、特殊的协变——数组
我们常说协变和逆变是.net 4.0中引入的概念,但实际上并不是。其实只要符合上面定义的,都是变体。我们先来看一个.net 1.0中就包含的一个协变:
Animal[] animalArray = new Dog[10];
这个不是多态,因为Dog[]的父类不是Animal[],而是object。
我们对照变体的定义来看一下,首先Animal = Dog,这个是成立的,因为Dog是Animal的子类;然后经过Array这个操作后,等式左右两边分别变成了Animal[]和dog[],并且这个等式仍然成立。这已经是满足协变的定义了。
可能有人会困惑,这为什么等号就成立了呢?
我们有一点要明确的是,因为C#语言规定了Array操作是协变,并且Compiler支持了,所以等式就成立了。变体都是人为定的,你甚至可以规定任何操作都是协变或者逆变,无非就是使编译和在运行期变体处的赋值通过。
我们再看一下Array的应用:
Animal[] animalArray = new Dog[10]; //Line1 animalArray[0] = new Bird(); //Line2
上面的代码能编译通过,Line1处也能运行通过,但是到了Line2处就会抛异常,所以说虽然Array这个操作是一个协变,但并不是安全的,在某些时候还是会出错。
至于说为什么要支持Array这样的协变,据Eric Lippert在Covariance and Contravariance in C#, Part Two: Array Covariance说,是为了兼容Java的语法,虽然他本人也不是很满意这样的设计。
二、委托中的变体
在.net 2.0中委托也支持了协变,不过暂时还只是支持方法的赋值。
考虑下面的代码
//一个入参为Dog的委托。抓住了一只Dog,应该怎么处理? delegate void DogCatched(Dog d); //定义两个方法 void OnAnimalCatched(Animal animal) {} //处理抓到的Animal void OnDogCatched(Dog dog) {} //处理抓到的Dog Catch catchDog = OnDogCatched; //把抓到的Dog交给处理Dog的方法 catchDog = OnAnimalCatched; //把抓到的Dog交给处理Animal的方法
以上两个赋值都可以成功,其中第一个为符合委托原型的赋值。第二个则可以看做是Operate(Dog) = Operate(Animal),那这是一个逆变。
同样的,下面就是一个协变。
//一个返回值为Animal的委托,一个需要抓到一只Animal的任务 delegate Animal AnimalCatching(); //两个方法 Animal CatchAnAnimal() { return new Animal(); } //抓到一个Animal Dog CatchADog() { return new Dog(); } //抓到一个Dog AnimalCatching animalCatching = CatchAnAnimal; //把抓Animal的任务交给能抓到Animal的方法 animalCatching = CatchADog; //把抓Animal的任务交给能抓到Dog的方法
至于Action<T>和Func<TResult>(.net 3.5)等泛型委托,其实也是如此,同样只局限于方法给委托实例赋值,而不支持委托实例赋值给委托实例。下面的例子编译时会报错。
Action<Animal> aa = animal => { }; Action<Dog> ad = aa; //编译错误
三、泛型中的变体
我们常说的协变和逆变,大多数指的是.net 4.0中引入的针对泛型委托和泛型接口的变体。
泛型委托
我们发现,到了.net 4.0,之前不能编译的这段代码通过了
Action<Animal> aa = animal => { }; Action<Dog> ad = aa; //编译通过
其实是Action的签名变了,多了in这个关键字。
public delegate void Action<T>(T obj); //.net 4 之前 public delegate void Action<in T>(T obj); //.net 4
类似的,Func的签名也变了,多了out关键字
public delegate TResult Func<TResult>(); //.net 4 之前 public delegate TResult Func<out TResult>(); //.net 4
in和out就是C# 4.0中用于在泛型中显式的表示协变和逆变的关键字。in表示逆变参数,out表示协变参数。
对于泛型委托的变体这一块上,.net 4.0相对于之前的版本主要增强的就是委托实例赋值委托实例(方法赋值给委托实例是.net 2.0就支持的)。
泛型接口
在.net 4.0以前,Array是协变的(尽管它不安全),但IList<T>却不是,IEnumerable<T>也不是。而到了.net 4.0,我们终于可以这样干了:
IEnumerable<Animal> animals = new List<Dog>(); //.net 4正确
不过以下的操作还是会造成编译失败:
IList<Animal> a2 = new List<Dog>(); //错误
究其原因,当然还是因为IEnumerable<T>在.net 4.0中是协变的,IList<T>不是:
public interface IEnumerable<out T> : IEnumerable public interface IList<T> : ICollection<T>, IEnumerable<T>, IEnumerable
那泛型接口既然有协变的,同样也有逆变的,如IComparable<T>。
四、一些疑问
1,问:我们自定义的泛型接口和泛型委托是否可以随便加上in/out关键字,来表明它是逆变或者协变的?
答:这个当然是不可能的,编译器会校验。
一般来说,如果一个泛型接口中,所有用到T的方法,都只将其用于输入参数,则T可以是逆变参数;如果用到T的方法,都只将其用于返回值,则T可以是协变参数。
委托的输入参数可以是逆变参数;返回值可以是协变参数。
2,问:既然in/out不能乱加,为什么还要加呢?完全由编译器来决定协变或者逆变的赋值不可以么?
答:这个理论上应该是可以的,不过in/out关键字就像是一个泛型委托和泛型接口定义者同使用者之间的契约,必须显式的指定使用方式,否则,程序中出现一些既不是多态,又没有标明是协变或逆变,却可以赋值成功的代码,看起来比较混乱。
3,问:是不是所有的泛型委托和接口都遵从输入参数是协变的,输出参数是逆变的这一规律呢?
答:我们定义一个泛型委托Operate<T>,它的输入参数是一个Action<T>
delegate void Operate<T>(Action<T> action);
//两个Action<T>的实例
Action<Mammal> MammalEat = mammal => Console.WriteLine("mammal eat"); Action<Panda> PandaEat = panda => panda.EatBamboo();
//Operate<T>的实例 Operate<Mammal> MammalOperation = action => action(new Dog()); //Action<T>是逆变,所以这里是允许的。
然后我们可以执行下面的操作
//操作1
MammalOperation(MammalEat);
如果我们想让这个泛型委托是一个变体,按照我们通常的理解,T是用作输入参数的,那肯定就是逆变,应该加上in关键字。我们不考虑编译器的提示,假设定义成这样:
delegate void Operate<in T>(Action<T> action);
因为是逆变,所以,我们可以将Operate<Mammal>赋给Operate<Panda>
Operate<Panda> PandaOperate = MammalOperation;
由于上面这个Operate的T已经改成了Panda,所以其对应参数Action的T也应该改为Panda,所以上面的“操作1”可以改成这样:
//操作2 MammalOperation(PandaEat);
最终变成了PandaOperate = (new Dog()).EatBamboo()。这是个啥?完全不合常理。
实际上,当我们给Operate<T>加上in的时候,编译器就已经告诉我们,这是不对的了。写成out就可以了,说明这是一个协变,下面的操作也是可以的:
Operate<Animal> AnimalOperate = MammalOperation;
上面这个例子似乎说明了,也并不是所有的输入参数都是逆变的?其实这已经不完全是一个输入参数了,由于有Action<T>的影响,似乎就变成了“逆逆得协”?如果把Action<T>换成Func<T>,则Operate<T>就应该用in关键字了。是不是比较费脑?还好平时工作中很少碰到这种情况,更何况还有编译器给我们把关。
以上内容参考自Eric Lippert的Covariance and Contravariance In C#系列,对.net中协变逆变的进化做了很详细的描述,有兴趣可以看一下。