C++20 three way comparison operator: Part 5

Gajendra Gulgulia
CodeX
Published in
9 min readJul 8, 2021
image ©: gajendra gulgulia

In the fourth part of the tutorial, I introduced the theoretical ideas behind the return types of the three way operator in C++20 and demonstrated that there might be semantic restrictions on the program that allow for comparison of equivalence instead of equality. Also I demonstrated by a simple example when can two objects be semantically incomparable, even though the syntax of program allows to compare them and how to deal with such cases with the help of operator<=>.

In this part of the tutorial, I’ll explain one of the three comparison category in the compare header which is the return type of the three way operator, i.e., std::strong_ordering. More concretely this tutorial will help in understanding the rules for using std::strong_ordering from cpp reference page and will guide developers on when to return std::strong_ordering when defining custom operator<=>.

Recalling the code snippet from fourth part:

MyClass obj1{1, 'a'};
MyClass obj1{2, 'b'};
auto intermediateResult = obj1 <=> obj2;
bool result = intermediateResult < 0;

we want to understand conditions on when the type of intermediateResult is std::strong_ordering and what does it mean to be strongly ordered.

1. std::strong_ordering

The cpp reference page on std::strong_ordering says the following:

(1) admits all six relational operators (==, >=, <=, !=, >, <)

(2) implies substitutability: if a is equivalent to b, f(a) is also equivalent to f(b), where f denotes a function that reads only comparison-salient state that is accessible via the argument’s public const members. In other words, equivalent values are indistinguishable.

(3) does not allow incomparable values: exactly one of a < b, a ==b, or a > b must be true

Lets try to understand the above rules one by one.

1.1 admits all six relational operators …

consider the part of the above code snippet to initialize intermediateResult

auto intermediateResult = obj1 <=> obj2;

If we assume for a moment that intermediateResult is of type std::strong_ordering, it simply means that all six relational operators (==, >=, <=, !=, >, <) can be applied to intermediateResult, but the catch is that it can be compared only with the literal 0 :

bool res1 = intermediateResult == 0
bool res2 = intermediateResult >= 0
bool res3 = intermediateResult <= 0
bool res4 = intermediateResult != 0
bool res5 = intermediateResult < 0
bool res6 = intermediateResult > 0

“admitting relational operators …” is simply a mathematical way of saying that the relational operators can be used with the three way operator.

1.2 implies substitutability: if a is equivalent to b, f(a) is also equivalent to f(b) ...

Here to understand the second rule, I need to explain a few more terms which appear in the second rule:

(1) value of an object:
In C++, the value of an object is the property that can be used to compare the object with another object. For a simple int i = 29 object the value of i is the value it holds in its register, i.e. the number 29 and not the address in memory where the object i is allocated ¹. For a C-style string :

char* str[] = "hello"

the value is both the string hello and the address where the pointer is allocated. Loosely speaking the value of an object depends on the semantics of the program and the objects being used and when two values are equal

(2) salient properties of an object:
A custom object in C++ has class members which can collectively define the value of the object. I say ‘.. can collectively … ’ because all members are not useful for defining the value of the object. Consider for e.g. std::vector which has a pointer to the memory where the array is stored, the member function size() and capacity(), push_back(), empty() and so on. But while comparing two std::vector, only the content of the array and the size is of interest. In other words, two std::vector objects are equal when the contents of the array are equal. Hence these are the salient properties of std::vector.

Any function f which can read the salient properties of the std::vector object and return values based on these salient properties is deemed fit to be a substitutable function and can be used with relational comparison operator (one of ==, >=, <=, !=, >, <). These functions can be either member functions or non member functions. In case of std::vector examples of substitutable functions are size() and , empty() . Examples of functions which doesn’t read the salient properties of a object is capacity() and push_back() and hence they are not substitutable.

To demonstrate these facts, consider the example of two std::vector<T>objects which provides the three way operator since C++20, which can be used to get the equality relation between the two objects:

#include <iostream>
#include <compare>
#include <vector>
void fillIntVectorFrom1To100(std::vector<int>& vec)
{
for(int i{0}; i<=100; ++i)
{
vec.push_back(i);
}
}
int main(){
std::vector<int> v1, v2;
v1.reserve(10000);
v2.reserve(9999);

fillIntVectorFrom1To100(v1);
fillIntVectorFrom1To100(v2);
std::cout << "Are vectors equal: " << ((v1 <=> v2)==0) << "\n"; std::cout << "Are capacities equal: "
<< (v1.capacity() == v2.capacity()) << "\n";
std::cout << "Are sizes equal: "
<< (v1.size() == v2.size()) << "\n";
stdout
------
true
false
true

Note in the above example how the operator<=> is invoked on std::vector objects for comparison to demonstrate the fact that it is available in most standard library components since C++20. Clearly the operator<=> doesn’t rely on the capacities of the two vectors for comparison. It only relies on substitutable function calls (if any) to check for equality. The above example can also be found here.

Similarly for std::shared_ptr the value is the pointer object that the pointer points to and the not control count or the value we get after dereferencing the pointer ². To demonstrate this consider the example below which prints false when two different std::shared_ptr having exact same value of what is being pointed to (value 1 ) .

#include <memory>
#include <compare>
#include <iostream>
int main(){
std::cout << std::boolalpha;

std::shared_ptr<int> sptr1 = std::make_shared<int>(1);
std::shared_ptr<int> sptr2 = std::make_shared<int>(1);
std::shared_ptr<int> sptr3 = sptr1;
std::cout << ((sptr1 <=> sptr2) == 0) << "\n";
std::cout << ((sptr1 <=> sptr3) == 0) << "\n";
stdout
-------
false
true

The main ideas to keep in mind and should serve as a strong hint on guiding the development and when you should return std::strong_ordering from a custom operator<=> on your objects:

  1. In the body of operator<=> , the members are compared in order and lexicographically. In such cases the default operator<=> is sufficient.
  2. In the body of the operator<=> the members need to be compared in or out of order they should be compared using operator<=> on each member.
  3. While comparing the members of the object within the body operator<=> , they themselves should return std::strong_ordering (see example in section 1.4.2 from Consistent Comparison paper ³ and section 2.1 below)
  4. In the body of operator<=> only substitutable functions (member or non-member) functions are invoked for comparison, i.e. ,functions that use only the salient properties of the object to compare values.

Lets finally look at the third and last statement from cppreference page

1.3. does not allow incomparable values: exactly one of a < b, a == b, or a > b must be true

If you remember section 2.1 from part four of the tutorial series where I demonstrated with example on when can two objects be incomparable, it should be pretty straight forward to understand that apart from the fact that all the rules described in subsections 1.1 and 1.2 hold, the objects must be comparable and except for != , exactly one of > , <or == should return true for the custom operator<=> to return std::strong_ordering .

2. Important points to note

In this section I want to explain some more ideas in detail with examples that were just mentioned to bolster the concepts and develop a clear understanding.

2.1 Class object must have members returning std::strong_ordering

I previously mentioned that for the body of operator<=> to return std::strong_ordering , all the members should be compared using <=> and they all must return std::strong_ordering, either explicitly or when deduced with auto. Consider the following example with a default operator<=> first:

#include <compare>
#include <iostream>
#include <typeinfo> //needed to print type of object
class Person{
private:
std::string name_;
int age_;
public:
Person(std::string name, int age): name_{name}, age_{age}
{ //empty body }

auto operator<=>(const Person& rhs) const = default;
};
int main(){
Person john{"John", 26};
Person jane{"Jane", 27};
auto resType = john <=> jane;
std::cout << typeid(resType).name() << "\n";
};
stdout on my machine with gcc 10.2
----------------------------------
St15strong_ordering

In the above example the type name for the type returned by the default three way comparison operator is St15strong_ordering which is nothing but std::strong_ordering . This is because when the compiler compared both the members ( name_ and age_ ) lexicographically via <=> , they both returned std::strong_ordering. If one or both of them returned a comparison category other than std::strong_ordering , then the print output would have been different.

Now instead of a default three way operator lets define an explicit one and do what compiler does for us. Because I’m explicitly defining the operator<=>, I need to also define operator== but I’ll omit it here to stay to the point:

auto Person::operator<=>(const Person& rhs) const
{
if(auto cmp = name_ <=> rhs.name_; cmp != 0)
{return cmp;}
return age_ <=> rhs.age_;
}
//explicitly define operator== too ...

In this case what I’m trying to show is that the invocation of <=> on the members of Person returns std::strong_ordering and hence the overall return of the Person::operator<=>(const Person& rhs) is also std::strong_ordering .

2.2 Class object contains a member NOT returning std::strong_ordering

For the sake of completion, it is prudent to see what happens when a class contains a member which returns a comparison category other than std::strong_ordering but the operator<=> on the class itself returns operator<=> :

#include <iostream>
#include <compare>
struct Int{
int intNum_;
std::strong_ordering operator<=>(const Int& rhs) const = default;
};
struct Float{
float floatNum_;
auto operator<=>(const Float& rhs) const = default;
};
struct Numbers{
Int first_;
Float second_;
std::strong_ordering operator<=>(const& Numbers rhs) const
{
if(auto cmp = first_<=> rhs.first_; cmp != 0)
{return cmp;}
return second_ <=> rhs.second_;
}
};

Compiling the above code results in compilation error indicating that the member Numbers::second_ cannot return comparison category std::strong_ordering with operator<=>

In member function 'std::strong_ordering NumericWrapper::operator<=> (const NumericWrapper& ) const':error: couldn't convert '((const NumericWrapper*)this->NumericWrapper::second_.FloatWrapper::operator<=>(rhs.NumericWrapper::second)' from std::partial_ordering to std::strong_ordering|        return second_ <=> rhs.second_;
| ~~~~~~~~~^~~~~~~~~~~~~~~
| |
| std::partial_ordering

Hence, it is clear that trying to return std::strong_ordering from operator<=> of a class object when the members themselves don’t, then it results in compilation error. Even if the operator<=> is defaulted, you can expect to see similar compiler errors.

3. Difference between equal and equivalent

All the three comparison categories can be used to determine if two objects are equivalent or not. In addition std::strong_ordering can also be used to determine if two objects are equal. In most cases when we are writing default version of the three way operator, it doesn’t matter to us. But when we’re required to define a custom spaceship operator, then it doesn’t hurt to know the how to write programs that can check for equality and equivalence.

C++ STL makes distinction between equality and equivalence in the following way⁴:

if(a == b){
std::cout << "a and b are equal\n";
}
if(!(a < b) && !(a > b)){
std::cout << "a and b are equivalent\n";
}

Hence when writing a custom three way comparison operator one can make use of this construct to check for equivalence. You can check out my tutorial part 8 which is independent from the remaining parts in the tutorial series to see an application where the above construct is used to return std::strong_ordering::equivalent .

Summary

  1. For an object to return std::strong_ordering in a custom operator<=> , all the members must be compared with <=> in the body of operator<=> and they all should return std::strong_ordering .
  2. In most cases including the default operator<=> , the return type is deduced to be std::strong_ordering .
  3. All the members of the object being compared in operator<=> (explicitly defined or defaulted) must also return std::strong_ordering for the operator<=> overall operator to return std::strong_ordering else it results in compilation error.
  4. Members of the object being compared must be comparable and except for != , exactly one of > , <or == should return true.

Conclusion

In this part of the tutorial series, I explained when a custom operator<=> should return the comparison category std::strong_ordering and in the process I explained some concepts that will be useful for the upcoming parts of the tutorial. In the sixth part, I explain the comparison category std::weak_ordering . Stay tuned!

References

[1] With permission from Jonathan Müller from his blog Mathematics of Comparison: Part 1
[2]Example idea borrowed with permission from Jonathan Müller from his blog Mathematics of Comparison: Part 1
[3] Consistent comparison, Herb Stutter, Jens Maurer, Walter E. Brown
[4] Distinction between equality and equivalence from cpp reference page on comparison operators

C++20 videos on youtube and course

You can learn more about Comparison Salient States from this youtube video. Subscribe to my youtube channel to learn more about coroutines and other advanced topics on C++20 or browse my course page to get access to all C++20 videos, quizzes and personal support.

Support me by becoming a member

If you like my tutorials and articles, please consider supporting me by becoming a member through my medium referral link and get unlimited access to all articles on medium.com for just $5 a month.

--

--

Gajendra Gulgulia
CodeX
Writer for

I'm a backend software developer and modern C++ junkie. I run a modern cpp youtube channel (https://www.youtube.com/@masteringmoderncppfeatures6302/videos)