Durée de lecture : environ 8 minutes

Avant que C# 8 ne sorte en 2019, le code suivant compilait sans problème :

string something = null;

Mais depuis C# 8 et si l’option <Nullable> est activée dans le csproj (ce qui est le cas par défaut depuis C# 10 et .Net 6), un avertissement apparaît lors de la compilation et la variable sera soulignée en bleu dans Visual Studio ou Rider. Il montre que la variable a été déclarée comme une chaîne de caractères non-nullable, ce qui signifie que lui affecter null n’est pas une bonne idée. Si vous voulez vraiment le faire alors il faut déclarer la variable ainsi :

string? something = null;

C’est ce à quoi sert le point d’interrogation : il signifie que la variable peut être nulle (= « est nullable »). C’est une déclaration explicite et sans elle alors il n’est pas attendu que la variable soit nulle. D’où le warning dans le premier code.

Il ne s’agit pas vraiment d’une syntaxe inconnue ; après tout, les types valeurs l’ont toujours eue :

int? someInt = null

Mais pas les types références jusqu’a C# 8. Dans cet article, nous verrons pourquoi les types références nullables sont importants et comment les gérer.

Déréférencement

Supposons que nous ayons un type référence nullable. Depuis C# 8, à chaque fois que le compilateur analyse et trouve un cas potentiel de NullReferenceException, il envoie un avertissement. Par exemple avec ce code :

string? something = UnAppelQuiPeutRenvoyerNull();
var length = something.Length;

Essayer d’accéder à une propriété sur un type référence est appelé un déréférencement. Cela signifie que quand vous avez besoin d’accéder à une propriété ou une méthode, il va falloir suivre la référence afin d’accéder à l’objet lui-même. Le faire sur null qui, par définition, ne pointe sur « rien », va évidemment causer une exception. Donc quand une variable peut être nulle et que vous essayez de la déréférencer, le compilateur va afficher un avertissement et le code incriminé sera souligné dans votre IDE favori.

Mais il ne s’agit que d’un avertissement en particulier concernant les types nullables ; il y en a toute une liste sur le site de Microsoft avec des exemples pour la plupart d’entre eux.

Pourquoi ?

L’intérêt de ces avertissements est justement de nous prévenir lors de la compilation. On peut encore agir avant que le problème n’apparaisse au runtime une fois l’appli déployée ! Je crois fermement que les retours d’information doivent être rapides pour que nous puissions voir les erreurs en toute sécurité avant le déploiement en production. C’est exactement ce dont nous avons besoin ici : l’infâme NullReferenceException peut être détectée avant qu’elle n’arrive. C’est de l’or en barres !

Inconvénients des nullables

Sentiment de submersion

Si on les active sur un projet legacy, l’option Nullable peut afficher un nombre important d’avertissements. Là encore, c’est une bonne chose car le but est justement d’éviter les exceptions au runtime. Mais parfois le nombre d’avertissements peut être intimidant et peut introduire beaucoup de bruit dans les logs du compilateur. Quand je suggère de réparer ces avertissements lorsqu’ils apparaissent, cela peut sembler difficile voire irréaliste dans ce genre de cas. Bien évidemment, il existe plusieurs possibilités pour supprimer ces avertissements :

  • Désactiver simplement l’option dans le csproj : <Nullable>disable</Nullable> Supprimer le noeud XML va avoir le même effet.
  • Utiliser la syntaxe ! pour dire au compilateur « je sais ce que je fais, cette variable ne sera jamais nulle ». Cela peut être intéressant si vous avez réellement fait une vérification préalable mais que le compilateur ne l’a pas remarquée. Par exemple :
[Test]
public void Do_some_stuff()
{
    string? someString = GetNullableString();

    Assert.That(someString, Is.Not.Null);
    Assert.That(someString!.Length, Is.EqualTo(0));
    Assert.That(someString.EndsWith("123"), Is.True);
}

On sait ligne 7 que la chaîne someString ne sera pas nulle parce que sinon le code ne s’exécuterait pas (l’Assert ligne 6 aurait stoppé l’exécution). Mais le compilateur ne le voit pas et va afficher un avertissement sur une déréférence possible. Donc pour le supprimer, on ajoute un point d’exclamation juste après le nom de la variable pour lui dire « ne t’en fais pas, je sais que ce n’est pas null ». Le compilateur ne va alors plus nous ennuyer par la suite lors de l’utilisation de cette variable et le point d’exclamation ne sera plus nécessaire comme on peut le voir ligne 8.

Le cas null!

Il existe une syntaxe bizarre et qui ne semble pas avoir beaucoup de sens au premier abord alors parlons-en. Le code suivant va compiler sans avertissement :

string someString = null!;

