Weitere Anwendungen von C-Zeigern: Der Zusammenhang von Feldern, Zeigern und Funktionen

Mit Hilfe von Zeigern kann man Funktionen realisieren, die Felder als Eingabewert beziehungsweise als Rückgabewert besitzen. Es ist sogar möglich, Zeiger auf Funktionen zu setzen und damit Funktionen als Eingabewerte anderer Funktionen einzusetzen.

Einordnung des Artikels

In diesem Kapitel werden die Kenntnisse über Felder, Zeiger und Funktionen zusammengeführt. Zahlreiche Aufgaben zu den hier vorgestellten Konzepten finden sich in Programmier-Aufgaben in C: Anwendungen von Feldern, Zeigern und Funktionen.

Einführung

So wie Funktionen bisher beschrieben wurden, gilt für sie:

In diesem Kapitel wird gezeigt, wie man Zeiger einsetzen kann, um Funktionen zu realisieren, die Felder als Eingabewert beziehungsweise Rückgabewert besitzen.

Zudem ist es möglich, Zeiger auf Funktionen zu setzen und diesen Zeiger an eine weitere Funktion zu übergeben. Das heißt auch eine Funktion kann Eingabewert einer anderen Funktion sein; Letztere wird dann meist als Funktion höherer Ordnung bezeichnet.

Die Beispiele in diesem Kapitel sind sehr einfach gehalten, um den Blick allein auf diese neuen Konzepte zu richten. Zahlreiche praktische Anwendungen werden in Programmier-Aufgaben in C: Anwendungen von Feldern, Zeigern und Funktionen vorgestellt.

Ein Feld als Eingabewert einer Funktion

Ein erstes Beispiel zur Einführung

Das folgende Beispiel zeigt den naiven Zugang, wie man eine Funktion implementieren wird, die als Eingabewert ein Feld (array) erhält (Deklaration in Zeile 1, Implementierung in Zeile 3 bis 6):

void testArray(int a []);

void testArray(int a[]){
    printf("size: %d \n", sizeof(a));       // Compiler-Warnung beachten!
    printf("length: %d \n", sizeof(a) / sizeof(int));       // Compiler-Warnung beachten!
}

Zeile 1: Um einen ersten Test auszuführen, wird eine möglichst einfache Funktion testArray() deklariert; sie erhält ein int-Feld als Eingabewert, aber hat keinen Rückgabewert.

Zeile 3 bis 6: In der Implementierung steht lediglich, dass mit Hilfe von sizeof() berechnet wird, wie viele Bytes das int-Feld belegt und welche Länge es hat.

Zum Testen wird in main() ein Feld mit 5 Komponenten angelegt und testArray() aufgerufen:

int a [] = {0, 2, 4, 6, 8};
testArray(a);

Die Ausgaben lauten:

size: 4 
length: 1

Man erhält andere Ausgaben, wenn die beiden printf()-Anweisungen in main() nach der Initialisierung von a aufgerufen werden:

int a [] = {0, 2, 4, 6, 8};
printf("size: %d \n", sizeof(a));
printf("length: %d \n", sizeof(a) / sizeof(int));

Jetzt erhält man die Ausgaben:

size: 20 
length: 5

Dieser Widerspruch klärt sich sofort auf, wenn man bedenkt:

  1. Der Name eines Feldes steht für einen Zeiger auf dessen erste Komponente (die erste Komponente besitzt den Index 0).
  2. Die Funktion sizeof() berechnet den vom Feld belegten Speicherplatz nur im Gültigkeitsbereich des Feldes. Bei der Übergabe des Feldes an eine Funktion wird nur ein Zeiger auf die erste Komponente übergeben und sizeof() kann nicht mehr auf das gesamte Feld zugreifen.

Man kann dies auch so formulieren:

Der Mechanismus call by value, mit dem bei einem Funktionsaufruf die aktuellen Parameter kopiert und an die Implementierung der Funktion übergeben werden, arbeitet ausschließlich bei elementaren Datentypen. Ist ein Feld aktueller Parameter, wird der Name des Feldes als Zeiger auf die erste Komponente interpretiert und diese Adresse wird an die Implementierung der Funktion übergeben.

Somit ist obige Deklaration von testArray() äquivalent zu folgender Deklaration:

void testArray(int * a);

Diese Deklaration ist sogar vorzuziehen, denn jetzt ist ausdrücklich erkennbar, dass ein Zeiger übergeben wird; der Aufruf testArray(a); kann nicht das gesamte Feld an die Implementierung von testArray() weiterreichen.

Wie soll man ein Feld an eine Funktion übergeben?

