
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:
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.
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.
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.
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.
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;
}
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).
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.
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.
/*
Statist.h */
#ifndef StatistH
#define StatistH
double arm_Mittel (double *werte int
anzahl);
double harm_Mittel(double *werte int
anzahl);
#endif
Auch die Header-Dateien
der
Laufzeitbibliothek verwenden diese Technik. Schauen Sie sich den
entsprechenden
Code einmal an.
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.
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;
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.
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 */ |
/*
Quelltext2.cpp */ |
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.
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.
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 */ |
/* Quelltext2.cpp */ |