UpToDate C#: Extra

C#12

(Actualizado a 10 de Agosto de 2024)

Características adicionales que no encajan en otros apartados

Configuración de tipos de referencia Nullable

Son una característica nueva que permite una mayor gestión a la hora de decirle al compilador cuando una variable es nula o no.

Esto es muy útil a la hora de crear librerías para mostrar advertencias a los usuarios sobre posibles efectos en el uso de las funciones en el manejo de valores nulos.

Esta característica está pensada para .Net Core 3 y .Net Standard 2.1. Si hay algún problema se puede configurar explicitamente la versión del lenguaje a usar.

<PropertyGroup>
    <LangVersion>8.0</LangVersion>
</PropertyGroup>

A partir de aquí hay dos opciones para habilitar esta característica. La primera está pensada para proyectos que ya están en marcha, así que lo que se hace es habilitarlo a nivel de proyecto con:

<PropertyGroup>
    <Nullable>enable</Nullable>
</PropertyGroup>

Y luego deshabilitarlo en cada fichero incluyendo:

#nullable disable

Esto generará avisos por el código, una vez que se han corregido todos se puede eliminar la directiva en cada fichero.

La segunda opción es la inversa, es decir, habilitarlo en ficheros individuales:

#nullable enable

Esto se hace en cada fichero hasta que todos estén anotados y las advertencias del compilador corregidas y entonces se habilita en el proyecto y se eliminan de los ficheros:

<PropertyGroup>
    <Nullable>enable</Nullable>
</PropertyGroup>

En un contexto nullable como el que se ha habilitado hay que tener en cuenta:

  • Cualquier variable por referencia es una referencia no nullable.
  • Caulquier referencia no nullable puede ser desreferenciada de una forma segura.
  • Un tipo de referencia nullable (usando ? como string?) puede ser nula. Durante un análisis estático se verifica si el tipo puede ser nulo de cara a mostrar o no una advertencia.
  • Se puede usar el operador ! para indicar que una referencia no será nula (se verá más adelante).

Tipos de referencia Nullable

Ahora partimos de la siguiente interfaz:

namespace UpToDate.Helpers
{
    public interface ISer<in T, out U>
        where T : notnull
        where U : notnull
    {
        U MagicCast(T input);
    }
}

Hemos indicado que tanto T como U no pueden ser nulos, por lo tanto solo se aceptarán valores no nulos o tipo valor (string?, int? darían error).

Se puede usar también la restricción class como se ve en el ejemplo pero en este caso solo podría ser un tipo referencia y no valor.

Esto también obliga a que cualquier clase que lo implemente debe incorporar las mismas restricciones para evitar los avisos del compilador como se pueden ver en las restricciones añadiendo notnull.

public class Sorcerer<T, U> : ISchool<T, U>
    where T : notnull
    where U : class
{
    private string name = string.Empty;
    [AllowNull]
    public string Name
    {
        get
        {
            return name;
        }
        set
        {
            name = value ?? string.Empty;
        }
    }
    public U Cast(T input)
    {
        return (input as U)!;
    }
}

Por lo que esta línea no funcionaría:

return new Sorcerer<string?, float?>();

Pero esta sería correcta:

return new Sorcerer<int, float>();

Nullable preconditions

Algunos atributos son precondiciones, esto quiere decir que solo se aplican a valores de entrada por ejemplo en una propiedad solo se aplicaría a set.

Siguiendo con este ejemplo podemos querer que ciertos valores acepten valores null, para eso es el atributo [AllowNull] de la propiedad Name, el ejemplo anterior evita que la siguiente línea de código de una advertencia:

var sorcerer = new Sorcerer<float, string>();
sorcerer.Name = null;

También se puede trabajar en la dirección contraria, impidiendo asignar valores nulos y emitiendo advertencias, por ejemplo si tenemos este código:

Person? p = null;
sorcerer.LowerName(ref p);

Indicar esa firma de método con [DisallowNull] provocará que el compilador emita una advertencia, hemos convertido la referencia a referencia nullable (usando ?) y después con el atributo impedimos que sea nula, ya que en contextos nullables los tipos referencia se consideran seguros para dereferenciar.

public string LowerName([DisallowNull]ref Person? person)
{
...
}

Nullable postconditions

También se pueden indicar posibles valores en la devolución, por ejemplo se puede indicar que una función puede devolver un valor nulo con [return: MaybeNull], esto es habitual en genéricos sin restricciones como también se verá más adelante, por ejemplo:

[return: MaybeNull]
public Z Find<Z>(Z s)
{
    Z z = default(Z);
    return s!.GetHashCode() > 100 ? s : z;
}

Puede devolver un nulo y por eso lo marcamos como [return: MaybeNull] de tal forma que el código que haga uso de este método emitirá una advertencia, como el siguiente:

public int ReturnNotNull()
{
    var sorcerer = new Sorcerer<float, string>();
    string z = "4";
    var x = sorcerer.Find(z);
    return x.Length;
}

O también podemos indicar que si el valor devuelto es por referencia nunca será nulo usando [NotNull], por ejemplo para este método se está indicando que el parámetro z que se envía por referencia a la función nunca será devuelto como nulo, aunque pueda ser enviado como nulo a la función.

