Menu

Genéricos en C#: clases genéricas, métodos y restricciones

Cómo funcionan los genéricos en C#: escribir clases y métodos genéricos con parámetros de tipo, la inferencia de tipos, las restricciones con where (class, struct, new(), clases base, interfaces), default(T), los miembros static por tipo y la covarianza con IEnumerable<out T>.

Esta página incluye editores ejecutables: edita, ejecuta y ve el resultado al instante.

Los genéricos te permiten escribir un tipo o un método una sola vez y usarlo con muchos tipos, sin perder la seguridad de tipos. En lugar de una List de object que necesita casts y acepta cualquier cosa, obtienes List<int> y List<Order>, cada una comprobada por el compilador. El marcador entre corchetes angulares, por convención T, es un parámetro de tipo; el tipo que aporta quien llama es el argumento de tipo.

Por qué genéricos: el problema que resuelven

Antes de los genéricos (C# 1), las colecciones guardaban object. Eso compilaba, pero los errores solo aparecían en tiempo de ejecución, y cada int sufría un boxing al entrar:

var prices = new System.Collections.ArrayList();
prices.Add(9.99m);
prices.Add("12.50");                  // compiles: anything is an object
decimal total = 0;
foreach (object p in prices)
    total += (decimal)p;              // InvalidCastException on the string

Con List<decimal>, el segundo Add es un error de compilación, el bucle no necesita cast y los decimales se guardan como decimales.

Clases genéricas

Declara los parámetros de tipo después del nombre de la clase y úsalos como cualquier otro tipo dentro de la clase:

Salida:

Page 2/3: Chen, Dara, more: True
Page 1/1: 90, 72, 85, more: False

Page<string> y Page<int> son dos tipos distintos producidos a partir de una sola definición. Una clase puede recibir varios parámetros de tipo (Dictionary<TKey, TValue>); llámalos T cuando haya uno y TSomething cuando haya varios.

Métodos genéricos e inferencia de tipos

Un método puede tener sus propios parámetros de tipo, sea o no genérica su clase. Quien llama suele omitir el argumento de tipo, porque el compilador lo deduce de los argumentos:

Salida:

2 1
back front
ho ho ho
4.50 EUR, 12.00 EUR, 0.99 EUR

La inferencia solo funciona a partir de los argumentos, nunca del tipo de retorno. Repeat(0, 3) no necesita anotación, pero un método como T Create<T>() no tiene nada de lo que deducir, así que quien llama debe escribir Create<Customer>(). La mayor parte de LINQ son métodos genéricos que dependen de esta inferencia: prices.Where(p => p > 1) es Where<decimal> sin que tengas que escribirlo.

Restricciones: lo que se permite que sea T

Dentro de un método genérico, el compilador solo te deja hacer lo que funciona para cualquier T posible. Sin restricciones, eso es poco más de lo que admite object. Para llamar a CompareTo, crear una instancia o leer una propiedad, dile al compilador qué debe ser T, con where:

Salida:

17
plum
19.99
42 Zoe

Las restricciones que usarás:

RestricciónSignificadoTe permite
where T : classtipo de referenciaasignar null, usar as
where T : structtipo de valor no nullableusar T? como Nullable<T>
where T : new()constructor público sin parámetrosllamar a new T() (debe ir la última)
where T : Entityderiva de Entityusar los miembros de Entity
where T : IComparable<T>implementa la interfazllamar a CompareTo
where T : Uderiva de otro parámetro de tiporelacionar dos parámetros
where T : unmanaged (C# 7.3)struct sin referencias dentropunteros, stackalloc
where T : Enum (C# 7.3)un tipo enumpasarlo a los métodos de Enum
where T : notnull (C# 8)tipo no nullableanotaciones de referencia nullable

Varias restricciones sobre un mismo parámetro se separan con comas; varios parámetros tienen cada uno su propia cláusula where: class Cache<TKey, TValue> where TKey : notnull where TValue : class.

Una restricción que falta aparece como un error de compilación en el punto en que usas el miembro, por ejemplo CS1061 ('T' does not contain a definition for 'CompareTo') o CS0304 al llamar a new T() sin new().

default(T)

El código genérico a veces necesita "el valor vacío" de un tipo que no conoce. default(T) da 0 para los números, false para bool, null para los tipos de referencia y los tipos nullable, y un struct a cero en los demás casos:

Salida:

31
0
Porto
True
0001-01-01

La segunda línea muestra la debilidad de este patrón: con los tipos de valor, "no encontrado" y "encontrado un 0" parecen lo mismo. Por eso los métodos del framework usan en su lugar la forma TryGet (bool TryGetValue(TKey key, out TValue value)). Desde C# 7.1 puedes escribir el más corto return default; cuando el tipo se deduce del contexto.

Los miembros static son por tipo construido

Cada tipo construido (Counter<int>, Counter<string>) tiene su propia copia de los campos static de la clase:

Salida:

2
1
False

Esto a veces es útil (una caché por tipo como TypeInfo<T>.Name, calculada una vez por cada T) y a veces es una sorpresa cuando esperabas un único contador compartido. Pon el estado compartido en una clase no genérica.

Colecciones genéricas

Las colecciones genéricas cubren la mayoría de las necesidades: List<T>, Dictionary<TKey, TValue>, HashSet<T>, Queue<T>, Stack<T>, y las interfaces que comparten, IEnumerable<T>, ICollection<T>, IList<T>, IReadOnlyList<T>. Acepta la interfaz más estrecha que necesite un método (IEnumerable<T> si solo recorre), y devuelve un tipo concreto o de solo lectura.

Covarianza y contravarianza

Un Dog es un Animal, pero una List<Dog> no es una List<Animal>:

List<Dog> dogs = new List<Dog>();
List<Animal> animals = dogs;
// error CS0029: Cannot implicitly convert type 'List<Dog>' to 'List<Animal>'

Si eso se permitiera, animals.Add(new Cat()) metería un gato en la lista de perros. Las interfaces que solo producen valores son seguras, y se declaran covariantes con out:

IEnumerable<Animal> readOnly = dogs;          // fine: IEnumerable<out T>
IReadOnlyList<Animal> alsoFine = dogs;        // fine: IReadOnlyList<out T>

La imagen especular, la contravarianza, marcada con in, se aplica a las interfaces y los delegados que solo consumen valores: un Action<Animal> puede usarse donde se espera un Action<Dog>, porque todo lo que sabe manejar cualquier animal sabe manejar un perro. IComparer<in T> es contravariante, y Func<in T, out TResult> usa los dos marcadores. La varianza solo se aplica a las interfaces y los delegados, y solo a argumentos de tipo de referencia: IEnumerable<int> no se convierte en IEnumerable<object>.

Errores comunes

  • Usar object donde va un parámetro de tipo. Renuncia a las comprobaciones en tiempo de compilación y hace boxing de los tipos de valor.
  • Comparar dos valores T con ==. Sin una restricción class no compila (CS0019), y con ella compara referencias. Usa EqualityComparer<T>.Default.Equals(a, b), que funciona con cualquier T y respeta el propio Equals del tipo.
  • Devolver default(T) como "no encontrado". Es ambiguo con los tipos de valor. Usa bool TryX(..., out T value).
  • Demasiadas restricciones. Cada una reduce quién puede usar el método. Restringe solo lo que el cuerpo necesita de verdad.
  • Esperar que List<Derived> se convierta en List<Base>. Acepta en su lugar IEnumerable<Base> o IReadOnlyList<Base>.

Preguntas frecuentes

¿Qué son los genéricos en C#?

Los genéricos te permiten escribir una clase, una interfaz o un método una sola vez con un tipo marcador, T, que rellena quien lo usa: List<int>, List<Customer>. El compilador comprueba cada uso contra el tipo real, así que obtienes seguridad de tipos sin casts, y los tipos de valor se guardan sin boxing. Las colecciones de System.Collections.Generic son el ejemplo más conocido.

¿Cómo escribo un método genérico en C#?

Pon los parámetros de tipo después del nombre del método: static T Largest<T>(List<T> items) where T : IComparable<T> { ... }. Quien llama suele omitir el argumento de tipo porque el compilador lo deduce de los argumentos: Largest(prices) funciona cuando prices es una List<decimal>. Escríbelo de forma explícita, Largest<decimal>(...), cuando no haya nada de lo que deducirlo.

¿Qué significa where T : class en C#?

Es una restricción: T debe ser un tipo de referencia, así que el código genérico puede asignar null a un T y usar as T. Otras restricciones son where T : struct (un tipo de valor no nullable), where T : new() (tiene un constructor público sin parámetros, así que el código puede llamar a new T()), where T : SomeBaseClass y where T : ISomeInterface, que permite al código llamar a los miembros de esa interfaz sobre T.

¿Qué es default(T) en C#?

El valor por defecto del tipo que resulte ser T: 0 para los números, false para bool, null para los tipos de referencia y los tipos nullable, y un struct a cero para los demás tipos de valor. El código genérico lo usa cuando necesita "ningún valor" sin conocer el tipo, por ejemplo como valor de retorno de una búsqueda fallida. C# 7.1 añadió el literal más corto default.

¿Por qué no puedo asignar una List<Dog> a una List<Animal> en C#?

Porque una List<Animal> acepta cualquier animal: si se permitiera la asignación, el código podría añadir un Cat a lo que en realidad es una List<Dog>. El compilador la rechaza (CS0029). Las interfaces de solo lectura son covariantes, así que IEnumerable<Animal> animals = dogs; e IReadOnlyList<Animal> animals = dogs; compilan los dos, porque a través de ellas no se puede añadir nada.

Coddy programming languages illustration

Aprende a programar con Coddy

COMENZAR