Einführung in C++

03 - Dynamische Speicherverwaltung, Templates, Container und Klassen

Prof. Dr. Malte Schilling

Autonomous Intelligent Systems Group

🚀 by Decker

Übersicht 3. Termin

  • IDE
  • Wiederholung zu Pointern,
  • Dynamischer Speicherverwaltung,
  • Template Funktionen,
  • Container und Iteratoren als Zugriff,
  • Einführung von Klassen.

IDE

Integrated Development Environments (IDEs)

Eine IDE ist eine Software-Anwendung, die Software-Entwicklern eine umfassende Reihe von Tools zur Verfügung stellt, um die Softwareentwicklung zu erleichtern.

Sie integriert normalerweise einen Quellcode-Editor, Build-Automatisierungstools und einen Debugger.

Vorteile von IDEs

  • Intellisense und Codevervollständigung – helfen Tippfehler zu vermeiden, Code schneller zu schreiben und benötigte Funktionen / Klassen leicht zu finden.
  • Produktivitätssteigerung – durch die Integration der Tools in einem System.
  • Debugging-Unterstützung: IDEs bieten oft eingebaute Debugging-Tools, die das Auffinden und Beheben von Fehlern erleichtern.
  • Codeverwaltung – viele IDEs integrieren Versionskontrolle, um das Management und die Zusammenarbeit von Code zu erleichtern.

Nutzen einer IDE – Visual Studio Code

  • Visual Studio Code ist eine kostenlose, Open-Source-IDE, die von Microsoft entwickelt wurde.
  • Sie ist plattformübergreifend und unterstützt viele verschiedene Programmiersprachen, einschließlich C++.

Funktionen von VS Code für C++

  • Intellisense: Codevervollständigungsfunktion und Syntaxhighlighting
  • Debugging: umfangreiche Unterstützung durch Breakpoints, Stepping etc.
  • Git-Integration

Vorteile von VS Code als IDE für die Programmierung

  • Hohe Anpassungsfähigkeit: Vielzahl von Erweiterungen für Entwickler
  • Leichtgewichtig und performant: VS Code ist bekannt für seine hohe Leistung im Vergleich zu anderen, schwereren IDEs.
  • Große Entwickler-Community: Durch die große Anzahl von Entwicklern, die VS Code nutzen, gibt es viele verfügbare Ressourcen und Unterstützung für Entwickler.

Zugriff auf Speicher

Wiederholung: Pointer

  • Ein Zeiger (engl. Pointer) enthält die Adresse einer Variable im Speicher.
  • Die Speicheradresse einer Variable liefert der Adressoperator &.
  • Spezielle Adresse: NULL – „Zeiger ins Nichts“.
  • Die Variable zu einem Pointer liefert der Dereferenzierungsoperator *.
  • Der Typ eines Pointers auf eine Variable vom Typ T ist T*.
int a = 42;
int* b = &a; // Adressoperator angewendet auf a
*b = 12; // Dereferenzierungsoperator angewendet auf b; a == 12

Visualisierung Pointer

code-32ad05cf.tex.svg
code-7b590b4c.tex.svg
code-53e300e0.tex.svg
code-df106a01.tex.svg
code-b45419f5.tex.svg
code-3368d916.tex.svg
code-b20ec228.tex.svg

Array-Variable und Pointer

  • Eine Array-Variable verhält sich wie ein Pointer, der auf das erste Element des Arrays zeigt.
  • Auf einen Pointer kann – wie auf ein Array – mit dem []-Operator zugegriffen werden. Dabei gilt: ptr[i] == *(ptr+i)
  • Übergibt man einen Array-Typ T[] als Parameter an eine Funktion, „verfällt“ der Typ zum entsprechenden Pointer T* (Array Decaying).

Referenzen

  • Der Umgang mit Pointern ist besonders am Anfang schwierig.
  • C++ führt mit Referenzen ein ähnliches Sprachmittel ein.
  • Wie Pointer sind Referenzen Variablen, die Verweise auf andere Variablen speichern.
  • Der Typ einer Referenz auf eine Variable vom Typ T ist T&.

Unterschied zu Pointern

  • Zur Initialisierung wird nicht der Adressoperator verwendet!
  • Zum Zugriff auf das gespeicherte Datum wird nicht der Dereferenzierungsoperator verwendet werden!
int b = 58;
int& c = b;
std::cout << "b: " << b << " / c: " << c << std::endl; // gibt 58 fuer beide aus
b = 42; // c == 42

Beispiel Referenzen

code-cd871d23.tex.svg
code-ee5b7b38.tex.svg
code-6ec1d950.tex.svg
code-c04868e3.tex.svg
code-981c9b42.tex.svg
code-a6b74c46.tex.svg
code-2342280b.tex.svg

Referenzen

  • Referenzen müssen immer sofort initialisiert werden:
  • Eine Referenz kann nicht neu zugewiesen werden!
  • Bei einer Zuweisung wird der referenzierte Wert überschrieben!
int i1 = 5;
int i2 = 6;
int& ir = i1; // ir zeigt auf i1
ir = i2; // ir zeigt immer noch auf i1 // i1 == i2

Unterschiede zwischen Pointern & Referenzen

Pointer Referenzen
Initialisierung int* ip = &i; int& ir = i;
Zugriff lesend *ip ir
Zugriff schreibend *ip = 5; ir = 5;
Kann ins „Nichts“ zeigen Ja Nein
Kann neu zugewiesen werden Ja Nein

Call by Reference

  • Faustregel: In C++ sollten (wenn möglich) Referenzen statt Pointer verwendet werden.
  • Falls doch Pointer benötigt werden, sollte man auf Smart Pointer (siehe spätere Vorlesung) zurückgreifen.

Speicherverwaltung

Statische und dynamische Speicherverwaltung

  • Bisher haben wir uns nicht explizit um die Verwaltung des Speichers gekümmert.
  • Variablen werden am Ende ihres Blocks automatisch freigegeben.
  • Freigeben heißt, dass der Destruktor des Objekts, welches die Variable repräsentiert, aufgerufen wird und anschließend der belegte Speicher anderweitig verwendet werden kann.
  • Dies bezeichnet man als statische Speicherverwaltung, da der Compiler Instruktionen erzeugt, die den Speicher automatisch verwalten.
  • Limitierung: Die Größen aller Variablen müssen zur Compilezeit bekannt sein. z. B. keine dynamischen Arrays mit variabler Größe.
  • Alternative zur statischen Speicherverwaltung: dynamische Speicherverwaltung.

Dynamische Speicherverwaltung

  • Mithilfe der dynamischen Speicherverwaltung kann zur Laufzeit eines Programms Speicher angefordert werden.
  • Die Verwaltung dieses Speichers geschieht dabei in C und C++ explizit durch den Programmierer.

Achtung

Es gibt keine automatische Garbage Collection!

Allgemeines Prinzip der dynamischen Speicherverwaltung

  • Durch Aufruf einer Funktion (C)/eines Operators (C++) wird ein Speicherbereich angegebener Größe reserviert.
  • Ein Pointer auf diesen Speicherbereich wird zurückgegeben.
  • Durch Aufruf einer anderen Funktion/eines anderen Operators wird (und soll!) der Speicherbereich später wieder freigegeben.

Stack und Heap Speicher

Zwei wichtige Begriffe bei der statischen und dynamischen Speicherverwaltung sind Stack und Heap (hiermit sind nicht die so benannten Datenstrukturen gemeint).

  • Stack und Heap sind zwei getrennte Speicherbereiche eines Programms während der Ausführung.
  • Zuweisung/ Freigabe von Stack-Speicher erfolgt durch Compiler,
  • während die Zuweisung und Freigabe von Heap-Speicher durch den Programmierer erfolgt.

Stack Speicher

Stack

Die statische Speicherverwaltung verwaltet den Stack.

  • lineare Datenstruktur,
  • wird nie fragmentiert,
  • greift nur auf lokale Variablen zu, während Heap den globalen Zugriff auf Variablen ermöglicht.
  • Variablen können nicht in der Größe verändert werden,
  • ein zusammenhängender Block wird zugewiesen,
  • erfordert keine Deallokation von Variablen

Heap Speicher

Heap

Mit der dynamischen Speicherverwaltung verwaltet der Programmierer den Heap.

  • hierarchische Datenstruktur,
  • kann fragmentiert werden (explizite Zuweisungen und Freigabe),
  • Heap-Variablen können in der Größe verändert werden,
  • Heap-Speicher kann in beliebiger Reihenfolge zugewiesen werden,
  • Deallokation ist erforderlich ist.

Stack vs. Heap Speicher

Basis for Comparison Stack Heap
Basic Memory is allocated LIFO Allocated in random order.
Allocation and Deallocation Automatic Manual
Cost Less More
Invoking O(1) O(N)
Issue Shortage of memory Memory fragmentation
Flexibility Fixed size and is not flexible Resizing is possible
Access time Faster Slower

Gründe für Dynamische Speicherverwaltung

  • Wenn Datenmenge, die zu verarbeiten ist, erst zur Laufzeit bestimmt ist:
  • Um viel Speicher zu “reservieren” - dies ist aber allgemein problematisch, da:
    • Speicher eine “rare” Resource ist
    • Der Speicher vielleicht trotzdem nicht ausreicht

Daher: Dynamische Speicherverwaltung → Programm kann zur Laufzeit Speicher reservieren und freigeben. Dies wird durch C++ unterstützt mit eingebauten Operatoren.

Speicherverwaltung in C: mit malloc

C bietet die Bibliotheksfunktion malloc (stdlib.h) zur Allokation von Speicher auf dem Heap:

void* malloc( size_t size );
  • Parametersize: Größe in Bytes.
  • Rückgabe: void* auf reservierten Speicherbereich (NULL, falls kein Speicher reserviert werden konnte).

malloc reserviert untypisierten Speicher!

Korrekter Typ wird durch einen impliziten Cast angegeben:

char* cp = malloc( 1 );

Speicherverwaltung mit malloc 2

Erinnerung: Der Operator sizeof liefert die Größe eines Datentyps bzw. des Datentyps eines Ausdrucks:

// int* ip = malloc( sizeof(int) * 10 );
int* ip = malloc( sizeof (*ip) * 10 );
for ( int i = 0; i < 10; ++i )
	ip[i] = i;

Im Vergleich dazu: In Java wird mit new Speicher auf dem Heap alloziert.

Speicherverwaltung mit malloc 3

Speicherverwaltung mit malloc und free

Wichtig: C bietet keine automatische Speicherverwaltung.

  • Reservierter Speicher muss “von Hand” freigegeben werden!
  • Hierfür wird die Funktion free verwendet:
void free( void* ptr );

  • Regel: Zu jedem malloc gehört auch immer ein free.
  • Ohne Aufruf von free entstehen Speicherlecks.
  • Wer reservierten Speicher wieder freigibt und wo im Programm dies geschieht, sind häufige Fehlerquellen in C.

Beispiel malloc und free in C

#include <malloc.h> 

int main(){
  // reserviere Speicher fuerr 100 Integers
  int *n = (int*)malloc(100 * sizeof(int));
  // Speicher kann verwendet werden
  for(int i=0;i<100;++i){ 
    n[i] = 0;
  }
// gibt Speicher wieder frei
free(n);
}

C++ – new und delete

  • In C++ existieren die Operatoren new und delete.
  • new erwartet einen Typ sowie (optional) Argumente für den Konstruktor und gibt einen Pointer des angegebenen Typs zurück: Widget* w = new Widget{args};
  • delete ruft den Destruktor des angegeben Pointers auf und gibt den entsprechenden Speicherbereich frei: delete w;
int *i = new int;

new

  • new T (T ist ein Typ) reserviert Speicher, damit ein Objekt vom Typ T hineinpasst
  • Dies kann aber auch mehr sein (C++-Runtime muss mit OS umgehen).
  • Der zurückgegebene Zeiger dient als ein “Schlüssel”, um an den Speicher (das Objekt) zu kommen.
  • Und auch, um ihn wieder freizugeben! → Das vergessen eines Zeigers führt zu Speicherlecks

Aufruf Konstruktor

Im Gegensatz zu der malloc-Variante, ruft new immer auch einen Konstrukor auf. Entweder explizit speziellen Konstruktor oder implizit den Standard Konstruktor.

Zugriff auf erstellte Objekte

Der Zugriff auf die erstellten Objekte erfolgt mithilfe des Dereferenzierungsoperators * (genau, wie bei Zeigern, auf Elemente von Arrays):

int *i = new int;
std::cout << *i << std::endl; 
*i = 1;
std::cout << *i << std::endl;

Bei Objekten kann alternativ auch der Zeiger-Operator verwendet werden

Point *p = new Point;
(*p).x = 4;
p−>y = 3; // gilt als besserer Stil

C++ - new und delete für Arrays

  • Um in C++ Speicher für ein Array anzufordern, gibt es den Operator new[].
  • Entsprechend muss der Speicherbereich mit dem Operator delete[] freigegeben werden: Widget* widgets = new Widget[10]; ... delete[] widgets;

Das Vermischen von new und delete[] sowie new[] und delete ist verboten!

Aufruf Konstruktor

  • new [] ruft immer für alle Elemente den Standard Konstruktor auf.
  • D.h. es muss einen Standard Konstruktor geben.

new[] und Konstruktoren

Bei Standarddatentypen (wie z.B. int und char) macht der Standardkonstruktor nichts!

  • Speicher bleibt un-initialisiert
  • Um mit new [] initialisierten Speicher mit 0 zu initialisieren:
    • Konstruktorklammern mit angeben
    • Dies gilt nur für Standarddatentypen!
int *as = new int[1000]; // uninitialisierter Speicher (meist zufaellig schon viel 0) 
int *bs = new int[1000](); // definitiv komplett 0

Aufgabe 3.1 Dynamische Speicherverwaltung

Hinweis: Aufgabe 3.1

Aufgabe zum expliziten erzeugen und nutzen der dynamischen Speicherverwaltung.

Im jupyter-book sind verschiedene Aufgaben angegeben:

Jupyter-Book Link

Hierüber kann dann direkt auf dem Hub eine Umgebung gestartet werden, in der C++ interpretiert wird (wird die ersten Termine genutzt):

JupyterHub Link

Probleme mit expliziter Speicherverwaltung

Explizite Speicherverwaltung ist fehleranfällig

Dies hat mehrere Gründe:

  • Zu jedem new muss genau ein entsprechendes delete gehören.
  • new und delete stehen aber im Quelltext oft weit außeinander.
    delete kann leicht vergessen werden.
  • Es ist nicht immer klar, wann oder wo delete aufgerufen werden muss. Wem gehört eigentlich die Ressource?

Konsequenzen bei nicht korrekter Speicherverwaltung:

  • Fehlt ein nötiges delete, wird Speicher nicht freigegeben. Man spricht von einem Speicherleck.
  • Wird delete mehrmals für den selben Pointer aufgerufen, führt dies zu einem Laufzeitfehler.
  • Aus diesen Gründen wurden verschiedene Mechanismen entwickelt, welche die Speicherverwaltung teilweise oder vollständig automatisch durchführen:
    • Garbage Collection (z. B. in Java).
    • Automatische Referenzzählung (z. B. in Objective-C).

Wo ist das Problem?

Das folgende Beispiel illustriert einige Probleme mit der manuellen Speicherverwaltung. Frage: Wird wp korrekt freigegeben?