En gros, ce code signifie « je déclare une variable qui ne doit pas être nulle mais je l’initialise avec null mais ne t’en fais pas, je sais ce que je fais et je te dis que ce n’est pas null ». Tout cela semble un peu contradictoire non ?
Avec le temps, je me suis rendu compte qu’il n’y avait pas beaucoup de cas où cela peut être utile. Ça peut être pratique quand on teste des cas à la marge, comme valider les paramètres d’entrée d’un contrôleur :

public record InputPayload(string? NullableString, int SomeInt, string NonNullableString);

[ApiController]
[Route("[controller]")]
public class MyController : ControllerBase
{
    [HttpGet("testNull")]
    public IActionResult TestNull(InputPayload input)
    {
        if (input.NonNullableString == null)
        {
            return BadRequest($"{nameof(input.NonNullableString)} cannot be null");
        }

        return Ok();
    }
}

Avec le test :

[Test]
public void Return_BadRequest_when_NonNullableString_is_null()
{
    var controller = new MyController();

    var result = controller.TestNull(new InputPayload(null, 0, null!));

    Assert.That(result, Is.TypeOf<BadRequestObjectResult>());
}

C’est un cas qui peut se produire car la non-nullabilité de la propriété NonNullableString n’est pas vérifiée par le framework et que le JSON envoyé peut complètement contenir null. Donc si on veut valider correctement l’entrée et la tester, on peut le faire en utilisant la syntaxe null! ligne 6.

Propagation 😱

Bien sûr on peut être tenté·e d’éviter d’avoir à gérer un avertissement de nullabilité en le propageant et en laissant l’appelant se débrouiller avec. Après tout, ajouter un simple point d’interrogation à la fin du type de retour est facile. Mais, comme pour les exceptions, je pense que la plupart du temps il est plus adéquat de gérer les valeurs nulles aussi tôt que possible. Donc comment gère-t-on les valeurs nulles proprement ?

Gestion des nulls

La première chose à faire est de se demander d’où le null peut venir, ce qui nous aidera à le gérer.

Désérialisation

Le null vient-il d’une désérialisation ? Cela signifie-t-il que le champ ou l’argument est optionnel ? Si oui, et en fonction de comment on va s’en servir ensuite, on peut vouloir utiliser une meilleure syntaxe pour une valeur qui peut être « vide » ou « non applicable ».
C’est ici que le pattern Null Object vient à notre rescousse : on désérialise des trucs nulls depuis notre infra ou notre adaptateur et on utilise une syntaxe parlante dans notre couche métier. Par exemple :

// Définitions dans la couche métier
public record Price(decimal Value, Currency Currency);
public record PriceNotFound() : Price(0m, new NoCurrency());
public record Currency(string Name, string Symbol);
public record NoCurrency() : Currency(string.Empty, string.Empty);

// Code dans la couche infra
public record PriceDto(decimal Value, Currency Currency, string RoomName, string RateName);

public class AvailabilityChecker
{
    public Price GetLowestPrice(Guid hotelId, DateOnly startDate, DateOnly endDate, int numberOfGuests)
    {
        var priceDto = GetLowestPriceFromDatabase(hotelId, startDate, endDate, numberOfGuests);
        if (priceDto == null)
        {
            return new PriceNotFound();
        }

        return new Price(priceDto.Value, priceDto.Currency);
    }

    private PriceDto? GetLowestPriceFromDatabase(Guid hotelId, DateOnly startDate, DateOnly endDate, int numberOfGuests)
    {
        // Effectue la requête en base ; renvoie null si aucun prix ne peut être trouvé par exemple si l'hôtel est complet.
    }
}

Les objets PriceNotFound et NoCurrency sont les point importants. Ils sont plus explicites qu’une valeur nulle et moins sujets à erreur (pas de NullReferenceException notamment). Au pire, si rien n’est fait dans le code appelant dans la couche métier, alors PriceNotFound se comportera comme un Price et il n’y aura pas de crash.
De plus, on pourrait imaginer requêter la base de données d’une façon telle que le résultat de GetLowestPriceFromDatabase() puisse détecter divers cas : l’hôtel n’existe pas en base, l’hôtel est complet, l’hôtel existe mais est fermé, etc… et renvoyer ainsi un type approprié pour chaque scénario. Ainsi la couche métier pourrait agir différemment en fonction du type de résultat.

C’est similaire au (mais pas exactement pareil que) pattern Result qui aide à se débarrasser des exceptions dans le flux de contrôle du logiciel. On sort du périmètre de cet article donc je vous suggère d’aller lire l’excellente série d’articles d’Andrew Lock sur le sujet.

Code défensif et nulls inutiles

Évitez le suffixe « OrDefault ». Bien souvent, la version « sans OrDefault » est suffisante.

Et est-ce qu’on ne supposerait pas que la valeur désérialisée est nulle alors qu’elle ne l’est jamais ? Ou est-ce qu’on est pas en train de faire fausse route en étant inutilement précautionneux ? Par exemple :

string[] someStrings = ["apple", "orange", "guava", "durian", "apricot"];
string? firstFruit = someStrings.FirstOrDefault();

