Aus Linux-Magazin 04/2022

C++-Core-Guidelines – Folge 63

© Rattanapon Muanpimthong / 123RF.com

C++ bietet viele Möglichkeiten, einen Algorithmus an einen Datentyp anzupassen. Jede Variante basiert auf dem Überladen von Operatoren und Funktionen oder auf der Template-Spezialisierung und hat spezifische Vor- und Nachteile.

Um es vorwegzunehmen: Dieser Artikel hat keinen besonders ausgeprägten Bezug zu den beiden Regeln T.64 “Use specialization to provide alternative implementations of class templates” [1] und T.67: “Use specialization to provide alternative implementations for irregular types” [2] der Core Guidelines. Er behandelt stattdessen beide Regeln in einem deutlich breiteren Kontext.

Das Problem

Fangen wir ganz einfach an. Die Klasse »Account« in Listing 1 besitzt einen Kontostand »balance«. Er soll für zwei Accounts festlegen, auf welchem sich weniger Geld befindet.

Listing 1

Accounts vergleichen

#include <iostream>
class Account{
  public:
    Account() = default;
    Account(double b): balance(b){}
    double getBalance() const {
    return balance;
    }
  private:
    double balance{0.0};
};
template<typename T>
bool isSmaller(T fir, T sec){
  return fir < sec;
}
int main(){
  std::cout << std::boolalpha << std::endl;
  double firDoub{};
  double secDoub{2014.0};
  std::cout << "isSmaller(firDoub, secDoub): " << isSmaller(firDoub, secDoub) << std::endl;
  Account firAcc{};
  Account secAcc{2014.0};
  std::cout << "isSmaller(firAcc, secAcc): " << isSmaller(firAcc, secAcc) << std::endl;
  std::cout << std::endl;
}

Zwei Datentypen zu vergleichen ist eine generische Idee. Daher bietet es sich an, die Funktion »isSmaller()« als Funktions-Template (ab Zeile 14) zu implementieren. Allerdings klappt das nicht, da sich zwei Accounts nicht vergleichen lassen. Die Klasse Account unterstützt den Kleiner-als-Operator (»<«) nicht, Abbildung 1 bringt das deutlich auf den Punkt.

Abbildung 1: Der Account unterst&uuml;tzt keinen Kleiner-als-Operator.

Abbildung 1: Der Account unterstützt keinen Kleiner-als-Operator.

Das Überladen des »<«-Operators stellt wohl den offensichtlichsten Weg zur Lösung des Problems dar: Schon die Fehlermeldung des Programms in Abbildung 1 deutet das an.

<C><<C>-Operator überladen

Dank des Überladens des »<«-Operators lassen sich die zwei Accounts vergleichen. In den Zeilen 7 bis 9 von Listing 2 wird dazu der »<«-Operator überladen. Er nimmt als Argumente zwei Accounts entgegen und greift mit der Member-Funktion »getBalance« auf den jeweiligen Kontostand zu.

Listing 2

<-Operator überladen

#include <iostream>
class Account{
  public:
    Account() = default;
    Account(double b): balance(b){}
    friend bool operator < (Account const& fir, Account const& sec) {
      return fir.getBalance() < sec.getBalance();
    }
    double getBalance() const {
      return balance;
    }
  private:
    double balance{0.0};
};
template<typename T>
bool isSmaller(T fir, T sec){
  return fir < sec;
}
int main(){
  std::cout << std::boolalpha << '\n';
  double firDou{};
  double secDou{2014.0};
  std::cout << "isSmaller(firDou, secDou): " << isSmaller(firDou, secDou) << '\n';
  Account firAcc{};
  Account secAcc{2014.0};
  std::cout << "isSmaller(firAcc, secAcc): " << isSmaller(firAcc, secAcc) << '\n';
  std::cout << '\n';
}

Das hat ein starkes Geschmäckle: Ein Datentyp wie Account sollte nicht nur den Kleiner-als-Operator umsetzen, sondern das Konzept Ordnung. Das wiederum setzt das Konzept Gleichheit voraus und würde noch die vier verbleibenden Ordnungsrelationen »<«, »<=«, »>«, »>=« benötigen. Das ergäbe zusammen mit dem Gleichheits- und dem Ungleich-Operator in Summe sechs Operatoren, die es zu implementieren gilt.

Das riecht nach viel Schreibarbeit, doch ein kleiner Sprung in die Zukunft (von C++11 auf C++20) löst das Problem in Wohlgefallen auf. C++20 erlaubt es, die sechs Ordnungsrelationen automatisch zu erzeugen.

