元组与字符串作为 C 语言中的字典键#

Tuple vs string as a Dictionary key in C#

提问人:Shahar 提问时间:1/30/2017 最后编辑:Shahar 更新时间:2/28/2018 访问量:15325

问:

我有一个使用 ConcurrentDictionary 实现的缓存, 我需要保留的数据取决于 5 个参数。 因此,从缓存中获取它的方法是:(为了简单起见,我在这里只显示了 3 个参数,并且为了清楚起见,我更改了数据类型以表示 CarData)

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

我想知道在我的 ConcurrentDictionary 中使用哪种类型的键会更好,我可以这样做:

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

或者像这样:

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

我不会在其他地方同时使用这些参数,因此没有理由仅仅为了将它们放在一起而创建一个类。

我想知道哪种方法在性能和可维护性方面更好。

C# .NET 缓存 相等 ConcurrentDictionary

评论

6赞 1/30/2017
如果你在谈论可维护性和可读性,我会说你仍然会使用自定义比较器为参数/键创建一个类。如果使用一个类,则只需编辑该类,而不必编辑代码中的两个或更多位置。不过,这就是我的全部意见。如果我必须在你的两个选项之间做出选择,我会选择元组作为键。它在性能方面更差,但更容易理解/维护。
5赞 1/30/2017
我的公司总是这样说:“你的代码只写一次,但你的代码会被读取一千次。不过,这完全取决于你,我只是发现可读性对于我编写的代码很重要:)
2赞 Me.Name 1/30/2017
就像 RandomStranger 一样,我更喜欢一个类来提高可维护性,但也希望提高性能。在你自己的类中,你可以根据最独特的值进行覆盖,并基于首先比较最独特的值来短路。 应尽可能简单,同时仍返回尽可能唯一的值,以实现最佳字典性能GetHashCodeEqualsGetHashCode
2赞 o_weisman 1/30/2017
我觉得很奇怪,同一个字典需要 5 个不同的键。
3赞 Chris 1/30/2017
如果您使用字符串连接,则需要确保不会重复键。如果任何字符串包含 _,那么它们可能会立即不明确。当然,这可能不会发生在你的密钥格式上,但这是需要注意的,也是为什么我更倾向于元组而不是字符串。

答:

3赞 Alex Aparin 1/30/2017 #1

恕我直言,在这种情况下,我更喜欢使用一些中间结构(在您的情况下将是).这种方法在参数和最终目标字典之间创建了额外的层。当然,这将取决于目的。例如,这种方式允许您创建不平凡的参数转换(例如,容器可能会“扭曲”数据)。Tuple

14赞 Tim Rutter 1/30/2017 #2

您可以创建一个重写 GetHashCode 和 Equals 的类(它仅在此处使用并不重要):

感谢 theDmi(和其他人)的改进......

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;
    }
}

如果不重写这些内容,则 ContainsKey 将执行引用等于。

注意:该类确实有自己的相等函数,其作用与上述基本相同。使用定制类可以清楚地表明这是要发生的事情 - 因此更有利于可维护性。它还具有您可以命名属性的优点,以便一目了然Tuple

注意 2:该类是不可变的,因为字典键需要避免在将对象添加到字典后哈希码更改的潜在错误 请参阅此处

GetHashCode 取自此处

评论

4赞 InBetween 1/30/2017
虽然这个答案很好,但我建议实现哪些自动文档可以更好地实现均衡检查的类型值语义。KeyIEquatable<Key>
3赞 luk32 1/30/2017
我不太明白为什么拥有冗余类型对可维护性更好。您的示例在元组上没有任何内容。你需要“完全控制比较”做什么?您方便地跳过了一件非常重要的事情,即 的实现。这似乎是显式地重新实现元组的存根,只是为了冗长。在架构中引入不必要的实体并不是一件好事。这种方法很好,但前提是现有的标准类型还不够。GetHashCode
4赞 theDmi 1/30/2017
这个类真的应该是不可变的。
1赞 theDmi 1/30/2017
实际上并非如此,元组不可变的。
1赞 Tim Rutter 1/30/2017
@JeroenMostert谢谢,我并没有试图创建完整的实现,但我已经更新了我的答案,我更新得越多,它提出的问题就越多 - 学到了一些东西,但希望对其他人也有用。
23赞 InBetween 1/30/2017 #3

我想知道哪种方法在性能和可维护性方面更好。

与往常一样,您拥有解决问题的工具。对两种可能的解决方案进行编码,并使它们竞争。赢家就是赢家,你不需要任何人在这里回答这个特定的问题。

关于维护,能够更好地自动记录自己并具有更好可扩展性的解决方案应该是赢家。在这种情况下,代码是如此微不足道,以至于自动文档并不是一个大问题。从可扩展性的角度来看,恕我直言,最好的解决方案是使用:Tuple<T1, T2, ...>

  • 你得到了你不需要的自由相等语义 保持。
  • 碰撞是不可能的,如果你选择,这是不正确的 字符串连接解决方案:

    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";
    

    是的,牵强附会,但是,从理论上讲,完全有可能,而你的问题恰恰是关于未来的未知事件,所以......

  • 最后但并非最不重要的一点是,编译器可以帮助您维护代码。例如,如果明天您必须添加到您的密钥中,将强烈键入您的密钥。另一方面,您的字符串连接算法可以幸福地快乐地生成密钥,并且您不会知道发生了什么,直到您的客户打电话给您,因为他们的软件没有按预期工作。param4Tuple<T1, T2, T3, T4>param4

