Einführung in die Programmiersprache C: Funktionen

Mit Funktionen lassen sich Quelltexte besser strukturieren und wiederkehrende Aufgaben können mit wenig Aufwand ausgeführt werden. Zur Syntax der Definition von Funktionen gehören ihre Deklaration (Funktionskopf), die Implementierung und ihr Aufruf aus anderen Funktionen heraus. Diese Bestandteile und wie man Deklaration und Implementierung im Quelltext anordnet, werden erklärt. Mehrere Aufgaben mit ausführlichen Lösungen dienen der Veranschaulichung.

Einordnung des Artikels

Einführung

Im C++-Kurs findet sich unter C++: Strukturierte Programmierung mit Funktionen eine deutlich ausführlichere Erklärung von Funktionen. Hier wird eher versucht, Funktionen exemplarisch mit Hilfe typischer Anwendungen einzuführen – einige mit Funktionen verknüpfte Konzepte werden dabei nicht behandelt.

Der Unterschied zwischen C und C++ beschränkt sich auf drei Elemente, über die man aber leicht hinwegsehen kann:

  1. Die Präprozessor-Direktiven lauten in C++ etwas anders.
  2. Für Konsolen-Ausgaben stehen in C++ andere Funktionen zur Verfügung.
  3. Für die Initialisierung von Variablen von elementarem Datentyp kann in C++ die sogenannte Initialisierungsliste verwendet werden.

Mit Letzterem ist gemeint, dass man in C++ schreiben sollte:

int n {17};

wenn man eine Variable zugleich deklarieren und initialisieren möchte. Der Name Initialisierungsliste rührt daher, dass in geschweiften Klammern eigentlich Listen von Werten stehen und hier eben eine Liste der Länge 1 angegeben wird. In C würde man stattdessen schreiben:

int n = 17;

In diesem Kapitel wird die Syntax der Definition einer Funktion erklärt. Dazu gehört:

Verwendung von Funktionen aus den C-Bibliotheken

Um zu verstehen, wie man

sollte man sich vergegenwärtigen, wie man Funktionen aus den C-Bibliotheken einsetzt.

Bisher sind in den Kapiteln zu C nur wenige derartige Funktionen vorgekommen, etwa:

Wie setzt man derartige Funktionen ein? Und was muss man über sie wissen, um sie einzusetzen? Und was muss man nicht wissen?

Dies soll ein Beispiel verdeutlichen:

#include <stdio.h>
#include <stdlib.h>
#include <math.h>

int main(void) {

    double x = 2.5;
    double y = sqrt(x);
    // ...
    
    return EXIT_SUCCESS;
}

Um die Wurzelfunktion sqrt() syntaktisch und semantisch richtig einzusetzen, muss man eigentlich nur folgende Informationen besitzen:

  1. Welche Eingabewerte erwarten die Funktionen? Genauer: welchen Datentyp müssen die Eingabewerte besitzen?
  2. Welchen Rückgabewert besitzt die Funktion? Genauer: welchen Datentyp besitzt der Rückgabewert?
  3. Was macht die Funktion?

Um die letzte Frage zu beantworten, reicht meist eine kurze Beschreibung, die vollständige Kenntnis der Implementierung der Funktion ist nicht nötig, um sie einzusetzen.

So reicht es zum Beispiel zu wissen, dass die Funktion sqrt()

  1. Als Eingabewert eine Zahl vom Datentyp double erwartet.
  2. Als Rückgabewert eine Zahl vom Datentyp double erzeugt.
  3. Die Quadratwurzel des Eingabewertes berechnet.

Wie die Wurzel berechnet wird, wird den Anwender der Funktion im Normalfall nicht interessieren – er wird sich darauf verlassen, dass die Implementierung keine Fehler enthält.

In der Dokumentation steht dann auch nur der sogenannte Funktionskopf der Wurzelfunktion:

double sqrt( double arg );

Und die Beschreibung:

"Computes square root of arg."

Eine Implementierung wird nicht angegeben – welcher Anwender der Wurzelfunktion hätte auch ein Interesse daran?

