Tranquillity in C# with EasyDictionary
Dictionary<TKey, TValue>
is probably among the most used collections in C#. If you are reading this blog then I am assuming you know the fundamentals of this data structure and if you are not, you can have a look at The .NET Dictionary which covers it in detail. As a summary however:
Dictionary<TKey, TValue>
is a Hash table mapping a set of keys to a set of values.- Each key in the dictionary must be unique according to the
IEqualityComparer<TKey>
(used to determine the equality of the keys in the dictionary). - Retrieving a value by using its key has a complexity of O(1) (very fast).
Typical use-case
Most of the time, I use a dictionary to map a model to one of its properties; Let’s say an Id or Name. For example, given the model:
public sealed record class Person(Guid Id, string Name, uint Age);
And given a collection of people from somewhere:
IEnumerable<Person> people = Enumerable.Range(1, 10)
.Select(n => new Person(Guid.NewGuid(), "P-" + n.ToString(), (uint)n));
We may now want to make people
available for fast lookups based on the Id of a given person.
Well that’s easy, let’s new-up a Dictionary
and add our people to it:
Dictionary<Guid, Person> dic = new();
foreach(Person p in people)
{
dic[p.Id] = p;
// or if you don't like the index syntax then you can instead do:
// dic.Add(p.Id, p);
}
We can also create a dictionary directly from the IEnumerable<Person>
by doing:
Dictionary<Guid, Person> dic = Enumerable.Range(1, 10)
.Select(n => new Person(Guid.NewGuid(), "P-" + n.ToString(), (uint)n))
.ToDictionary(p => p.Id, p => p);
Okay not the most verbose code to complain about but if you take into account that every time you want to Add
, TryGet
or check for existence (Contains
) of a given value you need to specify the TKey
explicitly then you will be able to also hear my OCD screaming to make things simpler.
What if…
Would it not be more elegant if we could just add a person and let the dictionary get the Id from it? Something similar to:
EasyDictionary<Guid, Person> dic = new (...);
foreach(Person p in people)
{
dic.Add(p)
}
In order to do this, we would need some sort of a key selector which for a given TValue
would return a TKey
. In other words, we need a Func<TValue, TKey>
which in our example would be a Func<Person, Guid>
.
Now that we know what a key selector would look like, we can start thinking about the structure of our new abstraction:
public sealed class EasyDictionary<TKey, TValue>
{
public EasyDictionary(Func<TValue, TKey> keySelector)
{
KeySelector = keySelector;
}
public Func<TValue, TKey> KeySelector { get; }
...
}
All we have to do now is put a Dictionary<TKey, TValue>
inside our wrapper, implement IReadOnlyDictionary<TKey, TValue>
as well as ICollection<TValue>
and then redirect all the required methods and properties to our internal dictionary.
Job done!
I am going to spare you the unexciting code here and instead, point you to the completed version in EasyDictionary
available as part of the Easy.Common NuGet package.
Armed with our much smarter dictionary, let us see what we can do with it (look at the unit tests for more examples):
Person[] people = Enumerable.Range(1, 10)
.Select(n => new Person(Guid.NewGuid(), "P-" + n.ToString(), (uint)n))
.ToArray();
EasyDictionary<Guid, Person> easyDic = new(p => p.Id);
foreach(Person p in people)
{
easyDic.Add(p);
}
// Let's lookup an existing value.
Person firstPerson = people[0];
easyDic.Contains(firstPerson); // true
easyDic.ContainsKey(firstPerson.Id); // true
easyDic[firstPerson.Id]; // firstPerson
easyDic.TryGetValue(firstPerson.Id, out Person yep); // true (yep is firstPerson)
easyDic.Remove(firstPerson.Id); // true (removed)
easyDic.Remove(firstPerson); // false (no longer there)
easyDic.TryGetValue(firstPerson.Id, out Person _); // false (no longer there)
We can also enumerate over the items:
foreach (Person p in easyDic) { /* do what you want with p */ }
But what about over-writing an existing item? Well, we can use AddOrReplace
for that:
Person olderPerson = new(
firstPerson.Id,
firstPerson.Name,
firstPerson.Age + 41);
easyDic.AddOrReplace(olderPerson); // true;
Bonus point
We can even throw a bunch of extension methods to be able to do:
EasyDictionary<Guid, Person> dic = Enumerable.Range(1, 10)
.Select(n => new Person(Guid.NewGuid(), "P-" + n.ToString(), (uint)n))
.ToEasyDictionary(p => p.Id);
Now this is tranquillity!