Bisher haben wir uns ja nur mit der Programmiersprache an sich und den Editoren beschäftigt. Das einzige Entwicklungswerkzeug, das wir benutzt haben, war der Compiler. Bei wachsender Größe eines Projekts reicht dieser allerdings nicht mehr aus. Zum Glück gibt es unter Linux eine Reihe von sehr mächtigen zusätzlichen Werkzeugen, die Sie bei der Softwareentwicklung unterstützen wollen. Die wichtigsten will ich Ihnen in diesem Kapitel vorstellen:
Bisher waren unsere Programme im Allgemeinen so klein, dass eine Datei für sie ausreichte. Schon bald aber werden Sie sicher größere Programme schreiben, bei denen Sie -- gemäß meiner Tipps (?) -- für jede Klasse eine eigene Datei anlegen. Wenn Sie dann an einer davon etwas ändern, ist der Aufwand nicht so groß, alle neu zu übersetzen.
Nehmen wir an, Sie wollen ein Programm erstellen, das Sie an die Geburtstage Ihrer Freunde erinnert, sobald Sie sich einloggen. Die Daten verwalten Sie in einer Liste; außerdem gibt es noch ein Hauptprogramm. Zum Kompilieren geben Sie dann an:
% g++ birthlist.cc birthday.cc -o birthcontrol
Date
, die das Datum universell handhaben
kann. Damit erhalten Sie natürlich neue Programmdateien date.cc und
date.h, also schon drei Dateien im Projekt. Wenn Sie nur eine ein wenig
ändern, müssen Sie immer alle drei neu übersetzen.
Sicher könnten Sie nur die betroffene Datei kompilieren und dann lediglich den Linker aufrufen. Wie geht das aber bei geänderten Header-Dateien? Haben Sie immer im Kopf, welche Implementationsdatei welche Header-Datei eigentlich einbindet und daher bei deren Änderung neu übersetzt werden muss?
Diese Probleme bekommen Sie mit dem Werkzeug make endlich in den Griff (hier ist die unter Linux gebräuchliche GNU-Variante gemeint, siehe org.gnu.de/software/make/make.html). Es verwaltet die Kompilierung und das Linken aller Dateien Ihres Projekts. Dazu vergleicht es das Datum der Quelldateien mit dem der Objektdateien; ist die Quelle neuer, sorgt es für die Kompilierung; Ähnliches gilt für das Linken. In einem so genannten Makefile legen Sie die Abhängigkeiten zwischen den Dateien und die Regeln für die Erzeugung einer Datei aus einer anderen nieder. Mit diesem wollen wir uns als erstes beschäftigen.
Obwohl folgende Erklärungen für einfachere Projekte ausreichen sollten, können sie bei weitem nicht alle Details zu make abdecken. Wenn Sie mehr erfahren wollen, können Sie entweder die Online-Hilfe (in Form von man- und info-Seiten) zu Rate ziehen oder in [ORAM 1991] alles Wissenswerte nachschlagen.
Ein Makefile ist eine Textdatei, die Sie daher mit jedem Editor bearbeiten können.
(Natürlich hat der Emacs auch dafür einen besonderen Modus ..., siehe Seite ).
Im Allgemeinen hat sie auch den Namen makefile (oder Makefile)
und befindet sich im gleichen Verzeichnis wie Ihre übrigen Projektdateien. Wenn
Sie einen anderen Namen wählen, müssen Sie diesen bei jedem Aufruf von make
ausdrücklich angeben -- dazu haben Sie sicher bald keine Lust mehr. Auch andere,
die einen Blick auf Ihr Projekt werfen, wissen am besten sofort, was gemeint
ist, wenn sie eine Datei Makefile vorfinden. Wenn ihr Name zudem noch
mit einem Großbuchstaben beginnt und damit vielleicht am Anfang aufgelistet
wird, fällt sie besonders ins Auge.
Bei sehr großen Projekten (wie vielen Linux-Anwendungen) sind die Quelldateien auf mehrere Verzeichnisse aufgeteilt. Da make nicht nur einen Compiler oder Linker, sondern jedes beliebige Programm einschließlich sich selbst aufrufen kann, gibt es dabei mehrere Make-Dateien für die verschiedenen Projektteile. Deren Zusammenspiel wird von einem zentralen Makefile gesteuert, das dann sehr komplex werden kann. Es wird nicht nur zum Übersetzen, sondern auch für Konfiguration, Installation und Ähnliches genutzt. Wenn Sie schon einmal Ihren Kernel übersetzt haben, haben Sie vielleicht davon etwas gespürt.
Eine Make-Datei besteht aus Regeln, wie die einzelnen Dateien, die aus Ihrem Projekt
hervorgehen, zu erzeugen sind, also auch welche Optionen Compiler und Linker
verwenden sollen oder welche Objektdateien für die ausführbare Datei nötig sind.
Pro Zeile steht immer nur eine Regel, Definition oder Befehl. Reicht Ihnen die
Zeile nicht aus, weil Sie beispielsweise eine große Zahl von Compileroptionen
übergeben, können Sie an das Ende einen Backslash \
anhängen. Das ist
ein spezielles Fortsetzungszeichen, welches make anweist, die nachfolgende
Zeile noch mit der aktuellen zusammenzufassen.
Zeilen mit ausführbaren Befehlen müssen Sie mit einem Tabulatorzeichen einrücken, damit diese als solche erkannt werden. (Der Emacs weist Sie beispielsweise durch eine Rotfärbung ausdrücklich darauf hin.) Das ist zwar am Anfang verwirrend; wenn aber etwas mit der Erzeugung nicht funktioniert, liegt es meistens am fehlenden Tab -- denn Leerzeichen sind hier nicht erlaubt!
Auch in Makefiles können Sie natürlich Kommentare einfügen.
Diese beginnen (wie in Shell-Skripten) mit einem Doppelkreuz #
, welches
Sie wie die Doppelstriche in C++ verwenden, das heißt, damit kommentieren Sie alles bis zum Ende der
aktuellen Zeile aus -- auch eventuelle Backslashes am Zeilenende.
In den Regeln können Sie explizit angeben, welche Datei(en) von welchen anderen abhängig sind. Meist ist damit eine Beziehung zwischen einer Quelldatei und der daraus erzeugten gemeint. In unserem Beispiel hatten wir die Dateien birthday.cc, birthlist.cc, birthlist.h, date.cc und date.h. Daraus ergeben sich folgende Abhängigkeiten:
birthcontrol: birthday.o birthlist.o date.o
birthday.o: birthday.cc birthlist.h
birthlist.o: birthlist.cc birthlist.h date.h
date.o: date.cc date.h
Wenn Sie mehrere Zieldateien gleichzeitig erzeugen wollen, dürfen Sie bei deren Angabe auch Wildcards wie * oder ? verwenden. Auf der anderen Seite ist es sogar erlaubt, Zieldateien ohne Quellen einzusetzen. Allerdings dürfen Sie eine Datei nur einmal als Ziel innerhalb eines Makefiles angeben.
Wie weiß make dann eigentlich, wann es fertig ist; was ist das endgültige
Ziel? Auf diese Frage gibt es im Grunde drei Antworten: Erstens: durch Angabe
des Ziels in der Kommandozeile, aber dazu kommen wir noch. Zweitens: Das als
Erstes im Makefile angegebene Ziel gilt als das zu erzeugende. Wenn dieses Ziel von
anderen abhängt, werden natürlich auch diese berücksichtigt. Drittens: Sie können
das endgültige Ziel auch ausdrücklich als Zieldatei all
angeben, zum
Beispiel
all: birthcontrol
Somit gilt also das Datum der letzten Änderung einer Datei als Kriterium (Sie wissen vielleicht, dass das nur einer von mehreren Zeitpunkten ist, die unter Linux mit einer Datei gespeichert werden). Manchmal möchte man aber auch das Neuübersetzen eines Programms erzwingen, ohne dass man gleich alles löscht. Dazu können Sie das Hilfsprogramm touch verwenden. Es erwartet als Argument den Namen einer oder mehrerer Dateien (analog zu anderen Kommandos wie ls oder rm) und setzt deren Änderungsdatum auf die aktuelle Systemzeit. Damit sind die betreffenden Dateien auf alle Fälle neuer als ihre Ziele. Ein Beispiel:
% touch *.cc
Wenn Sie dann eine Abhängigkeit formuliert haben, sollten Sie noch angeben, durch welchen Befehl denn das Ziel aus der Quelle erzeugt werden kann. Dieses Kommando geben Sie genauso ein, wie Sie es auch in der Shell tun würden. Achten Sie aber darauf, die Befehlszeilen immer mit einem Tabulatorzeichen zu beginnen! Für unser Beispielprojekt lautet das vollständige Makefile etwa:
birthcontrol: birthlist.o birthday.o date.o
g++ birthlist.o birthday.o date.o
-o birthcontrol
birthday.o: birthday.cc birthlist.h
g++ -c birthday.cc
birthlist.o: birthlist.cc birthlist.h date.h
g++ -c birthlist.cc
date.o: date.cc date.h
g++ -c date.cc
Besonders bei größeren Projekten ist es lästig, für jede Datei eine eigene Regel anzugeben, die sich ja nur im Namen voneinander unterscheiden. Hier bietet make die Möglichkeit, die Erstellung durch so genannte implizite Regeln wesentlich zu vereinfachen. Für unser Beispiel könnten wir folgende Regel anwenden:
.cc.o:
g++ -c $<
$<
ein Makro, das den Namen der Quelldatei bezeichnet. Derartige
Makros gibt es bei make noch einige weitere; wir werden gleich noch
darauf zurückkommen. Allgemein geben Sie also bei einer impliziten Regel zuerst
die Dateiendung der Quelldatei und dann die der Zieldatei an. Beide beginnen
jeweils mit einem Punkt, so dass ein zusätzliches Trennzeichen nicht nötig ist.
Auch bei impliziten Regeln müssen Sie darauf achten, die Befehlsliste mit einem
Tabulatorzeichen einzurücken.
Der Nachteil an impliziten Listen ist, dass damit auch die Abhängigkeiten implizit gesetzt werden, das heißt, es gilt dann nur die Abhängigkeit zwischen Quell- und Zieldatei. Eventuelle weitere Abhängigkeiten, etwa von Header-Dateien, bleiben unberücksichtigt. Aber auch dazu bietet Linux ein geeignetes Werkzeug -- nur Geduld!
Eine implizite Regel findet immer dann Anwendung, wenn es für das gerade zu erstellende Ziel keine explizite Regel gibt. Wenn Sie also für einzelne Dateien besondere Optionen angeben wollen, formulieren Sie für diese explizite Regeln, denn diese haben Vorrang.
Natürlich hat Ihr Projekt im Allgemeinen nur eine Zieldatei, nämlich die ausführbare
Datei oder eine Bibliothek. Dieses Standardziel legen Sie mit all
fest.
In vielen Fällen ist es aber sinnvoll und wünschenswert, noch weitere Ziele
zu haben. In vielen Projekten sind beispielsweise die in Tabelle
aufgeführten Ziele anzutreffen.
Ziel | Beschreibung |
---|---|
install |
installiert das Programm auf dem Rechner |
clean |
löscht das Programm und alle abhängig generierten Dateien |
dist |
erzeugt ein Quelltextpaket, um es weiterzugeben |
check |
führt Tests durch, um die ordnungsgemäße Erzeugung des Programms zu überprüfen |
depend |
berechnet die Abhängigkeiten unter den einzelnen Dateien neu und speichert diese (meist im Makefile) |
Für unser Beispielprojekt könnte das Ziel clean
etwa lauten:
clean:
-rm -f birthcontrol *.o
make clean
auch dann fehlerfrei funktioniert, wenn
die Ziele überhaupt nicht vorhanden sind.
Normalerweise rufen Sie make völlig ohne Argumente auf. Dann sucht
das Programm nach einer Datei namens makefile oder Makefile
und erzeugt entweder das erste oder das all
-Ziel.
Möchten Sie ein anderes Ziel erzeugen, geben Sie dies als Argument beim Aufruf an, zum Beispiel:
% make clean
Darüber hinaus kennt make eine ganze Reihe von Kommandozeilenoptionen. Die wichtigsten sind:
-k
-k
veranlassen Sie,
trotzdem mit der Abarbeitung fortzufahren.
-n
-n
. Dann werden alle Befehle nur ausgedruckt,
aber nicht ausgeführt -- eine Trockenübung also.
-f
-f
an, also etwa make -f myprog.mak
.
-p
-p
alle ausgeben lassen. Aber Vorsicht: Da
die Liste lang ist, sollten Sie sie lieber in eine Pipe schicken, hinter der
sich more oder less verbergen.
Wie gesagt, startet make beim Aufruf zunächst die Überprüfung, welche Dateien neuer als ihre Ziele sind. Trifft das für eines in der Kette der Abhängigkeiten des endgültigen Ziels zu, wird die zugehörige Regel ausgeführt, also ein anderes Programm (etwa ein Compiler) aufgerufen.
Bricht dieses Programm mit einem Fehler ab (zum Beispiel weil sich in Ihrer Quelldatei Syntaxfehler befinden), beendet auch make seine Arbeit:
make: *** [birthday.o] Error 1
make: *** No rule to make target `brthlist.o',
needed by `birthcontrol'. Stop.
Eine eher harmlose Meldung erscheint, wenn Sie make aufrufen, obwohl sich keine Dateien gegenüber der letzten Erzeugung verändert haben:
make: `birthcontrol' is up to date.
Die Syntax für die Zuweisung eines Makros entspricht der in Bourne-Shell-Skripten, also
MAKRONAME = Inhalt
$
-Zeichen angeben, also etwa $(MAKRONAME)
.
Eine häufige Anwendung von Makros ist die Definition von Compileroptionen, Compilernamen und so weiter. Auf diese Weise ist es nämlich möglich, an einer zentralen Stelle alle Optionen für die Übersetzung festzulegen; sollen diese einmal geändert werden (etwa, um von einer Debug- auf eine Release-Version zu schalten), muss nur diese Zeile geändert werden. Ähnlich verhält es sich, wenn ein anderer Compiler ausgewählt werden soll, zum Beispiel wenn Sie das Programm auf einer anderen Plattform übersetzen wollen, wo kein GCC vorhanden ist (gibt es das?!). Auch die nötigen Bibliotheken lassen Sie so übersichtlich auflisten.
all: birthcontrol
# Name des Compilers
CXX=g++
# Pfad für zusätzliche Header-Dateien
INCLUDE=.
# Compilerschalter
CCFLAGS=-g -Wall
# zusaetzliche Bibliotheken
LIBS=
birthcontrol: birthlist.o birthday.o date.o
$(CXX) birthlist.o birthday.o date.o
-o $@ $(LIBS)
.cc.o:
$(CXX) -I$(INCLUDE) $(CCFLAGS) -c $<
Makro | Bedeutung | |
---|---|---|
$@ |
Dateiname des Ziels, einschließlich Endung | |
$* |
Name des Ziels, aber ohne Dateierweiterung | |
$< |
Name der ersten abhängigen Datei | |
$? |
Namen aller abhängigen Dateien, die neuer als das Ziel sind, getrennt durch Leerzeichen | |
$+ |
Namen aller abhängigen Dateien, getrennt durch Leerzeichen | |
$ ^ |
Namen aller abhängigen Dateien, getrennt durch Leerzeichen; doppelt vorkommende werden dabei weggelassen |
Außerdem gibt es noch eine Reihe von weiteren internen Makros, Makromodifizierern
und Kontrollanweisungen (wie !if
oder !ifdef
), die Sie aber erst
bei recht komplexen Makefiles benötigen werden.
Natürlich haben Sie in einem Makefile auch Zugriff auf alle Umgebungsvariablen, die Sie in der Shell definiert haben.
Viele Regeln sind immer wieder sehr ähnlich. Aus diesem Grund sind einige bereits in make eingebaut. Es verwundert Sie sicher auch nicht zu hören, dass diese sich unter Linux ausschließlich auf den GCC beziehen. Praktisch bedeutet das, dass Sie nur noch die Abhängigkeiten angeben müssen; die Regel zum Übersetzen ist bereits bekannt.
Ganz bequem können Sie es sich machen, wenn Ihr Programm nur aus einer Quelldatei besteht. Dann ist nicht einmal ein Makefile nötig, denn die eine Zeile darin wird ja bereits durch die eingebaute Regel überflüssig. Nehmen wir beispielsweise unsere Datei lotto.cc aus dem letzten Kapitel, so genügt es, wenn Sie in der Shell aufrufen:
% make lotto
g++ lotto.cc -o lotto
Diese setzen sich selbst wieder aus Makros zusammen. Tabelle
zeigt Ihnen eine Liste wichtiger vordefinierter Makros. Diese können Sie sich
auch durch den Aufruf von
make -p
ausgeben lassen.
Makro | definiert als |
---|---|
CC |
cc |
CXX |
g++ |
OPTION_OUTPUT |
-o $@ |
COMPILE.c |
$(CC) $(CFLAGS) $(CPPFLAGS) $(TARGET_ARCH) -c |
COMPILE.cc |
$(CXX) $(CXXFLAGS) $(CPPFLAGS) $(TARGET_ARCH) -c |
LINK.c |
$(CC) $(CFLAGS) $(CPPFLAGS) $(LDFLAGS) $(TARGET_ARCH) |
LINK.cc |
$(CXX) $(CXXFLAGS) $(CPPFLAGS) $(LDFLAGS) $(TARGET_ARCH) |
AR |
ar |
ARFLAGS |
rv |
Dabei sind die Makros $(CFLAGS)
, $(CPPFLAGS)
und
$(TARGET_ARCH)
sowie $(CXXFLAGS)
und $(LDFLAGS)
nicht vordefiniert. Dass sie dennoch in den eingebauten Regeln verwendet
werden, bedeutet für Sie, dass Sie durch das Setzen dieser Definitionen eigene
Einstellungen in diese Regeln einbringen können. Dazu gibt es - wie immer
- mehrere Möglichkeiten:
MAKRONAME=Wert
. Wollen Sie beispielsweise
noch die Flags -g -Wall
beim Compileraufruf berücksichtigt haben möchten,
können Sie bei unserer Beispieldatei in der Kommandozeile schreiben:
% make CXXFLAGS=''-g -Wall'' lotto
g++ -g -Wall lotto.cc -o lotto
In Tabelle stehen sowohl die Definitionen der Makros für
die Übersetzung von C-Dateien als auch die für C++-Dateien. Den Typ der Datei
erkennt make an ihrer Endung. Als C++-Quelltext werden beispielsweise
die Dateien mit den Endungen .cc, .cpp oder .C erkannt.
Als eingebaute Regeln kommen dann folgende zum Einsatz:
%.o: %.cc
$(COMPILE.cc) $< $(OUTPUT_OPTION)
%: %.cc
$(LINK.cc) $^ $(LOADLIBS) $(LDLIBS) -o $@
%
.
Für kleinere Projekte sind die eingebauten Regeln sicher ganz praktisch. Wenn Sie allerdings an einem größeren Projekt arbeiten, rate ich Ihnen von deren Verwendung ab. Zum einen wird durch die ausdrückliche Angabe der Regeln in der Make-Datei viel eher deutlich, welche Einstellungen in Ihrem Projekt gelten. Zum anderen sind eingebaute Regeln im Allgemeinen abhängig von der aktuellen Plattform; auf einem anderen Unix können sie deutlich anders lauten. Indem Sie sich auf eingebaute Regeln verlassen, schränken Sie also die Portablität Ihres Projekts ein.
In diesem Abschnitt will ich Sie noch auf ein paar Spezialitäten hinweisen, die den Umgang mit make für Sie einfacher machen könnten. Hier zeigt sich übrigens, dass make nicht gleich make ist. Denn GNU-make, das Sie unter Linux nutzen, hat eine Reihe von Features, über die die entsprechenden Tools der kommerziellen Unix-Varianten nicht verfügen.
Das Übersetzen aller Dateien eines größeren Projekts kann des Öfteren selbst auf schnellen Maschinen einige Zeit in Anspruch nehmen. Obwohl Multitasking unter Linux ja kein Problem ist, können die Aktivitäten von make bei der Arbeit an der Konsole doch als störend empfunden werden.
Eine Möglichkeit der Abhilfe ist da die Option
-l
. Mit ihr können Sie angeben, dass make nur dann neue Befehle
starten soll, wenn die Systemauslastung (load average) unterhalb des angegebenen
Wertes gefallen ist. Den Wert geben Sie dabei als Dezimalbruch an (entsprechend
dem Prozentualwert der Auslastung). Wenn Sie als Schranke 25% setzen möchten,
lautet der Aufruf also:
% make -l 0.25
Als Alternative für große Projekte
bietet es sich an, möglichst viel gleichzeitig erledigen zu lassen. Denn oftmals hängen viele
Zwischenziele (etwa Objektdateien) nicht voneinander ab und könnten daher parallel
übersetzt werden. Auch dazu stellt make eine Option bereit. Mit -j
können Sie die Anzahl der Befehle angeben, die maximal gleichzeitig gestartet
werden. Für ein mittelgroßes Projekt ist ein Wert zwischen drei und fünf ein
guter Ausgangspunkt. Sie können ja die Ausführung beobachten (zum Beispiel durch
top in einem anderen Fenster) und dabei erkennen, ob noch mehr Prozesse
sinnvoll wären oder bereits die aktuelle Anzahl zu hoch ist. Wollen Sie etwa
5 als Maximalwert setzen, lautet der Aufruf:
% make -j 5
5
ist dabei optional.) Wenn Sie nun denken,
dass eine solche Parallelverarbeitung nur auf einem Mehrprozessorcomputer wirklich
Verbesserungen bringt, haben Sie zwar nicht ganz unrecht. Sie müssen sich allerdings
vor Augen halten, dass auch das Kompilieren nicht ein starrer Prozess ist, der
immer gleich viel CPU-Leistung erfordert, sondern auch sich in Unterprozesse
wie Präprozessor, Optimierer und so weiter aufspaltet, die jeweils für sich
Vor- und Nachbereitungen zu erledigen haben. Das führt dazu, dass bei der Übersetzung
einer Datei der Rechner längst nicht die ganze Zeit ausgelastet ist, sondern
durchaus weitere Prozesse vertragen könnte -- sofern nicht andere Benutzer
auch Rechenzeit für sich beanspruchen wollen.
An
dieser Stelle noch eine Warnung: Wenn Sie hinter
-j
keine
Zahl angeben, ist die Anzahl der gleichzeitig zu startenden Kommandos nicht
limitiert. Das kann bei größeren Projekten zur Folge haben, dass mehrere Dutzend
Compileraufrufe auf einmal abgesetzt werden. Selbst ein gut ausgebautes System
kann da vollständig lahmgelegt werden, so dass nicht einmal mehr Benutzerinteraktionen
möglich werden! Gewöhnen Sie sich daher besser an, diese Option immer
mit einer Beschränkung zu verwenden.
Ein sehr nützliches Zusatzwerkzeug zu make ist makedepend.
Es geht Ihren gesamten Quelltext durch und fügt alle impliziten Abhängigkeiten
(etwa von Header-Dateien) an Ihr Makefile an. Jedes Mal, wenn Sie Dateien hinzufügen
oder #include
-Anweisungen ändern, sollten Sie es daher aufrufen.
Dieses Tool ist im Rahmen des X11-Projektes entstanden und wird daher im Normalfall mit jeder Distribution ausgeliefert, die eine X11-Unterstützung enthält (und das dürften alle sein). Sie sollten es auch im gleichen Verzeichnis wie die X11-Server oder andere X-Anwendungen finden können, also /usr/X11/bin. Wenn nicht, können Sie es von ftp.x11.org oder einem Mirror (zum Beispiel www.leo.org) beziehen.
So nützlich makedepend auch ist und so sehr ich Ihnen dieses Werkzeug bei allen von Ihnen selbst verwalteten Make-Dateien ans Herz legen möchte -- auf ein Problem muss ich Sie noch hinweisen. Sie können makedepend auf zweierlei Arten verwenden, nämlich mit oder ohne Berücksichtigung der System-Header (wie cstdio oder iostream). Die Vorgabe ist, dass Sie diese Dateien berücksichtigen wollen. Leider passt sich makedepend nicht dem aktuellen Compiler an, sondern sucht die System-Header in Verzeichnissen, die bei seiner eigenen Erzeugung eingestellt waren. Das hat zur Folge, dass Sie immer wieder auf Fehlermeldungen treffen wie:
makedepend: warning: birthlist.cc, line 3:
cannot find include file "iostream"
not in ./iostream
not in /usr/local/lib/gcc-include/iostream
not in /usr/include/iostream
not in /usr/lib/gcc-lib/i486-linux/2.7.2.3/include/iostream
-I
-Option,
die Sie vom Compiler kennen, den Pfad zu Ihren Systemdateien mitteilen, also
etwa:
MKDEPINC=-I$(GCC_DIR)/include/g++-3 \
-I$(GCC_DIR)/lib/gcc-lib/i686-pc-linux-gnu/2.95/include
Bereits bei kleineren Projekten leistet makedepend nützliche Dienste, da
Sie oft nicht alle Querverbindungen zwischen Ihren Dateien im Kopf haben (was
auch gar nicht nötig sein sollte!). Am besten legen Sie sich in Ihrem Makefile
ein Ziel namens depend
an, das dann makedepend startet. (Wenn
Sie übrigens einen anderen Namen als makefile oder Makefile
verwenden, müssen Sie diesen auch bei makedepend mit der Option -f
ausdrücklich angeben.) Als Parameter erwartet dieses Tool übrigens nur eine Liste
mit Ihren Implementierungsdateien. Falls Sie eigene Pfade für Ihre Header-Dateien
mit -I
beim Compileraufruf angeben, müssen Sie diese Option natürlich
auch bei makedepend verwenden, da es sonst diese Header-Dateien nicht
finden kann. Empfehlenswert ist daher ein Makro $INCLUDE
, das Sie dann
in beiden Fällen verwenden, gleichzeitig aber zentral pflegen können.
Für unser Beispiel birthcontrol lässt sich die beschriebene Technik etwa wie folgt realisieren:
INCLUDE=-I.
CXXFLAGS=-Wall -g $(INCLUDE)
OBJECTS=birthlist.o birthday.o date.o
SOURCES=birthlist.cc birthday.cc date.cc
all: birthcontrol
birthcontrol: $(OBJECTS)
g++ $(OBJECTS) -o birthcontrol
depend:
makedepend $(INCLUDE) $(SOURCES)
# DO NOT DELETE
birthlist.o: birthlist.h date.h
birthday.o: date.h birthlist.h
date.o: date.h
Übrigens ist im GCC eine ähnliche Funktion zur Bestimmung von Abhängigkeiten
eingebaut, wenn auch mit etwas geringerer Leistungsfähigkeit. Sie müssen dazu
die Option -MM
verwenden, also
etwa
% g++ -MM birthlist.cc
In diesem Abschnitt haben Sie das Werkzeug make kennen gelernt, das für die Entwicklung von Programmen, die aus mehr als zwei Quelldateien bestehen, eigentlich unerlässlich ist. Auch wenn Ihnen Makefiles auf den ersten Blick etwas unverständlich vorkommen mögen -- im Grunde sind sie nicht besonders schwer zu lesen (wenn ihr Autor nicht gerade versucht hat, das Letzte an Funktionalität herauszuholen). Folgende Eigenschaften sollten Sie sich merken:
zieldatei: quelldatei1 quelldatei2
sowie
.cc.o:
). Bei diesen
Befehlen können Sie dann auch die automatischen Variablen verwenden (wie $<
für die aktuelle Quelldatei).
CXXFLAGS
definieren.
Darüber hinaus gibt es gerade für make noch eine ganze Reihe von zusätzlichen Werkzeugen. So haben beispielsweise Programme wie mkmf oder imake den Zweck, aus einem generischen Makefile ein für den jeweiligen Rechner angepasstes Makefile zu erzeugen. Aber solche Tools werden Sie nur bei sehr großen Projekten benötigen, die für mehrere Plattformen entwickelt sind. Bei den moderneren GPL-Projekten wie GNU-Tools, GNOME oder KDE kommt dafür außerdem autoconf zum Einsatz, das ungefähr denselben Zweck erfüllt (siehe org.gnu.de/software/autoconf).
Apropos: Unter org.gnu.de/software/make finden Sie bei den Codierungskonventionen für GNU-Projekte auch einen Abschnitt, wie Makefiles dabei aufgebaut sein sollten. Und mit dem Source-Code aller GNU-Programme werden auch immer Makefiles verteilt, aus denen Sie sicher noch einiges lernen können.
Jeder Mensch macht Fehler; und da auch Programmierer Menschen sind, enthalten
auch deren Programme Fehler. Der Richtwert eines durchschnittlichen Programmierers
liegt bei etwa einem Fehler auf je 100 Zeilen Quelltext (was natürlich von der
Komplexität des Programms sowie vielen anderen Faktoren abhängt). Ein mittelgroßes
Projekt mit ca. 50.000 - 100.000 Zeilen Code enthält also im Durchschnitt 500
bis 1000 Fehler. Wenn Sie Maßnahmen zur Qualitätssicherung ergreifen (etwa die
ab Seite vorgeschlagenen Konventionen einhalten),
können Sie damit viele Fehler schon von vornherein vermeiden.
In diesem Abschnitt soll es also um Fehler gehen, um ihre Gefahrenquellen und um Werkzeuge, mit denen sie sich aufspüren lassen:
assert()
, wie Sie also auch jenseits des Debuggers in Ihren
Programmen Fehler aufspüren können (ab Seite
Wo kommen also die Fehler her, die man im Englischen so plastisch als bugs, also Käfer, bezeichnet? Zum einen können Ihnen beim Programmieren immer wieder syntaktische Fehler unterlaufen, also Vertippen bei Schlüsselwörtern oder Variablennamen, falsche Typen bei Methodenargumenten und so weiter. Diese Fehler findet aber bereits der Compiler. Das ist zwar zuweilen etwas lästig, aber im Allgemeinen recht vollständig.
Schwerwiegender sind die semantischen Fehler. Dabei will ich diesen Begriff gar nicht darauf ausdehnen, dass Ihr Programm nicht die gestellten Anforderungen erfüllt. Ich meine hier nur die zur Laufzeit auftretenden Fehler, die entweder sofort schwerwiegende Folgen wie Segmentation Faults haben, oder aber die das Ergebnis des Programms nachhaltig verändern und verzerren können. Leider sind gerade diese Fehler oft sehr schlecht reproduzierbar, da sie von vielen Einflüssen innerhalb und außerhalb der Software abhängen können. Ist es in der Theorie des Software Engineering noch so, dass Entwickler und Tester eines Programms verschiedene Personen sein sollten, so sieht die Praxis doch häufig anders aus. Und Entwickler neigen nun leider einmal dazu, instinktiv die Schwachstellen ihres Programms beim Testen zu umgehen.
Doch zunächst wollen wir uns einige repräsentative Arten von Fehlern näher ansehen.
Zunächst sind da die Designfehler. Ich meine damit nicht so sehr Mängel bei der grundlegenden Architektur der Software, sondern einfach, wenn der Code zu einem anderen Verhalten führt, als der Programmierer es beabsichtigte.
Ein typisches Beispiel dafür sind if
-Anweisungen, bei denen die umschließende
Klammer fehlt, etwa
if (result == 0)
text = ``Hier ist ein Fehler!'';
cerr << text << endl;
result
abhängen.
Auf Seite
In die gleiche Kategorie fallen übrigens auch vergessene break
s bei switch
-Anweisungen
oder falsch gesetzte Strichpunkte, beispielsweise bei for
-Schleifen:
for(k=0; k<n; k++); // Ein Semikolon zuviel!
x += k;
Diese, aber leider auch andere Ursachen können zu Fehlern führen, die Daten verändern oder zerstören. In seltenen Fällen ist dieser Effekt durch Wechselwirkungen mit anderen Programmen bedingt. Hier haben Sie unter Linux aber weniger zu befürchten als auf anderen Plattformen, da die Prozesse streng voneinander abgetrennt sind und das Betriebssystem bereits verhindert, dass sich zwei Anwendungen ins Gehege kommen. Natürlich kann aber auch Ihr Programm selbst daran schuld sein; die häufigsten Ursachen sind Überschreitungen von Feldgrenzen, Überlauf von Variablen und fehlerhafte Weitergabe von Zeigern.
Ein ganz anderes Problemfeld ist das Ein- und Ausschließen von bestimmten Programmteilen durch bedingte Kompilierung. Oftmals baut man Testausgaben und -aufrufe ein, die dann in der endgültigen Version nicht mehr enthalten sein sollen, etwa in der Form:
#ifdef DEBUG
cout << ``Ergebnis: `` << res << endl;
#endif
#endif
vergessen, später aber
ein weiteres stehen haben, kann der Präprozessor sehr viel größere Teile aus
Ihrem Programm entfernen, als Sie das eigentlich wollten. Und bei verschachtelten
Anweisungen dieser Art ist noch größere Vorsicht geboten. Das fängt bereits
damit an, dass natürlich immer das korrekte Makro definiert sein muss.
Mit Rechen- und Rundungsfehlern hat man sich auseinander gesetzt, solange es Computer gibt. Nur allzu schnell vergessen viele Entwickler nämlich, dass sämtliche Zahlen in einem Rechner nur eine endliche Genauigkeit haben können. Die Numerik kennt dafür umfangreiche theoretische Untersuchungen, die eigentlich allen bekannt und bewusst sein sollten, die Rechenoperationen programmieren (siehe etwa [STOER 1989]). Ein Beispiel sind die Auslöschungen, die bei Verknüpfungen von sehr großen und sehr kleinen Zahlen miteinander auftreten. Dabei kann oft schon eine Vertauschung von Variablen innerhalb eines an sich assoziativen Ausdruckes zu völlig anderen Ergebnissen führen.
In die gleiche Kategorie fallen Überläufe. Ganzzahlige Datentypen haben
nur einen begrenzten Wertebereich. Führt eine Operation darüber hinaus, hat
die Variable anschließend einen völlig anderen, eventuell auch negativen Wert.
Auch mit dem Mischen von Werten mit und ohne Vorzeichen (signed
und unsigned
)
oder bei Zuweisungen beziehungsweise Parameterübergaben, die int
auf
short
abbilden, muss man sehr vorsichtig sein. Solche Probleme kann man
zwar relativ rasch aufspüren. Noch besser ist es aber, sie gleich zu vermeiden.
Abstürze sind unter Linux längst nicht so häufig wie unter Windows. Und wenn
ein Programm abstürzt, bleibt das übrige System davon im Allgemeinen unbeeindruckt.
Vorkommen können sie aber dennoch. Die häufigsten Ursachen für Totalabstürze
(segmentation fault oder bus error)
sind Fehler beim Umgang mit Zeigern oder der dynamischen Speicherverwaltung,
also delete
auf bereits freigegebenen Speicher, Zuweisungen an Nullzeiger
und so fort. Der große Vorteil bei Linux ist dabei, dass Sie nicht mühsam versuchen
müssen, das Problem zu reproduzieren, sondern dass Ihnen oftmals gleichzeitig
mit dem Absturz ein Speicherauszug (core)
erstellt wird. Diesen können Sie dann in den Debugger laden, der Ihnen daraufhin
die genaue Stelle des Absturzes anzeigen kann. Die eigentliche Ursache müssen
Sie dann aber schon selbst finden.
So praktisch ein solcher Speicherauszug auch ist -- selbstverständlich ist seine Erzeugung nicht. Da solche Core-Dateien oftmals immensen Speicher auf dem Datenträger verbrauchen, ist die Voreinstellung auf den meisten Linux-Systemen, dass überhaupt keine Speicherauszüge erzeugt werden; die maximale Größe für vom Benutzer generierte Core-Dateien ist dann 0. Um dies zu ändern, können Sie beispielsweise in der Bash mit dem Befehl
$ ulimit -c unlimited
Als größte Fehlerquellen mit dem schlechtesten Einfluss auf die Stabilität des Programms haben wir also folgende identifiziert:
string
oder list
aus der C++-Standardbibliothek)
verwenden, haben Sie das Auftreten einiger gravierender Fehler bereits ausgeschlossen.
Es ist eine typische Situation, die jeder Programmierer kennt: Ein Programm wurde gerade fertig, es lässt sich ohne Fehler übersetzen. Dann startet man es und -- rums! Ein Absturz mit segmentation fault. In dieser Situation sollten Sie zunächst überprüfen, ob der Fehler reproduzierbar ist, das heißt, ob das Programm wieder abstürzt, wenn Sie dieselben Eingaben vornehmen.
Dann müssen Sie das Gebiet im Code eingrenzen, in dem der Absturz passiert. Dazu haben Sie drei Möglichkeiten: Entweder Sie (ohne auch ein Kollege!) lesen den Quelltext aufmerksam durch, um mögliche Fehler zu entdecken. Diese Vorgehensweise ist zwar immer ratsam, führt aber leider nicht immer zum Ziel.
Die zweite Möglichkeit besteht darin, Ihren Quelltext mit Zwischenausgaben zu versehen.
Mit diesen können Sie nicht nur erkennen, welche Anweisungen bereits abgearbeitet
sind, sondern auch Werte einzelner Variablen ausgeben. Um diese Ausgaben wirklich
nur bei der Fehlersuche im Programm zu haben, sollten Sie sie in einen Block
einschließen, der nur mittels des Präprozessors übersetzt wird, wenn Sie
eine bestimmte Definition angegeben haben (siehe Seite ).
Beachten
Sie, dass Textausgaben nicht ständig auf den Bildschirm geschrieben
werden, sondern erst wenn der interne Puffer voll ist. Bei der Fehlersuche kommt
es daher häufiger vor, dass zwischen Ihrer
cout
-Anweisung und der eigentlichen
Ausgabe gerade der Programmabsturz stattfindet. Dann wissen Sie nicht mehr,
ob der Schritt, den Sie ausgeben wollten, bereits vollzogen war oder nicht.
Ich empfehle Ihnen daher, bei Debug-Ausgaben grundsätzlich eine Leerung des
Puffers mittels flush
ans Ende zu setzen, zum Beispiel:
cout << ``Schritt 1 fertig!'' << endl << flush;
Als Beispiel wollen wir ein Programm betrachten, das die Determinante einer Matrix
nach der Laplace-Entwicklung berechnet. Für alle Nichtmathematiker eine kurze
Erklärung: Die Determinate einer reellen, symmetrischen Matrix ist eine
reelle Zahl, die unter anderem darüber Auskunft gibt, ob die
Matrix invertierbar ist (
)
oder nicht (
). Das Berechnungsverfahren besagt, dass man Untermatrizen
zu bilden hat, indem man eine Zeile und eine Spalte weglässt. Die Determinante
ist dann eine Summe aus den Determinanten der Untermatrizen, multipliziert mit
dem Wert in der weggelassenen Zeile und Spalte, wobei in der Summe stets das
Vorzeichen wechselt. Alles klar? Sehen wir uns das für eine Matrix mit vier
Zeilen und Spalten einfach an.
Auf die Untermatrizen wird die Rechenregel wieder angewendet (also rekursiv); als Ergebnis erhält man schließlich 143.
Ein einfaches Beispiel sind zyklische Matrizen, zum Beispiel:
Wir definieren eine Klasse SymMatrix
mit folgender Schnittstelle:
1:// Datei: matrix.h
2:#ifndef _MATRIX_H_
3:#define _MATRIX_H_
4:5:
#include <string>
6:7:
class SymMatrix
8:{
9:public:
10:SymMatrix();
11:SymMatrix(unsigned int _n);
12:SymMatrix(const SymMatrix& _mat);
13:~SymMatrix();
14:15:
bool enter();
16:bool read(const string& _fname);
17:double at(unsigned int _i,
18:unsigned int _j) const;
19:double& at(unsigned int _i,
20:unsigned int _j);
21:unsigned int getSize() const;
22:SymMatrix subMatrix(
23:unsigned int _k) const;
24:25:
friend double determinant(
26:const SymMatrix& _a);
27:28:
private:
29:double** dat;
30:unsigned int size;
31:32:
void initMemory(unsigned int _n);
33:void freeMemory();
34:void print();
35:};
36:#endif
Der Konstruktor mit einer Ganzzahl soll dazu dienen, eine Matrix mit gegebener
Anzahl von Zeilen und Spalten zu initialisieren. Kopierkonstruktor und Destruktor
sollten Ihnen noch aus den Abschnitten und
geläufig sein. (Wenn nicht, schlagen Sie gleich auf Seite
nach!)
Die Methode enter()
erlaubt eine Matrixeingabe von Hand, während read()
die Elemente aus einer Datei liest. Die beiden Methoden at()
erlauben
den Zugriff auf einzelne Elemente und zwar in einer konstanten Version für Lese-
und einer anderen für Schreibzugriffe. Die Bildung der Untermatrix ist ebenfalls
als Methode der Klasse selbst (subMatrix()
) vorgesehen, wogegen die Determinantenberechnung
in einer getrennten Funktion erfolgt, die allerdings mit der Klasse befreundet
ist (zu friend
siehe Seite ).
main()
-Funktion
Gehen wir zunächst einmal davon aus, dass alles korrekt programmiert ist, und
betrachten die Funktion main()
.
1:// Datei main.cc
2:#include <iostream>
3:#include <iomanip>
4:#include "matrix.h"
5:6:
int main()
7:{
8:string filename;
9:cout << "Datei mit Matrix: ";
10:cin >> filename;
11:12:
SymMatrix a;
13:if (a.read(filename))
14:return -1;
15:16:
double d=determinant(a);
17:cout << "Determinante ist "
18:<< setprecision(6) << d << endl;
19:20:
return 0;
21:}
Dort wird lediglich ein Dateiname vom Benutzer erfragt, die Datei eingelesen
und die Determinante nach ihrer Berechnung ausgegeben. Die Methode read()
ist so definiert, dass sie true
im Erfolgsfall und false
im Fehlerfall
zurückliefert. Wenn wir nun das Programm starten und einen Dateinamen eingeben,
passiert anschließend überhaupt nichts mehr. Insbesondere erhalten wir keinerlei
Ausgabe. Bei einem Lesefehler müsste die read()
-Methode eine Fehlermeldung
ausgeben. Was ist also los? Wenn Sie dem Code nicht ansehen, wo der Fehler liegt,
müssen Sie den Debugger starten.
Der GNU Debugger gdb ist der verbreitetste Debugger unter Linux. Da er sich an die unter anderen Unix-Dialekten vorherrschenden Konventionen hält, können sehr viele darauf aufbauende Werkzeuge auch unter Linux eingesetzt werden. Denn der gdb ist zwar sehr leistungsfähig, aber recht schwer zu bedienen. Er hat nur eine Kommandozeile, auf der man alle Befehle an ihn eingeben muss. Obwohl Sie später sicherlich mehr mit so genannten grafischen Frontends zum gdb arbeiten werden, ist es ratsam, die grundlegenden Befehle schon einmal gesehen zu haben, um den Leistungsumfang beurteilen und gegebenfalls einzelne Anweisungen von Hand absetzen zu können.
Um ein Programm überwacht ablaufen zu lassen, müssen besondere Informationen eingefügt werden. Normalerweise sind Programme in reiner Maschinensprache, die keinen Bezug mehr zu Elementen der Programmiersprache wie Variablen oder Funktionen aufweist. Wir wollen aber beispielsweise Zeile für Zeile uns vorwärts durchs Programm bewegen und Inhalte von Variablen ansehen.
Die Option für den GCC zum Hinzufügen von Debug-Informationen ist
-g
(siehe auch Seite ). Dabei sollten Sie immer alle
Quelldateien eines Programms damit übersetzen -- und nicht nur die, in der
Sie den Fehler vermuten. Es ist zwar prinzipell möglich, Objektdateien mit und
ohne Debug-Informationen zu mischen und zusammen zu linken, aber in der Praxis
meist wenig hilfreich. Müssen Sie etwa einen Methodenaufruf verfolgen, landen
Sie schnell in einer anderen Datei, in der Sie dann nicht mehr sehen können,
was vor sich geht.
Darüber hinaus ist es ratsam, auch beim Linker diese Option anzugeben. Dadurch werden andere Systembibliotheken verwendet, so dass Sie der Debugger auch bei Aufrufen zur C-Standardbibliothek unterstützen kann.
Wenn wir unser Programm also mit der nötigen Option übersetzt haben, können
wir den Debugger starten. Dazu geben Sie einfach gdb
ein, gefolgt vom
Namen der ausführbaren Datei, etwa
% gdb DebugTest
(gdb)
help
) empfehle ich Ihnen die info-Seiten dazu
(siehe Seite
Wenn Ihr Programm beim Absturz einen Abzug des Speichers erzeugt (eine so genannte
Core-Datei, siehe auch Seite ), können Sie diesen
als zweites Argument beim Aufruf angeben. Der Debugger kann diesen Speicherauszug
laden und damit sofort die Stelle anzeigen, an der der Absturz passierte.
% gdb meinprog core
Nachdem der Debugger also läuft, wollen wir auch unser Programm starten. Dazu
geben wir das Kommando run
ein.
Der gdb kennt auch eine Reihe von Kürzeln für besonders häufig benutzte
Kommandos. Anstelle von run
reicht daher auch r
.
(gdb) r
run
angeben. Bei einem erneuten Start müssen Sie die Argumente nicht
wieder eingeben; sie werden intern gespeichert. Ein anderer Weg, Kommandozeilenargumente
zu übergeben, ist das Kommando set args
. Unmittelbar dahinter setzen
Sie die gewünschten Parameter. Diese Einstellung gilt so lange, bis Sie etwas
anderes festlegen.
Ähnlich wie die Shell oder der Emacs verfügt der gdb auch über eine automatische Vervollständigungsfunktion für Kommandos. Wenn Sie nur einen Teil eines Kommandos eingeben und dann TAB drücken, wird bei Eindeutigkeit die Anweisung vervollständigt, ansonsten eine Liste mit möglichen Fortsetzungen angezeigt. Natürlich hat der GNU Debugger auch eine Liste der zurückliegenden Befehle, so dass Sie mit den Cursortasten blättern und auch editieren können.
Für unser Beispiel erhalten wir folgende Ausgabe:
(gdb) r
Starting program: /usr/thomas/kap05/DebugTest
Datei mit Matrix: cyclic.dat
Program exited with code 0377.
Current language: auto; currently c
(gdb)
Wir sehen, dass auch hier das Programm auf den ersten Blick ordnungsgemäß durchläuft,
ohne aber weitere Ausgaben zu produzieren. Beachten sollten Sie, dass der Rückgabewert
des Programms nicht 0 wie bei ordentlicher Beendigung ist, sondern 0377
.
Das deutet darauf hin, dass das Programm bereits mit Zeile 14 beendet wurde.
Warum hat aber die Einlesemethode read()
keine Fehlermeldung ausgegeben?
Deren Code (den Sie unter anderem auf beiliegender CD-ROM finden) macht eigentlich
einen ganz korrekten Eindruck. Woran liegt es dann? Das können wir nur erfahren,
wenn wir den Ablauf des Programms Schritt für Schritt verfolgen.
Bei einem Haltepunkt (breakpoint) hält der Debugger das Programm an und wartet auf Ihre Eingaben. Sie können sich nun die Werte einzelner Variablen oder Objekte ansehen, die Argumente der jeweiligen Methode untersuchen, in einzelnen Schritten weiterlaufen oder das Programm fortsetzen.
Der einfachste Fall ist der unbedingte Stopp. Oft ist es aber interessant, das
Programm nur dann an einer Stelle anzuhalten, wenn eine bestimmte Bedingung
erfüllt ist, zum Beispiel, wenn ein Zähler i
eine gegebene Grenze überschritten
hat, sagen wir 12. Denn leider geht bei komplexeren Programmen meist nicht sofort
etwas schief, sondern erst nach einiger Zeit ...
Das Kommando zum Setzen eines Haltepunkts ist
break
oder einfach b
. Da der Debugger schon beim Start die Zusatzinformationen
über das Programm lädt, kann er zwei ganz verschiedene Argumente anbieten: die
Nummer der Zeile, bei der das Programm stehen bleiben soll, oder den Namen einer
Funktion beziehungsweise Methode, zu deren Beginn gestoppt wird.
(gdb) break 13
Breakpoint 1 at 0x804a72d: file main.cc,
line 13.
(gdb) break matrix.cc:101
Breakpoint 2 at 0x804ac87: file matrix.cc,
line 101.
break
auch den Namen einer Funktion oder Methode angeben.
Dann hält das Programm bei der ersten Anweisung dieser Funktion an. Bei Methoden
ist noch der Klassenname erforderlich. Nur bei Gefahr von Verwechslungen sollten
Sie noch den Dateinamen davorschreiben.
Nun ein Beispiel:
(gdb) b SymMatrix::read
Breakpoint 3 at 0x804ac52: file matrix.cc,
line 94.
Wenn Sie bei vielen Haltepunkten schon den Überblick verloren haben, geben Sie ein:
(gdb) info breakpoints
Wie wird man einen Haltepunkt nun wieder los? Das Gegenstück zu break
ist
clear
. Dieses Kommando versteht dieselben Argumente, zum Beispiel
(gdb) clear matrix.cc:101
Deleted breakpoint 2
Eine andere Möglichkeit ist delete
,
das jedoch als Argument die Nummer des Haltepunktes erwartet.
Wenn Sie nicht ganz auf den Haltepunkt verzichten wollen, können Sie ihn auch vorübergehend
deaktivieren. Das geschieht durch das Kommando disable
, das wieder die
Nummer erwartet. Mit enable
schaltet man ihn dann wieder ein. Eine Bestätigung
gibt es bei diesem Kommando nicht; den Zustand können Sie aber beispielsweise
aus der mit info breakpoints
erhältlichen Liste entnehmen.
Wenn wir jetzt run
eingeben, haben wir unser Ziel erreicht: das Programm
steht. Auf dem Bildschirm lesen wir:
(gdb) r
Starting program: /usr/thomas/kap05/DebugTest
Datei mit Matrix: cyclic.dat
Breakpoint 1, main () at main.cc:13
13 if (a.read(filename))
(gdb)
Wenn wir an einer Stelle anhalten, möchten wir natürlich auch den Kontext wissen,
in dem wir uns gerade befinden. Am einfachsten geht das mit dem Kommando
list
, kurz l
. Damit erhalten Sie den Code einige Zeilen vor und
nach der aktuellen Position:
(gdb) list
8 string filename;
9 cout << "Datei mit Matrix: ";
10 cin >> filename;
11
12 SymMatrix a;
13 if (a.read(filename))
14 return -1;
15
16 double d=determinant(a);
17 cout << "Determinante ist "
list
-Befehl versteht eine ganze Reihe von Argumenten (ähnlich wie
break
): eine Zeilennummer oder ein Funktionsname (beides auch mit Dateiname
davor) sowie -
, um die Zeilen vor, und
+
, um die Zeilen nach
dem letzten Ausschnitt zu zeigen. Die Größe des Ausschnitts können Sie mit set listsize <zahl>
verändern.
Wie geht es nach einem Haltepunkt weiter? Die typische Vorgehensweise bei der Fehlersuche ist, einen Haltepunkt an den Anfang des interessierenden Bereiches zu setzen und dann in einzelnen Schritten das Programm beim weiteren Ablauf zu beobachten. Dabei untersucht man auch die Inhalte der lokalen Variablen und Funktionsparameter, ob sie die erwarteten Werte haben. Anschließend kann man die Ausführung fortsetzen, entweder bis zum nächsten Haltepunkt, bis zum Programmende -- oder bis zum Absturz.
Zur Ausführung in Einzelschritten gibt es im Wesentlichen vier Kommandos, deren
Bedeutung und Unterschiede Sie sich für die Arbeit mit allen Arten von Debuggern
einprägen sollten: next
geht zur nächsten Codezeile, führt aber Funktionsaufrufe
sofort aus; step
geht immer zur nächsten Codezeile, auch wenn diese in
einer aufgerufenen Funktion liegt; until
geht zur nächsten Codezeile,
auch wenn diese außerhab der gerade durchlaufenen Schleife liegt; finish
führt alle Befehle bis zum Ende der aktuellen Funktion aus und kehrt dann zum
Aufrufer zurück. Diese Befehle wollen wir uns noch etwas genauer ansehen:
next
, kurz n
: Damit können Sie in der gerade betrachteten Funktion Zeile um
Zeile weitergehen. Steht in einer Zeile ein Funktionsaufruf, so wird dieser
als Ganzes ausgeführt und das Programm stoppt in der darauffolgenden Zeile wieder.
Bei unserem Beispiel gelangen wir somit unmittelbar von Zeile 13 nach Zeile 14:
Breakpoint 1, main () at main.cc:13
13 if (a.read(filename))
(gdb) n
14 return -1;
(gdb) n
21 }
step
, kurz s
: Mit diesem Befehl gehen Sie ebenfalls immer um eine Zeile voran.
Steht in dieser aber ein Funktionsaufruf, so springen Sie in die erste Zeile
der Unterfunktion und können dort wieder Zeile für Zeile abarbeiten lassen.
Wenn wir in unserem Beispielprogramm so vorgehen, führt das zu folgender Ausgabe:
Breakpoint 1, main () at main.cc:13
13 if (a.read(filename))
(gdb) s
SymMatrix::read (this=0xbffff5a8, _fname=
@0xbffff5c8) at matrix.cc:94
94 if (size != 0)
(gdb) s
101 ifstream in(_fname.c_str());
(gdb) s
basic_string<char, string_char_traits<char>,
__default_alloc_template<true, 0> >::c_str
(this=0xbffff5c8) at std/bastring.h:147
147 Rep *rep () const { return
reinterpret_cast<Rep *>(dat) - 1; }
c_str()
der string
-Klasse gelandet -- auch wenn das aus dieser
Ausgabe nicht sonderlich deutlich wird.
until
, kurz u
: Dieser Befehl ist next
sehr ähnlich. Der Unterschied macht
sich allerdings bei Schleifen bemerkbar. Wenn Sie sich am Ende einer Schleife
befinden (und eine weitere Iteration ansteht), gelangen Sie mit next
wieder an den Anfang. Wenn Sie jedoch until
eingeben, werden alle noch
ausstehenden Iterationen sofort ausgeführt und das Programm stoppt in der Zeile,
die auf die Schleife folgt. Auf diese Weise können Sie sich nervenaufreibende
Einzelschritte durch sich oft wiederholende Schleifen sparen.
Bei unserem Beispiel können Sie den Unterschied bei der Einlesefunktion SymMatrix::read()
erkennen.
121 for(unsigned int i=0; i<size; i++)
(gdb) n
122 for(unsigned int j=0; j<size; j++)
(gdb) n
125 in >> x;
(gdb) n
126 at(i,j) = x;
(gdb) n
129 if (in.eof())
(gdb) n
136 }
(gdb) n
125 in >> x;
(gdb) n
126 at(i,j) = x;
(gdb) n
129 if (in.eof())
(gdb) n
136 }
(gdb) u
138 return true;
finish
: Dieses Kommando dient dazu, die Funktion, in der Sie sich aktuell befinden,
zu beenden. Das bedeutet aber nicht, dass Sie die Ausführung abrupt beenden
und sofort zurückspringen (dafür gibt es auch im gdb den Befehl return
).
Bei finish
werden alle Anweisungen bis zum regulären Ende der Funktion
abgearbeitet; anschließend stoppt das Programm bei der nächsten Zeile in der
aufrufenden Funktion (sofern Sie nicht finish
in der main()
-Funktion
eingegeben haben).
Wenn wir unser Beispiel an der Stelle fortsetzen, an der wir bei until
stehen geblieben waren, gelangen wir mit finish
zu main()
zurück.
138 return true;
(gdb) finish
Run till exit from #0 SymMatrix::read
(this=0xbffff5a8, _fname=@0xbffff5c8)
at matrix.cc:138
0x804a73d in main () at main.cc:13
13 if (a.read(filename))
Value returned is $2 = true
(gdb)
Am letzten Beispiel sehen wir, dass read()
den Wert true
zurückliefert;
also hat das Einlesen funktioniert. Warum bleibt unser Programm aber dennoch
stehen? Ein genauer Blick zeigt, dass unser Programm einen lästigen, aber typischen
Tippfehler hatte:
if (a.read(filename))
return -1;
if (!a.read(filename))
return -1;
Als Beispiel nehme ich nun eine
zyklische Matrix vom Grad 3 (siehe Seite ).
Deren Determinante ist -18. Wenn wir unser Programm neu übersetzen, starten
und diese Matrix einlesen, beendet es sich mit einem Segmentation
fault. Wie in früheren Abschnitten (Seite
oder Seite
) beschrieben, ist eine häufige Ursache dafür ein falscher
Speicherzugriff -- entweder eine Bereichsüberschreitung eines Feldes beziehungsweise
das Schreiben in nicht reservierten Speicher oder Freigeben von vorher nicht reserviertem
Speicher.
Wenn wir das Programm aus der Shell starten, erhalten wir jedoch außer der Fehlermeldung keinerlei Hinweis, wo das Problem liegen könnte. Aber dazu haben wir ja den Debugger. Hier erfahren wir:
Program received signal SIGSEGV,
Segmentation fault.
0x804b1c3 in SymMatrix::subMatrix
(this=0xbffff5a8, _k=0) at matrix.cc:177
177 u.at(i, uj++) = dat[i+1][j];
Sehen wir uns die Methode SymMatrix::subMatrix()
etwas genauer an:
168:SymMatrix SymMatrix::subMatrix(
168:unsigned int _k) const
169:{
170:// Untermatrix eine Dimension kleiner
171:SymMatrix u(size-1);
172:173:
// Untermatrix nach der ersten Zeile
174:for(unsigned int i=0; i<size; i++)
175:for(unsigned int j=0, uj=0; j<size; j++)
175:// Betrachte nur Spalten ungleich _k
176:if(j != _k)
177:u.at(i, uj++) = dat[i+1][j];
178:179:
return u;
180:}
Hier wird die Untermatrix nach der ersten Zeile gebildet. Von wo aus wurde diese
Methode aber aufgerufen? Das erfahren wir durch das Kommando
backtrace
, kurz bt
. An dieser Stelle ist die Ausgabe:
(gdb) backtrace
#0 0x804b1c3 in SymMatrix::subMatrix
(this=0xbffff5a8, _k=0) at matrix.cc:177
#1 0x804b424 in determinant (_a=@0xbffff5a8)
at matrix.cc:209
#2 0x804a77e in main () at main.cc:16
Nun wissen wir zwar, dass _k
den Wert 0 hat. Von den anderen Variablen
ist uns jedoch nichts bekannt. Diese können wir mit dem Kommando
print
, kurz p
, untersuchen. Interessant sind ja insbesondere die
Schleifenvariablen i
und j
.
(gdb) p i
$1 = 2
(gdb) p j
$2 = 1
(gdb) p uj
$3 = 1
u.at(2, 1) = dat[3][1];
Der erste Teil der Ausgabe ist eine interne Referenznummer. Über die können
Sie den Wert selbst weiterverwenden, ohne sich ihn merken zu müssen. Noch kürzer
geht es mit $
, das die letzte Ausgabe, und $$
, welches die
vorletzte Ausgabe darstellt.
(gdb) p u.dat[$][$$]
$4 = 2
print
ausgeben (natürlich erscheinen dabei
nur die Attribute):
(gdb) p u
$5 = {dat = 0x8063400, size = 2}
(gdb) p *this
$6 = {dat = 0x8063420, size = 3}
(gdb) p dat[0][0]@size
$7 = {1, 2, 3}
display
. Damit
können Sie erreichen, dass der gdb die angegebenen Variablen jedes
Mal ausgibt, wenn das Programm stoppt. Dazu muss der Debugger aber gerade in
einem Programmkontext sein, in dem diese Variablen bekannt sind. Ein display
-Kommando
in einer anderen Funktion oder vorsichtshalber vor dem Programmstart funktioniert
daher nicht.
Setzen wir zum Beispiel in der Methode SymMatrix::subMatrix()
einen Haltepunkt
und lassen das Programm bis dahin laufen. Dann können wir auch die Beobachtung
der Schleifenvariablen aktivieren:
Breakpoint 1, SymMatrix::subMatrix
(this=0xbffff5a8, _k=0) at matrix.cc:171
171 SymMatrix u(size-1);
Current language: auto; currently c++
(gdb) display i
No symbol "i" in current context.
(gdb) n
174 for(unsigned int i=0; i<size; i++)
(gdb) n
175 for(unsigned int j=0, uj=0; j<size; j++)
(gdb) display i
1: i = 0
(gdb) n
177 if(j != _k)
1: i = 0
(gdb) display j
2: j = 0
continue
eingeben),
landen wir schließlich in folgender Situation:
Program received signal SIGSEGV, Segmentation
fault.
0x804b1c3 in SymMatrix::subMatrix
(this=0xbffff5a8, _k=0) at matrix.cc:177
177 u.at(i, uj++) = dat[i+1][j];
2: j = 1
1: i = 2
dat
ist eine Matrix mit drei Zeilen und drei Spalten. Da die Zählung in C++ immer
bei 0 beginnt, sind also Indizes von 0 bis 2 zulässig. Hier haben wir aber i+1
=3
als Zeilenindex für dat
verwendet, mit den bekannten Folgen. Das Problem
ist damit erkannt: Die Schleife über i
läuft zu weit. Da wir eine Untermatrix
bilden wollen, die eine Zeile und eine Spalte weniger hat als das Original,
müssen wir natürlich auf die Bedingung i<size-1
achten anstatt
auf i<size
. Wir müssen unser Programm daher an folgender Stelle korrigieren:
174: for(unsigned int i=0; i<size-1; i++)
Nachdem wir das Problem ausfindig gemacht haben, brauchen wir die Dienste des Debuggers
vorerst nicht mehr. Er lässt sich mit dem Kommando
quit
, kurz q
, beenden.
Wenn jedoch gerade ein Programm läuft und etwa an einem Haltepunkt steht, fragt der gdb erst noch einmal nach, ob es Ihnen mit dem Verlassen ernst ist:
The program is running. Exit anyway? (y or n)
Nehmen wir mal an, Sie wollten ordentlich sein und alles aufräumen, bevor Sie das
Werkzeug verlassen. Dann ist es das Beste, das Programm mit continue
bis zu seinem Ende weiterlaufen zu lassen, in unserem Fall bis zur Meldung:
Program terminated with signal SIGSEGV, Segmentation fault. The program no longer exists.
Wenn wir nun den Fehler korrieren, das Programm übersetzen und starten, erhalten wir endlich die erwartete Ausgabe:
Determinante ist 18
Mit dieser Beschreibung sind die Fähigkeiten des gdb nicht einmal annähernd vollständig aufgeführt. Es lohnt sich daher auf alle Fälle, die Info-Seiten zu studieren; Sie werden dort noch einige sehr interessante Features finden, die hier aus Platzgründen weggelassen werden mussten:
So leistungsfähig der gdb auch ist -- seine Bedienung über die Kommandozeile setzt jedoch genaue Kenntnisse über die Befehle und ihre Argumente voraus. Vielen PC-Umsteigern mutet eine solche Arbeitsweise zudem sehr spartanisch an. Für all jene ist der grafische Data Display Debugger DDD zu empfehlen.
Er ist entstanden aus Diplom- und Doktorarbeiten von Dorothea Lütkehaus und Andreas
Zeller an der Technischen Universität Braunschweig. Obwohl die Autoren diese
Hochschule mittlerweile verlassen haben, wird er dort immer noch gepflegt und
archiviert. Da er ebenfalls unter der GNU General public license (GPL, siehe
Seite ) steht, sind viele Entwickler weltweit an seiner Weiterentwicklung
beteiligt.
Sie können stets die neueste Version über die Web-Seite www.cs.tu-bs.de/softech/ddd (oder www.gnu.org/software/ddd) beziehen. Aber auch bei den meisten Linux-Distributionen ist der DDD mittlerweile enthalten.
Der Data Display Debugger ist keineswegs die einzige grafische Benutzerschnittstelle
zum gdb; neben den eigenständigen Frontends xxgdb und
tgdb
bringen auch die meisten integrierten Entwicklungsumgebungen eigene
Aufsätze auf den gdb mit, beispielsweise Kdbg in
KDevelop (siehe Seite )
oder der SNiFF-Debugger in SNiFF+ (Seite
). Aufgabe jedes dieser Programme ist
es, dem Benutzer die Eingabe der gdb-Befehle auf der Kommandozeile
zu ersparen und die Bedienung vorwiegend auf Mausklicks zu beschränken; dabei
legt man natürlich besonderen Wert auf die gebräuchlichsten Befehle und lässt
seltenere außen vor.
Das Besondere am DDD, dem er auch seine Popularität verdankt, ist seine Fähigkeit, komplexe Datenstrukturen als Graphen zu visualiseren. Durch einfache Mausklicks kann der Benutzer Zeiger dereferenzieren oder die Inhalte von Objekten darstellen lassen. Besonders bei verschachtelten Objekten lassen sich die Zusammenhänge sehr gut durch automatisch verwaltete Bäume veranschaulichen. Auf Wunsch dringt die Anzeige dabei immer tiefer in die Verschachtelungen vor.
In diesem Abschnitt setze ich voraus, dass Sie die Grundbegriffe des Debuggens
und des Einsatzes des gdb kennen, wie ich sie im letzten Abschnitt
ab Seite beschrieben habe. Zunächst wollen wir uns ansehen,
wie die gdb-Kommandos über den DDD erreichbar sind. Anschließend
werden wir einen Blick auf die Visualisierung von Datenstrukturen werfen.
Die Art des Aufrufes ist genau dieselbe wie beim gdb; Sie müssen lediglich die ersten zwei Buchstaben austauschen. Als Argument können Sie den Namen des Programms und eventuell eine Speicherauszugsdatei oder eine Prozessnummer angeben. Für unser Beispiel heißt das:
% ddd DebugTest
|
Gleich anschließend sehen Sie das DDD-Fenster vor sich (Abbildung ).
Die Aufteilung des Fensters dürfte klar sein: Menüleiste, Werkzeugleiste, Quelltextfenster,
Debugger-Konsole und Statusleiste. Je nach Situation können noch weitere Teile
hinzukommen, zum Beispiel das grafische Datenfenster. Wenn Sie möchten, stellt
Ihnen der DDD auch alle Teile in separaten Fenstern dar (siehe EDIT
| PREFERENCES | STARTUP | WINDOW LAYOUT).
Besonders praktisch ist das separate Werkzeugfenster für die wichtigsten Kommandos (in
Abbildung über der rechten Hälfte des Quelltextfensters). Darüber
können Sie die Einzelschrittausführung bequem steuern. Im Folgenden wollen wir
dieses Fenster kurz als Kommandoleiste bezeichnen. (Über EDIT
| PREFERENCES | SOURCE | TOOL BUTTONS LOCATION lassen sich die enthaltenen
Schaltflächen aber auch im Quelltextfenster platzieren. Überhaupt können Sie
mit dem PREFERENCES-Dialog den DDD sehr weitreichend an
Ihre persönlichen Vorlieben anpassen.)
Am einfachsten starten Sie Ihr Programm über die Schaltfläche RUN der Kommandoleiste oder mit der Taste F2. Wenn Sie Kommandozeilenargumente brauchen, können Sie diese über den Dialog festlegen, den Sie mittels des Menüpunktes PROGRAM | RUN erhalten. Der DDD speichert dort sogar mehrere Folgen von Argumenten, aus denen Sie sich eine aussuchen können.
Auch für die Festlegung von Haltepunkten gibt es mehrere Möglichkeiten. Am einfachsten
ist der Doppelklick am Anfang der Codezeile. Zur Kennzeichnung des Haltepunktes
erscheint ein Stoppschild. Alternativ gibt es den Punkt SET BREAKPOINT
aus dem Kontextmenü (öffnet sich durch Klick auf die rechte Maustaste) oder
dem Stopp-Symbol aus der Werkzeugleiste. Wenn Sie Haltepunkte über Funktionsnamen
festlegen wollen oder sonstige komplexere Vorgaben beabsichtigen, ist der Menüpunkt
SOURCE | EDIT BREAKPOINTS das Richtige. Schließlich steht es Ihnen
auch frei, das Kommando break
in die gdb-Konsole einzugeben.
Standardmäßig lädt DDD die Quelltextdatei, die die main()
-Funktion enthält.
Alle weiteren beteiligten Dateien können Sie über FILE | OPEN SOURCE
auswählen und laden.
Ein Programm in einzelnen Schritten unter Beobachtung ablaufen zu lassen, ist beim
DDD überaus einfach. Starten Sie das Programm über RUN auf
der Kommandoleiste und lassen Sie es bis zu einem eingestellten Haltepunkt laufen.
Dann stehen wieder die Befehle step
, next
, until
und finish
zur Verfügung (siehe Seite ) -- alles über die gleichnamigen
Schaltflächen auf der Kommandoleiste. Auch der Befehl
cont
zum Fortsetzen
ist darüber erreichbar.
Wenn Sie innerhalb einer Funktion zum Halten kommen, können Sie sich alle Funktionen
ausgeben lassen, über die das Programm hierher gelangt ist. Was beim gdb
noch backtrace
heißt, findet sich hier unter dem Menüpunkt STATUS
| BACKTRACE. Die Darstellung erfolgt über ein Dialogfenster (Abbildung ),
das noch eine weitere Hilfestellung in sich birgt. Mittels der Schaltflächen
UP und DOWN können Sie auch im Quelltextfenster zu den jeweiligen
Programmstellen springen, an denen der Aufruf steht. Dort können Sie dann nach
Wunsch weitere Untersuchungen anstellen.
Dabei wollen Sie sicher auch die Werte der verschiedenen Variablen überprüfen. Bei Standardtypen geht das am einfachsten, indem Sie mit dem Mauszeiger darauf deuten und einen Augenblick warten. Sofort erscheint ein kleines Hilfefenster, das den Wert der jeweiligen Variablen (oder auch Konstanten) enthält. Diese Information wird zudem gleichzeitig in der Statuszeile ausgegeben.
Wollen Sie alle lokalen Variablen verfolgen, genügt die Auswahl des Menüpunktes DATA | DISPLAY LOCAL VARIABLES. In einem Kästchen im Datenfenster werden Sie dann ständig über die aktuellen Werte informiert. Für Grafik-Freunde ist sogar eine Ausgabe von einer oder mehrerer Variablen als Plot über die gleichnamige Schaltfläche der Werkzeugleiste möglich (sofern Sie gnuplot installiert haben). Sie müssen lediglich die zu untersuchende Variable im Datenfenster oder im Quelltextfenster markiert haben.
Natürlich findet sich auch im DDD die dauerhafte Verfolgung von Variablenwerten
wieder, die wir beim gdb als display
-Kommando kennen gelernt
haben. Eine Möglichkeit bietet das Kontextmenü. Klicken Sie über der interessierenden
Variablen im Quelltextfenster die rechte Maustaste; hier können Sie nun sowohl
eine Anzeige des Wertes selbst als auch, bei Zeigern, des dereferenzierten Inhalts
aktivieren. Die Ausgabe erfolgt in einem Kästchen des Datenfensters.
Die Anzeige von Feldern geht nicht ganz so automatisch. Hier ist wieder Ihr
gdb-Know-how gefragt. Auf Seite hatten wir
festgestellt, dass man mehrere Einträge eines Feldes zusammen ausgeben kann,
indem man das @-Zeichen und die Anzahl der Werte hinter die Angabe des ersten
Wertes setzt. Diese Möglichkeit wählen wir auch hier: Klicken Sie auf die Schaltfläche
DISPLAY der Werkzeugleiste und halten Sie sie für einen Moment gedrückt.
Aus dem sich öffnenden Menü wählen wir OTHER. Dadurch gelangen wir
zu einem Dialog (Abbildung
), in dem wir unsere Eingabe,
beispielsweise
this->dat[0][0]@size
<Nx>
deutlich gemacht.
Wenn Sie beim Debuggen Ihren Fehler entdeckt haben, können Sie natürlich zu Ihrer Entwicklungsumgebung
zurückkehren, die Codestelle korrieren und das Programm übersetzen. Gehören
Sie jedoch zu den Kommandozeilenprogrammierern, die alle Arbeit an ihren
Programmen von der Shell aus erledigen, können Sie sich diesen Umweg sparen.
Über EDIT aus der Kommandoleiste können Sie ein Fenster öffnen, das
Ihnen das Editieren der aktuellen Datei mittels des vi-Editors (siehe
Seite ) erlaubt. Änderungen erscheinen sofort nach Schließen
des Editors im Quelltextfenster. Wenn Sie nun noch auf MAKE klicken,
wird die geänderte Datei (bei einem korrekten Makefile) sofort übersetzt und
das Programm aktualisiert. Der DDD merkt übrigens beim Starten eines
Programms, ob es in einem anderen Prozess neu gebaut wurde und liest die Symboltabelle
gegebenenfalls nochmals ein.
Und wenn Sie mal gar nicht mehr weiter wissen, gibt Ihnen HELP | WHAT NOW? immer einen freundlichen Hinweis, was Sie als Nächstes tun könnten. Um mehr über die Arbeit mit dem DDD zu erfahren, finden Sie unter HELP | DDD REFERENCE ein ausführliches Handbuch (das zudem der Installation auch im PostScript-Format beiliegt). Bei jedem Start begrüßt Sie der DDD außerdem mit einem Tip of the day; anhand dieser Tipps können Sie auch viel über die effiziente Bedienung dieses Werkzeugs lernen.
Insgesamt erkennen Sie, dass der DDD alle Fähigkeiten des gdb in bequemerer Form zugänglich macht, gleichzeitig aber einige eigene Funktionalität mitbringt. Dieser wollen wir uns nun widmen.
Als Beispiel verwende ich diesmal nicht die Determinantenberechnung aus dem letzten
Abschnitt; für diese Funktionen brauchen wir etwas komplexere Datenstrukturen.
Wir sehen uns daher das Programm birthcontrol aus Abschnitt
(ab Seite
) an, das Sie an Geburtstage Ihrer Familie und
Freunde erinnern soll. Die zentrale Rolle dabei spielt die Klasse
BirthList
.
Werfen wir einen Blick auf die Deklaration (die schon viel von der Definition
enthält):
1:// Datei: birthlist.h
2:#include <string>
3:#include "date.h"
4:5:
//--------------------------------------
6:// Klasse fuer Elemente der Liste
7://--------------------------------------
8:struct BirthListElement
9:{
10:BirthListElement* next;
11:string name;
12:string surname;
13:Date date;
14:15:
// Standardkonstruktor
16:BirthListElement() :
17:next(0) {}
18:19:
// Spezieller Konstruktor
20:BirthListElement(const string& _name,
21:const string& _surname,
22:unsigned short _day,
23:unsigned short _month,
24:unsigned short _year) :
25:next(0),
26:name(_name),
27:surname(_surname),
28:date(_day, _month, _year)
29:{}
30:};
31:32:
//--------------------------------------
33:// Listenklasse
34://--------------------------------------
35:class BirthList
36:{
37:private:
38:BirthListElement* first;
39:BirthListElement* last;
40:int size;
41:42:
public:
43:BirthList() :
44:first(0), last(0), size(0) {}
45:46:
virtual ~BirthList();
47:48:
bool empty() const
49:{ return (size == 0); }
50:51:
int getSize() const
52:{ return size; }
53:54:
void pushBack(const string& _name,
55:const string& _surname,
56:unsigned short _day,
57:unsigned short _month,
58:unsigned short _year);
59:60:
void popFront();
61:62:
BirthListElement* front()
63:{ return first; }
64:65:
int load(const string& filename);
66:67:
bool check(const Date& d);
68:};
Die einzelnen Daten werden dabei in Objekten vom Typ BirthListElement
abgelegt (Zeile 8-30). Wie in Abbildung (Seite
)
speichern wir auch hier die Daten in Form einer einfach verketteten Liste. Das
nächste Listenelement wird durch den Zeiger
next
ausgedrückt. Um
aber bequem auf die Liste zugreifen zu können, speichern wir in der Klasse BirthList
(Zeile 33-68) sowohl einen Zeiger auf das erste (first
) als auch auf
das letzte Element (last
).
![]()
|
Wie können wir die enthaltenen Daten sichtbar machen? Sobald Sie mit der linken
Maustaste im Quelltextfenster auf einen Bezeichner klicken, erscheint dieser
im Argumentfeld (das ist die Combobox unterhalb des Hauptmenüs, das mit einem
Klammerpaar davor gekennzeichnet ist). Alle print
- oder display
-Befehle,
die Sie nun über das Kontextmenü oder die Schaltfläche der Werkzeugleiste aktivieren,
nehmen darauf Bezug. (Daher taucht bei diesen auch immer das leere Klammernpaar
auf.) Die so ausgeführten display
-Kommandos erscheinen im Datenfenster
in Form von Kästchen. Dort können Sie dann weitere Details in Erfahrung bringen.
Bei Datenstrukturen, die wieder andere Strukturen enthalten (wie unser BirthListElement
),
werden die Daten zunächst in knapper Form angezeigt, etwa als Pünktchen ...
oder als Wert des Zeigers. Um diese zu expandieren, wählen Sie zunächst die
gewünschte Struktur aus (durch eine Klick, durch mehrere Klicks mit festgehaltener
-Taste oder durch Umranden der Kästchen mit einem Fangrechteck
mittels Ziehen mit der linken Maustaste). Anschließend öffnen Sie das Kontextmenü
und klicken auf SHOW ALL. Damit weisen Sie die Anzeige an, alle Verschachtelungen
aufzulösen und sämtliche Daten darzustellen. Ein mögliches Resultat können Sie
in Abbildung
sehen.
Über den Schalter SHOW beziehungsweise HIDE der Werkzeugleiste lässt sich das Expandieren noch selektiver gestalten. So zeigt etwa SHOW MORE die Inhalte der gerade verborgenen Datenstruktur an, aber nicht die Inhalte der in ihr enthaltenen. Mit SHOW JUST werden alle Details eingeschachtelter Strukturen verborgen und nur die aktuelle Ebene angezeigt. Als Gegenstück dazu verbirgt HIDE alles.
Die Pfeile zwischen den Kästchen (den so genannten Displays) verraten die
Beziehung zwischen ihnen. In Abbildung entspricht der rechten
Kasten beispielsweise einer Dereferenzierung des Zeigers im linken, angedeutet
durch
*()
über dem Pfeil. Wenn Sie jetzt auf den Eintrag next
doppelt klicken, erscheint ein neuer Kasten mit dessen Inhalt; über dem Pfeil
steht dann next
.
Spätestens wenn Sie sich beispielsweise mit DATA | EDIT DISPLAYS eine Liste aller aktuellen
Kästchen ausgeben lassen, werden Sie merken, dass diese recht lang werden kann.
Standardmäßig wird für jede zu verfolgende Variable ein eigenes Kästchen angelegt.
Um aber die Übersicht nicht zu verlieren, sollten Sie die Möglichkeit nutzen,
mehrere Displays zu einem zusammenzufassen. Ein solch fusioniertes Display bezeichnet
die DDD-Dokumentation als Cluster (Abbildung ).
Markieren Sie zur Clusterbildung die gewünschten Kästchen (durch Anklicken bei
gedrückter
-Taste oder durch Umranden der Kästchen mit einem
Fangrechteck), klicken Sie auf den Schalter UNDISP auf der Werkzeugleiste
(gekennzeichnet mit einer Art Totenkopf, in Abbildung
ganz rechts) und wählen Sie aus dem sich öffnenden Menü den Eintrag CLUSTER().
Alle künftigen Displays können Sie zu einem bestehenden Cluster hinzufügen,
indem Sie die Voreinstellung EDIT | PREFERENCES | DATA | CLUSTER DATA
DISPLAYS aktivieren.
Wie Sie gesehen haben, ist der DDD mehr als nur ein Frontend zum gdb. Er verfügt über eine Reihe eigener Fähigkeiten, von denen ich Ihnen hier leider nur einen kleinen Teil zeigen konnte. Lassen Sie sich als bei Ihrer Arbeit mit dem DDD durch den Tip of the day oder einen Blick ins Handbuch dazu anregen, weitere Möglichkeiten zu erkunden. Sie werden sehen: Es lohnt sich!
Bei den Fehlerquellen, die wir in Abschnitt untersucht
haben, blieb ein Typ völlig unberücksichtigt: falsch benutzte Methoden (oder Funktionen).
Denn jede Methode, die nicht gerade konstant ist, ändert den Zustand des Objekts
oder des ganzen Programms. Daher muss die Übergabe ungültiger Parameter oder
die falsche Reihenfolge des Aufrufs zu einem undefinierten Zustand führen, der
sich früher oder später auf das ganze Programm fatal auswirkt.
Um die Aufrufspezifikation exakt zu formulieren, sollte man Vor- und Nachbedingungen für jede Methode festlegen. Vorbedingungen sind die Erwartungen, die die Methode in die Parameter und den Aufrufkontext setzt (dass also zum Beispiel ein Parameter größer als null ist). Nachbedingungen drücken aus, was der Aufrufer von der Methode erwarten darf.
Auf diese Weise wird die Beziehung zwischen einer Funktion und deren Benutzer auf eine klar definierte Grundlage gestellt. Eine solche kann man auch als Vertrag zwischen beiden auffassen. Als einer der ersten hat Bertrand Meyer in seinem sehr lesenswerten Buch [MEYER 1997] auf diese Thematik hingewiesen. Er empfiehlt, sich bei der gesamten Entwicklung vom Vertragsgedanken zwischen den Softwarekomponenten leiten zu lassen und ein Design by Contract anzustreben.
Die Spezifikation des Vertrags sollte in der Header-Datei bei der jeweiligen Methode stehen, damit ein Benutzer sie auch schnell findet. Wie aber kann man das Einhalten der Vertragsbedingungen überprüfen? Und welche Sanktionen drohen bei Vertragsverletzung?
Eine einfache Möglichkeit, logische Annahmen zu verifizieren, bietet das Makro
assert()
, das in der Datei cassert der C++-Standardbibliothek
definiert ist. Es überprüft eine Bedingung. Ist diese erfüllt, passiert gar
nichts und das Programm kann ungestört weiter arbeiten. Ist sie jedoch verletzt,
beendet assert()
das Programm mit einer Fehlermeldung. (Das zugehörige
Substantiv assertion heißt übrigens auf Deutsch Zusicherung.)
Als Beispiel betrachten wir eine Funktion, die einen String mehrfach hintereinander auf einen übergebenen Stream ausgibt:
1:// Datei asserttest.cc
2:3:
#include <string>
4:#include <iostream>
5:#include <cassert>
6:7:
// Funktion: repeatedOutput
8:// Parameter:
9:// ostream* _o: Ausgabestream
10:// const string& _s: String
11:// unsigned short _n: Wiederholung
12:// Bedingung: _o != 0
13:void repeatedOutput(ostream* _o,
14:const string& _s, unsigned short _n)
15:{
16:assert(_o); // entspricht _o!=0
17:for(unsigned i=0; i<_n; i++)
18:*_o << _s;
19:}
20:21:
int main()
22:{
23:repeatedOutput(&cout, "-", 30);
24:cout << endl;
25:repeatedOutput(0, "?", 30);
26:27:
return 0;
28:}
In Zeile 16 wird überprüft, ob die Vorbedingung, nämlich dass _o
ungleich
0 ist, tatsächlich erfüllt wird. In Zeile 25 provozieren wir eine Vertragsverletzung
und erhalten als Ausgabe:
---------------
asserttest: asserttest.cc:16: void
repeatedOutput( ostream *, const string &,
short unsigned int): Zusicherung »_o« nicht
erfüllt.
Abort
Sorgfältig getesteter Code sollte solche Vertragsverletzung nicht mehr enthalten. Daher
kann für die endgültige Version Ihres Programms die ständige Überprüfung der
Bedingungen entfallen, die ja auch selbst Rechenzeit kostet. Um assert()
abzuschalten, müssen Sie den Schalter NDEBUG
definieren und zwar vor
dem Einbinden von cassert. Das können Sie beispielsweise mit der Präprozessor-Anweisung
#define NDEBUG
erledigen. Bequemer und sicherer ist aber die Übergabe
dieser Definition direkt an den Compiler mittels des Schalters -DNDEBUG
,
also etwa:
% g++ -g -DNDEBUG asserttest.cc -o asserttest
assert()
, ist die Ausgabe ein
wenig anders:
---------------
Segmentation fault
Allerdings ist auch assert()
kein Allheilmittel. Es ist zum Beispiel nicht ratsam,
es außer bei der Überprüfung von Designfehlern auch noch zum Aufdecken von
Laufzeitfehlern zu verwenden. Wenn ein ungültiger Zustand einer Variablen nicht
aus einer falschen Ansteuerung der Schnittstelle, sondern aus laufzeitbedingten
Einflüssen wie Benutzereingaben, Dateizugriffen oder Rechenoperationen herrührt,
so handelt es sich dabei nicht um eine verletzte Zusicherung. Denn assert()
ist immer ein sehr harter Eingriff, einfach weil das Programm dadurch beendet
wird. Es sollte daher nicht missbraucht werden. (Sie können sich auch eine weichere
Variante davon erstellen, in der Sie anstelle des abort()
eine eigene
Fehlermeldung, beispielsweise in eine Log-Datei ausgeben, aber das Programm anschließend
fortsetzen.)
Aus diesem Abschnitt sind folgende Aspekte hervorzuheben:
flush
nach jeder Ausgabe.
-g
übersetzt worden sein.
cont
fortsetzen, mit next
zur nächsten
Codezeile gehen, dabei aber Funktionsaufrufe sofort ausführen, mit step
immer zur nächsten Codezeile gehen, auch wenn diese in einer aufgerufenen Funktion
liegt, mit until
zur nächsten Codezeile gehen, auch wenn diese außerhab
der gerade durchlaufenen Schleife liegt und mit finish
alle Befehle bis
zum Ende der aktuellen Funktion ausführen und dann zum Aufrufer zurückkehren.
backtrace
gibt die Funktionen und Methoden aus, die nacheinander
aufgerufen wurden, um zur aktuellen Stelle zu kommen (den Aufrufstack).
print
kann man Variableninhalte einmalig, mit display
wiederholt
bei jedem weiteren Schritt ausgeben. Auf diese Weise lässt sich die Veränderung
der Daten eines Programms verfolgen.
assert()
kann man leicht das Einhalten von Vorbedingungen in Funktionen
und Methoden überprüfen und so Designfehler aufspüren.
Unter Versionskontrolle versteht man die Verfolgung von Änderungen, die an einer Datei vorgenommen werden, mit der Möglichkeit des Rückgriffs auf eine frühere Version. Meist sorgen entsprechende Werkzeuge auch gleich dafür, dass konkurrierende Zugriffe verschiedener Entwickler verhindert werden, dass also eine Datei nicht von zwei verschiedenen Personen gleichzeitig bearbeitet wird.
Vielleicht denken Sie bei dieser Definition zuerst an große Projekte, die von vielen Mitarbeitern entwickelt werden und Hunderttausende von Programmzeilen umfassen. Wenn Sie tatsächlich einmal an einem solchen Projekt mitarbeiten, wird eine Versionskontrolle über die Quellen eines entsprechenden Programms sowieso unerlässlich sein. Doch selbst, wenn Sie nur alleine oder mit ein oder zwei anderen an einem Projekt arbeiten, kann Ihnen der Einsatz einer Revisionskontrollsoftware durchaus Nutzen bringen.
Stellen Sie sich nur folgendes Beispiel vor: Ein Entwickler (oder natürlich eine Entwicklerin) arbeitet an einem momentan lauffähigen Programm. Nun möchte er eine bestimmte Routine durch eine leistungsfähigere ersetzen. Er modifiziert den dazugehörigen Quellcode und arbeitet ein bis zwei Stunden an der entsprechenden Verbesserung, bis er sich schließlich so verheddert hat, dass gar nichts mehr geht. Liebend gern würde er nun auf die ursprüngliche Routine -- die zwar weniger leistungsfähig, dafür aber funktionsfähig war -- zurückgreifen. Nur hat er im Eifer des Gefechts vergessen, die Datei, an der er Modifikationen vornahm, zuerst zu sichern. In mühevoller Kleinarbeit schafft er es endlich, nach erneuten zwei Stunden, die ursprüngliche Version zwar nicht vollständig, dafür aber lauffähig wieder zu restaurieren.
Bevor Sie in eine solche Situation geraten, sollten Sie daher mit dem Einsatz einer Software zur Revisionskontrolle Vorsichtsmaßnahmen vor einem solchen oder ähnlichen GAU treffen. Hätte im oben beschriebenen Beispiel ein Revisionskontrollsystem alle gemachten Änderungen mitprotokolliert, wäre es ein Leichtes gewesen, die lauffähige Version des Programms innerhalb weniger Minuten wiederherzustellen.
Unter Unix gibt es traditionell zwei Systeme zur Versionskontrolle. Dies ist zum einen SCCS (Source code control system), das Bestandteil des X/Open-Standards von Unix ist und daher bei allen kommerziellen Unix-Systemen mitgeliefert wird. Da es standardisiert ist und Urheberrechten unterliegt, ist es für Linux ungeeignet. Hier wird vielfach die Alternative RCS (Revision control system, siehe org.gnu.de/software/rcs) verwendet, das als Open Source entwickelt wird.
Hervorgegangen aus RCS, aber mit mittlerweile eigener Codebasis ist CVS (Concurrent version system, siehe www.sourcegear.com/CVS), das in sehr vielen Open-Source-Projekten eingesetzt wird. Seine Bedienung aus Sicht des Benutzers ist der von RCS so ähnlich, dass Sie sicher gut damit umgehen können, wenn Sie die nachfolgende Beschreibung verstanden haben. Die Möglichkeiten von CVS gehen aber noch wesentlich weiter, was die Konfiguration aus Sicht des Servers beziehungsweise Administrators ziemlich kompliziert machen kann. Aus diesem Grund will ich in diesem Rahmen nicht weiter darauf eingehen.
Ein wirkliches Frontend zu RCS ist das ebenfalls freie PRCS (Project revision control system, siehe www.xcf.berkeley.edu/~jmacd/prcs.html). Während RCS nur dateiorientiert arbeitet, also Zusammengehörigkeiten von Dateien in Projekten nicht berücksichtigen kann, ist PRCS in der Lage, auch ganze Projekte mit konsistenten Revisionsnummern zu verwalten. Auch wenn sich die Syntax ein wenig unterscheidet, sind die Aufgaben, die mit PRCS erledigt werden können, im Wesentlichen dieselben wie bei RCS. Daher gehe ich davon aus, dass Sie sich mit den erworbenen Kenntnissen schnell einarbeiten könnten, und belasse es bei dieser Erwähnung.
Daneben gibt es auch immer mehr Unterstützung für Linux durch die kommerziellen Versionskontrollsysteme. So sind beispielsweise Linux-Clients verfügbar für ClearCase (von Rational, siehe www.rational.com/products/clearcase) und PVCS (ursprünglich von Merant, auf Linux portiert von Synergex, siehe www.pvcs.synergex.com). Selbst für Visual SourceSafe von Microsoft gibt es bereits eine Portierung (von MainSoft, siehe www.mainsoft.com/products/visual).
Wenn Sie noch weitere Informationen über das Thema Konfigurationsmanagement (also Versionskontrolle im weiteren Sinn) brauchen, empfehle ich Ihnen die Web-Seite mit den am häufigsten gestellten Fragen (FAQ) der entsprechenden Diskussionsgruppe im Usenet, zu erreichen unter www.iac.honeywell.com/Pub/Tech/CM/CMFAQ.html.
Wie bereits angedeutet, liegt der Sinn der Versionskontrolle darin, von allen Projektdateien verschiedene Bearbeitungszustände zu speichern, um damit
Dabei kann eine Version als Revision fungieren, also als eine Neufassung, die eine bestehende Version ersetzt, oder als Variante, die gleichzeitig mit einer anderen Version existieren soll. Im Folgenden werden wir meistens Revisionen betrachten.
Dabei spielen zwei Begriffe eine wichtige Rolle: das Einchecken und das Auschecken. Das ist so ähnlich wie beim Fliegen:
Eine weitere wichtige Funktion ist der Vergleich von Revisionen. Auf diese Weise können Sie mit einem einfachen Kommando erkennen, welche Unterschiede zwischen einer Revision und einer anderen bestehen. Übrigens arbeitet RCS auch intern so, dass es lediglich die erste Revision vollständig speichert und bei den weiteren jeweils nur die Unterschiede zur vorhergehenden festhält.
Revisionsverwaltung ist nicht nur für Programmquelltexte empfehlenswert, sondern für alle Dateien, die Sie von Zeit zu Zeit ändern, deren Entwicklung Sie aber verfolgen wollen. Es funktioniert für HTML-Dateien ebenso wie für Systemkonfigurationsdateien (etwa aus dem Verzeichnis /etc).
RCS erzeugt zu jeder von ihm verwalteten Datei eine RCS-Datei, die denselben Namen wie die Datei trägt, allerdings mit einem angehängten ,v. Standardmäßig werden die RCS-Dateien in das gleiche Verzeichnis geschrieben, in dem sich auch die eigentlichen Dateien befinden. Das kann das Arbeitsverzeichnis sehr unübersichtlich machen. Legen Sie sich daher am besten vor dem Registrieren der ersten Datei ein Unterverzeichnis mit dem Namen RCS an. Ist nämlich ein solches vorhanden, werden die RCS-Dateien dort abgelegt.
Wenn Sie mit mehreren Kollegen an einem Projekt arbeiten, dann tauschen Sie Ihre Dateien vorwiegend über das lokale Netz aus, haben also eine gemeinsame Festplatte, die alle in ihr System eingebunden haben. Für die Organisation der Revisionsverwaltung gibt es dann zwei Möglichkeiten, von denen je nach Art und Umfang des Projekts eine gewählt werden sollte:
Als Beispiel wollen wir die Datei asserttest.cc aus dem vorherigen Kapitel
verwenden (ab Seite ). Zum Registrieren einer Datei verwendet
man das Kommando
rcs -i
, gefolgt vom Dateinamen. Dabei werden Sie noch
gebeten, eine Beschreibung der Datei einzugeben. Diese kann aus einer oder mehreren
Zeilen bestehen und muss mit einer Zeile, die nur einen Punkt enthält, oder
Strg+d abgeschlossen werden.
% mkdir RCS
% rcs -i asserttest.cc
RCS file: asserttest.cc,v
enter description, terminated with single '.'
or end of file:
NOTE: This is NOT the log message!
>> Testprogramm fuer assert-Kommando
>> .
done
%
% ls -l *
-rw-r-r- 1 thomas users 513
Dez 4 17:56 asserttest.cc
RCS:
total 1
-r-r-r- 1 thomas users 99
Dez 4 18:08 asserttest.cc,v
Sind wir mit der Bearbeitung fertig, geht es ans Einchecken. Dazu dient das Kommando ci (für check in). Dabei gibt es unterschiedliche Modi:
-u
-u
angeben. Das verhindert das Löschen.
-l
-l
.
-r
-r
explizit eine Revisionsnummer
an, zum Beispiel -r1.11
. Die Möglichkeit, eine Revisionsnummer ausdrücklich
anzugeben, haben Sie auch bei -u
und -l
.
% ci -u asserttest.cc
RCS/asserttest.cc,v <- asserttest.cc
initial revision: 1.1
done
Das analoge Kommando zum Auschecken ist co
(für check out). Wenn Sie es ohne weitere Schalter verwenden, erhalten
Sie eine Kopie der aktuellsten Revision, die Sie jedoch nur lesen und nicht
bearbeiten können. Wenn Sie eine Änderung vornehmen wollen, müssen Sie die Option
-l
angeben. Dadurch wird die Datei einerseits beschreibbar und andererseits
vor dem Zugriff von anderen durch eine Sperre (lock) geschützt.
% co -l asserttest.cc
RCS/asserttest.cc,v -> asserttest.cc
revision 1.1 (locked)
done
markus@sittich> co -l asserttest.cc
RCS/asserttest.cc,v -> asserttest.cc
co: RCS/asserttest.cc,v: Revision 1.1 is already
locked by thomas.
Normalerweise erhalten Sie beim Auschecken immer die aktuellste Revision. Wenn
Sie einmal auf eine ältere zugreifen wollen, geben Sie als Argument den Schalter
-r
gefolgt von der Revisionsnummer an:
% co -r1.1 asserttest.cc
RCS/asserttest.cc,v -> asserttest.cc
revision 1.1
done
Nehmen wir an, wir wären mit dem Programm noch nicht so ganz zufrieden. So soll beispielsweise eine Ausgabe eingebaut werden, mit der sich das Programm am Anfang meldet. Ist dies erledigt, können wir die Datei wieder einchecken. Dabei fällt auf, dass RCS bei jedem Einchecken eine Beschreibung der Änderungen verlangt. Auch wenn es -- wie wir gleich sehen werden -- eine Möglichkeit gibt, diese automatisch herauszufinden, können mit solchen knappen Kommentaren doch am besten die vorgenommenen Änderungen dokumentiert werden. So kann man auch nach längerer Zeit noch nachvollziehen, wann welche Umbauten vorgenommen wurden -- die dann eventuell zu einem Fehler geführt haben.
RCS/asserttest.cc,v <- asserttest.cc
new revision: 1.2; previous revision: 1.1
enter log message, terminated with single '.' or
end of file:
>> Programmbeschreibung wird nun am
>> Anfang ausgegeben.
>> .
done
Wenn Sie die Abfolge der Änderungen (die Revisionsgeschichte) ansehen wollen, verwenden Sie das Kommando rlog. Es liefert Ihnen eine vollständige Liste aller Revisionen, einschließlich Datum, Autor und Kommentar.
% rlog asserttest.cc
RCS file: RCS/asserttest.cc,v
Working file: asserttest.cc
head: 1.2
branch:
locks: strict
access list:
symbolic names:
keyword substitution: kv
total revisions: 2; selected revisions: 2
description:
Testprogramm fuer assert-Kommando
--------------
revision 1.2
date: 2000/12/04 17:30:34; author: thomas;
state: Exp; lines: +1 -0
Programmbeschreibung wird nun am Anfang ausgegeben
--------------
revision 1.1
date: 2000/12/04 17:25:11; author: thomas;
state: Exp;
Initial revision
=======================
Was hat sich denn nun genau geändert? Diese Frage beantwortet das Kommando rcsdiff. Es nimmt zwei Versionen aus dem Archiv und ruft das Programm diff auf, um die Änderungen herauszufinden. Die Form der Ausgabe ist sicher etwas gewöhnungsbedürftig.
% rcsdiff -r1.1 -r1.2 asserttest.cc
===================================
RCS file: RCS/asserttest.cc,v
retrieving revision 1.1
retrieving revision 1.2
diff -r1.1 -r1.2
22a23
> cout << "Testprogramm für assert()" << endl;
Gerade weil die zahlreichen Dateien, die zu einem Projekt gehören, verschieden oft bearbeitet werden und daher in der aktuellsten Revision ganz unterschiedliche Revisionsnummern tragen können, sollten Sie ab und zu einen Stand all Ihrer Quellen einfrieren, das heißt mit einem einheitlichen Etikett versehen. Auf diese Weise können Sie später genau wieder die Version identifizieren, die zu einem bestimmten Stand Ihrer Software gehörte.
Zu diesem Zweck verfügt das Kommando rcs über die Option -n
.
Daran schließt sich unmittelbar das Etikett an, dem wiederum ein Doppelpunkt
folgt. Dahinter können Sie eine konkrete Revisionsnummer angeben, die mit diesem
Etikett verbunden werden soll; wenn Sie dort aber nichts hinschreiben, wird
die aktuellste Revision verwendet. Damit mehrere Dateien auf einmal etikettiert
werden können, unterstützt dieses Kommando auch das Sternchen als Dateiname.
Wenn wir also alle Quellen im aktuellen Verzeichnis mit dem Etikett Iteration1
versehen wollen, geben wir ein:
% rcs -nIteration1: RCS/*
-N
, die ansonsten derselben Syntax folgt.
In der Ausgabe von rlog erscheint das Etikett unter der Rubrik symbolic names und deutet damit an, welche Revisionsnummer mit dem Etikett versehen wurde:
...
access list:
symbolic names:
Iteration1: 1.2
keyword substitution: kv
...
Oft möchten Sie nicht nur auf der Kommandozeile, sondern direkt im Quelltext
wissen, welche Revision Sie nun eigentlich gerade bearbeiten, wie RCS
mit dieser Datei umgeht und so weiter. Dazu bietet RCS eine Reihe
von Makros an, die bei jedem ci- oder co-Kommando aktualisiert
werden. In Tabelle finden Sie eine Übersicht.
Makro | Beschreibung | |
---|---|---|
$Author$ |
Der Name des Benutzers, der diese beziehungsweise die letzte Revision bearbeitet hat | |
$Date$ |
Datum und Uhrzeit, an dem die Datei zuletzt eingecheckt wurde | |
$Header$ |
Eine Zusammensetzung aus dem Dateinamen mit vollständigem Pfad, der Revisionsnummer, Datum, Uhrzeit, Bearbeiter und gegebenenfalls Sperrmarke | |
$Id$ |
Der gleiche Text wie $Header$ , allerdings ohne den
Pfad vor dem Dateinamen |
|
$Locker$ |
Der Name des Benutzers, der die Datei momentan gesperrt hat | |
$Log$ |
Fügt bei jedem Einchecken die Revisionsnummer und den Kommentar hinzu, wobei alle vorherigen Einträge erhalten bleiben. So kann die vollständige Revisionsgeschichte in der Datei dokumentiert werden. | |
$Name$ |
Das Etikett der aktuellen Revision (sofern vorhanden) | |
$RCSfile$ |
Der Name der zugehörigen RCS-Datei (ohne Pfad) | |
$Revision$ |
Die Nummer der aktuellen Revision | |
$Source$ |
Der Name der zugehörigen RCS-Datei mit vollständigem Pfad |
Diese Makros bauen Sie üblicherweise in Kommentare Ihres Quelltextes ein, um im Editor sofort die entsprechende Information finden zu können. In unserem Beispiel können wir etwa eingeben:
// Datei $RCSfile$
// zuletzt bearbeitet von $Author$
// $Date$, $Revision$
/*
* $Log$
*/
// Datei $RCSfile: asserttest.cc,v $
// zuletzt bearbeitet von $Author: thomas $
// $Date: 2000/12/04 19:52:59 $, $Revision: 1.3 $
/*
* $Log: asserttest.cc,v $
* Revision 1.3 2000/12/04 19:52:59 thomas
* RCS-Makros eingebaut
*
*/
Das $Header$
- oder $Id$
-Makro wird noch auf eine etwa raffiniertere
Weise verwendet. Wenn Sie eine Textvariable anlegen, die eines dieser Makros
enthält, wird diese Information auch in die Objektdateien und die ausführbaren
Dateien übernommen. Dort können Sie sie dann mit dem Kommando ident
abfragen. Fügen wir also ein:
const string Id = "$Id$";
const string Id = "$Id: asserttest.cc,v 1.3 2000/12/04
19:52:59 thomas Exp $";
% g++ -o asserttest asserttest.cc
% ident asserttest
asserttest:
$Id: asserttest.cc,v 1.3 2000/12/04 19:52:59
thomas Exp $
%
$Id$
,
die im Laufe der Kompilierung gefunden wurden.
Sie können sich sicher vorstellen, dass das nicht nur bei Implemetierungsdateien
funktioniert, sondern auch bei Headern. Ja gerade bei diesen kann es sinnvoll
sein, einen $Id$
-Vermerk einzufügen, um die Abhängigkeit der übersetzten
Dateien von den Versionen der einzelnen Header genau zu dokumentieren.
In diesem Abschnitt haben Sie die Grundlagen des Konfigurationsmanagements mit RCS kennen gelernt. Daraus sollten Sie vor allem folgende Schlüsselbegriffe behalten:
rcs -i <
Dateiname>
.
ci -u <
Dateiname>
.
co -l <
Dateiname>
.
rlog <
Dateiname>
ansehen.
rcsdiff -r<
Revision1> -r<
Revision2> <
Dateiname>
auf.
$RCSfile$
für den Namen der
RCS-Datei. Gibt man eine Textvariable im Programm an, die das Makro $Id$
enthält, so kann man diese Information mit dem Kommando ident
auch aus
Objekt- und ausführbaren Dateien extrahieren.
(C) T. Wieland, 2001. Alle Rechte vorbehalten.