C++20: Optimierte Vergleiche mit dem Spaceship Operator
Mit diesem Artikel schließe ich meine Miniserie zum Drei-Weg-Vergleichsoperator mit ein paar subtilen Feinheiten ab. Sie betreffen den durch den Compiler erzeugten Operator == und != sowie das Zusammenspiel der klassischen Vergleichsoperatoren mit dem Drei-Weg-Vergleichsoperator.
Mit diesem Artikel schließe ich meine Miniserie zum Drei-Weg-Vergleichsoperator mit ein paar subtilen Feinheiten ab. Sie betreffen den durch den Compiler erzeugten Operator == und != sowie das Zusammenspiel der klassischen Vergleichsoperatoren mit dem Drei-Weg-Vergleichsoperator.
Ich beendete meinen letzten Artikel "C++20: Mehr Details zum Spaceship Operator [1]" mit der Klasse MyInt
. In ihm hatte ich angekündigt, den Unterschied zwischen einem expliziten und nichtexpliziten Konstruktor für MyIn
t
genauer herausarbeiten zu wollen. Der Faustregel lautet, dass Konstruktoren, die nur ein Argument erhalten, explizit sein sollen.

Expliziter Konstruktor
Zur Erinnerung: Hier ist der benutzerdefinierte Datentyp MyInt
meines letzten Artikels:
// threeWayComparisonWithInt2.cpp
#include <compare>
#include <iostream>
class MyInt {
public:
constexpr explicit MyInt(int val): value{val} { } // (1)
auto operator<=>(const MyInt& rhs) const = default; // (2)
constexpr auto operator<=>(const int& rhs) const { // (3)
return value <=> rhs;
}
private:
int value;
};
int main() {
std::cout << std::boolalpha << std::endl;
constexpr MyInt myInt2011(2011);
constexpr MyInt myInt2014(2014);
std::cout << "myInt2011 < myInt2014: " << (myInt2011 < myInt2014) << std::endl; // (4)
std::cout << "myInt2011 < 2014: " << (myInt2011 < 2014) << std::endl; // (5)
std::cout << "myInt2011 < 2014.5: " << (myInt2011 < 2014.5) << std::endl; // (6)
std::cout << "myInt2011 < true: " << (myInt2011 < true) << std::endl; // (7)
std::cout << std::endl;
}
Konstruktoren, die nur ein Argument wie (1) annehmen, werden gerne Konvertierungskonstruktoren genannt. Er heißt so, da er wie in in dem konkreten Fall ein int
annehmen um damit ein MyInt
erzeugen kann.
MyInt
besitzt einen expliziten Konstruktor (1), einen durch den Compiler erzeugten Drei-Weg-Vergleichsoperator (2) und einen benutzerdefinierten Vergleichsoperator für int
(3). (4) verwendet den Compiler-erzeugten Vergleichsoperator für MyInt
; (5, 6 und 7) nutzen hingegen den benutzerdefinierten Vergleichsoperator für int
. Dank impliziter Verengung nach int
(6) und der integralen Promotion nach int
lassen sich Instanzen von Myint
mit double
- oder bool
-
Werten vergleichen.

Wenn ich MyInt
einem int
ähnlich entwerfe, wird der Vorteil eines expliziten Konstruktors (1) offensichtlich. Im folgenden Beispiel bietet MyInt
grundlegende Arithmetik an:
// threeWayComparisonWithInt4.cpp
#include <compare>
#include <iostream>
class MyInt {
public:
constexpr explicit MyInt(int val): value{val} { } // (3)
auto operator<=>(const MyInt& rhs) const = default;
constexpr auto operator<=>(const int& rhs) const {
return value <=> rhs;
}
constexpr friend MyInt operator+(const MyInt& a, const MyInt& b){
return MyInt(a.value + b.value);
}
constexpr friend MyInt operator-(const MyInt& a,const MyInt& b){
return MyInt(a.value - b.value);
}
constexpr friend MyInt operator*(const MyInt& a, const MyInt& b){
return MyInt(a.value * b.value);
}
constexpr friend MyInt operator/(const MyInt& a, const MyInt& b){
return MyInt(a.value / b.value);
}
friend std::ostream& operator<< (std::ostream &out, const MyInt& myInt){
out << myInt.value;
return out;
}
private:
int value;
};
int main() {
std::cout << std::boolalpha << std::endl;
constexpr MyInt myInt2011(2011);
constexpr MyInt myInt2014(2014);
std::cout << "myInt2011 < myInt2014: " << (myInt2011 < myInt2014) << std::endl;
std::cout << "myInt2011 < 2014: " << (myInt2011 < 2014) << std::endl;
std::cout << "myInt2011 < 2014.5: " << (myInt2011 < 2014.5) << std::endl;
std::cout << "myInt2011 < true: " << (myInt2011 < true) << std::endl;
constexpr MyInt res1 = (myInt2014 - myInt2011) * myInt2011; // (1)
std::cout << "res1: " << res1 << std::endl;
constexpr MyInt res2 = (myInt2014 - myInt2011) * 2011; // (2)
std::cout << "res2: " << res2 << std::endl;
constexpr MyInt res3 = (false + myInt2011 + 0.5) / true; // (3)
std::cout << "res3: " << res3 << std::endl;
std::cout << std::endl;
}
MyInt
bietet nun grundlegende Arithmetik mit Objekten vom Datentyp MyInt
(1) an; MyInt
stellt aber keine Arithmetik für Built-in-Daten wie int
(2), double
oder bool
(3) zur Verfügung. Die Fehlermeldung des Compilers bringt das direkt auf den Punkt:

