C-Programmierung: Funktionen

Eine wichtige Forderung der strukturierten Programmierung ist die Vermeidung von Sprüngen innerhalb des Programms. Wie wir gesehen haben, ist dies in allen Fällen mit Kontrollstrukturen möglich.

Die zweite Forderung der strukturierten Programmierung ist die Modularisierung. Dabei wird ein Programm in mehrere Programmabschnitte, die Module, zerlegt. In C werden solche Module auch als Funktionen bezeichnet. Andere Programmiersprachen bezeichnen Module als Unterprogramme oder unterscheiden zwischen Funktionen (Module mit Rückgabewert) und Prozeduren (Module ohne Rückgabewert). Trotz dieser unterschiedlichen Bezeichnungen ist aber dasselbe gemeint.

Objektorientierte Programmiersprachen gehen noch einen Schritt weiter und verwenden Klassen zur Modularisierung. Vereinfacht gesagt bestehen Klassen aus Methoden (vergleichbar mit Funktionen) und Attributen (Variablen). C selbst unterstützt keine Objektorientierte Programmierung, im Gegensatz zu C++, das auf C aufbaut.

Die Modularisierung hat eine Reihe von Vorteilen:

Bessere Lesbarkeit

Der Quellcode eines Programms kann schnell mehrere tausend Zeilen umfassen. Beim Linux Kernel sind es sogar über 15 Millionen Zeilen und Windows, das ebenfalls zum Großteil in C geschrieben wurde, umfasst schätzungsweise auch mehrere Millionen Zeilen. Um dennoch die Lesbarkeit des Programms zu gewährleisten, ist die Modularisierung unerlässlich.

Wiederverwendbarkeit

In fast jedem Programm tauchen die gleichen Problemstellungen mehrmals auf. Oft gilt dies auch für unterschiedliche Applikationen. Da nur Parameter und Rückgabetyp für die Benutzung einer Funktion bekannt sein müssen, erleichtert dies die Wiederverwendbarkeit. Um die Implementierungsdetails muss sich der Entwickler dann nicht mehr kümmern.

Wartbarkeit

Fehler lassen sich durch die Modularisierung leichter finden und beheben. Darüber hinaus ist es leichter, weitere Funktionalitäten hinzuzufügen oder zu ändern.

Funktionsdefinition

Bearbeiten

Im Kapitel Was sind Variablen haben wir die Quaderoberfläche berechnet. Nun wollen wir eine Funktion schreiben, die die Oberfläche eines Zylinders berechnet. Dazu schauen wir uns zunächst die Syntax einer Funktion an:

Rückgabetyp Funktionsname(Parameterliste)
{
	Anweisungen
}

Die Anweisungen werden als Funktionsrumpf bezeichnet, die erste Zeile als Funktionskopf.

Ein Programm mit einer Funktion zur Zylinderoberflächenberechnung sieht z.B. wie folgt aus:

#include <stdio.h>

#define PI 3.1415926535898f

float zylinder_oberflaeche(float h, float r)
{
	float o;
	o = 2 * PI * r * (r + h);
	return o;
}

