OOStuBS/MPStuBS
|
Dies ist bei weitem keine vollständige Einführung in C++, sondern ausschließlich eine Behandlung einzelner Themenkomplexe, die für dieses Fach von Bedeutung sind. Die Gebiete werden nicht nur in ihrer Verwendung erläutert, sondern auch in ihrer technischen Auswirkung. Die einzelnen Mechanismen sollen also entmystifiziert werden. Dies wird anhand von C++-Codebeispielen, Terminaloutputs und Assemblerlistings geschehen.
Eine gute Quelle für ein umfassenderes C++ Tutorial ist cplusplus.com.
Jede Variablendefinition reserviert ein Stück Speicher und gibt diesem Speicher einen Namen und einen Datentypen. Über den Namen kann der Speicher im Programm angesprochen werden. Der Datentyp gibt an, wie dieser zu interpretieren ist. Dazu wollen wir uns anschauen, was der Compiler (g++ -m32 -fno-PIE test.c -o test
) aus folgendem Stück Code macht:
Zunächst können wir uns mit dem Tool nm
ansehen, welche globalen Objekte mit welchen Adressen und Größen angelegt wurden (...
zeigt eine Auslassung an):
Aus dieser Ausgabe sehen wir, dass die beiden Variablen im BSS-Segment (B
) angelegt wurden, und 4 bzw. 8 Byte groß sind. Die grundlegende Eigenschaft des BSS-Segments ist es, dass der Speicher der Variablen beim Programmstart mit 0 initialisiert wird. Das passiert, weil beides Variablen sind und bei der Definition keinen Wert zugewiesen bekommen haben. Wir sehen also, dass der Name ganzzahl
einen Speicherbereich bezeichnet, der an 0x2028 liegt und 4 Byte groß ist. Allerdings haben wir im fertig übersetzten Programm keinerlei Informationen mehr darüber, von welchem Datentyp dieser Speicher ist. Diese Information wurde nur vom Compiler benutzt, um den passenden Binärcode zu erzeugen. Dies ist eine der Stellen, an der zu Tage tritt, dass C und C++ keine typsichere Sprache ist, da man den Speicher, der als fliesskomma
bekannt ist, auch völlig anders verwenden könnte. Schauen wir uns nun den erzeugten Assembler an:
Wie wir sehen, steht an der Adresse 0x52d, die wir schon aus der nm
-Ausgabe kennen, der Code der Funktion main
. Hier sind unsere drei Zeilen relativ gut zu erkennen. Zunächst inkrementieren wir ganzzahl
um 1, ohne den Umweg über ein Register zu nehmen. Dann passiert dasselbe Spiel mit der Fließkommazahl unter Zuhilfename der x86-Fließkommaeinheit, die einen eigenen Stack von Fließkommazahlen verwaltet. Anschließend schieben wir den Wert 0 in das Register eax
, in dem der Aufrufer von main
den Rückgabewert erwartet, und kehren mittels ret
zurück.
Meistens reicht es jedoch nicht, Objekte immer bei ihrem Namen zu nennen, da dies doch sehr unflexibel ist. In unserem Beispiel sind die Adressen unserer beiden Variablen beispielsweise direkt in den Maschinencode eingewoben. Viel schöner wäre es doch, wenn wir eine Indirektion einbauen würden und einer Funktion einen Namen übergeben, anstatt das Objekt selbst. Dies ist mittels einem Zeiger/Pointer möglich:
Hier übergeben wir der Funktion einen Zeiger auf einen Speicherbereich, der einen Integer beinhaltet. Immer wenn wir auf den Speicher zugreifen wollen, müssen wir den Zeiger dereferenzieren (*zahl
), um den eigentlichen Wert zu erhalten. Schauen wir uns nun den Assembler an:
Zunächst fällt auf, dass unsere Funktion einen sehr komischen Namen hat. Dies liegt am C++-Name-Mangling, bei dem die Parametertypen (in diesem Fall also void
und int *
) in den Namen der Funktion einkodiert werden. Im ersten Befehl laden wir den Pointer zahl
vom Stack, auf dem die Argumente übergeben werden, in das Register %eax
. Die nächste Zeile verwendet die Adressierungsart „Register-Indirekt“ ((%eax)
), um auf die dahinterliegende Speicherstelle zuzugreifen und diese zu inkrementieren. Bei diesem Beispiel hat der Aufrufer der Funktion Speicherplatz auf dem Stack angelegt, der innerhalb der Funktion unter dem Namen zahl
bekannt ist und als int *
interpretiert wird. Würden wir also unsere Funktion mit inkrement(&ganzzahl)
aufrufen, so hätte das Register %eax
in unserem Beispiel den numerischen Wert 0x2020.
Allerdings haben Pointer keine Garantien darüber, ob sie gerade auf ein valides/vorhandenes Stück Speicher zeigen oder ob ihr Wert völliger Bogus ist. Ein häufig verwendete Art, einen Fehler anzuzeigen, ist es beispielsweise, den Nullpointer zurückzugeben. Dieser hat auch tatsächlich den numerischen Wert 0 (daher der Name).
C++ bietet noch eine weitere Möglichkeit, Objekte zu adressieren: Referenzen. Eine Referenz ist zweiter Name bzw. ein Alias für ein Objekt. Dies bedeutet auch, dass sich hinter einer Referenz immer ein valides Objekt befindet.
Compiler implementieren das Sprachkonzept „Referenz“ meist auf der Basis von Pointern. Ihnen steht allerdings frei, in manchen Situationen andere Möglichkeiten zu verwenden.
Wie wir sehen, müssen wir die Referenz, die durch das &
angezeigt wird, nicht dereferenzieren, da sie ein Alias des referenzierten Objekts ist. Der Name zahl
tut so, als wäre es direkt das Objekt, auf das die Referenz zeigt. Wenn wir den dazu gehörigen Assembler betrachten, sehen wir, dass der Compiler in diesem Fall die Abbildung auf Pointer gewählt hat und exakt der gleiche Assembler entsteht.
Nun ist C++ dafür bekannt, dass es (auch) objektorientierte Programmierung erlaubt. Der erste Schritt in diese Richtung ist bereits in C unternommen worden, nämlich die Möglichkeit, mehrere Variablen unterschiedlichen Typs in einem Verbunddatentyp zu organisieren. Mit der folgenden Strukturdefinition kann man Objekte vom Typen foobar
erzeugen und herumreichen (zum Beispiel per Pointer oder per Referenz).
Zusätzlich dazu kann man in C++ nun Methoden innerhalb einer Struktur definieren, die auf den Daten der Struktur arbeiten. Dabei werden die in der Funktion verwendeten Namen zunächst in der umgebenden Struktur gesucht, bevor der Compiler annimmt, dass es sich um eine globale Variable handelt.
In unserem Beispiel sehen wir, wie zunächst eine Methode calc
deklariert wird und danach außerhalb der C++-Klasse definiert (mit Leben gefüllt) wird. Es funktioniert auch, dass man eine Methode direkt in einer Klasse/Struktur definiert, allerdings ist die Trennung von Interface und Implementierung in vielen Fällen schöner. Die Methode calc()
greift auf das Feld foo
zu. Dies bedeutet, dass die Methode, die wir hier sehen, irgendwie einen impliziten Parameter haben muss, der auf ein konkretes Objekt zeigt, in dessen Kontext calc()
ausgeführt wird. Dieser implizite Parameter ist unter dem Namen this
verfügbar und ist ein Pointer auf das Objekt (foobar *
). Dies bedeutet auch, dass this->foo
eine explizite Form ist, um auf foo
zuzugreifen. Schauen wir uns nun den erzeugten Assembler an:
Im Assembler sehen wir hier, dass die gesamte Abstraktion, die durch die C++-Objektorientierung eingeführt worden ist, (in diesem Fall) vollständig zusammenfällt. Der this
-Pointer wird einfach als erstes Argument im Assembler übergeben und alle anderen Parameter (x == 0x8(%esp)
) rutschen um eins nach hinten. In diesem Fall wäre dies äquivalent zu einer Funktionssignatur int calc(foobar * this, int x)
. Was wir außerdem sehen können, ist, dass der Zugriff auf foo
eine direkte Dereferenzierung des this
-Pointers ist: (%eax)
.
Allerdings ist die Kopplung von Code und Daten nicht das einzige Merkmal objektorientierter Programmierung. Oft ist damit auch die Möglichkeit zur Vererbung
gemeint. Das bedeutet, dass eine Klasse von einer anderen Klasse erben kann und diese um Daten und Funktionalität erweitert.
Hier sehen wir eine Klasse foobar_premium
, die von foobar
„public“ erbt. Dadurch können ihre Methoden auf die geerbten Felder zugreifen und neue Funktionalität anbieten. Dies ist sehr ähnlich zu den Mechanismen in Java, mit einigen wichtigen Unterschieden:
virtual
(was äquivalent zu java final
ist). Es wird daher nicht automatisch „weitervererbt“.private
alles erben, aber anderer Code sieht davon nichts).Nun stellt sich die Frage, wieso hier immer von Klassen geredet wird, wo doch im Code ständig struct
steht. Der interessierte Studierende weiß doch, dass es auch class
gibt. Der Punkt ist, dass die beiden Schlüsselwörter beinahe äquivalent sind. Der einzige Unterschied ist, dass in struct
s alle Felder per default public
sind und in einer class
private
.
In C/C++ gibt es prinzipiell drei Bereiche, in denen Daten abgelegt werden können:
main()
-Funktion initialisiert. Gibt es keine initiale Zuweisung, so wird der Speicher einfach mit Nullen gefüllt.malloc()
, um zunächst typlosen Speicher (void *
) vom Heap zu bekommen. Dieser Teil der libc baut dabei heutzutage intern auf den brk()
& mmap()
Systemaufrufen auf. In C++ wird malloc()
selten direkt benutzt, sondern über das Keyword new
.Da wir beim Betriebssystembau keine libc haben, müssen wir zunächst auf den Komfort dynamischer Speicherverwaltung verzichten. Daher sind nur die Varianten 1 und 2 für uns relevant (in BST schreiben wir uns unsere eigene Speicherverwaltung).
Besonders bei den lokalen Variablen gibt es allerdings häufig gemachte Fehler. So wird zum einen gerne vergessen, dass der Stack endlich (4096 Byte) ist und große Objekte zu einem Stackoverflow führen.
Dieser Stackoverflow führt allerdings nicht dazu, dass das Betriebssystem abstürzt, sondern zu einer Speicherkorruption, welche meistens recht schwierig zu finden ist. Man schreibt einfach über den Stack hinaus in den Bereich der globalen Variablen.
Der andere gern gemachte Fehler ist, die Lebensdauer von lokalen Objekten nicht zu beachten. So wird zum Beispiel gerne die Adresse einer lokalen Variable zurückgegeben, was ebenfalls zu einer Speicherkorruption führt. Meistens findet der Compiler diese fehlerhafte Verwendung lokaler Variablen, allerdings nicht in allen Fällen. Daher ist Vorsicht geboten!
Beim Betriebssystembau hat man häufig direkt mit der Hardware zu tun. Die Designer dieser Hardware versuchen ihrerseits die Schaltkreise möglichst effizient zu gestalten. Dies führt dazu, dass häufig mehrere Bits an Informationen, die nicht direkt zusammengehören, in einem Speicherwort zusammengepfercht sind. Ein Beispiel, das in der Aufgabe 1 auftaucht, ist der Speicher der CGA-Graphikkarte. Dort wird jedes Zeichen auf dem Bildschirm (80x25 Zeichen) von 2 Bytes dargestellt. Das erste der beiden Bytes ist dabei das dargestellte Zeichen (in ASCII), das zweite Byte ist die Konfiguration für Vordergrund- und Hintergrundfarbe.
Bit | 7 | 6 | 5 | 4 | 3 | 2 | 1 | 0 |
---|---|---|---|---|---|---|---|---|
Bedeutung | Blinken | Hintergrundfarbe | Vordergrundfarbe |
Nun kann man solche Attributbytes mittels der Bitoperationen, die C und C++ bereitstellen, zusammenbauen. Dabei gibt es das binäre UND (&
), das binäre ODER (|
), das binäre NICHT (~
), das binäre XOR (^
) und die beiden Shift-Operationen (<<
, >>
). Eine einfache Option für eine Wrapperfunktion wäre also:
In dieser Funktion werden zuerst die Variablen mit einer Maske verundet, um alle Bits, die außerhalb des Wertebereich des jeweiligen Attributs liegen, zu beschneiden. Danach werden die Bits innerhalb von background
und Blink
mittels eines Shifts nach links noch an die passende Stelle verschoben und in der Returnanweisung mittels eines binären ODERs kombiniert.
Zu diesen Operationen gibt es noch diverse Idiome, die man verwendet, um einzelne Bits oder eine Menge von Bits zu löschen, zu setzen oder auf ihren Wert zu prüfen. Diese funktionieren meist über ein Bitmaske, in der alle Bits, für die wir uns interessieren, gesetzt sind:
Diese Art, die einzelnen Bits zu manipulieren, ist für kleinere Anwendungen gut genug. Allerdings neigen Programme, die allzu heftig davon Gebrauch machen, dazu, völlig unlesbar und voll von magischen Konstanten zu sein. In C++ (und C) gibt es daher noch die Möglichkeit, Bitfelder zu verwenden. Diese weisen den Compiler an, einzelne Bits eines Speicherbereiches unter einem Namen bekannt zu machen.
Hierbei ist der oberste Eintrag bei den gängigen Compilern niederwertig (= näher bei Bit 0). Das zusätzliche Attribut packed
weist den Compiler an, keine Padding-Bits mehr zwischen den Feldern zu lassen (also zusätzliche unbenutzte Bits einzubauen, die z.B. durch besseres Alignment die Performance steigern können). Verwendet man nun diese Struktur, wird unsere Funktion vom Eingang deutlich einfacher. Wir können sie sogar in den Konstruktor packen.
Im Beispiel wird mittels einer Initialisierungsliste den Feldern direkt ein Wert zugewiesen. Dies erlaubt es dem Compiler, besseren Code für diese Zuweisungen zu erzeugen.
Vor Jahren habe ich in einem Mikrokontroller-Forum auf die Frage, wieso ein Programm, das Interrupts verwendet, nicht funktioniert, die Gegenfrage gelesen: "Hast du schon überall volatile
hingeschrieben?`.
Das ist natürlich nicht die richtige Antwort. Denn volatile
ist nicht das magische Schlüsselwort, mit dem der Compiler Wettlaufsituationen verhindert. Seine Bedeutung versteht man, wenn man verinnerlicht hat, dass der Compiler nicht für jeden Zugriff auf eine Variable aus dem Speicher ließt. Da der Speicher langsam und die Register deutlich schneller sind, ist es von großem Vorteil, eine Variable aus dem Speicher zu lesen, alle nötigen Modifikationen durchzuführen und dann erst zurückzuschreiben. Beispiel:
In dem Beispiel lebt Variable foo
zunächst im Speicher und für die mittleren drei Instruktionen im Register, bevor sie wieder zurückgeschrieben wird. Man muss quasi gedanklich den Geist einer Variable (wo ist der Wert aktuell) von ihrer aktuellen Speicherstelle trennen.
Das Keyword volatile
verbietet nun dem Compiler diese Optimierung. Für jedes Auftreten einer volatile
Variable muss der Compiler den Wert aus dem Speicher lesen und das Ergebnis zurückschreiben.
Eines der meist gehassten und am häufigst verteidigten Konzepte neben den C++-Templates ist die Möglichkeit, Operatoren zu überladen. Dies kann dazu führen, dass ein unschuldig aussehender Code a + b
beliebig kaputte Dinge im Hintergrund machen kann. Und weil andere damit unvernünftige Dinge machen, ist es wichtig, dass ihr auch wisst, wie man unvernünftige Dinge damit tut.
Prinzipiell ist Operatorüberladung sehr einfach. Für den Operator OP, den man gerne auf seinen Datentypen anpassen will, definiert man einfach eine Funktion, die den magischen Namen operatorOP
hat.
Damit definieren wir einen Operator, der die Gleichheit zweier selbst definierter Datentypen regelt. Da es noch andere (eingebaute) Operatorimplementierungen gibt, entscheiden die normalen C++-Überladungsregeln, welche Implementierung zur Anwendung kommt. Zusätzlich zu frei lebenden Operatoren kann man auch Operatoren innerhalb einer Klassendefinition mit einem Argument weniger als Methoden definieren:
In diesem Fall bekommt der Operator die linke Seite durch das implizite this
-Argument. Da der Operator als Methode definiert wurde, kann er auf die privaten Felder von foo
zugreifen, was mit einem frei schwebenden Operator nicht möglich wäre.
Eine besonders eigenwillige Verwendung einer Operatorüberladung wurde für die C++-Standardbibliothek für die Ein- und Ausgabe verwendet. Hier wird der <<
-Operator überladen, um Zahlen und Strings gleichermaßen in einem Ausgabekanal zu schicken.
Hierbei gibt jeder operator<<
-Aufruf eine Referenz auf den Ausgabekanal zurück:
Eine Besonderheit von Java ist es, dass jede Methode einer Klasse per default zunächst virtual
ist. Dies bedeutet, dass der Aufruf der Methode dynamisch dispatched wird. Die andere Art den Dispatch eines Methodenaufrufes durchzuführen, ist der statische Dispatch. Wir wollen uns zunächst (in C++) anschauen, wo der Unterschied im Verhalten von virtuellem und statischem Dispatch liegt.
In diesem kurzen Codeabschnitt erzeugen wir ein Objekt, dass von einem Object *
gehalten werden kann. Dies bedeutet, dass es entweder direkt ein Object
ist oder ein Objekt einer abgeleiteten Klasse, die von Object
erbt. Ob nun das Objekt, auf das obj
zeigt, ein Object
oder ein DerivedObject
ist, bestimmt seinen dynamischen Typ. Die Variable, welche die Referenz auf das Objekt hält, bestimmt ihren statischen Typ. Das heißt, dass in unserem Beispiel *obj
sicher den statischen Typen Object
hat, aber der dynamische Typ aus dem Code nicht direkt ablesbar ist.
Der Unterschied zwischen dynamischen und statischen Dispatch liegt darin, ob der dynamische oder der statische Typ herangezogen wird, um die Methode auszuwählen. Falls unsere ->method()
statisch dispatched wird, kann der Compiler direkt eine call
Instruktion generieren.
Zuerst wird der Pointer auf das Objekt auf den Stack gelegt und dann der Funktionskörper direkt mit einem Aufruf angesprungen. Auch hier sehen wir, dass der Funktionsname gemangled ist.
Soll nun ein dynamischer Dispatch an die Stelle des statischen Dispatches treten, muss unser Programm irgendwie herausfinden können, von welchem dynamischen Typen das referenzierte Objekt ist. Denn auf diesen kommt es beim dynamischen Dispatch ja an. Dazu muss das Wissen über den dynamischen Typen des Objekts überhaupt einmal irgendwo gespeichert werden. Denn normalerweise, wenn es keine einzige virtuelle Methode gibt, ist die Abbildung von C++ Klassen und Strukturen auf den Speicher direkt. Dazu ein Beispiel, wie unser Object
aussehen könnte:
In diesem Fall wird method()
statisch dispatched und es besteht keinerlei Notwendigkeit den dynamischen Typen solcher Objekte zu kennen, wieso also dafür wertvolle Bytes ausgeben. Daher spart sich C++ in diesem Fall den Speicher, um den dynamischen Typen zu vermerken und belegt für unser Objekt genau die 12 Bytes (3 * 4 Bytes für jeden Integer), die verwendet werden. Dabei werden die Felder der Klasse von oben nach unten aufsteigend in den Speicher gelegt. In diesem Fall ist daher:
Ebenfalls könnte man, wenn man glaubt zu verstehen, was der Compiler tut, ein Objekt direkt aus einem Pointer auf ein Stück Speicher casten. Im Beispiel wird auch sichtbar, dass unser Beispiel auf einer Litte-Endian Maschine kompiliert wurde.
Um Informationen über den dynamischen Typen hinzuzufügen, müssen nun im Speicher des Objekts zusätzliche Informationen untergebracht werden. Die meisten Compiler fügen dazu vor dem ersten Element der Klasse einen Virtual Function Table Pointer hinzu. Bei allen Objekten des gleichen dynamischen Typs zeigt dieser Pointer auf die gleiche virtuelle Funktionstabelle.
Dieses Beispiel nun in einer GDB-Session am angegeben Breakpoint.
Wir sehen also, dass das eingefügte Feld auf ein Objekt mit dem Namen <vtable for Object+8>
zeigt, welches vom Compiler erzeugt wurde. In dieser VTable ist ein Pointer auf den Funktionskörper von Object::method()
gespeichert. Da der VTable-Pointer direkt vom dynamischen Typen abhängt, kann über diese Funktionstabelle der dynamische Dispatch durchgeführt werden. Für unseren Methodenaufruf sieht der dazu passende Assembler so aus (Object *
in %eax
):
Jede virtuelle Methode, die wir definieren, bekommt also einen Slot in der virtuellen Funktionstabelle. Um noch genauer zu betrachten, was der Compiler in die VTable baut, können wir den Compiler mit g++ -fdump-class-hierarchy
aufrufen und die Ausgabe betrachten:
Wir sehen aus der Ausgabe, dass die VTable eigentlich 8 Byte weiter vorne anfängt als der vptr, der am Ende in unseren Objekten gespeichert wird. Zusätzlich zu unseren Methodenpointern wird noch ein Pointer auf die Run-Time-Type-Information (RTTI) gespeichert.
All dieses Wissen zusammengenommen können wir also auch mal ein Object und die dazu passende VTable zusammenfaken:
Als Ausgabe erhalten wir für dieses Programm (für -m32
, 32 Bit): X: 1 2 3
.
Was wir aus dieser technischen Betrachtung von virtuellen Funktionstabellen lernen, ist:
Links:
Wir haben bereits gelernt, wie in C++ Verbunddatentypen (struct, union, class) im Speicher abgelegt werden. Bei einfache Datenstrukturen (POD) werden die Felder (von oben nach unten) hintereinander in den Speicher gelegt (von den niedrigen Adressen zu den hohen Adressen. Und wir haben gelernt, wie Klassen, die eine virtual
-Methode enthalten, um einen VTable-Pointer erweitert werden.
Wie sieht es aber nun mit Vererbung aus? Dazu ein Beispiel, welches wir mit clang -Xclang -fdump-record-layouts
übersetzen:
Aus der Ausgabe bekommen wir heraus, dass die Klasse Base
ganz regulär in den Speicher abgelegt wird. Hierbei ist die Linke Spalte der Offset vom Pointer zum Objekt:
Die beiden Integer sind also einfach hintereinander in den Speicher gelegt. Wenn wir uns die Klasse Derived
anschauen, dann sehen wir, dass dieses Speicherabbild in den Anfang des Derived
-Objektes eingebaut wird und dadurch der Pointer zur Variable cc
einen Offset von 8 Byte aufweist.
Der Trick bei dieser Art der Einbettung ist nun, dass man den Pointer, der auf ein Derived
-Objekt zeigt, ohne Probleme wie ein Objekt vom Typen Base
verwenden kann, da alle Felder an der gleichen Stelle sind. Dieselben Regel gelten, wenn wir eine virtual
-Methode zur Base hinzufügen. In diesem Fall kann jedes Derived
-Objekt das Vtable-Pointer-Feld der primären Basisklasse verwenden, um auf die eigene Vtable zu zeigen:
Solange man nur Einfachvererbung hat, ist dies im Grunde alles, was man über Memory Layouts von Klassen wissen muss. In dem Moment, wo man Mehrfachvererbung hat, wird das Ganze eine Ecke komplizierter. Dazu ein Beispiel ohne virtual:
Das Speicherlayout der abgeleiteten Klasse sieht dann folgendermaßen aus:
Auffällig ist, dass wiederum die Speicherlayouts der geerbten Klassen 1:1 in der Derived
-Klasse zu finden sind. Allerdings besteht nun das Problem, dass wir den Pointer, der auf den Anfang zeigt, nicht mehr einfach verwenden können, um eine Funktion zu füttern, die ein Base2
Speicherlayout erwartet. In dem Fall muss also eine Anpassung des this
-Pointers erfolgen. Um dies zu illustrieren, schauen wir uns den Assembler der Funktion Base2 * foo(Derived * x) {return x;}
an:
Hier sehen wir, dass für die Konvertierung der beiden Pointer ein Offset von 8 aufaddiert wird. Dies führt dazu, dass aus einen Derived
-Pointer ein Base2
-Pointer wird. Es bedeutet aber auch, dass der Pointer nun mitten in das Objekt zeigt. Die Sequenz test; cmove
verhindert, dass aus einem Nullpointer ein Pointer mit dem Wert 8 wird.
Diese automatische Offsetanpassung ist auch der Grund, wieso man in C++ mehrere Cast-Operationen hat, die diese Offsetanpassung vornehmen (oder halt nicht). Wen man nun noch virtual
ins Spiel bringt, wird die Geschichte noch etwas komplizierter. Zu beidem verweisen wir aber auf die Links zu diesem Kapitel.