As a program grows, writing everything inside a single main() makes the code hard to read, hard to maintain and full of duplication. Functions are the tool for breaking a problem into pieces, reusing logic and giving software a clear structure. In this lesson we cover functions, variable scope and recursion in C thoroughly.

1. Why functions?

Consider the DRY principle β€” Don't Repeat Yourself. If you need the area of a rectangle in five different places, instead of copy-pasting the formula five times you write one function and call it.

Before using a function:

no_function.c
#include <stdio.h>

int main() {
    // 1st time: area of the living room
    double dai1 = 5.0, rong1 = 4.0;
    double dt1 = dai1 * rong1;
    printf("Dien tich phong khach: %.2f m2\n", dt1);

    // 2nd time: area of the bedroom
    double dai2 = 3.5, rong2 = 3.0;
    double dt2 = dai2 * rong2;
    printf("Dien tich phong ngu: %.2f m2\n", dt2);

    // 3rd time: area of the kitchen
    double dai3 = 4.0, rong3 = 2.5;
    double dt3 = dai3 * rong3;
    printf("Dien tich nha bep: %.2f m2\n", dt3);

    // ... repeating the same formula forever!
    return 0;
}

After using a function:

with_function.c
#include <stdio.h>

double dien_tich(double dai, double rong) {
    return dai * rong;
}

int main() {
    printf("Phong khach: %.2f m2\n", dien_tich(5.0, 4.0));
    printf("Phong ngu:   %.2f m2\n", dien_tich(3.5, 3.0));
    printf("Nha bep:     %.2f m2\n", dien_tich(4.0, 2.5));
    return 0;
}

The benefits are clear:

  • Reusability: write once, call as many times as you like.
  • Modularisation: break the program into small, readable units of behaviour.
  • Maintainability: when the formula changes, you change it in one place.
  • Testability: each function can be tested on its own (unit testing).

2. Declaration, definition and function prototypes

The C compiler reads source from top to bottom. If you call a function before the compiler has seen its definition, you get an error or a dangerous warning.

The error when there is no prototype:

no_prototype_error.c
#include <stdio.h>

int main() {
    // ERROR! The compiler has not seen add() yet
    int result = add(3, 5);
    printf("Tong = %d\n", result);
    return 0;
}

// The definition sits AFTER main()
int add(int a, int b) {
    return a + b;
}
// Compiling gives: warning: implicit declaration of function 'add'
// With -Werror it becomes an error!

Fixing it with a function prototype:

with_prototype.c
#include <stdio.h>

// Function prototype (forward declaration)
// Tells the compiler: "add takes 2 ints and returns an int"
int add(int a, int b);

int main() {
    int result = add(3, 5);  // OK! The compiler already knows the signature
    printf("Tong = %d\n", result);
    return 0;
}

// The full function definition
int add(int a, int b) {
    return a + b;
}

In summary:

  • Declaration / prototype: int add(int a, int b); β€” tells the compiler the function's signature only, with no body.
  • Definition: the signature together with the body { ... } holding the actual code.

In larger projects the prototypes usually live in a header file (.h) while the definitions live in a source file (.c).

3. Pass by value

An extremely important point: C only has pass by value. When you pass a variable into a function, C makes a copy of that value in a new stack frame. The function works on the copy, never on the original.

pass_by_value.c
#include <stdio.h>

void increment(int x) {
    x = x + 1;  // Only changes the copy on the stack
    printf("Trong ham: x = %d\n", x);  // x = 11
}

int main() {
    int a = 10;
    increment(a);
    printf("Outside: a = %d\n", a);  // a = 10 (UNCHANGED!)
    return 0;
}

How the stack frames look:

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ Stack Frame: increment  β”‚
β”‚   x = 11  (the copy)    β”‚  ← the function changes this copy
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚ Stack Frame: main       β”‚
β”‚   a = 10  (the original)β”‚  ← the original is untouched
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

To actually change the original from inside a function, you need to pass a pointer β€” which is still pass by value, except the value being passed is a memory address. This is covered in depth in Lesson 8: Pointers.

pass_by_pointer.c
#include <stdio.h>

void increment(int *px) {
    *px = *px + 1;  // Changes the value at the address px points to
}

int main() {
    int a = 10;
    increment(&a);   // Pass the address of a
    printf("a = %d\n", a);  // a = 11 (CHANGED!)
    return 0;
}

Put the two programs side by side and the difference is immediate: the first prints a = 10 after the call, this one prints a = 11. Same intention β€” "increment the variable" β€” but only the second achieves it, because it receives the address of a rather than a copy of its value. This is exactly why scanf in Lesson 2 always needs the &.

4. Local variables, global variables and the static keyword

A. Local variables

A variable declared inside a function or a { } block exists only within that scope. When the function returns, its stack memory is released and the local variable is gone.

local_var.c
#include <stdio.h>

void foo() {
    int local = 42;  // Lives on the stack, dies when foo() returns
    printf("local = %d\n", local);
}

int main() {
    foo();
    // printf("%d", local);  // ERROR: 'local' is not declared here
    return 0;
}

