C++20: Mehr Details zum Spaceship Operator

Modernes C++  –  45 Kommentare

Der Compiler führt beim Erzeugen der sechs Vergleichsoperatoren einen sehr smarten Job aus. Letztlich erzeugt er gratis intuitive und effiziente Vergleichsoperatoren. Eine genauere Betrachtung des Spaceship Operator.

Zuerst möchte ich in dem Artikel auf einen Punkt eingehen, den ich wohl schon in meinem letzten Artikel "C++20: Der Drei-Weg-Vergleichsoperator <=>" hätte vorstellen sollen.

Direkte Anwendung des Drei-Weg-Vergleichsoperators

Der Drei-Weg-Vergleichsoperator lässt sich auch direkt anwenden:

// spaceship.cpp

#include <compare>
#include <iostream>
#include <string>
#include <vector>

int main() {

std::cout << std::endl;

int a(2011);
int b(2014);
auto res = a <=> b; // (1)
if (res < 0) std::cout << "a < b" << std::endl;
else if (res == 0) std::cout << "a == b" << std::endl;
else if (res > 0) std::cout << "a > b" << std::endl;

std::string str1("2014");
std::string str2("2011");
auto res2 = str1 <=> str2; // (2)
if (res2 < 0) std::cout << "str1 < str2" << std::endl;
else if (res2 == 0) std::cout << "str1 == str2" << std::endl;
else if (res2 > 0) std::cout << "str1 > str2" << std::endl;

std::vector<int> vec1{1, 2, 3};
std::vector<int> vec2{1, 2, 3};
auto res3 = vec1 <=> vec2; // (3)
if (res3 < 0) std::cout << "vec1 < vec2" << std::endl;
else if (res3 == 0) std::cout << "vec1 == vec2" << std::endl;
else if (res3 > 0) std::cout << "vec1 > vec2" << std::endl;

std::cout << std::endl;

}

Der Spaceship Operator lässt sich direkt für ints (1), Strings (2) und Vektoren (3) anwenden. Dank des Wandbox-Online-Compilers und des neuesten GCC folgt hier auch schon die Ausgabe des Programms:

Jetzt stelle ich aber etwas Neues vor. C++20 führt das Konzept von "Rewritten Expressions" ein.

Ausdrücke umschreiben

Wenn der Compiler einen Ausdruck wie a < b parst, schreibt er ihn in einen Ausdruck (a <=> b) < 0 um und wendet dabei den Spacehip Operator an. Diese Regel gilt für alle sechs Vergleichsoperatoren: a OP b wird zu (a <=> b) OP 0. Das ist noch nicht die ganze Magie. Falls es keine Konvertierung von Datentyp (a) zu Datentyp (b) gibt, erzeugt der Compiler den Ausdruck 0 OP (b <=> a).

Das heißt zum Beispiel für den Kleiner-als-Operator, dass der Compiler 0 < (b <=> a) erzeugt, falls (a <=> b) < 0 nicht möglich ist. Damit kümmert sich der Compiler automatisch um die Symmetrie der Vergleichsoperatoren. Das folgende Beispiel zeigt dieses Umschreiben der Ausdrücke in Aktion:

// rewrittenExpressions.cpp

#include <compare>
#include <iostream>

class MyInt {
public:
constexpr MyInt(int val): value{val} { }
auto operator<=>(const MyInt& rhs) const = default;
private:
int value;
};

int main() {

std::cout << std::endl;

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

constexpr int int2011(2011);
constexpr int int2014(2014);

if (myInt2011 < myInt2014) std::cout << "myInt2011 < myInt2014" << std::endl; // (1)
if ((myInt2011 <=> myInt2014) < 0) std::cout << "myInt2011 < myInt2014" << std::endl;

std::cout << std::endl;

if (myInt2011 < int2014) std:: cout << "myInt2011 < int2014" << std::endl; // (2)
if ((myInt2011 <=> int2014) < 0) std:: cout << "myInt2011 < int2014" << std::endl;

std::cout << std::endl;

if (int2011 < myInt2014) std::cout << "int2011 < myInt2014" << std::endl; // (3)
if (0 < (myInt2014 <=> int2011)) std:: cout << "int2011 < myInt2014" << std::endl; // (4)

std::cout << std::endl;

}

