Programmiersprachen und -konzepte

Unterschiedliche Programmiersprachen weisen nicht nur verschiedene Syntaxelemente auf, sondern haben eine teilweise stark unterschiedliche Semantik. Beides hat Auswirkungen auf die Bausteine eines Compilers.

Subsections of Programmiersprachen und -konzepte

Einführung in C++ (Erinnerungen an C)

TL;DR

Für C wurde ein paar Jahre nach der Entstehung ein objektorientierter Aufsatz entwickelt: C++. Beide Sprachversionen werden aktiv weiterentwickelt, vor allem in C++ gibt es ca. alle 3 Jahre einen neuen Standard mit teilweise recht umfangreichen Ergänzungen. Hier fließen analog zu Java immer mehr Programmierkonzepte mit ein, die aus anderen Sprachen stammen (etwa funktionale Programmierung). Das macht das Erlernen und Beherrschen der Sprache nicht unbedingt leichter. Die für uns wichtigsten Neuerungen kamen mit C11 und C++11 bzw. C++14.

C und C++ versuchen (im Gegensatz zu Java) ressourcenschonende Sprachen zu sein: Ein korrektes Programm soll so schnell wie möglich ausgeführt werden können und dabei so effizient wie möglich sein (etwa in Bezug auf den Speicherbedarf). Deshalb gibt es keine Laufzeitumgebung, der Quellcode wird direkt in ein ausführbares (und damit Betriebssystem-abhängiges) Binary compiliert. Beide Sprachen erlauben dem Programmierer den Zugriff auf die Speicherverwaltung und damit viele Freiheiten. Die Kehrseite ist natürlich, dass Programmierfehler (etwa bei der Speicherallokation oder bei Indexberechnungen) nicht von der Laufzeitumgebung entdeckt und abgefangen werden können.

C-Programme sehen auf den ersten Blick Java-Code relativ ähnlich. Das ist nicht verwunderlich, da Java zeitlich nach C/C++ entwickelt wurde und die Syntax und große Teile der Schlüsselwörter von C und C++ übernommen hat. C++ hat die C-Syntax übernommen und fügt neue objektorientierte Konzepte hinzu. Mit gewissen Einschränkungen funktioniert also C-Code auch in C++.

In C++ gibt es Klassen (mit Methoden und Attributen), und zusätzlich gibt es Funktionen. Der Einsprungpunkt in ein Programm ist (analog zu Java) die Funktion main(), die ein int als Ergebnis zurückliefert. Dieser Integer kann vom Aufrufer ausgewertet werden, wobei der Wert 0 typischerweise als Erfolg interpretiert wird. Achtung: Das ist eine Konvention, d.h. es kann Programme geben, die andere Werte zurückliefern. Die Werte müssen dokumentiert werden.

Bevor der Compiler den Quelltext "sieht", wird dieser von einem Präprozessor bearbeitet. Dieser hat verschiedene Aufgaben, unter anderem das Einbinden anderer Dateien. Dabei wird ein #include "dateiname" (sucht im aktuellen Ordner) bzw. #include <dateiname> (sucht im Standardverzeichnis) ersetzt durch den Inhalt der angegebenen Datei.

C++-Code muss kompiliert werden. Dabei entsteht ein ausführbares Programm. Mit Make kann man den Kompiliervorgang über Regeln automatisieren (denken Sie an ANT in der Java-Welt, nur ohne XML). Eine Regel besteht aus einem Ziel (Target), einer Liste von Abhängigkeiten sowie einer Liste mit Aktionen (Anweisungen). Um ein Ziel zu "bauen" müssen zunächst alle Abhängigkeiten erfüllt sein (bzw. falls sie es nicht sind, erst noch "gebaut" werden - es muss entsprechend weitere Regeln geben, um diese Abhängigkeiten "bauen" zu können). Dann wird die Liste der Aktionen abgearbeitet. Ziele und Abhängigkeiten sind in der Regel Namen von Dateien, die existieren müssen bzw. über die Aktionen erzeugt werden sollen. Die Aktionen sind normale Befehlssequenzen, die man auch in einer Konsole eingeben könnte. Make berücksichtigt den Zeitstempel der Dateien: Ziele, die bereits existieren und deren Abhängigkeiten nicht neuer sind, werden nicht erneut gebaut.

Die gute Nachricht: In Bezug auf Variablen, Operatoren und Kontrollfluss verhalten sich C und C++ im Wesentlichen wie Java.

Es gibt in C++ den Typ bool mit den Werten true und false. Zusätzlich werden Integerwerte im boolschen Kontext (etwa in einer if-Abfrage) ausgewertet, wobei der Wert 0 einem false entspricht und alle anderen Integer-Werte einem true. (Dies steht etwas im Widerspruch zu den Werten, die in der main-Funktion per return zurückgeliefert werden: Hier bedeutet 0 in der Regel, dass alles OK war.)

Die Basisdatentypen sind (bis auf char und bool) in ihrer Größe maschinenabhängig. Es kann also sein, dass Code, der auf einem 64bit-Laptop ohne Probleme läuft, auf einem Raspberry PI Überläufe verursacht! Um besonders ressourcenschonend zu arbeiten, kann man die Speichergröße für einige Basisdatentypen durch die Typmodifikatoren short und long beeinflussen sowie die Interpretation von Zahlenwerten mit oder ohne Vorzeichen (signed, unsigned) einstellen.

Die Anzahl der für einen Typ oder eine Variable/Struktur benötigten Bytes bekommt man mit dem Operator sizeof heraus.

Mit typedef kann man einen neuen Namen für bereits existierende Typen vergeben.

In C++ gibt es Funktionen (analog zu Methoden in Java), diese existieren unabhängig von Klassen.

Wenn eine Funktion aufgerufen wird, muss dem Compiler die Signatur zur Prüfung bekannt sein. Das bedeutet, dass die Funktion entweder zuvor komplett definiert werden muss oder zumindest zuvor deklariert werden muss (die Definition kann auch später in der Datei kommen oder in einer anderen Datei). Das Vorab-Deklarieren einer Funktion nennt man auch "Funktionsprototypen".

Eine Deklaration darf (so lange sie konsistent ist) mehrfach vorkommen, eine Definition immer nur exakt einmal. Dabei werden alle Code-Teile, die zu einem Programm zusammencompiliert werden, gemeinsam betrachtet. => Das ist auch als One-Definition-Rule bekannt.

In C++ gilt beim Funktionsaufruf immer zunächst immer die Parameterübergabe per call-by-value (dito bei der Rückgabe von Werten). Wenn Referenzen oder Pointer eingesetzt werden, wird dagegen auch ein call-by-reference möglich. (Dazu später mehr.)

Unterscheidung in globale, lokale und lokale statische Variablen mit unterschiedlicher Lebensdauer und unterschiedlicher Initialisierung durch den Compiler.

Lernziele
  • (K1) Wie hängen C und C++ zusammen?
  • (K1) Wichtigste Unterschiede und Gemeinsamkeiten zu Java
  • (K1) Wichtigste Aufgaben des Präprozessors
  • (K3) Aufbau, Übersetzen und Starten von einfachen C++-Programmen
  • (K3) Standard-Ein-/Ausgabe-Kanäle in C++ sowie die Operatoren >> und <<
  • (K3) Nutzung der Basisdatentypen einschließlich der Modifikatoren
  • (K3) Deklaration von Variablen, Nutzung von Kontrollstrukturen und Operatoren
  • (K3) Interpretation von Integers im booleschen Kontext
  • (K3) Nutzung des Scope-Operators ::, Namensräume
  • (K3) Benutzung von sizeof zur Bestimmung des Speicherbedarfs
  • (K3) Benutzung von typedef zur Definition neuer Typen (Aliase bestehender Typen)
  • (K3) Erinnerung: Automatisiertes Übersetzen mit Hilfe von GNU Make und einfachsten Makefiles
  • (K2) Unterschied zwischen Deklaration und Definition, One Definition Rule
  • (K2) Problematik bei der Deklaration parameterloser Funktionen
  • (K2) Call-by-Value-Semantik bei der Parameterübergabe
  • (K2) Sichtbarkeit und Initialisierung von Variablen
  • (K3) Definition und Deklaration von Funktionen
  • (K3) Nutzung lokaler und globaler und lokaler statischer Variablen

Warum?

  • C++ erlaubt ressourcenschonende Programmierung
  • Objektorientierter "Aufsatz" auf C
  • Verbreitet bei hardwarenaher und/oder rechenintensiver Software

Sie werden C++ im Modul "Computergrafik" brauchen!

Geschichte

  • 1971-73: Ritchie entwickelt die Sprache C
  • Ab 1979: Entwicklung von C++ durch Bjarne Stroustrup bei AT&T
    • Erweiterung der prozeduralen Sprache C
    • Ursprünglich "C mit Klassen", später "C++" (Inkrement-Operator)
  • Bis heute: Fortlaufende Erweiterungen: alle 3 Jahre neuer Standard (C++11, C++14, ...)
C/C++ vs. Java
  • Java: Fokus auf Sicherheit und Robustheit
    • Diverse Sicherheitschecks durch Compiler und VM (zb. Array-Zugriff)
    • Speicherverwaltung (Garbage Collection), kein Speicherzugriff über Pointer
    • Automatische Initialisierung von Variablen
  • C/C++: Fokus auf Effizienz (Speicher, Laufzeit) für korrekte Programme
    • Vergleichsweise schwache Sicherheitschecks durch Compiler, keine VM (d.h. keine Prüfung von Array-Indizes u.a.)
    • Keine Garbage Collection, Programmierer hat direkten Zugriff auf Speicher
    • Keine automatische Initialisierung von Variablen

Hello World!

/*
 * HelloWorld.cpp (g++ -Wall HelloWorld.cpp)
 */

#include <cstdio>
#include <iostream>
#include <cstdlib>

using namespace std;

int main() {
    printf("Hello World from C++  :-)\n");
    cout << "Hello World from C++  :-)" << endl;
    std::cout << "Hello World from C++  :-)" << std::endl;

    return EXIT_SUCCESS;
}

Beobachtungen

Jedes (ausführbare) C++-Programm hat genau eine main()-Funktion. Die main()-Funktion ist keine Methode einer Klasse: In C/C++ gibt es Funktionen auch außerhalb von Klassen.

In C++ gibt es Namespaces (dazu später mehr). Die aus der Standardbibliothek importierten Funktionen sind in der Regel im Namespace std definiert. Mit using namespace std; können Sie auf die Elemente direkt zugreifen. Wenn Sie das using namespace std; weglassen, müssten Sie bei jeder Verwendung eines Symbols den Namensraum explizit dazu schreiben std::cout << "Hello World from C++ :-)" << std::endl;.

Sie können im C++-Code auch Funktionen aus C benutzen, d.h. Sie können für die Ausgabe beispielsweise printf nutzen (dazu müssen Sie den Header <cstdio> importieren). Die "richtige" Ausgabe in C++ ist aber die Nutzung des Ausgabestreams cout und des Ausgabeoperators <<. Das endl sorgt für einen zum jeweiligen Betriebssystem passenden Zeilenumbruch.

Der Rückgabewert signalisiert Erfolg bzw. Fehler der Programmausführung. Dabei steht der Wert 0 traditionell für Erfolg (Konvention!). Besser Makros nutzen: EXIT_SUCCESS bzw. EXIT_FAILURE (in cstdlib).

Präprozessor

Der Präprozessor transformiert den Quellcode vor dem Compiler-Lauf. Zu den wichtigsten Aufgaben gehören dabei die Makrosubstitution (#define Makroname Ersatztext) und das Einfügen von Header-Dateien (und anderen Dateien) per #include. Es gibt dabei zwei Formen, die an unterschiedlichen Orten nach der angegebenen Datei suchen:

  • #include "dateiname" sucht im aktuellen Ordner
  • #include <dateiname> sucht im Standardverzeichnis

Das #include kann wie in C genutzt werden, aber es gibt auch die Form ohne die Dateiendung ".h". Da es in C keine Funktionsüberladung gibt (in C++ dagegen schon), müssen die C-Header speziell markiert sein, um sie in C++ verwenden zu können. Für die Standard-Header ist dies bereits erledigt, Sie finden diese mit einem "c" vorangestellt:

  • Include in C: #include <stdio.h>
  • Include in C++: #include <cstdio>

Übersetzen, Linken, Ausführen

C++-Dateien werden üblicherweise mit der Endung ".cpp" oder ".cxx" oder ".cc" abgespeichert, Header-Dateien mit den Endungen ".hpp" oder ".hxx" oder ".hh".

Zum Übersetzen und Linken in einem Arbeitsschritt rufen Sie den Compiler auf: g++ HelloWorld.cpp bzw. besser g++ -Wall -o helloworld HelloWorld.cpp. Die Option -Wall sorgt dafür, dass alle Warnungen aktiviert werden.

Ausführen können Sie das erzeugte Programm in der Konsole mit: ./helloworld. Der aktuelle Ordner ist üblicherweise (aus Sicherheitsgründen) nicht im Suchpfad für ausführbare Dateien enthalten. Deshalb muss man explizit angeben, dass ein Programm im aktuellen Ordner (.) ausgeführt werden soll.

Variablen, Operatoren, Kontrollfluss

Im Wesentlichen wie von C und Java gewohnt ... :-)

  • Wichtig(st)e Abweichung:

    Im booleschen Kontext wird int als Wahrheitswert interpretiert: Alle Werte ungleich 0 entsprechen true (!)

    Anmerkung: Dies steht im Widerspruch zu den Werten, die in der main-Funktion per return zurückgeliefert werden: Hier bedeutet 0 in der Regel, dass alles OK war.

=> Vorsicht mit

int c;
if (c=4) { ... }

Ein- und Ausgabe mit printf und cin/cout

  • printf(formatstring, ...)

    string foo = "fluppie";
    printf("hello world : %s\n", foo.c_str());
    • Einbinden über #include <cstdio>

    • Format-String: Text und Formatierung der restlichen Parameter: %[flags][width][.precision]conversion

      • flags: hängt von der konkreten Ausgabe ab

      • width: Feldbreite

      • precision: Anzahl der Dezimalstellen

      • conversion: (Beispiele)

        c Zeichen (Char)
        d Integer (dezimal)
        f Gleitkommazahl
  • Standardkanäle: cin (Standardeingabe), cout (Standardausgabe), cerr (Standardfehlerausgabe)

    • Genauer: cout ist ein Ausgabestrom, auf dem der Operator << schreibt
    • Einbinden über #include <iostream>
    • Implementierung der Ein- und Ausgabeoperatoren (>>, <<) für Basistypen und Standardklassen vorhanden
    • Automatische Konvertierungen für Basistypen und Standardklassen
    // Ausgabe, auch verkettet
    string foo = "fluppie";
    cout << "hello world : " << foo << endl;
    
    // liest alle Ziffern bis zum ersten Nicht-Ziffernzeichen
    // (fuehrende Whitespaces werden ignoriert!)
    int zahl; cin >> zahl;
    // Einzelne Zeichen (auch Whitespaces) lesen
    char c; cin.get(c);

Sichtbarkeit und Gültigkeit und Namespaces

Wie in Java:

  • Namen sind nur nach Deklaration und innerhalb des Blockes, in dem sie deklariert wurden, gültig
  • Namen sind auch gültig für innerhalb des Blockes neu angelegte innere Blöcke
  • Namen in inneren Blöcken können Namen aus äußeren Scopes überdecken

Zusätzlich gibt es noch benannte Scopes und einen Scope-Operator.

  • C++ enthält den Scope-Operator :: => Zugriff auf global sichtbare Variablen

    int a=1;
    int main() {
        int a = 10;
        cout << "lokal: " << a << "global: " << ::a << endl;
    }
  • Alle Namen aus XYZ zugänglich machen: using namespace XYZ;

    using namespace std;
    cout << "Hello World" << endl;
  • Alternativ gezielter Zugriff auf einzelne Namen: XYZ::name

    std::cout << "Hello World" << std::endl;
  • Namensraum XYZ deklarieren

    namespace XYZ {
        ...
    }

Arrays und Vektoren in C++

  • Syntax: Typ Name[AnzahlElemente];

    int myArray[100];
    int myArray2[] = {1, 2, 3, 4};
    • Compiler reserviert sofort Speicher auf dem Stack => statisch: im Programmlauf nicht änderbar

    • Zugriff über den Indexoperator []

    • Achtung: "roher" Speicher, d.h. keinerlei Methoden

    • Größe nachträglich bestimmen mit sizeof:

      int myArray[100], i;
      int cnt = sizeof(myArray)/sizeof(myArryay[0]);

    Guter Stil: Anzahl der Elemente als Konstante deklarieren: Statt int myArray[100]; besser

    #define LENGTH 100
    int myArray[LENGTH];
  • Vordefinierter Vektor-Datentyp vector

    • Einbinden über #include <vector>
    • Parametrisierter Datentyp (C++: Templates) - Nutzung analog wie in Java (Erstellung von Templateklassen und -methoden aber deutlich anders!)
    • Anlegen eines neuen Arrays mit 10 Elementen für Integer:
    vector<int> v(10);
    vector<double> meinVektor = {1.1, 2.2, 3.3, 4.4};
    meinVektor.push_back(5.5);
    cout << meinVektor.size() << endl;
    • Zugriff auf Elemente:
    cout << v[0] << endl;        // ohne Bereichspruefung!
    cout << v.at(1000) << endl;  // mit interner Bereichspruefung
    
    • Zuweisung (mit Kopieren):
    vector<double> andererVektor;
    andererVektor = meinVektor;
    • Dynamische Datenstruktur:
    vector<int> meineDaten;      // initiale Groesse: 0
    meineDaten.push_back(123);   // Wert anhaengen
    
    meineDaten.pop_back();  // Wert loeschen
    meineDaten.empty();     // leer?
    

Vorsicht! vector<int> arr(); ist kein Vektor der Länge 0, sondern deklariert eine neue Funktion!

Alias-Namen für Typen mit typedef und using

  • Syntax: typedef existTyp neuerName; (C, C++)

    typedef unsigned long uint32;
    uint32 x, y, z;

    Im Beispiel ist uint32 ein neuer Name für den existierenden Typ unsigned long, d.h. die Variablen x, y und z sind unsigned long.

  • Syntax: using neuerName = existTyp; (C++)

    typedef unsigned long uint32;       // C, C++
    using uint32 = unsigned long;       // C++11
    
    typedef std::vector<int> foo;       // C, C++
    using foo = std::vector<int>;       // C++11
    
    typedef void (*fp)(int,double);     // C, C++
    using fp = void (*)(int,double);    // C++11
    

    Seit C++11 gibt es das Schlüsselwort using für Alias-Deklarationen (analog zu typedef). Dieses funktioniert im Gegensatz zu typedef auch für Templates mit (teilweise) gebundenen Template-Parametern.

