C# – « Nullabilité »

Quelques raccourcis :

Avec C#, comme avec Java et certains autres langages OO (pas tous, évidemment), seuls les accès indirects aux objets sont permis. Certains nomment ces indirections des références, d'autres les nomment pointeurs, mais l'idée est la même. Illustré à la manière C#, dans l'extrait suivant :

X x = new X();

... la variable x n'est pas un X mais bien une référence sur un X. De même, dans l'extrait suivant :

void F(X x)
{
   // ... utiliser x ...
}

... la variable x n'est pas un X mais bien une référence sur au moins un X. En effet, du fait que l'accès à travers une référence est indirect, la variable x peut référer à un X, bien sûr, mais aussi à n'importe quelle classe en dérivant. Ainsi, dans ce qui suit :

F(new X()); // Ok
F(new Y()); // Ok, un Y est un X
F(Gen()); // Ok, passe au moins un Y à F()

static void F(X x)
{
  // ... utiliser x ...
}
// ...
static Y Gen()
{
   // ...
}
class X
{
   // ...
}
class Y : X
{
   // ...
}

... la méthode F() se fait d'abord passer un X en paramètre, puis un Y (qui est aussi un X), puis quelque chose qui est au moins un Y. Toutefois, à moins de tricher le système de types par un transtypage, la méthode F() se limitera à l'interface d'un X pour utiliser la variable x qui lui sera passée. Cette pratique est une forme classique de polymorphisme.

Une conséquence de ce modèle où tout est indirect, outre les coûts en termes d'espace en mémoire et en termes de temps d'exécution que vous pouvez vous amuser à mesurer, est que les entités manipulées en pratique dans un programme ne sont pas des objets mais bien des références (ou des pointeurs) sur des objets, et que ces références sont typiquement susceptibles d'être nulles (il y a des nuances à cette affirmation, nous y reviendrons). L'exemple suivant l'illustre tout simplement :

F(Gen());

static void F(X x)
{
   if (x != null) // <-- ICI
   {
      // ... utiliser x ...
   }
}
// ...
static Y Gen()
{
   return null; // <-- ICI
}
class X
{
   // ...
}
class Y : X
{
   // ...
}

Cette prolifération de tests comparant une référence avec null a mené à la mise en place de mécanismes spécifiquement pensée pour alléger l'écriture.

L'opérateur de null-propagation : ?.

La forme suivante :

// ...
class X
{
   // ...
   public void F()
   {
      // ...
   }
   // ...
}
static void F(X x)
{
   // DÉBUT
   if (x != null)
   {
      x.F();
   }
   // FIN
}
// ...

...où une fonction F() est appelée à travers une référence x seulement si x!=null, est suffisamment commune en C# pour être représentée par un opérateur dit de null-propagation, soit l'opérateur ?. (aussi appelé opérateur « Elvis » du fait que sa forme évoque la coiffure du célèbre chanteur). L'écriture équivalente est :

Test manuel Accès avec ?.
if (x != null)
{
   x.F();
}
x?.F();

Ainsi, l'écriture de code utilisant une référence possiblement nulle peut être allégée. Notez que, si plusieurs utilisations des services d'une référence potentiellement nulle sont faites dans une même fonction, il peut être avantageux de faire un seul test initialement plutôt que de saupoudrer des appels à travers ?. ici et là (ce qui, en pratique, répéterait le test inutilement à plusieurs reprises).

