Afficher facilement une taille de fichier sous forme lisible par un humain

Si vous écrivez une application qui a un rapport avec la gestion de fichiers, vous aurez probablement besoin d’afficher la taille des fichiers. Mais si un fichier a une taille de 123456789 octets, ce n’est évidemment pas la valeur qu’il faudra afficher, car c’est difficile à lire, et l’utilisateur n’a généralement pas besoin de connaitre la taille à l’octet près. Vous allez plutôt afficher quelque chose comme 118 Mo.

Ca ne devrait a priori pas être très compliqué, mais en fait il y a différentes façons d’afficher une taille en octets… Par exemple, plusieurs conventions coexistent pour les unités et préfixes :

  • La convention SI (Système International d’Unités) utilise des multiples décimaux, basés sur des puissances de 10 : 1 kilooctet vaut 1000 octets, 1 mégaoctet vaut 1000 kilooctets, etc. Les préfixes sont ceux du système métrique (k, M, G, etc.).
  • La convention CEI  (Commission Electrotechnique Internationale) utilise des multiples binaires, basés sur des puissances de 2 : 1 kibioctet vaut 1024 octets, 1 mébioctet vaut 1024 kibioctets, etc. Les préfixes sont Ki, Mi, Gi, etc., pour éviter la confusion avec le système métrique.
  • Mais aucune de ces conventions n’est communément utilisée : la convention usuelle est d’utiliser des multiples binaires (1024), mais des préfixes décimaux (K, M, G, etc.).

Selon le contexte, on utilisera l’une ou l’autre de ces conventions. Je n’ai jamais vu la convention SI utilisée où que ce soit ; certaines applications (je l’ai vu dans VirtualBox par exemple) utilisent la convention CEI ; la plupart des applications et systèmes d’exploitation utilisent la convention usuelle. Vous pouvez lire cet article Wikipédia si vous voulez en savoir plus : Préfixe binaire.

OK, alors supposons qu’on a choisi la convention usuelle pour l’instant. Maintenant, il faut décider quelle échelle utiliser : voulez-vous écrire 0,11 Go, 118 Mo, 120564 Ko ou 123456789 o ? Habituellement, on choisit l’échelle de façon à ce que la valeur affichée soit entre 1 et 1024.

Il y a encore quelques autres éléments à prendre en compte :

  • Voulez-vous afficher des valeurs entières, ou inclure quelques chiffres après la virgule ?
  • Y a-t-il une unité minimale à utiliser (par exemple, Windows n’affiche jamais des octets : un fichier d’1 octet est affiché comme 1 Ko) ?
  • Comment la valeur doit-elle être arrondie ?
  • Comment faut-il formater la valeur ?
  • Pour les valeurs inférieures à 1 Ko, voulez vous utiliser le mot “octets”, ou juste le symbole “o” ?

Bon, ça suffit! Où veux-tu en venir?

Comme vous pouvez le voir, afficher une taille en octets sous forme lisible par des humains n’est pas aussi évident qu’on aurait pu s’y attendre… J’ai eu à écrire du code pour le faire dans plusieurs applications, et j’ai fini par en avoir assez de refaire à chaque fois, donc j’ai créé une librairie qui s’efforce de couvrir tous les cas d’utilisation. Je l’ai appelée HumanBytes, pour des raisons qui devraient être évidentes… Elle est également disponible sous forme de package NuGet.

Son utilisation est assez simple. Elle est basée sur la classe ByteSizeFormatter, qui expose des propriétés pour contrôler la façon dont la valeur est formatée :

var formatter = new ByteSizeFormatter
{
    Convention = ByteSizeConvention.Binary,
    DecimalPlaces = 1,
    NumberFormat = "#,##0.###",
    MinUnit = ByteSizeUnit.Kilobyte,
    MaxUnit = ByteSizeUnit.Gigabyte,
    RoundingRule = ByteSizeRounding.Closest,
    UseFullWordForBytes = true,
};

var f = new FileInfo("TheFile.jpg");
Console.WriteLine("The size of '{0}' is {1}", f, formatter.Format(f.Length));

Cependant, dans la plupart des cas, vous voudrez simplement utiliser les paramètres par défaut. Vous pouvez le faire facilement grâce à la méthode d’extension Bytes :

var f = new FileInfo("TheFile.jpg");
Console.WriteLine("The size of '{0}' is {1}", f, f.Length.Bytes());

Cette méthode renvoie une instance de la structure ByteSize, dont la méthode ToString formate la valeur avec le formateur par défaut. Vous pouvez changer les paramètres du formateur par défaut via la propriété statique ByteSizeFormatter.Default.

A propos de la localisation

Toutes les langues n’utilisent pas le même symbole pour “octet”, et bien sûr le mot “octet” lui-même est différent d’une langue à l’autre. Pour l’instant, HumanBytes ne supporte que l’anglais et le français ; si vous voulez ajouter le support d’une autre langue, n’hésitez pas à forker le projet, ajouter votre traduction, et faire une pull request. Il n’y a que 3 termes à traduire, donc ça ne devrait pas prendre trop longtemps Winking smile.

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.

Passage de paramètres par référence à une méthode asynchrone

L’asynchronisme dans C# est une fonctionnalité géniale, et je l’ai beaucoup utilisé depuis son apparition. Mais il y a quelques limitations agaçantes; par exemple, on ne peut pas passer des paramètres par référence (ref ou out) à une méthode asynchrone. Il y a de bonnes raisons pour cela; la plus évidente est que si vous passez par référence une variable locale, elle est stockée sur la pile, or la pile ne va pas rester disponible pendant toute l’exécution de la méthode asynchone (seulement jusqu’au premier await), donc l’emplacement de la variable n’existera plus.

