Tips & Tricks

C++20 Comparisons in ReSharper C++ 2020.3

Almost four years ago Herb Sutter published “Consistent Comparison”, a paper that proposed a new design intended to address the complexity and the boilerplate of working with comparison operators in C++. After several years of review and many iterations on the original proposal, the new rules for comparisons have finally been adopted into the C++20 standard.

ReSharper C++ 2020.3 brings full support for C++20’s changes to comparison semantics. In this post, we’ll briefly go over the language updates to comparisons in C++20 and take a look at how ReSharper C++ can help you use the new language features.

The complexity of pre-C++20 comparisons

Even as C++ has evolved over the past 30+ years, its comparison rules have stayed largely the same. There are six comparison operators, which all obey the same rules and are independent of each other: two equality operators (== and!=), and four relational operators (<,<=,>, and >=). If you want to implement one comparison operator for your class, it’s considered a good practice to implement all the other operators from the same group. To simplify this task, you can use the Generate feature of ReSharper C++:

Note that the idiomatic operator implementations generated by ReSharper C++ have several issues:

  • The comparison logic is contained in operator== and operator<, while the other operators have a boilerplate implementation that simply delegates to operator== or operator< to avoid code duplication.
  • There’s repetitious code in the implementations of operator== and operator<, which perform a comparison for each class member.
  • Some class members have to be compared twice in the implementation of operator< in order to establish the relationship between them.
  • The comparison operators are defined as non-member functions to support heterogeneous comparisons where the left argument requires an implicit conversion to the class type.

If you want to be able to directly compare two values of different types, the situation gets even more complicated, since now you need to double the number of comparison operators to account for the reversed order of arguments. The classic example is std::optional, which in C++17 requires 30 comparison operators: 6 to compare two arguments of the std::optional type, 12 to compare an std::optional with std::nullopt, and 12 to compare an std::optional with a value of the underlying type. If you want to write your own wrapper type similar to std::optional, providing all the comparison operators is a tedious task.

Three-way comparison operator

To help you cope with the complexity of pre-C++20 comparisons, C++20 introduces the three-way comparison operator<=>, also colloquially known as the spaceship operator. The main purpose of this brand new operator is to provide a single operation that can establish the relationship between two arguments. For two arguments x and y, we consider:

  • x less than y when (x <=> y) < 0
  • x equal to y when (x <=> y) == 0
  • and x greater than y when (x <=> y) > 0

An operator<=> for the pair class from the previous section could look like this:

template <class T1, class T2>
struct pair
{
    // The return type is not quite right yet.
    friend auto operator<=>(const pair& x, const pair& y)
    {
        if (auto r = x.first <=> y.first; r != 0) return r;
        return x.second <=> y.second;
    }

    T1 first;
    T2 second;
};

While a three-way comparison operator can return any type so long as it’s comparable with zero, the convention supported by the standard is to use one of the three standard comparison category types:

  • std::strong_ordering: for operators which implement a total order (meaning any two arguments can be compared with each other), and where arguments that compare equal can’t be distinguished using their public interface. This is the comparison category that you’re most likely to use in your own code.
  • std::weak_ordering: similar to std::strong_ordering, but distinct arguments can still compare equal. One example of such ordering is case-insensitive string comparison.
  • std::partial_ordering: use this type when two arguments might be unordered in relation to each other. You can encounter this category in comparisons of floating-point values, where comparing with a NaN will return std::partial_ordering::unordered.

The standard comparison category types are not predefined – you must include the C++20 <compare> header to use them. All the built-in comparison operators for primitive types return either std::partial_ordering for floating-point types or std::strong_ordering for everything else. This means that when you compare values of primitive types with operator<=> (explicitly or implicitly), you need to have the <compare> header included. A new quick-fix lets you quickly add the required include directive:

In our earlier pair::operator<=> example, we can’t simply let the language deduce the operator return type, since the member comparisons might have different return types. Instead, we can use the std::common_comparison_category helper from <compare> and declare the return type as std::common_comparison_category_t<decltype(x.first <=> y.first), decltype(x.second <=> y.second)>. This works because stronger comparison categories are implicitly convertible to weaker comparison categories (for example, std::strong_ordering is convertible to std::weak_ordering), and std::common_comparison_category_t returns the strongest comparison category to which all of its template arguments can be converted. The new return type might look unwieldy, but we’ll soon see a way to simplify pair::operator<=>.

Operator rewriting rules

With the foundation of the new three-way comparison operator, C++20 introduces its biggest change to how comparisons work – the ability to rewrite binary comparison expressions. There are two ways in which an expression can be rewritten.

First, expressions that use a secondary comparison operator (!=, <, <=, >, or >=) can be rewritten to use the corresponding primary comparison operator (== or <=>) instead. For example, x > y can be rewritten as (x <=> y) > 0, and x != y can be rewritten as !(x == y). With this change in place, a typical class now needs to define at most two comparison operators: operator== to support both equality operators, and operator<=> to support all the relational operators. With ReSharper C++, you can consult the tooltip to learn how an expression has been rewritten, or you can use Go to Declaration to navigate to the actual operator that is used in the rewritten expression.

You might wonder why we can’t express the equality operators in terms of the three-way comparison operator as well. The reason for this, like with many other things in C++, is performance: for certain classes, operator== can be implemented in a more performant manner than operator<=>. One example here is container classes: operator== can return early if the sizes of the two containers don’t match, while operator<=> has to compare individual elements until it finds a difference if it performs a lexicographical comparison.

