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
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__)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:
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)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:
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())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:
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)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
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())original: smoke/1234:PENDING
failed : smoke/1234:FAIL
same obj: False
chains : smoke/1234:PASSThe 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:
| Operation | Choice | Why |
|---|---|---|
| a run acquiring its final status | mutate | the run is that record; there is no "old" run worth keeping |
| a parser accumulating errors | mutate | the accumulation is the object's purpose |
| producing a formatted report line | return | a string is not a run |
| a configuration with one field changed | return | the caller's original is still valid |
| sorting a list you were handed | return | you 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:
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])run : FAIL
also_run : FAIL
in list : FAIL
one object: TrueOne 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:
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])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):
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")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
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)A: None
B: ['x']
C: ['x'] ['x', 'y']
D: ['x', 'z'] ['x', 'y']
E: True FalseA 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
Noneby convention — so chaining off it raisesAttributeErroronNoneType. sort()mutates and returnsNone;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.
