Softwaredesign mit Policies

Modernes C++ Rainer Grimm  –  7 Kommentare

Dank Templates gibt es neue Wege für den Softwareentwurf. Policies und Traits sind zwei dieser neuen Wege, die gerne in C++ verwendet werden.

Policy

Policies und Traits werden oft in einem Zug genannt. Beginnen möchte ich in diesem Artikel mit dem Vorstellen der Policies.

Policy

Eine Policy ist eine generische Funktion oder Klasse, deren Verhalten konfiguriert werden kann. Normalerweise gibt es Defaultwerte für die Policy-Parameter. std::vector und std::unordered_map sind typische Beispiele für Policies.

template<class T, class Allocator = std::allocator<T>> // (1)
class vector;

template<class Key,
class T,
class Hash = std::hash<Key>, // (3)
class KeyEqual = std::equal_to<Key>, // (4)
class allocator = std::allocator<std::pair<const Key, T>> // (2)
class unordered_map;

Diese Deklaration bedeutet, dass beide Container einen Standard-Allokator für ihre Elemente besitzen, der abhängig von T (Zeile 1) oder von std::pair<const Key, T> (Zeile 2) ist. Außerdem verfügt std::unorderd_map über eine Default-Hash-Funktion (Zeile 3) und eine Default-Gleichheitsfunktion (Zeile 4). Die Hash-Funktion berechnet den Hash-Wert auf der Grundlage des Schlüssels und die Gleichheits-Funktion kümmert sich um Kollisionen in den Buckets. In meinem Artikel "Hashfunktionen" findest du mehr Informationen über std::unordered_map.

Lass mich einen benutzerdefinierten Datentyp MyInt als Schlüssel in einer std::unordered_map verwenden:

// MyIntAsKey.cpp

#include <iostream>
#include <unordered_map>

struct MyInt{
explicit MyInt(int v):val(v){}
int val;
};

int main(){

std::cout << '\n';

std::unordered_map<MyInt, int> myMap{ {MyInt(-2), -2}, {MyInt(-1), -1},
{MyInt(0), 0}, {MyInt(1), 1} };

std::cout << "\n\n";

}

Der Versuch, das Programm zu übersetzen, scheitert kläglich, da MyInt weder eine Hash-Funktion noch die Gleichheitsfunktion besitzt.

Policy

Jetzt kommen Policies ins Spiel. Diese können explizit gesetzt werden. Die Klasse MyInt kann daher als Schlüssel in einer std::unordered_map verwendet werden.

// templatesPolicy.cpp

#include <iostream>
#include <unordered_map>

struct MyInt{
explicit MyInt(int v):val(v){}
int val;
};

struct MyHash{ // (1)
std::size_t operator()(MyInt m) const {
std::hash<int> hashVal;
return hashVal(m.val);
}
};

struct MyEqual{
bool operator () (const MyInt& fir, const MyInt& sec) const { // (2)
return fir.val == sec.val;
}
};

std::ostream& operator << (std::ostream& strm, const MyInt& myIn){ // (3)
strm << "MyInt(" << myIn.val << ")";
return strm;
}

int main(){

std::cout << '\n';

typedef std::unordered_map<MyInt, int, MyHash, MyEqual> MyIntMap; // (4)

std::cout << "MyIntMap: ";
MyIntMap myMap{{MyInt(-2), -2}, {MyInt(-1), -1}, {MyInt(0), 0}, {MyInt(1), 1}};

for(auto m : myMap) std::cout << '{' << m.first << ", " << m.second << "}";

std::cout << "\n\n";

}

Ich habe die Hash-Funktion (Zeile 1) und die Gleichheitsfunktion (Zeile 2) als Funktionsobjekt implementiert und den Ausgabeoperator (Zeile 3) überladen. Zeile 4 erstellt aus allen Komponenten einen neuen Typ MyIntMap, der MyInt als Schlüssel verwendet. Der folgende Screenshot zeigt die Ausgabe der Instanz myMap.

