Skip to main content
C · lesson 7 of 25
C beginner Lesson 7 of 25

Functions in C

C function prototypes, pass-by-value, output parameters, recursion, static, inline and variadic functions, all shown with real gcc output.

By SkillByExample · Updated

Function Basics

A function has a return type, a name, a parameter list and a body. A prototype at the top of the file lets main call functions defined further down:

#include <stdio.h>

/* Prototypes: signatures declared before use */
int add(int a, int b);
double average(const double *arr, int n);

int main(void) {
    printf("3 + 7 = %d\n", add(3, 7));

    double data[] = {4.0, 8.0, 15.0, 16.0, 23.0, 42.0};
    int len = sizeof data / sizeof data[0];
    printf("Average: %.2f\n", average(data, len));
    return 0;
}

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

double average(const double *arr, int n) {
    if (n <= 0) return 0.0;
    double sum = 0.0;
    for (int i = 0; i < n; i++) sum += arr[i];
    return sum / n;
}
$ gcc -Wall -Wextra -std=c17 -o basics basics.c
$ ./basics
3 + 7 = 10
Average: 18.00

The array’s length is computed in main, where data is still a real array, and passed in separately. Inside average, arr is only a pointer, so sizeof can’t recover the count there. const promises the function won’t modify the caller’s data.

Calling a Function Before It’s Declared

Remove the prototype and call add before its definition:

#include <stdio.h>

int main(void) {
    printf("%d\n", add(3, 7));   /* add is not declared yet */
    return 0;
}

int add(int a, int b) {
    return a + b;
}
$ gcc -Wall -Wextra -std=c17 -o noproto noproto.c
noproto.c: In function 'main':
noproto.c:4:20: error: implicit declaration of function 'add' [-Wimplicit-function-declaration]
    4 |     printf("%d\n", add(3, 7));   /* add is not declared yet */
      |                    ^~~
$ gcc -std=c89 -o noproto89 noproto.c
$ ./noproto89
10

Under C89 rules, the compiler guessed that an unknown function returns int and takes whatever arguments it’s given, and here the guess happened to be right. In -std=c89 mode, GCC 14 still builds it. C99 removed the rule, and GCC 14 turns it into a hard error. Older tutorials, and older compilers, may treat this as a mere warning. The guess was dangerous: a function returning double or a pointer would have been silently misread as an int.

Parameters Are Copies

C passes every argument by value. The function gets its own copy, so to change the caller’s variable you pass its address:

#include <stdio.h>

void try_modify(int x) {
    x = 999;             /* changes the local copy only */
}

void actually_modify(int *x) {
    *x = 999;            /* writes through the pointer to the caller's variable */
}

