为什么类类型参数的方差必须与其方法的返回/参数类型参数的方差匹配?

11
以下内容引起投诉:
interface IInvariant<TInv> {}
interface ICovariant<out TCov> {
    IInvariant<TCov> M(); // The covariant type parameter `TCov'
                          // must be invariantly valid on
                          // `ICovariant<TCov>.M()'
}
interface IContravariant<in TCon> {
    void M(IInvariant<TCon> v); // The contravariant type parameter
                                // `TCon' must be invariantly valid
                                // on `IContravariant<TCon>.M()'
}

但我无法想象这不是类型安全的地方。 (省略)这是为什么被禁止的原因,还是有其他违反类型安全的情况我不知道?


* 我最初的想法确实很复杂,但尽管如此,回答非常详细,@Theodoros Chatzigiannakis甚至以惊人的准确性剖析了我的最初假设。

除了回顾中的好耳光外,我意识到我错误地假设了ICovariant :: M的类型签名在将其 ICovariant<Derived> 分配给 ICovariant<Base>时保持为 Func<IInvariant<Derived>>。然后,将该M分配给Func<IInvariant<Base>>将从一个ICovariant<Base>出现,看起来没问题,但当然是非法的。为什么不禁止这个最后显然非法的转换?(所以我想)

我觉得这个错误和离题的猜测会削弱问题的重点,正如Eric Lippert所指出的那样,但为了历史记录,省略部分:

对我来说最直观的解释是,以ICovariant为例,协变的TCov意味着方法IInvariant<TCov> M()可以被强制转换为某个IInvariant<TSuper> M(),其中TSuper super TCov,这违反了IInvariantTInv的不变性。然而,这种暗示似乎并非必要的:可以通过禁止M的强制转换来强制执行IInvariantTInv上的不变性。


1
如果您想了解实际规则,请参见 https://blogs.msdn.microsoft.com/ericlippert/2009/12/03/exact-rules-for-variance-validity/。 - Eric Lippert
3个回答

6

让我们看一个更具体的例子。我们将实现这些接口的几个实例:

class InvariantImpl<T> : IInvariant<T>
{
}

class CovariantImpl<T> : ICovariant<T>
{
    public IInvariant<T> M()
    {
        return new InvariantImpl<T>();
    }
}

现在,让我们假设编译器没有对此进行抱怨,并尝试以简单的方式使用它:

static IInvariant<object> Foo( ICovariant<object> o )
{
    return o.M();
}

到目前为止一切顺利。 oICovariant<object>,该接口保证我们有一个可以返回IInvariant<object>的方法。我们不需要在这里执行任何强制转换或转换操作,一切都很好。现在让我们调用这个方法:

var x = Foo( new CovariantImpl<string>() );

因为ICovariant是协变的,所以这是一个有效的方法调用,我们可以在任何需要ICovariant<object>的地方替换成一个ICovariant<string>,因为协变性使得这种替换成立。
但我们有一个问题。在Foo内部,我们调用ICovariant<object>.M()并期望它返回一个IInvariant<object>,因为ICovariant接口表明它将这样做。但它不能这样做,因为我们实际传递的实现实际上实现了ICovariant<string>,其M方法返回IInvariant<string>,由于该接口的不变性,它与IInvariant<object>没有任何关系,它们是完全不同的类型。

5

我不确定你是否在之前的回答中得到了自己问题的答案。

为什么类类型参数的方差必须与其方法的返回/参数类型参数的方差匹配?

实际上并非如此,因此这个问题基于错误的前提。实际规则在这里:

https://blogs.msdn.microsoft.com/ericlippert/2009/12/03/exact-rules-for-variance-validity/

考虑现在:
interface IInvariant<TInv> {}
interface ICovariant<out TCov> {
   IInvariant<TCov> M(); // Error
}

这是被禁止的原因吗?还是有其他违反类型安全的情况我不知道?

我没有明白你的解释,所以我们不提到你的解释,直接说为什么这是被禁止的。这里,让我用一些等价的类型替换这些类型。 IInvariant<TInv> 可以是任何在 T 中不变的类型,比如说 ICage<TCage>:

interface ICage<TAnimal> {
  TAnimal Remove();
  void Insert(TAnimal contents);
}

也许我们有一个类型 Cage<TAnimal>,它实现了ICage<TAnimal>

现在让我们用

替换ICovariant<T>
interface ICageFactory<out T> {
   ICage<T> MakeCage();
}

让我们实现接口:

class TigerCageFactory : ICageFactory<Tiger> 
{ 
  public ICage<Tiger> MakeCage() { return new Cage<Tiger>(); }
}

一切进展顺利。ICageFactory是协变的,所以这是合法的:

ICageFactory<Animal> animalCageFactory = new TigerCageFactory();
ICage<Animal> animalCage = animalCageFactory.MakeCage();
animalCage.Insert(new Fish());

我们只是把一条鱼放进了老虎笼里。每一步都是完全合法的,但最终导致了类型系统违规。结论是,在首次将 ICageFactory 协变化之前,这种做法显然不是合法的。

接下来看一下你的逆变例子,基本上是同样的情况:

interface ICageFiller<in T> {
   void Fill(ICage<T> cage);
}

class AnimalCageFiller : ICageFiller<Animal> {
  public void Fill(ICage<Animal> cage)
  {
    cage.Insert(new Fish());
  }
}

现在,接口是逆变的,所以这是合法的:

ICageFiller<Tiger> tigerCageFiller = new AnimalCageFiller();
tigerCageFiller.Fill(new Cage<Tiger>());

我再次将一条鱼放入老虎笼中。我们再次得出结论,首先将类型逆变可能是非法的。

现在让我们考虑一个问题,我们如何知道这些是非法的。在第一个案例中,我们有

interface ICageFactory<out T> {
   ICage<T> MakeCage();
}

相关规则如下:

所有非 void 接口方法的返回类型必须有效地协变。

ICage<T> 是否“有效地协变”?

如果一个类型满足以下条件,则它是有效的协变: 1) 指针类型或非泛型类... 不行 2) 数组类型... 不行 3) 泛型类型参数类型... 不行 4) 构造的类、结构、枚举、接口或委托类型 X<T1, … Tk>,是的!... 如果第 i 个类型参数被声明为不变,则 Ti 必须有效地不变。

TAnimalICage<TAnimal> 中是不变的,因此在 ICage<T> 中,T 必须有效地不变。是吗?不是。要有效地不变,它必须同时有效地协变和逆变,但它只能有效地协变。

因此这是一个错误。

留给读者作为练习的是对逆变情况进行分析。


1
为什么类类型参数的方差必须与其方法的返回/参数类型参数匹配?
并不是这样!
返回类型和参数类型不需要匹配封闭类型的差异。在您的示例中,它们需要对两个封闭类型都是协变的。这听起来有些违反直觉,但原因将在下面的解释中变得显而易见。

为什么你提出的解决方案不可行

协变的 TCov 意味着方法 IInvariant<TCov> M() 可以被转换为一些 IInvariant<TSuper> M(),其中 TSuper super TCov,这违反了 IInvariantTInv 的不变性。然而,这种暗示似乎并不必要:通过禁止转换 M,可以轻松地强制执行 IInvariantTInv 的不变性。

  • 你所说的是,具有变体类型参数的通用类型可以分配给同一通用类型定义和不同类型参数的另一个类型。这部分是正确的。
  • 但你还说,为了解决潜在的子类型化违规问题,在此过程中方法的显式签名不应更改。这是不正确的!
例如,ICovariant<string> 有一个方法 IInvariant<string> M()。"禁止转换 M" 意味着当将 ICovariant<string> 分配给 ICovariant<object> 时,它仍保留带有签名 IInvariant<string> M() 的方法。如果允许这样做,那么这个完全有效的方法就会有问题:
void Test(ICovariant<object> arg)
{
    var obj = arg.M();
}

编译器应该推断obj变量的类型为什么类型? 应该是IInvariant<string>吗? 为什么不是IInvariant<Window>IInvariant<UTF8Encoding>IInvariant<TcpClient>? 它们都可以是有效的,您可以自行查看:
Test(new CovariantImpl<string>());
Test(new CovariantImpl<Window>());
Test(new CovariantImpl<UTF8Encoding>());
Test(new CovariantImpl<TcpClient>());

