Atributos para el análisis estático de estado NULL interpretados por el compilador de C#

En un contexto habilitado que admite un valor NULL, el compilador realiza un análisis estático del código para determinar el estado NULL de todas las variables de tipo de referencia:

  • not-null: el análisis estático determina que la variable tiene un valor distinto de NULL.
  • maybe-null: el análisis estático no puede determinar que a la variable se le asigna un valor distinto de NULL.

Estos estados permiten al compilador proporcionar advertencias cuando se puede desreferenciar un valor NULL, iniciando un objeto System.NullReferenceException. Estos atributos proporcionan al compilador información semántica sobre el estado NULL de los argumentos, los valores devueltos y los miembros de objeto en función del estado de los argumentos y los valores devueltos. El compilador proporciona advertencias más precisas cuando las API se han anotado correctamente con esta información semántica.

En este artículo se proporciona una breve descripción de cada uno de los atributos de tipo de referencia que acepta valores NULL y cómo usarlos.

Comencemos con un ejemplo. Imagine que la biblioteca tiene la siguiente API para recuperar una cadena de recursos. Este método se compiló originalmente en un contexto de tipo "oblivious" que admite un valor NULL:

bool TryGetMessage(string key, out string message)
{
    if (_messageMap.ContainsKey(key))
        message = _messageMap[key];
    else
        message = null;
    return message != null;
}

En el ejemplo anterior se sigue el conocido patrón de Try* en .NET. Hay dos parámetros de referencia para esta API: key y message. Esta API tiene las siguientes reglas relacionadas con el estado NULL de estos parámetros:

  • Los autores de la llamada no deben pasar null como argumento para key.
  • Los autores de la llamada pueden pasar una variable cuyo valor sea null como argumento de message.
  • Si el método TryGetMessage devuelve true, el valor de message no es NULL. Si el valor devuelto es false,, el valor de message es NULL.

La regla para key se puede expresar de forma sucinta: key debe ser un tipo de referencia que no acepta valores NULL. El parámetro message es más complejo. Permite una variable que sea null como argumento, pero garantiza que, si se ejecuta correctamente, el argumento out no sea null. En estos escenarios, necesita un vocabulario más completo para describir las expectativas. El atributo NotNullWhen, que se describe a continuación, describe el estado NULL del argumento utilizado para el parámetro message.

Nota

Al agregar estos atributos, se proporciona más información al compilador sobre las reglas de la API. Cuando el código que realiza la llamada se compila en un contexto habilitado para aceptar valores NULL, el compilador advertirá a los autores de la llamada cuando infrinjan esas reglas. Estos atributos no habilitan más comprobaciones en la implementación.

Atributo Category Significado
AllowNull Condición previa Un parámetro, campo o propiedad que no acepta valores NULL puede ser NULL.
DisallowNull Condición previa Un parámetro, campo o propiedad que acepta valores NULL nunca debe ser NULL.
MaybeNull Condición posterior Un parámetro, campo, propiedad o valor devuelto que no acepta valores NULL puede ser NULL.
NotNull Condición posterior Un parámetro, campo, propiedad o valor devuelto que acepta valores NULL nunca será NULL.
MaybeNullWhen Condición posterior condicional Un argumento que no acepta valores NULL puede ser NULL cuando el método devuelve el valor bool especificado.
NotNullWhen Condición posterior condicional Un argumento que admite un valor NULL nunca será NULL cuando el método devuelva el valor bool especificado.
NotNullIfNotNull Condición posterior condicional Un valor devuelto, propiedad o argumento no es NULL si el argumento del parámetro especificado no es NULL.
MemberNotNull Métodos del asistente para propiedades y métodos el miembro de la lista no será NULL cuando el método devuelva un valor.
MemberNotNullWhen Métodos del asistente para propiedades y métodos el miembro de la lista no será NULL cuando el método devuelva el valor bool especificado.
DoesNotReturn Código inaccesible Un método o propiedad nunca devuelve valores. Es decir, siempre inicia una excepción.
DoesNotReturnIf Código inaccesible Este método o propiedad nunca devuelve un valor si el parámetro bool asociado tiene el valor especificado.

Las descripciones anteriores son una referencia rápida a lo que hace cada atributo. En las secciones siguientes se describe el comportamiento y el significado de estos atributos de forma más exhaustiva.

Condiciones previas: AllowNull y DisallowNull

