Objekt-orientiertes Design in C++: Datenkapselung und UML-Diagramme

Mit den drei Zugriffs-Spezifikationen (access modifier) public, protected und private wird festgelegt, welche Klasse auf welche Datenelemente und Methoden zugreifen kann. Mit ihrer Hilfe lässt sich die Datenkapselung realisieren. Die Zugriffs-Spezifikation ist ein Bestandteil der Deklaration von Datenelementen und Methoden; es wird gezeigt, wie man die Deklarationen einer Klasse übersichtlich in einem UML-Diagramm darstellen kann. UML-Diagramme sind somit eines der wichtigsten Hilfsmittel beim Entwurf komplexer Programme.

Einordnung des Artikels

In den bisherigen Kapiteln über C++: Fortgeschrittene Syntax wurde ein kleines Statistik-Projekt entwickelt. An diesem wird nun gezeigt:

  • Wie ein Projekt erweitert werden kann (ohne große Veränderungen an den bestehenden Quelltexte vorzunehmen).
  • Wie man mit den Zugriffs-Spezifikationen (access modifier) public und private Daten kapseln kann.
  • Insbesondere wird gezeigt, welche Zufallsgeneratoren die Standard-Bibliothek bereitstelllt und wie man einen Zufallsgenerator in das Projekt einbauen kann (wiederum, ohne große Veränderungen am Quelltext vorzunehmen).
  • Wie man die Deklarationen einer Klasse übersichtlich in UML-Diagrammen darstellt; sie werden üblicherweise beim Entwurf von Klassen eingesetzt.

Datenkapselung

Die Zugriffs-Spezifikation (access modifier)

Bei der Definition einer Klasse wurde bereits gesagt, dass die Datenelemente und Methoden entweder public oder private bezeichnet werden. Eine Erweiterung des Statistik-Projektes kann die Bedeutung dieser Zugriffs-Spezifikation (im Englischen werden public und private als access modifier bezeichnet) besser verständlich machen. In den Aufgaben zur strukturierten Programmierung wurden schon Erweiterungen (wie Berechnung der Varianz, Standardabweichung, Kovarianz, Korrelationskoeffizient) vorgeschlagen.

Fragt man sich, wie man diese Erweiterungen realisieren soll, fallen sofort zwei Punkte auf:

  1. Bisher gab es nur eine relevante statistische Funktion, nämlich der Mittelwert einer Zahlenfolge. Und diese Funktion war als statische Funktion realisiert — sie gehört also nicht zu einem Objekt sondern zur Klasse Statistics. Die neuen Funktionen werden dann sicher auch als statische Funktionen implementiert.
  2. Zur Berechnung der Varianz und der Kovarianz ist es hilfreich, eine weitere statische Funktion anzubieten, nämlich das Skalarprodukt zweier Zahlenfolgen:

(x1, x2, ... , xn) • (y1, y2, ... , yn) =∑i xi yi

Der erste Punkt spricht dafür, das Skalarprodukt und die statistischen Funktionen gleichberechtigt zu behandeln. Dagegen spricht der zweite Punkt für eine Unterscheidung zwischen dem Skalarprodukt und den anderen statistischen Funktionen. Denn das Skalarprodukt ist eine reine Hilfsfunktion, die dem Programmierer die Arbeit erleichtert, wenn er die anderen statistischen Funktionen implementieren muss. Im Gegenatz dazu werden die statistischen Funktionen vom Nutzer des Programmes aufgerufen (im bisher entwickelten Projekt war dies der Client). Das Skalarprodukt sollte für den Nutzer nicht sichtbar sein, da es eigentlich nicht zu den statistischen Funktionen gehört — und hier kein Geometrie-Programm entwickelt werden soll.

Aber dieser Unterschied erklärt gerade die Verwendung der access modifier public und private:

  1. Datenelemente und Methoden sind public, wenn sie nach außen sichtbar sein sollen: andere Klassen können auf ihre Informationen zugreifen und ihre Funktionalitäten nutzen.
  2. Datenelemente und Methoden sind private, wenn sie nicht nach außen sondern nur innerhalb der eigenen Klasse sichtbar sein sollen.