Erinnerungen an C - Vergleich mit C++

Erinnerungen an C - Vergleich mit C++

Basisdatentypen

char Zeichen (ASCII, 8 Bit bzw. 1 Byte)
int Ganze Zahl (16, 32 oder 64 Bit)
float Gleitkommazahl (typ. 32 Bit)
double Doppelt genaue Gleitkommazahl (typ. 64 Bit)
void Ohne/kein Wert
bool true, false

Außerdem sind Arrays und Pointer mit diesen Typen möglich.

Typmodifikatoren ändern Bedeutung

Vorangestellte Modifikatoren ändern Bedeutung:

  1. Länge im Speicher

    short Speicher: halbe Wortlänge
    long Speicher: doppelte/dreifache Wortlänge
  2. Vorzeichen

    signed mit Vorzeichen (Default bei Zahlen)
    unsigned ohne Vorzeichen

Anwendung auf ganze Zahlen:

  • short und long sind Synonyme für short int und long int
  • long long ist typischerweise eine ganze Zahl mit 8 Byte
  • unsigned char sind Zahlen von 0, ..., 255 (1 Byte)
  • zusätzlich: long double (nur diese Form)

Sie können short, long und long long nur für ganze Zahlen (int) nutzen, mit der Ausnahme long double. Dagegen können signed und unsigned sowohl für char als auch für int benutzt werden.

vgl. en.wikipedia.org/wiki/C_data_types

Größe eines Datentyps ist maschinenabhängig

Der reservierte Speicherbereich und damit auch der Zahlenbereich für einen einfachen Typ in C/C++ ist maschinenabhängig!

  • Zahlenbereiche für konkrete Implementierung in Header-Files definiert

    limits.h und float.h: Konstanten INT_MAX, INT_MIN, ...

  • Alternativ Herausfinden der Größe in Bytes: Operator sizeof

    Syntax: sizeof(Typ)

Es gilt in C/C++:

  • sizeof(unsigned char) $=$ 1
  • sizeof(short int) $=$ 2
  • sizeof(short int) $\le$ sizeof(int) $\le$ sizeof(long int)
  • sizeof(float) $\le$ sizeof(double) $\le$ sizeof(long double)

Hinweis Arrays: sizeof gibt immer die Anzahl der Bytes für einen Typ oder eine Variable zurück. Bei Array ist das nicht unbedingt die Anzahl der Elemente im Array!

Beispiel:

char a[10];
double b[10];

sizeof(a) würde den Wert 10 als Ergebnis liefern, da ein char in C/C++ immer exakt ein Byte benötigt und entsprechend 10 char 10 Byte. sizeof(b) ist maschinenabhängig und liefert die Anzahl der Bytes, die man für die Darstellung von 10 Double-Werten benötigt.

Wenn man die Anzahl der Elemente im Array mit sizeof herausfinden will, muss man den Gesamtwert für das Array noch durch den Speicherbedarf eines Elements teilen, also beispielsweise sizeof(b)/sizeof(b[0]).

(Beispiele für) Schleifen und Kontrollstrukturen in C/C++

int x=5, y=1;


if (x>5) {
    x++;
} else if(y<=1) {
    y = y-x;
} else {
    y = 2*x;
}


while (y>0) {
    y--;
}


for (x=0; x<10; x++) {
    y = y*y;
}

Funktionen in C und C++

  • Funktionen sind mit Methoden in Java vergleichbar

    => sind aber unabhängig von Klassen bzw. Objekten

  • Syntax:

    Rueckgabetyp Funktionsname(Parameterliste) {
        Anweisungen (Implementierung)
    }
  • Aufruf: Nennung des Namens (mit Argumenten) im Programmcode

    int x = foo(42);

Anmerkung: Unterschied "Parameter" und "Argument":

  • Funktion hat "Parameter" in ihrer Parameterliste, auch "formale Parameter" genannt
  • Beim Aufruf werden "Argumente" übergeben, auch "aktuelle Parameter" genannt

In der Praxis verwendet man beide Begriffe i.d.R. synonym.

Funktionen: Deklaration vs. Definition

  • Deklaration: (Funktions-) Prototyp: Festlegen von Signatur (d.h. Funktionsname und Anzahl, Typ, Reihenfolge der Parameter) u. Rückgabetyp

    void machWas(int, int);
  • Definition: Implementierung der Funktion

    void machWas(int a, int b) {
        cout << "a: " << a << ", b: " << b << endl;
    }
  • Compiler "liest" Quellcode von oben nach unten

  • Funktionen müssen (wie alle anderen Symbole auch) vor ihrer Verwendung zumindest deklariert sein, d.h. es muss zumindest ihre Signatur bekannt sein (siehe nächste Folie)

  • Deklaration: Variablennamen können weggelassen werden

Deklaration vs. Definition
  • Deklaration: Macht einen Namen bekannt und legt den Typ der Variablen bzw. die Schnittstelle der Funktionen fest.
  • Definition: Deklaration plus Reservierung von Speicherplatz für die Variable oder Implementierung einer Funktion/Struktur/...

One Definition Rule (für Funktionen)

Jede Funktion darf im gesamten Programm nur einmal definiert sein!

Funktionen und Parameter

  • Funktionen "ohne" Parameter:

    Leere Parameter-Liste[^1] oder Schlüsselwort void

    void fkt();
    void fkt(void);
  • Funktionen mit Parameter:

    • Deklaration: Variablennamen können weggelassen werden
    • Definition: Variablennamen müssen angegeben werden
    void fkt(int, char);
    void fkt(int a, char b);
    
    void fkt(int a, char b) { ... }
Leere Parameterliste in C

Wenn eine Funktion keine Parameter hat, können Sie wie in C die Parameterliste entweder einfach leer lassen (int fkt();) oder das Schlüsselwort void nutzen (int fkt(void);).

Betrachten Sie folgendes Beispiel:

// Legal in C
int wuppie();                   // Deklaration: "Ich verrate Dir nicht, wieviele Parameter wuppie() hat."
int wuppie(int x) { return x; } // Aufruf mit Argumenten => ist okay

// Fehler in C
int fluppie(void);               // Deklaration: fluppie() hat KEINE Parameter!
int fluppie(int x) { return x; } // Aufruf mit Argumenten => Compiler-Fehler

Wenn Sie eine mit leerer Parameterliste deklarierte Funktion definieren bzw. aufrufen, akzeptiert der C-Compiler dennoch alle übergebenen Parameter. Dies kann zu schwer verständlichen Fehlern führen! Sobald eine Funktion explizit mit dem Schlüsselwort void in der Parameterliste deklariert wird, muss diese dann auch ohne Parameter aufgerufen werden.

=> Bevorzugen Sie in C die Variante mit dem Schlüsselwort void!

Leere Parameterliste in C++

Keine Parameter: Leere Liste und Schlüsselwort void gleichwertig

void fkt();
void fkt(void);

Defaultparameter in C++

  • Parameter mit Defaultwerten am Ende der Parameterliste
  • Bei Trennung von Deklaration und Definition: Defaultparameter nur in Deklaration
// Deklaration
void f(int i, int j=1, int k=2);

// Definition
void f(int i, int j, int k) { ... }

Überladen von Funktionen

  • Funktionen im gleichen Gültigkeitsbereich können überladen werden
  • Zu beachten:
    1. Funktionsname identisch
    2. Signatur (Anzahl, Typen der Parameter) muss unterschiedlich sein
    3. Rückgabewert darf variieren

=> Warnung: Überladene Funktionen sollten gleichartige Operationen für unterschiedliche Datentypen bereitstellen!

Probleme beim Überladen von Funktionen

  1. Defaultparameter

    int maximum(int, int);
    int maximum(int, int, int=10);
  2. Identische Signatur, Unterschied nur im Rückgabewert

    int maximum(int, int);
    double maximum(int, int);
  3. Überladen nur für Funktionen des selben Gültigkeitsbereichs!

    #include <iostream>
    using namespace std;
    
    void f(char c) {
        cout << "f(char): " << c << endl;
    }
    void f(int i) {
        cout << "f(int): " << i << endl;
    }
    
    
    int main() {
        void f(int i);  // f(char) nicht mehr sichtbar!
        f('a');
    
        return 0;
    }

Parameterübergabe in C/C++: Call-by-Value

int add_5(int x) {
    x += 5;
    return x;
}

int main() {
    int erg, i=0;
    erg = add_5(i);
}
 Aufrufer-Sicht
              i                      erg
           +-----+                 +-----+
           |     |                 |     |
           +--+--+                 +--^--+
              |                       |
              |                       |
--------------+-----------------------+-----
  Kopie bei   |                Kopie  |
  Aufruf      |                bei    |
              |                return |
           +--v--+                    |
           |     +--------------------+
           +-----+
              x
 Funktionssicht
  • Default in C/C++ ist die call-by-value Semantik:
    • Argumente werden bei Übergabe kopiert
    • Ergebniswerte werden bei Rückgabe kopiert
  • Folgen:
    • Keine Seiteneffekte durch Verändern von übergebenen Strukturen
    • Negative Auswirkungen auf Laufzeit bei großen Daten

Ausnahme: Übergabe von C++-Referenzen oder Pointern (wobei Pointer streng genommen auch kopiert werden, also per call-by-value übergeben werden ...)

Unterschiedliche Variablenarten

Lokale Variablen ("automatische Variablen")

int b = 1;

void f() {
    int b = 42;
}

int main() {
    int b = 3;

    {
        int b = 7;
    }
}
  • Innerhalb einer Funktion (oder Blockes) definierte Variablen

  • Gilt auch für Variablen aus Parameterliste

  • Überdecken globale Variablen gleichen Namens

  • Sichtbarkeit:

    • Außerhalb der Funktion/Blockes nicht zugreifbar
    • Beim Betreten der Funktion Reservierung von Speicherplatz für lokale Variablen
    • Dieser wird beim Verlassen des Blockes/Funktion automatisch wieder freigegeben
    • Namen sind nur nach Deklaration und innerhalb des Blockes, in dem sie deklariert wurden, gültig
    • Namen sind auch gültig für innerhalb des Blockes neu angelegte innere Blöcke

    Software Engineering: Vermeiden Sie lokale Namen, die Namen aus einem äußeren Scope überdecken!

=> Werden auch als automatische Variablen bezeichnet

Globale Variablen ("externe Variablen")

/* ======== Datei main.cpp (einzeln kompilierbar) ======== */
int main() {
    extern int global;  // Deklaration
}

int global;             // Definition
/* ======== Datei foo.cpp (einzeln kompilierbar) ======== */
extern int global;      // Deklaration

void foo() {
    global = 45;
}
  • Globale Variablen: Außerhalb jeder Funktion definierte Variablen
  • Globale Variablen gelten in allen Teilen des Programms
  • Auch in anderen Dateien! => müssen bei Nutzung in Funktionen als extern deklariert werden
  • Existieren die gesamte Programmlebensdauer über

=> Werden auch als externe Variablen bezeichnet

Die Dateien sind einzeln kompilierbar (extern sagt dem Compiler, dass die Variable woanders definiert ist) => erst der Linker löst das auf.

Hinweis: Bei globalen Konstanten in C++ brauchen Sie zusätzlich auch bei der Definition ein "extern", da die Konstante sonst nur in ihrer Datei sichtbar ist.

Statische lokale Variablen

void foo() {
    static int x = 42;
    x++;
}

int main() {
    foo();  foo();  foo();
}
  • Lokale Variablen mit "Gedächtnis": Definition mit dem vorangestellten Schlüsselwort "static"

    static int callCount;
  • Eigenschaften:

    • Wert bleibt für die folgenden Funktionsaufrufe erhalten
    • Wert kann in der Funktion verändert werden
    • Dennoch: lokale Variable, d.h. von außen nicht sichtbar/gültig

Hinweis: static für globale Variablen bedeutet etwas anderes! (s.u. "Sichtbarkeit")

Initialisierung von Variablen

(Automatische) Initialisierung von Variablen hängt von ihrer Speicherklasse ab!

  • Automatisch
    • Werden nicht automatisch initialisiert (!)
    • Bei vorgegebenem Wert ab Aufruf der Funktion
  • Extern
    • Mit dem Wert 0 oder vorgegebenem Wert
    • Bereits vor Programmstart (im Code enthalten)
  • Statisch
    • Mit dem Wert 0 oder vorgegebenem Wert
    • Ab erstem Aufruf der Funktion

Sichtbarkeit globaler Variablen (und Funktionen) beschränken

  • Beschränkung der Gültigkeit von globalen Variablen auf die Datei, wo sie definiert sind: Schlüsselwort static
    • werden (weiterhin) automatisch mit 0 initialisiert
    • sind nun nur in der Datei sichtbar/gültig, wo sie definiert sind
    • dient zur Vermeidung von Namenskonflikten bei globalen Variablen
  • Sichtbarkeitsbeschränkung gilt auch für Funktionen

static für globale Variablen beschränkt deren Sichtbarkeit auf die Datei, wo sie definiert sind. D.h. man kann diese dann nicht in einer anderen Datei nutzen, nicht mal mit extern ...

static für Funktionen beschränkt deren Sichtbarkeit ebenfalls auf die Datei, wo sie definiert sind. Man kann sie dann nur in anderen Funktionen, die ebenfalls in der selben Datei definiert werden, nutzen. In anderen Dateien sind die static Funktionen nicht sichtbar. D.h. es macht auch keinen Sinn, sie in einer Header-Datei zu deklarieren! (In der Praxis liefert der gcc dann sogar einen Fehler!). Das ist mit private Methoden vergleichbar.

Globale Konstanten

In C funktionieren globale Konstanten wie globale Variablen

  • Definition in einer Übersetzungseinheit ohne "extern"

    => Definition als "extern" wird in C mit einer Warnung quittiert!

  • Nutzung in anderen Übersetzungseinheiten durch (erneute) Deklaration als "extern"

  • Beispiel:

    /* ======== Datei main.c ======== */
    const int PI=123;       // Definition OHNE "extern" (C)
    
    int main() {
        fkt_a1();
        int x = PI;
        ...
    }
    /* ======== Datei a.c ======== */
    extern const int PI;    // (erneute) Deklaration mit "extern"
    void fkt_a1() {
        int x = PI;
        ...
    }

In C++ sind globale Konstanten per Default nur in ihrer Definitionsdatei sichtbar!

  • Abhilfe: Definieren und Deklarieren mit extern

  • Beispiel:

    /* ======== Datei main.cpp ======== */
    extern const int PI=123;    // Definition MIT "extern" (C++)
    
    int main() {
        fkt_a1();
        int x = PI;
        ...
    }
    /* ======== Datei a.cpp ======== */
    extern const int PI;        // (erneute) Deklaration mit "extern"
    void fkt_a1() {
        int x = PI;
        ...
    }

Alternativ: In beiden Sprachen Konstanten vorwärts deklarieren

Folgende Definition und (Vorwärts-) Deklaration der Konstanten PI funktioniert sowohl in C als auch in C++:

/* ======== Datei main.c ======== */
extern const int PI;    // (Vorwärts-) Deklaration mit "extern"
const int PI=123;       // Definition OHNE "extern"

int main() {
    fkt_a1();
    int x = PI;
    ...
}
/* ======== Datei a.c ======== */
extern const int PI;    // (erneute) Deklaration mit "extern"
void fkt_a1() {
    int x = PI;
    ...
}

Automatisieren der Buildvorgänge: GNU Make

Makefile: Textdatei mit Regeln für das Programm make

  • Abläufe automatisieren: Kompilieren, testen, Pakete bauen, aufräumen, ...
  • Java: ant, C/C++: make
  • Achtung: Verschiedene Make-Dialekte! Wir nutzen GNU Make!
 # Kommentar
 Ziel1: AbhaengigkeitenListe1
     Aktionen1

 Ziel2: AbhaengigkeitenListe2
     Aktionen2

 # ... und so weiter :-)
 # ACHTUNG:
 # Vor den Aktionen <TAB> benutzen, keine Leerzeichen!!!
 # Vorsicht mit Editor-Einstellungen!

Bedeutung: Um das Ziel Ziel1 zu erzeugen, müssen alle Abhängigkeiten der Liste AbhaengigkeitenListe1 erfüllt sein. Dann werden die Aktionen in Aktionen1 durchgeführt, um Ziel1 zu erzeugen. Aber nur, falls das Ziel Ziel1 nicht existiert oder veraltet ist!

Falls die Abhängigkeiten nicht erfüllt sind, wird nach Regeln gesucht, um diese zu erzeugen. Das bedeutet, dass u.U. zunächst weitere Targets "gebaut" werden, bevor die Aktionenliste ausgeführt wird.

Die Ziele und Abhängigkeiten sind i.d.R. Dateien (müssen es aber nicht sein).

Makefiles: Fiktives Beispiel

  • Annahme: Projekt besteht aus der Datei main.cpp, daraus soll das Programm "tollesProgramm" erzeugt werden

  • Passendes Makefile:

    CXXFLAGS = -Wall
    
    .PHONY: all
    all: tollesProgramm
    
    tollesProgramm: main.o
        $(CXX) $(LDFLAGS) $< $(LDLIBS) -o $@
    
    %.o: %.cpp
        $(CXX) $(CXXFLAGS) -c $< -o $@
    
    .PHONY: clean
    clean:
        rm -rf tollesProgramm *.o *.~
    

Bedeutung: Um das Ziel all zu erzeugen, muss die Abhängigkeit tollesProgramm erfüllt sein. Beachten Sie, dass im Beispiel all kein Dateiname ist, tollesProgramm dagegen schon.

Um tollesProgramm zu erzeugen, muss die Datei main.o vorhanden sein. Falls sie es nicht ist, wird sie mit Hilfe des dritten Targets erzeugt. Das % ist dabei ein Patternmatcher, d.h. wenn nach einem main.o gesucht ist, matcht %.o (das % bindet sich dabei an "main") und auf der rechten Seite des Targets steht als Abhängigkeit main.cpp.

Die Variablen CXX, CXXFLAGS, LDFLAGS und LDLIBS sind vordefinierte Variablen:

  • CXX: C++-Compiler, Default: g++
  • CXXFLAGS Extra Flags für den C++-Compiler (nur für Kompilieren)
  • LDFLAGS: Extra Flags, die für das Linken genutzt werden (Beispiel: -L.; nicht -lm)
  • LDLIBS: Bibliotheken, die für das Linken genutzt werden (Beispiel: -lm -lfoo; nicht -L.)

