Modularisierung von Projekten


Das folgende Kapitel stellt einen Auszug aus dem Buch "C/C++ Referenz und Praxis" von Dirk Louis (Verlag Markt & Technik, ISBN 3-8272-5592-9) dar, das ich jedem angehenden Informatiker als Nachschlagewerk empfehlen kann:
 

Modularisierung

Anwendung

Größere Programme sollten nicht als eine endlose Folge von sequentiell abzuarbeitenden Anweisungen aufgesetzt werden. Statt dessen organisiert man den Quellcode, indem man Teilprobleme identifiziert und in Form von Funktionen oder Klassen getrennt (und möglichst gleich für den allgemeinen Fall) löst.

Zur besseren Codeorganisation und in Hinblick auf die mögliche Verwendung in anderen Programmen werden Funktionen und Klassen von allgemeinem Interesse in eigene Quelltextdateien ausgelagert.

Umsetzung

Damit eine Funktion in sinnvoller Weise zur Modularisierung eines Programms beiträgt, sind folgende Kriterien zu berücksichtigen:
- Eine Funktion sollte eine präzise formulierte und scharf abgegrenzte Aufgabe erfüllen.
- Aufgabe und Funktion sind wiederum so allgemein zu formulieren, daß die Funktion in jedem Programm eingesetzt werden kann. Daher ist es wichtig, die Schnittstelle der Funktion sorgfältig zu planen.
- Schnittstellen sollten nicht zu kompliziert sein (wenige Parameter).
- Sollen mehrere Argumente übergeben werden, um das Verhalten der Funktion zu modifizieren, ist es sinnvoll, für die Standardeinstellung Vorgabeargumente zu vergeben (nur in C++ möglich).
- Insbesondere für Funktionen, die in Bibliotheken gesammelt werden sollen, gilt, daß die Funktionen möglichst unabhängig vom restlichen Programm sind, d.h., globale Variablen sollten vermieden werden. Wo globale Elemente erforderlich sind, müssen diese in der gemeinsamen Header-Datei deklariert sein.

Analoge Überlegungen gelten auch für die Definition von Klassen.

Werden Elemente (Funktionen, Klassen, Variablen, etc.) in eigene Quelltextdateien ausgelagert, ist es üblich,
- die Deklaration in eine eigene Header-Datei (.h, .hpp) zu schreiben
- die Definition in der zugehörigen Implementierungsdatei (.c, .cpp) aufzusetzen.

Diese Aufteilung ist zwar keine von der Sprache vorgegebene Notwendigkeit, doch ist sie allgemein üblich und trägt sehr zum unkomplizierten Einsatz von Programmelementen über Dateigrenzen hinweg bei.
 
 

Header-Datei und Implementierungsdatei konzeptionieren

Anwendung

Indem man den Code von Klassen und/oder Funktionen in eigene Quelltextdateien auslagert, erleichtert man die Wiederverwertung dieser Klassen und Funktionen in anderen Programmen.

Alternativ kann man

- einfach die Quelltextdateien in das neue Programm aufnehmen und mit dem Programm neu kompilieren,
- die Quelltextdatei einmal kompilieren lassen (zu obj-Datei) und dann die bereits kompilierte Objektdatei in andere Programme aufnehmen (erspart unnötiges Neukompilieren),
- die Objektdatei mit einem entsprechenden Tool in eine LIB-Datei (statische Bibliothek) konvertieren und sie wie eine Bibliothek verwenden.

Umsetzung

Wenn man Elemente in eigene Quelltextdateien auslagert, sollte man bereits daran denken, wie man diese Elemente in Programme integrieren und in anderen Quelltextdateien verwenden möchte.

Wichtig ist dabei,