The commented-out printf at the end is the part worth noticing: it does not compile, rather than printing garbage. A local variable does not merely lose its value when the function ends β€” outside the function, the compiler behaves as though that name never existed.

B. Global variables

A variable declared outside every function lives for the entire duration of the program. It is stored in the Data segment (if initialised) or the BSS segment (if not, defaulting to 0).

global_var.c
#include <stdio.h>

int counter = 0;  // A global variable - every function can reach it

void tang_dem() {
    counter++;
}

int main() {
    tang_dem();
    tang_dem();
    tang_dem();
    printf("counter = %d\n", counter);  // counter = 3
    return 0;
}
⚠️ Why are global variables dangerous?
Name collisions: in a large project with many .c files, two developers can accidentally pick the same global name, producing linker errors or behaviour nobody intended.

Hard to debug: any function at all can change a global, which makes tracing a bug back to its cause very difficult.

Not thread-safe: in multi-threaded code, several threads reading and writing a global without a lock produce a race condition.

C. The static keyword

static means two different things depending on context:

1. A static local variable: keeps its value between calls (it is not destroyed when the function returns).

static_local.c
#include <stdio.h>

void dem_so_lan_goi() {
    static int count = 0;  // Initialised exactly once
    count++;
    printf("Ham duoc goi lan thu: %d\n", count);
}

int main() {
    dem_so_lan_goi();  // Ham duoc goi lan thu: 1
    dem_so_lan_goi();  // Ham duoc goi lan thu: 2
    dem_so_lan_goi();  // Ham duoc goi lan thu: 3
    return 0;
}

2. A static global variable or function: restricts visibility to the current .c file (internal linkage). Other files cannot reach it with extern.

static_global.c
// file: helper.c

static int internal_counter = 0;  // Only this file can reach it

static void reset_counter() {     // A private, file-local function
    internal_counter = 0;
}

// file: main.c
// extern int internal_counter;  // LINKER ERROR: symbol not found

Variable scope at a glance:

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ Kind             β”‚ Lifetime       β”‚ Visibility   β”‚ Stored in     β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚ Local            β”‚ The function   β”‚ The function β”‚ Stack         β”‚
β”‚ Global           β”‚ Whole program  β”‚ Whole file   β”‚ Data/BSS      β”‚
β”‚ static local     β”‚ Whole program  β”‚ The function β”‚ Data/BSS      β”‚
β”‚ static global    β”‚ Whole program  β”‚ That file    β”‚ Data/BSS      β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

5. Recursion

Recursion is the technique where a function calls itself. Every recursive function needs two parts:

  • Base case: the condition that stops the recursion and prevents infinite calls.
  • Recursive case: the function calling itself with a smaller input, moving steadily towards the base case.

Example: factorial

Factorial: n! = n Γ— (n-1) Γ— (n-2) Γ— ... Γ— 1, with 0! = 1.

factorial.c
#include <stdio.h>

long long factorial(int n) {
    if (n <= 1) return 1;       // Base case
    return n * factorial(n - 1);  // Recursive case
}

int main() {
    int n = 5;
    printf("%d! = %lld\n", n, factorial(n));  // 5! = 120
    return 0;
}

How the call stack unwinds for factorial(4):

factorial(4)                          ← call
  └─ 4 * factorial(3)                 ← call
       └─ 3 * factorial(2)            ← call
            └─ 2 * factorial(1)       ← call
                 └─ return 1          ← BASE CASE, returns start here
            └─ return 2 * 1 = 2
       └─ return 3 * 2 = 6
  └─ return 4 * 6 = 24                ← the final result

Fibonacci: recursion O(2^N) versus a loop O(N)

Fibonacci is the classic demonstration that recursion is not always efficient.

fibonacci.c
#include <stdio.h>

// Option 1: recursion - O(2^N) time, O(N) stack memory
// VERY SLOW for large N: it recomputes the same values over and over
long long fib_recursive(int n) {
    if (n <= 1) return n;
    return fib_recursive(n - 1) + fib_recursive(n - 2);
}

// Option 2: iteration - O(N) time, O(1) memory
// Dramatically faster
long long fib_iterative(int n) {
    if (n <= 1) return n;
    long long prev = 0, curr = 1;
    for (int i = 2; i <= n; i++) {
        long long next = prev + curr;
        prev = curr;
        curr = next;
    }
    return curr;
}

int main() {
    int n = 40;

    // fib_recursive(40) takes seconds: roughly 2^40 calls
    printf("fib_recursive(%d) = %lld\n", n, fib_recursive(n));

    // fib_iterative(40) returns almost instantly
    printf("fib_iterative(%d) = %lld\n", n, fib_iterative(n));

    return 0;
}

Why is recursive Fibonacci slow? Because it recomputes the same values repeatedly. fib(5) calls fib(3) twice and fib(2) three times, and the number of calls grows exponentially.

Tail recursion

Tail recursion is when the recursive call is the very last operation in the function β€” nothing happens after it. The compiler can turn tail recursion into a loop (with the -O2 flag), saving stack memory.