Considere una propiedad de lectura y escritura que nunca devuelve null porque tiene un valor predeterminado razonable. Los autores de la llamada pasan null al descriptor de acceso set cuando lo establecen en el valor predeterminado. Por ejemplo, imagine un sistema de mensajería que solicita un nombre de pantalla en un salón de chat. Si no se proporciona ninguno, el sistema genera uno aleatorio:

public string ScreenName
{
    get => _screenName;
    set => _screenName = value ?? GenerateRandomScreenName();
}
private string _screenName;

Al compilar el código anterior en un contexto en el que se desconocen los valores NULL, todo es correcto. Una vez que se habilitan los tipos de referencia que admiten un valor NULL, la propiedad ScreenName se convierte en una referencia que no acepta valores NULL. Eso es correcto para el descriptor de acceso get: nunca devuelve null. No es necesario que los autores de la llamada comprueben null en la propiedad devuelta. Pero ahora, al establecer la propiedad en null, se genera una advertencia. Para admitir este tipo de código, agregue el atributo System.Diagnostics.CodeAnalysis.AllowNullAttribute a la propiedad, como se muestra en el código siguiente:

[AllowNull]
public string ScreenName
{
    get => _screenName;
    set => _screenName = value ?? GenerateRandomScreenName();
}
private string _screenName = GenerateRandomScreenName();

Es posible que tenga que agregar una directiva using para System.Diagnostics.CodeAnalysis a fin de usar este y otros atributos descritos en este artículo. El atributo se aplica a la propiedad, no al descriptor de acceso set. El atributo AllowNull especifica condiciones previas y solo se aplica a los argumentos. El descriptor de acceso get tiene un valor devuelto, pero no parámetros. Por tanto, el atributo AllowNull solo se aplica al descriptor de acceso set.

En el ejemplo anterior se muestra qué se debe buscar al agregar el atributo AllowNull en un argumento:

  1. El contrato general para esa variable es que no debe ser null, por lo que quiere un tipo de referencia que no acepte valores NULL.
  2. Existen escenarios para que un autor de llamada pase null como argumento, aunque no son el uso más común.

En la mayoría de los casos necesitará este atributo para las propiedades, o bien para los argumentos in, out y ref. El atributo AllowNull es la mejor opción cuando una variable normalmente no es NULL, pero debe permitir null como condición previa.

Compare esto con los escenarios donde se usaDisallowNull: este atributo se usa para especificar que un argumento de un tipo de referencia que admite un valor NULL no deba ser null. Considere una propiedad donde null es el valor predeterminado, pero los clientes solo pueden establecerla en un valor que no sea NULL. Observe el código siguiente:

public string ReviewComment
{
    get => _comment;
    set => _comment = value ?? throw new ArgumentNullException(nameof(value), "Cannot set to null");
}
string _comment;

El código anterior es la mejor manera de expresar el diseño de que ReviewComment pueda ser null, pero no se puede establecer en null. Una vez que este código admita valores NULL, puede expresar este concepto con más claridad para los autores de la llamada mediante System.Diagnostics.CodeAnalysis.DisallowNullAttribute:

[DisallowNull]
public string? ReviewComment
{
    get => _comment;
    set => _comment = value ?? throw new ArgumentNullException(nameof(value), "Cannot set to null");
}
string? _comment;

En un contexto que admite valores NULL, el descriptor de acceso get de ReviewComment podría devolver el valor predeterminado de null. El compilador advierte que se debe comprobar antes del acceso. Además, advierte a los autores de la llamada que, aunque podría ser null, no deberían establecerlo de forma explícita en null. El atributo DisallowNull también especifica una condición previa, no afecta al descriptor de acceso get. Use el atributo DisallowNull cuando observe estas características:

  1. La variable podría ser null en escenarios principales, a menudo cuando se crea su primera instancia.
  2. La variable no se debe establecer de forma explícita en null.

Estas situaciones son comunes en el código en el que originalmente se desconocían los valores NULL. Es posible que las propiedades de objeto se establezcan en dos operaciones de inicialización distintas. Es posible que algunas propiedades se establezcan solo después de que se haya completado algún trabajo asincrónico.

Los atributos AllowNull y DisallowNull permiten especificar que las condiciones previas de las variables puedan no coincidir con las anotaciones que admiten un valor NULL en esas variables. Proporcionan más detalles sobre las características de la API. Esta información adicional ayuda a los autores de la llamada a usar la API de manera correcta. Recuerde que debe especificar las condiciones previas mediante los atributos siguientes:

  • AllowNull: un argumento que no acepta valores NULL puede ser NULL.
  • DisallowNull: un argumento que admite un valor NULL nunca debe ser NULL.

