C#中的协变(Covariance)引起的面向对象设计问题一则

By | 2021年12月17日

协变(Covariance)与逆变(Contravariance)是Visual C# 4.0中引入的一个语言特性,目的是为了强化在泛型类型上继承关系的语义的合理性。举个非常简单的例子:爬行动物和哺乳动物都继承于动物,然后你不能将爬行动物的行为赋予动物类型,因为这种动物类型有可能是哺乳动物,它不具备爬行动物的行为能力。

虽然早在C# 4.0中就已经引入了对于协变与逆变的语言特性强化,并且我们在代码中多多少少获益于这种语法特征,但是平时我们很少关注过这个问题,甚至也没有仔细研究过,即使研究过,也很容易把自己绕进去。直到前两天,在项目中遇到了一个类型层次设计问题,这才使我不得不重温C# 4.0的“新特性”。

为了描述简单,我把我们项目中的代码用下面的类型层次替换:

class Animal
{
}

class Mammal : Animal
{
}

class Behavior<TAnimal>
    where TAnimal : Animal
{
}

class MammalBehavior<TMammal> : Behavior<TMammal>
    where TMammal : Mammal
{
}

class BehaviorService<TAnimal, TBehavior>
    where TAnimal : Animal
    where TBehavior : Behavior<Animal>
{
}

class MammalBehaviorService<TMammal, TMammalBehavior> : BehaviorService<TMammal, TMammalBehavior>
    where TMammal : Mammal
    where TMammalBehavior : MammalBehavior<TMammal>
{
}

然而,这段代码是编译不过的,Visual Studio在MammalBehaviorService<TMammal, TMammalBehavior>类上指出了编译错误:

对于这个类,它继承于BehaviorService<TMammal, TMammalBehavior>,第一个泛型类型参数被约束到Mammal类型,而BehaviorService<TMammal, TMammalBehavior>抽象类的第一个泛型类型约束是TAnimal,它是Animal类型,由于Mammal是Animal的子类,所以这个部分是没有问题的。问题在于第二个泛型类型参数TMammalBehavior,这里它被约束为MammalBehavior<TMammal>及其子类,而对于BehaviorService<TMammal, TMammalBehavior>抽象类而言,它的第二个泛型类型约束是Behavior<Animal>及其子类,由于MammalBehavior本身是Behavior的子类,而TMammal本身是Mammal及其子类,那么它当然也是Animal的子类,那么为什么TMammalBehavior这一泛型类型约束不能被转换到Behavior<Animal>类型呢?

于是,分析这个问题的思路就是简化代码,这个问题相当于就是:为什么下面的代码会编译出错:

其实这里的问题并不在于MammalBehavior和Behavior之间的继承关系,而在于两个泛型类型的泛型参数的类型不一致,即使它们存在继承关系。换句话说,下面的代码也是编译不过的:

初看感觉由于Mammal继承于Animal,这个编译理应通过,但其实并不是。为了分析其中的原因,我们在Behavior<TAnimal>中加入一个方法:

class Behavior<TAnimal>
    where TAnimal : Animal
{
    private TAnimal _animal;

    public void Execute(TAnimal animal)
    {
        _animal = animal;
    }
}

于是,问题显现出来了,MammalBehavior<TMammal>类型的泛型类型约束是Mammal类及其子类,也就意味着,MammalBehavior<TMammal>中的那个TAnimal成员变量其实是Mammal类及其子类,而如果上面的代码能够编译通过,那么在调用animalBehavior.Execute方法时,调用方完全可以传入一个Animal类及其子类的实例(例如Reptile(爬行动物)类),因为Behavior<Animal>类的TAnimal泛型类型就是Animal类及其子类。于是就回到了本文最开始举的例子:Mammal是Animal的子类,但是Animal的子类不仅仅有Mammal,Reptile也可以是Animal的子类,于是,animalBehavior.Execute方法在调用时,实际上是调用mb.Execute方法,而mb中的TAnimal被约束到Mammal及其子类上,那么将Reptile的实例传给animalBehavior.Execute方法就不合理了:Mammal和Reptile并没有继承关系。

但是,如果我把类的签名和方法改一下:

class Behavior<TAnimal>
    where TAnimal : Animal, new()
{
    public TAnimal CreateAnimal()
    {
        return new();
    }
}

那么理论上来说,上面的代码是可以编译通过的:将MammalBehavior<Mammal>类的实例赋给Behavior<Animal>类,那么在调用CreateAnimal方法的时候,它会返回Mammal实例,而Mammal正是Animal的子类,因此是完全没有问题的。

然而,C#没有在类的层面实现协变(以及逆变),而是在接口的层面(以及在委托层面,暂且不讨论),这是因为,C#没有不变类(immutable class)的设计,C#中的类的成员变量都是可以被赋值的,它并不要求其成员在构造函数执行时就确定其实例,因此,协变与逆变无法在C#的类层面实现。

于是,要改上面的设计,就需要引入支持协变的接口,从接口层面规定类的泛型类型是协变的:

interface IBehavior<out TAnimal>
    where TAnimal : Animal
{
}

然后让Behavior<TAnimal>类实现这个接口:

class Behavior<TAnimal> : IBehavior<TAnimal>
    where TAnimal : Animal
{
}

然后修改BehaviorService<TAnimal, TBehavior>类的定义,将TBehavior泛型类型约束从Behavior<Animal>改为IBehavior<Animal>,此时编译就自然成功了:

class BehaviorService<TAnimal, TBehavior>
    where TAnimal : Animal
    where TBehavior : IBehavior<Animal>
{
}

完整代码如下:

class Animal
{
}

class Mammal : Animal
{
}

interface IBehavior<out TAnimal>
    where TAnimal : Animal
{
}

class Behavior<TAnimal> : IBehavior<TAnimal>
    where TAnimal : Animal
{
}

class MammalBehavior<TMammal> : Behavior<TMammal>
    where TMammal : Mammal
{
}

class BehaviorService<TAnimal, TBehavior>
    where TAnimal : Animal
    where TBehavior : IBehavior<Animal>
{
}

class MammalBehaviorService<TMammal, TMammalBehavior> : BehaviorService<TMammal, TMammalBehavior>
    where TMammal : Mammal
    where TMammalBehavior : MammalBehavior<TMammal>
{
}

同理,下面的代码也能编译通过了:

这是因为,接口IBehavior<TAnimal>的泛型类型约束是协变的,它确保接口中所有使用到TAnimal类型的地方都是用作返回值,而不是函数的参数(也就是没有提供修改TAnimal实例的机会),从而确保类型的继承体系上不会出现问题。

(总访问量:292;当日访问量:1)

发表回复

您的电子邮箱地址不会被公开。 必填项已用*标注

此站点使用Akismet来减少垃圾评论。了解我们如何处理您的评论数据