Aus dem Beispiel des letzten Unterabschnittes kann man sofort folgern, wie man ein Feld an eine Funktion f() übergeben sollte:

  1. Mit dem Namen des Feldes wird der Zeiger auf die erste Komponente übergeben (Komponente mit Index 0). Im Argument von f() benötigt man daher einen Zeiger auf den Datentyp des Feldes (oben war es int * a ).
  2. Um innerhalb der Funktion auf die Komponenten des Feldes zuzugreifen, kann man die Zeigerarithmetik einsetzen: Mit a + i kann man auf die (i-1)-te Komponente von a zugreifen. Aber innerhalb von f() lässt sich mit Hilfe von sizeof() nicht mehr feststellen, wie viele Komponenten a hat. Daher sollte man als weiteren Eingabewert die Länge des Feldes an f() übergeben. Und diese Länge muss dort festgestellt werden, wo man noch mit sizeof() auf das gesamte Feld zugreifen kann.

Beispiel: Ausgabe der Komponenten eines Feldes

Es soll eine Funktion printArray() implementiert werden, die die Komponenten eines Feldes in einer Zeile ausgibt und dabei ein selbst zu bestimmendes Zeichen sep zwischen die Komponenten setzt. Nach obigen Ausführungen lauten der Funktionskopf und die Implementierung etwa:

// Ausgabe der Komponenten eines Feldes a mit Trennungszeichen sep zwischen den Komponenten:
void printArray(int * a, int length, char sep);

void printArray(int * a, int length, char sep){
    for(int i = 0;  i < length; ++i) {
        printf("%d %c", a[i], sep);
    }
    puts("\n");
}

Ein Aufruf der Funktion könnte dann wie folgt geschehen (mit einem Leerzeichen als Trennungszeichen):

int a [] = {0, 2, 4, 6, 8};
printArray(a, sizeof(a) / sizeof(int), ' ');
// 0  2  4  6  8

Die drei aktuellen Parameter im Funktionsaufruf (Zeile 2) sind somit:

  1. Der Name a des Feldes, also der Zeiger auf die erste Komponente von a.
  2. Die Länge des Feldes, die man hier mit Hilfe von sizeof(a) / sizeof(int) berechnen kann.
  3. Das Leerzeichen ' ' als Trennungszeichen.

Beispiel: Berechnung eines gewichteten Mittelwertes

Befinden sich n Massen m1, m2, ..., mn an Positionen x1, x2, ..., xn, so kann man nach der Koordinate x des Schwerpunktes dieser Massenverteilung fragen. Zur Berechnung von x werden

x = (x1 · m1 + ... + xn · mn) / (m1 + ... + mn).

Damit man dies fehlerfrei und eindeutig durchführen kann, müssen einige Bedingungen erfüllt sein:

  1. Es müssen genau so viele Koordinaten wie Massen vorliegen.
  2. Die Massen können nicht negativ sein.
  3. Die Summe der Massen muss positiv sein.

Dies fällt dann wieder unter Prüfung der Eingabewerte (siehe Aufgabe unten).

Soll dieser gewichtete Mittelwert mit Hilfe einer Funktion weightedMean() berechnet werden, so lautet die relevante Frage: Welcher Funktionskopf ist für diese Funktion zu wählen?

Die Felder der Koordinaten und Massen sind sicher Zahlen mit Nachkommastellen, also ist double (oder float) der geeignete Datentyp; der Rückgabewert, die Schwerpunktskoordinate, ist dann auch vom Datentyp double. Die Felder der Koordinaten und Massen werden wie im einführenden Beispiel oben übergeben: als Zeiger auf die erste Komponente und einem int-Wert, der die Länge der Felder angibt:

double weightedMean(double * x, int length_x, double * m, int length_m);

Die Implementierung ist – bis auf die Prüfung der Eingabewerte – sehr einfach:

double weightedMean(double * x, int length_x, double * m, int length_m){

    // TODO: Prüfung der Eingabewerte
    
    double weightedSum = 0;
    double totalMass = 0;
    
    for(int i = 0;  i < length_x; ++i) {
        weightedSum += x[i] * m[i];
        totalMass += m[i];
    }
    
    return weightedSum / totalMass;
}

Ein Aufruf der Funktion weightedMean() könnte wie folgt aussehen:

double coords [] = {0, 2, 4, 6, 8};
double ms [] = {1, 1, 3, 1, 3};

double centerOfMass = weightedMean(coords, 5, ms, 5);

Aufgabe: Implementieren Sie die Prüfung der Eingabewerte. Falls eine der oben genannten Bedingungen nicht erfüllt ist, soll eine entsprechende Fehlermeldung ausgegeben werden und der Rückgabewert ist gleich null.