Eigene Funktionen deklarieren und implementieren

Die im letzten Abschnitt beschriebene Verwendung von Funktionen aus den C-Bibliotheken sollte als Leitfaden dienen, um eigene Funktionen zu entwickeln. Man wird immer dann Anweisungen zu einer Funktion zusammenfassen, wenn man der Funktion eine klare Aufgabe zuweisen kann. Und damit man sie später syntaktisch richtig einsetzen kann, muss man klären,

  1. welche Eingabewerte und
  2. welchen Rückgabewert

sie besitzt.

Der Funktionskopf – wie er oben für die Wurzelfunktion gezeigt wurde – soll zeigen, wie die Funktion eingesetzt wird. Ein Funktionskopf wird auch als Funktionsprototyp oder die Deklaration der Funktion bezeichnet.

Diese Deklaration sollte im Quelltext dann mit einem Kommentar versehen werden, der kurz die Aufgabe der Funktion beschreibt. Als Faustregel gilt hier:

Lässt sich die Aufgabe der Funktion nicht in einem kurzen, leicht verständlichen Satz beschreiben, sollte man mehrere Aufgaben identifizieren und diese in mehrere Funktionen aufteilen.

Da man später als Anwender der Funktion nicht mehr an der Implementierung der Funktion interessiert ist, werden Deklaration und Implementierung voneinander getrennt. Abbildung 1 versucht die Anordnung von Deklaration, Implementierung und der Plazierung der main()-Funktion zu veranschaulichen.

Bei kleinen Projekten geht man folgendermaßen vor:

  1. Die Deklaration der Funktion steht vor der main()-Funktion.
  2. Die Implementierung steht nach der main()-Funktion.

Bei großen Projekten – mit dann sehr vielen Funktionen – wird dies unübersichtlich, daher werden jetzt 3 Dateien angelegt:

  1. Die Deklarationen der Funktionen erfolgen in den sogenannten Header-Dateien, die die Endung .h besitzen – Sie kennen sie bereits aus den include-Anweisungen.
  2. Die Implementierungen liegen in einer eigenen Datei, deren Name mit der Header-Datei übereinstimmt, die aber die Datei-Endung .c besitzt.
  3. Die main()-Funktion befindet sich in einer eigenen Datei.

Abbildung 1: Links: Bei kleinen Projekten reicht oft eine einzige Datei aus; die Deklarationen werden der main()-Funktion vorangestellt, die Implementierungen erfolgen nach der main()-Funktion. Rechts: Bei großen Projekten wird man mindestens drei Dateien anlegen. Eine Header-Datei für die Deklarationen, eine Datei für die Implementierungen, sowie die Datei, die die main()-Funktion enthält. In Letzterer muss dann die Header-Datei inkludiert werden (nicht die Datei mit den Implementierungen, die vom Compiler automatisch gefunden werden – sofern sie sich im selben Ordner befindet).Abbildung 1: Links: Bei kleinen Projekten reicht oft eine einzige Datei aus; die Deklarationen werden der main()-Funktion vorangestellt, die Implementierungen erfolgen nach der main()-Funktion. Rechts: Bei großen Projekten wird man mindestens drei Dateien anlegen. Eine Header-Datei für die Deklarationen, eine Datei für die Implementierungen, sowie die Datei, die die main()-Funktion enthält. In Letzterer muss dann die Header-Datei inkludiert werden (nicht die Datei mit den Implementierungen, die vom Compiler automatisch gefunden werden – sofern sie sich im selben Ordner befindet).

Hier wird nur die Vorgehensweise wie bei kleinen Projekten simuliert; grob sieht dann ein Quelltext mit

einer Funktion wie folgt aus:

#include <stdio.h>
#include <stdlib.h>

// Deklaration der Funktion

int main(void) {
    
    // Aufruf der Funktion
    
    return EXIT_SUCCESS;
}

// Implementierung der Funktion