Die Variablen $<, $^ und $@ lösen auf das Ziel bzw. die Abhängigkeiten eines Targets auf:

  • $< => gibt die erste Abhängigkeit an
  • $^ => gibt alle Abhängigkeiten an
  • $@ => gibt das Ziel an

Falls die Datei tollesProgramm nicht existiert oder aber älter ist als main.o, wird die Regel des Targets tollesProgramm ausgeführt, um die Datei tollesProgramm zu erzeugen: g++ main.o -o tollesProgramm.

Hinweis: Das Beispiel entspricht den minimalen Kenntnissen, die Sie über Make haben müssen.

Makefiles: Typische Aufrufe

  • make Sucht nach Datei mit dem Namen "GNUmakefile", "makefile" oder "Makefile" und erzeugt das erste Ziel in der Datei

    Konvention: Das erste Ziel hat den Namen all

  • make -f <datei> Sucht die Datei mit dem angegebenen Namen, erzeugt das erste Ziel in der Datei

  • make -f <datei> <ziel> Sucht die Datei mit dem angegebenen Namen, erzeugt das Ziel <ziel>

  • make <ziel> Sucht nach Datei mit dem Namen "GNUmakefile", "makefile" oder "Makefile" und erzeugt das Ziel <ziel>

Wrap-Up

  • C/C++ sind enge Verwandte: kompilierte Sprachen, C++ fügt OO hinzu

  • Funktionsweise einfachster Make-Files

  • Wichtigste Unterschiede zu Java

    • Kontrollfluss wie in Java
    • Basisdatentypen vorhanden
    • Typ-Modifikatoren zur Steuerung des Speicherbedarfs/Wertebereich
    • Integer können im booleschen Kontext ausgewertet werden
    • Operator sizeof zur Bestimmung des Speicherbedarfs
    • Alias-Namen für existierende Typen mit typedef definierbar
    • Funktionen mit Default-Parametern und Überladung
Challenges
  • Wie groß ist der Bereich der Basisdatentypen (Speicherbedarf, Zahlenbereich)? Wie können Sie das feststellen?

    unsigned char a;
    int b;
    
    long long x[10];
    long long y[] = {1, 2, 3};
    long long z[7] = {3};
  • Erklären Sie den Unterschied sizeof(x) vs. sizeof(x)/sizeof(x[0])!

  • Warum ist der folgende Code-Schnipsel gefährlich?

    if (i=3)
        printf("Vorsicht");
    else
        printf("Vorsicht (auch hier)");
  • Limits kennen: Datentypen, Wertebereiche

    Schreiben Sie ein C-Programm, welches die größtmögliche unsigned int Zahl auf Ihrem System berechnet.

    Verwenden Sie hierzu nicht die Kenntnis der systemintern verwendeten Bytes (sizeof, ...). Nutzen Sie auch nicht die Konstanten/Makros/Funktionen aus limits.h oder float.h oder anderen Headerdateien!

  • Erklären Sie die Probleme bei folgendem Code-Schnipsel:

    int maximum(int, int);
    double maximum(int, int);
    char maximum(int, int, int=10);
  • Erklären Sie die Probleme bei folgendem Code-Schnipsel:

    int maximum(int, int);
    double maximum(double, double);
    
    int main() {
        cout << maximum(1, 2.2) << endl;
    }
  • Erklären Sie den Unterschied zwischen

    int a=1;
    int main() {
        extern int a;
        return 0;
    }

    und

    int a=1;
    int main() {
        int a = 4;
        return 0;
    }
Quellen

C++: Pointer und Referenzen

TL;DR

Es gibt viele Arten Speicher, die sich vor allem in der Größe und Geschwindigkeit unterscheiden (Cache, RAM, SSD, Festplatte, ...). Der Kernel stellt jedem Prozess einen linearen Adressraum bereit und abstrahiert dabei von den darunter liegenden physikalischen Speichermedien (es gibt eine Abbildung auf die jeweiligen Speichermedien durch die MMU, dies ist aber nicht Bestandteil dieses Kurses).

Den virtuellen Speicher kann man grob in drei Segmente aufteilen: Text (hier befindet sich der Programmcode des Prozesses), Stack (automatische Verwaltung, für Funktionsaufrufe und lokale Variablen) und Heap (Verwaltung durch den Programmierer, dynamische Bereitstellung von Speicher während der Laufzeit des Programms).

Pointer sind Variablen, deren Wert als Adresse (im virtuellen Speicher) interpretiert wird. Pointer können auf andere Objekte bzw. Variablen zeigen: Der Adressoperator "&" liefert die Adresse eines Objekts im virtuellen Speicher, diese kann einem Pointer zugewiesen werden (der Wert des Pointers ist dann die zugewiesene Adresse). Pointer können mit "*" dereferenziert werden, d.h. es wird an der Speicherstelle im virtuellen Speicher nachgeschaut, deren Adresse im Pointer gespeichert ist. Dadurch erfolgt der Zugriff auf das verwiesene Objekt. (Dies hat noch nichts mit dynamischer Speicherverwaltung zu tun!) Die Deklaration eines Pointers erfolgt mit einem * zwischen Typ und Pointername: int *p;. Da Pointer normale Variablen sind, unterliegen Pointer-Variablen den üblichen Gültigkeitsbedingungen (Scopes).

In C++ gibt es zusätzlich Referenzen. Diese stellen Alias-Namen für ein Objekt (oder eine Variable) dar, d.h. ein Zugriff auf eine Referenz bewirkt den direkten Zugriff auf das verbundene Objekt. Referenzen müssen bei der Deklaration initialisiert werden (Typ &ref = obj;) und sind dann fest mit diesem Objekt verbunden.

In C und C++ werden Funktionsparameter immer per Call-by-Value übergeben: Der Wert des Arguments wird in die lokale Variable des Funktionsparameters kopiert. Wenn ein Pointer übergeben wird, wird entsprechend der Wert des Pointers kopiert, also die gespeicherte Adresse. Mit der Adresse eines Objekts kann man aber auch in der Funktion direkt auf dieses Objekt zugreifen und dieses auslesen und verändern, d.h. durch die Übergabe eines Pointers hat man zwar immer noch Call-by-Value (die Adresse wird kopiert), die Wirkung ist aber wie bei Call-by-Reference (also als ob eine Referenz auf das Objekt übergeben wurde). Bei der Verwendung von C++-Referenzen hat man dagegen echtes Call-by-Reference.

Zur Laufzeit kann man Speicher auf dem Heap reservieren (allozieren). Im Gegensatz zu Speicher auf dem Stack ist man selbst auch für die Freigabe des reservierten Speichers zuständig - wenn man dies nicht beachtet, läuft irgendwann der Heap voll. Allokation und Freigabe kann entweder mit den C-Funktionen malloc und free erfolgen oder mit den C++-Operatoren new und delete. Mischen Sie niemals nie malloc()/free() mit new/delete!

Zwischen Pointern und Arrays gibt es eine enge Verwandschaft. Die einzelnen Elemente eines Arrays werden vom Compiler direkt aufeinanderfolgend im Speicher angeordnet, der Array-Name ist wie ein (konstanter) Pointer auf das erste Element. Tatsächlich übersetzt der Compiler Indexzugriffe für ein Array in die passende Pointerdereferenzierung: a[i] wird zu *(a+i). Ein Pointer kann wiederum auch auf das erste Element eines zusammenhängenden Speicherbereichs zeigen, etwa wenn man über malloc Speicherplatz für mehrere Elemente anfordert. Da der Compiler aus einem Indexzugriff ohnehin die Pointerdereferenzierung macht, könnte man so einen Pointer auch per Indexzugriff abfragen. Dies ist aber gefährlich: Es funktioniert auch, wenn der Pointer nur auf ein anderes Objekt zeigt und nicht auf einen Speicherbereich ... Ein Arrayname wird vom Compiler fest der ersten Speicheradresse des Arrays zugeordnet und kann nicht verändert werden, der Inhalt eines (nicht-konstanten) Pointer dagegen schon (der Pointer selbst wird auch fest im Speicher angelegt).

Pointer haben einen Typ: Die Pointerarithmetik berücksichtigt die Speicherbreite des Typs! Damit springt man mit ptr+1 automatisch zum nächsten Objekt und nicht notwendigerweise zum nächsten Byte.

Videos (YouTube)
Lernziele
  • (K1) Virtueller Speicher, Segmente: Text, Data, Stack
  • (K2) Pointer sind Variablen, Wert wird als Adresse interpretiert
  • (K2) Pointer als spezielle Variablen: Wert des Pointers als Adresse interpretieren
  • (K2) Initialisierung und Scopes bei Pointern
  • (K3) Zuweisen einer Adresse an einen Pointer
  • (K3) Dereferenzierung eines Pointers und Zugriff auf das referenzierte Element
  • (K3) Pointer als Funktionsparameter: Call-by-Reference mit Hilfe von Pointern
  • (K2) Memory Leaks und Stale Pointer und deren Vermeidung
  • (K3) C++-Operatoren new und delete, Unterschied zu malloc(), free()
  • (K3) Referenzen in C++ (Deklaration, Initialisierung, Nutzung)
  • (K3) Zusammenhang und Unterschied Pointer und Arrays
  • (K3) Rechnen mit Pointern, Berücksichtigung des Typs

Virtueller Speicher

            +-----------------------------------------+
            |          Text                           | 0x0000
            |                                         |    |
            |-----------------------------------------|    |
            |          Heap (Data)                    |    |
            |                                         |    |
            |--------------------+--------------------|    |
            |                    |                    |    |
            |                    v                    |    |
            |                                         |    |
            |                                         |    v
            |                    ^                    |
            |                    |                    |
            |--------------------+--------------------|
            |                                         |
            |          Stack                          |
            +-----------------------------------------+
  • Kernel weist jedem Prozess seinen eigenen virtuellen Speicher zu
  • Linearer Adressbereich, beginnend mit Adresse 0 bis zu einer maximalen Adresse
  • Verwaltung durch MMU (Memory Management Unit)
    • MMU bildet logische Adressen aus virtuellem Speicher auf den physikalischen Speicher ab
    • Transparent für den Prozess

Segmente des virtuellen Speichers: Text (read-only)

  • Programm Code
  • Konstanten, String Literale

zusätzlich (nicht in Abbildung dargestellt):

  • Bereich initialisierter Daten (globale und static Variablen (explizit initialisiert))
  • Bereich uninitialisierter Daten (globale und static Variablen (uninitialisiert) => Wert 0)

Segmente des virtuellen Speichers: Stack

  • Dynamisch wachsend und schrumpfend
  • Stackframe je Funktionsaufruf:
    • Lokale Variablen ("automatische" Variablen)
    • Argumente und Return-Werte
  • Automatische Pflege
    • Nach Funktionsrückkehr wird der Stackpointer ("Top of Stack") weiter gesetzt
    • Dadurch "Bereinigung": Speicher der lokalen Variablen wird freigegeben

Segmente des virtuellen Speichers: Data (Heap)

  • Dynamisch wachsend und schrumpfend
  • Bereich für dynamischen Speicher (Allokation während der Laufzeit)
  • Zugriff und Verwaltung aus laufendem Programm => Pointer
    • malloc()/calloc()/free() (C)
    • new/delete (C++)
    • typischerweise Pointer
  • KEINE automatische Pflege - Programmierer ist selbst verantwortlich!

Konzept eines Pointers

int i = 99;
int *iptr;

iptr = &i;  /* Wert von iptr ist gleich Adresse von i */
*iptr = 2;  /* Deferenzierung von iptr => Veränderung von i */
        Variable    Speicheraddresse    Inhalt

                                        |          |
                                        +----------+
        i           10125               | 99       |  <--+
                                        +----------+     |
                                        |          |     |
                    ....                 ....            |
                                        |          |     |
                                        +----------+     |
        iptr        27890               | 10125    |  ---+
                                        +----------+
                                        |          |

Pointer sind Variablen

  • haben Namen und Wert
  • können mit Operatoren verändert werden
  • sind einer Speicheradresse im virtuellen Speicher zugeordnet

Im Beispiel:

  • Variable i:
    • Name: "i"
    • Wert: 99
    • Speicherzelle (Adresse): 10125
  • Variable iptr:
    • Name: "iptr"
    • Wert: 10125
    • Speicherzelle (Adresse): 27890

Pointer sind besondere Variablen

Der Wert eines Pointers wird als Adresse im Speicher behandelt

Der Wert von iptr ist nicht ein beliebiger Integer, sondern eine Adresse. In diesem Fall handelt es sich um die Adresse im virtuellen Speicher, wo die Variable i abgelegt ist.

Wirkung/Interpretation: Variable iptr "zeigt" auf die Adresse von Variable i.

Pointer und Adressen (Syntax)

  • Deklaration

    Typ * Name;
  • Zuweisung einer Adresse über den &-Operator:

    int i = 99;
    int *iptr;
    
    iptr = &i;  /* Wert von iptr ist gleich Adresse von i */
  • iptr ist ein Pointer auf eine (beliebige) Speicherzelle mit Inhalt vom Typ int

  • Nach Zuweisung: iptr ist ein Pointer auf die Speicherzelle der Variablen i

Dereferenzierung: Zugriff auf Ziel

  • Dereferenzierung mit *:

    int i = 99;
    int *iptr;
    
    iptr = &i;
    
    *iptr = 2;  // Zugriff auf verwiesene Speicherzelle i
    

Pointer: Schreibweisen

  • Position des * zwischen Typ und Name beliebig

    /* aequivalente Schreibweisen */
    int* iptr;
    int * iptr;
    int *iptr;
    /* Vorsicht Mehrfachdeklaration */
    int* iptr, ptr2;      /* ptr2 ist nur ein int! */
  • Dereferenzierung von Pointern auf Klassen/Structs: Operator ->

    /* aequivalente Schreibweisen */
    (*iptr).attribut;
    iptr->attribut;

Pointer: Zuweisungen an andere Pointer

int i=99, *iptr, *ptr2;

iptr = &i;

ptr2 = iptr;

*ptr2 = 2;

Jetzt zeigen zwei Pointer auf die Speicherzelle von Variable i: iptr (wegen iptr = &i), und weil der Wert von iptr in ptr2 kopiert wurde (ptr2 = iptr), zeigt nun auch ptr2 auf i.

Der Wert von iptr ist die Adresse von i. Wenn dieser Wert kopiert oder zugewiesen wird, ändert sich an dieser Adresse nichts. ptr2 bekommt diesen Wert zugewiesen, d.h. bei einer Dereferenzierung von ptr2 würde auf die Adresse von i zugriffen werden und dort gelesen/geschrieben werden.

Pointer und Scopes

Nicht auf Variablen außerhalb ihres Scopes zugreifen!

int i=9;
int *ip = &i;

*ip = 8;
{  /* neuer Block */
    int j=7;
    ip = &j;
}
*ip = 5;  /* AUTSCH!!! */
int* murks() {
    int i=99;
    return &i;  /* AUTSCH!!! */
}

Hotelzimmer-Analogie

  • Wenn Sie in ein Hotel einchecken, bekommen Sie den Schlüssel zu Ihrem Zimmer
    • Pointer == Schlüssel
    • Variable auf die Pointer zeigt == Zimmer
  • Wenn Sie auschecken, geben Sie normalerweise Ihr Zimmer auf und den Schlüssel ab
    • Pointer wird ungültig
    • Variable wird ungültig
  • Wenn Sie beim Auschecken den Schlüssel nicht abgeben, gehört das Zimmer dennoch nicht mehr Ihnen
    • Sie haben noch den Pointer
    • Die Variable, auf die der Pointer zeigt, ist ungültig
  • Wenn Sie jetzt auf das Zimmer gehen, kommen Sie (evtl.) noch rein
    • Evtl. ist das Zimmer noch nicht wieder belegt, und Sie finden Ihr vergessenes Handy
    • Bei Dereferenzierung erhalten Sie noch den alten Wert der Variablen
      • Evtl. wurde das Zimmer bereits wieder vergeben => Sie "brechen" bei einem Fremden ein!
      • Bei Dereferenzierung greifen Sie auf "fremde" Variablen (Speicherbereiche) zu!

Pointer und Initialisierung

Pointer werden vom Compiler nicht initialisiert!

  • Zeigen ohne explizite Initialisierung auf zufällige Adresse
  • Dereferenzierung uninitialisierter Pointer problematisch

Explizite Null-Pointer:

  • Wert 0 zuweisen
  • Besser: Symbolische Konstante NULL aus stdio.h bzw. cstdio bzw. in C++ nullptr

Speicherverwaltung

  • C: Funktionen zur Verwaltung dynamischen Speichers: malloc(), free(), ... (in <stdlib.h>)

    void* malloc(size_t size)
    • Alloziert size Bytes auf dem Heap und liefert Adresse zurück
    • Pointer auf void, da Typ unbekannt - vor Nutzung auf korrekten Typ umcasten
    • Im Fehlerfall wird ein Null-Pointer zurückgeliefert: NULL
    • Achtung: Speicher ist nicht initialisiert!
    int *p = (int*) malloc(sizeof(int));
    int *pa = (int*) malloc(4*sizeof(int));
    
    free(p);
    free(pa);
  • C++: Operatoren: new und delete

    • Direkte Angabe des Zieltyps
    • Rückgabe eines Pointers auf diesen Typ
    • Exception, wenn kein Speicher verfügbar
    • Form mit []-Operator für Arrays
    • Mit new allozierter Speicher muss mit delete freigegeben werden
    • Mit new [] allozierter Speicher muss mit delete [] freigegeben werden
    int *p = new int;
    int *pa = new int[4];
    
    delete p;
    delete [] pa;

Speicher allozieren: Standardidiom

In C müssen Sie die Rückgabe von malloc prüfen:

int *i, *x;

i = (int *) malloc(sizeof(int));
x = (int *) malloc(sizeof(*x));   /* Stern wichtig */

if (!i) {
    /* Fehlerbehandlung */
} else {
    /* mach was */
}

In C++ bekommen Sie eine Exception, falls new nicht erfolgreich war:

int *i;

try {
    i = new int;
    /* mach was */
} catch (...) { /* Fehlerbehandlung */ }

Hinweis: Pointer-Variablen i und x liegen auf Stack, angeforderter Speicher im Heap!

Pointer und Typen

  • Typ eines Zeigers relevant, wird vom Compiler geprüft
  • Zuweisung ohne expliziten Cast nur an allgemeinere Typen/Oberklassen
    • Jeder Zeiger auf Typ T kann automatisch zum void-Pointer konvertiert werden

    • Für Zuweisung von void-Pointern an Pointer auf Typ T expliziter Cast nach T* nötig (siehe auch nachfolgenden Hinweis zu C11)

      char *cp;
      void *vp;
      
      vp = cp;          /* OK */
      cp = vp;          /* problematisch */
      cp = (char *) vp; /* OK */

