Dieser und noch weitere Artikel wurde von rik erstellt.


Folgende Themen werden von diesem Artikel berührt:


Druckversion des Artikels


Inhalt

1. Einleitung
2. Typschaltung mit Funktionspointer-Tabellen in C
3. Implementierung des Objekt-Switching Patterns
4. Verallgemeinerung des Objekt-Switching Patterns
4.1 OnError-Policy
4.2 Singleton-Muster
4.3 Dummy Typ-Parameter
4.4 Implementierung des allgemeinen Objekt-Switching Patterns
5. Typschaltung globaler Funktionen
6. Typschaltung polymorpher Elementfunktionen
7. Erzeugung polymorpher Objekte
8. Schlusswort
9. Referenzen

1. Einleitung

Manchmal ist ein C- oder C++-Programmierer in der bedauernswerten Situation, sich mit ellenlangen switch-Anweisungen der Form

C++:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
//...
switch(condition)
{
  case condition_1:
    //....
    break;
  case condition_2:
    //
     break;
  //...
  case condition_n:
    //...
    break;
  default:
    //...
}
//...


herumplagen zu müssen. Das wäre zunächst mal kein Problem, solange man selber nicht betroffen ist. Leider musste ich schon mehr als ein Review über derartig gestaltete Switch-Gräber ertragen. Das Einzige, was in solchen Fällen hilft, ist Ausdauer und eine volle Kanne mit schwarzem Kaffee der Marke Xtra-Strong. Ganz Hartgesottene schrecken auch nicht davor zurück, verschachtelte Switch-Anweisungen zu produzieren, die sich über hunderte von Zeilen erstrecken. Leider können solche Implementierungen weder vernünftig gewartet noch getestet werden. Wer zu derartigen Konstrukten neigt, sollte sich den folgenden Artikel unbedingt durchlesen.
Dieser Artikel befasst sich mit einem Thema, das ich als Object-Switching bezeichnet habe. Object-Switching behandeln schlicht und ergreifend das immer wiederkehrende Problem, anhand eines Typ-Identifiziers eine Entscheidung treffen zu müssen. Object-Switching ist an das Design-Pattern der Object-Fabriken angelehnt, welches die Erzeugung polymorpher Objekte behandelt.

C++:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
//...
Base * pObject;
 
switch(condition)
{  
  case condition_1:
    pObject = new Derived;
    break;
  case condition_2:
    pObject = new AnotherDerived;
    break;
  //...
}
//...


Bem.: Dies ist in der Tat eine einfache Objekt-Fabrik

Das nachfolgend vorgestellte Design-Pattern ist für polymorphe und nicht polymorphe Implementierungen gleichermaßen geeignet. Wie wir am Ende des Artikels sehen werden, kann das Muster auch als Objekt-Fabrik verwendet werden. Im Folgendem möchte ich mit euch ein Objekt-Switching-Pattern entwickeln, welches das zu Anfang dargestellte Problem löst.
Es wird vorausgesetzt, dass alle aufrufbaren Entitäten (wie z. Bsp. Funktoren, Funktionsobjekte, etc.) über eine einheitliche Schnittstelle verfügen. Die Schnittstelle ist einheitlich, wenn der Typ des Return-Values und der Parameterliste für alle aufrufbaren Entitäten identisch ist.

Selbstverständlich haben Switch-Anweisungen ihre Berechtigung, sonst wären sie kein Sprachelement von C/C++. Allerdings sollte man nur darauf zurückgreifen, wenn die Anzahl der zu treffenden Entscheidungen nicht überhand nimmt. Mir fallen zu Switch-Case-Anweisungen im Wesentlichen die folgenden Eigenschaften ein:


2. Typschaltung mit Funktionspointer-Tabellen in C

In C werden solche statischen Konstrukte häufig durch Funktionspointer-Tabellen ersetzt. Der Code wird durch diese Maßnahme applizierbar, da für eine Änderung lediglich eine Funktion in der Tabelle geändert, eingetragen oder entfernt werden muss. Eine einfache Implementierung könnte z.B. wie folgt aussehen:

C++:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
typedef void (*fptr)(void); ///< Definition eines Funktionspointers
typedef struct fptr_tab_s   ///< Definition einer Struktur um Bedingung und Funktion zu verknüpfen
{
  int   condition;        
  fptr  func;
} fptr_tab;

#define CONDITION_1 2042

#define CONDITION_2 1012
//...
#define CONDITION_n 5211
 
fptr_tab array_fptr[] =
{
  {CONDITION_1, function_1}, ///< der Bedingung Condition_1 wird die Funktion function_1 zugeordnet.
  {CONDITION_2, function_2},
  //....
  {CONDITION_n, function_n}
};

#define MAX_COUNT_ARRAY_FPTR sizeof(array_fptr)/sizeof(fptr_tab)

 
int array_condition[] =
{
  CONDITION_3,
  CONDITION_2,
  CONDITION_9,
  //...
  CONDITION_8,
  //...
};

#define MAX_COUNT_ARRAY_CONDTION sizeof(array_condition)/sizeof(int)

 
//...
 
for(k = 0; k != MAX_COUNT_ARRAY_CONDITION; ++k)
  for(j = 0; j != MAX_COUNT_ARRAY_FPTR; ++j)
    if(array_fptr[j].condition == array_condition[k])
      array_fptr[j].func();  //Funktion aufrufen
 
//...    

Bem.: Die Tabelle array_condition[] simuliert Ereignisse, die z.B. zur Laufzeit angestoßen werden.

Implementierungen, die Funktionspointer-Tabellen verwenden, sind wesentlich übersichtlicher, da Ablauf und Logik an zentraler Stelle in einer Tabelle beschrieben werden. Ich habe die Erfahrung gemacht, dass solche Tabellen auch von Nicht-Programmierern (z. B. Verfahrenstechnikern) gepflegt werden können.

Die Eigenschaften von Implementierungen, die Funktionspointer-Tabellen verwenden, lassen sich wie folgt zusammenfassen:


3. Implementierung des Objekt-Switching Patterns

Betrachen wir nun aber eine echte C++-Lösung, die zur Laufzeit den Typidentifizierer und die aufrufbare Entität speichert. Zur Speicherung der Daten bietet sich die Zuordnungsliste std::map aus der Standardbibliothek an. Der Container std::map gehört zu den assoziativen Containern, der seine Elemente nach einem frei wählbaren Schlüsselwert sortiert hält. Assoziative Container sind intern als binärer Baum organisiert, was ein schnelles Auffinden der Elemente anhand des Schlüssels ermöglicht. Die Datenelemente bestehen aus Schlüssel-/Datenpaaren vom Typ std::pair. Um Elemente in einen assoziativen Container einzufügen, muss ein Objekt vom Typ std::pair erzeugt werden.

C++:
1
2
3
4
5
6
7
8
9
10
void insert_into_map(void)
{
  typedef std::map<int,std::string> s_map;
  bool success;
  s_map string_map;
  success = string_map.insert(s_map::value_type(1,"Hallo")).second;
  if(success)
    success = string_map.insert(s_map::value_type(2,"OK")).second; //success = true
  success = string_map.insert(s_map::value_type(2,"Not OK")).second;   //success = false
}

Bem.: value_type ist eine Typedefinition des Containers std::map, mit dessen Hilfe der Paartyp erzeugt wird. Alternativ könnte auch std::make_pair verwendet werden.

Bem.: Die Member-Funktion pair<iterator,bool> insert(const value_type& value) gibt ein Werte-Paar zurück, das einen Iterator auf das soeben eingefügte Element (first) und einen boolschen Wert (second) enthält. Der boolsche Wert gibt Auskunft darüber, ob die Einfügeoperation erfolgreich war.

Um Elemente zu löschen, verwenden wir die Member-Funktion erase.

C++:
1
2
3
4
5
6
7
8
//..
typedef std::map<int,std::string> s_map;
bool success(false);
s_map string_map;
int key(1);
//...
success = (string_map.erase(key) == 1);
//...

Bem.: Die Member-Funktion erase gibt die Anzahl der gelöschten Elemente zurück.

Das Suchen von Elementen erfolgt mit der Member-Funktion find, die einen Iterator zurückgibt.

C++:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
//..
typedef std::map<int,std::string> s_map;
s_map string_map;
//...
 
const std::string & get_value(int key)
{
  s_map::const_iterator s_iter = string_map.find(key);
  if(s_iter == string_map.end())
  {
    throw std::runtime_error("key not found");
  }
  return s_iter->second;
}
//...

Bem.: Falls das gesuchte Element nicht gefunden werden kann, wird eine Exception vom Typ std::runtime_error ausgelöst.

Mit diesem Wissen können wir eine Templateklasse schreiben, welches die Funktionalitäten von std::map auf die benötigten Elemente einschränkt.

C++:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
#include <map>
 
template <typename Key, typename Data>
class register_table
{
public:
  typedef Key key_type;
  typedef Data data_type;
  typedef std::map<key_type,data_type> table_type;
  typedef typename table_type::value_type value_type;
  typedef typename table_type::iterator   iterator;
  typedef typename table_type::const_iterator   const_iterator;
private:
  table_type callback_map;
public:
  bool erase(key_type key)
  {
    return (callback_map.erase(key) == 1);
  }
  bool insert(key_type key, data_type data)
  {
    return callback_map.insert(value_type(key,data)).second;
  }
  data_type find(key_type key) const
  {
    const_iterator cb_iter(callback_map.find(key));
    if(cb_iter == callback_map.end())
      throw std::runtime_error("key not found");
    return cb_iter->second;
  }
};

Bem.: Im public-Bereich der Klasse werden einige Typdefinitionen getätigt, um lästige Schreibarbeiten zu reduzieren. Außerdem finde ich, dass Ausdrücke wie std::map<key_type,data_type>::value_type den Source-Code ziemlich unleserlich machen. Des Weiteren benötigen wir natürlich Methoden zum Hinzufügen, Löschen und Finden von Einträgen.

Schreiben wir gleich ein kleines Programm, um unsere Template-Klasse zu testen.

C++:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
#include <iostream>
#include <vector>
#include "register_table.h"
using namespace std;
 
void fun1(void)
{
  cout << "fun1 " << endl;
}
void fun2(void)
{
  cout << "fun2 " << endl;
}
void fun3(void)
{
  cout << "fun3 " << endl;
}
int main()
{
  typedef void (*fptr_type) (void);
  typedef register_table<int, fptr_type> my_table_type;
  my_table_type my_table;
  std::vector<int> cond_v;
 
  cond_v.push_back(2);
  cond_v.push_back(1);
  cond_v.push_back(3);
  cond_v.push_back(2);
  cond_v.push_back(2);
  cond_v.push_back(1);
  cond_v.push_back(3);
 
  my_table.insert (1,fun1);
  my_table.insert (2,fun2);
  my_table.insert (3,fun3);
 
  try
  {
    for (size_t idx = 0 ; idx != cond_v.size(); ++idx)
      my_table.find(cond_v[idx])();
  }
  catch(std::runtime_error & e)
  {
    cout << e.what() << endl;
  }
  return 0;
}

Die Ausgabe liefert das erwartete Ergebnis

Code:
fun2
fun1
fun3
fun2
fun2
fun1
fun3


4. Verallgemeinerung des Objekt-Switching Patterns

4.1 OnError-Policy

Die Methode find wirft eine Exception vom Typ std::runtime. Besser ist es natürlich, wenn der Benutzer unserer Template-Klasse festlegen kann, wie die Klasse im Fehlerfall reagieren soll. Um dies zu erreichen, müssen wir lediglich einen weiteren Template-Parameter hinzufügen. Wird keiner angegeben, soll sich die Klasse wie bisher verhalten.

Für den Default-Fall stellen wir eine Implementierung mit einer statischen Methode zur Verfügung.

C++:
struct DefaultError
{
  static void process_error()
  {
    throw std::runtime_error("key not found");
  }
};

Da die Methode process_error() statisch ist, müssen keine Objekte erzeugt werden.

