为什么重写没有参数逆变性?

28

C++和Java在重写方法时支持返回类型协变。

然而,两者都不支持参数类型的逆变 - 而是将其转换为过载(Java)或隐藏(C++)。

为什么会这样呢? 在我看来,允许这种情况并不会有任何问题。我能够在Java中找到一个原因 - 因为它已经有了“选择最具体版本”的重载机制 - 但无法想出C++的原因。

示例(Java):

class A {
    public void f(String s) {…}
}
class B extends A {
    public void f(Object o) {…} // Why doesn't this override A.f?
}

4
逆变参数类型在实际中几乎没有什么实用价值。 - fredoverflow
5
可以采用简单的解决方案,比如声明一个方法来保留原始的签名,并将调用转发到修改后的签名。 - David Rodríguez - dribeas
6个回答

25

关于逆变性的问题

在语言中添加逆变性会带来许多潜在问题或不太干净的解决方案,并且提供的优势很小,因为它可以在没有语言支持的情况下轻松模拟:

struct A {};
struct B : A {};
struct C {
   virtual void f( B& );
};
struct D : C {
   virtual void f( A& );     // this would be contravariance, but not supported
   virtual void f( B& b ) {  // [0] manually dispatch and simulate contravariance
      D::f( static_cast<A&>(b) );
   }
};

通过简单的额外跳转,您可以手动克服不支持逆变的语言的问题。在此示例中,f( A& )无需为虚函数,且调用是完全合格的,以抑制虚分派机制。

这种方法展示了在将逆变添加到没有完全动态分派的语言中时出现的第一个问题之一:

// assuming that contravariance was supported:
struct P {
   virtual f( B& ); 
};
struct Q : P {
   virtual f( A& );
};
struct R : Q {
   virtual f( ??? & );
};

在逆变性生效的情况下,Q::f 将是 P::f 的重写,对于每个可以作为 P::f 参数的对象 o,该对象同样也是 Q::f 的有效参数。但是,通过在层级结构中添加额外的层级,我们面临着设计问题: R::f(B&) 是否是 P::f 的有效重写,还是应该是 R::f(A&)

如果没有逆变性,R::f(B&) 明显是 P::f 的重写,因为签名完全匹配。一旦在中间级别加入逆变性,问题在于存在在 Q 级别上有效但在 PR 级别上无效的参数。为了让 R 满足 Q 的要求,唯一的选择是强制签名为 R::f(A&),以使以下代码能够编译:

int main() {
   A a; R r;
   Q & q = r;
   q.f(a);
}

同时,语言中没有任何阻止以下代码的限制:

struct R : Q {
   void f( B& );    // override of Q::f, which is an override of P::f
   virtual f( A& ); // I can add this
};

现在我们有一个有趣的效果:

int main() {
  R r;
  P & p = r;
  B b;
  r.f( b ); // [1] calls R::f( B& )
  p.f( b ); // [2] calls R::f( A& )
}

在[1]中,有一个直接调用R成员方法的调用。由于r是一个局部对象而不是一个引用或指针,因此没有动态分派机制,最佳匹配是R::f( B& )。同时,在[2]中通过基类的引用进行了调用,启用了虚拟分派机制。

R::f( A& )Q::f( A& )的覆盖,后者又是P::f( B& )的覆盖,因此编译器应该调用R::f( A& )。虽然这在语言中可以完美定义,但是发现两个几乎完全相同的调用[1]和[2]实际上调用了不同的方法可能令人惊讶,而且在[2]中系统会调用一个不是最佳匹配的参数。

当然,也可以有不同的观点: R::f( B& ) 应该是正确的覆盖,而不是R::f( A& )。在这种情况下问题是:

int main() {
   A a; R r;
   Q & q = r;
   q.f( a );  // should this compile? what should it do?
}
如果你检查一下Q类,前面的代码是完全正确的:Q::f接受一个A&作为参数。编译器没有理由抱怨那段代码。但问题是,在这个假设下,R::f接受一个B&而不是A&作为参数!实际上,将要被使用的重载并不能处理a参数,即使在调用的地方方法的签名看起来是完全正确的。这条路线导致我们确定第二条路径比第一条更糟糕。R::f(B&)不可能是Q::f(A&)的重载。
遵循最小惊奇原则,对于编译器实现者和程序员来说,函数参数中不存在逆变性会更简单。这并不是因为它不可行,而是因为在代码中会有怪癖和意料之外的结果,考虑到如果语言中没有这个特性,则有简单的解决方法。
关于重载与隐藏:
无论是在Java还是C++中,第一个例子(其中包含A、B、C和D)如果去掉了手动分派 [0],C::fD::f都不是覆盖而是具有不同签名的重载。在这两种情况下,它们实际上都是同一个函数名称的重载,唯一的区别是由于C++查找规则,C::f重载将被D::f隐藏。但这仅意味着编译器默认情况下不会找到隐藏的重载,而不是不存在该重载:
int main() {
   D d; B b;
   d.f( b );    // D::f( A& )
   d.C::f( b ); // C::f( B& )
}

通过轻微改变类的定义,它可以被制作成与Java完全相同的工作方式:

struct D : C {
   using C::f;           // Bring all overloads of `f` in `C` into scope here
   virtual void f( A& );
};
int main() {
   D d; B b;
   d.f( b );  // C::f( B& ) since it is a better match than D::f( A& )
}

2
这只是我能想到的第一个。如果你阅读《C++设计与演化》一书,你会发现语言中的所有设计决策都归结为:它提供了什么优势,它会带来什么问题,它是否可以用现有的语言工具实现?委员会非常保守,如果某个特性引起任何问题,除非它提供了巨大的优势并且无法使用当前语言实现该特性,否则该特性将被舍弃。 - David Rodríguez - dribeas
嗨,大卫,"C++的设计与演化"这本书是否确实谈到了函数参数逆变?书中有一节关于参数重载放宽的讨论(13.7.1),但它讨论的是函数参数协变的常见错误提议。 - Edward Z. Yang