int main()
{
	float r, h;
	printf("Programm zur Berechnung einer Zylinderoberfläche\n\n");
	printf("Höhe des Zylinders: ");
	if (scanf("%f", &h) != 1) {
		printf("Die Höhe sollte eine Zahl sein!\n");
		return -1;
	}
	printf("Radius des Zylinders: ");
	if (scanf("%f", &r) != 1) {
		printf("Der Radius sollte eine Zahl sein!\n");
		return -1;
	}
	printf("Oberfläche: %f \n", zylinder_oberflaeche(h, r));
	return 0;
}
  • In Zeile 5 beginnt die Funktionsdefinition. Das float ganz am Anfang der Funktion, der sogenannte Funktionstyp, sagt dem Compiler, dass ein Wert mit dem Typ float zurückgegeben wird. In Klammern werden die Übergabeparameter h und r deklariert, die der Funktion übergeben werden.
  • Mit return wird die Funktion beendet und ein Wert an die aufrufende Funktion zurückgegeben (hier: main ). In unserem Beispiel geben wir den Wert von o zurück, also das Ergebnis unserer Berechnung. Der Datentyp des Ausdrucks sollte mit dem Typ des Rückgabewertes des Funktionskopfs übereinstimmen.
    Soll der aufrufenden Funktion kein Wert zurückgegeben werden, muss als Typ der Rückgabewert void angegeben werden. Eine Funktion, die lediglich einen Text ausgibt hat beispielsweise den Rückgabetyp void , da sie keinen Wert zurückgibt.
  • In Zeile 26 wird die Funktion zylinder_oberflaeche aufgerufen. Hier werden die beiden Parameter h und r übergeben. Der zurückgegebene Wert wird ausgegeben. Es wäre aber genauso denkbar, dass der Wert einer Variable zugewiesen, mit einem anderen Wert verglichen oder mit dem Rückgabewert weitergerechnet wird.
    Der Rückgabewert muss aber nicht ausgewertet werden. Es ist kein Fehler, wenn der Rückgabewert unberücksichtigt bleibt. Man kann allerdings einer Funktion ein sogenanntes Attribut zuweisen, das bewirkt, dass der Compiler eine Warnung ausgibt, wenn der Rückgabewert ignoriert wird, was z.B. bei scanf der Fall ist.

Auch die Funktion main hat einen Rückgabewert. Ist der Wert 0, so bedeutet dies, dass das Programm ordnungsgemäß beendet wurde, ist der Wert -1, so bedeutet dies, dass ein Fehler aufgetreten ist.

Beispiele fehlerhafter Funktionen

Bearbeiten
void foo()
{
	/* Code */
	return 5; /* Fehler */
}

Eine Funktion, die als void deklariert wurde, darf keinen Rückgabetyp erhalten. Der Compiler sollte hier eine Warnung oder sogar eine Fehlermeldung ausgeben.

#include <stdio.h>

int foo()
{
	/* Code */
	return 5;
	printf("Diese Zeile wird nie ausgeführt");
}

Bei diesem Beispiel wird der Compiler weder eine Warnung noch eine Fehlermeldung ausgeben. Allerdings wird die printf Funktion niemals ausgeführt, da return nicht nur einen Wert zurückgibt sondern die Funktion foo() auch beendet.

Sonstiges

Bearbeiten

In der ursprünglichen Sprachdefinition von K&R wurde nicht gefordert, dass jede Funktion einen Rückgabetyp besitzen muss. Wenn der Rückgabetyp fehlte, wurde standardmäßig int angenommen. Dies ist aber inzwischen nicht mehr erlaubt. Jede Funktion muss einen Rückgabetyp explizit angeben.
Wenn eine Funktion mit einem Rückgabewert, der nicht void ist, nichts mittels return zurückgibt, gibt der Compiler eine Warnung aus und der zurückgegebene Wert bei der Ausführung ist nicht definiert.

Prototypen

Bearbeiten

Auch bei Funktionen unterscheidet man wie bei Variablen zwischen Definition und Deklaration. Mit

float zylinder_oberflaeche(float h, float r)
{
	float o;
	o = 2 * PI * r * (r + h);
	return o;
}

wird die Funktion zylinder_oberflaeche (siehe oben) definiert.

Bei einer Funktionsdeklaration wird nur der Funktionskopf gefolgt von einem Semikolon angeben. Die Funktion zylinder_oberflaeche beispielsweise wird wie folgt deklariert:

float zylinder_oberflaeche(float h, float r);

Dies ist identisch mit

extern float zylinder_oberflaeche(float h, float r);

Die Meinungen, welche Variante benutzt werden soll, gehen hier auseinander: Einige Entwickler sind der Meinung, dass das Schlüsselwort extern die Lesbarkeit verbessert, andere wiederum nicht. Wir werden im Folgenden das Schlüsselwort extern in diesem Zusammenhang nicht verwenden.

