C++20: The Three-Way Comparison Operator

C++20: The Three-Way Comparison Operator

This is a cross-post from www.ModernesCpp.com.

The three-way comparison operator <=> is often just called spaceship operator. The spaceship operator determines for two values A and B whether A < B, A = B, or A > B. You can define the spaceship operator or the compiler can auto-generate it for you.

To appreciate the advantages of the three-way comparison operator, let me start classical.

Ordering before C++20

I implemented a simple int wrapper MyInt. Of course, I want to compare MyInt. Here is my solution using the isLessThan function template.

// comparisonOperator.cpp

#include <iostream>

struct MyInt {
    int value;
    explicit MyInt(int val): value{val} { }
    bool operator < (const MyInt& rhs) const {                  
        return value < rhs.value;
    }
};

template <typename T>
constexpr bool isLessThan(const T& lhs, const T& rhs) {
    return lhs < rhs;
}

int main() {

    std::cout << std::boolalpha << std::endl;

    MyInt myInt2011(2011);
    MyInt myInt2014(2014);

    std::cout << "isLessThan(myInt2011, myInt2014): "
              << isLessThan(myInt2011, myInt2014) << std::endl;

   std::cout << std::endl;

}

The program works as expected:

No alt text provided for this image

Honestly, MyInt is an unintuitive type. When you define one of the six ordering relations, you should define all of them. Intuitive types should be at least semi-regular: "C++20: Define the Concept Regular and SemiRegular."

Now, I have to write a lot of boilerplate code. Here are the missing five operators:

bool operator==(const MyInt& rhs) const { 
    return value == rhs.value; 
}
bool operator!=(const MyInt& rhs) const { 
    return !(*this == rhs);    
}
bool operator<=(const MyInt& rhs) const { 
    return !(rhs < *this);     
}
bool operator>(const MyInt& rhs)  const { 
    return rhs < *this;        
}
bool operator>=(const MyInt& rhs) const {   
    return !(*this < rhs);     
}

Done? No! I assume you want to compare MyInt with int's. To support the comparison of an int and a MyInt, and a MyInt and an int, you have to overload each operator three times because the constructor is declared as explicit. Thanks to explicit, no implicit conversion from int to MyInt kicks in. For convenience, you make the operators to a friend of the class. If you need more background information for my design decisions, read my previous post: "C++ Core Guidelines: Rules for Overloading and Overload Operators"

These are the three overloads for smaller-than.

friend bool operator < (const MyInts& lhs, const MyInt& rhs) {                  
    return lhs.value < rhs.value;
}

friend bool operator < (int lhs, const MyInt& rhs) {                  
    return lhs < rhs.value;
}

friend bool operator < (const MyInts& lhs, int rhs) {                  
    return lhs.value < rhs;
}

This means in total that you have to implement 18 comparison operators. Is this the end of the story? Maybe not, because you decided that the MyInt and all operators should become constexpr. You should also consider making the operators noexcept.

I assume this is enough motivation for the three-way comparison operators.

Ordering with C++20

You can define the three-way comparison operator or request it from the compiler with =default. In both cases you get all six comparison operators: ==, !=, <, <=, >, and >=.

// threeWayComparison.cpp

#include <compare>
#include <iostream>

struct MyInt {
    int value;
    explicit MyInt(int val): value{val} { }
    auto operator<=>(const MyInt& rhs) const {           // (1)      
        return value <=> rhs.value;
    }
};

struct MyDouble {
    double value;
    explicit constexpr MyDouble(double val): value{val} { }
    auto operator<=>(const MyDouble&) const = default;   // (2)
};

template <typename T>
constexpr bool isLessThan(const T& lhs, const T& rhs) {
    return lhs < rhs;
}

int main() {
    
    std::cout << std::boolalpha << std::endl;
    
    MyInt myInt1(2011);
    MyInt myInt2(2014);
    
    std::cout << "isLessThan(myInt1, myInt2): "
              << isLessThan(myInt1, myInt2) << std::endl;
              
    MyDouble myDouble1(2011);
    MyDouble myDouble2(2014);
    
    std::cout << "isLessThan(myDouble1, myDouble2): "
              << isLessThan(myDouble1, myDouble2) << std::endl;          
              
    std::cout << std::endl;
              
}

The user-defined (1) and the compiler-generated (2) three-way comparison operator work as expected.

No alt text provided for this image

