Septembre 2019. Microsoft publie la version 8 du langage C#. Parmi son lot de nouveautés, une nouvelle fonctionnalité passe quasi-inaperçue : les nullable reference types.

Introduction

Septembre 2019. Microsoft publie la version 8 du langage C#. Parmi son lot de nouveautés, une nouvelle fonctionnalité pourrait presque passer inaperçue : les nullable reference types.

On parle bien de type référence nullable car les types valeurs nullable existent bien quant à eux depuis C# 2.

OK, me direz-vous. Mais une variable de type référence peut-être, et ce depuis toujours, null...

Présentation

Cette notion de type de référence nullable vs non-nullable permet de faire des affirmations importantes sur les propriétés des variables de type référence :

  • une référence n'est pas censée être nulle
  • une référence peut être nulle

Lorsque les variables ne sont pas censées être nulles, le compilateur applique des règles qui garantissent que ces variables soient bien initialisées à une valeur non nulle et que ces variables ne peuvent jamais recevoir la valeur nulle.

Lorsque les variables peuvent être nulles, le compilateur applique différentes règles pour garantir que vous avez correctement vérifié une référence nulle avant de l'utiliser.

Grâce à ce mécanisme, l'intention de conception peut être déterminée. Le compilateur va désormais être en mesure d'éliminer certaines classes d'erreurs liées à la nullité des références au stade de la compilation.

Mise en oeuvre

Activation

En introduction j'indiquais que cette fonctionnalité pourrait presque passer inaperçue. En effet, il s'agit d'un breaking change pour le langage. Par conséquent cette fonctionnalité doit être explicitement activée.

Il existe plusieurs méthodes d'activation :

  • activation "globale" (niveau projet)
  • activation "locale" (portion de code)

Si la fonctionnalité n'est pas déclarée, le compilateur lève le warning suivant :

string? nom = ObtenirNom(); // CS8632: L'annotation pour les types référence Nullable doit être utilisée uniquement dans le code au sein d'un contexte d'annotations '#nullable'.

Activation globale

Afin d'activer la fonctionnalité au niveau d'un projet, il suffit de le déclarer dans le .csproj :

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

En combinaison avec la méthode d'activation locale, il est possible de désactiver la fonctionnalité sur une portion de code (cf. activation locale).

Activation locale

3 directives principales permettent l'activation / désactivation locale de la prise en charge des types référence nullable :

  • #nullable disable: définit les contextes d'annotation et d'avertissement des types références nullable sur désactivé
  • #nullable enable: définit les contextes d'annotation et d'avertissement des types références nullable sur activé
  • #nullable restore: restaure les contextes d'annotation et d'avertissement des types références nullable à la valeur définie au niveau projet (cf. activation globale)

Voici un exemple d'utilisation de ces directives sur un portion de code :

#nullable enable
    string? nom = ObtenirNom();
#nullable restore

Résumé

En résumé il est possible d'obtenir une grande finesse sur les portions de code pour lesquelles activer ou non les types références nullable.

Pour un projet "from scratch" je conseille de partir sur une activation globale de cette fonctionnalité. Et, au cas par cas, si des contraintes sont rencontrées, de désactiver localement la fonctionnalité.

Syntaxe

Pour commencer...

Une fois la fonctionnalité activée, voici un exemple de code avec en commentaire les warnings remontés par le compilateur :

static void Main(string[] args) {
  string firstName = "Sylvain";
  string lastName = null; // CS8600: Conversion de littéral ayant une valeur null ou d'une éventuelle valeur null en type non-nullable.
  WriteFullName(firstName, lastName); // CS8604: Existence possible d'un argument de référence null pour le paramètre 'lastName' dans 'void WriteFullName(string firstName, string lastName)'.
}

static void WriteFullName(string firstName, string lastName) {
  Console.WriteLine($"{firstName.ToTitleCase()} {lastName.ToTitleCase()}");
}

Le premier warning (ligne 3) indique que l'on tente d'assigner la valeur null à un type string non nullable (lastName). Le second warning (ligne 4) indique qu'une variable dont la valeur est null est passée en paramètre à une méthode dont l'argument n'est pas nullable (lastName).

Indiquons maintenant au compilateur notre intention. Imaginons que nous souhaitions qu'il soit effectivement possible que le nom soit nul :

static void Main(string[] args) {
  string firstName = "Sylvain";
  string? lastName = null;
  WriteFullName(firstName, lastName);
}

static void WriteFullName(string firstName, string? lastName) {
  Console.WriteLine($"{firstName.ToTitleCase()} {lastName.ToTitleCase()}"); // CS8604: Existence possible d'un argument de référence null pour le paramètre 's' dans 'string? StringExtensions.ToTitleCase(string s)'
}

