From 3b85419da3255441f647c0310b68d26059cd273f Mon Sep 17 00:00:00 2001 From: Oliver Booth Date: Sun, 26 Mar 2023 17:03:40 +0100 Subject: [PATCH] Add MinMax and MinMaxBy (resolves #72) --- CHANGELOG.md | 1 + X10D.Tests/src/Linq/EnumerableTests.cs | 159 ++++++++++ X10D/src/Linq/EnumerableExtensions.cs | 399 +++++++++++++++++++++++++ 3 files changed, 559 insertions(+) create mode 100644 X10D.Tests/src/Linq/EnumerableTests.cs create mode 100644 X10D/src/Linq/EnumerableExtensions.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index ca38525..d5e715d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - X10D: Added `IEnumerable.FirstWhereNotOrDefault(Func)`. - X10D: Added `IEnumerable.LastWhereNot(Func)`. - X10D: Added `IEnumerable.LastWhereNotOrDefault(Func)`. +- X10D: Added `IEnumerable.MinMax()` and `IEnumerable.MinMaxBy()`. - X10D: Added `IEnumerable.WhereNot(Func)`. - X10D: Added `IEnumerable.WhereNotNull()`. - X10D: Added `IList.RemoveRange(Range)`. diff --git a/X10D.Tests/src/Linq/EnumerableTests.cs b/X10D.Tests/src/Linq/EnumerableTests.cs new file mode 100644 index 0000000..2e3dc8c --- /dev/null +++ b/X10D.Tests/src/Linq/EnumerableTests.cs @@ -0,0 +1,159 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using X10D.Linq; + +namespace X10D.Tests.Linq; + +[TestClass] +public class EnumerableTests +{ + [TestMethod] + public void MinMax_ShouldReturnCorrectValues_UsingDefaultComparer() + { + IEnumerable source = Enumerable.Range(1, 10); + (int minimum, int maximum) = source.MinMax(); + Assert.AreEqual(1, minimum); + Assert.AreEqual(10, maximum); + } + + [TestMethod] + public void MinMax_ShouldReturnCorrectSelectedValues_UsingDefaultComparer() + { + IEnumerable source = Enumerable.Range(1, 10).Select(i => new Person {Age = i}); + (int minimum, int maximum) = source.MinMax(p => p.Age); + Assert.AreEqual(1, minimum); + Assert.AreEqual(10, maximum); + } + + [TestMethod] + public void MinMax_ShouldReturnOppositeSelectedValues_UsingInverseComparer() + { + IEnumerable source = Enumerable.Range(1, 10).Select(i => new Person {Age = i}); + (int minimum, int maximum) = source.MinMax(p => p.Age, new InverseComparer()); + Assert.AreEqual(10, minimum); + Assert.AreEqual(1, maximum); + } + + [TestMethod] + public void MinMax_ShouldReturnOppositeValues_UsingInverseComparer() + { + IEnumerable source = Enumerable.Range(1, 10); + (int minimum, int maximum) = source.MinMax(new InverseComparer()); + Assert.AreEqual(10, minimum); + Assert.AreEqual(1, maximum); + } + + [TestMethod] + public void MinMax_ShouldThrowArgumentNullException_GivenNullSelector() + { + IEnumerable source = Enumerable.Range(1, 10); + Assert.ThrowsException(() => source.MinMax((Func?)null!)); + } + + [TestMethod] + public void MinMax_ShouldThrowArgumentNullException_GivenNullSource() + { + IEnumerable? source = null; + Assert.ThrowsException(() => source!.MinMax()); + } + + [TestMethod] + public void MinMax_ShouldThrowInvalidOperationException_GivenEmptySource() + { + IEnumerable source = ArraySegment.Empty; + Assert.ThrowsException(() => source.MinMax()); + } + + [TestMethod] + public void MinMaxBy_ShouldReturnCorrectSelectedValues_UsingDefaultComparer() + { + IEnumerable source = Enumerable.Range(1, 10).Select(i => new Person {Age = i}); + (Person minimum, Person maximum) = source.MinMaxBy(p => p.Age); + Assert.AreEqual(1, minimum.Age); + Assert.AreEqual(10, maximum.Age); + } + + [TestMethod] + public void MinMaxBy_ShouldReturnOppositeSelectedValues_UsingInverseComparer() + { + IEnumerable source = Enumerable.Range(1, 10).Select(i => new Person {Age = i}); + (Person minimum, Person maximum) = source.MinMaxBy(p => p.Age, new InverseComparer()); + Assert.AreEqual(10, minimum.Age); + Assert.AreEqual(1, maximum.Age); + } + + [TestMethod] + public void MinMaxBy_ShouldThrowArgumentNullException_GivenNullSelector() + { + IEnumerable source = Enumerable.Range(1, 10).Select(i => new Person {Age = i}); + Assert.ThrowsException(() => source.MinMaxBy((Func?)null!)); + } + + [TestMethod] + public void MinMaxBy_ShouldThrowArgumentNullException_GivenNullSource() + { + IEnumerable? source = null; + Assert.ThrowsException(() => source!.MinMaxBy(p => p.Age)); + } + + [TestMethod] + public void MinMaxBy_ShouldThrowInvalidOperationException_GivenEmptySource() + { + IEnumerable source = ArraySegment.Empty; + Assert.ThrowsException(() => source.MinMaxBy(p => p.Age)); + } + + private struct InverseComparer : IComparer where T : IComparable + { + public int Compare(T? x, T? y) + { + if (x is null) + { + return y is null ? 0 : 1; + } + + return y is null ? -1 : y.CompareTo(x); + } + } + + private struct Person : IComparable, IComparable + { + public int Age { get; set; } + + public static bool operator <(Person left, Person right) + { + return left.CompareTo(right) < 0; + } + + public static bool operator >(Person left, Person right) + { + return left.CompareTo(right) > 0; + } + + public static bool operator <=(Person left, Person right) + { + return left.CompareTo(right) <= 0; + } + + public static bool operator >=(Person left, Person right) + { + return left.CompareTo(right) >= 0; + } + + public int CompareTo(Person other) + { + return Age.CompareTo(other.Age); + } + + public int CompareTo(object? obj) + { + if (ReferenceEquals(null, obj)) + { + return 1; + } + + return obj is Person other + ? CompareTo(other) + : throw new ArgumentException($"Object must be of type {nameof(Person)}"); + } + } +} diff --git a/X10D/src/Linq/EnumerableExtensions.cs b/X10D/src/Linq/EnumerableExtensions.cs new file mode 100644 index 0000000..a7bf24c --- /dev/null +++ b/X10D/src/Linq/EnumerableExtensions.cs @@ -0,0 +1,399 @@ +using System.Runtime.InteropServices; + +namespace X10D.Linq; + +/// +/// LINQ-inspired extension methods for . +/// +public static class EnumerableExtensions +{ + /// + /// Returns the minimum and maximum values in a sequence of values. + /// + /// A sequence of values to determine the minimum and maximum values of. + /// The type of the elements in . + /// A tuple containing the minimum and maximum values in . + /// is . + /// contains no elements. + public static (T? Minimum, T? Maximum) MinMax(this IEnumerable source) + { +#if NET6_0_OR_GREATER + ArgumentNullException.ThrowIfNull(source); +#else + if (source is null) + { + throw new ArgumentNullException(nameof(source)); + } +#endif + + return MinMax(source, Comparer.Default); + } + + /// + /// Returns the minimum and maximum values in a sequence of values, using a specified comparer. + /// + /// A sequence of values to determine the minimum and maximum values of. + /// The comparer which shall be used to compare each element in the sequence. + /// The type of the elements in . + /// A tuple containing the minimum and maximum values in . + /// is . + /// contains no elements. + public static (T? Minimum, T? Maximum) MinMax(this IEnumerable source, IComparer? comparer) + { +#if NET6_0_OR_GREATER + ArgumentNullException.ThrowIfNull(source); +#else + if (source is null) + { + throw new ArgumentNullException(nameof(source)); + } +#endif + + comparer ??= Comparer.Default; + T? minValue; + T? maxValue; + + // ReSharper disable once PossibleMultipleEnumeration + if (source.TryGetSpan(out ReadOnlySpan span)) + { + if (span.IsEmpty) + { + throw new InvalidOperationException("Source contains no elements"); + } + + minValue = span[0]; + maxValue = minValue; + + for (var index = 1; (uint)index < (uint)span.Length; index++) + { + T current = span[index]; + + if (comparer.Compare(current, minValue) < 0) + { + minValue = current; + } + + if (comparer.Compare(current, maxValue) > 0) + { + maxValue = current; + } + } + + return (minValue, maxValue); + } + + // ReSharper disable once PossibleMultipleEnumeration + using (IEnumerator enumerator = source.GetEnumerator()) + { + if (!enumerator.MoveNext()) + { + throw new InvalidOperationException("Source contains no elements"); + } + + minValue = enumerator.Current; + maxValue = minValue; + + while (enumerator.MoveNext()) + { + T current = enumerator.Current; + + if (minValue is null || comparer.Compare(current, minValue) < 0) + { + minValue = current; + } + + if (maxValue is null || comparer.Compare(current, maxValue) > 0) + { + maxValue = current; + } + } + } + + return (minValue, maxValue); + } + + /// + /// Invokes a transform function on each element of a sequence of elements and returns the minimum and maximum values. + /// + /// A sequence of values to determine the minimum and maximum values of. + /// A transform function to apply to each element. + /// The type of the elements in . + /// The type of the elements to compare. + /// A tuple containing the minimum and maximum values in . + /// is . + /// contains no elements. + public static (TResult? Minimum, TResult? Maximum) MinMax(this IEnumerable source, + Func selector) + { +#if NET6_0_OR_GREATER + ArgumentNullException.ThrowIfNull(source); + ArgumentNullException.ThrowIfNull(selector); +#else + if (source is null) + { + throw new ArgumentNullException(nameof(source)); + } + + if (selector is null) + { + throw new ArgumentNullException(nameof(selector)); + } +#endif + + return MinMax(source, selector, Comparer.Default); + } + + /// + /// Invokes a transform function on each element of a sequence of elements and returns the minimum and maximum values, + /// using a specified comparer. + /// + /// A sequence of values to determine the minimum and maximum values of. + /// A transform function to apply to each element. + /// The comparer which shall be used to compare each element in the sequence. + /// The type of the elements in . + /// The type of the elements to compare. + /// A tuple containing the minimum and maximum values in . + /// is . + /// contains no elements. + public static (TResult? Minimum, TResult? Maximum) MinMax(this IEnumerable source, + Func selector, + IComparer? comparer) + { +#if NET6_0_OR_GREATER + ArgumentNullException.ThrowIfNull(source); + ArgumentNullException.ThrowIfNull(selector); +#else + if (source is null) + { + throw new ArgumentNullException(nameof(source)); + } + + if (selector is null) + { + throw new ArgumentNullException(nameof(selector)); + } +#endif + + comparer ??= Comparer.Default; + TResult? minValue; + TResult? maxValue; + + // ReSharper disable once PossibleMultipleEnumeration + if (source.TryGetSpan(out ReadOnlySpan span)) + { + if (span.IsEmpty) + { + throw new InvalidOperationException("Source contains no elements"); + } + + minValue = selector(span[0]); + maxValue = minValue; + + for (var index = 1; (uint)index < (uint)span.Length; index++) + { + TResult current = selector(span[index]); + + if (minValue is null || comparer.Compare(current, minValue) < 0) + { + minValue = current; + } + + if (maxValue is null || comparer.Compare(current, maxValue) > 0) + { + maxValue = current; + } + } + + return (minValue, maxValue); + } + + // ReSharper disable once PossibleMultipleEnumeration + using (IEnumerator enumerator = source.GetEnumerator()) + { + if (!enumerator.MoveNext()) + { + throw new InvalidOperationException("Source contains no elements"); + } + + minValue = selector(enumerator.Current); + maxValue = minValue; + + while (enumerator.MoveNext()) + { + TResult current = selector(enumerator.Current); + + if (minValue is null || comparer.Compare(current, minValue) < 0) + { + minValue = current; + } + + if (maxValue is null || comparer.Compare(current, maxValue) > 0) + { + maxValue = current; + } + } + } + + return (minValue, maxValue); + } + + /// + /// Returns the minimum and maximum values in a sequence according to a specified key selector function. + /// + /// A sequence of values to determine the minimum and maximum values of. + /// A function to extract the key for each element. + /// The type of the elements in . + /// The type of the elements to compare. + /// A tuple containing the minimum and maximum values in . + /// is . + /// contains no elements. + public static (TSource? Minimum, TSource? Maximum) MinMaxBy(this IEnumerable source, + Func keySelector) + { +#if NET6_0_OR_GREATER + ArgumentNullException.ThrowIfNull(source); + ArgumentNullException.ThrowIfNull(keySelector); +#else + if (source is null) + { + throw new ArgumentNullException(nameof(source)); + } + + if (keySelector is null) + { + throw new ArgumentNullException(nameof(keySelector)); + } +#endif + + return MinMaxBy(source, keySelector, Comparer.Default); + } + + /// + /// Returns the minimum and maximum values in a sequence according to a specified key selector function. + /// + /// A sequence of values to determine the minimum and maximum values of. + /// A function to extract the key for each element. + /// The comparer which shall be used to compare each element in the sequence. + /// The type of the elements in . + /// The type of the elements to compare. + /// A tuple containing the minimum and maximum values in . + /// is . + /// contains no elements. + public static (TSource? Minimum, TSource? Maximum) MinMaxBy(this IEnumerable source, + Func keySelector, + IComparer? comparer) + { +#if NET6_0_OR_GREATER + ArgumentNullException.ThrowIfNull(source); + ArgumentNullException.ThrowIfNull(keySelector); +#else + if (source is null) + { + throw new ArgumentNullException(nameof(source)); + } + + if (keySelector is null) + { + throw new ArgumentNullException(nameof(keySelector)); + } +#endif + + comparer ??= Comparer.Default; + TSource? minValue; + TSource? maxValue; + + // ReSharper disable once PossibleMultipleEnumeration + if (source.TryGetSpan(out ReadOnlySpan span)) + { + if (span.IsEmpty) + { + throw new InvalidOperationException("Source contains no elements"); + } + + minValue = span[0]; + maxValue = minValue; + + for (var index = 1; (uint)index < (uint)span.Length; index++) + { + TSource current = span[index]; + TResult transformedCurrent = keySelector(current); + + if (minValue is null || comparer.Compare(transformedCurrent, keySelector(minValue)) < 0) + { + minValue = current; + } + + if (maxValue is null || comparer.Compare(transformedCurrent, keySelector(maxValue)) > 0) + { + maxValue = current; + } + } + + return (minValue, maxValue); + } + + // ReSharper disable once PossibleMultipleEnumeration + using (IEnumerator enumerator = source.GetEnumerator()) + { + if (!enumerator.MoveNext()) + { + throw new InvalidOperationException("Source contains no elements"); + } + + minValue = enumerator.Current; + maxValue = minValue; + + while (enumerator.MoveNext()) + { + TSource current = enumerator.Current; + TResult transformedCurrent = keySelector(current); + + if (minValue is null || comparer.Compare(transformedCurrent, keySelector(minValue)) < 0) + { + minValue = current; + } + + if (maxValue is null || comparer.Compare(transformedCurrent, keySelector(maxValue)) > 0) + { + maxValue = current; + } + } + } + + return (minValue, maxValue); + } + + private static bool TryGetSpan(this IEnumerable source, out ReadOnlySpan span) + { +#if NET6_0_OR_GREATER + ArgumentNullException.ThrowIfNull(source); +#else + if (source is null) + { + throw new ArgumentNullException(nameof(source)); + } +#endif + + var result = true; + + switch (source) + { + case TSource[] array: + span = array; + break; + +#if NET5_0_OR_GREATER + case List list: + span = CollectionsMarshal.AsSpan(list); + break; +#endif + + default: + span = default; + result = false; + break; + } + + return result; + } +}