Admettons-le : travailler avec des données potentiellement nulles n’est pas toujours facile. Combien de fois est-ce qu’on se retrouve avec une exception lancée en pleine exécution parce qu’on vient de tenter de déréférencer une valeur nulle ?

Les valeurs nulles peuvent venir de n’importe où, et pour plusieurs raisons différentes. En conséquence, on se retrouve à écrire du code défensif, où l’on ne sait pas toujours avec certitude si l’on a affaire à une valeur nulle ou pas, et trouver quoi faire dans le cas où la valeur est effectivement nulle. On se retrouve généralement avec un résultat comme celui-ci :

Le code ne fait pas grand-chose, et pourtant, à peu près la moitié de ses lignes servent à gérer la nullité potentielle des entrées…

Ainsi, dans cet article, nous allons voir comment il est possible de réduire la quantité de code qui sert à assurer la non-nullité des données avec lesquelles on travaille, à l’aide d’une fonction monadique nommée bind. Il y a plusieurs façons d’attaquer le problème, mais j’ai choisi la voie fonctionnelle puisque, déjà, j’aime beaucoup la programmation fonctionnelle, et également parce que je trouve que le système d’extensions de C# se prête très bien à cette tâche spécifique. Cela dit, malheureusement, le C# reste du C# : on ne trouvera pas ici un opérateur dédié comme >>=, qui ne peut pas être surchargé directement, ni d’expression comme do. Malgré cela, à l’aide de la syntaxe d’extension de C#, vous allez être en mesure de chainer vos différentes fonctions en partant du résultat précédent, d’une façon assez fluide.

Je tiens à préciser que nous n’allons pas parler de monades dans cet article. Le sujet est un peu plus complexe, et je tiens vraiment à me concentrer sur le cas des Nullables. Pour que vous puissiez suivre si vous n’êtes pas déjà familiarisé avec les monades, en voici une (très) simple définition : une monade est un conteneur générique qui a deux propriétés, soit de pouvoir être créé, et de pouvoir être manipulé à l’aide d’une fonction appelée bind, qui prend elle-même en paramètre une fonction qui a le droit de manipuler directement la donnée contenue par le conteneur. En ce sens, List<> est une monade, ainsi que Nullable (avec les fonctions que nous allons écrire plus bas). L’idée importante à retenir est que, dans le contexte d’une monade, pour avoir le droit de manipuler le contenu encapsulé dans le conteneur, il faut utiliser la fonction bind.

Pourquoi est-ce important ? Parce qu’ainsi, vous donnez une garantie de type aux utilisateurs de votre monade. Comme on ne peut pas faire de traitement sur notre donnée directement lorsqu’on a son conteneur en mains, il nous faut obligatoirement (bien sûr, tout dépend de la façon dont votre conteneur a été programmé) passer par la fonction bind pour accéder à la donnée sur laquelle on veut faire un traitement. De plus, bind va habituellement vous aider à réduire la quantité de code que vous avez besoin d’écrire en vous permettant de vous concentrer sur le cœur de votre traitement. Dans le cas de List<>, par exemple, bind (qui s’appelle SelectMany en Linq) vous permet d’éviter d’avoir à écrire votre boucle, et s’assure de ne pas appeler votre fonction si la liste est vide. Dans le cas du Bind que nous allons écrire aujourd’hui, on encapsule la vérification de nullité, et on type la non-nullité des valeurs.

Le degré de sécurité de type que l’on obtient à l’aide des monades varie selon le langage utilisé. Dans le cas de nos Nullables, en C#, nous avons une garantie de type pour la moitié de nos opérations, puisque les données par valeur (déclarées avec le mot-clé struct) doivent être explicitement typées si on veut qu’elles puissent être nulles, alors que les données par référence (déclarées avec le mot-clé class) sont toujours implicitement nullables (c’en est même leur valeur par défaut!). Cela va être appelé à changer, éventuellement (https://blogs.msdn.microsoft.com/dotnet/2017/11/15/nullable-reference-types-in-csharp/), et je peux vous dire que chez BesLogic, nous sommes très emballés à l’idée de pouvoir utiliser les données par référence nullables, lorsqu’elles feront partie de C# à l’avenir.