Eine Trennung von Definition und Deklaration ist notwendig, wenn die Definition der Funktion erst nach der Benutzung erfolgen soll. Eine Deklaration einer Funktion wird auch als Prototyp oder Funktionskopf bezeichnet. Damit kann der Compiler überprüfen, ob die Funktion überhaupt existiert und Rückgabetyp und Typ der Argumente korrekt sind. Stimmen Prototyp und Funktionsdefinition nicht überein oder wird eine Funktion aufgerufen, die noch nicht definiert wurde oder keinen Prototyp besitzt, so ist dies ein Fehler.

Das folgende Programm ist eine weitere Abwandlung des Programms zur Berechnung der Zylinderoberfläche. Die Funktion zylinder_oberflaeche wurde dabei verwendet, bevor sie definiert wurde:

#include <stdio.h>

#define PI 3.1415926535898f

float zylinder_oberflaeche(float h, float r);

int main()
{
	float r, h;
	printf("Programm zur Berechnung einer Zylinderoberfläche\n\n");
	printf("Höhe des Zylinders: ");
	if (scanf("%f", &h) != 1) {
		printf("Die Höhe sollte eine Zahl sein!\n");
		return -1;
	}
	printf("Radius des Zylinders: ");
	if (scanf("%f", &r) != 1) {
		printf("Der Radius sollte eine Zahl sein!\n");
		return -1;
	}
	printf("Oberfläche: %f \n", zylinder_oberflaeche(h, r));
	return 0;
}

float zylinder_oberflaeche(float h, float r)
{
	float o;
	o = 2 * PI * r * (r + h);
	return o;
}

Der Prototyp wird in Zeile 5 deklariert, damit die Funktion in Zeile 21 verwendet werden kann. An dieser Stelle kann der Compiler auch prüfen, ob der Typ und die Anzahl der übergebenen Parameter richtig ist (dies könnte er nicht, hätten wir keinen Funktionsprototyp deklariert). Ab Zeile 25 wird die Funktion zylinder_oberflaeche definiert.

Die Bezeichner der Parameter müssen im Prototyp und der Funktionsdefinition nicht übereinstimmen. Sie können sogar ganz weggelassen werden. So kann Zeile 5 auch ersetzt werden durch:

float zylinder_oberflaeche(float, float);

Wichtig: Bei Prototypen unterscheidet C zwischen einer leeren Parameterliste und einer Parameterliste mit void . Ist die Parameterliste leer, so bedeutet dies, dass die Funktion eine nicht definierte Anzahl an Parametern besitzt. Das Schlüsselwort void gibt an, dass der Funktion keine Werte übergeben werden dürfen. Beispiel:

int foo1();
int foo2(void);

int main()
{
	foo1(1, 2, 3); // kein Fehler
	foo2(1, 2, 3); // Fehler
	return 0;
}

Bei Aufruf der Funktion foo2 in Zeile 7 gibt der Compiler eine Fehlermeldung aus, bei Aufruf der Funktion foo1 in Zeile 6 nicht.

Diese Aussage gilt übrigens nur für Prototypen: Laut C Standard bedeutet eine leere Liste bei Funktionsdeklarationen, die Teil einer Definition sind, dass die Funktion keine Parameter hat. Im Gegensatz dazu bedeutet eine leere Liste in einer Funktionsdeklaration, die nicht Teil einer Definition sind (also Prototypen), dass keine Informationen über die Anzahl oder Typen der Parameter vorliegt - so wie wir das eben am Beispiel der Funktion foo1 gesehen haben.

Wenn das Programm mit einem C++ Compiler übersetzt wird, wird auch im Fall von foo1 eine Fehlermeldung ausgegeben, da dort auch eine leere Parameterliste bedeutet, dass der Funktion keine Parameter übergeben werden können.

Bibliotheksfunktionen wie printf oder scanf haben einen Prototyp, der sich üblicherweise in der Headerdatei stdio.h oder anderen Headerdateien befindet. Damit kann der Compiler überprüfen, ob die Anweisungen die richtige Syntax haben. Der Prototyp der printf Anweisung hat beispielsweise die folgende Form (oder ähnlich) in der stdio.h :

extern int printf (const char *__restrict __format, ...);

