Skip to content
VLSI Mentor

Python Part I · Module 16 · Object-Oriented Programming

Changing an Object's State

An object created ten minutes ago is rarely the object you still want. A test run acquires a status, a parser accumulates errors, a job records how long it took. Writing the update is trivial — one assignment — and the question worth a chapter is the one underneath it: when a method changes the object it was called on, versus when it leaves the object alone and hands back something new. The standard library answers that question differently in two places you already use, and the difference between them is the subject here.

Intermediate15 min readPythonPart IFundamentalsOOPState

Part I · Module 16 · Chapter 16.6 · Changing an Object's State

1. Assignment Is All It Takes

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
class TestRun:
    def __init__(self, name, seed):
        self.name = name
        self.seed = seed
        self.status = "PENDING"


run = TestRun("smoke", 1234)
print("before:", run.status, run.__dict__)

run.status = "FAIL"

print("after :", run.status, run.__dict__)
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
before: PENDING {'name': 'smoke', 'seed': 1234, 'status': 'PENDING'}
after : FAIL {'name': 'smoke', 'seed': 1234, 'status': 'FAIL'}

Attributes are ordinary names in the instance's storage, and assignment replaces one. There is no setter to write, no declaration, and nothing to enable — the __dict__ output shows the key being overwritten in place.

You can also add an attribute that __init__ never created — Chapter 16.2 Section 8 showed this — and it is legal and almost always a mistake, because the class body stops being an honest description of what instances hold.

2. A Method That Mutates

The canonical form puts the change behind a method:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
class TestRun:
    def __init__(self, name, seed):
        self.name = name
        self.seed = seed
        self.status = "PENDING"
        self.failures = []

    def record_failure(self, reason):
        self.status = "FAIL"
        self.failures.append(reason)


run = TestRun("smoke", 1234)
run.record_failure("timeout")
run.record_failure("bad handshake")

print("status  :", run.status)
print("failures:", run.failures)
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
status  : FAIL
failures: ['timeout', 'bad handshake']

The method sets two fields that must agree — a status of FAIL and a reason for it. That is the first argument for routing a change through a method rather than assigning directly: two assignments that must happen together can be made one operation.

3. A Mutating Method Returns None

record_failure has no return, so it returns None (Chapter 11.8). That is conventional, and it has a consequence:

Azvya Education Pvt. Ltd.VLSI Mentor
chain.py (BROKEN ON PURPOSE)
class TestRun:
    def __init__(self, name):
        self.name = name
        self.status = "PENDING"

    def record_failure(self, reason):
        self.status = "FAIL"

    def label(self):
        return f"{self.name}:{self.status}"


run = TestRun("smoke")
print(run.record_failure("timeout").label())
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
Traceback (most recent call last):
  File "chain.py", line 14, in <module>
    print(run.record_failure("timeout").label())
          ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
AttributeError: 'NoneType' object has no attribute 'label'

'NoneType' object has no attribute — Chapter 14.3's most common error, arriving here from a design decision rather than a bug. The mutation happened; the chain did not.

The standard library made exactly this choice, and you have already met it:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
values = [3, 1, 2]

print("sort() returns :", repr(values.sort()))
print("the list now   :", values)

other = [3, 1, 2]
print("sorted() returns:", sorted(other))
print("the list now    :", other)
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
sort() returns : None
the list now   : [1, 2, 3]
sorted() returns: [1, 2, 3]
the list now    : [3, 1, 2]

sort() mutates and returns None. sorted() returns a new list and leaves the original alone. Two spellings of one operation, differing in exactly the decision this chapter is about — and the None return is deliberate, so that x = values.sort() fails loudly instead of silently producing nothing.

If a method mutates, return None. If it returns a value, do not mutate.

Doing both is the shape that confuses every caller.

4. The Other Design: Return a New Object

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
class TestRun:
    def __init__(self, name, seed, status="PENDING"):
        self.name = name
        self.seed = seed
        self.status = status

    def with_status(self, status):
        """Returns a NEW run; leaves this one untouched."""
        return TestRun(self.name, self.seed, status)

    def label(self):
        return f"{self.name}/{self.seed}:{self.status}"


original = TestRun("smoke", 1234)
failed = original.with_status("FAIL")

print("original:", original.label())
print("failed  :", failed.label())
print("same obj:", original is failed)
print("chains  :", original.with_status("PASS").label())
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
original: smoke/1234:PENDING
failed  : smoke/1234:FAIL
same obj: False
chains  : smoke/1234:PASS

The original survives, and because the method returns an object, calls chain. The cost is a new object per change, which for a small record is nothing.

5. Which to Choose

Mutate when the object is the record being updated and the caller already owns it. Return a new value when the caller might still want the old one, or when the result is a different kind of thing.

Applied:

OperationChoiceWhy
a run acquiring its final statusmutatethe run is that record; there is no "old" run worth keeping
a parser accumulating errorsmutatethe accumulation is the object's purpose
producing a formatted report linereturna string is not a run
a configuration with one field changedreturnthe caller's original is still valid
sorting a list you were handedreturnyou do not own the caller's list

That last row is the one that causes incidents, and Section 6 is why.

6. Mutation Is Visible Through Every Name

Chapter 5.8 established this for lists. It is the same fact for objects, and it feels more surprising because an object looks like a record:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
class TestRun:
    def __init__(self, name):
        self.name = name
        self.status = "PENDING"


run = TestRun("smoke")
also_run = run
batch = [run]

run.status = "FAIL"

