Programmieren in C


von Prof. Jürgen Plate

9 Programmierstil, Fallstricke, Module

10.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.

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

Ü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:

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:

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.

Benutzung selbstdefinierter Typen

Dynamische Datenstrukturen

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.

10.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: 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

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

Konstante und Variable

(Null-)Pointer

Arrays und Pointer

Dynamische Speicherverwaltung

Fehlermeldungen beim Compilieren

Sie starten den Compiler und es erscheint eine Unmenge von Fehlermeldungen. Nicht verzweifeln, denn oft benötigt man für die Behebung der Fehler nur wenige Handgriffe. Achten Sie auf: Oft liegt der eigentliche Fehler auch einige Zeilen vor derjenigen, die in der Fehlermeldung steht. 90% aller Syntaxfehler beruhen auf schludriger Eingabe des Codes (nicht umsonst sind die Tools so beliebt, bei denen man sich fertigen Code zusammenklicken kann).

10.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. Typische Kommandozeilen wären:
gcc -Wall -o binary quelle.c            # nur eine Quelldatei, erzeugt ausführbares "binary" 
gcc -Wall -o binary main.c quelle.c     # dito mit zwei Quelldateien (Prototypdefinition
                                        # per Headerdatei quelle.h in main.c
gcc -Wall -o binary -lm quelle.c        # dito mit Einbinden der (vocompilierten) Mathe-Library
Wenn es komplexer wird, hilft das Make-Paket weiter:

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

Zum Inhaltsverzeichnis Zum nächsten Abschnitt


Copyright © FH München, FB 04, Prof. Jürgen Plate
Letzte Aktualisierung: 30. Mär 2013