【.NET C#基础】协变、逆变与不变

【.NET C#基础】协变、逆变与不变

文章目的:介绍变体的概念(包括协变、逆变与不变),并介绍其在C#中的意义以及使用
阅读基础要求:了解C#进阶语言功能的使用(尤其是泛型、委托、接口)

0.目录

1.基本概念

变体这一概念用于描述存在继承关系的类型间的转化,这一概念并非只适用于C#,在许多其他的OOP语言中也都有变体概念。

变体一共有三种:协变、逆变与不变。其中协变与逆变这两个词来自数学领域,但是其含义和数学中的含义几乎没有关系(就像编程语言的反射和光的反射之间的关系)。从字面上来看这三种变体的名字多少有点唬人,但其实际意思并不难理解。

广泛来说,三种变体的含义如下:

  1. 协变(Covariance):允许使用派生程度更大的类型。
  2. 逆变(Contravariance):允许使用派生程度更小的类型。
  3. 不变(Invariance):只允许目标类型。

或者换一种更具体的说法:

  1. 协变(Covariance):若类型T为协变量,则需要使用类型T的地方可以使用T的某个子类类型。
  2. 逆变(Contravariance):若类型T为逆变量,则需要使用类型T的地方可以使用T的某个基类类型。
  3. 不变(Invariance):若类型T为不变量,则需要使用类型T的地方只能使用T类型。

2.从示例入手

为了方便具体说明三者的含义,先定义两个类:

class Cat { }
class SuperCat : Cat { }

上述代码定义了一个Cat类,并从Cat类派生出一个SuperCat类,如无特殊说明,后文的所有代码都会假设这两个类存在。下面利用这两个类逐一说明三种变体的含义。

协变:在一个需要Cat的场合,可以使用SuperCat
例如,对于下列代码:

Cat cat = new SuperCat();

Cat是一个引用Cat对象的变量,从类型匹配的角度来说,它应该只能引用Cat对象,但是由于通常子类总是可以安全地转化为其某一基类,因此你也可以让其引用一个SuperCat对象。要实现这种用子类代替基类的操作就需要支持协变,由于几乎所有OO语言都支持子类向基类安全转化,所以协变在很多人看来是很十分自然的,也容易理解。

逆变:在一个需要SuperCat的场合,可以使用Cat
逆变有时也被称为抗变,你可能会觉得逆变的含义非常让人迷惑,因为通常来说基类并不能安全转化为其某一子类,从类型安全的角度来看,这一概念应该似乎没有实际的应用场合,尤其是对于静态类型的语言。然而,考虑以下代码:

delegate void Action<T>();
     
void Feed(Cat cat)
{
}
     
Action<SuperCat> f = Feed;

Feed是一个‘参数为Cat对象的方法’,而f是一个引用‘参数为SuperCat对象的方法’的委托。从类型匹配的角度来说,委托f应该只能引用参数为SuperCat对象的方法。然而如果你仔细思考上述代码,就会意识到既然委托f在调用时需要传入的是一个SuperCat对象,那么可以处理Cat类型的Feed方法显然也可以处理SuperCat(因为SuperCat可以安全转化为Cat),因此上面的代码从逻辑上来说是可以正常运行的。

那么也就是说,本来需要SuperCat类型的地方(这里是委托的参数类型)现在实际给的却是Cat类型,要实现这种用基类代替子类的操作就需要逆变。

不过,结合上述,你会发现所谓逆变实际还是依靠‘子类可以向基类安全转化’这一原则,只是因为我们是从委托f的角度去考虑而已。

不变:在一个需要Cat的场合,只能使用Cat
相比逆变和协变,不变更容易理解:只接受指定类型,不接受其基类或者子类。比如如果Cat类型具有不变性,那么下述代码将无法通过编译:

Cat cat = new SuperCat(); // 错误,cat只能引用Cat类型

显然不变从表现上来说是理所当然与符合常识的,无需过多阐述,故本文主要阐述协变与抗变。

3.C#中的变体

同大多数OO语言一样,C#同样遵循‘基类引用可以指向子类实例’这一基本原则,因此对C#来说协变是普遍存在的:

void Feed(Cat cat)
{
}
     
Cat cat = new SuperCat(); // 本来需要指向Cat对象的变量cat被指向了SuperCat对象,利用了协变性
Feed(superCat); // 同理,Feed方法需要Cat对象但是传入的是SuperCat对象,利用了协变性

C#中的不变体现在值类型上,这是因为值类型都不允许继承与被继承,自然也不存在基类或子类的概念,也不存在类型间通过继承关系转化的情况。

C#中的逆变在一般情况下没有体现,因为将基类转化为派生类是不安全的,对C#来说很多时候其实只是概念上的认识,真正让逆变对C#有意义的情况是使用泛型的场合,这在接下来就会提到。

