Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Natural string comparer #38

Open
wants to merge 19 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
7 changes: 4 additions & 3 deletions .github/workflows/build.yml
@@ -1,4 +1,5 @@
# build.yml v1.4
# build.yml v1.5
# 1.5 - Use prerelease ProjProps.
# 1.4 - Avoid set-env.
# 1.3 - Include tag workflow in this file.
# 1.2 - Define DOTNET_SKIP_FIRST_TIME_EXPERIENCE/NUGET_XMLDOC_MODE.
Expand Down Expand Up @@ -52,8 +53,8 @@ jobs:

- name: Get current version
run: |
dotnet tool install --global Nito.ProjProps
echo "NEWTAG=v$(projprops --name version --output-format SingleValueOnly --project src --project-search)" >> $GITHUB_ENV
dotnet tool install --global Nito.ProjProps --version 2.0.0-pre01
echo "NEWTAG=v$(projprops --name version --project src)" >> $GITHUB_ENV

- name: Build
run: |
Expand Down
7 changes: 7 additions & 0 deletions CHANGELOG.md
@@ -1,6 +1,13 @@
# Changelog
This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [6.3.0] -
### Added
- More nullable reference type support.
- Natural string comparers that compare numeric substrings as numeric values.
- Note: `GetHashCode` for natural string comparers will allocate memory on all platforms older than .NET Core 3.0, including all versions of .NET Framework.
- Note: `GetHashCode` will cause extra collisions when used with invariant cultures on platforms that only support .NET Standard 1.x (except .NET Framework). This means Xamarin.Android 7.1, Xamarin.iOS 10.8, and Xamarin.Mac 3.0 will have pathologically inefficient `GetHashCode` implementations for natural string comparers using an invariant culture. Xamarin.Android 8.0+, Xamarin.iOS 10.14+, and Xamarin.Mac 3.8+ will work properly.

## [6.2.2] - 2021-09-25
### Changed
- Bump Rx and Ix dependencies.
Expand Down
103 changes: 103 additions & 0 deletions future/StringSpanComparer.cs
@@ -0,0 +1,103 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.Text;

namespace Nito.Comparers.Util
{
/// <summary>
/// A type that can compare strings as well as read-only spans of characters.
/// </summary>
public sealed class StringSpanComparer : IFullComparer<string>
{
private readonly CompareInfo _compareInfo;
private readonly CompareOptions _options;

/// <summary>
/// Creates a new instance using the specified compare info and options.
/// </summary>
public StringSpanComparer(CompareInfo compareInfo, CompareOptions options)
{
_compareInfo = compareInfo;
_options = options;
}

/// <summary>
/// Creates a new instance using the specified string comparison.
/// </summary>
public StringSpanComparer(StringComparison comparison)
: this(GetCompareInfo(comparison), GetCompareOptions(comparison))
{
}

private static CompareInfo GetCompareInfo(StringComparison comparison)
{
return comparison switch
{
StringComparison.CurrentCulture => CultureInfo.CurrentCulture.CompareInfo,
StringComparison.CurrentCultureIgnoreCase => CultureInfo.CurrentCulture.CompareInfo,
(StringComparison)2 /* InvariantCulture */ => CultureInfo.InvariantCulture.CompareInfo,
(StringComparison)3 /* InvariantCultureIgnoreCase */ => CultureInfo.InvariantCulture.CompareInfo,
StringComparison.Ordinal => CultureInfo.InvariantCulture.CompareInfo,
StringComparison.OrdinalIgnoreCase => CultureInfo.InvariantCulture.CompareInfo,
_ => throw new ArgumentException($"The string comparison type {comparison} is not supported.", nameof(comparison)),
};
}

private static CompareOptions GetCompareOptions(StringComparison comparison)
{
return comparison switch
{
StringComparison.CurrentCulture => CompareOptions.None,
StringComparison.CurrentCultureIgnoreCase => CompareOptions.IgnoreCase,
(StringComparison)2 /* InvariantCulture */ => CompareOptions.None,
(StringComparison)3 /* InvariantCultureIgnoreCase */ => CompareOptions.IgnoreCase,
StringComparison.Ordinal => CompareOptions.Ordinal,
StringComparison.OrdinalIgnoreCase => CompareOptions.OrdinalIgnoreCase,
_ => throw new ArgumentException($"The string comparison type {comparison} is not supported.", nameof(comparison)),
};
}

/// <summary>
/// Compares two read-only spans of characters as though they were strings.
/// </summary>
public int Compare(ReadOnlySpan<char> x, ReadOnlySpan<char> y) => _compareInfo.Compare(x, y, _options);

/// <summary>
/// Determines whether two read-only spans of characters are equal, as though they were strings.
/// </summary>
public bool Equals(ReadOnlySpan<char> x, ReadOnlySpan<char> y) => Compare(x, y) == 0;

/// <summary>
/// Gets a hash code for a read-only span of characters, as though it were a string.
/// </summary>
public int GetHashCode(ReadOnlySpan<char> obj) => _compareInfo.GetHashCode(obj, _options);

/// <inheritdoc />
public int Compare(string? x, string? y)
{
throw new NotImplementedException();
}

/// <inheritdoc />
public bool Equals(string? x, string? y) => EqualityComparerHelpers.ImplementEquals(x, y, false, DoEquals!);

private bool DoEquals(string? x, string? y) => Equals(x == null ? default : x.AsSpan(), y == null ? default : y.AsSpan());

/// <inheritdoc />
public int GetHashCode(string? obj) => EqualityComparerHelpers.ImplementGetHashCode(obj, false, DoGetHashCode!);

private int DoGetHashCode(string? obj) => GetHashCode(obj == null ? default : obj.AsSpan());

int IComparer.Compare(object? x, object? y)
{
throw new NotImplementedException();
}

bool IEqualityComparer.Equals(object? x, object? y) => EqualityComparerHelpers.ImplementEquals<string>(x, y, false, DoEquals);

int IEqualityComparer.GetHashCode(object? obj) => EqualityComparerHelpers.ImplementGetHashCode<string>(obj, false, DoGetHashCode);
}
}
2 changes: 1 addition & 1 deletion src/Comparers.Ix/Comparers.Ix.csproj
Expand Up @@ -2,7 +2,7 @@

<PropertyGroup>
<Description>This old package just forwards to Nito.Comparers.Ix.</Description>
<TargetFrameworks>netstandard1.0;netstandard2.0;net461</TargetFrameworks>
<TargetFrameworks>netstandard1.0;netstandard2.0;net45;net461</TargetFrameworks>
<PackageTags>comparer;equalitycomparer;icomparable;iequatable</PackageTags>
<IsMetapackage>true</IsMetapackage>
</PropertyGroup>
Expand Down
2 changes: 1 addition & 1 deletion src/Comparers.Rx/Comparers.Rx.csproj
Expand Up @@ -2,7 +2,7 @@

<PropertyGroup>
<Description>This old package just forwards to Nito.Comparers.Rx.</Description>
<TargetFrameworks>netstandard1.0;netstandard2.0;net461</TargetFrameworks>
<TargetFrameworks>netstandard1.0;netstandard2.0;net45;net461</TargetFrameworks>
<PackageTags>comparer;equalitycomparer;icomparable;iequatable</PackageTags>
<IsMetapackage>true</IsMetapackage>
</PropertyGroup>
Expand Down
4 changes: 2 additions & 2 deletions src/Comparers/Comparers.csproj
@@ -1,8 +1,8 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<Description>This old package just forwards to Nito.Comparers.</Description>
<TargetFrameworks>netstandard1.0;netstandard1.3;netstandard2.0;net461;net472;netcoreapp2.0</TargetFrameworks>
<TargetFrameworks>netstandard1.0;netstandard1.3;netstandard2.0;net45;net461;net472;netcoreapp2.0</TargetFrameworks>
<PackageTags>comparer;equalitycomparer;icomparable;iequatable</PackageTags>
<IsMetapackage>true</IsMetapackage>
</PropertyGroup>
Expand Down
20 changes: 10 additions & 10 deletions src/Nito.Comparers.Core/Advanced/AdvancedComparerBase.cs
Expand Up @@ -42,30 +42,30 @@ protected AdvancedComparerBase(bool specialNullHandling)
/// </summary>
/// <param name="obj">The object for which to return a hash code. May be <c>null</c> if <see cref="SpecialNullHandling"/> is <c>true</c>.</param>
/// <returns>A hash code for the specified object.</returns>
protected abstract int DoGetHashCode(T obj);
protected abstract int DoGetHashCode(T? obj);

/// <summary>
/// Compares two objects and returns a value less than 0 if <paramref name="x"/> is less than <paramref name="y"/>, 0 if <paramref name="x"/> is equal to <paramref name="y"/>, or greater than 0 if <paramref name="x"/> is greater than <paramref name="y"/>.
/// </summary>
/// <param name="x">The first object to compare. May be <c>null</c> if <see cref="SpecialNullHandling"/> is <c>true</c>.</param>
/// <param name="y">The second object to compare. May be <c>null</c> if <see cref="SpecialNullHandling"/> is <c>true</c>.</param>
/// <returns>A value less than 0 if <paramref name="x"/> is less than <paramref name="y"/>, 0 if <paramref name="x"/> is equal to <paramref name="y"/>, or greater than 0 if <paramref name="x"/> is greater than <paramref name="y"/>.</returns>
protected abstract int DoCompare(T x, T y);
protected abstract int DoCompare(T? x, T? y);

/// <inheritdoc />
public int Compare(T x, T y) => ((IComparer<T>)_implementation).Compare(x, y);
public int Compare(T? x, T? y) => ((IComparer<T>)_implementation).Compare(x!, y!);

/// <inheritdoc />
public bool Equals(T x, T y) => ((IEqualityComparer<T>)_implementation).Equals(x, y);
public bool Equals(T? x, T? y) => ((IEqualityComparer<T>)_implementation).Equals(x!, y!);

/// <inheritdoc />
public int GetHashCode(T obj) => ((IEqualityComparer<T>)_implementation).GetHashCode(obj);
public int GetHashCode(T? obj) => ((IEqualityComparer<T>)_implementation).GetHashCode(obj!);

int IComparer.Compare(object x, object y) => ((IComparer)_implementation).Compare(x, y);
int IComparer.Compare(object? x, object? y) => ((IComparer)_implementation).Compare(x, y);

bool IEqualityComparer.Equals(object x, object y) => ((IEqualityComparer)_implementation).Equals(x, y);
bool IEqualityComparer.Equals(object? x, object? y) => ((IEqualityComparer)_implementation).Equals(x, y);

int IEqualityComparer.GetHashCode(object obj) => ((IEqualityComparer)_implementation).GetHashCode(obj);
int IEqualityComparer.GetHashCode(object? obj) => ((IEqualityComparer)_implementation).GetHashCode(obj!);

private sealed class Implementation : ComparerBase<T>
{
Expand All @@ -79,9 +79,9 @@ public Implementation(bool specialNullHandling, AdvancedComparerBase<T> parent)

public bool SpecialNullHandlingValue => SpecialNullHandling;

protected override int DoGetHashCode(T obj) => _parent.DoGetHashCode(obj);
protected override int DoGetHashCode(T? obj) => _parent.DoGetHashCode(obj);

protected override int DoCompare(T x, T y) => _parent.DoCompare(x, y);
protected override int DoCompare(T? x, T? y) => _parent.DoCompare(x, y);
}
}
}
16 changes: 8 additions & 8 deletions src/Nito.Comparers.Core/Advanced/AdvancedEqualityComparerBase.cs
Expand Up @@ -42,25 +42,25 @@ protected AdvancedEqualityComparerBase(bool specialNullHandling)
/// </summary>
/// <param name="obj">The object for which to return a hash code. May be <c>null</c> if <see cref="SpecialNullHandling"/> is <c>true</c>.</param>
/// <returns>A hash code for the specified object.</returns>
protected abstract int DoGetHashCode(T obj);
protected abstract int DoGetHashCode(T? obj);

