C++ Tutorial

 Klassen

Objektorientierte Programmierung

Bei der objektorientierten Programmierung werden, vereinfacht ausgedrückt, u.a. Daten und die sie verarbeitenden Funktionen in einem Datentyp zusammengefasst. Und in C++ ist dies der Datentyp Klasse. Die Daten einer Klasse werden dabei als deren Eigenschaften bezeichnet und die Funktionen als deren Memberfunktionen. Alle Eigenschaften und Memberfunktionen sind dann die Member der Klasse. Und die Instanziierung einer Klasse ist schließlich das Objekt.

Aber das Zusammenfassen von Daten und Funktionen in einem Datentyp kennzeichnet die objektorientierte Programmierung alleine nicht aus.

(Daten-)Kapselung

Der Zugriff auf die Member einer Klasse kann eingeschränkt werden. In der Regel werden die kritische Eigenschaften einer Klasse so geschützt, dass nur noch über Memberfunktionen darauf zugegriffen werden kann. Mehr dazu gleich in diesem Kapitel.

Vererbung

Vererbung bedeutet, dass Member einer Klasse an eine weitere Klasse weitergegeben (vererbt) werden. Diese weitere Klasse kann dann zusätzliche Member hinzufügen, so dass die Klasse eine erweiterte Funktionalität besitzt. Die Klasse, die ihre Member vererbt, wird als Basisklasse bezeichnet und die Klasse, die die Member erbt, als abgeleitete Klasse (siehe Kapitel Abgeleitete Klassen).

Polymorphie

Polymorphie kennzeichnet die Eigenschaft, dass sowohl in der Basisklasse wie auch in einer abgeleiteten Klasse gleichnamige Memberfunktionen definiert werden können, die aber unterschiedliche Implementierungen besitzen (siehe Kapitel Virtuelle Memberfunktionen).

Klassentypen

C++ kennt 3 verschiedene Klassentypen: class, struct und union.

Die Definition einer Klasse wird durch das Schlüsselwort class bzw. struct eingeleitet. Nach diesem Schlüsselwort folgt der Name der Klasse und anschließend ein Paar geschweifter Klammern. Innerhalb der Klammer werden deren Member definiert bzw. deklariert.

struct ClassName1
{
   // Hier stehen die Eigenschaften
   // und die Memberfunktionen
};

class ClassName2
{
   // Hier stehen die Eigenschaften
   // und die Memberfunktionen
};

Auf den Unterschied zwischen class und struct kommen wir gleich zurück.

Eigenschaften

Die Definition einer Eigenschaft erfolgt genauso wie die Definition einer Variable, nur jetzt innerhalb der geschweiften Klammer der Klasse.

struct Circle
{
   // Eigenschaften definieren
   int xpos, ypos;
   unsigned int radius;
};

Memberfunktionen

Eine Memberfunktion kann innerhalb oder außerhalb der Klasse definiert werden.

Definition innerhalb der Klasse

Wird eine Memberfunktion innerhalb der Klasse definiert, wird sie wie eine 'normale' Funktionen definiert. Der Zugriff auf die Eigenschaften ihrer Klasse erfolgt durch Angabe des Namens der jeweiligen Eigenschaft.

struct Circle
{
   // Eigenschaften definieren
   int xpos, ypos;
   unsigned int radius;
   // Memberfunktionen definieren
   // Eigenschaften setzen
  void SetData (int xp, int yp, unsigned int rad)
  {
      xpos = xp; ypos = yp;
      radius = rad;
  }
  // Ausgabe der Eigenschaften
  void Print()
  {
     std::cout << std::format("Kreis mit Radius {} auf ({},{})\n", radius, xpos, ypos);
  }
};

Definition außerhalb der Klasse

Wird die Memberfunktion außerhalb der Klasse definiert, ist die Memberfunktion innerhalb der Klasse zu deklarieren.

Bei der Definition außerhalb der Klasse ist vor dem Namen der Memberfunktion der Klassenname sowie der Zugriffsoperator :: (zwei Doppelpunkte) anzugeben.

struct Circle
{
   // Eigenschaften definieren
   int xpos, ypos;
   unsigned int radius;
   // Memberfunktionen deklarieren
   // Eigenschaften setzen
  void SetData(int xp, int yp, unsigned int rad);
  // Ausgabe der Eigenschaften
  void Print();
};

// Eigenschaften setzen
void Circle::SetData(int xp, int yp, unsigned int rad)
{
   xpos = xp; ypos = yp;
   radius = rad;
}
// Ausgabe der Eigenschaften
void Circle::Print()
{
   std::cout << std::format("Kreis mit Radius {} auf ({},{})\n", radius, xpos, ypos);
}

Vor C++20 erfolgte i.d.R. die Definition der Klasse in einer eigenen Header-Datei und die Definition der Memberfunktionen in einer entsprechenden Quellcode-Datei.

