C++-Programmierung/ Weitere Grundelemente/ Prozeduren und Funktionen


Unter einer Funktion (function, in anderen Programmiersprachen auch Prozedur oder Subroutine genannt) versteht man ein Unterprogramm, das eine bestimmte Aufgabe erfüllt. Funktionen sind unter anderem sinnvoll, um sich wiederholende Aufgaben zu kapseln, so dass die Befehle nicht jedesmal neu geschrieben werden müssen. Zudem verbessert es die Übersichtlichkeit der Quellcode-Struktur erheblich, wenn der Programmtext logisch in Abschnitte unterteilt wird.

Parameter und Rückgabewert

Bearbeiten

Die spezielle Funktion main() ist uns schon mehrfach begegnet. In C++ lassen sich Funktionen nach folgenden Kriterien unterscheiden:

  • Eine Funktion kann Parameter besitzen, oder nicht.
  • Eine Funktion kann einen Wert zurückgeben, oder nicht.

Dem Funktionsbegriff der Mathematik entsprechen diejenigen C++-Funktionen, die sowohl Parameter haben als auch einen Wert zurückgeben. Dieser Wert kann im Programm weiter genutzt werden, um ihn z.B. einer Variablen zuzuweisen.

int a = f(5); // Aufruf einer Funktion

Damit diese Anweisung fehlerfrei kompiliert wird, muss vorher die Funktion f() deklariert worden sein. Bei einer Funktion bedeutet Deklaration die Angabe des Funktionsprototyps. Das heißt, der Typ von Parametern und Rückgabewert muss angegeben werden. Das folgende Beispiel deklariert bspw. eine Funktion, die einen Parameter vom Typ char besitzt und einen int-Wert zurückgibt.

int f (char x); // Funktionsprototyp == Deklaration

Soll eine Funktion keinen Wert zurückliefern, lautet der Rückgabetyp formal void.

Nach dem Compilieren ist das Linken der entstandenen Objektdateien zu einem ausführbaren Programm nötig. Der Linker benötigt die Definition der aufzurufenden Funktion. Eine Funktionsdefinition umfasst auch die Implementation der Funktion, d.h. den Code, der beim Aufruf der Funktion ausgeführt werden soll. In unserem Fall wäre das:

int f(int x);     // Funktionsdeklaration

int main(){
    int a = f(3); // Funktionsaufruf
    // a hat jetzt den Wert 9
}

int f(int x){    // Funktionsdefinition
    return x * x;
}
Hinweis

Innerhalb eines Programms dürfen Sie eine Funktion beliebig oft (übereinstimmend!) deklarieren, aber nur einmal definieren.

Der Compiler muss die Deklaration kennen, um eventuelle Typ-Unverträglichkeiten abzufangen. Würden Sie die obige Funktion z.B. als int a = f(2.5); aufrufen, käme die Warnung, dass f() ein ganzzahliges Argument erwartet und keine Fließkommazahl. Eine Definition ist für den Compiler auch immer eine Deklaration, das heißt Sie müssen nicht explizit eine Deklaration einfügen, um eine Funktion aufzurufen, die zuvor definiert wurde.

Die Trennung von Deklaration und Definition kann zu übersichtlicher Code-Strukturierung bei größeren Projekten genutzt werden. Insbesondere ist es sinnvoll, Deklarationen und Definitionen in verschiedene Dateien zu schreiben. Oft will man, wenn man fremden oder alten Code benutzt, nicht die Details der Implementierung einer Funktion sehen, sondern nur das Format der Parameter o.ä. und kann so in der Deklarationsdatei (header file, üblicherweise mit der Endung .hpp, z.T. auch .h oder .hh) nachsehen, ohne durch die Funktionsrümpfe abgelenkt zu werden. (Bei proprietärem Fremdcode bekommt man die Implementation in der Regel gar nicht zu Gesicht!)

Bei einer Deklaration ist es nicht nötig, die Parameternamen mit anzugeben, denn diese sind für den Aufruf der Funktion nicht relevant. Es ist allerdings üblich, die Namen dennoch mit anzugeben, um zu verdeutlichen was der Parameter darstellt. Der Compiler ignoriert die Namen in diesem Fall einfach, weshalb es auch möglich ist den Parametern in der Deklaration und der Definition unterschiedliche Namen zu geben. Allerdings wird davon aus Gründen der Übersichtlichkeit abgeraten.

