Using record type with collections
An approach for the record collection problem.
With C# I find myself leveraging the record type quite a lot.
Its implicit immutability and value equality make it an ideal type for many use cases.
But; as I'm sure others are aware; equality for a collection of records is a problem.
Just to recap:
public record AThing(string Name);
public record ASetOfThings(AThing[] Items);
[TestMethod]
public void CheckEqual(){
var a = new ASetOfThings(new AThing[] { new("a") });
var b = new ASetOfThings(new AThing[] { new("a") });
Assert.IsFalse(ReferenceEquals(a.Items, b.Items), "different lists");
//FAIL!
Assert.AreEqual(a, b, "Should be value-equality");
}
This fails, since the equality checks for the collection is via object reference, not value equality.
Fixing the record.
We can fix the problems with ASetOfThings by just providing some record implementations ourselves:
- a copy constructor
- virtual Equals
- GetHashCode
public record ASetOfThingsFixed(AThing[] Items)
{
//copy ctor so 'with' expressions will clone.
protected ASetOfThingsFixed(ASetOfThingsFixed other)
{
Items = other.Items.Select(x => x with { }).ToArray();
}
//value-equality on the array.
public virtual bool Equals(ASetOfThingsFixed? other) =>
other is not null && Items.SequenceEqual(other.Items);
//override Equals so need GetHashCode().
public override int GetHashCode()
{
var hash = new HashCode();
foreach (var item in Items) hash.Add(item);
return hash.ToHashCode();
}
}
That works.
But I have a bit more boilerplate code for each record type that has a collection.
And a record with more properties will need to handle the extra properties.
| Note that equality assumes the items are ordered.
And an Array can be mutated; Array.SetValue() so it may not be the best choice.
Array mutations
Thankfully there is ImmutableArray<T>, you may need the NuGet to use this type.
And to my happy surprise, the ImmutableArray<T> works with STJ, so JSON serialization is fine.
//swap to ImmutableArray
public record ASetOfThingsFixed(ImmutableArray<AThing> Items)
{
//copy ctor so 'with' expressions work
protected ASetOfThingsFixed(ASetOfThingsFixed other)
{
Items = other.Items.Select(x => x with { }).ToImmutableArray();
}
//... equality and hashcode etc
Having said that:
In practice, I haven't seen code that mutates an array.
So an Array may be fine in your case.
What about writing a RecordArray<T> custom collection?
I still have that frustrating HashCode and value equality. And any updates to the record forces updates to the custom methods.
A custom collection based on IReadOnlyList<T> and IEquatable<T> will give us a collection, and the value-equality we need.
We'll use a T[] field as backing; that way most of the interface implementation will be forwarded to the internal Array.
See also ReadOnlyCollection
public class RecordArray<T> : IReadonlyList<T>, IEquitable<T>{
private T[] _data;
public RecordArray(IEnumerable<T>? data){
_data = data?.ToArray() ?? Array.Empty<T>();
}
#region IReadOnlyList
// pass through to backing _data
#endregion
#region Equality
// override GetHashCode
// override Equals
// == and != operators
#endregion
}
We cannot implement cloning; since that is a magic record method generated by the compiler.
That will leave us with:
public record ASetOfThingsFixed(RecordArray<AThing> Items)
{
protected ASetOfThingsFixed(ASetOfThingsFixed other)
{
Items = new( other.Items.Select(x => x with {}) )
}
}
Though we still need our copy constructor, It's looking neater.
Ah well, if we've gone this far... a little more to play nice.
Along with the IReadOnlyCollection; implement
public T[] Slice(int start, int length)
Now we support the C# range operator.
I like to be a little more explicit with implementing that copy-constructor as well. A quick helper in RecordArray:
public RecordArray<T> Copy(Func<T,T> map)
=> new(_data.Select(map));
And now my boilerplate code is just that little bit neater.
public record ASetOfThingsFixed(RecordArray<AThing> Items)
{
protected ASetOfThingsFixed(ASetOfThingsFixed other)
{
Items = other.Items.Copy(x => x with{});
}
}
There's still a little more to go though.
If we use a custom class, we'll want serialization.
A JSON Converter
The RecordArray<T> should serialize like an array of T.
For STJ a custom Converter does the trick; a similar approach works with the ever-popular Newtonsoft.Json
Since this is generic; we'll need a custom generic Converter<RecordArray<T>> along with a converter factory.
The converter implementation needs only leverage in-built serialization:
public override RecordArray<T>? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
var data = JsonSerializer.Deserialize<T[]>(ref reader, options);
return data is null ? null : new RecordArray<T>(data);
}
public override void Write(Utf8JsonWriter writer, RecordArray<T> value, JsonSerializerOptions options)
=> JsonSerializer.Serialize(writer, (IEnumerable<T>)value, options);
Finally; you need to include the converter factory whenever serializing a RecordArray
Final thoughts
Hopefully, this gives you a simple reference to creating your own record collection type.
I also have an implementation in kwd.CoreUtil you can use/reference as-like.
I expect some time in the future, the language will provide a more direct solution.
Until then records with collections look to need either:
case-by-case customisation:
Easier to implement, but needs updates if record-type changes.
a custom collection:
More of a challenge to implement.
Needs a custom serializer.
Which is your preferred approach?