Skip to content

Commit

Permalink
Merge pull request #36 from StephenCleary/unordered-collection-comparer
Browse files Browse the repository at this point in the history
Add unordered collection comparer
  • Loading branch information
StephenCleary authored Mar 7, 2021
2 parents 9953db7 + cf909b8 commit b8e6535
Show file tree
Hide file tree
Showing 15 changed files with 536 additions and 24 deletions.
2 changes: 1 addition & 1 deletion doc/comparer-extensions.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ For equality comparers, use `ThenEquateBy`. There is no notion of "descending" f

`Sequence` converts an `IComparer<T>` into an `IComparer<IEnumerable<T>>` by using lexicographical sorting.

For equality comparers, use `EquateSequence`.
For equality comparers, use `EquateSequence`. `EquateSequence` by default uses lexicographical equality, but it also takes an optional argument to ignore the order of elements when equating the sequence. Ignoring element order does introduce overhead.

# Fixing Incomplete Comparers

Expand Down
18 changes: 14 additions & 4 deletions src/Nito.Comparers.Core/EqualityComparerExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -53,12 +53,22 @@ public static IFullEqualityComparer<T> ThenEquateBy<T, TKey>(this IEqualityCompa
}

/// <summary>
/// Returns an equality comparer that will perform a lexicographical ordering on a sequence of items.
/// Returns an equality comparer that will perform a lexicographical equality on a sequence of items.
/// This is the same as calling <see cref="EquateSequence{T}(System.Collections.Generic.IEqualityComparer{T}?,bool)"/> with <c>ignoreOrder</c> set to <c>false</c>.
/// </summary>
/// <typeparam name="T">The type of sequence elements being compared.</typeparam>
/// <param name="source">The source comparer. If this is <c>null</c>, the default comparer is used.</param>
/// <returns>A comparer that will perform a lexicographical ordering on a sequence of items.</returns>
public static IFullEqualityComparer<IEnumerable<T>> EquateSequence<T>(this IEqualityComparer<T>? source) =>
new SequenceEqualityComparer<T>(source);
/// <returns>A comparer that will perform a lexicographical equality on a sequence of items.</returns>
public static IFullEqualityComparer<IEnumerable<T>> EquateSequence<T>(this IEqualityComparer<T>? source) => source.EquateSequence(ignoreOrder: false);

/// <summary>
/// Returns an equality comparer that will perform a lexicographical or unordered equality on a sequence of items.
/// </summary>
/// <typeparam name="T">The type of sequence elements being compared.</typeparam>
/// <param name="source">The source comparer. If this is <c>null</c>, the default comparer is used.</param>
/// <param name="ignoreOrder">Whether the sequences will be equated ignoring order.</param>
/// <returns>A comparer that will perform an equality on a sequence of items.</returns>
public static IFullEqualityComparer<IEnumerable<T>> EquateSequence<T>(this IEqualityComparer<T>? source, bool ignoreOrder) =>
ignoreOrder ? new UnorderedSequenceEqualityComparer<T>(source) : new SequenceEqualityComparer<T>(source);
}
}
43 changes: 43 additions & 0 deletions src/Nito.Comparers.Core/Internals/CommutativeHashCombiner.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
using System;
using System.Collections.Generic;
using System.Text;

#pragma warning disable CA1815, CA1721