Ein Feld als Rückgabewert einer Funktion

Übergabe des Feldes mit call by reference

Ein Feld kann nicht im Sinne von call by value der Eingabewert einer Funktion sein (das gesamte Feld kann nicht an die Funktion weitergereicht werden); dies ist nur bei elementaren Datentypen möglich. Ebenso kann ein komplettes Feld nicht als Rückgabewert an die aufrufende Einheit zurückkopiert werden. Es ist also nicht möglich eine Funktion zu deklarieren, die ein Feld als Rückgabewert besitzt.

Wie oben gezeigt wurde, kann man zwar einen Funktionskopf

void f(int a [])

angeben, beim Aufruf von f(a) wird aber lediglich der Zeiger auf die erste Komponente von a an f() übergeben.

Und berechnet eine Funktion ein Feld a und besitzt sie das return-statement

return a;

so wird damit ebenfalls nicht das gesamte Feld, sondern der Zeiger auf die erste Komponente zurückgegeben.

Um eine Funktion mit einem Feld als Rückgabewert zu realisieren, muss man also nach einer anderen Lösung suchen. In Definition und einfache Anwendungen von C-Zeigern wurde bereits diskutiert, wie man Funktionen mit mehreren Rückgabewerten simuliert: dazu wird der Mechanismus call by reference verwendet. Damit ist gemeint, dass man vor dem Aufruf einer Funktion die Variablen für die Rückgabewerte deklariert, ihre Adressen an die Funktion übergibt und in der Funktion ihre Werte berechnet. Innerhalb der Funktion können dadurch Variablen gesetzt oder verändert werden, die sich eigentlich in einem anderen Gültigkeitsbereich befinden.

Diesen Mechanismus kann man auch für ein Feld einsetzen: das Feld, das eigentlich zurückgegeben werden soll, wird durch den Zeiger auf die erste Komponente und die Länge charakterisiert. Übergibt man dies an die Funktion, kann man innerhalb der Funktion alle Komponenten des Feldes setzen. Der folgende Unterabschnitt soll ein einfaches Beispiel zeigen.

Beispiel: Berechnung der Quadrate eines Feldes

Es soll eine Funktion square() implementiert werden, die zu einem gegebenen int-Feld die Quadrate der Komponenten berechnet. Da beide Felder identische Länge haben, muss man der Funktion square() die zwei Adressen und eine Länge übergeben. Die Komponenten der Felder werden hier als int angenommen; der Funktionskopf lautet dann:

void square(int * x, int * y, int length);

Dabei soll x für die Eingabewerte und y für die "Rückgabewerte" stehen. Einen echten Rückgabewert gibt es hier nicht, daher ist der Datentyp des Rückgabewertes gleich void.

Die Implementierung iteriert jetzt lediglich über die Komponenten der Felder und berechnet jeweils das Quadrat von x[i] :

void square(int * x, int * y, int length){
    for (int i = 0; i < length; ++i) {
        y[i] = x[i] * x[i];
    }
    return;
}

Beim Aufruf der Funktion square() ist zu beachten, dass man das Feld y für die Quadrate mit gleicher Länge wie das Feld der x-Werte anlegt. Der Aufruf könnte dann lauten; zur Ausgabe wird die Funktion printArray() von oben eingesetzt:

int a[] = {1, 3, 5, 7, 9};
int b[5];

square(a, b, 5);
printArray(b, 5, ' ');
// 1  9  25  49  81

Zeiger auf Funktionen: Die Grundlage der funktionalen Programmierung

Ein erstes Beispiel: Zeiger auf eine Funktion

Für eine Variable x eines elementaren Datentyps ist es möglich, die Variable direkt über ihren Namen x anzusprechen oder einen Zeiger p auf x zu definieren und mit diesem den Wert von x abzufragen oder zu ändern. Dies wurde bei der Definition von Zeigern diskutiert; das folgende Beispiel soll dies nochmals zeigen:

// Variable:
double x = 5;

// Zeiger auf x:
double * p = & x;

// Abfragen von x mit Zeiger:
printf("x = %f\n", * p);            // x = 5

// Verändern von x über Zeiger:
* p = 17;
printf("x = %f\n", x);          // x = 17

Ganz ähnlich kann man den Aufruf einer Funktion über einen Zeiger realisieren.

Das folgende Beispiel soll die Syntax zeigen, wie man:

Die Funktion f() mit dem Funktionskopf double f(double x); berechnet das Quadrat eines eingegebenen Wertes x (Deklaration in Zeile 1, Implementierung in Zeile 16 bis 18).

double f(double x);