- daß alle Quelldateien zu einem Programm zusammengebunden werden. Liegt der Code noch als Quelltext vor, muß die Quelltextdatei zusammen mit den anderen Quelltextdateien des Programms zuerst kompiliert und die resultierende OBJ-Datei dann mit den anderen OBJ-Dateien zusammengelinkt werden. Liegt der Code bereits als kompilierte OBJ-Datei vor, spart man sich die Kompilierung und muß nur darauf achten, daß der Linker die OBJ-Datei mit einbindet. (Wie dies im einzelnen geht, hängt vom verwendeten Compiler ab; meist nutzt man Compiler mit Projektverwaltung, ansonsten muß man der Kommandozeile aus angeben, welche Quelldateien wie zusammengebunden werden sollen),
- daß Elemente, die in mehreren Dateien verwendet werden sollen, in allen Modulen (Übersetzungseinheit, üblicherweise bestehend aus Implementierungsdatei .cpp und Header-Dateien.h, .hpp), in denen sie zum Einsatz kommen, deklariert sind (damit der Compiler mit den Bezeichnern etwas anfangen kann), aber nur einmal im Programm definiert werden (also nur ein Speicherbereich für diese Bezeichner reserviert wird).

Um dies sicherzustellen, geht man üblicherweise wie folgt vor:

- Alle Deklarationen von Elementen, die man in anderen Modulen verwenden möchte, schreibt man in die Header-Datei. Dies sind meist Funktionsdeklarationen (ohne Anweisungsteil!), Klassendeklarationen mit inline-Methodendefinitionen, Typdefinitionen (sind nicht mit Speicherreservierung verbunden), extern-Deklarationen von Variablen.
- Alle Definitionen schreibt man in die Implementierungsdatei (.c, .cpp). Dies sind die Funktionsdefinitionen zu den Funktionsdeklarationen, die Nicht-inline-Methodendefinitionen zu den Klassendeklarationen, die Variablendefinitionen zu den extern-Variablen.
- Alle Hilfselemente (Funktionen, Klassen, Variablen, etc.), die man für die Implementierung der in der Header-Datei aufgeführten Elemente benötigt, schreibt man in die Implementierungsdatei. Die Implementierungsdatei kann also durchaus mehr Funktionen, Klassen und Variablen enthalten als in der Header-Datei aufgeführt werden. In diesem Fall wirkt die Header-Datei auch wie ein Filter, der festlegt, welche Elemente der Implementierungsdatei öffentlich zur Verfügung gestellt werden (Schnittstelle).
- Die Implementierungsdatei bindet per #include-Anweisung die eigene Header-Datei ein.

Um in einem anderen Modul auf die Elemente zugreifen zu können, muß man

- dem Compiler die zu verwendenden Elemente bekanntmachen. Dies ist dank der Header-Datei kein Problem, da diese alle benötigten Deklarationen enthält. Man muß also nur per #include-Anweisung die Header-Datei einbinden,
- sicherstellen, daß der Linker die Definitionen findet. Dazu hat man die Implementierungsdatei, die man wie oben beschrieben entweder als Quelltextdatei in das Programmprojekt aufnimmt und mit diesem kompilieren läßt oder vorab kompiliert und als Objektmodul einbindet.

Beispiel

Die Header-Datei MyClass.h:
 

#ifndef MyClassH
#define MyClassH

class MeineKlasse        // Definition der Klasse
{

int pWert;
public:
MeineKlasse(int i)    // inline-Funktion kann in
{
    pWert = i;       // Header-Datei definiert werden
}
int wert();          // Nicht-inline-Funktionen
                     // werden in Implementierungsdatei definiert

};

// Deklaration von Variablen
extern MeineKlasse meinobj;
extern int globaler_wert;

// Deklaration einer Funktion
void func();

#endif


Die Implementierungsdatei MyClass.cpp:
 

#include <math.h>
#include <iostream>
#include "MyClass.h"
using namespace std;

int MeineKlasse::wert()
{

return pWert;

}

// Definition von Variablen
MeineKlasse meinobj(4);
int globaler_wert = 5;

// Definition der Funktionen
void func()
{

cout « "Hallo aus Funktion" « endl;

}


Die Datei Main.cpp, die auf die Elemente aus MyClass.cpp zugreift:
 

