Febrero de 2018

Volumen 33, número 2

Essential .NET: C# 8.0 y tipos de referencia que aceptan valores NULL

Por Mark Michaelis | Febrero de 2018

Tipos de referencia que aceptan valores NULL... ¿qué? ¿No aceptan valores NULL todos los tipos de referencia? 

Me encanta C# y me parece fantástico el cuidado diseño del lenguaje. Sin embargo, tal como está actualmente e incluso después de 7 versiones de C#, seguimos sin tener un lenguaje perfecto. Con esto quiero decir que, aunque es razonable esperar que siempre existan nuevas características para agregar a C#, lamentablemente también habrá algunos problemas. Por problemas, no me refiero a errores, sino a problemas fundamentales. Quizás una de las principales áreas problemáticas, presente desde la versión de C# 1.0, esté relacionada con el hecho de que los tipos de referencia puedan ser nulos y, de hecho, lo son de manera predeterminada. A continuación se indican algunos de los motivos por los que los tipos de referencia que aceptan valores NULL no son del todo ideales:

  • La invocación de un miembro en un valor NULL emitirá una excepción System.NullReferenceException y cada invocación que resulte en System.NullReferenceException en el código de producción será un error. Lamentablemente, con los tipos de referencia que aceptan valores NULL, nos alineamos para hacer lo incorrecto en lugar de lo correcto. La acción de "alineación" consiste en invocar un tipo de referencia sin comprobar si acepta valores NULL.
  • Existe una incoherencia entre los tipos de referencia y los tipos de valor (siguiendo la introducción de Nullable<T>) en que los tipos de valor aceptan valores NULL cuando se adornan con "?" (por ejemplo, int? number); de lo contrario, no aceptan valores NULL de manera predeterminada. En cambio, los tipos de referencia aceptan valores NULL de manera predeterminada. Esto es "normal" para aquellos de nosotros que llevamos mucho tiempo programando con C#, pero si pudiésemos hacerlo todo de nuevo, querríamos que los tipos de referencia no aceptasen valores NULL de manera predeterminada y que la adición de "?" fuese una manera explícita de permitir valores NULL.
  • No es posible ejecutar el análisis de flujo estático para comprobar todas las rutas relacionadas con el hecho de que un valor pueda ser NULL antes de desreferenciarlo. Considere, por ejemplo, si había invocaciones de código no administrado, subprocesamiento múltiple o asignación/reemplazo de valores NULL en función de las condiciones del runtime. (Sin mencionar si el análisis incluiría la comprobación de todas las API de biblioteca invocadas).
  • No existe ninguna sintaxis razonable para indicar que un valor de tipo de referencia NULL no sea válido para una declaración concreta.
  • No existe ninguna manera de adornar parámetros para que no permitan valores NULL.

Como ya dije, a pesar de todo esto, me encanta C# hasta el punto de que solo acepto el comportamiento de NULL como una idiosincrasia de C#. Sin embargo, con C# 8.0, el equipo del lenguaje C# tiene la intención de mejorarlo. Específicamente, sus objetivos son los siguientes:

  • Proporcionar una sintaxis que espere valores NULL: permitir que el desarrollador identifique explícitamente cuándo se espera que un tipo de referencia contenga valores NULL y, por tanto, no marcar las ocasiones en que tenga asignado un valor NULL de manera explícita.
  • Hacer que los tipos de referencia predeterminados no admitan valores NULL: cambiar la expectativa predeterminada de que ningún tipo de referencia acepte valores NULL, pero hacerlo con un modificador de compilador de participación, en lugar de abrumar de repente al desarrollador con advertencias sobre el código existente.
  • Reducir la repetición de excepciones NullReferenceException: reducir la probabilidad de que se produzcan excepciones NullReferenceException mejorando el análisis de flujo estático que marca las ocasiones potenciales en que no se ha comprobado si un valor acepta valores NULL de manera explícita antes de invocar uno de los miembros del valor.
  • Habilitar la supresión de la advertencia de análisis de flujo estático: admitir alguna declaración de tipo "confíe en mí, soy programador" que permita al desarrollador invalidar el análisis de flujo estático del compilador y, por tanto, suprimir cualquier advertencia de posibles excepciones NullReferenceException.

En lo que queda del artículo, valoraremos cada uno de estos objetivos y cómo C# 8.0 implementa la compatibilidad básica con estos en el lenguaje C#.

Proporcionar la sintaxis que espere valores NULL