Es decir, independientemente del valor introducido siempre devolvereremos un valor no nulo.

public void Swap<Z>([NotNull]ref Z[]? z)
	where Z : new()
{
    if (z is null)
    {
        z = new Z[] { new Z() };
    }
}

Y podríamos usarlo de forma segura sin avisos ya que hemos indicado que no será nulo:

Person?[] people = null;
extra.Swap(ref people);
Console.WriteLine(people.Length);

Tanto [NotNull] como [MaybeNull] pueden ser usados en valores devueltos en cualquier forma: in, ref, out o return.

Conditional postconditions

Puede ser que queramos indicar si el valor devuelto será nulo o no en función del parámetro de devolución de la función, por ejemplo si la función devuelve true queremos indicar que el parámetro devuelto no será nulo, es una estructura muy típica de las funciones estilo TryXXX(out x).

Por ejemplo queremos indicar que el parámetro devuelto no será nulo cuando la función devuelva false.

public bool IsNullOrEmpty([NotNullWhen(false)] string? value)
{
    return String.IsNullOrEmpty(value);
}

Y lo usaríamos de la siguiente forma para obtener una advertencia al saber que la variable será nula. El compilador sabe que esto es un riesgo porque se ha establecido que no será nulo cuando el valor devuelto sea false.

string? str = null;
if(extra.IsNullOrEmpty(str))
{
    Console.WriteLine(str.Length);
}

Incluso existe el caso de que el parámetro pueda ser nulo aunque el propio tipo no lo permita para eso tenemos [MaybeNullWhen(bool)] , por ejemplo creamos una cola personalizada:

public class CustomQueue<T>
{
    private readonly Queue<T> queue = new Queue<T>();
    public bool TryDequeue([MaybeNullWhen(false)] out T result)
    {
        result = queue.Dequeue();
	return result != null;
    }
}

Entonces una línea como esta mostraría una advertencia ya que intentamos acceder a una variable que sabemos que puede ser nula.

Esto es muy habitual en genéricos donde los tipos pueden ser indistintamente referencia o valor y no hay forma de saber por adelantado si el valor podrá ser nulo o no.

CustomQueue<string> queue = new CustomQueue<string>();
if (!queue.TryDequeue(out var result))
    Console.WriteLine(result.Length);

Dependencias de nulidad entre entrada y salida

Podría ser que quisieramos que un valor de salida fuera nulo en función del valor de entrada, si por ejemplo la entrada no es nula la salida del método tampoco lo será, esto lo hacemos con [return: NotNullIfNotNull(param)]

[return: NotNullIfNotNull("s")]
public string? ToLower(string? s)
{
    if (String.IsNullOrEmpty(s))
        return string.Empty;
    return s.ToLower();
}

O en C# 11 se puede usar nameof para poder referenciar parámetros genéricos

[return: NotNullWhen(nameof(value))]
public T Process<T>(T value, bool condition)
{
    if (condition)
    {
        return value;
    }
    return default(T);
}

Este código lanzaría una advertencia ya que estamos pasando un valor nulo, este valor en este caso viene dado de forma explícita pero podría venir dado desde un parámetro de otra función.

string? toLower = null;
var lower = extra.ToLower(toLower);
Console.WriteLine(lower.Length);

Atributos de flujo

Podría ser que durante el flujo de la ejecución haya un método que haga una comprobación como:

  • Emitir una excepción si el parámetro por ejemplo es nulo.
  • Una función del tipo assert que emitirá una excepción dependiendo de un booleano de entrada, por ejemplo un true lanzaría una excepción pero un false no.
[DoesNotReturn]
public void ThrowLowerException(string s)
{
    throw new ArgumentException(s);
}

public void AssertIsLower([DoesNotReturnIf(false)] bool s)
{
    if (s)
        throw new ArgumentException("Lower string");
}

En el primer caso indicamos que no hace falta verificar el valor de retorno ya que será una excepción, y en el segundo indicamos que no hace falta realizar ninguna comprobación si el parámetro de entrada es false ya que la ejecución del código terminará en ese punto.

Null forgiveness operator (!)

Habíamos visto un operador ! este operador sirve para indicar que el parámetro no es nulo y no lo va a ser, es una forma de evitar algunas advertencias si sabemos de antemano que ese valor no puede ser nulo.

Se suele usar en pasos intermedio mientras la librería se prepara siguiendo los pasos anteriores, pero a medida que se normalize usando los atributos este operador debería ir desapareciendo.

Referencias:

Top level statements (C# 9)

Cuando se crea una aplicación hay que añadir una cierta cantidad de código que algunas veces no es necesario, ahora se puede evitar y simplificar, por ejemplo, un código clásico como:

using System;

namespace HelloWorld
{
    class Program
    {
        static void Main(string[] args)
        {
            Console.WriteLine("Hello World!");
        }
    }
}

Se quedaría en algo como:

using System;

Console.WriteLine("Hello World!");

System.Console.WriteLine("Hello World!"); // More succintly

Esto solo se puede hacer para un único fichero, así que es especialmente útil para aplicaciones de consola (mantiene el uso de args[] y se pueden devolver valores) o pequeños scripts como los que se pueden ver en Azure Functions o Jupyter Notebook.

UpToDate C#: Extra