Personnaliser l’interpolation de chaine avec C# 6

L’une des principales nouveautés de C# 6 est l’interpolation de chaines de caractères, qui permet d’écrire ce genre de chose :

string text = $"{p.Name} was born on {p.DateOfBirth:D}";

Un aspect peu connu de cette fonctionnalité est qu’une chaine interpolée peut être traitée soit comme un String, soit comme un IFormattable, selon le contexte. Quand elle est convertie en IFormattable, cela crée un objet FormattableString qui implémente l’interface et expose :

  • la chaine de format, avec les valeurs remplacées par des marqueurs numériques (compatible avec String.Format)
  • les valeurs pour les marqueurs

La méthode ToString() de cet objet appelle simplement String.Format(format, values). Mais il y a aussi une surcharge qui accepte un IFormatProvider, et c’est là que ça devient intéressant, parce que cela permet de personnaliser la façon dont les valeurs sont formatées. Il n’est peut-être pas évident de voir en quoi c’est utile, donc laissez moi vous montrer quelques exemples…

Spécifier la culture

Pendant la conception de la fonctionnalité d’interpolation de chaines, il y a eu un débat assez vif pour décider s’il fallait utiliser la culture courante ou la culture neutre (“invariant”) pour formater les valeurs; il y avait de bons arguments des deux côtés, mais au final il a été décidé d’utiliser la culture courante, par souci de cohérence avec String.Format et des APIs similaires qui utilisent la mise en forme composite. Utiliser la culture courante est pertinent quand on utilise l’interpolation de chaines pour construire des chaines qui seront affichée dans l’interface utilisateur ; mais il y a aussi des scénarios où on veut construire des chaines qui seront utilisées dans des APIs ou protocoles (URLs, requêtes SQL…), et dans ces cas là il faut généralement utiliser la culture neutre.

C# 6 fournit un moyen facile de faire cela, en tirant parti de la conversion en IFormattable. Il suffit de créer une méthode comme celle-ci :

static string Invariant(FormattableString formattable)
{
    return formattable.ToString(CultureInfo.InvariantCulture);
}

Et vous pouvez ensuite l’utiliser comme suit:

string text = Invariant($"{p.Name} was born on {p.DateOfBirth:D}");

Les valeurs dans la chaine interpolée seront formatées avec la culture neutre, et non plus avec la culture courante.

Construire des URLs

Voici un exemple plus avancé. L’interpolation de chaines est un moyen pratique de construire des URLs, mais si on inclut des chaines arbitraires dans l’URL, il faut prendre soin de les encoder pour ne pas avoir de caractères invalides dans l’URL. Un interpolateur de chaine personnalisé peut le faire pour nous; il faut juste créer un IFormatProvider personnalisé qui s’occupera d’encoder les valeurs. L’implémentation n’était pas évidente au premier abord, mais après quelques tâtonnements je suis arrivé à ceci :

class UrlFormatProvider : IFormatProvider
{
    private readonly UrlFormatter _formatter = new UrlFormatter();

    public object GetFormat(Type formatType)
    {
        if (formatType == typeof(ICustomFormatter))
            return _formatter;
        return null;
    }

    class UrlFormatter : ICustomFormatter
    {
        public string Format(string format, object arg, IFormatProvider formatProvider)
        {
            if (arg == null)
                return string.Empty;
            if (format == "r")
                return arg.ToString();
            return Uri.EscapeDataString(arg.ToString());
        }
    }
}

Ce formateur peut être utiliser comme ceci :

static string Url(FormattableString formattable)
{
    return formattable.ToString(new UrlFormatProvider());
}

...

string url = Url($"http://foobar/item/{id}/{name}");

Cela va correctement encoder les valeurs de id et name de façon à ce que l’’URL générée ne contienne que des caractères valides.

Aparté: Avez-vous remarqué le if (format == "r") ? C’est un spécificateur de format personnalisé qui indique que la valeur ne doit pas être encodé (“r” pour “raw”). Pour l’utiliser, il suffit de l’inclure dans la chaine de format comme ceci : {id:r}. Cela empêchera l’encodage de id.

