Projekte

Was ist ein Projekt in der Softwareentwicklung?

Alles, was wir bisher geschrieben haben, waren natürlich auch Projekte, aber seien wir ehrlich - bisher war noch nichts dabei, was irgendjemand vom Hocker hätte reißen können. Der Quelltext der letzten Lektion hat etwa 200 Zeilen und wirklich viel kann er noch nicht.

Wir haben bisher alle Programme in einer einzigen Quelltext-Datei geschrieben. Wenn wir uns die Programme ansehen, die wir im Alltag benutzen - einen Webbrowser, eine Textverarbeitung oder auch kleine Programme wie einen Texteditor - dann dürfte inzwischen klar sein, dass wir uns hier nicht mehr im Bereich von wenigen hundert Zeilen wiederfinden, sondern eher von mehreren zehntausend Zeilen. So viele Zeilen wollen wir nun nicht in einer einzelnen Quelltext-Datei haben. Wir müssen uns also Gedanken machen, wie wir mehrere Dateien zu einem Programm verbinden.

In dieser Lektion lernst Du das „Strickmuster“, wie man ein C-Projekt auf mehrere Dateien verteilt.

Ein Projekt aufsetzen

Mit den Datenstrukturen für den HTML-Text aus der letzten Lektion haben wir ein Projekt, das aus drei Komponenten besteht: den Parametern, den Knoten und dem eigentlichen Programm in der main-Funktion. Zunächst werden wir diese eine Datei daher entsprechend unterteilen und zwar in fünf Dateien. Die Deklaration der beiden Datenstrukturen packen wir in gleichnamige Header-Dateien. Die Dateien müssen nicht gleichnamig sein, aber das erleichtert das Wiederfinden in wirklich großen Projekten. Auch könnte man beide Datenstrukturen in eine einzelne Header-Datei packen, aber wir wollen ja auch etwas zu üben haben. Anschließend erstellen wir drei Quelltext-Dateien, die neben dem Hauptprogramm, die Funktionsdefinitionen für struct Parameter und struct Node enthalten.

Diese Strukturen beschreiben zwei unterschiedliche Klassen von Daten - eben Parameter und Node. Üblicherweise organisiert man die Quelltext-Dateien entsprechend dieser Klassen: In der Headerdatei werden die Struktur und die Deklaration der Funktionen eingetragen, die die Datenstruktur verändern. In der .cpp-Datei werden die Funktionen anschließend definiert.

Header-Dateien

Eine Header-Datei ist wie eine andere Quelltext-Datei eine ganz normale Textdatei. Der wichtigste Unterschied zu einer Quelltext-Datei (datei.c) zu einer Header-Datei (datei.h) ist, dass sie mit .h endet.

Header-Dateien werden in andere Dateien eingefügt, daher sollten sie ausschließlich Deklarationen enthalten. Finden sich auch Variablen- oder Funktionsdefinitionen in einer Header-Datei und wird diese mehrfach in Quelltext-Dateien (.c-Dateien) eingebunden, so werden diese Variablen- und Funktionsdefinitionen mehrfach kompiliert und existieren damit auch mehrfach. Das wird zu Verwechselungen führen, weswegen daraus kein lauffähiges Programm erzeugt werden kann. Kurz: Variablen- und Funktionsdefinitionen haben in Header-Dateien nichts zu suchen, hier werden ausschließlich Deklarationen untergebracht. Nochmal zur Erinnerung: Eine Deklaration erklärt dem Compiler, wie eine struct Node aussieht, wenn es denn eine gäbe. Eine Definition erklärt dem Compiler, dass er eine Variable mit dem Typ anlegen soll:

sturct Type { int member; };                     // Typdeklaration        Header: Ok
struct Type * variable;                          // Variablen-Definition  Header: Schlechte Idee
int main( int argc, char ** argv );              // Funktions-Deklaration Header: Ok
int main( int argc, char ** argv ) { return 0; } // Funktions-definition  Header: Schlechte Idee

'Schlechte Idee' heißt nicht, dass der Compiler es verbietet. Es heißt nur, dass es unter Umständen Probleme macht. Header-Dateien macht man meistens, damit man Deklarationen nur einmal schreiben muss, die dem Compiler erklären, dass es da eine(!) Funktion oder Variable gibt. Wenn die Funktion oder Variable dann aber mehrfach existiert, dann weiß man nie, ob man immer auf die gleiche Instanz zugreift. Und darum lässt sich das ganze dann in der Regel auch nicht fertig kompilieren.

