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 :

class Program
{
   class X
   {
      // ...
   }
   class Y : X
   {
      // ...
   }
   static void F(X x)
   {
     // ... utiliser x ...
   }
   // ...
   static Y Gen()
   {
      // ...
   }
   static void Main()
   {
      F(new X()); // Ok
      F(new Y()); // Ok, un Y est un X
      F(Gen()); // Ok, passe au moins un Y à F()
   }
}

... 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 :

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

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 Program
{
   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;
public class Program
{
   class X
   {
      public int F()
      {
         return 3;
      }
   }
   public static void Main()
   {
      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()
   }
}

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;
public class Program
{
   class X
   {
      public int F()
      {
         return 3;
      }
   }
   public static void Main()
   {
      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
   }
}

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;
public class Program
{
   class X
   {
      public int F()
      {
         return 3;
      }
   }
   public static void Main()
   {   
      X x = new Random().Next(0,2) == 0? new X() : null; // new X();
      int? n = x?.F();
      Console.WriteLine(n ?? -1); // <-- ICI
   }
}

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 »
class DivisionParZéro : ApplicationException
{
}
static int DivisionEntière(int num int dénom)
{
   if (dénom == 0)
   {
      throw new DivisionParZéro();
   }
   return num / dénom;
}
static void Main()
{
   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");
      }
   }
}
class DivisionParZéro : ApplicationException
{
}
static int? DivisionEntière(int num int dénom)
{
   if (dénom == 0)
   {
      return null;
   }
   return num / dénom;
}
static void Main()
{
   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");
      }
   }
}

À partir de C# v. 8.0 : « nullabilité » sur demande seulement

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# visera à 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.

Lectures complémentaires

Quelques liens pour enrichir le propos.

 

 


Valid XHTML 1.0 Transitional

CSS Valide !