Types références Nullables

Tip

Vous débutez avec le développement de logiciels ? Commencez par les didacticiels De prise en main .

Maîtrisez-vous une autre langue ? Si vous avez utilisé les types Nullable de Kotlin, les options de TypeScript strictNullChecksou Swift, le modèle est familier. C# utilise des diagnostics d’analyse statique et d’avertissement au lieu d’un type distinct. Parcourez rapidement Exprimer l’intention à l’aide d’annotations et Analyse de l’état de nullité, puis passez au didacticiel : exprimer votre intention de conception avec des types référence nullable et non nullable pour mettre cette fonctionnalité en pratique.

Les types de référence nullables sont un groupe de fonctionnalités qui réduisent le risque que votre code lève System.NullReferenceException. Vous déclarez quelles variables sont destinées à contenir null et qui ne le sont pas, et le compilateur avertit quand ces déclarations ne correspondent pas à la façon dont votre code les utilise. Le comportement d’exécution de votre programme n’est pas modifié. Les types de référence nullables sont entièrement une fonctionnalité au moment de la compilation.

Trois blocs de construction fonctionnent ensemble :

  • Les annotations variables (string vs. string?) expriment les références destinées à autoriser null.
  • L’analyse de l’état Null suit si la valeur d’une expression n’est pas null ou peut-être null à chaque point de votre code.
  • Les attributs sur les API décrivent des contrats plus nuanceux, tels que « cet argument peut être null, mais la valeur de retour est null uniquement lorsque l’argument est null ».

Le compilateur combine ces signaux pour produire des diagnostics. Les avertissements sur une variable non nullable signifient que la variable peut recevoir null. Les avertissements sur une variable nullable signifient que le code peut le déréférer sans vérification null. Dereference signifie utiliser la valeur à laquelle la variable fait référence. Par exemple, pour appeler une méthode sur celle-ci (variable.Method()), lire une propriété (variable.Property) ou l’indexer (variable[0]). Le déréférencement d’une variable ayant pour valeur null lève une exception à l’exécution. L’un ou l’autre type d’avertissement signifie que le comportement du code ne correspond pas à sa conception indiquée.

Contexte pouvant accepter la valeur Null

Les projets créés à partir de modèles .NET récents définissent <Nullable>enable</Nullable> dans le fichier de projet, de sorte que les indications de cet article s’appliquent telles quelles. Si vous travaillez dans un projet plus ancien, ouvrez le .csproj et vérifiez que le <PropertyGroup> contient la ligne suivante ; ajoutez-la si elle n’y figure pas :

<Nullable>enable</Nullable>

Pour plus d’informations sur la migration d’une grande application, consultez l’article sur les stratégies de migration nullables pour plus de paramètres et de directives.

Exprimer l’intention à l’aide d’annotations

Chaque variable de type référence n’est pas nullable par défaut. Ajoutez ? pour déclarer un type de référence nullable :

public static void Annotations()
{
    string required = "always set";   // non-nullable: assigning null produces a warning
    string? optional = null;          // nullable: holding null is allowed

    Console.WriteLine(required.Length);

    if (optional is not null)
    {
        Console.WriteLine(optional.Length);
    }
}

L’annotation ne modifie pas le type d’exécution. string et string? sont tous les deux System.String. ? informe le compilateur de vos intentions de conception. Cette intention forme les avertissements générés par le compilateur :

  • Une variable non nullable a un état de nullité par défaut de non null. Le compilateur avertit si vous affectez une valeur qui peut être null.
  • Une variable nullable a un état null par défaut de peut-être null. Le compilateur avertit si vous déréférencez la variable sans la vérifier d’abord.

Utilisez l’annotation pour rendre les valeurs obligatoires et facultatives visibles dans le système de type. Le type suivant Person déclare FirstName et LastName comme non nuls — chaque personne doit avoir les deux — et MiddleName comme nullable, car tout le monde n’en a pas.

public sealed class Person(string firstName, string lastName)
{
    public string FirstName { get; } = firstName;
    public string? MiddleName { get; init; }
    public string LastName { get; } = lastName;

    public override string ToString() => MiddleName is null
        ? $"{FirstName} {LastName}"
        : $"{FirstName} {MiddleName} {LastName}";
}

public static void DesignIntent()
{
    Person p1 = new("Ada", "Lovelace") { MiddleName = "King" };
    Console.WriteLine(p1);
    // Output: Ada King Lovelace

    Person p2 = new("Grace", "Hopper");
    Console.WriteLine(p2);
    // Output: Grace Hopper
}

Les annotations pilotent l’implémentation de ToString. Étant donné que FirstName et LastName sont non null, la redéfinition les utilise directement dans une chaîne interpolée (la syntaxe $"..." qui intègre des expressions dans des espaces réservés {}) sans vérification de la valeur null. MiddleName peut être null, donc la redéfinition le compare d’abord à null et ne l’inclut que s’il est présent. Le compilateur fait respecter cette distinction : le code qui transmet une valeur potentiellement nulle là où une valeur non nullable est attendue génère un avertissement, et un constructeur qui laisse un membre non nullable non initialisé génère également un avertissement.

Analyse de l’état nul

Le compilateur effectue le suivi de l’état null de chaque expression. L’état est l’une des deux valeurs suivantes :

  • not-null : l’expression est connue pour ne pas être null.
  • peut-être null : l’expression peut être null.

