Warm-up · Activity 1 of 7
// B4.6 · ~25 min · Beginner
References, copies and the mutable default trap
After this lesson you can predict when a change to one variable shows up in another, pick the right kind of copy, and write default arguments that do not leak state between calls.
Lesson 6 of 6 in B4 Data structures
You will be able to
- Explain why b = a does not copy a list, and use is and == correctly
- Choose between a slice, copy.copy() and copy.deepcopy() for nested data
- Replace a mutable default argument with the None sentinel pattern
Predict · Activity 2 of 7
Predict before you run it: what does this print?
a = [1, 2, 3] b = a b.append(4) print(a)Practice · Activity 3 of 7
Start from a = [1, [2, 3]]. Match each line of code to what it gives you.
import copy a = [1, [2, 3]]Practice · Activity 4 of 7
What does this print?
import copy grid = [[0, 0], [0, 0]] shallow = copy.copy(grid) deep = copy.deepcopy(grid) shallow[0][0] = 1 deep[1][1] = 9 print(grid)Practice · Activity 5 of 7
This function has the mutable default bug: calling add_tag("a") then add_tag("b") returns ['a', 'b'] the second time. Put the lines of the fixed version in order.
def add_tag(tag, tags=[]): tags.append(tag) return tags- 1. tags = []
- 2. tags.append(tag)
- 3. if tags is None:
- 4.def add_tag(tag, tags=None):
- 5. return tags
Brain teaser · Activity 6 of 7
Brain teaser. The += line raises an error, which the try block catches. What does the last line print?
pair = (["foo"], "bar") try: pair[0] += ["item"] except TypeError as e: print("TypeError:", e) print(pair)Apply · Activity 7 of 7
Mini-task. This module has two reference bugs. Run it and you get ['reader', 'admin'] and then ['start', 'stop'] ['start', 'stop']. Fix both functions, then add assert lines that prove each fix.
def add_event(event, log=[]): log.append(event) return log def snapshot(state): return state.copy() state = {"user": "ana", "roles": ["reader"]} saved = snapshot(state) state["roles"].append("admin") print(saved["roles"]) print(add_event("start"), add_event("stop"))Check your work against this list
Build it yourself
Read the worked example, then write the exercises. Your code runs in your browser or on your computer and is never uploaded.
Worked example
One week of shopping lists
A dict of shopping lists, reached in three ways: through a second name, a shallow copy and a deep copy. Then the original changes, and the output shows which of the three sees what. The function at the top uses the None default, so every call without a basket gets its own list. Change a line and predict the output before you run it.
main.py
import copy
def add_item(item: str, basket: list[str] | None = None) -> list[str]:
if basket is None:
basket = [] # a new list for every call without a basket
basket.append(item)
return basket
week = {"mon": ["bread"], "tue": ["milk"]}
alias = week # a second name, not a copy
shallow = week.copy() # new dict, same inner lists
deep = copy.deepcopy(week) # new dict, new inner lists
week["mon"].append("eggs")
week["wed"] = ["tea"]
print(alias is week, alias == week, shallow is week)
print(shallow)
print(deep)
print(add_item("apple"), add_item("pear"))
mine = ["salt"]
print(add_item("rice", mine) is mine, mine)
Run it with
python main.pyOutput
True True False
{'mon': ['bread', 'eggs'], 'tue': ['milk']}
{'mon': ['bread'], 'tue': ['milk']}
['apple'] ['pear']
True ['salt', 'rice']- alias is the same dict as week, so it sees every change.
- shallow sees "eggs", because its Monday list is week’s Monday list, but not the new key "wed", because it is a separate dict.
- deep shares nothing, so it still shows the week as it was.
Change it and run it
Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.
The first run downloads Python for your browser (up to 6.5 MB) and keeps it cached. Your code stays on your device.
Exercises
Exercise 1 of 2
A basket per call
add_item(item, basket) adds item to basket and returns it; called without a basket, it should start a new one. The starter has the mutable default bug: the second call below prints ['apple', 'pear']. Fix the function so that each call without a basket gets a new list, while a basket passed in, even an empty one, is extended and returned.
Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.
The first run downloads Python for your browser (up to 6.5 MB) and keeps it cached. Your code stays on your device.
Hints
Hint 1
The default list is created once, when def runs. Use None as the default instead.
Hint 2
Inside the function, create the new list only when basket is None. if not basket would also replace an empty basket.
Hint 3
def add_item(item: str, basket: list[str] | None = None), then if basket is None: basket = []
Show a solution
One way to solve it. Yours can look different and still pass the checks.
def add_item(item: str, basket: list[str] | None = None) -> list[str]:
"""Add item to basket and return it. Without a basket, start a new one."""
if basket is None:
basket = []
basket.append(item)
return basket
print(add_item("apple"))
print(add_item("pear"))
Run it on your computer
Install Python 3.14 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.
main.py
def add_item(item: str, basket: list[str] = []) -> list[str]:
"""Add item to basket and return it. Without a basket, start a new one."""
basket.append(item)
return basket
print(add_item("apple"))
print(add_item("pear"))
test_main.py
from main import add_item
def test_new_basket_each_call():
"""Two calls without a basket get two different lists"""
add_item("a")
got = add_item("b")
assert got == ["b"], f"the second call returned {got!r}, expected ['b']"
def test_uses_given_basket():
"""A basket passed in is extended and returned"""
mine = ["x"]
got = add_item("a", mine)
assert got is mine, "add_item returned a different list, not the basket it was given"
assert mine == ["x", "a"], f"the basket is {mine!r}, expected ['x', 'a']"
def test_empty_basket_kept():
"""An empty basket passed in is used, not replaced"""
mine: list[str] = []
got = add_item("a", mine)
assert got is mine, "an empty basket was replaced by a new list; test with is None"
On macOS and Linux, type python3 wherever these commands say python, as in the first lesson.
Run the program:
python main.pyRun the checks (needs learnrun.py in the same folder):
python learnrun.py testDownload learnrun.pyExercise 2 of 2
Grids that share nothing
make_grid(rows, cols) should return a grid of zeros whose rows are separate lists, and copy_grid(grid) a copy that shares no row with the original. The starter uses * for the rows and copy() for the copy, and both share rows: the program below prints [[1, 0, 0], [1, 0, 0]]. Fix both functions.
Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.
The first run downloads Python for your browser (up to 6.5 MB) and keeps it cached. Your code stays on your device.
Hints
Hint 1
[[0] * cols] * rows repeats a reference to one row. A list comprehension runs [0] * cols once per row.
Hint 2
grid.copy() is shallow. Copy each row as well, with a slice or copy.deepcopy().
Hint 3
return [[0] * cols for _ in range(rows)] and return [row[:] for row in grid]
Show a solution
One way to solve it. Yours can look different and still pass the checks.
def make_grid(rows: int, cols: int) -> list[list[int]]:
"""Return a rows x cols grid of zeros whose rows are separate lists."""
return [[0] * cols for _ in range(rows)]
def copy_grid(grid: list[list[int]]) -> list[list[int]]:
"""Return a copy of grid that shares no row with it."""
return [row[:] for row in grid]
board = make_grid(2, 3)
board[0][0] = 1
print(board)
Run it on your computer
Install Python 3.14 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.
main.py
def make_grid(rows: int, cols: int) -> list[list[int]]:
"""Return a rows x cols grid of zeros whose rows are separate lists."""
return [[0] * cols] * rows
def copy_grid(grid: list[list[int]]) -> list[list[int]]:
"""Return a copy of grid that shares no row with it."""
return grid.copy()
board = make_grid(2, 3)
board[0][0] = 1
print(board)
test_main.py
from main import copy_grid, make_grid
def test_grid_shape():
"""make_grid(2, 3) is two rows of three zeros"""
got = make_grid(2, 3)
assert got == [[0, 0, 0], [0, 0, 0]], f"make_grid(2, 3) returned {got!r}"
def test_rows_independent():
"""Changing one row of a new grid leaves the other rows alone"""
grid = make_grid(3, 2)
grid[0][0] = 1
assert grid == [[1, 0], [0, 0], [0, 0]], f"after grid[0][0] = 1 the grid is {grid!r}; the rows are one shared list"
def test_copy_equal():
"""The copy has the same values"""
grid = [[1, 2], [3, 4]]
got = copy_grid(grid)
assert got == grid, f"copy_grid returned {got!r}, expected [[1, 2], [3, 4]]"
def test_copy_independent():
"""Changing the copy, even inside a row, leaves the original alone"""
grid = [[1, 2], [3, 4]]
new = copy_grid(grid)
new[0][0] = 9
assert grid == [[1, 2], [3, 4]], f"the original became {grid!r}; the copy still shares its rows"
On macOS and Linux, type python3 wherever these commands say python, as in the first lesson.
Run the program:
python main.pyRun the checks (needs learnrun.py in the same folder):
python learnrun.py testDownload learnrun.pyCommon mistakes
Keeping a “backup” with b = a
scores = [3, 5]
backup = scores
scores.clear()
print(backup[0])
What Python prints
IndexError: list index out of rangeWhy, and the fix
backup = scores is a second name for the same list, so clear() empties both. Take a copy before you change the original: backup = scores.copy().
A shallow copy of nested data
settings = {"theme": {"dark": False}}
backup = settings.copy()
settings["theme"]["dark"] = True
assert backup["theme"]["dark"] is False, "the backup changed too"
What Python prints
AssertionError: the backup changed tooWhy, and the fix
settings.copy() makes a new outer dict, but backup["theme"] is still the same inner dict. For nested data use copy.deepcopy(settings).
Testing the default with if not
def add_tag(tag, tags=None):
if not tags:
tags = []
tags.append(tag)
return tags
mine = []
assert add_tag("a", mine) is mine, "the caller's list was replaced"
What Python prints
AssertionError: the caller's list was replacedWhy, and the fix
An empty list is falsy, so if not tags replaces the list the caller passed in. Test for the default itself: if tags is None.
Python in the browser: Pyodide 314.0.7, MPL-2.0. Licence and source
Exit ticket
5 questions, no hints. Score 80% or more to complete the lesson.
Finish every activity above to unlock the exit ticket.