Call boundaries: mutation and rebinding

Trace what a Java or Python call does to a mutable object when its parameter is changed or replaced. Objects and access · Object-oriented programming.

You pass a parcel to a helper. The helper edits its label, then assigns the parameter to a new parcel. Which parcel does the caller still hold?

One call, two names for a moment

Think of handing a parcel to a helper function. Two things the helper might do look alike but are not: it can write on the parcel it was handed, or it can pick up a different parcel instead. Earlier you saw two variables reach one object; a call sets up that second name for you. The caller, the code making the call, sees the first change and not the second.

When the caller runs inspect(parcel), the helper inspect, called the callee, gets its own variable item, set up from the value of parcel before its first line runs. parcel is the argument; item is the parameter. In the caller, parcel identifies one Parcel object, P1, labelled ready. At entry item identifies P1 too, so two names reach one parcel. The call made a new variable; it did not copy the parcel.

One parcel crosses a call boundary

Read the steps as text

  1. The caller starts with parcel → P1 (label ready).
  2. Calling inspect: the new parameter item starts out naming P1, the same object the caller holds.
  3. Through the parameter, P1's label changes from ready to scanned; the caller's name reaches it too.
  4. The callee makes P2. The parameter item now names P2; the caller's parcel still names P1.
  5. The call to inspect ends and its local name is gone. Caller: parcel → P1 (label scanned).

Now watch two different operations. When the callee runs item.label = "scanned", it changes the object reached by both names. When it later runs item = new Parcel("replacement"), it changes only the callee's binding. The caller's parcel still identifies P1, whose label is now scanned.

Java describes this precisely as actual argument values initializing newly created parameter variables. For an object argument, the value is a reference value. Python's official vocabulary is pass by assignment: the parameter becomes a local name bound to the object supplied by the caller. These descriptions preserve the same observable boundary without claiming that either language uses one universal “call by reference” rule.

final class Parcel {
    String label;

    Parcel(String label) {
        this.label = label;
    }
}

static void inspect(Parcel item) {
    item.label = "scanned";
    item = new Parcel("replacement");
}

Parcel parcel = new Parcel("ready");
inspect(parcel);
System.out.println(parcel.label); // scanned
class Parcel:
    def __init__(self, label):
        self.label = label

def inspect(item):
    item.label = "scanned"
    item = Parcel("replacement")

parcel = Parcel("ready")
inspect(parcel)
print(parcel.label)  # scanned

Both examples initially share P1 through the fresh parameter, so its field mutation is visible to the caller. After rebinding, only local item names the replacement. When the helper finishes, item is no longer live; the caller still identifies P1. This helper returns no replacement to adopt.

Keep two ledgers: object identity and variable bindings. P1 keeps its identity when its field changes; parcel and item belong to separate scopes, each function working only with its own names, in the part of the code where each name exists. An assignment targets its left-hand side: item.label writes P1’s field, whereas item changes the local binding. A reference alone does not tell you which operation occurred.

When you write a helper, decide which kind it is. If its job is to change an object, such as relabel(parcel) changing its label, say so in its name and let it be the one place that edits it; every other holder of that object will see the change. If its job is to produce a replacement, return it and have the caller assign the result: parcel = repack(parcel). Avoid the middle path of assigning to the parameter and hoping the caller notices, because that does nothing for the caller. Also avoid a helper that quietly edits an object the caller still shares, which can surprise every other part of the program.

When the argument is a scalar

Now use a scalar value instead of a mutable Parcel. Starting from count = 3, value = value + 1 changes only the callee’s local parameter. The caller stays at 3 until it explicitly assigns the returned 4.

An integer crosses a call boundary

Read the steps as text

  1. The caller starts with count = 3.
  2. Calling bump: its new parameter value starts as 3, a copy of the caller's count.
  3. The parameter value becomes 4 (3 + 1) in the callee only; the caller's count is still 3.
  4. The call to bump returns 4 to the caller. The caller does not assign it, so the result is dropped.
  5. Calling bump: its new parameter value starts as 3, a copy of the caller's count.
  6. The parameter value becomes 4 (3 + 1) in the callee only; the caller's count is still 3.
  7. The call to bump returns 4 to the caller.
  8. The caller assigns the result to count, so count changes from 3 to 4.

