Capítulo 4. Genéricos
Este trabajo se ha traducido utilizando IA. Agradecemos tus opiniones y comentarios: translation-feedback@oreilly.com
En el Capítulo 3, mostré cómo escribir tipos y describí los distintos tipos de miembros que pueden contener. Sin embargo, las clases, los structs, las interfaces, los delegados y los métodos tienen una dimensión adicional que no mostré. Pueden definir parámetros de tipo, marcadores de posición que te permiten introducir distintos tipos en tiempo de compilación. Esto te permite escribir un solo tipo y luego producir múltiples versiones del mismo. Un tipo que hace esto se llama tipo genérico. Por ejemplo, las bibliotecas en tiempo de ejecución definen una clase genérica llamada List<T>
que actúa como una matriz de longitud variable. T
es un parámetro de tipo aquí, y puedes utilizar casi cualquier tipo como argumento, de modo que List<int>
es una lista de enteros, List<string>
es una lista de cadenas, etc.1 También puedes escribir un método genérico, que es un método que tiene sus propios argumentos de tipo, independientemente de si su tipo contenedor es genérico.
Los tipos y métodos genéricos se distinguen visualmente porque siempre tienen paréntesis angulares (<
y >
) después del nombre. Éstos contienen una lista de parámetros o argumentos separados por comas. Aquí se aplica la misma distinción parámetro/argumento que con los métodos: la declaración especifica una lista de parámetros, y luego, cuando vayas a utilizar el método o tipo, suministrarás argumentos para esos parámetros. Así, List<T>
define un único parámetro de tipo, T
, y List<int>
proporciona un argumento de tipo, int
, para ese parámetro.
Puedes utilizar el nombre que quieras para los parámetros de tipo, dentro de las restricciones habituales para los identificadores en C#, pero hay algunas convenciones populares. Es habitual (pero no universal) utilizar T
cuando sólo hay un parámetro. En el caso de los genéricos multiparámetro, se suelen utilizar nombres algo más descriptivos. Por ejemplo, las bibliotecas en tiempo de ejecución definen la clase Dictionary<TKey, TValue>
clase colección. A veces verás un nombre descriptivo como ése aunque sólo haya un parámetro, pero en cualquier caso, tenderás a ver un prefijo T
para que los parámetros de tipo destaquen cuando los utilices en tu código.
Tipos genéricos
Las clases, los structs, los registros y las interfaces pueden ser genéricos, al igual que los delegados, que veremos en el Capítulo 9. El Ejemplo 4-1 muestra cómo definir una clase genérica. Resulta que utiliza la nueva sintaxis de constructor primario de C# 12.0, por lo que, después de la lista de parámetros de tipo (<T>
), este ejemplo también tiene una lista de parámetros de constructor primario.
Ejemplo 4-1. Definir una clase genérica
public
class
NamedContainer
<
T
>(
T
item
,
string
name
)
{
public
T
Item
{
get
;
}
=
item
;
public
string
Name
{
get
;
}
=
name
;
}
La sintaxis de los structs, registros e interfaces es muy parecida: el nombre del tipo va seguido inmediatamente de una lista de parámetros de tipo. El Ejemplo 4-2 muestra cómo escribir un registro genérico similar a la clase del Ejemplo 4-1.
Ejemplo 4-2. Definir un registro genérico
public
record
NamedContainer
<
T
>(
T
Item
,
string
Name
);
Dentro de la definición de un tipo genérico, puedo utilizar el parámetro de tipo T
en cualquier lugar donde normalmente verías un nombre de tipo. En los dos primeros ejemplos, lo he utilizado como tipo de un argumento del constructor y como tipo de la propiedad Item
. También podría definir campos del tipo T
. (De hecho lo he hecho, aunque no explícitamente. Las propiedades automáticas generan campos ocultos, por lo que mi propiedad Item
tendrá asociado un campo oculto de tipo T
.) También puedes definir variables locales de tipo T
. Y eres libre de utilizar parámetros de tipo como argumentos para otros tipos genéricos. Mi NamedContainer<T>
podría declarar miembros de tipo List<T>
, por ejemplo.
Los tipos que definen los Ejemplos 4-1 y 4-2 no son, como cualquier tipo genérico, tipos completos. Una declaración de tipo genérico no está ligada, lo que significa que hay parámetros de tipo que deben rellenarse para producir un tipo completo. Las preguntas básicas, como cuánta memoria necesitará una instancia de NamedContainer<T>
, no pueden responderse sin saber qué es T
: el campo oculto de la propiedad Item
necesitaría 4 bytes si T
fuera un int
, pero 16 bytes si fuera un decimal
. El CLR no puede producir código ejecutable para un tipo si no sabe cómo se organizará su contenido en la memoria. Por eso, para utilizar éste o cualquier otro tipo genérico, debemos proporcionar argumentos de tipo. El ejemplo 4-3 muestra cómo hacerlo. Cuando se proporcionan argumentos de tipo, el resultado a veces se denomina tipoconstruido. (Esto no tiene nada que ver con los constructores, el tipo especial de miembro que vimos en el Capítulo 3). De hecho, el Ejemplo 4-3 también los utiliza: invoca los constructores de un par de tipos construidos).
Ejemplo 4-3. Utilizar una clase genérica
var
a
=
new
NamedContainer
<
int
>(
42
,
"The answer"
);
var
b
=
new
NamedContainer
<
int
>(
99
,
"Number of red balloons"
);
var
c
=
new
NamedContainer
<
string
>(
"Programming C#"
,
"Book title"
);
Puedes utilizar un tipo genérico construido en cualquier lugar donde utilizarías un tipo normal. Por ejemplo, puedes utilizarlos como tipos para parámetros de métodos y valores de retorno, propiedades o campos. Incluso puedes utilizar uno como argumento de tipo para otro tipo genérico, como muestra el Ejemplo 4-4.
Ejemplo 4-4. Tipos genéricos construidos como argumentos de tipo
// ...where a, and b come from
Example 4-3.
List
<
NamedContainer
<
int
>
>
namedInts
=
[
a
,
b
]
;
var
namedNamedItem
=
new
NamedContainer
<
NamedContainer
<
int
>
>
(
a
,
"Wrapped"
)
;
Cada tipo diferente que aporto como argumento a NamedContainer<T>
construye un tipo distinto. (Y para los tipos genéricos con múltiples argumentos de tipo, cada combinación distinta de argumentos de tipo construiría un tipo distinto). Esto significa que NamedContainer<int>
es un tipo distinto de NamedContainer<string>
. Por eso no hay conflicto en utilizar NamedContainer<int>
como argumento de tipo para otro NamedContainer
, como hace la última línea del Ejemplo 4-4: aquí no hay recursividad infinita.
Dado que cada conjunto diferente de argumentos de tipo produce un tipo distinto, en la mayoría de loscasos no existe compatibilidad implícita entre las distintas formas del mismo tipo genérico. No puedes asignar un NamedContainer<int>
a una variable de tipo NamedContainer<string>
o viceversa. Tiene sentido que esos dos tipos sean incompatibles, porque int
y string
son tipos muy distintos. Pero, ¿qué pasaría si utilizáramos object
como argumento de tipo? Como se describe en el Capítulo 2, puedes poner casi cualquier cosa en una variable object
. Si escribes un método con un parámetro de tipo object
, no pasa nada si le pasas un string
, así que podrías esperar que un método que toma un NamedContainer<object>
se conformara con un NamedContainer<string>
. Eso no funcionará, pero algunos tipos genéricos (en concreto, las interfaces y los delegados) pueden declarar que desean este tipo de relación de compatibilidad. Los mecanismos que soportan esto (llamados covarianza y contravarianza) están estrechamente relacionados con los mecanismos de herencia del sistema de tipos. El capítulo 6 trata sobre la herencia y la compatibilidad de tipos, así que allí trataré este aspecto de los tipos genéricos.
El número de parámetros de tipo forma parte de la identidad de un tipo genérico no ligado. Esto permite introducir varios tipos con el mismo nombre, siempre que tengan distinto número de parámetros de tipo. (El término técnico para el número de parámetros de tipo es aridad).
Así que podrías definir una clase genérica llamada, digamos, Operation<T>
, y luego otra clase, Operation<T1, T2>
, y también Operation<T1, T2, T3>
, y así sucesivamente, todas en el mismo espacio de nombres, sin introducir ninguna ambigüedad. Cuando utilizas estos tipos, queda claro por el número de argumentos a qué tipo se refería:Operation<int>
utiliza claramente el primero, mientras que Operation<string, double>
utiliza el segundo, por ejemplo. Y por la misma razón, una clase no genérica Operation
sería distinta de los tipos genéricos del mismo nombre.
Mi ejemplo de NamedContainer<T>
no hace nada con las instancias de su argumento de tipo, T
-nunca invoca ningún método ni utiliza ninguna propiedad u otro miembro de T
. Todo lo que hace es aceptar un T
como argumento del constructor, que almacena para su posterior recuperación. Lo mismo ocurre con muchos tipos genéricos de las bibliotecas en tiempo de ejecución; he mencionado algunas clases de colección (y veremos más en el Capítulo 5), que son variaciones sobre el mismo tema de contener datos para recuperarlos más tarde.
Hay una razón para ello: una clase genérica puede encontrarse trabajando con cualquier tipo, por lo que puede presumir poco sobre sus argumentos de tipo. Sin embargo, no tiene por qué ser así. Puedes especificar restricciones para tus argumentos de tipo.
Restricciones
C# te permite establecer que el argumento de un tipo debe cumplir ciertos requisitos. Por ejemplo, supón que quieres poder crear nuevas instancias del tipo bajo demanda. El Ejemplo 4-5 muestra una clase sencilla que proporciona construcción diferida: pone a disposición una instancia a través de una propiedad estática, pero no intenta construir esa instancia hasta la primera vez que leas la propiedad.
Ejemplo 4-5. Crear una nueva instancia de un tipo parametrizado
// For illustration only. Consider using Lazy<T> in a real program.
public
static
class
Deferred
<
T
>
where
T
:
new
(
)
{
private
static
T
?
_instance
;
public
static
T
Instance
=
>
_instance
?
?
=
new
T
(
)
;
}
Advertencia
En la práctica, no escribirías una clase como ésta, porque las bibliotecas en tiempo de ejecución ofrecen Lazy<T>
, que hace el mismo trabajo pero con más flexibilidad. Lazy<T>
puede funcionar correctamente en código multihilo, mientras que el Ejemplo 4-5 no lo hará. El Ejemplo 4-5 es sólo para ilustrar cómo funcionan las restricciones. No lo utilices.
Para que esta clase haga su trabajo, debe ser capaz de construir una instancia de cualquier tipo que se proporcione como argumento para T
. El accesor get
utiliza la palabra clave new
, y como no pasa argumentos, está claro que requiere que T
proporcione un constructor sin parámetros. Pero no todos los tipos lo hacen, así que ¿qué ocurre si intentamos utilizar un tipo sin un constructor adecuado como argumento de Deferred<T>
?
El compilador la rechazará, porque viola una restricción que este tipo genérico ha declarado para T
. Las restricciones aparecen justo antes de la llave de apertura de la clase y comienzan con la palabra clave where
. La restricción new()
del ejemplo 4-5 establece que T
debe proporcionar un constructor sin argumentos .
Si esa restricción no estuviera presente, la clase del Ejemplo 4-5 no se compilaría: se produciría un error en la línea que intenta construir un nuevo T
. A un tipo genérico (o método) sólo se le permite utilizar características de sus parámetros de tipo que haya especificado mediante restricciones, o que estén definidas por el tipo base object
. (El tipo object
define un método ToString
, por ejemplo, así que puedes invocarlo en instancias de cualquier tipo sin necesidad de especificar una restricción).
C# sólo ofrece un conjunto muy limitado de restricciones. Por ejemplo, no puedes exigir un constructor que tome argumentos. De hecho, C# sólo admite siete tipos de restricciones sobre un argumento de tipo: una restricción de tipo, una restricción de tipo de referencia, una restricción de tipo de valor, default
, notnull
, unmanaged
, y la restricción new()
. La restricción default
sólo se aplica en situaciones de herencia, así que la veremos en el Capítulo 6, y acabamos de ver cómo funciona new()
, así que ahora veamos las cinco restantes.
Restricciones de tipo
Puedes restringir el argumento de un parámetro de tipo para que sea compatible con un tipo concreto. Por ejemplo, podrías utilizarlo para exigir que el tipo del argumento implemente una determinada interfaz. El ejemplo 4-6 muestra la sintaxis.
Ejemplo 4-6. Utilizar una restricción de tipo
public
class
GenericComparer
<
T
>
:
IComparer
<
T
>
where
T
:
IComparable
<
T
>
{
public
int
Compare
(
T
?
x
,
T
?
y
)
{
if
(
x
=
=
null
)
{
return
y
=
=
null
?
0
:
-
1
;
}
return
x
.
CompareTo
(
y
)
;
}
}
Me limitaré a explicar el propósito de este ejemplo antes de describir cómo aprovecha una restricción de tipo. Esta clase proporciona un puente entre dos estilos de comparación de valores que encontrarás en .NET. Algunos tipos de datos proporcionan su propia lógica de comparación, pero a veces puede ser más útil que la comparación sea una función independiente implementada en su propia clase. Estos dos estilos están representados por los símbolos IComparable<T>
y IComparer<T>
que forman parte de las bibliotecas de tiempo de ejecución. (Están en los espacios de nombres System
y System.Collections.Generics
, respectivamente.) Ya mostré IComparer<T>
en el Capítulo 3. Unaimplementación de esta interfaz puede comparar dos objetos o valores de tipo T
. La interfaz define un único método Compare
que toma dos argumentos y devuelve un número negativo, 0, o un número positivo si el primer argumento es, respectivamente, menor, igual o mayor que el segundo.
IComparable<T>
es muy similar, pero su método CompareTo
sólo toma un argumento, porque con esta interfaz, estás pidiendo a una instancia que se compare a sí misma con otra instancia.
Algunas de las clases de colección de las bibliotecas en tiempo de ejecución requieren que proporciones un IComparer<T>
para soportar operaciones de ordenación, como ordenar. Utilizan el modelo en el que un objeto independiente realiza la comparación, porque ofrece dos ventajas sobre el modelo IComparable<T>
. En primer lugar, te permite utilizar tipos de datos que no implementan IComparable<T>
. En segundo lugar, te permite introducir diferentes órdenes de clasificación. (Por ejemplo, supongamos que quieres ordenar algunas cadenas sin tener en cuenta mayúsculas y minúsculas. El tipo string
implementa IComparable<string>
, pero proporciona un orden que distingue entre mayúsculas y minúsculas, específico de la localidad). Así que IComparer<T>
es el modelo más flexible. Sin embargo, ¿qué ocurre si utilizas un tipo de datos que implementa IComparable<T>
, y estás perfectamente satisfecho con el orden que proporciona? ¿Qué harías si trabajas con una API que exige un IComparer<T>
?
En realidad, la respuesta es que probablemente sólo tendrías que utilizar la característica .NET diseñada para este mismo escenario: Comparer<T>.Default
. Si T
implementa IComparable<T>
, esa propiedad devolverá un IComparer<T>
que hace precisamente lo que tú quieres. Así que, en la práctica, no necesitarías escribir el código del Ejemplo 4-6, porque Microsoft ya lo ha escrito por ti. Sin embargo, es instructivo ver cómo escribirías tu propia versión, porque ilustra cómo utilizar una restricción de tipo.
La línea que comienza con la palabra clave where
indica que esta clase genérica requiere el argumento de su parámetro de tipo T
para implementar IComparable<T>
. Sin este añadido, el método Compare
no compilaría: invoca el método CompareTo
con un argumento de tipo T
. Ese método no está presente en todos los objetos, y el compilador de C# sólo lo permite porque hemos obligado a T
a ser una implementación de una interfaz que sí ofrece ese método.
Las restricciones de interfaz son algo extrañas: a primera vista, puede parecer que realmente no deberíamos necesitarlas. Si un método necesita que un argumento concreto implemente una interfaz determinada, lo normal sería utilizar esa interfaz como tipo del argumento. Sin embargo, el Ejemplo 4-6 no puede hacer esto. Puedes demostrarlo probando con el Ejemplo 4-7. No compilará.
Ejemplo 4-7. No compilará: interfaz no implementada
public
class
GenericComparer
<
T
>
:
IComparer
<
T
>
{
public
int
Compare
(
IComparable
<
T
>?
x
,
T
?
y
)
{
if
(
x
==
null
)
{
return
y
==
null
?
0
:
-
1
;
}
return
x
.
CompareTo
(
y
);
}
}
El compilador se quejará de que no he implementado el método Compare
de la interfaz IComparer<T>
. El Ejemplo 4-7 tiene un método Compare
, pero su firma es incorrecta: el primer argumento debería ser un T
. También podría probar la firma correcta sin especificar la restricción, como muestra el Ejemplo 4-8.
Ejemplo 4-8. No compila: falta una restricción
public
class
GenericComparer
<
T
>
:
IComparer
<
T
>
{
public
int
Compare
(
T
?
x
,
T
?
y
)
{
if
(
x
==
null
)
{
return
y
==
null
?
0
:
-
1
;
}
return
x
.
CompareTo
(
y
);
}
}
Esto tampoco compilará, porque el compilador no encuentra el método CompareTo
que intento utilizar. Es la restricción para T
en el Ejemplo 4-6 la que permite al compilador saber cuál es realmente ese método.
Por cierto, las restricciones de tipo no tienen por qué ser interfaces. Puedes utilizar cualquier tipo. Por ejemplo, puedes exigir que un argumento de tipo concreto derive de una clase base concreta. Más sutilmente, también puedes definir la restricción de un parámetro en términos de otro parámetro de tipo. Por ejemplo, el ejemplo 4-9 requiere que el primer argumento de tipo derive delsegundo.
Ejemplo 4-9. Restringir un argumento para que derive de otro
public
class
Foo
<
T1
,
T2
>
where
T1
:
T2
...
Las restricciones de tipo son bastante específicas: requieren una relación de herencia concreta o la implementación de determinadas interfaces. Sin embargo, puedes definir restricciones algo menos específicas.
Restricciones del tipo de referencia
Puedes restringir un argumento de tipo para que sea un tipo de referencia. Como muestra el Ejemplo 4-10, esto se parece a una restricción de tipo. Sólo tienes que poner la palabra clave class
en lugar de un nombre de tipo. Si estás en un contexto de anotación anulable habilitado, el significado de esta anotación cambia: requiere que el argumento de tipo sea un tipo de referencia no anulable. Si especificas class?
, eso permite que el argumento de tipo sea un tipo de referencia anulable o no anulable.
Ejemplo 4-10. Restricción que requiere un tipo de referencia
public
class
Bar
<
T
>
where
T
:
class
...
Esta restricción impide el uso de tipos de valor como int
, double
, o cualquier struct
como argumento de tipo. Su presencia permite que tu código haga tres cosas que de otro modo no serían posibles. En primer lugar, significa que puedes escribir código que compruebe si las variables del tipo correspondiente son null
.2 Si no has restringido el tipo para que sea un tipo de referencia, siempre existe la posibilidad de que sea un tipo de valor, y éstos no pueden tener valores null
. La segunda capacidad es que puedes utilizarlo como tipo de destino del operador as
, que veremos en el Capítulo 6. Esto no es más que una variación de la primera característica: la palabra clave as
requiere un tipo de referencia porque puede producir un resultado null
.
Nota
Los tipos anulables como int?
(o Nullable<int>
, como lo denomina el CLR) añaden anulabilidad a los tipos de valor, por lo que quizá te preguntes si puedes utilizarlos como argumento de un parámetro de tipo con una restricción class
. No puedes, porque aunque los tipos como int?
permiten la comparación con null
, funcionan de forma bastante diferente a los tipos de referencia, por lo que el compilador suele generar un código bastante diferente para los tipos anulables que para un tipo de referencia.
La tercera característica que permite una restricción de tipo de referencia es la posibilidad de utilizar otros tipos genéricos determinados. A menudo es conveniente que el código genérico utilice uno de sus argumentos de tipo como argumento de otro tipo genérico, y si ese otro tipo especifica una restricción, tendrás que poner la misma restricción en tu propio parámetro de tipo. Así, si algún otro tipo especifica una restricción de clase, esto podría requerir que restringieras uno de tus propios argumentos del mismo modo.
Por supuesto, esto plantea la cuestión de por qué el tipo que estás utilizando necesita la restricción en primer lugar. Podría ser que simplemente quisiera comprobar null
o utilizar el operador as
, pero hay otra razón para aplicar esta restricción. A veces, sólo necesitas que el argumento de un tipo sea un tipo de referencia: hay situaciones en las que un método genérico puede compilarse sin la restricción class
, pero no funcionará correctamente si se utiliza con un tipo de valor.
Un escenario habitual en el que surge esto es con las bibliotecas que pueden crear objetos falsos para utilizarlos como parte de una prueba generando código en tiempo de ejecución. Utilizar objetos falsos puede reducir a menudo la cantidad de código que tiene que ejercitar cualquier prueba, lo que puede facilitar la verificación del comportamiento del objeto que se está probando. Por ejemplo, una prueba puede necesitar verificar que mi código envía mensajes a un servidor en el momento adecuado. No quiero tener que ejecutar un servidor real durante una prueba unitaria, así que podría proporcionar un objeto que implemente la misma interfaz que la clase que transmitiría el mensaje, pero que en realidad no enviará el mensaje. Dado que esta combinación de un objeto bajo prueba más una falsificación es un patrón común, podría optar por escribir una clase base reutilizable que incorpore el patrón. Utilizar genéricos significa que la clase puede funcionar para cualquier combinación del tipo que se está comprobando y el tipo que se está falsificando. El Ejemplo 4-11 muestra una versión simplificada de este tipo de clase ayudante.
Ejemplo 4-11. Limitado por otra restricción
using
Microsoft.VisualStudio.TestTools.UnitTesting
;
using
Moq
;
public
class
TestBase
<
TSubject
,
TFake
>
where
TSubject
:
new
()
where
TFake
:
class
{
public
TSubject
?
Subject
{
get
;
private
set
;
}
public
Mock
<
TFake
>?
Fake
{
get
;
private
set
;
}
[TestInitialize]
public
void
Initialize
()
{
Subject
=
new
TSubject
();
Fake
=
new
Mock
<
TFake
>();
}
}
Hay varias formas de construir objetos falsos para realizar pruebas. Podrías limitarte a escribir nuevas clases que implementen la misma interfaz que tus objetos reales, pero también existen bibliotecas de terceros que pueden generarlos. Una de estas bibliotecas se llama Moq (un proyecto de código abierto), y de ella procede la clase Mock<T>
del Ejemplo 4-11. Es capaz de generar una implementación falsa de cualquier interfaz o de cualquier clase no sellada.(En el Capítulo 6 se describe la palabra clave sealed
.) Proporcionará implementaciones vacías de todos los miembros por defecto, y puedes configurar comportamientos más interesantes si es necesario. También puedes verificar si el código bajo prueba utilizó el objeto falso de la forma que esperabas.
¿Qué relevancia tiene esto para las restricciones? La clase Mock<T>
especifica una restricción de tipo de referencia en su propio argumento de tipo, T
. Esto se debe a la forma en que crea implementaciones dinámicas de tipos en tiempo de ejecución; es una técnica que sólo puede funcionar para tipos de referencia. Moq genera un tipo en tiempo de ejecución, y si T
es una interfaz, ese tipo generado la implementará, mientras que si T
es una clase, el tipo generado derivará de ella.3 No hay nada útil que pueda hacer si T
es un struct, porque no se puede derivar de un tipo de valor. Eso significa que cuando utilice Mock<T>
en el Ejemplo 4-11, tengo que asegurarme de que el argumento de tipo que paso no es un struct (es decir, debe ser un tipo de referencia). Pero el argumento de tipo que estoy utilizando es uno de los parámetros de tipo de mi clase: TFake
. Así que no sé de qué tipo será; eso dependerá de quien utilice mi clase TestBase
.
Para que mi clase compile sin errores, tengo que asegurarme de que cumplo las restricciones de cualquier tipo genérico que utilice. Tengo que garantizar que Mock<TFake>
es válido, y la única forma de hacerlo es añadir una restricción a mi propio tipo que exija que TFake
sea un tipo de referencia. Y eso es lo que he hecho en la tercera línea de la definición de clase del Ejemplo 4-11. Sin eso, el compilador informaría de errores en las dos líneas que hacen referencia a Mock<TFake>
.
Dicho de forma más general, si quieres utilizar uno de tus propios parámetros de tipo como argumento de tipo para un genérico que especifica una restricción, tendrás que especificar la misma restricción en tu propio parámetro de tipo.
Restricciones del tipo de valor
Igual que puedes restringir un argumento de tipo para que sea un tipo de referencia, puedes restringirlo para que sea un tipo de valor. Como se muestra en el Ejemplo 4-12, la sintaxis es similar a la de una restricción de tipo de referencia, pero con la palabra clave struct
.
Ejemplo 4-12. Restricción que requiere un tipo de valor
public
class
Quux
<
T
>
where
T
:
struct
...
Hasta ahora, habíamos visto la palabra clave struct
sólo en el contexto de los tipos de valor personalizados, pero a pesar de su aspecto, esta restricción permite los tipos bool
, enum
y cualquiera de los tipos numéricos incorporados, como int
, así como los structs personalizados.
El tipo Nullable<T>
de .NET impone esta restricción. Recuerda del Capítulo 3 queNullable<T>
proporciona una envoltura para los tipos de valor que permite que una variable contenga un valor o ningún valor. (Normalmente utilizamos la sintaxis especial que proporciona C#, por lo que escribiríamos, digamos, int?
en lugar de Nullable<int>
.) La única razón por la que existe este tipo es para proporcionar anulabilidad a tipos que, de otro modo, no podrían contener un valor nulo. Así que sólo tiene sentido utilizarlo con un tipo de valor: las variables de tipo de referencia ya se pueden establecer en null
sin necesidad de esta envoltura. La restricción del tipo de valor impide que utilices Nullable<T>
con tipos para los que no es necesario.
Tipos de valores hasta el final con restricciones no gestionadas
Puedes especificar unmanaged
como restricción, lo que requiere que el argumento de tipo sea un tipo de valor, pero también que no contenga referencias. Todos los campos del tipo deben ser tipos de valor, y si alguno de esos campos no es un tipo primitivo incorporado, entonces su tipo debe contener a su vez sólo campos que sean tipos de valor, y así sucesivamente hasta el final. En la práctica, esto significa que todos los datos reales deben ser o bien uno de un conjunto fijo de tipos incorporados (esencialmente, todos los tipos numéricos, bool
, o un puntero) o bien un tipo enum
. Esto es interesante sobre todo en situaciones de interoperabilidad, porque los tipos que coinciden con la restricción unmanaged
pueden pasarse de forma segura y eficiente a código no gestionado. También puede ser importante si estás escribiendo código de alto rendimiento que toma el control de dónde se asigna exactamente la memoria y cuándo se copia, utilizando las técnicas descritas en el Capítulo 18.
Restricciones no nulas
Si utilizas la función de referencias anulables descrita en el Capítulo 3 (que está activada por defecto al crear nuevos proyectos), puedes especificar una restricción notnull
. Esto permite tipos de valor o tipos de referencia no anulables, pero no tipos de referencia anulables.
Otras limitaciones de tipo especial
Enel Capítulo3 se describen varias clases especiales de tipos, incluidos los tipos de enumeración (enum
) y los tipos de delegado (tratados en detalle en el Capítulo 9). A veces es útil restringir los argumentos de tipo para que sean uno de estos tipos. No hay ningún truco especial para ello: basta con utilizar restricciones de tipo. Todos los tipos de delegado derivan de System.Delegate
, y todos los tipos de enumeración derivan de System.Enum
. Como muestra el Ejemplo 4-13, puedes escribir una restricción de tipo que exija que un argumento de tipo derive de cualquiera de ellos.
Ejemplo 4-13. Restricciones que requieren tipos de delegado y enum
public
class
RequireDelegate
<
T
>
where
T
:
Delegate
{
}
public
class
RequireEnum
<
T
>
where
T
:
Enum
{
}
Restricciones múltiples
Si quieres imponer varias restricciones para un mismo argumento de tipo, puedes ponerlas en una lista, como muestra el Ejemplo 4-14. Existen algunas restricciones. No puedes combinar las restricciones class
, struct
, notnull
, o unmanaged
-son mutuamente excluyentes. Si utilizas una de estas palabras clave, debe ser la primera de la lista. Si la restricción new()
está presente, debe ser la última.
Ejemplo 4-14. Restricciones múltiples
public
class
Spong
<
T
>
where
T
:
IEnumerable
<
T
>,
IDisposable
,
new
()
...
Cuando tu tipo tiene varios parámetros de tipo, escribe una cláusula where
para cada parámetro de tipo que desees restringir. De hecho, ya lo hemos visto antes: el ejemplo4-11 define restricciones para sus dos parámetros.
Valores similares a cero
Hay ciertas características que admiten todos los tipos y que, por tanto, no requieren una restricción. Esto incluye el conjunto de métodos definidos por la clase base object
, que se tratan en los Capítulos 3 y 6. Pero hay una característica más básica que a veces puede ser útil en el código genérico.
Las variables de cualquier tipo pueden inicializarse con un valor por defecto. Como has visto en los capítulos anteriores, hay algunas situaciones en las que el CLR hace esto por nosotros. Por ejemplo, todos los campos de un objeto recién construido tendrán un valor conocido aunque no escribamos inicializadores de campo y no proporcionemos valores en el constructor. Del mismo modo, una nueva matriz de cualquier tipo tendrá todos sus elementos inicializados con un valor conocido. El CLR lo hace llenando de ceros la memoria correspondiente. El significado exacto de esto depende del tipo de datos. Para cualquiera de los tipos numéricos incorporados, el valor será literalmente el número 0
, pero para los tipos no numéricos, es otra cosa. Para bool
, el valor por defecto es false
, y para un tipo de referencia, es null
.
A veces, puede ser útil para el código genérico poder obtener este valor cero inicial por defecto para uno de sus parámetros de tipo. Pero no puedes utilizar una expresión literal para hacer esto en la mayoría de las situaciones. No puedes asignar null
a una variable cuyo tipo esté especificado por un parámetro de tipo, a menos que ese parámetro haya sido restringido para ser un tipo dereferencia. Y no puedes asignar el literal 0
a ninguna variable de ese tipo (aunque la función matemática genérica de .NET 7.0 permite restringir unargumento de tipo para que sea de tipo numérico, en cuyo caso puedes escribir T.Zero
).
En su lugar, puedes solicitar el valor cero para cualquier tipo utilizando la palabra clave default
. (Se trata de la misma palabra clave que vimos dentro de una sentencia switch
en el Capítulo 2, pero utilizada de un modo completamente distinto. C# mantiene la tradición de la familia C de definir múltiples significados no relacionados para cada palabra clave). Si escribes default(SomeType)
donde SomeType
es un tipo específico o un parámetro de tipo, obtendrás el valor inicial por defecto para ese tipo: 0
si es un tipo numérico, y el equivalente para cualquier otro tipo. Por ejemplo, la expresión default(int)
tiene el valor 0
, default(bool)
es false
, y default(string)
es null
. Puedes utilizar esto con un parámetro de tipo genérico para obtener el valor por defecto para el argumento de tipo correspondiente, como muestra el Ejemplo 4-15.
Ejemplo 4-15. Obtener el valor por defecto (cero) de un argumento de tipo
static
void
ShowDefault
<
T
>()
{
Console
.
WriteLine
(
default
(
T
));
}
Dentro de un tipo o método genérico que defina un parámetro de tipo T
, la expresión default(T)
producirá el valor predeterminado, similar a cero, para T
-sea cual sea T
- sin requerir restricciones. Así que podrías utilizar el método genérico del Ejemplo 4-15 para verificar que los valores por defecto de int
, bool
y string
son los valores que he indicado.
Nota
Cuando está activada la función de referencias anulables (descrita en el Capítulo 3), el compilador considerará que un default(T)
es un valor potencialmente nulo, a menos que hayas descartado el uso de tipos de referencia aplicando la restricción struct
.
En los casos en que el compilador sea capaz de deducir qué tipo se necesita, puedes utilizar una forma más sencilla. En lugar de escribir default(T)
, puedes escribir simplemente default
. Eso no funcionaría en el Ejemplo 4-15. Console.WriteLine
puede aceptar prácticamente cualquier cosa, por lo que el compilador no puede reducirlo a una opción, pero funcionará en el Ejemplo 4-16. Allí, el compilador puede ver que el tipo de retorno del método genérico es T?
, por lo que éste debe necesitar un default(T)
. Como puede deducirlo, nos basta con escribir default
.
Ejemplo 4-16. Obtener el valor por defecto (tipo cero) de un tipo inferido
static
T
?
GetDefault
<
T
>()
=>
default
;
Y como acabo de mostrarte un ejemplo de uno, éste parece un buen momento para hablar de los métodos genéricos.
Métodos genéricos
Además de los tipos genéricos, C# también admite métodos genéricos. En este caso, la lista de parámetros de tipo genérico sigue al nombre del método y precede a la lista de parámetros normal del método. El Ejemplo 4-17 muestra un método con un único parámetro de tipo, T
. Utiliza ese parámetro como tipo de retorno y también como tipo de elemento de una matriz que se pasa como argumento del método. Este método devuelve el último elemento de la matriz y, como es genérico, funcionará con cualquier tipo de elemento de matriz.
Ejemplo 4-17. Un método genérico
public
static
T
GetLast
<
T
>(
T
[]
items
)
=>
items
[^
1
];
Nota
Puedes definir métodos genéricos dentro de tipos genéricos o de tipos no genéricos. Si un método genérico es miembro de un tipo genérico, todos los parámetros de tipo del tipo que lo contiene están en el ámbito del método, así como los parámetros de tipo específicos del método.
Al igual que con un tipo genérico, puedes utilizar un método genérico especificando su nombre junto con sus argumentos de tipo, como muestra el Ejemplo 4-18.
Ejemplo 4-18. Invocar un método genérico
int
[]
values
=
[
1
,
2
,
3
];
int
last
=
GetLast
<
int
>(
values
);
Los métodos genéricos funcionan de forma similar a los tipos genéricos, pero con parámetros de tipoque sólo tienen ámbito dentro de la declaración y el cuerpo del método. Puedes especificarrestricciones del mismo modo que con los tipos genéricos. Las restricciones aparecen después de la lista de parámetros del método y antes de su cuerpo, como muestra el Ejemplo 4-19.
Ejemplo 4-19. Un método genérico con una restricción
public
static
T
MakeFake
<
T
>()
where
T
:
class
{
return
new
Mock
<
T
>().
Object
;
}
Sin embargo, hay una diferencia importante entre los métodos genéricos y los tipos genéricos: no siempre es necesario especificar explícitamente los argumentos de tipo de un método genérico.
Inferencia de tipo
El compilador de C# suele ser capaz de deducir los argumentos de tipo de un método genérico. Puedo modificar el Ejemplo 4-18 eliminando la lista de argumentos de tipo de la invocación del método, como muestra el Ejemplo 4-20. Esto no cambia en nada el significado del código.
Ejemplo 4-20. Inferencia de argumentos de tipo de método genérico
int
[]
values
=
[
1
,
2
,
3
];
int
last
=
GetLast
(
values
);
Cuando se le presenta este tipo de llamada a un método de aspecto ordinario, si no hay disponible ningún método no genérico con ese nombre, el compilador empieza a buscar métodos genéricos adecuados. Si el método del Ejemplo 4-17 está en el ámbito, será un candidato, y el compilador intentará deducir el tipo de los argumentos. Se trata de un caso bastante sencillo. El método espera una matriz del tipo T
, y nosotros hemos pasado una matriz con elementos del tipo int
, por lo que no es difícil deducir que este código debe tratarse como una llamada a GetLast<int>
.
Se vuelve más complejo con casos más intrincados. La especificación de C# dedica muchas páginas al algoritmo de inferencia de tipo, pero todo es para apoyar un objetivo: permitirte omitir argumentos de tipo cuando serían redundantes. Por cierto, la inferencia de tipo se realiza siempre en tiempo de compilación, por lo que se basa en el tipo estático de losargumentos del método.
Con las API que hacen un uso extensivo de los genéricos (como LINQ, que es el tema del Capítulo 10), enumerar explícitamente cada argumento de tipo puede hacer que el código sea muy difícil de seguir, por lo que es habitual confiar en la inferencia de tipos. Y si utilizas tipos anónimos, entonces la inferencia de argumentos de tipo se vuelve esencial porque no es posible proporcionar los argumentos de tipo explícitamente.
Matemáticas genéricas
Una de las nuevas capacidades más significativas de C# 11.0 y .NET 7.0 se llama matemáticas genéricas. Esto permite escribir métodos genéricos que realizan operaciones matemáticas en variables declaradas con parámetros de tipo. Para mostrar por qué esto requiere nuevas características del lenguaje y del tiempo de ejecución, el Ejemplo 4-21 muestra un intento ingenuo de realizar operaciones aritméticas en un método genérico.
Ejemplo 4-21. Una técnica que no funciona en C# genérico
public
static
T
Add
<
T
>(
T
x
,
T
y
)
{
return
x
+
y
;
// Will not compile
}
El compilador se quejará del uso de la suma aquí, porque nada impide que alguien utilice este método con un parámetro de tipo que no admita la suma. ¿Qué ocurriría si llamáramos a este método pasando argumentos del tipo bool
? Probablemente nos gustaría que la respuesta fuera que tales intentos se bloquearan: sólo se nos debería permitir llamar a este método Add<T>
si utilizamos un argumento de tipo que admita la adición.
Éste es exactamente el tipo de escenario para el que están pensadas las restricciones. Lo único que tenemos que hacer es restringir el parámetro de tipo T
a tipos que implementen la adición. Pero hasta C# 11.0 no era posible especificar una restricción de este tipo. Podemos exigir a un argumento de tipo que proporcione determinados miembros mediante una restricción de tipo de interfaz, pero antes las interfaces no podían exigir a las implementaciones que definieran operadores concretos. Cuando los tipos implementan operadores como +
, éstos son miembros estáticos. (Tienen una sintaxis distintiva, pero en realidad son sólo métodos estáticos.) Y no era posible que una interfaz definiera miembros estáticos virtual
o abstract
. Estas palabras clave indican, respectivamente, que un tipo puede o debe definir su propia versión de un miembro concreto, y solían ser aplicables sólo en escenarios de herencia (descritos en el Capítulo 6) con métodos no estáticos.
Como viste en el Capítulo 3, .NET 7.0 ha ampliado el sistema de tipos de modo que ahora es posible que las interfaces exijan a los implementadores que proporcionen miembros estáticos específicos. C# 11.0 lo soporta con su nueva sintaxis static abstract
y static virtual
. Esto significa que ahora es posible definir interfaces que requieran que los implementadores ofrezcan, por ejemplo, el operador +
. Las bibliotecas en tiempo de ejecución de .NET definen ahora este tipo de interfaces, lo que significa que podemos definir una restricción para un parámetro de tipo que exija que admita aritmética. El Ejemplo 4-22 lo hace, permitiéndole utilizar el operador +
.
Ejemplo 4-22. Utilizar matemáticas genéricas
public
static
T
Add
<
T
>(
T
x
,
T
y
)
where
T
:
INumber
<
T
>
{
return
x
+
y
;
// No error, because INumber<T> requires + to be available
}
La interfaz INumber<T>
se define en el espacio de nombres System.Numerics
y la implementan todos los tipos numéricos incorporados. Puedes pensar en INumber<T>
como si dijera que cualquier tipo que implemente esta interfaz proporciona todas las operaciones matemáticas comunes para el tipo T
. Así que int
implementa INumber<int>
, float
implementa INumber<float>,
y así sucesivamente. La interfaz tiene que ser genérica, no puede ser sólo INumber
, porque tiene que especificar los tipos de entrada y salida de los operadores. El ejemplo 4-23 muestra el problema que se produciría si intentáramos definir este tipo de interfaz de restricción sin un argumento de tipo genérico.
Ejemplo 4-23. Por qué las interfaces de restricción de operadores deben ser genéricas
public
interface
IAdd
{
static
abstract
int
operator
+(
int
x
,
int
y
);
// Won't compile
}
Esta hipotética interfaz IAdd
intenta establecer que cualquier tipo implementador debe admitir el operador de suma definiéndolo como abstract
. Pero las declaraciones de operadores deben indicar sus tipos de entrada y salida. Este ejemplo elige int
, por lo que no serviría para ningún otro tipo. De hecho, ni siquiera compilaría: C# impone la norma de que cuando un tipo define un miembro operator
, al menos uno de los argumentos debe tener el mismo tipo que el tipo declarante, o bien debe derivar de ese tipo definidor o implementarlo. Así que MyType
puede definir la suma para un par de entradas de MyType
, y también puede definir la suma para un MyType
y un int
, pero no puede definir la suma para un par de valores de int
. (Eso crearía ambigüedad: si MyType
pudiera definir eso, C# no sabría si usar eso o el comportamiento normal incorporado al sumar dos valores int
). Las mismas reglas se aplican a las interfaces: al menos uno de los argumentos del Ejemplo 4-23 tendría que ser IAdd
. Pero, como verás en el Capítulo 7, declarar estos argumentos con un tipo de interfaz daría lugar a un encasillamiento cuando los tipos subyacentes fueran tipos de valor, lo que provocaría asignaciones a la pila cada vez que realizaras operaciones aritméticas básicas.
Por eso INumber<T>
toma un argumento de tipo. Lo introduce como los tipos de entrada y salida del operador +
. En realidad, no lo hace directamente: cada uno de losoperadores está separado en una interfaz distinta, y INumber<T>
los heredatodos. Por ejemplo, INumber<T>
hereda de IAdditionOperators<T,T,T>
, y como muestra el Ejemplo 4-24, esa es la interfaz que define realmente los miembros del operador. En este caso, los argumentos del operador no utilizan tipos de interfaz, sino el parámetro de tipo real (evitando cualquier sobrecarga de encasillado), pero el compilador está satisfecho porque una restricción sobre este parámetro de tipo requiere que implemente la interfaz.
Ejemplo 4-24. La interfaz IAdditionOperators<TSelf, TOther, TResult>
public
interface
IAdditionOperators
<
TSelf
,
TOther
,
TResult
>
where
TSelf
:
IAdditionOperators
<
TSelf
,
TOther
,
TResult
>?
{
static
abstract
TResult
operator
+(
TSelf
left
,
TOther
right
);
static
virtual
TResult
operator
checked
+(
TSelf
left
,
TOther
right
)
=>
left
+
right
;
}
Esto es más complejo que el hipotético IAdd
mostrado en el Ejemplo 4-23. Es genérico por las razones que acabamos de describir, pero tiene tres argumentos de tipo, no sólo uno. Esto es para que sea posible definir restricciones que requieran la suma con entradas mixtas. Hay algunos objetos matemáticos que son más complejos que los valores individuales (por ejemplo, vectores, matrices) y que admiten operaciones aritméticas comunes (por ejemplo, puedes sumar dos matrices), y en algunos casos podrías aplicar operaciones con distintos tipos de entrada. Por ejemplo, puedes multiplicar una matriz por un número ordinario (un escalar) y el resultado es una nueva matriz. Las interfaces de operadores son capaces de representar esto porque toman argumentos de tipos distintos para las entradas y las salidas, por lo que una biblioteca matemática que quisiera representar esta capacidad podría representarla como IMultiplyOperators<double, Matrix, Matrix>
.
Observarás que INumber<T>
sólo toma un argumento de tipo. Mientras que las interfaces de operador individuales son capaces de representar operaciones híbridas, INumber<T>
opta por no explotar esto: pasa su único parámetro de tipo como los tres argumentos de tipo a las interfaces de operador. Así, cualquier tipo que implemente INumber<T>
implementa IAdditionOperators<T, T, T>
, IMultiplyOperators<T, T, T>
, y así sucesivamente.
La otra característica que hace más complejo el Ejemplo 4-24 es que permite que los tipos proporcionen diferentes implementaciones de la suma para los contextos checked
y unchecked
. Como se describe en el Capítulo 2, C# no emite código que detecte el desbordamiento aritmético por defecto, pero dentro de bloques o expresiones marcados con la palabra clave checked
, las operaciones aritméticas que produzcan resultados demasiado grandes para caber en el tipo de destino provocarán excepciones. Los tipos que proporcionen implementaciones personalizadas de operadores aritméticos pueden suministrar un método diferente para que se utilice en un contexto checked
, de modo que puedan ofrecer la misma función. Esto es opcional, por lo que IAdditionOperators<TSelf, TOther, TResult>
define el operador checked +
como virtual
(no abstract
): proporciona una implementación por defecto que sólo llama a la versión no seleccionada. Es un valor por defecto adecuado para tipos que no se desbordan, como BigInteger
. Los tipos que pueden desbordarse suelen necesitar código especializado para detectarlo, en cuyo caso anularán el operador checked
.
Interfaces matemáticas genéricas
Como acabas de ver, System.Numerics
define múltiples interfaces que representan diversas capacidades matemáticas. Muy a menudo nos limitaremos a especificar una restricción de INumber<T>
, pero a veces necesitaremos ser un poco más específicos. Puede que nuestro código necesite poder representar números negativos, en cuyo caso INumber<T>
es demasiado amplio: lo implementan tanto los tipos con signo (por ejemplo, int
) como los tipos sin signo (por ejemplo, uint
). Si especificamos ISignedNumber<T>
como restricción, eso impedirá el uso de tipos sin signo. La matemática genérica define cuatro grupos de interfaces que representan distintos tipos de características que podríamos necesitar:
-
Categoría numérica (por ejemplo,
INumber<T>
,ISignedNumber<T>
,IFloatingPoint<T>
) -
Operador (por ejemplo,
IAdditionOperators<TSelf, TOther, TResult>
,IMultiplyOperators<TSelf, TOther, TResult>
) -
Función (por ejemplo,
IExponentialFunctions<TSelf, TOther, TResult>
oITrigonometricFunctions<TSelf, TOther, TResult>
) -
Análisis sintáctico y formateo
Las siguientes secciones describen cada uno de estos grupos.
Interfaces de categoría numérica
Las distintas interfaces de categoría numérica representan determinadas clases de características que podemos desear de los tipos numéricos, no todas ellas universales para todas las clases de números. Algunos métodos, como el método genérico Add<T>
del Ejemplo 4-22, podrán trabajar más o menos con cualquier tipo numérico, por lo que la restricción INumber<T>
es una opción razonable. Pero es posible que algún código sólo pueda trabajar con números enteros, mientras que otro código podría requerir absolutamente coma flotante. La Figura 4-1 muestra las interfaces decategoría que nos permiten expresar diversas capacidades que nuestro código genéricopodría necesitar. Cada uno de los tipos numéricos de .NET implementa algunas deestas interfaces, pero no todas.
Esta figura muestra las relaciones de herencia que definen las relaciones fijas entre algunas de las interfaces de la categoría. El Capítulo 6 describe la herencia en detalle, pero con las interfaces es bastante sencillo: cualquier tipo que implemente una interfaz debe implementar también cualquier interfaz heredada. Por ejemplo, si un número implementa IFloatingPoint<T>
, también debe implementar INumber<T>
. Hay otras relaciones que, aunque no son estrictamente necesarias, existen en la práctica en los tipos numéricos de .NET. Por ejemplo, aunque no existe un requisito absoluto de que los tipos que implementan ISignedNumber<T>
deban implementar también INumber<T>
, todos los tipos incorporados a las bibliotecas en tiempo de ejecución de .NET que implementan el primero implementan el segundo. (Por ejemplo, int
, double
y decimal
implementan ambos).
En general, la documentación de .NET te anima a utilizar INumber<T>
como restricción en el código en el que necesites operaciones aritméticas comunes pero no tengas ningún requisito particular sobre el tipo de número que se utiliza. Sin embargo, en la Figura 4-1 puedes ver que existe una interfaz aún más general: INumberBase<T>
. Todas las interfaces de ese diagrama, excepto IMinMaxValue<T>
, heredan directa o indirectamente de INumberBase<T>
(y en la práctica, los tipos que implementan IMinMaxValue<T>
suelen implementar INumberBase<T>
), así que si quieres escribir código que pueda funcionar con la gama más amplia posible de tipos, INumberBase<T>
es una opción mejor queINumber<T>
. El ejemplo 4-22 podría modificarse para especificar INumberBase<T>
como restricción, por ejemplo, y seguiría funcionando, porque los operadores aritméticos básicos están disponibles.
Entonces, ¿cuál es la diferencia? Si utilizas los tipos numéricos integrados en .NET, la única consecuencia de especificar INumber<T>
como restricción en lugar de INumberBase<T>
es que no podrás utilizar el tipo Complex
. Los números complejos son bidimensionales, lo que significa que para dos números complejos cualesquiera, no podemos decir necesariamente cuál es mayor: para cualquier número complejo concreto habrá infinitos números complejos con valores diferentes pero la misma magnitud. INumberBase<T>
exige que los tipos admitan la comparación por igualdad (¿es x igual a y?), pero no exige que los tipos admitan la comparación por orden (¿es x mayor que y?). INumber<T>
exige que los tipos admitan ambas cosas. Así que Complex
implementa INumberBase<T>
pero no puede implementar INumber<T>
. Además, Complex
no proporciona una forma de obtener el resto de una operación de división, y esto tampoco está presente en la interfaz INumberBase<T>
(pero sí en INumber<T>
). Éstas son las dos únicas diferencias entre estas dos interfaces. Así que, a menos que necesites que tus números se puedan ordenar, o que necesites calcular restos (con el operador %
), utilizar INumberBase<T>
como restricción es la forma más sencilla de trabajar con la gama más amplia posible de tipos numéricos.
INumberBase<T>
no sólo define los operadores aritméticos básicos. También define unas propiedades llamadas One
y Zero
. El ejemplo 4-25 utiliza Zero
para proporcionar un valor inicial para calcular la suma total de una matriz de valores. ¿Por qué no escribir simplemente el literal 0
? IL (la salida del compilador) utiliza diferentes representaciones para literales de diferentes tipos, y como T
aquí es un argumento de tipo genérico, no es posible que el compilador genere código que cree un literal de tipo T
.
Ejemplo 4-25. Utilizar INumberBase<T>.Zero
static
T
Sum
<
T
>(
T
[]
values
)
where
T
:
INumberBase
<
T
>
{
T
total
=
T
.
Zero
;
foreach
(
T
value
in
values
)
{
total
+=
value
;
}
return
total
;
}
Las propiedades Zero
y One
están disponibles en cualquier INumber<T>
, pero la interfaz también permite intentar conversiones a partir de otros valores. Define un método estático CreateChecked<TOther>(TOther value)
, por lo que en lugar de T.Zero
, podríamos haber escrito T.CreateChecked(0)
. Éste es un método genérico, y restringe el argumento de tipo TOther
para implementar INumberBase<TOther>
, de modo que puedes pasar cualquier tipo numérico, pero el compilador no te dejará llamar a T.CreateChecked("banana")
, por ejemplo. Sin embargo, no está garantizado que todas las conversiones tengan éxito: si el código pasa un valor a CreateChecked
de algún tipo con un rango mayor que el tipo de destino, el valor podría exceder el rango del tipo de destino, en cuyo caso el método lanzaría un OverflowException
. Por ejemplo, T.CreateChecked(uint.MaxValue)
fallaría si el método genérico se invoca con un argumento de tipo int
. (Por eso he utilizado T.Zero
: que no lanzará excepciones bajo ninguna circunstancia.) INumber<T>
también define CheckedSaturating
, que maneja los valores fuera de rango devolviendo el valor más grande o más pequeño disponible (dependiendo de la dirección en la que hayas excedido el rango). Por ejemplo, si el objetivo es int
, y pasas un valor demasiado alto, CreateSaturating
devolverá int.MaxValue
. Si pasas un número negativo demasiado grande, devolverá int.MinValue
. También existe CreateTruncating
, que sólo descarta los bits de orden superior. Por ejemplo, si la entrada es un long
y el destino es int
, CreateTruncating
utilizará los 32 bits inferiores del valor y simplemente ignorará los 32 bits superiores. (Esto es lo mismo que ocurre cuando se transforma un long
en un int
en un contexto sin comprobar).
INumberBase<T>
también proporciona varios métodos de utilidad que puedes utilizar para preguntar por determinadas características. Por ejemplo, exige que todos los tipos implementadores definan los métodos estáticos IsPositive
, IsNegative
y IsInteger
.
La interfaz IBinaryNumber<T>
la implementan todos los tipos que tienen una representación binaria definida. En la práctica, esto significa todos los tipos numéricos nativos de .NET (enteros y de coma flotante) excepto decimal
, y también incluye BigInteger
. Pone a tu disposición operadores bit a bit (como &
y |
), y define los métodos IsPow2
y Log2
. IsPow2
te permite descubrir si el valor tiene un solo dígito binario distinto de cero (lo que significa que será alguna potencia de dos). Log2
te indica esencialmente cuántos dígitos binarios se necesitan para representar el valor. IBinaryInteger<T>
es más especializado, ya que sólo lo implementan los tipos enteros (incorporados y también BigInteger
). Añade operadores de desplazamiento y rotación de bits, conversión a y desde secuencias de bytes en forma big o little endian, y algunas funciones de recuento de bits.
Existen varias interfaces que representan el punto flotante. Una de las razones por las que no podemos tener una definición única es que decimal
es técnicamente un tipo denúmero de punto flotante, pero es muy diferente de float
, double
y System.Half
, cada uno delos cuales implementa la norma internacional IEEE754 para la aritmética de punto flotante. Eso significa que esos tipos tienen una estructura binaria bien definida y admiten un conjunto concreto de operaciones estándar además de la aritmética básica. decimal
no tiene esas características, pero si todo lo que necesitas es la capacidad de representar números no enteros, puede ser suficiente, y si especificas una restricción de IFloatingPoint<T>
, eso permitirá cualquiera de los tipos de punto flotante, incluido decimal
. Especificarías IFloatingPointIeee754<T>
si necesitas los valores especiales que define IEEE754 (como NaN
, el valor de no número que puede surgir de algunos cálculos, o sus representaciones para el infinito positivo y negativo) o si necesitas acceder a algunas de las operaciones estándar de IEEE754, como las funciones trigonométricas o la exponenciación. O podrías especificarlo para excluir decimal
porque tiene algunas características muy diferentes en cuanto a precisión y error. Sería bastante raro necesitar el más especializado IBinaryFloatingPointIeee754<T>
. Lo implementan todos los tipos .NET nativos que implementan IFloatingPointIeee754<T>
, y permite utilizar operaciones a nivel de bits, pero es relativamente inusual realizar operaciones a nivel de bits en valores de coma flotante. En la mayoría de los casos, IFloatingPointIeee754<T>
será la restricción de punto flotante más específica que necesitarás.
Enla Figura 4-1 aparece una interfaz aparentemente sola: IMinMaxValue<T>
. De hecho, casi todos los tipos numéricos la implementan: pone a disposición las propiedades MinValue
y MaxValue
, que informan de los valores más alto y más bajo que puede representar el tipo. Sólo parece tan aislado porque ninguna de las otras interfaces de los tipos numéricos requiere IMinMaxValue<T>
; en la práctica es casi omnipresente. Una excepción es el tipo Complex
, que no implementa esto porque, como ya se ha dicho, no ordena completamente sus números, por lo que no hay un único valor máximo. BigInteger
tampoco lo implementa, porque su característica definitoria es que no tiene un límite superior fijo.
Hay dos interfaces más que la documentación .NET sitúa en el grupo de categorías numéricas: IAdditiveIdentity<TSelf, TResult>
y IMultiplicativeIdentity<TSelf, TResult>
. No las he incluido en la Figura 4-1 porque son ligeramente diferentes de las demás interfaces de categoría. Están estrechamente relacionadas con dos de las interfaces de operador que se describen en el apartado siguiente. Todos los tipos de las bibliotecas en tiempo de ejecución .NET que implementan IAdditionOperators<TSelf, TOther, TResult>
también implementan IAdditiveIdentity<TSelf, TResult>
, y lo mismo ocurre con IMultiplyOperators<TSelf, TOther, TResult>
y IMultiplicativeIdentity<TSelf, TResult>
. Cada una de ellas define una única propiedad, AdditiveIdentity
y MultiplicativeIdentity
, respectivamente. Si utilizas estos valores como uno de los operandos de su operación correspondiente, el resultado será el otro operando. (En otras palabras, x + T.AdditiveIdentity
y x * T.MultiplicativeIdentity
son iguales a x
.) En la práctica, eso significa que AdditiveIdentity
es cero y MultiplicativeIdentity
es uno para todos los tipos numéricos que proporciona .NET. Entonces, ¿por qué no utilizar simplemente T.Zero
y T.One
? Porque hay algunos objetos matemáticos menos convencionales que se comportan como números en ciertos aspectos, pero que no se corresponden directamente con números normales como el uno o el cero. Por ejemplo, a algunos matemáticos les gusta hacer matemáticas con formas, utilizando la rotación y la reflexión, y en algunos casos puede haber un comportamiento parecido a la suma, pero en el que la identidad aditiva no es un número simple. Aunque ninguno de los tipos numéricos incorporados entra en este tipo de territorio, las matemáticas genéricas se diseñaron para quefuera posible escribir bibliotecas que hicieran este tipo de matemáticas.
El Ejemplo4-26 utiliza IAdditiveIdentity<TSelf, TResult>
para implementar una alternativa al método Sum<T>
mostrado en el Ejemplo 4-25. En teoría, esto hace que este método sea menos restrictivo: puede funcionar con cualquier tipo añadible, y no requiere INumberBase<T>
. Todos los tipos de la biblioteca en tiempo de ejecución .NET que son agregables también implementan INumberBase<T>
, por lo que su utilidad es dudosa, pero si utilizas una biblioteca que represente objetos matemáticos más exóticos, puede que contenga tipos que no implementen INumberBase<T>
pero que funcionarían con este método más precisamente restringido.
Ejemplo 4-26. Utilizar AdditiveIdentity
public
static
T
Sum
<
T
>(
T
[]
values
)
where
T
:
IAdditionOperators
<
T
,
T
,
T
>,
IAdditiveIdentity
<
T
,
T
>
{
T
total
=
T
.
AdditiveIdentity
;
foreach
(
T
value
in
values
)
{
total
+=
value
;
}
return
total
;
}
La taxonomía algo compleja e intrincada de los tipos numéricos representados por las interfaces de categoría numérica permite definir restricciones muy específicas, que pueden hacer que el código genérico trabaje con la gama más amplia posible de tipos numéricos. Sin embargo, como muestra el Ejemplo 4-26, esto tiene el coste de cierta complejidad. La versión de este código del Ejemplo 4-25 que utilizaba una restricción de INumberBase<T>
es más sencilla, y no requiere que los desarrolladores que lean el código hayan memorizado la Figura 4-1, ni que estén lo suficientemente familiarizados con la terminología matemática como para entender exactamente qué es una identidad aditiva. Y puesto que todos los tipos numéricos de .NET implementan INumberBase<T>
, usar eso como restricción normalmente logrará un mejor equilibrio entre flexibilidad final y legibilidad.
Interfaces de operador
Ya has visto IAdditionOperators<TSelf, TOther, TResult>
, que podemos utilizar como restricción que exige que el operador +
esté disponible para un parámetro de tipo genérico. Ésta es una de una familia de interfaces que definen la disponibilidad de los operadores. La Tabla 4-1 enumera todas las interfaces de este tipo y muestra qué operadores define cada interfaz.
Interfaz | Operaciones | Disponible a través de |
---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
En la práctica, rara vez expresamos las restricciones directamente en términos de estas interfaces de operador, porque las distintas interfaces de categoría numérica heredan de ellas y son menos prolijas. La última columna de la tabla muestra la interfaz de categoría más general que hereda de la interfaz de operador de esa fila. Cuando un tipo implementa una interfaz de operador, normalmente también implementará la interfaz de categoría numérica correspondiente. Como se acaba de comentar en la sección anterior, aunque podrías especificar una restricción de IAdditionOperators<T, T, T>
, todos los tipos de las bibliotecas en tiempo de ejecución que la implementan también implementan INumberBase<T>
. A menos que utilices una biblioteca poco habitual que implemente algunas interfaces de operador pero no la interfaz de categoría correspondiente, en la práctica no tiene sentido ser más específico, así que cuando necesites un operador concreto, normalmente utilizarás la interfaz correspondiente de la tercera columna de la Tabla 4-1 como restricción.
Interfaces de funciones
Muchas operaciones matemáticas comunes no se expresan como operadores, sino como funciones. Durante la mayor parte de la historia de .NET, hemos utilizado los métodos definidos por la clase System.Math
, pero ese tipo es anterior a los genéricos. Las matemáticas genéricas hacen que funciones como las trigonométricas y la exponenciación estén disponibles a través de métodos abstractos estáticos definidos por las interfaces enumeradas en la Tabla 4-2.
Interfaz | Operaciones |
---|---|
|
Funciones exponenciales como |
|
Funciones hiperbólicas como |
|
Funciones logarítmicas como |
|
Función de potencia: |
|
Funciones de raíz como |
|
Funciones trigonométricas como |
Al igual que con las interfaces de operador, normalmente no necesitamos referirnos a estas interfaces directamente en las restricciones, porque son accesibles a través de una categoría numérica. Todos los tipos de la biblioteca en tiempo de ejecución .NET que implementan alguna de estas interfaces también implementan IFloatingPointIeee754<T>
.
Análisis sintáctico y formateo
El último conjunto de interfaces asociadas a las matemáticas genéricas nos permite trabajar con representaciones textuales de los números. En sentido estricto, estas cuatro interfaces no se limitan a trabajar sólo con tipos numéricos:DateTimeOffset
las implementa todas, por ejemplo. Sin embargo, INumberBase<T>
hereda de todas ellas, por lo que están disponibles en los tipos que admiten las matemáticas genéricas.
IParsable<T>
define los métodos y que te permiten convertir un al tipo numérico de destino. También existe , que ofrece métodos similares que pueden funcionar con . Los tipos Span, que Parse
TryParse
string
ISpanParsable<T>
ReadOnlySpan<char>
el Capítulo 18 describe en detalle, permiten trabajar con rangos de caracteres que no son necesariamente objetos completos por derecho propio. puede analizar texto en subsecciones de un existente, un , memoria asignada en la pila, o incluso en un bloque de memoria asignado por mecanismos fuera del control del tiempo de ejecución .NET (por ejemplo, a través de una llamada al sistema, o por alguna biblioteca externa no .NET). También existe un , que puede trabajar directamente con datos codificados en formato UTF-8. string
ISpanParsable<T>
string
char[]
IUtf8SpanParsable<T>
IFormattable<T>
, ISpanFormattable<T>
, y IUtf8SpanFormattable<T>
van en la otra dirección: son capaces de producir representaciones textuales de valores. (No son nuevos -existen desde .NET 6.0-, pero ahora están asociados a las matemáticas genéricas porque INumberBase<T>
hereda de ellos). IFormattable<T>
define una sobrecarga de ToString
que acepta el mismo tipo de formato compuesto que string.Format
, junto con un argumento opcional IFormatProvider
que controla detalles que varían según el idioma y la cultura (como las convenciones para los separadores de dígitos). ISpanFormattable<T>
define TryFormat
, que proporciona el mismo servicio, pero en lugar de devolver una cadena recién asignada, escribe su salida directamente en un Span<char>
, lo que puede permitir construir cadenas más complejas con menos asignaciones, reduciendo la presión sobre el recolector de basura.
Genéricos y tuplas
Las tuplas ligeras de C# tienen una sintaxis distintiva, pero en lo que respecta al tiempo de ejecución, no tienen nada de especial. No son más que instancias de un conjunto de tipos genéricos. Mira el Ejemplo 4-27. Aquí se utiliza (int, int)
como tipo de una variable local para indicar que es una tupla que contiene dos valores int
.
Ejemplo 4-27. Declarar una variable tupla de forma normal
(
int
,
int
)
p
=
(
42
,
99
);
Mira ahora el Ejemplo 4-28. Aquí se utiliza el tipo ValueTuple<int, int>
en el espacio de nombres System
. Pero es exactamente equivalente a la declaración del Ejemplo 4-27. En Visual Studio o VS Code, si pasas el ratón por encima de la variable p2
, te informará de que su tipo es (int, int)
.
Ejemplo 4-28. Declarar una variable tupla con su tipo subyacente
ValueTuple
<
int
,
int
>
p2
=
(
42
,
99
);
Una cosa que añade la sintaxis especial de C# para las tuplas es la posibilidad de nombrar los elementos de la tupla. La familia ValueTuple
nombra sus elementos Item1
, Item2
, Item3
, etc., pero en C# podemos elegir otros nombres. Cuando declaras una variable local con elementos de tupla nombrados, esos nombres son una ficción mantenida por C#: no tienen ninguna representación en tiempo de ejecución. Sin embargo, cuando un método devuelve una tupla, como en el Ejemplo 4-29, es diferente: los nombres tienen que ser visibles para que el código que consuma este método pueda utilizar los mismos nombres. Aunque este método esté en algún componente de la biblioteca al que mi código haya hecho referencia, quiero poder escribir Pos().X
, en lugar de tener que utilizar Pos().Item1
.
Ejemplo 4-29. Devolver una tupla
public
static
(
int
X
,
int
Y
)
Pos
()
=>
(
10
,
20
);
Para que esto funcione, el compilador aplica un atributo llamado TupleElementNames
al valor de retorno del método, y éste contiene una matriz que enumera los nombres de las propiedades a utilizar.(En el Capítulo 14 se describen los atributos.) En realidad, no puedes escribir código que haga esto por ti mismo: si escribes un método que devuelve un ValueTuple<int, int>
e intentas aplicar el TupleElementNamesAttribute
como atributo return
, el compilador producirá un error indicándote que no utilices este atributo directamente y que uses en su lugar la sintaxis de tupla. Pero ese atributo es la forma en que el compilador informa de los nombres de los elementos de la tupla.
Ten en cuenta que hay otra familia de tipos de tupla en las bibliotecas de tiempo de ejecución, Tuple<T>
, Tuple<T1, T2>
, etc. Son casi idénticos a la familia ValueTuple
. La diferencia es que la familia Tuple
de tipos genéricos son todos clases, mientras que todos los tipos ValueTuple
son structs. La sintaxis de tupla ligera de C# sólo utiliza la familia ValueTuple
. Sin embargo, la familia Tuple
ha existido en las bibliotecas de tiempo de ejecución durante mucho más tiempo, por lo que a menudo se utilizan en código antiguo que necesitaba agrupar un conjunto de valores sin definir un nuevo tipo sólo para esa tarea.
Resumen
Los genéricos nos permiten escribir tipos y métodos con parámetros de tipo, que pueden rellenarse en tiempo de compilación para producir distintas versiones de los tipos o métodos que funcionen con tipos concretos. Uno de los casos de uso más importantes de los genéricos cuando se introdujeron por primera vez fue hacer posible escribir clases de colección seguras desde el punto de vista de los tipos, como List<T>
. Veremos algunos de estos tipos de colecciones en elpróximo capítulo.
1 Al decir los nombres de los tipos genéricos, la convención es utilizar la palabra de como en "Lista de T" o "Lista de int".
2 Esto se permite aunque hayas utilizado la restricción simple class
en un contexto de anotación anulable habilitado. Esta restricción no proporciona garantías herméticas de no nulidad, por lo que C# permite la comparación con null
.
3 Moq se basa en la función de proxy dinámico del Proyecto Castle para generar este tipo. Si quieres utilizar algo similar en tu código, puedes encontrarlo en el Proyecto Castle.
Get Programación C# 12 now with the O’Reilly learning platform.
O’Reilly members experience books, live events, courses curated by job role, and more from O’Reilly and nearly 200 top publishers.