// Datei graphics.h
// ----------------
struct Circle
{
   // Eigenschaften definieren
   int xpos, ypos;
   unsigned int radius;
   // Memberfunktionen deklarieren
   // Eigenschaften setzen
  void SetData(int xp, int yp, unsigned int rad);
  // Ausgabe der Eigenschaften
  void Print();
};
// Datei graphics.cpp
// ------------------
#include <iostream>
#include <format>
#include "graphics.h"

// Eigenschaften setzen
void Circle::SetData(int xp, int yp, unsigned int rad)
{
   xpos = xp; ypos = yp;
   radius = rad;
}
// Ausgabe der Eigenschaften
void Circle::Print()
{
   std::cout << std::format("Kreis mit Radius {} auf ({},{})\n", radius, xpos, ypos);
}

Mit der Einführung von Modulen ab C++20 ist diese Aufteilung nicht mehr notwendig. Die Definition der Klasse und deren Memberfunktionen kann in einer Datei erfolgen.

// Datei graphics.cxx
// ------------------
module;
#include <iostream>
#include <format>

export module Graphics;

// Alle Member der Klasse exportieren
export struct Circle
{
   // Eigenschaften definieren
   int xpos, ypos;
   unsigned int radius;
   // Memberfunktionen deklarieren
   // Eigenschaften setzen
   void SetData(int xp, int yp, unsigned int rad);
   // Ausgabe der Eigenschaften
   void Print();
};

// Eigenschaften setzen
void Circle::SetData(int xp, int yp, unsigned int rad)
{
   xpos = xp; ypos = yp;
   radius = rad;
}
// Ausgabe der Eigenschaften
void Circle::Print()
{
   std::cout << std::format("Kreis mit Radius {} auf ({},{})\n", radius, xpos, ypos);
}

const Memberfunktionen

Memberfunktionen die keine Änderungen an den Eigenschaften vornehmen, sollten als const-Memberfunktionen definiert werden.

Eine const-Memberfunktion wird dadurch gekennzeichnet, dass nach der Parameterklammer das Schlüsselwort const angegeben wird.

Die Print()-Memberfunktion aus dem obigen Beispiel würde damit wie folgt aussehen:

module;
#include <iostream>
#include <format>

export module Graphics;

export struct Circle
{
   // Eigenschaften definieren
   ...
   // Memberfunktionen deklarieren
  ...
  void Print() const;
};
...
// Ausgabe der Eigenschaften
void Circle::Print() const
{...}

Objekte

Wird ein Datum vom Typ einer Klasse instanziiert, spricht man nicht mehr von einer Variable, sondern von einem Objekt. Bei der Definition eines Objekts kann bei Eindeutigkeit das Schlüsselwort struct bzw. class weggelassen werden.

struct Circle circle1;
Circle cicle2;

Sollen mehrere Objekte desselben Typs definiert werden, können diese zu einem Objektfeld zusammengefasst werden.

Circle circles[4];

Der direkte Zugriff auf die Member eines Objekts erfolgt in der Art, dass zuerst der Objektname angegeben wird, dann der Operator . (ein Punkt) und anschließend der Membername.

Bei einem indirekten Zugriff wird anstelle des Operators . der Operator -> (Minuszeichen+spitze Klammer zu) angegeben.

#include <iostream>
#include <format>

import Graphics;   // Definiert in der Datei graphics.cxx

int main()
{
   // Kreisobjekt definieren
   Circle circle1;
   // Kreisdaten setzen
   circle1.SetData(10,10,5);
   // Kreis ausgeben
   circle1.Print();

   // Zeiger auf ein Kreisobjekt definieren
   // und mit Verweis auf circle1 initialisieren
   Circle *pmyCircle = &circle1;
   // Kreisdaten indirekt neu setzen und ausgeben
   pmyCircle->SetData(20,20,10);
   pmyCircle->Print();
}

Zugriffrechte (Daten-)Kapselung

Sehen wir uns nun den Unterschied zwischen den Klassentypen struct und class an. Der einzige Unterschied zwischen den Klassentypen liegt im voreingestellten Zugriffsrecht auf deren Member.

Auf die Member des Klassentyps struct kann standardmäßig beliebig zugegriffen werden, was auch der Grund ist, warum wir bisher nur diesen Klassentyp verwendet haben.

Auf Member des Klassentyps class dagegen kann standardmäßig nur von Memberfunktionen der gleichen Klassen aus zugegriffen werden.

#include <iostream>
#include <format>

// Klassentyp struct
// Alle Member sind standardmaessig oeffentlich
struct CircleStruct
{
   // Eigenschaften
   int xpos,ypos;
   unsigned int radius;
   // Memberfunktionen
   void SetData(int xp, int yp, unsigned int rad)
   {...}
   // Ausgabe der Eigenschaften
   void Print() const
   {...}
};

