Tag Archives: C# 6

Filtres d’exception en C# 6 : leur plus grand avantage n’est pas celui qu’on croit

Les filtres d’exception sont l’une des fonctionnalités majeures de C# 6. Ils tirent parti d’une fonctionnalité du CLR qui a toujours existé, mais qui n’était pas exploitée en C# jusqu’ici. Ils permettent de spécifier une condition sur un bloc catch :

static void Main()
{
    try
    {
        Foo.DoSomethingThatMightFail(null);
    }
    catch (MyException ex) when (ex.Code == 42)
    {
        Console.WriteLine("Error 42 occurred");
    }
}

Comme on pourrait s’y attendre, le bloc catch ne sera exécuté que si ex.Code == 42. Si cette condition n’est pas vérifiée, l’exception sera propagée en remontant la pile jusqu’à ce qu’elle soit interceptée ailleurs ou qu’elle termine le processus.

A première vue, cela n’apporte rien de très nouveau. Après tout, on pouvait déjà faire ceci :

static void Main()
{
    try
    {
        Foo.DoSomethingThatMightFail(null);
    }
    catch (MyException ex)
    {
        if (ex.Code == 42)
            Console.WriteLine("Error 42 occurred");
        else
            throw;
    }
}

Puisque ce bout de code est équivalent au précédent, les filtres d’exception sont juste du sucre syntaxique… enfin, ils sont équivalents, non ?

NON !

Déroulement de pile

Il y a en fait une différence subtile mais importante : les filtres d’exception ne déroulent pas la pile. OK, mais qu’est-ce que ça veut dire ?

Quand on entre dans un bloc catch, la pile est déroulée : cela signifie que toutes les frames pour les appels de méthode plus “profonds” que la méthode courante sont dépilées. Cela implique que toutes les informations concernant l’état d’exécution de ces méthodes sont perdues, ce qui rend plus difficile de trouver la cause première de l’exception.

Supposons que la méthode DoSomethingThatMightFail lance une MyException avec le code 123, et que le débogueur soit configuré pour ne s’arrêter que sur les exceptions non gérées.

  • Dans le code sans filtre d’exception, on entre toujours dans le bloc catch (puisque le type de l’exception correspond), et la pile est immédiatement déroulée. Puisque l’exception ne satisfait pas la condition, elle est relancée. Le débogueur s’arrêtera donc sur le throw; dans le block catch ; aucune information sur l’état d’exécution de la méthode DoSomethingThatMightFail ne sera disponible. Autrement dit, on ne pourra pas savoir ce qui était en train de se passer dans la méthode qui a lancé l’exception.
  • En revanche, dans le code qui utilise un filtre d’exception, l’exception ne satisfait pas la condition, donc on n’entrera pas du tout dans le bloc catch, et la pile ne sera pas déroulée. Le débogueur s’arrêtera dans la méthode DoSomethingThatMightFail, ce qui permettra de voir facilement ce qui était en train de se passer quand l’exception a été lancée.

Bien sûr, quand on débogue directement une application dans Visual Studio, on peut configurer le débogueur pour s’arrêter dès qu’une exception est lancée, qu’elle soit gérée ou non. Mais on n’a pas toujours cette possibilité ; par exemple, si on débogue un problème en production, on travaille plutôt sur un crash dump, donc le fait que la pile n’ait pas été déroulée devient très utile, puisque ça permet de voir ce qui était en train de se passer dans la méthode qui a lancé l’exception.

Pile vs. trace de pile

Vous avez peut-être remarqué que j’ai parlé plus haut de la pile (call stack), et non de la trace de pile (stack trace). Bien qu’on utilise souvent le terme “pile” pour faire référence à la trace de pile, ce sont deux choses bien différentes. La pile est une zone mémoire allouée à chaque thread qui contient des informations sur les méthodes en cours d’exécution : adresse de retour, arguments, et variables locales. La trace de pile est juste une chaine qui contient les noms des méthodes actuellement sur la pile (et l’emplacement dans ces méthodes, si les symboles de débogage sont disponibles). La propriété Exception.StackTrace contient la trace de la pile telle qu’elle était quand l’exception a été lancée, et n’est pas affectée quand la pile est déroulée ; si on relance l’exception avec throw;, elle n’est pas modifiée non plus. Elle n’est écrasée que si on relance l’exception avec throw ex;. La pile elle-même, en revanche, est déroulée quand on entre dans un bloc catch, comme décrit plus haut.

Effets de bord

il est intéressant de noter qu’un filtre d’exception peut contenir n’importe quelle expression qui renvoie un bool (enfin presque… on ne peut pas utiliser await par exemple). Cela peut être une condition logique, une propriété, un appel de méthode, etc. Techniquement, rien n’empêche de causer des effets de bord dans un filtre d’exception. Dans la plupart des cas, je déconseillerais vivement de faire ça, car ça peut causer des comportements très déroutants ; il peut devenir très difficile de comprendre dans quel ordre les choses sont exécutées. Cependant, il y a un scénario courant qui pourrait bénéficier d’effets de bord dans un filtre d’exception: le logging. On pourrait facilement créer une méthode qui log l’exception et renvoie false pour qu’on n’entre pas dans le bloc catch. Cela permettrait de logger les exception à la volée sans les gérer, et donc sans dérouler la pile:

try
{
    DoSomethingThatMightFail(s);
}
catch (Exception ex) when (Log(ex, "An error occurred"))
{
    // this catch block will never be reached
}
 
...
 
static bool Log(Exception ex, string message, params object[] args)
{
    Debug.Print(message, args);
    return false;
}

Conclusion

Comme avez pu le voir, les filtres d’exception ne sont pas juste du sucre syntaxique. Contrairement à la plupart des fonctionnalités de C# 6, ce n’est pas vraiment une fonctionnalité de “codage” (dans le sens où ça ne rend pas le code significativement plus clair), mais plutôt une fonctionnalité de “débogage”. Bien compris et utilisés, ils peuvent rendre beaucoup plus facile la résolution de problèmes dans le code.

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.

StringTemplate: une autre approche de l’interpolation de chaines

Avec la version 6 de C# qui approche, il y a beaucoup de discussions sur CodePlex et ailleurs à propos de l’interpolation de chaines. Pas très étonnant, puisqu’il s’agit d’une des fonctionnalités majeures de cette version… Au cas où vous auriez vécu dans une grotte ces derniers mois et n’en auriez pas entendu parler, l’interpolation de chaines est un moyen d’insérer des expressions C# à l’intérieur d’une chaine de caractère, de façon à ce qu’elles soient évaluées lors de l’exécution et remplacées par leurs valeurs. En gros, vous écrivez quelque chose comme ça :

string text = $"{p.Name} est né le {p.DateOfBirth:D}";

Et le compilateur le transforme en ceci :

string text = String.Format("{0} est né le {1:D}", p.Name, p.DateOfBirth);

Note: la syntaxe présentée ci-dessus correspond aux dernières notes de conception sur cette fonctionnalité. Elle peut encore changer d’ici à la sortie finale, et la preview actuelle de VS2015 utilise encore une syntaxe différente : “\{p.Name} est né le \{p.DateOfBirth:D}”.

J’adore cette fonctionnalité. Elle va être extrêmement pratique pour des choses comme le logging, la génération d’URL ou de requêtes, etc. Je l’utiliserai certainement beaucoup, surtout maintenant que Microsoft a écouté les retours de la communauté et a inclus un moyen de personnaliser la façon dont les expressions dans la chaine sont évaluées (regardez la partie à propos de IFormattable dans les notes de conception).

Cependant, quelque chose me chiffonne : puisque les chaines interpolées sont interprétées par le compilateur, elles doivent être codées en dur ; on ne peut pas les extraire dans des ressources pour la localisation. Cela signifie que cette fonctionnalité n’est pas utilisable pour la localisation, et qu’on est obligés de continuer à utiliser des marqueurs numériques dans les chaines localisées.

Mais est-ce vraiment inévitable ?

Depuis quelques années, j’utilise un moteur d’interpolation de chaines que j’ai créé, qui s’utilise de la même façon que String.Format, mais avec des marqueurs nommés plutôt que des numéros. Il prend une chaine de format, et un objet avec des propriétés qui correspondent aux noms des marqueurs :

string text = StringTemplate.Format("{Name} est né le {DateOfBirth:D}", new { p.Name, p.DateOfBirth });

Bien sûr, si vous avez déjà un objet avec les propriétés que vous voulez inclure dans la chaine, vous pouvez simplement passer cet objet directement :

string text = StringTemplate.Format("{Name} est né le {DateOfBirth:D}", p);

Le résultat est exactement ce à quoi on pourrait s’attendre : les marqueurs sont remplacés par les valeurs des propriétés correspondantes.

En quoi est-ce mieux que String.Format ?

  • C’est beaucoup plus lisible; un marqueur nommé indique immédiatement quelle valeur ira à cet emplacement
  • On est moins susceptible de se tromper : pas besoin de faire attention à l’ordre des valeurs à formater
  • Quand on extrait les chaines de format dans des ressources pour la localisation, le traducteur voit un nom dans le marqueur, pas un numéro. Cela donne plus de contexte à la chaine, et permet de comprendre plus facilement à quoi la chaine finale va ressembler.

Notez que vous pouvez utiliser les mêmes spécificateurs de format que dans String.Format. La classe StringTemplate analyse la chaine de format et la transforme en une chaine compatible avec String.Format, extrait les valeurs des propriétés dans un tableau, et appelle String.Format.

Bien sûr, analyser la chaine et extraire les valeurs des propriétés par réflexion à chaque fois serait très inefficace, il y a donc des optimisations :

  • chaque chaine de format distincte est analysée une seule fois, et le résultat de l’analyse est mis en cache pour être réutilisé plus tard.
  • pour chaque propriété utilisée dans une chaine de format, un delegate accesseur est généré et mis en cache, pour éviter de faire appel à la réflexion à chaque fois.

Cela signifie que la première fois que vous utilisez une chaine de format données, il y a un coût lié à l’analyse et à la génération des delegates, mais les utilisations ultérieures de la même chaine de format sont beaucoup plus rapides.

La classe StringTemplate fait partie d’une librairie nommée NString, qui contient également des méthodes d’extension pour faciliter la manipulation de chaines. La librairie est une PCL qui peut être utilisée avec toutes les variantes de .NET à l’exception de Silverlight 5. Un paquet NuGet est disponible ici.