/// <summary>
/// Compares two objects and returns <c>true</c> if they are equal and <c>false</c> if they are not equal.
/// </summary>
/// <param name="x">The first object to compare. May be <c>null</c> if <see cref="SpecialNullHandling"/> is <c>true</c>.</param>
/// <param name="y">The second object to compare. May be <c>null</c> if <see cref="SpecialNullHandling"/> is <c>true</c>.</param>
/// <returns><c>true</c> if <paramref name="x"/> is equal to <paramref name="y"/>; otherwise, <c>false</c>.</returns>
protected abstract bool DoEquals(T x, T y);
protected abstract bool DoEquals(T? x, T? y);

/// <inheritdoc />
public bool Equals(T x, T y) => ((IEqualityComparer<T>)_implementation).Equals(x, y);
public bool Equals(T? x, T? y) => ((IEqualityComparer<T>)_implementation).Equals(x!, y!);

/// <inheritdoc />
public int GetHashCode(T obj) => ((IEqualityComparer<T>)_implementation).GetHashCode(obj);
public int GetHashCode(T? obj) => ((IEqualityComparer<T>)_implementation).GetHashCode(obj!);

bool IEqualityComparer.Equals(object x, object y) => ((IEqualityComparer)_implementation).Equals(x, y);
bool IEqualityComparer.Equals(object? x, object? y) => ((IEqualityComparer)_implementation).Equals(x, y);

int IEqualityComparer.GetHashCode(object obj) => ((IEqualityComparer)_implementation).GetHashCode(obj);
int IEqualityComparer.GetHashCode(object? obj) => ((IEqualityComparer)_implementation).GetHashCode(obj!);

private sealed class Implementation : EqualityComparerBase<T>
{
Expand All @@ -74,9 +74,9 @@ public Implementation(bool specialNullHandling, AdvancedEqualityComparerBase<T>

public bool SpecialNullHandlingValue => SpecialNullHandling;

protected override int DoGetHashCode(T obj) => _parent.DoGetHashCode(obj);
protected override int DoGetHashCode(T? obj) => _parent.DoGetHashCode(obj);

protected override bool DoEquals(T x, T y) => _parent.DoEquals(x, y);
protected override bool DoEquals(T? x, T? y) => _parent.DoEquals(x, y);
}
}
}
4 changes: 2 additions & 2 deletions src/Nito.Comparers.Core/ComparableBase.cs
Expand Up @@ -36,7 +36,7 @@ public abstract class ComparableBase<T> : IEquatable<T>, IComparable, IComparabl
/// </summary>
/// <param name="other">The object to compare with this instance. May be <c>null</c>.</param>
/// <returns>A value indicating whether this instance is equal to the specified object.</returns>
public bool Equals(T other) => ComparableImplementations.ImplementEquals(DefaultComparer, (T)this, other!);
public bool Equals(T? other) => ComparableImplementations.ImplementEquals(DefaultComparer, (T)this, other!);

/// <summary>
/// Returns a value indicating the relative order of this instance and the specified object: a negative value if this instance is less than the specified object; zero if this instance is equal to the specified object; and a positive value if this instance is greater than the specified object.
Expand All @@ -50,6 +50,6 @@ public abstract class ComparableBase<T> : IEquatable<T>, IComparable, IComparabl
/// </summary>
/// <param name="other">The object to compare with this instance. May be <c>null</c>.</param>
/// <returns>A value indicating the relative order of this instance and the specified object: a negative value if this instance is less than the specified object; zero if this instance is equal to the specified object; and a positive value if this instance is greater than the specified object.</returns>
public int CompareTo(T other) => ComparableImplementations.ImplementCompareTo(DefaultComparer, (T)this, other!);
public int CompareTo(T? other) => ComparableImplementations.ImplementCompareTo(DefaultComparer, (T)this, other!);
}
}
13 changes: 11 additions & 2 deletions src/Nito.Comparers.Core/ComparerBuilderFor.cs
Expand Up @@ -3,6 +3,8 @@
using System.ComponentModel;
using Nito.Comparers.Util;