int main(void) {

    // Zeiger auf f:
    double (* pf) (double) = f;
    
    // Funktionsaufruf mit x = 5:
    double y = pf(5);
    
    printf("y = %f\n", y);          // y = 17
    
    return EXIT_SUCCESS;
}

double f(double x){
    return x * x;
}

Der Zeiger auf die Funktion erhält den Namen pf (Zeile 6). Entscheidend in der Definition des Zeigers ist, dass man den Funktionskopf der Funktion nachbildet, auf die er zeigen soll:

  1. Die Funktion f() besitzt als Eingabewert double und daher steht nach dem Namen des Zeigers double in runden Klammern (wie der Eingabewert einer Funktion).
  2. Der Rückgabewert von f() ist double und daher steht vor dem Zeiger der Datentyp double.
  3. Initialisiert wird der Zeiger mit dem Namen der Funktion, auf die er zeigen soll. Dies ist ähnlich wie bei Feldern: es ist nicht nötig, einen Adressoperator anzugeben, da der Name der Funktion bereits ein Zeiger ist.

Zusätzlich muss (* pf) in runde Klammern eingeschlossen werden. Lässt man die runden Klammern weg, erhält man einen Syntax-Fehler (es wird versucht einen Zeiger auf eine Variable zu definieren).

Zeile 9: Der Zeiger pf kann jetzt wie die Funktion f() eingesetzt werden; das Argument wird in runde Klammern geschrieben. Zeile 9 ist somit gleichwertig zu double y = f(5);

Es soll ausdrücklich betont werden: Zeiger auf Funktionen wurden nicht eingeführt, um Quelltexte wie im letzten Beispiel zu ermöglichen – die Definition des Zeigers ist hier überflüssig, da man die Berechnung von y auch mit der Funktion f() erledigen kann. Interessant wird das Beispiel erst, wenn man einen Schritt weitergeht und den Zeiger auf eine Funktion an eine Funktion übergibt – dies wird im nächsten Unterabschnitt gezeigt.

Übergabe eines Funktions-Zeigers an eine Funktion

Oft kann man beim Programmieren noch nicht entscheiden, welche Funktion aufgerufen werden soll, da erst währen der Laufzeit des Programms entschieden wird, welche Funktion benötigt wird. Um dies zu realisieren, muss man die Möglichkeit haben, einer Funktion eine weitere Funktion als Eingabewert zu übergeben – der Eingabewert ist natürlich ein Zeiger auf die Funktion. Welche Funktion mit dem Zeiger angesprochen wird kann etwa zuvor in einer if-else-Anweisung entschieden werden.

1. Beispiel: Näherungsweise Integration

In Einführung in die Programmiersprache C: Funktionen wurde eine Funktion integrate() entwickelt, die die Integrationsgrenzen a und b sowie die Anzahl der Teilintervalle N als Eingabewerte erhält und daraus drei Näherungswerte für ein Integral berechnet (Untersumme, Obersumme und Trapezregel). Der Funktionskopf lautete:

void integrate(double a, double b, int N);

Die Implementierung von integrate() hatte einen großen Nachteil: Der Name der zu integrierende Funktion f() ist im Quelltext festgeschrieben. Mit anderen Worten: Möchte man eine andere Funktion integrieren, hat man 2 Alternativen zur Auswahl, die beide nicht dem "Geist des Programmierens" entsprechen:

  1. Entweder man ändert die Implementierung des Integranden f() ab.
  2. Oder man verändert alle Stellen in der Implementierung von integrate(), an denen f() aufgerufen wird durch eine andere Funktion g() und implementiert anschließend diese Funktion g().

Dem Problem angemessen ist dagegen: Die Funktion integrate() erhält als Eingabewert eine beliebige Funktion, die lediglich den geeigneten Funktionskopf haben muss; falls eine Implementierung dieser Funktion vorliegt, kann auch das näherungsweise Integral berechnet werden.

2. Beispiel: Berechnung einer Wertetabelle

Der Quelltext, der zeigt wie man die Übergabe einer Funktion an eine andere Funktion realisiert, soll an einem einfacheren Beispiel demonstriert werden. Dazu sind zwei Funktionen f() und g() implementiert, die beide einen double-Wert als Eingabewert und double als Rückgabewert besitzen; die Funktionsköpfe sind somit identisch:

double f(double x);
double g(double x);

Dabei soll gelten:

f(x) = x2 und g(x) = x3.

Ihre Implementierungen lauten also:

// Quadrat-Fkt x^2:
double f(double x){
    return x * x;
}

// x^3:
double g(double x){
    return x * x * x;
}