void foo() 
{
  auto wp = new Widget; 
  if( cond1 ) {
    while( !cond2 ) {
      if( cond3 && cond4 || cond5 ) {
        bar();
      } else { return; }
    }
  delete wp;
}

Antwort

Nein! Es gibt Verzweigungen in dieser Funktion, welche die Funktion beenden, ohne delete aufzurufen. Zusätzlich könnte bar() eine Exception auslösen und die Funktion so in Zeile 7 verlassen werden.

Templates und Container

Template-Funktionen

  • Idee der generischen Programmierung ist es, Algorithmen und Datenstrukturen unabhängig von konkreten Datentypen zu implementieren.
  • Hierfür gibt es in C++ Templates:

Beispiel: Der Absolutbetrag einer Zahl kann unabhängig davon bestimmt werden, ob es sich um int oder float Werte handelt:

template <typename T> 
T abs(T x) {
  if (x < 0) { return -x; }
  else { return x; }
}

Template-Funktionen

template <typename T> 
T abs(T x) { if (x < 0) { return -x; } else { return x; } }
  • Das Schlüsselwort template kündigt in C++ ein Template an, hier eine Template-Funktion.
  • In den spitzen Klammern stehen die Template-Argumente.
  • Vor jedem Template-Argument steht das Schlüsselwort typename. Während die Funktionsargumente stellvertretend für Werte stehen, stehen die Template-Argumente für Datentypen (z. B. int oder float).
  • In der Funktion kann T wie ein normaler Datentyp verwendet werden.

Template-Funktionen

Template-Funktionen können entweder wie normale Funktionen aufgerufen werden, oder der zu verwendende Datentyp wird explizit angegeben:

int i = -5; 
double f = 7.5;
int j = abs(i); 
double g = abs(f);
int k = abs<int>(i); 
double h = abs<double>(f);

Aufgabe 3.2: Template-Funktionen

In der folgenden Aufgabe (später zu bearbeiten): Implementieren und testen Sie eine Template-Funktion printMaxReturnMin welche zwei Argumente erwartet und das größere auf der Konsole ausgibt und das kleinere als Rückgabewert zurück gibt.

#include <iostream>

// printMaxReturnMin definition

int main () {
  int i = 39;
  int j = 20;
  auto k = printMaxReturnMin(i,j);

  double a = 13.5;
  double b = 20.7;
  auto c = printMaxReturnMin(a,b);
}

Container

Ein Objekt, das uns erlaubt andere Objekte zu sammeln und mit ihnen auf spezielle Art und Weise zu interagieren.

Beispiele: Vektoren, Stapel oder Warteschlangen!

Aufgabe von Containern

Was ist der Zweck von Containertypen für Programmierungsprachen?

  • Organisation: Verwandte Daten können zusammen gepackt werden.
  • Standardisierung: Gemeinsame Merkmale werden erwartbar und zusammen implementiert
  • Abstraktion: Komplexe Ideen werden einfacher nutzbar

Container - Standardisierung

Normalerweise bieten Container einige grundlegende Standard-Funktionen an.

  • Sie ermöglichen es, mehrere Objekte zu speichern (alle vom gleichen Typs)
  • Erlauben den Zugriff auf diese Sammlung auf eine (aber möglicherweise begrenzte) Art und Weise
    • ermöglicht dann zum Beispiel Iteration über allen Objekten

Beispiel Container der STL

  • Vektor
  • Stack
  • Queue
  • Set
  • Map

  • Array
  • Deque
  • List
  • Ungeordnete Menge
  • Ungeordnete Map

Kurzvorstellung

Array

Ein array ist die primitive Form eines vectors: Feste Größe in einer strengen Reihenfolge

Deque

Eine deque ist eine double ended queue

List

Eine list ist eine doppelt verkettete Liste = lann in beide Richtungen durchlaufen werden.

Unterscheidung von Containern

Sequenz

  • Container, auf die sequentiell zugegriffen werden kann = alles, was eine inhärente Ordnung hat

Assoziativ

  • Container, die nicht unbedingt eine sequentielle Reihenfolge haben
  • Leichter zu durchsuchen
  • Maps und Sets gehören hierher

Beispiel Vektor-Implementierung

Wie funktioniert vector eigentlich?

Auf einer hohen Ebene ist ein Vektor eine geordnete Sammlung von Elementen des gleichen Typs, die wachsen und schrumpfen kann.

  • Intern sind Vektoren als Array implementiert.

Dabei beobachten von Parametern der Variable:

  • _size = Anzahl der Elemente des Vektors
  • _capacity = Platz, der für die Elemente reserviert wurde

Unterscheidung von Containern

Sequenz

  • Container, auf die sequentiell zugegriffen werden kann = alles, was eine inhärente Ordnung hat

Assoziativ

  • Container, die nicht unbedingt eine sequentielle Reihenfolge haben
  • Leichter zu durchsuchen
  • Maps und Sets gehören hierher

Alle Container können prinzipiell alle Arten von Informationen enthalten! Wie wählen wir aus, welche wir sinnvollerweise verwenden sollten?

Aufgabe 3.2 Templates und Container

Hinweis: Aufgabe 3.2

Im jupyter-book sind verschiedene Aufgaben angegeben – den letzten Teil müssen sie jedoch im Terminal ausführen.

Jupyter-Book Link

Hierüber kann dann direkt auf dem Hub eine Umgebung gestartet werden – dies lädt das gitlab und sie können hier ein Terminal starten, in dem sie den Code kompilieren und ihr Programm dann ausführen können.

JupyterHub Link

Verwendung sequentielle Containers – Übersicht

Aufgabe std::vector std::deque std::list
Einfügen/entfernen Element vorne langsam schnell schnell
Einfügen/entfernen Element hinten super schnell sehr schnell schnell
Indizierter Zugriff super schnell schnell nicht möglich
Einfügen/entfernen Element in der Mitte langsam schnell sehr schnell
Speichernutzung gering hoch hoch
Verbinden (splicing, joining) langsam sehr langsam schnell
Stabilität (iterators, concurrency) schlecht sehr schlecht gut

std::vector

  • std::vector ist der (mit Abstand) am meisten verwendete Container.
  • std::vector modelliert ein dynamisches Array von Werten desselben Typs, der durch einen Template-Parameter angegeben wird (z. B.: std::vector<int>).
  • Jeder kopierbare (oder verschiebbare) Datentyp kann als Elementtyp verwendet werden.
  • Werte können zur Laufzeit hinzugefügt oder entfernt werden:
#include <iostream>
#include <vector > 
int main() {
  std::vector<int> v( 42 ); // erzeuge Vektor der Länge 42 
  for( unsigned int i = 0; i < v.size(); ++i ) {
    std::cout << v[i] << " "; }
  v.push_back( 23 ); // v.size() == 43; v.back() == 23
  v.pop_back(); // v.size() == 42 
}

std::vector

  • Vektoren können auf unterschiedliche Art und Weise erzeugt werden.
  • Es kann die initiale Größe angegeben werden: std::vector<float> vf( 1024 ); //1024 float Werte
  • Alternativ können die initialen Elemente des Vektors als eine Initializer List angegeben werden: std::vector<float> vf{ 1.1f, 2.2f, 3.3f, 4.4f, 5.5f }; //5 float Werte
  • Erinnerung: Aufpassen bei Vektoren von Datentypen, die mit einem int Wert initialisiert werden können: std::vector<int> vi1{ 1, 2, 3, 4, 5 }; //5 int Werte std::vector<int> vi2{ 1024 }; // 1 int Wert (1024)
  • Die Initializer List „gewinnt“ gegenüber den anderen Konstruktoren. Um einen Vektor der Größe 1024 zu erzeugen, müssen runde Klammern verwendet werden: std::vector<int> vi3( 1024 ); //1024 int Werte

std::vector

Jede Funktion hat eine fest definierte Komplexität, z. B.:

  • Zugriff auf Elemente mit [] oder at: O(1), vi[42] = 23; vi.at( 23 )= 42;
  • Einfügen am Ende des Vektors mit push_back: O(1), vi.push_back( 42 );
  • Einfügen an einer anderen Position mit insert: O(n), vi.insert( vi.begin(), 23 );
  • Vergrößern des Vektors mit resize: O(n), vi.resize( 23 );

std::vector

Achtung

Reicht für Funktionen wie push_back der bisher reservierte Speicher nicht aus, so rufen diese resize auf und die Komplexität wird O(n)!

  • Insbesondere invalidiert resize beim Vergrößern alle Zeiger und Referenzen, die auf Elemente des Vektors verweisen.
  • Daher: Wiederholtes Aufrufen von push_back vermeiden oder per reserve oder resize vorsorgen.

std::vector

Schlecht

#include <vector> 

int main () {
  std::vector<int> vi{};

  for( int i = 0; i < 1024; ++i ) 
  {
    vi.push_back( i ); 
  }
}

Besser

#include <vector>

int main () {
  std::vector<int> vi{}; 
  vi.resize( 1024 );
  for(int i = 0; i<vi.size(); ++i)
  {
    vi[i] = i;
  }
}

std::vector

Vorsicht mit Zeigern und Referenzen auf Elemente eines Vektors!

#include <vector> 
#include <iostream>

struct A{ int i = 24; };

int main () 
{
  std::vector <A> v{}; 
  v.push_back( A{} ); 
  v[0].i = 24;

  A* ap = &(v[0]); // ap ist ein A-Zeiger auf das erste Element in v 
  std::cout << ap->i << std::endl; // 24

  for( int i = 0; i < 100; ++i ) 
    v.push_back( A{} );
  
  std::cout << ap->i << std::endl; // 17005616
}

Range-based for loop

Seit C++11 gibt es eine weitere Möglichkeit einen Container vollständig zu durchlaufen:

#include <vector>
#include <iostream> 

int main() {
  std::vector<int> vi{ 4, 8, 15, 16, 23, 42 };
  for( int i : vi ) // wie for each in Java 
  {
    std::cout << i << " "; 
  }
}
  • Diese for-Schleife nennt man range-based for loop.
  • Sie kann mit Objekten jeder Klasse verwendet werden, die begin und end Funktionen implementiert, die Iteratoren zurückgeben.
  • Alle Container der C++-Standardbibliothek können derart durchlaufen werden.

Range-based for loop

  • Bei der range-based for loop bietet es sich an, auto zu verwenden: for( auto i : vi ){ std::cout << i << ""; }

Achtung: Die linke Schleife ist wirkungslos, da die Zuweisung an eine Kopie und nicht das Original geschieht. Stattdessen Referenzen (auto&) verwenden!

std::vector<int> vi{ 4, 8, 15, 16, 23, 42 };
/* falsch: */
for( auto i : vi ) {
  i = 0; /*Zuweisung an Kopie*/
}
std::vector<int> vi{ 4, 8, 15, 16, 23, 42 };
/* richtig: */
for( auto& i : vi ) {
  i = 0; /*Zuweisung an Referenz*/
}

Faustregel

Zum vollständigen Durchlaufen eines Containers sollte die range-based for loop verwendet werden und folgendermaßen aussehen:

for( <const> auto& <var> : <container> ) {...};

Vorausblick: Iteratoren

  • Iteratoren verhalten sich syntaktisch ähnlich zu Zeigern: std::vector<int> vi{1,2,3}; auto iterator = vi.begin(); int one = *iterator;
  • Die Verwendung von auto ist hier besonders praktisch. Der Typ des Iterators aus dem letzten Beispiel ist std::vector<int>::iterator.
  • Durch Aufruf von ++ bewegen sich Iteratoren durch einen Container: iterator++; int two = *iterator; int three = *(++iterator);
  • container.begin() verweist immer auf das erste Element;
  • container.end() verweist hinter das letzte Element – das Dereferenzieren von container.end() ist nicht erlaubt!
code-bd454453.tex.svg

Sequentielle Container: Zusammenfassung

  • Sequenz-Container sind dafür da, wenn du eine Ordnung in deine Informationen bringen musst!
  • Normalerweise kann man einen std::vector für fast alles verwenden
  • Wenn du besonders schnell Elemente vorne einfügen möchtest, solltest du eine std::deque nutzen
  • Wenn du mehrere Listen zusammenfügen oder mit ihnen arbeiten willst, solltest du eine std::list verwenden

Unterscheidung von Containern

Sequenz

  • Container, auf die sequentiell zugegriffen werden kann = alles, was eine inhärente Ordnung hat

Assoziativ

  • Container, die nicht unbedingt eine sequentielle Reihenfolge haben
  • Leichter zu durchsuchen
  • Maps und Sets gehören hierher

Alle Container können prinzipiell alle Arten von Informationen enthalten! Wie wählen wir aus, welche wir sinnvollerweise verwenden sollten?

Implementierung von map

Karten werden als pair implementiert: std::pair<const key, value>

  • Beachte das const! Schlüssel müssen unveränderlich sein.
  • Die Indizierung in der Map ( myMap[key] ) durchsucht die zugrunde liegende Sammlung von Paaren nach dem ersten Auftreten des Schlüsselattributs und gibt dessen zweites Attribut zurück.

std::map

  • std::map speichert Werte anhand eines Schlüssels ab: std::map<std::string, std::string> m; m["key"] = "value";
  • Hierfür wird std::map mit zwei Template-Parametern spezifiziert, dem Datentyp des Schlüssels (Key) und des Wertes (Value): std::map<KeyDatatype, ValueDatatype> m;
int main() {
  std::map<std::string, int> word_map{};
  std::vector <std::string>  words{"this", "and", "that", "and", "more" };

  for( auto& w : words ) {
    word_map[w]++; 
  }
}

std::map

  • Maps werden in der Regel leer erzeugt und dann mit Werten gefüllt: std::map<std::string, unsigned int> m; m["test"] = 4;
  • Mithilfe des []-Operators können sowohl Werte aus der Map gelesen als auch in die Map geschrieben werden: unsigned int length_of_test = m["test"]; m["c++"] = 3;

Achtung

Ist zu einem Schlüssel kein Wert vorhanden, so wird ein neuer Schlüssel angelegt!

`unsigned int length_of_unknown = m[“unknown”]; //return 0

std::map – Zugriff auf Schlüssel

  • Um herauszufinden, ob zu einem Schlüssel ein Wert existiert, wird find verwendet: auto pos = m.find("not_in_map"); if(pos == m.end()){...}
  • Mit erase können Schlüssel-Wert-Paare aus der Map entfernt werden: m.erase("test");

std::map

  • Wie bei std::vector hat jede Funktion eine definierte Komplexität, z. B. :
    • Einfügen oder Zugreifen auf Elemente: O(logn), m["key"] = "value"; auto v = m["key"];
    • Suchen nach Elementen: O(logn), auto pos = m.find("needle");
    • Löschen von Elementen. Wenn Position bekannt: O(1), sonst O(logn), m.erase(m.begin()); /*O(1)*/ m.erase("to_be_erased")/*O(log n)*/
  • Als Schlüssel können Objekte jeder Klasse verwendet werden, die den Vergleichsoperator < überladen.
  • Als Werte können Objekte jeder Klasse verwendet werden, die kopierbar (oder verschiebbar) ist.

Range-based for loop und Maps

Auch eine Map kann vollständig durchlaufen werden:

std::map<std::string, int> m; 
m["c++"] = 3; m["test"] = 4; m["needle"] = 6;

for( auto& p : m ) // type of p: std::pair<const std::string, int>& 
{
  std::cout << "key: " << p.first << " value: " << p.second << "\n"; 
}
  • Dabei erhält man Zugriff auf das Paar aus Schlüssel und Wert.
  • Mit p.first greift man auf den Schlüssel zu.
  • Mit p.second greift man auf den Wert zu.
  • first und second sind keine Funktionen, sondern einfache public Attribute.
  • Da die Map nach den Schlüsseln sortiert ist, ist auch die Ausgabe im Beispiel entsprechend sortiert.

Ungeordnete maps/ sets

Sowohl Maps als auch Sets in der STL haben eine ungeordnete Version!

  • Für geordnete Maps/Sets muss ein Vergleichsoperator definiert sein.
  • Ungeordnete Maps/Sets erfordern die Definition einer Hash-Funktion.
  • Ungeordnete Maps/Sets sind in der Regel schneller als geordnete Maps/Sets!

Auswahl assoziative Container

Es gibt viele Ähnlichkeiten zwischen Maps/Sets. Allgemeine Tipps zur Wahl:

  • Ungeordnete Container sind schneller, aber es kann schwierig sein mit verschachtelten Containern/Sammlungen zu arbeiten.
  • Wenn du komplexe Datentypen verwendest / mit Hash-Funktionen nicht vertraut bist, verwende einen geordneten Container

Zusammenfassung

  • Container sind eine Möglichkeit, zusammengehörige Daten zu sammeln und in einer standardisierten Weise mit ihnen zu arbeiten
  • Zwei Arten von Containern: sequenziell und assoziativ
  • Container-Adapter umhüllen bestehende Container, um den neuen/ eingeschränkten Zugriff auf die Schnittstelle für die Clients zu ermöglichen.

Überblick Container

Category Container After insertion, are… After erasure, are… Conditionally
iterators valid? references valid? iterators valid? references valid?
Sequence containers array N/A N/A
vector No N/A Insertion changed capacity
Yes Yes Before modified element(s)
(for insertion only if capacity didn’t change)
No No At or after modified element(s)
deque No Yes Yes, except erased element(s) Modified first or last element
No No Modified middle only
list Yes Yes, except erased element(s)
forward_list Yes Yes, except erased element(s)
Associative containers set
multiset
map
multimap
Yes Yes, except erased element(s)
Unordered associative containers unordered_set
unordered_multiset
unordered_map
unordered_multimap
No Yes N/A Insertion caused rehash
Yes Yes, except erased element(s) No rehash

Klassen in C++

Klassen und Objekte in C++

  • Objektorientierte Programmierung ist in C++ ähnlich zu Java realisiert.
  • Eine Klasse definiert wie eine Struktur einen neuen Datentypen.
  • In Klassen werden Verhalten (Funktionen) und Eigenschaften (Zustand/Daten) gekapselt und zusammenhängend organisiert.
  • Von einer Klasse werden Objekte instanziiert, die einen konkreten Vertreter der Klasse darstellen. Die Klasse ist der Datentyp des Objektes.
  • Klassen können durch Vererbung in Hierarchien organisiert werden.

Wichtige Konzepte der Objektorientierung

  • Ein Programm besteht aus Klassen, die die Komponenten des Programms beschreiben.
  • Objekte werden aus Klassen erzeugt.
  • Es können mehrere Objekte einer Klasse erzeugt werden.
  • Alle Objekte einer Klasse haben die selbe Struktur.
  • Objekte anderer Klassen haben eine andere Struktur.
  • Der Zustand jedes Objektes einer Klasse kann unterschiedlich sein.
  • Objekte besitzen Methoden (die über das Versenden von Botschaften aufgerufen werden können).
  • Methoden können Parameter und/oder Rückgabewerte (Botschaften) haben.

Definition Objekt

Objekt

aus Definition 8.2 (Vahrenhold 2022), basierend auf (Echtle und Goedicke 2000):

“Ein (korrekt modelliertes) Objekt modelliert ein gedanklich abgegrenztes Gebilde mit allen seinen Eigenschaften und Verhaltensweisen.”

Klassen in C++

  • Klassen werden durch das Schlüsselwort class eingeleitet.
  • Ein Semikolon schließt die Definition der Klasse ab (leicht zu vergessen). * In Klassen werden Eigenschaften und Verhaltensweisen definiert:
    • Für jedes Objekt:
      • Attribute (data members)
      • Funktionen (member functions)
    • Für die gesamte Klasse:
      • Klassenattribute (static data members)
      • Klassenfunktionen (static member functions)
  • this ist ein Pointer auf das instanziierte Objekt und in jeder Klasse vorhanden.

Beispiel-Klasse

class Widget {
  int _i; // data member
  static int _j; // static data member
public:
  void setI(int i) { _i = i; } // member function
  static void setJ(int j) { _j = j; } // static member function
};

Widget::setJ( 23 ); Widget w;
w.setI( 42 );

Definition und Deklaration von Membern

  • Member können nur innerhalb der Klasse deklariert werden.
  • Definitionen können sowohl innerhalb als auch außerhalb der Klasse stehen:
class Widget { 
  int _i;
public:
  void setI(int i) { _i = i; } // Deklaration & Definition 
  int getI(); // Deklaration
};
// Definition
int Widget::getI() { // Achtung: Namen der Klasse nicht vergessen!
  return _i; 
}
  • Oft steht die Klasse mit allen Deklarationen in einer Header-Datei (.h oder .hpp) und die Definitionen in einer Source-Datei (.cpp).

Recap: Programmaufteilung auf verschiedene Dateien

Bei größeren Projekten trennt man meist Deklarationen und Definitionen in - Header-Dateien und - Implementierungs-Dateien

→ Erlaubt separate Kompilierung/Verwendung von Prebuilt-Libraries.

// max.h
// Deklaration
int max( int , int );
// max.c
// Definition
int max( int a, int b ) {
	return a>b? a : b; }
// foo.c
#include <stdio.h>
#include "max.h"

int main( void ) {
	printf( "%i\n" , max( 37, 91 ) );
}

Beim Kompilieren müssen alle Implementierungsdateien angegeben werden: gcc -Wall -o foo max.c foo.c

Recap: Wie teile ich meinen Quellcode in Header und Implementierung auf?

Die Aufeilung in Header und Implementierung ist nicht immer einfach. Generell gilt:

  • Das Interface (wie wird die Bibliothek verwendet) gehört in einen oder mehrere Header.
  • Die Implementierung (was macht die Bibliothek) gehört in Quelltextdateien.

Implementierung

#include "Header.h"

void println( int x ) {
  std::cout << x << std::endl; 
}

Sichtbarkeit

  • In Klassen können Bereiche als public, private oder protected deklariert werden.
  • Von außerhalb der Klasse kann nur auf public Member zugegriffen werden.
  • Auf private Member können nur Objekte der Klasse zugreifen.
  • Auf protected Member können auch Objekte abgeleiteter Klassen zugreifen.
  • Standard bei der Sichtbarkeit ist private.
  • In C++ kann zur Definition einer Klasse anstelle von class auch struct verwendet werden. Dann ist die Standardsichtbarkeit public.

Member-Funktionen und const

  • Das Schlüsselwort const kann bei Member-Funktionen angegeben werden.
  • Objekte, welche const sind (z. B. die Referenz wr), können nur konstante Member-Funktionen ausführen.
  • Innerhalb einer konstanten Member-Funktion können keine Attribute des Objektes verändert werden.

  • Faustregel: Überprüfe bei jeder Member-Funktion, ob diese als const deklariert werden kann.

Member-Funktionen und const – Beispiel

class Widget { 
public:
  int set(int i) { _i = i; }
  int get() const //<- !!! { return _i; }
private: 
  int _i;
};
Widget w;
const Widget& wr = w;

w.set( 42 ); // ok 
int i = w.get(); // ok
wr.set( 23 ); // compile error 
int j = wr.get(); // ok

Konstruktoren und Destruktor

  • Bei der Instanziierung eines Objektes wird der Konstruktor aufgerufen.
  • Wird ein Objekt zerstört, so wird der Destruktor aufgerufen (wenn: Objekt verlässt den Scope, delete auf dem Objekt, Objekt ist statisch und Programm terminiert).
class Widget { 
  int _i;
  int _j;

public:
  Widget(int i, int j) : _i(i), _j(j) { // Konstruktor
    std::cout << "Widget created\n"; } 
    
  Widget(int i) : Widget(i, 42) { // Konstruktor
    std::cout << "Delegating constructor\n"; } 

  ~Widget() { // Destruktor
    std::cout << "Widget destroyed\n"; }
};

Konstruktoren und Destruktor

  • Es kann mehrere Konstruktoren mit unterschiedlichen Parametern geben.
  • Es gibt immer nur einen Destruktor (ohne Parameter).
  • Konstrukten und Destruktoren werden wie Funktionen definiert, jedoch haben sie keinen Rückgabetyp.
  • Im Konstruktor können/müssen die Attribute der Klasse direkt mit einer sogenannten member initialization list initialisiert werden (rot markiert im Beispielcode).

Faustregel:

Initialisiere alle member objects in der member initialization list.

Konstruktoren und Destruktor

  • Falls die Entwickelnden keinen Konstruktor angeben, wird automatisch ein parameterloser Standardkonstruktor (default constructor) erzeugt.
  • Dies funktioniert nur dann, wenn alle Attribute der Klasse ebenfalls über einen Standardkonstruktor verfügen.
class A { 
  int _i;

public:
  A(int i) : _i(i) {}
};

class B {
  A a; // compile error: no matching function for call to ’A::A()’
};
  • Auch der Destruktor und einige andere sogenannte special member functions werden bei Bedarf vom Compiler erzeugt.

Objekte instanziieren

  • Objekte einer Klasse werden mit einer ähnlichen Syntax wie Variablen von Basisdatentypen instanziiert.
  • In geschweiften Klammern stehen die Argumente des Konstruktors (ab C++11).
  • Anstelle der geschweiften Klammern sind auch runde Klammern möglich.

// neue Syntax (ab C++11)
Widget w1{1, 2};
// alte Syntax
Widget w2(1, 2);

Widget w3 {}; // ok

w4(); // Funktionsdeklaration
// stattdessen
Widget w5;

int i{12345};

Vererbung

  • Durch Vererbung kann eine Hierarchie von Klassen geschaffen werden, um Beziehungen zwischen Klassen zu modellieren.
  • Vererbungen sollten eine „ist-ein“-Beziehung darstellen.
  • Durch Vererbung entsteht eine starke Abhängigkeit zwischen zwei Klassen, daher sollte nicht jede Beziehung als Vererbung modelliert werden.
  • In C++ gibt es verschiedene Arten der Vererbung:
    • public-Vererbung vs. private-Vererbung.
    • Einfache Vererbung vs. Mehrfachvererbung. * …
  • Die Vererbung in Java entspricht der einfachen public-Vererbung in C++.

Einfache public-Vererbung

Bei einfacher Vererbung besitzt eine Klasse genau eine Oberklasse.

class Base { 
public:
  Base(int i) : _i{i} {}

  void setI(int i) {_i = i;}
  int getI() const {return _i;} 
protected: 
  int _i;
};

class Derived : public Base {
public:
  Derived(int i, int j) : 
    Base{i}, _j{j} {}
  void setJ(int j) {_j = j;}
  int getJTimesI() {return _j*_i;} 
private:
  int _j;
};

  • In Derived wird mit : public Base die Vererbung angegeben.
  • Im Konstruktor von Derived kann explizit ein Konstruktor von Base aufgerufen werden (ansonsten automatisch der Standardkonstruktor von Base).
  • protected- und public-Attribute einer Basisklasse behalten ihre Sichtbarkeit in der abgeleiteten Klasse.

Aufgabe 3.3 Klassen

Aufgabe: Klassen

  • Legen Sie eine Datei customer.cpp an.
  • Implementieren Sie die Klasse Account in der Datei customer.cpp gemäß dem UML-Diagramm.
code-4231a064.tex.svg

Aufgabe: Klassen

  • Überlegen Sie sich dabei sinnvolle Parameter für die Funktionen deposit, withdraw und transfer.
  • Beachten Sie, dass Überweisungen ausschließlich auf andere Konten durchgeführt werden können.
  • Fügen Sie einen Konstruktor hinzu, mit dem ein Account mit Startguthaben angelegt werden kann.
  • Implementieren Sie in der Datei customer.cpp eine main-Funktion und testen Sie die Klasse Account.

Hinweis: Kompilieren Sie Ihr Programm mit g++ -std=c++20 -Wall -Wextra -o customer customer.cpp

Aufgabe: Vererbung

  • Implementieren Sie eine Klasse Person mit einem Namen und einem Alter.
  • Implementieren Sie die Klasse Customer, die von Person erbt und dazu einen Account und eine ID (z. B. unsigned int) besitzt.
  • Fügen Sie in beiden Klassen eine Funktion print hinzu, die den Zustand des Objekts (Werte aller Attribute) ausgibt.
  • Achten Sie auf die Verwendung von virtual!
  • Sie können die Musterlösung aus der vorherigen Vorlesung als Basis nutzen.
code-2a1c4b44.tex.svg

Testen der Klasse Account

int main() {
  std::cout << std::boolalpha;

  Account a{ 25 };
  a.deposit( 75 );
  a.deposit( 50 );
  std::cout << (a.balance() == 150) << std::endl; // true 
  std::cout << a.withdraw( 25 )  << std::endl; // 25
  std::cout << (a.balance() ==  125) << std::endl; // true
  std::cout << a.withdraw( 150 ) << std::endl; // 0

  Account b{ a };
  std::cout << a.transfer( 50, a) << std::endl; // false
  std::cout << a.transfer( 126, b ) << std::endl; // false
  std::cout << a.transfer( 125, b ) << std::endl; // true 
  std::cout << (a.balance() == 0) << std::endl; // true 
  std::cout << (b.balance() == 250) << std::endl; // true 
}

Hinweis: Aufgabe 3.3

Aufgabe zum Anlegen einer Klasse und von Beispielinstanzen

Im jupyter-book ist die Aufgaben angegeben:

Jupyter-Book Link

Hierüber kann dann direkt auf dem Hub eine Umgebung gestartet werden, in der C++ interpretiert wird (wird die ersten Termine genutzt):

JupyterHub Link

References

Echtle, Klaus, und Michael Goedicke. 2000. Lehrbuch der Programmierung mit Java. dpunkt-Verlag.
Hymel, Shawn. 2021. „Introduction to RTOS - Solution to Part 4 (Memory Management)“. https://www.digikey.de/en/maker/projects/introduction-to-rtos-solution-to-part-4-memory-management/6d4dfcaa1ff84f57a2098da8e6401d9c.
Kölling, Michael, und John Rosenberg. 2001. „Guidelines for teaching object orientation with Java“. ACM SIGCSE Bulletin 33 (3). ACM New York, NY, USA: 33–36.
„Stack and Heap Layout of Embedded Projects“. 2021. https://visualgdb.com/documentation/embedded/stackheap/.
Vahrenhold, Jan. 2022. „Informatik I: Grundlagen der Programmierung, 8 Objekte und Klassen“. Lecture Notes, University of Münster.