C++ Core Guidelines: Vergleiche und die Funktionen swap und hash

Modernes C++  –  10 Kommentare


Ein Blog-Beitrag zu Vergleichen und die wichtigen Funktionen swap und hash. Damit endet die Tour zu Regeln in den Guidelines, die sich mit den Default-Operationen befassen.

Hier sind die neun Regeln:

C.80: Use =default if you have to be explicit about using the default semantics

Die Fünferregel besagt, dass man beim Implementieren einer der speziellen fünf Regeln alle fünf implementieren sollte. Hier kommt der entscheidende Punkt: Falls ich den Destruktor wie im folgenden Beispiel implementiere, sollte ich auch den Copy- und Move-Konstruktor und den Copy- und Move-Zuweisungsoperator zur Verfügung stellen.

class Tracer {
string message;
public:
Tracer(const string& m) : message{m} { cerr << "entering " << message << '\n'; }
~Tracer() { cerr << "exiting " << message << '\n'; }

Tracer(const Tracer&) = default;
Tracer& operator=(const Tracer&) = default;
Tracer(Tracer&&) = default;
Tracer& operator=(Tracer&&) = default;
};

Die C++-Laufzeit hat die ganze Arbeit erledigt. Aber ich kann auch selber Hand anlegen. Das ist im besten Fall langweilig, im schlechtesten fehlerhaft.

class Tracer2 {
string message;
public:
Tracer2(const string& m) : message{m} { cerr << "entering " << message << '\n'; }
~Tracer2() { cerr << "exiting " << message << '\n'; }

Tracer2(const Tracer2& a) : message{a.message} {}
Tracer2& operator=(const Tracer2& a) { message = a.message; return *this; }
Tracer2(Tracer2&& a) :message{a.message} {}
Tracer2& operator=(Tracer2&& a) { message = a.message; return *this; }
};

C.81: Use =delete when you want to disable default behavior (without wanting an alternative)

Gelegentlich sind die Methoden nicht erwünscht, die die C++-Laufzeit automatisch erzeugt. Hier kommt delete ins Spiel. delete wird sehr häufig in der Standard Library eingesetzt. So ist der Copy-Konstruktor von Datentypen wie Locks, Mutexe, Promises oder Futures auf delete gesetzt. Dasselbe gilt für den Smart Pointer std::unique_ptr: std::unique_ptr(const std::unique_ptr&) = delete.

delete kann natürlich auch dazu verwendet werden, seltsame Datentypen zu erzeugen. Instanzen von Immortal lassen sich nicht löschen.

class Immortal {
public:
~Immortal() = delete; // do not allow destruction
// ...
};

void use()
{
Immortal ugh; // error: ugh cannot be destroyed
Immortal* p = new Immortal{};
delete p; // error: cannot destroy *p
}

C.82: Don't call virtual functions in constructors and destructors

Diese Regel ist der Regel C.50: Use a factory function if you need "virtual behavior" during initialization, die ich in dem Artikel "C++ Core Guidelines: Konstruktoren" vorstellte, sehr ähnlich.

Bei den nächsten drei Regeln dreht sich alles um swap-Funktionen. Daher werde ich sie in einem Rutsch behandeln.

C.83: For value-like types, consider providing a noexcept swap function, C.84: A swap may not fail, and C.85: Make swap noexcept

Eine swap-Funktion ist ziemlich praktisch:

template< typename T >
void std::swap(T & a, T & b) noexcept {
T tmp(std::move(a));
a = std::move(b);
b = std::move(tmp);
}

Der C++-Standard bietet mehr als 40 Spezialisierungen der std::swap-Funktion an. Man kann sie als elementare Bausteine für viele C++-Idiome wie den Copy- und Move-Zuweisungsoperator verwenden. Eine swap-Funktion soll keine Ausnahme werfen. Daher sollte sie als noexcept deklariert werden.

Das folgende Beispiel zeigt einen Move-Zuweisungsoperator, der std:.swap verwendet. pdate zeigt auf ein Array:

class Cont{     
public:
Cont& operator=(Cont&& rhs);

private:
int *pData;
};

Cont& Cont::operator=(Cont&& rhs){
std::swap(pData, rhs.pData);
return *this;
}

C.86: Make == symmetric with respect of operand types and noexcept

