🔥码云GVP开源项目 12k star Uniapp+ElementUI 功能强大 支持多语言、二开方便! 广告
# 原则29:让接口支持协变和逆变 **By D.S.Qiu** **尊重他人的劳动,支持原创,转载请注明出处:[http://dsqiu.iteye.com](http://dsqiu.iteye.com)** 类型可变性,具体地,协变和逆变,定义了一个类型变化为另一个类型的两种情况。如果可能,你应该让泛型接口和委托支持泛型的协变和逆变。这样做可以让你的 APIs 能安全地不同方式使用。如果你不能将一个类型替换为另一个,那么就是不可变。 类型可变性是很多开发者遇到的却又不真正理解的很多问题之一。协变和逆变是类型替换的两种不同形式。如果你用声明类型的子类返回那么就是协变的。如果你用声明类型的基类作为参数传入那么就是逆变。面向对象原因普遍支持参数类型的协变。你可以传递子类对象到任何期望是基类参数的方法。例如, Console.WriteLine() 函数有一个使用 System.Object 参数的版本。你可以传入任何 System.Object 的子类对象。如果你重载实例方法返回 System.Object ,你可以返回任何继承自 System.Object 的对象。 普遍的行为让很多开发者认为泛型也遵循这个规则。你可以使用 IEnumerable&lt;MyDerived&gt; 传给参数为 IEnumerable&lt;Object&gt; 的方法。你会期望返回的 IEnumerable&lt;MyDerivedType&gt; 可以赋值给 IEnumerable&lt;Object&gt; 变量。不是这样的。在 C# 4.0之前,所有泛型类型都是不可变的。这意味着,很多次你都自以为泛型也有协变和逆变时,编译器却告诉你的代码是有问题的。数组是被看做协变的。然而,数组不支持安全的协变。随着 C# 4.0 ,新关键字可以让你的泛型支持协变和逆变。这使得泛型更有用,特别是在泛型接口和委托上你应该尽可能使用 in 和 out 参数。 我们开始通过数组理解协变的问题。考虑下面简单的类继承结构: ``` abstract public class CelestialBody { public double Mass { get; set; } public string Name { get; set; } // elided } public class Planet : CelestialBody { // elided } public class Moon : CelestialBody { // elided } public class Asteroid : CelestialBody { // elided } ``` 下面这个方法把 CelestialBody 对象数组当做协变,而且那样做事安全的: ``` public static void CoVariantArray(CelestialBody[] baseItems) { foreach (var thing in baseItems) Console.WriteLine("{0} has a mass of {1} Kg", thing.Name, thing.Mass); } ``` 下面这个方法也把 CelestialBody 对象数组当做协变,但这是不安排的。赋值语句会抛出异常。 ``` public static void UnsafeVariantArray( CelestialBody[] baseItems) { baseItems[0] = new Asteroid { Name = "Hygiea", Mass = 8.85e19 }; } ``` 如果你将子类赋给基类的数组元素一样会有相同的问题: ``` CelestialBody[] spaceJunk = new Asteroid[5]; spaceJunk[0] = new Planet(); ``` 把集合看着协变意味着当如果有两个类有继承关系是,你可以认为他们的关系和两个类型的数组是一样的。这不是一个严格的定义,但要记住它是很用的。 Planet 对可以传递给任何期望参数为 CelestialBody 的方法。这是因为 Planet 继承于 CelestialBody 。类似地,你可以将 Planet[] 传递给任何期望参数为 CelestianlBody[] 的方法。但是,正如上面的例子一样,它们总是不能如你期望一样工作。 当泛型被引入时,这个问题被十分严格的处理。泛型总是被当做不可变的。泛型类型不得不正确匹配。然而,在 C# 4.0,你可以将方向接口修饰变为协变或逆变。我们先讨论泛型协变,而后在讨论逆变。 下面这个方法调用参数为 List&lt;Planet&gt; : ``` public static void CoVariantGeneric( IEnumerable<CelestialBody> baseItems) { foreach (var thing in baseItems) Console.WriteLine("{0} has a mass of {1} Kg", thing.Name, thing.Mass); } ``` 这是因为 IEnumerable&lt;T&gt; 已经被扩展为限制 T 只能出现在输出位置: ``` public interface IEnumerable<out T> : IEnumerable { IEnumerator<T> GetEnumerator(); } public interface IEnumerator<out T> :IDisposable, IEnumerator { T Current { get; } // MoveNext(), Reset() inherited from IEnumerator } ``` 我给出了 IEnumerable&lt;T&gt; 和 IEnumerator&lt;T&gt; 的定义,因为 IEnumerator&lt;T&gt; 会有比较重要的限制。注意到 IEnumerator&lt;T&gt; 现在的参数类型 T 已经被修饰符 out 修饰。这就强制编译器类型 T 只能在输出位置。输出位置仅限于函数返回值,属性 get 访问器和委托的参数。 因此,使用 IEnumerable&lt;out T&gt; ,编译器知道你会查看序列的每个 T ,但是不会修改序列的内容。这个例子中把 Planet 当做 CelestailBody 就是这样的。 IEnumerable&lt;T&gt; 可以协变是因为 IEnumerator&lt;T&gt; 也是协变的。如果 IEnumerable&lt;T&gt; 返回的接口不是协变的,编译器会产生一个错误。协变类型必须返回值类型的参数或这个接口是协变的。 然而,下面方法替换队列的第一个元素的泛型是不可变的: ``` public static void InvariantGeneric( IList<CelestialBody> baseItems) { baseItems[0] = new Asteroid { Name = "Hygiea", Mass = 8.85e19 }; } ``` 因为 IList&lt;T&gt; 的参数 T 既没有被 in 又没有被 out 修饰符,你必须使用正确的类型进行匹配。 当然,你也可以创建逆变泛型接口和委托。用 in 修饰符替换 out 。这个告诉编译器类型参数只能出现在输入位置。.NET 框架已经为 IComparable&lt;T&gt; 加上了 in 修饰符: ``` public interface IComparable<in T> { int CompareTo(T other); } ``` 这说明如果 CelestialBody 实现 IComparable&lt;T&gt; ,可以使用很多不同的对象。它可以比较两个 Planet ,一个 Planet 和一个 Moon ,一个 Moon 和一个 Asteroid ,或者其他组合。比较了多个不同的对象,但这是有效的比较。 你会注意到 IEquatable&lt;T&gt; 是不可变的。按照定义, Planet 对象不会和 Moon 对象相等。它们是不同的类型,所以没有意义。如果两个对象是相同类型的如果相等而且不充分的,它是必要的(查看原则6)。 类型参数是可逆变的只有作为方法参数或某些地方的委托参数。 现在,你应该已经注意到我已经用了词组“某些地方的委托参数”两次。委托的定义可以协变也可以逆变。这是相当简单:方法参数逆变( in ),方法的返回值是协变( out )。BCL 更新了包括下面变种的很多委托的定义: ``` public delegate TResult Func<out TResult>(); public delegate TResult Func<in T, out TResult>(T arg); public delegate TResult Func<in T1, T2, out TResult>(T1 arg1,T2 arg2); public delegate void Action<in T>(T arg); public delegate void Action<in T1, in T2>(T1 arg1, T2 arg2); public delegate void Action<in T1, in T2, T3>(T1 arg1, T2 arg2, T3 arg3); ``` 在重复一次,这也许不太难。但是,如果你把它们混淆了,事情就得开动你的脑筋了。你已经看到你不能从协变接口返回不可变接口。你使用委托要么限制协变要么限制逆变。 如果你不仔细的话,委托在接口里会向协变和逆变偏移。这里有几个例子: ``` public interface ICovariantDelegates<out T> { T GetAnItem(); Func<T> GetAnItemLater(); void GiveAnItemLater(Action<T> whatToDo); } public interface IContravariantDelegate<in T> { void ActOnAnItem(T item); void GetAnItemLater(Func<T> item); Action<T> ActOnAnItemLater(); } ``` 接口里的方法的命名展示了它们具体的工作。仔细看 ICovariantDelegate 接口的定义。 GetAnItemLater() 只是检索元素。方法中可以调用 Func&lt;T&gt; 返回检索的元素。 T 仍然出现在输出位置上。这可能是有意义。 GetAnItemLater() 很容易让人困扰。这里,你的委托方法只是接收 T 对象。所以,即使 Action&lt;T&gt; 是协议的,它出现的 ICovarinatDelegate 接口的位置其实是 T 由实现 ICovariantDelegate&lt;T&gt; 的对象返回的。它看起来是逆变的,但是相对于接口来说是协变的。 IContravariantDelegate&lt;T&gt; 和一般的接口一样但是展示如何使用逆变接口。再说一次, ActOnAnItemLater() 方法就很明显。 ActOnAnItemLater() 方法有些复杂。你返回一个接受 T 类型对象的方法。这个最后方法,一次又一次强调,会引起一些困扰。它和其他接口的概念是一样的。 GetAnItemLater() 方法接受一个方法并返回 T 对象。即使 Func&lt;out T&gt; 声明为协变,它的作用是为实现 IContravariantDelegate 对象引入输入。它相对于 IContravariantDelegate 的作用是逆变的。 描述协变和逆变如何正确的工作十分复杂。值得庆幸的是,语法现在支持使用 in (逆变) 和 out (协变)修饰接口。你应该尽可能使用 in 或 out 修饰符修复接口和委托。然后,编译器就会纠正和你定义的有差异的用法。编译器会捕获到接口和委托的定义,并且发现你创建的类型的任何误用。 小结: 这个原则作为第三章的最后一个,虽然介绍的是类型的可变性,有些类似类型转换,但是情况却复杂的多,理解起来难度很大,想要更彻底的理解协变和逆变的概念,可以参考①。 欢迎各种不爽,各种喷,写这个纯属个人爱好,秉持”分享“之德! 有关本书的其他章节翻译请[点击查看](/category/297763),转载请注明出处,尊重原创! 如果您对D.S.Qiu有任何建议或意见可以在文章后面评论,或者发邮件(gd.s.qiu@gmail.com)交流,您的鼓励和支持是我前进的动力,希望能有更多更好的分享。 转载请在**文首**注明出处:[http://dsqiu.iteye.com/blog/2086977](/blog/2086977) 更多精彩请关注D.S.Qiu的博客和微博(ID:静水逐风) 参考: ①[1-2-3.cnblogs.com](http://www.cnblogs.com/1-2-3/): [http://www.cnblogs.com/1-2-3/archive/2010/09/27/covariance-contravariance-csharp4.html](http://www.cnblogs.com/1-2-3/archive/2010/09/27/covariance-contravariance-csharp4.html)