C++20: Der Drei-Weg-Vergleichsoperator <=>

Modernes C++  –  118 Kommentare

Der Drei-Weg-Vergleichsoperator <=> wird auch gerne Spaceship Operator genannt. Er bestimmt für zwei Werte A und B, ob A < B, A = B oder ob A > B ist. Der Spaceship Operator lässt sich direkt definieren oder vom Compiler automatisch erzeugen.

Um die Vorteile des Drei-Weg-Vergleichsoperators richtig wertzuschätzen, möchte ich meinen Artikel klassisch starten.

Ordnung vor C++20

Ich habe den einfachen Wrapper MyInt für int implementiert. Natürlich sollen sich Instanzen von MyInt vergleichen lassen. Die folgenden Zeilen zeigen meine Lösung mithilfe des Funktion-Templates isLessThan:

// 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;

}

Das Programm erfüllt erwartungsgemäß seine Aufgabe:

Ehrlich gesagt, verhält sich der Datentyp MyInt sehr unintuitiv. Wenn du einen der sechs Vergleichsoperatoren implementierst, solltest du alle sechs implementieren. Intuitive Datentypen sollten zumindest semiregulär sein: "C++20: Die Concepts SemiRegular und Regular definieren".

Nun gilt es, viel Boilerplate Code zu schreiben. Hier sind die fünf fehlenden Operatoren:

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);
}

Fertig? Leider nicht. Ich nehme an, dass du MyInt mit int vergleichen willst. Um den Vergleich von MyInt und MyInt, von int und MyInt und von MyInt und int zu unterstützen, müssen alle der sechs Operatoren dreimal überladen werden. Der Grund ist, dass keine implizite Konvertierungen von int nach MyInt möglich ist, denn der Konstruktor ist als explicit deklariert. Die einfachste Strategie ist daher, die Operatoren als friend zu deklarieren. Falls du an mehr Hintergrundwissen zu meiner Designentscheidung interessiert bist, so kannst du diese in meinem älteren Artikel "C++ Core Guidelines: Überladen von Funktionen und Operatoren" nachlesen.

Die anschließenden Codezeilen stellen die drei Überladungen für den Kleiner-als-Operator vor:

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;
}

Das bedeutet, dass du 18 Operatoren implementieren musst. Ist dies das Ende meines historischen Ausflugs? Leider noch nicht. Die 18 Operatoren sollten als constexpr deklariert werden. Darüber hinaus bietet es sich an, die Operatoren auch als noexcept zu deklarieren.

Ich denke, dieser kleine Exkurs enthält ausreichend Motivation für den Drei-Weg-Vergleichsoperator.

Ordnung mit C++20

Du kannst den Drei-Weg-Vergleichsoperator selbst definieren oder ihm vom Compiler mit =default anfordern. In beiden Fällen erzeugt der Compiler alle sechs Vergleichsoperatoren: ==, !=, <, <=, > und >=:

// 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;

}

Sowohl der benutzerdefinierte (1) als auch der vom Compiler erzeugte (2) Drei-Weg-Vergleichsoperator verrichten ihre Dienste zuverlässig:

Es gibt aber ein paar kleine Unterschiede bei den beiden Drei-Weg-Vergleichsoperatoren. Der vom Compiler deduzierte Rückgabetyp für MyInt (1) unterstützt die strenge Ordnung, der vom Compiler deduzierte Rückgabetyp für MyDouble (2) unterstützt hingegen nur die partielle Ordnung. Gleitkommazahlen können nur die partielle Ordnung unterstützen, da sich Werte wie NaN (Not a Number) nicht ordnen lassen. Zum Beispiel gilt, dass NaN == Nan zu false evaluiert.

Für den Rest des Artikels werde ich mich auf den vom Compiler erzeugten Spaceship Operator fokussieren.

Der Compiler erzeugte Spaceship Operator

Der vom Compiler erzeugte Drei-Weg-Vergleichsoperator benötigt die Header-Datei <compare>. Er ist implizit constexpr und noexcept. Darüber hinaus vergleicht er lexikografisch. Zugegeben, diese Erläuterung war zu kompakt, daher möchte ich mir zuerst constexpr genauer anschauen.

  • Vergleichen zur Compilezeit

Der Drei-Weg-Vergleichsoperator ist implizit constexpr. Konsequenterweise kann ich das vorherige Programm threeWayComparison vereinfachen und MyDouble in dem folgenden Programm zur Compilezeit vergleichen:

// 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;

}

Ich habe nach dem Ergebnis des Vergleichs zur Compilezeit gefragt (1) und es erhalten:

Der vom Compiler erzeugte Drei-Weg-Vergleichsoperator wendet lexikografisches Vergleichen an.

  • Lexikografisches Vergleichen

Lexikografisches Vergleichen bedeutet in diesem Fall, dass alle Basisklassen von links nach rechts verglichen werden und alle nichtstatischen Mitglieder der Klasse in ihrer Deklarationsreihenfolge. Hier muss ich gleich ein wenig einschränken: Aus Performanzgründen verhalten sich der ==- und der !=-Operator anders in C++20. Ich werde auf die Ausnahme der Regel noch in meinem nächsten Artikel eingehen.

Der Artikel "Simplify Your Code With Rocket Science: C++20’s Spaceship Operator" auf Microsofts C++ Team Blog stellt ein beeindruckendes Beispiel für lexikografisches Vergleichen vor:

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);
}

Ich denke, der anspruchsvollste Aspekt des Programms ist nicht der Spaceship Operator, sondern die Initialisierung von Base mittels Aggregat-Initialisierung (1). Sie erlaubt das direkte Initialisieren der Mitglieder eines Klassentyps (class, struct, union), wenn diese Mitglieder alle public sind. In diesem Fall können einfach geschweifte Klammern verwendet werden. Wenn du mehr zur Aggregat-Initialisierung wissen möchtest, bietet cppreference.com genau diese Information an. Ich werde die Aggregat-Initialisierung in einem zukünftigen Blogartikel genauer vorstellen, wenn ich mich mit der Designated Initialization in C++20 beschäftige.

Wie geht's weiter?

Der Compiler führt einen sehr smarten, fast schon magischen Job aus, wenn er alle Operatoren erzeugt. Am Ende erhält der Anwender die sich intuitiv und effizient verhaltenden Operatoren geschenkt. In meinem nächsten Artikel schaue ich mir die Magie unter der Haube genauer an.