Templates (C++)

Aus der Mikrocontroller.net Artikelsammlung, mit Beiträgen verschiedener Autoren (siehe Versionsgeschichte)
Wechseln zu: Navigation, Suche

Templates in C++ — mitunter auch Schablonen genannt — erlauben die "generische" Programmierung.

Es gibt Templates für Funktionen oder Methoden ebenso wie für Klassen.

Bei der Programmierung von Mikrocontrollern in C++ sind Templates deshalb interessant, weil sie zum Zeitpunkt der Kompilierung greifen und damit zu weniger Overhead führen (können) als universelle Bibliotheksfuntionen, deren Leistungsumfang von einer konkreten Applikation nur teilweise benutzt wird. Da Templates nur einmalig hinterlegt sind, vermeiden sie die Nachteile der Copy-und-Paste-Programmierung.

Ähnlich wie parametrisierte Makros kann man Templates als Quelltexte verstehen, die einmal geschrieben und häufig eingesetzt werden – jeweils mit mehr oder weniger großen Variationen. Insbesondere lassen sich mit Templates

  • Datentypen[1] und
  • Compilezeit-Konstanten

parametrisieren, das heißt bis zu einer konkreten Verwendung einer Template (= Instanziierung) offen halten.


Funktionstemplates

Als Beispiel hier eine Template(-Funktion) zum Vertauschen von zwei Ganzzahlen:

template<typename T>
void swap(T* xp, T* yp) {
     T tmp; tmp = *xp; *xp = *yp; *yp = tmp;
}

Die erste Zeile erklärt dabei dem C++-Compiler, dass das nachfolgend verwendete Symbol T einen beliebigen Typnamen repräsentiert. Ohne diese Zeile würde man eine Fehlermeldung wegen eines undefinierten Namens erhalten.

Bei der späteren, tatsächlichen Verwendung dieser Funktion kann man einen konkreten Typ angeben:

int a, b;
double c, d;
struct s e, f;
...
swap<int>(&a, &b);
swap<double>(&c, &d);
swap<struct s>(&e, &f);

oder – noch einfacher – lässt den Compiler aus dem Typ des verwendeten Template-Arguments auf den Typ von T schließen[2]:

swap(&a, &b);
swap(&c, &d);
swap(&e, &f);

Beim Programmieren mit Templates sollte allerdings auf die Minimierung von Abschnitten geachtet werden, für die der erzeugte Code nicht mit den Template-Parametern variiert, zumindest dann, wenn eine Template typischerweise in ein und demselben Quelltext mehrfach mit unterschiedlichen Parametern verwendet wird.

Für das obige Beispiel ist das insofern der Fall, als Zuweisungen in der Template-Funktion Maschinencode erzeugen werden, der für die unterschiedlichen Datentypen jeweils verschieden ausfällt z. B. ein MOV-Befehl für 2 oder 4 Byte bei für die Verwendung mit int, 8 Byte für double und vielleicht einen memmove-Aufruf für die Strukturzuweisung.

Andererseits könnte der Aufruf von swap mit den Datentypen long und float dazu führen, dass aufgrund der Template zwei Funktionen instanziiert werden, die vom Maschinencode her identisch sind (wenn float und long beide 4 Byte haben). Das selbe gilt bei der Verwendung von swap mit verschiedenen Struktur-Typen der selben Größe.


Hinweis
Um den berühmt-berüchtigten "Code-Bloat" durch Templates zu vermeiden, ist also eine gute Kenntnis der Hintergründe des Template-Mechanismus unabdingbar.


Klassentemplates

Ein typisches Beispiel für Templateklassen sind Container, also Datentypen, die irgendwelche anderen Werte aufnehmen sollen (Listen, Vektoren, Maps, Sets, ...). Ohne Templates müsste man eine Liste schreiben, um int-Werte zu speichern, eine weitere (praktisch identische) für double-Werte, und so weiter. Mit Templates schreibt man einmal die gesamte Funktionalität der Liste und lässt dabei den Typ der gespeicherten Daten einfach offen als Parameter des Templates (template < typename T > class Liste...). Zur Verwendung gibt man den gewünschten Parameter (z.B. int) hinter dem template-Namen an (Liste< int >) und der Compiler erzeugt dann aus dem Template genau eine vollständige Klasse, indem er das Template Liste nimmt und darin T durch int ersetzt.

Ein kleines lauffähiges Beispiel einer Liste:

template < typename T >
class Liste
{
public:

  // Konstruktor
  Liste()
    : m_listenanfang( NULL )
  {
  }

  // Destruktor
  virtual ~Liste()
  {
    delete m_listenanfang;
  }

