协变Covariance和逆变Contravariance
在 C# 中,协变和逆变能够实现数组类型、委托类型和泛型类型参数的隐式引用转换。简单掉说,协变和逆变有一个基本的公式:
协变:IFoo<父类> = IFoo<子类>; 简单点说:从儿子变成老子,年龄自然长大,顺理成章,叫做协变。
逆变:IBar<子类> = IBar<父类>; 简单点说:从老子变成儿子,年龄从大到小,有点不可逆,叫做逆变。
当然你应该看不懂这个公式,没关系,这边文章将会循序渐进的给大家介绍协变和逆变的概念,以及协变和逆变是为了解决什么问题。
下面我们先列出来C#中支持协变逆变的一些常用的接口和委托
C#中的协变接口和逆变接口
1、IEnumerable<T>(T 是协变)
2、IEnumerator<T>(T 是协变)
3、IQueryable<T>(T 是协变)
4、IGrouping<TKey,TElement>(TKey 和 TElement 都是协变)
5、IReadOnlyList<T>(T 是协变)
6、IReadOnlyCollection<T>(T 是协变)
7、IComparer<T>(T 是逆变)
8、IEqualityComparer<T>(T 是逆变)
9、IComparable<T>(T 是逆变)
C#中的协变接口和逆变接口
C#中的Func委托是支持协变的委托,Action是支持逆变的委托。
父类Person和子类Student
首先我们声明一个父类Person,包含一个Name字段,表示人的姓名。然后声明一个Student子类继承自Person,Student有一个字段School,表示学生所在的学习。声明如下:
class Person
{
public string Name { get; set; }
}
class Student : Person
{
public string School { get; set; }
}
然后我们在程序中可以这么调用,我们用Person去接受一个Student,这是非常好理解的,从面向对象继承的角度来讲完全合理,因为我需要一个人Person,你给了我一个学生Student。
Person person = new Student();
现在问题来了,我想要五个人,用一个List<Person>
表示,能够用List<Student>
对象去赋值给List<Person>
吗,答案是不可以!
List<Person> persons = new List<Student>();
当我们在C#中这么写的时候,就会编译失败,并且提示类型转换错误,如下图:

上面的编译错误告诉我们,无法将List<Student>
类型转换为List<Person>
。但是从现实世界的角度来讲,我们是希望这个可以编译通过的。我想要五个人List<Person>
,你赋值给了我五个学生List<Student>
,这完全合情合理,学生本身就是人。
但遗憾的是,C#无法将泛型子类(List<Student>
)隐士转换到泛型父类(List<Person>
)。
为了解决这个转换问题,微软提出了协变这个概念,对于泛型接口的参数T来说,给这个参数T加一个修饰符out,表示这个参数支持协变,也就是可以安全的从子类转换为父类。具体写法如下:、
IEnumerable<Person> persons= new List<Student>();
我们用IEnumerable<Person>
去接受一个Student集合 的时候,发现编译成功了,这是因为IEnumerable
的参数T有out关键字修饰,out关键字 表示参数类型T支持协变,同时限定参数T只能作为方法的返回值出现。IEnumerable
的定义如下:

List<Person>
不支持协变,是因为List
的泛型参数T没有out修饰符。
同时大家要注意的是:协变和逆变只支持泛型接口、委托和数组。无法对一个普通的泛型类添加out关键字(协变)或者in关键字(逆变)。
接下来我们来声明一个泛型接口类ICovariant
,这个接口的泛型T用out修饰。
interface ICovariant<out T>
{
T GetName();
}
紧接着我们就用下面的代码来看下:
static void Main(string[] args)
{
ICovariant<Student> covariant = default;
ICovariant<Person> covariant1 = covariant;
}
我们将一个ICovariant<Student>
的变量成功赋值给ICovariant<Person>
的变量,并且编译成功。但是如果ICovariant
接口的参数T没有用out修饰,就会直接编译失败 ,因为只有用out修饰的参数T才 支持协变。
这就是泛型接口的协变,通过这个方案,我们可以轻松的泛型子类转换为泛型父类。
上面我给大家演示的泛型接口,接下来我们给大家演示以下支持协变的委托。我们先声明一个委托用于创建Person对象或者Student对象。
public delegate T GetObjectDelegate<out T>();
注意这个委托的泛型T使用了out关键字,表示这个委托的参数T是支持协变的。然后我们声明两个方法创建Person对象和Student对象。这两个方法的声明和委托的声明保持一致。
public static Student GetStudent()
{
return new Student();
}
public static Person GetPerson()
{
return new Person();
}
最后,我们声明一个子类委托GetObjectDelegate<Student>
,然后可以成功赋值给父类委托GetObjectDelegate<Person>
。这个用于委托类型的协变。
然而只要委托类型GetObjectDelegate
参数去掉out关键字,就会编译失败!
static void Main(string[] args)
{
GetObjectDelegate<Student> student = GetStudent;
GetObjectDelegate<Person> person = student;
}
协变的经典用法
static void Main(string[] args)
{
//协变委托 Func<out T> 只能作为返回值
Func<string> func = () => default;
Func<object> func1 = func;
//协变接口 IEnumerable<out T> 只能作为返回值
IEnumerable<string> strings = default;
IEnumerable<object> objects = strings;
//协变数组
string[] arr = new string[10];
object[] arr1 = arr;
}
逆变
说完协变,我们来说逆变。还是上面Person和Student的案例,我现在需要修改一个Person的姓名(Name),该怎么办呢。我先声明一个修改姓名的委托,对于泛型参数T,我们加了一个in关键字,它表示这个参数T支持逆变,限制泛型参数T只能用于方法的参数。可以将一个父类委托赋值给一个子类委托。
public delegate void SetNameDelegate<in T>(T obj);
我们现在可以将一个父类委托赋值给一个子类委托。
static void Main(string[] args)
{
SetNameDelegate<Person> setNameDelegate = SetName;
SetNameDelegate<Student> setNameDelegate1 = setNameDelegate;
}
public static void SetName(Person obj)
{
obj.Name = "person";
}
上面的代码我们将一个SetNameDelegate<Person>
类型的父类委托赋值给一个SetNameDelegate<Student>
的子类委托,可以正确编译并且执行。
但是,如果你去掉in关键字,就会编译失败,因为默认情况下,父类Person是不能直接转换为子类Student的。
这就是逆变在委托中的应用。那么接下来来看下逆变在接口中的应用,也是很简单的。我们声明一个可以修改名称的接口IContravariant<in T>
。
interface IContravariant<in T>
{
void SetName(T obj);
}
public class Contravariant : IContravariant<Person>
{
void IContravariant<Person>.SetName(Person obj)
{
obj.Name = "person";
}
}
然后就可以缉将一个IContravariant<Person>的接口赋值给子类接口IContravariant<Student>.。代码如下:
static void Main(string[] args)
{
IContravariant<Person> contravariant = new Contravariant();
//逆变
IContravariant<Student> contravariant1 = contravariant;
Student student = new Student();
contravariant1?.SetName(student);
Console.WriteLine(student.Name);
}
上述代码是可以编译通过的,但是如果你把IContravariant
接口中泛型参数T的in关键字去掉,代码就会编译失败。
以上就是逆变的介绍,简单点说,就是将一个父类型的委托或者接口转换成子类型的委托或者接口。
逆变的经典用法
static void Main(string[] args)
{
//协变委托 T只能作为参数
Action<object> action = (s) => { };
Action<string> action1 = action;
action1("");
//逆变接口 T只能作为参数
IComparable<object> contravariant = default;
IComparable<string> contravariant1 = contravariant;
contravariant1.CompareTo("");
}