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ón | Significado | Te permite |
|---|---|---|
where T : class | tipo de referencia | asignar null, usar as |
where T : struct | tipo de valor no nullable | usar T? como Nullable<T> |
where T : new() | constructor público sin parámetros | llamar a new T() (debe ir la última) |
where T : Entity | deriva de Entity | usar los miembros de Entity |
where T : IComparable<T> | implementa la interfaz | llamar a CompareTo |
where T : U | deriva de otro parámetro de tipo | relacionar dos parámetros |
where T : unmanaged (C# 7.3) | struct sin referencias dentro | punteros, stackalloc |
where T : Enum (C# 7.3) | un tipo enum | pasarlo a los métodos de Enum |
where T : notnull (C# 8) | tipo no nullable | anotaciones 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
objectdonde 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
Tcon==. Sin una restricciónclassno compila (CS0019), y con ella compara referencias. UsaEqualityComparer<T>.Default.Equals(a, b), que funciona con cualquierTy respeta el propioEqualsdel tipo. - Devolver
default(T)como "no encontrado". Es ambiguo con los tipos de valor. Usabool 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 enList<Base>. Acepta en su lugarIEnumerable<Base>oIReadOnlyList<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.