Functions in C Part 2: Call by Value, Call by Reference, Scope, and Recursion

In Part 1, we covered what functions are and how to declare, define, and call them. In this post, we go deep into how values actually travel into and out of functions, what pointers are and how they enable call by reference, where variables “live” (scope), and one of the most important ideas in programming: a function calling itself, known as recursion. This post has been expanded with additional worked examples, memory-level diagrams, and step-by-step traces — the same style of progression used in standard textbooks like Programming in ANSI C by E. Balagurusamy and Let Us C by Yashavant Kanetkar, which are excellent companions to this post for further solved problems and exercises.

How Function Calls Actually Use Memory

Before comparing call by value and call by reference, it helps to see that every variable in your program lives at a specific memory address. You can print a variable’s address using the & (address-of) operator with the %p format specifier:

#include <stdio.h>

int main() {
    int x = 10;
    printf("Value of x: %d\n", x);
    printf("Address of x: %p\n", (void*) &x);
    return 0;
}

Sample Output (addresses will differ on your machine)

Value of x: 10
Address of x: 0x7ffee4d3a9ac
The core idea this entire post builds on: Every variable has two things associated with it: a VALUE (what’s stored) and an ADDRESS (where it’s stored in memory). Call by value copies the VALUE into a function. Call by reference copies the ADDRESS into a function, letting that function reach back and modify the original variable’s value directly. Everything below is really just this one idea, applied in different situations.

Call by Value: The Default in C

When you pass a variable to a function in C, by default, C copies the value of that variable into the function’s parameter — the parameter is a brand-new variable, with its own separate address, that just happens to start out holding the same value. The function works with this private copy — any changes made inside the function do not affect the original variable back in the caller.

#include <stdio.h>

void tryToDouble(int num) {
    printf("Address of num (inside function): %p\n", (void*) &num);
    num = num * 2;
    printf("Inside function, num = %d\n", num);
}

int main() {
    int value = 10;
    printf("Address of value (inside main): %p\n", (void*) &value);

    tryToDouble(value);
    printf("Back in main, value = %d\n", value);

    return 0;
}

Sample Output (addresses will differ on your machine)

Address of value (inside main): 0x7ffee4d3a9ac
Address of num (inside function): 0x7ffee4d3a988
Inside function, num = 20
Back in main, value = 10

Notice two things: first, num and value have different addresses — they are genuinely two separate memory locations, not the same variable under two names. Second, value in main() is still 10, even though num was doubled inside the function. This is call by value: modifying num only ever affects the memory at num‘s address, which has no connection to value‘s address once the copy has been made.

A Visual Way to Think About It

main()'s memory:                tryToDouble()'s memory:

  value                              num
+-------+                          +-------+
|  10   |  address: 0x...9ac       |  10   |  address: 0x...988
+-------+        |                 +-------+
                 |  COPY the VALUE (10) across
                 +---------------------------->
                                   num becomes 20 here,
                                   but value at 0x...9ac
                                   is never touched.

Call by Reference: Passing Addresses with Pointers

Sometimes you want a function to modify the caller’s actual variable — for example, a function that swaps two numbers, or updates several results at once (since a function can only return one value directly). C achieves this using pointers.

A Quick Pointer Primer

A pointer is a variable that stores a memory address instead of an ordinary value. Two operators are essential here:

  • & (address-of) — placed before a variable, gives you its address. &x means “the address where x is stored.”
  • * (dereference / indirection) — used two different ways depending on context:
    • In a declaration, int *p; means “p is a pointer that will hold the address of an int.”
    • In an expression, *p means “go to the address stored in p, and access the actual value sitting there.”
#include <stdio.h>

int main() {
    int x = 10;
    int *p;      // p is a pointer to an int (currently uninitialized — points nowhere valid yet)

    p = &x;       // p now holds x's address

    printf("Value of x directly: %d\n", x);
    printf("Value of x via pointer p (*p): %d\n", *p);
    printf("Address stored in p: %p\n", (void*) p);
    printf("Address of p itself: %p\n", (void*) &p);

    *p = 50;      // changes the value AT the address p points to — i.e. changes x itself
    printf("x after *p = 50: %d\n", x);

    return 0;
}

