协变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("");
}