Aufgabe: Jede Entwicklungsumgebung bietet eine Ansicht aller Deklarationen von Funktionen, die sich in der aktuell geöffneten Datei befinden. Diese Ansicht wird meist als Outline bezeichnet und liefert auch bei komplexen Projekten einen schnellen Überblick über das Projekt – allerdings nur, wenn man für die Funktionen treffende Namen gewählt hat.

Identifizieren Sie die Outline in Ihrer Entwicklungsumgebung!

Durch Anklicken einer Funktion in der Outline gelangen Sie zu ihrer Implementierung – dies werden Sie häufig beim Verbessern und Weiterentwickeln Ihrer Quelltexte einsetzen.

Die Syntax von Deklaration und Implementierung

Damit sind alle Vorbereitungen getroffen, um endlich die Syntax zu zeigen, wie selbst definierte Funktionen deklariert, implementiert und aufgerufen werden. Das folgende Beispiel zeigt dies am Beispiel der Berechnung des Mittelwertes zweier Zahlen vom Datentyp double. Die Funktion wird mit mean() bezeichnet; ihr Rückgabewert ist wieder eine Zahl vom Datentyp double.

#include <stdio.h>
#include <stdlib.h>

// Mittelwert zweier Zahlen x1 und x2                
double mean(double x1, double x2);

int main(void) {
    
    double x = 3;
    double y = 7;

    double meanValue = mean(x, y); 
    printf("Mittelwert: &f \n", meanValue);
    
    return EXIT_SUCCESS;
}

double mean(double x1, double x2){
    double mean = (x1 + x2) / 2;
    return mean;
}

Die Deklaration einer Funktion

Man erkennt am Beispiel oben, dass die Deklaration 3 Vereinbarungen trifft:

  1. Der Name der Funktion (hier mean), mit dem sie später aufgerufen wird.
  2. Die Liste der Eingabewerte und deren Datentypen.
  3. Der Datentyp des Rückgabewertes, der vor dem Namen der Funktion steht.

Für diese Vereinbarungen sind einige syntaktische Besonderheiten zu beachten (siehe Zeile 5):

  1. Für den Namen gelten die üblichen Regeln, welche Zeichen erlaubt sind. Zudem sollte man beachten, dass ein Unterstrich im Namen vorkommen kann, aber nicht am Anfang stehen sollte (der Compiler erzeugt für den internen Gebrauch derartige Namen und wenn man durch Zufall genau einen dieser Namen einsetzt, kann dies unvorhersehbare Auswirkungen haben). Der Name sollte möglichst aussagekräftig sein, aber nicht zu lange.
  2. Die Einträge in dieser Liste der Eingabewerte werden durch Kommas getrennt. Die Namen der Eingabewerte sind in der Deklaration irrelevant und können auch weggelassen werden. Wenn man ihnen treffende Namen geben kann, die ihre Bedeutung beschreiben, sollte man sie hinschreiben (hier soll x1, x2 andeuten, dass diese beiden Eingabewerte gleichberechtigt sind).
  3. Als Datentypen für Eingabewert und Rückgabewert sind alle elementaren Datentypen erlaubt.
  4. Für den Fall, dass eine Funktion keinen Eingabewert besitzt, bleibt die runde Klammer leer oder man schreibt der Deutlichkeit halber void in die Klammern (ein Beispiel folgt weiter unten).
  5. Hat die Funktion keinen Rückgabewert, muss man den Datentyp void angeben (vor dem Funktionsnamen).
  6. Die Deklaration wird durch einen Strichpunkt abgeschlossen.

Hilfreich für den Leser des Quelltextes ist es, wenn die Deklaration mit einem kurzen Satz als Kommentar versehen ist, der die Aufgabe der Funktion beschreibt. Man kann dies auch so formulieren: Der Kommentar soll kurz beschreiben, was die Funktion macht, nicht wie sie es macht.

Die Implementierung einer Funktion

