C# Varianza en delegados

¡Buenas! A raíz de una situación en la que me he encontrado en un proyecto real (de la que luego hablaré) me he decidido a escribir este post para comentar algunas cosillas sobre varianzas en los delegados mismos.

Cuando hablamos de varianzas en delegados hay que contemplar dos aspectos:

  • Varianzas entre los tipos definidos por el delegado y los tipos de la función asignada al delegado
  • Varianzas entre el tipo del delegado y otros tipos (en este caso object).
  • Las combinaciones entre esos dos puntos.

Los delegados son tipos por referencia, como si fuesen una clase. Eso significa que null es un valor aceptado y que no hay boxing/unboxing al convertir a/desde object. Como todos los tipos un delegado hereda de object:

public delegate void MyDelegate(int i, object o);
// Luego en cualquier función
object o = new MyDelegate((i, o) => { });

Eso compila sin problemas: creamos un MyDelegate vinculado a una función anónima (expresada con una expresión lambda) y asignamos el valor a una variable de tipo object.

Observa ahora el siguiente código:

MyDelegate d = new MyDelegate((i, o) => { });

Es obvio que funciona, pero podríamos intentar simplificarlo, eliminando la creación explícita del objeto MyDelegate puesto que la expresión lambda ya define una función válida:

MyDelegate d = (i, o) => { };

Eso es también correcto. En resúmen, ya sea creando explícitamente el objeto delegado (primer caso) o no (segundo caso) podemos asignar una lambda a un delegado, siempre y cuando los parámetros de la lambda sean «compatibles» con la firma del delegado. Para eso, claro está, el compilador debe «inferir» los tipos de los parámetros de la lambda. En este caso asume que el parámetro i es de tipo int y el parámetro es de tipo object, ya que esa es la manera de cumplir con la firma del delegado.

Vale, entonces podemos asignar delegados a objetos y podemos crear delegados a partir de lambdas y el compilador infiere los tipos de los parámetros. Pero, ¿qué ocurre si intentamos asignar un delegado a una función que no se corresponde con la firma del delegado, pero donde hay varianza entre sus parámetros?

Es decir, imagina eso:

MyDelegate d = (int i, string s) => { };

Aquí estamos forzando que el parámetro s de la expresión lambda sea una string. ¿Eso es correcto? Veamos:

  • string hereda de object
  • Por lo tanto cualquier función que tenga como parámetros (int, object) le podemos pasar (int, string) sin ningún problema.
  • Por lo tanto en un delegado (int, object) podemos guardar una función (int, string). (¡Ojo! Que este punto es el que contiene el razonamiento incorrecto. Ahora mismo lo vemos).

A priori parece que eso debería compilar. Pero la realidad es que no. Este código no compila. Vas a recibir un error CS1661: Cannot convert lambda expression to delegate type ‘MyDelegate’ because the parameter types do not match the delegate parameter types.

El problema es que hemos asumido, que dado que a cualquier función (int, object) le podemos pasar (int, string), las funciones (int, string) son «como un subtipo» de las funciones (int, object). O dicho de otro modo más técnico: hemos asumido que, como hay covarianza entre los tipos string y object (podemos usar strings donde se esperan objects) habrá covarianza entre los tipos «función (int, object) y «función (int, string)». Y no es ese el caso.

No hay manera de asignar una función (int, string) a un delegado cuya firma es (int, object). ¡Y con razón! Vamos a verlo con un ejemplo:

Eso no funciona:

public delegate void MyStringDelegate(int i, string s);
public delegate void MyObjectDelegate(int i, object o);
// Luego en cualquier sitio...
MyStringDelegate d = (int i, string s) => { }; 
MyObjectDelegate t = d;

La última línea da error, diciendo que no puede convertir un delegado (int, string) a un delegado (int, object). Da igual que la covarianza entre string y object. Aquí no aplica.

Podrías tener la tentación de usar in para forzar la covarianza:

public delegate void MyDelegate<in  T>(int i, T o);
MyDelegate<string> d = (int i, string s) => { };
MyDelegate<object> t = d;        // Error CS0266