Der Compiler kennt im Fall (2) keine Konvertierung von int
nach const MyInt
und in (3) keine Konvertierung von bool
nach const MyIn
t. Ein möglicher Weg, aus einem int
, double
oder bool
eine const MyInt
zu erzeugen, ist ein nichtexpliziter Konstruktor. Daher lässt sich das Programm übersetzen und ausführen, wenn ich das Schlüsselwort explicit
des Konstruktors (1) entferne, da nun implizite Konvertierung einsetzt. Das Ergebnis mag überraschen.

Die durch den Compiler erzeugten Operatoren ==
und !=
sind aus Performanzgründen besonders.
Optimierte Operatoren == und !=
In meinem Artikel "C++20: Der Drei-Weg-Vergleichsoperator [2]" stellte ich vor, dass die Compiler-erzeugten Vergleichsoperatoren lexikographisches Vergleichen anwenden. Das bedeutet in diesem Fall, dass alle Basisklassen von links nach rechts verglichen werden und alle nichtstatischen Mitglieder der Klasse in ihrer Deklarationsreihenfolge.
Andrew Koenig [3]schrieb zu meinem letzten Artikel "C++20: More Details to the Spaceship Operator [4]" einen Kommentar auf der Facebook-Gruppe "C++ Enthusiast", den ich zitieren möchte.
There’s a potential performance problem with <=> that might be worth mentioning: for some types, it is often possible to implement == and != in a way that potentially runs much faster than <=>.
For example, for a vectorlike or stringlike class, == and != can stop after determining that the two values being compared have different lengths, whereas <=> has to examine elements until it finds a difference. If one value is a prefix of the other, that makes the difference between O(1) and O(n).
Ich habe nichts zu Andrews Kommentar hinzuzufügen außer eine kleine Beobachtung. Das Standardisierungkomitee war sich dieses Performanzproblems bewusst und hat es mit dem Proposal P1185R2 [5] adressiert. Konsequenterweise vergleichen die Compiler-erzeugten Operatoren ==
und !=
im Fall des Strings oder Vektors zuerst deren Länge und dann, falls es notwendig ist, ihren Inhalt
Benutzerdefinierte und Compiler-erzeugte Vergleichsoperatoren
Falls du einen der sechs Vergleichsoperatoren definierst und auch alle sechs durch den Compiler erzeugen lässt, stellt sich die Frage: Welcher Operator wird verwendet? So besitzt zum Beispiel meine neue Klasse MyInt
einen benutzerdefinierten Kleiner-als-Operator und einen Gleichheitsoperator. Dazu lasse ich mir alle sechs Operatoren vom Compiler erzeugen:
// threeWayComparisonWithInt5.cpp
#include <compare>
#include <iostream>
class MyInt {
public:
constexpr explicit MyInt(int val): value{val} { }
bool operator == (const MyInt& rhs) const {
std::cout << "== " << std::endl;
return value == rhs.value;
}
bool operator < (const MyInt& rhs) const {
std::cout << "< " << std::endl;
return value < rhs.value;
}
auto operator<=>(const MyInt& rhs) const = default;
private:
int value;
};
int main() {
MyInt myInt2011(2011);
MyInt myInt2014(2014);
myInt2011 == myInt2014;
myInt2011 != myInt2014;
myInt2011 < myInt2014;
myInt2011 <= myInt2014;
myInt2011 > myInt2014;
myInt2011 >= myInt2014;
}
Um nachvollziehen zu können, wann meine benutzerdefinierten Operatoren ==
und <
zum Zuge kommen, lasse ich eine entsprechende Nachricht auf std::cout
schreiben. Dadurch kann ich beide Operatoren nicht mehr als constexpr
deklarieren, denn std::cout
wird zur Laufzeit des Programms ausgeführt.

In diesem Fall verwendet der Compiler den benutzerdefinierten Operator ==
und <
. Darüber hinaus erzeugt er den !=
- aus dem ==
-Operator. Der Compiler erzeugt aber nicht den ==
- aus dem !=-
Operator.
Diese Ausgabe hat mich nicht überrascht, da sich hier C++ ähnlich wie Python verhält. In Python 3 erzeugt der Compiler den Operator !=
aus dem Operator ==
. Der umgekehrte Automatismus gilt in Python 3 auch nicht. In Python 2 besitzt die sogenannte rich comparison (die benutzerdefinierten sechs Vergleichsoperatoren) eine höhere Priorität als der Drei-Weg-Vergleichsoperator __cmp__
. Ich habe im letzten Satz explizit von Python 2 gesprochen, da der Drei-Weg-Vergleichsoperator nicht mehr Bestandteil von Python 3 ist.
Wie geht's weiter?
Designated Initialization ist ein Spezialfall der Aggregat Initialization und erlaubt es, die Mitglieder einer Klasse direkt mithilfe ihres Namens zu initialisieren. Genau darum geht es in meinem nächsten Artikel zu C++20. ( [6])
URL dieses Artikels:
https://www.heise.de/-4797164
Links in diesem Artikel:
[1] https://heise.de/-4790117
[2] https://heise.de/-4782690
[3] https://en.wikipedia.org/wiki/Andrew_Koenig_(programmer)
[4] https://bit.ly/MoreDetailsToSpaceship
[5] http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2019/p1185r2.html
[6] mailto:rainer@grimm-jaud.de
Copyright © 2020 Heise Medien