泛型的协变和逆变的概念引入

variance差异      变异;变化;不一致;分歧;[数] 方差

convariance协变   仅仅用于返回值,范围扩大  out

contra相反        

contravariance逆变  仅仅用于函数参数,范围缩小  in

引入

https://www.codewars.com/kata/unflatten-a-list-easy/train/csharp

 无法将IEnumerable<int>转换为IEnumerable<object>

复制代码
  public static object[] Unflatten(int[] flatArray)
    {
        List<object> list = new List<object>();
        for (int i = 0; i < flatArray.Length;)
        {
            if (flatArray[i] >= 3)
            {
                //list.AddRange(flatArray.Take(flatArray[i]));
                //这里添加数据的时候提示cannot convert from 'System.Collections.Generic.IEnumerable<int>' to 'System.Collections.Generic.IEnumerable<object>'
                i = i + 3;
            }
            else
            {
                i++;
            }
        }
        return new object[0];
    }
复制代码

 

 

背景知识

很多人可能不不能很好地理解这些来自于物理和数学的名词。我们无需去了解他们的数学定义,但是至少应该能分清协变和反变。

实际上这个词来源于类型和类型之间的绑定。我们从数组开始理解。数组其实就是一种和具体类型之间发生绑定的类型。

 

数组类型Int32[]就对应于Int32这个原本的类型。任何类型T都有其对应的数组类型T[]。

那么我们的问题就来了,如果两个类型T和U之间存在一种安全的隐式转换,那么对应的数组类型T[]和U[]之间是否也存在这种转换呢?

这就牵扯到了将原本类型上存在的类型转换映射到他们的数组类型上的能力,这种能力就称为“可变性(Variance)”。

 

在.NET世界中,唯一允许可变性的类型转换就是由继承关系带来的“子类引用->父类引用”转换。

举个例子,就是String类型继承自Object类型,所以任何String的引用都可以安全地转换为Object引用。