17
class A {
    public void f(String s) {...}
    public void f(Integer i) {...}
}

class B extends A {
    public void f(Object o) {...} // Which A.f should this override?
}

9
好的观点,尽管我看不出为什么不能同时覆盖两者,因为每个“f”调用都可以合法地重定向到“B.f”。 - Oak

5

对于C++,Stroustrup在《C++的设计与演化》第3.5.3节中简要讨论了隐藏的原因(链接)。他的理由是(我来解释一下),其他解决方案会带来同样多的问题,自从C With Classes时代以来就一直如此。

举个例子,他给出了两个类 - A和B派生类。它们都有一个虚拟的copy()函数,该函数接受其各自类型的指针。如果我们这样说:

A a;
B b;
b.copy( & a );

目前存在错误,因为B的copy()函数覆盖了A的函数。如果没有错误,则只有A的copy()函数才能更新B的A部分。

再次强调,我进行了改述 - 如果您感兴趣,请阅读这本优秀的书籍。


1
你有这个问题的示例吗? - Oak
4
这对我来说听起来不像是逆变。如果我们有void A::copy(A*)void B::copy(B*),并且如果我们有B <: A,那么逆变应该是void B::copy(A*)void A::copy(B*)。你所描述的是协变,协变在返回类型上是允许的,但在参数类型上是类型不安全的。(虽然我可能搞反了——我有一个倾向于混淆这些内容。但我相当确定。) - Antal Spector-Zabusky
@Antal,OP的问题明确提到了C++中的隐藏,而他示例中的Java代码是协变的。 - anon
@Neil:我可能错了,但我查了一下,OP的示例是逆变的。如果它是协变的,那么它将是一种多重分派形式(请参阅http://en.wikipedia.org/wiki/Covariance_and_contravariance_(computer_science))。这是我的混淆,所以我删除了我的答案。 - stefaanv
这是关于为什么在C++中派生类中的方法会隐藏基类中的方法的论点(与Java将基类方法拉入派生类相比),但这与逆变性无关,逆变性才是主要问题。尽管如此,对于内容给予+1。 - David Rodríguez - dribeas
显示剩余2条评论

3

虽然这是任何面向对象语言中都很好的功能,但我仍然需要在我的当前工作中找到它的适用性。

也许现在并没有真正需要它。


2
感谢Donroby在上面的回答,我只是在此基础上进行拓展。
interface Alpha
interface Beta
interface Gamma extends Alpha, Beta
class A {
    public void f(Alpha a)
    public void f(Beta b)
}
class B extends A {
    public void f(Object o) {
        super.f(o); // What happens when o implements Gamma?
    }
}

您遇到的问题类似于多重继承不被鼓励的原因。(如果您尝试直接调用 A.f(g),将会得到一个编译错误。)


7
然而,super.f(o)应该引发编译时类型错误:可以在静态上下文中确定不存在接受Object参数的super.f方法。(例如,如果调用new B().f("bad")会发生什么?它不仅是模糊的,而且是类型不安全的!)如果您允许无限制地使用super,那么将出现大量类型不安全的程序。但您可以通过参数逆变来实现这一点。 - Antal Spector-Zabusky
我认为,如果我们想要构建参数逆变性,就必须有一个“super”的规范(但事实上,目前没有任何一项规范能够保持一致和有用)。那么,在B.f(Object)中完全禁止使用“super”,这个特性还有什么意义呢?除了向C++添加另一种方法链接规则之外,还有什么意义吗? - Jean Hominal

1
由于donroby和David的回答,我认为我理解引入参数逆变的主要问题是与重载机制的整合。
所以不仅存在一个方法的单个覆盖的问题,还有另一种方式:
class A {
    public void f(String s) {...}
}

class B extends A {
    public void f(String s) {...} // this can override A.f
    public void f(Object o) {...} // with contra-variance, so can this!
}

现在同一个方法有两个有效的重写:

A a = new B();
a.f(); // which f is called?

除了重载问题,我想不到其他的了。

编辑:我后来找到了这篇C++ FQA条目(20.8),它与上述观点一致——重载的存在为参数逆变性带来了严重问题。


@devoured 我猜它可以,但这意味着要向语言引入一个全新的解析机制 - 因为这不是重载。A只有一个f(),因此在调用类型为A的变量上的f时,重载不起作用。 - Oak
虽然Oak并不是一个全新的解析机制,对吧?它将以与在B中调用带有String参数的方法完全相同的方式进行解析。这是一种静态解析策略,用于调用重载方法时也是一样的,只是应用在不同的上下文中。 - Elias Vasylenko
当编译 a.f() 时,B 的代码可能还不可用,但这并不需要!只有在编译 B 时才需要执行覆盖/重载的此部分解析。举个例子:考虑协变覆盖的解析作为预编译步骤完成。我们知道你所给出的示例将编译良好并提供预期的行为,而该行为不需要更改。[待续...] - Elias Vasylenko
抱歉评论如此之多!字符限制真是愚蠢 :). 另外,格式也被删除了!最后一条评论看起来很糟糕:s... - Elias Vasylenko
@Oak:拥有一个隐式接受Object的方法覆盖了另一个接受其他参数类型的方法肯定会有问题。另一方面,在某些情况下,明确指定一个编写出来的源代码片段应被视为同时覆盖或实现一个或多个特定基类或接口方法以及实现具有更一般参数类型的新类型可能是有用的。 - supercat
显示剩余5条评论

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