record types with collection properties & collections with value semantics

Viewed 5021

In c# 9, we now (finally) have record types:

public record SomeRecord(int SomeInt, string SomeString);

This gives us goodies like value semantics:

var r1 = new SomeRecord(0, "zero");
var r2 = new SomeRecord(0, "zero");
Console.WriteLine(r1 == r2); // true - property based equality

While experimenting with this feature, I realized that defining a property of a (non-string) reference type may lead to counter-intuitive (albeit perfectly explainable if you think it through) behaviour:

public record SomeRecord(int SomeInt, string SomeString, int[] SomeArray);

var r1 = new SomeRecord(0, "test", new[] {1,2});
var r2 = new SomeRecord(0, "test", new[] {1,2});
Console.WriteLine(r1 == r2); // false, since int[] is a non-record reference type

Are there collection types with value semantics in .Net (or 3rd party) that may be used in this scenario? I looked at ImmutableArray and the likes, but these don't provide this feature either.

4 Answers

It looks like there is currently no such type available.

You can implement this yourself, but beware of the implications if you need this in a production-ready way. It's not so simple as it may seem at first sight, as this answer by @ryanholden8 demonstrates!

For my use case (and as a simplified example) I went along with this gist which decorates an IImutableList and can be used as follows:

var r1 = new SomeRecord(0, "test", new[] { 1, 2 }.ToImmutableList().WithValueSemantics());
var r2 = new SomeRecord(0, "test", new[] { 1, 2 }.ToImmutableList().WithValueSemantics());
Console.WriteLine(r1 == r2); // true

Obviously beware of the performance implications for very large lists.

Our team faced a similar problem and started implementing based on the idea from @jeroenh. However, we ran into the issue of records that could no longer be deserialized from json with System.Text.Json. Here's the gist (posted below as well) with everything we had to create in order to support this deep equality on records.

ImmutableArrayWithDeepEquality

using System.Collections.Generic;
using System.Linq;

namespace System.Collections.Immutable
{
    [System.Text.Json.Serialization.JsonConverter(typeof(JsonConverterForImmutableArrayWithDeepEqualityFactory))]
    public struct ImmutableArrayWithDeepEquality<T> : IEquatable<ImmutableArrayWithDeepEquality<T>>, IEnumerable, IEnumerable<T>
    {
        private readonly ImmutableArray<T> _list;

        public ImmutableArrayWithDeepEquality(ImmutableArray<T> list) => _list = list;

        #region ImmutableArray Implementation

        public T this[int index] => _list[index];

        public int Count => _list.Length;

        public ImmutableArrayWithDeepEquality<T> Add(T value) => _list.Add(value).WithDeepEquality();
        public ImmutableArrayWithDeepEquality<T> AddRange(IEnumerable<T> items) => _list.AddRange(items).WithDeepEquality();
        public ImmutableArrayWithDeepEquality<T> Clear() => _list.Clear().WithDeepEquality();
        public ImmutableArray<T>.Enumerator GetEnumerator() => _list.GetEnumerator();
        public int IndexOf(T item, int index, int count, IEqualityComparer<T> equalityComparer) => _list.IndexOf(item, index, count, equalityComparer);
        public ImmutableArrayWithDeepEquality<T> Insert(int index, T element) => _list.Insert(index, element).WithDeepEquality();
        public ImmutableArrayWithDeepEquality<T> InsertRange(int index, IEnumerable<T> items) => _list.InsertRange(index, items).WithDeepEquality();
        public int LastIndexOf(T item, int index, int count, IEqualityComparer<T> equalityComparer) => _list.LastIndexOf(item, index, count, equalityComparer);
        public ImmutableArrayWithDeepEquality<T> Remove(T value, IEqualityComparer<T> equalityComparer) => _list.Remove(value, equalityComparer).WithDeepEquality();
        public ImmutableArrayWithDeepEquality<T> RemoveAll(Predicate<T> match) => _list.RemoveAll(match).WithDeepEquality();
        public ImmutableArrayWithDeepEquality<T> RemoveAt(int index) => _list.RemoveAt(index).WithDeepEquality();
        public ImmutableArrayWithDeepEquality<T> RemoveRange(IEnumerable<T> items, IEqualityComparer<T> equalityComparer) => _list.RemoveRange(items, equalityComparer).WithDeepEquality();
        public ImmutableArrayWithDeepEquality<T> RemoveRange(int index, int count) => _list.RemoveRange(index, count).WithDeepEquality();
        public ImmutableArrayWithDeepEquality<T> Replace(T oldValue, T newValue, IEqualityComparer<T> equalityComparer) => _list.Replace(oldValue, newValue, equalityComparer).WithDeepEquality();
        public ImmutableArrayWithDeepEquality<T> SetItem(int index, T value) => _list.SetItem(index, value).WithDeepEquality();
        public bool IsDefaultOrEmpty => _list.IsDefaultOrEmpty;

