03 - 3-way comparison

Rewritten candidates

C++20 has made overload resolution smarter for comparison operators with the feature of rewritten candidates - if x != y is not valid, compiler will try !(x == y) and analogically for other comparison operators. Rewritten candidates are considered for both member and non-member overloads.

Thus:

  • The guideline to implement comparison operators as non-members is no longer needed.

  • You no longer need to implement so many operator overloads. The boilerplate work is now done by the compiler.

  • Some compiler errors have been improved.

Do rewritten candidates break existing special usages of operator overloading such as EDSL?

No. They are only considered if no implementation has been explicitly provided for remaining combinations.

Defaulted implementation

Another improvement is that you can now = default all comparison operators if they are member functions. They will compare class members in order of their appearance (identical to std::tie trick from previous lesson).

The spaceship operator

operator<=> represents 3-way comparison. It provides more information so a bool return type is not enough for it.

Before delving into what should be used instead of bool, you should first understand logical concepts that motivated multiple choices for the return type.

Can I call <=> a TIE Fighter?

No. I have been informed that:

  • TIE Fighter is |=|

  • <=> is Darth Vader's TIE Fighter

  • <==> is TIE Bomber

  • (=) is Inquisitor's TIE Advanced Prototype

Theory

Equality

There are 2 categories of equality:

  • strong equality - if x == y, objects are indistinguishable

  • weak equality - even if x == y, objects may differ

Examples:

  • Integers have strong equality. Two integers which compare equal are exactly the same.

  • Absolute value of integers has weak equality. Two integers can have equal absoule value (e.g. 3 and -3) but they are distinguishable.

  • Case-insensitive strings have weak equality. "abc" will compare equal to "ABC", but they are distinguishable.

  • Comparing players by their score has weak equality - 2 players can compare equal due to the same score, but they are 2 different players.

In short, weak equality means that in a given scenario different objects may be given the same ranking (player score, case-insensitive strings) but the objects themselves are distinct (players reached the same score through different means, strings have letters with different case).

Ordering

There are 3 categories of ordering:

  • strong ordering - if both x < y and y < x are false, objects are indistinguishable

  • weak ordering - even if both x < y and y < x are false, objects may differ

  • partial ordering - there is a possibility that for some x and y comparison has no answer

Examples:

  • Integers have strong ordering. If for some integers x and y both x < y and y < x are false, they are indistinguishable.

  • Case-insensitive strings have weak ordering. Even if for some x and y both x < y and y < x are false, the strings may differ.

  • Square roots in real numbers domain: for some real numbers (e.g. -4), there is no square root so for some pairs of numbers the order can not be determined.

  • Partial ordering is present in floating-point numbers: some of them represent special values like NaN and infinity. For practical reasons, comparisons of any floating-point values never invoke undefined behavior but special values break expected consistency - any comparison involving at least 1 special value always returns false (this even includes cases like 2 NaN).

  • Partial ordering is present in overload resolution:

    • const T is more cv-qualified than T.

    • volatile T is more cv-qualified than T.

    • const volatile T is more cv-qualified than const T.

    • const volatile T is more cv-qualified than volatile T.

    • There is no ordering specified between const T and volatile T. If a function has 2 such overloads and is given T, overload resolution will fail due to ambiguity.

How about partial equality?

There is no such thing, at least in C++.

Strong and weak orderings should always satisfy:

  • irreflexivity: x < x is always false

  • asymmetry: if x < y is true then y < x is false

  • transitivity: if x < y and y < z then x < z must be true

  • exactly one of x < y, x > y, x == y must be true for any x and y

Exercise

Which comparison categories are present in the following situations?

  • points by the sum of their X, Y coordinates

  • files by their size

  • files by their paths

  • users by their unique ID

  • people by their birth date

  • people by their ancestry tree

Answers
  • weak ordering (points 3,5 and 4,4 are equal)

  • weak ordering (files with same size can be different)

  • weak equality (symlinks can create multiple paths for the same file), weak ordering if we additionally consider sorting paths as strings

  • strong equality (IDs are unique so 2 identical IDs refer to the same user) or strong ordering (if IDs are treated as numbers and can be sorted)

  • weak ordering (date can be the same for different people)

  • partial ordering (for some pairs of people we can not determine their common ancestor)

Practice

operator<=> can return one of 3 ordering types, defined in <compare>. The types themselves are not enumerations but classes (and values implemented as inline static constexpr members) which allows to specify implicit convertions by writing non-explicit converting constructors.

std::strong_ordering is implicity-convertible to std::weak_ordering which is implicity-convertible to std::partial_ordering.

All values:

  • std::strong_ordering::less

  • std::strong_ordering::equal

  • std::strong_ordering::equivalent

  • std::strong_ordering::greater

  • std::weak_ordering::less

  • std::weak_ordering::equivalent

  • std::weak_ordering::greater

  • std::partial_ordering::less

  • std::partial_ordering::equivalent

  • std::partial_ordering::greater

  • std::partial_ordering::unordered

Equivalent elements are treated the same way for the purpose of comparison but are distinguishable. Equal elements are truly identical (thus this value is only present in std::strong_ordering).

If every member of the class has defined operator<=> and the operator<=> definition is defaulted, you can use auto as a return type and the compiler will pick the most powerful ordering category that is possible. Built-in types are considered to have the operator defined.