显然,方法(M())的静态已知返回类型不可能取决于对象的运行时类型实现的接口(ICovariant<>)!

因此,当泛型类型被分配给具有更通用类型参数的另一个泛型类型时,使用相应类型参数的成员签名必须随之更改为更通用的内容。如果我们想要维护类型安全性,就没有其他办法了。现在让我们看看每种情况中“更通用”的含义。


为什么 ICovariant<TCov> 需要 IInvariant<TInv> 是协变的

对于类型参数 string,编译器“看到”这个具体类型:

interface ICovariant<string>
{
    IInvariant<string> M();
}

如我们之前所见,对于类型参数为object的情况,编译器会将其替换为具体类型:

interface ICovariant<object>
{
    IInvariant<object> M();
}

假设有一种实现前面接口的类型:
class MyType : ICovariant<string>
{
    public IInvariant<string> M() 
    { /* ... */ }
}

注意,在这个类型中,M() 的实际实现只关心返回一个 IInvariant<string>,而不关心协变性。请记住这一点!
现在,通过将 ICovariant<TCov> 的类型参数声明为协变,你断言 ICovariant<string> 应该可以赋值给 ICovariant<object>,如下所示:
ICovariant<string> original = new MyType();
ICovariant<object> covariant = original;

...而且你还声称现在可以做到这一点:

IInvariant<string> r1 = original.M();
IInvariant<object> r2 = covariant.M();

请记住,original.M()covariant.M()是对同一个方法的调用。实际方法实现只知道它应该返回一个Invariant<string>

因此,在后者调用的执行过程中的某个时刻,我们将隐式地将IInvariant<string>(由实际方法返回)转换为IInvariant<object>(这是协变签名所承诺的内容)。为了发生这种情况,IInvariant<string>必须可以分配给IInvariant<object>

为了概括,每个IInvariant<S>IInvariant<T>都必须适用于S:T。这正是协变类型参数的描述。


为什么IContravariant<TCon>也需要IInvariant<TInv>是协变的

对于一个object类型参数,编译器“看到”的这个具体类型:

interface IContravariant<object>
{
    void M(IInvariant<object> v); 
}

对于类型参数为 string,编译器“看到”的是具体的类型。
interface IContravariant<string>
{
    void M(IInvariant<string> v); 
}

假设有一个实现前面接口的类型:
class MyType : IContravariant<object>
{
    public void M(IInvariant<object> v)
    { /* ... */ }
}

请注意,M() 的实际实现假定您会向其提供一个 IInvariant<object>,并且它不关心方差。

现在通过将 IContravariant<TCon> 的类型参数化,您断言 IContravariant<object> 应该可以分配给 IContravariant<string>,如下所示...

IContravariant<object> original = new MyType();
IContravariant<string> contravariant = original;

...并且你还断言你现在可以做到这一点:

IInvariant<object> arg = Something();
original.M(arg);
IInvariant<string> arg2 = SomethingElse();
contravariant.M(arg2);

再次强调,original.M(arg)contravariant.M(arg2)是对同一个方法的调用。该方法的实际实现希望我们传递任何一个IInvariant<object>

因此,在后者的调用执行过程中的某个时刻,我们会将一个IInvariant<string>(这是协变签名从我们期望的内容)隐式转换为一个IInvariant<object>(这是实际方法所期望的内容)。为了使这种情况发生,IInvariant<string>必须可以分配给IInvariant<object>

总的来说,每个IInvariant<S>都应该可以分配给IInvariant<T>,其中S:T。因此,我们再次看到了一个协变类型参数。


现在你可能会想知道为什么存在不匹配。协变和逆变的二元性去哪了?它仍然存在,但以一种不太明显的形式出现:
• 当你处于输出侧时,引用类型的方差与封闭类型的方差方向相同。由于在这种情况下封闭类型可以是协变或不变的,因此引用类型必须分别是协变或不变的。
• 当你处于输入侧时,引用类型的方差与封闭类型的方差方向相反。由于在这种情况下封闭类型可以是逆变或不变的,因此引用类型现在必须是协变或不变的。

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接