Fallstricke dynamischer Speicherverwaltung

Nur new und delete kombinieren bzw. malloc und free

  • delete darf nur auf mit new erzeugte Objekte angewendet werden

    • Vorsicht bei Pointern auf Stack-Variablen!
    • NIE mischen mit malloc()/calloc()/free()!
    int *p = (int *) malloc(sizeof(int));
    delete p;  // FEHLER! Absturzgefahr
    

delete[] genau nur bei new[]

  • delete[] darf nur auf mit new[] erzeugte Objekte angewendet werden (und muss dort auch angewendet werden)

    delete auf mit new[] erzeugtes Array würde nur erstes Element freigeben!

Vorsicht mit Pointern auf lokale Variablen

  • Funktioniert technisch, ist aber gefährlich:

    int* murks() {
        int i=99;
        return &i;  /* SO NICHT: Pointer auf lokale Variable! */
    }
  • Etwas besser:

    int* wenigerMurks() {
        int *p = (int *) malloc(sizeof(int)); /* neuer Speicher */
        *p=99;
        return p; /* das geht */
    }
Warum nur "etwas besser"?

Jetzt haben Sie aber ein neues Problem: Der Aufrufer der Funktion muss wissen, dass diese Speicher alloziert und muss sich selbst um die Freigabe kümmern. Dies ist unschön, da die Allokation und Freigabe in unterschiedlicher Verantwortung liegen! Dadurch können sehr schnell Fehler passieren.

Besser wäre, wenn der Aufrufer einen Pointer übergibt, mit dem dann in der Funktion gearbeitet wird. Dann liegt die Verantwortung für die Erstellung und Freigabe des Pointers komplett in der Hand des Aufrufers.

Memory Leaks

  • Pointer-Variablen unterliegen den Gültigkeitsregeln für Variablen

  • Mit malloc() reservierter Speicher existiert bis Programmende

    {
        int *i;
        i = (int *) malloc(sizeof(*i));
        *i = 99;
    }
    /* hier existiert die Variable i nicht mehr */
    /* aber der Speicher auf dem Heap bleibt belegt */
    /* ist aber nicht mehr zugreifbar -> SPEICHERLOCH! */

Double Free und Stale Pointer

  • free() darf nur einmal pro Objekt aufgerufen werden
    • Hintergrund: Intern wird eine Freispeicherliste verwaltet
  • Nach free() ist der Zeiger undefiniert:
    • Zeigt immer noch in den Heap (alte Adresse!)
    • Ist nicht gleich NULL oder 0
    • Zugriff ist möglich, aber gefährlich: Speicher kann wieder vergeben und überschrieben werden (Hotelzimmer-Analogie)
  • Mehrere Pointer auf ein Objekt: Einmal free() reicht!
    • Die anderen Pointer dürfen anschließend aber auch nicht mehr dereferenziert werden (stale/dangling pointer)

Beispiel Stale Pointer

    int *i, *k; i = (int *) malloc(sizeof(*i)); k = i;

    free(i);
    free(i); /* EINMAL reicht! */
    *k = 42; /* Speicher ist bereits frei - stale pointer */
    free(k); /* Speicher ist bereits frei - double free */
    *i = 99; /* Speicher ist bereits frei */

Anmerkung: Anwendung auf NULL-Pointer bewirkt nichts und ist unschädlich

Dereferenzieren von "Bad Pointern"

Der klassische Scanf-Bug :)

int i;
scanf("%d", i);

Tipp: i ist kein Pointer :)

Auslesen von nicht-initialisiertem Speicher

Wenn Programmierer denken, dass irgendwer den Heap zwischendurch immer mal wieder auf 0 setzt ...

/* return y = Ax */
int *matvec(int **A, int *x, int N) {
    int *y = malloc(N*sizeof(int));
    for (int i=0; i<N; i++) {
        for (int j=0; j<N; j++) {
            y[i] += A[i][j] * x[j];
        }
    }
    return y;
}

Tipp: y[i] += ... setzt sinnvolle Werte in y[i] voraus ...

Überschreiben von Speicher I

Allokation von falschen Größen

int *p;

p = malloc(N*sizeof(int));

for (int i=0; i<N; i++) {
    p[i] = malloc(M*sizeof(int));
}

Tipp: Jedes p[i] kann einen int speichern, bekommt aber einen Pointer zugewiesen (könnte deutlich breiter im Speicher sein als ein int) ...

Überschreiben von Speicher II

Indexberechnung kaputt, sogenannte "off-by-one-errors"

int **p;

p = malloc(N*sizeof(int));

for (int i=0; i<=N; i++) {
    p[i] = malloc(M*sizeof(int));
}

Tipp: Hier läuft i um einen Platz zu weit ...

Überschreiben von Speicher III

Einlesen von Strings, zu kleine Buffer

char s[8];
gets(s);

Tipp: Wenn hier mehr als 7 Zeichen eingegeben werden, gibt es Probleme :)

Überschreiben von Speicher IV

Pointerarithmetik falsch verstanden

int *search(int *p, int val) {
    while (*p && *p != val)
        p += sizeof(int);
    return p;
}

Tipp: Jeder Pointer hat einen Typ, und der Ausdruck "Pointer + 1" rutscht um so viele Bytes im Speicher weiter, wie der Typ breit ist. D.h. mit einem "Pointer + 1" gelangt man zum nächsten Element, während der obige Ausdruck p += sizeof(int); um sizeof(int) Elemente weiterspringt!

Pointer und Arrays

Ein Array-Name ist wie ein konstanter Pointer auf Array-Anfang: a[i] == *(a+i)

Ein Array-Name ist nur ein Label, welches der Adresse des ersten Array-Elements entspricht. Die Wirkung ist entsprechend die eines konstanten Pointers auf den Array-Anfang.

=> Der Compiler übersetzt Array-Zugriffe per Indexoperator in Pointerarithmetik: a[i] wird zu *(a+i) ...

Vgl. auch die Diskussion in eli.thegreenplace.net/2009/10/21/are-pointers-and-arrays-equivalent-in-c

char a[6], c, *cp;

&a[0] == a;
cp = a;

c = a[5];
c = *(a+5);
c = *(cp+5);
c = cp[5];

a = cp;  /* FEHLER */
a = &c;  /* FEHLER */

Iteration durch Arrays (Varianten)

int a[10], *pa=a;

for (int k=0; k<10; k++)    /* Iteration, Variante 1 */
    printf("%d ", a[k]);

for (int k=0; k<10; k++)    /* Iteration, Variante 2 */
    printf("%d ", *(a+k));

pa = a;
for (int k=0; k<10; k++)    /* Iteration, Variante 3 */
    printf("%d ", *pa++);


/* Iteration, KEINE Variante */
for (int k=0; k<10; k++)
    printf("%d ", *a++);    /* DAS GEHT NICHT */

*pa++: Operator ++ hat Vorrang vor *, ist aber die Postfix-Variante. D.h. ++ wirkt auf pa (und nicht auf *pa), aber zunächst wird für die Ausgabe *pa ausgewertet ...

*a++ ist nicht erlaubt, weil dadurch der Name des Arrays (== Adresse des ersten Array-Elements == konstanter Zeiger auf den Anfang des Arrays) verändert würde.

Array-Namen sind wie konstante Pointer

Array-Namen können NICHT umgebogen werden!

int a[], *pa=a, k;

/* erlaubt */
a + k;
pa++;

/* VERBOTEN */
a++;

Selbsttest: Was bedeutet was, was ist erlaubt/nicht erlaubt, was kommt raus? Warum?

int a[10], *pa, *pb, x;
pa = a;    pb = (int*) malloc(sizeof(int));

x = a[1];
x = *(a+1);
x = *(a++);

x = pa[1];
x = *(pa+1);
x = *(pa++);

x = pb[1];
x = *(pb+1);
x = *(pb++);

=> Arrays können wie konstante Pointer behandelt werden.

=> Pointer dürfen nicht immer wie Arrays behandelt werden! (Syntaktisch zulässig, semantisch normalerweise nicht!)

Pointerarithmetik: Typen beachten

  • Pointer zeigen auf Objekte mit einem bestimmten Typ
  • Typen haben unterschiedliche Speicherbreite
  • Inkrementierung/Dekrementierung: Pointer zeigt nicht auf nächste Speicheradresse, sondern auf die Adresse des nächsten Werts!
double d[10];
double *d1 = &d[2];
double *d2 = d1;

d2++;

printf("%ld\n", d2-d1);               // ergibt 1
printf("%ld\n", (long)d2 - (long)d1); // double -> zB. 8 Bytes

printf("%ld\n", sizeof(d1));  // Breite Pointervariable
printf("%ld\n", sizeof(*d1)); // Breite Pointerdatentyp

Referenzen in C++

Typ & Name = Objekt;

int i=2;
int j=9;

int &r=i;    // Referenz: neuer Name fuer i
r=10;        // aendert i: i==10
r=j;         // aendert i: i==9

int &s=r;    // aequivalent zu int &s = i;

Referenzen bilden Alias-Namen

int i = 99;
int *iptr = &i;

int &iref = i;   // Referenz: neuer Name fuer i
        Variable    Speicheraddresse    Inhalt

                                        |          |
                                        +----------+
        i, iref     10125               | 99       |  <--+
                                        +----------+     |
                                        |          |     |
                    ....                 ....            |
                                        |          |     |
                                        +----------+     |
        iptr        27890               | 10125    |  ---+
                                        +----------+
                                        |          |
  • Referenz bildet Alias-Namen für ein Objekt
  • Objekt hat damit mehrere Namen, über die es ansprechbar ist
  • Referenzen in C++ mit Hilfe des &-Operators deklarieren

Eigenschaften von Referenzen in C++

  • Referenzen müssen bei Deklaration initialisiert werden

  • Referenzen können nicht um-assigned werden

  • Referenzen brauchen keinen eigenen Speicherplatz

  • Vorsicht bei gleichzeitiger Deklaration mehrerer Referenzen:

    int i=2;
    int j=9;
    
    int& r=i, s=j;    // SO NICHT!!!
    int &r=i, &s=j;   // korrekt
    

Referenzen als Funktionsparameter

  • Signatur:

    void fkt(int&, char);
    void fkt(int &a, char b);  // a per Referenz
    
  • Aufruf: ganz normal (ohne extra &) ...

    int x=3;
    char y='a';
    fkt(x, y);  // x per Referenz
    

Im Beispiel werden die Variablen x und y an die Funktion fkt übergeben. Der erste Parameter wird per Referenz (call-by-reference), der zweite per Kopie (call-by-value) übergeben.

Der Funktionsparameter a bindet sich an x, ist eine Referenz auf/für x - jeder Zugriff auf a ist wie ein Zugriff auf x. Änderungen von a sind also Änderungen von x.

Der zweite Parameter bindet sich an den Wert von y, d.h. b hat den Wert 'a'. Zwar kann auch b verändert werden, das hat dann aber nur Auswirkungen innerhalb der Funktion und nicht auf die Variable y im äußeren Scope.

Call-by-Reference Semantik in C++

Variante A: Pointer (C und C++)

Mit Hilfe von Pointern lässt sich die Call-by-Reference Semantik in C und in C++ simulieren.

Bei der Übergabe eines Pointers wird der Wert des Pointers kopiert (call-by-value!). Im Inneren der Funktion kann diese Adresse dereferenziert werden und so auf das außerhalb der Funktion "lebende" Objekt zugegriffen werden. Damit bekommt man in der Wirkung call-by-reference.

void add_5(int *x) {
    *x += 5;
}

int main() {
    int i=0, *ip=&i;

    add_5(ip);

    add_5(&i);
}
  • Pointer wird nach wie vor per call-by-value übergeben:
    • Wert wird bei Übergabe kopiert (hier Adresse von i)
    • Kopierter Wert ist immer noch ein Pointer (hier Pointer auf i, da Adresse von i)
    • Dereferenzierung des kopierten Pointers: Zugriff auf das Original-Objekt (hier i)

Variante B: Referenzen (nur C++)

Referenzen müssen bei der Deklaration initialisiert werden und binden sich an das dabei genutzte Objekt. Sie stellen letztlich lediglich einen neuen Namen für das Objekt dar.

Bei der Übergabe von Variablen an Referenz-Parameter einer Funktion binden sich diese Parameter an die übergebenen Objekte. Jeder Zugriff innerhalb der Funktion auf einen Referenz-Parameter bewirken einen Zugriff auf das ursprüngliche Objekt.

int add_5(int &x) {
    x += 5;
    return x;
}

int main() {
    int i=0, erg;
    erg = add_5(i);
}
  • Funktionsparameter x ist eine Referenz
  • Bei Aufruf der Funktion wird dieser Parameter initialisiert - die Referenz x bindet sich im Beispiel an die Variable i
  • Zugriffe auf x in der Funktion sind also Zugriffe auf das Original-Objekt i - x += 5 ist nichts anderes als i += 5
  • Bei weiteren Aufrufen wird x dann neu gebunden

Call-by-Reference: const

  • Nachteil bei Call-by-Reference:

    Übergebenes Objekt könnte durch die Funktion (unbeabsichtigt) verändert werden

  • Abhilfe: Deklaration der Parameter als konstant (Schlüsselwort const):

    void fkt(const int&, char);
    void fkt(const int &a, char b);
    // a wird per Referenz uebergeben, darf aber in der Funktion nicht veraendert werden
    

=> const-heit ist Bestandteil der Signatur!

Arbeiten Sie (wo möglich/sinnvoll) mit (konstanten) Referenzen!

Rückgabe von Werten per Referenz

  • Normalerweise per call-by-value (Kopie)
  • Mit Referenzen oder Pointern auch als call-by-reference
int &fkt1(const int &, const char *);
int *fkt2(const int &, const char *);
  • Vorsicht mit lokalen Variablen (Gültigkeit)!

    int &fkt1(const int &i, const char *j) {
        int erg = i+1;
        return erg;   // Referenz auf lokale Variable!
    }
    int *fkt2(const int &i, const char *j) {
        int erg = i+2;
        return &erg;  // Pointer auf lokale Variable!
    }
    int main() {
        int &x = fkt1(2, "a");  // AUTSCH!!!
        int *y = fkt2(2, "b");  // AUTSCH!!!
        int  z = fkt1(2, "c");  // OK
    }

Die Zuweisung int &x = fkt1(2, "a"); ist syntaktisch erlaubt. Semantisch aber nicht: Die Referenz x bindet sich an das zurückgelieferte lokale erg - dieses existiert aber nicht mehr, da der Scope von erg beendet ist ...

=> Nur Pointer auf Speicher zurückliefern, der nach Beendigung des Funtionsaufrufes noch existiert! (Dies könnte beispielsweise Speicher aus malloc oder new oder ein Pointer auf das eigene Objekt (*this) sein.)

Die Zuweisung int *y = fkt2(2, "b"); ist syntaktisch erlaubt. Semantisch aber nicht: Der Pointer y übernimmt die zurückgelieferte Adresse des lokalen erg - dieses existiert aber nicht mehr, da der Scope von erg beendet ist ...

=> Nur Referenzen zurückliefern, die nach Beendigung des Funtionsaufrufes noch gültig sind! (Dies könnten beispielsweise Referenz-Inputparameter oder eine Referenz auf das eigene Objekt (*this) sein.)

Die Zuweisung int z = fkt1(2, "c"); ist unbedenklich, da z eine normale Integervariable ist und hier das übliche Kopieren der Rückgabe von ftk1 in die Variable stattfindet.

Diskussion

In C++ können Sie Call-by-Reference über Pointer und/oder über Referenzen erreichen.

In den obigen Beispielen wurde dies für die Parameter einer Funktion gezeigt - es sind aber auch Pointer und/oder Referenzen als Rückgabetypen möglich. Beachten Sie dabei, ob das jeweils wirklich Sinn ergibt! Eine Referenz oder ein Pointer auf eine lokale Variable ist eine große Fehlerquelle.

In C++ werden Referenzen über Pointer bevorzugt. Wenn Sie die Wahl zwischen den beiden Signaturen bar foo(wuppie&, bar) und bar foo(wuppie*, bar) haben, sollten Sie sich für bar foo(wuppie&, bar) entscheiden.

Vergleich Pointer mit Referenzen

Referenzen Pointer
Alias-Name für Objekte/Variablen, kein eigener Speicherplatz "Echte" Variablen mit eigenem Speicherplatz (für den Wert des Pointers)
Können nicht auf andere Objekte "umgebogen" werden Können auf andere Objekte zeigen (falls nicht const)
Operationen agieren direkt auf dem referenzierten Objekt Operationen auf referenzierten Objekt als auch auf dem Pointer selbst
Nur in C++ In C und in C++
Mit Pointern ist dynamische Speicherverwaltung möglich: Manipulation von Speicherbereichen im Heap

Wrap-Up

  • Virtueller Speicher: Kernel stellt Prozessen linearen Adressraum bereit, Segmente: Text, Stack, Heap

  • Pointer sind Variablen, deren Wert als Adresse interpretiert wird

    • Deklaration mit * zwischen Typ und Name
    • Adressoperator & liefert die Adresse eines Objekts
    • Dereferenzierung eines Pointers mit * vor dem Namen
  • Verwandtschaft zw. Arrays und Pointern: Array-Name ist konstanter Pointer auf Array-Anfang

  • Pointer haben Typ: Pointerarithmetik berücksichtigt Speicherbreite des Typs

  • C++-Referenzen als Alias-Namen für ein Objekt

    • Deklaration: Typ &ref = obj;
    • Fest mit Objekt verbunden
    • Zugriff auf Referenz: Direkter Zugriff auf das Objekt
Challenges

Pointer

  • Erklären Sie das Problem bei folgender Deklaration: int* xptr, yptr;

  • Seien p1 und p2 Pointer auf int. Was ist der Unterschied zwischen den beiden Code-Zeilen?

    p2  = p1;
    *p2 = *p1;
  • Ist *&x immer identisch mit x?

  • Ist &*x immer identisch mit x?

  • Wann kann die Funktion void f(int*) so aufgerufen werden: f(&x);?

Swap ...

  • Warum funktioniert die folgende swap()-Funktion nicht? Wie müsste sie korrigiert werden?

    void swap(int x, int y) {
        int tmp; tmp=x; x=y; y=tmp;
    }
  • Was ist mit dieser Version dieser swap()-Funktion?

    void swap(int *x, int *y) {
        int *tmp;
        tmp=x; x=y; y=tmp;
    }

