C++/Tutorial: 7. Einfache eigene Typen

Aus Scientia
Version vom 6. April 2011, 07:31 Uhr von Alexis Hiemis (Diskussion | Beiträge)

(Unterschied) ← Nächstältere Version | Aktuelle Version (Unterschied) | Nächstjüngere Version → (Unterschied)
Wechseln zu: Navigation, Suche

Nun können wir bereits unsere eigenen Funktionen erstellen und diese auf verschiedene Dateien aufteilen, aber wenn wir uns die Standardheader anschauen treffen wir auf viele Dinge, die wir noch nicht bewerkstelligen können. Eigentlich handelt es sich dabei größten Teils um neue Datentypen, die wir natürlich auch selbst definieren können. Klassen als Mittel zur Unterstützung von objektorientierter Programmierung machen einen Hauptteil dieser Typen aus, aber es gibt auch andere, einfachere Arten selbstdefinierter Typen(die auch schon in C vorhanden waren) und diese wollen wir in diesem Kapitel besprechen.

typedef

Die wohl einfachste Variante einen neuen Typ zu definieren ist ein typedef. Ein typedef ist eigentlich nur ein Typalias, also ein neuer Name für einen bereits existierenden Typ. Der Grund warum man so etwas wollen kann ist der selbe wie bei Konstanten, size_t ist beispielsweise ein typedef, der immer auf einen vorzeichenlosen integralen Datentyp mit der maximalen Größe für Addressen verweist.
Soviel zur Semantik, kommen wir zur Syntax: Ein Typedef kann wie eine Variable sowohl global als auch lokal definiert werden und hat die Struktur
typedef OLD_TYPE NEW_TYPE;
size_t könnte z.B. typedef size_t long; sein.
Ein Beispiel:

#include <iostream>
 
typedef int id_t;
 
id_t next_id(id_t id)
  {
  return id + 1;
  }
 
int main()
  {
  id_t new_id(next_id(0));
  }

Wir hätten an dieser Stelle an jeder Stelle int statt id_t nehmen können, der Vorteil von id_t ist aber auch eindeutig: Wenn wir bemerken, dass negative IDs schwachsinnig sind, ersetzen wir int im typedef einfach durch unsigned und müssen sonst nichts weiter am Code ändern.

Aufzählungen

Okay, weiter im Text, Neuer Typ Nummer 2: Aufzählungen.
Nehmen wir an, wir wollen eine Variable haben, die nur eine fixe Anzahl an Werten annehmen kann, zum Beispiel haben wir ein Fenster und wollen einen Status für dieses Fenster speichern, zum Beispiel ob es maximiert, minimiert, oder normal ist, dann können wir dafür eine Enumeration nehmen. Wir hätten zum Beispiel einen Typ window_state und dieser könnte die 3 Werte win_maximized, win_minimized und win_normal annehmen. Eine Möglichkeit dies zu erreichen ist window_state als typedef auf int anzulegen und die 3 Werte als Konstanten anzulegen, aber dann kann window_state nicht nur diese 3 Werte annehmen, sondern auch noch mehr, außerdem kann window_state als Integer verwendet werden(was bei enums im aktuellen C++ Standard aber nicht anders ist. Dafür wirds im nächsten C++ Standard die enum class geben). enum bietet eine andere Möglichkeit, wir defnieren einen Typ window_state als enum und geben ihm die drei genannten möglichen Werte.

enum identifier
  {
  value1,
  value2,
  ...
  };

wäre eine allgemeine Beschreibung der Grundsyntax einer Enumeration, wir schreiben also zunächst vom enum Schlüsselwort eingeleitet den Bezeichner des Typs und dann in geschweiften Klammern von Kommata getrennt die möglichen Werte, abschließend muss ein Semikolon folgen!
Der Typ kann dann wie jeder andere Typ verwendet werden und ihm können die einzelnen Werte zugewiesen werden.
In unserem Fall wäre es

enum window_state
  {
  win_maximized,
  win_minimized,
  win_normal
  };
 
int main()
  {
  window_state ws(win_normal);
  if(ws == win_minimized)
    {
    //...
    }
  //...
  }

als Beispiel, das zwar nichts tut, aber die Syntax demonstriert.

Wie bereits angemerkt ist es in C++ möglich die enums als Integerwerte zu verwenden, wenngleich dies merkwürdig ist, denn hinter jedem enum steht ein int und wir können den einzelnen Werten auch einen Integerwert zuweisen

enum window_state
  {
  win_maximized,
  win_minimized,
  win_normal = 20
  };

Die allgemeine Form einer Typdeklaration

Diese Form der Typdeklaration Schlüsselwort Bezeichner { Inhalt }; ist in C++ für verschiedene Arten selbstdefinierter Typen üblich.
In der Tat ist die Form noch allgemeiner denn es werden noch 2 weitere Möglichkeiten geboten werden:
Zum einen ist es möglich zwischen dem Ende der geschweiften Klammer und dem Semikolon eine Reihe von Variablen des Types zu erstellen, wir hätten unseren Typ zum Beispiel auch lokal definieren und ws dann direkt deklarieren können, das hätte dann so ausgesehen:

int main()
  {
  enum window_state
    {
    win_maximized,
    win_minimized,
    win_normal
    } ws(win_normal);
 
  if(ws == win_minimized)
    {
    //...
    }
  }

Zum anderen ist sogar der Bezeichner optional. Es ist möglich einen Typ anonym zu deklarieren, sodass es nicht möglich ist ihn weiterhin als Typ zu verwenden, sondern nur die Variablen die direkt angegeben werden von diesem Typ sein können. window_state wird in unserem letzten Beispiel nicht benutzt, wir könnten es also auch als anonymen Typ deklarieren

int main()
  {
  enum
    {
    win_maximized,
    win_minimized,
    win_normal
    } ws(win_normal);
 
  if(ws == win_minimized)
    {
    //...
    }
  }

POD-Typen

Die wohl wichtigste Art eigener Typen, die wir in diesem Kapitel besprechen wollen sind die sogenannten POD-Datentypen. POD steht für Plain Old Data und wird deswegen so genannt, weil sie eine Form von Objekten darstellen, die auch schon in C möglich war.
Stellen wir uns vor, wir erstellen ein Spiel und haben eine Reihe von Charakteren. Jeder dieser Charaktere hat verschiedene Werte, sowie HP, MP, Angriffsstärke u.s.w., die natürlich als Variable gespeichert werden. Naheliegend wäre es nun für jeden Wert einen vector anzulegen, da wir ja unbestimmt viele Charaktere haben (Die Anzahl könnte z.B. während des Spiels wechseln). Wenn wir das aber zu oft machen, wird es irgendwann sehr unübersichtlich, was wir also wollen, wäre diese Werte zusammenzufassen und genau das tun PODs.
PODs werden mit dem Schlüsselwort struct in der selben Form wie enums erstellt. In die geschweiften Klammern kommt dann eine Reihe von Variablen(die dort nicht initialisiert werden dürfen). Wenn wir also zum Beispiel diese 3 Eigenschaften für einen Charakter nehmen, erstellen wir eine struct mit dem Bezeichner "character" und diesen drei Eigenschaften:

struct character
  {
  int hp, mp, atk;
  };

Wir sind dabei nicht auf einen Typ begrenzt, wir können beliebig viele Variablen deklarieren, auf die selbe Weise wie wir sie außerhalb einer struct deklarieren würden. Nun haben wir also einen Datentyp "character" mit den drei Eigenschaften, aber was können wir damit anfangen?
Wenn wir eine Variable vom Typ character anlegen haben wir eine POD-Variable, mit den 3 genannten Eigenschaften, die wie Variablen sind, nur wie greifen wir auf diese Eigenschaften zu? Die Lösung ist der .-Operator. Die POD-Variable gefolgt vom . Operator gefolgt vom Namen der Eigenschaft steht für eben jene Variable, die für jeden character existiert.

int main()
  {
  character tom;
  tom.hp = 100;
  tom.mp = 50;
  tom.atk = 6;
  character bob;
  bob.hp = 30;
  bob.mp = 450;
  bob.atk = 13;
  }

Damit hätten wir ein Anwendungsbeispiel, wir haben 2 Variablen vom Typ character, jede dieser Variablen hat zusätzlich noch 3 Eigenschaften(quasi Untervariablen) vom Typ int. tom.hp kann wie eine Integervariable verwendet werden und ist völlig unabhängig von bob.hp.
Wir könnten uns nun also auf nur einen vector beschränken, der Elemente vom Typ character enthält, was wesentlich übersichtlicher ist, als jeden character eine Nummer zuzuweisen und dann mit dieser auf drei vectoren zuzugreifen.

int main()
  {
  vector<character> chara(5);
  //...
  //der zweite chara greift den ersten chara an
  chara[0].hp -= chara[1].atk;
  chara[1].mp -= chara[1].atk;
  }

Initialisierungslisten

Nun können wir character Variablen anlegen und anschließend den Variablen Werte zuweisen, aber was wenn wir die Variablen direkt initialisieren wollen?
Das können wir bei PODs nicht struct-weise machen, aber wir können beim anlegen der character Variable direkt Werte angeben, mit denen die Eigenschaften initialisiert werden. Das machen wir in geschweiften Klammern, dabei kommt es allerdings auf die Reihenfolge der Eigenschaften an, denn in dieser Reihenfolge werden die Werte durch Kommata getrennt in geschweiften Klammern angegeben.
Wir können tim die Werte also auch kürzer zuweisen:

character tim = {100, 50, 6};

Dass hier ein = benötigt wird ist eine Inkonsistenz in der C++ Syntax, die im nächsten Standard behoben wird (character tim{100, 50, 6}; wird dann möglich sein)

Bitfelder

Eine weitere Möglichkeit, die struct uns bietet, die man auf normalen Rechnern aber eigentlich nie braucht sind sogenannte Bitfelder. Sinn machen sie, wenn man auf einem Gerät mit sehr wenig Speicher hantiert. Ich werde dieses Sprachmittel nur kurz ansprechen.
Nehmen wir zum Beispiel an, wir haben eine struct mit 8 booleschen Variablen, wir würden also erwarten, dass diese struct auch nur 1 Byte benötigt, denn ein Wahrheitswert lässt sich schließlich mit einem Bit darstellen. Allerdings hat ein bool die Größe eines Bytes, denn es handelt sich bei einem byte um die kleinste addressierbare Einheit. Wenn wir nun aber ein struct haben, haben wir auch die Möglichkeit die bools auf die Größe eines Bits zu beschränken, denn die Eigenschaften folgen normalerweise im Speicher aufeinander und müssen daher nicht direkt addressierbar sein. Wir können nach der Deklaration einer Eigenschaft von einem Doppelpunkt getrennt die Größe im Speicher in Bits angeben, es handelt sich bei unserer struct dann um ein sogenanntes Bitfeld.
Ein Bitfeld mit 8 booleschen Variablen, die nur 1 Byte im Speicher belegt(gleichwohl etwas langsamer ist) würde also so aussehen:

struct some_bitfield
  {
  bool v1 : 1;
  bool v2 : 1;
  bool v3 : 1;
  bool v4 : 1;
  bool v5 : 1;
  bool v6 : 1;
  bool v7 : 1;
  bool v8 : 1;
  };

Union

Zu guter letzt kommen wir noch zu den unions. Genau wie Bitfelder sind auch sie dazu da, Speicherplatz zu sparen, sind aber gleichwohl bekannter als Bitfelder. Nehmen wir also an, wir haben 2 Variablen, wir wissen aber, dass immer nur eine davon auch gültig ist, sie haben aber beide unterschiedliche Typen. Wir könnten diese beiden Variablen im Speicher doch an der selben Stelle ablegen und dadurch Speicherplatz sparen und genau das tun wir mit einer union.
unions sind syntaktisch fast identisch zu POD-structs, mit dem einzigen Unterschied, das alle Eigenschaften an der selben Stelle im Speicher liegen. Die Intention ist es also nicht Variablen zusammenzufassen, sondern Speicherplatz zu sparen.
Nehmen wir zum Beispiel an, wir haben ein Programm, das entweder mit Gleitkommazahlen oder Integern hantiert, die Entscheidung steht nicht zur Compilezeit fest, wird aber definitiv zur Laufzeit getroffen. Dann können wir eine union "number" erstellen, die ein Element vom Typ int und ein Element vom Typ float beinhaltet.
Auf einem System, auf dem beide Typen 4 Byte belegen(was nicht selten ist), würde eine struct hier 8 Byte belegen, eine Union hingegen nur 4, denn die Elemente liegen an der selben Stelle im Speicher. Wann immer wir etwas in das Integerelement schreiben wird das Floatelement jedoch ungültig und umgekehrt.

#include <iostream>
 
using namespace std;
 
union number
  {
  int i;
  float f;
  };
 
int main()
  {
  number n;
  //hier verwenden wir das Integerelement von n
  n.i = 3;
  cout << n.i/2 << endl; //1
  //nun lassen wir n.i ungültig werden und verwenden den Speicherplatz für ein float
  n.f = 3;
  cout << n.f/2 << endl; //1.5
  }

Wenn es uns allerdings nur darum geht an einer Stelle 2 verschiedene Typen zur Laufzeut erlauben, lohnt sich ein Blick auf boost.variant.
Unions sind für Klassen(später mehr dazu, was das ist) mit eigenen Konstruktoren, Destruktoren oder Zuweisungsoperatoren nicht geeignet.


In diesem Kapitel haben wir gelernt einfache eigene Datentypen zu erstellen, die wir natürlich auch in Headerdateien definieren können(doppelte Definition ist kein Problem), während Bitfelder und Union auf PCs heutzutage an Bedeutung verloren haben sind typedefs, enums und structs immer noch wichtige Elemente der Programmierung.