// Klassentyp class
// Alle Member sind standardmaessig nicht oeffentlich sondern
// der Zugriff kann nur von Memberfunktionen aus erfolgen
class CircleClass
{
   // Eigenschaften
   int xpos,ypos;
   unsigned int radius;
   // Memberfunktionen
   void SetData(int xp, int yp, unsigned int rad)
   {...}
   // Ausgabe der Eigenschaften
   void Print() const
   {...}
};

int main()
{
   // Objekt vom Typ struct definieren
   CircleStruct circle1;
   // Auf alle Member kann standardmaessig zugegriffen werden
   circle1.xpos = 99;
   circle1.SetData(20,20,5);
   circle1.Print();
   // Objekt vom Typ class definieren
   // Auf alle Member kann standardmaessig nur aus
   // Memberfunktionen heraus zugegriffen werden
   CircleClass circle2;
   circle2.xpos = 88;         // FEHLER!
   circle2.SetData(30,30,10); // FEHLER!
}

Dieses voreingestellte Zugriffsrecht kann durch Angabe der Zugriffsspezifizierer public: bzw. private: innerhalb der Klasse geändert werden. Auf alle Member die nach public: folgen kann beliebig zugegriffen werden, während auf Member die nach private: folgen nur von Memberfunktionen heraus zugegriffen werden kann. Die folgenden beiden Klassen verhalten sich damit identisch.

// Klassentyp struct
// Alle Member sind standardmaessig oeffentlich
struct CircleStruct
{
   // Eigenschaften schuetzen
private:
   int xpos,ypos;
   unsigned int radius;
   // Memberfunktionen veroeffentlichen
public:
   void SetData(int xp, int yp, unsigned int rad)
   {...}
   // Ausgabe der Eigenschaften
   void Print() const
   {...}
};

// Klassentyp class
// Alle Member sind standardmaessig nicht oeffentlich sondern
// der Zugriff kann nur von Memberfunktionen aus erfolgen
class CircleClass
{
   // Eigenschaften sind geschuetzt
   int xpos,ypos;
   unsigned int radius;
   // Memberfunktionen veroeffentlichen
public:
   void SetData(int xp, int yp, unsigned int rad)
   {...}
   // Ausgabe der Eigenschaften
   void Print() const {...}
};

Das Zugriffsrecht auf die Member kann beliebig oft geändert werden. Damit eine Klasse nicht zu unübersichtlich wird, sollte die Definition der Member strukturiert erfolgen, z.B.

  • zuerst alle private Eigenschaften und Memberfunktionen
  • und dann die public Eigenschaften und Memberfunktionen.

Objekte zuweisen

Objekte können genauso wie Variablen zugewiesen werden. Dabei werden alle Eigenschaften des Quellobjekts in das Zielobjekt kopiert.

Objekte und Funktionen

Objekte als Parameter

Werden Objekte an (Member-)Funktionen übergeben, sollte dies per Referenz erfolgen. Bei einer Übergabe per call-by-value werden die Eigenschaften des zu übergebenden Objekts zunächst in ein temporäres Objekt kopiert, dieses an die Funktion übergeben und nach der Rückkehr aus der Funktion wird das Objekt gelöscht. Dies kann unter Umständen sehr viel Zeit und Speicherplatz belegen.

Der Nachteil der Übergabe eines Objekts per Referenz ist, dass das Objekt innerhalb der Funktion unbeabsichtigt verändert werden kann. Soll eine Änderung des Objekts ausgeschlossen werden, ist das Objekt als const-Referenz zu übergeben. Die Funktion darf dann aber nur const-Memberfunktionen aufrufen, da ansonsten wieder Änderungen am Objekt möglich wären.

#include <iostream>
#include <format>

import Graphics;

// Objektuebergabe call-by-value
void CalledByValue(Circle param)
{
   std::cout << "CalledByValue:\n";
   param.SetData(88,88,11);
   param.Print();
}
// Objektuebergabe als Referenz
void CalledReference(Circle& param)
{
   std::cout << "CalledReference:\n";
   param.SetData(99,99,22);
   param.Print();
}
// Objektuebergabe als const-Referenz
void CalledConstReference(const Circle& param)
{
   std::cout << "CalledConstReference:\n";
   // Der Aufruf von SetDate() fuehrt zu einem Fehler,
   // da eine Aenderung am Objekt nicht erlaubt ist
   // param.SetData(77,77,33);
   // Der Aufruf von Print() erzeugt dagegen
   // keinen Fehler, da Print als const-
   // Memberfunktion definiert ist
   param.Print();
}