Sample Output

Value of x directly: 10
Value of x via pointer p (*p): 10
Address stored in p: 0x7ffee4d3a9ac
Address of p itself: 0x7ffee4d3a9a0
x after *p = 50: 50
A subtlety that trips up almost every beginner: A pointer is ITSELF a variable, stored at its own address (see &p above) — and it holds, as its value, the address of something else (x, in this case). So there are three distinct things: x’s value (10, later 50), x’s address (where x lives), and p’s address (where p itself lives, completely separate from both). Keeping these three straight is the entire key to understanding pointers.

Putting It Together: The swap() Function

#include <stdio.h>

void swap(int *a, int *b) {
    printf("Inside swap: a points to address %p, which holds %d\n", (void*) a, *a);
    printf("Inside swap: b points to address %p, which holds %d\n", (void*) b, *b);

    int temp = *a;   // *a means "the value stored at the address a points to"
    *a = *b;
    *b = temp;
}

int main() {
    int x = 5, y = 10;

    printf("Before swap: x = %d (at %p), y = %d (at %p)\n", x, (void*) &x, y, (void*) &y);
    swap(&x, &y);   // passing ADDRESSES, not values
    printf("After swap:  x = %d, y = %d\n", x, y);

    return 0;
}

Sample Output (addresses will differ on your machine)

Before swap: x = 5 (at 0x7ffee4d3a9ac), y = 10 (at 0x7ffee4d3a9a8)
Inside swap: a points to address 0x7ffee4d3a9ac, which holds 5
Inside swap: b points to address 0x7ffee4d3a9a8, which holds 10
After swap:  x = 10, y = 5

Breaking This Down, Step by Step

  • swap(&x, &y) passes the memory addresses of x and y — not copies of their values.
  • Inside swap, the parameters a and b are themselves pointer variables (note their own type: int *a) — they store the addresses that were passed in.
  • *a means “go to the address stored in a, and read/write the value there.” Since a holds x‘s real address, writing to *a writes directly into x‘s memory.
  • This is why the swap is visible back in main() — unlike the tryToDouble() example, swap() never had its own private copy of the values; it always operated on the caller’s original memory through the addresses it was given.
Call by value vs. call by reference, side by side: CALL BY VALUE: function receives a COPY of the value. Changes inside the function are local and invisible outside. Use when the function only needs to READ data or compute and RETURN a new result. CALL BY REFERENCE (via pointers): function receives the ADDRESS of the original variable. Changes inside the function directly modify the caller’s variable. Use when the function needs to MODIFY the caller’s variable(s), or needs to send back more than one result.

A Practical Use Case: Returning Multiple Values

A function can only return one value. But with pointers, a function can update several variables at once — effectively returning multiple results.

#include <stdio.h>

void minMax(int arr[], int size, int *min, int *max) {
    *min = arr[0];
    *max = arr[0];

    for (int i = 1; i < size; i++) {
        if (arr[i] < *min) *min = arr[i];
        if (arr[i] > *max) *max = arr[i];
    }
}

int main() {
    int numbers[] = {23, 5, 67, 12, 89, 1};
    int smallest, largest;

    minMax(numbers, 6, &smallest, &largest);

    printf("Smallest: %d\n", smallest);
    printf("Largest: %d\n", largest);

    return 0;
}

Sample Output

Smallest: 1
Largest: 89

Note on arrays: unlike ordinary variables, when you pass an array to a function (like numbers above), C automatically passes it as a pointer to its first element — this is often called “array decay.” Arrays are effectively always passed “by reference” in C, even without an explicit &. This is why modifying arr[i] inside a function affects the original array in the caller, and why the array parameter int arr[] in the function signature above is functionally identical to writing int *arr.

Example: A Function That Modifies an Array In Place (Doubling Every Element)

