The Toppers List That Remembers the Previous Section
Class 10 results at a school in Jalandhar are processed by a small Python script. The teacher wrote:
def classify(marks, toppers=[]): for m in marks: if m >= 90: toppers.append(m) return toppers
print(classify([88, 92, 76])) print(classify([95, 60, 91]))
The first call prints [92] as expected, but the second prints [92, 95, 91] instead of [95, 91].
The teacher then adds a safety step before changing anything:
backup = marks marks.append(100) print(backup)
and finds the "backup" has grown to include 100 as well. Later the teacher writes:
result = marks.sort() print(len(result))
and the program stops with TypeError: object of type 'NoneType' has no len().
An assistant suggests simply writing toppers=None instead of toppers=[] and using backup = marks[:]. The teacher wants the reason behind each symptom first, because the same script will run on 40 sections next term and must not carry data from one section into the next.
1. Explain why the second call to classify() returns three values. When exactly is the default value created, and how could the teacher prove the explanation at the Python prompt?
Read the worked answer
Default argument values are evaluated once, when the def statement executes, not on every call. [] is a list object; it is created at definition time and stored on the function object in classify.__defaults__. Every call that omits toppers binds the parameter to that same object.
Trace of the given run:
- Call 1:
toppersis the stored list, empty. 92 passesm >= 90and is appended โ the stored list becomes[92]. It is returned and printed as[92]. - Call 2:
toppersis the same list, now containing 92. 95 and 91 are appended โ[92, 95, 91], which is printed.
So the function is not "returning the previous result"; it is mutating one shared object that never gets reset between calls. The bug is state leaking across calls, which is exactly the failure mode that would ruin a 40-section run.
Proof at the prompt:
print(classify.__defaults__) # ([92, 95, 91],)
print(id(classify.__defaults__[0])) # same id across calls
Calling classify([100]) a third time and seeing the old 92 survive in the output settles the question. Note that passing an explicit list suppresses the problem entirely, which is why a quick manual test with an argument often hides it.
2. Rewrite classify() so it is safe to call repeatedly, and rewrite the backup and sort steps so they behave as the teacher intends. Explain the difference between `backup = marks` and `backup = marks[:]` using the object identities involved.
Read the worked answer
def classify(marks, toppers=None):
if toppers is None:
out = []
else:
out = list(toppers) # work on a copy, protect the caller's list
for m in marks:
if m >= 90:
out.append(m)
return out
The None sentinel is the standard idiom because None is immutable and cannot accumulate state. A fresh list is built inside the body on every call, so each call is independent. The list(toppers) copy matters when the caller passes a list of their own: without it, classify would silently append into the caller's list, a second form of the same data-leak bug.
Backup and sorting:
backup = marks[:] # new list object, same element values
marks.append(100) # backup unaffected
sorted_view = sorted(marks) # returns a new sorted list
marks.sort() # in-place, return value is None - do not assign it
Identity. backup = marks copies only the reference: id(backup) == id(marks), one list object with two names, so any mutation is visible through both. marks[:] (or list(marks) / marks.copy()) creates a new list object with a different id, so appending to marks cannot touch it. Caveat: both are shallow - if the elements were themselves lists, editing an inner list would still be seen from both names.
sort(). list.sort() sorts in place and returns None by design. result = marks.sort() therefore stores None, and len(None) raises TypeError. Either call marks.sort() and use marks, or use numlist = sorted(marks).
3. Is fixing the default argument enough to make the script safe for 40 sections? Identify one further place where a shared list can still corrupt results, propose the fix, and describe two tests that would catch the class of bug rather than this one instance.
Read the worked answer
Fixing the default is necessary but not sufficient. The same aliasing hazard survives anywhere a list is passed in, returned and then reused:
- A module-level
all_toppers = []that each section appends into, then hands toclassify. classify(section_marks, last_terms_toppers)- the copy inside the function protects the caller, but the caller may still store the returned list in a variable that the next section reuses.- A shortcut "fix" such as
toppers = toppers or []is dangerous: an empty list passed deliberately is replaced by a new list, so the caller's list is never filled, and a non-empty list passed in is mutated in place. The explicitif toppers is Nonetest avoids both. - Nested lists (a list of per-question mark lists) are only shallow-copied, so
copy.deepcopyor building fresh inner lists is needed before mutation.
Tests that target the class of bug, not the instance.
- Idempotence / repeatability: call
classify([95, 60, 91])twice in a row and assert both results are[95]. Any cached state makes call 2 differ. - Isolation of caller data: build
mine = [91], snapshotbefore = mine[:], callclassify([99], mine), then assertmine == before. This catches hidden mutation of arguments.
Add a loop over the 40 sections that asserts each section's result length is at most that section's size, and run the test suite in a random order - order-dependent results are the fingerprint of shared mutable state.