TotW #2: Sorting with Custom Comparators Done Right — When Your Comparator Breaks std::sort
Suppose we have a vector of Person
1 | struct Person { |
and we want to sort it in the order of the age field, it is very easy and intuitive to use std::sort with a custom comparator:
1 | std::sort(people.begin(), people.end(), |
However, if now we need to sort in the combination of <age, name>, i.e. sort in the order of name if the age is same, how should we implement our comparator?
Before we get into the answer, let us revisit what a comparator must meet for std::sort().
Strict Weak Ordering
The standard requires a compliant custom comparator must establish strict weak ordering.
For a comparator:
1 | bool comp(constT& a, constT& b); |
comp(a, b) means:
ashould appear beforeb.
The usual example is:
1 | return a < b; |
The strict weak ordering must satisfy following 4 properties:
1. irreflexive
Nothing may come before itself:
1 | comp(a,a) == false |
This is why this comparator is wrong:
1 | return a <= b; |
because for equal values, a <= a is true.
2. asymmetric
If a comes before b, then b cannot come before a:
1 | if (comp(a, b)) { |
For ordinary <:
1 | a < b |
and:
1 | b < a |
cannot both be true.
3. transitive
If a comes before b, and b comes before c, then a must come before c:
1 | if (comp(a, b) && comp(b, c)) { |
For example:
1 | 1 < 2 |
A cyclic comparator violates this:
1 | rock < paper |
There is no consistent sorted order.
4. Equivalence must be transitive
Under a comparator, two values are considered equivalent when neither comes before the other:
1 | equivalent(a, b) => |
This does not necessarily mean a == b.
For example, when sorting people only by age:
1 | return lhs.age < rhs.age; |
these people are equivalent for sorting purposes:
1 | Person{"Alice", 30} |
Even though they are different objects, neither is ordered before the other.
That equivalence relation must also be transitive:
1 | a equivalent to b |
This fourth rule is the less obvious part of “weak” ordering.
Common Invalid Comparators
Using <=
1 | return lhs.key <= rhs.key; |
Invalid because:
1 | comp(x, x) == true |
Returning “not equal”
1 | return lhs.key != rhs.key; |
For different values, both directions are true:
1 | comp(a, b) == true |
Comparator depending on changing external state
1 | bool reverse = false; |
The same pair may produce different answers at different times, so the algorithm cannot construct a coherent order.
Compare floating-point numbers with plain comparison
1 | [](double lhs, double rhs) { |
WebRTC also Did it Wrong
The WebRTC was using a comparator below https://chromium.googlesource.com/external/webrtc/%2B/branch-heads/m73/rtc_base/network.cc
1 | bool CompareNetworks(const Network* a, const Network* b) { |
and this comparator is unfortunate invalid.
The problem is that it does not define a consistent lexicographical ordering. In particular, it violates the transitivity of comparator equivalence
A concrete counterexample
Consider three networks with the same name:
1 | A = { name: "eth0", prefix_length: 24, prefix: 10.0.0.0 } |
we have got
1 | A is equivalent to B |
Do it Again, in a Right Way, in Most Cases
Back to our question in the first place, lets implement the comparator in a right way
1 | std::sort(people.begin(), people.end(), |
So, how should we fix the webrtc’s bug?
1 | bool CompareNetworks(const Network* a, const Network* b) { |
But…it is still error-prone and hard to get it right when the number of fields is scaled.
Using std::tie()
1 | std::sort(people.begin(), people.end(), |
std::tie() creates a std::tuple with each member being a reference to the corresponding argument, and a tuple is well-defined for comparison.
Similarly, we can use std::tie() for the WebRTC issue
1 | bool CompareNetworks(const Network* a, const Network* b) { |
Using operator<=> since C++ 20
Noted: we swapped the order of the age and name in order to use compiler synthesized version.
1 | struct Person { |
We will elaborate on the spaceship operator in future, after we upgrade our toolchain to gain the support of C++20/23.
Sorting Floating Points
Hand-written with NaN Comes at the Last
1 | struct FloatLess { |
1 | -inf < finite values < +inf < NaN |
Using C++20
1 | struct FloatLess { |
References
- CppCon 2023 | A Long Journey of Changing std::sort Implementation at Scale - Danila Kutenin