Aufgabe 0: C++ Streams
Bei Programmiersprachen haben viele Informatiker Präferenzen, welche manchmal gar mit einer Art religiösem Eifer verteidigt werden. Und das gilt natürlich auch für die Betriebssystementwicklung, wenn auch hier die Auswahl der Sprachen etwas eingeschränkt ist. 2004 kam auf der LKML, der Linux Kernel Mailing List beispielsweise diese Mail, in der Linus Torvalds auf eine eben solche Diskussion antwortet, im konkreten Fall mit dem Vorschlag C++ für Teile des Kernels zu verwenden. Im berüchtigten Mailinglistenton verweist er dabei auf die hohe Komplexität dieser Programmiersprache sowie deren Übersetzer und sieht drei Gründe, warum man einen Betriebssystemkern überhaupt in C++ entwickeln würde:
- Man sucht Probleme
- Man ist C++ Jünger und erkennt nicht, dass man eh nur ein C-ähnliches Subset von C++ verwendet
- Die Hausaufgaben verlangen es.
Nun, machen wir es kurz: Auf euch trifft Punkt 3 zu, wir lassen euch schlicht keine andere Wahl.
Nachdem das jetzt geklärt ist, schauen wir uns diese Sprache mal genauer an. Ein Ziel bei der Entwicklung von C++ war es, zum klassischen C möglichst kompatibel zu bleiben, um das Programmieren den Umstieg zu erleichtern, soweit dies das neue Sprachkonzept erlaubt. Eine Ausnahme ist beispielsweise, wenn man Schlüsselworte verwendet, die es in C nicht gibt; eine Variable mit dem Namen class
ist in C valide, in C++ führt er aber zu einem Übersetzerfehler. Wenn man von solchen Sonderfällen absieht, ist einfaches ANSI C, wie ihr es aus dem Grundstudium kennt, in den meisten Fällen auch gleichzeitig valides C++.
Das gilt auch für dieses Beispielprogramm, welches lediglich "Informatik 4" ausgibt, und auch sowohl von einem C als auch einem C++ Übersetzer ohne Probleme akzeptiert wird. Allerdings würde ein Vollblut C++ Programmierer dies ein wenig anders schreiben, so würde er den Header stdio.h
durch cstdio
austauschen: Beide sind dem Übersetzer bekannt, aber cstdio
ist eine auf C++ angepasste Variante des C-Headers stdio.h
. Außerdem bietet C++ ein neues Konzept zur Aus- und Eingabe: Stream-Operatoren. Diese sind im Header iostream
definiert. Im Gegensatz zur fehleranfälligen printf
-Familie mit Formatstrings und einer variablen Anzahl an Parametern wird hier eine Verkettung von Aufrufen an den "shift"-Operator des cout
-Objekts durchgeführt.
Zudem sorgen Namensräume für Ordnung, alle Funktionen aus der C++ Standard Library sind im Namensraum std
definiert, was bei cout
und endl
mit angegeben werden muss. Da Stringverarbeitung mittels char
-Arrays in C eher kompliziert zu nutzen ist und regelmäßig zu Pufferüberläufen und damit Sicherheitslücken führt, gibt es in C++ einen dedizierten string
-Datentyp mit internem Längenfeld, welcher alle häufig benötigte Operationen bereitstellt.
Es wirkt bereits etwas umständlich, bei jeder Funktion und Objekt explizit den Namensraum durch das entsprechenden Prefix std
anzugeben. Alternativ kann mittels using namespace
der Suchpfad für Symbole ohne Namensraumprefix bestimmt werden. Dabei sind Namensräume eine hilfreiche Möglichkeit Funktionen zu gruppieren und hierarchisch zu Verwalten. Anders als in C, wo alle Symbole in einem flachen Namensraum organisiert werden, ist es in C++ möglich, Symbole gleichen Namens in verschiedenen Namensräumen zu haben – ähnlich wie Dateien mit gleichem Namen in unterschiedlichen Ordnern. Hier werden zwei Funktionen mit dem Namen getNum
definiert; Um die Funktionen aufrufen zu können muss der Namensraum explizit mit angegeben werden.
Ein weiteres Konzept, das C fehlt, sind Referenzen. Sie bieten die Möglichkeit, ein alias auf eine bestehende Variable zu erzeugen, ohne dazu einen Zeiger verwenden zu müssen. So ist die Variable bar
eine Referenz auf die Variable foo
, das heißt foo
und bar
zeigen beide auf denselben Speicherbereich. Allerdings sind sowohl foo
, als auch bar
, keine Pointer, insbesondere gibt es im Kontext von Referenzen damit den aus C bekannten NULL
-Pointer und dessen Probleme nicht. Referenzen bringen aber auch Einschränkungen mit sich: Anders als Pointer können Referenzen nur einmalig bei ihrer Definition initialisiert werden, es ist nicht möglich den referenzierten Speicher nachträglich zu ändern. Trotz dieser vermeintlichen Einschränkungen vereinfachen Referenzen das Leben deutlich – häufig sieht man konstante Referenzen als Funktionsparameter anstelle von Zeigern auf das entsprechende Objekt.
Hier im Beispiel eine Funktion, die zwei Objekte als Parameter bekommt: Eine nicht-konstante Referenz auf ein ostream
-Objekt, dessen interner Zustand bei der Ausgabe potenziell verändert wird. Der zweite Parameter wird nur lesend genutzt, daher ist er als konstante Referenz deklariert. Die Übergabe von Referenzen hat den Vorteil, dass man weniger mit Zeigern arbeiten muss und der Parameter dementsprechend auch nicht NULL
sein kann – man braucht somit auch keine Überprüfung auf NULL
. Gleichzeitig spart man sich bei der Übergabe von Referenzen den Kopieraufwand, was insbesondere bei großen Objekten relevant ist. Nicht-konstante Parameter kann man aber auch als "Ausgabeparameter" nutzen. Hier im Beispiel definieren wir uns eine Funktion inc
, die den übergebenen Parameter um eins erhöht, das heißt die als Parameter übergebene Variable aus der aufrufenden Funktion verändert. Aber Achtung: Ausgabeparameter sollten sparsam und gezielt eingesetzt werden, da man dem Funktionsaufruf im Allgemeinen nicht ansieht, dass die übergebenen Variablen verändert werden – was wiederum die Gefahr birgt, dass der Code bei übermäßiger Nutzung schwer verständlich wird.
C++ bietet auch die Möglichkeit, Parameter mit Standardwerten zu versehen, sollte sie beim Funktionsaufruf weggelassen werden. Die Funktion inc
erhöht hier nun die übergebene Variable um 1, falls kein zweiter Parameter angegeben wird. Wird er jedoch angegeben, so wird die Variable um den entsprechenden Wert erhöht. Und C++ erlaubt es Funktionen zu überladen, das heißt Funktionen gleichen Namens aber mit unterschiedlichen Parametertypen zu definieren, wie hier die Funktion isZero
. Beide Varianten erwarten einen Parameter, einmal allerdings von einem Ganzzahlen-Typ und einmal als Fließkommazahl. Je nach Datentyp des Parameters an der Aufrufstelle wird dann die entsprechend "passendste" Variante aufgerufen. C++ ist intern etwas komplex was die Auflösung der Funktionsüberladung angeht – Überladung sollte mit Augenmaß einsetzt werden, insbesondere wenn deren Auflösung nicht offensichtlich ist, sollte sie besser vermieden werden.
Strukturen existieren auch in C++ – allerdings mit deutlich erweiterter Funktionalität. Wie aus C bekannt kann man auch hier eine Struktur Disp definieren, mit einem Zeichen und zwei Ganzzahlen. In C++ sind Strukturen allerdings deutlich mächtiger, hier offenbart sich die objektorientierte Ausrichtung. Auch das Anlegen, Initialisieren und Verwenden von Strukturen ähnelt C – lediglich die Initialisierung mittels Bezeichner ist nicht erlaubt.
Dafür bietet C++ aber Konstruktoren, also Methoden die das neu erzeugte Objekt initialisieren. Sie haben keinen Rückgabetyp und tragen den Namen der Struktur – Anzahl und Typ der Parameter sind aber frei wählbar. Konstruktoren ohne Parameter werden dabei "default constructor" genannt. Um auf Instanzvariablen zuzugreifen, kann explizit der this
-Zeiger verwenden werden. Dies ist insbesondere dann notwendig, wenn es im Scope bereits eine andere Variable mit dem gleichen Namen gibt. Sofern es aber eindeutig ist, reicht auch nur der Variablenname aus.
Standardwerte für Parameter sind hier ebenfalls möglich: Dieser Konstruktor will ein char
bekommen und, optional, zwei Ganzzahlen, und schreibt dann die übergebenen Werte in die Elemente der Struktur. Die Parameter kann man wie bei Funktionen in normale Klammern schreiben, jedoch empfehlen wir hier geschweifte Klammern für die Uniform Initialization.
Dazu schieben wir ein Beispiel ein. Bei der Struktur Foo
braucht der Konstruktor einen Parameter. Egal ob mit geschweifter Klammer oder normaler Klammer, die Instanziierung klappt bei beiden. Die Struktur Bar
hingegen hat nur den Defaultkonstruktor ohne Parameter. Wie zuvor gezeigt, können wir hier ohne Klammern instanziieren, wie auch in C üblich. Leere geschweifte Klammern gehen ebenfalls, aber wenn wir leere normale Klammern nehmen, und anschließend auf die Instanz zugreifen wollen, wirft der Übersetzer einen Fehler, mit einer (meiner Meinung nach) auf den ersten Blick nicht so ganz offensichtlichen Meldung.
Das Problem ist, dass die Schreibweise mehrdeutig ist, und der Parser hier auch eine Deklaration der parameterlosen Funktion bar2
sieht, welche als Rückgabetyp ein Objekt Bar
liefert. Und diese Funktion ist nicht definiert. Über diesen Fehler stolpert fast jeder C++ Anfänger, weshalb er auch die Bezeichnung "ärgerlichstes Parserproblem" bekommen hat – eigene Wikipediaseite inklusive. Und deshalb wurde auch die neuere Uniform Initialization Syntax mit der geschweiften Klammer eingeführt, welche ein eindeutiges Verhalten hat und somit bevorzugt werden sollte.
Da das Zuweisen der Werte in Strukturelemente ein häufiger Anwendungsfall in Konstruktoren ist, wurde die Benutzung mit etwas syntaktischem Zucker versüßt: Diese sogenannte Member Initializer List steht mit einem Doppelpunkt als Trennzeichen nach dem Kopf und vor der Implementierung der Methode, und ist kürzer und vielleicht auch ein wenig lesbarer.
Wie auch bei jeder normalen Funktion ist es möglich Konstruktoren zu überladen. Wenn wir nun bei der Objekterstellung eine Ganzzahl übergeben, dann wird der zweite Konstruktor aufgerufen, auch hier nochmal der Hinweis: Die Auflösung von Überladungen kann je nach Datentyp nicht trivial sein – schreibt einfach verständlichen Code und vermeidet besser Überladungen, die nicht offensichtlich sind.
Neben einfachen Berechnungen kann in der Member Initializer List auch ein anderer Konstruktor aufgerufen werden. Das erspart bei aufwendigeren Konstruktoren gegebenenfalls die Duplikation von Code. Als Beispiel dazu soll die Anzahl der existierenden Instanzen von Disp
gezählt werden. Gelöst wird dies durch einen globalen Zähler, der im ersten Konstruktor inkrementiert und im Destruktor dekrementiert wird. Der zweite Konstruktor braucht nun dieses inkrementieren nicht ebenfalls implementieren, sondern ruft einfach den ersten Konstruktor auf.
Allerdings hat dieses Beispiel an einer anderen Stelle einen Schönheitsfehler: Der logische Zusammenhang der globalen Variable count zur Struktur Disp
ist leider nicht erkennbar. Um diesen Zusammenhang zu verdeutlichen, können wir eine statische Variable innerhalb der Struktur erzeugen. Hierbei gilt zu beachten, dass die Variable innerhalb der Struktur lediglich deklariert wird – sie muss dazu auch noch außerhalb der Struktur definiert werden, sonst gibt es einen Übersetzerfehler.
Das geht übrigens nicht nur bei statischen Variablen, sondern auch Strukturmitglieder können außerhalb der Strukturdeklaration definiert werden, wie hier beispielsweise der erste Konstruktor sowie der Destruktor. Die getrennte Definition ist insbesondere dann hilfreich, wenn durch die Implementierung zusätzliche Header benötigt werden und ein Einbinden dieser zu zirkulären Abhängigkeiten führen würde.
Der Zugriff auf die Elemente in Strukturen kann in C++ mittels Sichtbarkeiten eingeschränkt werden. Die Sichtbarkeit eines Elements gibt an, ob und "von wie weit entfernt" ein Element von außerhalb der Struktur sichtbar und somit zugreifbar ist. Das erreicht man, indem man die gewünschte Sichtbarkeit in die Strukturdefinitionen einträgt. Die Sichtbarkeit wird so auf alle Methoden und Variablen nach der Annotation angewendet, solange bis eine neue Annotation angetroffen wird. Auf Elemente, die mit der Sichtbarkeit private
versehen sind, kann nur von innerhalb der Struktur zugegriffen werden, in der sie definiert wurde. Auf öffentliche (also mit public
markierte) Elemente kann wie in C-Strukturen üblich, von überall aus zugegriffen werden. Unsere Änderung der Sichtbarkeiten bedeutet nun für die Funktion func
, die nicht Teil der Struktur Disp
ist, dass sie nicht auf die Variable val zugreifen darf, da diese privat
und somit "unsichtbar" ist. Der Übersetzer wird hier mit einem Fehler abbrechen. Natürlich kann er nicht verhindern, dass wir über andere Wege, zum Beispiel explizit über die Speicheradresse, darauf zugreifen – die Sichtbarkeit soll eher helfen Programmierfehler einzuschränken.
Wenn die Sichtbarkeitsannotation innerhalb von Strukturen weggelassen wird, dann sind (per Default) alle Elemente öffentlich zugreifbar – im Prinzip wie man das eben aus C kennt. Aber wir haben auch gesehen, dass in C++ Strukturen deutlich mächtiger sind, sie sind quasi das gleiche wie eine Klasse in objektorientierten Programmiersprachen. Allerdings gibt es in C++ auch noch das Schlüsselwort class
– welches sich aber fast wie die gezeigte Struktur verhält. Der wichtigste Unterschied ist dabei die Standardsichtbarkeit der Elemente: Während sie bei Strukturen ohne näheren Angaben implizit public
ist und damit den C-typischen Zugriff ermöglicht, ist sie bei Klassen private
, das heißt der Zugriff auf Elemente der Klasse ist standardmäßig nur von intern möglich. Dort ist es also notwendig, explizit einzelne Methoden oder Variablen öffentlich zugreifbar zu machen – eben durch die Nutzung von Sichtbarkeitsannotationen wie beispielsweise public
oder das bisher noch nicht erwähnte protected
.
Um protected
zu verstehen, müssen wir uns Vererbungen näher anschauen, ein grundlegendes Konzept Objektorientierter Sprachen. C++ unterscheidet sich hierbei allerdings etwas von Java: Anstelle von extends
oder implements
nutzt man in C++ einen Doppelpunkt, gefolgt von der Vererbungsart. Die Vererbungsart ist entweder public
, protected
oder private
und gibt die maximale Sichtbarkeit der geerbten Elemente der Basisklassen an.
Je nachdem ob es sich bei Abgeleitet
um eine Klasse oder eine Struktur handelt, ist die Standardvererbungsart private
oder public
, falls – wie hier im Beispiel gezeigt – keine Vererbungsart explizit angegeben wird. Private Elemente der Basisklasse sind allerdings nie von der erbenden Klasse zugreifbar – das erlaubt es uns beispielsweise sowohl in Foo
, als auch in Bar
eine eigene Variable mit dem Namen n
anzulegen. Zusammengefasst bedeutet das, das public
den Zugriff von überall erlaubt, während protected
nur den Zugriff aus der Klasse selbst, oder Klassen, die von ihr abgeleitet sind erlaubt und private ausschließlich Zugriffe von innerhalb der Klasse selbst erlaubt.
Anders als Java, wo eine Klasse maximal von einer anderen Klasse erben kann, kennt C++ das Konzept der Mehrfachvererbung. Die Klasse FooBaz
erbt hier im Beispiel von den Implementierungen der beiden Klassen Foo
und Baz
– die ihrerseits natürlich selbst wieder von anderen Klassen erben können. Sofern diese beiden von derselben Basisklasse stammem, entsteht das sogenannte Diamond-Problem – was aber für uns in der Übung nicht relevant werden dürfte.
Was aber bei uns vorkommen wird, ist, dass Methoden überschrieben werden, also Methoden aus der Basisklasse mit derselben Signatur in der abgeleiteten Klasse definiert werden. Nun möchte man natürlich in der Lage sein, zu bestimmen, wann welche der gleichnamigen Methoden ausgeführt werden soll. Hierfür wurde das Schlüsselwort virtual
eingeführt. Damit attribuierte Methoden unterscheiden sich von nicht-virtuellen Methoden darin, dass die Auswahl der auszuführenden Methode zu einem anderen Zeitpunkt stattfindet: Nicht-virtuelle Methoden werden anhand des statischen Typs des zugehörigen Objekts ermittelt – das heißt anhand des Datentyps, der direkt im Quellcode steht. Bei virtuellen Methoden wird die richtige Methode zur Laufzeit, auf Basis des "tatsächlichen" Datentyps während der Ausführung ermittelt.
Schauen wir uns das anhand eines Beispiels genauer an, mit der Klasse Foo
, und den zwei Klassen Bar
und Baz
, die jeweils von Foo
erben. Die Basisklasse Foo
definiert drei Methoden: Eine nicht-virtuelle Methode f1
, eine virtuelle Methode f2
, und eine rein virtuelle Methode f3
– pure virtual auf Englisch. Solche rein virtuelle Methoden beschreiben eine Art Interface, das von erbenden Klassen implementiert werden muss. Die Klassen Bar
und Baz
definieren ebenfalls eigene Methoden, dabei sollte das Überschreiben von virtuelle Methoden mit dem Schlüsselwort override
gekennzeichnet werden. Dies dient als Hinweis an den Übersetzer, dass wir erwarten, dass die jeweilige Methode eine virtuelle Methode aus der Basisklasse überschreibt – würde eine solche in der Basisklasse fehlen, dann würde der Übersetzer eine entsprechende Warnung anzeigen.
Aber wann wird nun welche Implementierung verwendet? Das wollen wir mal an verschiedenen aufrufen durchexerzieren. Als Erstes stellen wir dabei fest, dass wir von der Klasse Foo
keine Instanz erzeugen können, der Übersetzer bricht dabei ab. Das liegt an der rein virtuellen Funktion f3
, für die es in der Klasse Foo
keine Implementierung gibt. Solange eine oder mehrere rein virtuelle Funktionen in einer Klasse existieren, gilt die Klasse als abstrakt und kann nicht instanziiert werden.
Die Klasse Bar
hingegen stellt eine Implementierung für die Methode f3
bereit, Bar
ist daher nicht abstrakt und eine Instanziierung ist möglich. Legt man, wie hier gezeigt, ein Objekt der Klasse Bar
an und ruft die Methoden auf, dann wird – wenig überraschend – f1
aus Foo
aufgerufen, weil Bar
keine eigene Implementierung von f1
bereitstellt. f2
und f3
werden in Bar
implementiert, dementsprechend wird die Implementierung aus Bar
aufgerufen. Ähnliches gilt für Baz
: Die Methoden f1
und f3
, die in Baz
selbst definiert werden, werden ausgeführt. Da für f2
keine Implementierung in Baz
vorhanden ist, wird die f2
-Implementierung aus Foo
verwendet. Bisher ist die Auflösung der richtigen Methode noch relativ einfach, weil der statische und der dynamische Typ übereinstimmen.
Interessanter wird es, wenn man auf die gerade gezeigten Objekten bar
und baz
jeweils über einen Zeiger vom Typ Foo
– dem Basistyp – zugreift. Die ersten drei Methodenaufrufe auf diesen foo
-Zeiger, hinter dem sich das bar
-Objekt von oben verbirgt, zeigen das gleiche Ergebnis wie beim direkten Aufruf auf dem bar
-Objekt. Da Bar
keine eigene f1
-Methode mitbringt, wird aus Mangel an Alternativen f1
aus Foo
aufgerufen. Die Methode f2
ist als virtuell markiert, dementsprechend findet die Methodenauflösung zur Laufzeit, anhand des dynamischen Typs des Objekts, statt. Da foo
hier auf ein Objekt des Typs Bar
zeigt, wird die Methode f2
aus Bar
aufgerufen. Analog gilt dies für den Aufruf von f3
– mit dem Unterschied, dass hier keine Methode f3
aus der Basisklasse Foo
zur Verfügung steht. Für das zweite Set von Aufrufen wird foo
mit einem Zeiger auf baz
überschrieben. Hier zeigen sich nun Unterschiede im Vergleich zum Aufruf "direkt auf dem Objekt": Das Aufrufziel der nicht-virtuellen Methode f1
wird zur Übersetzungszeit anhand des statischen Typs, hier Foo
, entschieden. Dementsprechend wird die Methode f1
aus der Basisklasse Foo
aufgerufen. Für f2
und f3
gilt analog zu vorhin: Die näheste Implementierung von f2
und f3
wird zur Laufzeit gesucht und aufgerufen.
Damit mit eigenen, mittels Klassen definierten Datentypen ebenso komfortabel und lesbar wie mit konkreten Datentypen gearbeitet werden kann, bietet C++ als weiteren syntaktischen Zucker die Möglichkeit, die Behandlung von vielen Operatorsymbolen für die jeweiligen Klassen zu spezifizieren, sogenannte Operatorüberladung. Als Beispiel eine Klasse für komplexe Zahlen, in welche mit den Operatorsymbolen +
und -
die Addition und Subtraktion wie bei Ganzzahlen gehandhabt werden kann. Die Klasse Complex
enthält dazu, neben den üblichen Dingen, eine Implementierung für eine Methode mit dem speziellen Namen operator+
, die einen Parameter vom Typ Complex erwartet – den zweiten Summanden. Der erste Summand entspricht dem Objekt, auf dem operator+
aufgerufen wird – also this
, der eigenen Instanz. Eine weitere Möglichkeit einen solchen Operator zu definieren, ist es eine Funktion außerhalb der Klasse zu definieren, wie hier operator-
, die nun zwei Parameter der Klasse Complex
erwartet. Diese zweite Variante ermöglicht es, Operatoren für Klassen zu erzeugen, die man selbst nicht verändern kann, weil sie zum Beispiel aus einer Bibliothek kommen. Nachteilig ist allerdings, dass diese Funktion per se erstmal keinen Zugriff auf nicht-öffentliche Elemente der Klasse hat. Aber auch dieses Problem kann umgangen werden, indem man die Funktion als "Freund" der Klasse markiert – und ihr somit Zugriff auf die Interna erlaubt.
Die Hauptfrage, die jetzt aufkommt: Wofür brauchen wir das in unserer Übung überhaupt? Die Antwort ist überraschend einfach: für Streamoperatoren. Unser Betriebssystem soll mit dem OutputStream
eine Ausgabemöglichkeit bekommen, ähnlich zu cout
, der Eingangs gezeigten C++ Alternative zum printf
. Es soll alle relevanten konkreten Datentypen wie Zeichenketten, boolsche Werte sowie 8, 16, 32 und 64 bit Ganzzahlen unterstützen. Da man bei den Ganzzahlen manchmal unterschiedliche Darstellungen in der Ausgabe haben will, wie dezimal, binär, octal oder hexadezimal, werden zum Ändern der Darstellung sogenannte Manipulatoren eingesetzt, welche als Funktionszeiger mittels Streamoperator übergeben werden, und dann beispielsweise bei der Ausführung eine interne Variable zur Ausgabesteuerung ändern.
Typumwandlungen können in C++, bedingt durch Klassen- und Vererbungsstrukturen, deutlich komplexer als in C sein. Weshalb C++ auch verschiedenen Arten des expliziten *cast*ens anbietet: Der const_cast
ist dafür gedacht Attribute wie const
oder volatile
hinzuzufügen oder zu entfernen.
Der static-cast
erlaubt es innerhalb von Vererbungshierarchien "hoch und runter" umzuwandeln. Hier im Beispiel sind vier Klassen gezeigt – die Klasse F
, die von D0
und D1
erbt, die ihrerseits jeweils von B
erben. Versucht man nun ein Objekt x
mit statischem Typ B
, und dynamischen Typ D0
mittels static_cast
umzuwandeln, dann erlaubt der Übersetzter sowohl die Umwandlung auf D0
, als auch auf D1
– eben weil D0
und D1
die Klasse B
als Basisklasse haben und B
der statisch bekannte Typ von x
ist. Da x
allerdings eine Instanz von D0
ist, ist der static_cast
auf D1
ungültig, ein Zugriff darauf würde zu undefiniertem Laufzeitverhalten führen.
Erzeugt man nun anstelle der Instanz von D0
ein Objekt der Klasse F
, dann sind beide Umwandlungen, sowohl auf D0
, als auch auf D1
, erlaubt und zur Laufzeit gültig. Versucht man allerdings ein Objekt vom Typ D0
direkt auf D1
umzuwandeln, dann führt dies zu einem Übersetzungsfehler, da D0
und D1
"Geschwister" sind, die selbst nicht in einer Vererbungsbeziehung zueinander stehen.
Es ist aber dennoch möglich, eine Instanz von D0
auf D1
zu static-casten, indem man erst auf eine gemeinsame Klasse, hier bspw. F
, castet, gefolgt von einem zweiten static_cast
auf die Zielklasse – also D1
. Wenn man sowas macht, sollte man sich aber sehr sicher sein, dass man ein Objekt des richtigen Typs, hier also F
, vor sich hat – andernfalls werden merkwürdige Dinge zur Laufzeit passieren.
Der dynamic_cast
ist ein cast, der die Typinformationen, die bei polymorphen Klassen mit im Binärprogramm gespeichert sind, zur Laufzeit zum Umwandeln nutzt. Diese Art des *cast*s hat daher gewisse Laufzeitkosten, bietet aber den großen Vorteil, dass er ungültige Umwandlungen bei der Ausführung erkennt und verhindert. Wird eine solche ungültige Umwandlung erkannt, dann liefert die Ausführung von dynamic_cast
einen nullptr
bei Zeigern oder eine bad_cast
-Exception im Falle von Referenzen. Dieses Verhalten ermöglicht es, den dynamic_cast
gezielt zur Unterscheidung von Objekten einzusetzen.
Der reinterpret_cast
ist zum Umwandeln von Bitwerten, wie bspw. der Adresse des Hardwarebildschirmspeichers gedacht – hier im Beispiel wird die Ganzzahl 0xb8000
in eine Adresse, also einen Zeiger umgewandelt.
Die alten, aus C bekannten Umwandlungen mittels Klammern, die sogenannten *C-style cast*s, sind in C++ auch weiter möglich. Allerdings sollten sie unter allen Umständen vermieden werden, da mit der Nutzung der vier C++-casts, Fehler besser erkannt und abgefangen werden können und außerdem dem Leser des Quelltextes besser anzeigen, was die Intention hinter der jeweiligen Umwandlung ist.
Das war nun eine ganze Menge an Konzepte, die man am besten durch eigenes Anwenden erlernt. Dafür haben wir für euch die freiwillige Aufgabe 0 erstellt, in welcher ihr die wichtigsten C++ Konzepte für unsere Veranstaltung üben könnt, im Gegensatz zu den nachfolgenden Aufgaben durch die Laufzeitumgebung, welche euch über Fehler informiert, aber auch noch eine Art Fallnetz habt. C++ ist natürlich noch viel mächtiger, aber wir verwenden nur ein grundlegendes Subset der Sprache, insbesondere da wir später in StuBS ohne Standardbibliotheken arbeiten müssen. Entsprechend eignet sich dieses Fach auch gut für den Einstieg in diese Programmiersprache.
Viel Erfolg damit!