int main(void) {
    int val = 42;
    try_modify(val);
    printf("after try_modify:      %d\n", val);
    actually_modify(&val);
    printf("after actually_modify: %d\n", val);
    return 0;
}
$ gcc -Wall -Wextra -std=c17 -o byvalue byvalue.c
byvalue.c: In function 'try_modify':
byvalue.c:3:21: warning: parameter 'x' set but not used [-Wunused-but-set-parameter]
    3 | void try_modify(int x) {
      |                 ~~~~^
$ ./byvalue
after try_modify:      42
after actually_modify: 999

The compiler spotted the bug before the program ran. Assigning to x and then never reading it can’t affect anything, and -Wextra says so. actually_modify receives a copy too, but a copy of an address, and writing through *x reaches the original val.

Returning More Than One Value

A function returns one value. For more, pass pointers the function can write into. That leaves the return value free to report success or failure:

#include <stdio.h>
#include <stdbool.h>

/* Returns false on error; results go out through the pointer parameters */
bool divide(int a, int b, int *quotient, int *remainder) {
    if (b == 0) return false;
    *quotient  = a / b;
    *remainder = a % b;
    return true;
}

int main(void) {
    int q, r;
    if (divide(17, 5, &q, &r)) {
        printf("17 / 5 = %d remainder %d\n", q, r);
    }
    if (!divide(17, 0, &q, &r)) {
        printf("17 / 0 rejected\n");
    }
    return 0;
}
$ gcc -Wall -Wextra -std=c17 -o divide divide.c
$ ./divide
17 / 5 = 3 remainder 2
17 / 0 rejected

This “status return plus output parameters” shape is the standard C API pattern. strtol, fread and most POSIX functions follow some version of it. The caller must check the return value before trusting q and r, because on failure they are left untouched.

void Functions and Early Return

A void function does something without producing a value. A bare return; exits early:

#include <stdio.h>

void print_separator(char ch, int width) {
    for (int i = 0; i < width; i++) putchar(ch);
    putchar('\n');
}

void print_table(int rows, int cols) {
    if (rows <= 0 || cols <= 0) return;   /* early exit, nothing to print */
    print_separator('-', cols * 5);
    for (int r = 1; r <= rows; r++) {
        for (int c = 1; c <= cols; c++) printf("%4d ", r * c);
        printf("\n");
    }
    print_separator('-', cols * 5);
}

int main(void) {
    print_table(3, 4);
    print_table(0, 4);
    printf("(the second call printed nothing)\n");
    return 0;
}
$ gcc -Wall -Wextra -std=c17 -o table table.c
$ ./table
--------------------
   1    2    3    4 
   2    4    6    8 
   3    6    9   12 
--------------------
(the second call printed nothing)

The guard clause at the top of print_table handles bad input in one line, so the rest of the function can assume sensible values. Small functions like print_separator pay off quickly: the table calls it twice, and a future report can reuse it.

Recursion

A recursive function solves a problem by calling itself on a smaller version of it, and needs a base case that stops the chain:

#include <stdio.h>

long long factorial(int n) {
    if (n <= 1) return 1;            /* base case */
    return n * factorial(n - 1);     /* smaller subproblem */
}

void hanoi(int n, char from, char to, char via) {
    if (n == 0) return;
    hanoi(n - 1, from, via, to);     /* clear the n-1 disks out of the way */
    printf("move disk %d %c -> %c\n", n, from, to);
    hanoi(n - 1, via, to, from);     /* stack them back on top */
}

int main(void) {
    printf("5! = %lld, 20! = %lld\n", factorial(5), factorial(20));
    hanoi(3, 'A', 'C', 'B');
    return 0;
}
$ gcc -Wall -Wextra -std=c17 -o recursion recursion.c
$ ./recursion
5! = 120, 20! = 2432902008176640000
move disk 1 A -> C
move disk 2 A -> B
move disk 1 C -> B
move disk 3 A -> C
move disk 1 B -> A
move disk 2 B -> C
move disk 1 A -> C

20! is the largest factorial that fits in a 64-bit long long. 21! would overflow, which is undefined behavior for a signed type. Three disks take 2³ − 1 = 7 moves. Towers of Hanoi is the classic case where recursion is much clearer than any loop.

The Hidden Cost of Naive Recursion

Recursion is clear, but it isn’t free. Counting the calls in textbook Fibonacci shows how fast the work grows:

#include <stdio.h>

static long calls = 0;

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

int main(void) {
    int ns[] = {10, 20, 30};
    for (int i = 0; i < 3; i++) {
        calls = 0;
        int f = fibonacci(ns[i]);
        printf("fib(%d) = %d  (%ld calls)\n", ns[i], f, calls);
    }
    return 0;
}
$ gcc -Wall -Wextra -std=c17 -o fib fib.c
$ ./fib
fib(10) = 55  (177 calls)
fib(20) = 6765  (21891 calls)
fib(30) = 832040  (2692537 calls)

Adding 10 to n multiplies the work by about 123. Each call spawns two more, and the same values are recomputed over and over (fib(28) alone is computed twice for fib(30)). Practice 4 below replaces this with a loop that needs 30 steps instead of 2.7 million calls.

Deep Recursion and the Stack

Each call uses a stack frame, and the stack is only a few megabytes. A tail-recursive function makes its recursive call as the very last step, which lets the optimizer reuse the frame, but only if optimization is on:

#include <stdio.h>
#include <stdlib.h>

/* Tail-recursive: the recursive call is the very last thing done */
long long sum_tail(long long n, long long acc) {
    if (n == 0) return acc;
    return sum_tail(n - 1, acc + n);
}

int main(int argc, char *argv[]) {
    long long n = argc > 1 ? atoll(argv[1]) : 10;
    printf("sum 1..%lld = %lld\n", n, sum_tail(n, 0));
    return 0;
}
$ gcc -Wall -Wextra -std=c17 -o tail tail.c
$ ./tail 10
sum 1..10 = 55
$ ./tail 1000000
Segmentation fault (core dumped)
$ echo $?
139
$ gcc -Wall -Wextra -std=c17 -O2 -o tail tail.c
$ ./tail 10000000
sum 1..10000000 = 50000005000000

The unoptimized build crashed at a depth of one million. Its million stack frames overran the default 8 MB stack, and the kernel killed it with SIGSEGV, reported as exit status 139 (128 + signal 11). The -O2 build handled ten times the depth, because GCC turned the tail call into a jump. The C standard never promises this optimization, so code whose recursion depth depends on input size should use a loop.

Static Functions

A static function is private to its source file. It’s C’s way of hiding helpers behind a public API:

/* math_utils.c */
#include <math.h>

/* Private helper: invisible outside this file */
static double clamp(double val, double lo, double hi) {
    if (val < lo) return lo;
    if (val > hi) return hi;
    return val;
}

/* Public function */
double safe_sqrt(double x) {
    return sqrt(clamp(x, 0.0, 1e308));
}
/* main.c */
#include <stdio.h>

double safe_sqrt(double x);   /* normally from math_utils.h */

int main(void) {
    printf("safe_sqrt(16) = %.1f\n", safe_sqrt(16.0));
    printf("safe_sqrt(-4) = %.1f\n", safe_sqrt(-4.0));
    return 0;
}
$ gcc -Wall -Wextra -std=c17 -o program main.c math_utils.c -lm
$ ./program
safe_sqrt(16) = 4.0
safe_sqrt(-4) = 0.0

Suppose another file declares double clamp(double, double, double); and calls it directly:

$ gcc -Wall -Wextra -std=c17 -o bad main_bad.c math_utils.c -lm
/usr/bin/ld: /tmp/ccJ17dPF.o: in function `main':
main_bad.c:(.text+0x21): undefined reference to `clamp'
collect2: error: ld returned 1 exit status

The declaration let the call compile, but the linker found no public clamp to connect it to. Marking helpers static also frees their names: another file can define its own clamp without a clash.

Inline Functions (C99)

static inline suggests that the compiler paste the body in at each call site. You get the speed of a macro with real type checking:

#include <stdio.h>

static inline int max(int a, int b) { return (a > b) ? a : b; }
static inline int min(int a, int b) { return (a < b) ? a : b; }

static inline int clamp_int(int val, int lo, int hi) {
    return max(lo, min(val, hi));
}

int main(void) {
    printf("%d\n", clamp_int(150, 0, 100));
    printf("%d\n", clamp_int(-5, 0, 100));
    printf("%d\n", clamp_int(50, 0, 100));
    return 0;
}
$ gcc -Wall -Wextra -std=c17 -o inline inline.c
$ ./inline
100
0
50

inline is only a hint. At -O2, GCC inlines small functions like these whether or not you ask. The practical reason to write static inline is for small helpers in header files, where it lets every file include the definition without “multiple definition” link errors. Unlike a macro such as #define MAX(a, b) ((a) > (b) ? (a) : (b)), each argument is evaluated exactly once.

Variadic Functions

A variadic function takes a variable number of arguments, like printf. The <stdarg.h> macros walk through them, but nothing checks their types:

#include <stdio.h>
#include <stdarg.h>

int sum(int count, ...) {
    va_list args;
    va_start(args, count);         /* start after the last named parameter */
    int total = 0;
    for (int i = 0; i < count; i++) {
        total += va_arg(args, int);  /* caller must really pass ints */
    }
    va_end(args);
    return total;
}

int main(void) {
    printf("%d\n", sum(3, 10, 20, 30));
    printf("%d\n", sum(5, 1, 2, 3, 4, 5));
    printf("%d\n", sum(3, 1, 2.5, 3));   /* a double sneaks in */
    return 0;
}
$ gcc -Wall -Wextra -std=c17 -o variadic variadic.c
$ ./variadic
60
15
4

The last call compiled without a single warning and returned 4, not 6 or 6.5. Reading a double with va_arg(args, int) is undefined behavior. On x86-64 Linux the double travels in a floating-point register, so the three int reads got 1, then 3, then whatever sat in the next integer slot (0 on this run). printf avoids this because its format string tells it each argument’s type, and GCC checks printf calls against the format string. Your own variadic functions get no such check.

Practice

  1. Write swap(int *a, int *b) and use it to swap x = 3 and y = 8.
Solution
#include <stdio.h>

void swap(int *a, int *b) {
    int tmp = *a;
    *a = *b;
    *b = tmp;
}

int main(void) {
    int x = 3, y = 8;
    swap(&x, &y);
    printf("x = %d, y = %d\n", x, y);
    return 0;
}
$ gcc -Wall -Wextra -std=c17 -o p1 p1.c
$ ./p1
x = 8, y = 3

A swap(int a, int b) version would swap two local copies and leave x and y untouched. It’s the same trap as try_modify above.

  1. Write a recursive power(base, exp) for non-negative exp.
Solution
#include <stdio.h>

int power(int base, int exp) {
    if (exp == 0) return 1;
    return base * power(base, exp - 1);
}

int main(void) {
    printf("2^10 = %d\n", power(2, 10));
    printf("3^4  = %d\n", power(3, 4));
    printf("7^0  = %d\n", power(7, 0));
    return 0;
}
$ gcc -Wall -Wextra -std=c17 -o p2 p2.c
$ ./p2
2^10 = 1024
3^4  = 81
7^0  = 1

The base case exp == 0 returns 1, which also makes 7^0 correct. Without it, the recursion would run off into negative exponents until the stack overflowed.

  1. Write min_max that finds both the smallest and largest element of an array in one pass, returning them through pointers.
Solution
#include <stdio.h>

void min_max(const int *arr, int n, int *min, int *max) {
    *min = *max = arr[0];
    for (int i = 1; i < n; i++) {
        if (arr[i] < *min) *min = arr[i];
        if (arr[i] > *max) *max = arr[i];
    }
}

int main(void) {
    int data[] = {7, -3, 12, 0, 5};
    int lo, hi;
    min_max(data, 5, &lo, &hi);
    printf("min = %d, max = %d\n", lo, hi);
    return 0;
}
$ gcc -Wall -Wextra -std=c17 -o p3 p3.c
$ ./p3
min = -3, max = 12

Starting from arr[0] rather than 0 matters. An initial max of 0 would give the wrong answer for an all-negative array. This version assumes n >= 1. A production version would return a status for an empty array, like divide does.

  1. Rewrite Fibonacci as a loop and compute fib(90), which the recursive version could never finish.
Solution
#include <stdio.h>

long long fib_iter(int n) {
    long long a = 0, b = 1;
    for (int i = 0; i < n; i++) {
        long long next = a + b;
        a = b;
        b = next;
    }
    return a;
}

int main(void) {
    printf("fib(30) = %lld\n", fib_iter(30));
    printf("fib(90) = %lld\n", fib_iter(90));
    return 0;
}
$ gcc -Wall -Wextra -std=c17 -o p4 p4.c
$ ./p4
fib(30) = 832040
fib(90) = 2880067194370816120

fib(30) matches the recursive result. The loop needs 30 iterations where the recursion made 2,692,537 calls. The naive version makes 2·fib(n+1) − 1 calls (check it: 2 × 1,346,269 − 1 = 2,692,537 for n = 30), so fib(90) would take it about 9.3 quintillion calls. The loop finishes instantly.

Frequently Asked Questions

What is a function prototype and why do I need it?
A prototype declares a function's name, return type and parameter types before the function is used, so the compiler can check every call. Old C89 compilers guessed that an undeclared function returned int; since C99 that is invalid, and GCC 14 rejects it as an error: implicit declaration of function.
Does C support default parameter values?
No. Every argument must be passed explicitly. You can approximate defaults with a wrapper function that fills in the missing values, or by passing a struct of options, but there is no language-level default parameter feature.
What is the difference between pass by value and pass by reference in C?
C is always pass by value: the function receives a copy of each argument, so changing a parameter never changes the caller's variable. To let a function modify a caller's variable, pass its address and write through the pointer. That is still pass by value; the value being copied is the address.
Does C guarantee tail call optimization?
No. The standard says nothing about it. GCC and Clang usually turn a tail call into a jump at -O2, but at -O0 every recursive call still uses a new stack frame, so deep recursion that works in a release build can crash with a segmentation fault in a debug build. Use a loop when the depth can be large.