#include <iostream>
#include "MyClass.h"
using namespace std;

int main(int argc, char **argv)
{
// Verwendung der Klassendefinition

MeineKlasse neuobj(3);
cout « neuobj.wert() « endi;


// Verwendung der Variablen

cout « meinobj.wert() « endi;
cout « globaler_wert « endi;


// Verwendung der Funktion

func();
return 0;

}

Tips

Während man für normale Funktionen/ Methoden darauf achten muß, daß diese nur einmal im Programm definiert sind, sind inline-Funktionen in jedem Modul, in dem sie verwendet werden, (identisch) zu definieren. Dies erlaubt es, auch in Header-Dateien kleinere Methoden direkt in der Klasse zu definieren (was ja automatisch einer inline-Deklaration gleichkommt).
 
 

Mehrfachaufrufe von Header-Dateien vermeiden

Anwendung

Bei der Verwendung von Header-Dateien kann es schnell dazu kommen, daß eine Header-Datei in einem Modul mehrfach aufgerufen wird. Grund dafür ist meist, daß Header-Dateien selbst wieder andere Header-Dateien aufrufen

Verwendet beispielsweise eine Implementierungsdatei zwei Header-Dateien Al und A2, die selbst beide wiederum die Header-Datei B aufrufen, wird B in der Übersetzungseinheit für die Implementierungsdatei unnötigerweise zweifach aufgerufen.

Umsetzung

Um unnötige Mehrfachaufrufe von Header-Dateien zu unterbinden, bedient man sich der bedingten Kompilierung.

Man überlegt sich einen Compiler-Schalter, dessen Definition darüber entscheidet, ob der Inhalt der Header-Datei kompiliert werden soll oder nicht. Üblicherweise lautet dieser Compiler-Schalter ähnlich wie die Header-Datei.

Wichtig ist, daß der Compiler-Schalter zu Anfang nirgends definiert ist.

In der Header-Datei wird geprüft, ob der Schalter definiert ist. Ist dies nicht der Fall, wird der Schalter jetzt definiert und daraufhin der Inhalt der Header-Datei aufgeführt. Bei weiteren Aufrufen der Header-Dateien ist der Schalter definiert, und die Deklarationen der Header-Datei werden übersprungen.

Beispiel

/* Statist.h */
#ifndef StatistH
#define StatistH
double arm_Mittel (double *werte int anzahl);
double harm_Mittel(double *werte int anzahl);
#endif

Tips

Auch die Header-Dateien der Laufzeitbibliothek verwenden diese Technik. Schauen Sie sich den entsprechenden Code einmal an.
 
 

Elemente modulübergreifend nutzen

Anwendung

Teilt man ein Programm in mehrere Übersetzungseinheiten auf, stellt sich die Frage, wie man zwischen diesen Einheiten Programmelemente (Typdefinitionen, Funktionen, Variablen) austauscht.

Was bei der Codeorganisation in Implementierungs- und Header-Datei zu beachten ist, wurde bereits in den vorangehenden Abschnitten besprochen. In diesem Abschnitt geht es darum, was bezüglich der verschiedenen Elemente zu beachten ist. Denken Sie daran, daß Deklaration (Header-Datei) und Definition (Implementierungsdatei) der Elemente zu trennen sind, denn der Linker fordert, das jedes Element in einem Programm nur einmal definiert sein darf (sofern die Definition mit der Reservierung von Speicher einhergeht), aber in jedem Modul, in dem es verwendet wird, deklariert werden muß, damit der Compiler weiß, was mit dem Element anzufangen ist.

Umsetzung

Klassen
- Klassen sind Typdefinitionen, für die grundsätzlich kein Speicher reserviert werden muß. Die Klassendefinition kann also ohne Probleme in die Header-Datei geschrieben werden.
- Inline-Funktionen sind gemäß ANSI-Standard in jedem Modul, in dem sie verwendet werden, identisch zu definieren. Inline-Funktionen (Funktionen, die explizit als inline deklariert werden oder als Methoden direkt in der Klassendefinition definiert werden) können und sollen also in der Header-Datei definiert werden.
- Statische Datenelemente müssen außerhalb der Klasse, aber im gleichen Gültigkeitsbereich wie ihre Klasse definiert werden. Dies geschieht in der Implementierungsdatei.

