Functions in C

Functions are how C breaks a program into named, reusable, independently testable pieces. Everything else in this series of guides - macros, structs, file I/O - eventually gets wrapped inside a function, because functions are the actual unit of organization in a C program. This guide covers everything from the basic return_type name(params) { } shape all the way to function pointers, callbacks, and variadic functions.


0. Why they're needed, why industry leans on them, and why your syllabus barely covers half of this

Why they're needed at all

Without functions, a program is one long sequence of statements from top to bottom, with no way to run the same logic twice without literally retyping it, no way to give a chunk of logic a meaningful name, and no way to test a piece of behavior in isolation. Functions solve all three: reuse, abstraction (hiding how something works behind what it does), and decomposition (splitting a big problem into smaller, named, independently-checkable pieces).

Why real projects lean on them so heavily

Why your syllabus (probably) stopped early

Function pointers in particular are one of those features that feels completely abstract right up until the moment you use one to build a plugin system, a dispatch table, or a callback - and then suddenly C doesn't feel nearly as limited as "no first-class functions" made it sound.


1. Declaring, defining, and calling a basic function

A function has a return type, a name, a parameter list, and a body.

#include <stdio.h>

// Function definition: this IS the function, body and all
int add(int a, int b) {
    return a + b;
}

int main(void) {
    int result = add(3, 4);   // calling the function
    printf("%d\n", result);
    return 0;
}

return 0; inside main reports success to the operating system; a non-zero return typically signals an error (see section 20).


2. Declarations vs. definitions, and forward declaration

A declaration (also called a prototype) tells the compiler a function's signature without providing its body; a definition provides the actual body. You need a declaration before a function is used if its definition comes later in the file (or in another file entirely - see section 19).

#include <stdio.h>

int square(int x);   // declaration / prototype -- no body, ends in ';'

int main(void) {
    printf("%d\n", square(5));   // compiler already knows square's signature
    return 0;
}

int square(int x) {   // definition -- the actual body
    return x * x;
}

Without the forward declaration, calling square before its definition appears in the file would be a compile error (or, in very old pre-C99 compilers, a dangerous implicit-int assumption) - always declare before use.


3. Parameters and arguments - pass by value

C passes arguments to functions by value: the function receives a copy of each argument. Changes to a parameter inside the function never affect the caller's original variable.

#include <stdio.h>

void tryToDouble(int x) {
    x = x * 2;   // only modifies the LOCAL COPY
}

int main(void) {
    int n = 5;
    tryToDouble(n);
    printf("%d\n", n);   // still 5 -- the caller's n is untouched
    return 0;
}

If you need a function to modify the caller's variable, you must pass a pointer to it instead (section 5) - C has no native pass-by-reference like C++'s & parameters.


4. Return values and void functions

A function returns at most one value, via return. A function that returns nothing uses void as its return type, and either omits return entirely or uses a bare return;.

#include <stdio.h>

int max(int a, int b) {
    if (a > b) {
        return a;
    }
    return b;
}

void printBanner(const char *title) {
    printf("=== %s ===\n", title);
    return;   // optional here -- falling off the end works the same way
}

int main(void) {
    printBanner("Results");
    printf("max: %d\n", max(10, 20));
    return 0;
}

To return multiple values, the usual approaches are: return a struct (see the structs guide), or pass pointers to extra "output" parameters (section 5).


5. Pass by pointer - simulating pass by reference

Passing a pointer lets a function read and modify the caller's actual variable, and is also how a function can produce more than one output value.

#include <stdio.h>

void doubleIt(int *x) {
    *x = *x * 2;   // dereference the pointer to modify the CALLER's variable
}

void divide(int a, int b, int *quotient, int *remainder) {
    *quotient = a / b;
    *remainder = a % b;
}

int main(void) {
    int n = 5;
    doubleIt(&n);          // pass the ADDRESS of n
    printf("%d\n", n);     // 10 -- actually changed this time

    int q, r;
    divide(17, 5, &q, &r);   // two "outputs" via pointer parameters
    printf("%d remainder %d\n", q, r);
    return 0;
}

6. Arrays as function parameters - they decay to pointers

When you pass an array to a function, it decays to a pointer to its first element - the function never actually receives the whole array or knows its size.

#include <stdio.h>

void printArray(int arr[], int length) {   // arr[] here means "int *arr"
    printf("sizeof(arr) inside function: %zu\n", sizeof(arr));  // size of a POINTER, not the array!
    for (int i = 0; i < length; i++) {
        printf("%d ", arr[i]);
    }
    printf("\n");
}

int main(void) {
    int numbers[5] = {1, 2, 3, 4, 5};
    printf("sizeof(numbers) in main: %zu\n", sizeof(numbers));  // size of the WHOLE array
    printArray(numbers, 5);   // must pass the length separately -- the function can't know it
    return 0;
}

This is why nearly every C function that takes an array also takes an explicit length parameter - there's no other way for it to know how many elements are there.


7. Multi-dimensional array parameters

For a 2D array parameter, every dimension except the first must be specified, so the compiler knows how to calculate the memory offset for arr[i][j].

#include <stdio.h>

// The '3' here is required -- it's how the compiler computes row offsets.
// The first dimension can be omitted (or left as a separate int parameter).
void printGrid(int rows, int grid[][3]) {
    for (int i = 0; i < rows; i++) {
        for (int j = 0; j < 3; j++) {
            printf("%d ", grid[i][j]);
        }
        printf("\n");
    }
}

int main(void) {
    int matrix[2][3] = {{1, 2, 3}, {4, 5, 6}};
    printGrid(2, matrix);
    return 0;
}

8. Returning pointers - and the danger of returning a pointer to a local variable

A function can return a pointer, but returning a pointer to a variable that's local to that function is a serious bug: the variable's memory is invalid the instant the function returns.

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

// WRONG: returns a pointer to a variable that no longer exists once
// the function returns -- this is undefined behavior.
int *badGetPointer(void) {
    int localValue = 42;
    return &localValue;   // DANGER: localValue's memory is gone after return
}

// RIGHT: heap-allocate, so the memory outlives the function call.
// Caller becomes responsible for calling free() on it eventually.
int *goodGetPointer(void) {
    int *heapValue = malloc(sizeof(int));
    if (heapValue) {
        *heapValue = 42;
    }
    return heapValue;
}

int main(void) {
    int *p = goodGetPointer();
    if (p) {
        printf("%d\n", *p);
        free(p);   // caller's responsibility since the function allocated it
    }
    return 0;
}

Other valid alternatives: return a pointer to static storage (persists for the whole program, but is shared/overwritten across calls - use with care), or return a pointer that was passed in by the caller in the first place.


9. Recursion - a function calling itself

A recursive function calls itself, working toward a base case that stops the recursion. Every recursive function needs both a base case and progress toward it, or it never terminates.

#include <stdio.h>

int factorial(int n) {
    if (n <= 1) {          // base case -- stops the recursion
        return 1;
    }
    return n * factorial(n - 1);   // recursive case -- calls itself with smaller input
}

int main(void) {
    printf("%d\n", factorial(5));   // 5 * 4 * 3 * 2 * 1 = 120
    return 0;
}

Each call to factorial gets its own stack frame with its own copy of n - they don't share or overwrite each other's local variables.


10. Recursion vs. iteration - stack depth and performance

Every recursive call consumes stack space; deep enough recursion causes a stack overflow. The same problem often has both a recursive and an iterative solution - the iterative one uses constant stack space.

#include <stdio.h>

// Recursive: elegant, but N stack frames deep for input N.
// Very large N (e.g. a million) can overflow the stack.
long sumRecursive(int n) {
    if (n <= 0) return 0;
    return n + sumRecursive(n - 1);
}

// Iterative: identical result, constant stack usage regardless of N.
long sumIterative(int n) {
    long total = 0;
    for (int i = 1; i <= n; i++) {
        total += i;
    }
    return total;
}

int main(void) {
    printf("%ld\n", sumRecursive(100));    // fine
    printf("%ld\n", sumIterative(1000000)); // also fine -- no stack risk
    // sumRecursive(1000000) would likely crash with a stack overflow
    return 0;
}

Some compilers can optimize certain recursive patterns into loops ("tail-call optimization"), but C makes no guarantee of this - don't rely on it for deep recursion in portable code.


11. static functions - restricting visibility to one file

A function marked static has internal linkage: it's only visible within the .c file it's defined in, invisible to other files even if they try to declare it themselves. This is how C fakes "private" helper functions.

/* helpers.c */
#include <stdio.h>

static int internalHelper(int x) {   // only usable within THIS file
    return x * 2;
}

void publicFunction(void) {           // no 'static' -- usable from other files
    printf("%d\n", internalHelper(21));
}
/* main.c -- a different file in the same project */
void publicFunction(void);   // OK: declared with external linkage

/* int internalHelper(int x); -- if declared here, LINKING would fail:
   internalHelper has no external linkage, so it can't be found outside
   helpers.c, even with a matching declaration */

int main(void) {
    publicFunction();
    return 0;
}

Marking helper functions static is standard practice in real C projects - it keeps a file's internal implementation details out of the global namespace, avoiding name clashes across files.


12. static local variables - state that persists between calls

A static variable declared inside a function keeps its value between calls, instead of being reinitialized every time - unlike an ordinary local variable.

#include <stdio.h>

int nextId(void) {
    static int counter = 0;   // initialized ONCE, on the first call only
    counter++;
    return counter;
}

int main(void) {
    printf("%d\n", nextId());   // 1
    printf("%d\n", nextId());   // 2
    printf("%d\n", nextId());   // 3 -- counter persisted across calls
    return 0;
}

This is useful for things like ID generators or simple caches, but be careful: a static local variable is shared across all calls, which makes functions using them unsafe to call from multiple threads at once without additional synchronization.


13. inline functions

inline (C99) is a hint to the compiler that it can paste a function's body directly at each call site instead of doing a real function call - avoiding call overhead for small, frequently-used functions, similar in spirit to a macro but with real type checking.

#include <stdio.h>

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

int main(void) {
    printf("%d\n", max(3, 7));   // may be expanded inline, with no actual call
    return 0;
}

Unlike a macro, inline functions have real parameter types (caught by the compiler) and evaluate each argument exactly once - solving both of the classic macro pitfalls covered in the macros guide. inline is only a hint: the compiler is free to ignore it and generate a normal function call anyway.


14. Function pointers

A function pointer holds the address of a function, letting you store it in a variable, pass it as an argument, or call it indirectly. The syntax is famously awkward - read it as "a pointer to a function that takes these parameter types and returns this type."

#include <stdio.h>

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

int main(void) {
    int (*operation)(int, int);   // declares a pointer to a function: int -> (int, int)

    operation = add;
    printf("%d\n", operation(3, 4));   // 7 -- calls add through the pointer

    operation = subtract;
    printf("%d\n", operation(3, 4));   // -1 -- now calls subtract instead

    return 0;
}

A function's name, like an array's name, decays to its address when used without calling it - add and &add are equivalent here.


15. Passing function pointers as callbacks - qsort

The standard library's qsort is the classic example of a callback: you give it your own comparison function, and it calls that function internally to decide sort order - without qsort needing to know anything about your specific data type in advance.

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

int compareInts(const void *a, const void *b) {
    int intA = *(const int *)a;
    int intB = *(const int *)b;
    return intA - intB;   // negative, zero, or positive -- qsort uses the sign
}

int main(void) {
    int numbers[] = {5, 2, 8, 1, 9};
    int count = 5;

    qsort(numbers, count, sizeof(int), compareInts);   // compareInts is a callback

    for (int i = 0; i < count; i++) {
        printf("%d ", numbers[i]);
    }
    printf("\n");
    return 0;
}

This is the core pattern behind every "pluggable behavior" API in C: qsort, bsearch, thread start functions, signal handlers, and most event/driver callback interfaces all work exactly this way.


16. Arrays of function pointers - dispatch tables

An array of function pointers lets you select behavior by index (or by looking one up in a table) instead of a long if/switch chain - a lightweight alternative to the function-pointer-in-a-struct "vtable" pattern from the structs guide.

#include <stdio.h>

int add(int a, int b) { return a + b; }
int subtract(int a, int b) { return a - b; }
int multiply(int a, int b) { return a * b; }

int main(void) {
    int (*operations[3])(int, int) = {add, subtract, multiply};
    const char *names[3] = {"add", "subtract", "multiply"};

    for (int i = 0; i < 3; i++) {
        printf("%s(4, 2) = %d\n", names[i], operations[i](4, 2));
    }
    return 0;
}

17. Variadic functions - accepting a variable number of arguments

<stdarg.h> lets you write functions that accept a variable number of arguments, exactly like printf does. The function needs some way to know when to stop reading arguments - either a count, a sentinel value, or (as with printf) a format string.

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

int sum(int count, ...) {
    va_list args;
    va_start(args, count);   // start reading arguments AFTER 'count'

    int total = 0;
    for (int i = 0; i < count; i++) {
        total += va_arg(args, int);   // pull the next argument, as an int
    }

    va_end(args);   // required cleanup
    return total;
}

int main(void) {
    printf("%d\n", sum(3, 10, 20, 30));      // 60
    printf("%d\n", sum(5, 1, 2, 3, 4, 5));   // 15
    return 0;
}

There's no built-in way to know how many arguments were passed unless you tell the function explicitly (a count parameter, like above, or a sentinel value like NULL/-1 as the last argument) - the function has no automatic way to detect where the argument list ends.


18. const-correctness with parameters

Marking a pointer parameter const documents (and enforces) that a function won't modify what it points to - important both as documentation and as a compiler-checked guarantee for callers.