#include <stdio.h>

void doubleAll(int arr[], int size) {
    for (int i = 0; i < size; i++) {
        arr[i] = arr[i] * 2;   // modifies the CALLER's array directly — no pointer syntax (*) needed here
    }
}

int main() {
    int nums[] = {1, 2, 3, 4, 5};

    doubleAll(nums, 5);

    printf("After doubling: ");
    for (int i = 0; i < 5; i++) {
        printf("%d ", nums[i]);
    }
    printf("\n");

    return 0;
}

Sample Output

After doubling: 2 4 6 8 10
Why doesn't this contradict call by value?: It doesn't — because what's actually being copied into the function is the ARRAY'S ADDRESS (due to array decay), not the array's contents. arr inside doubleAll() is a copy of a pointer, but that copy still points to the SAME underlying array data as nums in main(). So the pointer variable itself follows call-by-value rules, but what it points TO is shared, which is why changes to arr[i] are visible in main(). This is a common point of confusion — re-read this note if it doesn't click immediately, since it explains a lot of C's array behavior.

Scope: Where Do Variables Live?

Every variable in C has a scope — the region of code where it's valid and accessible — and a lifetime — how long it actually exists in memory. These are related but distinct ideas, and it's worth separating them clearly.

#include <stdio.h>

int globalCounter = 0;   // global variable: visible to every function in this file, lives for the whole program

void increment() {
    int localValue = 100;   // local variable: only exists inside increment()
    globalCounter++;
    localValue++;
    printf("Inside increment: localValue = %d, globalCounter = %d\n", localValue, globalCounter);
}

int main() {
    increment();
    increment();
    increment();

    // printf("%d", localValue); // ERROR: localValue doesn't exist here — it's out of scope

    printf("Final globalCounter (visible in main too): %d\n", globalCounter);

    return 0;
}

Sample Output