You can call operator<=> and its result (one of standard library ordering types) can be compared with one of the terms listed above and also directly with literal 0:

1
2
3
4
5
6
7
const auto cmp = x <=> y; // cmp will be std::*_ordering
if (cmp < 0)
	std::cout << "x < y";
else if (cmp > 0)
	std::cout << "x > y";
else
	std::cout << "x == y";

What's the reason behind special treatment of operator== and operator!=?

Optimization. Take strings as an example. Determining which of "abc" and "abCD" is greater requires a loop that goes through multiple characters (potentially expensive operation if strings are long and many first characters are equal). Determining whether they are equal is instant because equality can start by comparing length and only consider looping through characters if lengths are the same. In other words, for some types 2-way equivalence check can be much faster than 3-way comparison. Thus, if operator<=> is not = default, operator== should not be = default too.

Fraction class

  • Weak ordering is used because two equal fractions can have different representation (e.g. 1/2 vs 2/4).

  • No operator is defaulted because the implementation is more complex than mere memberwise comparison.

  • operator== is defined because the 2-way equivalence check can be more optimal than 3-way comparison.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// (all inside class body)

bool operator==(fraction rhs) const
{
	if (denominator() == rhs.denominator())
		return numerator() == rhs.numerator();

	return numerator() * rhs.denominator() == rhs.numerator() * denominator();
}

std::weak_ordering operator<=>(fraction rhs) const
{
	if (denominator() == rhs.denominator())
	{
		if (denominator() > 0)
			return numerator() <=> rhs.numerator();
		else
			return rhs.numerator() <=> numerator();
	}

	const auto new_lhs_numerator = numerator() * rhs.denominator();
	const auto new_rhs_numerator = rhs.numerator() * denominator();

	if ((denominator() > 0) == (rhs.denominator() > 0))
		return new_lhs_numerator <=> new_rhs_numerator;
	else
		return new_rhs_numerator <=> new_lhs_numerator;
}

Mixed-type comparisons

Thanks to rewritten candidates, the example from previous lesson can be simplified to this code:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class player
{
private:
	int id;
	// lots of other fields... (potentially expensive to construct)

public:
	// [...]

	bool operator==(const player& rhs) const
	{
		return id == rhs.id;
	}

	bool operator==(int other_id) const
	{
		return id == other_id;
	}
};

Lexicographical comparison

Just like in the previous lesson, you can use std::tie trick to avoid writing bug-prone code:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <tuple>

struct package
{
	int rack;
	int shelf;
	int position;
	// [...] some other members that should not be used in comparison

	auto operator<=>(package rhs) const
	{
		return std::tie(rack, shelf, position)
		   <=> std::tie(rhs.rack, rhs.shelf, rhs.position);
	}
};

If there are no extra members and memberwise comparison is desired, the operator can be defined as = default.

Recommendations

Using comparisons

  • Only call operator<=> if you truly need a 3-way comparison answer (for performance reasons). Examples:

    • If you only need to know whether 2 objects are equal, use operator==.

    • If you search only for a minimum element, comparing subsequent elements with the minimum one using operator< is enough.

  • Don't implement operator<=> just because you need comparison for std::map or other container. If the type makes no sense in ordering (e.g. a complex number class) but you need something for a container it's much better to use a function object that only affects the container.

Implementing comparisons

For any class type:

  • If you want just equality, implement only operator==.

    • If you want it to compare all members in their order of appearance, you can = default it.

  • If you want ordering, implement operator<=>.

    • If you want to compare all members in order of their appearance, you can = default it.

    • If you want to optimize equality checks, you can additionally implement operator==.

    • Don't define operator<=> with std::partial_ordering return type - it's very surprising when for some pair of objects all x < y, x == y and x > y can return false. Instead, write a free function named exactly partial_order and use this function (this specific name will also be searched by standard library templates).

Advanced features

The standard library contains niebloids that can be used to compare objects and produce a result of specific comparison category (as long as the comparison is possible).

When writing generic code, it is recommended not to use operator<=> as not every type may have it defined. Instead, it is recommended to use a template like the one presented below that will check for operator<=> existence and fall back to other operators.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
#include <compare>
#include <type_traits>

struct synth_three_way_t
{
	template <typename T, std::totally_ordered_with<T> U>
	constexpr auto operator()(const T& lhs, const U& rhs) const
	{
		if constexpr (std::three_way_comparable_with<T, U>)
		{
			return lhs <=> rhs;
		}
		else
		{
			// Jonathan Müller used strong ordering here
			// I don't think we can assume that for arbitrary T and U
			if (lhs == rhs)
				return std::weak_ordering::equivalent;
			else if (lhs < rhs)
				return std::weak_ordering::less;
			else
				return std::weak_ordering::greater;
		}
	}
};
inline constexpr synth_three_way_t synth_three_way;

template <typename T, typename U = T>
using synth_three_way_category
	= decltype(synth_three_way(std::declval<const T&>(), std::declval<const U&>()));

Trivia

There was an idea to use the name <=> for the header <compare>[citation needed] but it was ultimately resigned from due to concern that = may not be in the set of supported characters for paths on some implementations.


This lesson has been based on: