Perché non c’è nessun parametro contro-varianza per l’override?

C ++ e Java supportano la covarianza di tipo restituito quando si sostituiscono i metodi.

Tuttavia, non supportano la contro-varianza nei tipi di parametro, ma traducono in overloading (Java) o hiding (C ++).

Perché è così ? Mi sembra che non ci sia nulla di male nel permetterlo. Posso trovarne una ragione in Java – dal momento che ha il meccanismo di “scegliere la versione più specifica” per il sovraccarico in ogni caso – ma non posso pensare ad alcun motivo per C ++.

Esempio (Java):

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

Sulla pura questione della contro-varianza

L’aggiunta di contro-varianza a una lingua apre un sacco di potenziali problemi o soluzioni impure e offre pochissimi vantaggi in quanto può essere facilmente simulata senza supporto linguistico:

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

Con un semplice salto in più puoi superare manualmente il problema di un linguaggio che non supporta la contro-varianza. Nell’esempio, f( A& ) non ha bisogno di essere virtuale e la chiamata è pienamente qualificata per inibire il meccanismo di invio virtuale.

Questo approccio mostra uno dei primi problemi che si presentano quando si aggiunge contro-varianza a un linguaggio che non ha un invio dinamico completo:

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

Con la controvarianza in effetti, Q::f sarebbe un override di P::f , e ciò andrebbe bene come per ogni object o che può essere un argomento di P::f , quello stesso object è un argomento valido per Q::f . Ora, aggiungendo un livello in più alla gerarchia, finiamo con il problema del design: R::f(B&) un override valido di P::f o dovrebbe essere R::f(A&) ?

Senza controversione R::f( B& ) è chiaramente un override di P::f , poiché la firma è una corrispondenza perfetta. Una volta aggiunta la controvarianza al livello intermedio, il problema è che ci sono argomenti validi al livello Q ma non ai livelli P o R Affinché R soddisfi i requisiti Q , l’unica scelta è forzare la firma a essere R::f( A& ) , in modo che il seguente codice possa essere compilato:

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

Allo stesso tempo, non c’è nulla nella lingua che inibisce il seguente codice:

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

Ora abbiamo un effetto divertente:

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

In [1], c’è una chiamata diretta a un metodo membro di R Poiché r è un object locale e non un riferimento o un puntatore, non esiste alcun meccanismo di invio dinamico e la corrispondenza migliore è R::f( B& ) . Allo stesso tempo, in [2] la chiamata viene effettuata attraverso un riferimento alla class base e il meccanismo di invio virtuale entra in gioco.

Poiché R::f( A& ) è l’override di Q::f( A& ) che a sua volta è l’override di P::f( B& ) , il compilatore dovrebbe chiamare R::f( A& ) . Mentre questo può essere perfettamente definito nella lingua, potrebbe essere sorprendente scoprire che le due chiamate quasi esatte [1] e [2] in realtà chiamano metodi diversi, e che in [2] il sistema chiamerebbe una corrispondenza non ottimale di gli argomenti.

Certo, può essere discusso in modo diverso: R::f( B& ) dovrebbe essere l’override corretto, e non R::f( A& ) . Il problema in questo caso è:

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

Se si controlla la class Q , il codice precedente è perfettamente corretto: Q::f accetta un argomento A& as. Il compilatore non ha motivo di lamentarsi di quel codice. Ma il problema è che sotto quest’ultima ipotesi R::f prende un B& e non un A& come argomento! L’override effettivo che sarebbe in atto non sarebbe in grado di gestire a argomento, anche se la firma del metodo nel punto di chiamata sembra perfettamente corretta. Questo percorso ci porta a stabilire che il secondo percorso è molto peggiore del primo. R::f( B& ) non può essere un override di Q::f( A& ) .

Seguendo il principio della sorpresa minima, è molto più semplice sia per il compilatore che per l’implementatore che per il programmatore non avere contro varianze negli argomenti delle funzioni. Non perché non sia fattibile, ma perché ci sarebbero stranezze e sorprese nel codice, e considerando che ci sono semplici soluzioni se la funzione non è presente nella lingua.

Sovraccarico vs Nascondere

Sia in Java che in C ++, nel primo esempio (con A , B , C e D ) rimuovendo il dispatch manuale [0], C::f e D::f sono diverse firme e non override. In entrambi i casi sono in realtà sovraccarichi dello stesso nome di funzione con la leggera differenza che a causa delle regole di ricerca C ++, il sovraccarico C::f verrà nascosto da D::f . Ma questo significa solo che il compilatore non troverà il sovraccarico nascosto di default, non che non sia presente:

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

E con un leggero cambiamento nella definizione della class, può essere fatto funzionare esattamente come 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; df( 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 Af should this override? } 

Per C ++, Stroustrup discute i motivi per nascondersi brevemente nella sezione 3.5.3 di The Design & Evolution of C ++ . Il suo ragionamento è (parafrasando) che altre soluzioni sollevano altrettanti problemi, ed è stato così fin dai tempi di C With Classes.

Ad esempio, fornisce due classi e una class derivata B. Entrambe hanno una funzione di copia virtuale () che accetta un puntatore dei rispettivi tipi. Se diciamo:

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

questo è attualmente un errore, poiché B’s copy () nasconde A’s. Se non fosse un errore, solo la parte A di B potrebbe essere aggiornata dalla funzione A ().

Ancora una volta, ho parafrasato – se sei interessato, leggi il libro, che è eccellente.

Anche se questo è un bel-to-have in qualsiasi linguaggio, ho ancora bisogno di incontrare la sua applicabilità nel mio attuale lavoro.

Forse non ce n’è davvero bisogno.

Grazie a Donroby per la sua risposta di cui sopra – mi sto semplicemente estendendo.

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

Stai cadendo su un problema simile al motivo per cui l’ereditarietà dell’implementazione multipla è scoraggiata. (Se provi a invocare Af (g) direttamente, otterrai un errore di compilazione.)

Grazie alle risposte di donroby e David, penso di capire che il problema principale con l’introduzione del parametro contro-varianza è l’ integrazione con il meccanismo di sovraccarico .

Quindi non solo c’è un problema con un singolo override per più metodi, ma anche l’altro modo:

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

E ora ci sono due override validi per lo stesso metodo:

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

Oltre ai problemi di sovraccarico, non riuscivo a pensare ad altro.

Modifica: Da allora ho trovato questa voce FQA C ++ (20.8) che concorda con quanto sopra – la presenza di sovraccarico crea un serio problema per parametro contro-varianza.