Para empezar, debe haber una sintaxis para distinguir cuándo un tipo de referencia debe esperar NULL y cuándo no. La sintaxis obvia para permitir valores NULL implica usar ? como declaración que acepta valores NULL (tanto para un tipo de valor como para un tipo de referencia). Al incluir compatibilidad en los tipos de referencia, el desarrollador cuenta con una posibilidad de participación para NULL, por ejemplo, con la sintaxis:

string? text = null;

La adición de esta sintaxis explica por qué la mejora crítica de aceptación de valores NULL se resume con el nombre aparentemente confuso "tipos de referencia que aceptan valores NULL". El motivo es que existe algún nuevo tipo de datos de referencia que acepta valores NULL, pero ahora se admite de manera explícita la participación para dicho tipo de datos.

Dada la sintaxis de los tipos de referencia que aceptan valores NULL, ¿en qué consiste la sintaxis de los tipos de referencia que no aceptan valores NULL?  Mientras que la sintaxis siguiente:

string! text = "Inigo Montoya"

puede parecer una decisión acertada, introduce la incógnita de qué se entiende por simplemente:

string text = GetText();

¿Nos quedan tres declaraciones, que son tipos de referencias que aceptan valores NULL, tipos de referencias que no aceptan valores NULL y tipos de referencia que no lo sé? ¡No!

En su lugar, lo que realmente queremos es lo siguiente:

  • Tipos de referencia que aceptan valores NULL: string? text = null;
  • Tipos de referencia que no aceptan valores NULL: string text = "Inigo Montoya"

Por supuesto, esto implica un cambio radical de lenguaje, de modo que esos tipos de referencia sin modificador no acepten valores NULL de manera predeterminada.

Hacer que los tipos de referencia predeterminados no admitan valores NULL

El cambio de las declaraciones de referencia estándar (ningún modificador acepta valores NULL) para que no acepten valores NULL es quizás el más difícil de todos los requisitos para reducir la idiosincrasia de la aceptación de valores NULL. La verdad es que hoy, string text; produce un tipo de referencia denominado textual que permite que el texto sea NULL, espera que el texto sea NULL y, de hecho, establece el texto de manera predeterminada para que sea NULL en muchos casos, como con un campo o una matriz. No obstante, como sucede con los tipos de valor, los tipos de referencia que permiten valores NULL deberían ser la excepción, no el valor predeterminado. Sería preferible que, cuando se asigne NULL al texto o no se pueda inicializar texto en algo distinto de NULL, el compilador marcara cualquier desreferencia de la variable de texto (el compilador ya marca las desreferencias de una variable local antes de que se inicialice).

Lamentablemente, esto implica cambiar el lenguaje y emitir una advertencia al asignar NULL (string text = null, por ejemplo) o un tipo de referencia que acepta valores NULL (como string? text = null; string moreText = text;). El primero de estos (string text = null) es un cambio revolucionario. (Emitir una advertencia de algo que anteriormente no generaba ninguna es un cambio revolucionario).  Para evitar abrumar a los desarrolladores con advertencias cuando comienzan a usar el compilador de C# 8.0, la compatibilidad de la nulabilidad estará desactivada de manera predeterminada, por lo que no se dará ningún cambio revolucionario. Por tanto, para aprovecharlo, deberá participar mediante la activación de la característica. (No obstante, tenga en cuenta que en la versión preliminar disponible en el momento de redactar este artículo, itl.tc/csnrtp, la nulabilidad está activada de forma predeterminada).

Por supuesto, una vez que la característica esté habilitada, aparecerán las advertencias y le presentarán la opción. Elija explícitamente si la intención del tipo de referencia es permitir valores NULL. De lo contrario, quite la asignación de valores NULL para quitar la advertencia. No obstante, esta acción puede introducir una advertencia más adelante, ya que la variable no está asignada y tendrá que asignarle un valor que no sea NULL. De manera alternativa, si se desea NULL explícitamente (que, por ejemplo, represente "desconocido"), cambie el tipo de declaración para que acepte valores NULL, como en:

string? text = null;

Reducir la repetición de excepciones NullReferenceException