Construire des requêtes SQL

On peut faire quelque chose de similaire pour les requêtes SQL. Bien sûr, intégrer des valeurs directement dans une requête est une mauvaise pratique bien connue, pour des raison de sécurité et de performance (il faut utiliser des requêtes paramétrées); mais pour un développement “à l’arrache”, ça peut parfois être utile. Et puis c’est une bonne illustration de cette fonctionnalité. Pour intégrer des valeurs dans une requête SQL, il faut :

  • encadrer les chaines entre des apostrophes, et échapper les apostrophes à l’intérieur des chaines en les doublant
  • formater les dates en fonction de ce que le SGBD attend (généralement MM/dd/yyyy)
  • formater les nombres selon la culture neutre
  • remplacer les valeurs nulles par le littéral NULL.

(il y a probablement d’autres choses à prendre en compte, mais ce sont les plus évidentes).

On peut utiliser la même approche que pour les URLs, et créer un SqlFormatProvider :

class SqlFormatProvider : IFormatProvider
{
    private readonly SqlFormatter _formatter = new SqlFormatter();

    public object GetFormat(Type formatType)
    {
        if (formatType == typeof(ICustomFormatter))
            return _formatter;
        return null;
    }

    class SqlFormatter : ICustomFormatter
    {
        public string Format(string format, object arg, IFormatProvider formatProvider)
        {
            if (arg == null)
                return "NULL";
            if (arg is string)
                return "'" + ((string)arg).Replace("'", "''") + "'";
            if (arg is DateTime)
                return "'" + ((DateTime)arg).ToString("MM/dd/yyyy") + "'";
            if (arg is IFormattable)
                return ((IFormattable)arg).ToString(format, CultureInfo.InvariantCulture);
            return arg.ToString();
        }
    }
}

On peut ensuite utiliser ce formateur comme ceci :

static string Sql(FormattableString formattable)
{
    return formattable.ToString(new SqlFormatProvider());
}

...

string sql = Sql($"insert into items(id, name, creationDate) values({id}, {name}, {DateTime.Now})");

De cette façon les valeurs seront correctement formatées pour générer une requête SQL valide.

Utiliser l’interpolation de chaines quand on cible des versions plus anciennes de .NET

Comme c’est souvent le cas avec les fonctionnalités du langage qui exploitent des types du .NET Framework, il est possible d’utiliser cette fonctionnalité avec des versions plus anciennes de .NET qui n’ont pas la classe FormattableString ; il suffit de créer la classe soi-même dans le namespace approprié. En fait, il y a en l’occurrence deux classes à implémenter : FormattableString et FormattableStringFactory. Jon Skeet était apparemment très pressé d’essayer, et il a déjà donné un exemple avec le code pour ces classes :

using System;

namespace System.Runtime.CompilerServices
{
    public class FormattableStringFactory
    {
        public static FormattableString Create(string messageFormat, params object[] args)
        {
            return new FormattableString(messageFormat, args);
        }

        public static FormattableString Create(string messageFormat, DateTime bad, params object[] args)
        {
            var realArgs = new object[args.Length + 1];
            realArgs[0] = "Please don't use DateTime";
            Array.Copy(args, 0, realArgs, 1, args.Length);
            return new FormattableString(messageFormat, realArgs);
        }
    }
}

namespace System
{
    public class FormattableString
    {
        private readonly string messageFormat;
        private readonly object[] args;

        public FormattableString(string messageFormat, object[] args)
        {
            this.messageFormat = messageFormat;
            this.args = args;
        }
        public override string ToString()
        {
            return string.Format(messageFormat, args);
        }
    }
}

C’est la même approche qui permettait d’utiliser Linq en ciblant .NET 2.0 (LinqBridge) ou les attributs d’infos de l’appelant quand on cible .NET 4.0 ou plus ancien. Bien sûr, ça nécessite quand même le compilateur C# 6 pour fonctionner…

Conclusion

