Sur le titre du blog

Je ne sais pas si vous avez fait attention, mais l’image en haut de mon blog montre une requête en Linq. J’adore cette technologie, et l’idée était de faire voir que la grammaire associée est tellement expressive qu’on peut écrire au final en C# des phrases qui pourraient presque être du langage de tous les jours.

Il m’a été remonté par un autre canal les quelques remarques suivantes :

  1. “List<string> est lourd, et devrait être remplacé par un array.”
  2. “Un simple Select plutôt qu’un ConvertAll est préférable.”
  3. “Le constructeur BlogEntry ne fournit pas de Published, donc le RemoveAll n’est pas obligatoirement correct.”
  4. (et le plus intéressant à étudier, à mon avis) “Le RemoveAll devrait être fait avant le FindAll pour améliorer les performances”.

Mise en place du banc de test

Vu que je suis plutôt du genre Saint Thomas, je me propose de vérifier tout ceci sur le code en question :

[TestMethod]
public void TestEnteteBlog()
{
    new List<string>() { "C#", "GPGPU", "F#", "Object Prevalence" }
        .ConvertAll<BlogEntry>(delegate(string subject) { return new BlogEntry(subject); })
        .FindAll(post => Industry.ShowsInterest(post))
        .RemoveAll(post => !Industry.WillUseBefore(post.Published + TimeSpan.FromDays(365)));
}

Bien sûr, pour que ceci compile, il nous faudra en plus une classe BlogEntry :

public class BlogEntry
{
    public string Subject { get; set; }
        
    public DateTime Published { get; set; }

    public BlogEntry(string subject)
    {
        this.Subject = subject;
        Published = DateTime.Now;
    }
}

Ainsi qu’une classe Industry, que je définis comme ceci :

public class Industry
{
    public static Random moteur = new Random();

    public static bool ShowsInterest(BlogEntry article)
    {
        return article.Subject.Contains("#");
    }

    public static bool WillUseBefore(DateTime endDate)
    {
        return moteur.Next(2) > 0;
    }
}

Avant de prendre en détail chacun de ces points, juste une remarque préliminaire : je ne traite ici que des aspects techniques et non de l’expressivité du code. Cette dernière qualité est en effet très subjective, et un code peut très bien paraitre expressif à l’un et lourd à un autre…

Liste ou array

image

Du point de vue de la syntaxe, ConvertAll ne peut pas être utilisée sur un array, mais uniquement sous sa forme statique sur la classe System.Array. Et lorsqu’on corrige ceci, on arrive sur un autre problème, à savoir que le RemoveAll n’existe pas sur un array. Le but ici était bien de faire voir le chaînage des méthodes typique de Linq.

J’imagine que la lourdeur dont parle la première remarque fait référence au fait que les array vont utiliser la pile plutôt que le tas. En pratique, comme les chaines sont de toute façon sur le tas, seule la structure fera la différence.

De manière plus générale, je n’ai personnellement jamais été confronté à des problèmes de performance ou d’utilisation mémoire avec les génériques, quelle que soit la classe utilisée. Je suis intéressé par toute documentation / cas d’étude sur ce point si vous en avez. Je parle bien sûr de retours argumentés et, si possible, chiffré.

Select préférable à ConvertAll

image

La remarque n’était pas plus précise, donc j’en suis réduit à penser que c’est pour des raisons de performance, parce que Select renvoie un IEnumerable plutôt qu’une List. Dans notre cas, on sera obligés de repasser en List pour le RemoveAll qui suit, donc ça ne change rien… J’ai fait le test, et les temps sont absolument similaires.

Là où c’est plus intéressant, c’est si la totalité des données ne vont pas être consommées (pagination, etc.), parce que la projection ne sera alors appliquée qu’à la portion des données consommées, et là, on gagne effectivement en performance.

Enfin, Select est effectivement plus intéressant dans la syntaxe courte de Linq, car il permet de mimer le comportement d’une requête SQL, ce qui rend Linq encore plus expressif pour quelqu’un qui ne vient pas de la programmation mais des bases de données :

var resultats = from entree in Liste
                where entree.Contains("#")
                select new { auto = true, contenu = entree.Substring(1, 2) };

Cet exemple est d’ailleurs intéressant pour montrer une autre fonctionnalité de C# très liée à Linq, à savoir les constructeurs anonymes. Le new sans nom de classe après est important justement pour mimer SQL, et ne pas avoir à créer une classe réceptacle pour toutes les projections possibles.

Pas de Published dans la construction de BlogEntry : problème possible

image

Pour le coup, pas besoin de chercher. Ce n’est pas parce que le constructeur de BlogEntry ne prend pas en paramètre un Published que cette propriété n’est pas accessible, et l’exemple de classe pour BlogEntry en début d’article le montre : tout compile bien, et la classe assure qu’il y aura systématiquement une valeur.