Jetzt soll die Funktion entwickelt werden, die eine Wertetabelle für eine der beiden Funktionen ausgibt. Dazu implementiert man zunächst eine Funktion tableOfValues(), die dies für eine fest vorgegebene Funktion erledigt (wie oben die Funktion integrate()).

Eingabewerte sind die x-Werte a und b, zwischen denen alle Funktionswerte berechnet werden sollen; dazu wird angenommen, dass a und b ganzzahlig sind und die Funktionswerte für

a, a + 1, a + 2, ..., b - 1, b

ausgegeben werden sollen. Und da weder die Wertetabelle noch die Funktionswerte als eigenes Objekt zurückgegeben werden, ist der Datentyp des Rückgabewertes gleich void. Der Funktionskopf lautet somit:

void tableOfValues(int a, int b);

Eine mögliche Implementierung lautet:

void tableOfValues(int a, int b){

    if (a > b) {
        int tmp = b;
        b = a;
        a = tmp;
    }

    for (int i = a; i <= b; ++i) {
        printf("x = %d || y = %f \n", i, f(i));
    }
    return;
}

Zeile 3 bis 7: Falls die Grenzen a und b falsch herum eingegeben wurden, werden sie vertauscht, damit die Wertetabelle mit dem kleineren x-Wert beginnt.

Zeile 10: Die entscheidende Stelle für die Diskussion dieses Abschnittes ist der Aufruf der Funktion f(). Sie muss irgendwo implementiert sein und ist in dieser Version dynamisch nicht veränderbar – dynamisch heißt hier während der Laufzeit des Programms.

Diese Funktion tableOfValues() muss jetzt so umgeschrieben werden, dass man die Funktion, die in Zeile 10 aufruft, als Eingabewert an tableOfValues() übergibt. Dazu wird ein Zeiger auf eine Funktion fun gesetzt; sie muss den Funktionskopf wie die Funktionen f() und g() von oben besitzen. Der Funktionskopf für tableOfValues() muss somit lauten:

void tableOfValues(double (* fun) (double), int a, int b);

Der Zeiger bildet somit den Funktionskopf der Funktion nach, die an tableOfValues() übergeben werden soll.

Und die Implementierung:

void tableOfValues(double (* fun) (double), int a, int b){

    if (a > b) {
        int tmp = b;
        b = a;
        a = tmp;
    }

    for (int i = a; i <= b; ++i) {
        printf("x = %d || y = %f \n", i, fun(i));
    }
    return;
}

Man erkennt, dass sich an der Implementierung nichts ändert: Der Aufruf der Funktion fun() erfolgt wie in der ersten Version oben (jeweils Zeile 10).

Der Aufruf von tableOfValues() könnte dann wie folgt aussehen; mit Hilfe der Variable z soll der dynamische Aufruf von f() beziehungsweise g() simuliert werden:

// z gegeben
if (z > 0) {
    tableOfValues(f, -5, 5);
} else {
    tableOfValues(g, -5, 5);
}

Für positives z ergibt sich folgende Ausgabe:

x = -5 || y = 25.000000 
x = -4 || y = 16.000000 
x = -3 || y = 9.000000 
x = -2 || y = 4.000000 
x = -1 || y = 1.000000 
x = 0 || y = 0.000000 
x = 1 || y = 1.000000 
x = 2 || y = 4.000000 
x = 3 || y = 9.000000 
x = 4 || y = 16.000000 
x = 5 || y = 25.000000

Funktionsköpfe für Funktionen höherer Ordnung

Zuletzt wurde ein Beispiel gezeigt, in dem die Funktion tableOfValues() als Eingabewert einen Zeiger auf eine Funktion fun erhält, wobei alle Funktionen f() mit dem folgenden Funktionskopf zulässig sind:

double f(double);

Das Argument in tableOfValues(), das den Zeiger aufnimmt, muss diesen Funktionskopf nachbilden:

void tableOfValues(double (* fun) (double), int a, int b);

Das folgende Listing zeigt die verschiedenen Möglichkeiten für Funktionen f() und Funktionen höherer Ordnung F() – der Rückgabewert von F() ist hier irrelevant und wird immer gleich void gesetzt:

// f(): kein Rückgabewert, kein Eingabewert:
void f(void);
void F(void (*fun) (void));

// f(): kein Rückgabewert, Eingabewert double:
void f(double x);
void F(void (*fun) (double));

// f(): Rückgabewert double, kein Eingabewert:
double f(void);
void F(double (*fun) (void));

// f(): Rückgabewert double, zwei Eingabewerte (jeweils double):
double f(double x, double y);
void F(double (*fun) (double, double));

Anstelle von double könnte natürlich jeder andere elementare Datentyp stehen.