Lektion 5 - Scope, Funktionen und Header Files

Scope

Wie wir in den vorherigen Lektionen gesehen haben, verbraucht jede neue Variablendeklaration etwas mehr Speicher auf dem Stack. Was passiert mit diesen Daten, wenn wir sie nicht mehr benötigen? Da es ziemlich ineffizient wäre, alle diese Daten während der gesamten Ausführung im Auge zu behalten, verfügt die Sprache C über einen Mechanismus, um die Lebensdauer bestimmter Daten zu verfolgen: den Scope. Eine Variable, die in einem bestimmten Scope deklariert ist, ist nur so lange gültig, wie ihr Scope gültig ist. Ein Scope wird zwischen zwei geschweiften Klammern ({}) eingeschlossen. Diese Bereiche dürften dir bereits bekannt sein.

main() {
  // some code
}

Wenn wir unsere main schreiben, schaffen wir einen Scope. Da die main die gesamte Ausführung überdauert, bekommen wir die Auswirkungen des Scope nicht wirklich mit. If-Bedingungen, for-Schleifen und while-Schleifen erzeugen ebenfalls einen Gültigkeitsbereich. Betrachte das folgende Stück Code:

int a = 5;
for(int i = 1; i <= 10; i++) {
  int b = a;
}

Hier gehört die Variable i zum Bereich der for-Schleife. Sobald das Programm die Schleife verlässt, wird der Speicher, in dem i gespeichert ist, ungültig. Selbst wenn wir versuchen würden, auf i zuzugreifen, ließe sich der Code nicht kompilieren.

Was ist mit der Variable b? Der Geltungsbereich dieser Variablen ist auf eine einzige Iteration der for-Schleife beschränkt. Die Variable b wird zugewiesen, erhält den Wert von a (5) und wird bei jeder neuen Iteration vergessen.

Auf die Variable b kann vor, während und nach der Ausführung der Schleife zugegriffen werden, da ihr entsprechender Scope den Scope der Schleife umschließt.

Funktionen

Obwohl wir bereits einige Funktionen gesehen haben (die main- und die printf-Funktion), halte ich jetzt den richtigen Zeitpunkt, diese im Detail zu besprechen. Funktionen werden hauptsächlich dazu verwendet, eine logische Trennung in einem Programm zu schaffen, und sind ein praktisches Werkzeug, um Wiederholungen zu vermeiden.

Ich würde sagen, dass Funktionen als ein Sprachfeature von C 3 definierende Eigenschaften haben:

  1. Sie schaffen einen Scope
  2. Sie können Parameter zugewiesen bekommen
  3. Sie können einen Wert zurückgeben

Anhand dieser drei Merkmale können wir uns ein Beispiel ansehen und sehen, welche Auswirkungen ein Funktionsaufruf auf den Speicher hat.

int successor(int n) {
  n++;
  return n;
}

int main() {
  int n = 5;
  int n_prime = successor(n);
}

Die successor Funktion nimmt einen Integer als Parameter entgegen und gibt einen Integer zurück. Beim Aufruf von successor wird der Parameter n (aus der Main-Funktion) auf den Stack kopiert und ist unter dem Namen n zugänglich. Lass dich nicht von der Tatsache verwirren, dass diese Variablen denselben Namen haben. Sie beziehen sich auf unterschiedliche Speicherplätze. Da die beiden Funktionen keinen gemeinsamen Scope haben, ist dies zulässig. Nach Vollendung wird der von der successor Funktion verwendete Speicher ungültig. Stattdessen erhalten wir Zugriff auf den Rückgabewert von successor, den wir “in den Scope bringen” können, indem wir ihn einer anderen Variablen, n_prime, zuweisen.

Dieses Konzept ist sehr wichtig und kann zu vielen Fehlern führen, weshalb es sich lohnt, es zu wiederholen: Wenn wir ein Argument an eine Funktion übergeben, arbeitet das Argument mit Kopien, nicht mit den Variablen, die wir für die Funktionsaufrufe verwendet haben! Nach dem Aufruf von successor hat die Variable n (die zu main gehört) immer noch den Wert 5, da die Operation n++ eine ganz andere Speicherstelle betrifft. Da wir mit dem Wert unserer Parameter arbeiten und nicht mit den Parametern selbst, wird diese Methode manchmal auch als call by value bezeichnet.

Wenn man darüber nachdenkt, kann dies ziemlich ineffizient sein. Wir erstellen eine Kopie von n, führen die Berechnung durch und nehmen dann eine weitere Kopie des Rückgabewerts. An dieser Stelle sind Pointer sehr nützlich.

void successor(int* n_p) {
  (*n_p)++;
}

int main() {
  int n = 5;
  successor(&n);
}

Hier übergeben wir nicht eine Kopie von n, sondern eine Kopie der Adresse von n. Auf diese Weise können wir direkt auf den Speicherplatz von n zugreifen, dessen Wert abrufen und diesen um eins inkrementieren. Da dies bereits das gewünschte Ergebnis bringt, ist kein Rückgabewert erforderlich. Diese Methode wird call by reference genannt.

In diesem Fall macht die Referenzübergabe wenig Sinn, da ein Integer normalerweise halb so groß ist wie ein Pointer. Diese Methode wird typischerweise bei der Arbeit mit Structs verwendet, da diese recht groß werden können. Beim Umgang mit Structs haben wir meist Zugriff auf ihre Adressen und nicht auf den Wert selbst. Achte darauf, dass du den ->-Operator verwendest, um auf einzelne Felder zuzugreifen.

Es gibt noch eine weitere Möglichkeit, Pointer in Kombination mit Funktionsaufrufen zu verwenden. Schreiben wir eine Funktion div, die zwei Integers a und b als Parameter annimmt und a/b errechnet. Außerdem wollen wir sicherstellen, dass diese Funktion unseren Code nicht zum Absturz bringt, also wollen wir Divisionen durch 0 vermeiden. Stattdessen soll die Funktion den Aufrufenden irgendwie informieren, dass etwas schief gelaufen ist. Das ist ein Problem. Im Gegensatz zu anderen Sprachen existiert hier nur ein Rückgabewert, während wir gerne zwei hätten: einen, um zu prüfen, ob der Aufruf fehlgeschlagen ist, und einen weiteren, um das Ergebnis zu verwenden. Betrachte diese Lösung:

int div(int a, int b, int* res) {
  if (b == 0) {
    return 1;
  } else {
    *res = a/b;
    return 0;
  }
}

int main() {
  int x = 5;
  int y = 0;

  int res;
  if(div(x, y, &res) == 1) {
    // handle error
  } else {
    printf(result: %d\n", res);
  }
}

Zusätzlich zu den Parametern a und b erhält div einen Zeiger, in den das Ergebnis geschrieben wird. Dabei kann der Rückgabewert frei für die Fehlerbehandlung verwendet werden. Dies ist ein sehr häufiges Pattern in C.

Zum Abschluss dieses recht langen Abschnitts über Funktionen möchte ich noch eine weitere häufige Fehlerquelle ansprechen:

int* create_array(int size) {
  int array[size];
  return array;
}

Auch wenn es nicht so offensichtlich ist, ist diese Funktion problematisch. Vorhin haben wir gelernt, dass Arrays wenig mehr als Pointers sind. Es sollte also kein Problem geben, oder? Diese Funktion weist einem Array Speicher auf dem Stack zu und gibt den Speicherort des Arrays zurück.

Diese Funktion kann zu ernsthaften Speicherfehlern führen. Die Funktion ordnet ein Array auf dem Stack zu, aber wenn der Funktionsaufruf abgeschlossen ist, fällt das Array aus dem Scope. Daher wird der Rückgabewert von create_array immer auf ungültigen Speicher verweisen. Wenn unser Stack wächst, kann dieser Speicher anderen Variablen zugewiesen werden. Wenn wir dann auf Elemente des Arrays zugreifen und ihnen einen Wert zuweisen, können wir dadurch andere Variablen überschreiben. Solche Fehler können beim Debuggen sehr unangenehm werden, da einige Variablen scheinbar aus dem Nichts Werte ändern.

Als praktische Übung: Betrachte die Programme, die du bereits geschrieben hast. Gibt es Teile des Codes, die du in separaten Funktionen unterbringen könntest, um die Main besser lesbar zu machen? Versuche, call by reference anzuwenden, um Argumente zu übergeben und mehrere Werte zurückzugeben (z.B. für die Fehlerprüfung).

Headers

Obwohl Funktionen eine gute Lösung sind, um eine logische Ordnung in dein Programm zu bringen, wird es sehr schnell unübersichtlich, wenn du alles in dieselbe Datei packst. Als Programmierer solltest du versuchen, deine Funktionen logisch in verschiedenen .c-Dateien zu gruppieren. Eine Gruppe von Funktionen, die zusammengehören, wird gewöhnlich als Modul bezeichnet. Eine separate .c-Datei sollte in der Regel eine entsprechende Header-Datei haben. Während eine .c-Datei die Implementierung von Funktionen enthält, die sich über Hunderte von Zeilen erstrecken kann, enthält eine Header-Datei in der Regel nur die Funktionsdeklarationen (d.h. wie werden die Funktionen genannt, welche Art von Argumenten erhalten sie, welchen Typ geben sie zurück) und die Datenstrukturbeschreibungen (structs). Dies dient zwei Zwecken:

  1. Aus Sicht der Sprache C wird die Header-Datei verwendet, um diese Bezeichner, die Funktionen und Strukturen beschreiben, in den Scope zu bringen. Eine .c-Datei, die die Header enthält, kennt vielleicht nicht die tatsächliche Implementierung in ihren Headern, aber sie kann zunächst einmal die Bezeichner verwenden und sich später um die “Verknüpfung” der richtigen Implementierungen kümmern.
  2. Aus der Sicht eines Programmierenden ist die Header-Datei der erste Ort, an dem man nachschaut, wenn man versucht, den Code anderer Leute zu verstehen. Im Idealfall reichen die Funktionsnamen bereits aus, um eine Vorstellung davon zu bekommen, was das Modul tun soll. Mit etwas Glück enthalten die Header-Dateien auch eine Dokumentation, die beschreibt, wofür die einzelnen Funktionen da sind.

Wir haben die Verwendung von Header-Dateien bereits in den früheren Übungen kennengelernt. Manchmal begannen meine Code-Beispiele mit der Zeile

#include <stdio.h>

stdio.h ist die Header-Datei eines der vielen Module (oder, in diesem Zusammenhang eher Bibliotheken genannt), die mit der Sprache C mitgeliefert werden. Sie wird in der Regel eingebunden, um die Funktion printf in den Scope zu bringen. Diese Art von Bibliotheken werden mit < >- Klammern eingefügt, so dass C weiß, wo es sie in Ihrem Dateisystem suchen muss (unter Linux befinden sich diese Dateien in /usr/include/).

Schauen wir uns einmal an, wie unsere eigene Implementierung einer Header-Datei aussehen könnte. Angenommen, wir implementieren ein Telefonbuch, dann könnte die Datei telephone_book.h etwa so aussehen:

struct contact {
  char[100] first_name;
  char[100] last_name;
  int telephone_number;
}

struct contact new_contact(char* first_name, char* last_name, int telephone_number);
void contact_change_first_name(char* first_name_buffer, char* new_first_name);
void contact_change_last_name(char* last_name_buffer, char* new_last_name);
void contact_change_number(int* contact_number, int new_number);

Diese Datei enthält keinen tatsächlichen Code, aber der Leser kann bereits eine Vorstellung davon bekommen, was der Zweck des Moduls ist. Angenommen, wir haben telephone_book.c bereits implementiert und alle Dateien befinden sich im selben Ordner, dann können wir die Funktionen in anderen Dateien verwenden, indem wir

#include "telephone_book.h"

am Anfang einfügen.

Scheint ganz einfach zu sein, oder? Nun, leider neigt dieses Include-System dazu, einige unangenehme Kompilierungsfehler zu verursachen und Fehlermeldungen auszugeben, die Einsteigern ziemlich kryptisch vorkommen können. In solchen Fällen ist es am besten, die Suchmaschine der Wahl nach dem möglichen Problem und der empfohlenen Lösung zu fragen.

Als praktische Übung: probiere, eine separate .c-Datei zu erstellen, füge eine Funktion ein und benutze eine Header-Datei, um auf diese Funktion in deinem Main zuzugreifen. Vielleicht möchtest du das mit einer Funktion üben, die du in der vorherigen Übung geschrieben hast. Ansonsten kannst du gerne auch diese minimale Vorgabe verwenden:

vorgabe5.c