Funktionen

Es steht dem Aufrufer frei, das Resultat zu ignorieren.
– Brian Kernighan & Dennis Ritchie

Unterprogramme: Prozeduren und Funktionen

Große Programme bestehen aus vielen kleineren Unterprogrammen. Wir sagen „Vertausche a und b.“ und wissen, was damit gemeint ist: Die Variablen a und b besitzen nach der Ausführung dieser Prozedur vertauschte Werte.

Funktionen sind als Programmbausteine eine Anwendung des EVA-Prinzips in kleineren Maßstab. Aufforderungen wie

  • berechne die Hypotenuse eines rechtwinkligen Dreiecks mit den Katheten a und b oder
  • gib mir die Quadratwurzel einer Zahl x sind klar verständlich formuliert.1 Dazu müssen wir nicht einmal wissen, auf welchem Weg man zu deren Ergebnissen kommt.

Die Berechnungen von Quadratwurzeln, Hypotenusen und größten gemeinsamen Teilern sind sogar Funktionen im mathematischen Sinne, eine eindeutige Zuordnung von Eingabewerten, den Argumenten, zu einem Ergebnis, einem Rückgabewert. Das Ergebnis einer Funktionsberechnung kann sofort in einem zusammengesetzten Ausdruck weiterverwendet werden. Prozeduren hingegen liefern kein Ergebnis und müssen als Anweisungen isoliert geschrieben werden. Andere Programmiersprachen unterscheiden sehr deutlich zwischen den Konzepten Prozedur und Funktion. In C++ werden Unterprogramme void-Funktionen genannt, Funktionen mit nicht existentem Rückgabewert (void = Leere).

Unterprogramme können genutzt werden, ohne deren internen Ablauf kennen zu müssen, als Black Box. Anders herum bedürfen jene, die Unterprogramme implementieren, keiner Kenntnis über die Art und Weise, wie die Unterprogramme benutzt werden. Das ist die Grundidee des Geheimnisprinzips (engl. information hiding)2. Beide Seiten müssen sich nur verständigen, wie Eingabewerte übergeben/übernommen werden und ob/wie Ergebnisse an den Aufrufer zurück geliefert werden. Diesen Übergabepunkt nennen wir eine Schnittstelle (engl. API = application programming interface).

Funktionsdeklaration als Schnittstelle

Syntax

Rückgabetyp name(Paramerliste);

Die Bekanntgabe des Funktionskopfes genügt, dem Compiler genügend Informationen zum Aufruf einer Funktion bereitzustellen:

//> cpp_basics/hypotenuse.cpp
import std;
double hypotenuse(double a, double b);
double quadratwurzel(double x);
 
int main()
{
	double a = 3.0, b = 5.0;
	double c = hypotenuse(a, b);
	std::println("Pythagoras: {}^2 + {}^2 = {}^2", a, b ,c);
}

Programme befolgen die Regel „Declare before use“.

Deklarationen dürfen einander nicht widersprechen:

int    eingabe();
double eingabe(); // Fehler!

Parameter und Argumente

Auf den Namen einer Funktion folgen runde Klammern. Darin wird bei der Deklaration, welche Datentypen an die aufgerufene Funktion übergeben werden sollen. Diese Platzhalter werden Parameter genannt. Die beim Funktionsaufruf eingesetzten Werte oder Variablen heißen Argumente. Eine Funktion, deren Parameterliste leer ist, übernimmt die Funktion beim Aufruf keine Argumente.

Funktionsdefinition

Syntax

Rückgabetyp name(Paramerliste) Funktionsrumpf

Eine Funktion darf mehrmals deklariert, jedoch nur ein einziges Mal im Programm definiert werden (ODR = „one definition rule“). Ineinander verschachtelte Funktionsdefinitionen sind nicht erlaubt, lokale Deklarationen hingegen schon. Die Definition einer Funktion erfordert einen Anweisungsblock als Funktionsrumpf. Darauf folgt kein Semikolon:

//>> cpp_basics/hypotenuse.cpp
 
double hypotenuse(double a, double b)
{
	return quadratwurzel(a * a + b * b);
}

Als letzte Anweisung im Funktionsrumpf steht eine return-Anweisung. Der zurückgegebene Ausdruck muss zum Rückgabetyp passen.

Das Heron-Verfahren zum Wurzelziehen ist ein Thema der numerischen Mathematik. Anstatt den Algorithmus zur Berechnung der Quadratwurzel selbst umzusetzen, greifen wir auf eine Funktion der Standard-Bibliothek zurück:

//>> cpp_basics/hypotenuse.cpp
 
double quadratwurzel(double x)
{
	return std::sqrt(x);
}

