FreeCampus Python

Changing Objects and Choosing Safe Keys

Predict which built-in operations mutate existing objects or return new values, then choose dictionary keys and set members from stable hash behavior.
python-foundations mutability-identity-copying mutability hashability
Open in Colab
  • Level: Python Foundations · Unit 6
  • Estimated time: 3.5–5 hours
  • You will learn: Distinguish mutation from rebinding, predict common method and += behavior, explain nested mutability, and choose hashable values for dictionary and set lookup.
  • Practice in: Google Colab, JupyterLab, or a local editor

1. One operation changes an object; another returns a replacement

supplies = ["water", "map"]
alias = supplies
mission_name = "moon trail"

append_result = supplies.append("rope")
upper_name = mission_name.upper()

assert append_result is None
assert supplies == ["water", "map", "rope"]
assert alias == ["water", "map", "rope"]
assert mission_name == "moon trail"
assert upper_name == "MOON TRAIL"

list.append mutates the existing list. Its alias observes the change. Strings are immutable: str.upper cannot change the string object, so it returns another string value. The original name still reaches the original lowercase value.

This lesson answers:

  • Which common objects can change in place?
  • Why do many mutating methods return None?
  • Why can += mutate one type and rebind another?
  • What makes a value suitable for dictionary or set lookup?

2. Mutation preserves identity while changing state

route = ["gate", "bridge"]
before_id = id(route)

route.append("tower")

assert id(route) == before_id
assert route == ["gate", "bridge", "tower"]

The list’s observable contents changed while its identity remained the same. Every reference to that list can observe the updated state.

Rebinding produces a different event:

route = ["gate", "bridge"]
before = route

route = route + ["tower"]

assert route == ["gate", "bridge", "tower"]
assert before == ["gate", "bridge"]
assert route is not before

List concatenation built a new list, then assignment rebound route. The name before still reaches the original object.

3. Common built-in types have different mutation contracts

Use this table as a guide, then read the documentation for the exact operation:

Common type Can that object change in place? Typical transformation
list yes append, item assignment, sort
dict yes item assignment, update, pop
set yes add, discard, update
bytearray yes item assignment, extend
int, float, complex, bool no arithmetic returns a value
str, bytes no methods return a value
tuple, frozenset no operations return a value
None no used as a singleton sentinel

User-defined instances are generally mutable unless their design prevents updates. Unit 11 develops classes and custom equality/hash behavior. For now, make decisions with built-in values whose contracts are documented.

“Immutable” does not mean a name can never change. A name may be rebound to a new immutable object:

attempts = 2
old_attempts = attempts
attempts = attempts + 1

assert attempts == 3
assert old_attempts == 2

The integer 2 did not become 3; the expression produced another integer and assignment changed the attempts binding.

4. Mutating methods usually return None

Several standard methods emphasize their effect by returning None:

items = ["map", "rope"]
mapping = {"north": "open"}
visited = {(0, 0)}

assert items.sort() is None
assert mapping.update({"east": "closed"}) is None
assert visited.add((1, 0)) is None

assert items == ["map", "rope"]
assert mapping == {"north": "open", "east": "closed"}
assert visited == {(0, 0), (1, 0)}

The returned None prevents an easy confusion between the action and a new collection result. This mistake loses the useful binding:

items = ["map"]
items = items.append("rope")

assert items is None

Repair it by keeping the command separate:

items = ["map"]
items.append("rope")

assert items == ["map", "rope"]

Not every mutating method returns None; always check its contract. The pattern is common enough to predict cautiously, not to invent a universal rule.

Checkpoint: mutation and returned values

5. Returning alternatives preserve the source

Compare methods and expressions that return new values:

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

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

regions = {"north"}
expanded = regions | {"east"}
assert regions == {"north"}
assert expanded == {"north", "east"}

sorted accepts an iterable and returns a list. Set union with | returns a set. Their in-place counterparts have different ownership effects:

numbers.sort()
regions.update({"east"})

assert numbers == [1, 2, 3]
assert regions == {"north", "east"}

Neither style is always preferable. Choose from whether the caller owns the source, whether aliases should observe the update, and whether preserving the old value is useful.

6. += follows the left operand’s type

With a list, augmented assignment normally performs in-place addition:

items = ["map"]
alias = items

items += ["rope"]

assert items is alias
assert alias == ["map", "rope"]

With a tuple, there is no in-place tuple mutation. A new tuple is returned and the target name is rebound:

steps = ("gate",)
old_steps = steps

steps += ("bridge",)

assert steps == ("gate", "bridge")
assert old_steps == ("gate",)
assert steps is not old_steps

Strings and numbers similarly produce new values. Do not classify syntax alone as mutating. Ask what the left operand’s type promises for that operation.

7. An immutable tuple can point to a mutable list

record = ("Nova", ["map"])
gear = record[1]

gear.append("rope")

assert record == ("Nova", ["map", "rope"])

The tuple still contains the same two references in the same positions. Its second reference reaches a list whose state changed. Tuple immutability prevents replacing a tuple slot; it does not freeze every object reachable from the tuple.

This distinction matters for copying and hashability. A nested mutable object can leak state and can make the containing tuple unhashable.

Checkpoint: += and nested mutability

8. Hashability supports reliable lookup

Dictionaries and sets use a hash to locate candidates efficiently, then equality to confirm a matching key. A hashable object promises hash/equality behavior stable enough for its lifetime in the collection.

room_names = {
    (0, 0): "entrance",
    (1, 0): "mirror hall",
}
visited = {(0, 0), (1, 0)}

candidate = tuple([1, 0])
assert candidate in visited
assert room_names[candidate] == "mirror hall"