But there are a few subtle differences in this case. The by the compiler deduced return type for MyInt (1) supports strong ordering, and the by the compiler deduced return type of MyDouble supports partial ordering. Floating pointer numbers only support partial ordering because floating-point values such as NaN (Not a Number) can not be ordered. For example NaN == NaN is false.

Now, I want to focus on this post about the compiler-generated spaceship operator.

The Compiler-Generated Spaceship Operator

The compiler-generated three-way comparison operator needs the header <compare>, is implicit constexpr and noexcept. Additionally, it performs a lexicographical comparison. What? Let me start with constexpr.

Comparison at Compile-Time

The three-way comparison operator is implicit constexpr. Consequently, I simplify the previous program threeWayComparison.cpp and compare MyDouble in the following program at compile-time.

// threeWayComparisonAtCompileTime.cpp

#include <compare>
#include <iostream>

struct MyDouble {
    double value;
    explicit constexpr MyDouble(double val): value{val} { }
    auto operator<=>(const MyDouble&) const = default;    
};

template <typename T>
constexpr bool isLessThan(const T& lhs, const T& rhs) {
    return lhs < rhs;
}

int main() {
    
    std::cout << std::boolalpha << std::endl;

              
    constexpr MyDouble myDouble1(2011);
    constexpr MyDouble myDouble2(2014);
    
    constexpr bool res = isLessThan(myDouble1, myDouble2); // (1)
    
    std::cout << "isLessThan(myDouble1, myDouble2): "
              << res << std::endl;          
              
    std::cout << std::endl;
              
}

 I ask for the result of the comparison at compile-time (1), and I get it.

No alt text provided for this image

The compiler-generated three-way comparison operator performs a lexicographical comparison. 

Lexicographical Comparison

Lexicographical comparison means in this case that all base classes are compared left to right and all non-static members of the class in their declaration order. I have to qualify: for performance reasons, the compiler-generated == and != operator behave differently in C++20. I will write about this exception to the rule in my next post.

The post "Simplify Your Code With Rocket Science: C++20’s Spaceship Operator" Microsoft C++ Team Blog provides an impressive example to the lexicographical comparison. 

struct Basics {
  int i;
  char c;
  float f;
  double d;
  auto operator<=>(const Basics&) const = default;
};

struct Arrays {
  int ai[1];
  char ac[2];
  float af[3];
  double ad[2][2];
  auto operator<=>(const Arrays&) const = default;
};

struct Bases : Basics, Arrays {
  auto operator<=>(const Bases&) const = default;
};

int main() {

  constexpr Bases a = { { 0, 'c', 1.f, 1. },    // (1)
                        { { 1 }, 
                          { 'a', 'b' }, 
                          { 1.f, 2.f, 3.f }, 
                          { { 1., 2. }, { 3., 4. } } 
                        } 
                      };

  constexpr Bases b = { { 0, 'c', 1.f, 1. },   // (1)
                        { { 1 }, 
                          { 'a', 'b' }, 
                          { 1.f, 2.f, 3.f }, 
                          { { 1., 2. }, { 3., 4. } } 
                        } 
                      };

  static_assert(a == b);
  static_assert(!(a != b));
  static_assert(!(a < b));
  static_assert(a <= b);
  static_assert(!(a > b));
  static_assert(a >= b);
}

I assume, the most complex aspect of the program is not the spaceship operator, but the initialization of Base via aggregate initialization (1). Aggregate initialization enables it to directly initialize the members of a class type (class, struct, union) when the members are all public. In this case, you can use brace initialization. If you want to know more about aggregate initialization, cppreference.com provides more information. I will write more about aggregate initialization in a future post when I will have a closer look at designated initialization in C++20.

What's next?

The compiler performs quite a clever job when it generates all operators. On the end, you get the intuitive and efficient comparison operators for free. My next post dives deeper into the magic under the hood.

 

Layton Perrin

Entrepreneur, Leader, Architect, Full-Stack Extreme Virtuoso: Business Analysis, Cyber Security, Data Science. ITIL BPM SLM Expert bringing Modern Approaches to drive Business Processes.

4 年

Hi Rainer, agree with your related post, "guidelines - rules for overloading and overload operators". There are two underlying dimensions here; the code completeness and correctness and the design of the solution domain. The notion of implicit, explicit, and friend types should be considered here as well, Thank you for the post :)

回复

要查看或添加评论,请登录

Rainer Grimm的更多文章

社区洞察

其他会员也浏览了