有没有理由使用 ContainsKey 而不是 TryGetValue?

10

我进行了一些测试,无论是全部命中还是全部未命中,使用TryGetValue总是更快的。那么什么情况下应该使用ContainsKey呢?


2
你的计时测试可能没有正确执行(假设你正在使用Dictionary),这两个函数都调用了一个名为FindEntry()的函数,只不过TryGetValue在执行完更多代码后(尽管只有一次赋值!)才结束,因此它不可能更快。https://referencesource.microsoft.com/#mscorlib/system/collections/generic/dictionary.cs,bcd13bb775d408f1 - starlight54
2
你的基准测试结果是错误的。很难说为什么,因为我们看不到它们。ContainsKey 要快得多;当你不需要检索内容,只需要知道它是否存在时,你可以使用它,因为它更快。 - Ken White
1
@KenWhite 如果他在那之后取值,基准测试就是正确的! - mybirthname
1
如果发帖者这样做,他们的基准绝对是不正确的,因为它并没有比较ContainsKey和TryGetValue;它将会比较TryGetValue与ContainsKey+TryGetValue。 - Ken White
在设置值时相同: d[key] = value; 与以下代码相比,跳过了额外的查找: if (!d.ContainsKey(key)) d.Add(key, value); - Brain2000
2个回答

29

这取决于您使用这些方法的目的。如果您打开引用源代码,您会看到。

public bool TryGetValue(TKey key, out TValue value)
{
  int index = this.FindEntry(key);
  if (index >= 0)
  {
    value = this.entries[index].value;
    return true;
  }
  value = default(TValue);
  return false;
}

public bool ContainsKey(TKey key)
{
  return (this.FindEntry(key) >= 0);
}

就像你所看到的,TryGetValueContainsKey加一个数组查找是一样的。

如果你的逻辑只是检查键是否存在于Dictionary中,而与此键相关的其他内容(获取键的值)没有任何关系,那么你应该使用ContainsKey

如果你想获取特定键的值,TryGetValueContainsKey更快。

if(dic.ContainsKey(keyValue))
{
    dicValue = dic[keyValue]; // here you do more work!
}

Dictionary[key] 的逻辑

    public TValue this[TKey key] 
    {
        get {
            int i = FindEntry(key);
            if (i >= 0) return entries[i].value;
            ThrowHelper.ThrowKeyNotFoundException();
            return default(TValue);
        }
        set {
            Insert(key, value, false);
        }
    }

如果你使用带有ContainsKey的键,并且之后用dic[key]获取值,那么基本上你将会执行FindEntry方法和数组查找两次。这样就会增加一次调用FindEntry的开销。


13

从概念上讲,这两种方法非常不同。 ContainsKey 仅检查给定的键是否在字典中。 TryGetValue 将尝试返回给定键的值(如果存在于字典中)。根据你想要做什么,两种方法都可以很快。

考虑以下方法,它从字典中返回一个值或返回 string.Empty。

Dictionary<int,string> Dict = new Dictionary<int,string>();
string GetValue1(int key)
{
    string outValue;
    if (!Dict.TryGetValue(key, out outValue))
        outValue = string.Empty;
    return outValue;
}

相比之下

string GetValue2(int key)
{
    return (Dict.ContainsKey(key))
        ? Dict[key]
        : string.Empty;
}

两者都相对较快,在大多数情况下它们的性能可以忽略不计(请参见下面的单元测试)。如果我们想要挑剔的话,使用TryGetValue需要使用out参数,这比普通参数多出了一些开销。如果未找到该值,则此变量将被设置为类型的默认值,对于字符串来说是null。在上面的示例中,虽然没有使用这个null值,但我们仍然产生了额外的开销。最后,GetValue1需要使用一个本地变量outValue,而GetValue2不需要。

BACON指出,在值被找到的情况下,GetValue2将使用2次查找,这比较昂贵。这是正确的,这也意味着在键未被找到的情况下,GetValue2的性能会更快。因此,如果程序预计大部分查找都未命中,请使用GetValue2。否则,请使用GetValue1。

如果处理ConcurrentDictionary,请使用TryGetValue,因为在多线程环境下对其进行的操作应该是原子的,即一旦您在检查值时,当您尝试访问该值时,该检查可能是不正确的。

下面包含2个单元测试,测试具有不同Key类型(int vs string)的字典中这两种方法的性能。此基准测试仅展示这两种方法之间的差距如何随着上下文的变化而缩小/扩大。

[TestMethod]
public void TestString()
{
    int counter = 10000000;
    for (var x = 0; x < counter; x++)
        DictString.Add(x.ToString(), "hello");

    TimedLog("10,000,000 hits TryGet", () =>
    {
        for (var x = 0; x < counter; x++)
            Assert.IsFalse(string.IsNullOrEmpty(GetValue1String(x.ToString())));
    }, Console.WriteLine);

    TimedLog("10,000,000 hits ContainsKey", () =>
    {
        for (var x = 0; x < counter; x++)
            Assert.IsFalse(string.IsNullOrEmpty(GetValue2String(x.ToString())));
    }, Console.WriteLine);

    TimedLog("10,000,000 misses TryGet", () =>
    {
        for (var x = counter; x < counter*2; x++)
            Assert.IsTrue(string.IsNullOrEmpty(GetValue1String(x.ToString())));
    }, Console.WriteLine);

    TimedLog("10,000,000 misses ContainsKey", () =>
    {
        for (var x = counter; x < counter*2; x++)
            Assert.IsTrue(string.IsNullOrEmpty(GetValue2String(x.ToString())));
    }, Console.WriteLine);
}