评论

0赞 VisualMelon 1/30/2017
+1 所有优点,以及针对该问题的合理讨论,尽管如果其他人必须使用代码,我仍然倾向于编写自己的类型。
0赞 InBetween 1/30/2017
@VisualMelon Thnks。是的,我完全同意,但已经有一个针对该特定解决方案的赞成问题。我只是专注于回答确切的问题。我还可能实现一个私有嵌套类来处理密钥,即使它确实增加了额外的维护成本。
0赞 VisualMelon 1/30/2017
是的,我认为这两种答案都有很大的价值,当然我没有轻视,你的回答非常好。
0赞 Tomer 1/30/2017
这可行,但比创建类型慢一点。绝对比字符串键好。stackoverflow.com/a/41938199/1176983
6赞 theDmi 1/30/2017 #4

实现自定义键类并确保它适用于此类用例,即实现 IEquatable 并使类不可变

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;
        }
    }
}

这是 Resharper 生成它的实现方式。这是一个很好的通用实现。根据需要进行调整。GetHashCode()


或者,使用像 Equ(我是该库的创建者)这样自动生成和实现的东西。这将确保这些方法始终包含类的所有成员,因此代码变得更加易于维护。然后,这样的实现将如下所示:EqualsGetHashCodeCacheKey

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; }
}

注意:显然应该使用有意义的属性名称,否则引入自定义类不会比使用元组提供太多好处。

评论

0赞 Taemyr 1/30/2017
如果对象是可插补的,并且专门用作字典键,那么哈希码不应该在对象初始化时计算吗?
1赞 theDmi 1/30/2017
可以,但为什么呢?你的建议是微优化。如果您真的想知道这个问题的答案,您必须对两个版本进行实际用例分析,看看哪个版本更快。
1赞 VisualMelon 1/30/2017
我认为编写自己的类而不是使用类的主要好处是 和 没有任何意义,和 也是如此。我建议包括一个注释,如果你要为这种情况创建一个专用的类,你应该去完整的猪并清楚地命名每个参数,因为我在这里看到的只是 2 个字符串和一个 int。也许你可以弄清楚里面是什么,但你怎么知道哪个字符串去哪里了?(P.S. +1 表示不可变且可行的实现)TupleItem1Item2Param1Param2GetHashCode
1赞 theDmi 1/30/2017
@VisualMelon 谢谢你的笔记,这当然是这个想法,我会澄清答案。由于问题中的名字毫无意义,所以我在这里也做了同样的事情......
0赞 pinkfloydx33 1/30/2017
我相信哪个是什么?至少我相信这个用例至少部分(也许是全部)是该接口存在的原因IStructuralEquatableTuple<>
4赞 Tomer 1/30/2017 #5

我想比较其他评论中描述的与“id_id_id”方法。我使用了这个简单的代码:TupleClass

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();
    }
}

结果:

我在 Release 中运行了每个方法 3 次,没有调试,每次运行都会注释掉对其他方法的调用。我取了 3 次运行的平均值,但无论如何都没有太大的差异。

测试元组:

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

测试类:

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

测试平面:

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

我很惊讶地发现类方法比元组方法和字符串方法都快。在我看来,它更具可读性,更面向未来,从某种意义上说,可以向类中添加更多功能(假设它不仅仅是一个键,它代表了一些东西)。Key

评论

0赞 Shahar 1/30/2017
比较干得好。似乎最好的解决方案是使用一个类,但所有给定的参数都是代码重置中其他类的一部分......
0赞 Tomer 1/30/2017
谢谢。即使参数是其他类的一部分,我认为创建新类也没有问题。
0赞 Shahar 1/30/2017
我认为是时候安排此代码中使用的所有类和结构了,然后我将为键创建新类。这需要一些时间,因为这不是我的代码。
18赞 svick 1/31/2017 #6

如果性能真的很重要,那么答案是不应使用任何一个选项,因为这两个选项都会在每次访问时不必要地分配一个对象。

相反,应使用 ,可以是自定义的,也可以是 System.ValueTuple 包中的structValueTuple

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

C# 7.0 还集成了语法 sugar,使此代码更易于编写(但您不需要等待 C# 7.0 在没有 sugar 的情况下开始使用):ValueTuple

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

评论

1赞 Jim Wolff 8/16/2017
您甚至可以命名密钥的各个部分,因此它变得非常像一个自定义类,您可以在其中实现您拥有的 Equals 和 GetHashCode
4赞 Grady Werner 2/28/2018 #7

我运行了 Tomer 的测试用例,将 ValueTuples 添加为测试用例(新的 c# 值类型)。他们的表现令人印象深刻。

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

测试代码如下:

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}");
}