Search code examples
c#linqdictionarynullable

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?


Solution

  • 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.