Im heutigen Artikel soll es noch einmal um die bereits vorgestellten strukturierten Datentypen gehen; konkret wollen wir uns anschauen, wie diese intern im Speicher abgelegt werden, welche Probleme es dabei bei der Komposition von strukturierten Typen geben kann und wie Pointer helfen, diese Probleme zu lösen.

Fangen wir mit der “einfachsten” Sache an: der Repräsentation der Strukturen im Speicher. Natürlich ist das Thema zwar – wie so oft – doch etwas komplexer, als hier im Folgenden beschrieben, für das allgemeine Verständnis ist es aber ausreichend, sich auf die Grundlagen zu beschränken1.

1Für interessierte Leser seien hier einmal die beiden Stichworte Data Alignment und Data Structure Padding erwähnt.

In einem früheren Artikel wurde bereits beschrieben, wie einfache, primitive Datentypen (also zum Beispiel ganze Zahlen) im Speicher repräsentiert werden – sie stehen dort einfach als Bitkette in einer einzelnen Speicherzelle. Haben wir in einer Funktion zwei Variablen nacheinander deklariert, so werden sie im Speicher auch in aufeinanderfolgenden Zellen gespeichert. Wie im vorherigen Artikel beschrieben, werden strukturierte Datentypen genutzt, um logisch zusammengehörige Werte auch im Programm zusammengefasst zu repräsentieren. Was also liegt näher, als die Bestandteile eines strukturierten Datentyps im Speicher ebenfalls einfach in aufeinanderfolgenden Zellen abzulegen? Richtig, nichts.

Handelsübliche C++-Compiler sind genau so aufgebaut, dass sie einen strukturierten Datentyp als Sammlung seiner einzelnen Bestandteile betrachten. Variablen, die mit so einem Typen deklariert werden, belegen also im Speicher genau so viele Zellen, wie die einzelnen Bestandteile für sich benötigen2.

2Wie bereits erwähnt ist dass zwar eine Vereinfachung der tatsächlichen Umsetzung, aber eine hinreichend genaue.

Woher dem Compiler bekannt ist, welche Speicherzelle durch eine bestimmte Variable angesprochen wird, wissen wir – die Variablen sind in der Reihenfolge ihrer Deklaration durchnummeriert, so dass die 3. Variable (bei rein primitiven Datentypen) der 3. Speicherzelle ausgehend von der Basis des aktuellen Stack Frames entspricht. Auf die Bestandteile eines strukturierten Datentyps kann nach der gleichen Logik zugegriffen werden: auf die einzelnen Werte eines strukturierten Datentyps kann über die (bekannte) Nummer ihrer Speicherzelle zugegriffen werden.

Schauen wir uns das einmal am Beispiel an, ausgehend von der Datenstruktur für gebrochene Zahlen aus dem letzten Artikel:

struct Fraction { int numerator; int denominator; };

Nehmen wir weiterhin eine Funktion f an, in welcher 2 Variablen vom Fraction-Typ deklariert werden:

void f() { Fraction x; Fraction y; ... }

Beim Aufruf der Funktion f wird sich dadurch nun (ungefähr) das folgende Speicherbild ergeben. Verweisen mehrere Variablen auf die gleiche Speicherzelle, ist das entsprechend vermerkt; zudem sind alle zu den Variablen x und y gehörenden Speicherzellen in der Spalte Var entsprechend markiert (auf die ebp-Offsets verzichte ich jetzt einmal, die sollten klar sein):

Adresse Inhalt Inhalt Inhalt Inhalt Var
123450 <– esp
123454 <– x
<– x.numerator
x
123458 <– x.denominator
123462 <– y
<– y.numerator
y
123466 <– y.denominator
123470 <– ebp
123490 <– bottom

Werden Werte eines solchen strukturierten Datentyps gespeichert oder gelesen, muss nur auf die passende Speicherzelle zugegriffen werden.

Natürlich können strukturierte Datentypen selbst wiederum Bestandteile eines strukturierten Datentyps sein (man spricht hier von der Komposition von strukturierten Typen), etwa in der folgenden Art, wo der Datentyp FractionPair ein Paar aus Brüchen darstellt (die Reihenfolge der Deklaration ist in C++ übrigens wichtig: erst, nachdem ein Datentyp deklariert wurde, kann er auch in weiter unten liegendem Code genutzt werden):

struct Fraction { int numerator; int denominator; }; struct FractionPair { Fraction f; Fraction s; };

Die Werte des FractionPair-Datentyps belegen nun nicht mehr einzelne Speicherzellen, sondern so viele, wie durch die einzelnen Bestandteile benötigt werden – hier also jeweils zwei Zellen. Haben wir also zum Beispiel die folgende Funktion:

void g() { FractionPair p; ... }

1 / 2 / 3 / Auf einer Seite lesen

Kommentare (2)

  1. #1 Nicolai
    November 13, 2013

    0 und nullptr drücken nicht das selbe aus – das eine kann als integraler Wert verwendet werden, das andere nur als Zeiger-Adresse und zur true / false Abfrage. Im Gegensatz zu NULL, das meistens als #define NULL 0 implementiert ist, ist der nullptr somit nur für Zeiger-Operationen gültig.

    Danke für die ausführlichen Artikel, ich finde es klasse, dass du ein wenig auf die Technik im Speicher eingehst. 🙂

    Kleine Frage: Kannst du grob erklären, wie ein Konstruktor übersetzt wird? Ich würde es mir wie eine normale Klassenfunktion vorstellen, Aufruf mit dem this-Pointer und den Argumenten, weiß aber nicht, ob das korrekt ist..

  2. #2 Marcus Frenkel
    November 13, 2013

    @Nicolai

    0 und nullptr drücken nicht das selbe aus – das eine kann als integraler Wert verwendet werden, das andere nur als Zeiger-Adresse und zur true / false Abfrage.

    Ich dachte mir schon, dass ein Kommentar dazu kommt. 😉
    Das gleiche sind sie natürlich nicht, aber sie drücken in Bezug auf die Nullpointer-Adresse das gleiche aus. Natürlich bestehen viele Unterschiede zwischen beiden Werten, aber im Kontext dieser einfachen Zuweisung sind sie äquivalent.

    Konstruktor kommt demnächst. So ein bisschen sind das wirklich normale Klassenfunktionen, die aber ein bisschen Extramagie beim Aufruf betreiben. Insbesondere werden hierbei zu Beginn des Konstruktors der Konstruktor der Basisklasse sowie die Konstruktoren alle Klassen-Attribute aufgerufen. Wo im Falle von RTTI die Typinformation gesetzt wird, weiß ich gerade gar nicht, das könnte aber auch im Konstruktor sein.