Diferencia entre covarianza y contravarianza
Tengo problemas para entender la diferencia entre covarianza y contravarianza.
La pregunta es "¿cuál es la diferencia entre covarianza y contravarianza?"
La covarianza y la contravarianza son propiedades de una función de mapeo que asocia un miembro de un conjunto con otro . Más específicamente, un mapeo puede ser covariante o contravariante con respecto a una relación en ese conjunto.
Considere los siguientes dos subconjuntos del conjunto de todos los tipos de C#. Primero:
{ Animal,
Tiger,
Fruit,
Banana }.
Y segundo, este conjunto claramente relacionado:
{ IEnumerable<Animal>,
IEnumerable<Tiger>,
IEnumerable<Fruit>,
IEnumerable<Banana> }
Hay una operación de mapeo del primer conjunto al segundo conjunto. Es decir, para cada T en el primer conjunto, el tipo correspondiente en el segundo conjunto es IEnumerable<T>
. O, en forma abreviada, el mapeo es T → IE<T>
. Observe que se trata de una "flecha delgada".
¿Conmigo hasta ahora?
Ahora consideremos una relación . Existe una relación de compatibilidad de asignación entre pares de tipos en el primer conjunto. Tiger
Se puede asignar un valor de tipo a una variable de tipo Animal
, por lo que se dice que estos tipos son "compatibles con la asignación". Escribamos "un valor de tipo X
se puede asignar a una variable de tipo Y
" en una forma más corta: X ⇒ Y
. Observe que se trata de una "flecha gruesa".
Entonces, en nuestro primer subconjunto, aquí están todas las relaciones de compatibilidad de asignaciones:
Tiger ⇒ Tiger
Tiger ⇒ Animal
Animal ⇒ Animal
Banana ⇒ Banana
Banana ⇒ Fruit
Fruit ⇒ Fruit
En C# 4, que admite la compatibilidad de asignación covariante de ciertas interfaces, existe una relación de compatibilidad de asignación entre pares de tipos en el segundo conjunto:
IE<Tiger> ⇒ IE<Tiger>
IE<Tiger> ⇒ IE<Animal>
IE<Animal> ⇒ IE<Animal>
IE<Banana> ⇒ IE<Banana>
IE<Banana> ⇒ IE<Fruit>
IE<Fruit> ⇒ IE<Fruit>
Observe que el mapeo T → IE<T>
preserva la existencia y dirección de la compatibilidad de asignación . Es decir, si X ⇒ Y
, entonces también es cierto que IE<X> ⇒ IE<Y>
.
Si tenemos dos cosas a cada lado de una flecha gruesa, entonces podemos reemplazar ambos lados con algo en el lado derecho de la flecha delgada correspondiente.
Un mapeo que tiene esta propiedad con respecto a una relación particular se llama "mapeo covariante". Esto debería tener sentido: se puede utilizar una secuencia de Tigres cuando se necesita una secuencia de Animales, pero lo contrario no es cierto. No necesariamente se puede utilizar una secuencia de animales donde se necesita una secuencia de Tigres.
Eso es covarianza. Consideremos ahora este subconjunto del conjunto de todos los tipos:
{ IComparable<Tiger>,
IComparable<Animal>,
IComparable<Fruit>,
IComparable<Banana> }
ahora tenemos el mapeo del primer conjunto al tercer conjunto T → IC<T>
.
En C#4:
IC<Tiger> ⇒ IC<Tiger>
IC<Animal> ⇒ IC<Tiger> Backwards!
IC<Animal> ⇒ IC<Animal>
IC<Banana> ⇒ IC<Banana>
IC<Fruit> ⇒ IC<Banana> Backwards!
IC<Fruit> ⇒ IC<Fruit>
Es decir, el mapeo T → IC<T>
ha preservado la existencia pero invirtió la dirección de la compatibilidad de la asignación. Es decir, si X ⇒ Y
, entonces IC<X> ⇐ IC<Y>
.
Un mapeo que preserva pero invierte una relación se llama mapeo contravariante .
Nuevamente, esto debería ser claramente correcto. Un dispositivo que puede comparar dos animales también puede comparar dos tigres, pero un dispositivo que puede comparar dos tigres no necesariamente puede comparar dos animales cualesquiera.
Esa es la diferencia entre covarianza y contravarianza en C# 4. La covarianza preserva la dirección de asignabilidad. La contravarianza lo revierte .
Probablemente sea más fácil dar ejemplos; así es como los recuerdo.
Covarianza
Ejemplos canónicos: IEnumerable<out T>
,Func<out T>
Puede convertir de IEnumerable<string>
a IEnumerable<object>
o Func<string>
a Func<object>
. Los valores sólo surgen de estos objetos.
Funciona porque si solo estás tomando valores de la API y va a devolver algo específico (como string
), puedes tratar ese valor devuelto como un tipo más general (como object
).
Contravarianza
Ejemplos canónicos: IComparer<in T>
,Action<in T>
Puede convertir de IComparer<object>
a IComparer<string>
o Action<object>
a Action<string>
; los valores sólo van en estos objetos.
Esta vez funciona porque si la API espera algo general (como object
), puedes darle algo más específico (como string
).
Más generalmente
Si tiene una interfaz, IFoo<T>
puede ser covariante T
(es decir, declararla como IFoo<out T>
si T
solo se usara en una posición de salida (por ejemplo, un tipo de retorno) dentro de la interfaz. Puede ser contravariante T
(es decir, IFoo<in T>
) si T
solo se usa en una posición de entrada ( por ejemplo, un tipo de parámetro).
Se vuelve potencialmente confuso porque la "posición de salida" no es tan simple como parece: un parámetro de tipo Action<T>
todavía solo se usa T
en una posición de salida; la contravarianza de Action<T>
lo convierte, si entiendes lo que quiero decir. Es una "salida" en el sentido de que los valores pueden pasar desde la implementación del método hacia el código de la persona que llama, tal como puede hacerlo un valor de retorno. Normalmente este tipo de cosas no suceden, afortunadamente :)
Espero que mi publicación ayude a obtener una visión del tema independiente del idioma.
Para nuestras capacitaciones internas he trabajado con el maravilloso libro "Smalltalk, Objects and Design (Chamond Liu)" y reformulé los siguientes ejemplos.
¿Qué significa "consistencia"? La idea es diseñar jerarquías de tipos seguros con tipos altamente sustituibles. La clave para lograr esta coherencia es la conformidad basada en subtipos, si trabaja en un lenguaje escrito estáticamente. (Aquí discutiremos el Principio de sustitución de Liskov (LSP) en un nivel alto).
Ejemplos prácticos (pseudocódigo/no válido en C#):
Covarianza: supongamos que las aves ponen huevos "consistentemente" con la tipificación estática: si el tipo Ave pone un huevo, ¿el subtipo de Ave no pondría un subtipo de huevo? Por ejemplo, el tipo Pato pone un Huevo de Pato, luego se le da la consistencia. ¿Por qué es esto consistente? Porque en tal expresión:
Egg anEgg = aBird.Lay();
la referencia aBird podría ser sustituida legalmente por una instancia de Bird o Duck. Decimos que el tipo de retorno es covariante con el tipo en el que se define Lay(). La anulación de un subtipo puede devolver un tipo más especializado. => “Entregan más”.Contravarianza: supongamos pianos que los pianistas pueden tocar "consistentemente" con escritura estática: si un pianista toca el piano, ¿podría tocar un piano de cola? ¿No preferiría un virtuoso tocar el piano de cola? (Ten cuidado; ¡hay un giro!) ¡Esto es inconsistente! Porque en tal expresión: ¡
aPiano.Play(aPianist);
unPiano no podría ser sustituido legalmente por un Piano o por una instancia de GrandPiano! Un piano de cola sólo puede ser tocado por un virtuoso, ¡los pianistas son demasiado generales! Los pianos de cola deben poder tocarse con tipos más generales para que la interpretación sea consistente. Decimos que el tipo de parámetro es contravariante al tipo en el que se define Play(). La anulación de un subtipo puede aceptar un tipo más generalizado. => “Requieren menos”.
Volviendo a C#:
debido a que C# es básicamente un lenguaje de tipo estático, las "ubicaciones" de la interfaz de un tipo que deben ser covariantes o contravariantes (por ejemplo, parámetros y tipos de retorno) deben marcarse explícitamente para garantizar un uso/desarrollo consistente de ese tipo. , para que el LSP funcione bien. En los lenguajes escritos dinámicamente, la coherencia LSP generalmente no es un problema; en otras palabras, podría deshacerse por completo del "marcado" covariante y contravariante en las interfaces y delegados .Net, si solo usara el tipo dinámico en sus tipos. - Pero esta no es la mejor solución en C# (no deberías usar dinámica en interfaces públicas).
Volviendo a la teoría:
la conformidad descrita (tipos de retorno covariantes/tipos de parámetros contravariantes) es el ideal teórico (respaldado por los lenguajes Emerald y POOL-1). Algunos lenguajes de programación (por ejemplo, Eiffel) decidieron aplicar otro tipo de coherencia, especialmente. también tipos de parámetros covariantes, porque describe mejor la realidad que el ideal teórico. En los lenguajes de tipado estático, la coherencia deseada a menudo debe lograrse mediante la aplicación de patrones de diseño como "doble envío" y "visitante". Otros lenguajes proporcionan los llamados “despacho múltiple” o métodos múltiples (básicamente seleccionan sobrecargas de funciones en tiempo de ejecución , por ejemplo con CLOS) u obtienen el efecto deseado mediante el uso de escritura dinámica.
La variación Co y Contra son cosas bastante lógicas. El sistema de tipos de lenguaje nos obliga a apoyar la lógica de la vida real. Es fácil de entender con el ejemplo.
Covarianza
Por ejemplo, quieres comprar una flor y tienes dos florerías en tu ciudad: una tienda de rosas y una tienda de margaritas.
Si le preguntas a alguien "¿dónde está la floristería?" y alguien te dice dónde está la tienda de rosas, ¿estaría bien? Sí, porque la rosa es una flor, si quieres comprar una flor puedes comprar una rosa. Lo mismo se aplica si alguien le respondió con la dirección de la tienda de margaritas.
Este es un ejemplo de covarianza : se le permite convertir A<C>
a A<B>
, donde C
hay una subclase de B
, si A
produce valores genéricos (devoluciones como resultado de la función). La covarianza tiene que ver con los productores, es por eso que C# usa palabras clave out
para la covarianza.
Tipos:
class Flower { }
class Rose: Flower { }
class Daisy: Flower { }
interface FlowerShop<out T> where T: Flower {
T getFlower();
}
class RoseShop: FlowerShop<Rose> {
public Rose getFlower() {
return new Rose();
}
}
class DaisyShop: FlowerShop<Daisy> {
public Daisy getFlower() {
return new Daisy();
}
}
La pregunta es "¿dónde está la floristería?", la respuesta es "la rosalería allí":
static FlowerShop<Flower> tellMeShopAddress() {
return new RoseShop();
}
Contravarianza
Por ejemplo, quieres regalarle una flor a tu novia y a ella le gustan las flores. ¿Puedes considerarla como una persona que ama las rosas o como una persona que ama las margaritas? Sí, porque si ama cualquier flor, amaría tanto la rosa como la margarita.
Este es un ejemplo de contravarianza : se le permite transmitir A<B>
a A<C>
, donde C
es la subclase de B
, si A
consume valor genérico. La contravarianza tiene que ver con los consumidores, es por eso que C# usa palabras clave in
para la contravarianza.
Tipos:
interface PrettyGirl<in TFavoriteFlower> where TFavoriteFlower: Flower {
void takeGift(TFavoriteFlower flower);
}
class AnyFlowerLover: PrettyGirl<Flower> {
public void takeGift(Flower flower) {
Console.WriteLine("I like all flowers!");
}
}
Estás considerando a tu novia que ama cualquier flor como alguien que ama las rosas y le regalas una rosa:
PrettyGirl<Rose> girlfriend = new AnyFlowerLover();
girlfriend.takeGift(new Rose());
Enlaces
- Publicación sobre programación genérica en general.
- La misma respuesta para el lenguaje de programación Java.
El delegado del convertidor me ayuda a entender la diferencia.
delegate TOutput Converter<in TInput, out TOutput>(TInput input);
TOutput
representa la covarianza donde un método devuelve un tipo más específico .
TInput
representa la contravarianza donde a un método se le pasa un tipo menos específico .
public class Dog { public string Name { get; set; } }
public class Poodle : Dog { public void DoBackflip(){ System.Console.WriteLine("2nd smartest breed - woof!"); } }
public static Poodle ConvertDogToPoodle(Dog dog)
{
return new Poodle() { Name = dog.Name };
}
List<Dog> dogs = new List<Dog>() { new Dog { Name = "Truffles" }, new Dog { Name = "Fuzzball" } };
List<Poodle> poodles = dogs.ConvertAll(new Converter<Dog, Poodle>(ConvertDogToPoodle));
poodles[0].DoBackflip();