Dado un método para declarar tipos como tipos que aceptan valores NULL o que no aceptan estos valores, es responsabilidad del análisis de flujo estático del compilador determinar si se produce una posible infracción de la declaración. Aunque podrá tanto declarar un tipo de referencia que acepta valores NULL como evitar una asignación de valores NULL a un tipo que no acepta estos valores, es posible que aparezcan nuevos errores o advertencias en el código más adelante. Como ya mencioné, los tipos de referencia que no aceptan valores NULL provocarán un error más adelante en el código si la variable local nunca se asigna (esto era cierto para las variables locales antes de C# 8.0). En cambio, el análisis de flujo estático marcará cualquier invocación de desreferencia de un tipo que acepta valores NULL para el que no puede detectar una comprobación anterior de valores NULL ni ninguna asignación del valor que acepta valores NULL a un valor que no sea NULL. En la Figura 1 se muestran algunos ejemplos.

Figura 1 Ejemplos de resultados de análisis de flujo estático

string text1 = null;
// Warning: Cannot convert null to non-nullable reference
string? text2 = null;
string text3 = text2;
// Warning: Possible null reference assignment
Console.WriteLine( text2.Length ); 
// Warning: Possible dereference of a null reference
if(text2 != null) { Console.WriteLine( text2.Length); }
// Allowed given check for null

De cualquier modo, el resultado final es una reducción de posibles excepciones NullReference­Exception usando el análisis de flujo estático para comprobar una intención que acepte valores NULL.

Como se explicó anteriormente, el análisis de flujo estático debería marcar los casos en que se asignará NULL a un tipo que no acepta estos valores, ya sea directamente o cuando se le asigne un tipo que acepte valores NULL. Lamentablemente, no es una medida infalible. Por ejemplo, si un método declara que devuelve un tipo de referencia que no acepta valores NULL (quizás una biblioteca que aún no está actualizada con modificadores de nulabilidad) o uno que devuelve NULL por error (quizás se ignoró una advertencia), o bien se produce una excepción no fatal y una asignación prevista no se ejecuta, un tipo de referencia que no acepte valores NULL podría terminar con un valor NULL. Es desafortunado, pero la compatibilidad de los tipos de referencia que aceptan valores NULL debería reducir la probabilidad de iniciar una excepción NullReferenceException, aunque no la eliminaría. (Esto es análogo a la falibilidad de la comprobación del compilador cuando se asigna una variable). De manera similar, el análisis de flujo estático no siempre reconocerá que, de hecho, el código comprueba la existencia de valores NULL antes de desreferenciar un valor. De hecho, el análisis de flujo solo comprueba la nulabilidad en el cuerpo de locales y parámetros de un método, y aprovecha las firmas de método y operador para determinar la validez. Por ejemplo, no indaga en el cuerpo de un método denominado IsNullOrEmpty para ejecutar un análisis basado en si el método comprueba correctamente la presencia de valores NULL, de modo que no se requiera ninguna comprobación adicional de estos valores.

Habilitar la supresión de la advertencia de análisis de flujo estático

Dada la posible falibilidad del análisis de flujo estático, ¿qué sucede si el compilador no reconoce la comprobación de valores NULL (quizás con una llamada como object.ReferenceEquals(s, null) o string.IsNullOrEmpty())? Si el programador sabe que un valor no va a ser NULL, puede desreferenciarlo siguiendo el operador ! (por ejemplo, text!) como en:

string? text;...
if(object.ReferenceEquals(text, null))
{  var type = text!.GetType()
}

Sin el signo de exclamación, el compilador advertirá de una posible invocación de valores NULL. De manera similar, al asignar un valor que acepte valores NULL a un valor que no los acepte, puede adornar el valor asignado con un signo de exclamación para informar al compilador de que, como programador, sabe lo siguiente:

string moreText = text!;

De este modo, puede invalidar el análisis de flujo estático del mismo modo que puede usar una conversión explícita. Por supuesto, en el runtime se seguirá produciendo la debida comprobación.

Resumen

Con la introducción del modificador de nulabilidad para los tipos de referencia no se introduce un nuevo tipo. Los tipos de referencia siguen aceptando valores NULL y la compilación de string? produce lenguaje intermedio (IL) que sigue siendo solo System.String. La diferencia en el nivel de IL es el adorno de tipos modificados que aceptan valores NULL con un atributo de:

System.Runtime.CompilerServices.NullableAttribute

Al hacerlo, las compilaciones de bajada pueden seguir usando la intención declarada. Además, suponiendo que el atributo esté disponible, las versiones anteriores de C# pueden seguir haciendo referencia a las bibliotecas compiladas con C# 8.0, aunque sin ninguna mejora de nulabilidad. Y lo más importante, esto significa que las API existentes (como la API de .NET) se pueden actualizar con metadatos que acepten valores NULL sin ninguna interrupción en la API. Además, esto indica que no se admite la sobrecarga basada en el modificador de nulabilidad.

Existe una consecuencia desafortunada para la mejora del control de valores NULL en C# 8.0. La transición de declaraciones que tradicionalmente aceptan valores NULL a declaraciones que no acepten estos valores introducirá inicialmente una cantidad de advertencias considerable. Aunque es una consecuencia desafortunada, creo que se mantiene un equilibrio razonable entre la irritación y la mejora del código propio:

  • Advertirle que quite una asignación de valores NULL a un tipo que no acepta valores NULL puede eliminar un error, ya que un valor ya no es NULL cuando debería serlo.
  • De manera alternativa, agregar un modificador que acepta valores NULL mejora el código, ya que es más explícito en cuanto a su intención.
  • Con el tiempo, el error de coincidencia de impedancia entre el código actualizado que acepta valores NULL y el código más antiguo se disolverá y se reducirán así los errores NullReferenceException que suelen ocurrir.
  • La característica de nulabilidad está desactivada de manera predeterminada en los proyectos existentes, de modo que puede retrasar ocuparse de ella hasta el momento que elija. Al final tendrá código más sólido. En aquellos casos en que tenga más conocimientos que el compilador, puede usar el operador ! (que declara "Confíe en mí, soy programador") como una conversión.

Mejoras adicionales de C# 8.0

Existen otras tres áreas de mejora principales a tener en cuenta para C# 8.0:

Flujos asincrónicos: la compatibilidad de los flujos asincrónicos permite esperar que la sintaxis se itere sobre una colección de tareas (Task<bool>). Por ejemplo, puede invocar

foreach await (var data in asyncStream)

y el subproceso no bloqueará ninguna instrucción tras la espera, sino que "continuará con" ellas hasta que se complete la iteración. El iterador suspenderá el elemento siguiente a petición (la solicitud es una invocación de Task<bool> MoveNextAsync en el iterador del flujo enumerable) seguido de una llamada a T Current { get; }.

Implementaciones de interfaces predeterminadas: con C#, puede implementar varias interfaces, de modo que se hereden las firmas de cada interfaz. Además, es posible proporcionar una implementación de miembro en una clase base para que todas las clases derivadas tengan una implementación predeterminada del miembro. Lamentablemente, no se pueden implementar varias interfaces ni proporcionar implementaciones predeterminadas de la interfaz, es decir, una herencia múltiple. Con la introducción de las implementaciones de interfaces predeterminadas, se supera la restricción. Suponiendo que se pueda realizar una implementación predeterminada razonable, con C# 8.0 podrá incluir una implementación de miembro predeterminado (solo propiedades y métodos) y todas las clases que implementen la interfaz tendrán una interfaz predeterminada. Mientras que la herencia múltiple puede ser una ventaja adicional, la mejora real que proporciona es la posibilidad de extender las interfaces con miembros adicionales sin introducir ningún cambio de API revolucionario. Por ejemplo, podría agregar un método Count a IEnumerator<T> (aunque para implementarlo requeriría volver a iterar todos los elementos de la colección) sin la interrupción de todas las clases que implementaron la interfaz. Tenga en cuenta que esta característica requiere una versión de marco correspondiente (algo que no era necesario desde C# 2.0 y genéricos).

Extensión de todo: con LINQ se introdujeron los métodos de extensión. Recuerdo una cena con Anders Hejlsberg por aquel entonces y preguntar por otros tipos de extensión, como las propiedades. Hejlsberg me explicó que el equipo solo estaba considerando cuáles eran las necesidades para implementar LINQ. Ahora, 10 años más tarde, ese supuesto se está volviendo a evaluar y se está considerando la posibilidad de agregar métodos de extensión, no solo para propiedades, sino también para eventos, operadores y, posiblemente, constructores (estos últimos facilitan algunas implementaciones de modelos predeterminados interesantes). El aspecto importante a tener en cuenta, especialmente en cuanto a las propiedades, es que los métodos de extensión se implementan en clases estáticas y, por tanto, no existe ningún estado de instancia adicional para el tipo extendido introducido. Si necesitase dicho estado, debería almacenarlo en una colección indexada por la instancia del tipo extendido, con la finalidad de recuperar el estado asociado.


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. Realiza ponencias en conferencias de desarrolladores y ha escrito varios libros, el más reciente de los cuales es "Essential C# 7.0 (6th 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 de Microsoft por revisar este artículo: Kevin Bost, Grant Ericson, Tom Faust y Mads Torgersen


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