Eine Funktionsdefinition ist zugleich eine Funktionsdeklaration. Man spart sich die vorherige Deklaration, wenn die Funktionsdefinition im Programm oberhalb ihres Aufrufs erfolgt. Deshalb steht main()häufig als letzte Funktion im Programm. Ebenso kann der Compiler seit C++14 den Rückgabetyp einer Funktion aus dem return-Ausdruck folgern, ebenso wie der Typ einer Variable aus dem Startwert herleitbar ist:

//> cpp_basics/hypotenuse_v2.cpp
import std;
 
auto quadratwurzel(double x)
{
	return std::sqrt(x);
}
 
auto hypotenuse(double a, double b)
{
	return quadratwurzel(a * a + b * b);
}
 
int main()
{
	auto a = 3.0, b = 5.0;
	auto c = hypotenuse(a, b);
	std::println("Pythagoras: {}^2 + {}^2 = {}^2", a, b ,c);
}

Studieren wir die Standardbibliothek genauer, entgeht uns nicht, dass diese neben der Quadratwurzelberechnung seit C++11 auch eine Funktion std::hypot(x,y) und seit C++17 auch deren dreidimensionale Entsprechung std::hypot(x,y,z) enthält. Damit schrumpft die Berechnung zu einem Einzeiler:

//> cpp_basics/hypotenuse_v3.cpp
import std;
 
int main()
{
	auto a = 3.0, b = 5.0;
	auto c = std::hypot(a, b);
	std::println("Pythagoras: {}^2 + {}^2 = {}^2", a, b ,c);
}

Rückkehr aus einer Funktion

Funktionen werden beim Erreichen einer return-Anweisung verlassen. Hinter return folgt der Rückgabewert, das Ergebnis der Funktionsberechnung. Endet die Abarbeitung einer Funktion ohne return-Anweisung, ist der Rückgabewert undefiniert.3 Prozeduren (Funktionen mit dem Rückgabe void) benötigen hingegen keine return-Anweisung. Die Verarbeitung endet mit der letzten Anweisung des Funktionsrumpfes. Für die main()-Funktion gilt eine Sonderregel: Wird diese ohne Angabe eines Rückgabewertes verlassen, ist dies gleichwertig zu return 0. Dieser Rückgabewert signalisiert dem aufrufenden Programm bzw. Betriebssystem das fehlerfreie Ende des Programms.

Der Funktionsrumpf kann mehrere return-Anweisungen enthalten. Dadurch ist das vorzeitige Verlassen einer Funktion möglich. Ein vorzeitiger Abbruch einer void-Funktion erfolgt durch return ohne Rückgabewert:

//> cpp_basics/heron.cpp
import std;
 
void heron(double x)
{
	if (x <= 0.0) return;
 
	auto const A = x;
	auto y = 1.0;
	
	while (std::abs(x - y) < 1e-6)
	{
		std::println("{} {} | {}",x, y, x-y);
		x = (x + y) / 2;
		y = A / x;	
	}
}
 
int main()
{
	heron(2.0); // Quadratwurzel aus 2
}

Wertparameter und Referenzparameter

Wertparameter wie oben angegeben sind Einbahnstraßen für die Übernahme von Werten in die Funktion hinein. Die Argumentwerte werden beim Aufruf in die lokalen Variablen kopiert (Kopierverhalten). Änderungen an den Parameterwerten in der Funktion haben keine Auswirkung auf Variablen im aufrufenden Programm. Deshalb ist es auch unerheblich, ob Werte oder Variablennamen beim Aufruf übergeben werden.

Am Parametertyp angefügte Kaufmanns-Und-Zeichen & kennzeichnen, dass interne Wertänderungen in das aufrufende Programm zurückwirken sollen. Solche Referenzparameter übernehmen den Speicherplatz der Variablen aus dem aufrufenden Programm unter einem Alias-Namen und ändern deren Werte direkt dort (Referenzverhalten). Damit das gelingt, müssen die Typen von Referenzparameter und Argument beim Aufruf übereinstimmen:

//> cpp_basics/referenz_parameter.cpp
import std;
 
void tausche(int& a, int& b)
{
	int temp = a;
	a = b;
	b = temp;
}
 
int main()
{
	int x = 2, y = 4;
	std::println("{} {}", x, y);
	tausche(x, y);
	std::println("{} {}", x, y);
}

Überladene Funktionen

Mehrere Funktionen dürfen den gleichen Namen tragen, sofern sich ihre Parameterlisten in Anzahl bzw. Typen der Parameter unterscheiden. Solche Funktionen heißen überladen.4 Funktionsname und die Liste der Parameter-Typen bilden zusammen die Signatur einer Funktion.

Aus den Typen der Argumente beim Aufruf ergibt sich, welche der Funktionen aufgerufen werden soll:

//> cpp_basics/ueberladene_fkt.cpp
import std;
 
void tausche(int& a, int& b)
{
	int temp = a;
	a = b;
	b = temp;
}
 
void tausche(double& a, double& b)
{
	double temp = a;
	a = b;
	b = temp;
}
 
int main()
{
	int m = 2, n = 4;
	double x = 2.71, y = 3.14;
	
	tausche(m, n);
	std::println("{} {}", m, n);
	tausche(x, y);
	std::println("{} {}", x, y);
}

Funktionsschablonen

Tauschen ist ein generischer Algorithmus, der für alle Typen in gleicher Weise ablaufen kann. Statt für jeden bisher bekannten und später für alle neuen Typen eine neue Tauschfunktion selbst zu schreiben, können wir mit einer Funktionsschablone (Vorlage, engl. template) den Compiler dazu bringen, diese Funktion bei Bedarf für einen Typ T selbst zu definieren:

//> cpp_basics/tausch_schablone.cpp
import std;
 
template <typename T>
void tausche(T& a, T& b)
{
	T temp = a;
	a = b;
	b = temp;
}
 
int main()
{
	int m = 2, n = 4;
	double x = 2.71, y = 3.14;
	
	tausche(m, n);
	std::println("{} {}", m, n);
	tausche(x, y);
	std::println("{} {}", x, y);
}

Die Standardbibliothek hält Funktionsschablonen zum Vertauschen, für Minimum und Maximum und andere schon bereit:

//> cpp_basics/std_schablonen.cpp
import std;
 
int main()
{
	int m = 2, n = 4;
	double x = 2.71, y = 3.14;
	
	std::swap(m, n);
	std::println("{} {} Min: {} Max: {}", m, n, std::min(m,n), std::max(m,n));
	std::swap(x, y);
	std::println("{} {} Min: {} Max: {}", x, y, std::min(x,y), std::max(x,y));	
}

Zwischen Funktionsname und der Klammer mit der Argumentliste können Template-Argumente, also Typen, in spitzen Klammern vorgegeben werden. Dies können die Typen von Parametern sein, aber auch der Rückgabetyp. Damit wird eine Funktion möglich, die ähnlich wie input(msg) in Python arbeitet, indem sie eine Ausgabeaufforderung ausgibt und daraufhin einem Wert von der Eingabe holt und als Rückgabewert liefert:

//> cpp_basics/input_schablone.cpp
import std;
 
template <typename T>
T input(std::string aufforderung)
{
	T x{};
	std::print("{}", aufforderung);
	std::cin >> x;
	return x;
}
 
int main()
{
	auto n = input<int>("Ganzzahl: ");
	auto x = input<double>("Dezimalbruch: ");
	
	std::println("int: {} double: {}", n, x);
}

Aufgaben

  1. Eine Prozedur liefert nichts zurück, eine Funktion nur einen Wert. Wie könnten mehrere Werte aus einem Unterprogramm zurück gegeben werden?
  2. Überprüfe, was mit der Funktion tausche() beim Entfernen der & geschieht.
  3. Warum kann tausche(x,y) nicht übersetzt werden, wennx und y verschiedene Typen haben? Es gibt eine „logische“ Antwort und eine Begründung aus Sicht des Compilers.
  4. Baue dein Programm zur Lösung allgemeiner quadratischer Gleichungen (Aufgabe aus dem Abschnitt über Steuerstrukturen) so um, dass es Prozeduren allgemeine_form(a,b,c), normalform(p,q)und linearform(m,n) nutzt, welche die entsprechenden Lösungen ausgeben. Dabei soll die allgemeine Form wenn möglich in die Normalform überführt werden, andernfalls in die Linearform. Bewerte die Übersichtlichkeit.

Anmerkungen

  1. Ein wenig mathematische Schulbildung wird vorausgesetzt.

  2. David Parnas, https://en.wikipedia.org/wiki/Information_hiding#cite_note-3

  3. Undefiniertes Verhalten (undefined behaviour = UB) ist das Schlimmste, was beim Ausführen von Programmen geschehen kann. Nichts ist auf dem ausführenden Computer und angeschlossenen Systemen mehr vorhersagbar, alles ist möglich: Das Wetter ändert sich, die Hölle friert zu (oder auch nicht).

  4. Die Bezeichnung Überladen ist eine unglückliche Übertragung (ein false friend) des englischen Wortes overload. Sie meint, dass der Sinn mancher Bezeichnungen sich nach dem Bedeutungszusammenhang richtet. Eine Bezeichnung wird mit anderen Bedeutungen „überfrachtet“. Eine Maus bezeichnet in der Biologie ein kleines Nagetier, jedoch auch ein Eingabegerät in der Computertechnik.