There are many types of Collections beyond List. Let’s discuss them and show how to make them work with our Data, including serialization.
Overview
For anyone who hasn’t made use of Collections beyond simple arrays or lists, I’d like to take a quick moment and discuss a few new collection types. Advanced readers, feel free to skip ahead.
There are multiple types of Collections that you can make use of. A List or Array is an ordered collection of something. Consider for example how a sky-scraper is like a collection of floors. When using an elevator, you may access each floor by an index, which is the order of floors in the building. As long as you know ahead of time which floor you wish to go to, then this collection type is ideal.
A Set (called HashSet in C#) has some similarities with other Collections like Lists, though it is unordered. When using this kind of collection, you usually care that it holds only a single instance of any given thing, or wish merely to know if the Set includes a thing or not. As an example, I could tell you whether or not I have purchased a given video game or not, but could probably not recall the order I had purchased them in.
A Dictionary also has some similarities with other Collections. The thing that makes it unique is that it holds pairs of Keys and Values. Each Key maps exactly to one Value. This would be like having a garage full of tools, where the owner always keeps his hammer in a specific place. When asked for the hammer, he can go straight to the tool and retrieve it, rather than needing to look through the entire garage of tools one by one until it is found.
A Queue is yet another type of Collection. It is designed to be efficient with adding and removing things in a particular order. You might hear a Queue designated as FIFO, which stands for “first in first out”. A FIFO queue would be like standing in line at a fast food restaurant. The first person in line gets to place their order and receive their food, then the next person in line, etc.
Though I won’t be using it in this lesson, and maybe not in the project at all, a Stack is also worth a mention. Like a Queue, it is efficient with adding and removing things in a particular order. It is LIFO, or “last in first out”. A stack is like a teacher grading tests as they are handed in. The teacher takes the top paper off the stack to begin grading, and then the next, but as more students turn in their test they add their test to the top of the stack and therefore will be graded before the tests that had been turned in earlier.
In the previous lesson, we implemented a Data system that serializes using Unity’s JsonUtility. That tool handles List without issue. Unfortunately HashSet and Dictionary Collection types are not yet supported. However, a HashSet is ideal for storing a single collection of all the Entities in use by our game, and Dictionary is ideal for mapping from an Entity to a Component Data. Therefore, as a part of this lesson, I will provide a solution to use those collection types that is also compatible with our serializer.
Getting Started
Feel free to continue from where we left off, or download and use this project here. It has everything we did in the previous lesson ready to go.
Core Set
Let’s begin by creating a serializable version of a Set – the type of collection which could hold all of the unique Entities in use by our game. Create a new C# script in Assets/Scripts/Data named CoreSet. Copy the following:
using System.Collections; using System.Collections.Generic; using System.Runtime.Serialization; using UnityEngine; [System.Serializable] public class CoreSet<T> : ISerializationCallbackReceiver, ICollection<T>, IEnumerable<T>, IEnumerable, IReadOnlyCollection<T>, ISet<T>, IDeserializationCallback, ISerializable { }
This class defines a generic collection named CoreSet. There are a bunch of interfaces to implement, but they fall into two main groups. First, ISerializationCallbackReceiver is a special interface provided by Unity. Conformance to this interface is why this collection will be compatible with JsonUtility – it gives us an opportunity to take action both before and after the serialization process.
All of the other interfaces are included because they are the same as the interfaces that a native HashSet conforms to, which means we should be able to use this collection in all the same ways that you may have wished to use a HashSet, had you been using it instead. I was able to determine this by using the “Go To Declaration” feature of my IDE after right-clicking on a HashSet in my code to see this:
public class HashSet<T> : ICollection<T>, IEnumerable<T>, IEnumerable, IReadOnlyCollection<T>, ISet<T>, IDeserializationCallback, ISerializable
Add the following fields to our CoreSet class:
[SerializeField] List<T> values = new List<T>(); HashSet<T> set = new HashSet<T>();
The reason I have added two collections is because I want to “work” with a HashSet, but I can let Unity automatically save and load a List.
Add the following method to our CoreSet class:
public void OnAfterDeserialize() { set.Clear(); var count = values.Count; for (int i = 0; i < count; ++i) set.Add(values[i]); } public void OnBeforeSerialize() { values.Clear(); foreach (var value in set) values.Add(value); }
To implement the ISerializationCallbackReceiver, we provide two methods: OnAfterDeserialize and OnBeforeSerialize. Since the JsonUtility knows how to serialize a List, what I will do is use these methods as opportunities to make a List from our Set when we need to save, and make a Set from our List when we need to load.
Next, we need to conform to all of the other interfaces and allow our CoreSet to feel like a HashSet. Add the following:
public int Count => set.Count; public bool IsReadOnly => ((ICollection<T>)set).IsReadOnly; public void Add(T item) => set.Add(item); public void Clear() => set.Clear(); public bool Contains(T item) => set.Contains(item); public void CopyTo(T[] array, int arrayIndex) => set.CopyTo(array, arrayIndex); public IEnumerator<T> GetEnumerator() => set.GetEnumerator(); public bool Remove(T item) => set.Remove(item); IEnumerator IEnumerable.GetEnumerator() => ((ICollection<T>) set).GetEnumerator(); bool ISet<T>.Add(T item) => set.Add(item); public void ExceptWith(IEnumerable<T> other) => set.ExceptWith(other); public void IntersectWith(IEnumerable<T> other) => set.IntersectWith(other); public bool IsProperSubsetOf(IEnumerable<T> other) => set.IsProperSubsetOf(other); public bool IsProperSupersetOf(IEnumerable<T> other) => set.IsProperSupersetOf(other); public bool IsSubsetOf(IEnumerable<T> other) => set.IsSubsetOf(other); public bool IsSupersetOf(IEnumerable<T> other) => set.IsSupersetOf(other); public bool Overlaps(IEnumerable<T> other) => set.Overlaps(other); public bool SetEquals(IEnumerable<T> other) => set.SetEquals(other); public void SymmetricExceptWith(IEnumerable<T> other) => set.SymmetricExceptWith(other); public void UnionWith(IEnumerable<T> other) => set.UnionWith(other); public void OnDeserialization(object sender) => set.OnDeserialization(sender); public void GetObjectData(SerializationInfo info, StreamingContext context) => set.GetObjectData(info, context);
Because our CoreSet "has" a HashSet and because HashSet already conforms to those interfaces, we simply wrapped its implementation and treated it as our own.
Core Dictionary
In addition to a HashSet, this project will be using Dictionary, which JsonUtility also doesn't know how to handle. This class will be very similar to the CoreSet, in that it will pretend to be a Dictionary by having a dictionary of its own that it wraps access to. When it comes time to serialize and deserialize, we can use two lists - one for the keys of the dictionary and one for the values.
Create a new C# script in Assets/Scripts/Data named CoreDictionary. Copy the following:
using System.Collections.Generic; using System.Collections; using UnityEngine; using System.Runtime.Serialization; [System.Serializable] public class CoreDictionary<TKey, TValue> : ISerializationCallbackReceiver, ICollection<KeyValuePair<TKey, TValue>>, IEnumerable<KeyValuePair<TKey, TValue>>, IEnumerable, IDictionary<TKey, TValue>, IReadOnlyCollection<KeyValuePair<TKey, TValue>>, IReadOnlyDictionary<TKey, TValue>, ICollection, IDictionary, IDeserializationCallback, ISerializable { }
This class defines a generic collection named CoreDictionary. There are a bunch of interfaces to implement, but like before they fall into two main groups. First, ISerializationCallbackReceiver will once again be used to help us serialize our data.
The remaining interfaces are included because they are the same as the interfaces that a native Dictionary conforms to, which means we should be able to use this collection in all the same ways that you may have wished to use a Dictionary, had you been using it instead. I was able to determine this by using the "Go To Declaration" feature of my IDE after right-clicking on a Dictionary in my code to see this:
public class Dictionary<TKey, TValue> : ICollection<KeyValuePair<TKey, TValue>>, IEnumerable<KeyValuePair<TKey, TValue>>, IEnumerable, IDictionary<TKey, TValue>, IReadOnlyCollection<KeyValuePair<TKey, TValue>>, IReadOnlyDictionary<TKey, TValue>, ICollection, IDictionary, IDeserializationCallback, ISerializable
Add the following fields to our CoreSet class:
[SerializeField] List<TKey> keys = new List<TKey>(); [SerializeField] List<TValue> values = new List<TValue>(); Dictionary<TKey, TValue> dictionary = new Dictionary<TKey, TValue>();
This time I need three collections. We of course have a dictionary, which is the collection we want to work with. Then we added two List collections, one for the keys of the dictionary and one for the values of the dictionary. They will be serialized and deserialized by Unity.
public void OnAfterDeserialize() { dictionary.Clear(); var count = Mathf.Min(keys.Count, values.Count); for (int i = 0; i < count; ++i) dictionary.Add(keys[i], values[i]); } public void OnBeforeSerialize() { keys.Clear(); values.Clear(); foreach (var kvp in dictionary) { keys.Add(kvp.Key); values.Add(kvp.Value); } }
As we did for CoreSet, CoreDictionary will use OnAfterDeserialize and OnBeforeSerialize as a sync point between the dictionary and lists. When serializing (saving) we copy from the dictionary to the lists. When deserializing (loading) we copy from the lists to the dictionary.
Next, we need to conform to all of the other interfaces and allow our CoreDictionary to feel like a Dictionary. Add the following:
public int Count => dictionary.Count; public bool IsReadOnly => ((ICollection<TKey>)dictionary).IsReadOnly; public ICollection<TKey> Keys => dictionary.Keys; public ICollection<TValue> Values => dictionary.Values; IEnumerable<TKey> IReadOnlyDictionary<TKey, TValue>.Keys => ((IReadOnlyDictionary<TKey, TValue>)dictionary).Keys; IEnumerable<TValue> IReadOnlyDictionary<TKey, TValue>.Values => ((IReadOnlyDictionary<TKey, TValue>)dictionary).Values; public bool IsSynchronized => ((ICollection)dictionary).IsSynchronized; public object SyncRoot => ((ICollection)dictionary).SyncRoot; public bool IsFixedSize => ((IDictionary)dictionary).IsFixedSize; ICollection IDictionary.Keys => ((IDictionary)dictionary).Keys; ICollection IDictionary.Values => ((IDictionary)dictionary).Values; public object this[object key] { get => ((IDictionary)dictionary)[key]; set => ((IDictionary)dictionary)[key] = value; } public TValue this[TKey key] { get => dictionary[key]; set => dictionary[key] = value; } public void Add(KeyValuePair<TKey, TValue> item) => ((ICollection<KeyValuePair<TKey, TValue>>)dictionary).Add(item); public void Clear() => dictionary.Clear(); public bool Contains(KeyValuePair<TKey, TValue> item) => ((ICollection<KeyValuePair<TKey, TValue>>)dictionary).Contains(item); public void CopyTo(KeyValuePair<TKey, TValue>[] array, int arrayIndex) => ((ICollection<KeyValuePair<TKey, TValue>>)dictionary).CopyTo(array, arrayIndex); public IEnumerator<KeyValuePair<TKey, TValue>> GetEnumerator() => dictionary.GetEnumerator(); public bool Remove(KeyValuePair<TKey, TValue> item) => ((ICollection<KeyValuePair<TKey, TValue>>)dictionary).Remove(item); IEnumerator IEnumerable.GetEnumerator() => dictionary.GetEnumerator(); public void Add(TKey key, TValue value) => dictionary.Add(key, value); public bool ContainsKey(TKey key) => dictionary.ContainsKey(key); public bool Remove(TKey key) => dictionary.Remove(key); public bool TryGetValue(TKey key, out TValue value) => dictionary.TryGetValue(key, out value); public void CopyTo(System.Array array, int index) => ((ICollection)dictionary).CopyTo(array, index); public void Add(object key, object value) => ((IDictionary)dictionary).Add(key, value); public bool Contains(object key) => ((IDictionary)dictionary).Contains(key); IDictionaryEnumerator IDictionary.GetEnumerator() => ((IDictionary)dictionary).GetEnumerator(); public void Remove(object key) => ((IDictionary)dictionary).Remove(key); public void OnDeserialization(object sender) => dictionary.OnDeserialization(sender); public void GetObjectData(SerializationInfo info, StreamingContext context) => dictionary.GetObjectData(info, context);
Because our CoreDictionary "has" a Dictionary and because Dictionary already conforms to those interfaces, we simply wrapped its implementation and treated it as our own.
Unit Tests
Let's go ahead and create some unit tests for our new scripts.
Core Set Tests
Create a new C# test script inside the Assets/Tests/Data folder and name the script CoreSetTests. Copy the following code into the script:
using NUnit.Framework; using UnityEngine; public class CoreSetTests { [Test] public void Add_And_Remove_Success() { CoreSet<int> sut = new CoreSet<int>(); sut.Add(42); Assert.AreEqual(1, sut.Count); Assert.True(sut.Contains(42)); sut.Remove(42); Assert.IsEmpty(sut); } }
For our first Test I just want to validate the basic ability of our collection. I need to be able to add or remove items from the collection. That could be two separate tests, but since you need to add something before you can remove it, it felt a bit redundant to make two tests.
Add the following:
[Test] public void Add_Duplicate_IsIgnored() { CoreSet<int> sut = new CoreSet<int>(); sut.Add(1); sut.Add(1); // duplicate sut.Add(2); sut.Add(3); Assert.AreEqual(3, sut.Count); }
For our second set, I demonstrate another neat feature of a set - it only holds unique entries. So when I add the same value more than once, the subsequent addition is ignored. Although I call Add four times, I have only passed along three unique values, so the Set only holds three things.
There isn't a whole lot of reason to test every feature of a Set, largely because I am simply using the existing Set under the hood. Instead, let's add some tests that demonstrate compatibility with JsonUtility. Add the following field and method:
const string json = "{\"values\":[1]}"; [Test] public void JsonUtility_Serialization_Success() { CoreSet<int> sut = new CoreSet<int>(); sut.Add(1); var result = JsonUtility.ToJson(sut); Assert.AreEqual(json, result); }
This test creates a CoreSet, adds a value to it, and then Serializes it to a Json string using JsonUtility. Although the values in a Set are unordered, I have only added a single value, so I know it will always produce the same output. This allows me to compare it to an expected string value.
Add one last test:
[Test] public void JsonUtility_Deserialization_Success() { CoreSet<int> sut = new CoreSet<int>(); JsonUtility.FromJsonOverwrite(json, sut); Assert.AreEqual(1, sut.Count); Assert.True(sut.Contains(1)); }
The last test demonstrates deserialization. We use the JsonUtility to configure our CoreSet based on a Json string. As expected, after deserialization our Set will hold a value.
Core Dictionary Tests
Create a new C# test script inside the Assets/Tests/Data folder and name the script CoreDictionaryTests. Copy the following code into the script:
using NUnit.Framework; using UnityEngine; public class CoreDictionaryTests { const int key = 1; const int value = 3; [Test] public void Add_And_Remove_Success() { CoreDictionary<int, int> sut = new CoreDictionary<int, int>(); sut.Add(key, value); Assert.AreEqual(1, sut.Count); Assert.AreEqual(value, sut[key]); sut.Remove(key); Assert.IsEmpty(sut); } }
For this and the following tests, I created a dictionary that maps from one number to another. That could represent something like "level 1 has 3 monsters". I created some consts to give our numbers names to help clarify when I mean to imply a key, value, or literal number in code. This first test is just for the basic functionality of adding and removing an entry into our collection.
Add the following field and method:
const string json = "{\"keys\":[1],\"values\":[3]}"; [Test] public void JsonUtility_Serialization_Success() { CoreDictionary<int, int> sut = new CoreDictionary<int, int>(); sut.Add(key, value); var result = JsonUtility.ToJson(sut); Assert.AreEqual(json, result); }
Now I want to test how our CoreDictionary works with the JsonUtility. Dictionary Key-Value pairs are also unsorted, but since I have only a single entry, I can know exactly what the output will be. Here we Serialize to a Json string and compare it against the output I expect to see.
Add one final test:
[Test] public void JsonUtility_Deserialization_Success() { CoreDictionary<int, int> sut = new CoreDictionary<int, int>(); JsonUtility.FromJsonOverwrite(json, sut); Assert.AreEqual(1, sut.Count); Assert.AreEqual(value, sut[key]); }
Our last test shows that we can restore our dictionary from a Json via the JsonUtility.
We're done for now! Head over to Unity and Use the TestRunner to make sure all of our new tests pass. Feel free to pat yourself on the back when you see all the new green check marks.
Summary
Collections are very powerful, especially when you use the right collection type for the right problem. Therefore I devoted this lesson to introducing several types of collections. Since Unity's JsonUtility doesn't currently work with HashSet or Dictionary, we learned how to create our own classes to encompass them, while also adding compatibility with Unity's tool.
If you got stuck along the way, feel free to download the finished project for this lesson here.
If you find value in my blog, you can support its continued development by becoming my patron. Visit my Patreon page here. Thanks!