Klassentemplates
In diesem und den nachfolgenden Kapiteln weichen wir etwas von der bisherigen Vorgehensweise ab, dass erst am Ende eines Kapitels die Übungen folgen. Wegen der Vielfältigkeit und Komplexität von Templates folgen Übungen teilweise im Anschluss an Unterkapitel.
Beginnen wir den Einstieg in die Klassentemplates wieder mit einem Beispiel. Nachfolgend sind zwei Klassen ShortStack und LongStack definiert. Der einzige Unterschied zwischen den beiden Klassen liegt nur im Datentyp des Zeigers auf die Stackdaten. Im ersten Fall ist dies ein short-Zeiger und im zweiten Fall ein long-Zeiger.
// Stack für short-Werte
class ShortStack
{
short *pData;
unsigned int index;
unsigend int stackSize;
public:
ShortStack(int size)
{
stackSize = size;
pData = new short[stackSize];
index = 0;
}
virtual ~ShortStack()
{
delete [] pData;
}
};
// Stack für auf long-Werte
class LongStack
{
long *pData;
unsigned int index;
unsigned int stackSize;
public:
LongStack(int size)
{
stackSize = size;
pData = new long[stackSize];
index = 0;
}
virtual ~LongStack()
{
delete [] pData;
}
};
int main()
{
ShortStack sStack{10};
LongStack lStack{25};
}
Da auch die Methoden zum Ablegen der Daten auf dem Stack und zum Auslesen der Daten in ihrem Ablauf identisch sein werden, bietet es sich an, hierfür ein Klassentemplate zu entwickeln.
Die Standardbibliothek enthält bereits eine Klasse stack. Das hier zu entwickelnde Klassentemplate dient lediglich zur Übung.
Definition eines Klassentemplates
Bevor auf die Definition eines Klassentemplates eingegangen wird, nochmals folgender wichtige Hinweis:
Werden Funktionen aus der Standardbibliothek verwendet und der eingesetzte Compiler bietet keine Möglichkeit die Standardbibliothek zu importieren, sollten alle Templates in einer Header-Datei und nicht in einer Moduldatei definiert werden. Nur so ist gewährleistet, dass bei der Instanziierung der richtige Code erzeugt wird.
Die Entwicklung eines Klassentemplates erfolgt analog zur Entwicklung eines Funktionstemplates.
1. Schritt:
Im ersten Schritt werden alle Klassendefinitionen bis auf eine entfernt und der Datentyp, der von Klasse zu Klasse unterschiedlich ist, durch einen beliebigen Namen, dem formalen Datentyp, ersetzt. Im Beispiel wurde der Datentyp wieder durch T ersetzt.
// Ersetzen der Datentypen
class Stack
{
T *pData;
unsigned int index;
public:
Stack(int size)
{
pData = new T[size];
index = 0;
}
virtual ~Stack()
{
delete [] pData;
}
};
2. Schritt:
Im zweiten Schritt müssen wir dem Compiler wiederum etwas helfen. Damit er weiß, dass T ein formaler Datentyp ist, wird vor die Klassendefinition die Anweisung
template <typename T>
gestellt.
// Template fuer einen Stack
template <typename T>
class Stack
{
...
};
Definition der Methoden
Der formale Datentyp kann ebenfalls als Parameter oder Returntyp von Methoden auftreten. So erhalten die Methoden Push() und Pop() als Parameter eine Referenz auf das abzulegende bzw. auszulesende Datum.
template <typename T>
class Stack
{
T *pData;
...
public:
...
bool Push(const T& val);
bool Pop(T& val);
};
Definition der Methoden innerhalb der Klasse
Werden Methoden innerhalb der Klasse definiert, erfolgt deren Definition wie gewohnt.
// Template fuer einen Stack
template <typename T>
class Stack
{
T *pData; // Stackbereich
unsigned int index; // Stackindex
unsigned int stackSize; // Stackgroesse
public:
// ctor
Stack(int size)
{
stackSize = size;
pData = new T[size];
index = 0;
}
// dtor
virtual ~Stack()
{
delete [] pData;
}
// Wert auf Stack ablegen
bool Push(const T& val)
{
// Wenn Stack belegt
if (index == stackSize)
return false;
pData[index++] = val;
return true;
}
// Wert aus Stack auslesen
bool Pop(T& val)
{
// Wenn keine Wert auf dem Stack
if (index == 0)
return false;
val = pData[--index];
return true;
}
};
int main()
{
// Stackobjekt definieren
}
Soll die Klasse Stack Objekte verarbeiten, sollte deren Klasse u.a. den Zuweisungsoperator = überladen, da in den Methoden Push() und Pop() Objektzuweisungen stattfinden.
Definition der Methoden außerhalb der Klasse
Werden Methoden außerhalb des Klassentemplates definiert, ist eine auf den ersten Blick etwas verwirrende Definition erforderlich.
template <typename T>
RTYP CLASS<T>::MName(PARAM)
{...}
T ist wieder der formale Datentyp, RTYP der Returntyp der Methode, CLASS der Name des Klassentemplates und MName der Name der Methode. Das nachfolgende Beispiel zeigt die Definitionen der Methoden Push() und Pop() der Klasse Stack.
// Template fuer einen Stack
template <typename T>
class Stack
{
T *pData; // Stackbereich
unsigned int index; // Stackindex
unsigned int stackSize; // Stackgroesse
public:
// ctor
Stack(int size)
{
stackSize = size;
pData = new T[stackSize];
index = 0;
}
// dtor
virtual ~Stack()
{
delete [] pData;
}
// Wert auf Stack ablegen
bool Push(const T& val);
// Wert aus Stack auslesen
bool Pop(T& val);
};
// Wert auf Stack ablegen
template <typename T>
bool Stack<T>::Push(const T& val)
{
// Wenn Stack belegt
if (index == stackSize)
return false;
pData[index++] = val;
return true;
}
// Wert aus Stack auslesen
template <typename T>
bool Stack<T>::Pop(T& val)
{
// Wenn keine Wert auf dem Stack
if (index == 0)
return false;
val = pData[--index];
return true;
}
int main()
{
// Stack-Objekte definieren
}
Definition von Objekten
Da bei der Definition des Klassentemplates nur der formale Datentyp angegeben wurde, ist bei der Definition eines Objekts der hierfür einzusetzende Datentyp nach dem Klassennamen in spitzen Klammern anzugeben. Im Beispiel wird zunächst ein Stack-Objekt für die Aufnahme von 5 long-Werte definiert und anschließend ein weiteres Stack-Objekt für die Aufnahme von 10 float-Werte.
#include print
// Definition der Templateklasse wie oben
int main()
{
// Stackgroessen definieren
const unsigned int LSIZE=5, FSIZE=10;
// Stack fuer 5 long-Werte
Stack<long> longStack{LSIZE};
// Stack fuer 10 float-Werte
Stack<float> floatStack{FSIZE};
// long-Stack fuellen
while (longStack.Push(std::rand()%100))
;
// long-Stack auslesen
long val;
while (longStack.Pop(val))
std::print("{}, ",val);
std::println("");
}
69, 0, 34, 67, 41,
Und erst bei der Definition eines Objekts werden durch den Compiler die Member des Klassentemplates instanziiert.
Übung
tmemfkt_01:
Implementieren Sie in einer Modul-Datei einen Ringpuffer für die Ablage von Daten eines beliebigen Datentyps. Ein Ringpuffer ist ein Puffer mit einer definierten Größe, bei dem das zuerst abgelegte Datum wieder zuerst ausgelesen wird (FIFO=first in, first out). Der Ringpuffer besitzt zwei Indizes: einen zur Ablage und einen zum Auslesen eines Datums. Erreicht ein Index das Pufferende, wird er wieder auf den Pufferanfang zurückgesetzt.
Der Ringpuffer ist so zu implementieren, dass ein Überschreiben eines noch nicht ausgelesenen Datums verhindert wird.
Ein kleiner Tipp: Legen Sie im Ringpuffer außer dem Datum noch ein Flag ab das anzeigt, ob das Datum ausgelesen wurde oder nicht.
Definieren Sie einen Ringpuffer zur Aufnahme von 10 int-Werten.
Legen Sie 4 int-Werte ab und lesen dann 2 Werte wieder aus.
Füllen Sie den gesamten Ringpuffer und lesen ihn vollständig wieder aus.
Definieren Sie einen Ringpuffer zur Aufnahme von 5 CData2-Objekten (Definition in der Datei cdata2.cxx).
Legen Sie 3 CData2-Objekte im Ringpuffer ab und lesen eines wieder aus.
Füllen Sie den gesamten Ringpuffer und lesen ihn vollständig wieder aus.
Lege 4 int-Daten ab:
10 11 12 13
Lese 2 int-Daten aus:
10 11
Fuelle int-Puffer komplett:
101 102 103 104 105 106 107 108
Leere int-Puffer komplett:
12 13 101 102 103 104 105 106 107 108
Lege 3 CData2-Objekte ab:
Lese 1 CData2-Objekt aus :
Daten Objekt 9:
41, 67,
Fuelle CData2-Puffer komplett :
Leere CData2-Puffer komplett:
Daten Objekt 9:
34, 0, 69,
Daten Objekt 9:
24, 78, 58, 62,
Daten Objekt 9:
64, 5, 45, 81, 27,
Daten Objekt 9:
61, 91, 95, 42, 27, 36,
Daten Objekt 9:
91, 4, 2, 53, 92, 82, 21,
Überladen von Methoden
Wie bei Funktionstemplates können die Methoden eines Klassentemplates überladen werden.
Angenommen es gibt ein Klassentemplate, das ein beliebiges Datum abspeichert. Zu diesem Datum soll nun mittels zweier Methoden Add() ein weiteres Datum addiert werden. Die erste Methode Add() erhält als Parameter eine Referenz auf das zu addierende Datum und die zweite einen Zeiger auf das Datum.
#include <print>
// Template-Definition
template <typename T>
class Save
{
T data;
public:
// ctor
Save (T nV): data(nV)
{}
T Get() const
{
return data;
}
// Addierte Datum per Referenz
void Add(const T& value)
{
data += value;
}
// Addiere ein Save-Objekt
void Add(const Save& obj)
{
data += obj.data;
}
};
int main()
{
// 2 Save-Objekte definieren
Save<int> save1{0};
Save<int> save2{100};
std::println("save1: {}, save2: {}",save1.Get(), save2.Get());
// Addiere 10 und save2 zu save1
save1.Add(10);
save1.Add(save2);
std::println("save1+10+save2: {}",save1.Get());
}
save1: 0, save2: 100
save1+10+save2: 110
Mehrere formale Datentypen
Wie Funktionstemplates können auch Klassentemplates mehrere formale Datentypen besitzen. Die formalen Datentypen werden bei der Definition des Klassentemplates in der template-Anweisung aufgelistet und bei der Definition eines Objekts sind die entsprechenden Datentypen anzugeben.
// Template-Definition
template <typename T1, typename T2>
class MyClass
{...};
// Objektdefinitionen
MyClass<int, char*> myObj1;
MyClass<float, double> myObj2;
Non-type Parameter
Ebenso können Klassentemplates non-type Parameter besitzen. Ein non-type Parameter kann die gleichen Datentype wie bei den Funktionstemplate besitzen:
- ein bool-, Zeichen- oder Integer-Datentyp
- Gleitkomma-Datentyp
- Zeiger auf ein Datum oder eine Funktion
- lvalue Referenz auf ein Objekt oder eine Funktion
- Memberzeiger
- std::nullptr_t (Datentyp des nullptr)
- eine Klasse mit nur public- und non-mutable-Eigenschaften
Der non-type Parameter wird, wie die formalen Datentypen, ebenfallls innerhalb der template-Anweisung aufgeführt und besteht aus dem Datentyp, dem Parameternamen und einem optionalen Defaultwert. Bei der Instanziierung eines Objekts wird dann der non-type Parameter durch das übergebene Argument ersetzt. Dadurch ist es z.B. möglich, als Eigenschaft Felder mit unterschiedlichen Feldgrößen bei der Definition von Objekten zu definieren.
Für das Template Stack könnte dies wie folgt aussehen:
#include <print>
#include <cstdlib>
// Template fuer einen Stack
template <typename T, int SIZE=10>
class Stack
{
T *pData; // Stackbereich
unsigned int index; // Stackindex
public:
// ctor
Stack()
{
pData = new T[SIZE];
index = 0;
}
// dtor
virtual ~Stack()
{
delete [] pData;
}
// Wert auf Stack ablegen
bool Push(const T& val);
// Wert aus Stack auslesen
bool Pop(T& val);
};
// Wert auf Stack ablegen
template <typename T, int SIZE>
bool Stack<T, SIZE>::Push(const T& val)
{
// Wenn Stack belegt
if (index == SIZE)
return false;
pData[index++] = val;
return true;
}
// Wert aus Stack auslesen
template <typename T, int SIZE>
bool Stack<T, SIZE>::Pop(T& val)
{
// Wenn keine Wert auf dem Stack
if (index == 0)
return false;
val = pData[--index];
return true;
}
int main()
{
// Stack fuer 5 short-Werte definieren
Stack<short,5> shortStack;
// Stack fuellen
while (shortStack.Push(std::rand()%100))
;
// short-Stack auslesen
short val;
while (shortStack.Pop(val))
std::print("{}, ",val);
std::println("");
}
69, 0, 34, 67, 41,
Wird die Template-Methode, wie im Beispiel, außerhalb des Klassentemplates definiert, ist der non-type Parameter dort ebenfalls anzugeben, allerdings ohne einen eventuellen Defaultwert.
Gleichfalls ist die Definition eines Objektes des Klassentemplates anzupassen, da hier der non-type Parameter mit anzugeben ist (wenn kein Defaultwert definiert ist).
Übung
tnontype_01:
Implementieren Sie ein Klassentemplate Matrix zur Ablage von Daten in einem 2-dimensionalen Feld. Die Größe des Feldes ist beim Erstellen eines Objekts mit anzugeben. Das Feld ist nicht dynamisch anzulegen.
Sollen die Daten in einer quadratischen Matrix abgelegt werden, ist bei der Definition des Objekts nur eine Größenangabe anzugeben.
Dem Konstruktor der Klasse ist ein Initialwert mitzugeben, mit dem das Feld initialisiert wird.
Außer dem Konstruktor ist eine Methode zur Ausgabe der Matrix zu implementieren.
Legen Sie eine 5x5 Matrix für die Ablage von int-Werten an, die mit dem Wert 11 zu initialisieren ist.
Matrix<int, 5> matrix(11);
Geben Sie Daten der Matrix aus.
Legen Sie anschließend eine 2x3 Matrix für die Ablage von CData3-Objekten an und geben die Matrix ebenfalls aus.
Die Klasse CData3 ist in der Datei cdata3.cxx definiert und muss für die Übung nicht modifiziert werden.
11,11,11,11,11,
11,11,11,11,11,
11,11,11,11,11,
11,11,11,11,11,
11,11,11,11,11,
CData3,CData3,CData3,
CData3,CData3,CData3,
Default-Datentyp
Genauso wie bei Methoden/Funktionen Defaultwerte für Parameter vorgegeben werden können, können für die formalen Datentypen Default-Datentypen vorgegeben werden. Dazu werden nach dem formalen Datentyp der Zuweisungsoperator und anschließend der Default-Datentyp angegeben. Wird bei der Definition eines Objekts des Klassentemplates kein Datentyp explizit angegeben, wird der Default-Datentyp verwendet.
// Definition des Klassentemplates
template <typename T=int>
class Stack
{ ... };
// Definition von Objekten
Stack<> intStack;
Stack<float> floatStack;
Übung
tdefdat_01:
Implementieren Sie ein Klassentemplate DynArray, welches ein Feld enthält, dessen Größe sich zur Programmlaufzeit an die Anzahl der im Feld abzulegenden Daten anpasst.
Bei der Definition eines DynArray-Objekts soll optional der Datentyp der im Feld abzulegenden Daten sowie die Ausgangsgröße des Feldes mit angegeben werden können. Wird kein Datentyp explizit vorgegeben, sollen int-Daten abgespeichert werden. Ist keine keine Ausgangsgröße angegeben, ist ein Feld für 5 Elemente anzulegen.
Für den Zugriff auf ein Feldelement ist der Index-Operator zu überladen. Wird beim Zugriff auf ein Element über die aktuelle obere Feldgrenze hinaus zugegriffen, ist das Feld entsprechend zu erweitern. Die neu hinzugefügten Feldelemente sind mit dem Standardkonstruktor des Datums zu initialisieren (siehe auch Dynamische Eigenschaften und Objekte).
Damit bei Integer- oder Gleitkomma-Datentypen eine Initialisierung mit 0 erfolgt, sind die Feldelemente explizit zu initialisieren. Die Prüfung, ob es sich um einen solchen Datentyp handelt, kann mithilfe von
std::is_arithmetic_v<T>
durchgeführt werden, wobei T der zu überprüfende Datentyp ist. Die Templatefunktion liefert true zurück, wenn es sich um einen Integer- oder Gleitkomma-Datentyp handelt.
Des Weiteren ist eine Methode zu implementieren, die den Inhalt des Feldes sowie dessen aktuelle Größe ausgibt.
Definieren Sie ein DynArray-Objekt für die Aufnahme von 5 int-Elementen. Weisen Sie dem 5. Element den Wert 44 zu und geben das Feld aus. Anschließend ist dem 7. Element den Wert 66 zuzuweisen. Geben Sie das Feld danach erneut aus.
Definieren Sie ein zweites DynArray-Objekt für die Aufnahme von 3 CData3-Objekten (siehe Hinweis zur Übung tnontype_01). Weisen Sie dem 2. Element ein beliebiges CData3-Objekt zu und geben Sie den Feldinhalt aus. Anschließend weisen Sie dem 6. Element ein weiteres CData3-Objekt zu und geben das Feld erneut aus.
Initiale int-Feldgroesse: 5
0, 0, 0, 0, 44,
Aktuelle int-Feldgroesse: 7
Und das komplette int-Feld:
0, 0, 0, 0, 44, 0, 66,
Initiale CData3-Feldgroesse: 3
, CData3[1], ,
Aktuelle CData3-Feldgroesse: 6
Und das komplette CData3-Feld:
, CData3[1], , , , CData3[5],
using-Anweisung und Templates
Mithilfe der using-Anweisung ist es möglich, Synonyme für Templates zu definieren.
// Definition Klassentemplate
// SIZE ist ein beliebiger non-type Parameter
// und soll nur dessen Handhabung demonstrieren
template <typename T, int SIZE>
class SArray
{...};
// Synonyme definieren
template <int S>
using shortArray = SArray<short,S>; // SArray short-Werte
template <int S>
using floatArray = SArray<float,S>; // SArray float-Werte
// Objektdefintionen
shortArray<10> array1(10); // Array 10 short-Werte
floatArray<50> array2(3.14f); // Array 50 float-Werte
Extern und Klassentemplates
Wie bei Funktionstemplates instanziiert der Compiler defaultmäßig erst dann ein Klassentemplate, wenn ein Objekt definiert wird. Dieses Verhalten führt aber dazu, dass auf Basis von Templates keine Bibliothek aufgebaut werden könnte, da das Objekt erst in der Anwendung definiert wird und nicht in der Bibliothek.
Die Lösung für dieses Problem lautet: externe Template-Instanziierung. Hierbei wird das Klassentemplate in der Bibliothek instanziiert und in der Anwendung dann mittels extern auf diese Instanz verwiesen. D.h. für Klassentemplates kann das gleiche Verfahren angewandt werden wie für externe Variablen. Sehen wir uns dies an einem Beispiel an.
In der Datei bibl.h wird das Klassentemplate zunächst wie gewohnt definiert.
// Datei bibl.h
// Definition des Klassentemplates
// Speichert ein beliebiges Datum und
// gibt es wieder zurueck
template <typename T>
class CAny
{
T data;
public:
CAny (T val): data(val)
{}
T GetData()
{
return data;
}
};
Anschließend werden in der Datei bibl.cpp die Instanzen des Klassentemplates erzeugt, hier einmal für int-Daten und einmal für string-Daten. Diese Instanziierungen bewirken, dass alle Methoden des Klassentemplates für int- und string-Daten durch den Compiler erstellt werden, aber kein Speicher für die Eigenschaften reserviert wird.
// Datei bibl.cpp
#include <string>
#include "bibl.h"
// Instanziierung des Klassemtemplates
// fuer den Datentyp int und std::string
template class CAny<int>; // Tpl mit int
template class CAny<std::string>; // Tpl mit string
In der Anwendung wird dann mittels extern auf diese Instanzen verwiesen, was dazu führt, dass nun der Speicher für Eigenschaften reserviert wird. Ohne die Angabe von extern würde der Compiler erneut in der Anwendung den Code für die verwendeten Methoden des Klassentemplates erzeugen und erst beim Linken der Dateien würden dann diese "überzähligen" Methoden wieder entfernt.
// Die Anwendung
#include <string>
#include <print>
// Definition des Klassentemplates einbinden
#include "bibl.h"
// Verweis auf externen
extern template class CAny<int>;
extern template class CAny<std::string>;
using namespace std::string_literals;
int main()
{
// Die folgenden Anweisung legen keine neue
// Instanz des Klassentemplates mehr an
CAny<int> ival(11);
CAny<std::string> sval("hurra!"s);
std::println("int-Wert: {}",ival.GetData());
std::println("string : {}",sval.GetData());
}