namespace Nito.Comparers.Internals
{
/// <summary>
/// A hash combiner that is implemented with a simple commutative algorithm. This is a mutable struct for performance reasons.
/// </summary>
public struct CommutativeHashCombiner
{
private uint _hash;

/// <summary>
/// Gets the current result of the hash function.
/// </summary>
public int HashCode => unchecked((int) _hash);

/// <summary>
/// Creates a new hash, starting at <paramref name="seed"/>.
/// </summary>
/// <param name="seed">The seed for the hash. Defaults to the FNV hash offset, for no particular reason.</param>
public static CommutativeHashCombiner Create(int seed = unchecked((int)2166136261)) => new() { _hash = unchecked((uint) seed) };

/// <summary>
/// Adds the specified integer to this hash. This operation is commutative.
/// </summary>
/// <param name="data">The integer to hash.</param>
public void Combine(int data)
{
unchecked
{
// Simple addition is pretty much the best we can do since this operation must be commutative.
// We also add a constant value to act as a kind of "length counter" in the higher 16 bits.
// The hash combination is free to overflow into the "length counter".
// The higher 16 bits were chosen because that gives a decent distinction for sequences of <64k items while also distinguishing between small integer values (commonly used as ids).
_hash += (uint)data + 65536;
}
}
}
}
11 changes: 4 additions & 7 deletions src/Nito.Comparers.Core/Util/SequenceComparer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,10 @@ public SequenceComparer(IComparer<T>? source)
/// <inheritdoc />
protected override int DoGetHashCode(IEnumerable<T> obj)
{
unchecked
{
var ret = Murmur3Hash.Create();
foreach (var item in obj)
ret.Combine(SourceGetHashCode(item));
return ret.HashCode;
}
var ret = Murmur3Hash.Create();
foreach (var item in obj)
ret.Combine(SourceGetHashCode(item));
return ret.HashCode;
}

/// <inheritdoc />
Expand Down
13 changes: 6 additions & 7 deletions src/Nito.Comparers.Core/Util/SequenceEqualityComparer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,10 @@ public SequenceEqualityComparer(IEqualityComparer<T>? source)
/// <inheritdoc />
protected override int DoGetHashCode(IEnumerable<T> obj)
{
unchecked
{
var ret = Murmur3Hash.Create();
foreach (var item in obj)
ret.Combine(Source.GetHashCode(item!));
return ret.HashCode;
}
var ret = Murmur3Hash.Create();
foreach (var item in obj)
ret.Combine(Source.GetHashCode(item!));
return ret.HashCode;
}

/// <inheritdoc />
Expand All @@ -41,6 +38,8 @@ protected override bool DoEquals(IEnumerable<T> x, IEnumerable<T> y)
{
if (xCount.Value != yCount.Value)
return false;
if (xCount.Value == 0)
return true;
}
}

Expand Down
102 changes: 102 additions & 0 deletions src/Nito.Comparers.Core/Util/UnorderedSequenceEqualityComparer.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
using Nito.Comparers.Internals;
using System;
using System.Collections.Generic;
using System.Linq;

namespace Nito.Comparers.Util
{
/// <summary>
/// A comparer that performs an unordered equality comparison on a sequence.
/// </summary>
/// <typeparam name="T">The type of sequence elements being compared.</typeparam>
internal sealed class UnorderedSequenceEqualityComparer<T> : SourceEqualityComparerBase<IEnumerable<T>, T>
{
/// <summary>
/// Initializes a new instance of the <see cref="UnorderedSequenceEqualityComparer&lt;T&gt;"/> class.
/// </summary>
/// <param name="source">The source comparer. If this is <c>null</c>, the default comparer is used.</param>
public UnorderedSequenceEqualityComparer(IEqualityComparer<T>? source)
: base(source, false)
{
}

/// <inheritdoc />
protected override int DoGetHashCode(IEnumerable<T> obj)
{
var ret = CommutativeHashCombiner.Create();
foreach (var item in obj)
ret.Combine(Source.GetHashCode(item!));
return ret.HashCode;
}

/// <inheritdoc />
protected override bool DoEquals(IEnumerable<T> x, IEnumerable<T> y)
{
var xCount = x.TryGetCount();
if (xCount != null)
{
var yCount = y.TryGetCount();
if (yCount != null)
{
if (xCount.Value != yCount.Value)
return false;
if (xCount.Value == 0)
return true;
}
}

var equivalenceClassCounts = new Dictionary<Wrapper, int>(EqualityComparerBuilder.For<Wrapper>().EquateBy(w => w.Value, Source));

using (var xIter = x.GetEnumerator())
using (var yIter = y.GetEnumerator())
{
while (true)
{
if (!xIter.MoveNext())
{
if (!yIter.MoveNext())
{
// We have reached the end of both sequences simultaneously.
// They are equivalent if all equivalence class counts have canceled each other out.
return equivalenceClassCounts.All(kvp => kvp.Value == 0);
}

return false;
}

if (!yIter.MoveNext())
return false;

// If both items are equivalent, just skip the equivalence class counts.
if (Source.Equals(xIter.Current, yIter.Current))
continue;

var xKey = new Wrapper { Value = xIter.Current };
var yKey = new Wrapper { Value = yIter.Current };

// Treat `x` as adding counts and `y` as subtracting counts; any counts not present are 0.
if (equivalenceClassCounts.TryGetValue(xKey, out var xValue))
++xValue;
else
xValue = 1;
equivalenceClassCounts[xKey] = xValue;
if (equivalenceClassCounts.TryGetValue(yKey, out var yValue))
--yValue;
else
yValue = -1;
equivalenceClassCounts[yKey] = yValue;
}
}
}

/// <summary>
/// Returns a short, human-readable description of the comparer. This is intended for debugging and not for other purposes.
/// </summary>
public override string ToString() => $"UnorderedSequence<{typeof(T).Name}>({Source})";

private struct Wrapper
{
public T Value { get; set; }
}
}
}
2 changes: 1 addition & 1 deletion test/Ix.UnitTests/Ix.UnitTests.csproj
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>netcoreapp2.1</TargetFramework>
<TargetFramework>netcoreapp3.1</TargetFramework>
</PropertyGroup>

