Varianza en los tipos genéricos (Guía de programación de C#)

Actualización: noviembre 2007

Una de las principales ventajas de agregar genéricos a C# es la posibilidad de crear fácilmente colecciones con establecimiento inflexible de tipos utilizando los tipos del espacio de nombres System.Collections.Generic. Por ejemplo, se puede crear una variable de tipo List<int>; el compilador comprobará todos los accesos a la variable y garantizará que sólo se agreguen valores ints a la colección. Ésta es una mejora de gran utilidad sobre las colecciones sin tipo disponibles en la versión 1.0 de C#.

Desgraciadamente, las colecciones con establecimiento inflexible de tipos tienen sus propios inconvenientes. Por ejemplo, suponga que tiene un objeto List<object> con establecimiento inflexible de tipos y desea anexar todos los elementos de List<int> a List<object>. Es posible que desee poder escribir código, como en el siguiente ejemplo:

List<int> ints = new List<int>();
ints.Add(1);
ints.Add(10);
ints.Add(42);
List<object> objects = new List<object>();

// doesnt compile ints is not a IEnumerable<object>
//objects.AddRange(ints); 

En este caso, podría tratar un objeto List<int> qué también es IEnumerable<int>, como IEnumerable<object>. Parece razonable hacer esto, ya que int se puede convertir en un objeto. Es muy similar a poder tratar una cadena[] como objeto[] tal como se puede hacer actualmente. Si se encuentra en esta situación, la característica que busca se llama varianza de genéricos. Esta característica trata una creación de instancias de un tipo genérico, en este caso IEnumerable<int>, como una creación de instancias diferente del mismo tipo, en este caso IEnumerable<object>.

Dado que C# no admite la variación para los tipos genéricos, al encontrar los casos como esto que tendrá que probar una de varias técnicas que puede utilizar para evitar este problema. En los casos más simples, como el de un único método denominado AddRange en el ejemplo anterior, puede declarar un método auxiliar sencillo que realice la conversión automáticamente. Por ejemplo, podría escribir este método:

// Simple workaround for single method
// Variance in one direction only
public static void Add<S, D>(List<S> source, List<D> destination)
    where S : D
{
    foreach (S sourceElement in source)
    {
        destination.Add(sourceElement);
    }
}

Que le permite realizar lo siguiente:

// does compile
VarianceWorkaround.Add<int, object>(ints, objects);

Este ejemplo muestra algunas características de una solución de varianza sencilla. El método auxiliar toma dos parámetros de tipo, para el origen y el destino, y el parámetro de tipo de origen S tiene una restricción que es el parámetro de tipo de destino D. Esto significa que el objeto List<> del que se lee debe contener elementos que sean convertibles al tipo de elementos del objeto List<> en el que se inserta. Esto permite al compilador aplicar la conversión de int en un objeto. La restricción de un parámetro de tipo para derivar de otro parámetro de tipo se denomina restricción de parámetro de tipo naked.

Definir un método único para evitar los problemas de varianza no está mal del todo. Desafortunadamente, los problemas de varianza se pueden volver bastante complejos con rapidez. El nivel siguiente de complejidad es cuando se desea tratar una interfaz de una creación de instancias como una interfaz de otra creación de instancias. Por ejemplo, tiene IEnumerable<int> y desea pasarlo a un método que sólo toma IEnumerable<object>. Esto también tiene sentido, porque IEnumerable<object> se puede considerar una secuencia de objetos e IEnumerable<int> es una secuencia de ints. Puesto que los valores ints son objetos, debería ser posible tratar una secuencia de ints como una secuencia de objetos. Por ejemplo:

static void PrintObjects(IEnumerable<object> objects)
{
    foreach (object o in objects)
    {
        Console.WriteLine(o);
    }
}

Método al que podría desear llamar, tal y como se muestra en el siguiente ejemplo:

// would like to do this, but cant ...
// ... ints is not an IEnumerable<object>
//PrintObjects(ints);

La solución para la interfaz case es crear un objeto contenedor que hace las conversiones de cada miembro de la interfaz. Debería tener una apariencia similar a la del ejemplo siguiente:

// Workaround for interface
// Variance in one direction only so type expressinos are natural
public static IEnumerable<D> Convert<S, D>(IEnumerable<S> source)
    where S : D
{
    return new EnumerableWrapper<S, D>(source);
}

private class EnumerableWrapper<S, D> : IEnumerable<D>
    where S : D
{

Que le permite realizar lo siguiente:

PrintObjects(VarianceWorkaround.Convert<int, object>(ints));

De nuevo, observe la restricción de parámetro de tipo naked en la clase contenedora y el método auxiliar. Esta metodología resulta bastante complicada pero el código de la clase contenedora es bastante sencillo; simplemente se delega en los miembros de la interfaz contenedora, que no hace nada más que conversiones de tipo directas a medida que surgen. ¿Por qué no hacer que el compilador permita directamente la conversión de IEnumerable<int> a IEnumerable<object>?

Aunque la varianza tiene seguridad de tipos en el caso de que se busquen vistas de sólo lectura de las colecciones, no la tiene en el caso de que se realicen operaciones de lectura y escritura. Por ejemplo, la interfaz IList<> no se podría tratar de esta manera automática. Sigue teniendo la opción de escribir un método auxiliar que ajuste todas las operaciones de lectura de IList<> con seguridad de tipos, pero ajustar las operaciones de escritura no es tan sencillo.

Lo siguiente es una parte de un contenedor para tratar la varianza en la interfaz IList<T> que muestra los problemas que surgen con la varianza en la lectura y la escritura:

private class ListWrapper<S, D> : CollectionWrapper<S, D>, IList<D>
    where S : D
{
    public ListWrapper(IList<S> source) : base(source)
    {
        this.source = source;
    }

    public int IndexOf(D item)
    {
        if (item is S)
        {
            return this.source.IndexOf((S) item);
        }
        else
        {
            return -1;
        }
    }

    // variance the wrong way ...
    // ... can throw exceptions at runtime
    public void Insert(int index, D item)
    {
        if (item is S)
        {
            this.source.Insert(index, (S)item);
        }
        else
        {
            throw new Exception("Invalid type exception");
        }
    }

El método Insert del contenedor tiene un problema. Toma como argumento D, pero debe insertarla en IList<S>. Como D es un tipo base de S, no todo D es S, por lo que se puede producir un error en la operación de inserción. Este ejemplo tiene un análogo con varianza en matrices. Cuando se inserta un objeto en un objeto[], se realiza una comprobación de tipo dinámico porque el objeto[], de hecho, puede ser una cadena[] en tiempo de ejecución. Por ejemplo:

object[] objects = new string[10];

// no problem, adding a string to a string[]
objects[0] = "hello"; 

// runtime exception, adding an object to a string[]
objects[1] = new object(); 

En el ejemplo IList<>, el contenedor del método Insert puede producir simplemente una excepción cuando el tipo actual no coincide con el tipo deseado en tiempo de ejecución. También en este caso se podía imaginar que el compilador generaría automáticamente el contenedor para el programador. Sin embargo, hay situaciones en las que esta directiva no es la solución correcta. El método IndexOf busca el elemento proporcionado en la colección y devuelve el índice de la colección si se encuentra el elemento. Sin embargo, si no se encuentra el elemento, el método IndexOf simplemente devuelve -1, no produce una excepción. Un contenedor generado automáticamente no puede proporcionar este tipo de ajuste.

Hasta ahora, se han descrito la dos formas más sencillas de evitar los problemas de varianza de genéricos. Sin embargo, los problemas de varianza pueden volverse arbitrariamente complejos. Por ejemplo, cuando List<IEnumerable<int>> se trata como List<IEnumerable<object>> o List<IEnumerable<IEnumerable<int>>> se trata como List<IEnumerable<IEnumerable<object>>>.

La generación de estos contenedores para evitar los problemas de varianza en el código puede producir una sobrecarga significativa en el código. Además, puede producir problemas de identidad de referencia, ya que los contenedores no tienen la misma identidad que la colección original, lo que podría conducir a errores imperceptibles. Al utilizar los genéricos, se debe seleccionar la creación de instancias de tipo para reducir las desigualdades entre componentes que se corresponden estrictamente. Esto puede requerir algunos compromisos en el diseño del código. Como siempre, el diseño implica contrapartidas entre requisitos que entran en conflicto y se deben tener en cuenta las restricciones del sistema de tipos del lenguaje en el proceso de diseño.

Hay sistemas de tipos que incluyen la varianza de genéricos como una primera parte de clase del lenguaje. Eiffel es un ejemplo idóneo de esto. Sin embargo, la inclusión de la varianza de genéricos como primera parte de clase del sistema de tipos aumentaría significativamente la complejidad del sistema de tipos de C#, incluso en escenarios relativamente sencillos en los que no se utilice varianza. Como resultado, los diseñadores de C# pensaron que no incluir la varianza era la opción adecuada para C#.

A continuación se muestra el código fuente completo de los ejemplos anteriores.

using System;
using System.Collections.Generic;
using System.Text;
using System.Collections;

static class VarianceWorkaround
{
    // Simple workaround for single method
    // Variance in one direction only
    public static void Add<S, D>(List<S> source, List<D> destination)
        where S : D
    {
        foreach (S sourceElement in source)
        {
            destination.Add(sourceElement);
        }
    }

    // Workaround for interface
    // Variance in one direction only so type expressinos are natural
    public static IEnumerable<D> Convert<S, D>(IEnumerable<S> source)
        where S : D
    {
        return new EnumerableWrapper<S, D>(source);
    }

    private class EnumerableWrapper<S, D> : IEnumerable<D>
        where S : D
    {
        public EnumerableWrapper(IEnumerable<S> source)
        {
            this.source = source;
        }

        public IEnumerator<D> GetEnumerator()
        {
            return new EnumeratorWrapper(this.source.GetEnumerator());
        }

        IEnumerator System.Collections.IEnumerable.GetEnumerator()
        {
            return this.GetEnumerator();
        }

        private class EnumeratorWrapper : IEnumerator<D>
        {
            public EnumeratorWrapper(IEnumerator<S> source)
            {
                this.source = source;
            }

            private IEnumerator<S> source;

            public D Current
            {
                get { return this.source.Current; }
            }

            public void Dispose()
            {
                this.source.Dispose();
            }

            object IEnumerator.Current
            {
                get { return this.source.Current; }
            }

            public bool MoveNext()
            {
                return this.source.MoveNext();
            }

            public void Reset()
            {
                this.source.Reset();
            }
        }

        private IEnumerable<S> source;
    }

    // Workaround for interface
    // Variance in both directions, causes issues
    // similar to existing array variance
    public static ICollection<D> Convert<S, D>(ICollection<S> source)
        where S : D
    {
        return new CollectionWrapper<S, D>(source);
    }


    private class CollectionWrapper<S, D> 
        : EnumerableWrapper<S, D>, ICollection<D>
        where S : D
    {
        public CollectionWrapper(ICollection<S> source)
            : base(source)
        {
        }

        // variance going the wrong way ... 
        // ... can yield exceptions at runtime
        public void Add(D item)
        {
            if (item is S)
            {
                this.source.Add((S)item);
            }
            else
            {
                throw new Exception(@"Type mismatch exception, due to type hole introduced by variance.");
            }
        }

        public void Clear()
        {
            this.source.Clear();
        }

        // variance going the wrong way ... 
        // ... but the semantics of the method yields reasonable semantics
        public bool Contains(D item)
        {
            if (item is S)
            {
                return this.source.Contains((S)item);
            }
            else
            {
                return false;
            }
        }

        // variance going the right way ... 
        public void CopyTo(D[] array, int arrayIndex)
        {
            foreach (S src in this.source)
            {
                array[arrayIndex++] = src;
            }
        }

        public int Count
        {
            get { return this.source.Count; }
        }

        public bool IsReadOnly
        {
            get { return this.source.IsReadOnly; }
        }

        // variance going the wrong way ... 
        // ... but the semantics of the method yields reasonable  semantics
        public bool Remove(D item)
        {
            if (item is S)
            {
                return this.source.Remove((S)item);
            }
            else
            {
                return false;
            }
        }

        private ICollection<S> source;
    }

    // Workaround for interface
    // Variance in both directions, causes issues similar to existing array variance
    public static IList<D> Convert<S, D>(IList<S> source)
        where S : D
    {
        return new ListWrapper<S, D>(source);
    }

    private class ListWrapper<S, D> : CollectionWrapper<S, D>, IList<D>
        where S : D
    {
        public ListWrapper(IList<S> source) : base(source)
        {
            this.source = source;
        }

        public int IndexOf(D item)
        {
            if (item is S)
            {
                return this.source.IndexOf((S) item);
            }
            else
            {
                return -1;
            }
        }

        // variance the wrong way ...
        // ... can throw exceptions at runtime
        public void Insert(int index, D item)
        {
            if (item is S)
            {
                this.source.Insert(index, (S)item);
            }
            else
            {
                throw new Exception("Invalid type exception");
            }
        }

        public void RemoveAt(int index)
        {
            this.source.RemoveAt(index);
        }

        public D this[int index]
        {
            get
            {
                return this.source[index];
            }
            set
            {
                if (value is S)
                    this.source[index] = (S)value;
                else
                    throw new Exception("Invalid type exception.");
            }
        }

        private IList<S> source;
    }
}

namespace GenericVariance
{
    class Program
    {
        static void PrintObjects(IEnumerable<object> objects)
        {
            foreach (object o in objects)
            {
                Console.WriteLine(o);
            }
        }

        static void AddToObjects(IList<object> objects)
        {
            // this will fail if the collection provided is a wrapped collection 
            objects.Add(new object());
        }
        static void Main(string[] args)
        {
            List<int> ints = new List<int>();
            ints.Add(1);
            ints.Add(10);
            ints.Add(42);
            List<object> objects = new List<object>();

            // doesnt compile ints is not a IEnumerable<object>
            //objects.AddRange(ints); 

            // does compile
            VarianceWorkaround.Add<int, object>(ints, objects);

            // would like to do this, but cant ...
            // ... ints is not an IEnumerable<object>
            //PrintObjects(ints);

            PrintObjects(VarianceWorkaround.Convert<int, object>(ints));

            AddToObjects(objects); // this works fine
            AddToObjects(VarianceWorkaround.Convert<int, object>(ints));
        }
        static void ArrayExample()
        {
            object[] objects = new string[10];

            // no problem, adding a string to a string[]
            objects[0] = "hello"; 

            // runtime exception, adding an object to a string[]
            objects[1] = new object(); 
        }
    }
}

Vea también

Conceptos

Guía de programación de C#

Referencia

Genéricos (Guía de programación de C#)