int main()
{
   // Objekt definieren und Eigenschaft setzen
   Circle circle1;
   circle1.SetData(11,11,5);
   std::cout << "Kreisdefinition:\n";
   circle1.Print();
   // Objekt call-by-value uebergeben
   // Aenderungen am Objekt in der Funktion haben
   // keine Auswirkung am Objekt in main()
   CalledByValue(circle1);
   std::cout << "main:\n";
   circle1.Print();
   // Objekt als Referenz uebergeben
   // Aenderungen am Objekt in der Funktion haben
   // Auswirkung am Objekt in main()
   CalledReference(circle1);
   std::cout << "main:\n";
   circle1.Print();
   // Objekt als const-Referenz uebergeben
   // Aenderungen am Objekt in der Funktion sind
   // nicht erlaubt
   CalledConstReference(circle1);
   std::cout << "main:\n"; circle1.Print();
}

Objekt als Rückgabewert

Wird aus einer Funktion ein Objekt zurückgegeben, ist immer das Objekt selbst und niemals eine Referenz darauf zurückzugeben.

import Graphics;

// Funktion die ein Kreisobjekt erstellt und
// dieses zurueckgibt
Circle CreateCircle()
{
   Circle circle1;
   circle1.SetData(20,20,5);
   return circle1;
}

int main()
{
   // Kreis erzeugen
   auto newCircle = CreateCircle();
   // Kreis ausgeben
   newCircle.Print();
}

Structured binding

Structured binding bezeichnet die Möglichkeit, mehrere Objekte oder Variablen in einer Anweisung zu definieren und dabei die public-Daten eines anderen Objekts zu übernehmen.

auto [var1,var2,...] = obj;

Die Anweisung definiert die Variablen varx und übernimmt die Eigenschaften aus obj in der Reihenfolgen ihrer Definition in die Variablen.

Außer bei Zuweisungen kann structured binding auch beim Durchlaufen von Feldern in einer range-for Schleife eingesetzt werden.

for (auto [var1,var2]: myArray)
{...}
#include <iostream>
#include <format>

// Definition eines Punkts
struct Point
{
   unsigned int xpos, ypos;
};
// Feld mit Punkten
Point myPoints[] {{10,10},{20,20},{30,30}};

int main()
{
   // Koordinate des 2. Punkts ausgeben
   auto [xp,yp] = myPoints[1];
   std::cout << std::format("XPos: {}, YPos: {}\n",xp,yp);
   // Alle Koordinaten der Punkte ausgeben
   for (auto [xp,yp]: myPoints)
      std::cout << std::format("XPos: {}, YPos: {}\n",xp,yp);
}

Da Klassen eine zentrale Rolle spielen, einmal eine Übung aus meinem Buch "C++ Programmierung".

#include <iostream>
#include <format>

// Klassendefinition
class Complex
{
   // Geschuetzte Eigenschaften
   double real = 0.0; // Real-Anteil
   double imag = 0.0; // Imaginär-Anteil
public:
   // Memberfunktionen
   void Init(decltype(real) r, decltype(imag) i);
   void AddComplex(const Complex& op);
   void SubComplex(const Complex& op);
   void PrintComplex() const;
};

// Definition der Memberfunktionen
// Komplexe Zahl initialisieren
void Complex::Init(decltype(real) r, decltype(imag) i)
{
   real = r; imag = i;
}
// 2 komplexe Zahlen addieren
void Complex::AddComplex(const Complex& op)
{
   real += op.real;
   imag += op.imag;
}
// 2 komplexe Zahlen subtrahieren
void Complex::SubComplex(const Complex& op)
{
   real -= op.real;
   imag -= op.imag;
}
// Komplexe Zahl ausgeben
void Complex::PrintComplex() const
{
   std::cout << std::format("Realteil:{:.4f} / Imaginaerteil:{:.4f}\n", real, imag);
}

// main() Funktion
int main()
{
   // 2 komplexe Zahlen (Objekte) definieren
   Complex number1, number2;
   // Objekte initialisieren
   number1.Init(1.1, 2.2);
   number2.Init(3.3, 4.4);
   // Objekte ausgeben
   std::cout << "1. Komplexe Zahl:\n";
   number1.PrintComplex();
   std::cout << "2. Komplexe Zahl:\n";
   number2.PrintComplex();
   // 2. Objekt zum 1. Objekt addieren
   number1.AddComplex(number2);
   // und Ergebnis ausgeben
   std::cout << "\nNach Addition 2. Zahl zur 1. Zahl\nNeue 1. Zahl:\n";
   number1.PrintComplex();
   // 1. Objekt vom 2. Objekt subtrahieren
   number2.SubComplex(number1);
   // und Ergebnis ausgeben
   std::cout << "\nNach Subtraktion der 1. Zahl von der 2. Zahl\n" "Neue 2. Zahl:\n";
   number2.PrintComplex();
}