Introduction

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

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

OK, me direz-vous. Mais un type référence peut-être, et ce depuis toujours, nullable

Présentation

C# 8.0 a donc introduit des types de référence nullable et des types de référence non-nullable qui permettent 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 : adios NullReferenceException ?

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 « classique » 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()}");
}

Bien, 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; }
}

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 postconditions

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 postconditions conditionnelles

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

Attribut NotNullIfNotNull

En cours de rédaction…

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 à ruiner nos applications à partir d’un moment donné. Renforcer le respect des règles de compilation associées au types référence nullable peut-être un excellent choix.

L’article suivant présente comment modifier la sévérité des règles de compilation pour votre solution.

En recherchant null dans la fenêtre de votre ruleset 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.

JetBrains.Annotations

Ceux qui utilisent l’outil Resharper doivent certainement connaître la librairire JetBrains.Annotations. Cette librairie fournie les attributs suivants :

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

Voici un exemple d’utilisation de cette librairie en .NET Framework :

[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;
}

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 :