Notez que, si la méthode appelée à travers ?. n'est pas void, alors le type retourné est lui-même considéré « nullables », même s'il s'agit d'un primitif (voir ceci pour plus d'informations) :

using System;

X x = new Random().Next(0,2) == 0? new X() : null; // new X();
int n0 = x.F(); // légal, mais risqué (lèvera une exception si x == null)
// int n1 = x?.F(); // illégal : retourne techniquement un int? (transtypage requis)
int n2 = (int) x?.F(); // légal; toutefois, utiliser n2 lèvera une exception si x était nul au moment de l'appel à x?.F()

class X
{
   public int F()
   {
      return 3;
   }
}

L'opérateur null-coalescing : ??

L'opérateur ?. est souvent combiné avec l'opérateur ??, qui permet de mettre en place un « plan B » dans le cas où la référence était nulle. À titre comparatif, supposant que la méthode F() ci-dessous retourne un int :

Test manuel Accès avec ??
int n; // pas initialisé (snif!)
if (x != null)
{
   n = x.F();
}
else
{
   n = -1;
}
int n = x?.F() ?? -1;

Reprenant l'exemple à la fin de la section précédente, nous obtenons :

using System;

X x = new Random().Next(0,2) == 0? new X() : null; // new X();
int n0 = x.F(); // légal, mais risqué (lèvera une exception si x == null)
// int n1 = x?.F(); // illégal : retourne techniquement un int? (transtypage requis)
int n2 = (int) x?.F(); // légal; toutefois, utiliser n2 lèvera une exception si x était nul au moment de l'appel à x?.F()
int n3 = x?.F() ?? -1; // Ok : on aura la valeur retournée par x.F() si x != null et -1 dans le cas contraire

class X
{
   public int F()
   {
      return 3;
   }
}

Primitifs « nullables »

Comme mentionné plus haut, C# permet les primitifs « nullables ». Par exemple, un int pourra entreposer les valeurs allant de int.MinValue à int.MaxValue inclusivement, alors qu'un int « nullable » pourra aussi être null. Un int « nullable » s'écrit int? et peut être converti en un int par voie de transtypage.

L'opérateur null-coalescing peut être utilisé pour obtenir une valeur par défaut dans le cas où une valeur nullable serait bel et bien nulle :

using System;

X x = new Random().Next(0,2) == 0? new X() : null; // new X();
int? n = x?.F();
Console.WriteLine(n ?? -1); // <-- ICI

class X
{
   public int F()
   {
      return 3;
   }
}

Ainsi, supposant la méthode DivisionEntière() suivante, qui souhaite éviter une division par zéro, retourner un entier nullable peut être une alternative à une levée d'exception dans les cas où les exceptions ne seraient pas jugées appropriées :

Avec levée d'exception Avec valeur de retour « nullable »
using System;
  
if (int.TryParse(Console.ReadLine(), out num) && 
    int.TryParse(Console.ReadLine(), out denom))
{
   try
   {
      Console.WriteLine(DivisionEntière(num, denom));
   }
   catch(DivisionParZéro)
   {
      Console.WriteLine("Tentative de division par zéro");
   }
}

static int DivisionEntière(int num int dénom)
{
   if (dénom == 0)
   {
      throw new DivisionParZéro();
   }
   return num / dénom;
}

class DivisionParZéro : ApplicationException
{
}
using System;
  
if (int.TryParse(Console.ReadLine(), out num) && 
    int.TryParse(Console.ReadLine(), out denom))
{
   int? quotient = DivisionEntière(num, denom);
   if(quotient != null)
   {
      Console.WriteLine((int) quotient);
   }
   else
   {
      Console.WriteLine("Tentative de division par zéro");
   }
}

static int? DivisionEntière(int num int dénom)
{
   if (dénom == 0)
   {
      return null;
   }
   return num / dénom;
}

« nullabilité » sur demande seulement

Ce qui suit ne s'applique qu'à partir de C# v. 8.0 

La prolifération de tests de références pour vérifier si elles sont nulles ou pas empoisonne le code de langages où les objets ne sont accédés qu'indirectement. Suivant le leadership du langage Eiffel entre autres, C# vise à partir de la version 8.0 du langage à se rapprocher de l'idée d'être null-safe, au sens où les références ne seront plus potentiellement nulles, sauf si elles sont explicitement identifiées comme telles. De cette manière, il ne sera plus utile de tester contre null plusieurs références; seules celles qui sont susceptibles d'être nulles devront être validées, et leur type l'indiquera explicitement.

Ainsi, prenant pour exemple le type string, avant C# v. 8.0 :

string s0 = "J'aime mon prof"; // Ok
string s1 = null; // Ok

... alors qu'à partir de C# v. 8.0 :

string s0 = "J'aime mon prof"; // Ok
// string s1 = null; // incorrect
string? s2 = null; // Ok

Il y aura une transition pour éviter de briser trop violemment le code existant : rendre nulle une référence qui n'est pas explictement « nullable » mènera initialement à un avertissement à la compilation, et cet avertissement deviendra éventuellement une erreur.

Opérateur « null-forgiving »

Que faire, une fois que le passage à la nullabilité sur demande seulement sera fait, si l'on souhaite tout de même passer une référence nulle (ou potentiellement nulle) à une fonction acceptant une référence non-nullable (par exemple pour fins de tests), ou encore quand on sait qu'une référence nullable est non-nulle mais que le compilateur, lui, ne le sait pas?

Pour cela, C# offre l'opérateur « null-forgiving ». En effet, désormais, l'exemple suivant génèrera un avertissement :

// ...
Personne? p = Trouver("Bertrand");
if (EstValide(p))
   Console.WriteLine($"J'ai trouvé {p.Nom}"); // <-- ICI

static Personne Trouver(string nom)
{
   // ...
}
public static bool EstValide(Personne? p) =>
   p != null && !string.IsNullOrEmpty(p.Nom);
class Personne
{
   public string Nom { get; }
   // ...
}
// ...

... car le compilateur C# ne fait pas le lien entre la validation faite par EstValide et l'accès à p.Nom dans l'alternative (le if) de Main.

Si on réécrit p.Nom en p!.Nom, alors le code compile sans avertissement.

Lectures complémentaires

Quelques liens pour enrichir le propos.


Valid XHTML 1.0 Transitional

CSS Valide !