Virtuelle Konstruktoren in C++

Wir kennen das Konzept der Polymorphie, die die dynamische Bindung (Dynamic Binding) auf Klassen-Funktionen ermöglicht. Der Vorteil der dynamischen Bindung ist die schwächere Kopplung zu Objekttypen. Wir müssen keine Typunterscheidung vornehmen, wenn wir eine Objektfunktion aufrufen, und dadurch ist unser Programm wartungsfreundlicher und erweiterbar.

In C++ erreichen wir das durch virtuelle Funktionen. Aber können wir dieses Konzept auch auf die Erstellung von Objekt-Typen anwenden? Also das unser Programm nicht schon beim Kompilieren wissen muss, welcher Typ zu erzeugen ist?

// konkreter Typ nicht bekannt, 
// wir kennen nur den Basis-Typ:
Base *obj = new Unbekannt();

Mit virtuellen Funktionen kann ein Modul Funktionen von unbekannten Objekt-Typen benutzen. Mit virtuellen Konstruktoren kann ein Modul unbekannte Objekt-Typen erzeugen.

Mögliches Szenario

Stellen wir uns folgendes Szenario vor: Wir haben ein Online-System, das eine Authentifizierung des Anwenders verlangt. Das Modul für den Login-Aufbau soll aber flexibel für mehrere Online-Systeme und deren Geheimhaltungsstufen arbeiten. Für nicht so spannende Daten reicht ein Name und Passwort aus. Für den Zugriff auf vertrauenswürdige Daten ist eine Zwei-Factor-Authentifizierung (z.B. mit RSA SecureID) nötig. Der Anwender kann sich auch immer wieder anmelden, z.B. nach einem Timeout, und muss deshalb den Login-Level kennen.

class Ticket; // Session Ticket (Fwd declaration)

struct LoginBase {
  virtual Ticket login() = 0;
};

// Einfache Login-Stufe
struct NamePasswordLogin : public LoginBase {
  Ticket login() { /* impl */ }
};

// Höhrere Login-Stufe
struct SecureIDLogin : public LoginBase {
  Ticket login() { /* impl */ }
};

Der jeweilige Login-Level erzeugt immer ein Session-Ticket. Die Entscheidung welches Login-Verfahren der Benutzer benötigt könnte per Switch-Case entschieden werden. Das User-Objekt wird mit einem Enum gefüttert:

// Login-Modul gibt dynamisch die Login-Stufen vor:
enum Level { low, high };
// ...
user->loginLevel(high);

Dann haben wir noch eine User-Klasse, die die Login-Verfahren erzeugt:

// Das User-Modul muss wissen, mit welcher Login-Stufe der User 
// sich anmelden soll oder kann
class User {
  Level m_loginLevel; // Enum-Abhängigkeit

public:
  void loginLevel(Level level) {
    m_loginLevel = level;
  }

  void login() {
    LoginBase *login; // abstrakte Abhängigkeit, gut!
    switch(m_loginLevel) {
      case low:
       login = new NamePasswordLogin; // konkrete Abhängigkeit, schlecht!
      break;
      case high;
       login = new SecureIDLogin; // konkrete Abhängigkeit, schlecht!
      break;
      // etwas vergessen? kommt noch was?
    }
    Ticket ticket = login->login(); // Konstruktor?
    // ...
  }
};

Was ist wenn morgen weitere Login-Stufen dazu kommen? Z.B. mit einer noch höheren Stufe oder eine sehr niedrige Test-Stufe für Integrations- und Unit-Tests? Was ist, wenn eine Login-Klasse komplett gestrichen wird?

Es müssen immer mind. zwei Module angepasst werden. Das Login-Modul selbst ist durch Vererbung zwar erweiterbar, muss aber auch das Enum anpassen. Das User-Modul muss immer an diese Veränderungen mit angepasst werden: die neuen Klassen müssen im Switch-Case-Block eingetragen werden und das Enum beachtet werden.

Starke Kopplung ist kein gutes Software-Design. Der Haken ist, das die Sprache uns keine virtuellen Konstruktoren bzw. keine Polymorphy bei der Erzeugung ermöglicht.

Factory Method Pattern

Es muss ein Workaround her: die Fabrikmethode, auch bekannt als virtueller Konstruktor.

Das Factory Method Pattern ist ein Workaround für virtuelle Konstruktoren.

Dabei bedienen wir uns der Polymorphie. Wie erstellen zu unseren Produkten (in unserem Fall Login-Klassen) parallel Login-Fabriken. Diese Fabriken nehmen die Rolle von virtuellen Konstruktoren ein. Die Fabriken haben dabei virtuelle create()-Funktionen:

// virtueller Konstruktor für LoginBase-Subklassen
struct LoginFactory {
 virtual LoginBase* create() = 0;
};

C++ kennt keine virtuellen Konstruktoren. Aber die Factory Method ist ein gutes Hilfsmittel um diese zu simulieren.

Wichtig ist, das die create()-Funktion eine Basisklasse als Rückgabetyp hat. Jetzt noch die speziellen Fabriken, die die konkreten Produkte erzeugen:

struct NamePasswordFactory : public LoginFactory {
 virtual LoginBase* create() {
   return new NamePasswordLogin;
 }
};
struct SecureIDFactory : public LoginFactory {
 virtual LoginBase* create() {
   return new SecureIDLogin;
 }
};

In unserem Szenario gibt es zwei konkrete Fabriken zu zwei konkreten Produkten. Unser User-Modul wird später aber nur noch die Basis-Klassen benutzen. Und die Fabriken selbst nicht erzeugen! Das würde ja wieder eine Abhängigkeit bringen.

Ein Framework oder Konfigurations-Modul sollte die Entscheidung treffen, mit welcher Login-Stufe der Anwender sich anmelden soll:

// Login-Modul füttert das User-Modul mit der Factorymethode (anstatt einem enum).
SecureIDFactory *secureIDFactory = new SecureIDFactory;
user->loginLevel(serureIDFactory);

Das kann durch eine Vorauswahl des Benutzers passieren, durch Konfiguration, Datenbankeintrag oder durch ein anderes Kriterium.

Das User-Modul selbst wird von alle dem nichts wissen. Es kennt nur zwei abstrakte Klassen: LoginFactory und LoginBase. Keine vererbten LoginBase-Klassen, keine vererbten Factories, nicht mal die Level oder Enums sind bekannt.

class User {
  LoginFactory *m_loginFactory; // abstrakte Abhängigkeit, kein Enum, gut!

public:
  void loginLevel(LoginFactory *factory) {
    m_loginFactory = factory;
  }

  void login() {
    // kein Switch-Case mehr
    LoginBase *login = m_loginFactory->create(); // Polymorphie, gut!
    Ticket ticket = login->login();
    // ...
  }
};

Damit hat das User-Modul nur die Abhängigkeit zur Basis-Factory und zum Basis-Login. Sollte in Zukunft das Login-Modul erweitert werden, muss das User-Modul nicht mehr angepasst werden. Eine lose Kopplung ist erreicht.

Wann benutzen wir das Factory Method Pattern?

Es gibt drei Kriterien die uns die Entscheidung vereinfachen soll:

  1. Gibt es eine Fallentscheidung, welcher Objekt-Typ erzeugt werden soll?
  2. Wird die Erzeugung wiederholt?
  3. Wird später vielleicht die Vererbungshierarchie erweitert?

Die Factory Method kann auch ein Baustein für Inversion of Control sein.