Die Implementierung der Funktion mean() ist oben in Zeile 18 bis 21 zu sehen. Auch hier sind einige Syntax-Regeln zu beachten:

  1. Der Funktionskopf aus der Implementierung wird wiederholt. Nach den runden Klammern folgt eine geschweifte Klammer, die den Funktionskörper enthält, also diejenigen Anweisungen, die beim Aufruf der Funktion ausgeführt werden.
  2. Die Eingabewerte müssen jetzt Namen besitzen; sie werden als die formalen Parameter der Funktion bezeichnet (später wird es aktuelle Parameter geben, die man hiervon unterscheiden muss).
  3. Innerhalb der Implementierung (also innerhalb der geschweiften Klammern) kann man die formalen Parameter einsetzen als wären sie initialisierte Variable mit dem im Funktionskopf vereinbarten Datentyp (sie werden ja beim Aufruf der Funktion gesetzt - und genau das sind dann die aktuellen Parameter).
  4. Die Implementierung muss mit einem return-statement abschließen. Nach return muss ein Ausdruck folgen, der den Datentyp des Rückgabewertes der Funktion besitzt.

Der Aufruf der Funktion

Im Listing oben geschieht der Aufruf der Funktion mean() innerhalb der Funktion main() in Zeile 12 auf der rechten Seite der Zuweisung. Seine Syntax sollte sich jetzt von selbst erklären:

  1. Die Eingabewerte der Funktion müssen den Datentyp besitzen, der in der Deklaration vereinbart wurde. Diese Eingabewerte im Aufruf sind die aktuellen Parameter, die an die Implementierung weitergereicht werden (siehe Abbildung 2 unten). Die aktuellen Parameter werden auch als aufrufende Parameter bezeichnet.
  2. Da die Funktion einen Rückgabewert besitzt und dessen Datentyp mit der Deklaration festgelegt wurde, kann man den Funktionsaufruf wie eine Variable vom entsprechenden Datentyp einsetzen.

Den Funktionsaufruf kann man sich wie in Abbildung 2 vorstellen:

Abbildung 2: Graphische Darstellung eines Funktionsaufrufes, der mit call by value realisiert ist. Die aktuellen Parameter werden an die Implementierung der Funktion übergeben und anstelle ihrer formalen Parameter eingesetzt. Damit kann ein Rückgabewert berechnet werden (hier vom Datentyp double), der an die aufrufende Instanz übergeben wird.Abbildung 2: Graphische Darstellung eines Funktionsaufrufes, der mit call by value realisiert ist. Die aktuellen Parameter werden an die Implementierung der Funktion übergeben und anstelle ihrer formalen Parameter eingesetzt. Damit kann ein Rückgabewert berechnet werden (hier vom Datentyp double), der an die aufrufende Instanz übergeben wird.

Der Mechanismus, wie beim Aufruf einer Funktion die aktuellen Parameter die formalen Parameter ersetzen und der Rückgabewert an die aufrufende Instanz übergeben wird, wird als call by value bezeichnet. Es wird auch noch den Mechanismus call by reference geben, der erst im Zusammenhang mit Zeigern verständlich ist.

Beispiele für Funktionsköpfe

Bisher wurden nur Beispiele betrachtet, bei denen eine Funktion einen Rückgabewert besitzt. Es gibt auch Funktionen, die keinen Rückgabewert besitzen, also nur Anweisungen ausführen. Früher hat man diese – um sie von Funktionen zu unterscheiden – als Prozeduren bezeichnet. Heute trifft man diese Unterscheidung nicht mehr und sagt stattdessen, eine Prozedur erzeugt einen Rückgabewert vom Datentyp void. Dieser Datentyp muss im Funktionskopf angegeben werden; es ist ein Syntax-Fehler, wenn man void vor dem Funktionsnamen weglässt.

Es gibt auch die Möglichkeit, dass eine Funktion keinen Eingabewert besitzt. Dann kann sowohl in der Deklaration als auch in der Implementierung die runde Klammer leer bleiben oder man schreibt void in die leere Klammer.

Das folgende Listing zeigt einige Sonderfälle für Deklarationen und Implementierungen:

// Deklarationen

