4

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?

Daniel Jennings
  • 6,304
  • 3
  • 32
  • 42
  • 2
    In case this comes down to a compiler bug or quirk, can you add your compiler, its version, and any additional flags you're using? – alter_igel Oct 08 '21 at 00:27
  • 1
    Different example, but same topic overall https://stackoverflow.com/q/66497269/817643 - the answer also touches on `std::tuple` and its fallback behavior. – StoryTeller - Unslander Monica Oct 08 '21 at 00:30
  • 3
    As far as the problem in larger code bases... I think some of the other critique in the other answer applies here too. You provide an *implicit* conversion. But your type isn't *really* implicitly replaceable by a pointer to a char buffer, is it? I think implicit conversion functions are so frowned upon because that is rarely true in general for most types. – StoryTeller - Unslander Monica Oct 08 '21 at 00:33
  • @alterigel MSVC 16.11, Clang 12.0+ Tested in godbolt's compiler explorer on those two, no compiler flags other than specifying the STD. – Daniel Jennings Oct 08 '21 at 00:41
  • 2
    @DanielJennings: This feels like a case where you've done something dubious. If a type is implicitly convertible, then one might reasonably expect that it is valid to compare the *converted* forms of the two objects. And in your case, it is not. So what does it mean that your type is it implicitly convertible to a `char const*`? – Nicol Bolas Oct 08 '21 at 01:07

1 Answers1

2

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.

Barry
  • 286,269
  • 29
  • 621
  • 977