La raison pour laquelle typer la nullité d’une donnée est si agréable, c’est qu’à partir de ce moment, vous savez que votre compilateur vous empêchera de passer des données qui pourraient être nulles à une fonction qui ne prend pas de données nulles en paramètres. Comme vous ne pouvez pas compiler votre programme si vous essayez de le faire, vous avez une garantie très forte, et pouvez donc écrire vos fonctions qui traitent des données non nulles avec une grande certitude, en vous évitant d’avoir à écrire des lignes comme if not null un peu partout, et sans avoir à vous demander quoi faire avec ces cas.

De l’autre côté, si vous travaillez avec des données nullables, les compilateurs et autres outils peuvent plus facilement vous donner des avertissements si vous essayez de l’utiliser comme si elles n’étaient pas nulles sans avoir testé leur nullité d’abord. Par exemple :

Tout dépendant des outils à votre disposition, le premier exemple sera peut-être souligné d’un avertissement dans votre éditeur, puisque vous tentez d’accéder à une donnée d’une manière interdite par sa nullité potentielle. On ne voit pas souvent ce genre d’avertissements sur les types par référence, puisqu’ils peuvent être littéralement nuls partout, en tout temps, ce qui rendrait le code un peu plus lourd à naviguer, surtout si vous avez la certitude que votre référence n’est pas nulle pour une raison ou une autre.

Mais même avec cette sécurité de type supplémentaire, il reste qu’il est assez verbeux de gérer le cas de la nullité chaque fois qu’une valeur est potentiellement nulle : les if ou les opérateurs ternaires continuent de parsemer le code, et il vous appartient encore de décider quoi faire avec la nullité.

C’est ici que bind entre en jeu. En effet, en l’utilisant, vous pouvez réécrire les exemples précédents de cette façon :

On notera que la première implémentation change un peu le comportement de sa version originale. En effet, ici, plus question de décider ce que l’on fait avec une donnée nulle : si on reçoit nul en entrée, on retourne nul en sortie (on laisse donc les utilisateurs de la fonction décider du comportement désiré en cas de valeur nulle, un peu à la manière d’une exception qui remonte jusqu’à ce qu’elle soit gérée par une clause catch).

Dans le deuxième cas, le comportement est le même, mais l’appel à Bind est un peu plus verbeux que le premier cas. Malheureusement, le système de types de C# n’est pas (encore, à tout le moins) capable d’inférer correctement le type de retour de la fonction passée à Bind, de sorte qu’il me faut l’aider un peu en y ajoutant un cast, pour transformer mon type non nullable en type nullable, aux yeux du compilateur.

Cela dit, même avec le besoin de faire un cast dans certains cas, j’ai maintenant une garantie absolue que mon type n’est pas nul quand vient le temps d’y appliquer mon traitement (ce qui me garantit donc qu’aucune erreur de type NullReferenceException ne peut désormais se produire en pleine exécution!), le tout à l’aide d’une syntaxe relativement concise et en tout cas très lisible, grâce aux extensions de C#.

Voici comment j’ai implémenté ces deux versions de Bind :

Comme vous pouvez le voir, l’implémentation est très simple. Rien de magique ne se passe ici. On ne fait, finalement, qu’encapsuler la vérification de nullité.

Bien que j’utilise le raccourci C# pour déclarer des Nullable<> (c’est le ? derrière les types), les conteneurs dont je parlais plus haut sont bien visibles : vous fournissez un conteneur Nullable de TIn et une fonction qui traite directement un TIn vers un conteneur Nullable de TOut, le tout retournant un conteneur Nullable de TOut. Écrit avec une autre syntaxe de déclarations : TIn? -> (TIn -> TOut?) -> TOut?. Cette transformation est assez simple, et c’est ce qui définit une monade.

J’espère que cela vous aidera à écrire du code un peu plus sécuritaire en exécution!

Petite note : les deux fonctions Bind montrées en exemple ne couvrent que la moitié des cas d’utilisation potentiels. Pouvez-vous trouver quels sont les deux autres cas ? Comment les implémenteriez-vous ?

À la semaine prochaine!