  void einfuegen( T neuerwert )
  {
    m_listenanfang = new Listenelement( neuerwert, m_listenanfang );
  }

  void drucke() const
  {
    for( Listenelement * i=m_listenanfang; i!=NULL; i=i->m_next )
    {
      std::cout << "" << i->get() << std::endl;
    }
  }

private:
  // nicht implementiert
  Liste( const Liste& );
  Liste& operator=( const Liste& );

  class Listenelement
  {
  public:
    // Konstruktor
    Listenelement( const T &wert, Listenelement *next = NULL )
      : m_daten( wert ),
        m_next( next )
    {
    }

    // Destruktor
    virtual ~Listenelement()
    {
      delete m_next;
    }

    const T &get()
    {
      return m_daten;
    }

    T                 m_daten;
    Listenelement *   m_next;
  };

  Listenelement  *m_listenanfang;
};

Die Verwendung würde so aussehen:

  ...
  Liste< int >   meineliste;
  meineliste.einfuegen( 42 );
  meineliste.einfuegen( 25 );

  meineliste.drucke();
  ...

Entsprechend würde der Compiler eine komplette weitere Klasse erstellen, wenn er auf Liste<double> trifft, und so weiter für jeden weiteren mit Liste<> verwendeten Datentyp.

Deshalb muß man sich bewusst sein, dass bei unvorsichtiger Verwendung mit Templates schnell viel Programmcode erzeugt wird. Hier ist das kein Problem, weil Container wie die Liste üblicherweise wenige einfache Operationen enthalten. Wenn eine Klasse aber viel Code enthält und in vielen Ausprägungen benötigt wird, ist es gegebenenfalls sinnvoller, eine Basisklasse zu schreiben, in der möglichst viel gemeinsamer Code untergebracht ist, und die Ausprägungen dann als Ableitungen zu erzeugen (evtl. als Template-Klasse, mit dem gemeinsamen Code als Basisklasse).

Ein sinnvolles Beispiel für Templateklassen findet sich zu Festkommazahlen in [[1]]. Hier steckt die gesamte Logik zu Festkommaarithmetik in einer Templateklasse, und über die Template-Parameter kann gesteuert werden, ob intern mit 8, 16, oder 32 Bit gerechnet wird, welcher Datentyp für interne Berechungen genutzt werden soll, und welche Skalierung genutzt werden soll (wo also das Komma zu stehen hat).

Spezialisierung

Manchmal könnte man mit einem Template viele Fälle vereinfachen, hat aber bei bestimmten Parametern Probleme.

Beispiel: Man schreibt sich eine Templatefunktion max(), die zwei Parameter bekommt und den größeren der beiden zurückliefert:

template < typename T >
T max( T a, T b)
{
  return ( a>b ? a : b );
}
...
    int i = 12, j = 42;
    ... max( i, j ) ... // liefert 42
...

Das funktioniert gut, solange man Werte hat, die sinnvoll mit dem Operator > verglichen werden können.

Ruft man das Makro mit Strings auf (max( "Emil", "Otto" )), dann wird natürlich nicht nach der alphabetischen Reihenfolge der Strings entschieden, sondern es werden vom Template die Adressen der Anfangsbuchstaben mit > verglichen.

Möchte man nun für die meisten Datentypen den Vergleich mit > haben, allerdings für die nullterminierten C-Strings lieber strcmp() verwenden, um einen lexikalischen Vergleich zu haben, schreibt man sich eine Spezialisierung:

// template für die meisten Datentypen:
template < typename T >
T max( T a, T b)
{
  return ( a>b ? a : b );
}

// Spezialisierung für nullterminierte Strings:
template<>
const char *max<const char *>( const char *a, const char *b)
{
  return ( std::strcmp( a, b )>0 ? a : b );
}
...
    int i = 12, j = 42;
    ... max( i, j ) ... // liefert 42 aus dem allgemeinen Template
    ... max( "Emil", "Otto" ) ... // verwendet die Spezialisierung
...

Man kann bei Bedarf mehrere Spezialisierungen angeben.

Analog kann man Templateklassen spezialsiieren.


Fußnoten

  1. Früher hatten Templates in C++ daher den Namen Parametrized Types
  2. Voraussetzung dafür ist, dass das Template eine Funktion und keine Klasse beschreibt und dass der parametrisierte Typ T aus den aktuellen Parametern erkannt werden kann. Hier: erwartet wird ein Zeiger auf T, übergeben werden mit &a und &b konkret Zeiger auf int, also muss es sich bei dem Typ T in diesem Aufruf um int handeln.