<< Home

Chapter 7: C Functions

Functions let you divide a program into small, reusable actions. Each function performs one clear task, making code easier to test, reuse, and understand. In C, you define functions by declaring their return type, name, and parameters, then calling them wherever their result or side effect is needed.

Defining and calling functions

Defining a function means specifying what it does and what kind of value it returns. Calling a function means executing it by name and supplying any arguments it requires. Functions can appear before or after main() in a file, but if they appear after, they must be declared beforehand.

Creating reusable functions

A function definition contains a return type, a name, a parameter list, and a body. You can place it before main(), or declare a prototype earlier if it appears below.

#include <stdio.h>

void greet(void) {
  puts("Hello from a function!");
}

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

Adding void in a parameter list states explicitly that a function takes no arguments, which avoids confusion with an unprototyped form.

💡 Keep function names action-oriented to show purpose, such as compute_sum() or display_menu(). This makes code read like a set of clear instructions.

Passing values and receiving results

Functions can accept arguments and return values. Each parameter’s type determines how the argument is passed and interpreted. The return type defines the kind of result produced.

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

int main(void) {
  int total = add(3, 4);
  printf("Result: %d\n", total);
  return 0;
}

Organizing code with forward declarations

If you define main() before helper functions, declare them first using prototypes. This allows the compiler to check types and link correctly later.

#include <stdio.h>

int square(int n); /* prototype */

int main(void) {
  printf("%d\n", square(5));
  return 0;
}

int square(int n) {
  return n * n;
}

Arguments and return values

Understanding how arguments and return values behave is central to C’s design. By default, arguments are passed by value, meaning the function receives a copy. To modify a caller’s variable, you must pass a pointer instead.

Passing by value

Each argument is evaluated, and a copy is passed into the function. The original variable remains unchanged.

void increment(int n) {
  n++;
  printf("Inside: %d\n", n);
}

int main(void) {
  int value = 5;
  increment(value);
  printf("Outside: %d\n", value); /* still 5 */
}

Passing by reference with pointers

When a function must change a caller’s variable, pass its address and use a pointer parameter to modify it directly.

void increment(int *n) {
  (*n)++;
}

int main(void) {
  int value = 5;
  increment(&value);
  printf("%d\n", value); /* 6 */
}
⚠️ Forgetting to dereference a pointer before assigning can change the pointer’s value instead of the variable it points to. Always check which level of indirection you are using.

Returning useful results

A function can return one value of its declared type. When no result is needed, declare the return type as void. The return statement also ends the function early.

double average(double a, double b) {
  return (a + b) / 2.0;
}
💡 Returning values rather than printing them makes a function more versatile. The caller can then decide whether to display, store, or combine the result.

Variable scope and static variables

Scope determines where a variable can be seen and used. Local variables exist only within their function. Global variables, defined outside any function, persist throughout the program. The static keyword alters lifetime and visibility rules in subtle but useful ways.

Understanding local and global scope

Local variables live within their block. Global variables live for the duration of the program. When both exist with the same name, the local one hides the global.

int counter = 0;    /* global */

void bump(void) {
  int counter = 10; /* local shadows global */
  printf("%d\n", counter);
}

Preserving values with static locals

A static local variable retains its value between calls but remains visible only inside its function. It is initialized once, before the first call.

void track(void) {
  static int count = 0;
  count++;
  printf("Called %d times\n", count);
}

Restricting visibility with static globals

Declaring a file-level variable or function as static makes it visible only within that translation unit. This helps avoid name clashes across multiple source files.

static int cache_size = 512;

static void reset_cache(void) {
  cache_size = 0;
}
⚠️ Overusing global variables or static state makes testing difficult. Favor parameters and return values when data should remain local to a computation.

Header files and prototypes

Declaring functions in headers makes them accessible to multiple source files. Each source file includes the same header to ensure consistent declarations. Prototypes tell the compiler what arguments and return type to expect before it sees the definition.

Creating and including headers

Header files typically end in .h. They contain prototypes, type definitions, and constants. The corresponding .c file contains the actual implementations.

/* mathutils.h */
#ifndef MATHUTILS_H
#define MATHUTILS_H

int add(int a, int b);
int subtract(int a, int b);

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

int add(int a, int b) { return a + b; }
int subtract(int a, int b) { return a - b; }
/* main.c */
#include <stdio.h>
#include "mathutils.h"

int main(void) {
  printf("%d\n", add(2, 3));
  return 0;
}
💡 Always guard headers with #ifndef, #define, and #endif to prevent multiple inclusion errors when several files include the same header.

Using prototypes to catch mistakes

Prototypes let the compiler verify that a function is called with the right number and types of arguments. Without them, C assumes default conversions that can cause subtle runtime bugs.

void report(int code);

int main(void) {
  report(5);   /* type checked */
  return 0;
}

void report(int code) {
  printf("Code: %d\n", code);
}

Using recursion

Recursion means defining a function in terms of itself. It works by breaking a problem into smaller pieces that look like the original, each time moving closer to a base case that stops the process. In C, recursion is used for problems with natural hierarchical structure, such as traversing trees or computing factorials.

Defining simple recursive functions

Each recursive function must include a base case that ends the calls and a recursive case that moves toward it. Without a base case, recursion never terminates.

int factorial(int n) {
  if (n <= 1) return 1;
  return n * factorial(n - 1);
}

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

Tracing recursive calls and stack depth

Each recursive call pushes a new stack frame. Deep recursion can exhaust the stack if the problem size is large or the base case is too far away. Iteration is safer for very large datasets.

⚠️ Stack overflow from uncontrolled recursion can crash a program. Limit recursion depth or convert tail recursion to loops when possible.

Using recursion for structured data

Recursive techniques shine when data has natural substructure, such as lists or trees. Each call handles one piece and lets recursion handle the rest.

struct node {
  int value;
  struct node *next;
};

void print_list(struct node *n) {
  if (n == NULL) return;
  printf("%d\n", n->value);
  print_list(n->next);
}
💡 Writing a recursive version first can clarify the logic of a problem. You can then transform it into an iterative loop later if efficiency or stack size matters.

Mastering functions and their relationships (arguments, scope, and recursion) forms the backbone of C programming. With these foundations in place, larger programs can grow naturally through small, well-defined building blocks.