// keine Rückgabewert, keine Eingabewerte:
void printHelloWorld(void);

// kein Rückgabewert, ganze Zahl als Eingabewert:
void printInteger(int n);

// ganze Zahl als Rückgabewert, kein Eingabewert:
int millionsOfCalculations(void);

// main()

// Implementierungen:

void printHelloWorld(void){
    puts("HelloWorld");
    return;
}

void printInteger(int n);{
    printf("n = &i \n", n);
    return;
}

int millionsOfCalculations(void){
    return 42;
}

Der Rückgabewert einer Funktion

Bisher wurde mehrmals der Rückgabewert der Funktion genannt, aber seine wichtigsten Eigenschaften sollen nochmals ausdrücklich formuliert werden:

Aufgaben

1. Aufgabe: Wertetabelle einer quadratischen Funktion

Implementieren Sie eine Funktion, die zu drei gegebenen reellen Koeffizienten a, b, c den Funktionswert der quadratischen Funktion

f (x) = a · x2 + b · x + c

an der Stelle x berechnet.

Erzeugen Sie aus der main()-Funktion heraus eine Wertetabelle für die quadratische Funktion mit den Koeffizienten

a = 1, b = 2, c = -15

für die x-Werte:

x = -10, -9, -8, ..., 9, 10.

2. Aufgabe: Berechnung der Wurzel mit dem Heron-Verfahren

In Einführung in die Programmiersprache C: Schleifen mit for und while wurde das Heron-Verfahren als ein einfaches Verfahren zur näherungsweisen Berechnung einer Quadratwurzel vorgestellt. Die wichtigsten Formeln sind in Abbildung 3 zu sehen.

Abbildung 3: Berechnung einer Wurzel mit Hilfe des Heron-Verfahrens.Abbildung 3: Berechnung einer Wurzel mit Hilfe des Heron-Verfahrens.

Implementieren Sie eine Funktion namens heron(), die zu einer gegebenen Schranke eps (mit der die Abbruchbedingung formuliert wird) die Quadratwurzel aus einer reellen Zahl x berechnet. Als Startwert für die Iteration kann x verwendet werden.

Diskutieren Sie, welcher Funktionskopf hier geeignet ist.

Sorgen Sie dafür, dass "unsinnige" Eingabewerte für eps und x mit einer Fehlermeldung beantwortet werden.

3. Aufgabe: näherungsweise Berechnung eines Integrals

Untersumme, Obersumme, Trapezregel

Es gibt mehrere einfache Möglichkeiten, wie ein Integral näherungsweise berechnet werden kann. In Abbildung 4 sind die Untersumme, die Obersumme und die Trapezregel dargestellt. Ihnen ist gemeinsam, dass der Integrationsbereich in N Teilintervalle zerlegt wird und dass die Fläche unter dem Integranden (in den Teilintervallen) durch eine einfache geometrische Figur ersetzt wird (Rechteck beziehungsweise Trapez). Durch den Einsatz einer Schleife kann man das Problem damit auf die Flächenberechnung dieser geometrischen Figur reduzieren. Und ist der Integrand stetig, kann man durch Erhöhung der Anzahl der Teilintervalle immer bessere Näherungswerte des Integrals erreichen.

Abbildung 4: Näherungsweise Berechnung eines Integrals durch die Untersumme, Obersumme und die Trapezregel. Hier wird der Integrationsbereich in 4 Teilintervalle zerlegt.Abbildung 4: Näherungsweise Berechnung eines Integrals durch die Untersumme, Obersumme und die Trapezregel. Hier wird der Integrationsbereich in 4 Teilintervalle zerlegt.

Die Aufgabenstellung

Berechnen Sie die Fläche eines Halbkreises (mit Radius 1), indem Sie eine geeignete Funktion integrieren. Implementieren Sie dazu sowohl die Untersumme, die Obersumme und die Trapezregel. Die 3 Näherungswerte sollen von einer geeigneten Funktion ausgegeben werden.

Berechnen Sie die Näherungswerte für das Integral über obige quadratische Funktion

