Null Checking en C#
Para ejecutar los ejemplos de código que indico en esta entrada, he utilizado .NET 5 (para soportar C# 9.0), y puedes hacer uso del SDK de .NET 5, Visual Studio Code, Visual Studio 2019 o bien, sharplab.io
Si utilizas este último, recuerda eliminar el Console.ReadKey(); de los ejemplos de código que comparto en esta entrada.
Introduction
El origen de utilizar null tiene nombre y apellidos, Tony Hoare.
Tony, cómo detallo en una de las entradas que indico al final, erró a la hora de inventar en el año 1965 las referencias null como parte del lenguaje de programación ALGOL W.
Muchos lenguajes de programación siguen y han seguido la estela del uso de null. Y entre ellos C#.
En esta entrada voy a hacer un pequeño recorrido por las formas más utilizadas a la hora de evaluar o chequear null en nuestros programas con el fin de reflexionar acerca de cómo escribimos código y de sus consecuencias.
Hay formas de controlar null desde las más a las menos legibles, desde las más a las menos rebuscadas, desde las más y menos cómodas, pero todas ellas, seguramente válidas.
El concepto de null
Lo primero que debemos hacer es preguntarnos por el concepto de null. Porqué existe y cómo enfrentarnos a él.
Muchas aplicaciones fugan en tiempo de ejecución por culpa de null.
Si hay un error a causa de null y estamos buscando el motivo… tu aplicación ha fugado. Así de sencillo.
Y esto sucede con los tipos por referencia, que pueden ser nullables y por lo tanto, su puntero puede apuntar a null.
Teniendo claro este concepto, entenderemos mejor que en otros tipos, null no entra en juego.
¿Es null un problema?
El uso de null no es un problema en sí mismo como tal, sobre todo si tenemos nuestro código bajo control, pero es obvio que somos humanos y podemos errar en un momento dado no cubriendo alguna bifurcación o parte de nuestro código, por lo que entra dentro de lo posible que trabajando con tipos por referencia, se nos pase controlar y chequear la posibilidad de que tratemos convenientemente estos tipos.
Y en consecuencia de todo ello, que se produzca un error en tiempo de ejecución, dándonos una desagradable sorpresa que no será detectada sino hemos cubierto los tests adecuados ni hemos tenido en consideración todas las posibilidades.
En el caso que nos ocupa, que es cuando se produce un problema, lo que sucede es que el valor de puntero nulo (null pointer) o referencia nula (null reference), nos indica que el puntero o referencia no apunta a un objeto válido, produciéndose un error, que en el caso de .NET es una excepción de tipo NullReferenceException.
Si controlamos esta situación, gestionaremos el error, pero lo ideal es actuar de forma proactiva ante estas posibilidades y no dejar que el error ocurra para gestionarlo, lo cuál por su parte, provoca un sobre coste a nuestra aplicación, además de no bifurcar el flujo de ejecución de forma idónea.
Pero todo, dependerá del contexto de nuestra aplicación y nuestros propósitos, porque como siempre digo, no existen balas de plata y a lo mejor nos interesa que ese error suba para que el control de flujo se produzca en un lugar concreto.
Todo depende como siempre.
Ejemplo de null pointer en C#
El siguiente ejemplo, nos demuestra el funcionamiento general de un tipo por referencia, un puntero a null, y su correspondiente excepción de tipo NullReferenceException.
Para ello, voy a crear una clase con una propiedad, voy a asignar esa clase como null, y voy a intentar acceder a la propiedad de la clase, cuya referencia no apunta a ningún objeto válido ya que no ha sido creado y es null.
public class Program { public static void Main(string[] args) { Person person = null; Console.WriteLine(person.Name.Length); Console.WriteLine("Press any key to close"); Console.ReadKey(); } } public class Person { public string Name { get; set; } }
Este pequeño código compila correctamente, y aparentemente está bien realizado, pero al ejecutar este código, se produce una excepción de tipo NullReferenceException como indicaba anteriormente.
Object reference not set to an instance of an object
Esto es en esencia lo que fluye detrás de este comportamiento «oculto» y que puede arruinarnos la vida.
La forma más sencilla de mitigar el problema anterior sería utilizando try – catch de la forma:
using System; public class Program { public static void Main(string[] args) { try { Person person = null; Console.WriteLine(person.Name.Length); Console.WriteLine("Press any key to close"); Console.ReadKey(); } catch (Exception ex) { Console.WriteLine(ex.Message); } } } public class Person { public string Name { get; set; } }
Este código producirá el mismo error anterior, pero controlaremos el error evitando que se produzca un error grave en nuestra aplicación, permitiéndonos de esta manera, continuar la ejecución de nuestra aplicación sin problema.
Pero no nos engañemos, estaremos ocultando o enmascarando la problemática inicial, y se estará produciendo por debajo una excepción que penalizará el rendimiento de nuestra aplicación no comportándose de forma adecuada.
Dicho de otro modo, no estaremos validando o chequeando la posibilidad de que nuestro objeto esté apuntando a null. Y salvo que registremos en el log este tipo de situaciones y lo analicemos para arreglarlo, no nos enteraremos jamás de esto.
Y esto es lo que vamos a hacer a partir de ahora. Recorrer algunas otras formas diferentes que los programadores de C# utilizamos para validar o chequear que nuestro objeto apunta a null o no.
Hay más formas de las que voy a indicar, pero estas son, creo yo, las más comunes y usadas.
Equality Operator ==
La forma básica, clásica, o general de chequear null en un tipo por referencia es utilizando el operador == o también conocido como Equality Operator.
Aquí preguntaremos por nuestro objeto como por ejemplo:
if (person == null) ...
El código completo de nuestro ejemplo, quedaría de la forma:
using System; public class Program { public static void Main(string[] args) { try { Person person = null; //Person person = new Person() { Name = "C#" }; if (person == null) throw new ArgumentNullException(nameof(Person)); Console.WriteLine(person.Name.Length); Console.WriteLine("Press any key to close"); Console.ReadKey(); } catch (Exception ex) { Console.WriteLine(ex.Message); } } } public class Person { public string Name { get; set; } }
is Operator
En C# 7.0, se introdujo el uso del operador is.
Esto nos ayuda a dar mayor legibilidad al código, y en el caso del Equality Operator que hemos visto anteriormente, es más sencillo de interpretar.
Aunque muchos programadores siguen utilizando == por costumbre, cada vez vemos más código escrito con este operador.
El mismo ejemplo anterior con este operador, quedaría de la forma:
using System; public class Program { public static void Main(string[] args) { try { Person person = null; //Person person = new Person() { Name = "C#" }; if (person is null) throw new ArgumentNullException(nameof(Person)); Console.WriteLine(person.Name.Length); Console.WriteLine("Press any key to close"); Console.ReadKey(); } catch (Exception ex) { Console.WriteLine(ex.Message); } } } public class Person { public string Name { get; set; } }
Al leer el código, resulta más evidente lo que está haciendo esta parte del código.
Null-Coalescing Operator ??
Pero en C# 7.0, también se introdujo una característica llamada Null-Coalescing Operator.
Con este operador, podemos evaluar si nuestro objeto es null o no, y en consecuencia, realizar una acción directa con esa problemática.
En el siguiente código, he decidido eliminar el try – catch, para forzar el comportamiento.
Nuestro código, en este caso, quedará de la siguiente forma:
using System; public class Program { public static void Main(string[] args) { Person person = null; //Person person = new Person() { Name = "C#" }; _ = person.Name ?? throw new ArgumentNullException(); Console.WriteLine(person.Name.Length); Console.WriteLine("Press any key to close"); Console.ReadKey(); } } public class Person { public string Name { get; set; } }
En este caso, se producirá un error en tiempo de ejecución, ya que hemos quitado el control del try – catch en nuestro código. El uso del operador ?? no nos mitiga el problema, salvo que en lugar de hacer un throw de una excepción como hago en el código, realicemos otra acción previa.
Se trata de un operador muy útil, pero que a veces no nos deja mucha libertad de acción.
is object
Una alternativa que no es muy usada o es más desconocida tal vez, es el uso de is object.
Preguntando de esta forma, nos aseguramos si nuestro objeto es null o no.
Aunque creo que queda más claro y directo hacer la pregunta por null como veíamos en is null, esta forma de preguntar por nuestro objeto, es también muy cómoda.
Nuestro ejemplo, quedaría por ejemplo y en este caso de esta forma:
using System; public class Program { public static void Main(string[] args) { Person person = null; //Person person = new Person() { Name = "C#" }; if (person is object) Console.WriteLine(person.Name.Length); else Console.WriteLine("es null"); Console.WriteLine("Press any key to close"); Console.ReadKey(); } } public class Person { public string Name { get; set; } }
! y not
Pero al igual que tenemos is, tenemos en C# 9.0 la posibilidad de utilizar ! o not.
Ambas tienen el mismo comportamiento, pero quizás not sea más legible que ! a primera vista, pero cualquier desarrollador está familiarizado con ambas.
No obstante, el carácter ! podría pasar desapercibido al leer rápidamente el código, mientras que not es más evidente.
En el caso de not, nuestro código quedará de la siguiente forma:
using System; public class Program { public static void Main(string[] args) { Person person = null; //Person person = new Person() { Name = "C#" }; if (person is not null) Console.WriteLine(person.Name.Length); else Console.WriteLine("es null"); Console.WriteLine("Press any key to close"); Console.ReadKey(); } } public class Person { public string Name { get; set; } }
Y en el caso de !, de esta otra manera:
using System; public class Program { public static void Main(string[] args) { Person person = null; //Person person = new Person() { Name = "C#" }; if (!(person is null)) Console.WriteLine(person.Name.Length); else Console.WriteLine("es null"); Console.WriteLine("Press any key to close"); //Console.ReadKey(); } } public class Person { public string Name { get; set; } }
Object.ReferenceEquals Method
Existe otra variante de todo esto que estamos viendo y desarrollando. Se trata del uso del método Object.ReferenceEquals.
El funcionamiento de este método es similar a todo lo que hemos visto hasta ahora, permitiéndonos chequear si una referencia es de un tipo concreto o no.
Aquí, chequearemos que esa referencia sea null.
Nuestro código de ejemplo quedará de la siguiente forma:
using System; public class Program { public static void Main(string[] args) { Person person = null; //Person person = new Person() { Name = "C#" }; if (object.ReferenceEquals(null, person)) throw new ArgumentNullException(); Console.WriteLine(person.Name.Length); Console.WriteLine("Press any key to close"); Console.ReadKey(); } } public class Person { public string Name { get; set; } }
Aquí indico también que podríamos encontrarnos con que Person no es null, pero sí lo es Name que es un tipo por referencia también.
Para ello, chequearíamos el null de la forma person?.Name.
En el caso del ejemplo, podríamos ampliar el if de validación de la forma:
if (object.ReferenceEquals(null, person) || object.ReferenceEquals(null, person?.Name)) throw new ArgumentNullException();
Uso de extensiones
Otra forma muy cómoda de chequear los null en nuestras aplicaciones, es utilizando una extensión que nos permita fácilmente chequear el estado de nuestros tipos por referencia.
La extensión la haremos como consideremos oportuna, pero un ejemplo de este tipo de extensiones sería el siguiente:
public static class NullCheckExtensions { public static T ThrowIfNull(this T @object, string paramName = "") where T : class { if (@object == null) { if (!String.IsNullOrEmpty(paramName)) throw new ArgumentNullException(paramName); throw new ArgumentNullException(); } return @object; } }
Y el ejemplo completo de gestión de null con esta extensión:
using System;
public class Program
{
public static void Main(string[] args)
{
try
{
Person person = new Person();
person.Name = null;
Console.WriteLine(person.Name.ThrowIfNull(nameof(person.Name)).Length);
}
catch (Exception ex)
{
Console.WriteLine($"ERROR{Environment.NewLine}{ex}");
}
Console.WriteLine("Press any key to close");
Console.ReadKey();
}
}
public class Person
{
public string Name { get; set; }
}
public static class NullCheckExtensions
{
public static T ThrowIfNull(this T @object, string paramName = "") where T : class
{
if (@object == null)
{
if (!String.IsNullOrEmpty(paramName))
throw new ArgumentNullException(paramName);
throw new ArgumentNullException();
}
return @object;
}
}
Nullable contexts
Pero no todo es infalible.
Todos los ejemplos que he puesto anteriormente, corren el riesgo de fugar y provocar un error no controlado en nuestras aplicaciones.
De hecho, en el ejemplo anterior, basta con cambiar parte del código sustituyendo:
Person person = new Person(); person.Name = null;
por
Person person = null;
Y la extensión que habíamos preparado fugará.
¿Qué quiero decir con esto?, pues que básicamente, por mucho que queramos controlar los null, y por mucha experiencia que tengamos, se nos pueden escapar entre los dedos y como consecuencia de ello, podemos tener problemas en tiempo de ejecución.
¿Y qué podemos hacer al respecto?, ¿cómo podemos controlar que no se nos escapan posibles null?.
Mitigar el uso de null todo lo que podamos, y fortalecer nuestro código al máximo, evitando su uso y controlando los flujos de ejecución y tests.
Microsoft es lo que está haciendo en .NET, y aunque aún no ha cubierto el 100% de su código, están en ello.
¿Y cómo se nos ayuda a lograr este propósito?.
Por defecto, en nada.
Es decir, Microsoft por defecto, no trata los nullable como warnings, así que el hecho de que fuguen y se nos descontrole es elevado, algo que es por otro lado, lo que sucede en todos los proyectos de .NET.
Y para resolver todo esto, entra en escena algo que se denomina Nullable contexts
Esta característica de C# 8.0 se utiliza para que el compilador controle la interpretación que tienen las variables de tipo por referencia de nuestro código.
Podemos habilitar esto para toda la clase, o sólo para una parte del código, habilitando o deshabilitando esta funcionalidad.
Personalmente, me gusta más hacerlo a nivel de clase entera, o incluso de ensamblado a través de una modificación que deberemos realizar en el csproj.
Indicando esta característica, el compilador interpretará la «nulabilidad» de los tipos, generando avisos o warnings.
Pero también podemos omitir estos avisos o warnings de forma forzada.
Para ello, podemos «jugar» en nuestro código con #nullable enable y #nullable disable.
Veamos esta aplicación con un ejemplo:
using System; public class Program { public static void Main(string[] args) { #nullable enable string name = String.Empty; name = null; #nullable disable string surname = String.Empty; surname = null; } }
Este código, permitirá al compilador validar lo que esté a partir del #nullable enlable hasta que encuentre un #nullable disable (si lo hay).
En consecuencia y para este ejemplo, name = null; devolverá un warning.
warning CS8600: Converting null literal or possible null value to non-nullable type.
Pero no ocurrirá así para surname = null; que no devolverá ningún warning, ya que estaremos omitiendo esta comprobación con #nullable disable.
Ahora bien, también podemos hacer esta comprobación a nivel de clase de esta forma:
#nullable enable using System; public class Program { public static void Main(string[] args) { string name = String.Empty; name = null; #nullable disable string surname = String.Empty; surname = null; } }
Pero lo habitual como digo, será dejar sólo #nullable enable en nuestro código, y a nivel de clase.
Así que lo más normal es que nuestro código tenga un aspecto similar a este:
#nullable enable
using System;
public class Program
{
public static void Main(string[] args)
{
string name = String.Empty;
name = null;
string surname = String.Empty;
surname = null;
}
}
Pero si nuestro proyecto cuenta con muchas clases, esta tarea puede convertirse en un auténtico quebradero de cabeza, por esa razón, tenemos dos estrategias.
Ponemos #nullable enable a nivel de clase una a una, y vamos analizando los warnings y realizando los ajustes concretos en nuestro código.
O bien, lo ponemos a nivel de proyecto, por lo que de una sola pasada de compilación, aparecerán multitud de warnings que deberemos resolver.
Quizás lo más sensato sea esta última acción, pudiendo activar y/o desactivar esta opción para ir resolviendo estos warnings e ir quitándolos de en medio poco a poco.
Para hacer esto, deberemos modificar el csproj indicando Nullable como enable de la siguiente forma:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net5</TargetFramework>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>
De esta manera, estaremos comprobando lo mismo pero a nivel de proyecto, con todas las clases del mismo.
E indudablemente, si queremos deshabilitar esta opción, bastará con quitar Nullable del csproj, o bien, ponerla a disable.
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net5</TargetFramework>
<Nullable>disable</Nullable>
</PropertyGroup>
</Project>
Pero si queremos ser más restrictivos aún, podemos forzar a que este comportamiento, no sea un warning, sino un error.
Para ello, debemos habilitar la opción WarningsAsErrors e indicar los tipos de warning que queremos que sean tratados como errores por el compilador.
Cada warning tiene un código, por ejemplo CSxxxx donde xxxx es el número de warning.
Bastará con indicar los warnings, separados por punto y coma ;
Por ejemplo y para el caso que nos ocupa, nuestro csproj será de la forma:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net5</TargetFramework>
<Nullable>enable</Nullable>
<WarningsAsErrors>CS8600</WarningsAsErrors>
</PropertyGroup>
</Project>
De esta forma, los warnings de nullable que estábamos viendo todo el momento, se convierten ahora en errores que nos impide compilar nuestra aplicación.
Aunque esta es una forma drástica de hacer las cosas, es bastante efectiva en tanto en cuanto nos avisa de posibles comportamientos de código, que pueden resultar nocivos para nuestra aplicación y nuestros intereses.
Lo que no cubre Nullable contexts
¿Nullable contexts cubre todas las posibilidades posibles?
No.
Así de rotundo y directo.
Veámoslo con un ejemplo y supongamos el ejemplo tipo con el que hemos estado trabajando en esta entrada.
Una clase Person con un variable de tipo string.
#nullable enable using System; public class Program { public static void Main(string[] args) { PrintName(default); } private static void PrintName(Person person) { Console.WriteLine(person.Name); } } public class Person { public string Name; }
Este código dará un error de ejecución, y en compilación nos estará advirtiendo del riesgo de null.
En concreto, se está produciendo un error de tipo:
warning CS8625: Cannot convert null literal to non-nullable reference type.
Error que se produce en:
PrintName(default);
Ahora bien, supongamos que en lugar de la clase Person, tenemos la estructura Person.
Algo como:
#nullable enable using System; public class Program { public static void Main(string[] args) { PrintName(default); } private static void PrintName(Person person) { Console.WriteLine(person.Name); } } public struct Person { public string Name; }
Lamentablemente, el warning anterior, ya no aparecerá.
De hecho, tampoco se producirá ningún error en tiempo de ejecución.
Y si por un casual, a este código que damos por bueno (de forma correcta), decidimos hacer una modificación y transformar la estructura en clase, estaremos delante de un error en tiempo de ejecución que no teníamos, pero que de repente se presenta.
La naturaleza del tipo de dato, por referencia en el caso de la clase y por valor en el tipo de la estructura, es la que define esta situación, y refactorizar nuestro código y pasar de un tipo a otro, puede desencadenar este tipo de problemas y fugas.
Conviene conocer cómo funcionan las cosas por debajo, para saber qué implicaciones tiene un cambio, que por pequeño que parezca, puede acarrear serios problemas.
Cuidado a la hora de poner tipos por referencia a null
Aunque ya ha quedado claro, creo yo, el riesgo de asumir null en nuestro código, un asunto no menor que no me gustaría dejar pasar por alto ya que hablo de ello, es que a muchos programadores nos gusta poner nuestros tipos por referencia a null,
Es la típica fea costumbre que incluso a mí me cuesta evitar.
Esta práctica no es nada recomendable. Sé que lo hacemos muchos programadores así, pero si no tenemos bien controlado el flujo de nuestras aplicaciones, podemos encontrarnos con problemas serios en tiempo de ejecución.
De hecho, el siguiente ejemplo, muy típico por otra forma, demuestra este comportamiento:
Person person = null; ... person.Name = "C#";
o bien:
Person person = new Person(); ... person.Name = null; ... Console.WriteLine(person.Name.Length);
Referencias
- C# 8.0 – Specification – Null coalescing assignment
- C# 8.0 – Specification – Nullable reference types
- Repasando Null-Coalescing Operator y Null-Coalescing Assignment Operator y convirtiendo tipos nullable a tipos no nullable
Conclusiones
El trabajo y gestión de null no es trivial, y existen múltiples consideraciones a la hora de abordarlos.
Controlar y gestionar null en nuestros desarrollos es clave para evitar funcionamientos no esperados de nuestro código.
No existe una forma única de gestionar esto, depende del contexto en el que nos encontremos y de lo que queramos hacer, pero aquí quería repasar algunas consideraciones y aspectos que muchas veces pasamos por alto o no tenemos en cuenta.
Espero que esta entrada te haya hecho reflexionar acerca de cómo escribes código y de las consecuencias de ello.
Esta entrada forma parte del 3er calendario de Adviento de C#, que encontrarás en este enlace.
Happy Coding!