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 in die Informatik
- C++: Fortgeschrittene Syntax
- C++: Strukturierte Programmierung mit Funktionen
- C++: Fortgeschrittene Syntax
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:
- die Programme werden unübersichtlich (und zwar überproportional zu ihrer Größe),
- semantische Fehler sind schwer auffindbar,
- manche Operationen können Nebeneffekte produzieren, die man bei Änderungen nicht beachtet,
- soll das Programm abgeändert werden, müssen alle betroffenen Stellen aufgesucht werden (vergisst man eine, produziert man neue Fehler),
- soll das Programm erweitert werden, ist es meist einfacher von Neuem zu beginnen.
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:
- wie man Funktionen selbst definiert und aufruft,
- wie man einer Funktion Parameter übergibt und einen Rückgabewert erzeugt,
- wie man Deklaration und Implementierung einer Funktionen trennt.
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:
- die Deklaration der Funktion mean() zu Beginn der Datei (nach den includes und dergleichen und vor main(),
- die main()-Methode, in der die Funktion mean() zweimal aufgerufen wird,
- die Implementierung der Funktion mean() am Ende der Datei (nach main()).
#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:
- 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).
- 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.
- 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:
- Wenn man eine Mittelwert-Berechnung mit drei Eingabewerten durchführen möchte, ist dann der Name mean() bereits vergeben oder kann er nochmals verwendet werden? Ja!
- Kann man Funktionen mit identischen Eingabewerten aber unterschiedlichen Datentypen des Rückgabewertes unter dem gleichen Namen anbieten? Nein!
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 zeigt:
- den Namen der Funktion,
- die Parameter-Liste (genauer: deren Datentypen, die Namen der Parameter können weggelassen werden) und
- den Typ des Rückgabewertes.
- Die Implementierung muss die Deklaration wiederholen (und zusätzlich den Parametern willkürliche Namen geben, da sie in der Implementierung verarbeitet werden).
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:
- die Datei-Endung muss angegeben werden,
- anstelle der spitzen Klammern werden Anführungsstriche gesetzt.
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:
- In der main()-Methode wird die globale Funktion mean() aufgerufen (Zeile 11) — dieser Schritt ist bereits bekannt.
- Damit der Aufruf erfolgen kann, muss auch hier die Datei Statistik.h inkludiert werden (Zeile 4) und Statistik.o muss beim build-Prozess erzeugt worden sein.
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.