        public static ImmutableArrayWithDeepEquality<T> Empty = new(ImmutableArray<T>.Empty);

        #endregion

        #region IEnumerable

        IEnumerator IEnumerable.GetEnumerator() => (_list as IEnumerable).GetEnumerator();
        IEnumerator<T> IEnumerable<T>.GetEnumerator() => (_list as IEnumerable<T>).GetEnumerator();

        #endregion

        #region IEquatable

        public bool Equals(ImmutableArrayWithDeepEquality<T> other) => _list.SequenceEqual(other);

        public override bool Equals(object obj) => obj is ImmutableArrayWithDeepEquality<T> other && Equals(other);

        public static bool operator ==(ImmutableArrayWithDeepEquality<T>? left, ImmutableArrayWithDeepEquality<T>? right) => left is null ? right is null : left.Equals(right);

        public static bool operator !=(ImmutableArrayWithDeepEquality<T>? left, ImmutableArrayWithDeepEquality<T>? right) => !(left == right);

        public override int GetHashCode()
        {
            unchecked
            {
                return _list.Aggregate(19, (h, i) => h * 19 + i!.GetHashCode());
            }
        }


        #endregion
    }

    public static class ImmutableArrayWithDeepEqualityEx
    {
        public static ImmutableArrayWithDeepEquality<T> WithDeepEquality<T>(this ImmutableArray<T> list) => new(list);

        public static ImmutableArrayWithDeepEquality<T> ToImmutableArrayWithDeepEquality<T>(this IEnumerable<T> list) => new(list.ToImmutableArray());
    }
}

ImmutableArrayWithDeepEquality JsonConverter

using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using System.Text.Json;
using System.Text.Json.Serialization;

namespace System.Collections.Immutable
{
    public class JsonConverterForImmutableArrayWithDeepEqualityFactory : JsonConverterFactory
    {
        public override bool CanConvert(Type typeToConvert)
            => typeToConvert.IsGenericType
            && typeToConvert.GetGenericTypeDefinition() == typeof(ImmutableArrayWithDeepEquality<>);

        public override JsonConverter CreateConverter(Type typeToConvert, JsonSerializerOptions options)
        {
            var elementType = typeToConvert.GetGenericArguments()[0];

            var arrayType = typeof(JsonConverterForImmutableArrayWithDeepEquality<>);

            var converter = (JsonConverter)Activator.CreateInstance(
                arrayType.MakeGenericType(elementType),
                BindingFlags.Instance | BindingFlags.Public,
                binder: null,
                args: null,
                culture: null)!;

            return converter;
        }