Indem man für jedes Datenelement und jede Methode eine Zugriffs-Spezifikation festlegt, kann man bestimmte Daten (also Datenelemente und auch Algorithmen, die darauf zugreifen) in einer Klasse kapseln. Die Vorteile dieser Datenkapselung liegen auf der Hand:

  1. Die öffentlichen Methoden werden üblicherweise schon beim Entwurf eines Programmes festgelegt und sollten sich danach nicht mehr ändern; es sollen höchstens Erweiterungen hinzukommen, die sich aber bei einem geschickten Entwurf leicht verwirklichen lassen.
  2. Da man beim Entwurf bereits alle Teile des Programmes und ihre Verantwortlichkeiten definiert, kann man in dieser Phase rein auf der Ebene der Deklarationen von öffentlichen Datenelementen und Methoden arbeiten.
  3. Zum Zeitpunkt des Entwurfes muss man noch nicht exakt wissen, wie später die Tätigkeiten der Objekte als Algorithmen implementiert werden. Da diese aber nach außen nicht sichtbar sind, können diese privaten Teile einer Klasse weitgehend unabhängig von den öffentlichen Schnittstellen der Klasse entwickelt werden (weitgehend unabhängig, da sie natürlich die Deklarationen der öffentlichen Datenelemente und Methoden beachten müssen).
  4. Und sollte sich im Lauf der Entwicklung des Programmes herausstellen, dass einige dieser privaten Teile anders (besser, schneller, effektiver) realisiert werden können, ist es nicht schwer diese Teile auszutauschen; die öffentlichen Schnittstellen sollten davon nicht betroffen sein.

Anwendung auf das Statistik-Projekt

Mit diesem Hintergrundwissen kann man leicht die Deklarationen der Klasse Statistics angeben (so dass die oben beschriebenen Erweiterungen enthalten sind); achten Sie insbesondere auf die access modifier:

// Statistics.h
// enthält die Deklarationen der Klasse Statistics

#ifndef STATISTICS_H_INCLUDED
#define STATISTICS_H_INCLUDED

#include<vector>

class Statistics
{
public:

// Berechnet den Mittelwert eines Vektors (Zufallsfolge randomSequence = rs)
static double mean(std::vector<int> rs);

// Berechnet die Varianz eines Vektors (Zufallsfolge randomSequence = rs)
static double variance(std::vector<int> rs);

// Berechnet die Standardabweichung eines Vektors (Zufallsfolge randomSequence = rs)
static double standardDeviation(std::vector<int> rs);

// Berechnet die Kovarianz zweier Vektoren
static double covariance(std::vector<int> a, std::vector<int> b);

// Berechnet den Korrelationskoeffizient zweier Vektoren
static double correlation(std::vector<int> a, std::vector<int> b);

// =================================
//     Regressionsgerade y = ax + b
// =================================

// Berechnet die Steigung a der Regressionsgerade
static double slope(std::vector<int> a, std::vector<int> b);

// Berechnet den y-Abschnitt (y-intercept) b der Regressionsgerade
static double getYIntercept(std::vector<int> a, std::vector<int> b);

private:

// Berechnet das Skalarprodukt zweier Vektoren
// Für den Fall unterschiedlicher Länge: Abbruch der Summation beim Minimum der Längen
static double scalarProduct(std::vector<int> a, std::vector<int> b);
};

#endif // STATISTICS_H_INCLUDED

Aufgaben:

1. Hier verwenden alle Methoden call by value. Diskutieren Sie: Welcher Übergabe-Mechanismus ist vorzuziehen call by value oder call by reference?

2. Erweitern Sie Ihr Statistik-Projekt um obige Deklarationen und verwenden Sie den geeigneten Übergabe-Mechanismus!

3. Implementieren Sie die noch fehlenden Methoden; wenn Sie die Methoden in einer geschickten Reihenfolge implementieren, übernimmt die private Methode scalarProduct() nahezu die gesamte Arbeit.

Einbau eines Zufallsgenerators

So wie die Klasse RandomGenerator bisher implementiert wurde, hält sie nicht was sie verspricht: Die Methode createRandomSequence() erzeugt zwar eine Zahlenfolge (mit dem gewünschten Wertebereich zwischen min und max), es ist aber noch keine Zufallsfolge.

Sie sollten auch nicht versuchen, einen Zufallsgenerator selber zu implementieren: die Aufgabe ist deutlich schwerer als sie erscheint.

Aber es gibt in der Standard-Bibliothek zwei Zufallsgeneratoren zur Auswahl, die man in die Klasse RandomGenerator einbauen kann.

Die Zufallsgeneratoren aus der Standard-Bibliothek

Zufallszahlen, die mit Hilfe des Computers erzeugt werden, entspringen immer Algorithmen und sind somit nur Pseudo-Zufallszahlen. Das heißt sie sind eigentlich periodische Zahlenfolgen; allerdings ist die Periodenlänge derart groß, dass man sie bei gewöhnlichen Anwendungen nicht erkennt und die Zahlen doch wie Zufallsfolgen erscheinen.

Selbst die Implementierung eines Algorithmus, der Pseudo-Zufallszahlen erzeugt, ist sehr schwer; insbesondere kann es leicht passieren, dass man an den produzierten Folgen — selbst weit unterhalb der Periodenlänge — Regelmäßigkeiten ablesen kann, etwa dass es bevorzugte Frequenzen gibt. Das soll heißen, die Zufallsfolge beinhaltet zwar alle Zahlen xmin, ... xmax mit gleicher Wahrscheinlichkeit; bei näherer Betrachtung stellt man aber fest, dass es eine Zahl n gibt, so dass eine Zahl x der Folge nach n, 2n, 3n, ... Wiederholungen des Zufallsexperimentes mit deutlich erhöhter Wahrscheinlichkeit auftritt.

Es gibt zwei Möglichkeiten, wie man mit Hilfe der Standard-Bibliothek Zufallszahlen erzeugen kann; und diese Zufallsgeneratoren unterscheiden sich in ihrer Qualität, wobei es für einfache Anwendungen auf die Unterschiede nicht ankommen sollte:

1. Die ältere Version, die man mit

#include <cstdlib>

inkludiert; sie besitzt insbesondere den Funktionen rand() und srand(). Für ernsthafte Anwendungen (wie zur Verschlüsselung) wird sie ausdrücklich nicht empfohlen. Für eine Anwendung wie hier — um Testdaten zu erzeugen — reicht sie allemal.

Die anspruchsvolleren, neuen Zufallsgeneratoren (seit dem Standard C++11), die man in der Standard-Bibliothek unter Numerics library, Pseudo-random number generation findet. Sie bieten mehrere Generatoren unterschiedlicher Qualität, die man zusätzlich mit unterschiedlichen Verteilungen konfigurieren kann.

Der Zufallsgenerator aus cstdlib

Der Zufallsgenerator aus cstdlib besteht im Wesentlichen aus zwei Funktionen, die nach dem Inkludieren von cstdlib wie globale Funktionen eingesetzt werden können:

  1. Mit Hilfe von srand() wird der Zufallsgenerator initialisiert (s steht für seed).
  2. Mit rand() wird eine Zufallszahl (vom Datentyp int) zwischen 0 und RAND_MAX erzeugt; den Zahlenwert von RAND_MAX sollten Sie vor dem Gebrauch bestimmen.

Bei der Verwendung dieser Funktionen ist zu beachten:

  • Ohne Initialisierung wird der Zufallsgenerator automatisch mit srand(1) initialisiert.
  • Wird der Zufallsgenerator immer mit derselben Zahl n initialisiert, so wird auch immer wieder dieselbe Zufallsfolge erzeugt.
  • Um Letzteres zu vermeiden, kann man den Zufallsgenerator vor jedem neuen Gebrauch mit der aktuellen Uhrzeit oder der Zeitspanne seit dem Programmstart initialisieren (aus ctime, siehe Beispiel unten).
  • Die Konstante RAND_MAX ist maschinenabhängig.
  • Die Zufallszahlen zwischen 0 und RAND_MAX sind gleichverteilt.
  • Um Zufallszahlen in einem gewünschten Bereich zu erzeugen, muss man die von rand() gelieferten Zahlen geeignet transformieren (siehe Beispiel unten).

In der Klasse RandomGenerator wird dazu die Methode createRandomSequence() abgeändert:

// Für den Einbau des Zufallsgenerators relevante Teile aus RandomGenerator.cpp

#include <ctime>
#include<cstdlib>

// Erzeugt eine Zufallsfolge mit int-Zahlen von min bis max der Länge number
vector<int> RandomGenerator::createRandomSequence(int number)
{
    int diff = max - min + 1;
    vector<int> randomSequence(number);

    srand(clock());             // Initialisierung des Zufallsgenerators
    for (int i = 0; i < number; i++)
    {
        randomSequence[i] = (rand() % diff) + min;       // Anpassen der Zufallszahl an den Zahlenbereich
        //cout << "in createRandomSequence: " << randomSequence[i] << endl;       // nur zum Testen
    }

    return randomSequence;
}

Zur Erklärung:

1. Zeile 3: Um auf die Funktion clock() zuzugreifen, muss ctime inkludiert werden. Deren Erklärung findet sich in:

C reference -> Date and time utilities

2. Zeile 4: In cstdlib befinden sich die Funktionen rand() und srand().

3. Zeile 12: Initialisierung des Zufallsgenerators mit Hilfe von clock().

4. Neu im Vergleich zur Vorgängerversion der Methode createRandomSequence() ist lediglich der Aufruf des Zufallsgenerators in Zeile 15. Nachdem der Zufallsgenerator einmal initialisiert wurde, liefert die Funktion rand() die nächste Zahl der Folge. Die Anpassung an den gewünschten Zahlenbereich ist wie in der Vorgängerversion.

5. Der Aufruf der Methode createRandomSequence() hat sich im Vergleich zur Vorgängerversion ebenfalls nicht geändert.

Die Vorteile dieser Lösung sind:

  1. Da man nur globale Funktionen aufruft, kann man ganz leicht einen Zufallsgenerator einbauen: Beachten Sie wie klein die Unterschiede zur Vorgängerversion von createRandomSequence() sind!
  2. Da die Funktion clock() bei jedem Programm-Aufruf schon eine Zufallszahl erzeugt, wird der eigentliche Zufallsgenerator jedesmal neu initialisiert und liefert bei aufeinanderfolgenden Programm-Aufrufen (mit hoher Wahrscheinlichkeit) andere Zufallsfolgen.

Der Nachteil muss aber auch genannt werden:

Die öffentliche Methode createRandomSequence() besitzt jetzt zwei Aufgaben:

  • Konfiguration des Zufallsgenerators und
  • Erzeugen der Zufallsfolge.

Und wenn man das Statistik-Projekt erweitern möchte — etwa wenn Zufallsfolgen ohne Laplace-Annahme erzeugen möchte — besteht die Gefahr, dass die Konfiguration des Zufallsgenerators mit einer selbstdefinierten Verteilungsfunktion (anstelle der Laplace-Annahme) ebenfalls in der Methode createRandomSequence() erfolgt.

Verbesserte Version der Methode createRandomSequence()

Betrachtet man nochmal genau, welche Aufgaben die Methode createRandomSequence() in der aktuellen Version oben erfüllt, so muss man unterscheiden:

  1. Es wird ein Objekt eines Vektors erzeugt (nötig, um den richtigen Rückgabetyp bereitzustellen).
  2. Der Zufallsgenerator wird initialisiert.
  3. Der Zufallsgenerator wird aufgerufen.
  4. In der Schleife wird die Zufallsfolge mit der gewünschten Länge erzeugt; mit Hilfe der beiden Zahlen min und max wird dafür gesorgt, dass die Elemente der Zufallsfolge den richtigen Wertebereich besitzen.

Angesichts dieser Fülle von Aufgaben, ist es naheliegend, eine weitere private Methode

private:

	int nextRandom();

anzubieten, die alle Aufgaben übernimmt, die den Zufallsgenerator betreffen. Allerdings ist es nicht nötig, bei jedem Aufruf von nextRandom() den Zufallsgenerator zu initialisieren; dies kann einmal geschehen und somit am Besten im Konstruktor.

In der Methode createRandomSequence() wird dann nur noch in der Schleife nextRandom() anstelle von rand() aufgerufen. Auch das Anpassen an die Zahlen min und max kann bereits in nextRandom() erfolgen.

Die relevanten Teile von RandomGenerator.h und RandomGenerator.cpp lauten jetzt:

// Für den Einbau des Zufallsgenerators relevante Teile aus RandomGenerator.h
// Deklaration der Klasse RandomGenerator, die Zufallsfolgen gewünschter Länge erzeugen kann
// Ein Objekt spezifiziert den Wertebereich der Zahlenfolge (siehe Konstruktor)

#ifndef RANDOMGENERATOR_H_INCLUDED
#define RANDOMGENERATOR_H_INCLUDED

#include<vector>
#include<cstdlib>
#include<random>

class RandomGenerator
{
public:

    //=========================================
    //          Konstruktoren und Destruktor
    //=========================================

    // Konstruktor zum Setzen von min und max und zusätzlicher Überprüfung der Eingabe sowie Initialisierung des Zufallsgenerators
    RandomGenerator::RandomGenerator(int minimum, int maximum, bool validate);
    
    // ... (weitere Konstruktoren)
    
    //===========================
    //          Methoden
    //===========================

    // Erzeugt Zufallsfolge mit int-Zahlen von min bis max der Länge number
    std::vector<int> createRandomSequence(int number);
    
    // ... (weitere Methoden)
    
private:

    int min;
    int max;    
    
    // Erzeugt die nächste Zufallszahl im richtigen Zahlenbereich
    int nextRandom();
    
};

#endif // RANDOMGENERATOR_H_INCLUDED
//  Für den Einbau des Zufallsgenerators relevante Teile aus RandomGenerator.cpp

    //=========================================
    //          Konstruktoren und Destruktor
    //=========================================

// Konstruktor zum Setzen von min und max und zusätzlicher Überprüfung der Eingabe sowie Initialisierung des Zufallsgenerators
RandomGenerator::RandomGenerator(int minimum, int maximum, bool validate)
{
    if (!validate)          // keine Überprüfung von minimum und maximum
    {
        init(minimum, maximum);
    }
    else                // Überprüfung von minimum und maximum
    {
        cout << "Überprüfung" << endl;
        if (minimum < maximum)
        {
            init(minimum, maximum);
        }
        else
            if (minimum == maximum)
            {
                cout << "Achtung: minimum == maximum!" << endl;
                init(minimum, maximum);
            }
            else
            {
                cout << "Achtung: Sie haben minimum > maximum eingegeben!\nDie Zahlen werden richtig sortiert!" << endl;
                init(maximum, minimum);          // Achtung: Vertauschung der Reihenfolge
            }
    }
    int time = clock();
    srand(time);            // Initialisierung des Zufallsgenerators
}

// ... (weitere Konstruktoren)

    //===========================
    //          Methoden
    //===========================

// Erzeugt eine Zufallsfolge mit int-Zahlen von min bis max der Länge number
// und greift dabei auf die private Methode nextRandom() zurück
vector<int> RandomGenerator::createRandomSequence(int number)
{
    vector<int> randomSequence(number);

    for (int i = 0; i < number; i++)
    {
        randomSequence[i] = nextRandom();
        //cout << "in createRandomSequence: " << randomSequence[i] << endl;       // nur zum Testen
    }

    return randomSequence;
}

// Erzeugt die nächste Zufallszahl im richtigen Zahlenbereich
int RandomGenerator::nextRandom()
{
    int result;
    int diff = max - min + 1;
    result = (rand() % diff) + min;
    return result;
}

// ... (weitere Methoden)

Aufgaben

1. Erweitern Sie Ihr Statistik-Projekt um den Zufallsgenerator, der jetzt in der Methode nextRandom() enthalten ist.

2. Achten Sie dabei darauf, dass Sie das Delegations-Prinzip für die Konstruktoren richtig einsetzen: Wenn Sie es falsch einsetzen, kann es leicht passieren, dass es einen Konstruktor gibt, der den Zufallsgenerator nicht initialisiert.

3. Erweitern Sie Ihr Projekt, so dass auch Zufallsfolgen für einen gezinkten Würfel erzeugt werden können.

Hinweis: Überlegen Sie sich zuerst, wie der Zufallsgenerator konfiguriert werden muss, um einen Würfel mit folgenden Wahrscheinlichkeiten zu simulieren:

P(1) = 1/12, P(2) = 1/6, P(3) = 1/6, , P(4) = 1/6, P(5) = 1/6, P(6) = 3/12.

Wie lässt sich dann ein beliebiger Würfel simulieren?

4. Informieren Sie sich, welche Zufallsgeneratoren in der Numerics library (C++11) angeboten werden. Implementieren Sie die Methode nextRandom() so, dass einer dieser Zufallsgeneratoren verwendet wird. (Man sieht hier: Für eine konsequente Umsetzung der Trennung der Aufgaben, sollte auch das Initialisieren des Zufallsgenerators in eine eigene private Methode ausgelagert werden, da sich beim Einabu eines neuen Zufallsgenerators dort Änderungen vorgenommen werden müssen.)