En ajoutant un ? (ligne 3 et 7) nous indiquons au compilateur que la valeur peut-être nulle.

On voit alors apparaître un nouveau warning levé par le compilateur : CS8604. En effet la méthode ToTitleCase prend un paramètre non nullable. Il ne nous reste plus qu'à corriger le problème :

static void Main(string[] args) {
  string firstName = "Sylvain";
  string? lastName = null;
  WriteFullName(firstName, lastName);
}

static void WriteFullName(string firstName, string? lastName) {
  Console.WriteLine($"{firstName.ToTitleCase()} {lastName?.ToTitleCase()}");
}

Cas particulier des génériques

Les types génériques sont particuliers car ils peuvent représenter des types valeur (struct) ou des types référence (class). Cela signifie que vous ne pouvez pas utiliser T? pour représenter un type nullable car il entrerait en conflit avec le Nullable<T> existant lorsque le générique est un type valeur.

Pour utiliser cette syntaxe, vous devez contraindre le générique à un type de référence ou à un type de valeur. Il est cependant possible d'utiliser certains attributs pour gérer des types génériques non contraints (cf. paragraphe Préciser les post-conditions).

Activer la suppression de l’avertissement d’analyse du flux statique

Parfois, le compilateur n'est pas assez intelligent pour détecter que quelque chose n'est pas nul dans le contexte et vous savez mieux que lui que quelque chose n'est pas nul. Dans ce cas, il est possible d'utiliser l'opérateur null-forgiving (!). Le compilateur considérera la valeur comme non nullable et supprimera les avertissements.

public void DoSomething(string? s) {
  AssertIsNotNull(s);

  Process(s); // CS8604: Existence possible d'un argument de référence null pour le paramètre 's' dans 'void Cours.Process(string s)'. (103, 12)
}

private void AssertIsNotNull(string? s) {
  if (s == null) { throw new ArgumentNullException(); }
}

private void Process(string s) {
  // do some smart processing of s...
}

Afin de supprimer le warning, il suffit de mettre à jour la ligne 4 comme suit :

  Process(s!);

Il est également possible d'utiliser l'opérateur null-forgiving dans le cadre du pattern DTO.

public class Project {
    [Required]
    public string Name { get; set; } // CS8618: Initialisation annulée pour le/la propriété 'Name' non-nullable. Déclarez le/la propriété comme étant nullable. (102, 18)
    [Required]
    public string Shipyard { get; set; } // CS8618: Initialisation annulée pour le/la propriété 'Name' non-nullable. Déclarez le/la propriété comme étant nullable. (102, 18)
    public string? Description { get; set; }
}

Lorsque l'on est sûr que les propriétés ne seront jamais nulles, il est possible de les compléter comme-suit :

public class Project {
    [Required]
    public string  Name        { get; set; } = default!;
    [Required]
    public string  Shipyard    { get; set; } = default!;
    public string? Description { get; set; }
}

Il est également possible, comme nous l'avons vu précédemment, de désactivé localement le contrôle.

#nullable disable

public class Project {
    [Required]
    public string Name        { get; set; }
    [Required]
    public string Shipyard    { get; set; }
    public string Description { get; set; }
}

#nullable restore

Préciser les pré-conditions

Attribut AllowNull

Soit une propriété en lecture / écriture qui ne renvoie jamais null car elle possède une valeur par défaut. Lorsque les appelants transmettent null à l'accesseur alors la valeur par défaut est appliquée :

public Person Manager {
	get => _manager;
	set => _manager = value ?? Person.Unknown;
}

private Person _manager;

public void FireManager() {
	Manager = null; // CS8625: Impossible de convertir un littéral ayant une valeur null en type référence non-nullable
}

Dans un contexte reference type nullable un warning apparaît (CS8625).

Pour aider le compilateur il suffit de placer l'attribut AllowNullAttribute sur la propriété :

[AllowNull]
public Person Manager {
	get => _manager;
	set => _manager = value ?? Person.Unknown;
}

L'attribut AllowNull spécifie les conditions préalables et s'applique uniquement aux entrées. L'intention précisé par cet attribut est que :

  • le contrat général pour cette variable est qu'elle ne doit pas être nulle (on utilise donc un type de référence non nullable)
  • il existe des scénarios pour lesquels la variable d'entrée peut-être nulle, bien que ce ne soit pas l'utilisation la plus courante

Cet attribut sera utilisé le plus souvent pour les propriétés ou pour les arguments in, out et ref. L'attribut AllowNull est le meilleur choix lorsqu'une variable est généralement non nulle.

NB : Cet attribut peut être également utilisé pour les paramètres références pour le même cas d'utilisation.

public void DoSomethingSmart([AllowNull]ref string value) {

Attribut DisallowNull

Vous pouvez également faire le contraire. Une propriété peut interdire null dans le setter mais peut retourner une valeur nullable dans le getter.

[DisallowNull]
public Person? Manager {
	get => _manager;
	set => _manager = value ?? throw new ArgumentNullException();
}

public void DoSomethingSmart([DisallowNull] ref string value) { 
...

En résumé...

  • AllowNull: un argument d'entrée non nullable peut être null
  • DisallowNull: Un argument d'entrée nullable ne doit jamais être null.

Préciser les post-conditions

Vous pouvez utiliser les attributs NotNull et MaybeNull pour exprimer la nullité de la valeur de retour ou la nullité des paramètres out / ref.

Quel intérêt puisqu'il existe désormais une déclaration de type nullable (?) ? Ces attributs vont être utiles avec des types génériques non contraints
car ne pouvez pas utiliser T? (cf. paragraphe Cas particulier des génériques). Ils permettent également certaines constructions de code (pas forcément recommandables) avec les paramètres ref.

Attribut NotNull

L'exemple suivant montre l'utilisation de l'attribut avec un argument ref. On imagine ici que la méthode définie une personne en fonction de certaines conditions.

public void ItSmell() {
	Person? person = null;
	DoSomethingStrange(ref person, Conditions.None);
	Console.WriteLine(person.Name);
}

public void DoSomethingStrange([NotNull] ref Person? person, Conditions conditions) { ... }

On comprend très clairement l'intention : les appelants de la méthode DoSomethingStrange peuvent passer une variable person nulle mais la valeur retournée est garantie non-nulle.

Attribut MayBeNull

Soit un repository possédant la déclaration de méthode suivante :

 public FaceArrangement GetBy(FaceArrangementId id);

Un retour null indique clairement que l'enregistrement n'a pas été trouvé. Dans cet exemple, si l'on souhaite déclarer l'intention il suffit de changer le type de retour de FaceArrangement en FaceArrangement?.

Pour les raisons mentionnée dans le paragraphe Cas particulier des génériques, cette technique ne fonctionne pas avec les méthodes génériques. On peut cependant s'en sortir grâce à l'attribut MayBeNull.

[return: MaybeNull]
public T GetBy<TEntity, TId>(TId id);

En résumé...

  • MaybeNull: une valeur de retour non nullable peut être nulle.
  • NotNull: une valeur de retour nullable ne sera jamais nulle.

Préciser les post-conditions conditionnelles

Il existe certaines méthodes pour lesquelles un argument out peut être nul ou non en fonction de la valeur de retour. Par exemple, Version.TryParse garantit que l'argument version n'est pas nul si la méthode renvoie true. Vous pouvez également vouloir indiquer qu'une valeur peut être nulle lorsque la valeur de retour est false. C'est le cas de Dictionary.TryGetValue. Pour exprimer cela, vous pouvez utiliser [NotNullWhen]et [MaybeNullWhen]. La dernière post-condition conditionnelle est [NotNullIfNotNull]. Il indique que la valeur de retour n'est pas nulle lorsqu'un paramètre spécifique n'est pas nul.

Attribut NotNullWhen

Soit la méthode IsNullOrUnknown qui retourne true si un objet de type Person est nul ou s'il représente une personne inconnue. Son utilisation dans le code suivant génère le warning CS8602 :

public void SendAnEmailTo(Person? somebody) {
	if (IsNullOrUnknown(somebody)) { return; }

	Email email = somebody.Email; // CS8602: Déréférencement d'une éventuelle référence null. (123, 18)

	// ...
}

public bool IsNullOrUnknown(Person? person) {
	if (person == null || person == Person.Unknown) { return true; }
	return false;
}

En effet, le compilateur est dans l'incapacité de faire le lien entre les assertions de la méthode IsNullOrUnknown et le fait que la variable somebody n'est pas nulle. Le problème est résolu grâce à la déclaration d'intention suivante :

public bool IsNullOrUnknown([NotNullWhen(returnValue:false)] Person? person) {
	return person == null || person == Person.Unknown;
}

Cet attribut peut également être utilisé pour des variables ref ou out :

bool TryParseFaceArrangementId(string s, [NotNullWhen(true)] out FaceArrangementId? id)

Attribut MaybeNullWhen

Reprenons l'exemple en introduction des post-conditions conditionnelles:

public bool TryGetValue(TKey key, [MaybeNullWhen(returnValue: false)]out TValue result) {
    // ...
}

L'intention est clairement définie : si la méthode TryGetValue retourne false alors il est possible que l'argument result soit nul.

Attribut NotNullIfNotNull

Dans l'exemple suivant, un chemin de fichier null est autorisé ; une extension aussi. On comprend facilement que le fait que le chemin de fichier soit null implique un chemin de fichier en retour null aussi.

[return: NotNullIfNotNull(parameterName: "path")]
public static string? ChangeExtension(string? path, string? extension) {
    // ...
}

En résumé...

  • MaybeNullWhen: un argument d'entrée non nullable peut être null lorsque la méthode renvoie la valeur booléenne spécifiée
  • NotNullWhen: un argument d'entrée nullable ne sera pas nul lorsque la méthode renvoie la valeur booléenne spécifiée
  • NotNullIfNotNull: une valeur de retour n'est pas nulle si l'argument d'entrée pour le paramètre spécifié n'est pas nul

Contrainte générique notnull

La contrainte notnull permet d'empêcher qu'un type générique soit nullable. L'exemple suivant se base sur une classe bien connue de tous : le dictionnaire.

public class MyDictionary<TKey, TValue> : IDictionary<TKey, TValue> where TKey : notnull { ...

Ici TKey dans MyDictionary ne peut pas être null grâce à la contrainte notnull.

Warnings ou erreurs ?

Je ne sais pas pour vous, mais de mon côté je peux compter sur les doigts d'une main le nombre de projets sur lesquels je suis intervenus et pour lesquelles les équipes se souciaient des warnings émis par le compilateur.

Les exceptions de référence nulle non gérées ont vraiment tendance à rapidement ruiner nos applications. Renforcer le respect des règles de compilation associées au types référence nullable peut-être un excellent choix.

Il est possible de changer l'action associée à une règle de compilation en la passant de warning à error. Que ce soit via le fichier ruleset ou via le fichier editorconfig, le résultat est le même.

Ci dessous, une capture d'écran de la fenêtre associé au ruleset d'un projet. En recherchant null dans cette fenêtre vous trouverez l'ensemble (voir un peu plus attention) des règles qui nous intéressent.

Recherche des règles comportant le terme null dans un fichier ruleset.

Ci-dessous, un exemple de fichier .editorconfig contenant quelques modifications d'actions associées à des règles de compilation liées aux type reference nullable.

# CS8601: Possible null reference assignment.
dotnet_diagnostic.CS8601.severity = error

# CS8618: Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable.
dotnet_diagnostic.CS8618.severity = error

# CS8620: Argument cannot be used for parameter due to differences in the nullability of reference types.
dotnet_diagnostic.CS8620.severity = error

# CS8600: Converting null literal or possible null value to non-nullable type.
dotnet_diagnostic.CS8600.severity = error

# CS8766: Nullability of reference types in return type doesn't match implicitly implemented member (possibly because of nullability attributes).
dotnet_diagnostic.CS8766.severity = error

# CS8631: The type cannot be used as type parameter in the generic type or method. Nullability of type argument doesn't match constraint type.
dotnet_diagnostic.CS8631.severity = error

L'activation du mode reference type nullable associé au changement des warnings en erreurs de compilation pour les règles liées est un outil extrêmement puissant pour lutter contre la prolifération des exceptions de type NullReferenceException.

Avant C# 8

Ceux qui utilisent l'outil Resharper connaissent peut-être la librairie JetBrains.Annotations. Elle fournie les attributs suivants :

  • [NotNull]
  • [CanBeNull]
  • [ItemNotNull]
  • [ItemCanBeNull]
  • [ContractAnnotation]

Elle est très utile lors du développement de projet basés sur une version inférieure de C# afin de proposer le même comportement:

[ContractAnnotation("null=>null;notnull=>notnull")
public FaceModel Map([CanBeNull] FaceEntity entity) {
    if (entity == null) { return null; }

    FaceModel model = new FaceModel();
    // un peu de mapping ici...

    return model;
}

Combiné avec un check systèmatique des arguments de méthodes publiques, le risque de NullReferenceException tombe pratiquement à zéro.

public class SomeClass {

  public void DoSomething(
    [NotNull] object objWhichShouldNotBeNull, 
    object objWhichCouldBeNull) {
    if (objWhichShouldNotBeNull == null) { throw new ArgumentNullException(nameof(objWhichShouldNotBeNull)); }

    string s = GetString(objWhichShouldNotBeNull);
    
    // ...
  }

  private string GetString(object objWhichShouldNotBeNull) {
    // ...
  }

}

Si vous êtes intéressés par cette librairie plusieurs liens sont disponibles dans la section suivante.

Pour en savoir plus

Voici quelques liens utiles pour aller plus loin avec les types référence nullable :

Ci-dessous des liens vers de la documentation sur la librairie JetBrains.Annotations :

Et encore: