原则29:让接口支持协变和逆变

By D.S.Qiu

尊重他人的劳动,支持原创,转载请注明出处:http://dsqiu.iteye.com

类型可变性,具体地,协变和逆变,定义了一个类型变化为另一个类型的两种情况。如果可能,你应该让泛型接口和委托支持泛型的协变和逆变。这样做可以让你的 APIs 能安全地不同方式使用。如果你不能将一个类型替换为另一个,那么就是不可变。

类型可变性是很多开发者遇到的却又不真正理解的很多问题之一。协变和逆变是类型替换的两种不同形式。如果你用声明类型的子类返回那么就是协变的。如果你用声明类型的基类作为参数传入那么就是逆变。面向对象原因普遍支持参数类型的协变。你可以传递子类对象到任何期望是基类参数的方法。例如, Console.WriteLine() 函数有一个使用 System.Object 参数的版本。你可以传入任何 System.Object 的子类对象。如果你重载实例方法返回 System.Object ,你可以返回任何继承自 System.Object 的对象。

普遍的行为让很多开发者认为泛型也遵循这个规则。你可以使用 IEnumerable<MyDerived> 传给参数为 IEnumerable<Object> 的方法。你会期望返回的 IEnumerable<MyDerivedType> 可以赋值给 IEnumerable<Object> 变量。不是这样的。在 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<Planet> :

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<T> 已经被扩展为限制 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<T> 和 IEnumerator<T> 的定义,因为 IEnumerator<T> 会有比较重要的限制。注意到 IEnumerator<T> 现在的参数类型 T 已经被修饰符 out 修饰。这就强制编译器类型 T 只能在输出位置。输出位置仅限于函数返回值,属性 get 访问器和委托的参数。

因此,使用 IEnumerable<out T> ,编译器知道你会查看序列的每个 T ,但是不会修改序列的内容。这个例子中把 Planet 当做 CelestailBody 就是这样的。

IEnumerable<T> 可以协变是因为 IEnumerator<T> 也是协变的。如果 IEnumerable<T> 返回的接口不是协变的,编译器会产生一个错误。协变类型必须返回值类型的参数或这个接口是协变的。

然而,下面方法替换队列的第一个元素的泛型是不可变的:

public static void InvariantGeneric(
IList<CelestialBody> baseItems) 
{
    baseItems[0] = new Asteroid { Name = "Hygiea", Mass = 8.85e19 };
}

因为 IList<T> 的参数 T 既没有被 in 又没有被 out 修饰符,你必须使用正确的类型进行匹配。

当然,你也可以创建逆变泛型接口和委托。用 in 修饰符替换 out 。这个告诉编译器类型参数只能出现在输入位置。.NET 框架已经为 IComparable<T> 加上了 in 修饰符:

public interface IComparable<in T> 
{
    int CompareTo(T other); 
}

这说明如果 CelestialBody 实现 IComparable<T> ,可以使用很多不同的对象。它可以比较两个 Planet ,一个 Planet 和一个 Moon ,一个 Moon 和一个 Asteroid ,或者其他组合。比较了多个不同的对象,但这是有效的比较。

你会注意到 IEquatable<T> 是不可变的。按照定义, 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<T> 返回检索的元素。 T 仍然出现在输出位置上。这可能是有意义。 GetAnItemLater() 很容易让人困扰。这里,你的委托方法只是接收 T 对象。所以,即使 Action<T> 是协议的,它出现的 ICovarinatDelegate 接口的位置其实是 T 由实现 ICovariantDelegate<T> 的对象返回的。它看起来是逆变的,但是相对于接口来说是协变的。

IContravariantDelegate<T> 和一般的接口一样但是展示如何使用逆变接口。再说一次, ActOnAnItemLater() 方法就很明显。 ActOnAnItemLater() 方法有些复杂。你返回一个接受 T 类型对象的方法。这个最后方法,一次又一次强调,会引起一些困扰。它和其他接口的概念是一样的。 GetAnItemLater() 方法接受一个方法并返回 T 对象。即使 Func<out T> 声明为协变,它的作用是为实现 IContravariantDelegate 对象引入输入。它相对于 IContravariantDelegate 的作用是逆变的。

描述协变和逆变如何正确的工作十分复杂。值得庆幸的是,语法现在支持使用 in (逆变) 和 out (协变)修饰接口。你应该尽可能使用 in 或 out 修饰符修复接口和委托。然后,编译器就会纠正和你定义的有差异的用法。编译器会捕获到接口和委托的定义,并且发现你创建的类型的任何误用。

小结:

这个原则作为第三章的最后一个,虽然介绍的是类型的可变性,有些类似类型转换,但是情况却复杂的多,理解起来难度很大,想要更彻底的理解协变和逆变的概念,可以参考①。

欢迎各种不爽,各种喷,写这个纯属个人爱好,秉持”分享“之德!

有关本书的其他章节翻译请点击查看,转载请注明出处,尊重原创!

如果您对D.S.Qiu有任何建议或意见可以在文章后面评论,或者发邮件([email protected])交流,您的鼓励和支持是我前进的动力,希望能有更多更好的分享。

转载请在文首注明出处:http://dsqiu.iteye.com/blog/2086977

更多精彩请关注D.S.Qiu的博客和微博(ID:静水逐风)

参考:

1-2-3.cnblogs.comhttp://www.cnblogs.com/1-2-3/archive/2010/09/27/covariance-contravariance-csharp4.html