4
\$\begingroup\$

In conjunction with a recent question of mine, I found that an ObservableDictionary would be very handy for my situation, so I decided to implement one.

I've written a few custom observable collections and objects before, but this one was a bit different, in that NotifyCollectionChangedEventArgs usually accepts an integer as it's modified index. Well, guess what Dictionary doesn't have any specific order in it and it also doesn't use integers as keys 100% of the time.

I started thinking of alternative collections and the first thing obviously that popped in my mind was a List<KeyValuePair<,>> but since initializing kvp's everytime takes way too much space and it looks awful (I even shortened it here!), I decided to make proper abstraction and add a small wrapper class, but shortly I found that there already is one DictionaryEntry. Combining that with a custom KeyedCollection<>, did the job fairly well.

So that's what I'm using behind the scenes, there is also a Dictionary, which only use is to get the Keys and Values properties separated more easily.

One odd thing about the KeyedCollection<> is it's indexer, since it doesn't have a setter, I had to save the index of the item currently modified, remove the item and insert a new copy at the same index. Which seems like too much work but I couldn't think of anything better.

I have tried to make it feel like a normal Dictionary<,> as much as possible, explicitly implementing some methods and properties, keeping the same constructors, etc. Tho I feel like I might've left some stuff implicitly implemented.

As for unit testing, I've been mostly comparing the content of the dictionary and the messages from the 2 fired events, to the native .NET's ObservableCollection, everything seems good and there are no errors, but if you find a discrepancy please let me know.

KeyedEntryCollection<>

public class KeyedEntryCollection<TKey> : KeyedCollection<TKey, DictionaryEntry>
{
    public KeyedEntryCollection()
    {            
    }

    public KeyedEntryCollection(IEqualityComparer<TKey> comparer) 
        : base(comparer)
    {           
    }

    public int IndexOf(TKey key)
        => this.Contains(key) ? base.IndexOf(new DictionaryEntry(key, this[key].Value)) : -1;

    protected override TKey GetKeyForItem(DictionaryEntry entry) => (TKey)entry.Key;
}

ObservableDictionary<,>

