具有集合属性的记录类型和具有值语义的集合

54
在C# 9中,我们现在(终于)有记录类型:
public record SomeRecord(int SomeInt, string SomeString);

这使我们获得了一些好处,例如值语义:

var r1 = new SomeRecord(0, "zero");
var r2 = new SomeRecord(0, "zero");
Console.WriteLine(r1 == r2); // true - property based equality
当尝试使用该功能进行实验时,我意识到定义非字符串引用类型的属性可能会导致直觉上不符合预期的行为(但如果你仔细思考,这种行为是可以完全解释的)。
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

在 .Net(或第三方)中是否有值语义的集合类型可用于此场景?我查看了 ImmutableArray 等,但这些也不提供此功能。


1
这不是关于引用类型的问题,而是关于类型是否在其内容方面实现了“Equals”。 - Panagiotis Kanavos
7
记录不是值类型,它们是具有值语义的类(类似于字符串)。 它们应该仅包含具有值语义的类型属性(其他记录、结构体、字符串和可能的具有值语义的集合 - 如果存在这些类型)。 - jeroenh
5
如果通用的集合类型具有值语义,可能会非常昂贵 - 如果它们完全是通用的,则无法强制其值具有值语义,因此您需要寻找适用于您特定情况的专用类型,而不是通用类型。 - Damien_The_Unbeliever
3
这里,我发现唯一的“集合”类型是ImmutableArray<T>,但正如你所发现的,它没有值语义。只有ValueTuple具有值语义,但它并不是真正的集合类型。如果您自己创建类型,请考虑使其不可变,否则将很难实现正确的GetHashCode - Jeff
4
虽然下面的答案提供了解决基于值的相等性问题的方法,但这个问题确实让我质疑结构中包含集合是否违反了记录的定义。如果记录不是一个扁平的数据结构,那么它似乎就缺乏与任何类层次结构的区别,对吧? - Derek Greer
显示剩余2条评论
4个回答

20

看起来目前没有这种类型可用。

你可以自己实现,但要注意如果在生产环境中需要使用,可能会带来一些问题。正如@ryanholden8的这个回答所示,这并不像乍一看那么简单!

对于我的用例(以及一个简化的示例),我使用了这个小技巧,它装饰了一个IImutableList,并且可以按照以下方式使用:

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

显然要小心非常大的列表的性能影响。


8

我们的团队遇到了类似的问题,并开始根据@jeroenh的想法进行实现。 然而,我们遇到了无法使用System.Text.Json从json反序列化的记录的问题。 这里是gist(也在下面发布),其中包含我们必须创建的所有内容,以支持记录的深度相等性。

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 Json转换器

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);
    }
}

结论

正如您所看到的,实施、支持和维护并不是一项小任务。在查看我们自己的需求之后,我们发现深度相等只需要用于掩盖上游问题产生的重复值,而这些重复值实际上从一开始就不需要被创建。


5

目前没有现成的解决方案,不过你可以尝试从Collection中派生出一个你需要的类。Microsoft专门为这种情况设计了该类,它避免了你自己编写添加、删除等代码。你也可以使用我们提供的类似概念的方法: ValueCollection.cs

现在它还通过便捷的进行了发布。


1

这并不是完美的解决方案,但只要你不欺骗自己,它就可以很有用。你可以创建一个从IReadOnlyCollection或IReadOnlyList(或任何其他IReadOnly...接口)继承的记录。然而,你将不得不手动创建几个记录通常会为你自动创建的方法(为了完整性和我认为你想要的操作)。这种记录类型必须是密封的,但除此之外,它是一个简单的实现(编辑:快速编写且未经测试):

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;
    }
}

然而,我必须提醒您,使用这种记录类型非常容易造假。不能保证列表中的项目是不可变的,这可能会破坏整个记录不可变的假设。一个真正糟糕的例子是,如果记录的一个实例包含普通可变列表的列表。但是这总是适用于记录,所以要小心并详细记录这个限制。

而且,这个记录是sealed并不是很大的问题。通常,我希望另一个记录拥有一系列值。例如,在一些工程代码中,我希望某个记录有一个(除了名称和其他单独的值之外)系数列表,并且我希望享受到列表(不是固定长度,没有额外的槽位,foreach...)的好处。对于基元、字符串、数字和其他不可变记录,这将起作用。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接