Strukturen

  • Was sind Strukturen?
  • Strukturen definieren
  • Strukturen verwenden
  • Strukturen übergeben
    • Call-by-Value
    • Call-by-Pointer
  • Größe eines Datentyps
  • Speicher für Strukturen anfordern
  • Arrays mit Strukturen
  • Das Semikolon nach der Definition

Was sind Strukturen?

Wir haben uns bisher mit einfachen Datentypen beschäftigt, wie rationale Zahlen (float) und abzählbare Zahlen (int), bzw. die Sonderform abzählbarer Zahlen für Buchstaben (char). Dann haben wir uns mit Arrays beschäftigt, also der Hintereinanderlegung von Zahlen oder eben Buchstaben, aus denen sich dann Strings ergeben. Die bisherigen Datentypen werden als „Primitive“ bezeichnet.

Damit können wir einfache Datentypen bilden, wie beispielsweise Bildschirmkoordinaten oder die Auflösung. Nehmen wir an, die Bildschirmauflösung beträgt 1280 * 800 Pixel und wir wollen diese Daten speichern:

int main( void )
{
  unsigned int width;
  unsigned int height;
 
  width  = 1280;
  height = 800;
 
  return 0;
};

Da die Datentypen identisch sind, können wir ein Array daraus machen.

int main( void )
{
  unsigned int screenResolution[2];
 
  screenResolution[0] = 1280;
  screenResolution[1] = 800;
 
  return 0;
};

Jetzt haben wir die zusammengehörigen Informationen in einer einzigen Variablen - aber leider wissen wir nicht mehr, ob width bzw. height nun im 0. oder 1. Element des Arrays liegt.

Strukturen bilden hier die Möglichkeit einen Datentyp zu definieren, der mehrere Datensätze zusammenfasst - in unserem Fall also zwei Datensätze vom Typ unsigned int, wobei jeder für sich einen eigenen Namen hat - hier also „width“ und „height“. Wir wollen Daten strukturieren, statt sie einfach hintereinander zu packen. Wir wollen Strukturen.

Strukturen definieren

Eine Struktur ist ein Datentyp. Wir beschreiben also die Werte, die wir uns merken wollen. Aber in der Beschreibung der Werte können wir noch keine Werte speichern. Die Beschreibung sieht ähnlich aus, wie die vorherige Definition von „width“ und „height“ in der Funktion main() weiter oben. Da es nur eine Beschreibung ist, können wir keine Werte zuweisen.

struct Resolution
{
  unsigned int width;
  unsigned int height;
};

Am Ende einer Strukturdefinition findet sich - im Gegensatz zu einer Funktion - ein Semikolon, um die Definition abzuschließen. Das Semikolon ist notwendig, um dem Compiler zu sagen, dass die Anweisung hier endet. Weshalb erkläre ich gleich.

Es lassen sich innerhalb von Strukturen beliebige Datentypen verwenden - auch Strukturen:

struct Address
{
  char           Street[64];
  unsigned int   houseNumber;
  unsigned int   zipCode;
  char           town[64];
};
 
struct Person
{
  char           firstName[64];
  char           familyName[64];
 
  unsigned int   age;
 
  struct Address mainAddress;
  struct Address secondaryAddress;
};

Strukturen verwenden

Bisher haben wir nur beschrieben wie Daten aussehen sollen. Wenn wir Werte speichern wollen, dann müssen wir entsprechende Variablen anlegen. Das geht genauso wie bei den primitiven Variablen:

int main( void )
{
  struct Person person;
 
  return 0;
}

Hiermit werden zwei char-Arrays mit je 64 Elementen für Vor- und Nachname, das Integer für Alter und auch jeweils zwei Strukturen Address reserviert. Für jede der beiden Address-Variablen wird wiederum jeweils Speicher für die beiden Arrays und die zwei Integer reserviert. Alles zusammen ist über die Variable person erreichbar.

Auf Elemente der Struktur greift man mit dem Punkt-Operator ('.') zu. Um Daten in ein char-Array zu kopieren, verwenden wir die Funktion strcpy().

#include <string.h>
 
struct Address
{
  char           Street[64];
  unsigned int   houseNumber;
  unsigned int   zipCode;
  char           town[64];
};
 
struct Person
{
  char           firstName[64];
  char           familyName[64];
 
  unsigned int   age;
 
  struct Address mainAddress;
  struct Address secondaryAddress;
};
 
int main( void )
{
  struct Person person;
 
  strcpy( person.firstName, "Hans" );
  strcpy( person.familyName, "Mustermann" );
  person.age = 20;
  strcpy( person.mainAddress.Street, "Hauptstraße" );
  person.mainAddress.houseNumber = 1;
  person.mainAddress.zipCode = 12345;
  strcpy( person.mainAddress.town, "Musterstadt" );
 
  return 0;
}

(svn://svn.proggen.org/wiki/c/tutorial/struct/input.c)

Strukturen übergeben

Eine Struktur beschreibt Daten in einer eindeutigen Form, denn jeder Datensatz im Datensatz hat einen eigenen Namen. Das bedeutet, dass Verwechslungen eigentlich nicht möglich sind. So lassen sich Datensätze auch eindeutig an Funktionen übergeben.

Call-by-Value

Werden die Daten direkt übergeben, spricht man von Call By Value. Um das zu verdeutlichen, übergebe ich den Datentyp struct Person hier an eine Funktion, die ich „callByValue“ nenne:

void callByValue( struct Person p )
{
  printf( "Name: %s %s (%d)\n", p.firstName, p.familyName, p.age );
  printf( "Adresse: %s %d; %d %s\n",
          p.mainAddress.Street,
          p.mainAddress.houseNumber,
          p.mainAddress.zipCode,
          p.mainAddress.town );
}
 
int main( void )
{
  struct Person person;
 
  strcpy( person.firstName, "Hans" );
  strcpy( person.familyName, "Mustermann" );
  person.age = 20;
  strcpy( person.mainAddress.Street, "Hauptstraße" );
  person.mainAddress.houseNumber = 1;
  person.mainAddress.zipCode = 12345;
  strcpy( person.mainAddress.town, "Musterstadt" );
 
  callByValue( person );
 
  return 0;
}

(svn://svn.proggen.org/wiki/c/tutorial/struct/callbyvalue.c)

Wird das Programm ausgeführt (hier vollständig|vollständig), erscheint folgende Ausgabe auf dem Bildschirm:

Name: Hans Mustermann (20)
Adresse: Hauptstraße 1; 12345 Musterstadt

Was passiert?

Zunächst wird die Struktur person aus der Hauptfunktion - nennen wir sie „main::person“ - kopiert, und zwar auf die Variable p, die als Parameter der Funktion callByValue verwendet wird. Wichtig ist zu verstehen, dass der Wert der Variable kopiert wird. Call-by-Value - Funktionsruf mit Wert. Wenn wir in einer Funktion, der wir die Werte kopieren, Teile der Struktur ändern, so gilt das nur für die lokale Kopie:

void overwriteAgeByValue( struct Person p )
{
  p.age = 99;
 
  print( "Alter von %s %s auf %d geändert\n", p.firstName, p.familyName, p.age );
}
 
int main( void )
{
  struct Person person;
 
  /* wie zuvor */
 
  callByValue( person );
  overwriteAgeByValue( person );
  callByValue( person );
 
  return 0;
}

(svn://svn.proggen.org/wiki/c/tutorial/struct/overwritebyvalue.c)

Entsprechend gibt das Programm folgendes aus:

Name: Hans Mustermann (20)
Adresse: Hauptstraße 1; 12345 Musterstadt
Alter von Hans Mustermann auf 99 geändert
Name: Hans Mustermann (20)
Adresse: Hauptstraße 1; 12345 Musterstadt

Die lokale Kopie overwriteAgeByValue::p wird modifiziert, aber das interessiert das Original main::person überhaupt nicht. Beim zweiten Aufruf von callByValue wird erneut die unveränderten Werte von main::person kopiert, entsprechend ist bei der Ausgabe innerhalb callByValue Hans Mustermann wieder jugendliche 20 Jahre alt.

Call-by-Value hat also den Vorteil, dass wir Werte an eine Funktion geben können und sicher sein können, dass die Originalwerte nicht verändert werden. Das ist aber genauso ein Nachteil, wenn wir Werte verändern wollen. Leider hat Call-by-Value weiterhin den sehr großen Nachteil, dass die Werte alle kopiert werden müssen und das kostet Zeit. Wer schnelle Programme schreiben möchte, sollte auf das Kopieren also lieber verzichten.

Call-by-Pointer

Um auf das Kopieren zu verzichten, übergeben wir in C üblicherweise einfach einen Pointer auf die Daten. Dies wird in vielen C-Lehrbüchern als „Call-by-Reference“ beschrieben, aber genau genommen unterstützt CCall-by-Reference gar nicht. Faktisch ist Call-by-Pointer das gleiche wie Call-by-Value, nur, dass der Wert in dem Fall eben der Pointer ist, der kopiert wird. Weil wir eigentlich aber nicht am Pointer interessiert sind, sondern an den Daten, wohin er zeigt, drücke ich diesen Unterschied mit dem Begriff „Call-by-Pointer“ aus. „Call-by-Reference“ gibt es erst in C++ und das ist eben nicht identisch mit „Call-by-Pointer“.

Dies ist ein C-Tutorial, also schauen wir, was wir mit C machen können. Wir sind bei Call-by-Value auf zwei Probleme gestoßen: Den Aufwand, dass die Werte kopiert werden (man stelle sich ein großes Array als Teil einer Struktur vor…) und dass die Originaldaten nicht verändert werden können. Hier kommt jetzt „Call-by-Pointer“ ins Spiel und ein neuer Operator, der Pfeiloperator ('→') ins Spiel:

void callByPointer( struct Person * p )
{
  printf( "Name: %s %s (%d)\n", p->firstName, p->familyName, p->age );
  printf( "Adresse: %s %d; %d %s\n",
          p->mainAddress.Street,
          p->mainAddress.houseNumber,
          p->mainAddress.zipCode,
          p->mainAddress.town );
}
 
void overwriteAgeByPointer( struct Person * p )
{
  p->age = 99;
 
  printf( "Alter von %s %s auf %d geändert\n", p->firstName, p->familyName, p->age );
}
 
int main( void )
{
  struct Person person;
 
  strcpy( person.firstName, "Hans" );
  strcpy( person.familyName, "Mustermann" );
  person.age = 20;
  strcpy( person.mainAddress.Street, "Hauptstraße" );
  person.mainAddress.houseNumber = 1;
  person.mainAddress.zipCode = 12345;
  strcpy( person.mainAddress.town, "Musterstadt" );
 
  callByPointer( &person );
  overwriteAgeByPointer( &person );
  callByPointer( &person );
 
  return 0;
}

Beginnen wir recht weit unten bei den Aufrufen:

  callByPointer( &person );
  overwriteAgeByPointer( &person );
  callByPointer( &person );

Statt die Variable selbst zu übergeben, übergeben wir nur den Zeiger auf die Variable. Dieser Zeiger wird per Call-by-Value an die Funktionen übergeben.

Um auf ein Element in der Struktur zuzugreifen, kennen wir den Punkt-Operator. Da wir aber einen Zeiger haben, muss zunächst dereferenziert werden, um aus dem Zeigerdatentyp wieder auf den eigentlichen Datentyp zurückzukommen, so dass wir den Punkt-Operator verwenden können. Der Operator zum Dereferenzieren muss zusätzlich geklammert werden, damit der Compiler versteht, dass man die Struktur dereferenzieren möchte und nicht das Element auf das man zugreift. Das sieht dann so aus:

(*p).firstName

Das gefiel den C-Entwicklern vermutlich auch nicht, deswegen wurde dafür der Pfeil-Operator eingeführt, der die gleiche Bedeutung hat, aber besser zu lesen ist:

p->firstName

Schauen wir uns das ganze in der Funktion an:

void callByPointer( struct Person * p )
{
  printf( "Name: %s %s (%d)\n", p->firstName, p->familyName, p->age );
  printf( "Adresse: %s %d; %d %s\n",
          p->mainAddress.Street,
          p->mainAddress.houseNumber,
          p->mainAddress.zipCode,
          p->mainAddress.town );
}

Hier fällt vielleicht auf, dass hier Pfeiloperator und Punktoperator bei mainAddress kombiniert auftreten: zum Beispiel p→mainAddress.Street. Das ist einfach zu erklären: Der Operator bezieht sich auf das linksstehende Objekt. Um an das Element mainAddress zu gelangen, muss der Zeiger von p mit dem Pfeiloperator dereferenziert werden. Und dann hat man die struct Address, genauso als würde man von einer struct Person mit dem Punktoperator auf mainAddress zugreifen. Der Punkt hinter mainAddress bleibt also unverändert stehen. Anders wäre es, wenn die Struktur statt einer Instanz einer Adresse nur einen Zeiger auf eine Adresse enthalten würde. Um zu entscheiden, ob der Punkt- oder der Zeigeroperator verwendet wird, ist also das Objekt links vom Operator entscheidend: Ist es ein Zeiger oder nicht?

Name: Hans Mustermann (20)
Adresse: Hauptstraße 1; 12345 Musterstadt
Alter von Hans Mustermann auf 99 geändert
Name: Hans Mustermann (99)
Adresse: Hauptstraße 1; 12345 Musterstadt

Const-Correctness

Die Funktion callByPointer muss keine Daten einer Person verändern. Wie wir bereits bei den Strings gelernt haben, ist es sinnvoll, diese Information dem Compiler mitzugeben, indem wir den Übergabeparameter als const deklarieren.

void callByPointer( struct Person const * p )
{
  printf( "Name: %s %s (%d)\n", p->firstName, p->familyName, p->age );
  printf( "Adresse: %s %d; %d %s\n",
          p->mainAddress.Street,
          p->mainAddress.houseNumber,
          p->mainAddress.zipCode,
          p->mainAddress.town );
}

Nun weiß der Compiler, dass er die Funktion callByPointer auch mit konstanten Personen-Objekten aufrufen darf und der Programmierer, dass ein Personen-Objekt mit dieser Funktion nicht verändert wird.

Das geht auch bei Call-By-Value, allerdings spielt es da seltener eine Rolle, wenn das kopierte Objekt verändert wird, da es schließlich nach dem Aufruf sowieso wieder zerstört wird, ohne das Original zu beeinflussen.

Größe eines Datentyps

Wir haben bisher Strukturen einfach als lokale Variable verwendet:

int main( void )
{
  struct Person person;
  ...

Wir können aber auch Speicher für eine Struktur mit malloc() anfordern. Dafür müssen wir jedoch die Größe einer solchen Struktur wissen. Bisher haben wir nur chars alloziiert, die zufälligerweise genauso groß sind, wie ein Byte. Wir müssen also herausfinden, wie viele Bytes wir für eine Struktur benötigen. Hierfür gibt es den sizeof()-Operator.

unsigned int size = sizeof( struct Person );

Speicher für Strukturen anfordern

Und damit können wir nun Speicher anfordern und erhalten einen Zeiger zurück, auf den wir wieder mit dem Pfeil-Operator zugreifen können:

#include <stdlib.h>
 
int main( void )
{
  struct Person * person;
 
  person = malloc( sizeof( struct Person ));
  // Sollte die vorherige Zeile nicht funktionieren die folgende verwenden:
  // person = (struct Person *) malloc( sizeof( struct Person ));
  // Erklärung dazu folgt in einem späteren Kapitel.
 
  if( person )
  {
    /* Wir haben Speicher bekommen */
 
    strcpy( person->firstName, "Hans" );
    strcpy( person->familyName, "Mustermann" );
    person->age = 20;
    strcpy( person->mainAddress.Street, "Hauptstraße" );
    person->mainAddress.houseNumber = 1;
    person->mainAddress.zipCode = 12345;
    strcpy( person->mainAddress.town, "Musterstadt" );
 
    callByPointer( person );
 
    free( person );
  }
 
  return 0;
}

Abschließend müssen wir den Speicher mit free() wieder frei geben.

Arrays mit Strukturen

Natürlich lassen sich auch Arrays von Strukturen anlegen und sich dafür Speicher anfordern:

int main( void )
{
  struct Person   personen[10];
  struct Person * weiterePersonen;
 
  weiterePersonen = (struct Person*) malloc( 10 * sizeof( struct Person ));
  if( weiterePersonen )
  {
    /* ... */
 
    callByPointer( &weiterePersonen[0] );
 
    free( weiterePersonen );
  }
 
  callByPointer( &personen[0] );
 
  return 0;
}

Das Semikolon nach der Definition

Da war noch was - das Semikolon nach der Definition:

struct ScreenResolution
{
  unsigned int width;
  unsigned int height;
};                            // <- Semikolon
 
void function()
{
  unsigned int width;
  unsigned int height;
}                             // <- kein Semikolon

Natürlich ist die Funktion function nicht sinnvoll, da sie mit den beiden Variablen nichts macht. Aber nichts zu tun, ist ja noch nicht falsch, die Funktion ist gültig und im Gegensatz zur Struktur, gibt es hier kein Semikolon - wieso also hinter der Strukturdefinition?

Schauen wir uns kurz die Definition der Struktur ScreenResolution und die Definition einer Variable vom Typ struct ScreenResolution an:

struct ScreenResolution
{
  unsigned int width;
  unsigned int height;
};                            // <- Semikolon
 
struct ScreenResolution myResolution;

Am Ende der Definition der Variablen myResolution findet sich - wie hinter jeder Anweisung - ein Semikolon.

Das ganze lässt sich zusammenfassen, denn die Definition der Struktur ist gleichzeitig die Aufforderung Variablen dieses Typs zu definieren, so dass sich die zwei Anweisungen zu einer einzigen Anweisung zusammenfassen.

struct ScreenResolution
{
  unsigned int width;
  unsigned int height;
} myResolution;

Damit der C-Compiler weiß, ob das nächste Wort nun der Name einer neuen Variablen ist, die vom Datentyp struct ScreenResolution definiert werden soll oder irgendetwas anderes, wie der Name einer anderen Variable oder Funktion, die verwendet werden soll, muss hier ein Semikolon stehen - eben auch dann, wenn keine Variable vom Typ ScreenResolution definiert wurde. Und wenn man keine neue Variable definieren möchte, dann folgt das Semikolon direkt der schließenden Klammer.

Fazit

An dieser Stelle des Tutorials haben wir das Wissen durchgegangen, das erforderlich ist, um die meisten Anwendungsprogramme zu schreiben. Wir können Probleme in kleinere Funktionen zerkleinern und können Daten in Strukturen zusammenfassen und ihnen einen ihrer Semantik (ihrer Bedeutung) entsprechenden Namen geben: zum Beispiel ScreenResolution oder Address.

Wir können Speicher für strukturierte Daten anfordern und somit soviele Daten anfordern, wie Speicher vorhanden ist. Das Wissen ist da, nun fehlt uns die Erfahrung, das Wissen anzuwenden. Das werden wir uns in der folgenden Lektion ansehen, die sich mit dem Laden und Speichern von Daten beschäftigen wird.