Saltar al contenido

Artículos, tutoriales, trucos, curiosidades, reflexiones y links sobre programación web ASP.NET Core, MVC, Blazor, SignalR, Entity Framework, C#, Azure, Javascript... y lo que venga ;)

17 años online

el blog de José M. Aguilar

Inicio El autor Contactar

Artículos, tutoriales, trucos, curiosidades, reflexiones y links sobre programación web
ASP.NET Core, MVC, Blazor, SignalR, Entity Framework, C#, Azure, Javascript...

¡Microsoft MVP!
martes, 9 de julio de 2019
.NET Core Cuando, en 1965, el bueno de Tony Hoare introdujo las referencias nulas en el lenguaje ALGOL simplemente "porque era fácil de implementar", no era consciente de que se trataba de un error que a la postre él mismo definiría como su "error del billón de dólares".

De hecho, en el top ten de errores de ejecución de aplicaciones creadas con casi cualquier lenguaje y plataforma, las excepciones o crashes debidos a las referencias nulas son, con diferencia, el tipo de error más frecuente que solemos encontrar.

Pues en este repaso que vamos dando a las novedades principales de C# 8, hemos llegado la que probablemente podría ser la característica más destacada en este entrega, cuyo objetivo es precisamente establecer las bases para que podamos olvidarnos de las referencias no controladas a nulos.
No olvidéis que hasta que sea lanzado oficialmente C# 8, para poder probar sus características hay que hacer algunas cosillas.

¿Permitir valores nulos en tipos referencia?

Ya, pensaréis que todos los tipos referencia son implícitamente anulables en .NET, y de hecho es así.
A diferencia de los tipos valor (int, char, double...), las variables o miembros que contienen tipos referencia no guardan sus valores, sino punteros a los mismos.

Y como punteros que son, pueden contener un valor nulo, razón por la cual el siguiente código es válido:
var s = "Hello, world!"; // La variable "s" apunta a la posición en memoria de la cadena
s = null;                // Ahora, "s" no apunta a ningún sitio
El problema es que en tiempo de ejecución nunca sabemos si una determinada variable contiene un nulo, por lo que, si queremos tener una aplicación realmente robusta, deberíamos comprobar continuamente si es así antes de utilizarla:
var s = GetSomeString(); // ¿Retornará null? ¿O tal vez no?
if(s != null)
{
    Console.WriteLine(s.Length);
}
Y claro, los disgustos llegan cuando olvidamos introducir dicha comprobación o cuando, por su contexto, intuimos que el valor nunca será nulo, ignorando esa famosa ley que afirma que "si un valor puede ser nulo, el algún momento será nulo" ;)

Para evitar esto, en C# 8 podemos indicar expresamente en qué variables consideramos que null puede ser un valor válido y en cuáles, aun siendo tipos referencia, no vamos a permitir este valor.

De esta forma, conseguimos desplazar al compilador la responsabilidad de realizar dichas comprobaciones, y llevándonos a tiempo de desarrollo la detección de errores debido a este asunto.

Tipos referencia anulables

A partir de C# 8, para indicar que podemos utilizar null en un tipo referencia, debemos indicarlo expresamente añadiendo una interrogación al mismo, de la misma forma que hacíamos con los tipos valor anulables como int? o DateTime?.

La idea es que el siguiente código nos lance errores (o como mínimo warnings) en tiempo de compilación, en lugar de explotar en runtime cuando se intente establecer usar la referencia incorrecta:
string? nullableString = "Nulls are welcome!";
string notNullableString = "Nulls are not welcome!";
nullableString = null; // Allowed
notNullableString = null; // Compilation warning!
Console.WriteLine(nullableString.Length + notNullableString.Length); // Compilation warning!
Así de sencillo :)

Pero cada cosa en su contexto, y dándole la importancia apropiada