Cependant, cette limitation est assez facile à contourner : il suffit de créer une classe Ref<T> pour encapsuler la valeur, et de passer une instance de cette classe par valeur à la méthode asynchrone:

async void btnFilesStats_Click(object sender, EventArgs e)
{
    var count = new Ref<int>();
    var size = new Ref<ulong>();
    await GetFileStats(tbPath.Text, count, size);
    txtFileStats.Text = string.Format("{0} files ({1} bytes)", count, size);
}

async Task GetFileStats(string path, Ref<int> totalCount, Ref<ulong> totalSize)
{
    var folder = await StorageFolder.GetFolderFromPathAsync(path);
    foreach (var f in await folder.GetFilesAsync())
    {
        totalCount.Value += 1;
        var props = await f.GetBasicPropertiesAsync();
        totalSize.Value += props.Size;
    }
    foreach (var f in await folder.GetFoldersAsync())
    {
        await GetFilesCountAndSize(f, totalCount, totalSize);
    }
}

La class Ref<T> ressemble à ceci:

public class Ref<T>
{
    public Ref() { }
    public Ref(T value) { Value = value; }
    public T Value { get; set; }
    public override string ToString()
    {
        T value = Value;
        return value == null ? "" : value.ToString();
    }
    public static implicit operator T(Ref<T> r) { return r.Value; }
    public static implicit operator Ref<T>(T value) { return new Ref<T>(value); }
}

Comme vous pouvez le voir, il n’y a rien de très compliqué. Cette approche peut également être utilisée pour les blocs itérateurs (yield return), qui n’autorisent pas non plus les paramètres ref ou out. Elle a aussi un avantage par rapport aux paramètres ref et out standards: elle permet de rendre le paramètre optionel, par exemple si on n’est pas intéressé par le résultat (évidemment il faut que la méthode appelée gère ce cas de façon appropriée).

Un moyen facile de tester unitairement la validation des arguments null

Quand on teste unitairement une méthode, une des choses à tester est la validation des arguments : par exemple, vérifier que la méthode lève bien une ArgumentNullException quand un argument null est passé pour un paramètre qui ne doit pas être null. Ecrire ce genre de test est très facile, mais c’est une tâche fastidieuse et répétitive, surtout pour une méthode qui a beaucoup de paramètres. J’ai donc écrit une méthode qui automatise en partie cette tâche : elle essaie de passer null pour chacun des arguments spécifiés, et vérifie que la méthode lève bien une ArgumentNullException. Voici un exemple qui teste une méthode d’extension FullOuterJoin :

[Test]
public void FullOuterJoin_Throws_If_Argument_Null()
{
    var left = Enumerable.Empty<int>();
    var right = Enumerable.Empty<int>();
    TestHelper.AssertThrowsWhenArgumentNull(
        () => left.FullOuterJoin(right, x => x, y => y, (k, x, y) => 0, 0, 0, null),
        "left", "right", "leftKeySelector", "rightKeySelector", "resultSelector");
}

Le premier paramètre est une expression lambda qui représente la façon d’appeler la méthode testée. Dans cette lambda, tous les arguments passés à la méthode doivent être valides. Les paramètres suivants sont les noms des paramètres qui ne doivent pas être null. Pour chacun des noms spécifiés, AssertThrowsWhenArgumentNull va remplacer l’argument correspondant par null dans l’expression lambda, compiler et invoquer l’expression lambda, et vérifier que la méthode lève bien une ArgumentNullException.

Grâce à cette méthode, au lieu d’écrire un test pour chacun des arguments à valider, il suffit d’un seul test.

Voici le code de la méthode TestHelper.AssertThrowsWhenArgumentNull (vous pouvez aussi le trouver sur Gist):

using System;
using System.Linq;
using System.Linq.Expressions;
using NUnit.Framework;

namespace MyLibrary.Tests
{
    static class TestHelper
    {
        public static void AssertThrowsWhenArgumentNull(Expression<TestDelegate> expr, params string[] paramNames)
        {
            var realCall = expr.Body as MethodCallExpression;
            if (realCall == null)
                throw new ArgumentException("Expression body is not a method call", "expr");

            var realArgs = realCall.Arguments;
            var paramIndexes = realCall.Method.GetParameters()
                .Select((p, i) => new { p, i })
                .ToDictionary(x => x.p.Name, x => x.i);
            var paramTypes = realCall.Method.GetParameters()
                .ToDictionary(p => p.Name, p => p.ParameterType);
            
            

            foreach (var paramName in paramNames)
            {
                var args = realArgs.ToArray();
                args[paramIndexes[paramName]] = Expression.Constant(null, paramTypes[paramName]);
                var call = Expression.Call(realCall.Method, args);
                var lambda = Expression.Lambda<TestDelegate>(call);
                var action = lambda.Compile();
                var ex = Assert.Throws<ArgumentNullException>(action, "Expected ArgumentNullException for parameter '{0}', but none was thrown.", paramName);
                Assert.AreEqual(paramName, ex.ParamName);
            }
        }

    }
}

Notez que cette méthode a été écrite pour NUnit, mais vous pouvez facilement l’adapter à d’autres frameworks de test unitaire.

J’ai utilisé cette méthode dans ma librairie Linq.Extras, qui fournit de nombreuses méthodes d’extension supplémentaires pour travailler avec des séquences et collections (elle inclut par exemple la méthode FullOuterJoin mentionnée plus haut).