Second, the order of arguments in a binary comparison can be reversed. Combined with the previous rule, this allows 1 < x to be transformed into 0 < (x <=> 1), in which the member A::operator<=> can be used.

This means that member comparison operators can now handle heterogeneous comparisons. You can still opt to use non-member operators, but there should no longer be any need to do that except for style reasons. By default, you should prefer member operators since they facilitate better error messages and slightly faster compilation times.

When a comparison operator cannot be resolved, the error message will list all the considered overloading candidates to help you figure out what went wrong:

In some rare cases, the changes to comparison semantics can even cause a change in the behavior of a correct program. For example, in the following snippet the resolved operator differs between C++17 and C++20:

struct B {};

struct A
{
    bool operator==(const B&);  // #1
};

bool operator==(const B&, const A&);  // #2

int main()
{
  B{} == A{};  // C++17: calls #2
               // C++20: calls #1, because #1 is not const-qualified and
               //        std::is_const_v<decltype(A{})> == false
}

In practice, this should not be a concern, since the changes in C++20 were carefully considered to avoid breaking typical existing code.

Defaulted comparison operators

If your comparison operators perform a simple member-wise lexicographical comparison, you can let the language generate their implementations by declaring them with the = default specifier. The generated implementation of a defaulted equality operator or a defaulted three-way comparison operator compares all the bases and members of the declaring class (which together are known as class subobjects) in the order of their declaration.

As a convenience feature for working with the existing code, the generated body of a defaulted operator<=> with an explicit return type can also use operator< together with operator== for a class subobject when operator<=> is not available for this subobject:

#include <compare>

struct A {};
bool operator<(A, A);
bool operator==(A, A);

struct B
{
    A a;
    // OK, even though there's no operator<=> defined for A.
    // Note that the return type must be explicit.
    std::strong_ordering operator<=>(const B&) const = default;
};

A defaulted operator needs to have the standard-mandated signature, and ReSharper C++ will let you know if there is any inconsistency:

Secondary comparison operators can also be defaulted. In practice it rarely makes sense to do so unless you have a very specific reason, for example when you need to be able to get a pointer to the operator function. The generated body for a secondary operator @ with parameters x and y simply contains return x @ y, which usually resolves to the corresponding primary operator.

A defaulted three-way comparison operator can have its return type declared as auto. In this case the return type is deduced as the common comparison type of all the comparison expressions in the generated operator body. Remember that this is exactly what we had to do when implementing pair::operator<=>, but now we can simply replace our custom implementation with a defaulted operator<=> with the auto return type. As with other functions with the auto return type, ReSharper C++ automatically deduces the actual return type. You can see the deduced type in the tooltip or right in the editor when type name hints are enabled for function return types.

If a defaulted comparison operator uses a function that is not usable in its implementation, the operator is implicitly deleted. In this case ReSharper C++ warns you with the “Defaulted special member function is implicitly deleted” inspection. If you are not sure why an operator is implicitly deleted, you can check the detailed tooltip message to find out the exact reason. A number of quick-fixes help you with common issues:

Additionally, C++20 automatically deduces whether a defaulted comparison operator is constexpr or noexcept. You can add explicit specifiers, but they must match the specifiers that the language deduces:

Implicitly generated operator==

To further reduce the need for boilerplate code, C++20 allows you to omit the declaration of a defaulted operator== in certain cases. If the class definition does not explicitly declare any member or friend operator==, a defaulted operator== is declared for each defaulted operator<=>.

The implicitly declared operator== has the same function signature as the corresponding defaulted operator<=>. It also behaves in the same way as if you had written its declaration manually, which means that operator== and operator<=> still have their own independent implementations.

ReSharper C++ lets you know that operator== is implicitly declared, and Go to Declaration takes you to the corresponding operator<=>.

With this final improvement in place, let’s see what we’ve accomplished. We started from the six non-member operators with custom bodies in our initial implementation of comparisons for the pair class. Using C++20 features, we’ve been able to replace them with a single defaulted member operator<=> declaration, greatly simplifying our code while retaining at least the same performance or even improving it. All in all, the C++20 comparison rules are a great example of how the core language can be modernized and simplified.

Creating new operators

The Create operator from usage quick-fix has been updated to support the C++20 comparison rules. You can now create both the member and the friend versions of an operator in addition to the non-member version. For secondary operators that can be rewritten in terms of a primary one, you can choose to create either the primary or the secondary operator. The quick-fix also lets you choose the required comparison category return type and automatically adds the = default specifier if the function’s signature allows it. If you need a custom operator implementation, any of the Generate implementation context actions will remove the specifier.

When you want to create a new comparison operator, you can use the new synthetic completion items for operator<=> and operator== to quickly insert an operator declaration. This works for both member and friend operators in a class scope. If you want to make the operator defaulted, make sure to complete with the = character, and the completion engine will offer you the options to insert the = default or the = delete specifier.

The above covers the most important aspects of the C++20 comparison rules and their support in ReSharper C++. If you want to dive deeper into the topic and learn about the guidelines for authoring your own comparison operators, I recommend checking out Barry Revzin’s article, “Comparisons in C++20”, and Jonathan Müller’s excellent talk, “Using C++20’s Three-way Comparison <=>”.

Be sure to give ReSharper C++ 2020.3 a try and give us your feedback!

DOWNLOAD RESHARPER C++ 2020.3 EAP

Your ReSharper C++ team
JetBrains
The Drive to Develop

image description