Fijaos que el hecho de marcar con la interrogación los tipos referencia anulables implicaría que gran parte de nuestro código actual sería incorrecto, por lo que el uso de esta característica se convertiría en un breaking change de los gordos en los proyectos escritos con versiones anteriores a C# 8.

Por esta razón, se trata de una característica que podemos activar o desactivar a nivel de proyecto y de bloque de código.

Por ejemplo, para indicar que el compilador debe realizar estos chequeos en el proyecto completo, basta con añadir el elemento <NullableContextOptions> al archivo .csproj como sigue:
<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>netcoreapp3.0</TargetFramework>
    <NullableContextOptions>enable</NullableContextOptions>
    ...
</Project>
Los proyectos que no incluyan este elemento, o bien lo establezcan como disable no relizarán ningún tipo de comprobación, por lo que todo funcionará como en versiones anteriores del compilador.
Otra posibilidad es establecer este valor a nivel de bloque de código, para lo cual podemos utilizar la directiva #nullable, como en el siguiente ejemplo:
13: // Activamos el chequeo de nulos...
14: #nullable enable
15: string? nullableString = "Nulls are welcome!";
16: string notNullableString = "Nulls are not welcome!";
17: nullableString = null; // Ok, we allow nulls
18: notNullableString = null; // Warning!
19: Console.WriteLine(
20:    nullableString.Length + notNullableString.Length); // Warning!

21: // Ahora volvemos a aplicar la configuración a nivel de proyecto
22: #nullable restore
23: ...
Al compilar un proyecto con este código desde la CLI, el resultado obtenido por consola sería el siguiente:
D:\Test>dotnet build
Microsoft (R) Build Engine versión 16.0.443 para .NET Core
Copyright (C) Microsoft Corporation.
...

Program.cs(18,33): warning CS8600: Converting null literal or possible null value to non-nullable type. 
                   [D:\Test\ConsoleCore2.csproj]
Program.cs(20,17): warning CS8602: Possible dereference of a null reference. [D:\Test\ConsoleCore2.csproj]
Program.cs(20,41): warning CS8602: Possible dereference of a null reference. [D:\Test\ConsoleCore2.csproj]
    3 Advertencia(s)
    0 Errores

Tiempo transcurrido 00:00:00.76
D:\Test>_
Por defecto, podemos ver que el compilador nos avisará de los problemas encontrados mediante warnings y ya es decisión nuestra si queremos dejarlo así, o si preferimos "ascenderlos" a errores para bloquear la generación de binarios, por ejemplo añadiendo al .csproj el elemento <TreatWarningsAsErrors>true</TreatWarningsAsErrors>, o bien indicando expresamente qué warnings queremos que sean tratados como errores:
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>netcoreapp3.0</TargetFramework>
    <WarningsAsErrors>CS8600;CS8602;CS8603</WarningsAsErrors>
    ...
</Project>
En cualquier caso, los avisos nos están indicando de que:
  • Hemos establecido a nulo una variable que no lo admite (línea 18).
  • Estamos accediendo a propiedades de una referencia que podría ser nula (línea 20, dos veces).

Uso de tipos referencia anulables

En la práctica, el uso de esta característica nos permitirá crear código muy robusto, pues la comprobación de nulos no se dejará al azar o al criterio del desarrollador: será el compilador el que vele por nuestra seguridad.

Por ejemplo, en el siguiente código, vemos que el método GetPerson() está declarando explícitamente que retornará objetos Person que nunca van a ser nulos. Sin embargo, dado que en uno de los caminos de ejecución es posible que se retorne dicho valor, el compilador lanzará un warning indicándolo:
Person GetPerson(int id)
{
    var person = DateTime.Now.Second % 2 == 0? _database.People.Find(id): null;
    return person; // Warning CS8603: possible null reference return.
}
En cambio, si gestionamos este valor nulo internamente, el compilador ya no emitirá warnings porque detectará que el método nunca va a devolver null y, por tanto, podemos usar su retorno con total seguridad:
Person GetPerson(int id)
{
    var person = DateTime.Now.Second % 2 == 0
        ? _database.People.Find(id)
        : null;
    if(person == null)
        person = Person.Anonymous;
    return person;
}

// Usage:
var person = GetPerson(1);
Console.WriteLine(person.Age); // It's ok, Person is always a valid instance
El compilador también estará atento al caso contrario. Si, por ejemplo, en un método indicamos que retorna un tipo anulable, null será considerado válido y no se generará ningún warning:
Person? GetPerson(int id)
{
    var person = DateTime.Now.Second % 2 == 0? _database.People.Find(id): null;
    return person; // Ok, null is a valid return value
}
Ahora bien, si un tipo lo hemos marcado como anulable e intentamos acceder a sus miembros sin haber asegurado previamente que no contiene nulos, nos avisará de que hay algo raro:
Person? person = GetPerson(1);
Console.WriteLine(person.Age); // CS8602: Possible dereference of a null reference
La forma de solucionarlo es bien sencilla. Podemos simplemente chequear el valor nulo antes de acceder a sus propiedades con alguna de las construcciones existentes. El siguiente bloque de código no generará ningún tipo de warning o error:
if(person != null)
{
    Console.WriteLine(person.Age); // Ok, person is not null
}
Console.WriteLine(person?.Age); // If person is null, "0" will be shown

Null-forgiving operator (!)

Para aquellos casos en los que estamos absolutamente seguros de que un objeto no es nulo y queremos acceder a sus miembros sin necesidad de realizar un chequeo explícito como los vistos anteriormente, podemos usar el operador exclamación "!", aka "olvida los nulos", de la siguiente forma:
Person? person = GetPerson(1);
Console.WriteLine(person!.Age); // Ok, but it could crash in runtime
Básicamente, lo que hacemos con este operador es desactivar justo en ese punto la comprobación de nulos del compilador. O en otras palabras: estamos asegurando al compilador que ahí nunca habrá un nulo (¡y si lo hay, es responsabilidad nuestra!)

Reflexiones finales

Probablemente estemos ante una de las novedades más revulsivas introducidas al lenguaje en los últimos tiempos (bueno, exceptuando quizás el famoso operador virgulilla ;D).

Aunque su impacto ha sido bastante suavizado al permitir su opcionalidad e incluso su adopción progresiva, probablemente a la larga acabará calando en nuestras aplicaciones y evitará gran parte de los frecuentes null reference exception que sufrimos a día de hoy. El "a la larga" se debe a que, por las implicaciones que tendría introducir esta característica en código existente, en la mayoría de ocasiones lo más práctico será usarla en proyectos nuevos.

Iremos viendo...

Publicado en Variable not found.

2 Comentarios:

Kriogénico dijo...

Buenas.
Un detalle, en:

Person GetPerson(int id)
{
var person = DateTime.Now.Second % 2 == 0
? _database.People.Find(id)
: null;
if(person == null)
person = Person.Anonymous;
return person;
}

Lo haces así ¿Por motivos didácticos?
Porque yo lo veo más claro:

Person GetPerson(int id)
{
return (DateTime.Now.Second % 2 == 0
? _database.People.Find(id)
: null) ?? Person.Anonymous;
}

será deformación mía simplificando líneas.
Saludos.

José María Aguilar dijo...

Hola!

Jejeje, pues sí, pero no creas que no me he quedado con las ganas de acortarlo aún más ;D

La intención es que cualquiera que lea el post pueda entenderlo fácilmente. Aunque también soy de los que intentan simplificar al máximo y ahorrar pulsaciones de tecla, creo que a veces expresar todo en una única línea puede resultar más corto de escribir pero más difícil de leer. En este caso, he preferido que la lógica quede clara para mostrar el uso de la nueva característica de C# 8, aun a costa de introducir un código algo más verboso.

Muchas gracias por comentar!