Agosto de 2017

Volumen 32, número 8

Essential .NET: descripción de tuplas para C# 7.0

Por Mark Michaelis

Mark MichaelisEn la edición especial de Connect(); de noviembre, escribí una introducción a C# 7.0 (msdn.microsoft.com/magazine/mt790178), donde presenté las tuplas. En este artículo, vuelvo a abordar las tuplas y cubro la totalidad de las opciones de sintaxis.

Para comenzar, pensemos en la pregunta: ¿por qué usar tuplas? En ocasiones, es probable que le resulte útil combinar elementos de datos. Por ejemplo, suponga que está trabajando con información sobre países, como el país más pobre del mundo en 2017: Malawi, cuya capital es Lilongüe, con un producto interior bruto (PIB) per cápita de 226,50 USD. Evidentemente, se podría declarar una clase para estos datos, pero no representa la típica combinación nombre/objeto. Se parece más a una colección de datos relacionados que a un objeto. Seguramente, si fuera a tener un objeto Country, por ejemplo, tendría varios datos más que solo las propiedades para el nombre, la capital y el PIB per cápita. De lo contrario, podría almacenar cada elemento de datos en variables individuales, pero el resultado no generaría ninguna asociación entre los elementos de datos; 226,50 USD no tendría ninguna asociación con Malawi, excepto tal vez por un sufijo o prefijo comunes en los nombres de las variables. Otra opción sería combinar todos los datos en una única cadena, con la desventaja de que trabajar con cada elemento de datos de manera individual exigiría su análisis. Un enfoque final podría consistir en crear un tipo anónimo, pero esto también tiene sus limitaciones; de hecho, las suficientes como para que esas tuplas reemplacen completamente los tipos anónimos. Abordaré este tema al final del artículo.

La mejor opción podría ser la tupla de C# 7.0, que, en su versión más sencilla, ofrece una sintaxis que le permite combinar la asignación de varias variables, de distintos tipos, en una única instrucción:

(string country, string capital, double gdpPerCapita) = 
  ("Malawi", "Lilongwe", 226.50);

En este caso, no solo asigno varias variables, sino que también las declaro.

No obstante, las tuplas tienen varias otras posibilidades de sintaxis adicionales, como se muestra en la Figura 1.

Figura 1: código de ejemplo para la declaración y asignación de tuplas

EjemploDescripciónCódigo de ejemplo
1.Asignar una tupla a variables declaradas individualmente.(string country, string capital, double gdpPerCapita) =
("Malawi", "Lilongwe", 226.50);
System.Console.WriteLine(
$@"The poorest country in the world in 2017 was {
country}, {capital}: {gdpPerCapita}");
2.Asignar una tupla a variables declaradas individualmente que están declaradas previamente.string country;
string capital;
double gdpPerCapita;
(country, capital, gdpPerCapita) =
("Malawi", "Lilongwe", 226.50);
System.Console.WriteLine(
$@"The poorest country in the world in 2017 was {
country}, {capital}: {gdpPerCapita}");
3.Asignar una tupla a variables declaradas individualmente y con tipos implícitos.(var country, var capital, var gdpPerCapita) =
("Malawi", "Lilongwe", 226.50);
System.Console.WriteLine(
$@"The poorest country in the world in 2017 was {
country}, {capital}: {gdpPerCapita}");
4.Asignar una tupla a variables declaradas individualmente que tienen tipos implícitos con una sintaxis distributiva.var (country, capital, gdpPerCapita) =
("Malawi", "Lilongwe", 226.50);
System.Console.WriteLine(
$@"The poorest country in the world in 2017 was {
country}, {capital}: {gdpPerCapita}");
5.Declarar una tupla de elemento con nombre y asignarle valores de tupla, y luego acceder a los elementos de tupla por nombre.(string Name, string Capital, double GdpPerCapita) countryInfo =
("Malawi", "Lilongwe", 226.50);
System.Console.WriteLine(
$@"The poorest country in the world in 2017 was {
countryInfo.Name}, {countryInfo.Capital}: {
countryInfo.GdpPerCapita}");
6.Asignar una tupla de elemento con nombre a una única variable con tipo implícito y luego acceder a los elementos de tupla por nombre.var countryInfo =
(Name: "Malawi", Capital: "Lilongwe", GdpPerCapita: 226.50);
System.Console.WriteLine(
$@"The poorest country in the world in 2017 was {
countryInfo.Name}, {countryInfo.Capital}: {
countryInfo.GdpPerCapita}");
7.Asignar una tupla sin nombre a una única variable con tipo implícito y luego acceder a los elementos de tupla por la propiedad de número de elemento.var countryInfo =
("Malawi", "Lilongwe", 226.50);
System.Console.WriteLine(
$@"The poorest country in the world in 2017 was {
countryInfo.Item1}, {countryInfo.Item2}: {
countryInfo.Item3}");
8.Asignar una tupla de elemento con nombre a una única variable con tipo implícito y luego acceder a los elementos de tupla por la propiedad de número de elemento.var countryInfo =
(Name: "Malawi", Capital: "Lilongwe", GdpPerCapita: 226.50);
System.Console.WriteLine(
$@"The poorest country in the world in 2017 was {
countryInfo.Item1}, {countryInfo.Item2}: {
countryInfo.Item3}");
9.Descartar porciones de la tupla con guiones bajos.(string name, _, double gdpPerCapita) countryInfo =
("Malawi", "Lilongwe", 226.50);

