LINQ Extension
I present a LINQ method ArgBy
that returns the item of a sequence that corresponds to the predicate specified. Two additional methods MinBy
, MaxBy
are provided as specific cases. Its specification how to handle null is the same as the existing LINQ methods (see link).
The purpose is to replace a two-pass function:
var max = collection.Max(item => item.Value);
var argMax = collection.First(item => item.Value == max);
by a single-pass function:
var argMax = collection.MaxBy(x => x.Value);
Questions
- Is this method usable in practice?
- Is this correct LINQ style?
- Am I compliant to the existing LINQ specification of equivalent methods?
- Are there edge cases or optimisations that require more thought?
Source Code
ArgBy
iterates the source sequence and tests each item against a predicate. Any match on the predicate overwrites the referenence to test against for remaining items. The last match is returned. As with the existing LINQ implementation (see reference source Min
for instance) there is slightly different behavior for value types vs null-asssignable types. There is additional complexity over existing methods, since we include a key selector, and a key itself could also be either a value type or null-assignable.
public static class LinqExtension
{
public static TSource ArgBy<TSource, TKey>(
this IEnumerable<TSource> source,
Func<TSource, TKey> keySelector,
Func<(TKey Current, TKey Previous), bool> predicate)
{
if (source == null) throw new ArgumentNullException(nameof(source));
if (keySelector == null) throw new ArgumentNullException(nameof(keySelector));
if (predicate == null) throw new ArgumentNullException(nameof(predicate));
var value = default(TSource);
var key = default(TKey);
if (value == null)
{
foreach (var other in source)
{
if (other == null) continue;
var otherKey = keySelector(other);
if (otherKey == null) continue;
if (value == null || predicate((otherKey, key)))
{
value = other;
key = otherKey;
}
}
return value;
}
else
{
bool hasValue = false;
foreach (var other in source)
{
var otherKey = keySelector(other);
if (otherKey == null) continue;
if (hasValue)
{
if (predicate((otherKey, key)))
{
value = other;
key = otherKey;
}
}
else
{
value = other;
key = otherKey;
hasValue = true;
}
}
if (hasValue) return value;
throw new InvalidOperationException("Sequence contains no elements");
}
}
}
Two additional methods are provided to get the item with respectively minimum or maximum value given a key selector.
public static TSource MinBy<TSource, TKey>(
this IEnumerable<TSource> source,
Func<TSource, TKey> keySelector,
IComparer<TKey> comparer = null)
{
if (comparer == null) comparer = Comparer<TKey>.Default;
return source.ArgBy(keySelector, lag => comparer.Compare(lag.Current, lag.Previous) < 0);
}
public static TSource MaxBy<TSource, TKey>(
this IEnumerable<TSource> source,
Func<TSource, TKey> keySelector,
IComparer<TKey> comparer = null)
{
if (comparer == null) comparer = Comparer<TKey>.Default;
return source.ArgBy(keySelector, lag => comparer.Compare(lag.Current, lag.Previous) > 0);
}
Unit Tests
Let's create both a struct and class.
struct Tag
{
public readonly ValueProvider ValueProvider;
public readonly string Description;
public Tag(ValueProvider valueProvider, string description)
=> (ValueProvider, Description) = (valueProvider, description);
}
class ValueProvider : IComparable<ValueProvider>
{
public readonly int Value;
public ValueProvider(int value) => Value = value;
public int CompareTo(ValueProvider other)
{
return Value.CompareTo(other.Value);
}
}
And perform some basic tests:
[TestMethod]
public void MinByClass()
{
var sequence = new[]
{
null,
new ValueProvider(1),
new ValueProvider(0),
null,
new ValueProvider(2),
};
var min = sequence.MinBy(x => x.Value);
var max = sequence.MaxBy(x => x.Value);
Assert.AreEqual(0, min.Value);
Assert.AreEqual(2, max.Value);
}
[TestMethod]
public void MinByStruct()
{
var sequence = new[]
{
new Tag(new ValueProvider(1), "B"),
new Tag(new ValueProvider(0), "A"),
new Tag(null, "D"),
new Tag(new ValueProvider(2), "C"),
};
var min = sequence.MinBy(x => x.ValueProvider);
var max = sequence.MaxBy(x => x.ValueProvider);
Assert.AreEqual("A", min.Description);
Assert.AreEqual("C", max.Description);
}
Min
andMinBy
orMax
andMaxBy
? Currently they are the same and I'm having a hard time wrapping my head around it. \$\endgroup\$ArgumentException: At least one object must implement IComparable.
mhmm. I was thinkig of a test where you do both operations likesource.Min(x => x); // <-- 2
andsource.MinBy(x => x); // <-- 4
or I completely miss the point of these extensions hehe. \$\endgroup\$var value = default(TSource);
:P and I forgive you that onebool hasValue = false;
\$\endgroup\$