DIGITAL
Auf diesem Übungsblatt arbeiten wir drei weitere wichtige Inhalte von Lerneinheit 04 auf, Templates, Speicherverwaltung und Containerklassen.
Wir haben im Tutorium 04 und auf dem Übungsblatt 04 zwei „Orte“ zum Speichern von Daten in C++ kennen gelernt:
new
angesprochen.Das Hauptproblem von raw-Pointern ist die einfache Frage, wer sich um das Löschen der unnötigen Objekte kümmert. In Sprachen wie Python oder Java stellt sich die Frage meistens nicht. Dort kümmert sich ein Mechanismus namens Garbage Collector um das Aufräumen des Speichers.1
Auf diesem Blatt werden wir den Begriff der Speicherverwaltung (aus Vorlesung C07) aus zwei verschiedenen Sichtweisen betrachten.
Und übrigens: Die drei Strukturen std::tuple
, std::shared_ptr
und std::vector
, die auf diesem Blatt (natürlich vereinfacht) nachimplementiert werden, dürfen ab dem nächsten Übungsblatt in jeder Lösung mit verwendet werden, sofern es sich anbietet.
[1] Dabei werden periodisch alle Objekte darauf geprüft, ob es noch einen Zeiger gibt, der darauf zeigt. Alle „freistehenden“ Objekte können demnach gelöscht werden. In C++ gibt es diesen Mechanismus nicht von Haus aus.
.h
) ausformulieren. Dies liegt daran, dass Templates zur Compilezeit evaluiert werden, nicht erst beim Linken der Dateien, denn zum Zeitpunkt des Kompilierens kann ein Template noch nicht übersetzt werden, da zu diesem Zeitpunkt typischerweise noch nicht alle verwendeten Typen feststehen. Die einfache Lösung ist: Wir deklarieren und definieren Templates im Header und binden die Datei per #include
ein wo auch immer wir es benötigen. Ein typischer Nachteil davon ist, dass in großen Projekten mit vielen Templates die Compilezeit zunmmt (alle Cpp-Dateien müssen bei einer Änderung eines eingebundenen Templates stets neu übersetzt werden) und die Dateigröße der resultierenden ausführbaren Datei nimmt zu, da jede Cpp, die das Template einbindet eine Kopie des Kompilierten Template-Codes enhält.
Auf Blatt 3 haben wir gesehen, wie man struct
s zur sinnvollen Gruppierung von Daten einsetzen kann. Braucht man viele solcher Gruppierungen, kann die Anzahl an struct
s schnell dazu führen, dass man die Übersicht verliert. Wir wollen jetzt mithilfe von Templates eine Alternative zu struct
s implementieren und deren Tradeoffs diskutieren. Als Basis verwenden wir folgende Deklaration:
template <typename First, typename Second>
struct pair {
First first;
Second second;
void swap(pair<First, Second> &other);
};
template <typename First, typename Second>
bool operator ==(const pair<First, Second> &lhs, const pair<First, Second> &rhs);
template <typename First, typename Second>
bool operator !=(const pair<First, Second> &lhs, const pair<First, Second> &rhs);
template <typename T>
void flip(pair<T, T> &p);
first
und second
initialisiert.==
und !=
.swap
, die ein anderes pair
mit denselben Typparametern akzeptiert und jeweils first
und second
zwischen den Paaren austauscht.pair
eine freistehende Funktion flip
, die innerhalb eines Paares die Werte von first
und second
vertauscht. T
und nicht First
und Second
als Templateargument?pair
ist dem Programmierer des Codes der Fehler unterlaufen, dass ein 2D-Punkt an eine Funktion übergeben wird, die eigentlich einen Bruch kürzen soll. Der Compiler kann diesen Fehler nicht erkennen, da für ihn beide Paare dieselbe Bedeutung haben. struct
s (eins für Punkte, eins für Brüche) so um, dass dieser Fehler zur Compilezeit gefunden wird. Überlegen Sie sich gleichzeitig sinnvolle Namen für die Membervariablen Ihrer struct
s (nicht first
und second
!)#include <numeric>
pair<int64_t, int64_t> project_point_onto_second_axis(const pair<int64_t, int64_t> &p) {
return pair<int64_t, int64_t>(p.first, 0);
}
pair<int64_t, int64_t> cancel_fraction(const pair<int64_t, int64_t> &p) {
// find greatest common divisor (c++17 and later)
int64_t gcd = std::gcd(p.first, p.second);
return pair<int64_t, int64_t>(p.first / gcd, p.second / gcd);
}
int main() {
// this is a fraction
pair<int64_t, int64_t> a(10, 4);
// this is a 2D point on an integer grid
pair<int64_t, int64_t> p(-2, 3);
pair<int64_t, int64_t> b = project_point_onto_second_axis(p);
pair<int64_t, int64_t> c = cancel_fraction(b);
std::cout << c.first << "\n";
}
In dieser Aufgabe möchten wir nun zwei Ideen vereinen:
return
) übergeben werden, da die Objekte, auf die sie zeigen, nicht kopiert oder bewegt werden, sondern nur die Adressen, die auf die Objekte zeigen.Das Ziel dieser Aufgabe ist also ein Objekt zu bauen, dass schnell kopiert werden kann, weil es nicht die eigentlichen Daten enhält, und wie eine Stackvariable automatisch den eigenen Speicher löscht, sobald die Zuständigkeit des Objektes erlischt (kein Pointer zeigt mehr darauf).
C++ liefert von daher von Haus aus die Klasse shared_ptr
mit, die genau die oben genannten Anforderungen erfüllt:
shared_ptr(T* ptr)
und der Destruktor virtual ~shared_ptr()
ptr
zeigt zu löschen, sobald es nicht mehr benötigt wird. Mit anderen Worten ruft der Destruktor von shared_ptr
„delete ptr
“ auf. Das bedeutet, wir nutzen den Mechanismus des Stack (automatisches Aufrufen des Destruktors), um ein Objekt auf dem Heap zu löschen. Viel können wir jedoch noch nicht damit tun, daher definieren wir zusätzlich die Memberfunktionen:
T* operator->()
und T& operator *()
shared_ptr
wie auch mit einem Pointer. shared_ptr(const shared_ptr &other)
Die Schwierigkeit besteht nun darin sicherzustellen, dass der Destruktor des eigentlichen Objektes nicht mehrfach aufgerufen wird. Hierzu gibt es zwei Möglichkeiten (welche Möglichkeit implementiert wird ist an dieser Stelle egal):
new uint64_t[1]
, der somit auf dem Heap liegt. Beim Erstellen einer Kopie des shared_ptr
kopieren wir nicht nur die beiden Pointer (nicht die Objekte, auf die sie zeigen), sondern inkrementieren auch den Zähler auf dem Heap. Wir müssen jetzt einzig darauf achten, dass wir im Destruktor genau dann delete ptr
aufrufen, wenn der Zähler \(0\) ist.std::shared_ptr
verwenden, statt mit raw-Pointern hantieren zu müssen.
shared_ptr<T>
. T
ist hierbei ein beliebiges, aber festes Struct.shared_ptr
erstellen und initialisiern, dannshared_ptr
in eine Funktion übergeben, dann#include "shared_ptr.h"
#include <iostream>
int main() {
uint32_t *p = new uint32_t{5};
shared_ptr<uint32_t> my_ptr(p);
shared_ptr<uint32_t> my_ptr2(p);
std::cout << *my_ptr << "\n";
}
std::vector
verwenden, statt mit C-Arrays hantieren zu müssen.
C-Arrays (solche, die beispelsweise per new int[123];
initialisiert werden) haben so ihre Probleme (sie kennen etwa ihre eigene Größe nicht direkt, die Größe wird zur Compilezeit festgelegt, ...). Ein Array, das man nachträglich vergrößern oder verkleinern kann, nennt man "dynamisches Array" (vergleichbar mit Listen in Python). Die Standardbibliothek in C++ bietet glücklicherweise eine Implementierung von dynamischen Arrays names vector<T>
. T
ist der Typ der gespeicherten Elemente und wird durch ein Template-Typargument festgelegt. Eine komplette Liste an Funktionen erhalten Sie auf https://en.cppreference.com/w/cpp/container/vector.
Intern wird hierbei ein C-Array als Speicher verwendet. Falls also eine Operation ein größeres C-Array benötigen würde (z.B. push_back
), erzeugen wir ein neues C-Array mit doppelter Länge (oder Länge 10, falls das Array zu Beginn leer war) und kopieren alle Werte vom alten C-Array in das neue C-Array um. Intern müssen wir uns also nicht nur merken wie groß das C-Array ist ("Kapazität"), sondern auch wie viele Elemente wir darin gespeichert haben ("Größe").
Wir wollen jetzt std::vector
teilweise nachimplementieren. Schreiben Sie dazu folgende Funktionen. Achten Sie darauf, das alte C-Array vom Speicher zu löschen, falls Sie es gegen ein neues ersetzen.
vector(T initial_value, size_t initial_size = 0)
initial_size
. Alle Werte darin werden mit initial_value
initialisiert.void reserve(size_t new_size)
new_size
bereitstellt. size_t size() const
size_t capacity() const
void push_back(const T &val)
void insert(size_t pos, const T &val)
pos
ein. T& operator[](size_t pos)
und T operator[](size_t pos) const
operator[]
der User der Klasse darum kümmern, dass der Index zulässig ist.void clear()
void shrink_to_fit()
vector
).virtual ~vector()
vector(const vector<T>& other)
vector<T>& operator=(const vector<T>& other)
vector
mit dem von other
.Hinweis: Viele der Methoden sollten einfache Ein-Zeiler sein. Es ist hier auch nicht nötig die „Machbarkeit“ der Funktionen (etwa a[100000]
) abzufangen.
#include <iostream>
int main(){
vector<int> myvec(0, 0);
for(int i = 0; i < 200; i++)
myvec.push_back(i);
// 200
std::cout << myvec.size() << std::endl;
// 320
std::cout << myvec.capacity() << std::endl;
myvec.shrink_to_fit();
// 200
std::cout << myvec.size() << std::endl;
// 200
std::cout << myvec.capacity() << std::endl;
for(int i = 0; i < myvec.size(); i++)
std::cout << myvec[i] << " ";
std::cout << std::endl;
}