<ItemGroup>
Expand Down
2 changes: 1 addition & 1 deletion test/Linq.UnitTests/Linq.UnitTests.csproj
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>netcoreapp2.1</TargetFramework>
<TargetFramework>netcoreapp3.1</TargetFramework>
</PropertyGroup>

<ItemGroup>
Expand Down
2 changes: 1 addition & 1 deletion test/Rx.UnitTests/Rx.UnitTests.csproj
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>netcoreapp2.1</TargetFramework>
<TargetFramework>netcoreapp3.1</TargetFramework>
</PropertyGroup>

<ItemGroup>
Expand Down
20 changes: 19 additions & 1 deletion test/UnitTests/EqualityCompare_EquateSequence.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
using System.Linq;
using Nito.Comparers;
using Xunit;
using System.Globalization;

namespace UnitTests
{
Expand Down Expand Up @@ -174,6 +173,20 @@ public void SequencesAreEqualIfElementsAreEqual()
Assert.Equal(EqualityComparerBuilder.For<int>().Default().EquateSequence().GetHashCode(new[] { 3, 4 }), EqualityComparerBuilder.For<int>().Default().EquateSequence().GetHashCode(new[] { 3, 4 }));
}

[Fact]
public void SequencesAreEqualIfEmpty()
{
Assert.True(EqualityComparerBuilder.For<int>().Default().EquateSequence().Equals(Array.Empty<int>(), Array.Empty<int>()));
Assert.Equal(EqualityComparerBuilder.For<int>().Default().EquateSequence().GetHashCode(Array.Empty<int>()), EqualityComparerBuilder.For<int>().Default().EquateSequence().GetHashCode(Array.Empty<int>()));
}

[Fact]
public void EnumerablesAreEqualIfEmpty()
{
Assert.True(EqualityComparerBuilder.For<int>().Default().EquateSequence().Equals(E_Empty(), E_Empty()));
Assert.Equal(EqualityComparerBuilder.For<int>().Default().EquateSequence().GetHashCode(E_Empty()), EqualityComparerBuilder.For<int>().Default().EquateSequence().GetHashCode(E_Empty()));
}

[Fact]
public void EnumerablesAreEqualIfElementsAreEqual()
{
Expand Down Expand Up @@ -211,6 +224,11 @@ public void NullIsNotEqualToEmpty()
Assert.False(EqualityComparerBuilder.For<int>().Default().EquateSequence().Equals(Enumerable.Empty<int>(), null));
}

private static IEnumerable<int> E_Empty()
{
yield break;
}

private static IEnumerable<int> E_3_4()
{
yield return 3;
Expand Down
Loading

0 comments on commit b8e6535

Please sign in to comment.