Funktionen
Video zum Kapitel

Syntax
In C-Programmen stehen einem, neben der einzelnen Datei, nur die Funktionen als Strukturierungsmöglichkeit zur Verfügung. D. h. C-Programme bestehen aus Funktionen, die Daten bekommen und Daten zurückgeben.
Die Syntax für die Deklaration von C-Funktionen ist identisch zu der von Methoden in Java – kein Wunder, da Java sich die Syntax hier einfach bei C abgeschaut hat.
- Syntax ist identisch zur Methodendeklaration in Java
RETURN_TYPE NAME(TYPE PARAM, TYPE PARAM, ...) {
RUMPF
}
- Wert wird mit
return
zurück gegeben - hat die Funktion keinen Rückgabewert, so ist deklariert man den Rückgabetyp
void
int add(int a, int b) {
return a + b;
}
Was in C natürlich fehlt ist die Angabe einer Sichtbarkeit mit public
, protected
und private
, wie wir sie aus Java kennen. Mit dem Schlüsselwort static
(siehe unten) kann man die Sichtbarkeit einer Funktion auf die aktuelle Quelltextdatei beschränken. Ohne Zusatz sind Funktionen immer global sichtbar.
Leere Parameterliste
Anders als in Java, wird eine Funktion mit leerer Parameterliste nicht mit zwei leeren Klammern ()
deklariert, sondern die Abwesenheit von Parametern muss durch das Schlüsselwort void
angezeigt werden.
Wenn eine Funktion einfach nur mit einer leeren Liste ()
deklariert wird, bedeutet dies, dass diese Funktion beliebig viele Parameter nehmen kann.
An dieser Stelle weicht C von C++ ab, denn C++ hält sich an den Java-Stil und signalisiert fehlende Parameter mit ()
.
Achtung: Abweichung zu Java
func()
⇒ Funktion mit beliebig vielen Parameternfunc(void)
⇒ Funktion ohne Parameter
int f1() { } /* beliebig viele Parameter */
int f2(void) { } /* keine Paramater */
int main(int argc, char** argv) {
f1();
f1(1, 2); /* <- Kein Compile-Fehler */
f();
f2(1, 2); /* <- Fehler */
}
Dieser Mechanismus ist auch der Grund, warum man manchmal C-Programme sieht, bei denen die main
-Funktion als int main()
deklariert ist und nicht als int main(int argc, char** argv)
. Hier interessieren die Parameter nicht und man lässt sie bei der Definition der Funktion einfach weg.
Es ist im Rahmen einer defensiven C-Programmierung sinnvoll, immer alle Funktionen ohne Parameter mit void
zu deklarieren, damit der Compiler falsche Aufrufe sofort erkennt und meldet.
Deklaration vs. Definition
Java kennt keine strenge Unterscheidung zwischen der Deklaration und Definition einer Funktion. In C sind diese beiden Begriffe aber streng auseinanderzuhalten. Eine Deklaration macht die Funktion nur bekannt und kann beliebig oft erfolgen, eine Definition liefert hingegen ihren Funktionsrumpf – also ihre Implementierung – und darf nur einmal in einem Programm vorkommen.
- (Funktions-) Deklaration (aka Prototyp) macht dem Compiler bekannt
- den Namen der Funktion
- den Rückgabetyp
- die Parameter
- (Funktions-) Definition liefert die Implementierung einer Funktion
- Funktion kann
- beliebig oft deklariert
- nur einmal definiert werden
- durch die Definition wird die Funktion auch deklariert
Diese Unterscheidung wurde schon früher diskutiert.
Anders als in Java müssen in C die Funktionen vor ihrer Benutzung deklariert sein das heißt der Compiler schaut nicht weiter unten im Quelltext, ob dort noch irgendwo die Funktion vorkommt. Eine Definition liefert auch gleichzeitig eine Deklaration der Funktion.
- Die Deklaration muss vor der Benutzung erfolgen
- ist die Funktion vor der Benutzung definiert, muss sie nicht mehr deklariert werden
- Deklarationen finden sich meist in
.h
-Dateien - Definitionen finden sich meist in
.c
-Dateien
int add(int a, int b);
int add(int a, int b) {
return a + b;
}
Im folgenden Beispiel wird die add
-Funktion von calc()
bereits vor deren Definition verwendet. Deswegen muss eine Deklaration erfolgen. Andernfalls käme es zu einem Compiler-Fehler.
/* Deklaration */
int add(int a, int b);
/* Benutzung */
void calc(void) {
int result;
result = add(5, 7);
}
/* Definition nach der Benutzung */
int add(int a, int b) {
return a + b;
}
Da die Definition einer Funktion auch gleichzeitig deren Deklaration beinhaltet, muss in diesem Beispiel keine Deklaration mehr erfolgen: add
ist vor der Benutzung bereits definiert und damit deklariert.
/* Definition vor der Benutzung, keine Deklaration nötig */
int add(int a, int b) {
return a + b;
}
/* Benutzung */
void calc() {
int result;
result = add(5, 7);
}
Aufteilung .c und .h-Datei
Wie bereits diskutiert, ist es in C üblich die Deklaration und Definition auf verschiedene Dateien aufzuteilen. Die Deklarationen findet man üblicherweise in den .h
-Dateien (Header-Files) die Definitionen in den dazugehörigen .c
-Dateien.
Wenn eine Funktion nur innerhalb einer Quelltextdatei benötigt wird und diese auch nicht an andere Teile des Programms exportiert werden soll, nimmt man sie nicht in die Header-Datei auf.
- Deklarationen finden sich in
.h
-Dateien, Definitionen in.c
-Dateien .c
-Datei sollte ihre korrespondierende.h
-Datei selbst inkludieren
(verhindert Abweichungen Definition ↔ Deklaration)
#ifndef GREETER_H
#define GREETER_H
/* Enum für die verschiedenen Tageszeiten. */
typedef enum { MORGEN, ABEND } TagesZeit;
/*
* Grüßt die als n übergebene Person passend zur Tageszeit z.
*/
void greet(TagesZeit z, char* n);
#endif
Die Header-Datei enthält die Deklarationen der Funktion greet()
und einer von dieser Funktion verwendeten Enumeration TagesZeit
. Damit stellt sie alle Informationen zur Verfügung, die ein Nutzer der Funktion benötigt.
#include "greeter.h"
#include <stdio.h>
void greet(TagesZeit zeit, char* name) {
printf("%s %s", zeit == MORGEN ? "Guten Morgen" : "Guten Abend", name);
}
#include "greeter.h"
int main(int argc, char** argv) {
greet(ABEND, "Thomas");
}
In der dazugehörigen .c
-Datei finden sich die Implementierung der Funktion (ihre Definition). Der Verwender der Funktionalität user.c
inkludiert die Header-Datei und kennt somit die Deklaration der Funktion greet()
. Deren Definition ist unwichtig und wird vom Linker später hinzugefügt.
Pass-by-Value
Beim Aufruf einer Funktion in C werden die Parameter als Pass-By-Value übergeben. Damit entspricht das Verhalten von C exakt dem von Java: Alle Werte werden beim Funktionsaufruf kopiert. Möchte man stattdessen die Inhalte der Variablen in der Funktion verändern, also ein Pass-By-Reference durchführen, muss man dies mit Pointern simulieren. Java verhält sich hier genauso, nur dass Objekte als Referenzen übergeben werden, die beim Funktionsaufruf kopiert werden.
- Alle Funktionsparameter werden per Pass-By-Value übergeben
- Mit Pointern kann man aber jederzeit Pass-By-Reference simulieren
void swap(int a, int b) {
int tmp = a;
a = b;
b = tmp;
}
int main(int argc, char** argv) {
int a = 42, b = 23;
swap(a, b);
printf("a=%d, b=%d", a, b); /* a=42, b=23 */
}
Die swap
-Funktion funktioniert nicht, weil die Integer-Werte a
und b
innerhalb der Funktion Kopien der Werte von main
sind.
Will man, dass die Funktion die Werte vertauschen kann, muss man Pointer verwenden.
void swap(int* a, int* b) {
int tmp = *a;
*a = *b;
*b = tmp;
}
int main(int argc, char** argv) {
int a = 42, b = 23;
swap(&a, &b);
printf("a=%d, b=%d", a, b); /* a=23, b=42 */
}
Durch die Verwendung eines Pointers, sind in der Funktion keine Kopien der Werte vorhandne, sondern die Funktion kennt die Speicheradressen der Variablen. So kann sie deren Inhalte vertauschen.
static
Das Schlüsselwort static
ist für Java-Programmierer besonders verwirrend, weil es in C eine völlig andere Bedeutung hat: Während es in Java dafür sorgt, dass Variablen und Methoden an der Klasse hängen und nicht mehr am Objekt, schränkt des in C bei globalen Variablen und Funktionen deren Sichtbarkeit ein.
- Werden Funktionen oder globale Variablen als
static
gekennzeichnet, dann sind sie außerhalb der Datei nicht sichtbar (→protected
in Java)
static char gruss[] = "Guten Tag";
static void gruesse(void) {
printf("%s\n", gruss);
}
Deklariert man in C eine lokale Variabel als der static
, dann handelt es sich gar nicht um eine lokale Variable, sondern um eine globale Variable, deren Sichtbarkeit auf diese Funktion beschränkt ist. Wie alle globalen Variablen wird sie nicht auf dem Stack, sondern im Daten-Segment alloziert. Die Initialisierung der Variabel in der Funktion wird nur ein einziges Mal, beim ersten Aufruf durchgeführt.
- Lokale Variablen mit
static
werden nicht auf dem Stack alloziert, sondern im Daten-Segment und werden nur einmal initialisiert
#include <stdio.h>
void counter(void) {
static int c = 1;
printf("%d\n", c++);
}
int main(void) {
counter(); /* 1 */
counter(); /* 2 */
counter(); /* 3 */
}
In dem Beispiel sieht man deutlich, dass die Variable c
über die Funktionsaufrufe hinweg ihren Wert behält. Beim ersten Aufruf von counter()
wird sie mit dem Wert 1 initialisiert, bei allen weiteren Aufrufen findet Initialisierung aber nicht mehr statt, sodass sie ihren Wert über die Funktionsaufrufe hinweg behält.
Schlüsselwort const
Ein Schlüsselwort in C, das es in Java überhaupt nicht gibt, ist const
. In Java hat man darauf verzichtet, weil man es als zu komplex angesehen hat und sich stattdessen für final
entschieden, das eine andere Semantik als const
hat. Mit const
kann man in C kennzeichnen, dass eine Variabel nicht verändert werden darf.
Relativ kompliziert wird die Verwendung von const
, wenn es sich um Pointer handelt: Das const
kann sich entweder auf den Pointer beziehen oder aber auf das Objekt auf das der Pointer zeigt oder sogar auf beides.
- Mit
const
kann deklariert werden, dass ein Argument nicht geändert wird - bei Funktionen meist nur sinnvoll für Pointer (wg. Pass-by-Value)
- bei Pointern muss man unterscheiden
- Pointer auf einen konstanten Wert: Pointer kann geändert werden, Wert nicht
const int *ptr
oderint const *ptr
- Konstanter Pointer auf einen Wert: Pointer kann nicht geändert werden, Wert jedoch schon
int *const ptr
- Konstanter Pointer auf einen konstanten Wert: Weder Pointer noch Wert können geändert werden
const int *const ptr
Bei Funktionsparametern ist const
im Allgemeinen nur bei Pointern sinnvoll, weil die Parameter ohnehin by-value übergeben werden und const
hier wenig bringt.
int i = 10, j = 20;
const int *ptr = &i; /* Pointer auf konstanten Wert */
ptr = &j; /* Ok */
*ptr = 100; /* Fehler */
int i = 10, j = 20;
int *const ptr = &i; /* Konstanter Pointer */
ptr = &j; /* Fehler */
*ptr = 100; /* Ok */
int i = 10, j = 20;
const int *const ptr = &i; /* Konstanter Pointer */
ptr = &j; /* Fehler */
*ptr = 100; /* Fehler */
Da der Compiler bei const
deklarierten Objekten Optimierungen durchführen kann, z. B. die Daten in Read-Only-Speicher legen, ist es verboten ein const
einfach durch einne Cast zu entfernen.
const
- darf nicht durch einen Cast entfernt werden, wenn das Original-Datenobjekt als
const
deklariert wurde - führt zu undefined behavior
int i = 12;
const int j = 12;
const int *ip = &i;
const int *jp = &j;
*(int *)ip = 42; /* OK aber nicht schön */
*(int *)jp = 42; /* undefined behavior */
Die Abgründe des undefined behaviors werden weiter unten noch diskutiert und näher betrachtet.
Schlüsselwort extern
Den Unterschied zwischen der Deklaration und der Definition einer Funktion kann der Compiler am fehlenden Rumpf der Funktion erkennen, sodass kein spezielles Schlüsselwort benötigt wird, um die Fälle zu unterscheiden. Deswegen wird hier das Schlüsselwort extern
nicht benötigt und der Compiler fügt es automatisch hinzu.
Bei Variablen gibt es keine Möglichkeit, ohne zusätzliches Schlüsselwort, die Deklaration von der Definition zu unterscheiden. Deswegen muss hier bei der Deklaration extern
eingesetzt werden.
- Mit
extern
kann man Variablen deklarieren, die an anderer Stelle (anderer Datei) deklariert werden extern
-Deklaration führt keine Initialisierung durch
extern char buffer[]; /* kein Speicher reservieren */
int main(int argc, char** argv) {
printf("%s\n", buffer);
}
char buffer[] = "Hallo Welt!"; /* Speicher reservieren */
Eine Variable muss – genauso wie eine Funktion – vor ihrer Benutzung deklariert werden. Wenn eine Variable, die in einer anderen Datei (Compilationseinheit) definiert wurde, benutzt werden soll, muss man sie entsprechend deklarieren. Durch das Schlüsselwort extern
vor der Variable wird angezeigt, dass kein Speicher reserviert werden soll, da dies an anderer Stelle bei der Definition erfolgt.
In diesem Beispiel wird buffer
in file1.c
nur deklariert und in file2.c
dann definiert. Der Linker kümmert sich am Ende darum, dass die main
-Funktion auf die richtige Variable im Speicher zugreift.
Die Objektdateien enthalten die notwendigen Informationen zu den erwarteten bzw. bereitgestellten Variablen wieder in Form von Exports und Imports.
$ rabin2 -i file1.o
[Imports]
nth vaddr bind type lib name
4 ---------- GLOBAL NOTYPE buffer
5 ---------- GLOBAL NOTYPE puts
$ rabin2 -E file2.o
[Exports]
nth paddr vaddr bind type size lib name demangled
2 0x00000040 0x08000040 GLOBAL OBJ 12 buffer
In den Expoers von file2.o
kann man erkennen, dass die Größe der Variable dem Linker über die Exports bekannt gegeben wird.
Funktionspointer
C hat – anders als Java – von Anfang an Funktionen als „first class citizens“ betrachtet, sodass man Funktionen wie jedes andere Datenobjekt verwenden kann. Das Mittel dazu sind Funktionspointer, d. h. Pointer, die auf Funktionen zeigen. Diese Pointer können wie alle anderen Pointer auch übergeben und zugewiesen werden. Pointer-Arithmetik scheidet aus naheliegenden Gründen aus.
Leider ist die Syntax für die Deklaration von Variablen, die Funktionspointer enthalten können etwas sehr kompliziert ausgefallen, sodass es sogar Webseiten gibt, die die Syntax übersetzen.
Funktionen können über Funktionspointer wie Daten übergeben und verwaltet werden
int func()
: Funktion, die einenint
zurückgibtint *func()
: Funktion, die einen Pointer auf einenint
zurückgibtint (*func)()
: Pointer auf eine Funktion, die einenint
zurückgibtint *(*func)()
: Pointer auf eine Funktion, die einen Pointer auf einenint
zurückgibtint *(*func)(char*)
: Pointer auf eine Funktion, die einen Pointer auf einenint
zurückgibt und einen Pointer aufchar
als Parameter hat
Über den Aufrufoperator ()
kann die Funktion, auf die der Funktionspointer zeigt, dann aufgerufen werden.
int rechne(int (*func)(int a, int b), int a, int b) {
return (*func)(a, b);
}
int add(int a, int b) { return a + b; }
int sub(int a, int b) { return a - b; }
int main(int argc, char** argv) {
int r;
r = rechne(add, 2, 17); /* 19 */
printf("%d\n", r);
r = rechne(sub, 2, 17); /* -15 */
printf("%d\n", r);
}
Das Beispiel verwendet Funktionspointer, um der Funktion rechne()
zur Laufzeit eine beliebige Implementierung einer arithmetischen Operation zu übergeben.
Hier einmal dasselbe Beispiel in Java unter Verwendung von Lambdas:
import java.util.function.IntBinaryOperator;
public class Rechner {
public static int rechne(IntBinaryOperator func, int a, int b) {
return func.applyAsInt(a, b);
}
public static void main(String[] args) {
IntBinaryOperator sub = (a, b) -> a - b;
IntBinaryOperator add = (a, b) -> a + b;
int r;
r = rechne(add, 2, 17);
System.out.println(r); /* 19 */
r = rechne(sub, 2, 17);
System.out.println(r); /* -15 */
}
}
Varag-Funktionen
In C ermöglichen vararg-Funktionen die Definition von Funktionen mit einer variablen Anzahl von Argumenten. Das bedeutet, dass eine Funktion eine unterschiedliche Anzahl von Argumenten akzeptieren kann, die zur Laufzeit übergeben werden.
Da das Konzept von Funktionen mit variabler Anzahl von Parameters keine Fähigkeit der Prozessoren ist, C aber ja direkt auf der Hardware läuft, muss dieses Feature recht umständlich durch gewisse Tricks realisiert werden. Eine direkte Unterstützung auf der Hardwareseite gibt es nicht.
Vararg-Funktionen
- Syntax
funktionsname(PARAM, [PARAM], ...)
- Ellipse
...
zeigt die variablen Parameter an - Variable Anzahl von Parametern (vgl.
printf
) - Müssen mindestens einen „normalen“ Parameter haben
- Müssen über
va_start
,va_arg
,va_end
undva_list
aus<stdarg.h>
realisiert werden
⇒ kein Teil der Sprache
Die Anforderung an einen „normalen“ Parameter kommt daher, dass der Stack durchwandert werden muss, um die weiteren Parameter einzusammeln. Der normale Parameter liefert die Adresse, ab der diese Untersuchung stattfinden muss.
Als Konsequenz können die Parameter einer solchen Funktion nicht in Prozessorregistern übergeben werden, sondern müssen immer auf den Stack gelegt werden. Dies hat durchaus Auswirkungen auf die Performance der Funktionen.
va_list
Datentyp (Liste) für den Zugriff auf die Parameterva_start(va_list ap, last)
Initialisiert die Liste ausgehend vom letzten „normalen“ Parameterlast
type va_arg(va_list ap, type)
Liefert den nächsten Parameter. Typ isttype
va_end(va_list ap)
Gibt den Speicher für die Argumente frei
va_start
, va_arg
und va_end
sind keine C-Funktionen, sondern Makros. Dies erlaubt, dass man z. B. den Typ va_arg
angibt, was bei einer Funktion nicht möglich wäre.
va_start
: Dieses Makro initialisiert eineva_list
, die verwendet wird, um auf die variablen Argumente in der Funktion zuzugreifen. Es erwartet den Namen des letzten festen Arguments als Parameter.va_arg
: Dieses Makro ermöglicht den Zugriff auf den nächsten Parameter in derva_list
. Der zweite Parameter ist der Datentyp, auf den zugegriffen werden soll. Es gibt den Wert des Arguments zurück und aktualisiert dieva_list
, um auf das nächste Argument zu verweisen.va_end
: Dieses Makro muss aufgerufen werden, um dieva_list
nach der Verwendung zu bereinigen.
#include <stdio.h>
#include <stdarg.h>
void printNumbers(int count, ...) {
va_list args; /* Struktur für Argumente */
va_start(args, count); /* Vorgang beginnen */
for (int i = 0; i < count; i++) {
int num = va_arg(args, int); /* nächsten Wert holen */
printf("%d ", num);
}
va_end(args); /* Speicher freigeben */
}
In diesem Beispiel akzeptiert die Funktion printNumbers
eine variable Anzahl von Argumenten. Sie verwendet die va_list
, um auf die Argumente zuzugreifen, und druckt sie nacheinander aus. Die Anzahl der Argumente wird als erstes Argument count
übergeben.
Ausgabe: 10 20 30
- Keine Prüfung der Parameteranzahl oder Typen durch den Compiler
- Anzahl muss übergeben werden oder sich anderweitig ergeben
- Risiko von schwerwiegenden Fehlern
int main(int argc, char** argv) {
printNumbers(4, 10.9); /* falsche Anzahl */
return 0;
}
1407264456 1407264472 91233720 0
Die Verwendung von Vararg-Funktionen in C birgt einige Risiken und Herausforderungen, die berücksichtigt werden sollten:
- Mangelnde Typsicherheit: Vararg-Funktionen bieten keine Typsicherheit. Der Entwickler muss sicherstellen, dass die Argumente korrekt interpretiert werden, da dies nicht vom Compiler überprüft wird.
- Keine Informationen über die Anzahl der Argumente: Da die Anzahl der Argumente zur Laufzeit festgelegt wird, gibt es keine statische Prüfung auf die korrekte Anzahl der Argumente. Dies kann zu Fehlern führen, wenn die Funktion mit einer falschen Anzahl von Argumenten aufgerufen wird oder wenn die Reihenfolge der Argumente nicht richtig ist.
- Schwierigkeiten bei der Fehlersuche: Da der Compiler keine Überprüfung auf Typen oder Anzahl der Argumente durchführt, kann die Fehlersuche in Vararg-Funktionen schwierig sein.
- Plattform- und Compilerabhängigkeit: Die Implementierung von Vararg-Funktionen kann von Plattform zu Plattform und von Compiler zu Compiler variieren. Dies kann zu Inkompatibilitäten führen, insbesondere wenn der Code auf verschiedenen Systemen oder mit unterschiedlichen Compilern verwendet wird.