Findet der Compiler nun beispielsweise die folgende Zeile im Programm, gibt er einen Fehler aus:

printf(45);

Der Compiler vergleicht den Typ des Parameters mit dem des Prototypen in der Headerdatei stdio.h und findet dort keine Übereinstimmung. Nun "weiß" er, dass der Anweisung ein falscher Parameter übergeben wurde und gibt eine Fehlermeldung aus.

Das Konzept der Prototypen wurde als erstes in C++ eingeführt und war in der ursprünglichen Sprachdefinition von Kernighan und Ritchie noch nicht vorhanden. Deshalb kam auch beispielsweise das "Hello World" Programm in der ersten Auflage von "The C Programming Language" ohne include Anweisung aus. Erst mit der Einführung des ANSI Standards wurden auch in C Prototypen eingeführt.

Inline-Funktionen

Bearbeiten

Neu im C99-Standard sind Inline-Funktionen. Sie werden definiert, indem das Schlüsselwort inline vorangestellt wird. Beispiel:

inline float zylinder_oberflaeche(float h, float r)
{
  float o;
  o = 2 * 3.141 * r * (r + h);
  return(o);
}

Eine Funktion, die als inline definiert ist, soll gemäß dem C-Standard so schnell wie möglich aufgerufen werden. Die genaue Umsetzung ist der Implementierung überlassen. Beispielsweise kann der Funktionsaufruf dadurch beschleunigt werden, dass die Funktion nicht mehr als eigenständiger Code vorliegt, sondern an der Stelle des Funktionsaufrufs eingefügt wird. Dadurch entfällt eine Sprunganweisung in die Funktion und wieder zurück. Allerdings muss der Compiler das Schlüsselwort inline nicht beachten, wenn der Compiler keinen Optimierungsbedarf feststellt. Viele Compiler ignorieren deshalb dieses Schlüsselwort vollständig und setzen auf Heuristiken, wann eine Funktion inline sein sollte.

Globale und lokale Variablen

Bearbeiten

Alle bisherigen Beispielprogramme verwendeten lokale Variablen. Sie wurden am Beginn einer Funktion deklariert und galten nur innerhalb dieser Funktion. Sobald die Funktion verlassen wird verliert sie ihre Gültigkeit. Eine Globale Variable dagegen wird außerhalb einer Funktion deklariert (in der Regel am Anfang des Programms) und behält bis zum Beenden des Programms ihre Gültigkeit und dementsprechend einen Wert.

#include <stdio.h>

int GLOBAL_A = 43;
int GLOBAL_B = 12;

void funktion1( );
void funktion2( );

int main( void )
{
    printf( "Beispiele für lokale und globale Variablen: \n\n" );
    funktion1( );
    funktion2( );
    return 0;
}

void funktion1( )
{
    int lokal_a = 18;
    int lokal_b = 65;
    printf( "\nGlobale Variable A: %i", GLOBAL_A );
    printf( "\nGlobale Variable B: %i", GLOBAL_B );
    printf( "\nLokale Variable a: %i", lokal_a );
    printf( "\nLokale Variable b: %i", lokal_b );
}

void funktion2( )
{
    int lokal_a = 45;
    int lokal_b = 32;
    printf( "\n\nGlobale Variable A: %i", GLOBAL_A );
    printf( "\nGlobale Variable B: %i", GLOBAL_B );
    printf( "\nLokale Variable a: %i", lokal_a );
    printf( "\nLokale Variable b: %i \n", lokal_b );
}

Die Variablen GLOBAL_A und GLOBAL_B sind zu Beginn des Programms und außerhalb der Funktion deklariert worden und gelten deshalb im ganzen Programm. Sie können innerhalb jeder Funktion benutzt werden. Lokale Variablen wie lokal_a und lokal_b dagegen gelten nur innerhalb der Funktion, in der sie deklariert wurden. Sie verlieren außerhalb dieser Funktion ihre Gültigkeit.

