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:
#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:
#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:
#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:
#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.
#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.
#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.
#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).
#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;
}
.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).
#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.
// 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.
#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.
#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.
#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.
#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_fixedis the final fixed parameter va_arg(ap, type)β fetches the next argument with the given typeva_end(ap)β cleans up
#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;
}
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:
#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.
void foo() {
static int x = 0;
x++;
printf("%d ", x);
}
int main() {
foo(); foo(); foo();
return 0;
}
Comments