Eso tampoco te va a compilar.

¿Y por qué no se puede? Pues muy sencillo. Imagina que cualquiera de esos dos códigos fuese posible. Entonces yo podría hacer:

MyDelegate<string> d = (int i, string s) => { };
MyDelegate<object> t = d;
t(1, DateTime.Now);

¡He llamado a una función que aceptaba (int, string) y le he pasado (int, DateTime)! (De hecho le he pasado (int, object), por lo que a la práctica le puedo pasar (int, cualquier cosa)). ¡Es obvio que esto no debe compilar!

Claro, el problema radica en que un delegado me permite invocar a la función que contiene. Así, realmente lo que parece un tema de covarianza (usar strings en lugar de objects) es realmente un problema de contravarianza (usar objects en lugar de strings). Por lo tanto, a pesar de que una variable de tipo object puede contener una string, un delegado cuya firma sea (object) no puede contener una función con firma(string).

Es decir, sí, a cualquier función que acepte (int, object) le podemos pasar (int, string) pero eso ¡¡¡NO es asignar una función (int, string) a un delegado (int, object)!!!

De hecho el uso que hemos intentado con la palabra clave in nos habilita precisamente la conversión contraria:

MyDelegate<object> d = (int i, object s) => { };
MyDelegate<string> t = d;
t(1, "");

Observa como podemos asignar un MyDelegate<object> a un MyDelegate<string>. Eso te puede sonar contra-intuitivo (a fin de cuentas una variable string no puede contener un object), pero de nuevo tiene toda la lógica si lo miras desde el punto de vista del delegado: El delegado d contiene una función que acepta (int, object), por lo tanto esta función aceptará parámetros (int, string) que es precisamente lo que exige el delegado t. ¡Todo en orden!

En este caso decimos que el delegado MyDelegate<T> es contravariante respecto a T, que es una forma de decir que dado un MyDelegate<T> se puede asignar a un MyDelegate<U> donde U es un subtipo de T. La palabra clave in habilita la contravarianza.

En lugar de contravarianza podemos declarar que un delegado es covariante respecto a un tipo. Es decir que dado MyDelegate<T> podemos asignarlo a un MyDelegate<U> donde U es un «supertipo» de T. Para ello usamos out en lugar de in al definir el delegado:

public delegate void MyDelegate<out  T>(int i, T o);    // Error CS1961

Claro que eso no compila (si lo hiciera volveríamos al punto inicial de pasar DateTimes a funciones que esperan cadenas). De hecho para que ese funcione el parámetro genérico T debe estar como tipo de retorno del delegado. Tomemos p. ej. Func<T, R>. Este delegado está declarado así:

public delegate R Func<in T, out R>(R r)

Es decir, Func es contravariante respecto a T y covariante respecto a R. Lo que significa que puedo asignar un Func<T,R> a cualquier Func<T’,R’> siempre que:

  • T’ sea un subtipo de T
  • R’ sea un supertipo de R

Así pues el siguiente código es correcto:

class A { }
class B : A { }
class C : B { }
// En cualquier lugar
Func<B, A> f = _ => new A();
Func<C, object> f2 = f;

Observa como se cumplen las dos reglas:

  • C es un subtipo de B (T’ es subtipo de T)
  • object es supertipo de A (R’ es supertipo de R)

Eso es lógico ya que:

  • Forzando que T’ sea subtipo de T, evitamos pasar como parámetro un objeto no permitido: todos los parámetros (de tipo T’) serán de un tipo que será subtipo del original.
  • Forzando que R’ sea un supertipo de R, evitamos que el resultado esté en una referencia errónea. En nuestro caso el resultado «real» (tipo A) se guardará en una referencia de tipo object (supertipo de A), si invocamos la función a través de f2.

Resumiendo: es fácil que pensando rápido caigamos en confusiones y cosas que nos parecen «obvias» no lo sean tanto. Una discusión muy parecida a esta se da también en interfaces genéricas, de las que ya hablé hace muuuucho tiempo (aunque centrándome solo en la covarianza).

Otra gente que ha hablado en el pasado de lo mismo:

Deja un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *