C Basics · beginner · ~12 min

Functions

By the end of this lesson you will be able to: - Define a function with a return type, a name, a typed parameter list, and a body, and call it from `main` or from other functions. - Explain why C passes arguments **by value** and predict whether a change inside a function is visible to the caller. - Use a **pointer parameter** to let a function modify the caller's data (the only way to get "output" beyond the single return value). - Write and place **function prototypes** (declarations) so files can call functions defined elsewhere or later. - Decompose one large `main` into small, single-purpose functions with clear names and contracts. - Avoid the classic memory bug of returning a pointer to a local variable.

Overview

A function is a named, reusable block of code with defined inputs and one output. You write it once and call it as many times as you like. Think of a function as a small machine: you feed it values (the arguments), it does its job, and it hands back a result (the return value).

You already met one function in every program you have written: main. C programs are nothing but functions calling other functions. main calls printf; printf calls lower-level functions; and so on. The skill you learn here — splitting work into named pieces — is the single biggest jump from "a beginner who can write a loop" to "a programmer who can build something."

This lesson builds directly on for / while / do-while loops. A loop lets you repeat work; a function lets you name a chunk of work and reuse it. The two combine constantly: you will often write a function whose body is a loop (for example, summing an array), then call that function wherever you need the sum.

The vocabulary you will see throughout: a function's signature (its return type, name, and parameter types) is its public contract; parameters are the named inputs declared in the definition; arguments are the actual values you pass at a call; the body is the code between the braces; and a prototype is a one-line declaration of the signature that lets other code call the function before seeing its body.

Why it matters

Functions are your primary tool for managing complexity. A single 500-line main is nearly impossible to read, test, or change without fear. Ten 50-line functions, each with a clear name and one job, read almost like prose and can be reasoned about one at a time.

Three concrete payoffs:

  • Testable — you can call one function with known inputs and check the output in isolation, without running the whole program.
  • Reusable — the same function can be called from many places, so a fix or improvement happens in exactly one spot.
  • Replaceable — you can rewrite a function's body (make it faster, fix a bug) without touching any of the code that calls it, as long as the signature stays the same.

In real software this is non-negotiable. Every library you will ever use — the C standard library, an HTTP client, a database driver — is just a set of functions with documented signatures. Good function boundaries are also a quiet security benefit: input validation, bounds checks, and error handling all live in one named place where they can be reviewed, instead of being scattered and forgotten.

Core concepts

1. The signature (the contract)

Definition. A signature is return_type name(parameter_list). It is the function's public promise: callers rely on it, and the body must honor it.

Plain language. The signature tells you everything you need to use a function without reading its body: what to give it and what you get back. double sqrt(double x) says "give me one double, I return one double."

How it works internally. When you call a function, the compiler uses the signature to check that your arguments have compatible types and that you use the return value correctly. It also lets the compiler set up the call: arguments are placed in registers or on the call stack, control jumps to the function's code, and the return value comes back in a register.

When to use / when not to. Always give a function the narrowest, most honest signature you can. Do not sneak extra behavior in through global variables when a parameter would be clearer.

Pitfall. Omitting the return type or parameter types. In modern C, int f() (empty parentheses) means "unspecified parameters," which disables argument checking. Write int f(void) to mean "takes no arguments."

Knowledge check: In your own words, what is the difference between int f() and int f(void)?

2. Parameters are copies (pass by value)

Definition. When you call f(x), the function receives its own copy of each argument. Modifying a parameter inside the function does not change the caller's variable.

Plain language. Passing an argument is like photocopying a document and handing over the copy. The function can scribble all over its copy; your original is untouched.

How it works internally. Each call creates a new stack frame — a small region of memory holding that call's parameters and local variables. The parameter lives in this frame and is initialized from the argument's value.

CALL:  modify(x)        x is 10 in main

main's frame            modify's frame (fresh copy)
+-----------+           +-----------+
| x  = 10   |  copy --> | n  = 10   |   n++  -> n becomes 11
+-----------+           +-----------+
     ^                        |
     |                        v (frame destroyed on return)
  x still 10  <----------- no link back

When to use / when not to. Pass by value for plain inputs (numbers, small structs, flags). Do not expect the caller to see changes — for that you need a pointer (next concept).

Pitfall. Writing a swap(int a, int b) that swaps a and b inside the function and being surprised the caller's variables are unchanged. The copies were swapped, not the originals.

Knowledge check (predict the output): if void inc(int n){ n++; } is called as int v=5; inc(v); printf("%d", v);, what prints, and why?

