C++: Strukturierte Programmierung mit Funktionen

Strukturierte Programmierung heißt vor allem, dass man wiederkehrende Quelltexte zu Unterprogrammen (Funktionen) zusammenfasst. Dieser Abschnitt zeigt, wie Funktionen in C++ realisiert werden (genauer: wie sie definiert und aufgerufen werden). Die Quelltexte lassen sich besser strukturieren, wenn man die Definition einer Funktion in Deklaration und Implementierung aufteilt. Weiter wird das Überladen von Funktionen besprochen.

Einordnung des Artikels

Einführung

Bisher wurden Algorithmen im Sinne der prozeduralen Programmierung geschrieben: man schreibt eine Folge von Anweisungen, die von oben nach unten ausgeführt werden, wobei die Strukturelemente Bedingungsprüfung, Alternative und Schleifen für einen verwickelten Ablauf sorgen können.

Es sollte klar sein, dass die prozedurale Programmierung bei kleinen Programmen (solange sie etwa nur einige Bildschirme füllen) sehr effektiv eingesetzt werden kann. Bei größeren Vorhaben stößt diese Art der Programmierung schnell an ihre Grenzen:

Mit dem Grundsatz teile und herrsche lassen sich diese Nachteile zwar nicht vollständig vermeiden, man kann aber bei deutlich komplexeren Programmen den Überblick behalten. Mit teile und herrsche ist gemeint, dass man abgegrenzte Teile des Algorithmus in Funktionen auslagert. Dies setzt allerdings voraus, dass diese Funktionen möglichst keine Abhängigkeit zum restlichen Algorithmus besitzen, so dass sie in verschiedenen Situationen eingesetzt werden können. Man spricht dann nicht mehr von prozeduraler Programmierung sondern von strukturierter Programmierung. Klug definierte Funktionen vermeiden Wiederholungen im Quelltext, können wiederverwedet werden und führen somit zu einer Modularisierung des Programmes.

Mit den mathematischen Funktionen aus der Standard-Bibliothek cmath haben sie Paradebeispiele für solche Funktionen kennengelernt. Dieser Abschnitt soll in diese Technik einführen, Sie werden lernen:

Tip:

Jede Funktion sollte eine und nur eine, klar definierte Aufgabe erfüllen.

Man kann die Sichtweise auch umkehren:

Beim Entwickeln eines Programmes muss man sich immer überlegen, welche Aufgaben mehrfach vorkommen und sinnvoll in Funktionen verlegt werden können.

Trennung von Deklaration und Implementierung einer Funktion

Die folgenden Programmzeilen berechnen aus zwei gegebenen Gleitkommazahlen deren Mittelwert:

double x;
double y;
// x und y werden irgendwie gesetzt

double mean = (x + y) / 2;

Wird diese Mittelwert-Berechnung öfters verwendet, bietet es sich an, für diese Aufgabe eine Funktion zu definieren; sie soll den Namen mean() erhalten.

Ähnlich wie Variablen deklariert und initialisiert werden, wird eine Funktion deklariert und implementiert. Die Deklaration benötigt der Compiler, um die Aufrufe der Funktion auf Korrektheit zu prüfen. Die Implementierung wird dann erst bei der Ausführung des Programmes benötigt, wenn die Funktion tatsächlich aufgerufen wird und die Berechnungen ausgeführt werden müssen.

Die Deklaration kann sogar weggelassen werden, da der Compiler auch die Implementierung auswerten kann; ihre Anwesenheit sorgt aber für eine bessere Lesbarkeit des Quelltextes. Lässt man die Deklaration weg, muss die Implementierung vor dem Aufruf der Funktion stehen. Üblich ist es die Deklaration möglichst weit oben im Programm zu plazieren (vor main() und die Implementierung nach main()). Zudem sollte die Deklaration mit einer kurzen Beschreibung der Aufgabe der Funktion versehen werden.

Diese Anordnung ist naheliegend: dem Leser des Programms sollte die Deklaration der Funktion ausreichen, um sie sinnvoll einzusetzen; die Implementierung wird er nur in Zweifelsfällen aufsuchen.

Hier das Beispiel, in dem die Mittelwert-Berechnung mit Hilfe von mean() ausgeführt wird. Das Beispiel enthält:

#include <iostream>
using namespace std;

