Funktionen

Nach dem aufwendigen Kapitel mit den Schleifen, schauen wir uns nun ein einfacheres Thema an. Nicht einfacher, weil es einfacher wäre, sondern weil wir bereits aus den vorherigen Lektionen Wissen mitbringen, das wir nun in ähnlicher Form wiederfinden werden. Und letztendlich, weil wir ja bereits die ganze Zeit mit einer Funktion namens main arbeiten. Und auch printf() ist kein Schlüsselwort von C, sondern nur eine Funktion, die bei C aber grundsätzlich mitgeliefert wird.

Aufruf einer Funktion

Wie eine Funktion aufgerufen wird, kennen wir durch printf() bereits: Wir schreiben den Funktionsnamen und übergeben in Klammern die Argumente (auch Parameter genannt):

printf( "Hallo Welt\n" );

printf() ist eine besondere Funktion, denn sie akzeptiert beliebig viele Argumente. Das ist aber eher die Ausnahme und für den Anfang nicht wichtig, daher wird diese Eigenschaft später in einem eigenen Artikel erklärt. Wir werden uns hier erstmal um ganz normale Funktionen kümmern. Funktionen haben in der Regel eine Rückgabe, denn oftmals sollen sie ja etwas ausrechnen - wie in der Mathematik. printf() druckt Zeichen auf den Bildschirm und liefert zurück, wieviele Zeichen es gedruckt hat. Wenn man weiß, wieviele Zeichen der Bildschirm breit ist, weiß man so entsprechend, wieviel Platz man noch hat.

int printedChars;
 
printedChars = printf( "Hallo Welt\n" );

Nehmen wir an eine Funktion soll zwei Zahlen addieren - das ließe sich natürlich einfach mit dem +-Operator machen, aber darum geht es ja nicht. In jedem Fall würde man eine solche Funktion wohl so aufrufen:

int result;
 
result = add( 1, 2 );

und anschließend erwarten, dass result den Wert 3 besitzt. Nun wissen wir, wie wir eine Funktion rufen würden, wenn wir sie schreiben könnten. Und wie das funktioniert schauen wir uns nun an.

Deklaration einer Funktion

Wie wir bereits zu Beginn des Tutorials gelernt haben, unterscheidet man zwischen Deklaration und Definition. Die Deklaration enthält nur die Information, wie die Funktion verwendet werden kann. Also wie sie heißt, welchen Datentyp sie zurück liefert und welche Parameter sie bekommt:

<Rückgabedatentyp> <Identifier>( <Parameterliste> );

Diesen grammatikalischen Aufbau nennt man auch Signatur einer Funktion.

Eine Deklaration benötigt man, um dem Compiler den Identifier (den Namen der Funktion) und dessen Bedeutung (also, dass es sich um eine Funktion handelt) bekannt zu geben. Für die Deklaration einer Funktion wird auch oft der Begriff „Prototyp“ verwendet. Prototypen sind erforderlich, wenn eine Funktion noch nicht definiert wurde, aber bereits gerufen werden soll. Es galt lange Zeit zum guten Stil, dass alle Funktionen zunächst zu Beginn (bzw. in einer eigenen Headerdatei) im Quelltext deklariert wurden, so dass man alle Funktionen sofort verwenden kann. Heute hat sich diese Sicht etwas geändert - je weniger Funktionen man kennt, desto weniger kann man damit verkehrt machen.

Deklarieren wir mal eine Funktion, die zwei Integer-Werte addieren und entsprechend die Summe als Integer zurückliefern soll. Hierfür schreiben wir die Signatur einer solchen Funktion und fügen ihr ein Semikolon an:

int add( int left, int right );

Wo ist die Addition? Egal, eine Deklaration behauptet ja auch nur, dass es eine solche Funktion gibt, damit der Compiler weiß, dass der Identifier add zwei Integer-Parameter bekommt und ein Integer zurückliefert.

Wichtig ist, dass nach einer Funktionsdeklaration ein Semikolon („;“) folgt. Das beendet die Anweisung und gibt dem Compiler zu verstehen, dass der Programmierer jetzt noch nicht beschreiben möchte, was diese Funktion genau machen soll.

Definition einer Funktion

Um zu beschreiben, was die Funktion tun soll, definieren wir die Funktion. Dazu wiederholen wir die Signatur und statt eines Semikolons öffnen wir nun einen Anweisungsblock mit einer geschweiften Klammer. In diesem Anweisungsblock stehen die Anweisungen, die die Funktion ausführen soll:

int add( int left, int right )
{
  /* Anweisungen */
}

Die Signatur unserer Funktion sagt aus, dass ein Integerwert zurückgegeben wird. Wann immer ein Wert zurück gegeben wird, muss man klar aussagen, welchen Wert man zurück gibt. Wir kennen hier bereits den Befehl return, dem der zurück zu gebende Wert folgt.

int add( int left, int right )
{
  /* Anweisungen */
 
  return 0; 
}

Nun wollen wir aber nicht grundsätzlich 0 zurückliefern. Stattdessen wollen wir die beiden Werte, die wir als Parameter erhalten (left und right) miteinander addieren und das Ergebnis zurückliefern.

int add( int left, int right )
{
  int sum;
 
  sum = left + right; 
 
  return sum; 
}

Die Parameter left und right sind ganz normale Variablen, wie es sum auch ist. Sie können gelesen und beschrieben werden, der einzige Unterschied ist, dass sie durch den Aufrufer der Funktion bereits Werte zugewiesen bekommen haben. Die Aufgabe dieser Funktion ist es, diese beiden vom Aufrufer zugewiesenen Werte miteinander zu verrechnen.

Wir haben ja bereits gelernt, dass C mit Expressions arbeitet. left und right sind Expressions vom Datentyp int. left + right ist eine Expression, die ebenfalls vom Datentyp int ist. Auch sum ist eine Expression vom Datentyp int, daher dürfen wir left + right der Variablen sum zuweisen. Die Funktion add liefert ebenfalls einen int zurück, deswegen darf sum per return zurückgegeben werden. Alle int-Expressions sind miteinander austauschbar, denn aus allen kann ein int ausgelesen werden (Wichtig: Es geht ums Lesen. Der Expression left + right kann nichts zugewiesen werden, left oder right schon.).

Wir sorgen mit sum = left + right; dafür, dass in der Variablen sum der Wert gespeichert wird, den die Expression left + right erzeugt. Und wir speichern den Wert, damit wir den gleichen Wert später wieder auslesen können - hier wird der Wert bei return sum wieder ausgelesen.

Die return-Anweisung benötigt einen Integer, und die Expression left + right liefert ja einen Integer. Wir können uns das Zwischenspeichern in einer extra dafür angelegten Variable also sparen:

int add( int left, int right )
{
  return left + right; 
}

Das macht den Quelltext kürzer und einfacher. Die Variable sum habe ich lediglich angelegt, um die Gleichartigkeit von lokalen Variablen und Parametern aufzuzeigen.

Alle Parameter werden kopiert, das bedeutet, dass beim Aufruf auch die Zahlen kopiert werden:

int add( int left, int right )
{
  return left + right; 
}
 
int main( void )
{
  int links = 1, rechts = 2;
  int sum = add( links, rechts );         // <--- Aufruf
 
  printf( "Die Summe ist %d\n", sum );
 
  return 0;
}

Wie im obigen Beispiel ersichtlich, erfolgt der Aufruf einer Funktion durch die Verwendung des Namens, gefolgt von einem Klammernpaar und einem Semikolon. In den Klammern stehen entsprechend der Signatur erforderliche Parameter. Liefert die Funktion zusätzlich noch einen Wert zurück, muss dieser wie im Beispiel direkt einer Variable zugewiesen werden, ansonsten geht er verloren.
Denken wir wieder in Werten und Expressions: Beim Aufruf der Funktion add werden zwei Expressions vom Typ int angegeben. Diese beiden Expressions werden ausgewertet. links hat den Wert 1, rechts hat den Wert 2. Diese Werte werden jetzt in den Arbeitsbereich der Funktion kopiert. Diese Form des Aufrufs nennt man Call by Value, was soviel heißt wie „Aufruf mit Werten“. Man kann das so verstehen, dass vor dem Aufruf von add der Speicher für die Funktion bereitgestellt wird und dort, wo später die lokale Variable left liegt, wird der Wert der Expression (links) hineinkopiert und dort, wo später die lokale Variable right liegen wird, wird der Wert der Expression rechts hinkopiert.

Ich habe die Variablen hier in der Hauptfunktion main extra deutsch hingeschrieben, damit man sieht, dass die Namen der Variablen unterschiedlich sein dürfen, also die Namen der Variablen beim Aufruf überhaupt nichts mit dem Namen der Parametervariablen zu tun haben. Hier werden die Werte kopiert und existieren damit zweimal. Überschreibt man in der Funktion add nun den Wert für left dann wirkt sich das nicht auf den Wert von links aus, die in Speicherbereich von der Funktion main als lokale Variable definiert ist.

Das lässt sich leicht vor Augen führen, wenn wir andere Expressions beim Aufruf auswerten:

  int sum = add( 1, 2 );         // <--- Aufruf

1 ist ebenso eine Expression mit dem Datentyp int und dem Wert 1. Die Expression wird ausgewertet und der Wert 1 wird nun wieder in den Speicherbereich für add an die Stelle geschrieben, wo die Funktion später mit der Variablen left zugreift. Würde die Funktion add nun left überschreiben, wird nur die Kopie des Wertes überschrieben. Die 1, die beim Aufruf angegeben wurde, kann man natürlich nicht überschreiben, denn 1 ist eine konstante Zahl. Konstanten kann man - wie der Name schon sagt - nicht verändern.

Abbrechen einer Funktion

Mit der return-Anweisung kann man eine Funktion sofort verlassen. Sie ist damit in gewisser Weise verwandt mit der break-Anweisung für Schleifen. Wir haben im Kapitel über Wächter gesprochen.

Nehmen wir an, dass wir nun eine Funktion schreiben wollen, die 1 bei einer positiven Zahl, -1 bei einer negativen Zahl und 0 bei 0 zurückgeben soll.

int sign( int value )
{
  int result;
 
  if( value )
  {
    if( value > 0 ) result =  1;
    else            result = -1;
  }
  else              result =  0;
 
  return result;
}

Wenn value wahr ist (also nicht 0 ist), dann wird geprüft, ob value größer oder kleiner 0 ist. Sonst ist das Ergebnis Null.

Wir wollen die Funktion nun aber anders neu schreiben, wobei wir die Variable result aber einsparen wollen und die Funktion verlassen wollen, sobald wir das Ergebnis kennen. Hierfür installieren wir Wächter. Mit return brechen wir die Funktion sofort ab und geben das Ergebnis zurück. Jeder nachfolgende Code der Funktion wird ignoriert.

int sign( int value )
{
  if( !value )
    return 0;
 
  if( value > 0 ) 
    return 1;
 
  return -1;
}

Wir sehen, dass die Funktion nun keine temporäre Variable mehr benötigt und sogar etwas kürzer ist. Am Schluss wird nicht mehr gefragt, ob value kleiner 0 ist, denn eine andere Möglichkeit bleibt schließlich nicht mehr über, wenn die beiden Wächter value schon die Fälle value gleich 0 und value größer 0 abgefangen haben.

Prozeduren

Eine Funktion hat in der Mathematik die Aufgabe aus diversen Eingabevariablen einen Funktionswert zu bestimmen. Nun kann man in C aber auch Funktionen schreiben, die nichts zurückgeben. Will man die Tatsache betonen, dass es keine Funktionsrückgabe gibt, spricht man gelegentlich von „void-Funktionen“ oder aus dem Pascal-Sprachgebrauch von „Prozeduren“. Eine Prozedur handelt halt einige Anweisungen entsprechend der Übergabeparameter ab und fertig. Die Unterscheidung wurde in Pascal mit „procedure“ und „function“ vollzogen, in C wird der Unterschied nicht so offensichtlich festgehalten: man gibt als Rückgabetyp einfach void an.

  void SayHello( void )
  {
    printf( "Hello\n" );  
  }

Wie man sieht, fehlt hier auch die return-Anweisung am Ende. Da auch nichts zurückgegeben werden muss, kann man return auch keinen Wert übergeben und so endet die Prozedur, sobald die schließende geschweifte Klammer erreicht wird. Möchte man eine Funktion dennoch vorzeitig verlassen, zum Beispiel weil man nicht mehr als fünfmal hintereinander „Hallo“ sagen möchte, so kann man return auch ohne Parameter aufrufen:

  void SayHello( int howOften )
  {
    int i;  
    for( i=0; i<howOften; i++ )
    {
      if( i == 5 )    
        return;     // nach 5mal abbrechen.
 
      printf( "Hello\n" );
    }
  }    

Dieser Code zeigt nur, dass man return jederzeit verwenden kann. An dieser Stelle wäre es schöner vor der Schleife die Variable howOften einmalig zu überprüfen und falls sie größer als 5 ist, sie auf 5 zu korrigieren. Damit kann man sich die Abfrage innerhalb der Schleife wieder sparen. Probiert das doch mal als kleine Übung :-)

Rekursion

Ich werde Dir nun eine Funktion zeigen, die uns im Verlauf des Tutorials noch häufiger begegnen wird. Sie berechnet ein Element der Fibonacci-Folge.

Folgendes Problem: Ein Element der Fibonacci-Folge ist definiert über die natürlichen Zahlen und entspricht der Summe der beiden vorangegangenen Elemente. Eine Besonderheit gilt für die beiden ersten Elemente, da sie natürlich nicht über zwei Vorgänger verfügen. Hier gilt, dass das 0. Element 0 ist und das 1. Element den Wert 1 besitzt.

fib(0) = 0
fib(1) = 1
fib(n) = fib( n-1 ) + fib( n-2 )