[Serializable]
public sealed class ObservableDictionary<TKey, TValue> : IDictionary<TKey, TValue>, IDictionary, ISerializable,
    IDeserializationCallback, INotifyCollectionChanged, INotifyPropertyChanged
{
    private const string IndexerName = "Item[]";
    private const string ValuesName = "Values";
    private const string KeysName = "Keys";

    public event NotifyCollectionChangedEventHandler CollectionChanged;

    private event PropertyChangedEventHandler PropertyChanged;

    event PropertyChangedEventHandler INotifyPropertyChanged.PropertyChanged
    {
        add => PropertyChanged += value;
        remove => PropertyChanged -= value;
    }

    private readonly SerializationInfo _serializationInfo;

    private bool _requiresSyncronization;

    private KeyedEntryCollection<TKey> _keyedEntryCollection;

    private Dictionary<TKey, TValue> _dictionary;

    private Dictionary<TKey, TValue> Dictionary
    {
        get
        {
            if (_requiresSyncronization)
            {
                _dictionary = _keyedEntryCollection.ToDictionary(entry => (TKey)entry.Key,
                    entry => (TValue)entry.Value);
                _requiresSyncronization = false;
            }
            return _dictionary;
        }
    }

    #region Constructors

    public ObservableDictionary(IDictionary<TKey, TValue> dictionary, IEqualityComparer<TKey> comparer = null)
        : this(comparer)
    {
        if (dictionary == null)
        {
            throw new ArgumentNullException(nameof(dictionary));
        }
        foreach (var kvp in dictionary)
        {
            AddEntry(kvp);
        }
    }

    public ObservableDictionary(IEqualityComparer<TKey> comparer = null)
    {
        _keyedEntryCollection = new KeyedEntryCollection<TKey>(comparer);
    }

    /// <summary>
    /// Used for deserialization.
    /// </summary>
    /// <param name="info"></param>
    /// <param name="context"></param>
    private ObservableDictionary(SerializationInfo info, StreamingContext context)
    {
        _serializationInfo = info;
    }

    #endregion

    #region KeyedEntryCollection Modifiers

    private void AddEntry(KeyValuePair<TKey, TValue> keyValuePair)
    {
        var entry = new DictionaryEntry(keyValuePair.Key, keyValuePair.Value);
        _keyedEntryCollection.Add(entry);
        _requiresSyncronization = true;

        OnCommonPropertiesChanged();
        OnCollectionChanged(NotifyCollectionChangedAction.Add, keyValuePair, _keyedEntryCollection.Count - 1);
    }

    private void AddEntry(TKey key, TValue value)
    {
        AddEntry(new KeyValuePair<TKey, TValue>(key, value));
    }

    private bool RemoveEntry(TKey key)
    {
        var itemIndex = _keyedEntryCollection.IndexOf(key);
        if (itemIndex != -1)
        {
            var value = _keyedEntryCollection[key];
            _keyedEntryCollection.RemoveAt(itemIndex);

            _requiresSyncronization = true;

            OnCommonPropertiesChanged();
            OnCollectionChanged(NotifyCollectionChangedAction.Remove, value, itemIndex);

        }
        return false;
    }

    private void SetEntry(TKey key, TValue value)
    {
        if (_keyedEntryCollection.Contains(key))
        {
            var entry = _keyedEntryCollection[key];
            var entryIndex = _keyedEntryCollection.IndexOf(entry);
            if (!entry.Value.Equals(value))
            {
                _keyedEntryCollection.Remove(key);
                _keyedEntryCollection.Insert(entryIndex, new DictionaryEntry(key, value));
            }

            _requiresSyncronization = true;

            OnCommonPropertiesChanged();
            OnCollectionChanged(NotifyCollectionChangedAction.Replace, entry, new DictionaryEntry(key, value), entryIndex);
        }
    }

    #endregion

    #region Implementation of IEnumerable

    public void Clear()
    {
        _keyedEntryCollection.Clear();

        OnCommonPropertiesChanged();
        OnCollectionReset();
    }

    IDictionaryEnumerator IDictionary.GetEnumerator()
    {
        return GetEnumerator();
    }

    void IDictionary.Remove(object key)
    {
        RemoveEntry((TKey)key);
    }

    object IDictionary.this[object key]
    {
        get => TryGetValue((TKey)key, out var value) ? (object)value : null;
        set => this[(TKey)key] = (TValue)value;
    }

    IEnumerator<KeyValuePair<TKey, TValue>> IEnumerable<KeyValuePair<TKey, TValue>>.GetEnumerator()
    {
        return GetEnumerator();
    }

    IEnumerator IEnumerable.GetEnumerator()
    {
        return GetEnumerator();
    }

    #endregion

    #region Implementation of ICollection<KeyValuePair<TKey,TValue>>

    public void Add(KeyValuePair<TKey, TValue> item)
    {
        AddEntry(item);
    }

    bool IDictionary.Contains(object key)
    {
        return _keyedEntryCollection.Contains((TKey)key);
    }

    void IDictionary.Add(object key, object value)
    {
        AddEntry(new KeyValuePair<TKey, TValue>((TKey)key, (TValue)value));
    }

    void ICollection<KeyValuePair<TKey, TValue>>.Clear()
    {
        Clear();
    }

    public bool Contains(KeyValuePair<TKey, TValue> item)
    {
        return _keyedEntryCollection.Contains(new DictionaryEntry(item.Key, item.Value));
    }

    public void CopyTo(KeyValuePair<TKey, TValue>[] array, int arrayIndex)
    {
        if (array == null)
        {
            throw new ArgumentNullException(nameof(array));
        }
        for (int i = 0; i < _keyedEntryCollection.Count; i++)
        {
            var entry = _keyedEntryCollection[i];
            array[i] = new KeyValuePair<TKey, TValue>((TKey)entry.Key, (TValue)entry.Value);
        }
    }

    public bool Remove(KeyValuePair<TKey, TValue> item)
    {
        return RemoveEntry(item.Key);
    }

    void ICollection.CopyTo(Array array, int arrayIndex)
    {
        ((ICollection)_keyedEntryCollection).CopyTo(array, arrayIndex);
    }

    int ICollection.Count => _keyedEntryCollection.Count;
    object ICollection.SyncRoot => ((ICollection)_keyedEntryCollection).SyncRoot;
    bool ICollection.IsSynchronized => ((ICollection)_keyedEntryCollection).IsSynchronized;
    int ICollection<KeyValuePair<TKey, TValue>>.Count => _keyedEntryCollection.Count;
    ICollection IDictionary.Values => Dictionary.Values;
    bool IDictionary.IsReadOnly => false;
    public bool IsFixedSize => false;
    bool ICollection<KeyValuePair<TKey, TValue>>.IsReadOnly => false;

    #endregion

    #region Implementation of IDictionary<TKey,TValue>

    public Dictionary<TKey, TValue>.Enumerator GetEnumerator()
    {
        return Dictionary.GetEnumerator();
    }

    public bool ContainsKey(TKey key)
    {
        return _keyedEntryCollection.Contains(key);
    }

    public void Add(TKey key, TValue value)
    {
        AddEntry(key, value);
    }

    public bool Remove(TKey key)
    {
        return RemoveEntry(key);
    }

    public bool TryGetValue(TKey key, out TValue value)
    {
        if (_keyedEntryCollection.Contains(key))
        {
            value = (TValue)_keyedEntryCollection[key].Value;
            return true;
        }
        value = default(TValue);
        return false;
    }

    public TValue this[TKey key]
    {
        get => (TValue)_keyedEntryCollection[key].Value;
        set => SetEntry(key, value);
    }

    ICollection<TKey> IDictionary<TKey, TValue>.Keys => Dictionary.Keys;
    ICollection IDictionary.Keys => Dictionary.Keys;
    ICollection<TValue> IDictionary<TKey, TValue>.Values => Dictionary.Values;

    #endregion

    #region Implementation of ISerializable

    [SecurityPermission(SecurityAction.LinkDemand, Flags = SecurityPermissionFlag.SerializationFormatter)]
    void ISerializable.GetObjectData(SerializationInfo info, StreamingContext context)
    {
        var entries = new DictionaryEntry[_keyedEntryCollection.Count];
        for (int i = 0; i < entries.Length; i++)
        {
            entries[i] = _keyedEntryCollection[i];
        }
        info.AddValue("entries", entries);
        info.AddValue("comparer", _keyedEntryCollection.Comparer);
    }

    #endregion

    #region Implementation of IDeserializationCallback

    public void OnDeserialization(object sender)
    {
        var entries = (DictionaryEntry[])_serializationInfo.GetValue("entries", typeof(DictionaryEntry[]));
        var comparer =
            (EqualityComparer<TKey>)_serializationInfo.GetValue("comparer", typeof(EqualityComparer<TKey>));

        _keyedEntryCollection = new KeyedEntryCollection<TKey>(comparer);
        foreach (var entry in entries)
        {
            AddEntry((TKey)entry.Key, (TValue)entry.Value);
        }
    }

    #endregion

    #region INotifyCollectionChanged implementation

    private void OnCollectionChanged(NotifyCollectionChangedAction action, object item, int index)
    {
        OnCollectionChanged(new NotifyCollectionChangedEventArgs(action, item, index));
    }

    private void OnCollectionChanged(NotifyCollectionChangedAction action, object oldItem, object newItem,
        int index)
    {
        OnCollectionChanged(new NotifyCollectionChangedEventArgs(action, newItem, oldItem, index));
    }

    private void OnCollectionReset()
    {
        OnCollectionChanged(new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset));
    }

    private void OnCollectionChanged(NotifyCollectionChangedEventArgs e)
    {
        CollectionChanged?.Invoke(this, e);
    }

    #endregion

    #region INotifyPropertyChanged implementation

    private void OnPropertyChanged([CallerMemberName] string propertyName = null)
    {
        PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
    }

    private void OnCommonPropertiesChanged()
    {
        OnPropertyChanged(IndexerName);
        OnPropertyChanged(ValuesName);
        OnPropertyChanged(KeysName);
        OnPropertyChanged(nameof(_keyedEntryCollection.Count));
    }

    #endregion
}
\$\endgroup\$
2
\$\begingroup\$