tail_recursion.c
#include <stdio.h>

// PLAIN recursion: n * factorial(n-1) - must wait for the result to multiply
long long factorial_plain(int n) {
    if (n <= 1) return 1;
    return n * factorial_plain(n - 1);  // The multiply happens AFTER the recursive call
}

// TAIL recursion: the result accumulates through a parameter
long long factorial_tail(int n, long long acc) {
    if (n <= 1) return acc;       // Base case: return the accumulated result
    return factorial_tail(n - 1, n * acc);  // The recursive call is the LAST thing done
}

int main() {
    printf("5! = %lld\n", factorial_tail(5, 1));  // 120
    return 0;
}
// Build with: gcc -O2 tail_recursion.c -o tail_recursion
// The compiler may turn this into a loop (tail call optimisation)

The risk of stack overflow

Every recursive call creates a new stack frame on the call stack, and the stack has a fixed size (typically 1–8 MB on a modern operating system). Recursion that goes too deep causes a stack overflow and the program crashes.

stack_overflow.c
#include <stdio.h>

void count_forever(int n) {
    printf("n = %d\n", n);
    count_forever(n + 1);  // No base case -> stack overflow!
}

int main() {
    count_forever(1);  // CRASH: Segmentation fault
    return 0;
}

6. Variadic functions

Have you ever wondered how printf("a=%d b=%d", a, b) can accept any number of arguments? That is thanks to variadic functions β€” functions with a variable argument count.

C provides <stdarg.h> with these macros:

  • va_list β€” the type used to walk the argument list
  • va_start(ap, last_fixed) β€” initialises it; last_fixed is the final fixed parameter
  • va_arg(ap, type) β€” fetches the next argument with the given type
  • va_end(ap) β€” cleans up
variadic_sum.c
#include <stdio.h>
#include <stdarg.h>

// A sum function taking any number of arguments
// The first parameter (count) says how many numbers follow
double tong(int count, ...) {
    va_list args;
    va_start(args, count);  // Initialise after the last fixed parameter

    double sum = 0.0;
    for (int i = 0; i < count; i++) {
        sum += va_arg(args, double);  // Pull each argument as a double
    }

    va_end(args);  // Mandatory cleanup
    return sum;
}

int main() {
    printf("Tong 3 so: %.1f\n", tong(3, 1.5, 2.5, 3.0));   // 7.0
    printf("Tong 5 so: %.1f\n", tong(5, 1.0, 2.0, 3.0, 4.0, 5.0));  // 15.0
    return 0;
}
⚠️ Safety warnings
No type checking: if you pass an int but read it with va_arg(args, double), the program misreads the stack β€” undefined behaviour.

You must know when to stop: there is no automatic way to discover how many arguments were passed. You need either a count parameter (as in the example above) or a sentinel value marking the end.

Why printf is safer: it uses the format string (%d, %s, …) to work out both the type and the number of arguments to read.

7. Function pointers and callbacks (an introduction)

In C, functions also have addresses in memory. You can store a function's address in a variable called a function pointer and call the function through it. This is the foundation of the callback technique β€” passing one function into another to customise its behaviour.

A practical example: qsort() from <stdlib.h> takes a user-supplied comparison function:

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

// Comparison function for ascending order
int so_sanh_tang(const void *a, const void *b) {
    return (*(int*)a - *(int*)b);
}

// Comparison function for descending order
int so_sanh_giam(const void *a, const void *b) {
    return (*(int*)b - *(int*)a);
}

void in_mang(int arr[], int n) {
    for (int i = 0; i < n; i++) printf("%d ", arr[i]);
    printf("\n");
}

int main() {
    int arr[] = {42, 17, 88, 5, 63, 29};
    int n = sizeof(arr) / sizeof(arr[0]);

    printf("Mang goc:     ");
    in_mang(arr, n);

    // qsort takes the comparison function pointer as a callback
    qsort(arr, n, sizeof(int), so_sanh_tang);
    printf("Tang dan:     ");
    in_mang(arr, n);

    qsort(arr, n, sizeof(int), so_sanh_giam);
    printf("Giam dan:     ");
    in_mang(arr, n);

    return 0;
}

The details of function pointers β€” the declaration syntax and the more advanced uses β€” are covered fully in Lesson 8: Pointers & memory management.

πŸ“₯ Download the sample source: functions_recursion.c

A small challenge for you

Write a recursive function power(int base, int exp) computing base^exp (so power(2, 10) = 1024). Hint: base^exp = base * base^(exp-1), and the base case is exp == 0 β†’ return 1.

πŸ“ Check your understanding β€” Lesson 5
What does the following code print?
void foo() {
    static int x = 0;
    x++;
    printf("%d ", x);
}
int main() {
    foo(); foo(); foo();
    return 0;
}

Related lessons in this series

Lesson 6: Arrays, strings & text processing Lesson 4: Branching & loops in C: thinking algorithmically Back to the C series roadmap (Vietnamese)