// mean() berechnet den Mittelwert zweier Zahlen x1 und x2                // Kommentar zur Beschreibung der Aufgabe der Funktion mean()
double mean(double x1, double x2);                                      // Deklaration der Funktion mean()

int main()
{
    double x {3};
    double y {7};

    double meanValue = mean(x, y);                          // Aufruf der Funktion mean() mit zwei Variablen
    cout << "Mittelwert: " << meanValue << endl;

    meanValue = mean(1, 7);                                 // Aufruf der Funktion mean() mit zwei Zahlen
    cout << "Mittelwert: " << meanValue << endl;
    return 0;
}

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

Zur Erklärung:

  1. Die Deklaration (Zeile 5) besteht aus drei Teilen:
    • Der Name der Funktion wird vereinbart (hier mean()).
    • Die Parameter-Liste muss angegeben werden, also die Eingabewerte der Funktion: hier sind es zwei Gleitkommazahlen vom Datentyp double. Die Namen der Parameter sind unerheblich und können sogar weggelassen werden. Nicht weggelassen werden dürfen die Datentypen der Eingabewerte. Oftmals können die Namen der Parameter aber sinnvoll gewählt werden, da sie auf deren Bedeutung hinweisen. Hier wurden sie x1 und x2 genannt, was darauf hindeuten soll, dass sie austauschbar sind (ohne den berechneten Wert zu verändern). Die Einträge der Parameter-Liste sind durch Kommas getrennt.
    • Der Datentyp des Rückgabewertes (dieser steht vor dem Namen der Funktion): Die Funktion mean() berechnet einen Wert vom Datentyp double. Das heißt beim Aufruf der Funktion mean() entsteht ein Zahlenwert vom Datentyp double, der in einen Befehl eingebaut werden kann (siehe Aufruf der Funktion).
  2. Die Implementierung der Funktion mean() (in den Zeilen 20 bis 24) muss natürlich in der ersten Zeile mit der Deklaration übereinstimmen (Parameter-Liste und Datentyp des Rückgabewertes). In den geschweiften Klammern stehen dann die Anweisungen, die zur Berechnung des Rückgabewertes führen. Die Implementierung endet mit dem return-statement (Zeile 23). Die Namen der Parameter sind zwar frei wählbar, dürfen in der Implementierung aber nicht weggelassen werden, da mit ihnen im Körper der Funktion Berechnungen ausgeführt werden.
  3. Der Aufruf der Funktion mean() (in den Zeilen 12 und 15) erfolgt einmal mit Variablen und einmal mit Zahlen; bei diese Berechnungen sind natürlich die Datentypen der Deklaration von mean() zu beachten (beziehungsweise die Typumwandlungen). Der Aufruf von mean() erzeugt stets eine Gleitkommazahl (double), die hier in einer Zuweisung verwendet wird.

Tip:

Ordnen Sie Kommentar (zur Beschreibung der Aufgabe einer Funktion), Deklaration und Implementierung immer so an wie im Beispiel oben.

Spezialfälle

In der strukturierten Programmierung hat man anfangs streng zwischen Prozedur und Funktion unterschieden (siehe Prozeduren und Funktionen in der Übersicht über Programmiersprachen), heute gibt man diese Trennung meist auf. Die folgenden Spezialfälle sollte man aber kennen.

Rückgabetyp void

Eine Funktion kann auch keinen Rückgabewert berechnen und stattdessen nur Aktionen ausführen (etwa Konsolen-Ausgaben). Meistens brauchen diese Funktionen kein return-statement — sie können sogar mehrere haben wie die folgenden Beispiele zeigen.

Beispiele:

1. Die Funktion printFactors(int n) soll die Teiler der Zahl n ausgeben:

#include <iostream>
using namespace std;

// Ausgabe der Teiler einer natürlichen Zahl
void printFactors(int n);

int main()
{
    printFactors(27);           // 1 3 9 27
    return 0;
}

void printFactors(int n)
{
    cout << 1 << endl;
    for (int i {2}; i < 1 + n / 2; i++)
    {
        if (n % i == 0)
            cout << i << endl;
    }
    cout << n << endl;
}

Die Funktion printFactors(int n) kommt ohne return-statement aus und liefert dennoch für n > 2 die richtigen Ausgaben. Ausnahme ist der Aufruf

printFactors(1);

Jetzt wird der Teiler 1 zweimal ausgegeben.