print("run      :", run.status)
print("also_run :", also_run.status)
print("in list  :", batch[0].status)
print("one object:", run is also_run is batch[0])
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
run      : FAIL
also_run : FAIL
in list  : FAIL
one object: True

One object, three names, one change visible through all of them. batch did not store a copy — it stored the object.

The practical rule for functions that receive objects:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
class TestRun:
    def __init__(self, name, status):
        self.name = name
        self.status = status


def mark_all_failed(runs):
    """Mutates the caller's objects -- say so in the name and docstring."""
    for r in runs:
        r.status = "FAIL"


def names_of_failed(runs):
    """Reads only. The caller's objects are untouched."""
    return [r.name for r in runs if r.status == "FAIL"]


batch = [TestRun("smoke", "PASS"), TestRun("cdc", "PASS")]
print("before:", [r.status for r in batch])
print("read  :", names_of_failed(batch))
mark_all_failed(batch)
print("after :", [r.status for r in batch])
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
before: ['PASS', 'PASS']
read  : []
after : ['FAIL', 'FAIL']

A function receiving objects can change them, and the caller has no protection. Naming is the only warning the reader gets, which is why mark_all_failed says what it does and names_of_failed does not sound like it would.

7. A Method Can Validate; an Assignment Cannot

The strongest reason to route a change through a method is that a method can refuse (Chapter 14.7):

Azvya Education Pvt. Ltd.VLSI Mentor
validate.py (BROKEN ON PURPOSE)
LEGAL = {"PENDING", "PASS", "FAIL", "TIMEOUT"}


class TestRun:
    def __init__(self, name):
        self.name = name
        self.status = "PENDING"

    def set_status(self, status):
        if status not in LEGAL:
            raise ValueError(f"not a legal status: {status!r}")
        self.status = status


run = TestRun("smoke")
run.set_status("FAIL")
print("ok:", run.status)

run.status = "FAILED"          # nothing stops this
print("bypassed:", run.status)

run.set_status("FAILED")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
ok: FAIL
bypassed: FAILED
Traceback (most recent call last):
  File "validate.py", line 22, in <module>
    run.set_status("FAILED")
    ~~~~~~~~~~~~~~^^^^^^^^^^
  File "validate.py", line 11, in set_status
    raise ValueError(f"not a legal status: {status!r}")
ValueError: not a legal status: 'FAILED'

The method catches the typo — FAILED is not FAIL — and the direct assignment does not, because Python has no private attributes. Nothing prevents run.status = "FAILED", and the object is now in a state no method would have allowed.

That is not a flaw to work around; it is a deliberate language design, and Chapter 16.7 is about the convention that stands in for enforcement.

8. Predict, Run, Observe

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
class Bag:
    def __init__(self):
        self.items = []

    def add(self, item):
        self.items.append(item)

    def with_item(self, item):
        new = Bag()
        new.items = list(self.items)
        new.items.append(item)
        return new


a = Bag()
print("A:", a.add("x"))
print("B:", a.items)

b = a.with_item("y")
print("C:", a.items, b.items)

c = a
c.add("z")
print("D:", a.items, b.items)
print("E:", a is c, a is b)
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
A: None
B: ['x']
C: ['x'] ['x', 'y']
D: ['x', 'z'] ['x', 'y']
E: True False

A is None — add mutates, so it returns nothing, as Section 3 requires.

C — with_item built a new Bag with a copied list, so a is unchanged. Note list(self.items) is doing real work: without it both bags would share one list, which is Chapter 5.8's copy problem inside an object.

D — c is another name for a, so c.add("z") changed a. b is a genuinely separate object and did not move.

E confirms which names refer to the same object.

9. Exercises

Exercise 1 — assign a field. Create an object, change one attribute, and print __dict__ before and after.

Exercise 2 — two fields together. Write a method that must set two attributes that have to agree. Say what could go wrong if a caller set them separately.

Exercise 3 — the chaining failure. Call a mutating method and then a second method on its result. Record the error and explain it in one sentence.

Exercise 4 — sort versus sorted. Show the return value and the effect of both on the same data. State the convention they illustrate.

Exercise 5 — the returning version. Write with_status for a class of your own. Prove the original is unchanged and that calls chain.

Exercise 6 — three names. Put one object in a list and in two variables. Mutate through one and observe the others.

Exercise 7 — the copying constructor. Reproduce Section 8's with_item without list(self.items). Show the bug and explain which chapter covered it.

Exercise 8 — mutate or return. For each, choose and justify: (a) appending a log line to a parser (b) formatting a result as a CSV row (c) marking a job cancelled (d) producing a copy of a config with one override (e) normalising a test name to lower case.

10. Summary

  • Assignment updates an attribute; there is no setter and nothing to enable.
  • A method can set several fields that must agree, which a bare assignment cannot.
  • A mutating method returns None by convention — so chaining off it raises AttributeError on NoneType.
  • sort() mutates and returns None; sorted() returns a new list — the standard library's own illustration of the choice.
  • If a method mutates, return None; if it returns a value, do not mutate.
  • Mutate when the object is the record and the caller owns it; return a new value when the old one may still be wanted.
  • Never mutate a collection you were merely handed.
  • A mutation is visible through every name that refers to the object, including one stored in a list.
  • A function receiving objects can change them, and naming is the caller's only warning.
  • A method can validate and refuse; a direct assignment cannot — and nothing in Python prevents the direct assignment.

The next chapter is about that last point: what Python does instead of privacy.

Where this fits

Part of the Python curriculum.