#include <stdio.h>
#include <string.h>

// This signature promises: "I will read *name, but never modify it."
size_t nameLength(const char *name) {
    // name[0] = 'X';   // COMPILE ERROR if uncommented -- name is const
    return strlen(name);
}

int main(void) {
    const char *myName = "Alice";
    printf("%zu\n", nameLength(myName));   // works fine: const in, const-respecting function
    return 0;
}

For pointer parameters especially, const lets a function accept both const and non-const data, while a non-const parameter would reject const data outright - so marking read-only parameters const actually makes a function more broadly usable, not less.


19. Declarations in headers, definitions in .c files

Real multi-file C projects put function declarations in a .h header (shared, included wherever needed) and the definition in exactly one .c file - this is how separately-compiled files call each other's functions.

/* math_utils.h */
#ifndef MATH_UTILS_H
#define MATH_UTILS_H

int add(int a, int b);        // declaration only
int multiply(int a, int b);   // declaration only

#endif
/* math_utils.c */
#include "math_utils.h"

int add(int a, int b) {          // the actual definition
    return a + b;
}

int multiply(int a, int b) {     // the actual definition
    return a * b;
}
/* main.c */
#include <stdio.h>
#include "math_utils.h"   // brings in the declarations, not the bodies

int main(void) {
    printf("%d\n", add(2, 3));         // compiler trusts the declaration,
    printf("%d\n", multiply(2, 3));    // the LINKER finds the actual bodies later
    return 0;
}

Compiled together (e.g. gcc main.c math_utils.c -o program), the compiler checks each file against the header's declarations, and the linker connects the calls in main.c to the definitions in math_utils.c - this is exactly how the standard library itself works (you #include <stdio.h> for declarations; the actual printf definition lives in a precompiled library you link against).


20. The main function - return codes and command-line arguments

main is where every C program starts, and it has a few standard signatures. Its return value becomes the program's exit status, readable by whatever shell or process launched it.

#include <stdio.h>

int main(int argc, char *argv[]) {
    printf("Program name: %s\n", argv[0]);   // argv[0] is always the program's own name
    printf("Argument count: %d\n", argc);

    for (int i = 1; i < argc; i++) {   // start at 1 -- skip the program name itself
        printf("argv[%d] = %s\n", i, argv[i]);
    }

    if (argc < 2) {
        fprintf(stderr, "Usage: %s <name>\n", argv[0]);
        return 1;   // non-zero -- signals failure to the calling shell/process
    }

    return 0;   // zero -- signals success
}

Running ./program hello world gives argc == 3, with argv[1] == "hello" and argv[2] == "world". int main(void) (no arguments) is equally valid when a program doesn't need command-line input.


21. Common pitfalls checklist


Quick reference

FeatureSyntaxPurposeSection
Function definitionReturnType name(params) { ... }The actual function body1
Declaration / prototypeReturnType name(params);Tell the compiler a signature before use2
Pass by valuevoid f(int x)Function gets a copy; caller unaffected3
Pass by pointervoid f(int *x)Function can read/modify the caller's variable5
Array parametervoid f(int arr[], int len)Array decays to a pointer; pass length separately6
2D array parametervoid f(int arr[][N])All dimensions but the first must be specified7
Return pointer safelyheap / static / caller-provided bufferAvoid returning a pointer to a dead local variable8
Recursionfunction calling itself, with a base caseNatural fit for recursively-structured problems9
static functionstatic ReturnType f(...)Internal linkage - private to one file11
static local variablestatic Type var = init;Value persists across calls12
inline functioninline ReturnType f(...)Hint to paste the body at call sites13
Function pointerRetType (*fp)(ArgTypes)Store/pass a function's address14
Callbackfunction pointer param, e.g. in qsortLet library code call your code15
Dispatch tableRetType (*table[])(ArgTypes)Select behavior by index instead of if/switch16
Variadic function..., <stdarg.h>, va_list/va_argAccept a variable number of arguments17
const parametervoid f(const Type *p)Promise not to modify what's pointed to18
Header + source split.h declarations, .c definitionsStructure for multi-file projects19
main signatureint main(int argc, char *argv[])Program entry point, exit code, CLI args20

Coverage note

This guide covers function declaration and definition, all the parameter- passing mechanics (value, pointer, arrays), recursion, linkage and storage duration (static), inline, the full function-pointer story (pointers, callbacks, dispatch tables), variadic functions, const correctness, multi-file project structure, and main's real signature - the complete set of what you'll encounter working with functions in real-world C. If you've read every section here, you have everything needed to design, use, and debug any function-related pattern you'll run into.

Previous:
Pointers in C
Next:
Standard I/O in C