Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

More silent behaviour changes with C++20 three-way comparison

To my surprise, I ran into another snag like C++20 behaviour breaking existing code with equality operator?.

Consider a simple case-insensitive key type, to be used with, e.g., std::set or std::map:

// Represents case insensitive keys struct CiKey : std::string {     using std::string::string;     using std::string::operator=;      bool operator<(CiKey const& other) const {         return boost::ilexicographical_compare(*this, other);     } }; 

Simple tests:

using KeySet   = std::set<CiKey>; using Mapping  = std::pair<CiKey, int>; // Same with std::tuple using Mappings = std::set<Mapping>;  int main() {     KeySet keys { "one", "two", "ONE", "three" };     Mappings mappings {         { "one", 1 }, { "two", 2 }, { "ONE", 1 }, { "three", 3 }     };      assert(keys.size() == 3);     assert(mappings.size() == 3); } 
  • Using C++17, both asserts pass (Compiler Explorer).

  • Switching to C++20, the second assert fails (Compiler Explorer)

    output.s: ./example.cpp:28: int main(): Assertion `mappings.size() == 3' failed.


Obvious Workaround

An obvious work-around is to conditionally supply operator<=> in C++20 mode: Compile Explorer

#if defined(__cpp_lib_three_way_comparison)     std::weak_ordering operator<=>(CiKey const& other) const {         if (boost::ilexicographical_compare(*this, other)) {             return std::weak_ordering::less;         } else if (boost::ilexicographical_compare(other, *this)) {             return std::weak_ordering::less;         }         return std::weak_ordering::equivalent;     } #endif 

Question

It surprises me that I ran into another case of breaking changes - where C++20 changes behaviour of code without diagnostic.

On my reading of std::tuple::operator< it should have worked:

3-6) Compares lhs and rhs lexicographically by operator<, that is, compares the first elements, if they are equivalent, compares the second elements, if those are equivalent, compares the third elements, and so on. For non-empty tuples, (3) is equivalent to

if (std::get<0>(lhs) < std::get<0>(rhs)) return true; if (std::get<0>(rhs) < std::get<0>(lhs)) return false; if (std::get<1>(lhs) < std::get<1>(rhs)) return true; if (std::get<1>(rhs) < std::get<1>(lhs)) return false; ... return std::get<N - 1>(lhs) < std::get<N - 1>(rhs); 

I understand that technically these don't apply since C++20, and it gets replaced by:

Compares lhs and rhs lexicographically by synthesized three-way comparison (see below), that is, compares the first elements, if they are equivalent, compares the second elements, if those are equivalent, compares the third elements, and so on

Together with

The <, <=, >, >=, and != operators are synthesized from operator<=> and operator== respectively. (since C++20)

The thing is,

  • my type doesn't define operator<=> nor operator==,

  • and as this answer points out providing operator< in addition would be fine and should be used when evaluating simple expressions like a < b.

  1. Is the behavior change in C++20 correct/on purpose?
  2. Should there be a diagnostic?
  3. Can we use other tools to spot silent breakage like this? It feels like scanning entire code-bases for usage of user-defined types in tuple/pair doesn't scale well.
  4. Are there other types, beside tuple/pair that could manifest similar changes?
like image 237
sehe Avatar asked Mar 05 '21 17:03

sehe


People also ask

What is three way comparison operator C++?

The three-way comparison operator “<=>” is called a spaceship operator. The spaceship operator determines for two objects A and B whether A < B, A = B, or A > B. The spaceship operator or the compiler can auto-generate it for us.

What is the spaceship operator?

Spaceship Operator is used to compare two expressions. It returns -1, 0 or 1 when first expression is respectively less than, equal to, or greater than second expression.


1 Answers

The basic problem comes from the facts that your type is incoherent and the standard library didn't call you on it until C++20. That is, your type was always kind of broken, but things were narrowly enough defined that you could get away with it.

Your type is broken because its comparison operators make no sense. It advertises that it is fully comparable, with all of the available comparison operators defined. This happens because you publicly inherited from std::string, so your type inherits those operators by implicit conversion to the base class. But the behavior of this slate of comparisons is incorrect because you replaced only one of them with a comparison that doesn't work like the rest.

And since the behavior is inconsistent, what could happen is up for grabs once C++ actually cares about you being consistent.

A larger problem however is an inconsistency with how the standard treats operator<=>.

The C++ language is designed to give priority to explicitly defined comparison operators before employing synthesized operators. So your type inherited from std::string will use your operator< if you compare them directly.

C++ the library however sometimes tries to be clever.

Some types attempt to forward the operators provided by a given type, like optional<T>. It is designed to behave identically to T in its comparability, and it succeeds at this.

However, pair and tuple try to be a bit clever. In C++17, these types never actually forwarded comparison behavior; instead, it synthesized comparison behavior based on existing operator< and operator== definitions on the types.

So it's no surprise that their C++20 incarnations continue that fine tradition of synthesizing comparisons. Of course, since the language got in on that game, the C++20 versions decided that it was best to just follow their rules.

Except... it couldn't follow them exactly. There's no way to detect whether a < comparison is synthesized or user-provided. So there's no way to implement the language behavior in one of these types. However, you can detect the presence of three-way comparison behavior.

So they make an assumption: if your type is three-way comparable, then your type is relying on synthesized operators (if it isn't, it uses an improved form of the old method). Which is the right assumption; after all, since <=> is a new feature, old types can't possibly get one.

Unless of course an old type inherits from a new type that gained three-way comparability. And there's no way for a type to detect that either; it either is three-way comparable or it isn't.

Now fortunately, the synthesized three-way comparison operators of pair and tuple are perfectly capable of mimicking the C++17 behavior if your type doesn't offer three-way comparison functionality. So you can get back the old behavior by explicitly dis-inheriting the three-way comparison operator in C++20 by deleting the operator<=> overload.

Alternatively, you could use private inheritance and simply publicly using the specific APIs you wanted.

Is the behavior change in c++20 correct/on purpose?

That depends on what you mean by "on purpose".

Publicly inheriting from types like std::string has always been somewhat morally dubious. Not so much because of the slicing/destructor problem, but more because it is kind of a cheat. Inheriting such types directly opens you up to changes in the API that you didn't expect and may not be appropriate for your type.

The new comparison version of pair and tuple are doing their jobs and doing them as best as C++ can permit. It's just that your type inherited something it didn't want. If you had privately inherited from std::string and only using-exposed the functionality you wanted, your type would likely be fine.

Should there be a diagnostic?

This can't be diagnosed outside of some compiler-intrinsic.

Can we use other tools to spot silent breakage like this?

Search for case where you're publicly inheriting from standard library types.

like image 76
Nicol Bolas Avatar answered Sep 22 '22 00:09

Nicol Bolas