#pragma warning disable IDE0060

namespace Nito.Comparers
{
/// <summary>
Expand Down Expand Up @@ -35,6 +37,13 @@ public static class ComparerBuilderForExtensions
/// </summary>
public static IFullComparer<T> Default<T>(this ComparerBuilderFor<T> @this) => (IFullComparer<T>)ComparerHelpers.NormalizeDefault<T>(null);

/// <summary>
/// Gets a natural string comparer, which treats numeric sequences (0-9) as numeric.
/// </summary>
/// <param name="this">The comparer builder.</param>
/// <param name="comparison">The comparison type used to compare the text segments of the string (not used for numeric segments).</param>
public static IFullComparer<string> Natural(this ComparerBuilderFor<string> @this, StringComparison comparison) => new NaturalStringComparer(comparison);

/// <summary>
/// Creates a key comparer.
/// </summary>
Expand All @@ -46,7 +55,7 @@ public static class ComparerBuilderForExtensions
/// <param name="specialNullHandling">A value indicating whether <c>null</c> values are passed to <paramref name="selector"/>. If <c>false</c>, then <c>null</c> values are considered less than any non-<c>null</c> values and are not passed to <paramref name="selector"/>. This value is ignored if <typeparamref name="T"/> is a non-nullable type.</param>
/// <param name="descending">A value indicating whether the sorting is done in descending order. If <c>false</c> (the default), then the sort is in ascending order.</param>
/// <returns>A key comparer.</returns>
public static IFullComparer<T> OrderBy<T, TKey>(this ComparerBuilderFor<T> @this, Func<T, TKey> selector, Func<ComparerBuilderFor<TKey>, IComparer<TKey>> comparerFactory, bool specialNullHandling = false, bool descending = false)
public static IFullComparer<T> OrderBy<T, TKey>(this ComparerBuilderFor<T> @this, Func<T?, TKey?> selector, Func<ComparerBuilderFor<TKey>, IComparer<TKey>> comparerFactory, bool specialNullHandling = false, bool descending = false)
{
_ = comparerFactory ?? throw new ArgumentNullException(nameof(comparerFactory));
var comparer = comparerFactory(ComparerBuilder.For<TKey>());
Expand All @@ -64,7 +73,7 @@ public static class ComparerBuilderForExtensions
/// <param name="specialNullHandling">A value indicating whether <c>null</c> values are passed to <paramref name="selector"/>. If <c>false</c>, then <c>null</c> values are considered less than any non-<c>null</c> values and are not passed to <paramref name="selector"/>. This value is ignored if <typeparamref name="T"/> is a non-nullable type.</param>
/// <param name="descending">A value indicating whether the sorting is done in descending order. If <c>false</c> (the default), then the sort is in ascending order.</param>
/// <returns>A key comparer.</returns>
public static IFullComparer<T> OrderBy<T, TKey>(this ComparerBuilderFor<T> @this, Func<T, TKey> selector, IComparer<TKey>? keyComparer = null, bool specialNullHandling = false, bool descending = false)
public static IFullComparer<T> OrderBy<T, TKey>(this ComparerBuilderFor<T> @this, Func<T?, TKey?> selector, IComparer<TKey>? keyComparer = null, bool specialNullHandling = false, bool descending = false)
{
var selectComparer = new SelectComparer<T, TKey>(selector, keyComparer, specialNullHandling);
return descending ? selectComparer.Reverse() : selectComparer;
Expand Down