Funktionen
- Funktionsdefinitionen schreibt man in die Implementierungsdatei.
- In die Header-Datei kommt die reine Funktionsdeklaration.
Inline-Funktionen müssen in jedem Modul, in dem sie verwendet werden, identisch definiert sein. Sie werden daher in der Header-Datei definiert.

Variablen
Auch für Variablen gilt, daß die Deklaration in die Header-Datei kommt, die Definition in die Implementierungsdatei. Das Besondere ist hierbei die Unterscheidung zwischen Deklaration und Definition, denn die übliche Deklaration
 

typ variablenbezeichner;


ist zugleich Deklaration und Definition.

Um anzuzeigen, daß mit einer solchen Deklaration keine Speicherreservierung einhergehen soll, stellt man das Schlüsselwort extern voran.
 

extern typ variablenbezeichner;

Warnung

Wird im Zuge einer extern-Deklaration die Variable initialsisiert, hebt dies den Effekt der extern-Deklaration auf, denn für die Zuweisung des Wertes muß ja Speicher für die Variable definiert werden.

Variablen dürfen daher nie in Header-Dateien initialisiert werden.

Beispiel

Die oben beschriebenen Grundsätze für die Trennung von Definition und Deklaration sind nicht an das Konzept der Header-Dateien gebunden. Die Header-Datei dient ja letztlich nur dazu, auf bequeme Weise die Deklarationen zu einer Implementierungsdatei in eine andere Implementierungsdatei einzuführen. Wo es keine Header-Datei gibt oder man sich den Aufwand sparen möchte, kann man die Deklarationen auch direkt in eine Quelltextdatei schreiben.

Das folgende Beispiel zeigt zwei Quelltextdateien eines Programms. In der ersten Datei sind eine Klasse, eine Funktion und eine Variable definiert. Die zweite Datei zeigt die Deklarationen, die erforderlich sind, um die Elemente aus der ersten Datei verwenden zu können.
 
 

/* Quelltext1.cpp */
dass demo {
...
};
int func(int par)
{
... }
int i;

/* Quelltext2.cpp */
class demo {  // Definition wird
              // wiederholt
...
};
int func(int par);  // Deklaration ohne
                    // Anweisungsteil
extern int i;       // extern-Deklaration


 
 

Elemente auf ein Modul begrenzen

Anwendung

Manchmal möchte man globale Elemente definieren, deren Gültigkeit aber auf den eigenen Dateibereich beschränkt bleiben sollen, beispielsweise eine globale Variable, über die alle Funktionen in der aktuellen Datei Daten austauschen können, die aber von gleichlautenden globalen Variablen anderer Übersetzungseinheiten des Programms unterschieden werden soll.

Umsetzung

Traditionell verwendet man zur Definition von Funktionen und Variablen, die in ihrer Datei global, ansonsten aber auf ihre Datei beschränkt sein sollen, das Schlüsselwort static. In C++ kommt dies der Deklaration in einem unbenannten Namensbereich gleich.

Alternativ kann man in C++ die Elemente auch in einem benannten Namensbereich deklarieren. Dies hat den Vorteil, daß man danach in anderen Übersetzungseinheiten immer noch entscheiden kann, ob man auf die Elemente durch Einführung des Namensbereiches oder über qualifizierte Bezeichner zugreifen möchte.

Beispiel

Möchte man in einer zweiten Quelltextdatei eigene Definitionen für func() und i verwenden, muß man den Bezeichner interne Bindung zuweisen. Dies geschieht durch die Deklaration als static.
 

/* Quelltextl.cpp */
int func(int par) {... }
int i;

/* Quelltext2.cpp */
static int func(int par) {...}
static int i;


zurück zur Homepage