        private class JsonConverterForImmutableArrayWithDeepEquality<T> : JsonConverter<ImmutableArrayWithDeepEquality<T>>
        {
            public override ImmutableArrayWithDeepEquality<T> Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
            {
                if (reader.TokenType != JsonTokenType.StartArray)
                {
                    throw new JsonException();
                }

                reader.Read();

                List<T> elements = new();

                while (reader.TokenType != JsonTokenType.EndArray)
                {
                    var value = JsonSerializer.Deserialize<T>(ref reader, options);

                    if (value is not null)
                    {
                        elements.Add(value);
                    }

                    reader.Read();
                }

                return elements.ToImmutableArrayWithDeepEquality();
            }

            public override void Write(Utf8JsonWriter writer, ImmutableArrayWithDeepEquality<T> value, JsonSerializerOptions options)
            {
                JsonSerializer.Serialize(writer, value.AsEnumerable(), options);
            }
        }
    }
}

ImmutableDictionaryWithDeepEquality

using System.Collections.Generic;
using System.Linq;
using System.Text.Json.Serialization;

namespace System.Collections.Immutable
{
    [JsonConverter(typeof(JsonConverterForImmutableDictionaryWithDeepEqualityFactory))]
    public class ImmutableDictionaryWithDeepEquality<TKey, TValue> : IEquatable<ImmutableDictionaryWithDeepEquality<TKey, TValue>>, IEnumerable<KeyValuePair<TKey, TValue>>, IEnumerable, IReadOnlyCollection<KeyValuePair<TKey, TValue>> where TKey : notnull
    {
        private readonly ImmutableDictionary<TKey, TValue> _dictionary;

        public ImmutableDictionaryWithDeepEquality(ImmutableDictionary<TKey, TValue> dictionary) => _dictionary = dictionary;

        #region ImmutableArray Implementation

        public TValue this[TKey index] => _dictionary[index];

        public int Count => _dictionary.Count;

        public ImmutableDictionaryWithDeepEquality<TKey, TValue> Add(TKey key, TValue value) => _dictionary.Add(key, value).WithDeepEquality();
        public ImmutableDictionaryWithDeepEquality<TKey, TValue> AddRange(IEnumerable<KeyValuePair<TKey, TValue>> pairs) => _dictionary.AddRange(pairs).WithDeepEquality();
        public ImmutableDictionaryWithDeepEquality<TKey, TValue> Clear() => _dictionary.Clear().WithDeepEquality();
        public ImmutableDictionary<TKey, TValue>.Enumerator GetEnumerator() => _dictionary.GetEnumerator();
        public ImmutableDictionaryWithDeepEquality<TKey, TValue> Remove(TKey key) => _dictionary.Remove(key).WithDeepEquality();
        public ImmutableDictionaryWithDeepEquality<TKey, TValue> RemoveRange(IEnumerable<TKey> keys) => _dictionary.RemoveRange(keys).WithDeepEquality();
        public ImmutableDictionaryWithDeepEquality<TKey, TValue> SetItem(TKey key, TValue value) => _dictionary.SetItem(key, value).WithDeepEquality();
        public bool IsEmpty => _dictionary.IsEmpty;

        public static ImmutableDictionaryWithDeepEquality<TKey, TValue> Empty = new(ImmutableDictionary<TKey, TValue>.Empty);

        #endregion

        #region IEnumerable

        IEnumerator IEnumerable.GetEnumerator() => (_dictionary as IEnumerable).GetEnumerator();
        IEnumerator<KeyValuePair<TKey, TValue>> IEnumerable<KeyValuePair<TKey, TValue>>.GetEnumerator() => (_dictionary as IEnumerable<KeyValuePair<TKey, TValue>>).GetEnumerator();

        #endregion

        #region IEquatable

        public bool Equals(ImmutableDictionaryWithDeepEquality<TKey, TValue> other) => _dictionary.SequenceEqual(other);

        public override bool Equals(object obj) => obj is ImmutableDictionaryWithDeepEquality<TKey, TValue> other && Equals(other);

        public static bool operator ==(ImmutableDictionaryWithDeepEquality<TKey, TValue>? left, ImmutableDictionaryWithDeepEquality<TKey, TValue>? right) => left is null ? right is null : right is not null && left.Equals(right);

