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.

In Pocket speichern vorlesen Druckansicht
Lesezeit: 8 Min.
Von
  • Rainer Grimm
Inhaltsverzeichnis

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" mit der Klasse MyInt. In ihm hatte ich angekündigt, den Unterschied zwischen einem expliziten und nichtexpliziten Konstruktor für MyInt genauer herausarbeiten zu wollen. Der Faustregel lautet, dass Konstruktoren, die nur ein Argument erhalten, explizit sein sollen.

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 MyInt. 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.

In meinem Artikel "C++20: Der Drei-Weg-Vergleichsoperator" 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 schrieb zu meinem letzten Artikel "C++20: More Details to the Spaceship Operator" 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 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

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.

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. ()