Punjab PSEB SS Computer Science study materials
Punjab PSEB SS Computer Science study resources

The Toppers List That Remembers the Previous Section

Punjab PSEB SS Computer Science ยท Put your knowledge to work in context.

All case studies

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: toppers is the stored list, empty. 92 passes m >= 90 and is appended โ†’ the stored list becomes [92]. It is returned and printed as [92].
  • Call 2: toppers is 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 to classify.
  • 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 explicit if toppers is None test avoids both.
  • Nested lists (a list of per-question mark lists) are only shallow-copied, so copy.deepcopy or building fresh inner lists is needed before mutation.

Tests that target the class of bug, not the instance.

  1. Idempotence / repeatability: call classify([95, 60, 91]) twice in a row and assert both results are [95]. Any cached state makes call 2 differ.
  2. Isolation of caller data: build mine = [91], snapshot before = mine[:], call classify([99], mine), then assert mine == 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.