c# - Covarianza de parámetros de tipo genérico e implementaciones de múltiples interfaces
c# in keyword (5)
El compilador no puede arrojar un error en la línea
IGeneric<Base> b = x;
Console.WriteLine(b.GetName()); //Derived1
porque no hay ninguna ambigüedad que el compilador pueda conocer. GetName()
es, de hecho, un método válido en la interfaz IGeneric<Base>
. El compilador no rastrea el tiempo de ejecución de b
para saber que hay un tipo que podría causar ambigüedad. Entonces queda en el tiempo de ejecución decidir qué hacer. El tiempo de ejecución podría arrojar una excepción, pero los diseñadores del CLR aparentemente decidieron no hacerlo (lo que personalmente considero una buena decisión).
Para decirlo de otra manera, digamos que, en cambio, simplemente has escrito el método:
public void CallIt(IGeneric<Base> b)
{
string name = b.GetName();
}
y no proporciona clases implementando IGeneric<T>
en su ensamblado. Usted distribuye esto y muchos otros implementan esta interfaz solo una vez y son capaces de llamar a su método muy bien. Sin embargo, alguien eventualmente consume su ensamblaje y crea la clase DoubleDown
y la pasa a su método. ¿En qué punto debería el compilador arrojar un error? Seguramente el ensamblado ya compilado y distribuido que contiene la llamada a GetName()
no puede producir un error de compilación. Podría decir que la asignación de DoubleDown
a IGeneric<Base>
produce la ambigüedad. pero una vez más podríamos agregar otro nivel de indirección en el ensamblaje original:
public void CallItOnDerived1(IGeneric<Derived1> b)
{
return CallIt(b); //b will be cast to IGeneric<Base>
}
Una vez más, muchos consumidores podrían llamar a CallIt
o CallItOnDerived1
y estar bien. Pero nuestro consumidor que DoubleDown
también está haciendo una llamada perfectamente legal que no podría causar un error de compilación cuando llaman a CallItOnDerived1
ya que la conversión de DoubleDown
a IGeneric<Derived1>
debería estar bien. Por lo tanto, no hay ningún punto en el que el compilador pueda arrojar un error que no sea posible en la definición de DoubleDown
, pero esto eliminaría la posibilidad de hacer algo potencialmente útil sin solución alternativa.
De hecho, he respondido a esta pregunta más a fondo en otro lugar, y también proporcioné una posible solución si el idioma pudiera cambiarse:
Dado que la posibilidad de que el lenguaje cambie para admitir esto es virtualmente cero, creo que el comportamiento actual es correcto, excepto que debe estar establecido en las especificaciones para que todas las implementaciones del CLR se comporten de la misma manera.
Si tengo una interfaz genérica con un parámetro de tipo covariante, como este:
interface IGeneric<out T>
{
string GetName();
}
Y si defino esta jerarquía de clase:
class Base {}
class Derived1 : Base{}
class Derived2 : Base{}
Luego puedo implementar la interfaz dos veces en una sola clase, como esta, usando la implementación de interfaz explícita:
class DoubleDown: IGeneric<Derived1>, IGeneric<Derived2>
{
string IGeneric<Derived1>.GetName()
{
return "Derived1";
}
string IGeneric<Derived2>.GetName()
{
return "Derived2";
}
}
Si utilizo la clase DoubleDown
(no genérica) y la echo a IGeneric<Derived1>
o IGeneric<Derived2>
, funciona como se esperaba:
var x = new DoubleDown();
IGeneric<Derived1> id1 = x; //cast to IGeneric<Derived1>
Console.WriteLine(id1.GetName()); //Derived1
IGeneric<Derived2> id2 = x; //cast to IGeneric<Derived2>
Console.WriteLine(id2.GetName()); //Derived2
Sin embargo, al IGeneric<Base>
la x
a IGeneric<Base>
, se obtiene el siguiente resultado:
IGeneric<Base> b = x;
Console.WriteLine(b.GetName()); //Derived1
Esperaba que el compilador emitiera un error, ya que la llamada es ambigua entre las dos implementaciones, pero devolvió la primera interfaz declarada.
¿Por qué está permitido?
(inspirado en una clase A implementando dos IObservables diferentes . Intenté mostrarle a un colega que esto fallaría, pero de alguna manera, no fue así)
La pregunta fue: "¿Por qué no produce esto una advertencia de compilador?". En VB, lo hace (lo implementé).
El sistema de tipo no contiene suficiente información para proporcionar una advertencia en el momento de la invocación sobre la ambigüedad de la varianza. Entonces la advertencia tiene que ser emitida antes ...
En VB, si declaras una clase
C
que implementa tantoIEnumerable(Of Fish)
comoIEnumerable(Of Dog)
, da una advertencia diciendo que los dos entrarán en conflicto en el caso comúnIEnumerable(Of Animal)
. Esto es suficiente para eliminar la ambigüedad de varianza del código escrito completamente en VB.Sin embargo, no ayuda si la clase de problema fue declarada en C #. También tenga en cuenta que es completamente razonable declarar dicha clase si nadie invoca a un miembro problemático.
En VB, si realiza un yeso de dicha clase
C
enIEnumerable(Of Animal)
, entonces se emite una advertencia en el reparto. Esto es suficiente para eliminar la ambigüedad de la varianza incluso si importó la clase de problema de los metadatos .Sin embargo, es una ubicación de advertencia deficiente porque no es procesable: no puedes ir y cambiar el elenco. La única advertencia procesable para las personas sería volver atrás y cambiar la definición de la clase . También tenga en cuenta que es completamente razonable realizar dicho lanzamiento si nadie invoca a un miembro problemático en él.
Pregunta:
¿Cómo es que VB emite estas advertencias, pero C # no?
Responder:
Cuando los puse en VB, me entusiasmé con la informática formal, y solo había estado escribiendo compiladores durante un par de años, y tuve el tiempo y el entusiasmo para codificarlos.
Eric Lippert los estaba haciendo en C #. Tenía la sabiduría y la madurez para ver que la codificación de tales advertencias en el compilador tomaría mucho tiempo que podría ser mejor gastado en otro lugar, y era lo suficientemente complejo como para ser de alto riesgo. De hecho, los compiladores de VB tenían errores en estas mismas advertencias que solo se corrigieron en VS2012.
Además, para ser franco, era imposible generar un mensaje de advertencia lo suficientemente útil como para que la gente lo entendiera. De paso,
Pregunta:
¿Cómo resuelve el CLR la ambigüedad al elegir cuál invocar?
Responder:
Se basa en el orden léxico de las declaraciones de herencia en el código fuente original, es decir, el orden léxico en el que declaraste que
C
implementaIEnumerable(Of Fish)
eIEnumerable(Of Dog)
.
Santo Dios, muchas buenas respuestas aquí a lo que es una pregunta bastante difícil. Resumiendo:
- La especificación del idioma no dice claramente qué hacer aquí.
- Este escenario generalmente surge cuando alguien intenta emular la covarianza o contravariancia de la interfaz; ahora que C # tiene varianza de interfaz, esperamos que menos personas usen este patrón.
- La mayoría de las veces "solo elige uno" es un comportamiento razonable.
- La manera en que el CLR realmente elige qué implementación se usa en una conversión covariante ambigua es definida por la implementación. Básicamente, escanea las tablas de metadatos y elige la primera coincidencia, y C # pasa a emitir las tablas en orden de código fuente. Sin embargo, no puedes confiar en este comportamiento; cualquiera puede cambiar sin previo aviso.
Solo agregaría otra cosa, y es que: la mala noticia es que la semántica de la reimplementación de la interfaz no coincide exactamente con el comportamiento especificado en la especificación CLI en escenarios donde surgen este tipo de ambigüedades. La buena noticia es que el comportamiento real del CLR al volver a implementar una interfaz con este tipo de ambigüedad generalmente es el comportamiento que usted desearía. Descubrir este hecho condujo a un enérgico debate entre mí, Anders y algunos de los mantenedores de especificaciones de CLI, y el resultado final fue que no hubo cambios ni en la especificación ni en la implementación. Dado que la mayoría de los usuarios de C # ni siquiera saben para qué empezará la reimplementación de la interfaz, esperamos que esto no afecte negativamente a los usuarios. (Ningún cliente me ha llamado la atención).
Si ha probado ambos de:
class DoubleDown: IGeneric<Derived1>, IGeneric<Derived2> {
string IGeneric<Derived1>.GetName() {
return "Derived1";
}
string IGeneric<Derived2>.GetName() {
return "Derived2";
}
}
class DoubleDown: IGeneric<Derived2>, IGeneric<Derived1> {
string IGeneric<Derived1>.GetName() {
return "Derived1";
}
string IGeneric<Derived2>.GetName() {
return "Derived2";
}
}
Usted ya debe haberse dado cuenta de que el resultado en realidad cambia con el orden que declara las interfaces para implementar . Pero diría eso, simplemente no está especificado .
Primero, la especificación (§13.4.4 Mapeo de interfaz) dice:
- Si coincide más de un miembro, no se especifica qué miembro es la implementación de IM
- Esta situación solo puede ocurrir si S es un tipo construido donde los dos miembros declarados en el tipo genérico tienen firmas diferentes , pero los argumentos de tipo hacen que sus firmas sean idénticas.
Aquí tenemos dos preguntas para considerar:
P1: ¿Sus interfaces genéricas tienen diferentes firmas ?
A1: Sí. SonIGeneric<Derived2>
eIGeneric<Derived1>
.Q2: ¿Podría la declaración
IGeneric<Base> b=x;
hace que sus firmas sean idénticas a los argumentos de tipo?
A2: No. Ha invocado el método a través de una definición de interfaz covariante genérica.
Por lo tanto, su llamada es cumplir con la condición no especificada. Pero, ¿cómo podría suceder esto?
Recuerde, sea cual sea la interfaz que especificó para hacer referencia al objeto de tipo DoubleDown
, siempre es DoubleDown
. Es decir, siempre tiene estos dos métodos GetName
. La interfaz que especifique para hacer referencia a ella, de hecho, realiza la selección del contrato .
Lo siguiente es la parte de la imagen capturada de la prueba real
Esta imagen muestra lo que se devolvería con GetMembers
en tiempo de ejecución. En todos los casos que lo referencia, IGeneric<Derived1>
, IGeneric<Derived2>
o IGeneric<Base>
, no son nada diferentes. Siguiendo dos imágenes mostradas en más detallado:
Las imágenes muestran que estas dos interfaces derivadas genéricas no tienen el mismo nombre ni otras firmas / tokens las hacen idénticas.
Y ahora, solo sabes por qué.
Tratando de profundizar en las "especificaciones del lenguaje C #", parece que el comportamiento no está especificado (si no me perdí en mi camino).
7.4.4 Invocación de miembro de función
El procesamiento en tiempo de ejecución de una invocación a un miembro de función consta de los siguientes pasos, donde M es el miembro de la función y, si M es un miembro de la instancia, E es la expresión de la instancia:
[...]
o La implementación del miembro de función para invocar se determina:
• Si el tipo de E de tiempo de compilación es una interfaz, el miembro de función a invocar es la implementación de M proporcionada por el tipo de tiempo de ejecución de la instancia a la que hace referencia E. Este miembro de función se determina aplicando las reglas de mapeo de interfaz (§ 13.4.4) para determinar la implementación de M proporcionada por el tipo de tiempo de ejecución de la instancia a la que hace referencia E.
13.4.4 Mapeo de interfaz
La asignación de interfaz para una clase o estructura C ubica una implementación para cada miembro de cada interfaz especificada en la lista de clase base de C. Se determina la implementación de un miembro de interfaz particular IM, donde I es la interfaz en la que se declara el miembro M examinando cada clase o estructura S, comenzando con C y repitiendo para cada clase base sucesiva de C, hasta que se encuentre una coincidencia:
• Si S contiene una declaración de una implementación de miembro de interfaz explícita que coincide con I y M, este miembro es la implementación de IM
• De lo contrario, si S contiene una declaración de un miembro público no estático que coincide con M, este miembro es la implementación de IM. Si más de un miembro coincide, no se especifica qué miembro es la implementación de IM . Esta situación solo puede ocurrir si S es un tipo construido donde los dos miembros declarados en el tipo genérico tienen firmas diferentes, pero los argumentos de tipo hacen que sus firmas sean idénticas.