L’état null d’une variable locale est mis à jour, car le compilateur analyse votre code. Deux choses le modifient : les affectations et les vérifications de nullité. Après une affectation, l’état null de la variable correspond à l’expression du côté droit. Si l’expression est null ou nullable, la variable devient peut-être null. Si l’expression est un littéral non null, la variable devient non null. Après un test de valeur null, l’état de nullité de la variable reflète la branche empruntée.

public static void NullStateTracking()
{
    string? message = null;

    // Warning: dereference of a possibly null reference.
    Console.WriteLine(message.Length);

    message = "Hello, World!";

    // No warning: the compiler tracks that message is now not-null.
    Console.WriteLine(message.Length);
}

Dans l’exemple précédent, le premier déréférencement génère un avertissement, car message est susceptible d’être null. Après l’affectation à un littéral non null, le compilateur sait messagequ’il n’est pas null, de sorte que la deuxième déréférencement est sécurisée.

L’analyse des valeurs Null s’applique aux vérifications if, à la correspondance de motifs (expressions telles que is null ou is { } qui testent la structure d’une valeur), ainsi qu’au flux de contrôle qui boucle ou quitte prématurément :

 public sealed class Node(string name)
 {
     public string Name { get; } = name;
     public Node? Parent { get; init; }
 }

 public static void FlowAnalysis(Node start)
 {
     Node? current = start;
     while (current is not null)
     {
         // Inside the loop, the compiler knows current is not-null.
         Console.WriteLine(current.Name);

         current = current.Parent;
     }
}

L’analyse n’entre pas dans le corps des méthodes. Si vous avez besoin d’une méthode pour communiquer l’état Null à ses appelants, utilisez des attributs d’analyse nullables sur sa signature.

Remplacer les avertissements par !

Parfois, vous savez plus que le compilateur. L’opérateur ! déclare qu’une expression n’est pas null, même si l’analyse indique sinon :

public static void NullForgiving()
{
    // "ada" matches a switch arm that returns a non-null string,
    // but the return type is string? so the compiler treats the
    // result as maybe-null.
    string? maybeName = LookUpName("ada");

    // The ! tells the compiler "trust me, this isn't null." We just
    // passed "ada", which the switch maps to "Ada Lovelace".
    int length = maybeName!.Length;
    Console.WriteLine(length); // => 12
}

// Returns string? because the wildcard arm yields null.
private static string? LookUpName(string id) => id switch
{
    "ada" => "Ada Lovelace",
    _ => null,
};

Utilisez ! avec parcimonie. Chaque occurrence est un endroit où le compilateur ne peut plus vous protéger. Préférez ajouter une vérification null, restructurer le code ou annoter l’API appropriée afin que le compilateur atteigne la bonne conclusion en soi.

Attributs qui décrivent les contrats d’API

Les annotations sur un paramètre ou un type de retour ne sont pas toujours expressives. Une méthode peut accepter un argument éventuellement null, mais garantir un résultat non null. Une méthode de test peut retourner true uniquement lorsque son argument n’est pas null. Utilisez les attributs d’analyse de nullabilité pour exprimer ces contrats :

public static bool IsPresent([NotNullWhen(true)] string? value) =>
    !string.IsNullOrEmpty(value);

public static void NullAnalysisAttributes()
{
    string? input = ReadInput();

    if (IsPresent(input))
    {
        // No null-forgiving operator needed: the attribute tells the compiler
        // input is not-null when IsPresent returns true.
        Console.WriteLine(input.Length);
    }
}

private static string? ReadInput() => "hello";

NotNullWhenAttribute indique au compilateur que lorsque IsPresent retourne true, l’argument est non null. À l’intérieur du bloc if, le compilateur traite value comme non nul, sans qu’aucun opérateur d’annulation de la valeur null ne soit nécessaire. À partir de .NET 5, toutes les API d’exécution .NET sont annotées, de sorte que l’analyse bénéficie de tout code qui les appelle.

Pièges connus

Deux schémas peuvent laisser une référence non nullable contenir null sans générer d’avertissement. Les deux modèles sont des limitations de l’analyse statique, et non des bogues dans votre code.

structures par défaut

Vous pouvez créer un struct avec des champs de référence non nullables à l’aide default ou new(). Cette approche laisse les champs du struct non initialisés :

public struct Student
{
    public string FirstName;
    public string? MiddleName;
    public string LastName;
}

public static void DefaultStructPitfall()
{
    Student s = default;            // No warning, but FirstName and LastName are null.
    Console.WriteLine(s.FirstName?.Length ?? -1);
}

Les champs sont suspendus null au moment de l’exécution, mais le compilateur n’avertit pas. Si vous devez utiliser un struct, préférez les membres requis, qui sont les membres que l’appelant doit initialiser via un initialiseur d’objet, ou un constructeur paramétrable que les appelants doivent appeler.

Tableaux de références et de structures

Un nouveau tableau d’un type de référence non nullable contient tous les éléments null tant que vous n’avez pas assigné une valeur à chacun d’eux :

public static void ArrayPitfall()
{
    string[] values = new string[3];      // Elements are null at run time.
    Console.WriteLine(values[0]?.Length ?? -1);

    string[] initialized = ["a", "b", "c"]; // Collection expression initializes every slot.
    Console.WriteLine(initialized[0].Length);
}

Le même piège s’applique aux tableaux de structs : chaque élément commence comme valeur par défaut du struct, de sorte que les champs de référence non nullables de chaque élément commencent comme null.

Initialisez les éléments du tableau lors de sa création. Les expressions de collection (la syntaxe littérale [1, 2, 3]) et dont le type est déduit à partir de la cible new (écrire new() lorsque le compilateur peut déduire le type) permettent une initialisation complète concise.