        public static bool operator !=(ImmutableDictionaryWithDeepEquality<TKey, TValue>? left, ImmutableDictionaryWithDeepEquality<TKey, TValue>? right) => !(left == right);

        public override int GetHashCode()
        {
            unchecked
            {
                return _dictionary.Aggregate(19, (h, i) => h * 19 + i.Key.GetHashCode() + (i.Value?.GetHashCode() ?? 0));
            }
        }

        #endregion
    }

    public static class ImmutableDictionaryWithDeepEqualityEx
    {
        public static ImmutableDictionaryWithDeepEquality<TKey, TValue> WithDeepEquality<TKey, TValue>(this ImmutableDictionary<TKey, TValue> dictionary) where TKey : notnull => new(dictionary);

        public static ImmutableDictionaryWithDeepEquality<TKey, TValue> ToImmutableDictionaryWithDeepEquality<TKey, TValue>(this IEnumerable<KeyValuePair<TKey, TValue>> list) where TKey : notnull => new(list.ToImmutableDictionary());
    }
}

ImmutableDictionaryWithDeepEquality JsonConverter

using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using System.Text.Json;
using System.Text.Json.Serialization;

namespace System.Collections.Immutable
{
    public class JsonConverterForImmutableDictionaryWithDeepEqualityFactory : JsonConverterFactory
    {
        public override bool CanConvert(Type typeToConvert) =>
            typeToConvert.IsGenericType
            && typeToConvert.GetGenericTypeDefinition() == typeof(ImmutableDictionaryWithDeepEquality<,>);

        public override JsonConverter CreateConverter(Type typeToConvert, JsonSerializerOptions options)
        {
            var keyType = typeToConvert.GetGenericArguments()[0];
            var valueType = typeToConvert.GetGenericArguments()[1];

            var dictionaryType = typeof(JsonConverterForImmutableDictionaryWithDeepEquality<,>);

            var converter = (JsonConverter)Activator.CreateInstance(
                dictionaryType.MakeGenericType(keyType, valueType),
                BindingFlags.Instance | BindingFlags.Public,
                binder: null,
                args: null,
                culture: null)!;

            return converter;
        }

        private class JsonConverterForImmutableDictionaryWithDeepEquality<TKey, TValue> : JsonConverter<ImmutableDictionaryWithDeepEquality<TKey, TValue>> where TKey : notnull
        {
            public override ImmutableDictionaryWithDeepEquality<TKey, TValue> Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
            {
                if (reader.TokenType != JsonTokenType.StartArray)
                {
                    throw new JsonException();
                }

                reader.Read();

                Dictionary<TKey, TValue> elements = new();

                while (reader.TokenType != JsonTokenType.EndArray)
                {
                    var value = JsonSerializer.Deserialize<KeyValuePair<TKey, TValue>>(ref reader, options);

                    elements.Add(value.Key, value.Value);

                    reader.Read();
                }

                return elements.ToImmutableDictionaryWithDeepEquality();
            }

            public override void Write(Utf8JsonWriter writer, ImmutableDictionaryWithDeepEquality<TKey, TValue> value, JsonSerializerOptions options)
            {
                JsonSerializer.Serialize(writer, value.AsEnumerable(), options);
            }
        }
    }
}

ImmutableDeepEqualityTests

using System.Collections.Generic;
using Xunit;

namespace System.Collections.Immutable.Tests
{
    public class ImmutableDeepEqualityTests
    {
        [Fact]
        public void ArraysWithSameValues_AreConsideredEqual()
        {
            var array1 = new int[] { 1, 2, 3 }.ToImmutableArrayWithDeepEquality();
            var array1Copy = new int[] { 1, 2, 3 }.ToImmutableArrayWithDeepEquality();

            Assert.Equal(array1, array1Copy);
            Assert.True(array1 == array1Copy);
            Assert.False(array1 != array1Copy);
            Assert.True(array1.Equals(array1Copy));
        }

