作为IEnumerable的KeyCollection会产生不一致的LINQ行为

cigdeys3  于 2023-04-27  发布在  其他
关注(0)|答案(3)|浏览(139)

下面的代码打印“false”

IEnumerable<string> x = new List<string>();
Console.WriteLine(x.Contains(null));

但是下面的代码抛出了一个ArgumentNullException

IEnumerable<string> x = new Dictionary<string, string>().Keys;
Console.WriteLine(x.Contains(null));

我看到this post解释了为什么传入null时Dictionary.ContainsKey会抛出,所以我猜这一行为是相关的。然而,在ContainsKey的情况下,我得到了漂亮的绿色波浪线,而在IEnumerable的情况下,我的应用程序崩溃了:

消费代码不会知道传递给它的IEnumerable的底层类型,所以我们需要:

  • 一般情况下不对可空类型使用IEnumerable.Contains(),或者
  • KeyCollection转换为列表,然后将其视为IEnumerable

这是正确的,还是我错过了什么?

l5tcr1uw

l5tcr1uw1#

我假设您希望将Keys属性公开为IEnumerable<TKey>序列,以允许搜索null。一种简单的方法是将集合 Package 在IEnumerable<TKey>实现中,该实现隐藏了集合的标识:

static IEnumerable<T> HideIdentity<T>(this IEnumerable<T> source)
{
    ArgumentNullException.ThrowIfNull(source);
    foreach (var item in source) yield return item;
}

使用示例:

IEnumerable<string> x = new Dictionary<string, string>().Keys.HideIdentity();

这样,LINQ Contains操作符将不会检测到集合实现了ICollection<T>接口,并且将遵循枚举集合并使用TKey类型的默认比较器比较每个键的缓慢路径。这有两个缺点:
1.这是一个复杂度为O(n)的算法。

  1. Dictionary<K,V>.Comparer的比较语义将被忽略。因此,如果字典被配置为不区分大小写,Contains将执行区分大小写的搜索。这可能不是你想要的。
    一种更复杂的方法是将集合 Package 在ICollection<TKey>实现中,其中包括在Contains方法中对null的特殊处理:
class NullTolerantKeyCollection<TKey, TValue> : ICollection<TKey>
{
    private readonly Dictionary<TKey, TValue>.KeyCollection _source;

    public NullTolerantKeyCollection(Dictionary<TKey, TValue>.KeyCollection source)
    {
        ArgumentNullException.ThrowIfNull(source);
        _source = source;
    }

    public int Count => _source.Count;
    public bool IsReadOnly => true;
    public bool Contains(TKey item) => item == null ? false : _source.Contains(item);
    public void CopyTo(TKey[] array, int index) => _source.CopyTo(array, index);
    public void Add(TKey item) => throw new NotSupportedException();
    public bool Remove(TKey item) => throw new NotSupportedException();
    public void Clear() => throw new NotSupportedException();
    public IEnumerator<TKey> GetEnumerator() => _source.GetEnumerator();
    IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
}

static NullTolerantKeyCollection<TKey, TValue> NullTolerant<TKey, TValue>(
    this Dictionary<TKey, TValue>.KeyCollection source)
{
    return new NullTolerantKeyCollection<TKey, TValue>(source);
}

使用示例:

IEnumerable<string> x = new Dictionary<string, string>().Keys.NullTolerant();

这样,生成的序列将保留基础集合的性能和行为特征。
你在问题中提到了第三种选择:使用ToList LINQ操作符将集合转换为List<T>。这将创建键的副本,并将返回调用ToList时键的快照。这可能是一个不错的选择,以防字典被冻结,并且键的数量很少。

zpqajqem

zpqajqem2#

这种行为背后的原因是,当source实现ICollection<TSource>时,Enumerable.Contains<IEnumerable<TSource>>(this IEnumerable<TSource> source, TSource value)有一个快捷方式。在这些情况下,它只需调用sourceContains方法。请参阅Enumerable.Contains的参考源代码。
字典的Keys属性是由KeyCollection(引用源)实现的。在内部,它的Contains方法包括对非空键的验证,这是您正在运行的。
如果你想对Contains使用非快捷方式的linq方法,你应该总是能够通过在这个方法的另一个重载中提供一个comparer参数来调用它--即使比较器是null

mwngjboj

mwngjboj3#

这两种方法都可以,但我推荐第三种方法:为你的项目启用nullable reference types。这样,你就不能把null传递给IEnumerable<string>.Contains,因为string不允许空值。它是自文档化的,怎么样?
如果你把警告当作错误,你得到的将不仅仅是绿色的曲线,你得到的将是真正的编译器错误,这意味着没有崩溃的可能性。

相关问题