 |
Algorithmen & Datenstrukturen Programmieren 1
von Prof. Jürgen Plate |
9 Programmierstil, Fallstricke, Module
9.1 Programmierstil in C
Dies ist eine kleine Stilkunde, die das Schreiben klarer Programme fördern
soll. Diese Hinweise und Erfahrungen sollten an Bedeutung gewinnen, so wie Ihre
Erfahrung mit C wächst.
Allgemeines
Über das Schreiben von Ausdrücken
Wegen der großen Zahl von Operatoren, des nicht immer einleuchtenden
Vorrangs und der Regeln der automatischen Typangleichung liegt es in
der Verantwortung des Programmierers sicherzustellen, daß ein
Ausdruck in der erwarteten Art und Weise arbeitet. Es folgen hier
einige Anregungen, die es Ihnen erleichtern sollen, leicht lesbare,
effiziente (und fehlerfreie) Programme zu schreiben.
- Einige der Vorrangregeln in C sind nicht einleuchtend. Falls Sie unsicher
sind, benutzen Sie Klammern, um die Reihenfolge und Priorität der
Bewertung anzugeben. Denken Sie jedoch daran, daß der Compiler
die Bewertungsreihenfolge bestimmter Ausdrücke (solche, die nur
die Operatoren &, /, ^, + oder * enthalten), selbst bei
Anwesenheit von Klammern, frei wählen kann.
- Typumwandlungen können im allgemeinen benutzt werden, um die
Angleichungen an den erforderlichen Typ zu erzwingen. Da Arithmetik mit
unterschiedlichen Typen automatische Typangleichung mit sich bringt,
die manchmal anders als erwartet ausfällt, benutzen Sie eine
Umwandlung, um das Gewünschte zu erhalten.
- Es gibt keine Leistungseinbuße durch die Bewertung von
Konstantenausdrücken, da sie zur Compilationszeit bewertet
werden. Zum Beispiel wandelt der Compiler den Ausdruck
a = 17.3 / 14.7 * PI / vals[i];
(vorausgesetzt, PI ist als 3.14159 definiert worden) in
a = 3.697245371 / vals[i]; um.
- Einige C-Operationen können geschickt in vielfältiger Weise
eingesetzt werden. Zum Beispiel kann das logische UND zweier
Bedingungen manchmal durch das bitweise UND ersetzt werden, was
möglicherweise schneller zu bewerten ist. Das ist jedoch nur
angebracht, wenn Sie alle Bedingungen bewertet haben wollen.
- Seien Sie vorsichtig bei der Benutzung der additiven Operatoren
++ und --. Im allgemeinen sollten Sie nicht mehr als
einen vor- oder nachgestellten Operator in einem einzelnen Ausdruck benutzen.
Welches Ergebnis haben beispielsweise die folgenden Ausdrücke?
a[i++] = a[--i];
a[--i] = b[++i];
- Es ist leicht, unklare Ausdrücke zu schreiben. Selbst
wenn diese Ausdrücke vom Compiler eindeutig interpretiert
werden können, werden sie doch die meisten Programmierer
verwirren. Wenn Sie sich nicht sicher sind, vereinfachen Sie es.
Programmformat
Obwohl die äußere Erscheinung des Quelltextes keine Rolle spielt,
hat das Programmformat erheblichen Einfluß auf seine Verständlichkeit.
Das Layout von Quellcode ist nicht nur eine Frage des persönlichen
Geschmacks oder des Programmierstils. Ein vereinheitlichtes
Code-Layout in einem Softwareprojekt ist eine wichtige Maßnahme
zur besseren Interaktion von Teammitgliedern.
Ein paar Regeln zur Schreibweise
- Delimiter und Ausdrücke
Delimiter von Statements (der Strichpunkt) oder Variablen (das Komma)
dürfen nicht unmittelbar einem Leerzeichen folgen, müssen
aber von einem Zeilenvorschub, einem Leerzeichen oder weiteren
Delimitern gefolgt werden.
- Trennzeichen in zusammengesetzten Datentypen
Trennzeichen für zusammengesetzte Datentypen (also z.B. der Punkt beim
Spezifizieren der Komponente einer struct oder union in C) dürfen weder
direkt vor einem Leerzeichen stehen noch direkt von einem Leerzeichen
gefolgt werden.
- Leerzeichen und Zuweisungsoperator
Der Zuweisungsoperator muss zwischen zwei
Leerzeichen oder zwischen einem Leerzeichen und
einem Zeilenvorschub stehen.
- Leerzeichen und unäre Operatoren
Unäre Operatoren (z.B. der Adressoperator & oder der
Inhaltsoperator *) dürfen von ihrem Operanden nicht durch ein Leerzeichen
getrennt werden.
- Unäre Operatoren als Teil von Datendeklarationen
Ein unärer Operator, der in der Bezeichnung einer Variablen
vorkommen kann, darf vom Variablenbezeichner nicht getrennt werden.
- Leerzeichen und biäre Operatoren
Biären Booleschen Operatoren (z. B. &&, ||) muss ein Leerzeichen
direkt vorangehen und eines direkt nachfolgen. Die Leerzeichen um biäre
Operatoren darf visuell nicht den Vorrangregeln arithmetischer Ausdrücke
wiedersprechen . Ein Leerzeichen muss entweder auf beiden Seiten des Operators
stehen oder auf keiner Seite.
- Empfehlung zur Klammersetzung
Eine öffnende eckige oder runde Klammer sollte einem Leerzeichen,
Zeilenvorschub, einer anderen Klammer oder einem Funktions-/Variablennamen
folgen und nicht direkt von einem Leerzeichen gefolgt werden.
Eine schliessende eckige oder runde Klammer sollte nicht direkt hinter einem
Leerzeichen stehen und sollte direkt von einem Trennzeichen, Delimiter oder
einer weiteren Klammer gefolgt werden.
Vor öffnenden Klammern, welche die Parameter vom Namen einer Funktion
trennen, sollte kein Leerzeichen stehen.
Zwischen einem Schlüsselwort (if, while, do, etc.) und einer öffnenden
Klammer muss ein Leerzeichen stehen.
Über das Schreiben leicht lesbarer Programme
Wir haben wenig Zeit darauf verwandt, Kriterien dafür zu finden, was
eine gute Funktion ausmacht. Hier folgen einige Merkmale, die eine gut
geschriebene Funktion besitzen sollte:
- Zusammenhalt:
Eine Funktion sollte nur eine Aufgabe ausführen und alle
Anweisungen in der Funktion sollten auf diese Aufgabe bezogen sein.
Wenn die Wirkungsweise der Funktion nicht in einem einzigen Satz
beschrieben werden kann, versucht die Funktion, zu viel zu tun.
- Allgemeingültigkeit
Eine Funktion sollte ihre eine Aufgabe gut erfüllen. Eine
Sortierroutine sollte z.B. für alle Eingabemengen gut arbeiten
und Fehlerfälle (wie wenn keine zu sortierenden Elemente
vorhanden sind) angemessen behandeln.
- Einfachheit:
Eine Funktion sollte ihre Aufgabe auf die einfachst mögliche
Weise erledigen; versuchen Sie nicht, durch übertriebenes
Ausfeilen des Codes noch ein oder zwei Operationen einzusparen. Im
allgemeinen wird ein Wechsel des Algorithmus mehr zur Effizienz
beitragen als irgendwelche Manipulationen am Code.
- Kürze:
Funktionen, die eine einzelne Aufgabe in einfacher Weise erledigen,
sind im allgemeinen nicht lang. Es bewährt sich, Funktionen auf
ca. 25 bis 200 Zeilen) zu beschränken. Natürlich können
auch zu viele kleine Funktionen ein Programm zu sehr zerreißen und
damit die Lesbarkeit und die Effizienz des Programms ruinieren. Die
meisten Programmierer schreiben jedoch eher zu lange als zu kurze
Funktionen.
- Dokumentation:
Jede Funktion hat Anspruch auf einen Kommentar, der ihm Aufgabe
beschreibt. Die Parameter und lokalen Variablen der Funktion sollten
ebenfalls kommentiert werden. Häufig genügt ein einziger
Satz zur Beschreibung der Funktion oder Variablen. Schreiben Sie die
Kommentare immer gleichzeitig mit dem Code, und warten Sie nicht,
bis die Funktion fertig geschrieben ist.
Schreiben von Makros
Makroersetzung ist die am häufigsten benutzte Fähigkeit des
Preprozessors. Wir geben hier einige Richtlinien als Entscheidungshilfe, wann
Makros angebracht sind, und einige stilistische Hinweise, die die Benutzung
vereinfachen:
- Ersetzen Sie einfache Funktionen aus Effizienzgründen durch Makros.
Achten Sie jedoch darauf, daß Sie nicht versuchen, zu viel in einem
Makro zu tun. Es lohnt sich nicht, eine Funktion in ein Makro
umzuschreiben, wenn der Verwaltungsaufwand des Funktionsaufrufs
gering im Vergleich zu dem ist, was die Funktion ausführt.
-
Ersetzen Sie, um den Code zu vereinfachen, komplizierte oder verwirrende
Ausdrücke durch Makros. Durch das Makro erhält der
Ausdruck einen Namen, was der Lesbarkeit dient. Dies ist besonders
wichtig, wenn der Ausdruck an mehreren Stellen vorkommt.
-
Schreiben Sie Makros, die in Ausdrücke und nicht in Anweisungen
umgewandelt werden. Dadurch erreichen Sie, daß der Makroaufruf
überall dort verwendet werden kann, wo auch ein Funktionsaufruf
stehen kann. Wenn das nicht möglich ist, schließen Sie
den Text des Makros in geschweifte Klammern ein, Sie dürfen
dann allerdings das Makro nicht mit einem Semikolon abschließen.
-
Vermeiden Sie Seiteneffekte in Makroaufrufen, unterstellen Sie nie,
daß Argumente nur einmal bewertet werden, und stellen Sie keine
Vermutungen darüber an, in welcher Reihenfolge Argumente
bewertet werden. Diese Regeln gelten auch für Funktionen, da es
manchmal nicht möglich ist, zu entscheiden, ob ein Aufruf einem
Makro oder einer Funktion gilt.
-
Klammern Sie Ausdrücke innerhalb eines Makroersatztextes, um sich
gegen unerwartete Ergebnisse bei der Makroumwandlung zu schützen.
Makrotexte, die in Ausdrücke umgewandelt werden, sollten
ebenfalls geklammert werden.
Zeiger und Arrays
Die Komplexität der Arrays, Zeiger, Arrays aus Zeigern, Zeigern auf
Zeilen eines Arrays, Zeiger auf Spalten eines Arrays und Zeiger auf
Zeiger können überwältigend sein. Wir wollen hier
einige Anregungen geben, wie Sie den "Zeiger" in die
richtige Richtung finden.
-
Benutzen Sie Arrays mit Zeigern auf Zeichenketten anstatt zweidimensionaler
Arrays, wenn die Zeichenketten unterschiedlich lang sind Zur
Speicherzuweisung für die Zeichenketten sollten Sie malloc
benutzen, um einiges von dem sonst bei jeder Zeichenkette
vergeudeten Speicherplatz einzusparen. Merken Sie sich jedoch, daß
bei der Vereinbarung eines Arrays aus Zeigern nicht automatisch
Speicherplatz für das, worauf die Zeiger zeigen, zugewiesen wird.
-
Fragen Sie den Rückgabewert von malloc
immer ab. Unterlassen Sie das, können Sie sich ernsthafte
Schwierigkeiten bereiten, die in den meisten Fällen zu einem
Abbruch Ihres Programms durch das Betriebssystem führen,
insbesondere, wenn Sie versuchen, mit einem Zeiger auf NULL
Speicherplatz zuzuweisen. Es kann sein, daß Sie nichts
Sinnvolles mehr tun können, wenn der verbliebene dynamische
Speicher nicht mehr aus- reicht; die Entscheidung, das Programm dann
abzubrechen, sollte jedoch bei Ihnen liegen. Die allgemeine Regel
besagt, daß maschinennahe Routinen wie solche, die malloc
aufrufen, dem Aufrufenden mitteilen sollten, daß es
Schwierigkeiten gibt. Der aufrufende Teil sollte sich dann um den
Fehler kümmern.
- Benutzen Sie typedef,
um Vereinbarungen und Umwandlungen lesbar zu gestalten. Selbst wenn
Sie in der Lage sind, komplexe Vereinbarungen zu verstehen, wird es
unter den Lesern Ihres Programms sicher welche geben, die es nicht
können.
Benutzung selbstdefinierter Typen
- Benutzen Sie Aufzählungstypen, wenn eine Variable einen Wert aus
einer Menge von Werten annehmen kann. Sie bieten eine bequeme
Möglichkeit, Konstanten zu definieren, fördern die
Lesbarkeit und ermöglichen zusätzliche Typprüfung
durch den Compiler. Beachten Sie jedoch, daß Aufzählungstypen
keine Ganzzahlen sind und erst in geeigneter Weise umgewandelt
werden müssen, wenn sie als Ganzzahlen benutzt werden sollen.
-
Benutzen Sie Strukturen, wenn Sie Daten, die in einem engen Zusammenhang
stehen, speichern wollen. Strukturen bieten außerdem eine weitere
Möglichkeit, Informationen zu verbergen, da sie im Ganzen
verarbeitet werden können, ohne daß man sich um den
Inhalt ihrer Felder kümmern muß. Benutzen Sie Varianten,
wenn ein Speicherplatz verschiedene Typen aufnehmen soll.
-
Beachten Sie den Unterschied zwischen Strukturen und Varianten, der darin
besteht, daß jedem Glied einer Struktur Speicherplatz
zugewiesen wird, bei Varianten jedoch nur ein Platz entsprechend dem
längsten Feld zugewiesen wird. Sie können nur jeweils
einen Wert in einem Feld einer Variante speichern.
-
Unterstellen Sie nie, daß die Felder einer Struktur fortlaufend
gespeichert sind. Strukturen werden oft aufgefüllt, um
Ausrichtungsnotwendigkeiten zu erfüllen. Die Annahme, daß
nicht aufgefüllt wird, führt zu nicht portierbarem Code.
Dynamische Datenstrukturen
-
Dynamische Datenstrukturen ersparen dem Programmierer viel Zeit. Sie haben
außerdem die angenehme Eigenschaft, daß sie Speicher für
beliebige Wertezusammenstellungen bereitstellen, wobei nur
Speicherplatz für die tatsächlich gespeicherten Werte
verbraucht wird. Schließlich sind sehr viele Algorithmen am
einfachsten zu implementieren, wenn dynamisch zugewiesener Speicher
zur Verfügung steht.
-
Dynamische Datenstrukturen sind jedoch zur Laufzeit nicht notwendigerweise
effizienter. Normalerweise müssen das Betriebssystem und die
Laufzeitumgebung von C den frei zur Verfügung stehenden Speicherplatz so
verwalten, daß Knoten jeder Größe in beliebiger
Reihenfolge angefordert und freigegeben werden können. Bei
Arrays fester Größe entlasten wir das System zur
Laufzeit, müssen jedoch als Ausgleich dafür den
Speicherbedarf im voraus genau angeben.
-
Speicherzuweisung für dynamische Datenstrukturen kann versagen; daher
müssen wir den Rückgabewert von malloc
prüfen. Diese Funktion versagt (gibt NULL zurück), wenn im
System kein Speicher mehr vorhanden ist.
Auswahl der geeignetsten Bibliotheksfunktion
Die Standard-E/A-Bibliothek enthält verschiedene nützliche
Funktionen. Unglücklicherweise sind es so viele, daß es
manchmal schwierig ist zu entscheiden, welche Funktion man benutzen
soll.
- Benutzen Sie nach Möglichkeit immer zeichenweise Ein- und -ausgabe.
Die Funktionen getc, putc, getchar
und putchar sind effizienter und einfacher zu benutzen als
die anderen Bibliotheksfunktionen.
- Benutzen Sie bei Anwendungen, bei denen eine zeilenweise Verarbeitung
ihrer Eingabe nahe liegt, die zeilenorientierten Funktionen fgets,
fputs, gets und puts. Sie sind nicht so
effizient wie die Zeichen-E/A-Funktionen, sind aber effizienter als die
formatierten E/A-Funktionen scanf, printf. Passen Sie jedoch bei
Zeilen auf, die länger als erwartet sind.
- Sparen Sie sich die formatierten Funktionen für die Fälle auf,
in denen sie wirklich benötigt werden: Lesen und Schreiben
formatierter Ein- und Ausgabe. Wegen des zusätzlichen Aufwandes
beim Analysieren der Formatzeichenkette sind sie erheblich langsamer
und erzeugen wesentlich mehr Code als alle anderen E/A-Funktionen.
- Benutzereingaben sind grundsätzlich als fehlerbehaftet und
unkorrekt zu betrachten.
9.2 Fallen in C
Feldgrenzen
In der Lernphase mit der Sprache C erlebt man immer wieder nicht nachvollziehbare
AbstürzezurLaufzeit(memory fault - core dumped) bzw. das Verschwinden von Strings,
obwohl nicht darauf zugegriffen wurde. Gibt es Hinweise, um solche Katastrophen
zu vermeiden?
In der Sprache C muß der Programmierer das Überprüfen der Feldgrenzen
selbst in die Hand nehmen und bei Strings auf das abschließende NULL-Byte achten.
Der C-Programmierer muß selbst dafür sorgen, daß solche Kontrollen,
falls notwendig, vorgenommen werden. Ein Feld char string[100]; belegt
genau 100 Zeichen (Byte)-Speicherplätze. Der String, der darin abgelegt werden
kann, darf also höchstens 99 Zeichen lang sein, da per Konvention als Ende des
Strings ein NULL-Byte ('\0') eingefügt werden muß. Ist der String zu lang,
wird über den nachfolgenden Speicherplatz hinaus geschrieben. Das hat zur Folge,
daß andere Variablen, die dort stehen, überschrieben werden. Im schlimmsten
Fall ist der Speicherplatz dann nicht mehr für den Prozeß definiert, was
zu obiger Fehlermeldung (memory fault - core dumped) führt. Übrigens
läuft der Index in C von 0 bis Dimension-1.
Dumme Fehler können auch entstehen, wenn im Speicher mehrere Strings hintereinander
stehen und man mit der Länge nicht aufpaßt. Schreibt man nämlich die
abschließende ' \ 0 , über den ersten String hinaus, steht sie unter
Umständen als erstes Zeichen im zweiten String, sodaß dieser als
Leerstring erscheint, obwohl er explizit nicht belegt wurde:
char s1[10], s2[12];
| a | b | c | d | e | f | g | h | i | j |\0 | x | y | z |\0 | | |
^s1 ^s2
printf("%s\n",s1); --> "abcdefghij"
printf("%s\n",s2); --> ""
Natürlich hängt es sehr von der Speicherorganisation ab, wo die Variablen
im einzelnen abgelegt sind (oft sind sie auf eine Wortgrenze ausgerichtet, besitzen
also etwas Spielraum!).
Gibt man einen solchen String an eine Funktion weiter, so ist innerhalb der
Funktion nur die Startadresse bekannt (Feldname ist Anfangsadresse). Man kann
dann nur mit einem weiteren Argument überprüfen, ob der Speicherbereich
überschritten wird:
int getline(char buf[], int len); /* Deklaration (Prototyp) von getline */
main ()
{
int ll;
char string[80];
ll = getline(string, sizeof string); /* Aufruf von getline */
return 0;
}
int getline(char s[], int lim);
{
int i = 0;
for(i=0; i<lim-1; i++)
s[i] = ....;
s[i] = '\0';
return i;
}
Folgende Regeln lassen sich daraus ableiten:
- Felder immer groß genug wählen
- Feldgrenzen immer abfragen
- Bei Strings auf die abschließende'\0'achten
Werden diese Hinweise beachtet, können viele Speicherabstürze
vermieden werden.
Falsch gesetzte Semikolons
Falsche Einrückung
Bei der if-Kontrollstruktur ist oft nicht klar, zu welchem if ein else gehört:
if (i > j)
if (k < 100)
tuwas();
else
tuwasanderes();
Es hat hier den Anschein, als ob das else zum ersten if gehört - das ist falsch.
Ein else gehört immer zum nächst höheren if, das noch kein else hat -
also zum zweiten if! Korrektur durch eine Block-Klammer:
if (i > j)
{
if (k < 100)
tuwas();
}
else
tuwasanderes();
Die switch-Falle
Eine weitere Kontrollstruktur kann Kummer machen: switch. Wird hier ein break vergessen,
so arbeitet C sequentiell weiter, auch wenn andere Marken anstehen. Jede Auswahl sollte
also normalerweise mit break abgeschlossen werden.
switch (c)
{
case '1': tuwas(); break;
case '2': tudies(); break;
case '3': tujenes(); break;
default: printf("error\n"); break;
}
Vielleicht kann hier der Präprozessor helfen:
#define CASE break;case
#define DEFAULT break;default
Dann kann die Auswahl so aussehen:
switch (c)
{
CASE '1': tuwas();
CASE '2': tudies();
CASE '3': tujenes();
DEFAULT: printf("error\n");
}
Float-Ausdrücke
Eine weitere Falle ist folgende Rechnung:
double d = 3/4;
Hier wird zunächst 3 durch 4 geteilt - was für Integerwerte natürlich
0 ist. Dann wird dieses Ergebnis d zugewiesen als 0.000000 (über eine implizite
Typumwandlung) und bleibt somit natürlich 0. Abhilfe schafft die Verwendung
der richtigen Konstanten 3.0 und 4.0 als double (zumindest eine):
double d - 3.0/4.0;
Vergleichsoperator vs. Zuweisungsoperator
In Vergleichen vergißt man leicht, daß der Vergleichsoperator = = und der
Zuweisungsoperator = unterschiedliche Funktionen haben. Eine (scheinbare) Bedingung
while (i = 20) weist der Variablen i den Wert 20 zu, was logisch gesehen
wahr (ungleich 0) ist, also wieder eine Endlosschleife ergibt. Natürlich
kann dies (z. B. bei Strings) auch positiv genutzt werden:
char s1(100), s2[100];
/* s1 nach s2 kopieren (bis Stringende '\0') */
while (s2[i] = s1[i])
i++;
Reihenfolge der Auswertung
- i++ * i++ undefiniert
- Reihenfolge nur definiert bei &&, ||,
Komma und ?:, dort sogar bedingte Auswertung
- Alle Argumente einer Funktion werden in unbestimmerter Reihenfolge vor
dem Aufruf ausgewertet
Eingabe-Probleme
Desweiteren gibt es oft bei der Bibliotheksfunktion scanf Schwierigkeiten.
Vergißt man doch allzuleicht das Adreß-Zeichen & bei den Argumenten.
Weniger durchschaubar ist aber, daß scanf das abschließende \n im
Tastaturpuffer läßt, so daß ein nachfolgender getchar()-Aufruf
dies als erstes geliefert bekommt. Möchte man scanf und getchar
mischen, empfiehlt es sich, den Tastaturpuffer zu löschen:
#include
int i, c, ret;
ret = scanf("%d",&i); /* gepufferte Eingabe */
if(ret != 1) error(); /* Fehlerbehandlung */
while (getchar() != '\n'; /* Eingabepuffer leeren */
getchar(); /* Einzelzeichen lesen */
Das Überprüfen des Return-Codes ist außerdem fast ein Muß,
sofern man nicht sowieso die ganze Zeile einliest und untersucht:
fgets(buf, MAX, stdin);
sscanf(buf, .... );
Dann kann auch auf versehentliche Leerzeilen reagiert werden.
Preprozessor
- Ein mit #if ausgeklammertem Text muß aus gültigen
Preprozessortokens bestehen
- Makros können ebenfalls Probleme bereiten: #define sqr(x) x = x*x
Hier wird ein Square-Makro definiert, der das Quadrat einer Zahl ermitteln soll.
Dies funktioniert auch für Variablen ganz gut: int i = 10; ... sqr(i); ...
Nun steht in i das Quadrat, also 100. Benutzt man dieses Makro aber für einen Wert
(sqr(10);), so macht die Zuweisung Probleme. Also sollte man die Zuweisung im
Makro unterlassen: #define sqr(x) x*x
- i = sqr(10); funktioniert nun. Ruft man dieses Makro nun aber mit einem
Ausdruck auf, gibt es wieder Probleme: i = sqr(2 + 8); wird zu 2 + 8 * 2 + 8
ersetzt und da Punktrechnung vor Strichrechnung geht, ist das Ergebnis 2 + 16 + 8 = 26,
also falsch. Die zweite Regel sollte also lauten, Klammern zu verwenden:
#define sqr(x) ((x)*(x))
Doch damit ist beispielsweise sqr(++i); noch nicht gelöst, da nun ++i
zweimal ausgeführt wird, und zwar ++i * ++i. Dieses Problem ist mit Makros nicht
zu lösen. Also keine Zuweisungen oder andere Seiteneffekte in Makroaufrufen!
Konstante und Variable
- Bei const char *a und char *b ist a=b
ok (Erlaubnis zum Ändern wird nicht übernommen), b=a ist
dagegen falsch (Erlaubnis zum Ändern wird fälschlicherweise gegeben)
- Bei const int a und int b ist a=b
falsch (Unerlaubte Änderung), b=a ist erlaubt (Verarbeitung nur
einer Kopie)
(Null-)Pointer
- NULL ist nur #define NULL 0 - Automatische Typumwandlung
- Pointer sind keine Integer-Werte.
- Ein Zeiger muß immer mit einer gültigen Speicherplatzadresse
initialisiert sein.
Arrays und Pointer
- In einer Datei char a[5], in der anderen extern char
*a ist falsch, extern char a[] ist richtig
- Äquivalenz der Deklarationen char a[] und char
*a nur bei formalen Argumenten von Funktionen
- char b[][] und char **b nicht äquivalent,
letzteres verwendet real im Speicher vorhandene Pointer
- Aufzeichnen: Realer Speicher bei char **a, char
*a[], char (*a)[], char a[][]
- Dynamische Allozierung mehrdimensionaler Felder: Am besten Allozieren
eines Feldes mit Pointern und Allozieren jeder einzelnen Zeile
Dynamische Speicherverwaltung
- char *s; gets(s); ist falsch, da kein Speicher bereitsteht
- char *s="Hal",*t="lo!",*u=strcat(*s,*t) ist genauso falsch
- Mit free() freigegebener Speicher darf nicht mehr angesprochen werden
9.3 Modularisierung und Makefiles
Compiler und Linker
Wie von Anfang an bekannt, kann unser C-Compiler nicht nur den Quellcode übersetzen,
sondern auch einzelne Binärobjekte zu einen aausführbaren Programm zusammenbinden
(linken). Mittels gcc -c foo.c erzeugt man ein Object-File foo.o und
mittels gcc -o foo foo.o kann daraus ein ausführbares Programm erzeugt werden.
Das Ganze funktioniert auch mit mehreren Quell- und Object-Dateien, z. B.:
gcc -c foo.c Erzeugen der Object-Dateien (*.o)
gcc -c bar.c
gcc -c test.c
gcc -o go *.o Erzeugen des Executables (go)
Die Option "-c" weist den gcc an, nur zu kompilieren und die Option "-o" sorgt für das
Linken. Der gcc hat noch ein paar wichtige Optionen zu bieten:
-c nur übersetzen, nicht linken
-Idir Include-Dateien in dir suchen
-Wall alle Warnungen aktivieren
-g Debugging-Symbole erzeugen
-o file fertiges Programm in file schreiben
-Olevel Optimierungen einschalten, z. B. -O2
-foption generelle Compiler-Optionen, z. B. -ffast-math
-llib Bibliothek linken, z. B. bindet "-lm" die libm.o ein
-Ldir Bibliotheken in dir suchen
Daneben gibt es noch zahlreiche andere Optionen.
Make
Unter den C-Quellen (Dateien mit den Endungen ".C2 bzw. ".h") ergibt sich bei einem Projekt
ein System von Abhängigkeiten. Wenn bestimmte Quell-Dateien modifiziert wurden, müssen einige,
aber in der Regel nicht alle, Module neu erstellt werden. Bei mehreren tausend Dateien (wie
z. B. beim Linux-Kernel) wäre es aber unsinnig bei jeder kleinen Korrektur alle Dateien neu
zu übersetzen. Ebenso mühsam ist, manuell diejenigen Dateien herauszufinden, die sich geändert
haben. Man braucht also ein Werkzeug, das diese Abhängigkeiten erkennt und jeweils nur die
nötigen Module neu übersetzt.
Das Make-Utility leistet das Gewünschte. Dem Make-Utility muss ein sogenanntes Makefile
(Datei-Name: "Makefile", diese Schreibweise ist wichtig) bereit gestellt werden, das die
Abhängigkeiten eines Projekts beschreibt: Module, Versionen (Debug-Version, Release-Version)
sowie andere Informationen. Make erkennt anhand der Modifikations-Zeit der Dateien
was sich geändert hat. Das Makefile beschreibt die Abhängigkeiten mittels "Targets" (engl. Ziele).
Das Makefile bestehen aus einer Menge von Regeln zur Steuerung der Übersetzung. Sein
Aufbau ist:
Ziel: Quelle Quelle ...
Shellkommando
Shellkommando
...
Das Ziel gibt meist an, welche Datei erzeugt wird. Falls eine der Quellen neuer ist als
das Ziel, wird das Ziel aktualisiert. Aktualität und Vorhandensein der Quellen werden
vorher rekursiv sichergestellt. Die Shellkommandos (durch Tabulatorzeichen eingeleitet!)
erzeugen das Ziel aus den Quellen. Dazu ein Beispiel:
go: bar.o foo.o test.o
gcc bar.o foo.o test.o -lm -lglib -o go
bar.o: bar.c
gcc -Wall -O2 -c bar.c
foo.o: foo.c
gcc -Wall -O2 -c foo.c
test.o: test.c
gcc -Wall -O2 -c test.c
clean:
rm -rf *.o
Ein Eintrag eines Makefiles sieht formal folgendermaßen aus:
Target_Name: <Dateien oder Sub-Targets von den dieses abhängt>
<Tab-Zeichen> <Regel zum erstellen dieses Targets>
Eine gemeine Falle für Anfänger ist, daß die zweite Zeile mit einem
<tab> anfangen muß, und nicht mit Leerzeichen.
Beim Aufruf von make kann angegeben werden, welches Target man erstellen möchte.
Make erzeugt dann alle hierfür nötigen Subtargets. Wird make ohne Parameter gestartet,
wird das erste (oberste) Target aus dem Makefile erstellt. Im Beispiel oben werden
dann foo.o, bar.o, test.o und go erzeugt.
Es gibt auch Targets, die keine Abhängigkeiten haben. Im obigen Makefile haben wir
ein zusätzliches Target "clean", das dazu da ist, alle o-Files wieder sauber zu
löschen. Somit kann man mit dem Kommando make clean wieder aufräumen.
Viele targets können nicht (wie oben) durch einen einzigen Befehl erzeugt
werden, sondern benötigen mehrere Kommandos. In diesem Fall folgen auf die Zeile
mit den Abhängigkeiten einfach mehrere Zeilen, die alle mit
<tab> beginnen. Auch die Abhängigkeiten für ein target dürfen
auf mehrere Zeilen verteilt sein.
Im Beispiel oben ist "go" gleichzeitig ein Target-Name und ein Datei-Name.
make sieht darin keinen Unterschied. Auch die beiden Dateien foo.c und
bar.c sind für make nichts anderes als Targets. Diese hängen von keinen
anderen Targets ab, sind also immer aktuell, und es gibt auch keine Regel, um
sie zu erzeugen. Würde nun beispielsweise die Datei bar.c nicht existieren,
würde make feststellen, daß es das target bar.c, welches für bar.o
benötigt wird, nicht erzeugen kann. Die Fehlermeldung lautet
dementsprechend:
make: *** No rule to make target `bar.c'. Stop.
make kennt noch viele weitere Möglichkeiten, von denen hier nur einige besprochen werden.
Es ist möglich, in Makefiles Variablen (eigentlich sind es Makros) zu definieren und zu
benutzen. Normalerweise verwendet man Großbuchstaben. Gebräuchlich sind
beispielsweise folgende Variablen:
CC der Compiler
CFLAGS Compiler-Optionen
LDFLAGS Linker-Optionen
Auf den Inhalt dieser Variablen greift man dann mit $(CC),
$(CFLAGS) bzw. $(LDFLAGS) zu. Ein einfaches Makefile eines
Programmes namens go, welches aus einer Reihe von Objekt-Dateien zusammengelinkt
werden soll, könnte also so aussehen:
VERSION = 3.02
CC = /usr/bin/gcc
CFLAGS = -Wall -g -DVERSION=\"$(VERSION)\"
LDFLAGS = -lm -lglib
OBJ = datei1.o datei2.o datei3.o datei4.o datei5.o
all: $(OBJ)
$(CC) $(CFLAGS) -o go $(OBJ) $(LDFLAGS)
%.o: %.c
$(CC) $(CFLAGS) -c $<
Das %-Zeichen ist hier der Platzhalter für Regelmengen.
Das Defaulttarget ist hier all, das erzeugte ausführbare Programm go. Dieses
hängt von allen Objekt-Dateien ab. Beim Linken werden zwei Libraries dazugelinkt.
An diesem Beispiel sieht man, dass eine Shell die Befehle ausführt: Ohne die Backslashes
in \"$(VERSION)\" würde diese nämlich die Anführungzeichen entfernen. Die
Versionsnummer soll dem C-Präprozessor aber als konstante Zeichenkette übergeben werden.
Interessant ist die letzte Zeile, wo der Compiler angewiesen wird, eine Quelle namens $<
zu übersetzen. Bei $< handelt es sich um eines der sogenannten automatischen Makros,
deren es unter anderem folgende gibt:
$@ Ziel
$< erste Quelle
$ˆ alle Quellen
$? Quellen, die neuer sind als das Ziel
Dazu ein Beispiel:
go: $(OBJECTS)
$(CC) $ˆ -lm -lglib -o $@
%.o: %.c
$(CC) $(CFLAGS) -c $<
Module
Module sind die einzige höhere Abstraktion, die C bietet, man sollte also reichlich Gebrauch davon machen.
Wie zerlegt man nun einm Programm in Module? Betrachten Sie dazu das folgende einfache Beispiel:
#include <stdio.h>
/* Funktions-Prototypen */
int foo(int a, int b);
void bar(int a, int *result);
int main(void)
{
int X = 2,Y = 3, res;
res = foo(X, Y);
printf("%d\n", res);
bar(X, &res);
printf("%d\n", res);
return(0);
}
int foo(int a, int b)
{
return a + b;
}
void bar(int a, int *result)
{
*result = a;
}
Es sollen nun die Funktionen foo() und bar() in ein Modul ausgelagert werden. Es ergibt
sich erstens ein Programm-Testmodul test.c:
/* Testmodul */
#include <stdio.h>
/* Funktions-Prototypen */
int foo(int a, int b);
void bar(int a, int *result);
int main(void)
{
int X = 2,Y = 3, res;
res = foo(X, Y);
printf("%d\n", res);
bar(X, &res);
printf("%d\n", res);
return(0);
}
Das Zweite ist ein Funktionsmodul modul.c:
int foo(int a, int b)
{
printf("I am foo\n");
return a + b;
}
void bar(int a, int *result)
{
printf("I am bar\n");
*result = a;
}
Das ist schon ganz nett und mittels make kann man modul.c und test.c
übersetzen (ergibt modul.o und test.o) und zusammenlinken. Das Modul test.c
muss auf jeden Fall den Prototyp von foo() und bar() kennen. Man könnte
nun versucht sein, diese manuell vor das main() zu schreiben. Dies ist aber nicht ratsam,
denn man müsste alle Prototyp-Definitionen überall eintragen bzw. ändern, wenn irgendwo modul
verwendet respektive geändert würde.
Besser ist es, die Prototypen in eine separate Datei zu schreiben, die man modul.h nennt (das "h"
steht für "Header-Datei"). Für das Beispiel oben sieht die Header-Datei mdul.h so aus:
/* Funktions-Prototypen */
int foo(int a, int b);
void bar(int a, int *result);
Bei jeder Verwendung von module muss man jetzt nur noch dafür sorgen, dass der Inhalt des
Header-Files am Anfang hinein kopiert wird, was sich mittels #include "modul.h" erledigen
lässt. Beachten Sie die Gänsefüßchen! Für das #include-Makro gilt: Dateien zwischen < >
werden in den vordefinierten Header-Verzeichnissen gesucht, Dateien zwischen " " werden im aktuellen
Verzeichnis (realtiv zur C-Quelle) gesucht. Unser Testmodul sieht nun so aus:
/* Testmodul */
#include <stdio.h>
#include "modul.h"
int main(void)
{
int X = 2,Y = 3, res;
res = foo(X, Y);
printf("%d\n", res);
bar(X, &res);
printf("%d\n", res);
return(0);
}
Es ist übrigens sinnvoll, auch in der Datei modul.c zu Beginn das Header-File modul.h
per #include einzubinden (schon, damit der Compiler warnt, falls die Prototypen und die
Funktionen sich auseinander entwickeln).
Bei größeren Systemen kann es durchaus vorkommen, dass einzelne Module die Funktionen anderer Module
verwenden und daher deren Header-Dateien includieren. So kann es recht schnell zum mehrfachen Einbinden
der Header-Dateien (mit entsprechen seltsamen Verhaltensweisen von Programm und Compiler) kommen.
Mit den Präprozessor-Konstrukten #define und #ifndef kann man gewährleisten, dass eine
Header-Datei jeweils nur beim ersten Mal effektiv includiert wird. Damit sieht unser Beispiel-Headerfile
folgendermaßen aus:
#ifndef _MODUL_H_
#define _MODUL_H_
/* Funktions-Prototypen */
int foo(int a, int b);
void bar(int a, int *result);
#endif
/* _MODUL_H_ */
Nur wenn _MODUL_H_ noch nicht definiert worden ist, wird der Quellcode zwischen #ifndef
und #endif eingefügt. Zugleich wird auch _MODUL_H_ definiert. Hiermit wird sichergestellt,
dass die #ifndef-Bedingung bei zukünftigen Includes nicht mehr erfüllt ist.
Wir folgen der C/C++-Konvention, indem wir für die jeweiligen Symbole den Datei-Namen der Header-Datei
verwenden und jeweils ein Unterline-Zeichen vorne und hinten anfügen. Ebenso wird der Punkt im Dateinamen
durch das Underline-Zeichen ersetzt. Wenn Sie sich für jede Header-Datei an diese Konvention halten,
kann nichts mehr schief gehen. Tun Sie es auch, wenn Sie der festen Überzeugung sind, dass keine
Mehrfach-Inkludierung vorkommt. Beim #endif geben Sie als Kommentar die Bezeichnung zum
zugehörigen Symbol der #ifndef-Direktive an. So wissen wir immer, zu welchem #ifdef
ein #endif gehört.
Das Makefile zum Beispiel muss natürlich nun auch das Header-File berücksichtigen:
go: modul.o test.o
gcc modul.o test.o -lm -lglib -o go
modul.o: modul.c modul.h
gcc -Wall -O2 -c modul.c
test.o: test.c
gcc -Wall -O2 -c test.c
clean:
rm -rf *.o
Copyright © FH München, FB 04, Prof. Jürgen Plate
Letzte Aktualisierung: 10. Mär 2009