Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

KeyCollection as IEnumerable produces inconsistent LINQ behavior

The following code prints "false"

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

But this code throws an ArgumentNullException:

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

I saw this post explaining why Dictionary.ContainsKey throws if null is passed in, so I'm guessing this behavior is related. However, in the case of ContainsKey I get nice green squigglies, whereas with IEnumerable my app crashes:

enter image description here

The consuming code isn't going to know the underlying type of IEnumerable passed to it, so either we need to:

  • Not use IEnumerable.Contains() with nullable types in general or
  • Convert KeyCollection to a list before treating them as IEnumerable

Is this right, or am I missing something?

like image 243
chanban Avatar asked Oct 29 '25 15:10

chanban


2 Answers

I assume that you want to expose the Keys property as an IEnumerable<TKey> sequence that allows searching for null. An easy way to do it is to wrap the collection in an IEnumerable<TKey> implementation, that hides the identity of the collection:

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

Usage example:

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

This way the LINQ Contains operator will not detect that the collection implements the ICollection<T> interface, and will follow the slow path of enumerating the collection and comparing each key using the default comparer of the TKey type. There are two downsides to this:

  1. The CPU complexity of the operation will be O(n) instead of O(1).
  2. The comparison semantics of the Dictionary<K,V>.Comparer will be ignored. So if the dictionary is configured to be case-insensitive, the Contains will perform a case-sensitive search. This might not be what you want.

A more sophisticated approach is to wrap the collection in an ICollection<TKey> implementation, that includes special handling for the null in the Contains method:

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 Dictionary<TKey,TValue>.KeyCollection.Enumerator GetEnumerator()
        => _source.GetEnumerator();
    IEnumerator<TKey> IEnumerable<TKey>.GetEnumerator() => GetEnumerator();
    IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
}

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

Usage example:

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

This way the resulting sequence will preserve the performance and behavior characteristics of the underlying collection.

Note: Checking for null with item == null instead of item is null is intentional. It is consistent with how the Dictionary<K,V> class does the check internally.

You mentioned a third option in the question: converting the collection to a List<T> with the ToList LINQ operator. This will create a copy of the keys, and will return a snapshot of the keys at the time the ToList was called. It might be a decent option in case the dictionary is frozen, and the number of keys is small.

like image 112
Theodor Zoulias Avatar answered Nov 01 '25 06:11

Theodor Zoulias


The reason behind this behaviour is that Enumerable.Contains<IEnumerable<TSource>>(this IEnumerable<TSource> source, TSource value) has a shortcut when source implements ICollection<TSource>. In these cases it just invokes the Contains method of source. See the reference source for Enumerable.Contains.

The Keys property of the dictionary is implemented by a KeyCollection (reference source). Internally, its Contains method includes validation for a non-null key, which you are running into.

If you want to use the non-shortcut linq method for Contains you should be able to always call it by supplying a comparer parameter in the other overload of this method - even if the comparer is null.

like image 39
moreON Avatar answered Nov 01 '25 05:11

moreON



Donate For Us

If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!