DEV Community

Pierre Gradot
Pierre Gradot

Posted on • Edited on

Let's try C++20 | Compare objects

Let's try C++20 | comparisons

C++20 comes with new possibilities to compare objects.

Let's start with this simple code where we try to compare 2 objects of user-defined type:

#include <iostream>

struct Foo {
    const int value;
};

int main() {
    Foo a{42};
    Foo b{66};

    std::cout << std::boolalpha;
    std::cout << (a == b) << '\n';
    std::cout << (a != b) << '\n';

    std::cout << (a > b) << '\n';
    std::cout << (a >= b) << '\n';

    std::cout << (a < b) << '\n';
    std::cout << (a <= b) << '\n';
}
Enter fullscreen mode Exit fullscreen mode

This code doesn't compile, no matter which version of C++ you are using. Indeed, none of these operators are implicitly defined by the compiler and we get 6 error (one per operator):

prog.cc: In function 'int main()':
prog.cc:12:21: error: no match for 'operator==' (operand types are 'Foo' and 'Foo')
   12 |     std::cout << (a == b) << '\n';
      |                   ~ ^~ ~
      |                   |    |
      |                   Foo  Foo
prog.cc:13:21: error: no match for 'operator!=' (operand types are 'Foo' and 'Foo')
   13 |     std::cout << (a != b) << '\n';
      |                   ~ ^~ ~
      |                   |    |
      |                   Foo  Foo
prog.cc:15:21: error: no match for 'operator>' (operand types are 'Foo' and 'Foo')
   15 |     std::cout << (a > b) << '\n';
      |                   ~ ^ ~
      |                   |   |
      |                   Foo Foo
prog.cc:16:21: error: no match for 'operator>=' (operand types are 'Foo' and 'Foo')
   16 |     std::cout << (a >= b) << '\n';
      |                   ~ ^~ ~
      |                   |    |
      |                   Foo  Foo
prog.cc:18:21: error: no match for 'operator<' (operand types are 'Foo' and 'Foo')
   18 |     std::cout << (a < b) << '\n';
      |                   ~ ^ ~
      |                   |   |
      |                   Foo Foo
prog.cc:19:21: error: no match for 'operator<=' (operand types are 'Foo' and 'Foo')
   19 |     std::cout << (a <= b) << '\n';
      |                   ~ ^~ ~
      |                   |    |
      |                   Foo  Foo
Enter fullscreen mode Exit fullscreen mode

Until C++17, we had to define all operators manually. We were not even able to default them:

struct Foo {
    const int value;

    bool operator==(const Foo& other) = default;
};
Enter fullscreen mode Exit fullscreen mode

Compiler error was:

error: defaulted 'bool Foo::operator==(const Foo&)' only available with '-std=c++20' or '-std=gnu++20'

Defaulted operator==()

Let's simply listen to GCC and compile our code with -std=c++20 option:

error: defaulted 'bool Foo::operator==(const Foo&)' must be 'const'

When we add const, the compiler generates operator==() as requested and it implicitly defines operator=!(). In fact, the later is also implicitly defined if the former is user-defined.

Consequence: the 6 errors about missing operators are reduced to 4. operator==() and operator=!() work as expected:

int main() {
    Foo a{42};
    Foo b{66};

    std::cout << std::boolalpha;
    std::cout << (a == b) << '\n';
    std::cout << (a != b) << '\n';
}
Enter fullscreen mode Exit fullscreen mode

Output:

false
true
Enter fullscreen mode Exit fullscreen mode

What does this defaulted operator==() do? As always, cppreference has the answer. "Compiler generates element-wise equality testing" for this operator:

A class can define operator== as defaulted, with a return value of bool. This will generate an equality comparison of each base class and member subobject, in their declaration order. Two objects are equal if the values of their base classes and members are equal. The test will short-circuit if an inequality is found in members or base classes earlier in declaration order.

Try to Default Other Operators