C’est d’ailleurs l’intérêt de ne pas utiliser trop systématiquement les classes anonymes dont nous avons parlé juste avant : on pourrait alors oublier de mettre la propriété. Mais le compilateur ne l’oublierait pas, lui !

Inversion de RemoveAll et FindAll pour la performance

image

Autant des améliorations de performance peuvent être trouvées sur l’ordonnancement en fonction des nombres d’entrées listées, de la proportion de ce qui est parcouru, de si on travaille sur des listes ou des énumérateurs, autant il est impossible de faire une généralité sur ceci. De plus, sur le cas particulier dont nous parlons ici, c’est faux, comme le montre une comparaison des deux tests ci-dessous…

Le premier reproduit le code tel qu’il apparait dans le titre du blog :

[TestMethod]
public void TestPerformance()
{
    Stopwatch chrono = Stopwatch.StartNew();
    string contenu = null;

    var etape1 = Liste.ConvertAll<BlogEntry>(delegate(string subject) { return new BlogEntry(subject); });
    var etape2 = etape1.FindAll(post => Industry.ShowsInterest(post));
    etape2.RemoveAll(post => !Industry.WillUseBefore(post.Published + TimeSpan.FromDays(365)));
            
    foreach (BlogEntry b in etape2)
        contenu = b.Subject;

    chrono.Stop();
    Debug.WriteLine("Non optimisé : {0}", chrono.Elapsed);
}

Le second utilise l’optimisation proposée :

[TestMethod]
public void TestPerformanceInversion()
{
    Stopwatch chrono = Stopwatch.StartNew();
    string contenu = null;

    var etape1 = Liste.ConvertAll<BlogEntry>(delegate(string subject) { return new BlogEntry(subject); });
    etape1.RemoveAll(post => !Industry.WillUseBefore(post.Published + TimeSpan.FromDays(365)));
    var etape2 = etape1.FindAll(post => Industry.ShowsInterest(post));

    foreach (BlogEntry b in etape2)
        contenu = b.Subject;

    chrono.Stop();
    Debug.WriteLine("Optimisé : {0}", chrono.Elapsed);
}

Pour que tout ceci fonctionne, il faut bien sûr une fonction pour initialiser la liste, qui est la suivante :

private List<string> Liste;

[TestInitialize]
public void Initialisation()
{
    Random moteur = new Random();

    Stopwatch chrono = Stopwatch.StartNew();

    Liste = new List<string>();
    for (int i = 0; i < 1000000; i++)
        Liste.Add(new string(
            moteur.Next(5) > 3 ? '#' : 'a',
            moteur.Next(10)));

    chrono.Stop();
    Debug.WriteLine("Initialisation : {0}", chrono.Elapsed);
}

Les résultats sont les suivants :

image

Après, on peut bien sûr argumenter sur la proportion des symboles # qui font changer le résultat de la fonction ShowsInterest, etc. Mais visiblement, on ne peut pas parler de gain de performance en règle général.

Je suis d’accord pour dire que c’est un bon principe de filtrer une liste le plus en amont possible, mais ceci n’est vrai que si on applique des traitements plus longs que le filtre, de façon à ne pas effectuer des traitements qui ne serviront pas. Or, ici, le traitement suivant est un Find qui va également filtrer, donc ça ne change rien qu’on le fasse dans un ordre ou dans un autre.

Une remarque de plus

Allez, une dernière remarque (mais qui vient de moi-même), plus sur la forme. J’avais dit que je ne parlerai pas d’expressivité, mais celle-là, ça me gêne Sourire 

Il s’agit de la double négation sur le RemoveAll : plutôt que de dire qu’on va supprimer tout ce qui n’est pas “WillUseBefore”, il serait plus logique de faire un FindAll / Select sur la version sans opérateur “!”. Surtout que ça permettrait dans ce cas d’avoir le Select dans sa position standard en Linq, c’est-à-dire en fin de traitement.

Conclusion

De mon point de vue, la balance entre expressivité du code et aspects purement techniques n’est pas suffisamment déplacée : mon titre de blog reste.

About JP Gouigoux

Jean-Philippe Gouigoux est Architecte Logiciel, MVP Connected Systems Developer. Il intervient régulièrement à l'Université de Bretagne Sud ainsi qu'à l'Agile Tour. Plus de détails sur la page "Curriculum Vitae" de ce blog.
This entry was posted in .NET, Performance. Bookmark the permalink.

Laisser un commentaire

Votre adresse de messagerie ne sera pas publiée. Les champs obligatoires sont indiqués avec *

Captcha Captcha Reload