Connect(); 2016
Volumen 31, número 12
.NET Framework: novedades de C# 7.0
Por Mark Michaelis
En diciembre de 2015, me centré en el diseño de C# 7.0 (msdn.com/magazine/mt595758). Desde el último año han cambiado muchas cosas, pero ahora el equipo está puliendo los últimos detalles en el desarrollo de C# 7.0, y Visual Studio 2017 Release Candidate implementará prácticamente todas las nuevas características. (Digo prácticamente porque, en realidad, hasta que no se publique Visual Studio 2017, siempre hay posibilidades de que se produzcan cambios). Para obtener una introducción breve, puede echar un vistazo a la tabla de resumen en itl.tc/CSharp7FeatureSummary. En este artículo, exploraré cada una de las nuevas características detalladamente.
Deconstructores
Desde C# 1.0, ha sido posible llamar a una función (el constructor) que combina los parámetros y los encapsula en una clase. Sin embargo, nunca ha existido una manera cómoda de deconstruir el objeto de nuevo en cada una de sus partes. Por ejemplo, imagine una clase PathInfo que toma todos los elementos de un nombre de archivo (nombre de directorio, nombre de archivo, extensión) y los combina en un objeto, con compatibilidad para manipular posteriormente los diversos elementos del objeto. Ahora, imagínese que quiere extraer (deconstruir) el objeto en cada una de sus partes.
En C# 7.0, este proceso pasa a ser sencillo gracias al deconstructor, que devuelve los componentes identificados específicamente del objeto. Tenga cuidado de no confundir un deconstructor con un destructor (desasignación y limpieza de un objeto determinista) o un finalizador (itl.tc/CSharpFinalizers).
Eche un vistazo a la clase PathInfo en la Figura 1.
Figura 1 Clase PathInfo con un deconstructor con pruebas asociadas
public class PathInfo
{
public string DirectoryName { get; }
public string FileName { get; }
public string Extension { get; }
public string Path
{
get
{
return System.IO.Path.Combine(
DirectoryName, FileName, Extension);
}
}
public PathInfo(string path)
{
DirectoryName = System.IO.Path.GetDirectoryName(path);
FileName = System.IO.Path.GetFileNameWithoutExtension(path);
Extension = System.IO.Path.GetExtension(path);
}
public void Deconstruct(
out string directoryName, out string fileName, out string extension)
{
directoryName = DirectoryName;
fileName = FileName;
extension = Extension;
}
// ...
}
Evidentemente, puede llamar al método Deconstruct como lo haría en C# 1.0. Sin embargo, C# 7.0 ofrece el azúcar sintáctico que simplifica de forma considerable la invocación. Dada la declaración de un deconstructor, puede invocarlo mediante una nueva sintaxis de tipo tupla de C# 7.0 (vea la Figura 2).
Figura 2 Invocación y asignación de deconstructor
PathInfo pathInfo = new PathInfo(@"\\test\unc\path\to\something.ext");
{
// Example 1: Deconstructing declaration and assignment.
(string directoryName, string fileName, string extension) = pathInfo;
VerifyExpectedValue(directoryName, fileName, extension);
}
{
string directoryName, fileName, extension = null;
// Example 2: Deconstructing assignment.
(directoryName, fileName, extension) = pathInfo;
VerifyExpectedValue(directoryName, fileName, extension);
}
{
// Example 3: Deconstructing declaration and assignment with var.
var (directoryName, fileName, extension) = pathInfo;
VerifyExpectedValue(directoryName, fileName, extension);
}
Observe cómo, por primera vez, C# permite la asignación simultánea a varias variables de distintos valores. No es lo mismo que la declaración de asignación nula, en la que todas las variables se inicializan en el mismo valor (null):
string directoryName, filename, extension = null;
En su lugar, con la nueva sintaxis de tipo tupla, a cada variable se asigna un valor distinto que no corresponde a su nombre, sino al orden en el que aparece en la declaración y la instrucción de deconstrucción.
Tal como cabe esperar, el tipo de los parámetros out debe coincidir con el tipo de variables que se asignan, y la variable se permite porque el tipo se puede inferir de los tipos de parámetro Deconstruct. Sin embargo, observe que mientras puede colocar una sola variable fuera de los paréntesis, según se muestra en el ejemplo 3 de la Figura 2, en este momento no es posible extraer una cadena, aunque todas las variables sean del mismo tipo.
Tenga en cuenta que, en este momento, la sintaxis de tipo tupla de C# 7.0 requiere, como mínimo, que aparezcan dos variables dentro de los paréntesis. Por ejemplo, (FileInfo path) = pathInfo; no se permite aunque exista un deconstructor para:
public void Deconstruct(out FileInfo file)
Es decir, no puede usar la sintaxis del deconstructor de C# 7.0 para los métodos Deconstruct con solo un parámetro out.
Trabajo con tuplas
Tal y como he mencionado, en los ejemplos anteriores se aprovechaba la sintaxis de tipo tupla de C# 7.0. La sintaxis se caracteriza por los paréntesis que rodean las diversas variables (o propiedades) que están asignadasn. Uso el término "tipo tupla" porque, de hecho, ninguno de estos ejemplos de deconstructores aprovecha ningún tipo de tupla internamente. (En realidad, la asignación de tuplas a través de una sintaxis de deconstructor no está permitida y se podría decir que es algo que no es necesario porque el objeto asignado ya es una instancia que representa las partes constituyentes encapsuladas).
Con C# 7.0, ahora hay una sintaxis simplificada especial para trabajar con tuplas, tal y como se muestra en la Figura 3. Esta sintaxis se puede usar siempre que se permita un especificador de tipo, incluidas las declaraciones, los operadores de conversión y los parámetros de tipo.
Figura 3 Declaración, creación de instancias y uso de la sintaxis de tupla de C# 7.0
[TestMethod]
public void Constructor_CreateTuple()
{
(string DirectoryName, string FileName, string Extension) pathData =
(DirectoryName: @"\\test\unc\path\to",
FileName: "something",
Extension: ".ext");
Assert.AreEqual<string>(
@"\\test\unc\path\to", pathData.DirectoryName);
Assert.AreEqual<string>(
"something", pathData.FileName);
Assert.AreEqual<string>(
".ext", pathData.Extension);
Assert.AreEqual<(string DirectoryName, string FileName, string Extension)>(
(DirectoryName: @"\\test\unc\path\to",
FileName: "something", Extension: ".ext"),
(pathData));
Assert.AreEqual<(string DirectoryName, string FileName, string Extension)>(
(@"\\test\unc\path\to", "something", ".ext"),
(pathData));
Assert.AreEqual<(string, string, string)>(
(@"\\test\unc\path\to", "something", ".ext"), (pathData));
Assert.AreEqual<Type>(
typeof(ValueTuple<string, string, string>), pathData.GetType());
}
[TestMethod]
public void ValueTuple_GivenNamedTuple_ItemXHasSameValuesAsNames()
{
var normalizedPath =
(DirectoryName: @"\\test\unc\path\to", FileName: "something",
Extension: ".ext");
Assert.AreEqual<string>(normalizedPath.Item1, normalizedPath.DirectoryName);
Assert.AreEqual<string>(normalizedPath.Item2, normalizedPath.FileName);
Assert.AreEqual<string>(normalizedPath.Item3, normalizedPath.Extension);
}
static public (string DirectoryName, string FileName, string Extension)
SplitPath(string path)
{
// See http://bit.ly/2dmJIMm Normalize method for full implementation.
return (
System.IO.Path.GetDirectoryName(path),
System.IO.Path.GetFileNameWithoutExtension(path),
System.IO.Path.GetExtension(path)
);
}
Para aquellos que no están familiarizados con las tuplas, son una forma de combinar varios tipos en un solo tipo contenedor en una sintaxis ligera que está disponible fuera del método en el que se crea una instancia. Esto es ligero porque, en lugar de definir una estructura o clase, las tuplas pueden "declararse" insertadas y de forma dinámica. Sin embargo, a diferencia de los tipos dinámicos que también admiten la creación de instancias y la declaración insertada, se puede acceder a las tuplas fuera de su miembro contenedor y, de hecho, se pueden incluir como parte de una API. A pesar de la compatibilidad con API externas, las tuplas no tienen ningún medio de extensión compatible con la versión (a menos que los parámetros de tipo admitan la derivación), por lo tanto, deberían usarse con precaución en API públicas. Por este motivo, una opción mejor sería usar una clase estándar para la devolución en una API pública.
Antes de C# 7.0, el marco ya tenía una clase de tupla System.Tuple<…> (introducida en Microsoft .NET Framework 4). Sin embargo, C# 7.0 es diferente de la versión anterior, porque inserta la intención semántica en la declaración e introduce un tipo de valor de tupla: System.ValueTuple<…>.
Analicemos el intento semántico. Observe que, en la Figura 3, la sintaxis de la tupla de C# 7.0 le permite declarar nombres de alias para cada elemento ItemX que contiene la tupla. La instancia de la tupla pathData de la Figura 3, por ejemplo, tiene las propiedades definidas fuertemente tipadas DirectoryName: string, FileName: string, y Extension: string, cosa que, por ejemplo, permite realizar llamadas a pathData.DirectoryName. Esta es una mejora considerable porque, en la versión anterior a C# 7.0, los únicos nombres disponibles eran nombres ItemX, en los que la X aumentaba para cada elemento.
Ahora, mientras los elementos de una tupla de C# 7.0 están fuertemente tipados, los nombres en sí no son distintivos en la definición de tipo. Por lo tanto, puede asignar dos tuplas con alias de nombre diversos y lo único que obtendrá será una advertencia que le indicará que el nombre de la derecha se ignorará:
// Warning: The tuple element name 'AltDirectoryName1' is ignored
// because a different name is specified by the target type...
(string DirectoryName, string FileName, string Extension) pathData =
(AltDirectoryName1: @"\\test\unc\path\to",
FileName: "something", Extension: ".ext");
De manera similar, puede asignar tuplas a otras tuplas que puede que no tengan el nombre de elemento de alias definido:
// Warning: The tuple element name 'directoryName', 'FileNAme' and 'Extension'
// are ignored because a different name is specified by the target type...
(string, string, string) pathData =
(DirectoryName: @"\\test\unc\path\to", FileName: "something", Extension: ".ext");
Para ser claro, el tipo y el orden de cada elemento define la compatibilidad del tipo. Solo se ignoran los nombres de elementos. Sin embargo, aunque se ignoren cuando son diferentes, siguen proporcionando IntelliSense dentro del IDE.
Tenga en cuenta que, independientemente de que un alias de nombre de elemento esté definido, todas las tuplas tienen nombres ItemX, en los que la X corresponde al número del elemento. Los nombres ItemX son importantes porque hacen que las tuplas estén disponibles desde C# 6.0, aunque los nombres de elemento de alias no lo estén.
Otro punto importante que se tiene que tener en cuenta es que el tipo de tupla subyacente de C# 7.0 es System.ValueTuple. Si no hay ningún tipo así disponible en la versión de marco en la que realiza la compilación, puede acceder a él a través de un paquete NuGet.
Para obtener detalles sobre el funcionamiento interno de las tuplas, consulte intellitect.com/csharp7tupleiinternals.
Coincidencia de patrones con expresiones Is
En ocasiones, tendrá una clase base, por ejemplo, Storage, y una serie de clases derivadas, DVD, UsbKey, HardDrive, FloppyDrive (¿se acuerda de ellos?), etc. Para implementar un método Eject para cada una, tiene varias opciones:
- Operador as
- Convertir y asignar con el operador as
- Comprobar el resultado para NULL
- Realizar la operación de expulsión
- Operador is
- Comprobar el tipo que usa el operador is
- Convertir y asignar el tipo
- Realizar la operación de expulsión
- Cast
- Convertir y asignar de forma explícita
- Capturar excepciones posibles
- Realizar la operación
- ¡Puaj!
Hay un cuarto método mucho mejor que usa polimorfismo en el que se realizan distribuciones mediante funciones virtuales. Sin embargo, solo está disponible si tiene código fuente para la clase Storage y puede agregar el método Eject. Es una opción que supongo que no está disponible para este tema, por este motivo, es necesaria la coincidencia de patrones.
El problema de todos estos métodos es que la sintaxis es bastante detallada y siempre requiere varias instrucciones para cada clase que se quiere convertir. C# 7.0 ofrece la coincidencia de patrones como mecanismo para combinar la prueba y la asignación en una sola operación. Como resultado, el código de la Figura 4 se simplifica según lo que se muestra en la Figura 5.
Figura 4 Conversión de tipos sin coincidencia de patrones
// Eject without pattern matching.
public void Eject(Storage storage)
{
if (storage == null)
{
throw new ArgumentNullException();
}
if (storage is UsbKey)
{
UsbKey usbKey = (UsbKey)storage;
if (usbKey.IsPluggedIn)
{
usbKey.Unload();
Console.WriteLine("USB Drive Unloaded.");
}
else throw new NotImplementedException(); }
else if(storage is DVD)
// ...
else throw new NotImplementedException();
}
Figura 5 Conversión de tipos con coincidencia de patrones
// Eject with pattern matching.
public void Eject(Storage storage)
{
if (storage is null)
{
throw new ArgumentNullException();
}
if ((storage is UsbKey usbDrive) && usbDrive.IsPluggedIn)
{
usbDrive.Unload();
Console.WriteLine("USB Drive Unloaded.");
}
else if (storage is DVD dvd && dvd.IsInserted)
// ...
else throw new NotImplementedException(); // Default
}
La diferencia entre las dos no es muy considerable, pero cuando se realiza de forma frecuente (para todos los tipos derivados, por ejemplo), la sintaxis anterior resulta en una idiosincrasia molesta de C#. La mejora de C# 7.0 (la combinación de la asignación, la declaración y la prueba de tipo en una única operación) deja la sintaxis anterior prácticamente en desuso. En la sintaxis anterior, si comprueba el tipo sin asignar ningún identificador, solo conseguirá, en el mejor de los casos, que el "valor predeterminado" sea aún más engorroso. En cambio, la asignación permite condiciones adicionales que van más allá de la simple comprobación de tipos.
Tenga en cuenta que el código de la Figura 5 empieza con un operador is de coincidencia de patrones con compatibilidad con un operador de comparación null:
if (storage is null) { ... }
Coincidencia de patrones con la instrucción Switch
Mientras que la compatibilidad de la coincidencia de patrones con el operador supone una mejora, se puede decir que la compatibilidad de la coincidencia de patrones con una instrucción Switch es aún más significativa, como mínimo, cuando hay varios tipos compatibles a los que realizar la conversión. Esto es debido a que C# 7.0 incluye instrucciones case con coincidencia de patrones y, además, si el patrón de tipo está conforme en la instrucción ase, se puede proporcionar y asignar un identificador, así como obtener acceso a él, en toda la instrucción case. En la Figura 6 se proporciona un ejemplo.
Figura 6 Coincidencia de patrones en una instrucción Switch
public void Eject(Storage storage)
{
switch(storage)
{
case UsbKey usbKey when usbKey.IsPluggedIn:
usbKey.Unload();
Console.WriteLine("USB Drive Unloaded.");
break;
case DVD dvd when dvd.IsInserted:
dvd.Eject();
break;
case HardDrive hardDrive:
throw new InvalidOperationException();
case null:
default:
throw new ArgumentNullException();
}
}
Tenga en cuenta en el ejemplo cómo las variables locales como usbKey y dvd se declaran y se asignan automáticamente dentro de la instrucción case. Asimismo, como cabe esperar, el ámbito está limitado a la instrucción case.
Tal vez, la condición adicional que se puede anexar en la instrucción case con una cláusula when es tan importante como la asignación y la declaración de variable. El resultado es que una instrucción case puede filtrar por completo un escenario no válido sin ningún filtro adicional dentro de la instrucción case. Esto tiene la ventaja añadida de la evaluación posterior de la siguiente instrucción case si realmente no se cumple por completo la instrucción case anterior. También significa que las instrucciones case ya no están limitadas a las constantes y, además, una expresión switch puede ser de cualquier tipo (ya no está limitada a un booleano, char, cadena, integral o enumeración).
Otra característica importante que presenta la nueva funcionalidad de la instrucción switch de coincidencia de patrones de C# 7.0 es que el orden de la instrucción case es significativo y se valida en tiempo de compilación. (Esto contrasta con las versiones anteriores del lenguaje, en las que, sin la coincidencia de patrones, el orden de la instrucción case no era significativo). Por ejemplo, si introdujera una instrucción case para Storage antes de una instrucción case de coincidencia de patrones que se derive de Storage (UsbKey, DVD y HardDrive), case Storage eclipsaría todas las otras coincidencias de patrones de tipo (que derivaran de Storage). Una instrucción case de un tipo base que eclipsa las otras instrucciones case de tipo derivado de la evaluación dará lugar a un error de compilación en la instrucción case eclipsada. De esta forma, los requisitos de orden de la instrucción case son similares a las instrucciones catch.
Los lectores recordarán que un operador is en un valor null devolverá false. Por lo tanto, ninguna instrucción case de coincidencia de patrones de tipo coincidirá con una expresión switch con valor null. Por este motivo, el orden de la instrucción case null no será importante; este comportamiento coincide con las instrucciones switch anteriores a la coincidencia de patrones.
Además, para respaldar la compatibilidad con las instrucciones switch anteriores a C# 7.0, la instrucción case predeterminada es la última que se evalúa independientemente del lugar en que aparezca en el orden de instrucciones case. (Dicho esto, por motivos de legibilidad, en general sería mejor colocarla al final porque siempre es la última que se evalúa). Además, las instrucciones goto case aún funcionan solo para las etiquetas constant case y no para la coincidencia de patrones.
Funciones locales
Aunque ya es posible declarar un delegado y asignarle una expresión, C# 7.0 lo lleva un paso más allá al permitir que la declaración completa de una función local se inserte dentro de otro miembro. Considere la función IsPalindrome de la Figura 7.
Figura 7 Ejemplo de una función local
bool IsPalindrome(string text)
{
if (string.IsNullOrWhiteSpace(text)) return false;
bool LocalIsPalindrome(string target)
{
target = target.Trim(); // Start by removing any surrounding whitespace.
if (target.Length <= 1) return true;
else
{
return char.ToLower(target[0]) ==
char.ToLower(target[target.Length - 1]) &&
LocalIsPalindrome(
target.Substring(1, target.Length - 2));
}
}
return LocalIsPalindrome(text);
}
En esta implementación, primero compruebo que el argumento pasado a IsPalindrome no sea null o solo un espacio en blanco. (Podría haber usado la coincidencia de patrones con "text is null" para la comprobación null). A continuación, declaro una función LocalIsPalindrome en la que comparo el primer y el último carácter recursivamente. La ventaja de este enfoque es que no declaro LocalIsPalindrome dentro del ámbito de la clase en la que posiblemente se puede llamar de manera errónea, por lo tanto, evito la comprobación IsNullOrWhiteSpace. En otras palabras, las funciones locales ofrecen una restricción de ámbito adicional, pero solo dentro de la función adyacente.
El escenario de validación de parámetros de la Figura 7 es uno de los casos de uso comunes de la función local. Otro que suelo encontrar a menudo se produce dentro de las pruebas unitarias, por ejemplo, cuando se prueba la función IsPalindrome (vea la Figura 8).
Figura 8 La prueba unitaria usa a menudo las funciones locales
[TestMethod]
public void IsPalindrome_GivenPalindrome_ReturnsTrue()
{
void AssertIsPalindrome(string text)
{
Assert.IsTrue(IsPalindrome(text),
$"'{text}' was not a Palindrome.");
}
AssertIsPalindrome("7");
AssertIsPalindrome("4X4");
AssertIsPalindrome(" tnt");
AssertIsPalindrome("Was it a car or a cat I saw");
AssertIsPalindrome("Never odd or even");
}
Las funciones de iterador que devuelven elementos IEnumerable<T> e yield return son otro caso de uso común de la función local.
Para concluir este tema, a continuación se enumeran más puntos que hay que tener en cuenta para las funciones locales:
- Las funciones locales no permiten el uso de un modificador de accesibilidad (público, privado o protegido).
- Las funciones locales no admiten la sobrecarga. No puede tener dos funciones locales en el mismo método con el mismo nombre aunque las firmas no se superpongan.
- El compilador emitirá una advertencia para las funciones locales que no se invocan nunca.
- Las funciones locales pueden acceder a todas las variables del ámbito envolvente, incluidas las variables locales. Se produce el mismo comportamiento con las expresiones lambda definidas localmente, excepto en que las funciones locales no asignan un objeto que represente el cierre, tal y como hacen las expresiones lambda definidas localmente.
- Las funciones locales se encuentran en el ámbito del método completo, independientemente de si se invocan antes o después su declaración.
Devolución por referencia
Desde C# 1.0, se han podido pasar argumentos en una función por referencia (ref). El resultado es que cualquier cambio en el parámetro en sí se volverá a pasar al autor de llamada. Considere la siguiente función Swap:
static void Swap(ref string x, ref string y)
En este caso, el método llamado puede actualizar las variables originales del autor de llamada con nuevos valores y, por consiguiente, cambiar lo que hay almacenado en el primer y el segundo argumento.
A partir de C# 7.0, también puede pasar una referencia a través de la devolución de funciones (no solo un parámetro ref). Considere, por ejemplo, una función que devuelve el primer píxel en una imagen asociada con un ojo rojo, como se muestra en la Figura 9.
Figura 9 Devolución ref y declaración local Ref
public ref byte FindFirstRedEyePixel(byte[] image)
{
//// Do fancy image detection perhaps with machine learning.
for (int counter = 0; counter < image.Length; counter++)
{
if(image[counter] == (byte)ConsoleColor.Red)
{
return ref image[counter];
}
}
throw new InvalidOperationException("No pixels are red.");
}
[TestMethod]
public void FindFirstRedEyePixel_GivenRedPixels_ReturnFirst()
{
byte[] image;
// Load image.
// ...
// Obtain a reference to the first red pixel.
ref byte redPixel = ref FindFirstRedEyePixel(image);
// Update it to be Black.
redPixel = (byte)ConsoleColor.Black;
Assert.AreEqual<byte>((byte)ConsoleColor.Black, image[redItems[0]]);
}
Al devolver una referencia a la imagen, el autor de llamada puede actualizar el píxel con otro color. La comprobación de la actualización mediante la matriz muestra que el valor ahora es negro. Se podría afirmar que la alternativa al uso de un parámetro por referencia es menos evidente y legible:
public bool FindFirstRedEyePixel(ref byte pixel);
Hay dos restricciones importantes en la devolución por referencia (ambas debido a la duración del objeto). Una es que no se deberían recopilar los elementos no utilizados de las referencias de objetos mientras aún se les hace referencia y, la otra, es que no deberían consumir memoria cuando ya no tienen ninguna referencia. De un lado, solo puede devolver referencias a campos, otras propiedades o funciones de devolución de referencias u objetos que se pasaron como parámetros a la función de devolución por referencia. Por ejemplo, FindFirstRedEyePixel devuelve una referencia a un elemento de la matriz de la imagen que era un parámetro para la función. De manera similar, si la imagen estuviera almacenada como un campo dentro de una clase, podría devolver el campo por referencia:
byte[] _Image;
public ref byte[] Image { get { return ref _Image; } }
De otro lado, los valores ref local se inicializan en una determinada ubicación de almacenamiento en la memoria y no se pueden modificar para apuntar a otra ubicación. (No puede tener un puntero en una referencia y modificar la referencia (un puntero a un puntero para aquellos que tengan formación en C++).
Hay varias de estas características de devolución por referencia que se deben tener en cuenta:
- Si devuelve una referencia, evidentemente la tendrá que devolver. Por lo tanto, esto significa que, en el ejemplo de la Figura 9, aunque no exista ningún píxel de ojo rojo, igualmente debe devolver ref byte. La única solución sería lanzar una excepción. En cambio, el enfoque del parámetro por referencia le permite dejar el parámetro sin modificar y devolver un valor bool que indique que el proceso se realizó correctamente. En muchos casos, esta opción puede ser preferible.
- Al declarar una variable local de referencia, es necesaria la inicialización. Esto implica asignarla a ref return desde una función o referencia a una variable:
ref string text; // Error
- Aunque es posible declarar una variable local de referencia en C# 7.0, no se permite declarar un campo de tipo ref:
class Thing { ref string _Text; /* Error */ }
- No puede declarar un tipo por referencia para una propiedad implementada automáticamente:
class Thing { ref string Text { get;set; } /* Error */ }
- Las propiedades que devuelven una referencia están permitidas:
class Thing { string _Text = "Inigo Montoya";
ref string Text { get { return ref _Text; } } }
- Una variable local de referencia no se puede inicializar con un valor (como null o una constante). Se debe asignar desde un miembro de devolución por referencia o un campo o variable local:
ref int number = null; ref int number = 42; // ERROR
Variables out
Desde la primera versión de C#, la invocación de métodos que contenían parámetros out siempre requería la declaración previa del identificador del argumento out antes de la invocación del método. Sin embargo, C# 7.0 elimina esta idiosincrasia y permite la declaración del argumento out insertada con la invocación del método. En la Figura 10 se muestra un ejemplo.
Figura 10 Declaración insertada de los argumentos out
public long DivideWithRemainder(
long numerator, long denominator, out long remainder)
{
remainder = numerator % denominator;
return (numerator / denominator);
}
[TestMethod]
public void DivideTest()
{
Assert.AreEqual<long>(21,
DivideWithRemainder(42, 2, out long remainder));
Assert.AreEqual<long>(0, remainder);
}
En el método DivideTest, observe cómo la llamada a DivideWithRemainder desde dentro de la prueba incluye un especificador de tipo después del modificador out. Además, vea cómo la parte restante sigue dentro del ámbito del método automáticamente, según lo evidencia la segunda invocación Assert.AreEqual. Bien.
Mejoras literales
A diferencia de las versiones anteriores, C# 7.0 incluye un formato literal binario numérico, según lo demuestra el siguiente ejemplo:
long LargestSquareNumberUsingAllDigits =
0b0010_0100_1000_1111_0110_1101_1100_0010_0100; // 9,814,072,356
long MaxInt64 { get; } =
9_223_372_036_854_775_807; // Equivalent to long.MaxValue
Observe también la compatibilidad con el guión bajo "_" como un separador de dígitos. Simplemente se usa para mejorar la legibilidad y se puede colocar en cualquier sitio entre los dígitos del número: binario, decimal o hexadecimal.
Tipos de valor devueltos asincrónicos generalizados
Cuando se implementa un método asincrónico, puede devolver el resultado de forma sincrónica y aplicar un cortocircuito en una operación de larga ejecución porque el resultado es casi instantáneo o incluso ya se conoce. Considere, por ejemplo, un método asincrónico que determina el tamaño total de los archivos en un directorio (bit.ly/2dExeDG). Si, en realidad, no hay ningún archivo en el directorio, el método puede devolverse inmediatamente sin ejecutar ninguna operación de larga ejecución. Hasta C# 7.0, los requisitos de la sintaxis asincrónica exigían que la devolución de ese método debía ser Task<long> y, por lo tanto, que debía crearse una instancia de una tarea aunque esta no fuese necesaria. (Para lograrlo, el patrón general consiste en devolver el resultado de Task.FromResult<T>).
En C# 7.0, el compilador ya no limita las devoluciones del método asincrónico a void, Task o Task<T>. Ahora puede definir tipos personalizados, como la estructura System.Threading.Tasks.ValueTask<T> proporcionada por .NET Core Framework, que sean compatibles con una devolución de método asincrónica. Consulte itl.tc/GeneralizedAsyncReturnTypes para obtener más información.
Más miembros con forma de expresión
C# 6.0 introdujo miembros con forma de expresión para funciones y propiedades, lo que permitió una sintaxis simplificada para implementar propiedades y métodos triviales. En C# 7.0, las implementaciones con forma de expresión se agregan a constructores, descriptores de acceso (implementaciones de las propiedades get y set) e incluso a finalizadores (vea la Figura 11).
Figura 11 Uso de los miembros con forma de expresión en descriptores de acceso y constructores
class TemporaryFile // Full IDisposible implementation
// left off for elucidation.
{
public TemporaryFile(string fileName) =>
File = new FileInfo(fileName);
~TemporaryFile() => Dispose();
Fileinfo _File;
public FileInfo File
{
get => _File;
private set => _File = value;
}
void Dispose() => File?.Delete();
}
Espero que el uso de los miembros con forma de expresión sea común especialmente para los finalizadores, dado que la implementación más común consiste en llamar al método Dispose, tal y como se muestra.
Me complace anunciar que la compatibilidad adicional con los miembros con forma de expresión la implementó la comunidad de C# en lugar del equipo de C# de Microsoft. ¡Viva el código abierto!
Precaución: Esta característica no está implementada en Visual Studio 2017 RC.
Expresiones throw
La clase temporal de la Figura 11 se puede mejorar para que incluya la validación de parámetros dentro de los miembros con forma de expresión; por lo tanto, puedo actualizar el constructor de la siguiente manera:
public TemporaryFile(string fileName) =>
File = new FileInfo(filename ?? throw new ArgumentNullException());
Sin expresiones throw, la compatibilidad de C# con miembros con forma de expresión no permitiría ninguna validación de parámetros. Sin embargo, con la compatibilidad de C# 7.0 con throw como expresión, no solo como instrucción, es posible realizar informes de errores insertados dentro de expresiones contenedoras más grandes.
Precaución: Esta característica no está implementada en Visual Studio 2017 RC.
Resumen
Admito que cuando empecé a escribir este artículo pensaba que sería más corto. Sin embargo, a medida que dedicaba más tiempo programando y probando las características, descubrí que C# 7.0 era mucho más extenso de lo que pensé tras leer los títulos de las características y seguir el desarrollo del lenguaje. En muchos casos, mediante la declaración de las variables out, las literales binarias, las expresiones throw, etc., no es necesario tener muchos conocimientos para comprender y usar las características. No obstante, varios casos (por ejemplo, la devolución por referencia, los deconstructores y las tuplas) requieren muchos más conocimientos de la característica de lo que uno podría esperar al principio. En estos últimos casos, no solo se trata de la sintaxis, sino también de saber cuándo es pertinente la característica.
C# 7.0 sigue reduciendo la lista de idiosincrasias que disminuye rápidamente (identificadores out predeclarados y la falta de expresiones throw) y, al mismo tiempo, se amplía para incluir compatibilidad con características que anteriormente no se habían visto en el nivel de lenguaje (tuplas y coincidencia de patrones).
Espero que esta introducción le sirva para empezar a programar C# 7.0 inmediatamente. Para obtener más información sobre los desarrollos de C# 7.0 posteriores a este artículo, eche un vistazo a mi blog en intellitect.com/csharp7, así como a una actualización de mi libro "Essential C# 7.0" (está previsto que se publique poco después de lanzar la versión RTM de Visual Studio 2017).
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, siendo el más reciente “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 a los siguientes expertos técnicos por revisar este artículo: Kevin Bost (IntelliTect), Mads Torgersen (Microsoft) y Bill Wagner (Microsoft)