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

Tuple vs string as a Dictionary key in C#

我有一个使用 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));

我不会在任何其他地方一起使用这些参数,因此没有理由创建 class 只是为了将它们放在一起。

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

恕我直言,我更喜欢在这种情况下使用一些中间结构(在你的情况下它将是 Tuple)。这种方法在参数和 end-target 字典之间创建了附加层。当然,这将取决于目的。例如,这种方式允许您创建不平凡的参数转换(例如容器可能 "distort" 数据)。

您可以创建一个覆盖 GetHashCode 和 Equals 的 class(不要紧,它只在这里使用):

感谢 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 class 确实有自己的相等函数,基本上与上面的相同。使用定制的 class 可以清楚地表明这是打算发生的事情——因此更利于可维护性。它还具有可以命名属性的优点,因此很清楚

注意 2:class 是不可变的,因为字典键需要是不可变的,以避免在对象添加到字典后更改哈希码的潜在错误 See here

GetHashCode taken from here

I want to know which approach is a better in terms of performance and maintainability.

一如既往,您拥有解决问题的工具。对两种可能的解决方案进行编码,并使它们成为 race。谁赢谁赢,这个问题不需要任何人来回答。

关于维护,autodocuments本身更好并且具有更好的可扩展性的解决方案应该是赢家。在这种情况下,代码是如此微不足道以至于自动文档不是什么大问题。从可扩展性的角度来看,恕我直言,最好的解决方案是使用 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";
    

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

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

实施自定义密钥 class 并确保它适合此类用例,即 实施 IEquatable 并使 class 不可变:

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() 实现。这是一个很好的 general-purpose 实现。根据需要进行调整。


或者,使用 Equ(我是该库的创建者)之类的东西自动生成 EqualsGetHashCode 实现。这将确保这些方法始终包含 CacheKey class 的所有成员,因此 代码变得更易于维护 。这样的实现将看起来像这样:

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

注意:您显然应该使用 有意义的 属性 名称,否则引入自定义 class 并不会比使用Tuple.

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

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

我惊讶地发现 class 方法比元组方法和字符串方法都快。在我看来,它更具可读性和更多 future-safe,因为可以将更多功能添加到 Key class(假设它不仅仅是一个键,它代表某种东西)。

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

相反,您应该使用 struct,自定义的,或来自 the System.ValueTuple package:

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

C# 7.0 还包含语法糖以使此代码更易于编写(但您无需等待 C# 7.0 开始使用没有糖的 ValueTuple):

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

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