En los primeros cuatro ejemplos y, si bien el lado derecho representa una tupla, el lado izquierdo aún representa variables individuales que se asignan juntas mediante sintaxis de tupla, que involucra dos elementos o más separados por comas y asociados con paréntesis. (Uso el término “sintaxis de tupla” porque el tipo de datos subyacente que genera el compilador en el lado izquierdo no es técnicamente una tupla). El resultado es que, aunque comienzo con valores combinados como una tupla a la derecha, la asignación a la izquierda deconstruye la tupla en sus partes constituyentes. En el ejemplo 2, la asignación de la izquierda es a variables declaradas previamente. No obstante, en los ejemplos 1, 3 y 4, las variables se declaran dentro de la sintaxis de tupla. Dado que solo declaro variables, la convención de nombres y del uso de mayúsculas sigue las instrucciones de diseño de Framework de aceptación general; por ejemplo, “Usar camelCase para nombres de variables locales”.

Tenga en cuenta que, si bien los tipos implícitos (var) pueden distribuirse para cada declaración de una variable dentro de la sintaxis de tupla, como se muestra en el ejemplo 4, no es posible hacer lo mismo con un tipo explícito (por ejemplo, string). En este caso, está declarando un tipo de tupla y no usa simplemente la sintaxis de tupla, por tanto, deberá agregar una referencia al paquete System.ValueType NuGet, al menos hasta .NET Standard 2.0. Dado que las tuplas permiten que cada elemento sea de un tipo de datos diferente, distribuir el nombre de tipo explícito entre todos los elementos no funcionaría necesariamente, a menos que todos los tipos de datos de elementos fueran idénticos (e, incluso así, el compilador no lo permite).

En el ejemplo 5, declaro una tupla a la izquierda y luego asigno la tupla a la derecha. Tenga en cuenta que la tupla tiene elementos con nombre (nombres a los que luego puede hacer referencia para recuperar de la tupla los valores de los elementos). Esto es lo que permite la sintaxis countryInfo.Name, countryInfo.Capital y countryInfo.GdpPerCapita en la instrucción System.Console.WriteLine. El resultado de la declaración de la tupla a la izquierda es una agrupación de las variables en una única variable (countryInfo) desde la que luego puede tener acceso a las partes constituyentes. Esto resulta útil porque luego puede pasar esta única variable a otros métodos, y esos métodos también podrán acceder a los elementos individuales dentro de la tupla.

