Why is there no parameter contra-variance for overriding?

On the pure issue of contra-variance

Adding contra-variance to a language opens a whole lot of potential problems or unclean solutions and offers very little advantage as it can be easily simulated without language support:

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) );
   }
};

With a simple extra jump you can manually overcome the problem of a language that does not support contra-variance. In the example, f( A& ) does not need to be virtual, and the call is fully qualified to inhibit the virtual dispatch mechanism.

This approach shows one of the first problems that arise when adding contra-variance to a language that does not have full dynamic dispatch:

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

With contravariance in effect, Q::f would be an override of P::f, and that would be fine as for every object o that can be an argument of P::f, that same object is a valid argument to Q::f. Now, by adding an extra level to the hierarchy we end up with design problem: is R::f(B&) a valid override of P::f or should it be R::f(A&)?

Without contravariance R::f( B& ) is clearly an override of P::f, since the signature is a perfect match. Once you add contravariance to the intermediate level the problem is that there are arguments that are valid at the Q level but are not at either P or R levels. For R to fulfill the Q requirements, the only choice is forcing the signature to be R::f( A& ), so that the following code can compile:

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

At the same time, there is nothing in the language inhibiting the following code:

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

Now we have a funny effect:

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& )
}

In [1], there is a direct call to a member method of R. Since r is a local object and not a reference or pointer, there is no dynamic dispatch mechanism in place and the best match is R::f( B& ). At the same time, in [2] the call is made through a reference to the base class, and the virtual dispatch mechanism kicks in.

Since R::f( A& ) is the override of Q::f( A& ) which in turn is the override of P::f( B& ), the compiler should call R::f( A& ). While this can be perfectly defined in the language, it might be surprising to find out that the two almost exact calls [1] and [2] actually call different methods, and that in [2] the system would call a not best match of the arguments.

Of course, it can be argued differently: R::f( B& ) should be the correct override, and not R::f( A& ). The problem in this case is:

int main() {
   A a; R r;
   Q & q = r;
   q.f( a );  // should this compile? what should it do?
}

If you check the Q class, the previous code is perfectly correct: Q::f takes an A& as argument. The compiler has no reason to complain about that code. But the problem is that under this last assumption R::f takes a B& and not an A& as argument! The actual override that would be in place would not be able to handle the a argument, even if the signature of the method at the place of call seems perfectly correct. This path leads us to determine that the second path is much worse than the first. R::f( B& ) cannot possibly be an override of Q::f( A& ).

Following the principle of least surprise, it is much simpler both for the compiler implementor and the programmer not to have contra variance in function arguments. Not because it is not feasible, but because there would be quirks and surprises in code, and considering that there are simple work-arounds if the feature is not present in the language.

On Overloading vs Hiding

Both in Java and C++, in the first example (with A, B, C and D) removing the manual dispatch [0], C::f and D::f are different signatures and not overrides. In both cases they are actually overloads of the same function name with the slight difference that because of the C++ lookup rules, the C::f overload will by hidden by D::f. But that only means that the compiler will not find the hidden overload by default, not that it is not present:

int main() {
   D d; B b;
   d.f( b );    // D::f( A& )
   d.C::f( b ); // C::f( B& )
}

And with a slight change in the class definition it can be made to work exactly the same as in 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& )
}

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?
}

Leave a Comment