C++ Core Guidelines: reguläre und semireguläre Datentypen

Modernes C++  –  8 Kommentare

Das Thema des Blogbeitrags ist dann sehr wichtig, wenn du eigene Datentypen entwirfst: reguläre und semireguläre Datentypen.

Genau um diese Regel geht es heute:

T.46: Require template arguments to be at least Regular or SemiRegular

Die erste Frage, die ich zu beantworten habe, ist recht offensichtlich. Was ist ein regulärer oder ein semiregulärer Datentyp? Meine Antwort basiert auf dem Proposal p0898: Ich denke, du ahnst es bereits. Regular und SemiRegular sind Concepts, die auf elementareren Concepts basieren.

Regular
  • DefaultConstructible
  • CopyConstructible, CopyAssignable
  • MoveConstructible, MoveAssignable
  • Destructible
  • Swappable
  • EqualityComparable
SemiRegular
  • Regular – EqualityComparable

Der Begriff Regular geht auf Alexander Stephanov, den Vater der Standard Template Library zurück. Er führte ihn in seinem Buch "Fundamentals of Generic Programming" ein (zum Buch gibt es hier einen kleinen Auszug). Es ist relativ einfach, die acht Concepts im Kopf zu behalten, die einen regulären Datentyp definieren. Zuerst einmal gibt es die sehr bekannte Sechserregel.

  • Default constructor: X()
  • Copy constructor: X(const X&)
  • Copy assignment: operator=(const X&)
  • Move constructor: X(X&&)
  • Move assignment: operator=(X&&)
  • Destructor: ~X()

Füge nun lediglich die Concepts Swappable und EqualityComparable hinzu und du erhältst das Concept Regular. Es gibt eine deutlich umgangssprachlichere Art, auszudrücken, dass ein Datentyp regulär ist: T is regulär, wenn er sich wie ein int anfühlt.

Um nun SemiRegular zu erhalten, musst du lediglich EqualityComparable von dem Concept Regular subtrahieren.

Ich höre bereits deine nächste Frage: Warum sollen unsere Template-Argumente zumindest regulär oder semiregulär sein oder sich wie ints verhalten? Die STL-Container und die Algorithmen insbesondere sind für reguläre Datentypen konzipiert.

Was ist nun ein häufig verwendeter aber nicht regulärer Datentyp? Genau, eine Referenz.

Referenzen sind nicht regulär

Dank der Type-Traits-Bibliothek prüft das folgende Programm zu Compile-Zeit, ob int& ein semiregulärer Datentyp ist:

// semiRegular.cpp

#include <iostream>
#include <type_traits>

int main(){

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

std::cout << "std::is_default_constructible<int&>::value: "
<< std::is_default_constructible<int&>::value << std::endl;
std::cout << "std::is_copy_constructible<int&>::value: "
<< std::is_copy_constructible<int&>::value << std::endl;
std::cout << "std::is_copy_assignable<int&>::value: "
<< std::is_copy_assignable<int&>::value << std::endl;
std::cout << "std::is_move_constructible<int&>::value: "
<< std::is_move_constructible<int&>::value << std::endl;
std::cout << "std::is_move_assignable<int&>::value: "
<< std::is_move_assignable<int&>::value << std::endl;
std::cout << "std::is_destructible<int&>::value: "
<< std::is_destructible<int&>::value << std::endl;
std::cout << std::endl;
std::cout << "std::is_swappable<int&>::value: "
<< std::is_swappable<int&>::value << std::endl; // requires C++17

std::cout << std::endl;

}

Zuerst einmal setzt die Funktion std::is_swappable den C++17-Standard voraus. Hier ist die Ausgabe des Programms:

Du siehst, eine Referenz wie int& besitzt keinen Default-Konstruktor. Die Ausgabe zeigt, dass eine Referenz nicht semiregulär und damit auch nicht regulär ist. Um zur Compile-Zeit zu prüfen, ob ein Datentyp regulär ist, benötige ich die Funktion isEqualityComparable. Diese Funktion ist nicht Bestandteil der Type-Traits-Bibliothek. Selbst ist der Entwickler.

isEqualityComparable

In C++20 werden wir wohl das Detection-Idiom erhalten, das Bestandteil des library fundamental TS v2 ist. Damit ist es ein Kinderspiel, isEqualityComparable zu implementieren:

// equalityComparable.cpp

#include <experimental/type_traits> // (1)
#include <iostream>

template<typename T> // (2)
using equal_comparable_t = decltype(std::declval<T&>() == std::declval<T&>());

template<typename T>
struct isEqualityComparable:
std::experimental::is_detected<equal_comparable_t, T>{}; // (3)

struct EqualityComparable { }; // (4)
bool operator == (EqualityComparable const&, EqualityComparable const&) { return true; }

struct NotEqualityComparable { }; // (5)

int main() {

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

std::cout << "isEqualityComparable<EqualityComparable>::value: " <<
isEqualityComparable<EqualityComparable>::value << std::endl;

std::cout << "isEqualityComparable<NotEqualityComparable>::value: " <<
isEqualityComparable<NotEqualityComparable>::value << std::endl;

std::cout << std::endl;

}

Das neue Feature gehört zum experimental Namensraum (1). Die Zeile (3) ist die entscheidende. Diese Zeile ermittelt, ob der Ausdruck (2) gültig für den Datentyp T ist. Das Type-Traits isEqualityComparable gibt die richtige Antwort für den Datentyp EqualityComparable (4) und NotEqualityComparable (5). Lediglich EqualityComparable gibt true zurück, da ich den Gleichheitsoperator überladen habe.

Bis C++20 wurden die Vergleichsoperatoren lediglich für arithmetische Datentypen, Aufzähler und mit Einschränkungen für Zeiger automatisch erzeugt. Das wird sich wohl mit C++20 dank des Spaceship-Operators <=> ändern. Wenn mit C++20 eine Klasse den <=> implementiert, werden automatisch die sechs Operatoren ==, !=, <, <=, > und >= generiert. Es ist bereits ausreichend, den <=> als default zu deklarieren. Dies zeigt der Datentyp Point:

class Point {
int x;
int y;
public:
auto operator<=>(const Point&) const = default;
....
};
// compiler generates all six relational operators

In diesem Fall sorgt der Compiler für die Implementierung. Der Default-Operator <=> wendet lexikografische Vergleiche an, in dem er mit seinen Basisklassen (von links nach rechts; zuerst in die Tiefe) beginnt und dann seinen Vergleich mit den nicht-statischen Attributen in ihrer Deklarationsreihenfolge fortsetzt. Diese Vergleiche wenden Kurzschlussauswertung an. Das heißt, dass die Evaluierung eines logischen Ausdrucks dann endet, wenn das Ergebnis bereits feststeht.

Jetzt sind alle Bestandteile verfügbar um Regular und SemiRegular zu definieren. Hier sind meine neuen Type-Traits:

// isRegular.cpp

#include <experimental/type_traits>
#include <iostream>

template<typename T>
using equal_comparable_t = decltype(std::declval<T&>() == std::declval<T&>());

template<typename T>
struct isEqualityComparable:
std::experimental::is_detected<equal_comparable_t, T>
{};

template<typename T>
struct isSemiRegular: std::integral_constant<bool,
std::is_default_constructible<T>::value &&
std::is_copy_constructible<T>::value &&
std::is_copy_assignable<T>::value &&
std::is_move_constructible<T>::value &&
std::is_move_assignable<T>::value &&
std::is_destructible<T>::value &&
std::is_swappable<T>::value >{};

template<typename T>
struct isRegular: std::integral_constant<bool,
isSemiRegular<T>::value &&
isEqualityComparable<T>::value >{};


int main(){

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

std::cout << "isSemiRegular<int>::value: " << isSemiRegular<int>::value << std::endl;
std::cout << "isRegular<int>::value: " << isRegular<int>::value << std::endl;

std::cout << std::endl;

std::cout << "isSemiRegular<int&>::value: " << isSemiRegular<int&>::value << std::endl;
std::cout << "isRegular<int&>::value: " << isRegular<int&>::value << std::endl;

std::cout << std::endl;

}

Durch die Verwendung der neuen Type-Traits isSemiRegular und isRegular ist das Programm deutlich einfacher zu lesen.

Wie geht's weiter?

Mit meinem nächsten Blogbeitrag springe ich direkt in die Definition von Templates.