I'm not convinced by the index implementation. Since dictionaries don't have them, it should always be -1 as not supported. In a context of dictionaries it's virtually meaningless as you cannot use it anyway.

See: Interpreting NotifyCollectionChangedEventArgs


What I don't understand and find a little bit strange is that you sometimes use the internal dictionary and another time the keyed-collection for dictionary APIs:

public Dictionary<TKey, TValue>.Enumerator GetEnumerator()
{
  return Dictionary.GetEnumerator();
}

public bool ContainsKey(TKey key)
{
  return _keyedEntryCollection.Contains(key);
}

I see now why...

_dictionary = _keyedEntryCollection.ToDictionary

You are copying the collection into a dictionary... not sure whether this is such a good idea either. This kind of breaks the dictionary contract because for some calls it won't be a O(1) operation anymore.

\$\endgroup\$
  • \$\begingroup\$ Hmm, since I wrote that in about 2 hours I guess I got tunnel visioned and didn't notice the obvious, very good point. Will there be significant benefits to changing it back to use a Dictionary internally? \$\endgroup\$ – Denis Mar 30 '18 at 13:14
  • 2
    \$\begingroup\$ @Denis I think so. It should then be easy to implement... you could create the ObservableDictionary as a decorator for a dictioanry, just add the ObservableCollection APIs on top of it and redirect all call the the other dictionary. This would be a pretty brainless task I suppose :] \$\endgroup\$ – t3chb0t Mar 30 '18 at 13:17
  • \$\begingroup\$ Yes copying the dictionary is the reason why only use it when it's required. As I said in my question I was expecting to use the decorator pattern as that's usually the best way most of the time, but those indexes man.. They made me do so much unnecessary work. \$\endgroup\$ – Denis Mar 30 '18 at 13:33
  • 1
    \$\begingroup\$ @Denis water under the bridge... but I'm pretty sure you've learned a couple of new things so it probably wasn't entirely in vain :-) Well, let's wait for other reviewes and see what else they find :-] \$\endgroup\$ – t3chb0t Mar 30 '18 at 13:36

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.