Mit der Anweisung return gibt die Funktion einen Wert zurück, in unserem Beispiel x * x, wobei die Variable x als Parameter bezeichnet wird. Als Argument bezeichnet man eine Variable oder einen Wert, mit denen eine Funktion aufgerufen wird. Bei Funktionen mit dem Rückgabetyp void schreiben Sie einfach return; oder lassen die return-Anweisung ganz weg. Nach einem return wird die Funktion sofort verlassen, d.h. alle nachfolgenden Anweisungen des Funktionsrumpfs werden ignoriert.

Erwartet eine Funktion mehrere Argumente, so werden die Parameter durch Kommas getrennt. Eine mit

int g(int x, double y);

deklarierte Funktion könnte z.B. so aufgerufen werden:

int a = g(123, -4.44);

Für eine leere Parameterliste schreiben Sie hinter den Funktionsnamen einfach ().

int h(){     // Deklaration von h()
  // Quellcode ...
}

int a = h(); // Aufruf von h()
Hinweis

Vergessen Sie beim Aufruf einer Funktion ohne Parameter nicht die leeren Klammern. Andernfalls erhalten Sie nicht den von der Funktion zurückgelieferten Wert, sondern die Adresse der Funktion. Dies ist ein „beliebter“ Anfängerfehler, daher sollten Sie im Falle eines Fehlers erst einmal überprüfen, ob Sie nicht vielleicht irgendwo eine Funktion ohne Parameter falsch aufgerufen haben.

Übergabe der Argumente

Bearbeiten

C++ kennt zwei Varianten, wie einer Funktion die Argumente übergeben werden können: call-by-value und call-by-reference.

call-by-value

Bearbeiten

Bei call-by-value (Wertübergabe) wird der Wert des Arguments in einen Speicherbereich kopiert, auf den die Funktion mittels Parametername zugreifen kann. Ein Werteparameter verhält sich wie eine lokale Variable, die „automatisch“ mit dem richtigen Wert initialisiert wird. Der Kopiervorgang kann bei Klassen (Thema eines späteren Kapitels) einen erheblichen Zeit- und Speicheraufwand bedeuten!

#include <iostream>

void f1(int const x) {
    x = 3 * x;       // ungültig, weil Konstanten nicht überschrieben werden dürfen
    std::cout << x << std::endl;
}

void f2(int x) {
    x = 3 * x;
    std::cout << x << std::endl;
}

int main() {
   int a = 7;
   f1(a);          // Kompiler-Fehler, da Übergabeparameter der Funktion const ist.
   f2(5);          // Ausgabe: 15
   std::cout << x; // Fehler! x ist hier nicht definiert
   std::cout << a; // a hat immer noch den Wert 7

   return 0;
}

Wird der Parameter als const deklariert, so darf ihn die Funktion nicht verändern (siehe erstes Beispiel). Im zweiten Beispiel kann die Variable x verändert werden. Die Änderungen betreffen aber nur die lokale Kopie und sind für die aufrufende Funktion nicht sichtbar.

call-by-reference

Bearbeiten

Sollen die von einer Funktion vorgenommen Änderungen auch für das Hauptprogramm sichtbar sein, müssen in C sogenannte Zeiger verwendet werden. C++ stellt ebenfalls Zeiger zur Verfügung. C++ gibt Ihnen aber auch die Möglichkeit, diese Zeiger mittels Referenzen zu umgehen, was im alten C nicht möglich war. Beide sind jedoch noch Thema eines späteren Kapitels.

Im Gegensatz zu call-by-value wird bei call-by-reference die Speicheradresse des Arguments übergeben, also der Wert nicht kopiert. Änderungen der (Referenz-)Variable betreffen zwangsläufig auch die übergebene Variable selbst und bleiben nach dem Funktionsaufruf erhalten. Um call-by-reference anzuzeigen, wird der Operator & verwendet, wie Sie gleich im Beispiel sehen werden. Wird keine Änderung des Inhalts gewünscht, sollten Sie den Referenzparameter als const deklarieren, um so den Speicherbereich vor Änderungen zu schützen. Fehler, die sich aus der ungewollten Änderung des Inhaltes einer übergebenen Referenz ergeben, sind in der Regel schwer zu finden.

Die im folgenden Beispiel definierte Funktion swap() vertauscht ihre beiden Argumente. Weil diese als Referenzen übergeben werden, überträgt sich das auf die Variablen, mit denen die Funktion aufgerufen wurde:

#include <iostream>

void swap(int &a, int &b) {
    int tmp = a; // "temporärer" Variable den Wert von Variable a zuweisen
    a = b;       // a mit dem Wert von Variable b überschreiben
    b = tmp;     // b den Wert der "temporären" Variable zuweisen (Anfangswert von Variable a) 
}

int main() {
    int x = 5, y = 10;

    swap(x, y);

    std::cout << "x=" << x << " y=" << y << std::endl;

    return 0;
}
Ausgabe:
x=10 y=5
Tipp

Ob das kaufmännische Und (&) genau nach int (int& a, ...), oder genau vor der Variablen a (int &a, ...), oder dazwischen (int & a, ...) steht, ist für den Compiler nicht von Bedeutung. Auch möglich wäre (int&a, ...).

Nicht-konstante Referenzen können natürlich nur dann übergeben werden, wenn das Argument tatsächlich eine Speicheradresse hat, sprich eine Variable bezeichnet. Ein Literal z.B. 123 oder gar ein Ausdruck 1 + 2 wäre hier nicht erlaubt. Bei const-Referenzen wäre das möglich, der Compiler würde dann eine temporäre Variable anlegen.

Thema wird später näher erläutert…

Wir werden die oben angesprochenen Zeiger in einem späteren Kapitel ausführlich kennen lernen. Sie ermöglichen z.B. das Arbeiten mit Arrays. Soll eine Funktion mit einem Array umgehen, werden ihr die Startadresse des Arrays (ein Zeiger) und seine Größe als Parameter übergeben. Wenn das Array nur gelesen werden soll, deklariert man die Werte, auf die der Zeiger zeigt, als const. Das Zeichen * signalisiert, dass es sich um einen Zeiger handelt.

#include <iostream>

void print(int const* array, int const arrayGroesse) {
    std::cout << "Array:" << std::endl;
    for (int i = 0; i < arrayGroesse; ++i) {
        std::cout << array[i] << std::endl;
    }
}

int main() {
    int array[] = { 3, 13, 113 };
    int n = sizeof(array) / sizeof(array[0]);

    print(array, n);
}
Ausgabe:
Array:
3
13
113

Default-Parameter

Bearbeiten

Default-Parameter dienen dazu, beim Aufruf einer Funktion nicht alle Parameter explizit angeben zu müssen. Die nicht angegebenen Parameter werden mit einer Voreinstellung (default) belegt. Parameter, die bei einem Aufruf einer Funktion nicht angegeben werden müssen, werden auch als „fakultative Parameter“ bezeichnet.

int summe(int a, int b, int c = 0, int d = 0) {
    return a + b + c + d;
}
 
int main() {
    int x = summe(2, 3, 4, 5); //x == 14
    x = summe(2, 3, 4);        //x == 9, es wird d=0 gesetzt
    x = summe(2, 3);           //x == 5, es wird c=0, d=0 gesetzt   
}

Standardargumente werden in der Deklaration einer Funktion angegeben, da der Compiler sie beim Aufruf der Funktion kennen muss. Im obigen Beispiel wurde die Deklaration durch die Definition gemacht, daher sind die Parameter hier in der Definition angegeben. Bei einer getrennten Schreibung von Deklaration und Definition könnte das Beispiel so aussehen:

int summe(int a, int b, int c = 0, int d = 0); // Deklaration

int main() {
    int x = summe(2, 3, 4, 5); //x == 14
    x = summe(2, 3, 4);        //x == 9, es wird d=0 gesetzt
    x = summe(2, 3);           //x == 5, es wird c=0, d=0 gesetzt   
}

int summe(int a, int b, int c, int d) {        // Definition
    return a + b + c + d;
}

Funktionen überladen

Bearbeiten

Überladen (overloading) von Funktionen bedeutet, dass verschiedene Funktionen unter dem gleichen Namen angesprochen werden können. Damit der Compiler die Funktionen richtig zuordnen kann, müssen die Funktionen sich in ihrer Funktionssignatur unterscheiden. In C++ besteht die Signatur aus dem Funktionsnamen und ihren Parametern, der Typ des Rückgabewerts gehört nicht dazu. So ist es nicht zulässig, eine Funktion zu überladen, die den gleichen Namen und die gleiche Parameterliste wie eine bereits existierende Funktion besitzt und sich nur im Typ des Rückgabewerts unterscheidet. Das obige Beispiel lässt sich ohne Default-Parameter so formulieren:

int summe(int a, int b, int c, int d) {
    return a + b + c + d;
}

int summe(int a, int b, int c) {
    return a + b + c;
}

int summe(int a, int b) {
    return a + b;
}

int main() {
    // ...
}

Funktionen mit beliebig vielen Argumenten

Bearbeiten

Wenn die Zahl der Argumente nicht von vornherein begrenzt ist, wird als Parameterliste die sog. Ellipse ... angegeben. Der Funktion werden die Argumente dann in Form einer Liste übergeben, auf die mit Hilfe der (in der Headerdatei cstdarg definierten) va-Makros zugegriffen werden kann.

#include <cstdarg>
#include <iostream>