Wer seine Anwender nicht überraschen will, sollte den == operator symmetrisch implementieren. In dem folgenden Beispiel kommt ein unintuitiver == operator zum Einsatz, der innerhalb der Klasse definiert ist.

class MyNumber {
int num;
public:
MyNumber(int n): num(n){};
bool operator==(const MyNumber& rhs) const { return num == rhs.num; }
};

int main(){
MyNumber(5) == 5;
// 5 == MyNumber(5);
}

Der Aufruf MyNumber(5) == 5 ist gültig, da der Konstruktor sein int-Argument in eine Instanz von MyNumber konvertiert. Die letzte Zeile hingegen gibt eine Fehlermeldung. Der Vergleichsoperator von natürlichen Zahlen nimmt kein Argument vom Datentyp MyNumber an.

Die elegante Art die Asymmetrie zu lösen, ist den == operator in der Klasse als friend zu deklarieren. Hier ist die zweite Variante von MyNumber.

class MyNumber {
int num;
public:
MyNumber(int n): num(n){};
bool operator==(const MyNumber& rhs) const { return num == rhs.num; }
friend bool operator==(const int& lhs, const MyNumber& rhs){
return lhs == rhs.num;
}
};

int main(){
MyNumber(5) == 5;
5 == MyNumber(5);
}

Die Überraschungen gehen weiter.

C.87: Beware of == on base classes

Das Schreiben eines narrensicheren == operator für Typhierarchien ist ein sehr anspruchsvoller Job. Die Guidelines geben ein Beispiel dafür, was alles schiefgehen kann. Hier ist die Typhierarchie:

class B {
string name;
int number;
virtual bool operator==(const B& a) const
{
return name == a.name && number == a.number;
}
// ...
};

class D :B {
char character;
virtual bool operator==(const D& a) const
{
return name == a.name && number == a.number && character == a.character;
}
// ...
};

Probieren geht über Studieren.

B b = ...
D d = ...
b == d; // compares name and number, ignores d's character // (1)
d == b; // error: no == defined // (2)
D d2;
d == d2; // compares, name, number, and character
B& b2 = d2;
b2 == d; // compares name and number, ignores d2's and d's character // (1)

Vergleiche von Instanzen vom Typ B oder vom Typ D verhalten sich anständig. Werden die beiden Datentypen bei Vergleichen aber gemischt, geht die Überraschung los. Falls Bs == operator verwendet wird, wird Ds character (1) ignoriert. Falls Ds == operator verwendet wird, schlägt der Vergleich für Instanzen von B fehl (3). Die letzte Zeile ist ziemlich trickreich. Der == operator von B wird eingesetzt. Warum? Der == operator von D überschreibt doch den == operator von B. Wirklich? Nein! Beide Operatoren haben verschiedene Signaturen. Einer bekommt eine Instanz von B, der andere Operator eine Instanz von D. Das heißt insbesondere, dass Ds Version nicht Bs Version überschreibt.

Diese Beobachtungen gelten auch für die verbleibenden fünf Vergleichsoperatoren: !=, <, <=, and >=.

C.89: Make a hash noexcept

Hashfunktionen werden implizit von ungeordneten assoziativen Containern wie std::unordered_map verwendet. Der Anwender erwartet einfach nicht, dass diese eine Ausnahme werfen. Wer einen eigenen Datentyp als Schlüssel in einem ungeordneten assoziativen Container verwenden will, muss die Hashfunktion für den Schlüssel definieren.

Dafür implementiert man die hash-Funktion, indem man die std::hash-Funktion auf die Attribute einer Klasse anwendet und deren Werte mit ^ (xor) kombiniert.

struct MyKey{
int valInt = 5;
double valDou = 5.5;
};

struct MyHash{
std::size_t operator()(MyKey m) const {
std::hash<int> hashVal1;
std::hash<double> hashVal2;
return hashVal1(m.valInt) ^ hashVal2(m.valDou);
}
};

Gemäß den Guidelines sollte das nächste Themen Container und andere Ressource-Handle sein. Aber in den Guidelines gibt es erst die Überschrift zu den Regeln, daher werde ich diese Regeln überspringen und mich direkt auf die Lambda-Ausdrücke stürzen.

  • Funktionen anfordern und unterdrücken mit default und delete: Automatik mit Methode (freier Artikel für das Linux-Magazin)