Skip to content

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.

C
#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;
}
Output
12
70

int 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.

C
#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;
}
Output
30

The 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.

C
#include <stdio.h>

void print_banner(void) {
    printf("=== Report ===\n");
}

int main(void) {
    print_banner();
    printf("Nothing to report.\n");
    print_banner();
    return 0;
}
Output
=== 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.

C
#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;
}
Output
inside the function n is 42
back in main value is 21

The 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:

C
#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;
}
Output
value is 42

The 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.

C
#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;
}
Output
0
7
10

No 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:

C
#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;
}
Output
5.00
10.33

3.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.

C
#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;
}
Output
call number 1
call number 2
call number 3
total calls: 3

local 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.

C
#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;
}
Output
1! = 1
2! = 2
3! = 6
4! = 24
5! = 120
6! = 720

factorial(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.

C
#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;
}
Output
2024 is a leap year: 1
February 2024 has 29 days
February 2023 has 28 days
April 2024 has 30 days

is_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.