从学习语言语法的角度来说,了解变体对学习C#的帮助其实不大,但如果想更进一步理解C#中泛型的设计原理,就有必要理解变体了。

4.泛型与变体

理解变体对理解C#的泛型设计原理有重要意义,C#中泛型的类型参数默认为不变量,但可以使用in和out关键字来指示类型参数为协变量或者逆变量,其中in关键字用于修饰输入参数的兼容性,out关键字用于修饰输出参数的兼容性。在这一节将通过具体的泛型使用示例来解释变体概念对C#泛型的意义。

4.1.泛型委托

(1) 输入参数的兼容性:逆变
考虑下面的泛型委托声明:

delegate void Action<T>(T arg);

上述委托是一个接受参数类型为T,返回类型为void的委托。下面来定义一个方法:

void Feed(Cat cat)
{
}

Feed是一个接受一个Cat对象,并返回一个void对象的方法。因此,下面的代码是理所当然的:

Action<Cat> act = Feed;

然而,从逻辑上来讲,下面的代码也应该是合法的:

Action<SuperCat> act = Feed;

委托act接受的参数类型为SuperCat,也就是说当调用委托act的时候传入的将会是一个SuperCat(或者其子类)对象,显然SuperCat(及其子类对象)可以安全地转换为Feed所需要的Cat对象,因此这一转变是安全的。

我们以委托act的视角来看:本来act应该引用的是一个‘参数类型为SuperCat’的方法,然而我们却把一个‘参数类型为Cat的’Feed方法赋值给了它,但结合上面的分析我们知道这一赋值行为是安全的。

也就是说,本来此时泛型委托Action<T>中泛型类型参数T需要的类型是SuperCat,但现在实际给的类型却是Cat:

void Feed (Cat)
Action <SuperCat>

由于Cat是SuperCat的基类,即这时泛型委托Action<T>的类型参数T这个位置上出现了逆变。

不过,尽管从逻辑上来说这是合理的,但是C#中泛型类型参数默认具有不变性,因此如果要使上述代码通过编译,还需要将泛型委托Action的类型参数T声明为逆变量,在C#中,可以通过在泛型类型参数前添加in关键字将泛型参数声明为逆变量:

delegate void Action<in T>(T arg);

(2) 输出参数的兼容性:协变
另一方面,下面的代码从逻辑上说也应该是合法的:

delegate T Func<T>();
     
SuperCat GetSuperCat()
{
}
     
Func<Cat> func = GetSuperCat;

委托func被调用时需要返回一个Cat对象,而GetSuperCat返回的是一个SuperCat对象,这显然是满足func的要求的:

SuperCat GetSuperCat ()
Func <Cat>

同样以委托func的视角来看,本来需要类型Cat的地方现在实际给的类型是SuperCat,也就是说,此时出现了协变。同样的,如果要使上述代码通过编译,应该需要将Func的类型参数T声明为协变量,可以在泛型参数前添加out关键字将泛型类型参数声明为协变量:

delegate T Func<out TReturn>();

4.2.泛型接口

(1) 输出参数的兼容性:协变
假设现有以下用于表示集合的接口声明与实现该接口的泛型类:

interface ICollection<T>
{ 
}
     
class Collection<T> : ICollection<T>
{
}

根据上述定义,理所当然的,下面的语句是合法的:

ICollection<Cat> cats = new Collection<Cat>();

然而,从逻辑上讲,下面的语句也应该是合法的:

ICollection<Cat> cats = new Collection<SuperCat>();

既然SuperCat是Cat的子类,那么Collection中的任意一个SuperCat对象都应该可以安全转化为Cat对象,那么SuperCat的集合也应该视为Cat的集合。从事实上讲,若对任何一个需要Cat对象集合的方法,即便传入的是一个SuperCat对象的集合也应该可以正常工作。同样以类型为ICollection<Cat>的接口变量cats的视角来看,ICollection<Cat>类型上本来应该为Cat类型的地方现在被SuperCat类型所替代:

ICollection <Cat>
Collection <SuperCat>

SuperCat代替了Cat,也就是说出现了协变。同样的,如果要使上述代码通过编译,则需要将类型参数T声明为协变量:

interface ICollection<out T> {}

(2) 输入参数的兼容性:逆变
接着再来考虑一个接口与实现类:

interface IHand<T>
{ 
    void Pet(T animal);
}
 
class Hand<T> : IHand<T> 
{
    void Pet(T animal) { ... }
}

下面的代码应该是合理的:

SuperCat cat = new SuperCat();        
 
IHand<SuperCat> hand = new Hand<Cat>(); 
 
hand.Pet(cat);

既然实现IHand<Cat>接口的Hand<Cat>的Pet方法可以处理Cat类型,显然其应该也可以处理作为Cat子类的SuperCat。同样的,以类型为IHand<SuperCat>的接口变量hand来看,本来应该需要类型为SuperCat的地方现在实际却是Cat类型:

IHand <SuperCat>
Hand <Cat>

Cat替代了SuperCat,也就是说此时发生了逆变。同样的,如果要让上述代码通过编译,需要将IHand<T>的类型参数T声明为逆变量:

interface IHand<in T>
{ 
    void Pet(T animal);
}

这样下述代码就可以通过编译:

IHand<SuperCat> hand = new Hand<Cat>();

4.3.泛型方法

与泛型委托和泛型接口不同的是,泛型方法不允许修改类型参数的变体类型,泛型方法的类型参数只能是不变量。这是因为让泛型方法的类型参数为变体没有意义。一方面,泛型方法的类型参数会在方法被调用时直接使用目标类型,因此不存在需要变体的情况:

void Pet<T>(T cat)
{
    ...
}
 
Pet(new Cat());      // 此时T为Cat
Pet(new SuperCat()); // 此时T为SuperCat

另一方面,你不能给一个方法赋值:

TReturn Foo<T, TReturn>(T t) 
{
    ...
}
 
Foo = ...; // ???

显然上述代码是无法通过编译的。综上,给泛型方法的类型参数定义为协变量或者逆变量是没有意义的,因此也没有必要提供这一功能。

4.4.泛型类

C#中的泛型类的类型参数同样只允许为不变量,这里以常用的泛型List<T>为例,下面的代码是不允许的:

List<Cat> cats = new List<SuperCat>();

哪怕从概念上说一个SuperCat的对象的集合用于需要Cat对象的集合的场景是合法的,但是这一行为确实是不允许的,原因是CLR不支持。

此外,C#限制协变量只能为方法的返回类型(后文会解释),所以下面的类定义是不可行的:

class Foo<out T>
{
    public T Get() { }              // 可以,协变量用于返回类型
    public Set(T arg) { }           // 错误,协变量不可用于方法参数
    public T Field;                 // 错误,参数类型T既不是作为方法的返回类型,也不是作为方法的参数
}

既然连字段的类型都不能是协变的泛型类型,那么显然这样的类没有太大的意义。由于以上原因,泛型变体对于定义泛型类的意义不大。

5.变体限制

C#对泛型中允许变体的类型参数有严格的使用限制,主要限制如下:

  1. 协变量只能作为输出参数(方法的返回值,不包括out参数)
  2. 逆变量只能作为输入参数(方法的参数,不包括in、out以及ref参数)
  3. 只能是不变量、协变量或者逆变量三者之一

上述限制也说明了为何C#选择用out关键字来修饰协变量,in关键字来修饰逆变量。如果没有以上限制,可能出现一些很奇怪的操作,例如:

(1) 假设:协变量可用于输入参数

delegate void Action<out T>(T arg); // 此处协变量T作为了方法参数

void Call(SuperCat cat)
{
     
}
     
Action<Cat> f = Call;
f(new Cat()); // 错误,委托f只需要一个Cat类型的参数,然而其指向的Call方法需要的是一个SuperCat类型的参数

上述代码中当委托f被调用时可能会传入一个Cat对象,然而其引用Call方法需要的是一个SuperCat对象,此时Cat类型无法安全转化为SuperCat类型,因此会出现运行时错误。

(2) 假设:逆变量可用于方法的输出参数

delegate T Func<in T>(); // 此处类型参数T作为了方法返回类型

Cat GetCat()
{

}

Func<SuperCat> f = GetCat;

SuperCat cat = f(); // 错误,委托f应返回SuperCat,然而其指向的GetCat方法只返回Cat

上述代码中委托f被调用后,应当返回一个SuperCat对象,然而其引用的GetCat方法返回的只是一个Cat对象,同样,会出现运行时错误。

从上述例子中可以看出,对变体的适用范围进行限制显然有助于编写更安全的代码。

(3) 另外,还有值类型安全
使用变体要求类型可以在引用类型的层面上进行转换,简单来说就是变体只作用于引用类型之间。因此尽管object是所有类型的基类,但是下述代码依然无法通过编译:

IEnumerable<object> data = new List<int>();

这是由于int为值类型,显然值类型无法在引用类型层面转化为object。

6.变体杂谈

(1) 老生常谈的历史问题 - 数组协变
C#的数组支持协变,也就是说下面的代码是允许的:

Cat[] cats = new SuperCat[10];

咋一看没什么问题,SuperCat的数组当然可以安全转化为Cat数组使用,然而这意味着下述代码也能通过编译:

object[] objs = new Cat[10];
objs[0] = new Dog();

但显然这会在运行时出现错误。数组协变在某些场合下可能有用,但很多时候错误的使用会导致没必要的运行时错误,因此应当尽可能避免使用这一特性。

posted @ 2022-06-08 12:48  HiroMuraki  阅读(2101)  评论(2编辑  收藏  举报