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.
Unterschiedliche Programmiersprachen weisen nicht nur verschiedene Syntaxelemente auf, sondern haben eine teilweise stark unterschiedliche Semantik. Beides hat Auswirkungen auf die Bausteine eines Compilers.
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.
>>
und <<
::
, Namensräumesizeof
zur Bestimmung des Speicherbedarfstypedef
zur Definition neuer Typen (Aliase bestehender Typen)Sie werden C++ im Modul "Computergrafik" brauchen!
Geschichte
/*
* 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;
}
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
).
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 StandardverzeichnisDas #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 <stdio.h>
#include <cstdio>
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.
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) { ... }
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)
cout
ist ein Ausgabestrom, auf dem der Operator <<
schreibt#include <iostream>
>>
, <<
) für Basistypen und Standardklassen vorhanden// 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);
Wie in Java:
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 {
...
}
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
#include <vector>
vector<int> v(10);
vector<double> meinVektor = {1.1, 2.2, 3.3, 4.4};
meinVektor.push_back(5.5);
cout << meinVektor.size() << endl;
cout << v[0] << endl; // ohne Bereichspruefung!
cout << v.at(1000) << endl; // mit interner Bereichspruefung
vector<double> andererVektor;
andererVektor = meinVektor;
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!
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.
C/C++ sind enge Verwandte: kompilierte Sprachen, C++ fügt OO hinzu
Funktionsweise einfachster Make-Files
Wichtigste Unterschiede zu Java
sizeof
zur Bestimmung des Speicherbedarfstypedef
definierbarWie 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;
}
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.
new
und delete
, Unterschied zu malloc()
, free()
+-----------------------------------------+
| Text | 0x0000
| | |
|-----------------------------------------| |
| Heap (Data) | |
| | |
|--------------------+--------------------| |
| | | |
| v | |
| | |
| | v
| ^ |
| | |
|--------------------+--------------------|
| |
| Stack |
+-----------------------------------------+
zusätzlich (nicht in Abbildung dargestellt):
malloc()
/calloc()
/free()
(C)new
/delete
(C++)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 | ---+
+----------+
| |
Im Beispiel:
i
:
iptr
:
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
.
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 mit *
:
int i = 99;
int *iptr;
iptr = &i;
*iptr = 2; // Zugriff auf verwiesene Speicherzelle i
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;
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.
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!!! */
}
Pointer werden vom Compiler nicht initialisiert!
Explizite Null-Pointer:
NULL
aus stdio.h
bzw. cstdio
bzw. in C++ nullptr
C: Funktionen zur Verwaltung dynamischen Speichers: malloc()
,
free()
, ... (in <stdlib.h>
)
void* malloc(size_t size)
size
Bytes auf dem Heap und liefert Adresse zurückvoid
, da Typ unbekannt - vor Nutzung auf korrekten Typ umcastenNULL
int *p = (int*) malloc(sizeof(int));
int *pa = (int*) malloc(4*sizeof(int));
free(p);
free(pa);
C++: Operatoren: new
und delete
[]
-Operator für Arraysnew
allozierter Speicher muss mit delete
freigegeben werdennew []
allozierter Speicher muss mit delete []
freigegeben werdenint *p = new int;
int *pa = new int[4];
delete p;
delete [] pa;
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!
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 */
delete
darf nur auf mit new
erzeugte Objekte angewendet werden
malloc()
/calloc()
/free()
!int *p = (int *) malloc(sizeof(int));
delete p; // FEHLER! Absturzgefahr
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!
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 */
}
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.
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! */
free()
darf nur einmal pro Objekt aufgerufen werden
free()
ist der Zeiger undefiniert:
NULL
oder 0free()
reicht!
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
Der klassische Scanf-Bug :)
int i;
scanf("%d", i);
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;
}
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));
}
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));
}
Einlesen von Strings, zu kleine Buffer
char s[8];
gets(s);
Pointerarithmetik falsch verstanden
int *search(int *p, int val) {
while (*p && *p != val)
p += sizeof(int);
return p;
}
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 */
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 können NICHT umgebogen werden!
int a[], *pa=a, k;
/* erlaubt */
a + k;
pa++;
/* VERBOTEN */
a++;
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!)
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
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;
int i = 99;
int *iptr = &i;
int &iref = i; // Referenz: neuer Name fuer i
Variable Speicheraddresse Inhalt
| |
+----------+
i, iref 10125 | 99 | <--+
+----------+ |
| | |
.... .... |
| | |
+----------+ |
iptr 27890 | 10125 | ---+
+----------+
| |
&
-Operators deklarierenReferenzen 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
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.
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);
}
i
)i
, da
Adresse von i
)i
)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);
}
x
ist eine Referenzx
bindet sich
im Beispiel an die Variable i
x
in der Funktion sind also Zugriffe auf das Original-Objekt i
- x += 5
ist nichts anderes als i += 5
x
dann neu gebundenNachteil 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!
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.
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.
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 |
Virtueller Speicher: Kernel stellt Prozessen linearen Adressraum bereit, Segmente: Text, Stack, Heap
Pointer sind Variablen, deren Wert als Adresse interpretiert wird
*
zwischen Typ und Name&
liefert die Adresse eines Objekts*
vor dem NamenVerwandtschaft 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
Typ &ref = obj;
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.
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;
}
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;
}
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
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.)
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;
};
private
)this
ist keine Referenz, sondern ein Pointer auf das eigene Objektclass 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.
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?
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!)
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)
// .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
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:
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.
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:
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.
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.
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:
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);
NULL
nicht möglichnew
nötig (würde Objekt auf Heap anlegen und Pointer liefern)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
Dummy b; b=3;
)?static
Attribute initialisiert werden?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.
Neben dem eigentlichen Konstruktor existieren in C++ weitere wichtige Konstruktoren/Operatoren: die sogenannten "Big Three":
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;
}
Dummy::~Dummy();
(Konstruktor mit vorgesetzter Tilde)delete
für einen Pointer auf ein Objekt (auf dem Heap!) aufgerufen wirdDummy::Dummy(const Dummy &);
"Merkregel": Linke Seite unfertiges Objekt (noch zu bauen), rechte Seite fertiges Objekt.
Dummy &Dummy::operator=(const Dummy &)
"Merkregel": Linke Seite fertiges Objekt, rechte Seite fertiges Objekt.
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:
a
wird auf dem Heap Speicher für einen int
reserviert und
dort der Wert 2
hineingeschrieben.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
.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
.Dummy a(0); Dummy b(1); Dummy c(2); Dummy d(3);
a = b = c = d; // entspricht: a.operator=(b.operator=(c.operator=(d)));
Erinnerung:
this
ist ein Pointer auf das eigene Objektdelete
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:
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!
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)!
Auch wenn es zunächst irgendwie sinnvoll aussieht - rufen Sie niemals nie delete this
im Destruktor auf!
class Dummy {
public:
Dummy() = default;
Dummy(int a) { value = a; }
Dummy(const Dummy &a) = delete;
private:
int value;
Dummy &operator=(const Dummy &d);
};
new
delete
private
setzen (Definition nicht nötig)delete
: Entfernt Default-Methode/-Operatordefault
class Studi {
static int getCount();
static int count;
};
int Studi::count = 0;
int Studi::getCount() {
return Studi::count;
}
static
nicht in Implementierung wiederholenclass 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.
static
nicht in Implementierung wiederholenconst
gehört zur Signatur der Methode!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
delete this
keine gute Idee (nicht nur im Destruktor)?!Die "Großen Drei"
Beschreiben Sie den Unterschied der folgenden beiden Codeblöcke (A
sei
eine beliebige Klasse):
A a, b = a;
A a, b; b = a;
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.
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.
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;
}
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.
friend
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
MyString a("hallo");
cout << a << endl;
class MyString {
ostream &operator<<(ostream &o) { return o << str; }
};
So funktioniert das leider nicht!
cout << a
entspricht cout.operator<<(a)
MyString
überladen werden!ostream
müsste erweitert werden
=> Geht aber nicht, da System-weite Klasse!=> Lösung: Operator außerhalb der Klasse überladen => 2 Parameter
Operator außerhalb der Klasse überladen => 2 Parameter
ostream &operator<<(ostream &out, const MyString &s) {
return out << s.str;
}
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;
void test();
class TestDummy {
int ganzTolleMethode();
};
class Dummy {
private:
int *value;
friend class TestDummy;
friend int TestDummy::ganzTolleMethode();
friend void test();
};
Alle normalen arithmetischen Operatoren
Zuweisung, Vergleich, Ein-/Ausgabe
Index-Operator []
, Pointer-Dereferenzierung *
und
->
, sowie ()
, new
und delete
(auch in []
-Form)
Ausnahmen:
Anmerkungen:
Vgl. Tabelle 9.1 (S. 318) im [Breymann2011]
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!
Präfix: o1 = ++o2;
Typ &operator++()
Postfix: o1 = o2++;
Typ operator++(int)
(=> int
dient nur zur Unterscheidung der Präfix-Variante, wird nie benutzt)Operatoren werden nicht vom System zusammengesetzt
operator+
und operator+=
sind zwei verschiedene Operatoren!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!
friend
einer KlasseOperator "++"
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);
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.
public
- und private
-Vererbungpublic
-Vererbung in C++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 KlassePerson
: Basisklasse: public
: Vererbungsbeziehung (analog zu extends
in Java)public
-Vererbung: Verhalten wie in Javaprotected
, private
), vgl. Semesterliteraturoverride
:
Die Methode muss eine virtuelle Methode der Klassenhierarchie überschreiben.final
:
Die virtuelle Methode darf nicht in abgeleiteten Klassen überschrieben werden.super
wie in JavaZuerst 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;
}
friends
der Basisklasse haben keinen Zugriff auf zusätzliche
private Attribute/Methoden der Unterklassen=0
" folgtAbstrakte Methoden können Implementierung haben! => Implementierung außerhalb der Klassendeklaration
class Person {
public:
virtual string toString() const = 0;
...
};
string Person::toString() const { ... } // Implementierung :-)
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 erwartetp.toString()
=> Person::toString()
=> unerwartet!C++ entscheidet zur Kompilierzeit, welche Methode aufgerufen wird
p
ist vom Typ Person
=> p.toString()
=> Person::toString()
Von Java her bekannt: dynamisches Binden
Für dynamische Polymorphie müssen in C++ drei Bedingungen erfüllt sein:
Methoden in Basisklasse als virtuelle Funktion deklarieren
=> Schlüsselwort virtual
Virtuelle Methoden in Subklasse normal überschreiben (gleiche Signatur)
Zusätzlich muss der Rückgabetyp exakt übereinstimmen (Ausnahme: Rückgabe Pointer/Referenz auf abgeleitete Klasse)
Objekte mittels Basisklassen-Referenzen bzw. -Pointer zugreifen (siehe nächste Folie)
class Person {
virtual string toString() const { ... }
};
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
Student
ist erlaubt (Polymorphie)p
hat aber nur Speicherplatz für genau eine Person
=> "Abschneiden" aller Elemente, die nicht Bestandteil von
Person
sind!=> 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++.
class HiWi: public Student, public Angestellter {...};
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;
class Angestellter: virtual public Person {...};
class Student: virtual public Person {...};
class HiWi: public Student, public Angestellter {...};
Person
ist jetzt eine virtuelle BasisklasseHiWi
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
Virtuelle Ableitung: Potentiell Konflikte zwischen Konstruktoren!
Deshalb gibt es bei virtueller Ableitung folgende Sonderregeln:
Für virtuelle Basisklassen ist Mechanismus des Weiterreichens von Initialisierungswerten deaktiviert
Konstruktor einer virtuellen Basisklasse kann in Initialisierungsliste von indirekten Unterklassen aufgerufen werden
Sonst wird der Defaultkonstruktor der virtuellen Basisklasse genutzt!
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.
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++
virtual
deklarierenKonzept der Mehrfachvererbung
Problem bei rautenförmiger Vererbungsbeziehung: Attribute und Methoden mehrfach vorhanden
Virtuelle Basisklassen: Gemeinsam genutzte Attribute nur noch einfach vorhanden
Destruktoren und Vererbung
Welcher Destruktor würde im folgenden Beispiel aufgerufen?!
Student *s3 = new Student("Holger", 1.0);
Person *p = s3;
delete p;
Vererbung
public
-Form noch? Was bewirken diese Formen?public
-Form der Vererbung vorgezogen
(zumindest, wenn man dynamische Polymorphie nutzen will)?Virtuelle Methoden, Dynamische Polymorphie in C++
Hinweis: Möglicherweise müssen jeweils mehrere Fälle betrachtet werden!
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:
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".
bool cmp(const int &a, const int &b) {
return a<b;
}
double
?string
?=> Präprozessor-Makro?
=> Funktionen überladen?
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 Basistypenclass
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)
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!
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.
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!
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'
) ...
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.
// 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);
}
Ü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:
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
}
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).
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) { ... }
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;
}
template <int I>
void print() {
cout << I;
}
print<5>();
template <unsigned u>
struct MyClass {
enum { X = u };
};
cout << MyClass<2>::X << endl;
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!
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.
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;
};
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) { ... }
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() {}
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.
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 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
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)
Generische Programmierung (Funktions-Templates)
template <typename T>
der Funktionsdefinition voranstellenGenerische Programmierung (Klassen-Templates)
Compiler stellt je instantiiertes Template eine konkrete Funktion/Klasse bereit
Funktions-Templates
Funktions-Templates in C++
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
.
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
.
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.
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);
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;
};