        [Fact]
        public void ArraysWithDifferentValues_AreConsideredNotEqual()
        {
            var array1 = new int[] { 1, 2, 3 }.ToImmutableArrayWithDeepEquality();
            var array2 = new int[] { 4, 5, 6 }.ToImmutableArrayWithDeepEquality();

            Assert.NotEqual(array1, array2);
            Assert.False(array1 == array2);
            Assert.True(array1 != array2);
            Assert.False(array1.Equals(array2));
        }

        [Fact]
        public void DictionariesWithSameValues_AreConsideredEqual()
        {
            var dict1 = new Dictionary<string, string> { { "Key1", "1" }, { "Key2", "2" } }.ToImmutableDictionaryWithDeepEquality();
            var dict1Copy = new Dictionary<string, string> { { "Key1", "1" }, { "Key2", "2" } }.ToImmutableDictionaryWithDeepEquality();

            Assert.Equal(dict1, dict1Copy);
            Assert.True(dict1 == dict1Copy);
            Assert.False(dict1 != dict1Copy);
            Assert.True(dict1.Equals(dict1Copy));
        }

        [Fact]
        public void DictionariesWithDifferentKeys_AreConsideredNotEqual()
        {
            var dict1 = new Dictionary<string, string> { { "Key1", "1" }, { "Key2", "2" } }.ToImmutableDictionaryWithDeepEquality();
            var dict2 = new Dictionary<string, string> { { "KeyA", "1" }, { "KeyB", "2" } }.ToImmutableDictionaryWithDeepEquality();

            Assert.NotEqual(dict1, dict2);
            Assert.False(dict1 == dict2);
            Assert.True(dict1 != dict2);
            Assert.False(dict1.Equals(dict2));
        }

        [Fact]
        public void DictionariesWithDifferentValues_AreConsideredNotEqual()
        {
            var dict1 = new Dictionary<string, string> { { "Key1", "1" }, { "Key2", "2" } }.ToImmutableDictionaryWithDeepEquality();
            var dict2 = new Dictionary<string, string> { { "Key1", "A" }, { "Key2", "B" } }.ToImmutableDictionaryWithDeepEquality();

            Assert.NotEqual(dict1, dict2);
            Assert.False(dict1 == dict2);
            Assert.True(dict1 != dict2);
            Assert.False(dict1.Equals(dict2));
        }

        [Fact]
        public void RecordsUseDeepEquality()
        {
            var array1 = new int[] { 1, 2, 3 }.ToImmutableArrayWithDeepEquality();
            var array1Copy = new int[] { 1, 2, 3 }.ToImmutableArrayWithDeepEquality();

            var dict1 = new Dictionary<string, string> { { "Key1", "1" }, { "Key2", "2" } }.ToImmutableDictionaryWithDeepEquality();
            var dict1Copy = new Dictionary<string, string> { { "Key1", "1" }, { "Key2", "2" } }.ToImmutableDictionaryWithDeepEquality();

            var model1 = new TestModel(array1, dict1);
            var model1Copy = new TestModel(array1Copy, dict1Copy);

            Assert.Equal(model1, model1Copy);
            Assert.True(model1 == model1Copy);
            Assert.False(model1 != model1Copy);
            Assert.True(model1.Equals(model1Copy));
        }

        [Fact]
        public void Records_ThatUseDeepEquality_CanSerialize()
        {
            var array1 = new int[] { 1, 2, 3 }.ToImmutableArrayWithDeepEquality();

            var dict1 = new Dictionary<string, string> { { "Key1", "1" } }.ToImmutableDictionaryWithDeepEquality();

            var model1 = new TestModel(array1, dict1);

            var json = System.Text.Json.JsonSerializer.Serialize(model1);

            Assert.Equal("{\"Ints\":[1,2,3],\"Dictionary\":[{\"Key\":\"Key1\",\"Value\":\"1\"}]}", json);
        }