Und noch schlimmer:

printFactors(0);

liefert die Ausgabe 1 und 0. Um dies zu vermeiden, wird die Funktion mit mehreren return-statements implementiert.

2. Neue Version von printFactors(int n) (hier ist nur ihre Implementierung gezeigt, die Deklaration bleibt unverändert):

void printFactors(int n)
{
    if (n == 0)
        return;
    if (n == 1)
    {
        cout << 1 << endl;
        return;
    }
    cout << 1 << endl;
    for (int i {2}; i < 1 + n / 2; i++)
    {
        if (n % i == 0)
            cout << i << endl;
    }
    cout << n << endl;
}

Der Quelltext ist zwar deutlich aufgebläht, liefert aber bei den Spezialfällen n = 0 und n = 1 die richtigen Ausgaben. Die return-statements sorgen dafür, dass die Funktion vorzeitig verlassen wird.

Funktionen ohne Parameter

Eine Funktion muss keine Parameter besitzen. Man darf in diesem Fall aber die runden Klammern nicht weglassen. So wie bisher die main()-Methode eingesetzt wurde, war sie genau dieser Spezialfall. Ein anderes Beispiel:

#include <iostream>
using namespace std;

// life, the universe and everything
int getAnswer();

int main()
    {
        cout << getAnswer() << endl;            // 42
        return 0;
    }
    
int getAnswer()
{
    // millions of calculations
    return 42;
}

Funktionen überladen

Das obige Beispiel der Mittelwert-Berechnung mit mean() wirft sofort Fragen auf:

Es ist erlaubt, eine Funktion zu überladen, damit ist gemeint, dass sie identischen Namen aber eine andere Parameter-Liste besitzt. Das folgende Beispiel definiert zusätzlich die Funktion mean() für drei Eingabewerte:

#include <iostream>
using namespace std;

// mean berechnet den Mittelwert zweier Zahlen x1 und x2
double mean(double x1, double x2);                                          // Deklaration der Funktion mean() mit zwei Eingabewerten

// mean berechnet den Mittelwert dreier Zahlen x1, x2 und x3
double mean(double x1, double x2, double x3);                               // Deklaration der Funktion mean() mit drei Eingabewerten

int main()
{
    double x {3};
    double y {7};
    double z {11};

    double meanValue = mean(x, y);                              // Aufruf der Funktion mean() mit zwei Variablen
    cout << "Mittelwert: " << meanValue << endl;                // 5

    double meanValue = mean(x, y, z);                           // Aufruf der Funktion mean() mit drei Variablen
    cout << "Mittelwert: " << meanValue << endl;                // 7
    
    return 0;
}

double mean(double x1, double x2)                               // Implementierung der Funktion mean() mit zwei Eingabewerten
{
    double mean = (x1 + x2) / 2;
    return mean;
}

double mean(double x1, double x2, double x3)                    // Implementierung der Funktion mean() mit drei Eingabewerten
{
    double mean = (x1 + x2 + x3) / 3;
    return mean;
}

Der Rückgabetyp darf nicht überladen werden. Das folgende Beispiel führt — schon durch die Deklaration — zu einem Compiler-Fehler:

double mean(double x1, double x2);

int mean(double x1, double x2);         // Compiler-Fehler

Tip:

Setzen Sie das Überladen von Funktionen nur ein, wenn die Funktionen identische Aufgaben erfüllen.

Deklaration, Prototyp und Signatur einer Funktion

Oben wurde zwischen Deklaration und Implementierung einer Funktion unterschieden:

Die Deklaration wird oft auch als Prototyp einer Funktion bezeichnet.

Deklaration und Implementierung zusammen nennt man die Definition der Funktion.

Bei überladenen Funktionen unterscheiden sich nur die Parameter-Listen, Name und Typ des Rückgabewertes sind identisch. Man nennt Name und Parameter-Liste einer Funktion ihre Signatur.

Globale Funktionen

Am Beispiel der Funktion mean() wurde im vorhergehenden Abschnitt gezeigt, wie Deklaration und Implementierung getrennt werden. Der Nachteil dieser Lösung ist allerdings, dass beide in derselben Datei waren wie die main()-Methode, die dann mean() aufgerufen hat. Wünschenswert ist stattdessen eine Lösung wie sie zum Beispiel bei den Funktionen von cmath angeboten wird: Diese sind als globale Funktionen definiert und können von jedem beliebigen Programm aus aufgerufen werden.

Das Beispiel mit mean() soll jetzt — bei identischer Deklaration und Implementierung — so aufgebaut werden, dass mean() als globale Funktion aufgerufen werden kann. Um die Trennung zwischen Deklaration und Implementierung beizubehalten, werden zwei Dateien angelegt:

Statistik.h             // enthält die Deklaration von mean()
Statistik.cpp           // enthält die Implementierung von mean()

Der Name Statistik für die Dateien ist wieder frei wählbar — er soll darauf hindeuten, dass die Dateien erweitert werden und später mehrere statistische Funktionen anbieten. Es folgen zuerst die beiden Dateien für die globale Funktion mean() und anschließend die Datei

main.cpp            // Test des Aufrufs von mean()

von der aus die Funktion mean() aufgerufen wird.

// Statistik.h          
// Deklaration von mean()

#ifndef STATISTICS_H_INCLUDED
#define STATISTICS_H_INCLUDED

double mean(double x1, double x2);

#endif // STATISTICS_H_INCLUDED
// Statistik.cpp            
// Implementierung von mean()

#include "Statistik.h"

double mean(double x1, double x2)
{
    return (x1 + x2) / 2;
}
// main.cpp
// Test zum Aufruf der globalen Funktion mean()

#include "Statistik.h"          // ermöglicht den Aufruf von mean, die in Statistik.h deklariert ist

#include <iostream>
using namespace std;

int main() {

    double meanValue = mean(3.5, 5.5);
    cout << meanValue << endl;          // 4.5
    return 0;
}

Zur Erklärung:

1. Die Datei Statistik.h:

Zum Anlegen der Datei wählt man in Code::Blocks

File -> New... -> C/C++ header

und wählt als Datei-Endung .h. Damit werden Header-Dateien bezeichnet.

Neu sind die drei Präprozessor-Direktiven (Zeile 4, 5 und 9), die von Code::Blocks angelegt werden. Ihre Bedeutung ist folgende: Die Datei Statistik.h muss von mehreren anderen Dateien inkludiert werden; bei komplexen Projekten kann es dann leicht passieren, dass eine Datei mehrfach inkludiert wird — genau das verhindern die drei Direktiven, man nennt sie daher auch include guards.

Mit diesem Wissen sollten die Namen der Direktiven selbsterklärend sein: Sie definieren ein Symbol STATISTICS_H_INCLUDED für den Fall, dass es noch nicht definiert ist und so weiter. Der Name STATISTICS_H_INCLUDED ist beliebig wählbar, darf nur nicht in einer anderen Header-Datei verwendet werden — sonst sind die include guards wirkungslos. Inkludiert wird dann alles, was in die Präprozessor-Direktiven define und endif eingeschlossen ist:

#define STATISTICS_H_INCLUDED

// alles hier wird inkludiert

#endif // STATISTICS_H_INCLUDED

2. Die Datei Statistik.cpp:

Sie enthält die Implementierung der Funktion mean(). Achten Sie darauf, dass die Datei tatsächlich kompiliert wird und die zugehörige Objekt-Datei

Statistik.o

in Ihrem Projekt-Ordner enthalten ist. Wenn später das Kompilieren von main.cpp zu einem Fehler führt und die Fehlermeldung besagt, dass mean() nicht definiert ist, kann es daran liegen, dass Statistik.cpp nicht kompiliert wurde.

Da jetzt Deklaration und Implementierung in getrennten Dateien enthalten sind, muss der include-Befehl

#include "Statistik.h"

enthalten sein. Beachten Sie, dass die Datei anders angegeben wird als dies bei includes aus der Standard-Bibliothek der Fall ist:

Ein umgekehrter include-Befehl ist nicht nötig: die Header-Datei muss nicht ihre Implementierung inkludieren (wenn Sie bedenken, wie der Compiler arbeitet, sollte dies klar sein!).

3. Die Datei main.cpp:

Aufgabe:

Legen Sie ein neues Projekt mit den drei besprochenen Dateien an. Verwenden Sie dazu am Besten

File -> New -> Project -> Empty project

Überprüfen Sie, ob die Objekt-Datei Statistik.o erzeugt wurde.

Später wird dieses Projekt um einige statistische Funktionen erweitert.