3. Pointer parameters (letting a function change the caller's data)

Definition. A pointer parameter receives the address of a caller's variable, so the function can read and write that variable directly.

Plain language. Instead of a photocopy, you hand over the location of the original. Now the function can edit the real thing.

CALL:  add_one(&x)      x is 10 at address 0x7ffe...

main's frame              add_one's frame
+-------------------+     +-------------------+
| x = 10  @0x7ffe   |<----| p = 0x7ffe        |
+-------------------+     +-------------------+
        ^                          |
        +----- *p = *p + 1 --------+   writes through the address
  x is now 11

How it works internally. The pointer holds the variable's address. Inside the function, *p (dereference) reaches back to the caller's storage. This is how scanf("%d", &n) fills your variable.

When to use / when not to. Use a pointer when the function must produce more than one output, or modify data in place. Do not use one just to avoid copying a single int — the copy is cheaper than the indirection. Use const (e.g. const int *p) to promise you will only read through the pointer.

Pitfall. Passing the value instead of the address: calling add_one(x) where the function expects int * — the compiler will warn; never ignore that warning.

4. The return value

Definition. A function returns at most one value, of the type named in its signature. void means it returns nothing.

Plain language. One result comes back through the return statement. To hand back several results, return a struct (a bundle of values) or write through pointer parameters.

Pitfall. Reaching the end of a non-void function without a return. The returned value is then undefined — a real bug the compiler warns about under -Wall.

Knowledge check (find the bug): int max(int a,int b){ if(a>b) return a; } — what is wrong, and when does it bite?

5. Declarations (prototypes) vs. definitions

Definition. A declaration (prototype) such as int square(int); states the signature only. A definition provides the body.

Plain language. A prototype is an introduction ("this function exists and looks like this"); the definition is the actual person showing up to work. The compiler needs the introduction before the first call so it can check the call.

  header.h            other.c
  +--------------+    +-------------------------+
  | int sq(int); |--->| includes header.h       |
  +--------------+    | calls sq(5);  // checked |
                      +-------------------------+
  math.c
  +-------------------------+
  | int sq(int n){return n*n;}  // the real body
  +-------------------------+

When to use / when not to. Put prototypes in a .h header so many .c files can call the function; put each definition in exactly one .c. Within a single file you can skip the prototype if the definition appears above every call.

Pitfall. A prototype that disagrees with the definition (different parameter count or types). The two contracts conflict; behavior becomes unreliable.

Syntax notes

// 1. Declaration / prototype: signature only, ends with a semicolon.
int add(int a, int b);

// 2. Definition: same signature, plus a body in braces.
int add(int a, int b) {
    return a + b;            // hand one value back to the caller
}

// 3. void return: performs an action, returns nothing.
void greet(const char *name) {        // const = we only read 'name'
    printf("Hello, %s\n", name);
}

// 4. Pointer parameter: lets the function change the caller's variable.
void add_one(int *p) {                 // p holds an address
    *p = *p + 1;                       // write through the address
}

// 5. No parameters: use (void), not ().
int roll_die(void);

// 6. Call sites:
int result = add(2, 3);                // result == 5
int v = 10; add_one(&v);               // pass the ADDRESS; v becomes 11

Lesson

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

You call it by name and pass arguments.

Pass by value

Arguments are passed by value. The function works on copies, so changes to a parameter do not affect the caller.

To let a function modify the caller's data, pass a pointer instead. (Pointers are covered in the Pointers section.)

Prototypes

Declare a prototype, such as int sum(int, int);, in a header file. This lets other files call the function before they have seen its definition.

Code examples

#include <stdio.h>

/* Prototypes: introduce each function before main uses it. */
int  square(int n);
int  max_int(int a, int b);
void swap(int *a, int *b);            /* pointers => can modify caller's data */
int  sum_to(int n);                   /* uses a loop inside a function */

int main(void) {
    /* Pass by value: square gets a copy of 5. */
    printf("square(5)  = %d\n", square(5));

    /* Use a return value directly inside another call. */
    printf("max_int    = %d\n", max_int(square(3), 8)); /* max(9, 8) = 9 */

    /* Pointers let swap change x and y for real. */
    int x = 1, y = 2;
    printf("before swap: x=%d y=%d\n", x, y);
    swap(&x, &y);                     /* pass the ADDRESSES of x and y */
    printf("after  swap: x=%d y=%d\n", x, y);

    printf("sum_to(5)  = %d\n", sum_to(5)); /* 1+2+3+4+5 = 15 */
    return 0;
}

/* Definitions */

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

int max_int(int a, int b) {
    return (a > b) ? a : b;           /* always returns on every path */
}

void swap(int *a, int *b) {
    int tmp = *a;                     /* read through the pointers */
    *a = *b;
    *b = tmp;                         /* originals are now exchanged */
}

int sum_to(int n) {
    int total = 0;
    for (int i = 1; i <= n; i++) {    /* loop body lives inside a function */
        total += i;
    }
    return total;
}

What it does. main exercises four functions: square (pass by value), max_int (chooses the larger of two ints and is used as an argument to another call), swap (uses pointers to exchange the caller's variables), and sum_to (a loop wrapped in a reusable function).

Expected output:

square(5)  = 25
max_int    = 9
before swap: x=1 y=2
after  swap: x=2 y=1
sum_to(5)  = 15

Edge cases. square(46341) overflows a 32-bit int (the product exceeds INT_MAX) — undefined behavior; use long for larger ranges. sum_to(0) returns 0 because the loop never runs; sum_to(-3) also returns 0 for the same reason, which may or may not be what you want — decide and document.

Line by line

Walking through the program in execution order:

  1. The prototypes at the top introduce square, max_int, swap, and sum_to. The compiler now knows each signature, so it can check every call inside main even though the bodies appear later.
  2. square(5)5 is copied into parameter n. The body returns 5 * 5 = 25. The copy is discarded when the function returns.
  3. max_int(square(3), 8) — arguments are evaluated first: square(3) returns 9. Then max_int(9, 8) runs; 9 > 8 is true, so the ternary yields 9.
  4. int x = 1, y = 2; then swap(&x, &y). The & operator produces the addresses of x and y. Inside swap, a points at x, b points at y.
  5. Inside swap: tmp = *a reads 1; *a = *b writes 2 into x; *b = tmp writes 1 into y. Because we wrote through the pointers, the real x and y change.
  6. sum_to(5)total starts at 0; the loop adds 1,2,3,4,5; returns 15.

Trace table for swap(&x, &y) (x at addr A, y at addr B):

step            tmp   *a (=x)   *b (=y)
----            ---   -------   -------
start            ?      1         2
tmp = *a         1      1         2
*a  = *b         1      2         2
*b  = tmp        1      2         1

After the call, main sees x=2, y=1. Contrast this with a value-based swap(int a,int b): it would shuffle copies in its own frame and main's x and y would stay 1 and 2.

Common mistakes

Mistake 1 — expecting pass-by-value to change the caller.

void swap(int a, int b) {   // WRONG: copies
    int t = a; a = b; b = t;
}
// caller: swap(x, y);  -> x and y are unchanged

Why it is wrong: a and b are copies; the originals are never touched. Fix: take pointers and pass addresses.

void swap(int *a, int *b) { int t = *a; *a = *b; *b = t; }
// caller: swap(&x, &y);  -> x and y are exchanged

Recognize it: the function "does nothing" from the caller's view. Always ask "did I need a copy or the original?"

Mistake 2 — a path with no return in a non-void function.

int max(int a, int b) {     // WRONG: nothing returned when a <= b
    if (a > b) return a;
}

Why it is wrong: when a <= b the function falls off the end and the returned value is undefined. Fix: cover every path.

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

Prevent it: compile with -Wall; it warns "control reaches end of non-void function."

Mistake 3 — prototype and definition disagree.

int area(int w, int h);            // prototype says two ints
int area(int w) { return w * w; }  // WRONG: body takes one

Why it is wrong: callers trust the prototype and pass two arguments; the definition expects one. Keep the two identical. Putting the prototype in a header and including that header in the .c that defines it makes the compiler catch the mismatch for you.

Mistake 4 — int f() when you mean no parameters. Use int f(void) so the compiler rejects accidental extra arguments.

Debugging tips

Compiler errors / warnings

  • implicit declaration of function 'foo' — you called foo before declaring it. Add a prototype or move the definition above the call, and #include the right header.
  • conflicting types for 'foo' — the prototype and definition (or two prototypes) disagree. Make them identical.
  • control reaches end of non-void function (under -Wall) — add a return on every path.
  • passing argument 1 of 'foo' makes pointer from integer without a cast — you passed a value where a pointer is expected; you probably forgot &.

Runtime / logic errors

  • Function "has no effect" on the caller: you almost certainly passed by value when you needed a pointer.
  • Wrong result: print the inputs at the top of the function and the value just before each return. Compare against a hand calculation for one small case.
  • Crash when a function returns a string/array: see the memory-safety section — you likely returned a pointer to a local.

Always build with warnings on: gcc -std=c11 -Wall -Wextra prog.c -o prog. Most function bugs are warnings you can turn into errors with -Werror.

Questions to ask when it doesn't work: Did I pass the address or the value? Does every path return? Do the prototype and definition match exactly? Is the return type wide enough for the result?

Memory safety

Never return a pointer to a local variable. A local's storage lives in the function's stack frame, which is destroyed the instant the function returns. The caller would hold a dangling pointer to reclaimed memory — reading or writing through it is undefined behavior (often a crash or silent corruption).

char *bad(void) {
    char buf[32];
    strcpy(buf, "hi");
    return buf;          // WRONG: buf dies when bad() returns
}

Safe ways to return data from a function:

  • Fill a caller-provided buffer, and take its size so you cannot overflow it:
    void greet(char *out, size_t n) { snprintf(out, n, "hi"); }
    
  • Return by value — ideal for small structs and all scalars; the value is copied out safely.
  • malloc inside the function and document that the caller must free it. Always check malloc's return for NULL, and free exactly once.

Other function-level concerns:

  • Initialize before use. A local variable is not zeroed automatically; reading it before assignment is undefined.
  • Integer overflow. Arithmetic inside a function (e.g. n * n, a + b) can overflow the return type; choose a wide enough type or check bounds.
  • Validate pointer parameters. If a function may receive NULL, check for it before dereferencing, and document the contract ("p must not be NULL").

Real-world uses

Every C API you will ever touch is a set of functions: printf, strlen, fopen/fclose, malloc/free, pthread_create, socket, read, write. Operating system kernels, embedded firmware, databases, and web servers are all built from layers of small functions calling one another. When you read library documentation, you are reading signatures.

Professional habits for functions:

Beginner rules

  • One function, one job. If you cannot name it with a clear verb phrase (parse_line, compute_average), it is probably doing too much.
  • Keep functions short enough to read on one screen.
  • Validate inputs at the top; handle the error path before the happy path.
  • Match the prototype and definition exactly, and keep prototypes in headers.

Advanced rules

  • Use const on pointer parameters you only read; it documents intent and lets the compiler help.
  • Prefer returning an error code and writing results through out-parameters for functions that can fail, so the caller is forced to consider failure.
  • Keep ownership clear: document who allocates and who frees. Resource cleanup (free, fclose, close) should pair with allocation, ideally in the same function.
  • Avoid hidden global state; pass what the function needs so it stays testable and thread-safe.

Practice tasks

Beginner 1 — square. Write int square(int n) that returns n * n. Call it from main for n = 0, 4, -3 and print each result. Concepts: defining and calling a function, return value. Hint: declare a prototype above main or define square before it.

Beginner 2 — larger_double. Write double larger(double a, double b) that returns the larger of two doubles (return either if equal). Print larger(3.5, 2.1) and larger(-1.0, -0.5). Concepts: parameters, a single return on every path. Hint: a ternary ?: keeps it to one line and guarantees every path returns.

Intermediate 1 — swap with pointers. Write void swap(int *a, int *b) that exchanges the two integers the pointers refer to. In main, set x=7, y=9, call swap(&x, &y), and print before and after. Constraints: do not use a global; do all work through the pointers. Concepts: pointer parameters, dereference, pass-by-address. Hint: use a temporary int.

Intermediate 2 — min_max via out-parameters. Write void min_max(const int *arr, int n, int *out_min, int *out_max) that scans an array of n ints and writes the smallest to *out_min and the largest to *out_max. Example: for {4, 1, 9, 2} it sets *out_min = 1, *out_max = 9. Constraints: assume n >= 1; treat arr as read-only (note the const). Concepts: returning multiple results through pointers, looping inside a function, const correctness. Hint: initialize both outputs to arr[0] before the loop.

Challenge — refactor a monolith. Take a single long main that (1) reads numbers into an array, (2) computes their average, and (3) prints a small histogram of how many fall in each of three ranges. Split it into at least three functions — e.g. int read_values(int *buf, int cap), double average(const int *buf, int n), and void print_histogram(const int *buf, int n) — and have main call them in order. Requirements: each function has a single job and a clear name; pass the buffer and its length (never a global); validate that n >= 1 before dividing for the average. Concepts: decomposition, pointer parameters with length, const, returning by value vs. through buffers. Hint: decide and document what average does for an empty input before you write the division.

Summary

  • A function packages work under a name with typed inputs and one return value; even main is a function. Functions make code testable, reusable, and replaceable.
  • The signature return_type name(params) is the contract callers rely on; use (void) for no parameters.
  • C passes arguments by value — the function gets a copy, so changes to a parameter do not reach the caller. To modify the caller's data, take a pointer and pass the address with &, then write through *p.
  • A function returns at most one value; use a struct or out-parameters for more. Make sure every path returns in a non-void function.
  • Keep the prototype and definition identical; put prototypes in headers, definitions in one .c each.
  • Never return a pointer to a local variable — it dangles. Fill a caller buffer, return by value, or malloc and document who frees.
  • Build with -Wall -Wextra; most function bugs show up as warnings. Remember: a program built from small, well-named functions reads almost like prose.

Practice with these exercises