        [Fact]
        public void Records_ThatUseDeepEquality_CanDeserialize()
        {
            var json = "{\"Ints\":[1,2,3],\"Dictionary\":[{\"Key\":\"Key1\",\"Value\":\"1\"}]}";

            var modelFromJson = System.Text.Json.JsonSerializer.Deserialize<TestModel>(json);

            var array1 = new int[] { 1, 2, 3 }.ToImmutableArrayWithDeepEquality();

            var dict1 = new Dictionary<string, string> { { "Key1", "1" } }.ToImmutableDictionaryWithDeepEquality();

            var model1 = new TestModel(array1, dict1);

            Assert.Equal(model1, modelFromJson);
        }

        private sealed record TestModel(
            ImmutableArrayWithDeepEquality<int> Ints,
            ImmutableDictionaryWithDeepEquality<string, string> Dictionary);
    }
}

Conclusion

As you can see it's no small task to implement, support, and maintain. After looking at are own needs we found deep equality was only needed to mask upstream issues that produced duplicate values when duplicate values never needed to be created in the first place.

There is no out-of-the-box solution. You could try however derive out of Collection. Microsoft made it specifically for such cases where you do not want to create whole boilerplate (adding, removing etc.). You could use our approach to the same concept: ValueCollection.cs

Now it's also available via convenient package

This is an imperfect solution, but as long as you don't lie to yourself it can be useful. You can create a record that inherits from IReadOnlyCollection or IReadOnlyList (or any of the other IReadOnly... interfaces). However you will have to manually create several of the methods that records would make for you automatically (both for completeness and for the operations I expect you would want). This record type must be sealed, but otherwise is a straight forward implementation (EDIT: written quickly and untested):

public sealed record ReadOnlyListRecord<T> : IReadOnlyList<T>
{
    private readonly IReadOnlyList<T> _baseList;

    /// <summary>
    /// Gets or initializes the items in this collection.
    /// </summary>
    public IReadOnlyList<T> Items
    {
        get { return _baseList; }
        init { _baseList = value.ToList().AsReadOnly(); } 
    }

    /// <inheritdoc />
    public IEnumerator<T> GetEnumerator()
    {
        return Items.GetEnumerator();
    }

    /// <inheritdoc />
    IEnumerator IEnumerable.GetEnumerator()
    {
        return GetEnumerator();
    }

    /// <inheritdoc />
    [IgnoreMember]
    public int Count
    {
        get { return _baseList.Count; }
    }

    /// <inheritdoc />
    public T this[int index] => _baseList[index];

    /// <inheritdoc />
    public override int GetHashCode()
    {
        int someHashValue = -234897289; 
        foreach(var item in _baseList)
        {
            someHashValue = someHashValue ^ item.GetHashCode();
        }

        return someHashValue;
    }

    /// <inheritdoc />
    public bool Equals(ReadOnlyListRecord<T> other) 
    {
        // create a proper equality method...
        if(other == null || other.Count != Count)
        {
            return false;
        }
        for(int i=0; i < Count;i++)
        {
            if(!other[i].Equals(this[i]))
            {
                return false;
            }
        }

        return true;
    }
}

However, I must caution you that it is VERY easy to lie with this type. There is no guarantee that the items in the list are immutable, and that would break the assumptions that the overall record is immutable. A really bad example is if you make an instance of this record contain a list of normal mutable lists. BUT that is always true of records so just be careful and document that limitation well.

And the fact that this is sealed isn't that big of a problem. Generally I want another record to HAVE a list of values. For example, in some engineering code, I want a record of something to have (among other things like names and other 1-off values) a list of coefficients, and I want the benefits of a list (not a fixed length, no extra slots, foreach...). For primitives, strings, numbers, other immutable records, this will work well.

Related