.NET C#杂谈(1):变体 – 协变、逆变与不变
0. 文章目的:
介绍变体的概念,并介绍其对C#的意义
1. 阅读基础
了解C#进阶语言功能的使用(尤其是泛型、委托、接口)
2. 从示例入手,理解变体
变体这一概念用于描述存在继承关系的类型间的转化,这一概念并非只适用于C#,在许多其他的OOP语言中也都有变体概念。变体一共有三种:协变、逆变与不变。其中协变与逆变这两个词来自数学领域,但是其含义和数学中的含义几乎没有关系(就像编程语言的反射和光的反射之间的关系)。从字面上来看这三种变体的名字多少有点唬人,但其实际意思并不难理解。广泛来说,三种变体的意思如下:
- 协变(Covariance):允许使用派生程度更大的类型
- 逆变(Contravariance):允许使用派生程度更小的类型
- 不变(Invariance):只允许目标类型
或者换一种更具体的说法:
- 协变(Covariance):若类型A为协变量,则需要使用类型A的地方可以使用A的某个子类类型。
- 逆变(Contravariance):若类型A为逆变量,则需要使用类型A的地方可以使用A的某个基类类型。
- 不变(Invariance):若类型A为不变量,则需要使用类型A的地方只能使用A类型。
(注意是‘协变/量’而不是‘协/变量’)
为了方便说明三者的含义,先定义两个类:
class Cat { }
class SuperCat : Cat { }
上述代码定义了一个Cat类,并从Cat类派生出一个SupreCat类,如无特殊说明,后文的所有代码都会假设这两个类存在。下面利用这两个类逐一说明三种变体的含义。
2.1 协变:在一个需要Cat的场合,可以使用SuperCat
例如,对于下列代码:
Cat cat = new SuperCat();
cat是一个引用Cat对象的变量,从类型安全的角度来说,它应该只能引用Cat对象,但是由于通常子类总是可以安全地转化为其某一基类,因此你也可以让其引用一个SuperCat对象。要实现这种用子类代替基类的操作就需要支持协变,由于OOP语言基本都支持子类向基类安全转化,所以协变在很多人看来是很十分自然的,也容易理解。
2.2 逆变:在一个需要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的角度去考虑而已。
2.3 不变:在一个需要Cat的场合,只能使用Cat
相比逆变和协变,不变更容易理解:只接受指定类型,不接受其基类或者子类。比如如果Cat类型具有不变性,那么下述代码将无法通过编译:
Cat cat = new SuperCat(); // 错误,cat只能引用Cat类型
显然不变从表现上来说是理所当然与符合常识的,故本文主要阐述协变与抗变。
3. C#中的变体
3.1 C#中的变体
同大多数语言一样,C#同样遵循‘基类引用可以指向子类’这一基本原则,因此对C#来说协变是普遍存在的:
Feed(Cat cat)
{
...
}
Cat cat = new SuperCat(); // 本来需要指向Cat对象的变量cat被指向了SuperCat对象,利用了协变性
SuperCat superCat = new SuperCat();
Feed(superCat); // 同理,Feed方法需要Cat对象但是传入的是SuperCat对象,利用了协变性
C#中的不变体现在值类型上,这是因为值类型都不允许继承与被继承,自然也不存在基类或子类的概念,也不存在类型间通过继承转化的情况。
C#中的逆变在一般情况下没有体现,因为将基类转化为派生类是不安全的,C#不支持这种操作。所以逆变对C#来说很多时候其实只是概念上的认识,真正让逆变对C#有意义的情况是使用泛型的场合,这在接下来就会提到。
从学习语言语法的角度来说,了解变体对学习C#的帮助其实不大,但如果想更进一步理解C#中泛型的设计原理,就有必要理解变体了。
3.2 泛型与变体
理解变体对理解C#的泛型设计原理有重要意义,C#中泛型的类型参数默认为不变量,但可以是out
与in
关键字来指示类型为参数为协变量或者逆变量。简单来说,in
关键字用于修饰输入参数的兼容性,out
关键字用于修饰输出参数的兼容性。这一节会通过具体的泛型使用示例来解释变体概念对C#泛型的意义。
3.2.1 泛型委托
(1)输入参数的兼容性:逆变
考虑下面的泛型委托声明:
delegate void Action<T>(T arg);
上述委托可以接受一个参数类型为T,返回类型为TReturn的委托。下面来定义一个方法:
void Feed(Cat cat)
{
}
Foo是一个接受一个Cat对象,并返回一个SuperCat对象的方法。因此,下面的代码是理所当然的:
Action<Cat> act = Feed;
然而,从逻辑上来讲,下面的代码也应该是合法的:
Action<SuperCat> act = Feed;
委托act接受的参数类型为SuperCat,也就是说当调用委托act的时候传入的将会是一个SuperCat对象,显然SuperCat对象可以安全地转换为Foo所需要的Cat对象,因此这一转变是安全的。我们以委托act的视角来看:本来act应该引用的是一个‘参数类型为SuperCat’的方法,然而我们却把一个‘参数类型为Cat的’Feed方法赋值给了它,但结合上面的分析我们知道这一赋值行为是安全的。也就是说,本来此时泛型委托Action<T>中泛型类型参数T需要的类型是SuperCat,但现在实际给的类型却是Cat:
(红色是方法参数类型)
Cat是SuperCat的基类,也就是说这时候泛型委托Action<T>的类型参数T这个位置上出现了逆变。尽管从逻辑上来说这是合理的,但是C#中泛型类型参数默认具有不变性,因此如果要使上述代码通过编译,还需要将泛型委托Func的类型参数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的要求的:
同样以委托func的视角来看,本来需要类型Cat的地方现在实际给的类型是SuperCat,也就是说,此时出现了协变。同样的,如果要使上述代码通过编译,应该需要将Func的类型参数T声明为协变量,可以在泛型参数前添加out关键字将泛型类型参数声明为协变量:
delegate T Func<out TReturn>();
3.2.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类型所替代:
SuperCat代替了Cat,也就是说出现了协变,那么如果要使上述代码通过编译,则需要将类型参数T声明为协变量:
interface ICollection<out T>
{
}
C#中的IEnumerable接口就将其类型参数T声明为了协变量,因此下面的代码可以正常运行:
IEnumerable<Cat> cats = new List<SuperCat>();
(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类型:
Cat替代了SuperCat,也就是说此时发生了逆变。同样的,如果要让上述代码通过编译,需要将IHand<>的类型参数T声明为逆变量:
interface IHand<in T>
{
void Pet(T animal);
}
这样下述代码就可以通过编译:
IHand<SuperCat> hand = new Hand<Cat>();
3.2.3 泛型方法
与泛型委托和泛型接口不同的是,泛型方法不允许修改类型参数的变体类型,泛型方法的类型参数只能是不变量,因为让泛型方法的类型参数为变体没有意义。一方面,泛型方法的类型参数会在方法被调用时直接使用目标类型,因此不存在需要变体的情况:
void Pet<T>(T cat)
{
...
}
Pet(new Cat()); // 此时T为Cat
Pet(new SuperCat()); // 此时T为SuperCat
另一方面,你不能给一个方法赋值。
TReturn Foo<T, TReturn>(T t)
{
...
}
Foo = ...; // ???
显然上述代码是无法通过编译的。综上,给泛型方法的类型参数定义为协变量或者逆变量是没有意义的,因此也没有必要提供这一功能。
3.2.4 泛型类
C#中的泛型类的类型参数同样只允许为不变量,这里以常用的泛型List<>为例,下面的代码是不允许的:
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既不是作为方法的返回类型,也不是作为方法的参数
}
既然连字段的类型都不能是协变的泛型类型,那么显然这样的类没有太大的意义。由于以上原因,泛型变体对于定义泛型类的意义不大。
4. 变体限制
C#对泛型中允许变体的类型参数有严格的使用限制,主要限制如下:
- 协变量只能作为输出参数(方法的返回值,不包out参数)
- 逆变量只能作为输入参数(方法的参数,不包括in、out以及ref参数)
- 只能是不变量、协变量或者逆变量三者之一
上述限制也说明了为何C#选择用out关键字来修饰协变量,in关键字来修饰逆变量。如果没有以上限制,可能出现一些很奇怪的操作,例如:
(1)假设:协变量可用于输入参数:
delegate void Action<out T>(T arg); // 此处协变量T作为了方法参数
void Call(SuperCat cat)
{
}
Action<Cat> f = GetCat;
上述代码中当委托f被调用时可能会传入一个Cat对象,然而其引用Call方法需要的是一个SuperCat对象,此时Cat类型无法安全转化为SuperCat类型,因此会出现运行时错误。
(2)假设:逆变量可用于方法的输出参数
delegate T Func<in T>(); // 此处类型参数T作为了方法返回类型
Cat GetCat()
{
...
}
Func<SuperCat> f = GetCat;
上述代码中当委托f被调用后,应当返回一个SuperCat对象,然而其引用的GetCat方法返回的只是一个Cat对象,同样,会出现运行时错误。
从上述例子中可以看出,对变体的适用范围进行限制显然有助于提高编写更安全的代码。
6. 变体杂谈
6.1 历史问题
C#的数组支持协变,也就是说下面的代码是允许的:
Cat[] cats = new SuperCat[10];
咋一看没什么问题,SuperCat的数组当然可以安全转化为Cat数组使用,然而这意味着下述代码也能通过编译:
object[] objs = new Cat[10];
objs[0] = new Dog();
但显然这会在运行时出现错误。数组协变在某些场合下可能有用,但很多时候错误的使用或者误用会导致没必要的运行时错误,因此应当尽可能避免使用这一特性。
6.2 缺点
使用变体要求类型可以在引用类型的层面上进行转换,简单来说就是变体只作用于引用类型之间。因此尽管object是所有类型的基类,但是下述代码依然无法通过编译:
IEnumerable<object> data = new List<int>();
这是由于int为值类型,显然值类型无法在引用类型层面转化为object。