C++: new und delete

Betrachten Sie folgende Code-Schnipsel. Erklären Sie die Wirkung der jeweiligen Anweisungen.

void fkt() {
    char *cp = new char[100];
    cp[0] = 'a';
}
int i=10;
int *p = &i;
delete p;
char *p;
{
    char *cp = new char[100];
    p = cp;
    free(cp);
}
delete p;

Referenzen vs. Pointer: Welche der Aufrufe sind zulässig?

void f1(int*);
void f2(int&);

int main() {
    int i=0, *ip=&i, &ir=i;

    f1(i);      f1(&i);     f1(*i);
    f1(ip);     f1(&ip);    f1(*ip);
    f1(ir);     f1(&ir);    f1(*ir);

    f2(i);      f2(&i);     f2(*i);
    f2(ip);     f2(&ip);    f2(*ip);
    f2(ir);     f2(&ir);    f2(*ir);
}

C++-Referenzen und Pointer

Betrachten Sie folgende Code-Schnipsel. Erklären Sie die Wirkung der jeweiligen Anweisungen.

int x=5, &y=x;
int *ptr1 = &x;
int *ptr2 = &y;

*ptr1 += 1;
*ptr1++;

ptr2  = ptr1;
*ptr2 = *ptr1;

ptr1  == ptr2;
*ptr1 == *ptr2;

Fallstricke mit C++-Referenzen

Betrachten Sie folgende Code-Ausschnitte. Welchen Wert haben die Variablen nach der Ausführung? Begründen Sie Ihre Antwort.

int i=2, j=9;
int &r=i, &s=r;
s=200;
int &versuch(int i, int j) {
    int erg = i+j;
    return erg;
}
int main() {
    int &z = versuch(2, 10);
    return 0;
}

Referenzen in C++

Betrachten Sie folgende Code-Ausschnitte (C++). Erklären Sie, ob sich dort Fehler verstecken und falls ja, wie diese zu beheben wären.

  1. Versuch

    int &versuch(int&, int&);
    
    int main() {
        int a=10, b=20;
        int &z = versuch(a, b);
        return 0;
    }
    
    int &versuch(int &i, int &j) {
        int &erg = i+j;
        return erg;
    }
  2. Versuch

    int &versuch(int&, int&);
    
    int main() {
        int a=10, b=20;
        int &z = versuch(a, b);
        return 0;
    }
    
    int &versuch(int &i, int &j) {
        int erg = i+j;
        return erg;
    }
  3. Versuch

    int &versuch(int&, int&);
    
    int main() {
        int a=10, b=20;
        int &z = versuch(a, 10);
        return 0;
    }
    
    int &versuch(int &i, int &j) {
        j += i;
        return j;
    }

Pointer und Arrays

  • Erklären Sie die Unterschiede folgender Anweisungen. Welche sind erlaubt, welche nicht? Welche führen möglicherweise zu Fehlern?

    int a[10], *pa, *pb, x;
    pa = a;
    pb = new int;
    
    x = a[1];
    x = *(a+1);
    x = *(a++);
    
    x = pa[1];
    x = *(pa+1);
    x = *(pa++);
    
    x = pb[1];
    x = *(pb+1);
    x = *(pb++);

Typ eines Pointers bei Adressarithmetik

  • Was ist der Unterschied zwischen den beiden folgenden Statements?

    ((char *)ptr)+1
    ((double *)ptr)+1
Quellen

C++: Klassen

TL;DR

Klassen werden in C++ mit dem Schlüsselwort class definiert. Dabei müssen Klassendefinitionen immer mit einem Semikolon abgeschlossen werden(!). Bei Trennung von Deklaration und Implementierung muss die Definition der Methoden mit dem Namen der Klasse als Namespace erfolgen:

// .h
class Fluppie {
public:
    int wuppie(int c=0);
};

// .cpp
int Fluppie::wuppie(int c) { ... }

Die Sichtbarkeiten für die Attribute und Methoden werden blockweise definiert. Für die Klassen selbst gibt es keine Einstellungen für die Sichtbarkeit.

Objekt-Layout: Die Daten (Attribute) liegen direkt im Objekt (anderenfalls Pointer nutzen). Sofern der Typ der Attribute eine Klasse ist, kann man diese Attribute nicht mit NULL initialisieren (kein Pointer, keine Referenz).

Für den Aufruf eines Konstruktors ist kein new notwendig, es sei denn, man möchte das neue Objekt auf dem Heap haben (inkl. Pointer auf das Objekt).

Beachten Sie den Unterschied der Initialisierung der Attribute bei einer Initialisierung im Body des Konstruktors vs. der Initialisierung über eine Initialisierungsliste. (Nutzen Sie in C++ nach Möglichkeit Initialisierungslisten.)

Videos (YouTube)
Lernziele
  • (K2) Attribute von C++-Klassen sind Speicherplatz im Objekt
  • (K2) Explizite Konstruktoren
  • (K2) Problematik mit Defaultkonstruktoren/-operatoren (Pointer)
  • (K3) Konstruktoren (eigene, Default)
  • (K3) Unterschied Initialisierungslisten vs. Initialisierung im Body

OOP in C++

public abstract class Dummy {
    public Dummy(int v) { value = v; }
    public abstract int myMethod();

    private int value;
}
class Dummy {
public:
    Dummy(int v = 0);
    int myMethod();
    virtual ~Dummy();
private:
    int value;
};

OOP in C++: Unterschiede zu Java

  • Klassendefinition muss mit Semikolon beendet werden
  • Sichtbarkeit wird immer blockweise eingestellt (per Default immer private)
  • Wie bei Funktionen: Deklaration muss vor Verwendung (= Aufruf) bekannt sein
  • this ist keine Referenz, sondern ein Pointer auf das eigene Objekt

Objektlayout: Java vs. C++

Java: Referenzen auf Objekte

class Student {
    String name;
    Date birthday;
    double credits;
}

In Java werden im Objektlayout lediglich die primitiven Attribute direkt gespeichert.

Für Objekte wird nur eine Referenz auf die Objekte gehalten. Die Attribute selbst liegen aber außerhalb der Klasse, dadurch benötigt das Objekt selbst nur relativ wenig Platz im Speicher.

C++: Alles direkt im Objekt

class Student {
    string name;
    Date birthday;
    double credits;
};

In C++ werden alle Attribute innerhalb des Objektlayouts gespeichert. Ein Objekt mit vielen oder großen Feldern braucht also auch entsprechend viel Platz im Speicher.

Wollte man eine Java-ähnliche Lösung aufbauen, müsste man in C++ entsprechend Pointer einsetzen:

class Student {
private:
    string *name;
    Date *birthday;
    double credits;
}

Warum nicht Referenzen?

Objekte erzeugen mit Konstruktoren

class Dummy {
public:
    Dummy(int c=0) { credits = c; }
private:
    int credits;
};

Erzeugen neuer Objekte:

Dummy a;
Dummy b(37);
Dummy c=99;

=> Kein Aufruf von new!

(new würde zwar auch ein neues Objekt anlegen, aber auf dem Heap!)

Default-Konstruktoren

Der C++-Compiler generiert einen parameterlosen Defaultkonstruktor - sofern man nicht selbst mindestens einen Konstruktor definiert.

Dieser parameterlose Defaultkonstruktor wendet für jedes Attribut dessen parameterlosen Konstruktor an, für primitive Typen erfolgt keine garantierte Initialisierung!

Achtung: Default-Konstruktor wird ohne Klammern aufgerufen!

Dummy a;    // Korrekt
Dummy a();  // FALSCH!!! (Deklaration einer Funktion `a()`, die ein `Dummy` zurueckliefert)

C++: Trennung .h und .cpp

// .h
class Dummy {
public:
    Dummy(int c=0);
private:
    int credits;
};
// .cpp
Dummy::Dummy(int c) {
    credits = c;
}

Klassenname ist der Scope für die Methoden

Konstruktoren: Normale (Java-like) Initialisierung

class Student {
public:
    Student(const string &n, const Date &d, double c) {
        name = n;
        birthday = d;
        credits = c;
    }
private:
    string name;
    Date birthday;
    double credits;
};

Hier erfolgt die Initialisierung in zwei Schritten:

  1. Attribut wird angelegt und mit Defaultwert/-konstruktor des Datentyps initialisiert
  2. Anschließend wird die Zuweisung im Body des Konstruktors ausgeführt

Das klappt natürlich nur, wenn es einen parameterlosen Konstruktor für das Attribut gibt.

Beispiel oben: Beim Anlegen von birthday im Speicher wird der Defaultkonstruktor für Date aufgerufen. Danach wird im Body der übergebene Datumswert zugewiesen.

Konstruktoren: Initialisierungslisten

class Student {
public:
    Student(const string &n, const Date &d, double c)
    : name(n), birthday(d), credits(c)
    {}
private:
    string name;
    Date birthday;
    double credits;
};

In diesem Fall erfolgt die Initialisierung in nur einem Schritt:

  1. Attribut wird angelegt und direkt mit übergebenen Wert (Kopie) initialisiert

Das klappt natürlich nur, wenn ein passender Konstruktor für das Attribut existiert.

Achtung: Die Reihenfolge der Auswertung der Initialisierungslisten wird durch die Reihenfolge der Attribut-Deklarationen in der Klasse bestimmt!!!

Beispiel oben: Beim Anlegen von birthday im Speicher wird direkt der übergebene Wert kopiert.

Zwang zu Initialisierungslisten

In manchen Fällen muss man die Initialisierung der Attribute per Initialisierungsliste durchführen.

Hier einige Beispiele:

  • Attribut ohne parameterfreien Konstruktor

    Bei "normaler" Initialisierung würde zunächst der parameterfreie Konstruktor für das Attribut aufgerufen, bevor der Wert zugewiesen wird. Wenn es keinen parameterfreien Konstruktor für das Attribut gibt, bekommt man beim Kompilieren einen Fehler.

  • Konstante Attribute

    Bei "normaler" Initialisierung würde das Attribut zunächst per parameterfreiem Konstruktor angelegt (s.o.), danach existiert es und ist konstant und darf nicht mehr geändert werden (müsste es aber, um die eigentlich gewünschten Werte im Body zu setzen) ...

  • Attribute, die Referenzen sind

    Referenzen müssen direkt beim Anlegen initialisiert werden.

C++11 und delegierende Konstruktoren

class C {
    // 1: Normaler Konstruktor
    C(int x) { }

    // 2: Delegiert zu (1)
    C() : C(42) { }

    // 3: Rekursion mit (4)
    C(char c) : C(42.0) { }

    // 4: Rekursion mit (3)
    C(double d) : C('a') { }
};

Delegierende Konstruktoren gibt es ab C++11:

  • Vor C++11: Ein Objekt ist fertig konstruiert, wenn der Konstruktor durchgelaufen ist
  • Ab C++11: Ein Objekt ist fertig konstruiert, wenn der erste Konstruktor fertig ausgeführt ist => Jeder weitere aufgerufene Konstruktor agiert auf einem "fertigen" Objekt.
  • Vorsicht mit rekursiven Aufrufen: Compiler kann warnen, muss aber nicht.

C++ und explizite Konstruktoren

  • Implizite Konvertierung mit einelementigen Konstruktoren:

    class Dummy {
    public:
        Dummy(int c=0);
    };
    
    Dummy a;
    a = 37;     // Zuweisung(!)
    

    Auf der linken Seite der Zuweisung steht der Typ Dummy, rechts ein int. Der Compiler sucht nach einem Weg, aus einem int einen Dummy zu machen und hat durch die Gestaltung des Konstruktors von Dummy diese Möglichkeit. D.h. in dieser Zuweisung wird implizit aus der 37 ein Objekt vom Typ Dummy gebaut (Aufruf des Konstruktors) und dann die Zuweisung ausgeführt.

    Dieses Verhalten ist in vielen Fällen recht praktisch, kann aber auch zu unerwarteten Problemen führen. Zur Abhilfe gibt es das Schlüsselwort explicit.

  • Falls unerwünscht: Schlüsselwort explicit nutzen

    explicit Dummy(int c=0);

Wrap-Up

  • Klassendefinition mit Semikolon abschließen (!)
  • Sichtbarkeiten blockweise, keine für Klasse
  • Daten liegen direkt im Objekt (anderenfalls Pointer nutzen)
  • Attribute sind echte Objekte: Initialisieren mit NULL nicht möglich
  • Konstruktoren: Kein new nötig (würde Objekt auf Heap anlegen und Pointer liefern)
Challenges

C++: Klassen

Erklären Sie die Unterschiede zwischen den Klassendefinitionen (Java, C++):

class Student {
    private String name;
    private Date birthday;
    private double credits;
}
class Student {
private:
    string name;
    Date birthday;
    double credits;
};

Konstruktoren

  • Wie kann der implizite Aufruf eines Konstruktors verhindert werden (beispielsweise in Dummy b; b=3;)?
  • In welchen Fällen muss eine Initialisierung von Attributen in der Initialisierungsliste stattfinden?
  • Wie können/müssen static Attribute initialisiert werden?
Quellen

C++: Big 3

TL;DR

Für C++-Klassen kann man Destruktoren, Copy-Konstruktoren und Zuweisungsoperatoren definieren. Wenn man keine eigenen definiert, erzeugt C++ Default-Varianten. Diese bereiten u.U. Probleme, wenn man Pointertypen für die Attribute verwendet: Dann werden u.U. nur flache Kopien erzeugt bzw. es wird u.U. der Platz auf dem Heap nicht freigegeben.

Der Default-Destruktor ruft die Destruktoren der Objekt-Attribute auf. Der Copy-Konstruktor wird aufgerufen, wenn die linke Seite (einer scheinbaren "Zuweisung") ein unfertiges Objekt ist (noch zu bauen) und die rechte Seite ein fertiges Objekt ist. Der Zuweisungs-Operator wird dagegen aufgerufen, wenn auf beiden Seiten ein fertiges Objekt vorliegt.

Innerhalb einer Klasse kann man über den Pointer this auf das eigene Objekt zugreifen (analog zu self in Python oder this in Java, dort aber Referenzen).

Bei statischen Methoden und Attributen wird die Deklaration als static nicht in der Implementierung wiederholt! Statische Attribute müssen außerhalb der Klassendefinition einmal initialisiert werden!

Methoden können als "konstant" ausgezeichnet werden (const rechts von der Parameterliste). Das const gehört zur Signatur der Methode! Konstante Methoden dürfen auf konstanten Objekten/Referenzen aufgerufen werden.

Videos (YouTube)
Lernziele
  • (K2) Problematik mit Defaultkonstruktoren/-operatoren (Pointer)
  • (K2) Problematik konstanter Funktionen, wann werden diese aufgerufen
  • (K3) 'Big Three': Destruktor, Copy-Konstruktor, Zuweisungsoperator

Big Three

Neben dem eigentlichen Konstruktor existieren in C++ weitere wichtige Konstruktoren/Operatoren: die sogenannten "Big Three":

  • Copy-Konstruktor
  • Destruktor: Gegenstück zum Konstruktor
  • Zuweisungsoperator (operator=)

Anmerkung: Für Fortgeschrittenere sei hier auf die in C++11 eingeführte und den Folgeversionen verbesserte und verfeinerte Move-Semantik und die entsprechenden Varianten der Konstruktoren und Operatoren verwiesen. Man spricht deshalb mittlerweile auch gern von den "Big Five" bzw. der "rule of five".

class Dummy {
public:
    Dummy(int a=0);
    Dummy(const Dummy &d);
    ~Dummy();
    Dummy &operator=(const Dummy &d);
private:
    int value;
};
Dummy::Dummy(int a): value(a) {}
Dummy::Dummy(const Dummy &d): value(d.value) {}
Dummy::~Dummy() {}
Dummy::Dummy &operator=(const Dummy &d) {
    if (this != &d) { value = d.value; }
    return *this;
}

Big Three: Destruktor

  • Syntax: Dummy::~Dummy(); (Konstruktor mit vorgesetzter Tilde)
  • Wird aufgerufen:
    • wenn ein Objekt seinen Scope verlässt, oder
    • wenn explizit delete für einen Pointer auf ein Objekt (auf dem Heap!) aufgerufen wird
  • Default-Destruktor ruft Destruktoren der Objekt-Attribute auf

Big Three: Copy-Konstruktor

  • Syntax: Dummy::Dummy(const Dummy &);
  • Wird aufgerufen bei:
    • Deklaration mit Initialisierung mit Objekt
    • Objektübergabe und -rückgabe mit Call-by-Value
    • Nicht bei Zuweisung
  • Default-Copy-Konstruktor kopiert einfach elementweise => bei Pointern also nur flache Kopie

"Merkregel": Linke Seite unfertiges Objekt (noch zu bauen), rechte Seite fertiges Objekt.

Big Three: Zuweisungsoperator

  • Syntax: Dummy &Dummy::operator=(const Dummy &)
  • Wird aufgerufen:
    • bei Zuweisung bereits initialisierter Objekte
  • Default-Zuweisungsoperator kopiert einfach elementweise => bei Pointern also nur flache Kopie

"Merkregel": Linke Seite fertiges Objekt, rechte Seite fertiges Objekt.

Big Three: Defaults

Analog zum Default-Konstruktor kann der Compiler auch Defaults für die Big Three (Copy-Konstruktor, Destruktor, Zuweisungsoperator) generieren. Das funktioniert nur, so lange Sie nicht selbst einen Copy-Konstruktor, Destruktor oder Zuweisungsoperator definiert haben.

Diese Defaults passen normalerweise, wenn die Data-Member vom Typ int, double, vector<int>, string, vector<string> o.ä. sind.

Problematisch wird es, wenn Pointer dabei sind: Dann werden flache Kopien erzeugt bzw. Speicher auf dem Heap nicht oder mehrfach freigegeben! Sobald Sie für die Attribute Pointer verwenden, sollten Sie eigene Konstruktoren, Copy-Konstruktoren, Destruktoren und Zuweisungsoperatoren definieren!

Hier ein Beispiel für die Wirkung:

class Dummy {
public:
    Dummy(int initValue = 0) {
        value = new int(initValue);
    }

    int getValue() {
        return *value;
    }
    void setValue(int a) {
        *value = a;
    }
private:
    int *value;
};

void main() {
    // oberer Teil der Abbildung
    Dummy a(2);
    Dummy b = a;
    Dummy c;

    // unterer Teil der Abbildung
    c=b;
    a.setValue(4);
}