UML-Diagramme

Einführung

Die letzten beiden Abschnitte sollten nochmals verdeutlichen, wie wichtig es beim Entwurf einer Klasse ist (und erst recht bei einem Projekt, das aus vielen Klassen besteht), Entscheidungen auf einer abstrakten Ebene zu treffen: es kommt vorerst weniger auf die konkrete Realisierung des Quelltextes an, sondern auf die Aufteilung der Aufgaben und die entsprechenden Deklarationen für die Datenelemente und Methoden. Um dies zu erleichtern, gibt es als Hilfsmittel die sogenannten UML-Klassendiagramme, die alle — auf dieser Ebene — relevanten Informationen über eine Klasse (oder über ein ganzes Projekt) kompakt darstellen.

Die Abkürzung UML steht für Unified Modeling Language; in dieser Sprache werden Diagramme nach einer gewissen Syntax für Modelle erstellt. Es gibt mehrere Typen von UML-Diagrammen, wie Anwendungsfall-Diagramm (use-case), Aktivitätsdiagramm, Zustandsdiagramm und so weiter. Für den objektorientierten Entwurf von Anwendungen ist mit Abstand das Klassendiagramm der wichtigste Diagrammtyp.

In einem Klassendiagramm werden die Datenelemente und Methoden einer Klasse dargestellt, siehe Abbildung 1.

Abbildung 1: UML-Klassendiagramm, das in drei Abschnitte unterteilt ist &mdash; Klassenname, Datenelemente und Methoden.Abbildung 1: UML-Klassendiagramm, das in drei Abschnitte unterteilt ist — Klassenname, Datenelemente und Methoden.

UML-Klassendiagramm für RandomGenerator

Im UML-Klassendiagramm werden nicht nur die Datenelemente und Methoden einer Klasse dargestellt, es werden zusätzlich gezeigt:

  • die Konstruktoren werden wie Methoden dargestellt (der Konstruktor ist leicht als solcher zu erkennen, da hier der Funktions-Name mit dem Namen der Klasse übereinstimmt)
  • die Datentypen der Datenelemente
  • die Datentypen der Eingabewerte von Methoden
  • die Datentypen der Rückgabewerte der Methoden (der Datentyp void kann weggelassen werden)
  • die Zugriffs-Spezifikation (access modifier) der Datenelemente und Methoden; dabei steht ein Pluszeichen für public und ein Minuszeichen für private.

Abbildung 2 zeigt das UML-Klassendiagramm für RandomGenerator.

Abbildung 2: UML-Klassendiagramm für RandomGenerator (Erklärung im Text).Abbildung 2: UML-Klassendiagramm für RandomGenerator (Erklärung im Text).

Das Klassendiagramm enthält dieselbe Information, die in den Deklarationen der Klasse RandomGenerator enthalten ist; allerdings gibt es keine Kommentare — aber sie erübrigen sich, wenn man für die Datenelemente, Methoden und deren Eingabewerte treffende Namen wählt. Zu erkennen sind in Abbildung 2:

  1. Als Datenelemente gibt es nur die beiden privaten Attribute min und max.
  2. Die Methoden enthalten die Konstruktoren:
    • Default-Konstruktor
    • Konstruktor mit zwei Eingabewerten
    • Konstruktor mit drei Eingabewerten (das Delegations-Prinzip ist nicht erkennbar)
    • Kopier-Konstruktor
    • Destruktor.
  3. Als weitere Methoden:
    • die beiden öffentlichen Methoden createRandomSequence() und printSequence()
    • die öffentlichen Setter und Getter für min und max
    • die privaten Methoden init() (übernimmt die Aufgabe des Konstruktors mit zwei Eingabewerten bei der Realisierung der Delegation) und nextRandom() (für den Zufallsgenerator)
    • wie oben besprochen, wäre es noch sinnvoll eine private Methode anzubieten, die die Initialisierung des Zufallsgenerators übernimmt.
Alle Kommentare
Durch die Nutzung dieser Website erklären Sie sich mit der Verwendung von Cookies einverstanden. Außerdem werden teilweise auch Cookies von Diensten Dritter gesetzt. Genauere Informationen finden Sie in unserer Datenschutzerklärung sowie im Impressum.