La conversion de chaines interpolées en IFormattable avait déjà été mentionnée il y a quelque temps, mais n’était pas encore implémentée dans Visual Studio 2015 CTP 5. La CTP 6 qui vient d’être publiée embarque une nouvelle version du compilateur qui inclut cette fonctionnalité, vous pouvez donc commencer à jouer avec ! Cette fonctionnalité rend l’interpolation de chaine très flexible, et je suis sûr que les gens vont trouver toutes sortes de cas d’utilisation auxquels je n’avais pas pensé.

Vous pouvez trouver le code des exemples ci-dessus sur GitHub.

Test unitaires asynchrones avec NUnit

Récemment, mon équipe et moi avons commencé à écrire des tests unitaires pour une application qui utilise beaucoup de code asynchrone. Nous avons utilisé NUnit (2.6) parce que nous le connaissions déjà bien, mais nous ne l’avions encore jamais utilisé pour tester du code asynchrone.

Supposons que le système à tester soit cette très intéressante classe Calculator :

    public class Calculator
    {
        public async Task<int> AddAsync(int x, int y)
        {
            // simulate long calculation
            await Task.Delay(100).ConfigureAwait(false);
            // the answer to life, the universe and everything.
            return 42;
        }
    }

(Indice: ce code contient un bug… 42 n’est pas toujours la réponse. Ça m’a fait un choc quand j’ai appris ça!)

Et voici un test unitaire pour la méthode AddAsync :

        [Test]
        public async void AddAsync_Returns_The_Sum_Of_X_And_Y()
        {
            var calculator = new Calculator();
            int result = await calculator.AddAsync(1, 1);
            Assert.AreEqual(2, result);
        }

async void vs. async Task

Avant même de lancer ce test, je me suis dit : Ça ne va pas marcher! une méthode async void va retourner immédiatement sur le premier await, NUnit va donc croire que le test est terminé alors que l’assertion n’a pas été exécutée, et le test va donc passer même si l’assertion échoue. J’ai donc changé la signature de la méthode en async Task, en me croyant très malin d’avoir évité ce piège…

        [Test]
        public async Task AddAsync_Returns_The_Sum_Of_X_And_Y()

Comme prévu, le test a échoué, ce qui confirme que NUnit sait gérer les tests asynchrones. J’ai corrigé la classe Calculator, et je n’y ai plus pensé. Jusqu’au jour où j’ai remarqué qu’un collègue écrivait ses tests avec async void. J’ai donc commencé à lui expliquer pourquoi ça ne pouvait pas marcher, et j’ai essayé de le lui démontrer en ajoutant une assertion qui échouerait… et à ma grande surprise, le test a échoué, prouvant que j’avais tort !

Etant d’une nature curieuse, j’ai aussitôt commencé à investiguer… Ma première idée a été de vérifier le SynchronizationContext courant, et en effet, j’ai vu que NUnit l’avant remplacé par une instance de NUnit.Framework.AsyncSynchronizationContext. Cette classe maintient une file des continuations qui sont postées dessus. Après que la méthode async void retourne (c’est-à-dire la première fois qu’on await une tâche qui n’est pas encore terminée), NUnit appelle la méthode WaitForPendingOperationsToComplete, qui exécute toute les continuations de la file, jusqu’à ce que celle-ci soit vide. C’est seulement là que le test sera considéré comme terminé.

La morale de cette histoire est donc que vous pouvez écrire des tests async void avec NUnit 2.6. Cela fonctionne aussi avec les delegates passés à Assert.Throws, qui peuvent avoir le modificateur async. Cela étant dit, ce n’est pas parce que vous pouvez le faire que c’est forcément une bonne idée… Les frameworks de test unitaire n’ont pas tous le même support pour cela, et la prochaine version de NUnit (la 3.0, encore en alpha), ne supportera pas les tests async void.

Donc, à moins que vous ne comptiez rester sur NUnit 2.6.4 ad vitam æternam, il vaut probablement toujours utiliser async Task dans vos tests unitaires.