Como ya se mencionó, las variables definidas con la sintaxis de tupla usan camelCase. Sin embargo, la convención de nombres de los elementos de la tupla no está bien definida. Entre las sugerencias, se incluye el uso de convenciones de nomenclatura de parámetros cuando la tupla se comporta como un parámetro; por ejemplo, al devolver varios valores que antes de la sintaxis de tupla hubiesen usado parámetros. La alternativa es usar PascalCase, según la convención de nomenclatura para propiedades y campos públicos. Recomiendo encarecidamente este último enfoque de acuerdo con las reglas de uso de mayúsculas y minúsculas para identificadores (itl.tc/caprfi). Los nombres de los elementos de una tupla se representan como miembros de la tupla, y la convención para todos los miembros (públicos) (a los que se puede acceder con un operador de punto) es PascalCase.

En el ejemplo 6, se ofrece la misma funcionalidad que en el ejemplo 5, aunque usa elementos de tupla con nombre en el valor de tupla de la derecha y una declaración de tipo implícito a la izquierda. Los nombres de los elementos son persistentes en la variable con tipos implícitos, no obstante, siguen estando disponibles para la instrucción WriteLine. Por supuesto, esto abre la posibilidad para que asigne un nombre a los elementos de la izquierda con nombres que sean distintos a los usados a la derecha. Aunque el compilador de C# lo permite, emitirá una advertencia indicando que se ignorarán los nombres de los elementos a la derecha, ya que tienen prioridad los de la izquierda.

Si no se especifica ningún nombre de elemento, los elementos individuales siguen estando disponibles a partir de la variable de tupla asignada. Sin embargo, los nombres son Item1, Item2, etc., como se muestra en el ejemplo 7. De hecho, el nombre ItemX siempre está disponible en la tupla, incluso cuando se proporcionan nombres personalizados (véase el ejemplo 8). Sin embargo, al usar herramientas del IDE, como cualquiera de los tipos recientes de Visual Studio que admiten C# 7.0, la propiedad ItemX no aparecerá dentro de la lista desplegable de IntelliSense, lo que es positivo, ya que supuestamente se prefiere el nombre proporcionado.

Como se muestra en el ejemplo 9, las porciones de una asignación de tupla pueden excluirse mediante un guion bajo, lo que se denomina "descartar".

Las tuplas son una solución ligera para encapsular datos en un único objeto, de la misma manera que una bolsa puede contener diversos artículos que recoge de una tienda. A diferencia de las matrices, las tuplas contienen tipos de datos de elementos que pueden variar casi sin restricciones (aunque no se permiten los punteros), excepto porque se identifican mediante código y no pueden cambiarse en tiempo de ejecución. Además, a diferencia de las matrices, la cantidad de elementos dentro de la tupla también se codifica de forma rígida en tiempo de compilación. Por último, no es posible agregar comportamientos personalizados a una tupla (a pesar de los métodos de extensión). Si necesita comportamientos asociados con los datos encapsulados, el enfoque preferido es aprovechar la programación orientada a objetos y la definición de una clase.

Tipo System.ValueTuple<…>

El compilador de C# genera código que se basa en un conjunto de tipos de valor genéricos (estructuras), por ejemplo, System.ValueTuple<T1, T2, T3>, como la implementación subyacente para la sintaxis de tupla de todas las instancias de tupla en el lado derecho de los ejemplos de la Figura 1. De igual manera, el mismo conjunto de tipos de valor genéricos System.ValueTuple<...> se usa para el tipo de datos de la izquierda a partir del ejemplo 5. Como sería de esperar con un tipo de tupla, los únicos métodos incluidos son aquellos relacionados con la comparación y la igualdad. No obstante, tal vez de forma inesperada, no hay propiedades para ItemX, sino campos de lectura y escritura (lo que al parecer infringe las instrucciones de programación de .NET más básicas, como se explica en itl.tc/CS7TuplesBreaksGuidelines).