Policy

Es gibt zwei typische Möglichkeiten, Policies zu implementieren: Komposition und Vererbung.

Komposition

Die folgende Klasse Message verwendet Komposition, um ihr Ausgabegerät während der Compilezeit zu konfigurieren.

// policyComposition.cpp

#include <iostream>
#include <fstream>
#include <string>

template <typename OutputPolicy> // (1)
class Message {
public:
void write(const std::string& mess) const {
outPolicy.print(mess); // (2)
}
private:
OutputPolicy outPolicy;
};

class WriteToCout { // (5)
public:
void print(const std::string& message) const {
std::cout << message << '\n';
}
};

class WriteToFile { // (6)
public:
void print(const std::string& message) const {
std::ofstream myFile;
myFile.open("policyComposition.txt");
myFile << message << '\n';
}
};


int main() {

Message<WriteToCout> messageCout; // (3)
messageCout.write("Hello world");

Message<WriteToFile> messageFile; // (4)
messageFile.write("Hello world");

}

Die Klasse Message besitzt den Template-Parameter OutputPolicy (Zeile 1) als Policy. Ein Aufruf ihrer Mitgliedsfunktion write delegiert direkt an ihr Mitglied outPolicy (Zeile 2). Du kannst zwei verschiedene Message-Instanzen erstellen (Zeile 3 und 4). Eine schreibt in die Konsole (Zeile 5), die andere in eine Datei (Zeile 6).

Der Screenshot zeigt den Schreibvorgang nach cout und in die Datei policyComposition.txt.

Policy
Vererbung

Die auf Vererbung basierende Implementierung ist der auf Komposition basierenden in dem Beispiel policyComposition.cpp sehr ähnlich. Der Hauptunterschied besteht darin, dass die Komposition-Implementierung die Policy besitzt, während die Vererbungs-Implementierung von der Policy abgeleitet ist.

// policyInheritance.cpp

#include <iostream>
#include <fstream>
#include <string>

template <typename OutputPolicy>
class Message : private OutputPolicy { // (1)
public:
void write(const std::string& mess) const {
print(mess); // (2)
}
private:
using OutputPolicy::print;
};

class WriteToCout {
protected:
void print(const std::string& message) const {
std::cout << message << '\n';
}
};

class WriteToFile {
protected:
void print(const std::string& message) const {
std::ofstream myFile;
myFile.open("policyInheritance.txt");
myFile << message << '\n';
}
};


int main() {

Message<WriteToCout> messageCout;
messageCout.write("Hello world");

Message<WriteToFile> messageFile;
messageFile.write("Hello world");

}

Anstelle der vorherigen Implementierung der Klasse Message leitet diese von ihrem Template-Parameter privat ab und führt die privat geerbte print-Funktion in den Klassenscope ein. Die Ausgabe des Programms überspringe ich aus offensichtlichen Gründen. Okay, ich höre deine Frage: Soll ich Komposition oder Vererbung für die Umsetzung eines Policy-based Design verwenden?

Komposition oder Vererbung

Im Allgemeinen ziehe ich Komposition der Vererbung vor. Aber für einen Policy-basierten Entwurf solltest du Vererbung in Betracht ziehen.

Wenn OutputPolicy leer ist, kannst du von der sogenannten empty base class optimization profitieren. Leer bedeutet, dass OutputPolicy keine nicht statischen Datenmitglieder und keine nicht leeren Basisklassen hat. Folglich trägt OutputPolicy nicht zur Größe von Message bei. Im Gegenteil dazu gilt jedoch: wenn Message das Member OutputPolicy hat, erhöht OutputPolicy die Größe von Message um mindestens ein Byte. Mein Argument klingt vielleicht nicht überzeugend, aber oft verwendet eine Klasse mehr als eine Policy.

Wie geht's weiter?

Traits sind Klassen-Templates, die Eigenschaften aus einem generischen Typ herausziehen. Ich werde in meinem nächsten Beitrag genauer auf sie eingehen.