Der Drei-Weg-Vergleichsoperator

Den Drei-Weg-Vergleichsoperator »<=>« nennt man auch Spaceship Operator. Er bestimmt für zwei Werte »A« und »B«, ob »A < B«, »A = B« oder »A > B« ist. Der Operator lässt sich automatisch vom Compiler erzeugen oder direkt definieren. Der automatisch vom Compiler erzeugte Spaceship Operator benötigt die Header-Datei »compare«. Der Einfachheit halber stellt Listing 3 den automatisch erzeugten Drei-Weg-Vergleichsoperator für »Account« vor.

Listing 3

Drei-Weg-Vergleichsoperator

class Account{
  public:
    Account() = default;
    Account(double b): balance(b){}
    auto operator<=>(const Account&) const = default;
    double getBalance() const {
      return balance;
    }
  private:
    double balance{0.0};
};

Der Ausdruck »auto operator<=>(const Account&) const = default;« fordert den Drei-Weg-Vergleichsoperator vom Compiler an. Der dadurch erzeugte Spaceship Operator ist »constexpr« und wendet einen lexikografischen Vergleich an. Lexikografisches Vergleichen bedeutet in diesem Fall, dass er alle Basisklassen von links nach rechts vergleicht und alle nichtstatischen Mitglieder der Klasse in ihrer Deklarationsreihenfolge. Aus Performance-Gründen verhalten sich lediglich der »==«- und der »!=«-Operator in C++20 anders. Prüft man etwa zwei Strings auf Gleichheit, genügt es oft schon zu bestimmen, ob sie dieselbe Länge besitzen.

Der direkt definierte Spaceship Operator bietet sich dann an, wenn die Ordnung der Datentypen von der lexikografischen Ordnung abweichen soll. So besitzt der Account in Listing 4 einen zusätzlichen Familiennamen. Als Vergleichskriterium soll aber der Kontostand dienen. Der Spaceship Operator in den Zeilen 5 bis 7 vergleicht daher lediglich die Kontostände der beiden Accounts. Falls man die Definition von »Account« nicht verändern kann, lässt sich zumindest das Funktions-Template »isSmaller« vollständig spezialisieren.

Listing 4

Direkt definierter Spaceship Operator

class Account{
  public:
    Account(const std::string& n): name(n) {}
    Account(const std::string& n, double b): name(n), balance(b){}
    auto operator<=>(const Account& other) const {
      return balance <=> other.getBalance();
    }
    double getBalance() const {
      return balance;
    }
  private:
    std::string name;
    double balance{0.0};
};

Vollständige Spezialisierung

Listing 5 zeigt die vollständige Spezialisierung des Funktions-Templates »isSmaller« für »Account« (Zeile 17 bis 20). Anstelle eines vollständig spezialisierten Funktions-Templates sollte aber eine klassische Funktion wie in Listing 6 zum Einsatz kommen.

Der Compiler zieht in diesem Fall die Funktion dem Funktions-Template nur dann vor, wenn die Argumente vom Typ »Account« sind. Das Funktions-Template »isSmaller« lässt sich nicht nur vollständig spezialisieren wie in Listing 5 oder durch eine Funktion überladen wie in Listing 6: Man kann es auch erweitern.

Listing 5

Vollständige Spezialisierung

class Account{
  public:
    Account() = default;
    Account(double b): balance(b){}
    double getBalance() const {
      return balance;
    }
  private:
    double balance{0.0};
};
template<typename T>
bool isSmaller(T fir, T sec){
  return fir < sec;
}
template<>
bool isSmaller<Account>(Account fir, Account sec){
  return fir.getBalance() < sec.getBalance();
}

Listing 6

isSmaller() überladen

bool isSmaller(Account fir, Account sec){
  return fir.getBalance() < sec.getBalance();
}

Erweitern der Vergleichsfunktion

Listing 7 erweitert das Funktion-Template »isSmaller« um den Typparameter »Pred«, der ein binäres Prädikat aufnehmen kann. Dabei handelt es sich um eine Funktion, die zwei Argumente annimmt und einen Wahrheitswert zurückgibt. Dieses Muster kommt in der Standardvorlagenbibliothek häufig zum Einsatz.

Listing 7

Erweiterung der Vergleichsfunktion