Globale Variablen unterscheiden sich in einem weiteren Punkt von den lokalen Variablen: Sie werden automatisch mit dem Wert 0 initialisiert wenn ihnen kein Wert zugewiesen wird. Lokale Variablen dagegen erhalten immer den (zufälligen) Wert, der sich gerade an der vom Compiler reservierten Speicherstelle befindet (Speichermüll). Diesen Umstand macht das folgende Programm deutlich:

#include <stdio.h>

int ZAHL_GLOBAL;

int main( void )
{
    int zahl_lokal;
    printf( "Lokale Variable: %i", zahl_lokal );
    printf( "\nGlobale Variable: %i \n", ZAHL_GLOBAL );
    return 0;
}

Das Ergebnis:

Lokale Variable: 296
Globale Variable: 0

Verdeckung

Bearbeiten

Sind zwei Variablen mit demselben Namen als globale und lokale Variable definiert, wird immer die lokale Variable bevorzugt. Das nächste Beispiel zeigt eine solche "Doppeldeklaration":

#include <stdio.h>

int zahl = 5;
void func( );

int main( void )
{
    int zahl = 3;
    printf( "Ist die Zahl %i als eine lokale oder globale Variable deklariert?", zahl );
    func( );
    return 0;
}

void func( )
{
    printf( "\nGlobale Variable: %i \n", zahl );
}

Neben der globalen Variable zahl wird in der Hauptfunktion main eine weitere Variable mit dem Namen zahl deklariert. Die globale Variable wird durch die lokale verdeckt. Da nun zwei Variablen mit demselben Namen existieren, gibt die printf Anweisung die lokale Variable mit dem Wert 3 aus. Die Funktion func soll lediglich verdeutlichen, dass die globale Variable zahl nicht von der lokalen Variablendeklaration gelöscht oder überschrieben wurde.

Man sollte niemals Variablen durch andere verdecken, da dies das intuitive Verständnis behindert und ein Zugriff auf die globale Variable im Wirkungsbereich der lokalen Variable nicht möglich ist. Gute Compiler können so eingestellt werden, dass sie eine Warnung ausgeben, wenn Variablen verdeckt werden.

Ein weiteres (gültiges) Beispiel für Verdeckung ist

#include <stdio.h>


int main( void )
{
    int i;
    for( i = 0; i<10; i++ )
    {
        int i;
        for( i = 0; i<10; i++ )
        {
            int i;
            for( i = 0; i<10; i++ )
            {
                printf( "i = %d \n", i );
            }
        }
    }
    return 0;
}

Hier werden 3 verschiedene Variablen mit dem Namen i angelegt, aber nur das innerste i ist für das printf von Belang. Dieses Beispiel ist intuitiv schwer verständlich und sollte auch nur ein Negativbeispiel sein.

Mit der Bibliotheksfunktion exit() kann ein Programm an einer beliebigen Stelle beendet werden. In Klammern muss ein Wert übergeben werden, der an die Umgebung - also in der Regel das Betriebssystem - zurückgegeben wird. Der Wert 0 wird dafür verwendet, um zu signalisieren, dass das Programm korrekt beendet wurde. Ist der Wert ungleich 0, so ist es implementierungsabhängig, welche Bedeutung der Rückgabewert hat. Beispiel:

  exit(2);

Beendet das Programm und gibt den Wert 2 an das Betriebssystem zurück. Alternativ dazu können auch die Makros EXIT_SUCCESS und EXIT_FAILURE verwendet werden, um eine erfolgreiche bzw. fehlerhafte Beendigung des Programms zurückzuliefern.

Anmerkung: Unter DOS kann dieser Rückgabewert beispielsweise mittels IF ERRORLEVEL in einer Batchdatei ausgewertet werden, unter Unix/Linux enthält die spezielle Variable $? den Rückgabewert des letzten aufgerufenen Programms. Andere Betriebssysteme haben ähnliche Möglichkeiten; damit sind eigene Miniprogramme möglich, welche bestimmte Begrenzungen (von z.B. Batch- oder anderen Scriptsprachen) umgehen können. Sie sollten daher immer Fehlercodes verwenden, um das Ergebnis auch anderen Programmen zugänglich zu machen.