FreeCampus Python

Designing Functions That Control Mutation

Trace object sharing across calls and design explicit contracts for in-place effects, returned copies, mutable defaults, retained inputs, and safe snapshots.
python-foundations mutability-identity-copying function-boundaries ownership
Open in Colab
  • Level: Python Foundations · Unit 6
  • Estimated time: 3.5–5 hours
  • You will learn: Distinguish local parameter rebinding from caller-visible mutation, design in-place and copying APIs, prevent shared defaults, and protect retained or returned mutable state.
  • Practice in: Google Colab, JupyterLab, or a local editor

1. A helper can change caller-owned state

def record_badge(player, badge):
    player["badges"].append(badge)


nova = {"name": "Nova", "badges": ["starter"]}
result = record_badge(nova, "vault scout")

assert result is None
assert nova["badges"] == ["starter", "vault scout"]

The parameter name player is local to the call, but it reaches the dictionary supplied by the caller. The nested append mutates caller-owned state. If the caller expected a separate returned record, the function’s ownership contract is wrong even though the Python is valid.

This lesson answers:

  • Why can mutation cross a call boundary while parameter rebinding does not?
  • How should an API distinguish an in-place command from a transformer?
  • When should a function copy an input or returned result defensively?
  • How do mutable defaults and returned aliases leak state between calls?

2. Argument evaluation creates a local binding to the object

When Python evaluates record_badge(nova, "vault scout"):

  1. the caller evaluates nova and obtains its dictionary object;
  2. the function call binds local parameter player to that object;
  3. the caller name and local parameter temporarily reach the same dictionary;
  4. mutation through either path changes that one object; and
  5. the local parameter binding disappears when the call returns, but the changed dictionary remains reachable through nova.

This is sometimes called “call by sharing” or “pass by object reference.” Avoid saying only “pass by reference,” which can wrongly suggest that assigning the parameter will assign the caller’s variable.

The caller and parameter reach one object during an in-place call. A copying transformer instead creates a new object and returns its reference.

flowchart LR
  A["caller name: nova"] --> B["caller-owned dictionary"]
  C["local parameter: player"] --> B
  B --> D["shared badges list"]
  C -->|"copying transformer creates"| E["new dictionary"]
  E --> F["new badges list"]
  E -->|"return reference"| G["caller name: updated"]

3. Rebinding a parameter cannot rebind the caller’s name

def replace_route(route):
    route = ["new gate"]
    return route


original = ["old gate"]
replacement = replace_route(original)

assert original == ["old gate"]
assert replacement == ["new gate"]
assert replacement is not original

Assignment changes the local route binding. The caller name original is in a different namespace and still reaches its old list. Returning the new object lets the caller decide whether to bind another name to it.

Compare mutation:

def replace_first_in_place(route):
    route[0] = "new gate"


original = ["old gate"]
replace_first_in_place(original)

assert original == ["new gate"]

Item assignment follows the parameter reference and changes the shared list object; it does not rebind the parameter.

4. Put the ownership choice in the public contract

Use two functions when callers need two different policies:

def add_badge_in_place(player, badge):
    """Append badge to caller-owned player; return None."""
    player["badges"].append(badge)


def with_badge(player, badge):
    """Return an independent player containing badge; preserve player."""
    from copy import deepcopy

    updated = deepcopy(player)
    updated["badges"].append(badge)
    return updated

Evidence distinguishes them:

source = {"name": "Nova", "badges": ["starter"]}

updated = with_badge(source, "vault scout")
assert source["badges"] == ["starter"]
assert updated["badges"] == ["starter", "vault scout"]
assert updated["badges"] is not source["badges"]

result = add_badge_in_place(source, "pathfinder")
assert result is None
assert source["badges"] == ["starter", "pathfinder"]

The suffix _in_place is not required Python syntax, but it communicates an effect. Names such as update, record, or append can also signal mutation when the surrounding API and docstring make ownership clear.

Checkpoint: rebinding and caller-visible effects

5. Standard APIs offer both effect and result styles

Python already uses these contrasting contracts:

numbers = [3, 1, 2]
alias = numbers

ordered = sorted(numbers)
assert ordered == [1, 2, 3]
assert numbers == [3, 1, 2]

result = numbers.sort()
assert result is None
assert alias == [1, 2, 3]

Other pairs include:

In-place effect Returned alternative
set.update(other) left | right
dict.update(other) left | right on supported Python versions
list.reverse() list(reversed(values))
item assignment a reconstruction or explicit copy-and-edit helper

The returned alternative is not necessarily a deep independent result. A new outer list can still contain the same nested objects. Copy depth remains part of the contract.

6. Mutating and returning the same object can hide aliases

def add_and_return(items, item):
    items.append(item)
    return items


source = ["map"]
result = add_and_return(source, "rope")

assert result is source

This API permits chaining but may look like a transformer that produced a new list. It is valid if documented, yet callers must understand that result is a new alias. Many built-in in-place methods return None precisely to make the effect style harder to mistake for independent output.

Choose one clear primary story. If a function mutates and returns the same object, state both facts and test identity explicitly.

7. A mutable default belongs to the definition, not each call

def remember_badge(badge, history=[]):
    history.append(badge)
    return history


first = remember_badge("starter")
second = remember_badge("vault scout")

assert first is second
assert second == ["starter", "vault scout"]

The list expression runs once when Python executes the def statement. Every call that omits history binds its local parameter to that same default list. This is a definition-time ownership decision, not random memory.

Create per-call state explicitly:

def remember_badge(badge, history=None):
    if history is None:
        history = []
    history.append(badge)
    return history


first = remember_badge("starter")
second = remember_badge("vault scout")

assert first == ["starter"]
assert second == ["vault scout"]
assert first is not second

When a caller supplies a list, this version intentionally mutates it:

shared_history = []
result = remember_badge("starter", shared_history)

assert result is shared_history
assert shared_history == ["starter"]

The default case and explicit-input case can have different ownership, so the docstring should state that supplied history is updated.

Checkpoint: effect style and defaults

8. Copying on input protects retained state

A function or closure may retain an input after the call. Storing the caller’s list directly shares future changes:

def make_route_reader(route):
    def read_route():
        return route

    return read_route


source = ["gate"]
read_route = make_route_reader(source)
source.append("bridge")

assert read_route() == ["gate", "bridge"]

If the contract says the reader captures a snapshot at creation, copy on input:

def make_route_reader(route):
    saved_route = list(route)

    def read_route():
        return saved_route

    return read_route


source = ["gate"]
read_route = make_route_reader(source)
source.append("bridge")

assert read_route() == ["gate"]

This shallow copy is enough only because the route contains immutable strings. A nested mutable route would require a deeper ownership decision.

9. Returning internal mutable state creates another leak

The copied-input version still returns its internal list directly:

source = ["gate"]
read_route = make_route_reader(source)
leaked = read_route()
leaked.append("secret door")

assert read_route() == ["gate", "secret door"]

The caller received an alias to retained state. Return an immutable snapshot or a suitable copy:

def make_safe_route_reader(route):
    saved_route = list(route)

    def read_route():
        return tuple(saved_route)

    return read_route


source = ["gate"]
read_route = make_safe_route_reader(source)
snapshot = read_route()

assert snapshot == ("gate",)
assert source == ["gate"]

The tuple works because its elements are immutable strings and the caller needs a read-only sequence. A dictionary or nested mutable snapshot may need explicit copying instead.

10. Defensive copying has a cost

Copying at every boundary can consume time and memory, hide useful intentional sharing, and create stale snapshots. Not copying can expose state to changes from places that did not appear in the public call.

For every retained input or returned collection, state:

  1. Who created this object?
  2. Who is allowed to mutate it?
  3. Will the function retain it after returning?
  4. Does the result share mutable descendants with the input or internal state?
  5. Which source objects must remain unchanged?

Then choose no copy, a shallow/selective copy, a deep copy, or an immutable representation. This is an API decision, not a style requirement.

11. Value checks and identity checks prove different promises

source = {"badges": ["starter"]}
updated = with_badge(source, "vault scout")

assert source == {"badges": ["starter"]}
assert updated == {"badges": ["starter", "vault scout"]}
assert updated is not source
assert updated["badges"] is not source["badges"]

The first two assertions establish values. The final two establish ownership relationships required for safe later edits. Do not add identity assertions for immutable fields unless identity itself belongs to the contract.

12. Lab: publish a mutation-aware quest API

Implement four ownership contracts:

def record_clue_in_place(state, room, clue):
    """Record room and clue in caller-owned state; return None."""
    raise NotImplementedError


def with_clue(state, room, clue):
    """Return independent state containing room and clue; preserve state."""
    raise NotImplementedError


def remember_event(event, history=None):
    """Append event to supplied history or a fresh per-call list; return it."""
    raise NotImplementedError


def journal_snapshot(state):
    """Return an immutable snapshot of rooms and clues."""
    raise NotImplementedError

Use this state and evidence:

source = {
    "name": "Nova",
    "journal": {
        "rooms": [(0, 0)],
        "clues": ["entrance"],
    },
}

assert record_clue_in_place(source, (1, 0), "mirror") is None
assert source["journal"] == {
    "rooms": [(0, 0), (1, 0)],
    "clues": ["entrance", "mirror"],
}

before = {
    "name": "Nova",
    "journal": {
        "rooms": [(0, 0), (1, 0)],
        "clues": ["entrance", "mirror"],
    },
}
updated = with_clue(source, (2, 0), "moon")

assert source == before
assert updated["journal"]["rooms"] == [(0, 0), (1, 0), (2, 0)]
assert updated["journal"]["clues"] == ["entrance", "mirror", "moon"]
assert updated is not source
assert updated["journal"] is not source["journal"]
assert updated["journal"]["rooms"] is not source["journal"]["rooms"]
assert updated["journal"]["clues"] is not source["journal"]["clues"]

first_history = remember_event("gate")
second_history = remember_event("bridge")
assert first_history == ["gate"]
assert second_history == ["bridge"]
assert first_history is not second_history

supplied_history = []
assert remember_event("tower", supplied_history) is supplied_history
assert supplied_history == ["tower"]

snapshot = journal_snapshot(updated)
assert snapshot == {
    "rooms": ((0, 0), (1, 0), (2, 0)),
    "clues": ("entrance", "mirror", "moon"),
}
assert isinstance(snapshot["rooms"], tuple)
assert isinstance(snapshot["clues"], tuple)

with_clue may use deepcopy for this small supported graph or explicit selective copying. Be prepared to explain the choice. journal_snapshot returns a new dictionary whose sequence values are immutable tuples.

Hint: make ownership visible in each function body

The in-place function appends directly. The transformer copies before reusing the in-place operation. The default helper constructs a list only when history is None. The snapshot converts each internal list to a tuple rather than returning the internal lists.

Show one complete solution after attempting the lab
from copy import deepcopy


def record_clue_in_place(state, room, clue):
    """Record room and clue in caller-owned state; return None."""
    state["journal"]["rooms"].append(room)
    state["journal"]["clues"].append(clue)


def with_clue(state, room, clue):
    """Return independent state containing room and clue; preserve state."""
    updated = deepcopy(state)
    record_clue_in_place(updated, room, clue)
    return updated


def remember_event(event, history=None):
    """Append event to supplied history or a fresh per-call list; return it."""
    if history is None:
        history = []
    history.append(event)
    return history


def journal_snapshot(state):
    """Return an immutable snapshot of rooms and clues."""
    return {
        "rooms": tuple(state["journal"]["rooms"]),
        "clues": tuple(state["journal"]["clues"]),
    }

Checkpoint: safe API boundaries

13. Explain the ownership contract aloud

For each lab function, state:

  1. who owns each mutable input before the call;
  2. whether the function mutates, retains, or copies it;
  3. which result is returned and whether it aliases anything;
  4. which future caller mutation can cross the boundary; and
  5. which assertions provide value and identity evidence.

Then deliberately return state["journal"]["clues"] from the snapshot, append through the result, and observe the leak. Restore the tuple snapshot and rerun from a clean state.

Key points

TipKey points
  • Function arguments bind local parameter names to evaluated objects; Python does not automatically copy them.
  • Rebinding a parameter stays local, while mutating an object through it is visible through caller references.
  • In-place commands and source-preserving transformers should advertise distinct ownership contracts.
  • Returning a mutated input creates another alias unless the API says otherwise.
  • Mutable defaults are definition-owned objects reused across omitted-argument calls; construct per-call state with a sentinel.
  • Copying retained input protects against later caller edits; copying or freezing output protects internal state from callers.
  • Defensive copying costs time and memory, so apply it where ownership requires isolation rather than at every boundary.

References

Back to top