C#泛谈 —— 变体(协变/逆变)

有如下四个类。

    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中协变逆变的进化做了很详细的描述,有兴趣可以看一下。

 

posted @ 2013-06-28 16:20  Joe Cheung  阅读(945)  评论(0编辑  收藏  举报