Además de la discrepancia con las instrucciones de programación, surge otro aspecto relativo al comportamiento. Dado que los nombres de elementos personalizados y sus tipos no están incluidos en la definición de System.ValueTuple<...>, ¿cómo es posible que cada nombre de elemento personalizado sea aparentemente un miembro del tipo System.ValueTuple<...> y puede accederse a él como miembro de ese tipo?

Lo sorprendente (en particular para aquellos familiarizados con la implementación de tipos anónimos) es que el compilador no genera código de Lenguaje intermedio común (CIL) subyacente para los miembros que corresponden a los nombres personalizados. Sin embargo, incluso sin un miembro subyacente con el nombre personalizado, existe (aparentemente), desde la perspectiva de C#, un miembro tal.

Por ejemplo, para todos los ejemplos de variables locales de tupla con nombre:

var countryInfo = (Name: "Malawi", Capital: "Lilongwe", GdpPerCapita: 226.50)

es posible, claramente, que el compilador no conozca los nombres para el resto del ámbito de la tupla, ya que ese ámbito está limitado al miembro en el que se declara. Y, de hecho, el compilador (y el IDE) sencillamente se basan en este ámbito para permitir el acceso a cada elemento por el nombre. En otras palabras, el compilador ve los nombres de los elementos en la declaración de la tupla y los aprovecha para permitir el código que usa esos nombres dentro del ámbito. También por este motivo, los métodos de ItemX no se muestran en IntelliSense del IDE como miembros disponibles en la tupla (el IDE sencillamente los ignora y los reemplaza por los elementos con nombre).

Desde el punto de vista del compilador, es razonable establecer los nombres de los elementos cuando se definió su ámbito dentro de un miembro, pero ¿qué sucede cuando una tupla se expone fuera del miembro, por ejemplo, un parámetro o una devolución de un método que está en otro ensamblado (para el que posiblemente no haya disponible un código fuente)? Para todas las tuplas que forman parte de la API (ya sea una API pública o privada), el compilador agrega nombres de elementos a los metadatos del miembro en forma de atributos. Por ejemplo, este código:

[return: System.Runtime.CompilerServices.TupleElementNames(
  new string[] {"First", "Second"})]
public System.ValueTuple<string, string> ParseNames(string fullName)
{
  // ...
}

es el equivalente de C# de lo que genera el compilador para el siguiente código:

public (string First, string Second) ParseNames(string fullName)

Como aspecto relacionado, C# 7.0 no permite el uso de nombres de elemento personalizados al usar el tipo de datos explícito System.ValueTuple<…>. Por tanto, si reemplaza var en el ejemplo 8 de la Figura 1, recibirá advertencias que indicarán que se omitirá cada nombre de elemento.