int summe(int a, ...) {
    int summe = 0;
    int i = a;

    va_list Parameter;              // Zeiger auf Argumentliste
    va_start(Parameter, a);         // gehe zur ersten Position der Liste

    while (i != 0){                 // Schleife, solange Zahl nicht 0 (0 ist Abbruchbedingung)
        summe += i;                 // Zahl zur Summe addieren
        i = va_arg(Parameter, int); // nächstes Argument an i zuweisen, Typ int
    }

    va_end(Parameter);              // Liste löschen

    return summe;
}

int main() {
    std::cout << summe(2, 3, 4, 0) << std::endl; // 9
    std::cout << summe(2, 3, 0) << std::endl;    // 5

    std::cout << summe(1,1,0,1,0) << std::endl;  // 2 (da die erste 0 in der while-Schleife für Abbruch sorgt)
}
Ausgabe:
9
5
2

Obwohl unspezifizierte Argumente manchmal verlockend aussehen, sollten Sie sie nach Möglichkeit vermeiden. Das hat zwei Gründe:

  • Die va-Befehle können nicht erkennen, wann die Argumentliste zu Ende ist. Es muss immer mit einer expliziten Abbruchbedingung gearbeitet werden. In unserem Beispiel muss als letztes Argument eine 0 stehen, ansonsten gibt es, je nach Compiler unterschiedliche, „interessante“ Ergebnisse.
  • Die va-Befehle sind Makros, d.h. eine strenge Typüberprüfung findet nicht statt. Fehler werden - wenn überhaupt - erst zur Laufzeit bemerkt.
Tipp

Mit dem kommenden C++ Standard C++11 wird mit den „variadic templates“ eine Technik eingeführt, welche diese Art von variabler Parameterliste überflüssig macht. Über Templates wie Sie im aktuellen Standard stehen, werden Sie später mehr erfahren. Sobald der neue Standard endgültig verabschiedet ist, wird es auch zu den „variadic templates“ ein Kapitel geben. Die meisten Compiler beherrschen diese Technik bereits in weiten Teilen, sofern man den neuen Standard explizit aktiviert:

#include <iostream>

template < typename First >
First summe(First first){
	return first;
}

template < typename First, typename ... Liste >
First summe(First first, Liste ... rest){
	return first + summe(rest ...);
}

int main() {
    std::cout << summe(2, 3, 4) << std::endl;
    std::cout << summe(2, 3) << std::endl;
    std::cout << summe(1, 1, 0, 1) << std::endl;
}
Ausgabe:
9
5
3

Diese Implementierung ist typsicher und benötigt keine Tricks um den letzten Parameter zu kennzeichnen. Hierbei entspricht der Rückgabetyp immer dem Typ des ersten Parameters, was nicht sonderlich sinnvoll ist, aber eine Implementierung, die sich auch bezüglich des Rückgabetyps sinnvoll verhält, würde an dieser Stelle endgültig zu weit führen. „Variadic templates“ sind eine extrem nützliche Technik für Fortgeschrittene. Für Sie genügt es zum gegenwärtigen Zeitpunkt zu wissen, dass sie existieren und dass man derartige Funktionen wie jede andere Funktion aufrufen kann.

Inline-Funktionen

Bearbeiten

Um den Aufruf einer Funktion zu beschleunigen, kann in die Funktionsdeklaration das Schlüsselwort inline eingefügt werden. Dies ist eine Empfehlung (keine Anweisung) an den Compiler, beim Aufruf dieser Funktion keine neue Schicht auf dem Stack anzulegen, sondern den Code direkt auszuführen - den Aufruf sozusagen durch den Funktionsrumpf zu ersetzen.

Da dies – wie eben schon erwähnt – nur eine Empfehlung an den Compiler ist, wird der Compiler eine Funktion nur dann tatsächlich inline einbauen, wenn es sich um eine kurze Funktion handelt. Ein typisches Beispiel:

inline int max(int a, int b) {
    return a > b ? a : b;
}
Thema wird später näher erläutert…

Das Schlüsselwort inline wird bei der Deklaration angegeben. Allerdings muss der Compiler beim Aufruf den Funktionsrumpf kennen, wenn er den Code direkt einfügen soll. Für den Aufruf einer inline-Funktion genügt also wie immer die Deklaration, um dem Compiler jedoch tatsächlich das Ersetzen des Funktionsaufrufs zu ermöglichen, muss auch die Definition bekannt sein. Über diese Eigenheit von inline-Funktionen erfahren Sie im Kapitel „Headerdateien“ mehr. Auch die Bedeutung von Deklaration und Definition wird Ihnen nach diesem Kapitel klarer sein.