candidate is a distinct tuple constructed through a different path. It compares equal to (1, 0) and has compatible hash behavior, so it finds the same set member and dictionary entry.

The core invariant is: objects that compare equal must produce the same hash while used as keys. Unequal objects may still have equal hashes; the collection checks equality to resolve such collisions.

9. Common key choices depend on the complete value

Value Usually hashable? Key/set-member result
string, integer, bytes yes commonly suitable
tuple of hashable elements yes commonly suitable
frozenset of hashable elements yes suitable when unordered grouping matters
list no mutable and rejected
dictionary no mutable and rejected
set no mutable and rejected
tuple containing a list no nested list makes the complete tuple unhashable

Observe supported values:

markers = {
    "entrance",
    7,
    (1, 2),
    frozenset({"north", "east"}),
}

assert (1, 2) in markers
assert frozenset({"east", "north"}) in markers

These calls reveal unsupported boundaries when run separately:

list_key = [1, 2]
tuple_with_list = ("route", [1, 2])

# hash(list_key)
# hash(tuple_with_list)

Each uncommented call raises TypeError because the complete value is not hashable. Unit 8 develops systematic exception handling; here the error directly answers the key-suitability question.

10. Hashes are not secure or persistent IDs

hash(value) returns an integer used by hash-based collections. Do not treat it as:

  • encryption;
  • a password hash;
  • a collision-free fingerprint;
  • an object’s identity;
  • a database identifier; or
  • a value guaranteed to remain the same across Python runs.

For example, Python commonly randomizes string hashes between processes. The program-level contract is that a live hashable key remains findable in its collection, not that its printed hash belongs in permanent data.

11. Choose a key from the meaning of the data

A room coordinate fits a tuple because its two positions form one fixed value:

coordinate = (3, 5)
visited = {coordinate}

A collection of permissions might fit a frozenset when order does not matter:

permissions = frozenset({"read", "map"})
access_levels = {permissions: "scout"}

assert access_levels[frozenset({"map", "read"})] == "scout"

Do not convert a list to a tuple only to silence an error if its meaning is supposed to change while stored. Redesign the key as a stable identifier, or keep mutable state in the dictionary value instead.

12. Lab: track an expedition state

Implement four focused operations:

def add_supply_in_place(state, supply):
    """Append supply to state and return None."""
    raise NotImplementedError


def with_status(state, status):
    """Return a new outer state with status and shared untouched values."""
    raise NotImplementedError


def visit_room_in_place(state, coordinate):
    """Add one hashable coordinate to visited and return None."""
    raise NotImplementedError


def observation_counts(observations):
    """Return counts keyed by hashable observation labels."""
    raise NotImplementedError

Use this fixture and evidence:

state = {
    "name": "Nova",
    "status": "searching",
    "supplies": ["water"],
    "visited": {(0, 0)},
}
alias = state

assert add_supply_in_place(state, "rope") is None
assert alias["supplies"] == ["water", "rope"]

updated = with_status(state, "ready")
assert updated == {
    "name": "Nova",
    "status": "ready",
    "supplies": ["water", "rope"],
    "visited": {(0, 0)},
}
assert updated is not state
assert state["status"] == "searching"

assert visit_room_in_place(state, (1, 0)) is None
assert state["visited"] == {(0, 0), (1, 0)}
assert observation_counts(["rune", "key", "rune"]) == {
    "rune": 2,
    "key": 1,
}
assert observation_counts([]) == {}

with_status changes only an outer immutable field, so a shallow outer copy is enough for this contract. Do not mutate supplies through updated; Lesson 3 will show why that different requirement needs a deeper ownership decision.

Hint: separate in-place commands from returned transformations

The two _in_place functions call mutating collection methods and rely on their implicit None result. with_status can use state.copy() then replace only the status slot. Count observations with a local dictionary whose string keys are hashable.

Show one complete solution after attempting the lab
def add_supply_in_place(state, supply):
    """Append supply to state and return None."""
    state["supplies"].append(supply)


def with_status(state, status):
    """Return a new outer state with status and shared untouched values."""
    updated = state.copy()
    updated["status"] = status
    return updated


def visit_room_in_place(state, coordinate):
    """Add one hashable coordinate to visited and return None."""
    state["visited"].add(coordinate)


def observation_counts(observations):
    """Return counts keyed by hashable observation labels."""
    counts = {}
    for observation in observations:
        counts[observation] = counts.get(observation, 0) + 1
    return counts

Checkpoint: hashable expedition state

13. Explain each update and lookup

Use your lab to answer:

  1. Which functions mutate an object and which return a new outer object?
  2. Why does a returned None not mean the in-place commands failed?
  3. Which nested values remain shared by with_status, and why is that acceptable only under its narrow contract?
  4. Why do equal tuple coordinates find one set entry?
  5. Why would converting a changing route list into a key be a design problem even if a tuple conversion silenced TypeError?

Then change with_status so it also appends a supply. Predict the leak through the shallow copy before running. Revert the change; Lesson 3 will implement the appropriate deeper strategy.

Key points

TipKey points
  • Mutation changes an existing object’s state; rebinding makes a name reach a different object.
  • Common mutating collection methods return None; keep the command separate from the useful collection binding.
  • Returning alternatives such as sorted preserve their source, while in-place counterparts make aliases observe a change.
  • += follows the left operand’s type: lists commonly mutate, while tuples, strings, and numbers produce values and rebind the target.
  • An immutable container can still refer to mutable descendants.
  • Hashability supports stable dictionary/set lookup and depends on the complete value, not merely tuple punctuation.
  • A hash is not encryption, object identity, or a persistent record identifier.

References

Back to top