Pourquoi utilise-t-on FirstOrDefault() ici ? On sait que le tableau n’est pas vide, on vient de le définir. Évidemment il s’agit d’un bout de code simpliste, mais utiliser une méthode « OrDefault » de Linq (ex : FirstOrDefault, SingleOrDefault…) alors qu’il n’y a absolument pas de raison que le « default » va se produire est bien plus courant que vous ne le pensez. Et cela provoque de la gestion inutile de nulls ou, pire, de la propagation.

Une version plus subtile (mais tout aussi courante) est d’utiliser des propriétés nullables sur un DTO en requêtant une base de données alors que la colonne correspondante ne peut pas contenir de donnée nulle.

Revenez toujours à l’origine d’une valeur nulle potentielle et demandez-vous si elle est réellement possible. Et évitez le suffixe « OrDefault ». Bien souvent, la version « sans OrDefault » (First ou Single ou Last ou quoi) est suffisante.

Nouveaux cas métier possibles

Il est aussi possible que vous ayez trouvé un cas à la marge. Genre un appel qui renvoie null dans certains cas et qui est complètement valide. C’est juste que vous ne savez pas quoi en faire. Cela peut arriver lorsqu’un référentiel (base de données, services tiers…) contient des valeurs nulles par exemple.
C’est le meilleur moment pour avoir une petite discussion avec les expert·es métier ! Faites-leur part de votre découverte et dans quel(s) cas cela peut se produire. Assurez-vous de bien comprendre les conditions métier spécifiques qui feront que des nulls peuvent avoir lieu et demandez à vos expert·es comment l’application doit se comporter dans de tels cas.

Peut-être que ce n’est pas important, peut-être qu’il n’y a pas besoin de gérer ces cas parce que le métier a déterminé qu’il est peu probable que cela arrive, peut-être qu’il y a déjà un plan de leur côté pour gérer ça (ex : rattrapage de donnés à la main 3 fois par an), etc… Mais le bon côté est que vous serez au courant. Documentez ce cas et ajoutez des tests pour pérenniser cette connaissance.

Pousser un cran plus loin

Si vous détestez les nulls, vous pouvez carrément traiter les avertissements comme des erreurs. La compilation échouera au lieu de passer avec des avertissements lorsque des nullables seront détectés.

Il y a deux lignes de code : string[] someStrings = [« apple », « orange », « guava », “durian”, « apricot »]; string firstFruit = someStrings.FirstOrDefault();
La dernière partie (someStrings.FirstOrDefault) est soulignée en rouge dans Visual Studio.
Là on entre en zone dangereuse 😁

Cela peut sembler zélé ou complètement fou, surtout si vous avez l’habitude d’un code legacy avec peu de gestion des nulls. L’idée est soit de démarrer un projet en configurant dès le départ les avertissements comme des erreurs. Ou alors corriger les avertissements s’il y en a un nombre limité puis de faire cette configuration. Démarrer propre est ce qu’il y a de mieux.

Comment faire ? Il suffit d’ouvrir le csproj (on ne peut malheureusement pas faire cette configuration au niveau de la solution) où vous souhaitez traiter les avertissements comme des erreurs puis d’ajouter la ligne suivante dans le PropertyGroup principal (juste en-dessous du noeud Nullable, c’est parfait) :

<WarningsAsErrors>nullable</WarningsAsErrors>

Et c’est tout ! Tous les avertissements concernant les nullables seront dès maintenant traités comme des erreurs lors de la compilation. Et si c’est vraiment trop compliqué, il est toujours possible de revenir en arrière en supprimant cette ligne ou de définir manuellement quels avertissements spécifiques vous voulez voir traités comme des erreurs.

En résumé

Pour moi, la leçon que je retire de tout ça est : .Net nous donne la possibilité de gérer les NullReferenceExceptions avant qu’elles arrivent au runtime. Ce n’est pas infaillible, ce n’est pas parfait mais c’est énorme d’avoir ces informations à la compilation. Nous ne devrions pas laisser passer cette opportunité en ignorant les avertissements mais plutôt capitaliser dessus.

Software developer since 2000, I try to make the right things right. I usually work with .Net (C#) and Azure, mostly because the ecosystem is really friendly and helps me focus on the right things.
I try to avoid unnecessary (a.k.a. accidental) complexity and in general everything that gets in the way of solving the essential complexity induced by the business needs.
This is why I favor a test-first approach to focus on the problem space and ask questions to business experts before even coding the feature.

Catégories : Dev

Guillaume Téchené

Software developer since 2000, I try to make the right things right. I usually work with .Net (C#) and Azure, mostly because the ecosystem is really friendly and helps me focus on the right things. I try to avoid unnecessary (a.k.a. accidental) complexity and in general everything that gets in the way of solving the essential complexity induced by the business needs. This is why I favor a test-first approach to focus on the problem space and ask questions to business experts before even coding the feature.