C lesson 4 of 8
C Functions: Prototypes, Arguments and Return Values
How to define and call a function in C, why the compiler needs a prototype before the call, what pass by value really means for your variables, and how return values, void and recursion fit together.
Published · Every example on this page was run before it was published.
You have already written one function in every program so far: main. A function is a named block
of statements with a declared set of inputs and one declared output type, and C gives you no way to
organise a program except by writing more of them. That constraint turns out to be the language's great
strength - a C program is a set of small, separately understandable functions, and learning to draw the
lines between them well is most of learning to write C.
Defining and Calling
A definition is a return type, a name, a parenthesised list of parameters with their types, and a body.
#include <stdio.h>
int area(int width, int height) {
return width * height;
}
int main(void) {
printf("%d\n", area(3, 4));
printf("%d\n", area(10, 7));
return 0;
}12
70int area(int width, int height) says: this function is called area, it must be given two ints, and
it hands back an int. Unlike some languages, C insists on a type for every single parameter - you
cannot write area(int width, height).
width and height are parameters: names that exist only inside area, each behaving like a local
variable that arrives with a value already in it. 3 and 4 in the call are arguments: the actual
values handed over. Parameters are written once, in the definition; arguments are supplied afresh on
every call.
return hands a value back and ends the function immediately. Because area(3, 4) becomes the value
12, it can go anywhere a 12 could go - into a printf, into a variable, into another expression, or
straight into another function call.
Prototypes: Telling the Compiler First
A C compiler reads your file once, top to bottom. At the moment it reaches a call it must already know
that function's name, how many arguments it takes and of what types - otherwise it cannot check the call
or even work out how to make it. Defining area above main, as we just did, satisfies that. But real
programs cannot always be ordered that way, and main reads better at the top.
The answer is a prototype: the function's header, with a semicolon instead of a body.
#include <stdio.h>
int area(int width, int height);
int main(void) {
printf("%d\n", area(6, 5));
return 0;
}
int area(int width, int height) {
return width * height;
}30The prototype is a promise that a function with exactly this shape exists somewhere. The compiler checks every call against it, and the linker later makes sure the promise was kept - a prototype with no matching definition anywhere compiles fine and then fails at the link stage, with a message about an undefined symbol rather than a line number.
This is precisely what #include <stdio.h> has been doing all along. That header contains a prototype
for printf and its relatives, which is how the compiler has been able to check your calls. The
parameter names in a prototype are optional - int area(int, int); is equally valid - but writing them
documents the order, which is worth far more than the keystrokes cost.
Calling a function the compiler has never heard of is an error in modern C. There is no longer any
"assume it returns int" fallback, so a missing prototype or a missing #include stops the build.
void: No Arguments, No Result
A function that does something rather than computing something is declared to return void, meaning
"nothing". void in the parameter list means the same on the way in.
#include <stdio.h>
void print_banner(void) {
printf("=== Report ===\n");
}
int main(void) {
print_banner();
printf("Nothing to report.\n");
print_banner();
return 0;
}=== Report ===
Nothing to report.
=== Report ===A void function may still use return; with no value, to leave early; it simply cannot carry anything
out with it. And a call to a void function is a complete statement on its own - there is no value to
use, so trying to write int x = print_banner(); is an error the compiler will catch.
Arguments Are Copied: Pass by Value
This is the rule that explains the most C behaviour. Every argument is copied into its parameter. The function works on the copy, and the caller's variable is untouched no matter what the function does to it.
#include <stdio.h>
void try_to_double(int n) {
n = n * 2;
printf("inside the function n is %d\n", n);
}
int main(void) {
int value = 21;
try_to_double(value);
printf("back in main value is %d\n", value);
return 0;
}inside the function n is 42
back in main value is 21The doubling really happened - n is 42 on the line that prints it. But n is a separate variable
that happened to start with a copy of value, and when the function returned, n ceased to exist and
its 42 went with it. Nothing the function did could reach value.
There is no way to change that for a plain int. What you can do is give the value back:
#include <stdio.h>
int doubled(int n) {
return n * 2;
}
int main(void) {
int value = 21;
value = doubled(value);
printf("value is %d\n", value);
return 0;
}value is 42The other route is to hand the function the location of your variable rather than a copy of its contents, which is what pointers are for and what the next lesson is about. Even then the rule has not changed: the pointer itself is copied. It simply happens that a copy of an address still points at the original.
Several Returns, and Returning Early
A function may contain as many return statements as it needs; exactly one of them runs per call, and
whichever runs ends the function there and then. That turns a chain of conditions into a flat, readable
series of exits.
#include <stdio.h>
int clamp(int value, int low, int high) {
if (value < low) {
return low;
}
if (value > high) {
return high;
}
return value;
}
int main(void) {
printf("%d\n", clamp(-4, 0, 10));
printf("%d\n", clamp(7, 0, 10));
printf("%d\n", clamp(99, 0, 10));
return 0;
}0
7
10No else is needed anywhere, because a return makes the rest of the function unreachable for that
call. Notice also that the three calls pass the same bounds each time; a function's parameters are its
entire contract, and everything it needs must arrive through them or be a genuine global.
A non-void function must return a value on every path that can be reached. Falling off the end of an
int function and then using the result is undefined behaviour, and it is the kind of mistake that hides
in the one branch nobody tested. Compilers will usually warn; take the warning seriously.
Returning a double works exactly the same way, and is the natural place for the cast from the
variables lesson:
#include <stdio.h>
double average_of_three(int a, int b, int c) {
return (a + b + c) / 3.0;
}
int main(void) {
printf("%.2f\n", average_of_three(4, 5, 6));
printf("%.2f\n", average_of_three(10, 10, 11));
return 0;
}5.00
10.333.0 rather than 3 is what makes this a floating-point division; with 3 the sum would have been
divided as integers and only then widened, and the second answer would have come out as 10.00.
Local Variables, Globals and Scope
Scope is the region of the program in which a name can be seen. A variable declared inside a
function - including a parameter - is local to it: created when the call begins, destroyed when the
call ends, and invisible to everything else. Two functions can each have an i without any relationship
between them, which is exactly what makes functions independently understandable.
A variable declared outside every function is global: visible to every function below it in the file, and alive for the whole run of the program. Globals declared this way are also guaranteed to start at zero, unlike locals.
#include <stdio.h>
int calls = 0;
void record(void) {
int local = 1;
calls = calls + local;
printf("call number %d\n", calls);
}
int main(void) {
record();
record();
record();
printf("total calls: %d\n", calls);
return 0;
}call number 1
call number 2
call number 3
total calls: 3local was created and destroyed three times, once per call, and always started at 1. calls
survived between calls because it lives outside any of them.
Use globals sparingly. A function that reads and writes shared state cannot be understood from its
signature, cannot be tested in isolation, and turns "who changed this value?" into a search of the whole
file. Constants are the honourable exception, and const double TAX_RATE = 0.2; at file scope is
perfectly good style. For everything else, take what you need through parameters and hand the result back
with return.
C also offers static on a local variable, which keeps it alive between calls while still hiding it from
the rest of the program - a middle ground worth knowing exists.
Recursion
A function may call itself. Each call gets its own fresh set of locals, so the calls do not interfere; what matters is that there is a base case which returns without recursing, and that every other path moves toward it.
#include <stdio.h>
long factorial(int n) {
if (n <= 1) {
return 1;
}
return n * factorial(n - 1);
}
int main(void) {
for (int n = 1; n <= 6; n++) {
printf("%d! = %ld\n", n, factorial(n));
}
return 0;
}1! = 1
2! = 2
3! = 6
4! = 24
5! = 120
6! = 720factorial(4) cannot answer on its own; it asks factorial(3), which asks factorial(2), which asks
factorial(1), which returns 1 without asking anybody. The answers then multiply back up the chain.
The %ld specifier goes with long, just as %d goes with int.
Factorials grow fast enough to overflow quickly, which is why this example stops at six. Recursion also costs memory: every outstanding call keeps its locals, and a recursion with no reachable base case will exhaust the stack rather than looping forever. Plenty of recursive functions are clearer written as loops, and in C the loop is usually the cheaper choice - but some problems, especially ones on trees and nested structures, are genuinely easier to see recursively.
A Worked Example
Two small functions, one calling the other, with prototypes at the top so main can be read first.
#include <stdio.h>
int is_leap_year(int year);
int days_in_month(int month, int year);
int main(void) {
int year = 2024;
printf("%d is a leap year: %d\n", year, is_leap_year(year));
printf("February %d has %d days\n", year, days_in_month(2, year));
printf("February 2023 has %d days\n", days_in_month(2, 2023));
printf("April 2024 has %d days\n", days_in_month(4, 2024));
return 0;
}
int is_leap_year(int year) {
if (year % 400 == 0) {
return 1;
}
if (year % 100 == 0) {
return 0;
}
return year % 4 == 0;
}
int days_in_month(int month, int year) {
if (month == 2) {
return is_leap_year(year) ? 29 : 28;
}
if (month == 4 || month == 6 || month == 9 || month == 11) {
return 30;
}
return 31;
}2024 is a leap year: 1
February 2024 has 29 days
February 2023 has 28 days
April 2024 has 30 daysis_leap_year returns 1 or 0 rather than anything wordier, which is what lets it be used directly as
a condition inside days_in_month and printed with %d inside main. The final return year % 4 == 0;
is not a shortcut for an if - it returns the value of the comparison, which is already exactly 1 or
0.
days_in_month calls is_leap_year, and the compiler was happy with that because the prototype at the
top of the file had already described it, even though the definition appears further down. The two
functions are ordered for a reader's benefit rather than the compiler's, which is the whole point of
prototypes.
One thing this example does not do is validate its input. days_in_month(13, 2024) would cheerfully
return 31, because nothing checks that a month is between 1 and 12. Deciding whether a function
validates its arguments or trusts its caller is a real design decision in C, and the important part is
that the answer is deliberate and written down.
Common Mistakes
Calling a function before the compiler has met it. Either define it above the call or write a prototype near the top of the file. The error message will be about an undeclared identifier, and it points at the call rather than at the missing declaration.
A prototype that does not match the definition. Change a parameter from int to double in one
place and not the other, and the compiler will complain about conflicting types for the same name. Keep
them together in your head as one thing with two copies.
Expecting a function to modify its argument. The pass-by-value rule. void reset(int n) { n = 0; }
does nothing observable at all. Return the new value, or take a pointer.
Forgetting to use the return value. clamp(x, 0, 10); on a line of its own computes the answer and
throws it away. C allows it - the value is simply discarded - so nothing complains. Assign it:
x = clamp(x, 0, 10);.
Omitting void in a parameter list. int f() is not quite the same as int f(void). The empty
version declares a function taking an unspecified number of arguments, which switches off the checking
you wanted. Write (void) when there are no parameters.
Returning the address of a local variable. A local ceases to exist when the function returns, so a pointer to it points at memory that is no longer yours. This compiles, often warns, and is undefined behaviour to use. The dynamic-memory lesson shows the right ways to hand a block of data back to a caller.
A recursion with no reachable base case. factorial(-1) in the version above is fine, because the
test is n <= 1. Written as n == 1 it would recurse forever on any negative input. Make the base case
catch everything below the interesting range, not just the one value you had in mind.
Next Steps
In the C playground, split one of your earlier programs into functions: one that
computes a value, one that prints a formatted line, and a main that does neither except call them.
Then move all the definitions below main and add the prototypes needed to make it compile again - that
exercise alone makes the purpose of a prototype permanent.
The next two lessons are the heart of C. Arrays and strings come first, then pointers, which is where pass by value stops being a limitation.