我们发现String[]数组类型的引用也继承了这种转换能力,它可以转换成Object[]数组类型的引用,数组这种与原始类型转换方向相同的可变性就称作协变(covariant

 

由于数组不支持反变性,我们无法用数组的例子来解释反变性,所以我们现在就来看看泛型接口和泛型委托的可变性。

假设有这样两个类型:TSub是TParent的子类,显然TSub型引用是可以安全转换为TParent型引用的。

如果一个泛型接口IFoo<T>,IFoo<TSub>可以转换为IFoo<TParent>的话,我们称这个过程为协变,而且说这个泛型接口支持对T的协变

而如果一个泛型接口IBar<T>,IBar<TParent>可以转换为T<TSub>的话,我们称这个过程为反变(contravariant而且说这个接口支持对T的反变

因此很好理解,如果一个可变性和子类到父类转换的方向一样,就称作协变;而如果和子类到父类的转换方向相反,就叫反变性。你记住了吗?

 

.NET 4.0引入的泛型协变、反变性

刚才我们讲解概念的时候已经用了泛型接口的协变和反变,但在.NET 4.0之前,无论C#还是VB里都不支持泛型的这种可变性。

不过它们都支持委托参数类型的协变和反变。由于委托参数类型的可变性理解起来抽象度较高,所以我们这里不准备讨论。

已经完全能够理解这些概念的读者自己想必能够自己去理解委托参数类型的可变性。

 

在.NET 4.0之前为什么不允许IFoo<T>进行协变或反变呢?

因为对接口来讲,T这个类型参数既可以用于方法参数,也可以用于方法返回值。

设想这样的接口

复制代码
interface IFoo<T>
{
    /// <summary>
    /// T作为函数参数
    /// </summary>
    /// <param name="param"></param>
    void Method1(T param);

    /// <summary>
    /// T作为函数的返回值
    /// </summary>
    /// <returns></returns>
    T Method2();
}
复制代码

 

举例,比如有一个Animal类,以及从Animal派生出来的Cat类和Dog类

复制代码
public class Animal
{
}

public class Cat : Animal
{
}

public class Dog : Animal
{
}
复制代码

 

范围变大(函数参数不支持),本来是TSub,范围要扩大到TParent  

外部传递进来的参数类型从子类扩大到父类,但是并非所有的父类都可以转换为子类

如果我们允许协变,从IFoo<TSub>到IFoo<TParent>转换,

那么IFoo.Method1(TSub)就会变成IFoo.Method1(TParent)。  比如本来是Method1(Cat)变成Method1(Animal),但是假如传递一个Dog进来,Cat是兼容不了的

我们都知道TParent是不能安全转换成TSub的,所以Method1这个方法就会变得不安全。

所以,传递进来的函数参数范围不允许扩大

 

范围变小(函数返回值不支持),本来是TParent,范围要缩小到TSub  

函数传递出去的返回值类型从父类缩小到子类,原本是父类类型的返回值未必能转换成正确的子类

同样,如果我们允许反变IFoo<TParent>到IFoo<TSub>,

则TParent IFoo.Method2()方法就会变成TSub IFoo.Method2(),原本返回的TParent引用未必能够转换成TSub的引用,Method2的调用将是不安全的。

比如本来是Animal Method2(),这种情况返回Cat或者Dog都可以的。但是变成Cat Method2()的话,那就只能返回Cat而无法返回Dog了

所以,传递出去的函数返回值范围不允许缩小

 

由此可见,在没有额外机制的限制下,接口进行协变或反变都是类型不安全的。

 

.NET 4.0改进了什么呢?它允许在类型参数的声明时增加一个额外的描述,以确定这个类型参数的使用范围。

我们看到,如果一个类型参数仅仅能用于函数的返回值,那么这个类型参数就对协变相容。

而相反,一个类型参数如果仅能用于方法参数,那么这个类型参数就对反变相容。

如下所示:

复制代码
/// <summary>
/// 协变
/// 传递出去的函数返回值范围可以扩大
/// </summary>
/// <typeparam name="T"></typeparam>
interface ICo<out T>
{
    T Method();
}

/// <summary>
/// 逆变
/// 传递进来的函数参数范围可以缩小
/// </summary>
/// <typeparam name="T"></typeparam>
interface IContra<in T>
{
    void Method(T param);
}
复制代码

 

可以看到C#4和VB10都提供了大同小异的语法,用Out来描述仅能作为返回值的类型参数,用In来描述仅能作为方法参数的类型参数。

一个接口可以带多个类型参数,这些参数可以既有In也有Out,因此我们不能简单地说一个接口支持协变还是反变,只能说一个接口对某个具体的类型参数支持协变或反变。

比如若有IBar<in T1, out T2>这样的接口,则它对T1支持反变而对T2支持协变。举个例子来说,IBar<object, string>能够转换成IBar<string, object>,这里既有协变又有反变。

 

在.NET Framework中,许多接口都仅仅将类型参数用于参数或返回值。

为了使用方便,在.NET Framework 4.0里这些接口将重新声明为允许协变或反变的版本。

例如IComparable<T>就可以重新声明成IComparable<in T>,而IEnumerable<T>则可以重新声明为IEnumerable<out T>。

不过某些接口IList<T>是不能声明为in或out的,因此也就无法支持协变或反变。

 

下面提起几个泛型协变和反变容易忽略的注意事项:

1.       仅有泛型接口和泛型委托支持对类型参数的可变性,泛型类或泛型方法是不支持的。

2.       值类型不参与协变或反变,IFoo<int>永远无法变成IFoo<object>,不管有无声明out。因为.NET泛型,每个值类型会生成专属的封闭构造类型,与引用类型版本不兼容。

3.       声明属性时要注意,可读写的属性会将类型同时用于参数和返回值。因此只有只读属性才允许使用out类型参数,只写属性能够使用in参数。

 

 

协变和反变的相互作用

这是一个相当有趣的话题,我们先来看一个例子:

复制代码
interface IFoo<in T>
{

}

interface IBar<in T>
{
    void Test(IFoo<T> foo); //对吗?
}
复制代码

 

你能看出上述代码有什么问题吗?我声明了in T,然后将他用于方法的参数了,一切正常。但出乎你意料的是,这段代码是无法编译通过的!

编译器直接给出的错误提示:

 Invalid variance: The type parameter 'T' must be covariantly valid on 'IBar<T>.Test(IFoo<T>)'. 'T' is contravariant.

 

反而是这样的代码通过了编译

复制代码
interface IFoo<in T>
{

}

interface IBar<out T>
{
    void Test(IFoo<T> foo);
}
复制代码

什么?明明是out参数,我们却要将其用于方法的参数才合法?初看起来的确会有一些惊奇。我们需要费一些周折来理解这个问题。

现在我们考虑IBar<string>,它应该能够协变成IBar<object>,因为string是object的子类。因此IBar.Test(IFoo<string>)也就协变成了IBar.Test(IFoo<object>)。

 

当我们调用这个协变后方法时,将会传入一个IFoo<object>作为参数。想一想,这个方法是从IBar.Test(IFoo<string>)协变来的,所以参数IFoo<object>必须能够变成IFoo<string>才能满足原函数的需要。

这里对IFoo<object>的要求是它能够反变成IFoo<string>!而不是协变。

也就是说,如果一个接口需要对T协变,那么这个接口所有方法的参数类型必须支持对T的反变

同理我们也可以看出,如果接口要支持对T反变,那么接口中方法的参数类型都必须支持对T协变才行。这就是方法参数的协变-反变互换原则

所以,我们并不能简单地说out参数只能用于返回值,它确实只能直接用于声明返回值类型,但是只要一个支持反变的类型协助,out类型参数就也可以用于参数类型!

换句话说,in参数除了直接声明方法参数之外,也仅能借助支持协变的类型才能用于方法参数,仅支持对T反变的类型作为方法参数也是不允许的。

要想深刻理解这一概念,第一次看可能会有点绕,建议有条件的情况下多进行一些实验。

 

刚才提到了方法参数上协变和反变的相互影响。那么方法的返回值会不会有同样的问题呢?

我们看如下代码:

复制代码
/// <summary>
/// T协变
/// </summary>
/// <typeparam name="T"></typeparam>
interface IFooCo<out T>
{
}

/// <summary>
/// T逆变
/// </summary>
/// <typeparam name="T"></typeparam>
interface IFooContra<in T>
{
}

/// <summary>
/// T1协变
/// T2逆变
/// </summary>
/// <typeparam name="T1"></typeparam>
/// <typeparam name="T2"></typeparam>
interface IBar<out T1, in T2>
{
    IFooCo<T1> Test1();
    IFooContra<T2> Test2();
}
复制代码

我们看到和刚刚正好相反,如果一个接口需要对T进行协变或反变,那么这个接口所有方法的返回值类型必须支持对T同样方向的协变或反变这就是方法返回值的协变-反变一致原则

也就是说,即使in参数也可以用于方法的返回值类型,只要借助一个可以反变的类型作为桥梁即可。

如果对这个过程还不是特别清楚,建议也是写一些代码来进行实验。

至此我们发现协变和反变有许多有趣的特性,以至于在代码里in和out都不像他们字面意思那么好理解。

当你看到in参数出现在返回值类型,out参数出现在参数类型时,千万别晕倒,用本文的知识即可破解其中奥妙。

 

 

 

参考:

.NET 4.0中的泛型协变和反变

作者:Chuck Lu    GitHub    
posted @   ChuckLu  阅读(358)  评论(0编辑  收藏  举报
编辑推荐:
· 记一次.NET内存居高不下排查解决与启示
· 探究高空视频全景AR技术的实现原理
· 理解Rust引用及其生命周期标识(上)
· 浏览器原生「磁吸」效果!Anchor Positioning 锚点定位神器解析
· 没有源码,如何修改代码逻辑?
阅读排行:
· 全程不用写代码,我用AI程序员写了一个飞机大战
· DeepSeek 开源周回顾「GitHub 热点速览」
· MongoDB 8.0这个新功能碉堡了,比商业数据库还牛
· 记一次.NET内存居高不下排查解决与启示
· 白话解读 Dapr 1.15:你的「微服务管家」又秀新绝活了
历史上的今天:
2016-03-10 Resharper 实现接口的方式
2016-03-10 Explicit Interface Implementation (C# Programming Guide)
2016-03-10 Interfaces (C# Programming Guide)
点击右上角即可分享
微信分享提示