通過.net core原始碼看下Dictionary的實現

風靈使發表於2019-01-16

.net core的程式碼位置
https://github.com/dotnet/corefx/blob/master/src/Common/src/CoreLib/System/Collections/Generic/Dictionary.cs

C#中,Dictionary這個資料結構並不是很容易理解,因為看上不去並不像C++map。底層是如何實現一個字典的並完全可知,因為從資料結構來說,很多結構都可以支援一個類似的加速key-value對儲存的訪問形式。比如tree,跳錶,hashtable等等。

基於bucketHashtable

Dictionary的基本思想是通過一個Entry數值儲存資料(keyvalue),其中的資料是緊密排布的。然後,通過bucket陣列實現hashcode加速查詢。如果兩個物件的hashcode%length(數值的長度)相等,實現類似hashtable碰撞的退避規則,並通過Entry.next的引用住新的退避位置(用陣列下標實現連線)。

            private struct Entry
            {
                public int hashCode;    // Lower 31 bits of hash code, -1 if unused
                public int next;        // Index of next entry, -1 if last
                public TKey key;           // Key of entry
                public TValue value;         // Value of entry
            }
     
            private int[] _buckets;
            private Entry[] _entries;

在這裡插入圖片描述
一個key-value資料,在經過Key.GetHashCode後的返回值,再對_buckets的長度取模。決定隱射到的_buckets下標,而實際儲存的區域_entries是一個連續儲存的陣列,用來儲存鍵值對(Entry)。如上圖,如果插入時出現hash桶碰撞,會直接找到下一個空的格子插入資料,並把這個格子的id儲存到上一個entry.next中,方便刪除或查詢時使用。

反之,如果刪除資料時,就需要級聯更新entry.next的情況。刪除的關鍵程式碼如下,如果是一個通過next找到的entry,那last必然>0,所以需要把last.next指向自己的next,繞過自己。如果last<0則說明,自己是第一個元素,直接更新bucket指向自己的next(可能是-1,也可能是真的下一個元素的下標)。

            if (last < 0)
            {
                // Value in buckets is 1-based
                buckets[bucket] = entry.next + 1;
            }
            else
            {
                entries[last].next = entry.next;
            }

關於KeysValues

private KeyCollection _keys;
private ValueCollection _values;

許多時候,我們會用到對KeysValues的訪問。那我們來看看,這兩個屬性是如何實現的。先看一下KeyCollection的實現。這裡刪除了一些多餘的程式碼,可以看出,他僅僅對dict的一個組合關係,內部的實際工作者是dict

            public sealed class KeyCollection : ICollection<TKey>, ICollection, IReadOnlyCollection<TKey>
            {
                private Dictionary<TKey, TValue> _dictionary;
     
                public KeyCollection(Dictionary<TKey, TValue> dictionary)
                {
                    _dictionary = dictionary;
                }
     
                void ICollection<TKey>.Add(TKey item)
                    => ThrowHelper.ThrowNotSupportedException(ExceptionResource.NotSupported_KeyCollectionSet);
     
                void ICollection<TKey>.Clear()
                    => ThrowHelper.ThrowNotSupportedException(ExceptionResource.NotSupported_KeyCollectionSet);
     
                bool ICollection<TKey>.Contains(TKey item)
                    => _dictionary.ContainsKey(item);
            }

然後,看一下迭代過程的實現。非常簡單,僅僅是每次都把_currentKey賦值為_entries的下一個元素。所以,可以看出來,Keys的訪問是有序的(按插入順序)。

            public bool MoveNext()
            {
                while ((uint)_index < (uint)_dictionary._count)
                {
                    ref Entry entry = ref _dictionary._entries[_index++];
     
                    if (entry.hashCode >= 0)
                    {
                        _currentKey = entry.key;
                        return true;
                    }
                }
     
                _index = _dictionary._count + 1;
                _currentKey = default;
                return false;
            }

valueskeys的實現是完全一致的,所以Values的訪問和Keys的訪問效能是差不多的,不存在訪問Keys快,訪問Values慢的情況。

關於空間大小演算法

大家知道hash表是需要先分配一塊比較大的空間,並在保持一定資料密度的情況下,會擁有比較高的儲存和訪問效率。

C#dict,永遠會去找當前需求的capacity的下一個素數,作為陣列的分配size。如果,預設new Dict,傳遞的capacity0,那麼實際此時的_entries大小是3

找素數的邏輯稍微提下。會先順序遍歷儲存的primes陣列;如果找不到,再用逐個數字遍歷的方式找接下來的素數。

          public static readonly int[] primes = {
                3, 7, 11, 17, 23, 29, 37, 47, 59, 71, 89, 107, 131, 163, 197, 239, 293, 353, 431, 521, 631, 761, 919,
                1103, 1327, 1597, 1931, 2333, 2801, 3371, 4049, 4861, 5839, 7013, 8419, 10103, 12143, 14591,
                17519, 21023, 25229, 30293, 36353, 43627, 52361, 62851, 75431, 90523, 108631, 130363, 156437,
                187751, 225307, 270371, 324449, 389357, 467237, 560689, 672827, 807403, 968897, 1162687, 1395263,
                1674319, 2009191, 2411033, 2893249, 3471899, 4166287, 4999559, 5999471, 7199369 };

關於讀取資料的效率

題外話,講一下有的同學喜歡這麼寫資料訪問的程式碼。

if (techAddonDict.ContainsKey(3))
{
  var c = techAddonDict[3];
}

從底層來說,所有查詢的程式碼,都會先通過bucket找到一次entry物件(通過FindEntry函式)。那麼上一段函式中實際需要訪問兩次FindEntry函式。

float v;
if (techAddonDict.TryGetValue(3, out v))
{
   //todo xxx
}

這段函式就很明顯了,只需要訪問一次FindEntry函式,效能自然會好一倍。

相關文章