C / FUNCTIONS
Pass-by-value and why arguments never change
Predict exactly what a caller sees after a function call, and get values back the two ways C allows: a return value or a write through a passed-in address.
What you will learn
- Predict what a caller can observe after a call: the return value and pointer writes only
- Explain why a swap(int x, int y) function leaves the caller's variables unchanged
- Return a new value, or take a pointer and write through it with *p = ...
- See pointer and array parameters as copied addresses, not copied data
Understanding Pass-by-value and why arguments never change
When you write f(expr), the compiler evaluates expr down to a value and uses that value to initialise the parameter. The parameter is a distinct object created for this one call, with storage of its own; the argument and the parameter are connected only at that instant of initialisation. Afterwards there is no channel leading back, which is why assigning to a parameter is perfectly legal C (parameters are ordinary modifiable local variables) and yet completely invisible to the caller. A compiler may even keep the parameter in a register that never had an address in the caller's frame at all.
Because copying is the only thing that happens at the call boundary, a function has exactly two ways to affect its caller: the value it returns, and writes it performs through an address it was handed. Passing an address does not change the rule, it just gives the callee something useful to copy: *p = 3 follows the copied address to the caller's object, while p = &other rewrites only the callee's copy of the address. C has no reference parameters at all, so "pass by reference" in C always means "pass a pointer by value and dereference it".
Structs and unions are copied member by member, including any arrays nested inside them, so a by-value struct parameter can be genuinely expensive and mutations inside it are unobservable outside. Arrays look like an exception only because an array expression in an argument position converts to a pointer to its first element: the array is never copied, and the parameter, however you spell it (int a[3], int a[], int *a), is a pointer variable. That single conversion explains both why element writes inside the function are visible to the caller and why sizeof on an array parameter measures a pointer, forcing you to pass the length separately.
<stdio.h>
/* n is bump's own object, initialised with a copy of the argument. */
void bump(int n)
{
n++;
printf("inside bump: n = %d\n", n);
}
/* The returned value is the only thing that escapes the call. */
int bumped(int n)
{
n++;
return n;
}
int main(void)
{
int count = 5;
bump(count);
printf("after bump: count = %d\n", count);
count = bumped(count);
printf("after bumped: count = %d\n", count);
return 0;
}
A parameter is a brand-new local object initialised with a copy of the argument's value, so writing to the parameter can never reach the caller's object.
Worked examples
The parameter is a different object
Compares the address of a parameter with the address of the caller's variable to show they are two separate objects.
<stdio.h>
void inspect(int n, const int *from_caller)
{
printf("copy holds %d\n", n);
printf("same object as the caller's? %s\n",
(&n == from_caller) ? "yes" : "no");
n = 0;
printf("copy is now %d, the caller's is still %d\n", n, *from_caller);
}
int main(void)
{
int value = 7;
inspect(value, &value);
return 0;
}
Example explained
Line 1inspect(value, &value) passes two copies: the number 7 and the address of value.
Line 2&n == from_caller is false because n has storage of its own, created for this call.
Line 3n = 0 writes into that storage only, so *from_caller still reads 7.
Line 4When inspect returns, n stops existing and nothing it held can be recovered.
A pointer argument is copied too
Shows that writing through a pointer parameter reaches the caller, while reassigning the pointer parameter does not.
<stdio.h>
void set_to_42(int *p)
{
*p = 42; /* follows the copied address to the caller's object */
}
void repoint(int *p)
{
int other = 0;
p = &other; /* overwrites only this function's copy of the address */
*p = 1;
}
int main(void)
{
int a = 0, b = 0;
int *q = &b;
set_to_42(&a);
repoint(q);
printf("a = %d\n", a);
printf("b = %d\n", b);
printf("q still points at b? %s\n", (q == &b) ? "yes" : "no");
return 0;
}
Example explained
Line 1set_to_42 receives a copy of &a, and *p = 42 follows that address, so a really changes.
Line 2In repoint, p = &other overwrites the copied address and cuts the link to b.
Line 3*p = 1 then stores into repoint's own local, which is why b stays 0.
Line 4q in main is unaffected because repoint only ever had a copy of it; changing q would require int **.
Arrays decay, structs are copied
Contrasts an array argument, which arrives as a pointer to the caller's data, with a struct argument, which arrives as a full copy.
<stdio.h>
struct Triple { int v[3]; };
void write_array(int a[3], const int *caller_first)
{
printf("array parameter is just &arr[0]? %s\n",
(a == caller_first) ? "yes" : "no");
a[0] = 100;
}
void write_struct(struct Triple t)
{
t.v[0] = 200;
printf("inside write_struct: t.v[0] = %d\n", t.v[0]);
}
int main(void)
{
int arr[3] = {1, 2, 3};
struct Triple tri = {{1, 2, 3}};
write_array(arr, &arr[0]);
write_struct(tri);
printf("arr[0] = %d\n", arr[0]);
printf("tri.v[0] = %d\n", tri.v[0]);
return 0;
}
Example explained
Line 1arr in the call converts to &arr[0], so a == caller_first prints yes: no array was copied.
Line 2a[0] = 100 means *(a + 0) = 100 through that address, which is why main sees 100.
Line 3write_struct gets all three ints copied into t, so the 200 it prints exists only inside the call.
Line 4Wrapping an array in a struct is how you get true by-value array semantics in C.
Important notes
C has no reference parameters; void f(int &x) is C++. In C you pass &x and the function copies that address.
const int n in a parameter list only stops the function from modifying its own copy; it gives the caller no protection, because the caller's object was never reachable anyway.
Common mistakes
Writing swap(int x, int y) that exchanges x and y: it compiles without a warning and prints a correct swap inside, but the caller's two variables keep their original order, so the bug is silent.
Taking int *p and then writing p = 42 or p = &local instead of *p = 42: the caller's object is never touched, and the value is lost when the callee's copy of the address disappears.
Assuming arrays are passed by reference and using sizeof a inside the function to count elements: a is a pointer, so you get sizeof(int *) and the loop runs over the wrong number of items.
Try it yourself
Change, predict, then run
Write void add_tax(double price, double rate) that does price += price * rate, call it from main and print price afterwards. Then change it to return the new price, have main assign the result, and compare the two printed numbers.
Open the C workspaceCheck your understanding
A function takes int *p, then does p = &local; *p = 7; where local is one of its own variables. The caller passed &x with x equal to 1 and prints x after the call. What does the caller see?
- 7, because *p = 7 writes through the pointer the caller passed
- 5, because the caller ends up seeing the callee's local value
- 1, because p held only a copy of the address and reassigning p cut the link to x
- Undefined behaviour, because the function wrote through a reassigned pointer parameter
Show answer
p is initialised with a copy of the address in the caller's argument; p = &local overwrites that copy, so *p = 7 stores into the callee's own object, which ceases to exist at the return, and x keeps its value of 1. The first option is tempting because dereferencing a pointer parameter usually does reach the caller, but that only holds while the parameter still contains the address it was given; and the write itself is perfectly well defined, just aimed elsewhere, so it is not undefined behaviour either.