Parameter-Passing Mechanisms

When a procedure is called, the actual parameters (the arguments at the call site) have to be connected to the formal parameters (the names inside the procedure). Exactly how that connection is made is a language-design decision with surprisingly deep consequences: it determines whether the callee can change the caller's variables, whether two names can secretly refer to the same storage, and even whether an argument's expression is evaluated once, many times, or never. This is the theory of parameter passing, and it is one of the sharpest places where two languages that look alike behave completely differently.

Everything here builds on the activation record: the formal parameters are slots in the callee's frame, and a passing mechanism is simply the rule for what the caller writes into those slots — a copied value, an address, or an unevaluated recipe.

The five classic mechanisms

The organising question is what gets put in the formal's slot and when the actual is evaluated. Value and value-result copy an evaluated value; reference passes an address; name and need defer the evaluation itself.

Value vs reference, made concrete

The difference is easiest to feel with an increment. Under call-by-value the callee scribbles on a private copy; under call-by-reference it scribbles on the caller's own cell. The visual shows the crux: under reference the formal x and the actual a are two names for one storage cell — they are aliases.

This model implements a tiny memory as an array of cells. Passing "by value" copies a cell's contents into the formal; passing "by reference" passes the cell's index, so writes go straight back to the actual. Watch the same increment and swap produce completely different after-effects:

// A tiny memory: named cells holding numbers. const mem: Record<string, number> = { a: 10, b: 20 }; // --- CALL-BY-VALUE: the callee gets a private copy --- function incByValue(x: number): void { x = x + 1; // mutates the local copy only } function swapByValue(x: number, y: number): void { const t = x; x = y; y = t; // swaps copies; caller untouched } // --- CALL-BY-REFERENCE: the callee gets the actual's address (its name) --- function incByRef(name: string): void { mem[name] = mem[name] + 1; // writes straight back to the caller's cell } function swapByRef(n1: string, n2: string): void { const t = mem[n1]; mem[n1] = mem[n2]; mem[n2] = t; } console.log(`start: a=${mem.a}, b=${mem.b}`); incByValue(mem.a); // pass the VALUE 10 console.log(`after incByValue(a): a=${mem.a} (unchanged)`); incByRef("a"); // pass the NAME "a" console.log(`after incByRef(a): a=${mem.a} (changed!)`); swapByValue(mem.a, mem.b); console.log(`after swapByValue: a=${mem.a}, b=${mem.b} (unchanged)`); swapByRef("a", "b"); console.log(`after swapByRef: a=${mem.a}, b=${mem.b} (swapped!)`);

Call-by-value is the reason a classic C swap(a, b) that takes plain ints does nothing — you swap the copies. To make swap work you must pass addresses (pointers in C, references in C++), i.e. move to call-by-reference semantics.

What each mechanism does to an actual

The cleanest way to compare mechanisms is: after the call returns, what happened to the actual, and how many times was the argument expression evaluated?

MechanismWhat goes in the formalEffect on the actualSeen in
Call-by-valuea copy of the valuenone — actual is unchangedC, Java, Python (for the binding), most defaults
Call-by-referencethe actual's address (alias)changes are visible immediatelyC++ &, Pascal var, Fortran
Call-by-value-resulta copy in, then copied back outupdated once, on returnAda in out
Call-by-namean unevaluated thunkre-evaluated on each useAlgol 60; Scala => by-name
Call-by-needa thunk, memoisedevaluated at most onceHaskell (lazy evaluation)

A subtle point: call-by-reference and call-by-value-result agree in the absence of aliasing but can disagree when aliasing is present — reference sees intermediate writes immediately, while value-result only commits at the end. That gap is exactly why language standards must pin the mechanism down precisely.

Call-by-name and the power of a thunk

Call-by-name is the strangest and most powerful. The argument is wrapped in a thunk — a parameterless closure that re-computes the actual in the caller's environment — and this thunk is invoked afresh every time the formal name appears. It is as if the argument text were pasted in at each use.

// Model call-by-name: pass a THUNK, re-evaluate on every use. let i = 0; const arr = [10, 20, 30, 40]; // "sum over a[i] for i = lo..hi" — the body re-reads BOTH the index i and a[i] // each iteration, because in call-by-name they are re-evaluated thunks. function sumByName(term: () => number, lo: number, hi: number): number { let total = 0; for (i = lo; i <= hi; i++) { // mutating i is visible inside the thunk! total += term(); // re-evaluate a[i] with the current i } return total; } // Jensen's device: one thunk a[i] sums a whole array, because i changes underneath it. const result = sumByName(() => arr[i], 0, 3); console.log(`sum of arr via one by-name term = ${result}`); // 100

This trick — a single argument expression that sweeps an array because a shared index variable changes under it — is the famous Jensen's device. It is elegant and almost unreadable, which is roughly why call-by-name did not survive as a default; but its idea lives on in lazy evaluation and in every "by-name" or deferred argument in modern languages.

Java is always call-by-value — but for object types the value that is copied is a reference (a pointer to the object). So the callee can mutate the pointed-to object's fields and the caller sees it, which feels like call-by-reference; yet if the callee reassigns the parameter itself to a new object, the caller's variable is untouched — the giveaway that it was by-value all along. The precise phrasing is "call-by-value, where the value passed for an object is a reference." Python behaves the same way (sometimes called "call-by-object-sharing"). Knowing the exact rule is the difference between a subtle bug and a correct program.

Two traps hide in these mechanisms. First, aliasing under call-by-reference: if you call f(x, x) where f(a, b) takes both by reference, then a and b are the same cell — writing a silently changes b, and code that assumed they were independent breaks. The same happens if a global and a by-reference parameter name the same variable. Second, re-evaluation under call-by-name: because the thunk fires on every use, an argument with side effects (or a costly computation) runs again and again. Pass a[i++] by name and the index advances on every mention; pass an expensive call and you pay for it repeatedly. Call-by-need fixes the cost by memoising, but call-by-name does not — and both are surprising if you expected a value to be computed exactly once, up front.