#include <functional>
#include <iostream>
#include <string>
class Account{
  public:
    Account() = default;
    Account(double b): balance(b){}
    double getBalance() const {
      return balance;
    }
  private:
    double balance{0.0};
};
template <typename T, typename Pred = std::less<T> >
bool isSmaller(T fir, T sec, Pred pred = Pred() ){
  return pred(fir, sec);
}
int main(){
  std::cout << std::boolalpha << '\n';
  double firDou{};
  double secDou{2014.0};
  std::cout << "isSmaller(firDou, secDou): " << isSmaller(firDou, secDou) << '\n';
  Account firAcc{};
  Account secAcc{2014.0};
  auto res = isSmaller(firAcc, secAcc, [](const Account& fir, const Account& sec){
    return fir.getBalance() < sec.getBalance();
  });
  std::cout << "isSmaller(firAcc, secAcc): " << res << '\n';
  std::cout << '\n';
  std::string firStr = "AAA";
  std::string secStr = "BB";
  std::cout << "isSmaller(firStr, secStr): " << isSmaller(firStr, secStr) << '\n';
  auto res2 = isSmaller(firStr, secStr, [](const std::string& fir, const std::string& sec){
    return fir.length() < sec.length();
  });
  std::cout << "isSmaller(firStr, secStr): " << res2 << '\n';
  std::cout << '\n';
}

Das Funktion-Template verwendet in Zeile 16 das vordefinierte Funktionsobjekt [3] »std::less<T>«. Das binäre Prädikat »Pred« wird in Zeile 17 instanziiert und in Zeile 18 verwendet. Zusätzlich kann man wie in den Zeilen 29 und 38 ein binäres Prädikat direkt angeben; ein Lambda-Ausdruck ist wie geschaffen für diese Aufgabe. Abbildung 2 zeigt die Ausgabe des Programms.

Abbildung 2: Ein Vergleich von Datentypen mit verschiedenen Pr&auml;dikaten.

Abbildung 2: Ein Vergleich von Datentypen mit verschiedenen Prädikaten.

Ein kleines Fazit

Wo liegen nun die Unterschiede der drei vorgestellten Variationen rund um Accounts? Die Tabelle “Lösungen im Vergleich” fasst die Ergebnisse kurz und knackig zusammen. Der Einfachheit halber geht sie nur auf den Operator »<« ein. Alle Anmerkungen zu diesem Operator lassen sich freilich auch direkt auf den Operator »<=>« anwenden.

allgemeine Lösung

Konfigurationszeitpunkt

Erweiterung

Variabilität

Operator »<«

ja

Übersetzung

Datentyp

nein

vollständige Spezialisierung

nein

Übersetzung

Funktion

nein

Erweiterung mit Prädikat

ja

Laufzeit

Funktion

ja

Die vollständige Spezialisierung stellt keine allgemeine Lösung dar. Sie funktioniert nur für die Funktion »isSmaller()«. Im Gegensatz dazu lässt sich der Operator »<« recht häufig anwenden, und jeder Datentyp kann die Erweiterung mit Prädikat verwenden.

Der Operator »<« und die vollständige Spezialisierung sind statisch. Das bedeutet, dass die Reihenfolge während der Kompilierung definiert wird und im Datentyp oder der generischen Funktion kodiert ist. Im Gegensatz dazu lässt sich die Erweiterung mit dem Prädikat mit verschiedenen Prädikaten aufrufen. Die Entscheidung erfolgt zur Laufzeit.

Der Operator »<« erweitert den Datentyp, die beiden anderen Varianten die Funktion. Die Erweiterung mit Prädikat erlaubt es, den Typ auf verschiedene Arten zu ordnen. Damit lassen sich beispielsweise Zeichenketten lexikografisch oder nach ihrer Länge vergleichen.

Auf der Grundlage dieses Vergleichs kann man als Faustregel definieren: Implementieren Sie einen Operator »<« für die Datentypen, und fügen Sie bei Bedarf den generischen Funktionen eine Erweiterung hinzu.

Ausblick

Verwendet der Programmierer C++-Container richtig, dankt es das Programm mit optimaler Arbeitsgeschwindigkeit. Der nächste Artikel dieser Serie zu den C++-Core-Guidelines stellt die entsprechenden Regeln genauer vor. (jcb/jlu)

DIESEN ARTIKEL ALS PDF KAUFEN
EXPRESS-KAUF ALS PDFUmfang: 4 HeftseitenPreis €0,99
(inkl. 19% MwSt.)
LINUX-MAGAZIN KAUFEN
EINZELNE AUSGABE Print-Ausgaben Digitale Ausgaben
ABONNEMENTS Print-Abos Digitales Abo
TABLET & SMARTPHONE APPS Readly Logo
E-Mail Benachrichtigung
Benachrichtige mich zu:
0 Kommentare
Älteste
Neuste Beste Bewertung
Inline Feedbacks
Alle Kommentare anzeigen
Nach oben