Also gilt für die Fibonacci-Folge: 0, 1, 1, 2, 3, 5, 8… usw. Die Reihe sieht zunächst ziemlich langweilig aus, aber Fibonacci wird uns noch auf viele Abenteuer der Programmierung begleiten. Ihre Formulierung ist jedoch etwas besonderes, denn der n.-Wert hängt vom n-1. und dem n-2. Wert ab. Eine solche Funktion wird rekursiv genannt.

Schauen wir uns das Hauptproblem an:

int fib( int n )
{
  return fib( n-1 ) + fib( n-2 );
}

Das kann man so nicht stehen lassen, denn hier würde die Funktion sich bis in alle Ewigkeit selbst aufrufen. Da jeder Funktionsaufruf ein wenig Speicher kostet, wird das Programm irgendwann wegen Speichermangel abstürzen. Wir müssen also beschreiben, wann die Rekursion enden soll. Diese Bedingung nennt man Rekursionsanker und wir werden diesen Anker hier als Wächter implementieren. Denn wir haben ja noch unsere beiden Sonderfälle bei den Indizes 0 und 1. Hierfür positionieren wir einen passenden Wächter:

int fib( int n )
{
  if( n <= 1 )
    return n;
 
  return fib( n-1 ) + fib( n-2 );
}

Wird die Funktion fib mit den Werten 0 oder 1 für n gerufen, so wird 0 bzw. 1 zurückgegeben. Das passt also. Schauen wir uns den Aufruf für 2 an, ruft sie sich selbst für die Werte 1 und 0 und addiert die Rückgaben: für fib(2) erhalten wir also 1.

Das ganze als vollständiges Programm:

#include <stdio.h>
 
int fib( int n )
{
  if( n <= 1 )
    return n;
 
  return fib( n-1 ) + fib( n-2 );
}
 
int main( void )
{
  int index = 0;
 
  while( index <= 10 )
  {
    printf( "fib( %d ) => %d\n", index, fib( index ));
 
    index = index + 1;
  }
 
  return 0;
}

Wir bekommen folgende Ausgabe:

fib( 0 ) => 0
fib( 1 ) => 1
fib( 2 ) => 1
fib( 3 ) => 2
fib( 4 ) => 3
fib( 5 ) => 5
fib( 6 ) => 8
fib( 7 ) => 13
fib( 8 ) => 21
fib( 9 ) => 34
fib( 10 ) => 55

Und das entspricht ja - wie gewünscht - genau der Fibonacci-Folge.

So gemächlich die Fibunacci-Folge erstmal aussieht, sie steigt sehr schnell an. Und diese Form der Implementierung sorgt dafür, dass damit ein moderner Rechner relativ schnell an seine Grenzen stößt.

Übergabekosten

Es ist nicht „umsonst“, Argumente an eine Funktion zu übergeben. Keine Angst, es kostet natürlich kein Geld. Nun kommt die Frage auf: Was kostet es denn dann?

Beim Aufruf unserer Funktion add, werden die Werte der beiden Variablen left und right an die Funktion übergeben. Dabei werden die Werte aus den Speicherstellen, die durch die Variablen definiert sind in neue, extra für die Funktion add bereitgestellte Speicherbereiche kopiert. Diese Übergabe der Funktionsargumente nennt man wie bereits erwähnt Call by Value.

Das ist an sich nichts Schlimmes. Es werden dann eben (je nach Computer) 4 oder 8 Byte mehr Speicher benötigt. Bei der Verwendung von Arrays mit vielen Elementen, großen Strukturen oder tiefen Rekursionen, kann der Speicherverbrauch aber deutlich ansteigen. Wie man dieses Problem vermeidet, werden wir im Kapitel zu Zeiger lernen.

Ziel dieser Lektion

Du solltest nun in der Lage sein, die Signatur einer Funktion, die Deklaration und die Definition einer Funktion zu unterscheiden. Du hast gelernt, dass C Parameter beim Funktionsaufruf kopiert (Fachbegriff Call-by-Value).

Die Bedeutung von Expressions und Werten sollte Dir weiterhin bewusst sein.

Wir werden in den kommenden Kapiteln noch eine Vielzahl von Funktionen schreiben, so dass Dir der Aufbau von Funktionen mit anderen Parametern und Rückgabeparametern sicherlich bald in Fleisch und Blut übergeht, doch Du solltest den grundsätzlichen Aufbau einer Funktion verstanden haben und wissen, dass Funktionen andere Funktionen rufen können (main() ruft fib()) und sich selbst rufen können (fib() ruft fib()). Wenn Funktionen sich selbst rufen, nennt man dies einen rekursiven Aufruf. Rekursive Funktionen brauchen einen Rekursionsanker, also eine Bedingung, die dafür sorgt, dass sich die Funktion irgendwann aufhört, sich selbst zu rufen.

In der nächsten Lektion werden wir uns mit Arrays beschäftigen.