He aquí algunos datos más que deben tenerse en cuenta sobre System.ValueTuple<…>:

  • Hay un total de ocho estructuras genéricas de System.ValueTuple correspondientes a la posibilidad de admitir una tupla con hasta siete elementos. Para la octava tupla, System.ValueTuple<T1, T2, T3, T4, T5, T6, T7, TRest>, el último parámetro de tipo permite especificar una tupla de valor adicional, lo que permite admitir n elementos. Por ejemplo, si especifica una tupla con ocho parámetros, el compilador generará automáticamente System.ValueTuple<T1, T2, T3, T4, T5, T6, T7, System.ValueTuple<TSub1>> como tipo de implementación subyacente. (Por una cuestión de integridad, System.Value<T1> existe, pero en verdad solo se usará directamente y solo como tipo. El compilador nunca lo usará directamente, ya que la sintaxis de tupla de C# exige un mínimo de dos elementos).
  • Existe un tipo System.ValueTuple no genérico que sirve como fábrica de tuplas con métodos Create correspondientes a cada aridad de tupla de valores. La facilidad de usar un literal de tupla, como var t1 = (“Inigo Montoya”, 42), sustituye el método Create, al menos para los programadores de C# 7.0 (o superior).
  • Para todos los fines prácticos, los desarrolladores de C# pueden omitir prácticamente System.ValueTuple y System.ValueTuple<T>.

Hay otro tipo de tupla que se incluyó en .NET Framework 4.5: System.Tuple<…>. En su momento, se esperaba que fuera la implementación básica de tuplas en adelante. Sin embargo, una vez que C# admitió la sintaxis de tupla, se comprobó que, en general, un tipo de valor tenía un mejor rendimiento, por lo que se introdujo System.ValueTuple<…>, que reemplazaba a System.Tuple<…> de manera eficaz en todos los casos, excepto para compatibilidad con versiones anteriores de API existentes que dependen de System.Tuple<…>.

Resumen

De lo que mucha gente no se dio cuenta cuando se introdujo por primera vez, es que la nueva tupla de C# 7.0 hace de todo menos reemplazar los tipos anónimos, y brinda funcionalidad adicional. Por ejemplo, las tuplas pueden devolverse de los métodos, y los nombres de elementos persisten en la API, de modo que los nombres significativos pueden usarse en lugar de los nombres de tipo ItemX. Asimismo, al igual que los tipos anónimos, las tuplas incluso pueden representar estructuras jerárquicas complejas, como aquellas que pueden construirse en consultas de LINQ más complejas (aunque, al igual que con los tipos anónimos, los desarrolladores deben hacerlo con cuidado). Ahora bien, esto podría llevar posiblemente a situaciones en las que el tipo de valor de la tupla superara los 128 bytes y, por tanto, podría ser un caso extremo de cuándo usar tipos anónimos, ya que es un tipo de referencia. Excepto por estos casos extremos (el acceso a través de la reflexión típica podría ser otro ejemplo), no hay ningún motivo por el que deba usar un tipo anónimo al programar con C# 7.0 o superior.

La capacidad de programar con un objeto de tipo tupla ha estado disponible desde hace mucho (como se mencionó, una clase de tupla, System.Tuple<…>, se introdujo con .NET Framework 4, pero estaba disponible en Silverlight antes de eso). No obstante, estas soluciones nunca habían tenido una sintaxis de C# que las acompañara, sino nada más que una API de .NET. C# 7.0 aporta una sintaxis de tupla de primera línea que permite literales, como var tuple = (42, “Inigo Montoya”), tipado implícito, tipado fuerte, utilización de API públicas, compatibilidad con IDE integrado para datos de ItemX con nombre, etc. Es verdad que puede ser algo que no use en cada archivo de C#, pero probablemente lo agradecerá cuando surja la necesidad y preferirá la sintaxis de tupla frente a la alternativa de parámetro de salida o tipo anónimo.

Gran parte de este artículo proviene de mi libro “Essential C#” (IntelliTect.com/EssentialCSharp), que actualmente estoy actualizando a “Essential C# 7.0”. Para obtener más información sobre este tema, consulte el capítulo 3.

INSTRUCCIONES DE NOMENCLATURA DE ELEMENTOS DE TUPLA

Use camelCase para todas las variables declaradas mediante sintaxis de tupla.

Considere el uso de PascalCase para todos los nombres de elementos de tupla.


Mark Michaelis es el fundador de IntelliTect y trabaja de arquitecto técnico como jefe y formador. Durante casi dos décadas, ha sido MVP de Microsoft, y director regional de Microsoft desde 2007. Michaelis trabaja con varios equipos de revisión de diseño de software de Microsoft, como C#, Microsoft Azure, SharePoint y Visual Studio ALM. Hace presentaciones en conferencias de desarrolladores y escribió varios libros, el más reciente de los cuales es “Essential C# 6.0 (5th Edition)” (itl.tc/EssentialCSharp). Póngase en contacto con él en Facebook en facebook.com/Mark.Michaelis, en su blog IntelliTect.com/Mark, en Twitter @markmichaelis o a través de la dirección de correo electrónico mark@IntelliTect.com.

Gracias al siguiente experto técnico de Microsoft por su ayuda en la revisión de este artículo: Mads Torgersen


Discuta sobre este artículo en el foro de MSDN Magazine