BLUF: Define an extension function .ThenByLazy() and supporting helper classes that make use of the System.Lazy<T> class. This will defer property access to only when needed, while caching the result to prevent repeated accesses.
(Note: The extension functions below owe partial credit to Ivan Petrov's earlier answer. I have combined his ideas with mine in this answer.)
I will assume that:
- You wish to minimize accesses to the
Name property, because it is a relatively expensive property to evaluate in your real-world case.
- When that property is accessed during the sort, it should be accessed no more than once per item, and then only for items where
Age is not unique.
You can accomplish this by using the Lazy class to defer accessing the property until its value is actually needed. The Lazy class will also cache the property result once it is accessed, preventing repeated property getter references.
Because the default comparator for the Lazy class compares the object references instead of the actual values, you will need to either define a custom comparator or wrap Lazy up in a subclass that defines its own value comparator. In both cases, a .ThenByLazy() extension function can be written to simplify the call.
For the first option, you can define the following custom comparator class:
public class LazyValueComparator<TKey>: Comparer<Lazy<TKey>>
where TKey : IComparable
{
public override int Compare(Lazy<TKey> x, Lazy<TKey> y)
{
return x.Value.CompareTo(y.Value);
}
}
With a matching extension function:
public static class Extensions {
public static IOrderedEnumerable<TSource> ThenByLazy<TSource, TKey>
(this IOrderedEnumerable<TSource> source, Func<TSource, TKey> keySelector)
where TKey : IComparable
{
return source.ThenBy(
x => new Lazy<TKey>(() => keySelector(x)),
new LazyValueComparator<TKey>());
}
}
The second option is to define a custom Lazy subclass that has a .CompateTo() override:
public class LazyValueComparable<TKey>
: Lazy<TKey>, IComparable<LazyValueComparable<TKey>> where TKey : IComparable
{
public LazyValueComparable(Func<TKey> keySelector) : base(keySelector) {}
public int CompareTo(LazyValueComparable<TKey> other)
{
return this.Value.CompareTo(other.Value);
}
}
With a matching extension function:
public static class Extensions {
public static IOrderedEnumerable<TSource> ThenByLazy<TSource, TKey>
(this IOrderedEnumerable<TSource> source, Func<TSource, TKey> keySelector)
where TKey : IComparable
{
return source.ThenBy(x => new LazyValueComparable<TKey>(() => keySelector(x)));
}
}
In both cases, you can then update your code to:
var sorted = data
.OrderBy(x => Tracer("First criterion", x.Age))
.ThenByLazy(x => Tracer("Second criterion", x.Name));
If needed, a matching .ThenByLazyDescending() extension can also be easily added. Corresponding .OrderByLazy() and .OrderByLazyDescending() are also possible, but I envision no particular need for them, as there is little to be saved at the top level of the sort.
See this .NET Fiddle and this .NET Fiddle for demos of the first and second options (with added test data).
Both versions produce same output. Note that the Name property is never referenced more than once for a given item and is not referenced at all for items having unique Age values.
Computing First criterion key: 30
Computing First criterion key: 25
Computing First criterion key: 25
Computing First criterion key: 93
Computing First criterion key: 93
Computing First criterion key: 93
Computing First criterion key: 93
Computing First criterion key: 89
Computing First criterion key: 89
Computing First criterion key: 89
Computing First criterion key: 89
Computing First criterion key: 89
Computing First criterion key: 89
Computing First criterion key: 89
Computing First criterion key: 1
Computing First criterion key: 2
Computing First criterion key: 3
Computing First criterion key: 3
Computing Second criterion key: Happy
Computing Second criterion key: Doc
Computing Second criterion key: Sleepy
Computing Second criterion key: Bashful
Computing Second criterion key: Sneezy
Computing Second criterion key: Dopey
Computing Second criterion key: Curley
Computing Second criterion key: Moe
Computing Second criterion key: Larry
Computing Second criterion key: Shemp
Computing Second criterion key: Grumpy
Computing Second criterion key: Bob
Computing Second criterion key: Charlie
Computing Second criterion key: Three-A
Computing Second criterion key: Three-B
One - 1
Two - 2
Three-A - 3
Three-B - 3
Bob - 25
Charlie - 25
Alice - 30
Bashful - 89
Doc - 89
Dopey - 89
Grumpy - 89
Happy - 89
Sleepy - 89
Sneezy - 89
Curley - 93
Larry - 93
Moe - 93
Shemp - 93
After you've confirmed the desired behavior, you can remove your Tracer() function references, yielding the following final code:
var sorted = data
.OrderBy(x => x.Age)
.ThenByLazy(x => x.Name);
Note that using the Lazy class and other techniques above have their own costs, including the creation of closures for the reference functions () => x.Name for each item. There may be some better ways that explicitly pass item references and property getter functions to a wrapper classes, but I have pursued those options at this time.
Addendum:
As Theodor Zoulias mentioned in the comments, the Lazy class may have more overhead baggage that we would like. As a simpler and presumably more efficient alternative, the following code implements an simplified lazy class that accomplished what we want with less overhead.
public class SimpleLazyComparable<T> : IComparable where T : IComparable
{
Func<T> _factory;
bool isInitialized = false;
T _value;
public SimpleLazyComparable(Func<T> factory)
{
_factory = factory;
}
T Value => isInitialized ? _value : CreateValue();
T CreateValue()
{
_value = _factory();
isInitialized = true;
return _value;
}
public int CompareTo(object? other)
{
// For its intended use, the types should always be the same
var otherLazy = other as SimpleLazyComparable<T>;
return this.Value.CompareTo(otherLazy.Value);
}
}
The .ThenByLazy() extension function would then be:
public static class Extensions {
public static IOrderedEnumerable<TSource> ThenByLazy<TSource, TKey>
(this IOrderedEnumerable<TSource> source, Func<TSource, TKey> keySelector)
where TKey : IComparable
{
return source.ThenBy(x => new SimpleLazyComparable<TKey>(() => keySelector(x)));
}
}
See this fiddle for a demo. The results are the same.
OrderByto do? That method only sorts the elements and doesn't do any filtering, therefore the collection will naturally be the same size after theOrderByas before. So what is the desired behavior that you're trying to achieve?Traceris called onmap, not onordering: havingitemwe want to obtain criterium to sort by, say, getAgeand this must be done for each item and only then ordering comes. dotnetfiddle.net/lxUUXm