[TestMethod]
public void TestInt()
{
    int counter = 10000000;
    for (var x = 0; x < counter; x++)
        DictInt.Add(x, "hello");

    TimedLog("10,000,000 hits TryGet", () =>
    {
        for (var x = 0; x < counter; x++)
            Assert.IsFalse(string.IsNullOrEmpty(GetValue1Int(x)));
    }, Console.WriteLine);

    TimedLog("10,000,000 hits ContainsKey", () =>
    {
        for (var x = 0; x < counter; x++)
            Assert.IsFalse(string.IsNullOrEmpty(GetValue2Int(x)));
    }, Console.WriteLine);

    TimedLog("10,000,000 misses TryGet", () =>
    {
        for (var x = counter; x < counter * 2; x++)
            Assert.IsTrue(string.IsNullOrEmpty(GetValue1Int(x)));
    }, Console.WriteLine);

    TimedLog("10,000,000 misses ContainsKey", () =>
    {
        for (var x = counter; x < counter * 2; x++)
            Assert.IsTrue(string.IsNullOrEmpty(GetValue2Int(x)));
    }, Console.WriteLine);
}

public static void TimedLog(string message, Action toPerform, Action<string> logger)
{
    var start = DateTime.Now;
    if (logger != null)
        logger.Invoke(string.Format("{0} Started at {1:G}", message, start));

    toPerform.Invoke();
    var end = DateTime.Now;
    var span = end - start;
    if (logger != null)
        logger.Invoke(string.Format("{0} Ended at {1} lasting {2:G}", message, end, span));
}

TestInt 的结果

10,000,000 hits TryGet Started at ...
10,000,000 hits TryGet Ended at ... lasting 0:00:00:00.3734136
10,000,000 hits ContainsKey Started at ...
10,000,000 hits ContainsKey Ended at ... lasting 0:00:00:00.4657632
10,000,000 misses TryGet Started at ...
10,000,000 misses TryGet Ended at ... lasting 0:00:00:00.2921058
10,000,000 misses ContainsKey Started at ...
10,000,000 misses ContainsKey Ended at ... lasting 0:00:00:00.2579766

对于命中的情况,ContainsKey 大约比 TryGetValue 慢 25%。

对于未命中的情况,TryGetValue 大约比 ContainsKey 慢 13%。

TestString 的测试结果。

10,000,000 hits TryGet Started at ...
10,000,000 hits TryGet Ended at ... lasting 0:00:00:03.2232018
10,000,000 hits ContainsKey Started at ...
10,000,000 hits ContainsKey Ended at ... lasting 0:00:00:03.6417864
10,000,000 misses TryGet Started at ...
10,000,000 misses TryGet Ended at ... lasting 0:00:00:03.6508206
10,000,000 misses ContainsKey Started at ...
10,000,000 misses ContainsKey Ended at ... lasting 0:00:00:03.4912164

对于命中,ContainsKey比TryGetValue慢约13%

对于未命中,TryGetValue比ContainsKey慢约4.6%


TryGetValue文档指出:“此方法结合了ContainsKey方法和Item属性的功能。”虽然相对于GetValue2GetValue1具有本地变量和通过引用传递参数的“开销”,但与在Dict包含键key的情况下执行两次查找的GetValue2相比,这是可以忽略不计的:一次在调用Dict.ContainsKey(key)时进行查找,另一次在检索Dict[key]时进行查找。 - Lance U. Matthews
谢谢您的评论。我已经更新了我的帖子以反映您的观点。 - Paul Tsai
从源代码中我们可以得知,当键值未找到时,相比于ContainsKeyTryGetValue只需要执行额外的跳转并写入输出参数。我认为这个答案过于关注微观优化,如避免使用局部变量和输出参数。这个基准测试显示,在1000万次查找(全部未命中)中,TryGetValue仅比ContainsKey略慢。除非有特殊情况证明TryGetValue成为瓶颈,并且测试表明[继续] - Lance U. Matthews
ContainsKey + Dict[key] 提供了值得的性能提升,我认为一般建议应该根据其意图选择 Dictionary<> 方法:ContainsKey 用于检查不需要值的键是否存在,而 TryGetValue 则用于尝试检索可能不存在的键的值。 - Lance U. Matthews
你的观点是正确的。谢谢你提供基准链接,我已经更新了我的文章,加入了一些使用不同类型关键字典且规模更大的基准测试。很明显,在某些情况下,其中一个的性能要比另一个好得多,甚至键的类型也会影响性能。 - Paul Tsai

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接