Skip to content
academia.sh

Lesson 11 / 18

Passing by Value and by Reference

Argument-passing models, the visibility of an in-function change from outside, and the discipline of side effects.

Contents

When a list is given to a function, is an element added to it inside the function visible to the caller? The answer is not “it depends”; it depends on precise rules, a direct extension of the two models introduced in the variables lesson.

This lesson defines those rules and distinguishes the three confusing terms.

The Three Models

Pass by value. A copy of the argument is bound to the parameter; no change inside the function affects the outside. For large data, the copy has a cost.

Pass by reference. The parameter is bound to the caller’s variable itself; if the function assigns it a new value, the caller’s variable changes too.

Pass by value of object reference. The parameter is bound by copying the object’s address. The function and caller share the same object; assigning a new object to the parameter changes only the local binding.

The third model is the most common and the most confused. It has two observable consequences that appear to contradict each other:

def append_in_place(values: list[int]) -> None:
    """Appends an element to the given list: the change is visible from outside."""
    values.append(99)

def rebind(values: list[int]) -> None:
    """Assigns a new list to the parameter: the outside is not affected."""
    values = [0, 0, 0]

measurements = [12, 18]

append_in_place(measurements)
print(measurements)          # [12, 18, 99]  — the object changed

rebind(measurements)
print(measurements)          # [12, 18, 99]  — the binding changed, not the object

The difference in one sentence: modifying the object is visible from outside; rebinding the name is not. The function accesses the object the caller’s variable points to, not the variable itself.

No Difference for Immutable Types

The distinction established in the types lesson is useful here: the content of an immutable value cannot be changed, so the first behavior is impossible.

def increment(number: int) -> int:
    number += 1            # only the local binding changed
    return number

value = 10
print(increment(value), value)      # 11 10

Numbers, strings, and similar immutable types therefore behave the same way under every model. The confusion arises only with mutable types.

Side Effect

The trace a function leaves in the outside world, apart from its return value, is called a side effect: modifying an argument, updating a global variable, printing to the screen, writing to a file.

A side effect is not inherently bad; a program must eventually act. The problem is a side effect being unexpected — a function named average sorting the list produces a change the caller did not account for.

It is possible to write the same task under two different contracts:

def sorted_copy(measurements: list[int]) -> list[int]:
    """Returns a NEW sorted list; the input is not changed."""
    return sorted(measurements)

def sort_in_place(measurements: list[int]) -> None:
    """Sorts the given list IN PLACE; returns no value."""
    measurements.sort()

a = [12, 18, 7]
b = sorted_copy(a)
print(a, b)              # [12, 18, 7] [7, 12, 18]

sort_in_place(a)
print(a)                 # [7, 12, 18]

Both forms are legitimate. What matters is that the contract is visible in the name and documentation. Functions that modify in place typically return no value — command–query separation from the previous lesson applied in practice. A call that both returns a value and modifies its input mixes the two contracts.

Variadic Arguments

For some functions, the number of arguments is unknown in advance. Languages provide a syntax that collects the remaining arguments into a single collection:

def largest(*values: int) -> int:
    """Returns the largest of the given values. At least one value is required."""
    if not values:
        raise ValueError("at least one value is required")
    largest_value = values[0]
    for value in values:
        if value > largest_value:
            largest_value = value
    return largest_value

print(largest(12, 18, 7))          # 18
print(largest(*[12, 18, 7, 25]))   # 25  — the list was unpacked into separate arguments

The asterisk in the second call unpacks a collection into separate arguments. The same symbol collects arguments in a definition and distributes them in a call — the two uses are reverse of each other.

The cost of this flexibility is a less precise contract: argument count cannot be read from the signature. If the arguments have different meanings — one a limit, another a list of measurements — separate parameters are always clearer. Variadic arguments suit only values of the same type and equivalent role.

Defensive Copy

If a function stores data it receives, and the caller modifying it later would cause a problem, the function must make a copy. This is called a defensive copy.

class MeasurementRecord:
    def __init__(self, measurements: list[int]) -> None:
        self.measurements = list(measurements)     # defensive copy

outside_list = [12, 18]
record = MeasurementRecord(outside_list)
outside_list.append(99)                      # the outside list changed

print(record.measurements)                   # [12, 18]  — the record was not affected

Without the copy, the record’s content could be silently changed from outside. The class structure here is only an example in this course; it is introduced properly in the paradigms topic.

The cost of the copy is memory and time. The shallow–deep copy distinction from the variables lesson applies here too: copying only the outer shell of nested structures does not fully break the sharing.

The same problem exists in the opposite direction: a function returning its stored collection directly leaks internal state. The caller can modify the returned collection and update the object’s internals without going through its rules. This is the return-side counterpart of the defensive copy: the collection handed out is either copied or presented as an immutable view.

A third option is carrying the data in an immutable type; such data need not be copied, and sharing it is safe. This preference of the functional programming approach will be treated in the paradigms topic.

Writing the No-Modification Promise into the Type

Some languages allow declaring, at the type level, that a parameter will not be modified; an operation in the body that modifies it then produces a compile error.

This has two benefits: the contract lives in the signature rather than the documentation, so the reader need not consult it, and the promise becomes provable — a line added to the body later cannot silently break it.

In languages without such a declaration, the same promise is protected only by documentation and review discipline. Preferring immutable types is then the most reliable way to make it enforceable.

Determining Which Model Is in Use

Which model a language uses can be determined with two small tests:

  1. Assign a new value to the parameter. If the caller’s variable changes, pass by reference is in use.
  2. Modify the object in place. If this is visible on the caller’s side, the object is being shared.

If neither is visible, it is pass by value; if only the second is visible, it is pass by value of object reference — the shortest way to determine the model without documentation.

Summary

  • In pass by value, a copy is passed; in pass by reference, the caller’s variable itself is passed; in pass by value of object reference, the address is copied and the object is shared.
  • The most common model is the third: modifying the object is visible from outside, rebinding the parameter is not.
  • This distinction is not observed with immutable types; the confusion arises only with mutable types.
  • A side effect is any trace beyond the return value; the problem is not its existence but it being unexpected.
  • Both the in-place-modifying contract and the new-value-producing contract are legitimate; the choice is made visible in the name and the documentation.
  • Data to be stored is given a defensive copy; immutable types remove this need.

Next Step

Names defined inside a function body are not visible from outside; names from outside, however, can be read from inside the body. What is the exact definition of these visibility rules, and if a name is defined in more than one place, which one wins? The next lesson takes up the rules of scope and lifetime.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close