Record proposal: collection members #3622
-
The proposal for records does not address collections. At some point, however, people are going to use records that contain collections, wanting recursive structural equality on their elements. If such an implementation is not covered by records, people will start rolling their own. In fact, this might be tricky for a developer to retrofit onto records, so people may even substitute records entirely. Special-casing collections seems to me like an important part of a successful record type. Having been working extensively on a library for structural equality (such as to implement Domain-Driven Design's "Value Objects"), I have studied this case quite thoroughly. I propose the following special cases (code samples further below):
Custom overrides. An additional consideration is to ignore the above rules if the compile-time type of one of the above has a more specific override of Same generic interface for multiple type parameters. The above remarks "for exactly one T" and "for a single set of type parameters" are needed to determine what to do for types that implement a generic collection interface for multiple types. We prefer the most specific or most strongly materialized interface that is implemented for only a single [set of] generic type parameter[s]. This can (and even needs to) be determined at compile time. All the other type checks on Hash codes. Clearly, the hash code implementations should follow the same rules. However, they can take shortcuts by allowing false positives in the uncommon case. For example, the hash code of an Non-indexable but materialized collections ( Unmaterialized collections ( Strings. Strings already have the desired equality and hash code calculation. For long strings, it might be beneficial to optimize the hash code calculation, e.g. similar to how we optimize it for Code samples The code samples show the runtime decisions. Other decisions described above can and probably should be made at compile time. /// <param name="nullSafeEquals">A function that returns the equality between two given elements.</param>
public static bool EnumerableEquals(IEnumerable? left, IEnumerable? right, Func<TElement, TElement, bool> nullSafeEquals)
{
if (ReferenceEquals(left, right)) return true;
if (left is null || right is null) return false;
// Prefer to index directly, to avoid allocation of an enumerator
if (left is IReadOnlyList<TElement> leftIndexable && right is IReadOnlyList<TElement> rightIndexable)
return IndexableEquals(leftIndexable, rightIndexable, nullSafeEquals);
// Honor sets, which may be order-agnostic or dictate the equality comparer (the left side is leading, matching the asymmetry in HashSet<T>.SetEquals and our dictionary and lookup implementations)
else if (left is ISet<TElement> leftSet && right is IEnumerable<TElement> rightSetComparand)
return leftSet.SetEquals(rightSetComparand);
else if (left is IReadOnlyCollection<TElement> leftCollection && right is IReadOnlyCollection<TElement> rightCollection)
return CollectionEquals(leftCollection, rightCollection, nullSafeEquals);
else if (left is IEnumerable<TElement> leftEnumerable && right is IEnumerable<TElement> rightEnumerable)
return GenericEnumerableEquals(leftEnumerable, rightEnumerable, nullSafeEquals);
else
{
System.Diagnostics.Debug.Assert(typeof(TElement) == typeof(object)); // For non-generic IEnumerables, we always use object as the type param for this internal method
return EnumerableEquals(left, right, nullSafeEquals);
}
// Local function that compares key-value indexable collections
static bool IndexableEquals(IReadOnlyList<TElement> leftIndexable, IReadOnlyList<TElement> rightIndexable, Func<TElement, TElement, bool> nullSafeElementEqualsFunction)
{
if (leftIndexable.Count != rightIndexable.Count) return false;
for (var i = 0; i < leftIndexable.Count; i++)
if (!nullSafeElementEqualsFunction(leftIndexable[i], rightIndexable[i])) return false;
return true;
}
// Local function that compares materialized collections
static bool CollectionEquals(IReadOnlyCollection<TElement> leftCollection, IReadOnlyCollection<TElement> rightCollection, Func<TElement, TElement, bool> nullSafeElementEqualsFunction)
{
if (leftCollection.Count != rightCollection.Count) return false;
var rightEnumerator = rightCollection.GetEnumerator();
using (rightEnumerator as IDisposable)
{
foreach (var leftElement in leftCollection)
{
rightEnumerator.MoveNext();
if (!nullSafeElementEqualsFunction(leftElement, rightEnumerator.Current)) return false;
}
return true;
}
}
// Local function that compares generic enumerables
static bool GenericEnumerableEquals(IEnumerable<TElement> leftEnumerable, IEnumerable<TElement> rightEnumerable, Func<TElement, TElement, bool> nullSafeElementEqualsFunction)
{
var rightEnumerator = rightEnumerable.GetEnumerator();
using (rightEnumerator as IDisposable)
{
foreach (var leftElement in leftEnumerable)
if (!rightEnumerator.MoveNext() || !nullSafeElementEqualsFunction(leftElement, rightEnumerator.Current)) return false;
if (rightEnumerator.MoveNext()) return false;
return true;
}
}
// Local function that compares non-generic enumerables
static bool EnumerableEquals(IEnumerable leftEnumerable, IEnumerable rightEnumerable, Func<TElement, TElement, bool> nullSafeElementEqualsFunction)
{
System.Diagnostics.Debug.Assert(typeof(TElement) == typeof(object));
var rightEnumerator = rightEnumerable.GetEnumerator();
using (rightEnumerator as IDisposable)
{
foreach (var leftElement in leftEnumerable)
if (!rightEnumerator.MoveNext() || !Object.Equals(leftElement, rightEnumerator.Current)) return false;
if (rightEnumerator.MoveNext()) return false;
return true;
}
}
}
/// <param name="nullSafeGetHashCode">A function that returns the hash code for a given element. Invoked only for types that implement <see cref="IReadOnlyList{T}"/>.</param>
public static int GetEnumerableHashCode(IEnumerable? enumerable, Func<TElement, int> nullSafeGetHashCode)
{
// Prefer to use the count, first element, and last element (if IReadOnlyList, without string elements, to avoid casing complications)
if (enumerable is IReadOnlyList<TElement> list && list.Count > 0)
{
unchecked
{
return ((ComparisonConstants.HashCodeInitialValue
** ComparisonConstants.HashCodeMultiplier + list.Count)
** ComparisonConstants.HashCodeMultiplier + nullSafeGetHashCode(list[0]))
** ComparisonConstants.HashCodeMultiplier + nullSafeGetHashCode(list[^1]);
}
}
// Prefer to use the count (if IReadOnlyCollection)
else if (enumerable is IReadOnlyCollection<TElement> collection)
{
unchecked
{
return 1 + collection.Count; // Keep 0 available for null
}
}
// Otherwise, use a constant
else
{
return -1;
}
} /// <param name="nullSafeValueEquals">A function that returns the equality between two given values from the dictionaries.</param>
public static bool DictionaryEquals(IReadOnlyDictionary<TKey, TValue>? left, IReadOnlyDictionary<TKey, TValue>? right, Func<TValue, TValue, bool> nullSafeValueEquals)
{
if (ReferenceEquals(left, right)) return true;
if (left is null || right is null) return false;
foreach (var rightPair in right)
{
if (!left.TryGetValue(rightPair.Key, out var leftValue) || !nullSafeValueEquals(leftValue, rightPair.Value))
return false;
}
foreach (var leftPair in left)
{
if (!right.TryGetValue(leftPair.Key, out var rightValue) || !nullSafeValueEquals(rightValue, leftPair.Value))
return false;
}
return true;
}
/// <param name="nullSafeValueEquals">A function that returns the equality between two given values from the dictionaries.</param>
public static bool DictionaryEquals(IDictionary<TKey, TValue>? left, IDictionary<TKey, TValue>? right, Func<TValue, TValue, bool> nullSafeValueEquals)
{
if (ReferenceEquals(left, right)) return true;
if (left is null || right is null) return false;
foreach (var rightPair in right)
{
if (!left.TryGetValue(rightPair.Key, out var leftValue) || !nullSafeValueEquals(leftValue, rightPair.Value))
return false;
}
foreach (var leftPair in left)
{
if (!right.TryGetValue(leftPair.Key, out var rightValue) || !nullSafeValueEquals(rightValue, leftPair.Value))
return false;
}
return true;
} /// <param name="nullSafeValueEquals">A function that returns the equality between two given values from matching groups in the lookups.</param>
public static bool LookupEquals(ILookup<TKey, TElement>? left, ILookup<TKey, TElement>? right, Func<TElement, TElement, bool> nullSafeValueEquals)
{
if (ReferenceEquals(left, right)) return true;
if (left is null || right is null) return false;
foreach (var rightGroup in right)
{
using var rightGroupEnumerator = rightGroup.GetEnumerator();
foreach (var leftElement in left[rightGroup.Key])
if (!rightGroupEnumerator.MoveNext() || !nullSafeValueEquals(rightGroupEnumerator.Current, leftElement))
return false;
}
foreach (var leftGroup in left)
{
using var leftGroupEnumerator = leftGroup.GetEnumerator();
foreach (var rightElement in right[leftGroup.Key])
if (!leftGroupEnumerator.MoveNext() || !nullSafeValueEquals(leftGroupEnumerator.Current, rightElement))
return false;
}
return true;
} |
Beta Was this translation helpful? Give feedback.
Replies: 4 comments
-
This sounds like it belongs on the csharplang repo since it impacts the language specification. IMO, records will consider the equality of its members based on how the types of those members determine equality. If you want value equality based on collections, use collections that are based on value equality and implement their own |
Beta Was this translation helpful? Give feedback.
-
Hi @Timovzl I've transferred this issue to the right repo. Thanks! |
Beta Was this translation helpful? Give feedback.
-
@HaloFour You make a valid point. I had considered it, except for the last part: "It also makes the language asymmetric if the records consider those collections as equivalent but those collections themselves don't." In any case, I'm wondering out loud if that solution is sufficient, in practice. Do you know of any collection types that implement structural equality? Would most developers you know of be aware that adding an array or list member stops their record from showing the desired equality behavior? |
Beta Was this translation helpful? Give feedback.
-
Records have already shipped, so it's too late to change this. However you can implement your own version of records with whatever equality you want using Source Generators |
Beta Was this translation helpful? Give feedback.
Records have already shipped, so it's too late to change this. However you can implement your own version of records with whatever equality you want using Source Generators