Condiciones posteriores: MaybeNull y NotNull

Imagine que tiene un método con la siguiente firma:

public Customer FindCustomer(string lastName, string firstName)

Probablemente haya escrito un método como este para devolver null cuando no se encuentra el nombre que se busca. null indica claramente que no se ha encontrado el registro. En este ejemplo, es probable que cambie el tipo de valor devuelto de Customer a Customer?. Al declarar el valor devuelto como un tipo de referencia que admite un valor NULL, se especifica claramente la intención de esta API:

public Customer? FindCustomer(string lastName, string firstName)

Por motivos que se tratan en Tipos de referencia que aceptan valores NULL - Genéricos, es posible que esa técnica no produzca el análisis estático que coincida con la API. Puede tener un método genérico que siga un patrón similar:

public T Find<T>(IEnumerable<T> sequence, Func<T, bool> predicate)

El método devuelve null cuando no se encuentra el elemento buscado. Puede aclarar que el método devuelve null cuando no se encuentra un elemento agregando la anotación MaybeNull a la devolución del método:

[return: MaybeNull]
public T Find<T>(IEnumerable<T> sequence, Func<T, bool> predicate)

El código anterior informa a los autores de la llamada de que el valor devuelto puede realmente ser NULL. También informa al compilador de que el método puede devolver una expresión null aunque el tipo no acepte valores NULL. Si tiene un método genérico que devuelve una instancia de su parámetro de tipo, T, puede expresar que nunca devuelve null mediante el atributo NotNull.

También puede especificar que un valor devuelto o un argumento no sean NULL aunque el tipo sea un tipo de referencia que admite un valor NULL. El método siguiente es un método auxiliar que se produce si su primer argumento es null:

public static void ThrowWhenNull(object value, string valueExpression = "")
{
    if (value is null) throw new ArgumentNullException(nameof(value), valueExpression);
}

Puede llamar a esta rutina de esta forma:

public static void LogMessage(string? message)
{
    ThrowWhenNull(message, $"{nameof(message)} must not be null");

    Console.WriteLine(message.Length);
}

Después de habilitar los tipos de referencia NULL, querrá asegurarse de que el código anterior se compila sin advertencias. Cuando el método devuelve un valor, se garantiza que el parámetro value no es NULL. Pero es aceptable llamar a ThrowWhenNull con una referencia nula. Puede convertir a value en un tipo de referencia que acepte valores NULL y agregar la condición posterior NotNull a la declaración del parámetro:

public static void ThrowWhenNull([NotNull] object? value, string valueExpression = "")
{
    _ = value ?? throw new ArgumentNullException(nameof(value), valueExpression);
    // other logic elided

En el código anterior se expresa con claridad el contrato existente: los autores de la llamada pueden pasar una variable con el valor null, pero se garantiza que el valor devuelto nunca será NULL.

Las condiciones posteriores condicionales se especifican mediante los atributos siguientes:

  • MaybeNull: un valor devuelto que no acepta valores NULL puede ser NULL.
  • NotNull: un valor devuelto que admite un valor NULL nunca será NULL.

Condiciones posteriores condicionales: NotNullWhen, MaybeNullWhen y NotNullIfNotNull

Es probable que esté familiarizado con el método string de String.IsNullOrEmpty(String). Este método devuelve true cuando el argumento es NULL o una cadena vacía. Es una forma de comprobación de valores NULL: No es necesario que los autores de la llamada comprueben los valores NULL del argumento si el método devuelve false. Para hacer que un método como este admita valores NULL, tendría que establecer el argumento en un tipo de referencia que admite un valor NULL y agregar el atributo NotNullWhen:

bool IsNullOrEmpty([NotNullWhen(false)] string? value)

Esto informa al compilador de que no es necesario comprobar los valores NULL en el código cuyo valor devuelto sea false. La adición del atributo informa al análisis estático del compilador que IsNullOrEmpty realiza la comprobación de valores NULL necesaria: cuando devuelve false, el argumento no es null.

string? userInput = GetUserInput();
if (!string.IsNullOrEmpty(userInput))
{
    int messageLength = userInput.Length; // no null check needed.
}
// null check needed on userInput here.

Nota

El ejemplo anterior solo es válido en C# 11 y versiones posteriores. A partir de C# 11, la expresión nameof puede hacer referencia a los nombres de parámetro y de tipo cuando se usan en un atributo aplicado a un método. En C# 10 y versiones anteriores, debe usar un literal de cadena en lugar de la expresión nameof.

El método String.IsNullOrEmpty(String) se anotará como se ha mostrado antes para .NET Core 3.0. Es posible que tenga métodos similares en el código base que comprueben valores NULL en el estado de los objetos. El compilador no reconocerá los métodos de comprobación de valores NULL personalizados y tendrá que agregar personalmente las anotaciones. Al agregar el atributo, el análisis estático del compilador sabe cuándo se han comprobado los valores NULL en la variable.

Otro uso de estos atributos es el patrón Try*. Las condiciones posteriores para los argumentos ref y out se comunican a través del valor devuelto. Considere este método mostrado anteriormente (en un contexto deshabilitado que admite un valor NULL):

bool TryGetMessage(string key, out string message)
{
    if (_messageMap.ContainsKey(key))
        message = _messageMap[key];
    else
        message = null;
    return message != null;
}

El método anterior sigue una expresión de .NET típica: el valor devuelto indica si message se ha establecido en el valor encontrado o, si no se ha encontrado ningún mensaje, en el valor predeterminado. Si el método devuelve true, el valor de message no es NULL; de lo contrario, el método establece message en NULL.

En un contexto habilitado que admite un valor NULL, puede comunicar esa expresión mediante el atributo NotNullWhen. Al anotar parámetros para los tipos de referencia que admiten un valor NULL, convierta message en string? y agregue un atributo:

bool TryGetMessage(string key, [NotNullWhen(true)] out string? message)
{
    if (_messageMap.ContainsKey(key))
        message = _messageMap[key];
    else
        message = null;
    return message is not null;
}

En el ejemplo anterior, se sabe que el valor de message no es NULL cuando TryGetMessage devuelve true. Debe anotar de la misma forma los métodos similares en el código base: los argumentos pueden ser iguales a null, y se sabe que no son NULL cuando el método devuelve true.

Hay un atributo final que también puede necesitar. En ocasiones, el estado NULL de un valor devuelto depende del estado NULL de uno o más argumentos. Estos métodos devolverán un valor no NULL siempre que determinados argumentos no sean null. Para anotar correctamente estos métodos, use el atributo NotNullIfNotNull. Observe el método siguiente:

string GetTopLevelDomainFromFullUrl(string url)

Si el argumento url no es NULL, el resultado no es null. Una vez habilitadas las referencias que admiten un valor NULL, debe agregar más anotaciones si la API puede aceptar un argumento NULL. Puede anotar el tipo de valor devuelto como se muestra en el código siguiente:

string? GetTopLevelDomainFromFullUrl(string? url)

Esto también funciona, pero a menudo obliga a los autores de la llamada a implementar comprobaciones de null adicionales. El contrato es que el valor devuelto solo será null cuando el argumento url sea null. Para expresar ese contrato, tendría que anotar este método como se muestra en el código siguiente:

[return: NotNullIfNotNull(nameof(url))]
string? GetTopLevelDomainFromFullUrl(string? url)

En el ejemplo anterior se usa el operador nameof para el parámetro url. Esa característica está disponible en C# 11. Antes de C# 11, deberá escribir el nombre del parámetro como una cadena. El valor devuelto y el argumento se han anotado con ?, lo que indica que cualquiera podría ser null. El atributo aclara todavía más que el valor devuelto no será NULL cuando el argumento url no sea null.

Las condiciones posteriores condicionales se especifican mediante estos atributos:

  • MaybeNullWhen: un argumento que no acepta valores NULL puede ser NULL cuando el método devuelve el valor bool especificado.
  • NotNullWhen: un argumento que admite un valor NULL nunca será NULL cuando el método devuelva el valor bool especificado.
  • NotNullIfNotNull: un valor devuelto no es NULL si el argumento del parámetro especificado no es NULL.

Métodos del asistente :MemberNotNull y MemberNotNullWhen

Estos atributos especifican su intención cuando se ha refactorizado código común de los constructores en métodos auxiliares. El compilador de C# analiza los constructores y los inicializadores de campo para asegurarse de que todos los campos de referencia que no aceptan valores NULL se han inicializado antes de que se devuelva cada constructor. Sin embargo, el compilador de C# no realiza un seguimiento de las asignaciones de campo a través de todos los métodos auxiliares. El compilador emite una advertencia CS8618 cuando los campos no se inicializan directamente en el constructor, sino en un método auxiliar. Agregue MemberNotNullAttribute a una declaración de método y especifique los campos que se inicializan en un valor distinto de NULL en el método. Por ejemplo, considere el siguiente ejemplo:

public class Container
{
    private string _uniqueIdentifier; // must be initialized.
    private string? _optionalMessage;

    public Container()
    {
        Helper();
    }

    public Container(string message)
    {
        Helper();
        _optionalMessage = message;
    }

    [MemberNotNull(nameof(_uniqueIdentifier))]
    private void Helper()
    {
        _uniqueIdentifier = DateTime.Now.Ticks.ToString();
    }
}

Puede especificar varios nombres de campo como argumentos para el constructor de atributo MemberNotNull.

MemberNotNullWhenAttribute tiene un argumento bool. Utiliza MemberNotNullWhen en situaciones en las que el método auxiliar devuelve bool, lo cual indica si el método auxiliar ha inicializado los campos.

Detención del análisis que admite un valor NULL cuando un método al que se le llama genera un valor

Algunos métodos, normalmente los asistentes de excepciones u otros métodos de utilidad, siempre salen mediante el inicio de una excepción. O bien, un asistente puede iniciar una excepción basada en el valor de un argumento booleano.

En el primer caso, puede agregar el atributo DoesNotReturnAttribute a la declaración del método. El análisis del estado NULL del compilador no comprueba ningún código de un método que siga una llamada a un método anotado con DoesNotReturn. Considere este método:

[DoesNotReturn]
private void FailFast()
{
    throw new InvalidOperationException();
}

public void SetState(object containedField)
{
    if (containedField is null)
    {
        FailFast();
    }

    // containedField can't be null:
    _field = containedField;
}

El compilador no emite ninguna advertencia después de la llamada a FailFast.

En el segundo caso, se agrega el atributo System.Diagnostics.CodeAnalysis.DoesNotReturnIfAttribute a un parámetro booleano del método. Puede modificar el ejemplo anterior de esta manera:

private void FailFastIf([DoesNotReturnIf(true)] bool isNull)
{
    if (isNull)
    {
        throw new InvalidOperationException();
    }
}

public void SetFieldState(object? containedField)
{
    FailFastIf(containedField == null);
    // No warning: containedField can't be null here:
    _field = containedField;
}

Cuando el valor del argumento coincide con el valor del constructor DoesNotReturnIf, el compilador no realiza ningún análisis de estado NULL después de ese método.

Resumen

Agregar tipos de referencia que aceptan valores NULL proporciona un vocabulario inicial para describir las expectativas de las API para las variables que podrían ser null. Los atributos proporcionan un vocabulario más completo para describir el estado NULL de las variables como condiciones previas y posteriores. Estos atributos describen con más claridad las expectativas y proporcionan una mejor experiencia para los desarrolladores que usan las API.

A medida que actualice las bibliotecas para un contexto que admite un valor NULL, agregue estos atributos para guiar a los usuarios de las API al uso correcto. Estos atributos ayudan a describir de forma completa el estado NULL de los argumentos y los valores devueltos.

  • AllowNull: un campo, parámetro o propiedad que no admite un valor NULL puede ser NULL.
  • DisallowNull: un campo, parámetro o propiedad que admite un valor NULL nunca debe ser NULL.
  • MaybeNull: un campo, parámetro, propiedad o valor devuelto que no acepta valores NULL puede ser NULL.
  • NotNull: un campo, parámetro, propiedad o valor devuelto que admite un valor NULL nunca será NULL.
  • MaybeNullWhen: un argumento que no acepta valores NULL puede ser NULL cuando el método devuelve el valor bool especificado.
  • NotNullWhen: un argumento que admite un valor NULL nunca será NULL cuando el método devuelva el valor bool especificado.
  • NotNullIfNotNull: un parámetro, propiedad o valor devuelto no es NULL si el argumento del parámetro especificado no es NULL.
  • DoesNotReturn; un método o propiedad nunca devuelve valores. Es decir, siempre inicia una excepción.
  • DoesNotReturnIf: este método nunca devuelve un valor si el parámetro bool asociado tiene el valor especificado.