Analyse:

  1. Es sind Pointer im Spiel. Es wurde ein eigener Konstruktor definiert, aber kein Copy-Konstruktor, d.h. diesen "spendiert" der Compiler.
  2. Beim Anlegen von a wird auf dem Heap Speicher für einen int reserviert und dort der Wert 2 hineingeschrieben.
  3. Beim Anlegen von b wird der Default-Copy-Konstruktor verwendet, der einfach elementweise kopiert. Damit zeigt der Pointer value in b auf den selben Speicher wie der Pointer value in a.
  4. Der Ausdruck c=b ist eine Zuweisung (warum?). Auch hier wird der vom Compiler bereitgestellte Default genutzt (elementweise Zuweisung). Damit zeigt nun auch der Pointer value in c auf den selben Speicher wie die value-Pointer in a und b.

Hinweis Abarbeitungsreihenfolge

Dummy a(0); Dummy b(1); Dummy c(2); Dummy d(3);
a = b = c = d; // entspricht: a.operator=(b.operator=(c.operator=(d)));

delete this?

Erinnerung:

  • this ist ein Pointer auf das eigene Objekt
  • delete darf nur für Pointer auf Objekte, die mit new angelegt wurden, aufgerufen werden => Freigabe von Objekten auf dem Heap!
  • delete ruft den Destruktor eines Objekts auf ...

Frage: Ist das folgende Konstrukt sinnvoll? Ist es überhaupt erlaubt? Was passiert dabei?

class Foo {
public:
    ~Foo() {
        delete this;
    }
};

Analyse: Wir haben hier gleich zwei Probleme:

  1. delete ruft den Destruktor des verwiesenen Objekts auf. Da this ein Pointer auf das eigene Objekt ist, ruft delete this; den eigenen Destruktor auf, der dann wiederum delete this; aufruft und so weiter. => Endlosschleife!

  2. Außerdem wissen wir im Destruktor bzw. im Objekt gar nicht, ob das Objekt wirklich mit new auf dem Heap angelegt wurde! D.h. wenn wir nicht in die Endlosschleife eintreten würden, würde das Programm abstürzen.

Der Destruktor wird aufgerufen, wenn ein Objekt zerstört wird, d.h. wenn ein Objekt seine Lebensdauer beendet (Verlassen des Scopes, in dem das Objekt definiert wurde) bzw. wenn explizit ein delete auf das Objekt aufgerufen wird (d.h. delete auf einen Pointer auf das Objekt, wobei dieses mit new angelegt wurde).

Im Destruktor sollten durch das Objekt verwaltete Resourcen freigegeben werden, d.h. sämtliche im Objekt mit new oder malloc allozierten Resourcen auf dem Heap müssen freigegeben werden. Außerdem sollten ggf. offene Verbindungen (offene Dateien, Datenbankverbindungen, Kommunikation, ...) geschlossen werden, wenn sie durch das Objekt geöffnet wurden bzw. in der Verantwortung des Objekts stehen. Einfache Datentypen oder Objekte, die nicht per Referenz oder Pointer im Objekt verwaltet werden, werden automatisch freigegeben (denken Sie an das Speichermodell - diese Daten "stehen" direkt im Speicherbereich des Objekts).

Der Speicherbereich für das Objekt selbst wird nach Beendigung des Destruktors automatisch freigegeben (auf dem Stack wegen des Verlassen des Scopes (=> automatische Variable), auf dem Heap durch das vorherige Aufrufen von delete auf den Pointer auf das Objekt im Heap), d.h. Sie brauchen im Destruktor kein delete auf "sich selbst" (das ist wie oben demonstriert sogar schädlich)!

Warnung

Auch wenn es zunächst irgendwie sinnvoll aussieht - rufen Sie niemals nie delete this im Destruktor auf!

C++11: default und delete

class Dummy {
public:
    Dummy() = default;
    Dummy(int a) { value = a; }
    Dummy(const Dummy &a) = delete;

private:
    int value;
    Dummy &operator=(const Dummy &d);
};
  • C++ erzeugt etliche triviale Methoden/Operatoren, sofern man diese nicht selbst definiert:
    • Methoden:
      • Standardkonstruktor
      • Copy-Konstruktor
      • Zuweisungsoperator
      • Destruktor
    • Operatoren:
      • Operator new
      • Operator delete
      • Adresse von
      • Indirektion
      • Elementzugriff
      • Elementindirektion
  • Vor C++11: Default-Methode/-Operator verbieten: Sichtbarkeit auf private setzen (Definition nicht nötig)
  • Ab C++11: Schlüsselwort delete: Entfernt Default-Methode/-Operator
  • C++11: Default-Methode/-Operator zusätzlich zu selbst implementierten: Schlüsselwort default

Statische Methoden und Attribute

class Studi {
    static int getCount();
    static int count;
};
int Studi::count = 0;

int Studi::getCount() {
    return Studi::count;
}
  • Deklaration als static nicht in Implementierung wiederholen
  • Statische Attribute: Initialisierung immer außerhalb der Klasse!

Konstante Methoden und Kontexte

class Studi {
    int getCredits() const;
    int getCredits();
};
int Studi::getCredits() const {
    return credits;
}

int Studi::getCredits() {
    return credits;
}

Das const gehört zur Signatur der Methode!

So wie im Beispiel gezeigt, gibt es jetzt zwei Methoden getCredits() - eine davon ist konstant. Konstante Methoden dürfen auf konstanten Objekten/Referenzen aufgerufen werden.

Was passiert, wenn das const auf der linken Seite steht? Dann bezieht es sich auf den Rückgabewert:

const foo wuppie(foo&, foo&);

Hier darf der Rückgabewert nicht als L-Wert benutzt werden: wuppie(a,b) = c; ist verboten.

Wrap-Up

  • Klassen: Destruktoren, Copy-Konstruktor, Zuweisungsoperator
  • Vorsicht mit Default-*struktoren/-operatoren
  • Statische Methoden und Attribute:
    • Deklaration als static nicht in Implementierung wiederholen
    • Statische Attribute: Initialisierung außerhalb der Klasse!
  • Konstante Methoden und Kontexte
    • const gehört zur Signatur der Methode!
    • Konstante Methoden dürfen auf konstanten Objekten/Referenzen aufgerufen werden
Challenges

Konstruktor, Copy-Konstruktor, Zuweisungsoperator?

  • Erklären Sie die folgenden Anweisungen, worin liegt der Unterschied?

    Dummy a;
    Dummy b = 3;
    Dummy c(4);
  • Erklären Sie die folgenden Anweisungen:

    Dummy a;
    Dummy b = 3;
    Dummy c(4);
    Dummy d = b;
    Dummy e(b);
    Dummy f;
    f = b;

Destruktor

  • Erklären Sie die Wirkungsweise eines Destruktors.
  • Wann wird ein Destruktor aufgerufen?
  • Warum ist delete this keine gute Idee (nicht nur im Destruktor)?!
  • Was sollten Sie im Destruktor aufräumen, was nicht?

Die "Großen Drei"

  1. Beschreiben Sie den Unterschied der folgenden beiden Codeblöcke (A sei eine beliebige Klasse):

    A a, b = a;
    A a, b; b = a;
  2. Erläutern Sie an einem Beispiel die Regel der "Big Three":

    Ist ein Copy-Konstruktor, ein Destruktor oder ein eigener Zuweisungsoperator notwendig, muss man in der Regel die jeweils anderen beiden ebenfalls bereit stellen.

  3. Beim Zuweisungsoperator werden Selbstzuweisungen, d.h. ein Objekt soll an sich selbst zugewiesen werden, üblicherweise durch eine entsprechende Prüfung vermieden.

    Begründen Sie diese Praxis, indem Sie ein Beispiel konstruieren, bei dem es zu Datenverlust kommt, wenn die Selbstzuweisung nicht unterbunden wird.

    Wenn vor der Wertzuweisung der alte Inhalt freigegeben werden muss, führt
    Selbstzuweisung zum Fehler.
    
    Können Sie ein konkretes Beispiel angeben?
    

Quiz: Was passiert bei den folgenden Aufrufen?

class Foo {
public:
    const Foo &bar(const vector<Foo> &a) { return a[0]; }
};

int main() {
    Foo f;  vector<Foo> a = {"hello", "world", ":)"};

    Foo s1 = f.bar(a);
    const Foo &s2 = f.bar(a);
    Foo &s3 = f.bar(a);
    Foo s4;
    s4 = f.bar(a);

    return EXIT_SUCCESS;
}
Quellen

C++: Operatoren

TL;DR

In C++ können existierende Operatoren überladen werden, etwa für die Nutzung mit eigenen Klassen. Dabei kann die Überladung innerhalb einer Klassendefinition passieren (analog zur Implementierung einer Methode) oder außerhalb der Klasse (analog zur Definition einer überladenen Funktion).

Beim Überladen in einer Klasse hat der Operator nur einen Parameter (beim Aufruf das Objekt auf der rechten Seite) und man kann auf die Attribute der Klasse direkt zugreifen. Bei der Überladung außerhalb der Klasse hat der Operator zwei Parameter und darf nicht auf die Attribute der Klasse zugreifen.

Man kann Funktionen, Methoden/Operatoren und Klassen als friend einer Klasse deklarieren. Damit bricht man die Kapselung auf und erlaubt den Freunden den direkten Zugriff auf die internen Attribute einer Klasse.

Um bei der Implementierung von Post- und Präfix-Operatoren die Variante für den Compiler unterscheidbar zu machen, hat die Signatur der Postfix-Variante einen Dummy-Parameter vom Typ int. Dieser wird beim Aufruf aber nicht genutzt.

Videos (YouTube)
Lernziele
  • (K2) Implizite Typkonvertierungen bei Operatoren
  • (K3) Überladen von Operatoren (innerhalb bzw. außerhalb einer Klasse)
  • (K3) Anwendung der Deklaration als friend
  • (K3) Implementierung von Post- und Präfix-Operatoren

Überladen von Operatoren in Klassen

MyString a, b("hallo");
a = b;      // ???
a.operator=(b);

Aufruf a=b ist äquivalent zu a.operator=(b)

Überladen ähnlich wie bei Methoden:

class MyString {
    MyString &operator=(const MyString &s) {
        if (this != &s) {
            // mach was :-)
        }
        return *this;
    }
};

Analog weitere Operatoren, etwa operator==, operator+, ... überladen

Überladen von Operatoren außerhalb von Klassen

MyString a("hallo");
cout << a << endl;
class MyString {
    ostream &operator<<(ostream &o) { return o << str; }
};

So funktioniert das leider nicht!

  • Erinnerung: cout << a entspricht cout.operator<<(a)
    • Operator kann nicht in MyString überladen werden!
    • Klasse ostream müsste erweitert werden => Geht aber nicht, da System-weite Klasse!

=> Lösung: Operator außerhalb der Klasse überladen => 2 Parameter

Überladen von Operatoren außerhalb von Klassen (cnt.)

Operator außerhalb der Klasse überladen => 2 Parameter

ostream &operator<<(ostream &out, const MyString &s) {
    return out << s.str;
}
  • Nachteil: Benötigt Zugriff auf Klassen-Interna
    • entweder umständlich über Getter-Funktionen

    • oder als friend der Klasse MyString deklarieren

      Alternativ Zugriffsmethoden (aka Getter) nutzen wie toString() ...

Anmerkung: Rückgabe der Referenz auf den Stream erlaubt die typische Verkettung: cout << s1 << s2 << endl;

Meine Freunde dürfen in mein Wohnzimmer

void test();

class TestDummy {
    int ganzTolleMethode();
};


class Dummy {
    private:
        int *value;

    friend class TestDummy;
    friend int TestDummy::ganzTolleMethode();
    friend void test();
};

(Fast) alle Operatoren lassen sich überladen

  • Alle normalen arithmetischen Operatoren

  • Zuweisung, Vergleich, Ein-/Ausgabe

  • Index-Operator [], Pointer-Dereferenzierung * und ->, sowie (), new und delete (auch in []-Form)

  • Ausnahmen:

    1. .
    2. ::
    3. ?:
    4. sizeof
  • Anmerkungen:

    • Beim Überladen muss die Arität erhalten bleiben
    • Nur existierende Operatoren lassen sich überladen => Es lassen sich keine neuen Operatoren erschaffen

Vgl. Tabelle 9.1 (S. 318) im [Breymann2011]

Implizite Typkonvertierungen bei Aufruf

MyString s;
s != "123";     // ???
"123" != s;     // ???
  • Operatoren in Klasse überladen: Typ der linken Seite muss exakt passen

    class MyString {
    public:
        MyString(const char *s = "");
        bool operator!=(const MyString&);
    };
    
    MyString s;
    s != "123";    // impliziter Aufruf des Konstruktors, danach MyString::operator!=
    "123" != s;    // KEIN operator!=(char*, MyString&) vorhanden!
    

    Das ist letztlich wie bei einem Methodenaufruf: Um die richtige Methode aufzurufen, muss der Typ (die Klasse) des Objekts bekannt sein.

  • Operatoren außerhalb überladen: Konvertierung auf beiden Seiten möglich

    class MyString {
    public:
        MyString(const char *s = "");
    };
    bool operator!=(const MyString&, const MyString&);

NIEMALS beide Formen gleichzeitig für einen Operator implementieren!

Anmerkung zu "++" und "-$\,$-" Operatoren: Präfix und Postfix

  • Präfix: o1 = ++o2;

    • Objekt soll vor Auswertung inkrementiert werden
    • Signatur: Typ &operator++()
  • Postfix: o1 = o2++;

    • Objekt soll erst nach Auswertung inkrementiert werden
    • Signatur: Typ operator++(int) (=> int dient nur zur Unterscheidung der Präfix-Variante, wird nie benutzt)

Weitere Anmerkungen

  • Operatoren werden nicht vom System zusammengesetzt

    • operator+ und operator+= sind zwei verschiedene Operatoren!
    • Implementierung ist prinzipiell unabhängig! => Erwartung: operator+= $\;==\;$ (operator+ $\;+\;$ operator=)
  • Operatoren lassen sich in C++ verketten:

    Dummy a(0); Dummy b(1); Dummy c(2);
    a = b = c;  // a.operator=(b.operator=(c));
    
  • Übertreiben Sie nicht!

    Firma f;
    Person p;
    f += p;  // ??!
    

    Nutzen Sie im Zweifel lieber Methoden mit aussagekräftigen Namen!

Wrap-Up

  • Überladen von Operatoren (innerhalb und außerhalb einer Klasse)
    • Innerhalb: 1 Parameter (Objekt auf der rechten Seite)
    • Außerhalb: 2 Parameter
  • Zugriff auf Attribute: friend einer Klasse
  • Implementierung von Post- und Präfix-Operatoren
Challenges

Operator "++"

Betrachten Sie die folgende Klasse:

class Studi {
public:
    Studi(int credits);
    ~Studi();
private:
    int *credits;
};

Implementieren Sie den operator++ sowohl in der Präfix- als auch in der Postfix-Variante.

C'toren und Operatoren: Was muss noch deklariert werden?

class Studi {
public:
    Studi(int credits);
private:
    int *credits;
};

int main() {
    Studi a(1), b, *c = new Studi(99);
    b = *c+a+1;
    std::cout << "b: '" << b << "' credits" << std::endl;

    return 0;
}

Schreiben Sie Code, damit folgender Code kompiliert:

test wuppie;
bool fluppie = wuppie(3);
Quellen

C++: Vererbung und Polymorphie

TL;DR

Vererbung analog zu Java passiert in C++ über die "public-Vererbung": Subklasse : public Superklasse. Dabei gibt es in C++ keine gemeinsame Oberklasse wie Object und entsprechend kein super. (Es kann auch private Vererbung geben.)

Operatoren und *struktoren werden in den vom Compiler erzeugten Defaults richtig verkettet. Bei der eigenen Implementierung von Operatoren und Konstruktoren muss zunächst der Operator/Konstruktor der Basisklasse aufgerufen werden (Basisklassen-Konstruktoren dabei in der Initialisierungsliste!), danach erfolgt die Implementierung für die eigenen Attribute der abgeleiteten Klasse. Der Zugriff auf die Elemente der Elternklasse erfolgt dabei über den Namen der Elternklasse und den Scope-Operator (nicht mit super!). Destruktoren von abgeleiteten Klassen müssen sich dagegen nur um die zusätzlichen Attribute der abgeleiteten Klasse kümmern, der Basisklassendestruktor wird automatisch verkettet bzw. aufgerufen.

Abstrakte Klassen in C++ haben mindestens eine abstrakte Methode. Eine Methode ist abstrakt, wenn sie als "virtual" deklariert ist und der Deklaration ein "=0" folgt.

In C++ hat man aus Effizienzgründen per Default statische Polymorphie. Bei der Zuweisung eines Objekts einer abgeleiteten Klasse (rechte Seite) an ein Objekt vom Typ der Oberklasse (linke Seite) erfolgt dabei "Slicing", d.h. alle zusätzlichen Eigenschaften der abgeleiteten Klasse gehen dabei verloren. Dynamische Polymorphie kann man in C++ nutzen, indem man (a) die gewünschten Methoden in der Basisklasse als virtual deklariert und (b) für den Zugriff auf die Objekte der abgeleiteten Klasse Pointer oder Referenzen vom Basisklassen-Typ benutzt.

In C++ ist Mehrfachvererbung möglich, d.h. eine Klasse kann von mehreren anderen Klassen erben. Damit erbt sie auch das Objekt-Layout aller Elternklassen.

Bei rautenförmigen Vererbungsbeziehung führt dies zu Problemen, da Attribute und Methoden der gemeinsamen Basisklasse mehrfach vorhanden (über jeden Zweig der Raute).

Zur Umgehung des Problems kann man die gemeinsam genutzten Basisklassen "virtual" deklarieren. Dadurch sind gemeinsam genutzte Attribute und Methoden nur noch einfach vorhanden. Da die Klassen "in der Raute" ihrerseits den Konstruktor der Basisklasse aufrufen (könnten) und es dadurch zu Konflikten beim Setzen der Attribute der Basisklasse kommen kann, gelten bei virtueller Ableitung Sonderregeln: Für die virtuelle Basisklasse wird die Weiterleitung der Werte aufgehoben (es muss also ein parameterloser Konstruktor existieren, der durch die direkten Unterklassen aufgerufen wird) und die Klasse am "unteren Ende der Raute" kann direkt den Konstruktor der virtuellen Basisklasse am "oberen Ende der Raute" aufrufen.

Lernziele
  • (K2) Unterschied zwischen public- und private-Vererbung
  • (K2) Unterschied Überladen und Überschreiben
  • (K2) Slicing in C++
  • (K2) Probleme bei Mehrfachvererbung und Einsatz virtueller Basisklassen
  • (K3) public-Vererbung in C++
  • (K3) Verkettung von Operatoren und *struktoren
  • (K3) Statische und dynamische Polymorphie in C++
  • (K3) Abstrakte Klassen in C++
  • (K2) Probleme bei Mehrfachvererbung und Einsatz virtueller Basisklassen
  • (K3) Praktischer Umgang mit Mehrfachvererbung

