web-dev-qa-db-fra.com

Tuple vs string comme clé de dictionnaire en C #

J'ai un cache que j'implémente en utilisant un ConcurrentDictionary, Les données que je dois conserver dépendent de 5 paramètres. Ainsi, la méthode pour l'obtenir à partir du cache est: (je montre seulement 3 paramètres ici pour plus de simplicité, et j'ai changé le type de données pour représenter CarData pour plus de clarté)

public CarData GetCarData(string carModel, string engineType, int year);

Je me demande quel type de clé sera mieux utilisé dans mon ConcurrentDictionary, je peux le faire comme ceci:

var carCache = new ConcurrentDictionary<string, CarData>();
// check for car key
bool exists = carCache.ContainsKey(string.Format("{0}_{1}_{2}", carModel, engineType, year);

Ou comme ça:

var carCache = new ConcurrentDictionary<Tuple<string, string, int>, CarData>();
// check for car key
bool exists = carCache.ContainsKey(new Tuple(carModel, engineType, year));

Je n'utilise ces paramètres ensemble nulle part ailleurs, il n'y a donc aucune justification pour créer une classe juste pour les garder ensemble.

Je veux savoir quelle approche est la meilleure en termes de performances et de maintenabilité.

27
Shahar

Vous pouvez créer une classe (peu importe qu'elle soit uniquement utilisée ici) qui remplace GetHashCode et Equals:

Merci theDmi (et autres) pour les améliorations ...

public class CarKey : IEquatable<CarKey>
{
    public CarKey(string carModel, string engineType, int year)
    {
        CarModel = carModel;
        EngineType= engineType;
        Year= year;
    }

    public string CarModel {get;}
    public string EngineType {get;}
    public int Year {get;}

    public override int GetHashCode()
    {
        unchecked // Overflow is fine, just wrap
        {
            int hash = (int) 2166136261;

            hash = (hash * 16777619) ^ CarModel?.GetHashCode() ?? 0;
            hash = (hash * 16777619) ^ EngineType?.GetHashCode() ?? 0;
            hash = (hash * 16777619) ^ Year.GetHashCode();
            return hash;
        }
    }

    public override bool Equals(object other)
    {
        if (ReferenceEquals(null, other)) return false;
        if (ReferenceEquals(this, other)) return true;
        if (other.GetType() != GetType()) return false;
        return Equals(other as CarKey);
    }

    public bool Equals(CarKey other)
    {
        if (ReferenceEquals(null, other)) return false;
        if (ReferenceEquals(this, other)) return true;
        return string.Equals(CarModel,obj.CarModel) && string.Equals(EngineType, obj.EngineType) && Year == obj.Year;
    }
}

Si vous ne les remplacez pas, ContainsKey fait une référence égale.

Remarque: la classe Tuple possède ses propres fonctions d'égalité qui feraient essentiellement la même chose que ci-dessus. L'utilisation d'une classe sur mesure montre clairement que c'est ce qui est prévu - et est donc meilleur pour la maintenabilité. Il a également l'avantage que vous pouvez nommer les propriétés de sorte qu'il soit clair

Remarque 2: la classe est immuable car les clés de dictionnaire doivent l'être pour éviter que des bogues potentiels avec des codes de hachage ne changent après que l'objet ait été ajouté au dictionnaire voir ici

GetHashCode tiré d'ici

14
Tim Rutter

Je veux savoir quelle approche est la meilleure en termes de performance et de maintenabilité.

Comme toujours, vous avez les outils pour le comprendre. Codez les deux solutions possibles et faites-les courir . Celui qui gagne est le gagnant, vous n'avez besoin de personne ici pour répondre à cette question particulière.

En ce qui concerne la maintenance, la solution qui auto-documente mieux et a une meilleure évolutivité devrait être gagnante. Dans ce cas, le code est si trivial que la documentation automatique n'est pas vraiment un problème. Du point de vue de l'évolutivité, à mon humble avis, la meilleure solution consiste à utiliser Tuple<T1, T2, ...>:

  • Vous obtenez une sémantique d'égalité gratuite que vous n'avez pas besoin de maintenir.
  • Les collisions ne sont pas possibles, ce qui n'est pas vrai si vous choisissez la solution de concaténation de chaînes:

    var param1 = "Hey_I'm a weird string";
    var param2 = "!"
    var param3 = 1;
    key = "Hey_I'm a weird string_!_1";
    
    var param1 = "Hey";
    var param2 = "I'm a weird string_!"
    var param3 = 1;
    key = "Hey_I'm a weird string_!_1";
    

    Ouais, tiré par les cheveux, mais, en théorie, tout à fait possible et votre question porte précisément sur des événements inconnus dans le futur, alors ...

  • Enfin et surtout, le compilateur vous aide à maintenir le code. Si, par exemple, vous devez ajouter demain param4 à votre clé, Tuple<T1, T2, T3, T4> tapera fortement votre clé. D'un autre côté, votre algorithme de concaténation de chaînes peut vivre avec des clés de génération parfaitement heureuses sans param4 et vous ne saurez pas ce qui se passe jusqu'à ce que votre client vous appelle parce que son logiciel ne fonctionne pas comme prévu.

21
InBetween

Si les performances sont vraiment importantes, alors la réponse est que vous ne devez utiliser aucune des deux options, car les deux allouent inutilement un objet à chaque accès.

À la place, vous devez utiliser un struct, soit personnalisé, soit ValueTuple de le package System.ValueTuple :

var myCache = new ConcurrentDictionary<ValueTuple<string, string, int>, CachedData>();
bool exists = myCache.ContainsKey(ValueTuple.Create(param1, param2, param3));

C # 7.0 contient également du sucre de syntaxe pour rendre ce code plus facile à écrire (mais vous n'avez pas besoin d'attendre que C # 7.0 commence à utiliser ValueTuple sans le sucre):

var myCache = new ConcurrentDictionary<(string, string, int), CachedData>();
bool exists = myCache.ContainsKey((param1, param2, param3));
10
svick

Implémentez une classe de clé personnalisée et assurez-vous qu'elle convient à de tels cas d'utilisation, c'est-à-dire implémentez IEquatable et rendez la classe immuable :

public class CacheKey : IEquatable<CacheKey>
{
    public CacheKey(string param1, string param2, int param3)
    {
        Param1 = param1;
        Param2 = param2;
        Param3 = param3;
    }

    public string Param1 { get; }

    public string Param2 { get; }

    public int Param3 { get; }

    public bool Equals(CacheKey other)
    {
        if (ReferenceEquals(null, other)) return false;
        if (ReferenceEquals(this, other)) return true;
        return string.Equals(Param1, other.Param1) && string.Equals(Param2, other.Param2) && Param3 == other.Param3;
    }

    public override bool Equals(object obj)
    {
        if (ReferenceEquals(null, obj)) return false;
        if (ReferenceEquals(this, obj)) return true;
        if (obj.GetType() != GetType()) return false;
        return Equals((CacheKey)obj);
    }

    public override int GetHashCode()
    {
        unchecked
        {
            var hashCode = Param1?.GetHashCode() ?? 0;
            hashCode = (hashCode * 397) ^ (Param2?.GetHashCode() ?? 0);
            hashCode = (hashCode * 397) ^ Param3;
            return hashCode;
        }
    }
}

Il s'agit d'une implémentation de GetHashCode() comment Resharper la génère. C'est une bonne implémentation à usage général. Adaptez au besoin.


Sinon, utilisez quelque chose comme Eq (je suis le créateur de cette bibliothèque) qui génère automatiquement les implémentations Equals et GetHashCode. Cela garantira que ces méthodes incluent toujours tous les membres de la classe CacheKey, donc le code devient beaucoup plus facile à maintenir . Une telle implémentation ressemblerait alors simplement à ceci:

public class CacheKey : MemberwiseEquatable<CacheKey>
{
    public CacheKey(string param1, string param2, int param3)
    {
        Param1 = param1;
        Param2 = param2;
        Param3 = param3;
    }

    public string Param1 { get; }

    public string Param2 { get; }

    public int Param3 { get; }
}

Remarque: vous devez évidemment utiliser des noms de propriété significatifs , sinon l'introduction d'une classe personnalisée ne présente pas beaucoup d'avantages par rapport à l'utilisation d'un Tuple.

6
theDmi

Je voulais comparer les approches Tuple contre Class contre "id_id_id" décrites dans les autres commentaires. J'ai utilisé ce code simple:

public class Key : IEquatable<Key>
{
    public string Param1 { get; set; }
    public string Param2 { get; set; }
    public int Param3 { get; set; }

    public bool Equals(Key other)
    {
        if (ReferenceEquals(null, other)) return false;
        if (ReferenceEquals(this, other)) return true;
        return string.Equals(Param1, other.Param1) && string.Equals(Param2, other.Param2) && Param3 == other.Param3;
    }

    public override bool Equals(object obj)
    {
        if (ReferenceEquals(null, obj)) return false;
        if (ReferenceEquals(this, obj)) return true;
        if (obj.GetType() != this.GetType()) return false;
        return Equals((Key) obj);
    }

    public override int GetHashCode()
    {
        unchecked
        {
            var hashCode = (Param1 != null ? Param1.GetHashCode() : 0);
            hashCode = (hashCode * 397) ^ (Param2 != null ? Param2.GetHashCode() : 0);
            hashCode = (hashCode * 397) ^ Param3;
            return hashCode;
        }
    }
}

static class Program
{

    static void TestClass()
    {
        var stopwatch = new Stopwatch();
        stopwatch.Start();
        var classDictionary = new Dictionary<Key, string>();

        for (var i = 0; i < 10000000; i++)
        {
            classDictionary.Add(new Key { Param1 = i.ToString(), Param2 = i.ToString(), Param3 = i }, i.ToString());
        }
        stopwatch.Stop();
        Console.WriteLine($"initialization: {stopwatch.Elapsed}");

        stopwatch.Restart();

        for (var i = 0; i < 10000000; i++)
        {
            var s = classDictionary[new Key { Param1 = i.ToString(), Param2 = i.ToString(), Param3 = i }];
        }

        stopwatch.Stop();
        Console.WriteLine($"Retrieving: {stopwatch.Elapsed}");
    }

    static void TestTuple()
    {
        var stopwatch = new Stopwatch();
        stopwatch.Start();
        var tupleDictionary = new Dictionary<Tuple<string, string, int>, string>();

        for (var i = 0; i < 10000000; i++)
        {
            tupleDictionary.Add(new Tuple<string, string, int>(i.ToString(), i.ToString(), i), i.ToString());
        }
        stopwatch.Stop();
        Console.WriteLine($"initialization: {stopwatch.Elapsed}");

        stopwatch.Restart();

        for (var i = 0; i < 10000000; i++)
        {
            var s = tupleDictionary[new Tuple<string, string, int>(i.ToString(), i.ToString(), i)];
        }

        stopwatch.Stop();
        Console.WriteLine($"Retrieving: {stopwatch.Elapsed}");
    }

    static void TestFlat()
    {
        var stopwatch = new Stopwatch();
        stopwatch.Start();
        var tupleDictionary = new Dictionary<string, string>();

        for (var i = 0; i < 10000000; i++)
        {
            tupleDictionary.Add($"{i}_{i}_{i}", i.ToString());
        }
        stopwatch.Stop();
        Console.WriteLine($"initialization: {stopwatch.Elapsed}");

        stopwatch.Restart();

        for (var i = 0; i < 10000000; i++)
        {
            var s = tupleDictionary[$"{i}_{i}_{i}"];
        }

        stopwatch.Stop();
        Console.WriteLine($"Retrieving: {stopwatch.Elapsed}");
    }

    static void Main()
    {
        TestClass();
        TestTuple();
        TestFlat();
    }
}

Résultats:

J'ai exécuté chaque méthode 3 fois dans Release sans débogage, chaque exécution commentant les appels aux autres méthodes. J'ai pris la moyenne des 3 descentes, mais il n'y avait pas de grande différence de toute façon.

TestTuple:

initialization: 00:00:14.2512736
Retrieving: 00:00:08.1912167

TestClass:

initialization: 00:00:11.5091160
Retrieving: 00:00:05.5127963

TestFlat:

initialization: 00:00:16.3672901
Retrieving: 00:00:08.6512009

J'ai été surpris de voir que l'approche de classe était plus rapide que l'approche Tuple et l'approche par cordes. À mon avis, c'est plus lisible et plus sûr pour l'avenir, dans le sens où plus de fonctionnalités peuvent être ajoutées à la classe Key (en supposant que ce n'est pas seulement une clé, cela représente quelque chose).

4
Tomer

À mon humble avis, je préfère utiliser dans de tels cas une structure intermédiaire (dans votre cas, ce sera Tuple). Une telle approche crée une couche supplémentaire entre les paramètres et le dictionnaire cible final. Bien sûr, cela dépendra des objectifs. Une telle façon, par exemple, vous permet de créer une transition non triviale des paramètres (par exemple, le conteneur peut "déformer" les données).

3
LmTinyToon

J'ai exécuté les cas de test de Tomer, en ajoutant ValueTuples comme cas de test (nouveau type de valeur c #). J'ai été impressionné par leur performance.

TestClass
initialization: 00:00:11.8787245
Retrieving: 00:00:06.3609475

TestTuple
initialization: 00:00:14.6531189
Retrieving: 00:00:08.5906265

TestValueTuple
initialization: 00:00:10.8491263
Retrieving: 00:00:06.6928401

TestFlat
initialization: 00:00:16.6559780
Retrieving: 00:00:08.5257845

Le code du test est ci-dessous:

static void TestValueTuple(int n = 10000000)
{
    var stopwatch = new Stopwatch();
    stopwatch.Start();
    var tupleDictionary = new Dictionary<(string, string, int), string>();

    for (var i = 0; i < n; i++)
    {
        tupleDictionary.Add((i.ToString(), i.ToString(), i), i.ToString());
    }
    stopwatch.Stop();
    Console.WriteLine($"initialization: {stopwatch.Elapsed}");

    stopwatch.Restart();

    for (var i = 0; i < n; i++)
    {
        var s = tupleDictionary[(i.ToString(), i.ToString(), i)];
    }

    stopwatch.Stop();
    Console.WriteLine($"Retrieving: {stopwatch.Elapsed}");
}
3
Grady Werner