Java’s primitive int parameter is initialized from the argument’s value. Python binds a local name to an immutable int; adding one produces another integer, and assignment rebinds that local name. Neither operation updates the caller’s variable. Return-and-assign lets the caller adopt the result.

static int bump(int value) {
    value = value + 1;
    return value;
}

int count = 3;
bump(count);
System.out.println(count); // 3

count = bump(count);
System.out.println(count); // 4
def bump(value):
    value = value + 1
    return value

count = 3
bump(count)
print(count)  # 3

count = bump(count)
print(count)  # 4

The first call changes only value, so its returned 4 is discarded and count remains 3. The second call starts a fresh local value at that unchanged 3, returns 4, and count = bump(count) explicitly assigns that result to the caller's count. This is the scalar counterexample to “a changed argument always changes the caller”: without a mutation of a shared object, a callee assignment remains local.

Carry the distinction into a second object

For a fresh trace, the caller’s receipt names R1, whose mutable notes list starts as ["draft"]. The helper has its own parameter also named receipt. Which object enters the call, what does the append reach, and what does a replacement change?

A second receipt, the same boundary

Read the steps as text

  1. The caller starts with receipt → R1 (notes [draft]).
  2. Calling review: the new parameter receipt starts out naming R1, the same object the caller holds.
  3. Through the parameter, reviewed is appended to N1, the list R1 holds.
  4. The callee makes N2 and R2. The parameter receipt now names R2; the caller's receipt still names R1.
  5. The call to review ends and its local name is gone. Caller: receipt → R1 (notes [draft, reviewed]).

In Java, receipt.notes.add("reviewed") mutates the list held by the shared receipt. In Python, receipt.notes.append("reviewed") does the corresponding list mutation. Both append reviewed to R1’s list, so the caller sees ["draft", "reviewed"]. The list is the target of the update: neither the Receipt nor its notes list is replaced by that append. Neither operation assigns a new value to the caller’s variable. A later receipt = new Receipt() or receipt = Receipt() gives only the callee parameter a new receipt with an empty list; the caller still names R1 after the helper finishes.

final class Receipt {
    java.util.List<String> notes = new java.util.ArrayList<>();
}

static void review(Receipt receipt) {
    receipt.notes.add("reviewed");
    receipt = new Receipt();
}
class Receipt:
    def __init__(self):
        self.notes = []

def review(receipt):
    receipt.notes.append("reviewed")
    receipt = Receipt()

Label the operation before predicting its effect: object.field = value or the shown add/append mutates a target; parameter = new object rebinds one local parameter. A method call need not mutate anything.

Recap

  • A parameter is a new callee variable initialized from the argument value; at entry it can identify the caller's object.
  • Mutating that shared object is visible through the caller's name, while rebinding the parameter selects a different local target, so the caller still holds the original parcel, now labelled scanned.
  • Java uses argument values and reference values; Python calls its model pass by assignment. Neither requires a call-by-reference claim. An ignored return leaves the caller unchanged; a caller assignment can adopt the returned value.
  • Choose deliberately: mutate the shared object on purpose, or return the new value and have the caller assign it.
Go deeper

The distinction also survives when the parameter is passed through another helper. Each call introduces its own parameter variable, but the object being named can remain the same across several calls. Follow the current variable name at each boundary, then follow the target object separately. That small notation prevents a common leap: treating a changed field as proof that a caller variable was replaced. The field belongs to an object; the variable belongs to a scope.

The diagrams intentionally show observable bindings, not a required stack, heap or pointer layout. Java's parameter variables and Python's local names are language-level concepts; implementations may represent them differently. A primitive Java argument or an immutable Python value makes the same rebinding distinction easier to miss because there is no shared mutable field to observe.

“Pass by reference” is useful only when a language's precise definition and the operation being discussed are clear. In this lesson it would blur two separate questions: whether a parameter initially identifies the same object, and whether assigning a new object to that parameter can update the caller's variable. Keep those questions separate when you move to arrays, collections or user-defined wrappers.

Continue to class attributes and shared state to see how state can belong to a class. Later, explore shallow and deep copy when you need an edit to stop at a copy boundary. Practise the same mutation-versus-link distinction in Linked List Fundamentals.