You may be tempted to default other comparison operators. Spoiler alert: it won't work (*). From the 6 operators, only operator()== can be defaulted.

#include <iostream>

struct Foo {
    const int value;

    bool operator>(const Foo& other) const = default;
};

int main() {
    Foo a{42};
    Foo b{66};

    std::cout << std::boolalpha;
    std::cout << (a > b) << '\n';
}
Enter fullscreen mode Exit fullscreen mode

Errors are:

prog.cc: In function 'int main()':
prog.cc:14:23: error: use of deleted function 'bool Foo::operator>(const Foo&) const'
   14 |     std::cout << (a > b) << '\n';
      |                       ^
prog.cc:6:10: note: 'bool Foo::operator>(const Foo&) const' is implicitly deleted because the default definition would be ill-formed:
    6 |     bool operator>(const Foo& other) const = default;
      |          ^~~~~~~~
prog.cc:6:10: error: no match for 'operator<=>' (operand types are 'const Foo' and 'const Foo')
Enter fullscreen mode Exit fullscreen mode

This is where three-way comparison comes into play.

(*) = cppreference seems to say that it is possible to default all 6 comparison operators, but I got errors with both gcc and clang, except with operator()==. Seems like they can be defaulted only if operator== and/or operator<=> are defined.

Three-way Comparison

There is a new operator in C++20: operator<=>(). It is called "spaceship operator" and it performs a three-way comparison:

A three-way comparison takes two values A and B belonging to a type with a total order and determines whether A < B, A = B, or A > B in a single operation, in accordance with the mathematical law of trichotomy.

You may not be familiar with the names "spaceship operator" or "three-way comparison" but you have probably experienced them already.

Remember strcmp() from C?

int strcmp(const char *s1, const char *s2);

Return Value

The strcmp() and strncmp() functions return an integer less than, equal to, or greater than zero if s1 (or the first n bytes thereof) is found, respectively, to be less than, to match, or be greater than s2.

Ever tried the Comparable interface in Java?

Interface Comparable<T>

int compareTo​(T o)
Returns: a negative integer, zero, or a positive integer as this object is less than, equal to, or greater than the specified object.

These are three-way comparisons.

Defaulted operator<=>

Let's get back to our struct Foo and try to default this spaceship operator. Doing so will also generate other operators:

A class that defines operator<=> as defaulted will generate a 3-way comparison element-wise. It will perform 3-way comparisons on each base class and member subobject, in declaration order. These comparisons will short-circuit on the first non-equal base class or member. If the return type of the defaulted function is auto, then the comparison type will be the most restrictive of all of the 3-way comparison results for its subobjects.

Per the rules for any operator<=> overload, a defaulted <=> overload will also allow the type to be compared with <, <=, >, and >=. If operator<=> is defaulted and operator== is not declared at all, then operator== is implicitly defaulted.

#include <compare> // this is important

struct Foo {
    const int value;

    auto operator<=>(const Foo&) const = default;
};

#include <iostream>

int main() {
    Foo a{42};
    Foo b{66};

    std::cout << std::boolalpha;
    std::cout << (a == b) << '\n';
    std::cout << (a != b) << '\n';

    std::cout << (a > b) << '\n';
    std::cout << (a >= b) << '\n';

    std::cout << (a < b) << '\n';
    std::cout << (a <= b) << '\n';
}
Enter fullscreen mode Exit fullscreen mode

This code compiles and outputs:

false
true
false
false
true
true
Enter fullscreen mode Exit fullscreen mode

Ain't that great?! 😍

Object Comparison Really Changes in C++20

Early papers for C++20 proposed that operator<=>() would be used to generate all other 6 operators, including operator==(). Barry Revzin then writes several papers to change this behavior. See for instance P1630R1: Spaceship needs a tune-up. Eventually, his document P1185R2: <=> != == has made it into the standard, as you can see in the section "Three-way comparison (“spaceship”) and defaulted comparisons" on Changes between C++17 and C++20 DIS.

They are many papers in this section and they create a complete change in how we will compare objects in C++.