f (x) = a · x2 + b · x + c, mit a = 1, b = 2, c = -15,

wobei die Integrationsgrenzen die Nullstellen von f(x) sind.

Führen Sie beide Integrationen mit N = 100 und N = 1000 Teilintervallen aus.

Vergleichen Sie Ihre Ergebnisse mit den exakten Werten für die Integrale.

Diskutieren Sie, welche Funktionen man für diese Aufgabenstellung implementieren wird. Dabei soll es möglichst wenig Aufwand erfordern, den Integranden auszutauschen.

Lösungsvorschläge zu den Aufgaben

Lösung zur 1. Aufgabe (quadratische Funktion)

Man implementiert eine Funktion f(), die die drei Parameter a, b, c der Parabel sowie den x-Wert als Eingabewerte besitzt. Alle Eingabewerte und der Rückgabewert haben Datentyp double. (Deklaration in Zeile 5, Implementierung in den Zeilen 18 bis 20).

Die Wertetabelle wird mit Hilfe einer for-Schleife innerhalb der main()-Funktion erzeugt (Zeile 10 bis 12). Die drei Parameter

a = 1, b = 2, c = -15

der quadratischen Funktion f(x) werden sofort an die Funktion f() in printf() übergeben und nicht als Variable gespeichert (Zeile 11).

#include <stdio.h>
#include <stdlib.h>

// f (x) = a x^2 + b x + c
double f(double a, double b, double c, double x);

int main(void) {

    // Wetetabelle für f (x):
    for (int i = -10; i < 11; ++i) {
        printf("x = %i || y = %.1f \n", i, f(1, 2, -15, i));
    }

    return EXIT_SUCCESS;
}

// f (x) = a x^2 + b x + c
double f(double a, double b, double c, double x){
    return a * x * x + b * x + c;
}

Die Wertetabelle lautet:

x = -10 || y = 65.0 
x = -9 || y = 48.0 
x = -8 || y = 33.0 
x = -7 || y = 20.0 
x = -6 || y = 9.0 
x = -5 || y = 0.0 
x = -4 || y = -7.0 
x = -3 || y = -12.0 
x = -2 || y = -15.0 
x = -1 || y = -16.0 
x = 0 || y = -15.0 
x = 1 || y = -12.0 
x = 2 || y = -7.0 
x = 3 || y = 0.0 
x = 4 || y = 9.0 
x = 5 || y = 20.0 
x = 6 || y = 33.0 
x = 7 || y = 48.0 
x = 8 || y = 65.0 
x = 9 || y = 84.0 
x = 10 || y = 105.0

Lösung zur 2. Aufgabe (Heron-Verfahren)

In Einführung in die Programmiersprache C: Schleifen mit for und while wurde das Heron-Verfahren implementiert, allerdings als Folge von Anweisungen, die innerhalb von main() ausgeführt werden. Die eigentliche Aufgabe besteht nun darin, sich zu überlegen:

  1. Wie möchte der Anwender später mit der dort entwickelten Funktionalität umgehen? Und
  2. Wie muss entsprechend der Funktionskopf für eine Funktion – etwa mit Namen heron() – aussehen?

Die erste Frage sollte man sofort konkretisieren:

Die Fragen lassen sich nicht eindeutig beantworten, man kann hier – wie fast immer bei derartigen Problemen – unterschiedliche Design-Entscheidungen treffen; hier ein Vorschlag:

Der Funktionskopf der Funktion heron() lautet somit:

double heron(double x, double eps);

Und ein Vorschlag für die Implementierung, der vorerst nur die eigentlichen Berechnungen zeigt:

double heron(double x, double eps){
    // y: Startwert der Folge x_n
    double y = x;       
    // nächstes Folgenglied
    double z;           
    // Abstand benachbarter Folgenglieder   
    double diff;

    do {
        z = (y + x / y) / 2;            // z = x_n+1 (neues Folgenglied)
        diff = fabs(y - z);          // Differenz |x_n+1 - x_n|
        y = z;                      // y für nächsten Iterationsschritt vorbereiten
    } while (diff > eps);
    return z;
}