C++:
1
2
3
4
5
6
7
8
9
10
11
12
template <typename Key, typename Data, typename OnError=DefaultError>
class register_table
{
//..
  data_type find(key_type key) const
  {
    const_iterator cb_iter = callback_map.find(key);
    if(cb_iter == callback_map.end())
      OnError::process_error();
    return cb_iter->second;
//...
}

Bem.: Die Parameter-Liste einer Template-Klasse darf Default-Typen enthalten. Sie werden verwendet, falls kein Typ angegeben wird. Die Default-Typen dürfen nur am Ende der Parameter-Typ-Liste erscheinen.

Der Benutzer der Template-Klasse hat nun die Möglichkeit, das Verhalten der Methode find von außen zu steuern. Solche Klassen werden in der Literatur als Policy-Klassen bezeichnet. Sie geben eine syntaktische Schnittstelle vor, die strikt eingehalten werden muss.

C++:
struct IgnoreError
{
  static void process_error() {} //do nothing
};
//...
typedef register_table<int,fptr_type,IgnoreError> my_table_type;

Wenn die Methode process_error() keine Exception wirft, fehlt das Kriterium, ob der zurückgegebene Datensatz gültig ist. Deshalb ist die Verwendung der Klasse IgnoreError keine gute Idee, da nun das Gültigkeitskriterium auf andere Weise nachgeliefert werden muss. Wird der Default-Parameter OnError vom Aufrufer belegt, sollte er eine Error-Policy implementieren, die eine spezifische Exception wirft.

4.2 Singleton-Muster

Unsere Klasse soll so verändert werden, dass nur eine einzige Instanz erzeugt werden kann. Außerdem soll ein globaler Einstiegspunkt zur Verfügung stehen.

Die einfachste Art, das Singleton-Muster zu implementieren, ist die Verwendung einer lokalen statischen Variablen. Dieses Design-Muster wurde erstmalig von Scott Meyers vorgeschlagen.

C++:
Singleton & instance()
{
  static Singleton obj;
  return obj;
}

Beim ersten Aufruf der Funktion wird das Objekt erzeugt und initialisiert. Wenn wir die Singleton-Funktion als statische Methode unserer Klasse implementieren, können wir den Konstruktor privatisieren. Auf diese Weise ist sichergestellt, dass nur eine Objektinstanz erzeugt werden kann. Der Anwender unserer Klasse kann somit keine Instanz der Klasse mehr erzeugen. Stattdessen verwendet er die Elementfunktion instance, um eine Referenz auf das Objekt zu erhalten.
Damit eröffnet sich die Möglichkeit, Funktionspointer dezentral zu registrieren. Die Deklaration der zu registrierenden Funktionen musste bisher der Registrierungstabelle bekannt sein. Nun können wir einen anderen Weg einschlagen. Jedes Modul inkludiert die Registrierungstabelle und trägt die zu registrierende Funktion ein. Damit ist die Verantwortlichkeit an eine andere Stelle verschoben worden. Für die Initialisierung muss lediglich eine statische Dummy-Variable angelegt werden. Da statische Variablen vor dem Aufruf der main-Funktion initialisert werden, sind beim Programmstart alle Einträge in der Registrierungstabelle vorhanden. Ich hoffe, dass jetzt auch klar wird, warum die Methode insert einen boolschen Wert zurückliefert.

C++:
1
2
3
4
5
6
7
8
9
10
// my_function.cpp
#include "register_table.h"
 
static bool dummy(  instance().insert(1,fun1)
                  &&instance().insert(2,fun2)
                  &&instance().insert(3,fun3)
                 );
void fun1() {...}
void fun2() {...}
void fun3() {...}

Bem.: Es gibt keine Festlegung in welcher Reihenfolge statische Variablen in den jeweiligen Modulen initialisiert werden.

Diese Herangehensweise ist sehr wartungsfreundlich, da nicht mehr in vorhandenen Source-Code eingegriffen werden muss. Stattdessen können wir durch Hinzufügen von Modulen Funktionalitäten erweitern.

4.3 Dummy Typ-Parameter

Da wir das Singleton-Muster implementieren, können wir pro Typ genau eine Instanz erzeugen. Das beschränkt leider die Einsatzmöglichkeiten unserer Template-Klasse, da in manchen Programmen mehrere unabhängige Register-Tabellen desselben Typs benötigt werden. Wir lösen das Problem, indem wir unserer Template-Klasse einen zusätzlichen Default-Dummy-Pararmeter spendieren. Dadurch können wir beliebig viele Typen generieren, ohne die interne Datenstruktur der Registrierungs-Tabelle zu ändern.

C++:
1
2
3
4
5
6
7
8
9
template <typename Key, typename Data, int Inst = 0, typename OnError=DefaultError>
class register_table
{
  //...
}
//...
typedef register_table<int, fptr_type>   my_table_type1; //1. Instanz (Dummy-Template-Parameter)
typedef register_table<int, fptr_type,1> my_table_type2; //2. Instanz
typedef register_table<int, fptr_type,2> my_table_type3; //3. Instanz


4.4 Implementierung des allgemeinen Objekt-Switching Patterns

Nehmen wir nun alle Zutaten und erweitern unsere Template-Klasse um

Natürlich müssen wir auch eine Implementierung der Default-Error-Policy zur Verfügung stellen. Die Template-Klasse könnte auf folgende Weise realisiert werden.

C++:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
#ifndef __register_table_4_02_05_2006__
#define __register_table_4_02_05_2006__
//----------------------------------------------------------------------------------------------
#include <map>
#include "default_policy.h"
//----------------------------------------------------------------------------------------------
template <typename Key, typename Data, int Inst = 0, typename OnError=DefaultError>
class register_table
{
public:
  typedef Key key_type;
  typedef Data data_type;
  typedef std::map<key_type,data_type> table_type;
  typedef typename table_type::value_type value_type;
  typedef typename table_type::iterator   iterator;
  typedef typename table_type::const_iterator   const_iterator;
private:
  table_type callback_map;
  register_table(){}
  register_table(const register_table &); //Deaktiviere Copy-Ctor
  register_table & operator=(const register_table &);
public:
  ~register_table(){}
//----------------------------------------------------------------------------------------------
  bool erase(key_type key)
  {
    return (callback_map.erase(key) == 1);
  }
//----------------------------------------------------------------------------------------------
  bool insert(key_type key, data_type data)  
  {
    return callback_map.insert(value_type (key,data)).second;
  }
//----------------------------------------------------------------------------------------------
  data_type find(key_type key) const        
  {
    const_iterator cb_iter(callback_map.find(key));
    if(cb_iter == callback_map.end())
      OnError::process_error();
    return cb_iter->second;
  }
//----------------------------------------------------------------------------------------------
  static register_table<Key,Data,Inst,OnError> & instance()
  {
    static register_table<Key,Data,Inst,OnError> rt;
    return rt;
  }
};
//----------------------------------------------------------------------------------------------
#endif


5. Typschaltung globaler Funktionen

Im ersten Anwendungbeispiel für unsere Template-Klasse werden globale Funktionen registriert und abhängig von einer Bedingung aufgerufen. Wir betrachten sowohl die Registrierung durch statische Initialisierung als auch die Registrierung innerhalb der main-Funktion. Außerdem nutzen wir die Möglichkeit, mehrere Instanzen der Registrierungstabelle zu erzeugen.

C++:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
#include <iostream>
#include <vector>
#include "register_table2.h"
using namespace std;
 
void fun1(void)
{
  cout << "fun1 " << endl;
}
void fun2(void)
{
  cout << "fun2 " << endl;
}
void fun3(void)
{
  cout << "fun3 " << endl;
}
typedef void (*fptr_type) (void);
 
typedef register_table<int, fptr_type,2> my_table_type2;   ///< Typdefinition der ersten Registrierungstabelle
#define __REG__(id,fun) (my_table_type2::instance().insert(id,fun)) ///< Makro, um Elemente in Reg. Tab einzufügen
#define __EXEC__(id) (my_table_type2::instance().find(id)())        ///< Makro, um Funktion aufzurufen
 
static bool dummy ( __REG__(1,fun3) &&  ///< Registrierung durch statische Initialisierung
                    __REG__(3,fun2) &&
                    __REG__(2,fun1) );
int main()
{
  typedef register_table<int, fptr_type,1> my_table_type; ///< Typdefinition der zweiten Registrierungstabelle
  std::vector<int> cond_v;    ///< Tabelle, um Bedingungen zu speichern
 
  cond_v.push_back(2);
  cond_v.push_back(1);
  cond_v.push_back(3);
  cond_v.push_back(2);
  cond_v.push_back(2);
  cond_v.push_back(1);
  cond_v.push_back(3);
 
  my_table_type::instance().insert (1,fun1);  ///< Registrierungen zur Programmlaufzeit
  my_table_type::instance().insert (2,fun2);
  my_table_type::instance().insert (3,fun3);
 
  try
  {
    for (size_t idx = 0 ; idx != cond_v.size(); ++idx)
      my_table_type::instance().find(cond_v[idx])();   ///< Syntax wirkt wahrscheinlich eher abschreckend
 
    cout << "-----------------------------" << endl;
 
    for (size_t idx = 0 ; idx != cond_v.size(); ++idx)
      __EXEC__(cond_v[idx]);                           ///< einfach anzuwenden
  }
  catch(std::runtime_error & e)
  {
    cout << e.what() << endl;
  }
  return 0;
}


Die Makros __REG__ und __EXEC__ wurden lediglich zur Vereinfachung der Syntax eingeführt. Mit Hilfe der Makros können die Funktionen nun ganz einfach registriert und ausgeführt werden.

Die Ausgabe liefert das erwartete Ergebnis
Code:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
fun2
fun1
fun3
fun2
fun2
fun1
fun3
-----------------------------
fun1
fun3
fun2
fun1
fun1
fun3
fun2


Unser Modul hat folgende Eigenschaften:


6. Typschaltung polymorpher Elementfunktionen

Schauen wir uns noch ein weiteres Anwendungsbeispiel an. Diesmal verwende ich Funktoren aus der Loki-Bibliothek, um Elementfunktionen von polymorphen Objekten aufzurufen. Hauptaufgabe des Funktors ist die Speicherung von aufrufbaren Entitäten. Wie die Funktoren der Loki-Bibliothek im Detail funktionieren, möchte ich hier aber nicht erläutern, da dieses Thema Stoff für einen eigenständigen Artikel böte. Im Buch Modern C++ werden Funktoren sehr ausführlich beschrieben. Wenn ihr das Beispiel nachprogrammieren möchtet, könnt ihr die Loki hier downloaden. Ihr müsst dann entweder die Units smallobj.cpp und singleton.cpp aus der Loki-Bibliothek oder die Loki.lib zu eurem Projekt dazulinken.

C++:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
#include <iostream>
#include <vector>
#include <string>
#include "register_table3.h"
#include <Loki/Functor.h>
using namespace std;
//----------------------------------------------------------------------------------------------
class Base  
{
public:
  Base() {cout << "ctor Base" << endl;}
  virtual void do_smth()  const {cout << "call Base::do_smth()" << endl;}
  virtual ~Base() {cout << "dtor Base" << endl;}
};
//----------------------------------------------------------------------------------------------
class Derived : public Base
{
public:
  Derived() {cout << "ctor Derived" << endl;}
  virtual void do_smth() const {cout << "call Derived::do_smth()" << endl;}
  virtual ~Derived() {cout << "dtor Derived" << endl;}
};
//----------------------------------------------------------------------------------------------
class AnotherDerived : public Base
{
public:
  AnotherDerived() {cout << "ctor AnotherDerived" << endl;}
  virtual void do_smth() const {cout << "call AnotherDerived::do_smth()" << endl;}
  virtual ~AnotherDerived() {cout << "dtor AnotherDerived" << endl;}
};
//----------------------------------------------------------------------------------------------
template <typename Class> Class * instance() ///< schon wieder ein Singleton Pattern :-)
{
  static Class obj;
  return &obj;
}
//----------------------------------------------------------------------------------------------
// 1. Param -> Returntyp der Memberfunktion;
// 2. Param (optional) Typliste mit Aufrufparametern der Memberfunktion;                  
typedef Loki::Functor<void> PTMF_callback;
 
typedef register_table<int, PTMF_callback> reg_tab_polymorph; ///< Typdefinition der Registrierungstabelle
//----------------------------------------------------------------------------------------------
template <typename Class> bool Register(int id)
{
  return reg_tab_polymorph::instance().insert(id,PTMF_callback(instance<Class>(),&Class::do_smth));  
}
//----------------------------------------------------------------------------------------------
void Execute(int id)
{
  reg_tab_polymorph::instance().find(id)();
}
static bool dummy(   Register<Base>(0)               ///< Registrierung durch statische Initialisierung
                  && Register<Derived>(1)
                  && Register<AnotherDerived>(2)
                 );
 
//----------------------------------------------------------------------------------------------
int main()
{
  try
  {
    cout << std::string(40,'-') << endl;
    for (int idx = 0 ; idx != 3; ++idx)      
      Execute(idx);
    cout << std::string(40,'-') << endl;
  }
  catch(std::runtime_error & e)
  {
    cout << e.what() << endl;
  }
  return 0;
}
//----------------------------------------------------------------------------------------------


Die Ausgabe liefert wieder das erwartete Ergebnis.

Code:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
ctor Base
ctor Base
ctor Derived
ctor Base
ctor AnotherDerived
----------------------------------------
call Base::do_smth()
call Derived::do_smth()
call AnotherDerived::do_smth()
----------------------------------------
dtor AnotherDerived
dtor Base
dtor Derived
dtor Base
dtor Base


7. Erzeugung polymorpher Objekte

Kommen wir nun zu einem letzten ausführlicheren Beispiel. Wie eingangs erwähnt können wir mit dem Object-Switching-Pattern auch eine Objekt-Fabrik implementieren. Es sollen Grafikobjekte aus einer Datei eingelesen und am Bildschirm dargestellt werden. Solche Problemstellungen werden nur selten in C++-Büchern abgehandelt, da sie etwas ausführlicher diskutiert werden müssen. Da es in diesem Artikel aber um dieses Thema geht, stellen wir uns dem Problem. Entwerfen wir also eine einfache Hierarchie von Grafikobjekten. Zunächst benötigen wir einige einfache Datentypen.

C++:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
//----------------------------------------------------------------------------------------------
struct point
{
  int x;
  int y;
};
//----------------------------------------------------------------------------------------------
struct gui_elem_file_s
{
  int id;            ///< Identifizierer
  point origin;      ///< Ursprungs-Koordinaten
  const char * data; ///< inhomogene Daten
};
#endif


Die Struktur point wird zum Speichern von Koordinaten benötigt. gui_elem_file_s ist eine Struktur, die in binären Files gespeichert wird. Im Element data werden die objektspezifischen Daten abgelegt.

Als Basisklasse für alle Grafikobjekte dient die Klasse Shape. Sie ist nicht vollständig beschrieben, da sie nur die Elemente enthält, die für das Einlesen und Ausgeben von Shape-Objekten relevant sind.

C++:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#ifndef __shape_04_06_2006_hdr__
#define __shape_04_06_2006_hdr__
#include <string>
//----------------------------------------------------------------------------------------------
struct gui_elem_file_s;  ///< Vorwärtsdeklarationen
struct point;
//----------------------------------------------------------------------------------------------
class Shape
{
protected:
  point * origin_;      ///< Pointer auf Ursprungskoordinaten
private:
  virtual void parse(const std::string & buffer) = 0;           ///< Objekt einlesen
  virtual std::string & display(std::string & str) const = 0;   ///< Objekt ausgeben
  virtual std::string & whoami(std::string & str) const = 0;    ///< Wer bin ich
public:
  Shape();                                             ///< ctor
  Shape(const Shape & obj);                            ///< copy ctor
  Shape & operator=(const Shape & obj);                ///< Zuweisungsoperator    
  std::string & display_data(std::string & str) const; ///< NVI-Funktion
  void parse_data(const gui_elem_file_s & buffer);     ///< NVI-Funktion
  virtual ~Shape();
};
//----------------------------------------------------------------------------------------------
#endif


Um die Struktur point vorwärts deklarieren zu können, wird die Membervariable origin_ als Pointer implementiert. Aus dieser Entscheidung resultiert, dass wir den Konstruktor, den Copy-Konstruktor sowie den Zuweisungsoperator per Hand implementieren müssen. Die meisten Designer bevorzugen es, virtuelle Elementfunktionen privat zu deklarieren. Aus diesem Grund werden die öffentlichen, nicht virtuellen Elementfunktionen display_data und parse_data definiert. Die NVI-Elementfunktionen sind Wrapper für die privaten polymorphen Elementfunktionen. Diese Technik wird häufig als Nicht Virtuelles Interface (NVI) bezeichnet.

C++:
1
2
3
4
5
6
7
8
9
10
11
std::string & Shape::display_data(std::string & str) const
{
  std::string tmp;
  return whoami(str)
        .append(" x = ")
        .append(boost::lexical_cast<std::string>(origin_->x))  
        .append(" y = ")
        .append(boost::lexical_cast<std::string>(origin_->y))
        .append(" ")
        .append(display(tmp));
}

Bem.: Implementierung der NVI-Elementfunktion display_data. Sie klammert die privaten virtuellen Elementfunktionen whoami und display. Hätten wir nicht auf diese Technik zurückgegriffen, müssten wir die Ausgabe der Koordinaten in jede virtuelle Elementfunktion implementieren.

Definieren wir nun noch einige von Shape abgeleitete Klassen. Ein Kreis ist sicherlich ein sinnvolles Zeichenobjekt. Jede von Shape abgeleitete Klasse definiert eine ID, die zum Identifizieren der Klasse dient.

C++:
1
2
3
4
5
6
7
8
9
10
11
12
#include <string>
#include "Shape.h"
//----------------------------------------------------------------------------------------------
class Circle : public Shape
{
public:
  enum {ID = 0};
private:
  int radius_;
  //...
};
//----------------------------------------------------------------------------------------------


Die Implementierungsdatei circle.cpp birgt keine Überraschungen mehr. Das Einzige, auf das es sich hier nochmals hinzuweisen lohnt, ist die Initialisierung der statischen Variablen dummy mit der Registrierungsfunktion.

C++:
#include "circle.h"
//.. weitere Includes
 
static bool dummy(Reg<Circle>());  //Registrierung von Circle
 
//.. Implementierungsdetails


Die Registrierungsfunktion ist als Template implementiert und trägt die ID sowie die dazugehörige Creator-Funktion in die Registrierungstabelle ein. Außerdem müssen wir noch die Typdefinition für die Callback-Funktion und die Registrierungstabelle schreiben. Der Funktor speichert Zeiger auf Funktionen bzw. Methoden, die einen Pointer auf ein Shape-Objekt zurückliefern.

C++:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
//----------------------------------------------------------------------------------------------
typedef Loki::Functor<Shape*> PTMF_callback; //Funktor der Shape-Erzeuger-Funktionen speichert
typedef register_table<int, PTMF_callback> reg_tab_polymorph; //Typdefinition der Registrierungstabelle
//----------------------------------------------------------------------------------------------
template <typename Class> bool Reg()
{rators für den Pointer
  typedef Class *(*f_ptr)(void);
  f_ptr f_ptr_creator(&create<Class>); ///< Angabe des Adressoperators für Templatefunktionen
  return reg_tab_polymorph::instance().insert(Class::ID,PTMF_callback(f_ptr_creator));
}
//----------------------------------------------------------------------------------------------
template <typename Class>
Class * create()
{
  return new Class;
}
//----------------------------------------------------------------------------------------------

Bem.: Überraschenderweise muss der Adressoperator für den Pointer auf die Template-Funktion angegeben werden. Nicht-Template-Funktionen benötigen ihn nicht.
Bem.: Die Registrierungsfunktion Reg musste ich für den Borland-Compiler umschreiben. Er konnte die Funktion nicht kompilieren, weil der Funktionspointer auf &create<Class> direkt als Argument für den Funktor übergeben wurde.

Die Klasse Rectangle und Square sind ähnlich wie Circle implementiert. Bemerkenswert ist vielleicht, dass Square nicht von Rectangle (Ein Quadrat ist ein Rechteck), sondern von Shape abgeleitet ist. Dadurch wird ein Verstoß gegen das liskovsche Substitutionsprinzip (LSP) vermieden.

Würde Square von Rectangle abgeleitet, könnte dies im Kontext eines Grafikprogramms falsch sein, da mit diesem üblicherweise grafische Elemente verändern werden können. So lässt sich z.B. bei Rechtecken die Länge der beiden Seiten unabhängig voneinander ändern. Für ein Quadrat gilt das jedoch nicht, denn nach einer solchen Änderung wäre es kein Quadrat mehr. Hat also die Klasse Rechteck Methoden wie set_width() oder set_height() (wurde im Beispiel zur besseren Übersicht weggelassen), so erbt Square die Methoden, obwohl deren Anwendung für ein Quadrat nicht erlaubt ist. Das LSP verlangt, dass die Bedeutung von Eigenschaften der Basis-Klassen in abgeleiteten Klassen nicht verändert wird.

Nach soviel Vorbereitung sollten wir nun endlich den Vorhang für unsere Objekt-Fabrik aufmachen. Das Programm ist Gott sei Dank recht einfach gestrickt. Wir können uns also entspannt zurücklehnen. Das Array gui_elem_array[] simuliert die Datei, in der die Daten der Grafikobjekte gespeichert sind. Außerdem definiert das Hauptprogramm noch das Funktionsobjekt DeleteObj, das wir benötigen, um den Speicher für die Objekte mit Hilfe von std::for_each freizugeben.

Kommen wir zum Programmablauf:


C++:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
#include <iostream>
#include <vector>
#include <algorithm>
#include "shape_types.h"
#include "shape.h"
#include "shape_reg.h"
using namespace std;
using gfx::Shape;
using gfx::gui_elem_file_s;
using gfx::reg_tab_polymorph;
//----------------------------------------------------------------------------------------------
// gui_elem_array[] simuliert Datei, in der die Metainformationen über Grafikobjekte vorliegen.
const gui_elem_file_s gui_elem_array[]  =
{
   { 0 ,{ 5, 5}, "5"    } //Kreis mit Radius 5
  ,{ 1 ,{10,10}, "7;8"  } //Rechteck mit Breite 7 und Höhe 8
  ,{ 2 ,{20,15}, "3"    } //Quadrat mit Seitenlänge 3
  ,{ 1 ,{35,47}, "13;40"} //Rechteck mit Breite 13 und Höhe 40
  ,{ 2 ,{60,80}, "99"   } //Quadrat mit Seitenlänge 99
};
//----------------------------------------------------------------------------------------------
Shape * Create(int id) ///< gibt registrierte Creator-Funktion auf Grafikobjekt zurück
{
  return reg_tab_polymorph::instance().find(id)();
}
//----------------------------------------------------------------------------------------------
struct DeleteObj ///< Funktionsobjekt zum Löschen von dynamisch allokierten Objekten
{
  template <typename T> inline void operator() (T * object) const
  {
    delete object;
  }
};
//----------------------------------------------------------------------------------------------
int main()
{
  try
  {
    std::vector<Shape *> shape_v;
    std::string data;
    for(int idx = 0; idx != sizeof(gui_elem_array)/sizeof(gui_elem_file_s); ++idx)
    {
      shape_v.push_back(Create(gui_elem_array[idx].id)); ///< Füllt Vector mit Grafikobjekt-Pointer
      shape_v[idx]->parse_data(gui_elem_array[idx]);     ///< Grafikobjekte mit Daten füllen
      cout << shape_v[idx]->display_data(data) << endl;  ///< Grafikobjekte ausgeben
    }
    std::for_each(shape_v.begin(),shape_v.end(),DeleteObj()); ///< alle Grafikobjekt-Pointer löschen
  }
  catch(std::runtime_error & e)
  {
    cerr << e.what() << endl;
  }
  return 0;
}
//----------------------------------------------------------------------------------------------


Bem.: alle Shape-Klassen und Funktionen liegen im Namensraum gfx

Die Ausgabe zeigt die erzeugten Grafikobjekte.

Code:
Circle    x = 5 y = 5 radius = 5
Rectangle x = 10 y = 10 width = 7 height = 8
Square    x = 20 y = 15 length = 3
Rectangle x = 35 y = 47 width = 13 height = 40
Square    x = 60 y = 80 length = 99


Irgendwie finde ich das Beispiel cool. Obwohl die Header-Dateien für Circle, Rectangle und Square nicht in das main-Programm eingebunden sind, können Kreise, Rechtecke und Quadrate dargestellt werden. Das main-Programm weiß nicht einmal, dass diese Grafikobjekte existieren. Wenn es viele von Shape abgeleitet Klassen gibt, können durch diese Art der losen Kopplung die Zeiten für das Kompilieren eines Projektes drastisch reduziert werden.

8. Schlusswort

Ich hoffe, dass euch das Objekt-Switching-Muster gefallen hat. Vielleicht wandert das Design-Pattern ja in eure Trickkiste. Das Muster ist vielseitig einsetzbar und kann einiges an manueller Kodierungsarbeit ersparen. Die Codestellen habe ich mit dem Visual-C++-Compiler 2005 und dem Borland C++ Builder 6 übersetzt. Sie sollten aber mit jedem standardkonformen C++-Compiler kompiliert werden können. Den vollständigen Source-Code für das Objekt-Fabrik-Beispiel könnt ihr hier als Zip-Archiv downloaden.

9. Referenzen

ISBN:3827322979

Effektiv C++ programmieren von Scott Meyers
ISBN: 3827322979

ISBN:0201749629

Effective STL von Scott Meyers
ISBN: 0201749629

ISBN:3826613473

Modernes C++ Design. Generische Programmierung und Entwurfsmuster angewendet von Andrei Alexandrescu
ISBN: 3826613473

Sie können Kommentare zu diesem Artikel im Forum schreiben. (Eine Registrierung ist nicht notwendig.)

Logo-Design: MastaMind Webdesign