你可以从两个盒子中抓住现象:逻辑盒子或者数学盒子。逻辑盒子看上去有些粗糙,但是很坚固;数学盒子很精致,却有些脆弱。数学盒子可以把一个问题漂亮地包装起来,但如果没有逻辑盒子首先抓住问题,数学盒子自己抓不住问题本身。
——John R. Platt
摘要
● 协变和逆变的定义是什么?给我们带来了什么便利?如何应用?
● 对于可变的泛型接口,为什么要区分成协变的和逆变的两种?只要一种不是更方便吗?
● 为什么还有不可变的泛型接口,为什么有的泛型接口要故意声明成不可变的?
● 复合的可变泛型接口遵循哪些规则?
● 协变和逆变的数学定义是什么?如何利用数学模型解释C#4里的协变和逆变的规则?
正文
简单来说,如果 IEnumerable<T> 接口是协变的,而且我们实现了一个这样的函数:
1 2 3 4 5 6 7 | static void PrintPersonName(IEnumerable<Person> persons) { foreach (Person person in persons) { Console.WriteLine(person.Name); } } |
PrintPersonName() 就可以接受 Person 的任意子类列表作为它的参数,譬如如果 Student 和 Teacher 都是 Person 的子类,我们就可以:
1 2 3 4 | IList<Student> students = new List<Student>(); IList<Teacher> teachers = new List<Teacher>(); PrintPersonName(students); PrintPersonName(teachers); |
在 C#4 之前,上面的代码是无法通过编译的,因为在 C#4 之前,IEnumerable接口是不可变的(invariant),这会迫使我们必须为每一个 Person 的子类型实现一个 PrintPersonName() 的重载,实在是非常麻烦:
1 2 3 4 5 6 7 8 | static void PrintPersonName(IEnumerable<Student> persons) { ... } static void PrintPersonName(IEnumerable<Teacher> persons) { ... } |
PS:在 C#4 之前,另一个解决方法是把 PrintPersonName() 也变成泛型方法,并把 T 指定为 Person 的子类:
1 2 3 4 5 6 7 | static void PrintPersonName<T>(IEnumerable<T> persons) where T : Person { foreach (Person person in persons) { Console.WriteLine(person.Name); } } |
这个函数也可以工作得很好,但是不如直接写 IEnumerable<Person> 那么简单、直接。
注:说一个泛型接口或者泛型参数是协变的或逆变的并不十分准确(后文会详加说明),不过为了简单起见,暂且先这么说着。
不可变、协变和逆变的定义
1. 如果接口的泛型参数没有用 in 或 out 参数修饰,它就是不可变的,例如 IList<T>。
我们只能这样:
1 2 3 | IList<Person> personList1 = null ; IList<Person> personList2 = null ; personList1 = personList2; |
既不能这样:
1 2 3 | IList<Person> personList1 = null ; IList<Student> stuList = null ; personList1 = stuList; // 编译错误:无法将IList<Student>隐式转换为IList<Person> |
也不能这样:
1 2 3 | IList<Person> personList1 = null ; IList<Student> stuList = null ; stuList = personList1; // 编译错误:无法将IList<Person>隐式转换为IList<Student> |
2. 如果接口的泛型参数以 out 修饰,表示它是协变的,例如 IEnumerable<out T>
我们不但可以这样:
1 2 3 | IEnumerable<Person> persons1 = null ; IEnumerable<Person> persons2 = null ; persons1 = persons2; |
也可以这样:
1 2 3 | IEnumerable<Person> persons = null ; IEnumerable<Student> students = null ; persons = students; // 可以将IEnumerable<Student>隐式转换为IEnumerable<Person> |
但是不能这样:
1 2 3 | IEnumerable<Person> persons = null ; IEnumerable<Student> students = null ; students = persons; // 无法将IList<Person>隐式转换为IList<Student> |
3. 如果接口的泛型参数以 in 修饰,表示它是逆变的,例如 IComparer<in T>
我们不但可以这样:
1 2 3 | IComparer<Person> personComparer1 = null ; IComparer<Person> personComparer2 = null ; personComparer1 = personComparer2; |
也可以这样:
1 2 3 | IComparer<Person> personComparer = null ; IComparer<Student> studentComparer = null ; studentComparer = personComparer; // 可以把IComparer<Person>隐式转换为IComparer<Student> |
但是不可以这样:
1 2 3 | IComparer<Person> personComparer = null ; IComparer<Student> studentComparer = null ; personComparer = studentComparer; // 无法将IComparer<Student>隐式转换为IComparer<Person> |
4. 从以上 3 点我们可以知道,协变和逆变是一对互斥的概念,亦即,如果一个泛型参数是协变的就一定不是逆变的;如果是逆变的就一定不是协变的;泛型参数可以既不是协变的也不是逆变的(也就是不可变的)。
5. 只有接口和委托可以是协变的或逆变的。
6. 协变的泛型参数只能作为方法的返回值的类型,逆变的泛型参数只能作为方法的参数的类型。(这个说法虽然不是完全准确,不过暂时我们可以先这么认为,后面还会详加讨论)例如:
1 2 3 4 5 6 7 8 9 10 | public interface ICovariantDemo< out T> { T GetAnItem(); // void SetAnItem(T v); // 将导致编译错误 } public interface IContravarianceDemo< in T> { //T GetAnItem(); // 将导致编译错误 void SetAnItem(T v); } |
逆变和协变的设计
我们可以看到,在 C#4 基础类库里,一些接口,像 IEnumerator<out T> 和 IComparable<in T> 已经分别用 out 和 in 关键字声明为协变的和逆变的:
1 2 3 4 5 6 7 8 | public interface IEnumerator< out T> : IDisposable, IEnumerator { T Current { get ; } } public interface IComparable< in T> { int CompareTo(T other); } |
而另一些接口,例如IList<T> 和 IEquatable<T>,仍然是不可变的:
1 2 3 4 5 6 7 8 9 10 | public interface IList<T> : ICollection<T>, IEnumerable<T>, IEnumerable { T this [ int index] { get ; set ; } void Insert( int index, T item); void RemoveAt( int index); } public interface IEquatable<T> { bool Equals(T other); } |
那么,第一个问题:为什么不把所有接口的泛型参数都自动变成可变的?对于可变的泛型参数,又为什么要区分为协变和逆变?我们可以把这个问题分解成3个具体的子问题:
1. 为什么 IComparable<in T> 被声明成可变的而 IEquatable<T> 却被声明成不可变的?
2. 为什么 IList<T> 被声明为不可变的?
3. 为什么一个泛型参数不可以即是协变的又是逆变的?
1. 为什么 IComparable<in T> 被声明成可变的而 IEquatable<T> 却被声明成不可变的?
我们先来看看 IComparable<in T> 被声明成可变的之后给我们带来了什么好处。为了更有实战的感觉,我们使用了一个稍稍有些复杂例子。
首先,为了能够把 IComparable<in T> 接口改成不可变的以便可以比较看看有什么不同,我们在示例代码中使用一个和它差不多的 IMyComparable 接口作为它的替代品:
1 2 3 4 | public interface IMyComparable< in T> { int Compare(T other); } |
然后,我们可以基于这个接口实现几个扩展方法:
1 2 3 4 5 6 7 8 9 10 11 | public static class CompareExtension { public static bool IsGreateThan<T>( this IMyComparable<T> lhs, T rhs) { return lhs.Compare(rhs) > 0; } public static bool IsLessThan<T>( this IMyComparable<T> lhs, T rhs) { return lhs.Compare(rhs) < 0; } } |
这样,任何实现了 IMyComparable 接口的类都可以自动拥有 IsGreateThan() 和 IsLessThan() 这两个辅助函数了。现在,我们让 Person 实现 IMyComparable 接口,它比较 Name 属性的大小:
1 2 3 4 5 6 7 8 | public class Person : IMyComparable<Person> { public string Name { get ; set ; } public int Compare(Person other) { return Name.CompareTo(other.Name); } } |
继承了 Person 的 Student 和 Teacher 也具有了按照 Name 属性比较大小的方法,不过,我们希望当 Student 和 Student 进行比较的时候,如果 Name 相同就按 Grade 比较大小:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | public class Student : Person, IMyComparable<Student> { public int Grade { get ; set ; } public int Compare(Student other) { if (Name == other.Name) return Grade.CompareTo(other.Grade); else return Name.CompareTo(other.Name); } } public class Teacher : Person { public int Level { get ; set ; } } |
现在,让我们测试一下:
1 2 3 4 5 6 7 8 9 | static void Main( string [] args) { Student jcl = new Student { Name = "Jing" , Grade = 2 }; // 景春雷 Student jcldd = new Student { Name = "Jing" , Grade = 1 }; // 景春雷的弟弟 Teacher ln = new Teacher { Name = "ln" , Level = 5 }; // 李老师 Console.WriteLine(jcl.IsGreateThan(ln)); // 输出:False Console.WriteLine(jcl.IsGreateThan(jcldd)); // 输出:True } |
在第8行,我们调用 IsGreateThan() 比较 jcl 和 jcldd 的大小。由于 Student 类实现了 IMyComparable<Student> 接口,而 Student 的父类 Person 实现了 IMyComparable<Person> 接口,所以编译器不知道第一个参数 lhs 到底应该是 IMyComparable<Person> 类型还是 IMyComparable<Student>,于是编译器放弃第一个参数,根据第二个参数 rhs 作出的类型推断:
1 | bool IsGreateThan<Student>( this IMyComparable<Student> lhs, Student rhs); |
这样就如我们期望的调用了 Student 里定义的 Compare() 函数。
有意思的是在第7行,同样的,由于 Student 类实现了 IMyComparable<Student> 接口,而 Student 的父类 Person 实现了 IMyComparable<Person> 接口,所以编译器不知道第一个参数 lhs 到底应该是 IMyComparable<Person> 类型还是 IMyComparable<Student>,于是编译器放弃第一个参数,根据第二个参数 rhs 作出的类型推断:
1 | bool IsGreateThan<Teacher>( this IMyComparable<Teacher> lhs, Teacher rhs); |
接下来发生的事情,可以等价于下面的代码:
1 2 3 | IMyComparable<Person> percmp = jcl; // 由于 Student 的父类实现了IMyComparable<Person>,所以这个赋值语句是合法的 IMyComparable<Teacher> teacmp = percmp; // 逆变的接口可以这样进行隐式类型转换 CompareExtension.IsGreateThan<Teacher>(teacmp, ln); |
最后实际调用的是 Person 里定义的 Compare() 函数。
现在,让我们把 IMyComparable 接口的泛型参数改为不可变的,也就是把 IMyComparable<in T> 改成 IMyComparable<T>,重新编译,会发现代码
Console.WriteLine(jcl.IsGreateThan(ln));
无法通过编译,因为我们把泛型参数改成了不可变的,就不再允许从 IMyComparable<Person> 到 IMyComparable<Teacher> 的隐式转换。
下面再来看看 IEquatable<T>,我们还是通过一个有代表性的小例子来比较逆变的和不可变两个版本的 IEquatable<T> 有什么异同。首先,我们声明一个支持逆变的 IMyEquatable<in T>:
1 2 3 4 5 | public interface IMyEquatable< in T> { // 返回 this 是否与 other 相等 bool Eq(T other); } |
然后,我们定义一个名为 Has() 的扩展方法,它会调用 Eq() 来判断两个对象是否相等:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | public static class EnumerableExtension { // 判断 source 是否包含 item public static bool Has<T>( this IEnumerable<T> source, T item) where T : IMyEquatable<T> { foreach (T i in source) { if (i.Eq(item)) return true ; } return false ; } } |
假设我们希望 Person 和它的所有的子类都通过 Name 属性的值判断是否相等,就会让 Person 实现 IMyEquatable<T> 接口,它的子类只要继承 Person 类就好:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | public class Person : IMyEquatable<Person> { public string Name { get ; set ; } public bool Eq(Person other) { return this .Name == other.Name; } } public class Student : Person { public int Grade { get ; set ; } } public class Teacher : Person { public int Level { get ; set ; } } |
这样,Person 的子类列表,例如 IList<Student>,也可以使用 Has() 方法:
1 2 3 4 5 6 | Student jcl = new Student { Name = "Jing" , Grade = 2 }; // 景春雷 Student jcldd = new Student { Name = "Jing" , Grade = 1 }; // 景春雷的弟弟 IList<Student> stus = new List<Student>(); stus.Add(jcl); stus.Add(jcldd); Console.WriteLine(stus.Has(jcldd)); // 输出:True |
第6行的 stus.Has(jcldd) 相当于 Has<Student>(sts, jcldd),它要求泛型参数实现 IMyEquatable<Student> 接口,但是 Student 没有实现IMyEquatable<Student> 接口,不过它继承了父类实现的 IMyEquatable<Person> 接口,又由于 IMyEquatable<in T> 的泛型参数被声明成了逆变的,也就是允许从 IMyEquatable<Person> 到 IMyEquatable<Student> 的隐式转换,所以这行代码才是合法的。
下面我们把 IMyEquatable<in T> 改成 IMyEquatable<T>,也就是把它改成不可变的:
1 2 3 4 5 | public interface IMyEquatable<T> { // 返回 this 是否与 other 相等 bool Eq(T other); } |
就会发现 stus.Has(jcldd) 无法通过编译了。那么,为什么不把 IEquatable<T> 声明成可变的呢?只能认为,IEquatable<T> 的设计者认为判断不同类型的对象是否相等的方法都应该是不同的,也就是说,他鼓励我们为每个类型实现一个 Equals() 方法:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | public class Person : IMyEquatable<Person> { public string Name { get ; set ; } public bool Eq(Person other) { return this .Name == other.Name; } } public class Student : Person, IMyEquatable<Student> { public int Grade { get ; set ; } public bool Eq(Student other) { return this .Name == other.Name && this .Grade == other.Grade; } } |
这样即使 IMyEquatable<T> 是不可变的,stus.Has(jcldd) 也可以通过编译了。当然,有些时候,我们确实需要让整个类层次都使用同样的 Eq() 方法(例如对于实体类,都是通过判断 Id 属性是否相等来判断两个实体是否相等),这时就必须显式指定泛型参数是基类型:
1 | Console.WriteLine(stus.Has<Person>(jcldd)); |
也就是说,IEquatable<T> 之所以没有被声明成支持逆变,不是不能,而是不愿。下面要讨论的 IList<T> 则是不能声明成可变的一个例子。
2. 为什么 IList<T> 被声明为不可变的?
简单来说,既然协变的接口的泛型参数只能作为函数的返回值,而逆变的接口的泛型参数只能作为函数的参数,那么像 IList<T> 这种 T 既要做为返回值又要作为参数的情况,自然只能声明为不可变的了。
更一般地说,既然泛型参数是协变的就一定不是逆变的;是逆变的就一定不是协变的,那么如果说一个泛型参数“既是协变的也是逆变的”,等同于说这个泛型参数“既不是逆变的也不是协变的”,也就是说这个泛型参数是不可变的。
3. 为什么一个泛型参数不可以既是协变的又是逆变的?
是为了在编译期进行类型安全检查。
我们都知道,只有子类型到父类型的隐式转换是类型安全的。例如下面的语句是类型安全的:
1 2 | Student jcl = new Student { Name = "Jing" , Grade = 2 }; // 景春雷 Person p = jcl; // 子类向父类的隐式转换是安全的 |
而父类向子类的类型转换是不安全的,我们希望下面的语句无法通过编译:
1 2 | Person p = new Person { Name = "aa" }; Student jcl = p; // 应该无法通过编译 |
当然,如果我们知道可以把父类转换成子类,可以使用显式的向下转型操作:
1 2 | Person p = new Student { Name = "Jing" , Grade = 2 }; Student jcl = p as Student; // 如果转型失败,jcl 将会是 null |
虽然编译器允许我们做这种显式向下转型操作绕过编译期类型检查,但是大多数情况下我们喜欢并且依赖着编译期类型检查。当语言支持泛型接口间的隐式转换时,我们同样希望编译器能为我们做类型安全检查。
可以设想一下,如果不必区分协变和逆变,只要我们想声明可变的泛型接口,直接就用加上一个 var 关键字,岂不是很方便?
1 2 3 4 5 6 | // 伪代码 public interface ISomeInterface< var T> { T GetAnItem(); void SetAnItem(T v); } |
然后,我们就可以这样使用它:
1 2 3 4 5 | ISomeInterface<Student> istu = null ; ISomeInterface<Person> iperson = null ; iperson = istu; Person p = iperson.GetAnItem(); // 将一个 Student 对象赋值给一个 Person 类型的变量,没问题 iperson.SetAnItem( new Person()); // 将一个 Person 对象赋值给一个 Student 类型的变量,不安全! |
在第4行,iperson 实际是一个 ISomeInterface<Student>,所以 GetAnItem() 返回的是一个 Student 对象,把一个 Student 对象赋值给一个 Person 类型的变量,是没问题的。
第5行,由于 iperson 的类型是 ISomeInterface<Student>, 所以 SetAnItem() 允许传入一个 Person 对象,但是 iperson 实际是一个 ISomeInterface<Student>,所以实际调用的是 ISomeInterface<Student>.SetAnItem(Student),也就是说实际是把一个 Person 对象赋值给了类型为 Student 的变量,这时候我们希望它无法通过编译。
反之,如果我们这样使用它:
1 2 3 4 5 | ISomeInterface<Student> istu = null ; ISomeInterface<Person> iperson = null ; istu = iperson; Student s = istu.GetAnItem(); // 将一个 Person 对象赋值给一个 Student 类型的变量,不安全! istu.SetAnItem( new Student()); // 将一个 Student 对象赋值给一个 Person 类型的变量,没问题 |
在第4行,istu 实际是一个 ISomeInterface<Person>,所以GetAnItem() 返回的是一个 Person 对象,把一个 Person 对象赋值给一个 Student 类型的变量,是不安全的,我们希望它无法通过编译。
在第5行,由于 istu 被声明成 ISomeInterface<Student> 类型,所以它的 SetAnItem() 允许接受一个 Student 对象,当然,istu 实际是一个 ISomeInterface<Person>,所以实际是把一个 Student 对象赋值给了 Person 类型的参数,这是没问题的。
由此可知,既然
1. 类型间的安全的隐式转换是单向的(譬如由子类向父类转换时),泛型接口(作为类型的一种)间安全的类型转换也必然是单向的。
2. 泛型接口间的类型转换有两个方向,一是只允许 ISomeInterface<子类型> 到 ISomeInterface<父类型> 的隐式转换,这时我们说这个泛型接口是协变的;一是只允许 ISomeInterface<父类型> 到 ISomeInterface<子类型> 的隐式转换,这时我们说这个泛型接口是逆变的。在多数情况下(不考虑函数的返回值或参数是另一个泛型接口或委托的情况),协变的泛型参数只允许作为输出的对象的类型,逆变的泛型参数只允许作为输入的对象的类型,这也是关键字被定为“out” 和 “in” 的原因。
假设我们分别定义了一个协变的接口 ICovariantDemo<out T> 和一个逆变的接口 IContravarianceDemo<in T>,下图可以让我们有更加直观的感受:
复合的情况
看一下 IEnumerable<out T> 的定义:
1 2 3 4 5 6 7 8 | public interface IEnumerable< out T> : IEnumerable { IEnumerator<T> GetEnumerator(); } public interface IEnumerator< out T> : IDisposable, IEnumerator { T Current { get ; } } |
一个协变的接口的函数的返回值是另一个协变的接口,这种复合情况又要遵循哪些规则呢?为了便于演示,我们定义一个 ICovariantDemo<out T> 接口:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 | public interface ICovariantDemo< out T> { // void SetAnIList(IList<T> list); // 编译错误 // IList<T> GetAnIList(); // 编译错误 T GetAnItem(); // void SetAnItem(T v); // 编译错误 ICovariantDemo<T> GetACoInterface(); // IContravarianceDemo<T> GetACotraInterface(); // 编译错误 // void SetACoInterface(ICovariantDemo<T> a); // 编译错误 void SetAContraInterface(IContravarianceDemo<T> a); Func<T> GetAnItemLater(); // void GetAnItemFromDelegate(Func<T> f); // 编译错误 // Action<T> DoSthLater(); // 编译错误 void DoSth(Action<T> a); } |
把这些情况背下来并不难,但是要想理解它却很容易晕头转向。如果我们把协变简单地理解为“T 只能作为输出而不能作为输入的对象的类型”,那第13行的“void SetAContraInterface(IContravarianceDemo<T> a); ”又怎么理解呢?第9行的返回值是另一个支持协变的接口又意味着什么呢?也许,我们可以试着更加抽象一点地思考这个问题。Eric Lippert 的一篇文章里有一个非常简明的小例子解释了数学上的协变和逆变的概念,我们一起来看一下。首先,我们要用到一个叫作投影(Projection)的术语。投影这个词在不同的地方有很多种不同的定义。譬如我们在中学的几何课上就曾经用过这个词儿(你还能想起来吗?想不起来没关系,反正我也想不起来了)。投影因其字面上的含义常常用来指降维,譬如三维空间里一根直线在一个平面上的投影可能只是一个点。对于一架飞机的行为,我们通常把它三维的飞行路线通过投影描绘成二维的地图。我们的路径不是:
路径 = f(经度, 维度, 高度)
而是:
路径 = f(经度, 维度)
投影后的路径可以看成是太阳直射到飞机上形成的投影跟踪曲线,这也是用“投影”这个术语的缘由。现在假设我们有3个投影 D、N、S,分别是:
D(x) = x * 2
N(x) = 0 - x
S(x) = x * x
先来看D,如果给出两个整数 x 和 y,如果满足 x <= y,是否一定有 D(x) <= D(y)?答案是肯定的,所以我们说投影 D 是协变的(covariant)。
再看N,如果给出两个整数 x 和 y,如果满足 x <= y,是否一定有 N(x) >= N(y)?答案是肯定的,所以我们说投影 N 是逆变的(contravariant)。
至于S,如果给出两个整数 x 和 y,如果满足 x <= y,是否一定有 S(x) <= S(y)?不一定。当 x=2 y=3 时,满足 x < y,此时 S(x) < S(y);但是当 x = -3 y = 2 时,同样满足 x < y,但是此时 S(x) > S(y) 。那么如果满足 x <= y,是否一定有 S(x) >= S(y)?也不一定。所以说 S 既不是协变的也不是逆变的,它是不一定怎么变的(invariant)。
值得注意的是,整数本身并不是可变的(variant),“小于”关系也不是可变的。投影——接收一个旧的整数并产生一个新的整数的规则——才是协变的或逆变的。
再来考虑引用类型到引用类型的投影。类型是否也可以有大小关系呢?我们可以这么定义类型间的大小关系:如果类型 Y 的对象可以赋值给类型为 X 的变量,我们就说 Y <= X。(为什么这么说呢?可以想象如果变量是一个盒子的话,既然 Y 的对象可以放到 X 盒子中去,自然是因为 Y 比 X 小了。当然你也可以反驳说既然 Y 的对象可以赋值给 X 类型的变量,说明 Y 的信息量比 X 大,所以应该是 Y >= X 才对,这也不是不行,重要的是这里只要有一个一致的定义就好)。我们可以定义一个投影 M,它接收一个类型 T 并产生一个类型 IEnumerable<out T>,即:
M(T) = IEnumerable<out T>
因为 Student 对象可以赋值给 Person 类型的变量,所以我们说 Student <= Person;又因为 IEnumerable<Student> 类型的对象可以赋值给 IEnumerable<Person> 类型的变量,所以有 IEnumerable<Student> <= IEnumerable<Person>,也就是 M(Student) <= M(Person)。简短来说,如果有两个引用类型 t1、t2 满足 t1 <= t2,就有 M(t1) <= M(t2),所以我们说“投影 M 是协变的”,把这句话说全了就是“接收一个引用类型 T 并产生一个泛型接口 IEnumerable<out T> 的投影是一个协变的投影”,为了简短起见,可以(概念上十分不准确地)说“IEnumerable<out T> 是协变的”。
如果说“IEnumerable<out T> 是协变的”的说法还勉强说得过去的话,是因为IEnumerable<out T>只有一个泛型参数。考虑下面这个 ISomeInterface 接口:
1 2 3 4 5 | public interface ISomeInterface< out P, in Q> { P GetAnItem(); void SetAnItem(Q q); } |
ISomeInterface 接口是协变的还是逆变的?我们既可以把它当作协变的接口:
1 2 3 | ISomeInterface<Student, Person> i = null ; ISomeInterface<Person, Person> j = null ; j = i; |
也可以把它当作逆变的接口:
1 2 3 | ISomeInterface<Person, Person> i = null ; ISomeInterface<Person, Student> j = null ; j = i; |
其实,ISomeInterface 可以当作一个协变的接口和一个逆变的接口的组合:
1 2 3 4 5 6 7 8 9 10 11 | public interface ISomeInterface1< out P> { P GetAnItem(); } public interface ISomeInterface2< in Q> { void SetAnItem(Q q); } public interface ISomeInterface< out P, in Q> : ISomeInterface1<P>, ISomeInterface2<Q> { } |
进一步地,我们可以认为协变的泛型接口只不过是一些协变的泛型函数的集合。这样我们就可以抛开“协变的泛型接口”这一容易分散我们的注意力的概念,直接考虑协变的函数。投影 M:
M(T) = IEnumerable<out T>
可以重新理解为“接收一个引用类型 T 并且生成一些泛型函数,这些泛型函数都是协变的”,而且既然这些函数都是离散的,为了简单可以一个一个地加以考虑,所以不妨把它简化成“接收一个引用类型 T 并生成一个泛型函数,且这个泛型函数是协变的”,但是什么是协变的泛型函数呢?函数可以当作类型并创建变量和实例以及比较大小吗?这个问题C#早就给出了答案,就是委托。也就是说我们可以认为
W(T) = Func<out T>
和
W(T) = ISomeInterface<out T> { T GetAnItem(); }
本质上是一样的。
现在可以考虑复合的情况了。
1. 为什么 IEnumerable<out T> 接口里的 “IEnumerator<T> GetEnumerator()” 函数是协变的。
如果有协变的投影 T:
T(x) = x * 3
把 D 和 T 复合得到 P:
P(x) = T(D(x))
如果高中课程没有忘光了的话,应该可以证明两个协变的投影 T 和 D 的复合 P 也是协变的。
对于类型的投影,如果
G(T) = Func<out T>
H(T) = IEnumerator<out T>
令 K(T) = G(H(T)) = Func<IEnumerator<out T>>
因为已知两个协变的投影的复合也是协变的,而 G 和 H 都是协变的,所以 K 是协变的,也就是说 IEnumerable<out T> 接口里的 “IEnumerator<T> GetEnumerator()” 函数是协变的。
2. 为什么 ICovariantDemo<out T> 接口里的 “void SetAContraInterface(IContravarianceDemo<T> a);” 函数是协变的。
如果有逆变的投影 N 和 O
N(x) = 0 - x
O(x) = x * (-2)
把 N 和 O 复合得到 Q:
Q(x) = N(O(x))
如果高中课程没有忘光了的话,应该可以证明两个逆变的投影 N 和 O 的复合 Q 是协变的。
对于类型的投影,如果
L(T) = Action<in T>
R(T) = IContravarianceDemo<in T>
令 S(T) = L(R(T)) = Action<IContravarianceDemo<in T>>
因为已知两个逆变的投影的复合是协变的,而 L 和 R 都是逆变的, 所以 S 是协变的,也就是说 ICovariantDemo<out T> 接口里的 “void SetAContraInterface(IContravarianceDemo<T> a);” 函数是协变的。
再来看一下逆变的接口 IContravarianceDemo<in T>
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 | public interface IContravarianceDemo< in T> { // void SetAnIList(IList<T> list); // 编译错误 // IList<T> GetAnIList(); // 编译错误 // T GetAnItem(); // 编译错误 void SetAnItem(T v); //ICovariantDemo<T> GetACoInterface(); // 编译错误 IContravarianceDemo<T> GetACotraInterface(); void SetACoInterface(ICovariantDemo<T> a); // 编译错误 //void SetAContraInterface(IContravarianceDemo<T> a); // 编译错误 //Func<T> GetAnItemLater(); // 编译错误 void GetAnItemFromDelegate(Func<T> f); Action<T> DoSthLater(); // void DoSth(Action<T> a); // 编译错误 } |
3. 为什么 IContravarianceDemo<in T> 接口的 “IContravarianceDemo<T> GetACotraInterface()” 函数是逆变的。
如果有协变的投影 D 和逆变的投影 N:
D(x) = x * 2
N(x) = 0 - x
把 D 和 N 复合得到 A:
A(x) = D(N(x))
如果高中课程没有忘光了的话,应该可以证明协变的投影 D 和逆变的投影 N 的复合 A 是逆变的。
对于类型的投影,如果
U(T) = Func<out T>。
V(T) = IContravarianceDemo<in T>
令 W(T) = Func<IContravarianceDemo<in T>>
因为已知协变的投影和逆变的投影的复合是逆变的,而 U 是协变的、V 是逆变的,所以 W 是逆变的,也就是说 IContravarianceDemo<in T> 接口的 “IContravarianceDemo<T> GetACotraInterface()” 函数是逆变的。
4. 为什么 IContravarianceDemo<in T> 接口的“void SetACoInterface(ICovariantDemo<T> a)”函数是逆变的。
如果有协变的投影 D 和逆变的投影 N:
D(x) = x * 2
N(x) = 0 - x
把 N 和 D 复合得到 B:
B(x) = N(D(x))
如果高中课程没有忘光了的话,应该可以证明逆变的投影 N 和协变的投影 D 的复合 B 是逆变的。
对于类型的投影,如果
C(T) = Action<in T>
E(T) = ICovariantDemo<out T>
令 F(T) = C(E(T)) = Action<ICovariantDemo<out T>>
因为已知逆变的投影和协变的投影的复合是逆变的,而 C 是逆变的、E 是协变的,所以 F 是逆变的,也就是说IContravarianceDemo<in T> 接口的“void SetACoInterface(ICovariantDemo<T> a)”函数是逆变的。
现在明白是明白了,但是变来变去的好像不太好记呀。我们小结一下:
协变和协变的复合是协变;
逆变和逆变的复合是协变;
协变和逆变的复合是逆变;
逆变和协变的复合是逆变;
这不就跟“负负得正、正正得正、正负得负”的规则正好相当嘛。
参考
Eric Lippert's Blog: Covariance and Contravariance.
Liskov substitution principle. wiki.
Projection(mathematics). wiki.
Bill Wagner, Effective C# (Covers C# 4.0). Addison-Wesley, 2010.
Jon Skeet, C# in Depth. Manning Publications, 2008.
杰拉尔德·温伯格,系统化思维导论(银年纪念版)。清华大学出版社,2003。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· Linux系列:如何用 C#调用 C方法造成内存泄露
· AI与.NET技术实操系列(二):开始使用ML.NET
· 记一次.NET内存居高不下排查解决与启示
· 探究高空视频全景AR技术的实现原理
· 理解Rust引用及其生命周期标识(上)
· DeepSeek 开源周回顾「GitHub 热点速览」
· 物流快递公司核心技术能力-地址解析分单基础技术分享
· .NET 10首个预览版发布:重大改进与新特性概览!
· AI与.NET技术实操系列(二):开始使用ML.NET
· 单线程的Redis速度为什么快?