Vererbung: "IS-A"-Beziehung zw. Klassen

class Student : public Person { ... }
Student(const string &name = "", double c = 0.0)
: Person(name), credits(c) { }

Student(const Student &s)
: Person(s), credits(s.credits) { }

Analog zu Java:

  • Student: abgeleitete Klasse
  • Person: Basisklasse
  • : public: Vererbungsbeziehung (analog zu extends in Java)
  • public-Vererbung: Verhalten wie in Java
  • Hinweis: Es gibt weitere Spielarten (protected, private), vgl. Semesterliteratur
  • Ab C++11:
    • Schlüsselwort override: Die Methode muss eine virtuelle Methode der Klassenhierarchie überschreiben.
    • Schlüsselwort final: Die virtuelle Methode darf nicht in abgeleiteten Klassen überschrieben werden.

Vererbung und Konstruktoren

  • Defaultkonstruktoren werden automatisch richtig verkettet
    • zuerst Aufruf des Basisklassen-Konstruktors
    • anschließend Behandlung der zusätzlichen Attribute
  • Eigene Konstruktoren verketten:
    • Zuerst Basisklassen-Konstruktor aufrufen (in Initialisierungsliste!) => Konkreten Konstruktor nehmen, nicht super wie in Java

Vererbung und Destruktoren

  • Defaultdestruktoren werden automatisch richtig verkettet
    • zuerst werden die Destruktoren der zusätzlichen Attribute aufgerufen
    • dann der Destruktor der Basisklasse
  • Eigene Destruktoren werden automatisch verkettet
  • Destruktor abgeleiteter Klasse muss sich nur um zusätzliche Attribute kümmern

Vererbung und Operatoren

  • Defaultoperatoren werden automatisch richtig verkettet
    • zuerst Aufruf des Basisklassen-Operators
    • anschließend Behandlung der zusätzlichen Attribute
  • Eigene Operatoren am Beispiel Zuweisungsoperator:
    • Zuerst den Zuweisungsoperator der Basisklasse aufrufen

    • Zugriff über Superklassennamen und Scope-Operator (nicht mit super!)

      const Student &operator=(const Student &s) {
          if (this != &s) {
              Person::operator=(s);
              credits = s.credits;
          }
          return *this;
      }

Vererbung von Freundschaften

  • Freundschaften werden nicht vererbt!
  • friends der Basisklasse haben keinen Zugriff auf zusätzliche private Attribute/Methoden der Unterklassen
  • Aber: weiterhin Zugriff auf die geerbten privaten Elemente!

Abstrakte Klassen

  • Eine Klasse ist abstrakt, wenn sie mindestens eine abstrakte Methode hat
  • Eine Methode ist in C++ abstrakt, wenn sie
    1. als virtuell deklariert ist, und
    2. der Deklaration ein "=0" folgt

Abstrakte Methoden können Implementierung haben! => Implementierung außerhalb der Klassendeklaration

class Person {
public:
    virtual string toString() const = 0;
...
};

string Person::toString() const { ... }  // Implementierung :-)

Polymorphie: Was passiert im folgenden Beispiel?

IS-A Beziehung: Objekte können als Objekte ihrer Oberklasse behandelt werden

class Person { ... }
class Student : public Person { ... }

Student s("Heinz", "heizer");
Person &p = s;

cout << s.toString() << endl;
cout << p.toString() << endl;

Antwort: Es wird die falsche Methode aufgerufen!

  • s.toString() => Student::toString() => wie erwartet
  • p.toString() => Person::toString() => unerwartet!

Polymorphie: statisch und dynamisch

  • C++ entscheidet zur Kompilierzeit, welche Methode aufgerufen wird

    • p ist vom Typ Person => p.toString() => Person::toString()
    • Dieses Verhalten wird statisches Binden genannt.
  • Von Java her bekannt: dynamisches Binden

    • Typ eines Objektes wird zur Laufzeit ausgewertet

Dynamisches Binden geht auch in C++ ...

Für dynamische Polymorphie müssen in C++ drei Bedingungen erfüllt sein:

  1. Methoden in Basisklasse als virtuelle Funktion deklarieren => Schlüsselwort virtual

  2. Virtuelle Methoden in Subklasse normal überschreiben (gleiche Signatur)

    Zusätzlich muss der Rückgabetyp exakt übereinstimmen (Ausnahme: Rückgabe Pointer/Referenz auf abgeleitete Klasse)

  3. Objekte mittels Basisklassen-Referenzen bzw. -Pointer zugreifen (siehe nächste Folie)

class Person {
    virtual string toString() const { ... }
};

Vorsicht Slicing

Student s("Heinz", 10.0);
Person p("Holger");

p = s;
cout << "Objekt s (Student): " << s.toString() << endl;
cout << "Objekt p (Person):  " << p.toString() << endl;

=> p ist vom Typ Person

  • Zuweisung von Objekten vom Typ Student ist erlaubt (Polymorphie)
  • p hat aber nur Speicherplatz für genau eine Person => "Abschneiden" aller Elemente, die nicht Bestandteil von Person sind!
  • Slicing passiert immer beim Kopieren/Zuweisen von Objekten

=> Dyn. Polymorphie in C++ immer über Referenzen (bzw. Pointer) und virtuelle Methoden

Wir hatten die Methode toString in der Basisklasse Person zwar als virtual deklariert, und wir hatten diese Methode in der ableitenden Klasse Studi passend überschrieben.

Damit haben wir aber nur zwei der drei Bedingungen für dynamische Polymorphie in C++ erfüllt. Wenn wir Objekte vom Typ Studi über eine normale Variable vom Typ Person handhaben, haben wir immer noch statische Polymorphie - uns stehen also nur die Methoden aus und in Person zur Verfügung.

Zusätzlich haben wir durch die Zuweisung p = s; das Objekt s in den Speicherbereich von p "gequetscht". Dieses ist vom Typ Person und hat auch nur (Speicher-) Platz für Elemente dieses Typs. Alles andere wird bei der Zuweisung "abgeschnitten", d.h. p ist immer noch ein Objekt vom Typ Person, der zusätzliche Rest aus Studi fehlt ...

Wir könnten das durch Pointer oder Referenzen heilen:

// Variante mit Basisklassen-Pointer
Student s("Heinz", 10.0);
Person *p;

p = &s;
cout << "Objekt s (Student): " << s.toString()  << endl;
cout << "Objekt p (Person):  " << p->toString() << endl;

Anmerkung: Der Operator -> ist die zusammengefasste Dereferenzierung des Pointers und der nachfolgende Zugriff auf Methoden oder Attribute. Man könnte also entsprechend auch (*p).toString() statt p->toString() schreiben.

// Variante mit Basisklassen-Referenz
Student s("Heinz", 10.0);
Person &p = s;

cout << "Objekt s (Student): " << s.toString() << endl;
cout << "Objekt p (Person):  " << p.toString() << endl;

Erst damit erfüllen wir die dritte Bedingung und haben echte dynamische Polymorphie in C++.

Anmerkungen zu Polymorphie in C++

  • Gestaltung der API:
    • Zum Überschreiben gedachte Methoden als virtuell deklarieren
    • Nicht virtuelle Methoden aus der Basisklasse nicht überschreiben
  • Trennung von Deklaration und Implementierung:
    • Deklaration als virtuelle Funktion nur im Deklarationsteil
    • Keine Wiederholung im Implementierungsteil (analog zu Defaultwerten)
  • "Virtualität vererbt sich":
    • Virtuelle Funktionen sind virtuell in der Vererbungshierarchie hinab ab der ersten Deklaration als virtuell
  • Virtualität ist "teuer": Es muss eine Tabelle aller virtuellen Funktionen aufgebaut werden und zur Laufzeit geprüft werden, welche Funktion genommen werden soll

Mehrfachvererbung in C++

class HiWi: public Student, public Angestellter {...};

Hinweis Speicherlayout ...

Problem 1: Gleichnamige Methoden aus Basisklassen geerbt

Namenskollision bei Mehrfachvererbung auflösen:

  • Scope-Operator :: nutzen:

    HiWi h("Anne", 23.0, 40.0);
    
    cout << h.Student::toString() << endl;
    cout << h.Angestellter::toString() << endl;
    cout << h.Student::getName() << endl;
    cout << h.Angestellter::getName() << endl;
  • Methode in abgeleiteter Klasse überschreiben

    HiWi h("Anne", 23.0, 40.0);
    
    cout << h.toString() << endl;
    cout << h.Student::toString() << endl;
    cout << h.Angestellter::toString() << endl;

Problem 2: Gemeinsam geerbte Attribute sind mehrfach vorhanden

Mehrfachvererbung in C++: Virtuelle Basisklassen

class Angestellter: virtual public Person {...};
class Student: virtual public Person {...};

class HiWi: public Student, public Angestellter {...};
  • Person ist jetzt eine virtuelle Basisklasse
  • Auswirkungen erst in Klasse HiWi
  • Dadurch sind gemeinsam genutzte Anteile nur einfach vorhanden
Student s("Heinz", 10.0);           // wie vorher: nur EIN name-Feld
Angestellter a("Holger", 80.5);     // wie vorher: nur EIN name-Feld

HiWi h("Anne", 23.0, 40.0);         // jetzt auch nur EIN name-Feld

Sonderregeln bei virtueller Ableitung

Virtuelle Ableitung: Potentiell Konflikte zwischen Konstruktoren!

  • Gemeinsam geerbtes Attribut nur noch einmal vorhanden
  • Konstruktoren werden nacheinander aufgerufen, alle wollen das gemeinsame Attribut initialisieren (durch Aufruf des Konstruktors der jeweiligen Basisklasse)
  • Zuletzt aufgerufener Konstruktor würde "gewinnen"

Deshalb gibt es bei virtueller Ableitung folgende Sonderregeln:

  1. Für virtuelle Basisklassen ist Mechanismus des Weiterreichens von Initialisierungswerten deaktiviert

  2. Konstruktor einer virtuellen Basisklasse kann in Initialisierungsliste von indirekten Unterklassen aufgerufen werden

    Sonst wird der Defaultkonstruktor der virtuellen Basisklasse genutzt!

Mehrfachvererbung in C++ ist ein recht kompliziertes Thema

Warum ist die Möglichkeit dennoch nützlich?

  • In Java kann man nur von einer Klasse erben, aber viele Interfaces implementieren. In C++ gibt es keine Interfaces ...

    => Interfaces mit abstrakten Klassen Interfaces simulieren

    => Mehrfachvererbung!

Tatsächlich dürfen Java-Interfaces mittlerweile auch Verhalten implementieren und vererben, wodurch eine ähnliche Situation wie hier in C++ entsteht und es ausgefeilte Regeln für die Konfliktauflösung braucht. Allerdings ist das in Java auf Verhalten beschränkt, d.h. Attribute (Zustand) ist in Java-Interfaces (noch) nicht erlaubt.

Wrap-Up

  • public-Vererbung in C++: Subklasse : public Superklasse

  • Keine gemeinsame Oberklasse wie Object, kein super

  • Verkettung von Operatoren und *struktoren

  • Abstrakte Klassen in C++

  • Statische und dynamische Polymorphie in C++

    • Methoden in Basisklasse als virtual deklarieren
    • Dyn. Polymorphie nur mittels Pointer/Referenzen
    • Slicing in C++ (bei Call-by-Value)
  • Konzept der Mehrfachvererbung

  • Problem bei rautenförmiger Vererbungsbeziehung: Attribute und Methoden mehrfach vorhanden

  • Virtuelle Basisklassen: Gemeinsam genutzte Attribute nur noch einfach vorhanden

Challenges

Destruktoren und Vererbung

Welcher Destruktor würde im folgenden Beispiel aufgerufen?!

Student *s3 = new Student("Holger", 1.0);
Person  *p  = s3;

delete p;

Vererbung

  • Welche Formen der (einfachen) Vererbung gibt es in C++ neben der public-Form noch? Was bewirken diese Formen?
  • Warum wird in C++ die public-Form der Vererbung vorgezogen (zumindest, wenn man dynamische Polymorphie nutzen will)?
  • Wie müssen Konstruktoren/Destruktoren richtig verkettet werden?
  • Arbeiten Sie das Beispiel auf S. 274 im [Breymann2011]: "Der C++ Programmierer" durch.

Virtuelle Methoden, Dynamische Polymorphie in C++

  1. Was sind virtuelle Methoden und wie setze ich diese ein?
  2. Wozu brauche ich in C++ virtuelle Klassen? Was muss beachtet werden?
  3. Was passiert in C++, wenn eine virtuelle Methode innerhalb von Konstruktoren verwendet wird? Schreiben Sie ein kurzes Programm zur Verdeutlichung.
  4. Wie verhält es sich mit der Problematik aus (a) in Java?
  5. Wie unterscheiden sich in C++ virtuelle und nicht virtuelle Destruktoren? Schreiben Sie ein kurzes Programm zur Verdeutlichung.
  6. Was passiert, wenn in C++ aus einem Destruktor heraus eine virtuelle Methode aufgerufen wird?

Hinweis: Möglicherweise müssen jeweils mehrere Fälle betrachtet werden!

Quellen

C++: Templates

TL;DR

In C++ können Funktionen über Funktions-Templates definiert werden. Dafür stellt man ein template <typename T> mit einer Aufzählung typename T aller Template-Parameter der Funktionsdefinition voran. In der Funktion kann man dann den Typ T wie einen normalen anderen Typ nutzen.

Funktions-Templates sind (vollständig) spezialisierbar. Dazu wiederholt man die komplette Funktionsdefinition (inkl. der dann leeren Template-Deklaration template <>) und legt alle Template-Parameter über die Funktionssignatur fest. Alle Spezialisierungen müssen nach dem eigentlichen ("primären") Funktions-Template formuliert werden.

Funktions-Templates sind überladbar mit "normalen" Funktionen und anderen Funktions-Templates.

Beim Aufruf kann man die Template-Parameter entweder selbst festlegen (über eine Auflistung der Typen in spitzen Klammern hinter dem Funktionsnamen) oder den Compiler inferieren lassen. Dabei wird die am besten "passende" Variante genutzt:

  1. Zuerst die exakt passende normale Funktion,
  2. dann ein passendes spezialisiertes Template (bei mehreren passenden spezialisierten Templates das am meisten spezialisierte Template),
  3. dann das allgemeine ("primäre") Template,
  4. ansonsten die normale Funktion mit impliziten Casts.

In C++ können Klassen als Klassen-Templates definiert werden. Dafür stellt man ein template <typename T> mit einer Aufzählung typename T aller Template-Parameter der Klassendefinition voran. In der Klasse kann man dann den Typ T wie einen normalen anderen Typ nutzen. Bei der Implementierung der Methoden außerhalb der Klassendeklaration muss die Template-Deklaration (template <typename T>) wiederholt werden.

Klassen-Templates sind spezialisierbar (vollständig und partiell). Dazu wiederholt man die komplette Klassendefinition (inkl. der Template-Deklaration template <typename T>) und entfernt aus der Template-Deklaration alle Typen, die man konkret festlegen möchte. Hinter dem Klassennamen werden dann in spitzen Klammern alle Typen (verbleibende Typ-Parameter aus der Template-Deklaration sowie die konkretisierten Typen) in der Reihenfolge angegeben, wie sie im primären Template vorkamen. Spezialisierungen müssen nach dem eigentlichen ("primären") Klassen-Template formuliert werden.

Klassen- und Funktions-Templates können gemischt werden.

Bei der Instantiierung werden die Template-Parameter in spitzen Klammern hinter dem Klassennamen spezifiziert.

Template-Parameter können einen konkreten (aufzählbaren) Typ haben (beispielsweise int). Template-Parameter können Default-Werte haben.

Im Unterschied zu Java gibt es keine Type-Erasure. Der C++-Compiler stellt je instantiiertem Template eine konkrete Funktion bzw. Klasse bereit! Im resultierenden Code sind also nur diejenigen Funktionen und Klassen enthalten, die aus einem tatsächlichen Aufruf resultieren, das Template selbst ist nicht im Code enthalten. Dies gilt auch für Bibliotheken, weshalb sich diese beiden Konzepte etwas "quer liegen".

Lernziele
  • (K2) Unterschied zu Java bei der Nutzung von Templates
  • (K3) Erstellen und spezialisieren von Funktions-Templates
  • (K3) Unterschied zwischen überladenen Funktionen und Funktions-Templates
  • (K3) Aufruf (Nutzung) von Funktions-Templates
  • (K2) Unterschied zu Java bei der Nutzung von Templates
  • (K3) Erstellen und spezialisieren von Klassen-Templates
  • (K3) Nutzung von Methoden-Templates innerhalb von Klassen-Templates
  • (K3) Aufruf (Nutzung) von Klassen-Templates

Vergleichsfunktion für zwei Integer?

bool cmp(const int &a, const int &b) {
    return a<b;
}
  • Und für double?
  • Und für string?
  • ...

=> Präprozessor-Makro?

=> Funktionen überladen?

  • Überladen von Funktionen:
    • Ähnliche Funktionalität für unterschiedliche Datentypen
    • Mühselig, wenn exakt gleiche Funktionalität!
  • (bessere) Antwort: Funktions-Templates
    • Templates: Funktionen mit parametrisierten Datentypen
    • Deklaration/Definition für (zunächst) unbestimmte Datentypen
    • Bei Verwendung der Funktion:
    • Konkretisierung der Datentypen
    • Compiler erzeugt automatisch passende Funktionsinstanz

Definition von Funktions-Templates

template <typename T>
bool cmp(const T &a, const T &b) {
    return a<b;
}
  • Statt typename kann auch class geschrieben werden

  • Konvention:

    • typename wenn sowohl Klassen als auch Basistypen
    • class falls eher Klassen-Typen

    => class gilt als veraltet, deshalb immer typename verwenden!

  • Bei mehreren Typen "typename NAME" wiederholen (Komma-separierte Liste in < und >)

    beispielsweise so: (Achtung, soll nur die Verwendung demonstrieren, hat sonst keinen Sinn)

    template <typename T1, typename T2, typename T3>
    T1 cmp(const T2 &a, const T3 &b) {
        return a<b;
    }

Vorsicht: Im Beispiel oben muss operator< für die verwendeten Typen T implementiert sein! (sonst Fehler zur Compile-Zeit)

Bestimmung der Template-Parameter I: Typ-Inferenz

