Functions in C — void, Prototypes andPass by Reference Indispensable Guide
Functions in C work with the same concept as in Python — a named block of code you can reuse — but the syntax is stricter and the way data moves in and out is fundamentally different. In Python you never had to think about whether a function could modify the original variable. In C the distinction between pass by value and pass by reference is explicit, visible in the code, and gets things wrong in interesting ways if you misunderstand it.
This article covers everything: declaration, prototypes, void, return, pass by value, pass by reference with pointers, and passing arrays.
Table of Contents
Function structure in C
Every C function has four parts:
return_type function_name(parameter_list) {
body
return value; /* required if return_type is not void */
}
return_type — what type of value the function sends back. If it sends nothing back, use void.
function_name — the name you use to call it. Same rules as variables: lowercase, no spaces, use underscores.
parameter_list — the inputs. Each parameter needs a type: int x, double grade, char *name. If no parameters, write void or leave empty: void greet(void) or void greet().
body — the code that runs when the function is called.
// Returns an int, takes two int parameters
int add(int a, int b) {
return a + b;
}
// Returns nothing, takes a string parameter
void greet(char *name) {
printf("Hello, %s!\n", name);
}
// Returns nothing, takes nothing
void print_separator(void) {
printf("========\n");
}
Prototypes — why C needs them
In Python you could call a function defined anywhere in the file, even below the call site. C reads files top to bottom — it must know a function exists before seeing a call to it. If you define add below main and call it in main, the compiler complains.
The solution is the prototype — a declaration that tells the compiler “this function exists, here’s its signature, the full definition comes later”:
#include <stdio.h>
/* Prototypes — tell the compiler what exists */
int add(int a, int b); /* full parameter names optional */
void greet(char *name);
double average(double *arr, int n);
int main() {
printf("%d\n", add(3, 4)); /* works — compiler knows add() from prototype */
greet("Sergio");
return 0;
}
/* Full definitions below main */
int add(int a, int b) {
return a + b;
}
void greet(char *name) {
printf("Hello, %s!\n", name);
}
In IC2 most labs put main at the bottom, which avoids the prototype problem entirely — all functions are defined before main sees them. But in real projects with multiple files, prototypes go in header files (.h).
void — functions that don’t return a value
void means “nothing”. A void function performs an action but doesn’t give back a result:
void print_grade(double grade) {
if (grade >= 9.0) printf("Outstanding\n");
else if (grade >= 7.0) printf("Merit\n");
else if (grade >= 5.0) printf("Passed\n");
else printf("Failed\n");
}
int main() {
print_grade(8.5); // prints "Merit"
print_grade(4.0); // prints "Failed"
// you can't assign the return value — there is none
return 0;
}
A void function can have a bare return; (with no value) to exit early — the equivalent of return in Python:
void process_grade(double grade) {
if (grade < 0 || grade > 10) {
printf("Error: invalid grade\n");
return; /* exit the function early */
}
/* continues here if grade is valid */
printf("Grade: %.2f\n", grade);
}
return — sending values back
return value; does two things: sends value back to the caller and immediately exits the function. A function can have multiple return statements — it exits from the first one it reaches:
const char* classify(double grade) {
if (grade >= 9.0) return "Outstanding";
if (grade >= 7.0) return "Merit";
if (grade >= 5.0) return "Passed";
return "Failed";
}
int main() {
printf("%s\n", classify(8.5)); // → Merit
printf("%s\n", classify(3.0)); // → Failed
return 0;
}
The function type must match what return sends back. If the prototype says int and you return 3.14, the compiler silently truncates it to 3.
Pass by value — the default in C
When you call a function in C, the default behaviour is pass by value — the function receives a copy of the argument’s value. Modifying the copy doesn’t affect the original:
void try_to_double(int n) {
n = n * 2; /* modifies the copy — not the original */
printf("Inside: %d\n", n);
}
int main() {
int x = 5;
try_to_double(x);
printf("Outside: %d\n", x); /* x is still 5 */
return 0;
}
Inside: 10 Outside: 5 ← x was not modified
This is identical to Python’s behaviour with integers. try_to_double(x) passes a copy of 5 to the function. n is a new variable that starts as 5 and becomes 10. When the function returns, n disappears. x is untouched.
Pass by reference — with pointers
To let a function modify the original variable, you pass its address — a pointer. The function receives the address, dereferences it and modifies what’s stored there:
void double_value(int *p) { /* p is a pointer to int */
*p = *p * 2; /* dereference p to get/set the value */
printf("Inside: %d\n", *p);
}
int main() {
int x = 5;
double_value(&x); /* pass the ADDRESS of x */
printf("Outside: %d\n", x); /* x is now 10 */
return 0;
}
Inside: 10 Outside: 10 ← x WAS modified
The comparison:
/* Pass by value — function gets a copy */
void f_value(int n) {
n = 99; /* local copy only */
}
int x = 5;
f_value(x);
// x is still 5
/* Pass by reference — function gets the address */
void f_ref(int *p) {
*p = 99; /* modifies the original through the address */
}
int x = 5;
f_ref(&x);
// x is now 99
In Python, this distinction is hidden — integers are immutable and you can’t modify them through a function call anyway. In C it’s explicit and important: f(x) passes a copy, f(&x) passes the address.
Returning multiple values through pointers
A function can only return one value. Pointers let you return multiple values by writing directly to addresses passed by the caller — the pattern you saw in the pointers article:
#include <stdio.h>
void divide(int a, int b, int *quotient, int *remainder) {
if (b == 0) {
*quotient = 0;
*remainder = 0;
printf("Error: division by zero\n");
return;
}
*quotient = a / b;
*remainder = a % b;
}
void min_max(int *arr, int n, int *min, int *max) {
*min = arr[0];
*max = arr[0];
for (int i = 1; i < n; i++) {
if (arr[i] < *min) *min = arr[i];
if (arr[i] > *max) *max = arr[i];
}
}
int main() {
int q, r;
divide(17, 5, &q, &r);
printf("17 / 5 = %d remainder %d\n", q, r);
int numbers[] = {4, 2, 8, 1, 9, 3};
int minimum, maximum;
min_max(numbers, 6, &minimum, &maximum);
printf("Min: %d, Max: %d\n", minimum, maximum);
return 0;
}
Output:
17 / 5 = 3 remainder 2 Min: 1, Max: 9
Passing arrays to functions
Arrays in C are special — you can’t pass an array by value. When you pass an array name to a function, you’re passing a pointer to its first element. The function can modify the original array directly without needing &:
#include <stdio.h>
/* These three declarations are equivalent for functions */
void print_array(int arr[], int n); /* style 1 */
void print_array(int *arr, int n); /* style 2 — more explicit */
/* void print_array(int arr[5], int n); style 3 — size ignored */
void print_array(int *arr, int n) {
printf("[");
for (int i = 0; i < n; i++) {
printf("%d", arr[i]);
if (i < n - 1) printf(", ");
}
printf("]\n");
}
void fill_array(int *arr, int n, int value) {
for (int i = 0; i < n; i++) {
arr[i] = value; /* modifies the original — no & needed */
}
}
void double_elements(int *arr, int n) {
for (int i = 0; i < n; i++) {
arr[i] *= 2;
}
}
double array_average(int *arr, int n) {
if (n <= 0) return 0.0;
int sum = 0;
for (int i = 0; i < n; i++) {
sum += arr[i];
}
return (double)sum / n; /* cast before division */
}
int main() {
int numbers[] = {1, 2, 3, 4, 5};
int n = 5;
printf("Original: ");
print_array(numbers, n);
double_elements(numbers, n);
printf("Doubled: ");
print_array(numbers, n);
printf("Average: %.2f\n", array_average(numbers, n));
int arr[8];
fill_array(arr, 8, 7);
printf("Filled: ");
print_array(arr, 8);
return 0;
}
Output:
Original: [1, 2, 3, 4, 5] Doubled: [2, 4, 6, 8, 10] Average: 6.00 Filled: [7, 7, 7, 7, 7, 7, 7, 7]
Why does arr parameter work without & in the call? Because numbers in main is already a pointer to the first element — the same as &numbers[0]. When double_elements(numbers, n) is called, the function receives that pointer and modifies the actual array in memory.
The const modifier — read-only parameters
When you pass an array (or any pointer) and don’t want the function to modify it, use const:
/* const prevents modification — good practice for read-only parameters */
void print_array(const int *arr, int n) {
for (int i = 0; i < n; i++) {
/* arr[i] = 0; → compile error: assignment to read-only location */
printf("%d ", arr[i]);
}
printf("\n");
}
Using const makes the function’s intent clear and catches accidental modifications at compile time.
A complete practical example
#include <stdio.h>
/* Prototypes */
double average(double *arr, int n);
void sort_bubble(double *arr, int n);
void swap(double *a, double *b);
int count_above(double *arr, int n, double threshold);
void stats(double *arr, int n, double *min, double *max, double *avg);
int main() {
double grades[] = {7.5, 3.0, 8.5, 5.0, 9.5, 4.5, 6.0};
int n = 7;
printf("=== GRADE ANALYSIS ===\n\n");
/* Sort the array */
sort_bubble(grades, n);
printf("Sorted grades: ");
for (int i = 0; i < n; i++)
printf("%.1f ", grades[i]);
printf("\n");
/* Statistics */
double min, max, avg;
stats(grades, n, &min, &max, &avg);
printf("Min: %.2f\n", min);
printf("Max: %.2f\n", max);
printf("Average: %.2f\n", avg);
printf("Above 5: %d\n", count_above(grades, n, 5.0));
return 0;
}
double average(double *arr, int n) {
double sum = 0;
for (int i = 0; i < n; i++)
sum += arr[i];
return sum / n;
}
void swap(double *a, double *b) {
double temp = *a;
*a = *b;
*b = temp;
}
void sort_bubble(double *arr, int n) {
for (int i = 0; i < n - 1; i++) {
for (int j = 0; j < n - 1 - i; j++) {
if (arr[j] > arr[j+1]) {
swap(&arr[j], &arr[j+1]);
}
}
}
}
int count_above(double *arr, int n, double threshold) {
int count = 0;
for (int i = 0; i < n; i++)
if (arr[i] > threshold)
count++;
return count;
}
void stats(double *arr, int n, double *min, double *max, double *avg) {
*min = arr[0];
*max = arr[0];
double sum = arr[0];
for (int i = 1; i < n; i++) {
if (arr[i] < *min) *min = arr[i];
if (arr[i] > *max) *max = arr[i];
sum += arr[i];
}
*avg = sum / n;
}
Output:
=== GRADE ANALYSIS === Sorted grades: 3.0 4.5 5.0 6.0 7.5 8.5 9.5 Min: 3.00 Max: 9.50 Average: 6.29 Above 5: 4
Visualise with Python Tutor
Select C from the dropdown and paste in pythontutor.com:
#include <stdio.h>
void swap_wrong(int a, int b) {
int temp = a;
a = b;
b = temp;
}
void swap_correct(int *a, int *b) {
int temp = *a;
*a = *b;
*b = temp;
}
int main() {
int x = 5, y = 10;
swap_wrong(x, y);
printf("After wrong swap: x=%d, y=%d\n", x, y);
swap_correct(&x, &y);
printf("After correct swap: x=%d, y=%d\n", x, y);
return 0;
}
Step through and observe the fundamental difference. When swap_wrong(x, y) is called, Python Tutor creates a new frame with a=5 and b=10 — copies. The swap happens inside the frame, but when the frame closes x and y are unchanged. When swap_correct(&x, &y) is called, the new frame has a pointing at x and b pointing at y — you can see the arrows in the memory diagram. *a = *b follows the arrow, reaches x in main’s frame, and writes y‘s value there. When the frame closes, x and y have genuinely swapped. This visual is the clearest demonstration of why pointers exist.
Quick summary
/* FUNCTION STRUCTURE */
return_type name(type param1, type param2) {
/* body */
return value; /* required unless return_type is void */
}
/* VOID — no return value */
void greet(char *name) {
printf("Hello, %s!\n", name);
/* optional: return; to exit early */
}
/* PROTOTYPE — declare before use */
int add(int a, int b); /* tells compiler it exists */
int add(int a, int b) { return a + b; } /* full definition */
/* PASS BY VALUE — function gets a copy */
void f(int n) { n = 99; } /* n is a copy */
int x = 5;
f(x);
/* x is still 5 */
/* PASS BY REFERENCE — function gets the address */
void f(int *p) { *p = 99; } /* dereference to modify original */
int x = 5;
f(&x); /* pass address with & */
/* x is now 99 */
/* MULTIPLE RETURN VALUES — through pointer parameters */
void divide(int a, int b, int *q, int *r) {
*q = a / b;
*r = a % b;
}
int q, r;
divide(17, 5, &q, &r); /* q=3, r=2 */
/* PASSING ARRAYS — no & needed, already a pointer */
void process(int *arr, int n) { ... }
int numbers[] = {1, 2, 3};
process(numbers, 3); /* numbers is already &numbers[0] */
/* const — read-only parameter */
void print(const int *arr, int n) { ... } /* cannot modify arr[i] */
/* COMMON MISTAKES */
/* 1. Forgetting & when passing non-array for reference */
/* 2. Using %f instead of %lf in scanf for double param */
/* 3. Returning local array — it disappears when function returns */
/* 4. Calling function before prototype or definition */
/* 5. Mismatching return type with actual return value type */
/* COMPILE AND RUN */
/* gcc program.c -o program -Wall */
/* ./program */
In the next article we practice C functions with real programs compiled in Fedora.

2 Comments