Inside increment: localValue = 101, globalCounter = 1
Inside increment: localValue = 101, globalCounter = 2
Inside increment: localValue = 101, globalCounter = 3
Final globalCounter (visible in main too): 3
  • Local variables (like localValue) have local scope (only accessible inside the function they're declared in) and automatic lifetime (created fresh each time the function runs, destroyed when it returns) — that's why localValue is always 101 at print time, never accumulating across calls, and why main() can't access it at all.
  • Global variables (like globalCounter) have global (file) scope and lifetime equal to the entire program's run — which is why the increments accumulate across all three calls, and why main() can read the final value.
Why overusing global variables is considered poor practice: With global variables, ANY function in the file can read or modify them at any time — which means understanding what a function does may require reading every other function that touches the same global, not just the function itself. This makes bugs harder to trace (a value changing unexpectedly might be modified from a completely unrelated part of the program) and makes functions harder to reuse elsewhere. Prefer passing values as parameters and returning results (or using pointers when a function genuinely needs to modify caller data), reserving globals for genuinely program-wide constants or state that many functions legitimately need to share.

Block Scope: A Third, Narrower Level

#include <stdio.h>

int main() {
    int x = 5;

    if (x > 0) {
        int y = 10;   // y's scope is limited to this if-block
        printf("Inside block: x = %d, y = %d\n", x, y);
    }

    // printf("%d", y); // ERROR: y is out of scope here, even though we're still in main()

    printf("Outside block: x = %d\n", x);

    return 0;
}

Any variable declared inside a pair of { } — whether that's a function body, an if block, or a loop body — is only visible within that block. This nested structure (block scope inside function scope inside file/global scope) is one of the most fundamental structural rules in C.

Recursion: A Function That Calls Itself

Recursion is when a function calls itself to solve a smaller version of the same problem, until it reaches a base case simple enough to answer directly.

Starting Simple: Sum of the First N Natural Numbers

Before factorial, it's worth seeing recursion on the simplest possible example: adding up 1 + 2 + 3 + ... + n.

#include <stdio.h>

int sumToN(int n) {
    if (n == 0) {          // base case
        return 0;
    }
    return n + sumToN(n - 1);   // recursive case
}

int main() {
    printf("Sum of first 5 natural numbers: %d\n", sumToN(5));
    return 0;
}

Sample Output

Sum of first 5 natural numbers: 15

Tracing sumToN(5)

sumToN(5) = 5 + sumToN(4)
          = 5 + (4 + sumToN(3))
          = 5 + (4 + (3 + sumToN(2)))
          = 5 + (4 + (3 + (2 + sumToN(1))))
          = 5 + (4 + (3 + (2 + (1 + sumToN(0)))))
          = 5 + (4 + (3 + (2 + (1 + 0))))     <- sumToN(0) hits the base case, returns 0
          = 15

Example: Factorial

The factorial of n (written n!) is n × (n-1) × (n-2) × ... × 1. Notice that n! = n × (n-1)!, and (n-1)! is just "factorial, but for a smaller number" — the same recursive shape as the sum example above.

#include <stdio.h>

int factorial(int n) {
    if (n == 0 || n == 1) {   // base case: stops the recursion
        return 1;
    }
    return n * factorial(n - 1);   // recursive case
}

int main() {
    for (int i = 0; i <= 5; i++) {
        printf("%d! = %d\n", i, factorial(i));
    }
    return 0;
}

Sample Output

0! = 1
1! = 1
2! = 2
3! = 6
4! = 24
5! = 120

What Actually Happens in Memory: The Call Stack

Every function call — recursive or not — gets its own private block of memory called a stack frame (or activation record), holding that call's own copies of its parameters and local variables. These stack frames are stacked on top of each other in the order calls are made, and removed in the reverse order as each call finishes — hence the name call stack.

Calling factorial(4) builds up these stack frames, one on top of the previous:

+-------------------+
| factorial(1)       |  <- called last, finishes FIRST (base case, returns 1 immediately)
| n = 1               |
+-------------------+
| factorial(2)       |  <- waiting for factorial(1) to return before it can compute 2 * 1
| n = 2               |
+-------------------+
| factorial(3)       |  <- waiting for factorial(2) to return
| n = 3               |
+-------------------+
| factorial(4)       |  <- called FIRST, finishes LAST
| n = 4               |
+-------------------+
| main()              |
+-------------------+

Unwinding (as each call returns, its frame is removed from the stack):

factorial(1) returns 1                      -> factorial(2) computes 2 * 1 = 2, returns 2
factorial(2) returns 2                      -> factorial(3) computes 3 * 2 = 6, returns 6
factorial(3) returns 6                      -> factorial(4) computes 4 * 6 = 24, returns 24
factorial(4) returns 24                     -> main() receives 24
Why every recursive call needs its OWN n: Each stack frame has its own private copy of n — factorial(4)'s n is a completely separate memory location from factorial(3)'s n, even though they share the same variable NAME. This is exactly the same call-by-value copying principle from the start of this post, just applied to a function calling ITSELF instead of a different function. Without separate copies per call, recursion could never work — every call would stomp on the same shared n.
What happens if you forget the base case (or it's unreachable): Without a base case that's actually reachable, each call pushes another stack frame without ever popping one off — the call stack grows without bound. Eventually the program runs out of stack memory and crashes with a "stack overflow" error. This is the recursive equivalent of an infinite loop, and just as easy to accidentally write — always double check that your recursive case genuinely moves TOWARD your base case on every call.

Every Correct Recursive Function Needs Two Things

  • A base case — a condition simple enough to answer without recursing further (here, n == 0 || n == 1).
  • A recursive case that moves toward the base case — each call must work on a "smaller" version of the problem (here, n - 1), guaranteeing it eventually reaches the base case.

Example: Fibonacci Sequence

#include <stdio.h>

int fibonacci(int n) {
    if (n == 0) return 0;   // base case 1
    if (n == 1) return 1;   // base case 2
    return fibonacci(n - 1) + fibonacci(n - 2);   // recursive case
}

int main() {
    printf("First 10 Fibonacci numbers:\n");
    for (int i = 0; i < 10; i++) {
        printf("%d ", fibonacci(i));
    }
    printf("\n");
    return 0;
}

Sample Output

First 10 Fibonacci numbers:
0 1 1 2 3 5 8 13 21 34

Each Fibonacci call branches into two further recursive calls instead of one (fibonacci(n-1) AND fibonacci(n-2)), unlike factorial's single chain — which is why this version becomes noticeably slow for larger n (it recomputes the same values repeatedly). This trade-off between recursion's elegance and its efficiency is explored in much more depth, including a full recursion-tree diagram, in this blog's dedicated post on recursion trees.

Another Classic Example: Greatest Common Divisor (Euclidean Algorithm)

The GCD of two numbers can be computed recursively using the rule: gcd(a, b) = gcd(b, a % b), until b becomes 0, at which point a is the answer.

#include <stdio.h>

int gcd(int a, int b) {
    if (b == 0) {          // base case
        return a;
    }
    return gcd(b, a % b);   // recursive case
}

int main() {
    int x = 48, y = 18;
    printf("GCD of %d and %d is %d\n", x, y, gcd(x, y));
    return 0;
}

Sample Output

GCD of 48 and 18 is 18

Tracing gcd(48, 18)

gcd(48, 18) -> b is not 0, call gcd(18, 48 % 18) = gcd(18, 12)
gcd(18, 12) -> b is not 0, call gcd(12, 18 % 12) = gcd(12, 6)
gcd(12, 6)  -> b is not 0, call gcd(6, 12 % 6)  = gcd(6, 0)
gcd(6, 0)   -> b IS 0, base case reached, return a = 6

So gcd(48, 18) = 6

Recursion vs. Iteration

Anything recursion can do, a loop can also do (and vice versa) — they're different tools for expressing the same kind of repetition. As a rule of thumb:

  • Use iteration (loops) when the problem is naturally a simple repeated count or accumulation — usually faster and uses less memory, since it doesn't build up a chain of stack frames.
  • Use recursion when the problem is naturally defined in terms of smaller versions of itself (factorial, Fibonacci, GCD, tree/directory traversal, divide-and-conquer algorithms like merge sort) — often much shorter and easier to read than the equivalent loop-based version.

Practice Problems

To test your understanding of this post, try writing these yourself before checking a solution:

  • Write a recursive function power(base, exponent) that computes base raised to exponent, without using pow() from math.h.
  • Write a function using call by reference that takes three int pointers and sorts the three values into ascending order.
  • Write a recursive function to reverse the digits of a number (compare this to the do-while loop version covered elsewhere on this blog — same problem, different technique).
  • Write a recursive function to check whether a string is a palindrome.
Where to go for more solved examples: Both E. Balagurusamy's Programming in ANSI C and Yashavant Kanetkar's Let Us C dedicate full chapters to parameter passing and recursion, with many additional solved problems beyond what's covered here — highly recommended as a next step for more practice.

Summary

  • Every variable has both a value and an address — call by value copies the value; call by reference (via pointers) shares the address.
  • A pointer stores an address; & gets a variable's address, * dereferences a pointer to access/modify the value it points to.
  • Call by value (the default): the function gets a copy; changes inside don't affect the caller's original variable.
  • Call by reference (via pointers): the function gets the actual address and can modify the caller's variable directly — also how a function can effectively "return" more than one value.
  • Arrays are always passed by reference in C automatically ("array decay") — the pointer itself is copied (call by value), but it still points to the same shared data.
  • Local variables exist only during a function's execution (and only within their block); global variables exist for the whole program and are visible everywhere in the file.
  • Recursion requires a base case (to stop) and a recursive case that moves toward it (to make progress) — each call gets its own private stack frame, which is why recursive calls don't interfere with each other's variables.

Coming up in Part 3: storage classes (static, extern, auto, register) and what they mean for functions and variables, function pointers, and how to split a program across multiple files using header files.

Leave a Comment