Daniel Jennings
Daniel Jennings

Reputation: 6480

Breaking change in std::tuple lexicographic comparison in C++20 with conversion operators?

Consider the following code, a simple Struct with all of the pre-C++20 comparison operators defined, as well as a conversion operator to const char * (that for this example throws, for simplicity of tracing).

struct Struct
{
    int _i;
    Struct( int i ) : _i( i ) {}

    bool operator==( const Struct &b ) const { return _i == b._i; }
    bool operator!=( const Struct &b ) const { return _i != b._i; }
    bool operator<( const Struct &b ) const { return _i < b._i; }
    bool operator<=( const Struct &b ) const { return _i <= b._i; }
    bool operator>( const Struct &b ) const { return _i > b._i; }
    bool operator>=( const Struct &b ) const { return _i >= b._i; }

    operator const char *() const { throw "Crash"; return nullptr; }
};

Now let us put that structure into a std::tuple, just a single element for simplicity's sake. Create two of those and sort them (lexicographically, per std::tuple):

#include <cstdio>
#include <tuple>

int main()
{
    std::tuple<Struct> a( 1 ), b( 2 );

    printf( "%s\n", a < b ? "Right" : "Wrong" );
    return 0;
}

What does this output? Under C++17 it will print "Right" as you may expect. Under C++20, though, the same code will throw the exception in the const char * conversion operator.

Why? Because we haven't defined an operator <=> in our struct, and C++20's std::tuple<Struct> will end up calling std::operator<=><Struct, Struct> in order to determine whether a < b is true. Per the C++20 standard, std::tuple only defines the operator == and operator <=> comparison operators, which the compiler then uses to perform the < operation.

What's surprising is std::operator<=><Struct, Struct> ends up being producing code equivalent to (const char *) <=> (const char *). It ignores the Struct comparison operators that could have otherwise been used to synthesize operator <=>, in favor of the conversion operator.

In practice this means that our std::tuple<Struct> had a well-defined ordering in C++17 that now takes a different codepath through the operator const char * conversion, which results in different behavior at run-time.

My question:

Other than manually looking at all instantiations of std::tuple and verifying that either lexicographic comparisons are not performed, there are no conversion operators, or that any classes or structures contained within define operator <=>, is there a way to identify at compile-time that this problem exists in a large codebase?

Upvotes: 4

Views: 539

Answers (1)

Barry
Barry

Reputation: 303576

Other than manually looking at all instantiations of std::tuple and verifying that either lexicographic comparisons are not performed, there are no conversion operators, or that any classes or structures contained within define operator <=>, is there a way to identify at compile-time that this problem exists in a large codebase?

There, unfortunately, definitely isn't something you can do within C++ to verify this in any way.

But at least you don't have to look at std::tuple (or std::pair or std::vector or ..., which behave the same) - you need to look for types, T, that:

  • do not provide a <=> directly, and
  • do provide a <, and
  • provide a conversion function to some type U, where
  • U does provide a <=>, and
  • U's <=> is a builtin.

This basically requires U to be a builtin type, because when we're evaluating t1 <=> t2, in order for U(t1) <=> U(t2) to be a candidate function, it needs to be either a builtin or an operator<=> candidate that is found by ADL on T -- but in order for it to be a candidate it would need to be a non-member, non-template candidate. operator<=> should be a member function or, if not that, a hidden friend or, if not that, a non-member function template. None of those options would work.

This list seems like something that would be possible to write a clang-tidy check for. One difficulty would be that both <=> and < can be defined outside of the class. And also you have class templates to consider.

At least, the fix is easy once you run into the problem: provide a <=>. The following works in both C++17 and C++20:

struct Struct
{
    int _i;
    Struct( int i ) : _i( i ) {}

    bool operator==( const Struct &b ) const { return _i == b._i; }
#ifdef __cpp_lib_three_way_comparison
    std::strong_ordering operator<=>( const Struct &b ) const { return _i <=> b._i; }
#else
    bool operator!=( const Struct &b ) const { return _i != b._i; }
    bool operator<( const Struct &b ) const { return _i < b._i; }
    bool operator<=( const Struct &b ) const { return _i <= b._i; }
    bool operator>( const Struct &b ) const { return _i > b._i; }
    bool operator>=( const Struct &b ) const { return _i >= b._i; }
#endif

    operator const char *() const { throw "Crash"; return nullptr; }
};

In this particular case, you could even default operator<=>, but in the general case that may not be true, so I'd rather write it out for the example.

Upvotes: 2

Related Questions