Das Funktions-Template wird wie eine normale Funktion aufgerufen ... => Der Compiler inferiert Typen und erzeugt passende Funktionsinstanz(en).

template <typename T>
bool cmp(const T &a, const T &b) {
    return a<b;
}

int main() {
    cmp(3, 10);                         // cmp(int, int)
    cmp(2.2, 10.1);                     // cmp(double, double)
    cmp(string("abc"), string("ABC"));  // cmp(string, string)
    cmp(3, 3.4);                        // Compiler-FEHLER!!!
}

Vorsicht bei Typ-Inferenz: Typen müssen exakt passen!

Bestimmung der Template-Parameter II: Explizite Angabe

template <typename T>
bool cmp(const T &a, const T &b) {
    return a<b;
}

int main() {
    cmp<int>('a', 'A');     // cmp(int, int)
    cmp<int>(3, 3.4);       // cmp(int, int)
}

Bei expliziter Angabe der Typen beim Aufruf (cmp<int>) kann der Compiler automatisch casten.

Typ-Inferenz und explizite Bestimmung mischen

  • Compiler nutzt die vorgegebenen Typ-Parameter, ...
  • ... inferiert die restlichen, und ...
  • ... castet notfalls die Parameter
template <typename T1, typename T2, typename T3>
void fkt(T2 a, T3 b, T2 c, int d) { ... }


int main() {
    fkt<void*, int>(42, "HUHU", 'a', 99);
}

=> In Parameterliste nicht vorkommende Typ-Parameter explizit angeben!

  • Reihenfolge der Angabe der Typen in spitzen Klammern beim Aufruf wie in Template-Deklaration
  • Wenn ein Typ-Parameter nicht in der Parameterliste der Funktion vorkommt, ist eine Inferenz für den Compiler unmöglich. Deshalb muss dieser Typ beim Aufruf explizit in der Liste mit den spitzen Klammern angegeben werden!
  • Im Beispiel oben:
    • fkt<a, b, c>(...): a wäre der Typ für T1, b für T2, c für T3

    • Mit fkt<..., int>(...) beim Aufruf wird T2 zu int und damit für Parameter c der Char als int interpretiert (T3 wird inferiert)

      Ohne <..., int> beim Aufruf gibt es ein Problem beim Erkennen von T2: int vs. char (a=42, c='a') ...

Typ-Inferenz funktioniert nicht immer!

template <typename T>
T zero() {
    return 0;
}

int main() {
    int x, y;
    x = zero(); // Fehler: couldn't deduce template parameter 'T'
    y = zero<int>(); // korrekter Aufruf
}

Die Funktion hat keine Parameter - der Compiler hat also keine Chance, den Typ T zu inferieren. In diesem Fall muss der Typ beim Aufruf explizit angegeben werden.

Spezialisierung von Funktions-Templates

// Primaeres Template
template <typename T>
bool cmp(const T &a, const T &b) {
    return a<b;
}
// Spezialisiertes Template
template <>
bool cmp<int>(const int &a, const int &b) {
    return abs(a)<abs(b);
}

Spezialisierte Templates nach "primärem" Template definieren

Achtung: Reihenfolge der Deklaration/Definition ist wichtig. Immer zuerst das allgemeine ("primäre") Template definieren, danach dann die Spezialisierungen! Anderenfalls gibt es "seltsame" Fehlermeldungen vom Compiler oder sogar seltsames Verhalten.

Achtung: Im Unterschied zu Klassen-Templates können Funktions-Templates nur vollständig spezialisiert werden (d.h. bei mehreren Template-Parametern müssen dann alle Template-Parameter konkret spezifiziert werden)!

Anmerkung: Die Angabe der Typen in spitzen Klammern nach dem Funktionsnamen ist freiwillig, so lange alle Typ-Parameter in der Parameterliste der Funktion auftauchen. Man könnte die obige Spezialisierung also auch so schreiben (cmp( statt cmp<int>():

// Spezialisiertes Template
template <>
bool cmp(const int &a, const int &b) {
    return abs(a)<abs(b);
}

Alternativ: Überladen der Funktions-Templates mit normalen Funktionen

Überladen mit normalen Funktionen funktioniert wie bei spezialisierten Templates, d.h. auch hier zuerst das primäre Template definieren, danach eventuelle Spezialisierungen und danach Überladungen mit normalen Funktionen.

Allerdings gibt es Unterschiede für eventuell nötige Typumwandlungen der Parameter beim Aufruf der Funktionen:

  • In gewöhnlichen Funktionen sind automatische Typumwandlungen möglich
  • In (spezialisierten) Templates sind keine automatischen Typumwandlungen erlaubt (sofern man mit Typ-Inferenz arbeitet, d.h. die Template-Typen nicht beim Aufruf explizit angegeben werden)
template <typename T>
bool cmp(T a, T b) {
    return a<b;
}

bool cmp(int a, int b) {
    return abs(a)<abs(b);
}

int main() {
    cmp(3, 6);          // true:  überladene normale Funktion
    cmp(3, 3.4);        // FALSE: überladene normale Funktion (Cast)
    cmp<int>(3, 3.4);   // FALSE: Template
}

Aufruf: Compiler nimmt die am besten "passende" Variante:

  • Keine Template-Parameter beim Aufruf angegeben (d.h. Typ-Inferenz):
    1. Zuerst exakt passende normale Funktion,
    2. dann passendes spezialisiertes Template (bei mehreren passenden spezialisierten Templates das am meisten spezialisierte Template, ohne Casts),
    3. dann das allgemeine ("primäre") Template (ohne Casts),
    4. ansonsten normale Funktion mit impliziten Casts
  • Template-Parameter beim Aufruf angegeben: am besten passendes Template

Hinweis: Durch reine Deklaration von Spezialisierungen (d.h. ohne die entsprechende Implementierung) lässt sich die Instantiierung einer Templatefunktion für bestimmte Typen verhindern. Beispiel:

template <typename T>
bool cmp(const T &a, const T &b) {
    return a<b;
}

template <>
bool cmp<int>(const int &a, const int &b);

Damit könnte man die cmp-Funktion nicht mehr für int benutzen (Compiler- bzw. Linker-Fehler).

Klassen-Templates in C++

template <typename T>
class Matrix {
    Matrix(unsigned rows = 1, unsigned cols = 1);
    vector<vector<T> > xyField;
};

Hinweis: Template-Parameter innerhalb von Template-Parametern verursachen bei den schließenden spitzen Klammern u.U. Parser-Probleme. Diese lassen sich durch ein extra Leerzeichen (hat sonst keine Funktion!) umgehen: Statt vector<vector<T>> xyField; besser vector<vector<T> > xyField; schreiben.

int main() {
    Matrix<int> m1;
    Matrix<double> m2(12, 3);
}

Template-Parameter gehören zur Schnittstelle und müssen bei der Instantiierung angegeben werden. Matrix m; würde im obigen Beispiel nicht funktionieren.

template <typename T>
Matrix<T>::Matrix(unsigned rows, unsigned cols) { ... }

Klassen-Templates in C++ (Variante mit Konstanten)

template <typename T, unsigned rows, unsigned cols>
class Matrix {
    Matrix();
    vector<vector<T> > xyField;
};

Template-Parameter können neben Typen auch konstante Werte (Basisdatentypen, außer float und double) sein. Innerhalb der Klasse Matrix kann auf die Werte von rows und cols zugegriffen werden.

Achtung: rows und cols sind keine Attribute der Klasse Matrix!

Hinweis: Konstanten als Template-Parameter funktioniert auch bei Funktions-Templates.

int main() {
    Matrix<int, 1, 1> m1;
    Matrix<double, 12, 3> m2;
    Matrix<string, 1, 1> m3;
}

Beispiel: Konstanten als Template-Parameter

template <int I>
void print() {
    cout << I;
}

print<5>();
template <unsigned u>
struct MyClass {
    enum { X = u };
};

cout << MyClass<2>::X << endl;
  • Konstante muss explizit übergeben werden
  • Wert muss eine zur Compile-Zeit bekannte Konstante sein
  • Nur aufzählbare Typen für derartige Konstanten erlaubt (z.B. int, aber nicht double)

Anmerkung: Durch Konstruktion mit dem anonymen enum in der Klasse MyClass wird der Wert der Konstanten "gespeichert" und kann später (von außen) abgefragt werden. (Innerhalb der Klasse selbst können Sie natürlich jederzeit auf den Wert von u zugreifen.)

Wollte man dies über ein normales Attribut erledigen, sähe der Code deutlich komplexer aus (zusätzlich zur oben gezeigten Variante mit dem enum einmal als statisches Attribut Y und einmal als "normales" Attribut Z):

template <unsigned u>
struct MyClass {
    enum { X = u };
    static unsigned Y;
    unsigned Z;

    MyClass() : Z(u) {}
};

template <unsigned u>
int MyClass<u>::Y = u;

int main() {
    cout << MyClass<2>::X << endl;
    cout << MyClass<2>::Y << endl;
    cout << MyClass<2>().Z << endl;
}

Falls man mit :: zugreifen wollte, müsste das Attribut static sein und entsprechend außerhalb der Klasse initialisiert werden. Für ein "normales" Attribut braucht man dann einen extra Konstruktor und muss den Aufruf dann extra klammern: MyClass<2>().Z statt MyClass<2>.Z.

Die Variante mit dem enum werden Sie entsprechend sehr häufig in C++ finden!

Klassen-Templates mit Defaults

template <typename T = int, unsigned rows = 1, unsigned cols = 1>
class Matrix {
    Matrix();
    vector<vector<T> > xyField;
};
int main() {
    Matrix<> m1;    // Leere spitze Klammern Pflicht!
    Matrix<double, 12, 3> m2;
    Matrix<string> m3;
}

Leere spitze Klammern bei Klassen-Templates mit Default-Parameter Pflicht!

Hinweis: Defaults für Template-Parameter waren zunächst nur für Klassen-Templates erlaubt. Seit C++11 kann man solche Defaults auch bei Funktions-Templates einsetzen.

Klassen-Templates in C++ spezialisieren

template <typename T>
class Matrix {
    Matrix(unsigned rows, unsigned cols);
    vector< vector<T> > xyField;
};
template <>
class Matrix<uint> {
    Matrix(unsigned rows, unsigned cols);
    vector< vector<uint> > xyField;
};
Hinweis auf Implementierung außerhalb der Klasse

ACHTUNG: Implementierung außerhalb der Klasse: Bei den Methoden des voll spezialisierten Templates das "template<>" weglassen! Alles andere ist ein Syntax-Fehler.

Der Grund dafür bleibt ein Geheimnis des C++-Standards ... ;-)

// Implementierung fuer primaeres Template
template <typename T>
Matrix<T>::Matrix(unsigned rows, unsigned cols) { ... }

// Implementierung fuer vollstaendig spezialisiertes Template
Matrix<uint>::Matrix(unsigned rows, unsigned cols) { ... }

Partielle Spezialisierung

template <typename T1, typename T2>
class Array {
    Array();
    vector<T1> v;
    vector<T2> w;
};
template <typename T>
class Array<T, int> {
    Array();
    vector<T> v;
    vector<int> w;
};

ACHTUNG: Implementierung außerhalb der Klasse: Bei den Methoden des partiell spezialisierten Templates muss das "template<T>" wieder benutzt werden!

// Implementierung fuer primaeres Template
template <typename T1, typename T2>
Array<T1, T2>::Array() {}

// Implementierung fuer partiell spezialisiertes Template
template <typename T>
Array<T, int>::Array() {}

Vergleich verschiedene Spezialisierungen

Allgemeine Templates vs. partiell spezialisierte Templates vs. vollständig spezialisierte Templates

// Primaeres (allgemeines) Template
template <unsigned line, unsigned column>
class Matrix {
public:
    Matrix();
};

// Partiell spezialisiertes Template
template <unsigned line>
class Matrix<line, 1> {
public:
    Matrix();
};

// Vollstaendig spezialisiertes Template
template <>
class Matrix<3, 3> {
public:
    Matrix();
};


// Aufrufe
int main() {
    Matrix<3, 4> matrix;        // allg. Template
    Matrix<20, 1> vector;       // partiell spez. Templ.
    Matrix<3, 3> dreiKreuzDrei; // vollst. spez. Templ.
}


// Implementierung
template <unsigned line, unsigned column>
Matrix<line, column>::Matrix() { cout << "allgemeines Template" << endl; }

template <unsigned line>
Matrix<line, 1>::Matrix() { cout << "partiell spezialisiertes Template" << endl; }

Matrix<3, 3>::Matrix() { cout << "vollstaendig spezialisiertes Template" << endl; }

Regel: Das am meisten spezialisierte Template wird verwendet.

Mischen von Klassen- und Funktions-Templates

Sie können innerhalb eines Klassen-Templates auch ein Funktions-Template (Methoden-Template) definieren. Bei der Implementierung außerhalb der Klasse müssen entsprechend alle Template-Deklarationen wiederholt werden!

template <typename T, unsigned n>
class Array {
public:
    enum { size = n };
    template <typename C >
    void copy_from(const C &c);
private:
    T data[n];
};


template <typename T, unsigned n>
template <typename C>
void Array<T,n>::copy_from(const C &c) { ... }

Templates: Java vs. C++

  • Templates sind nur Schablonen!

    Die Definitionen der Templates werden nicht in den Object-Code kompiliert! Erst bei der Instantiierung von Templates wird durch den Compiler eine passende Instanz erzeugt und im Object-Code zur Nutzung abgelegt.

  • Unterschied zu Java

    • C++: Für jeden Aufruf/Typ eine passende Instanz (!)
    • Java: Nur eine Klasse mit gemeinsamen Obertyp
  • Offener Code: Templates im .h-File implementieren!

    Ohne die Template-Definition kann der Compiler keine Instanzen anlegen!

  • Bibliotheken und Templates passen nicht recht

    Templates werden immer bei der ersten Verwendung instantiiert! Wird ein Template nicht im zu kompilierenden Code verwendet, dann generiert der Compiler auch nichts, was man in den Objektdateien finden könnte ...

    • Nur instantiierte Templates sind in einer dynamischen/statischen Bibliothek enthalten!

    • Falls Einsatz nur für wenige Typen vorgesehen => Explizite Instantiierung:

      • Entweder mit "template": template class Matrix<5,5>;, oder

      • mit "typedef": typedef Matrix<5,5> Matrix5x5;

        => Dann aber nur in exakt diesen Versionen in der Bibliothek enthalten und entsprechend nur so nutzbar (sofern die Template-Definition nicht zur Verfügung steht)

Wrap-Up

  • Generische Programmierung (Funktions-Templates)

    • template <typename T> der Funktionsdefinition voranstellen
    • Funktions-Templates sind spezialisierbar und überladbar
    • Aufruf: Compiler nimmt die am besten "passende" Variante
  • Generische Programmierung (Klassen-Templates)

    • Funktionsweise analog zu Funktions-Templates
    • Bei Implementierung außerhalb der Deklaration: Template-Deklaration mitführen!
    • Klassen-Templates lassen sich partiell spezialisieren
  • Compiler stellt je instantiiertes Template eine konkrete Funktion/Klasse bereit

Challenges

Funktions-Templates

  • Wie kann eine Funktion als Funktions-Template definiert werden?
  • Wie wird ein Funktions-Template benutzt, d.h. wie wird es aufgerufen? Worin liegt der Unterschied zu Java?
  • Wie kann ein Funktions-Template spezialisiert werden? Was ist dabei zu beachten?
  • Kann man Funktions-Templates überladen?
  • Worin liegt der Unterschied zwischen einem spezialisierten Funktions-Template und einem überladenen Funktions-Template?

Funktions-Templates in C++

  1. Schreiben Sie in C++ eine Funktion invert(), die zu einem übergebenen numerischen Wert den negativen Wert zurückgibt. Realisieren Sie die Funktion als Funktions-Template.

    Testen Sie Ihre Implementierung mit den Typen int, double und short.

  2. Spezialisieren Sie das eben erstellte Funktions-Template, so daß invert() auch für string aufgerufen werden kann. In diesem Fall soll der String umgekehrt werden und zurückgeliefert werden, d.h. für die Eingabe von "abcde" soll "edcba" zurück geliefert werden.

    Testen Sie die Funktionen wiederum mit int, double, short und nun auch string.

  3. Schreiben Sie ein weiteres Funktions-Template string getType(T t) mit Template-Spezialisierungen, die den Typ des Parameters t als String zurückgibt. Für nicht bekannte Typen soll der String "unbekannter Typ" zurückgeliefert werden.

    Implementieren Sie mind. 3 Spezialisierungen.

    Hinweis: Verwenden Sie an dieser Stelle keine explizite Typüberprüfung (in der Funktion)! Realisieren Sie die Reaktion auf unterschiedliche Parametertypen ausschließlich mit Hilfe von Templates.

  4. Erklären Sie folgenden Code-Schnipsel:

        // Definition
        template <typename T2, typename T1>
        void f(T1 a, T2 b, int c) {
            ...
        }
    
        // Aufruf
        f<char *>(99, "Hello World", 42);
  5. Erklären Sie nun folgenden Code-Schnipsel:

    template<typename T2, typename T1, typename T3>
    void fkt(T2 a, T3 b, T2 c, int d) { ... }
    void fkt(int a, int b, int c, int d) { ... }
    
    int main() {
        fkt<int, void*>(42, "HUHU", 'a', 99);
        fkt<int, int, int>(1,2,3,4);
        fkt(1,2,3,4);
    }

Klassen-Templates und partielle Spezialisierung

Definieren Sie ein Klassen-Template zum Umgang mit Vektoren. Diese sollen Elemente eines unbestimmten Typs (Typ-Variable) aufnehmen. Die Größe des Vektors soll ebenfalls als Typ-Variable in die Template-Definition eingehen. Definieren Sie den operator* für das Skalarprodukt von zwei Vektoren.

Erstellen Sie eine partielle Spezialisierung des Klassen-Templates zur Repräsentation von einstelligen Vektoren (Skalare).

Schreiben Sie ein main()-Funktion, instantiieren einige Vektoren und berechnen Sie die Skalarprodukte.

Beispiel aus dem echten Leben

Erklären Sie das folgende Beispiel eines Klassen-Templates RingBuffer:

template <typename T, size_t size, typename alloc_t = std::allocator<T>>
class RingBuffer {
public:
    typedef AllocatorType alloc_t;

    RingBuffer(const alloc_t &rb_allocator = alloc_t());
    ~RingBuffer();
    void writeBuffer(T *data);

private:
    alloc_t m_allocator;
    size_t count;
    size_t head;
    T **elems;
};
Quellen