Der Aufruf in main() geschieht dann etwa mit (die main()-Funktion ist jetzt nicht mehr gezeigt):

double x = 2;
double root = heron(x, 10e-10);
printf("Wurzel aus %f: %.10f \n", x, root);

Und als Ausgabe erhält man:

Wurzel aus 2.000000: 1.4142135624

Zusätzlich sollte man sich bei derartigen Implementierungen ein weiteres Problem überlegen, das oft als die Prüfung der Eingabewerte bezeichnet wird: Der Anwender der Funktion kann natürlich für die Eingabewerte Zahlen setzen, die dem Problem nicht angemessen sind (etwa negatives x, eine negative Schranke eps oder eine Schranke, die in der Größenordung von x liegt).

Derartige Eingaben sollte "abgefangen" werden, das heißt der Nutzer erhält eine Warn-Meldung, die Berechnung bricht ab und oft wird ein vereinbarter Rückgabewert erzeugt, an dem man erkennt, dass die Funktion ihre eigentliche Aufgabe nicht erfüllen konnte.

Ein Beispiel für die Funktion heron() mit Prüfung der Eingabewerte lautet:

double heron(double x, double eps){
    // Prüfung der Eingabewerte:
    if (x < 0 || eps <= 0 || eps > x) {
        puts("Abbruch der Berechnung, -1 wird zurückgegeben!");
        return -1;
    } else {            // Heron-Verfahren
    
        // y: Startwert der Folge x_n
        double y = x;
        // nächstes Folgenglied
        double z;
        // Abstand benachbarter Folgenglieder
        double diff;

        do {
            z = (y + x / y) / 2;            // z = x_n+1 (neues Folgenglied)
            diff = fabs(y - z);          // Differenz |x_n+1 - x_n|
            y = z;                      // y für nächsten Iterationsschritt vorbereiten
        } while (diff > eps);
        return z;
    }

Wird versucht die Wurzel aus -2 zu berechnen, erhält man die Ausgabe:

Abbruch der Berechnung, -1 wird zurückgegeben!
Wurzel aus -2.000000: -1.0000000000

Man könnte auch die drei Bedingungen in Zeile 3 einzeln abfragen und detailliertere Ausgaben erzeugen.

Lösung zur 3. Aufgabe (näherungsweise Integration)

Zur Berechnung der Fläche des Halbkreises wird eine Funktion f(x) implementiert:

double f(double x){
    return sqrt(1 - x * x);
}

Die Berechnung des Integrals geschieht in eienr Funktion integrate(), die drei Eingabewerte besitzt:

Der Rückgabewert von integrate kann void sein, da die drei zu berechnenden Näherungswerte nur ausgegeben werden. Der Funktionskopf lautet somit:

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

Die Berechnung der Näherungswerte für das Integral erfolgt in einer Schleife über die Teilintervalle (siehe Zeile 40 bis 53), wozu man drei Variablen benötigt, die die Summen der Rechtecks- beziehungsweise Trapezflächen speichern (siehe Zeile 26 bis 28).

Die Anzahl N der Teilintervalle muss mit der Anzahl der Schleifendurchläufe übereinstimmen, da je ein Teilintervall abgearbeitet wird. Die linke Intervallgrenze wird jeweils mit x, die rechte Integrationsgrenze mit y bezeichnet. Für den ersten Durchlauf werden sie vor der Schleife vorbereitet (siehe Zeile 31 bis 33). Nachdem sie in der Schleife verarbeitet wurden, werden sie – immer noch in der Schleife – für den nächsten Durchlauf vorbereitet (siehe Zeile 51 und 52)

Man muss jetzt noch die Formeln aus Abbildung 4 implementieren: Dazu wird für jedes Teilintervall die Funktion f() an der jeweils linken und rechten Intervallgrenze ausgewertet (siehe Zeile 42 und 43) und damit die Fläche für Untersumme, Obersumme und Trapezregel berechnet (siehe Zeile 46 bis 88).

Nach der Schleife werden die Näherungswerte ausgegeben (siehe Zeile 55 bis 57).

#include <stdio.h>
#include <stdlib.h>
#include <math.h>

// Halbkreis mit Radius 1
double f(double x);

// a, b: untere, obere Integrationsgrenze
// N: Anzahl der Intervalle
// N+1: Anzahl der Stützstellen
void integrate(double a, double b, int N);

int main(void) {

    // a = -1, b = 1, N = 1000:
    integrate(-1, 1, 1000);

    return EXIT_SUCCESS;
}

double f(double x){
    return sqrt(1 - x * x);
}

void integrate(double a, double b, int N){
    double sum_min = 0;
    double sum_max = 0;
    double sum_trapez = 0;

    // [x; y] Teilintervall der Länge (b - a) / N
    double delta = (b - a) / N;
    double x = a;
    double y = a + delta;

    //Funktionswerte an den Intervallgrenzen x, y:
    double fx;
    double fy;

    // für jedes Teilintervall wird eine Fläche berechnet -> es muss N Schleifendurchläufe geben!!
    for (int i = 0; i < N; ++i) {
        // Funktionswerte an Intervallgrenzen berechnen:
        fx = f(x);
        fy = f(y);

        // Flächen zu Teilintervall berechnen
        sum_min = sum_min + delta * fmin(fx, fy);
        sum_max = sum_max + delta * fmax(fx, fy);
        sum_trapez = sum_trapez + delta * ( fx + fy ) / 2;

        // x, y für nächsten Schritt vorbereiten
        x = y;
        y = a + (i+1) * delta;
    }

    printf("Untersumme: %f \n", sum_min);
    printf("Obersumme: %f \n", sum_max);
    printf("Trapezregel: %f \n", sum_trapez);
}

Der Aufruf der Funktion integrate() erfolgt innerhalb von main() in Zeile 16. Hier wird die Fläche des Halbkreises mit 1000 Teilintervallen berechnet, als Ausgaben erhält man:

Untersumme: 1.568870 
Obersumme: 1.572744 
Trapezregel: 1.570807

Der exakte Wert is π / 2 ≈ 1.570796.

Aufgaben:

1. In Zeile 52 wird die rechte Intervallgrenze y neu berechnet. Dies könnte auch mit

y += delta;

erfolgen.

Testen Sie diese Variante und versuchen Sie das Verhalten zu erklären.

2. Erklären Sie, warum die Variablen fx und fy vor der Schleife deklariert, aber erst in der Schleife initialisiert werden. Und warum werden sie überhaupt eingeführt – die Funktionswerte könnte man auch immer dort berechnen, wo sie benötigt werden, also in den Zeilen 46 bis 48?

♦ ♦ ♦

Um die quadratische Funktion zu integrieren, kann der Großteil des bestehenden Programms übernommen werden. Insbesondere die Funktion integrate() "weiß" nicht, welche Funktion eigentlich integriert wird. Daher reicht es,

  1. die Implementierung des Integranden zu ersetzen und
  2. den Aufruf von integrate() anzupassen.

Aus der ersten Aufgabe wird die quadratische Funktion übernommen, aber jetzt unter einem anderem Namen, etwa g(). In der Implementierung von f() wird diese Funktion g() mit den geeigneten Parametern a, b, c aufgerufen. Die Implementierungen von f() und g() lauten somit:

// g (x) = a x^2 + b x + c
double g(double a, double b, double c, double x){
    return a * x * x + b * x + c;
}

// a = 1, b = 2, c = -15
double f(double x){
    return g(1, 2, -15, x);

Die Nullstellen der quadratischen Funktion liegen bei x = -5 und x = 3, daher wird jetzt integrate() mit diesen Grenzen aufgerufen:

integrate(-5, 3, 1000);

Als Ausgabe erhält man:

Untersumme: -85.461248 
Obersumme: -85.205759 
Trapezregel: -85.333504

Der exakte Wert des Integrals ist -85.333...