Primary vs Secondary Operators

The first important change is the new spaceship operator and the possibility to default comparison operators. These operators can be split in 2 groups : on one side, we have equality operators == and !=, and on the other side we have relational operators <=>, >, >=, <, <=.

In it's a blog article "Comparisons in C++20", Barry Revzin introduces the names "primary" and "secondary" operators. == and <=> are primary, the other are secondary.

Primary operators can be defaulted. Secondary operators can be defaulted if the corresponding primary operator is defined.

Secondary operators can be implicitly defined by the compiler if the primary operators are user-defined or defaulted. For instance, defaulted != generates a call to negated ==.

Defaulting <=> will also implicitly default == if it is not defined, and hence defaulting all operators.

Defaulted == and != do not invoke <=>.

Reversing Comparisons

The second important change is the ability for compilers to reverse operators and operands in comparisons.

To understand this, let's get back to C++17 and consider this code:

struct Foo {
    const int value;

    bool operator==(int v) const {
        return value == v;
    }
};

#include <iostream>

int main() {
    Foo foo{42};

    std::cout << std::boolalpha << (foo == 42);
}
Enter fullscreen mode Exit fullscreen mode

This code compiles fine and prints true. Unfortunately, std::cout << std::boolalpha << (42 == foo); doesn't compile:

error: no match for 'operator==' (operand types are 'int' and 'Foo')

The solution is to write a free operator:

bool operator==(int v, Foo foo) {
    return foo.value == v;
}
Enter fullscreen mode Exit fullscreen mode

In C++20, you don't have to do that. Both comparisons compile and print true. Indeed, primary operators can be reversed automatically by the compiler. When the compiler evaluates 42 == foo but doesn't find a suitable operator, it reverses the operation as foo == 42 and looks again for a suitable operator, which is found in my example.

In the blog article linked above, Barry Revzin says:

In C++20, expressions containing secondary comparison operators will also try to look up their corresponding primary operators and write the secondary comparison in terms of the primary.

Because the language considers reversing candidates, you can write all of these operators as member functions too. No more writing non-member functions just to handle heterogeneous comparison.

Its means that secondary operators cannot be reversed on their own. For instance, the following code doesn't compile (not even in C++20):

struct Foo {
    const int value;

    bool operator!=(int v) const {
        return value == v;
    }
};

#include <iostream>

int main() {
    Foo foo{42};

    std::cout << std::boolalpha << (42 != foo);
}
Enter fullscreen mode Exit fullscreen mode

The error is:

error: no match for 'operator!=' (operand types are 'int' and 'Foo')

Nevertheless, the compiler is able to reverse an operation with a secondary operator by using the corresponding primary operator. Here is a correct code:

struct Foo {
    const int value;

    bool operator==(int v) const {
        return value == v;
    }
};

#include <iostream>

int main() {
    Foo foo{42};

    std::cout << std::boolalpha << (42 != foo);
}
Enter fullscreen mode Exit fullscreen mode

All in all, you just have to write the primary operators and all operations with secondary operators will work. Example:

#include <compare>

struct Foo {
    const int value;

    bool operator==(int v) const {
        return value == v;
    }

    auto operator<=>(int v) const {
        return value <=> v;
    }
};

#include <iostream>

int main() {
    Foo foo{42};

    std::cout << std::boolalpha << (42 != foo) << '\n';

    std::cout << std::boolalpha << (42 <= foo) << '\n';
    std::cout << std::boolalpha << (42 < foo) << '\n';

    std::cout << std::boolalpha << (42 >= foo) << '\n';
    std::cout << std::boolalpha << (42 > foo) << '\n';
}
Enter fullscreen mode Exit fullscreen mode

Output:

true
false
true
false
true
false
Enter fullscreen mode Exit fullscreen mode

Strong vs Weak vs Partial Ordering

A last point I have to talk about is the return type of the spaceship operator. As stated in the section "Three-way comparison" of the "Operator Comparison" on cppreference:

The three-way comparison operator expressions have the form

lhs <=> rhs (1)

The expression returns an object such that

  • (a <=> b) < 0 if lhs < rhs
  • (a <=> b) > 0 if lhs > rhs
  • (a <=> b)== 0 if lhs and rhs are equal/equivalent.

Nevertheless, this operator doesn't return an integer and this is why the <compare> header must be included to define it:

//#include <compare>

struct Foo {
    const int value;

    auto operator<=>(const Foo&) const = default;
};

int main() {
    Foo foo{42};
    Foo bar{66};

    return foo <=> bar;
}
Enter fullscreen mode Exit fullscreen mode
<source>: In member function 'constexpr auto Foo::operator<=>(const Foo&) const':
<source>:6:10: error: 'strong_ordering' is not a member of 'std'
    6 |     auto operator<=>(const Foo&) const = default;
      |          ^~~~~~~~
<source>:1:1: note: 'std::strong_ordering' is defined in header '<compare>'; did you forget to '#include <compare>'?
Enter fullscreen mode Exit fullscreen mode

The auto-deduced return type is std::strong_ordering here and it is defined in <compare>.

If you uncomment the #include, you will get another error:

<source>:13:16: error: cannot convert 'std::strong_ordering' to 'int' in return
   13 |     return foo <=> bar;
      |            ~~~~^~~~~~~
      |                |
      |                std::strong_ordering
Enter fullscreen mode Exit fullscreen mode

The type can be compared to but not converted to an integer. You have to change the return statement:

Statement Value
return (foo <=> bar) == 0; 0
return (foo <=> foo) == 0; 1
return (bar <=> bar) == 0; 1
return (bar <=> foo) == 0; 0
return (bar <=> foo) > 0; 1
return (bar <=> foo) < 0; 0
return (foo <=> bar) > 0; 0
return (foo <=> bar) < 0; 1

std::strong_ordering is not always the return type of the spaceship operator. It is also possible that it returns std::weak_ordering or std::partial_ordering.

What are the differences between them?

Here is what Barry Revzin says in its article (you should really read it, it's great 😉):

strong_ordering: a total ordering, where equality implies substitutability (that is (a <=> b) == strong_ordering::equal implies that for reasonable functions f, f(a) == f(b). “Reasonable” is deliberately underspecified – but shouldn’t include functions that return the address of their arguments or do things like return the capacity() of a vector, etc. We want to only look at “salient” properties – itself very underspecified, but think of it as referring to the value of a type. The value of a vector is the elements it contains, not its address, etc.). The values are strong_ordering::greater, strong_ordering::equal, and strong_ordering::less.

weak_ordering: a total ordering, where equality actually only defines an equivalence class. The canonical example here is case-insensitive string comparison – where two objects might be weak_ordering::equivalent but not actually equal (hence the naming change to equivalent).

partial_ordering: a partial ordering. Here, in addition to the values greater, equivalent, and less (as with weak_ordering), we also get a new value: unordered. This gives us a way to represent partial orders in the type system: 1.f <=> NaN is partial_ordering::unordered.

Conclusion

When I started to write, I though it would be an easy-to-write article about the spaceship operator. Damned it wasn't! 😨

C++20 really changes the deal with comparison operators. It is now possible to default operators and heterogeneous operations are handled by the compiler. In my opinion, this is a real improvement in the language that will help to write readable, easy-to-use code and reduce bugs.

Top comments (2)

Collapse
 
sandordargo profile image
Sandor Dargo

Great to see more C++ content on Dev! What compiler do you use with decent support for C++20? I was advised recently MSVC++, but I'm on Linux, so not a viable option.

Collapse
 
pgradot profile image
Pierre Gradot

Hey! Thanks :)

For my tests, I am using wandbox.org/ or godbolt.org/

Head versions of both GCC and CLANG seem to work properly. See en.cppreference.com/w/cpp/compiler... more for more details about which features is supported by each major compilers ;)