Functions
Functions are how a program stops being one long sequence and becomes a set of named ideas. C's version is simple and has exactly one rule that catches everyone: arguments are copied, always, with no exceptions.
- Declare, define, and call functions, and explain what a prototype is for.
- Describe what happens to the call stack during a call and a return.
- Explain why a function cannot modify its caller's variable, and what that implies.
- Decompose a long
maininto functions with single responsibilities. - Design a function contract that reports failure rather than hiding it.
1Declaration, definition, call
A definition provides the body:
int add(int a, int b)
{
return a + b;
}A declaration, also called a prototype, gives only the interface and ends with a semicolon:
int add(int a, int b);A call uses it:
int sum = add(3, 4);The compiler reads a file top to bottom and must know a function's interface before it sees a call. Two ways to arrange that: define the function above its first use, or declare it at the top and define it anywhere. The second scales better, and in week 28 the declarations move into a header file.
Why prototypes matter
A prototype lets the compiler check the call. Without one, in pre-C99 code, the compiler assumed the function returned int and checked nothing at all:
/* no declaration in scope */
double result = square(3.0); /* compiler assumes int square() */The argument would be passed as a double but read as an int, and the return value interpreted as an int too. The result is garbage, and in C89 there was no diagnostic. This is the single largest safety improvement C89 made, and modern compilers now reject it:
error: implicit declaration of function 'square'
[-Wimplicit-function-declaration]void in both positions
void print_banner(void); /* returns nothing, takes nothing */Write (void) for an empty parameter list, not (). In C89, empty parentheses mean "unspecified parameters — check nothing", which switches off the very checking prototypes exist to provide. C23 finally made () mean the same as (void), but the explicit form remains correct everywhere.
2The call stack
Week 17 covers program memory layout in full; the part needed now is the stack.
When a function is called, the machine pushes a stack frame containing the arguments, the local variables, and the address to return to. When the function returns, that frame is popped and the memory is reused by the next call.
Frames stack up as calls nest and unwind as they return. Week 18 watches this happen in a debugger.
Two consequences follow directly, and both matter.
Local variables do not survive the return. The frame is gone; the memory belongs to whatever is called next. Returning a pointer to a local is therefore returning a pointer to memory that no longer belongs to you — week 16's "dangling return".
Deep recursion exhausts the stack. Each call consumes a frame, and the stack has a fixed size — typically 8 MB on Linux. Week 18 measures exactly how deep you can go.
3Pass by value — the rule with no exceptions
When you call a function, C copies each argument into the function's parameter. The parameter is a separate variable that happens to start with the same value.
void try_to_double(int n)
{
n = n * 2; /* modifies the copy */
}
int main(void)
{
int value = 5;
try_to_double(value);
printf("%d\n", value); /* 5 — unchanged */
}This is not a limitation to work around casually; it is a guarantee. A function cannot secretly alter the variables you pass it, which makes calls easy to reason about. The cost is that a function which genuinely needs to modify the caller's data must be given something else: the variable's address. That is week 13, and it is why scanf in week 8 required an &.
The swap that cannot work
void swap(int a, int b)
{
int temp = a;
a = b;
b = temp;
}
/* swaps two copies, then discards both */Every C programmer writes this once. It compiles cleanly, runs without error, and does nothing. Keep the file — week 13 fixes it in three characters, and the fix is the clearest possible demonstration of what a pointer is for.
Arrays are the apparent exception. Pass an array to a function and the function can modify its contents, which looks like a contradiction. It is not: what gets copied is a pointer to the first element, so the copy still refers to the same memory. Week 13 explains this properly under the name "array decay". Note it now so it does not look like magic in week 12.
4Designing a function
One job
A function should do one thing that can be named without using "and". read_config_and_connect is two functions. The test is the name: if an honest name needs a conjunction, split it.
Reporting failure
C has no exceptions. A function that can fail must say so through its return value or through an output parameter, and the caller must check. Three conventions dominate:
| Convention | Example | Use when |
|---|---|---|
| Return the result; use an impossible value for failure | find returns n for not-found | There is a value outside the valid range |
| Return a status; write the result through a pointer | bool parse_int(const char *s, int *out) | Every value is a legitimate result |
Return NULL or a negative code | fopen, malloc | Returning a pointer |
The second is the most generally useful and the one week 16 develops. Pick one convention per project and apply it everywhere — week 27 turns this into an explicit error contract.
static for helpers
static int helper(int x) { … }Marking a function static restricts its name to the current source file. Use it for everything that is not part of a file's public interface: it prevents name collisions, lets the compiler inline more aggressively, and documents intent. Week 28 makes this the basis of information hiding.
Parameters
- Few is better than many. More than four or five usually signals that some of them belong together in a structure — week 21.
- Mark pointer parameters you do not modify as
const. It documents the contract and lets the compiler enforce it. Week 16. - Name parameters in the prototype too.
int add(int, int);is legal, butint add(int left, int right);tells the reader which is which.
5Worked example: decomposing a long main
Here is a program that works. It reads scores, computes statistics, and prints a report — all in one function.
#include <stdio.h>
#include <stdlib.h>
int main(void)
{
int scores[] = { 88, 92, 79, 95, 61, 73, 84 };
size_t n = sizeof scores / sizeof scores[0];
int total = 0;
for (size_t i = 0; i < n; i++) total += scores[i];
double mean = (double)total / n;
int hi = scores[0], lo = scores[0];
for (size_t i = 1; i < n; i++) {
if (scores[i] > hi) hi = scores[i];
if (scores[i] < lo) lo = scores[i];
}
int passing = 0;
for (size_t i = 0; i < n; i++) if (scores[i] >= 70) passing++;
printf("n=%zu mean=%.2f hi=%d lo=%d pass=%d\n", n, mean, hi, lo, passing);
return 0;
}Nothing is wrong with the output. What is wrong is that no part of it can be reused, tested, or read independently, and the reader must hold the whole thing in their head at once.
The same program, decomposed
#include <stdio.h>
#include <stdlib.h>
#include <stdbool.h>
#define PASS_MARK 70
/* Each of these does one thing, has a name that says so,
and can be tested on its own. */
static int sum(const int values[], size_t n)
{
int total = 0;
for (size_t i = 0; i < n; i++) {
total += values[i];
}
return total;
}
/* Returns false for an empty array rather than dividing by zero. */
static bool mean(const int values[], size_t n, double *out)
{
if (n == 0) {
return false;
}
*out = (double)sum(values, n) / (double)n;
return true;
}
static int maximum(const int values[], size_t n)
{
int best = values[0]; /* caller guarantees n > 0 */
for (size_t i = 1; i < n; i++) {
if (values[i] > best) {
best = values[i];
}
}
return best;
}
static int minimum(const int values[], size_t n)
{
int best = values[0];
for (size_t i = 1; i < n; i++) {
if (values[i] < best) {
best = values[i];
}
}
return best;
}
static size_t count_at_least(const int values[], size_t n, int threshold)
{
size_t found = 0;
for (size_t i = 0; i < n; i++) {
if (values[i] >= threshold) {
found++;
}
}
return found;
}
static void print_report(const int values[], size_t n)
{
if (n == 0) {
puts("no scores to report");
return;
}
double average = 0.0;
mean(values, n, &average); /* cannot fail: n > 0 checked above */
printf("%-16s %zu\n", "count", n);
printf("%-16s %.2f\n", "mean", average);
printf("%-16s %d\n", "highest", maximum(values, n));
printf("%-16s %d\n", "lowest", minimum(values, n));
printf("%-16s %zu of %zu\n", "passing",
count_at_least(values, n, PASS_MARK), n);
}
int main(void)
{
const int scores[] = { 88, 92, 79, 95, 61, 73, 84 };
const size_t n = sizeof scores / sizeof scores[0];
print_report(scores, n);
puts("\n-- the empty case, which the original would have divided by zero --");
print_report(scores, 0);
puts("\n-- pass by value --");
int value = 5;
printf("before: %d\n", value);
/* try_to_double(value) would change nothing; see below */
printf("after: %d\n", value);
return EXIT_SUCCESS;
}gcc -std=c17 -Wall -Wextra -g -o report report.c
./reportWhat the decomposition bought
An edge case became visible. The original divided by n with no check. Writing mean as its own function with a stated contract forced the question "what if n is zero?", and the answer became part of the interface. Bugs hide in code that is never named.
Each piece is testable. maximum can be called with a one-element array, a sorted array, an array of equal values. In week 35 these become actual unit tests; the decomposition is what makes that possible.
const documents and enforces. Every function that only reads its array says so. Try adding values[0] = 0; inside sum and the compiler refuses — the contract is checked, not merely described.
The reader can stop early. Someone who wants to know how the report is laid out reads print_report and nothing else.
The swap experiment
Add this and run it. It is the setup for week 13.
static void swap(int a, int b)
{
int temp = a;
a = b;
b = temp;
printf(" inside swap: a=%d b=%d\n", a, b);
}
/* in main: */
int x = 1, y = 2;
printf("before: x=%d y=%d\n", x, y);
swap(x, y);
printf("after: x=%d y=%d\n", x, y);before: x=1 y=2
inside swap: a=2 b=1
after: x=1 y=2The swap genuinely happened — to the copies. Both frames existed at once, held different values, and then the callee's frame was discarded. Nothing was lost in translation; there was simply never a connection between a and x.
6Common mistakes
| Mistake | What happens | Fix |
|---|---|---|
| Calling a function before declaring it | implicit declaration error | Prototype at the top, or define above the call. |
Writing int f() for no parameters | Pre-C23, disables argument checking | int f(void). |
| Expecting a function to modify its argument | Nothing changes | Pass the address — week 13. |
| Returning a pointer to a local variable | Dangling pointer; undefined behavior | Return by value, or let the caller supply the storage. |
| Ignoring a function's error return | The program continues on bad data | Check every return that can fail. |
Omitting const on read-only pointer parameters | Contract unstated and unenforced | Add it; the compiler will check. |
| A function that needs six parameters | Calls become unreadable and error-prone | Group related parameters into a struct — week 21. |
No static on file-local helpers | Names leak; collisions at link time | Mark them static — week 28. |
7Check yourself
What is the difference between a declaration and a definition?
A declaration states the interface — name, return type, parameter types — and ends with a semicolon. A definition additionally supplies the body. A program may contain many identical declarations of a function but exactly one definition. Week 28 puts declarations in headers and definitions in source files for precisely this reason.
Why write (void) rather than () for an empty parameter list?
Before C23, empty parentheses meant "parameters unspecified", which tells the compiler to check nothing at the call site — the opposite of what a prototype is for. (void) explicitly states that the function takes no arguments, so a call passing any is an error.
Why can't a C function modify a variable passed to it?
Because arguments are copied into the parameters. The parameter is a distinct variable in a distinct stack frame; assigning to it changes only the copy, which is discarded on return. To modify the caller's variable the function must be given its address instead — which is what scanf's & is doing.
Why is returning a pointer to a local variable wrong?
The local lives in the function's stack frame, and that frame is discarded when the function returns. The pointer then refers to memory that belongs to whatever is called next. It often appears to work, because the value has not yet been overwritten, which makes the bug worse rather than better.
Your function computes an average and the caller passed an empty array. What should it do?
Report failure rather than divide by zero — either by returning a status and writing the result through an output parameter, or by documenting a precondition the caller must satisfy. What it must not do is compute a value that means nothing. Deciding this is part of designing the function, not an afterthought.
8Where this leads
Week 12 introduces arrays, which is where the pass-by-value rule appears to break: pass an array to a function and the function can change it. Understanding why that is consistent rather than exceptional requires pointers, which arrive in week 13 — and which also fix the swap you just watched fail.