An den Stellen (1), (2) und (3) habe ich den Kleiner-als-Operator und den entsprechenden Ausdruck für den Spaceship Operator verwendet. (4) ist besonders interessant. Das Beispiel veranschaulicht, wie der
Vergleich (int2011 < myInt2014) das Erzeugen des Spaceship-Ausdrucks (0 < (myInt2014 <=> int2011) anstößt.

Um ehrlich zu sein, besitzt MyInt ein Designproblem. Konstruktoren, die ein Argument annehmen, sollten explizit deklariert werden.

Explizite Konstruktoren

Konstruktoren, die wie MyInt(int val) nur ein Argument annehmen, sind Konvertierungskonstruktoren. Das heißt in dem konkreten Fall, dass sich MyInt mit jeder Ganz- oder Gleitkommazahl erzeugen lässt. Der Grund ist, dass jede Ganzzahl oder Gleitkommazahl sich in ein int konvertieren lässt. Diese implizite Konvertierung nach int ist in der Regel nicht im Sinne des Klassendesigners.

Erster Versuch

Um die implizite Konvertierung zu unterbinden, annotiere ich den Konstruktor mit dem explicit-Schlüsselwort und folge damit Metaregel von Python: "explicit is better than implicit". Im folgenden Beispiel kommt der explizite Konstruktor zum Einsatz:

// threeWayComparisonWithInt1.cpp

#include <compare>
#include <iostream>

class MyInt {
public:
constexpr explicit MyInt(int val): value{val} { }
auto operator<=>(const MyInt& rhs) const = default;
private:
int value;
};

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

int main() {

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

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

constexpr int int2011(2011);
constexpr int int2014(2014);

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

std::cout << "isLessThan(int2011, myInt2014): "
<< isLessThan(int2011, myInt2014) << std::endl; // (3)

std::cout << "isLessThan(myInt2011, int2014): "
<< isLessThan(myInt2011, int2014) << std::endl; // (2)

constexpr auto res = isLessThan(myInt2011, int2014);

std::cout << std::endl;

}

Das war einfach. Dank des expliziten Konstruktors ist die implizite Konvertierung von int nach MyInt in (1) nicht mehr zulässig. Der Compiler gibt mir das deutlich zu verstehen:

Wenn du sorgfältig die Fehlermeldung liest, fällt auf, dass es keinen Operator < für einen rechten int-Operanden gibt. Darüber hinaus kann der Compiler keine Konvertierung von int nach MyInt anwenden. Interessanterweise beschwert er sich zuerst über den Ausdruck (2) und nicht den Ausdruck (3). Beide sind aber nicht zulässig.

Zweiter Versuch

Um Vergleiche von MyInt und ints zu ermöglichen, benötigt MyInt einen zusätzlichen Drei-Weg-Vergleichsoperator:

#include <compare>
#include <iostream>

class MyInt {
public:
constexpr explicit MyInt(int val): value{val} { }
auto operator<=>(const MyInt& rhs) const = default; // (4)
constexpr auto operator<=>(const int& rhs) const { // (1)
return value <=> rhs;
}
private:
int value;
};

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

int main() {

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

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

constexpr int int2011(2011);
constexpr int int2014(2014);

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

std::cout << "isLessThan(int2011, myInt2014): "
<< isLessThan(int2011, myInt2014) << std::endl; // (3)

std::cout << "isLessThan(myInt2011, int2014): "
<< isLessThan(myInt2011, int2014) << std::endl; // (3)

constexpr auto res = isLessThan(myInt2011, int2014); // (2)

std::cout << std::endl;

}

In (1) habe ich den Drei-Weg-Vergleichsoperator definiert und ihn als constexpr deklariert. Der benutzerdefinierte Drei-Weg-Vergleichsoperator ist im Gegensatz zum Compiler-erzeugten nicht constexpr. Konsequenterweise kann ich die Funktion isLessThan (4) zur Compile-Zeit ausführen. Die Vergleiche von MyInts und ints sind nun in allen Kombinationen möglich:

Ich finde die Implementierung der verschiedenen Drei-Weg-Vergleichsoperatoren sehr elegant. Der Compiler erzeugt den Vergleich mit MyInts, der Benutzer definiert den Vergleich mit ints. In diesem Fall ist es ausreichend, zwei Operatoren zu definieren und damit 18 = 3 * 6 Kombinationen von Vergleichsoperatoren zu erhalten. 3 steht für die Kombination für ints und MyInts, in der zumindest ein MyInt beteiligt ist, und 6 für die Vergleichsoperatoren. In meinem letzten Artikel "C++20: Der Drei-Weg-Vergleichsoperator <=>" ging ich auf die 18 Vergleichsoperatoren ein, die es vor C++20 in diesem Fall zu implementieren galt.

Ein Punkt muss ich noch hervorheben: Mit der jetzigen Implementierung lässt sich immer MyInt mit jedem Datentyp vergleichen, der sich zu int konvertieren lässt.

Stopp! Du magst dich jetzt fragen, warum die aktuelle Implementierung, die auf einem expliziten Konstruktor basiert,

class MyInt {
public:
constexpr explicit MyInt(int val): value{val} { }
auto operator<=>(const MyInt& rhs) const = default;
constexpr auto operator<=>(const int& rhs) const {
return value <=> rhs;
}
private:
int value;
};

besser ist als die ursprüngliche Implementierung. Diese verwendet einen Konstruktor, der implizite Konvertierungen erlaubt? Beide Implementierung erlauben den Vergleich mit Ganz- und Gleitkommazahlen:

class MyInt {
public:
constexpr MyInt(int val): value{val} { }
auto operator<=>(const MyInt& rhs) const = default;
private:
int value;
};

Wie geht's weiter?

Es gibt einen feinen Unterschied zwischen dem expliziten und dem nicht expliziten Konstruktor im Fall von MyInt. Dieser Unterschied wird deutlich, wenn ich in meinem nächsten Artikel MyInt int ähnlich entwerfe. Dies sind aber nicht alle Punkte: Der Compiler erzeugt aus Performanzgründen spezielle ==- und !=-Operatoren. Darüber hinaus verdient das Zusammenspiel der klassischen und der Drei-Weg-Vergleichsoperatoren einen eigenen Artikel.