Nun wissen wir schon, dass die Quelltext-Datei für struct Node Funktionen rufen wird, die in der Quelltext-Datei für struct Parameter stehen. Damit der Compiler beim kompilieren von node.c weiß, dass es solche Funktionen geben soll, deklarieren wir sie zusätzlich in parameter.h. Selbiges machen wir für die Funktionen von struct Node in node.h, damit wir aus der main-Funktion heraus darauf zugreifen können.

Schauen wir uns nun die beiden Header-Dateien an:

parameter.h

/*
** parameter.h
****************************************************************
** Autor: Xin
** Datum: 12. November 2011
**
** für proggen.org-Tutorial
***************************************************************/
 
// Beschreibt einen Parameter für ein HTML-Tag
 
struct Parameter
{
  struct Parameter * Next;
 
  char * Name;
  char * Value;
};
 
// Deklaration der Funktionen zur Manipulation eines struct Parameter
 
extern struct Parameter * parameter_newWithLength( char const * name, 
                                                   unsigned int nameLength, 
                                                   char const * value, 
                                                   unsigned int valueLength );
 
extern struct Parameter * parameter_new          ( char const * name, 
                                                   char const * value );
 
extern void parameter_delete( struct Parameter * toDelete );
 

node.h

/*
** node.h
****************************************************************
** Autor: Xin
** Datum: 12. November 2011
**
** für proggen.org-Tutorial
***************************************************************/
 
#include "parameter.h"
 
// Beschreibt einen Knoten (HTML-Tag)
 
struct Node
{
  struct Node      * Next;
 
  char             * Text;         // Falls es ein Textknoten, wie "proggen.org" ist.
 
  char             * Name;         // Name des Knotens
  struct Parameter * Parameter;    // Parameter wie bei <body ...>
  struct Node      * SubNodes;     // H1 ist der erste Subknoten von body.
};
 
// Deklaration der Funktionen zur Manipulation eines struct Node
 
extern void node_delete                 ( struct Node * toDelete );
 
extern struct Node * node_newWithLength ( char const * name, unsigned int nameLength ); 
extern struct Node * node_new           ( char const * name );
extern struct Node * node_textWithLength( char const * text, unsigned int textLength ); 
extern struct Node * node_text          ( char const * text );
 
extern void          node_addParameter( struct Node * node, struct Parameter * parameter ); 
extern void          node_addSubNode  ( struct Node * node, struct Node * subnode );

Ich habe mal einen netten Kommentar als Kopf vor die Struktur gepackt, damit man weiß, wer hier wann was getan hat und eine kurze Beschreibung, worum es in dieser Datei geht. Die struct Node benutzt einen Zeiger auf einen Parameter und bindet die 'parameter.h' per #include ein. Statt #include <parameter.h> steht dort jedoch #include „parameter.h“. Die Anführungszeichen bedeuten, dass zuerst im lokalen Verzeichnis gesucht werden soll. Bei den spitzen Klammern werden nur die Verzeichnisse durchsucht, die dem Compiler bekannt sind, zum Beispiel, wo er die Datei 'stdio.h' findet. Dank der spitzen Klammern spart er sich die Suche im lokalen Verzeichnis und nimmt auch dann die richtige 'stdio.h', wenn ein Scherzkeks eine eigene 'stdio.h'-Datei im lokalen Verzeichnis angelegt hat.

#include fügt an der Stelle die jeweilige Datei ein. Die 'node.h' enthält damit den Inhalt der Datei 'parameter.h', als würde er darin stehen. Fügt man nun 'node.h' irgendwo ein, hat man die Informationen von 'parameter.h' automatisch dabei.

Quelltext-Dateien

Genauso wie die Deklarationen in den Header-Dateien, schneiden wir die Definitionen aus der großen Quelltext-Datei aus und fügen sie in entsprechende Quelltext-Dateien ein. Mit #include fügen wir die Deklarationen aus den Header-Dateien hinzu, die wir benötigen.

parameter.c

/*
** parameter.c
****************************************************************
** Autor: Xin
** Datum: 12. November 2011
**
** für proggen.org-Tutorial
***************************************************************/
 
#include "parameter.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
 
// Definitionen für Datenmanipulation der struct Parameter
 
void parameter_delete( struct Parameter * toDelete )
{
  if( toDelete )
  {
    printf( "-Parameter: %s\n", toDelete->Name );
 
    free( toDelete->Name );
    free( toDelete->Value );
    free( toDelete );
  }
}
 
struct Parameter * parameter_newWithLength( char const * name, 
                                            unsigned int nameLength, 
                                            char const * value, 
                                            unsigned int valueLength )
{
  struct Parameter * result = malloc( sizeof( struct Parameter ) );
 
  printf( "+Parameter: %*s\n", nameLength, name );
 
  if( result )
  {
    result->Next  = NULL;
 
    result->Name  = malloc( 1 +  nameLength );
    result->Value = malloc( 1 + valueLength );
 
    if( result->Name && result->Value )
    {
      memcpy( result->Name,   name,  nameLength );
      result->Name[nameLength] = '\0';
      memcpy( result->Value, value, valueLength );
      result->Value[valueLength] = '\0';
 
      return result;
    }
 
    /* Parameter konnte nicht angelegt werden */
 
    parameter_delete( result );
  }
 
  return NULL;
}
 
struct Parameter * parameter_new( char const * name, 
                                  char const * value )
{
  return parameter_newWithLength( name, strlen( name ), value, strlen( value ) );
}

node.c

/*
** node.c
****************************************************************
** Autor: Xin
** Datum: 12. November 2011
**
** für proggen.org-Tutorial
***************************************************************/
 
#include "node.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
 
// Definitionen für Datenmanipulation der struct Node
 
void node_delete( struct Node * toDelete )
{
  if( toDelete )
  {
	struct Parameter *tempP, *p;
	struct Node *tempN, *n;
 
    printf( "-%s: \"%s\"\n", ( toDelete->Name ) ? "Node" : "Text", 
                             ( toDelete->Name ) ? toDelete->Name : toDelete->Text );
 
    free( toDelete->Name );  // Falls Name vorhanden, dann löschen
    free( toDelete->Text );  // Falls Text vorhanden, dann löschen
 
    // Parameter löschen
    p = toDelete->Parameter;
    while( p )
    {
      tempP = p->Next;
      parameter_delete( p );
      p = tempP;
    }
 
    // Unterknoten löschen
    n = toDelete->SubNodes;
    while( n )
    {
      tempN = n->Next;
      node_delete( n );     // Rekursiver Aufruf
      n = tempN;
    }
 
    // Diese Node löschen
    free( toDelete );
  }
}
 
struct Node * node_newWithLength( char const * name, unsigned int nameLength )
{
  struct Node * result = malloc( sizeof( struct Node ) );
 
  printf( "+Node: %*s\n", nameLength, name );
 
  if( result )
  {
    result->Name = malloc( 1 + nameLength );
    if( result )
    {
      memcpy( result->Name, name, nameLength );
      result->Name[nameLength] = '\0';
 
	  result->Next      = NULL;
      result->Text      = NULL;
      result->Parameter = NULL;
      result->SubNodes  = NULL;
 
      return result;
    }
 
    node_delete( result );
  }
 
  return NULL;
}
 
struct Node * node_new( char const * name )
{
  return node_newWithLength( name, strlen( name ) );
}
 
struct Node * node_textWithLength( char const * text, unsigned int textLength )
{
  struct Node * result = malloc( sizeof( struct Node ) );
  printf( "+Text: %*s\n", textLength, text );
 
  if( result )
  {
    result->Text = malloc( 1 + textLength );
    if( result )
    {
      memcpy( result->Text, text, textLength );
      result->Text[textLength] = '\0';
 
	  result->Next      = NULL;
      result->Name      = NULL;
      result->Parameter = NULL;
      result->SubNodes  = NULL;
 
      return result;
    }
 
    node_delete( result );
  }
 
  return NULL;
}
 
struct Node * node_text( char const * text )
{
  return node_textWithLength( text, strlen( text ) );
}
 
void node_addParameter( struct Node * node, struct Parameter * parameter )
{
  if( node )
  {
    struct Parameter ** parameterPtr = &node->Parameter;
 
    /* Letzten Parameter finden */
    while( *parameterPtr )
      parameterPtr = &(*parameterPtr)->Next;
 
    *parameterPtr = parameter;
  }
}
 
void node_addSubNode( struct Node * node, struct Node * subnode )
{
  if( node )
  {
    struct Node ** nodePtr = &node->SubNodes;
 
    /* Letzte Node finden */
    while( *nodePtr )
      nodePtr = &(*nodePtr)->Next;
 
    *nodePtr = subnode;
  }
}

main.c

Die main.c macht die offensichtlichste Veränderung durch. Statt eines langen, langen Quelltexts, haben wir hier nur noch die Informationen stehen, die beschreiben, was das Programm machen soll. Der ganze Kram mit Nodes und Parametern verschwindet irgendwo im Hintergrund. Mit #include „node.h“ wissen wir, dass es diese Funktionen gibt. Aber weder interessiert uns wie es funktioniert, noch wo es steht. Genauso wie printf() wollen wir es einfach nur benutzen. Weil sich diese Funktionen nun nicht mehr im Quelltext ausbreiten, können wir uns auf das wesentliche konzentrieren.

Die Aufteilung von Quellen erlaubt uns also unser Programmierproblem zu abstrahieren.

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
 
#include "node.h"
 
int main( void )
{
  /* Variablendeklarationen */
 
  struct Node      * html;
  struct Node      * body;
  struct Parameter * par;
  struct Node      * text;
  struct Node      * headline;
 
  /* Programmteil */
 
  html     = node_new( "html" );
  body     = node_new( "body" );
 
  node_addSubNode( html, body );
 
  par      = parameter_new( "background", "white" );
 
  node_addParameter( body, par );
 
  text     = node_text( "proggen.org" );
  headline = node_new( "h1" );
 
  node_addSubNode( headline, text );
  node_addSubNode( body, headline );
 
  text = node_text( "C-Tutorial" );
  node_addSubNode( body, text );
 
  /* Datenstruktur fertig */
 
  node_delete( html );
 
  return 0;
}

Ein Projekt kompilieren

Wenn wir nun main.o kompilieren gibt's als erstes mal Ärger:

xin@trinity:~/proggen.org/tutorial$ gcc main.c
/tmp/ccaBFAII.o: In function `main':
main.c:(.text+0xe): undefined reference to `node_new'
main.c:(.text+0x1c): undefined reference to `node_new'
main.c:(.text+0x33): undefined reference to `node_addSubNode'
main.c:(.text+0x42): undefined reference to `parameter_new'
main.c:(.text+0x59): undefined reference to `node_addParameter'
main.c:(.text+0x63): undefined reference to `node_text'
main.c:(.text+0x71): undefined reference to `node_new'
main.c:(.text+0x88): undefined reference to `node_addSubNode'
main.c:(.text+0x9b): undefined reference to `node_addSubNode'
main.c:(.text+0xa5): undefined reference to `node_text'
main.c:(.text+0xbc): undefined reference to `node_addSubNode'
main.c:(.text+0xc8): undefined reference to `node_delete'
collect2: ld returned 1 exit status

Diese Fehler haben keine Zeilennummern, das liegt daran, dass der Compiler das Programm 'main.c' problemlos kompilieren konnte. Leider kann er kein Programm erstellen, denn ihm fehlen eine ganze Reihe von Funktionen, wie zum Beispiel node_new(). Wir wissen, dass diese Funktionsdefinitionen sich in der Datei 'node.c' befinden. Wir haben ihm in der Headerdatei mit den Funktionsdeklarationen ja nur erklärt, dass diese Funktion irgendwo existieren sollen, aber nicht wo.

Wir müssen also alle zu kompilierenden Quelltexte übergeben:

xin@trinity:~/proggen.org/tutorial$ gcc main.c node.c parameter.c
xin@trinity:~/proggen.org/tutorial$ 

Nun erhalten wir wieder die gleiche Ausgabe wie zuvor:

xin@trinity:~/proggen.org/tutorial$ ./a.out 
+Node: html
+Node: body
+Parameter: background
+Text: proggen.org
+Node: h1
+Text: C-Tutorial
-Node: "html"
-Node: "body"
-Parameter: background
-Node: "h1"
-Text: "proggen.org"
-Text: "C-Tutorial"

Fazit der Lektion

Wir haben hier einen etwas größeren unübersichtlichen Quelltext in fünf Dateien zerteilt und beim Kompilieren wieder zusammengefügt. Dabei haben wir zum einen eine bessere Übersicht gewonnen, zum Beispiel weil wir bei Parameter-Funktionen sofort wissen, in welcher Datei wir zu suchen haben und in der Datei auch nicht von Definitionen abgelenkt werden, die sich eigentlich auf die Nodes beziehen.

Weiterhin haben wir dadurch ein Abstraktionslevel geschaffen: Wir haben das Problem in Teilprobleme zerlegt und dafür die Datenstrukturen Node und Parameter herausgearbeitet, also zwei Klassen von Datenstrukturen beschrieben. In den Dateien 'node.c' und 'parameter.c' befindet sich die Low-Level-Funktionalität, auf die wir in 'main.c' nun zurückgreifen können. Wir sind nun nicht mehr darin begrenzt unsere Programme auf eine Datei zu beschränken. Wir können uns nun also regelrecht austoben, ohne die Übersicht zu verlieren.

In der nächsten Lektion werden wir diesen Quelltext erweitern und weitere Abstraktionsschichten einfügen. Anschließend werden wir uns weitere Vorteile dieser Aufteilung ansehen und zu Nutze machen.