Python Tuples and Immutability Practice Problems & Exercises
Practice: Tuples and Immutability
← Back to lessonA common Python gotcha is confusing parenthesized expressions with single-element tuples.
Write classify_types that takes a list of values and returns their type names as strings.
Then call it with this specific input to verify you understand which expressions produce tuples:
def classify_types(values):
"""Return a list of type names for each value."""
return [type(v).__name__ for v in values]
# These five expressions — which ones are actually tuples?
test_values = [
(1, 2, 3), # Expression A
(42), # Expression B
(42,), # Expression C
("hello"), # Expression D
tuple(), # Expression E
]
print(classify_types(test_values))Solution
def classify_types(values):
return [type(v).__name__ for v in values]
test_values = [
(1, 2, 3), # tuple — multiple elements, commas present
(42), # int — parentheses alone do NOT make a tuple
(42,), # tuple — trailing comma makes it a tuple
("hello"), # str — same trap, no comma means just a string
tuple(), # tuple — explicit constructor, empty tuple
]
print(classify_types(test_values))
# ['tuple', 'int', 'tuple', 'str', 'tuple']
Key insight: The comma creates the tuple, not the parentheses. (42) is just the integer 42 grouped in parentheses. You need (42,) for a single-element tuple. The tuple() constructor always returns a tuple, even when empty.
def classify_types(values):
"""Return a list of type names for each value.
Args:
values: a list of Python expressions (already evaluated)
Returns:
list[str]: the type name of each value
e.g. ["tuple", "int", "tuple", "str"]
"""
# TODO: return [type(v).__name__ for each v in values]
passExpected Output
['tuple', 'int', 'tuple', 'str', 'tuple']Hints
Hint 1: The comma creates a tuple, not the parentheses. (42) is just 42 in parentheses.
Hint 2: A single-element tuple requires a trailing comma: (42,)
Hint 3: tuple() with no arguments creates an empty tuple, which is still a tuple.
Tuple unpacking with the * splat operator is essential for processing variable-length sequences.
Write head_tail that splits a tuple into its first element and a tuple of the remaining elements.
def head_tail(data):
"""Split a tuple into its first element and everything else."""
first, *rest = data
return (first, tuple(rest))
print(head_tail((1, 2, 3, 4, 5)))
print(head_tail(("a", "b", "c")))
print(head_tail((42,)))Solution
def head_tail(data):
first, *rest = data
return (first, tuple(rest))
print(head_tail((1, 2, 3, 4, 5))) # (1, (2, 3, 4, 5))
print(head_tail(("a", "b", "c"))) # ('a', ('b', 'c'))
print(head_tail((42,))) # (42, ())
Key insight: *rest always captures into a list, even when unpacking a tuple. This is by design since the captured portion has variable length. If you need a tuple back, wrap it: tuple(rest). For a single-element input, *rest captures an empty list [], which becomes ().
def head_tail(data):
"""Split a tuple into its first element and everything else.
Args:
data: a tuple with at least one element
Returns:
tuple: (first_element, rest_as_tuple)
rest_as_tuple should be a tuple, not a list
"""
# TODO: use splat (*) unpacking
# Remember: *rest captures into a list, so convert it
passExpected Output
(1, (2, 3, 4, 5))
('a', ('b', 'c'))
(42, ())Hints
Hint 1: Use the pattern: first, *rest = data
Hint 2: The starred variable *rest always captures into a list. Convert it with tuple().
Hint 3: For a single-element tuple input, *rest will be an empty list [], which becomes ().
Python's tuple packing/unpacking lets you swap variables without temporary storage.
Write rotate_left that rotates three values one position to the left using a single tuple assignment.
def rotate_left(a, b, c):
"""Rotate three values left by one position."""
a, b, c = b, c, a
return (a, b, c)
print(rotate_left(1, 2, 3))
print(rotate_left("a", "b", "c"))
print(rotate_left(10, 20, 30))Solution
def rotate_left(a, b, c):
a, b, c = b, c, a
return (a, b, c)
print(rotate_left(1, 2, 3)) # (2, 3, 1)
print(rotate_left("a", "b", "c")) # ('b', 'c', 'a')
print(rotate_left(10, 20, 30)) # (20, 30, 10)
Key insight: Python evaluates the entire right-hand side b, c, a into a temporary tuple (b, c, a) first, then unpacks it into a, b, c. For simple two-variable swaps, CPython optimizes this with ROT_TWO bytecode that avoids allocating a heap tuple entirely.
def rotate_left(a, b, c):
"""Rotate three values one position to the left using tuple swap.
Example: rotate_left(1, 2, 3) -> (2, 3, 1)
Args:
a, b, c: three values
Returns:
tuple: (b, c, a) — rotated left by one position
Rules:
- Use a SINGLE tuple pack/unpack statement
- Do NOT use a temporary variable
"""
# TODO: one-line tuple swap
passExpected Output
(2, 3, 1)
('b', 'c', 'a')
(20, 30, 10)Hints
Hint 1: Python evaluates the entire right side before assigning to the left side.
Hint 2: a, b, c = b, c, a is a single tuple pack/unpack — no temp variable needed.
Hint 3: The right side creates a tuple (b, c, a), then unpacks into a, b, c simultaneously.
Python supports nested structural unpacking, matching the shape of the left side to the right.
Write flatten_pairs that takes ((a, b), (c, d)) and returns (a, b, c, d).
def flatten_pairs(pair_of_pairs):
"""Unpack nested pairs into a flat 4-tuple."""
(a, b), (c, d) = pair_of_pairs
return (a, b, c, d)
print(flatten_pairs(((1, 2), (3, 4))))
print(flatten_pairs((("a", "b"), ("c", "d"))))
print(flatten_pairs(((0.0, 1.0), (2.0, 3.0))))Solution
def flatten_pairs(pair_of_pairs):
(a, b), (c, d) = pair_of_pairs
return (a, b, c, d)
print(flatten_pairs(((1, 2), (3, 4)))) # (1, 2, 3, 4)
print(flatten_pairs((("a", "b"), ("c", "d")))) # ('a', 'b', 'c', 'd')
print(flatten_pairs(((0.0, 1.0), (2.0, 3.0)))) # (0.0, 1.0, 2.0, 3.0)
Key insight: Nested unpacking mirrors the structure. The left side (a, b), (c, d) tells Python to expect a 2-element iterable where each element is itself a 2-element iterable. If the shapes do not match, you get a ValueError. This pattern is commonly used when iterating over enumerate(zip(...)) results.
def flatten_pairs(pair_of_pairs):
"""Unpack a tuple of two pairs into four individual values.
Args:
pair_of_pairs: ((a, b), (c, d))
Returns:
tuple: (a, b, c, d) — all four values flattened
"""
# TODO: use nested unpacking in a single statement
passExpected Output
(1, 2, 3, 4)
('a', 'b', 'c', 'd')
(0.0, 1.0, 2.0, 3.0)Hints
Hint 1: Python supports nested unpacking: (a, b), (c, d) = pair_of_pairs
Hint 2: The structure on the left must mirror the structure on the right.
Hint 3: This works in for loops too: for (x, y), (w, z) in list_of_pair_pairs.
A tuple is hashable only when every element — recursively — is also hashable. Write a function that checks deep hashability without relying on hash() raising an exception.
def is_deeply_hashable(obj):
"""Check if an object is deeply hashable (recursively)."""
if isinstance(obj, (list, dict, set)):
return False
if isinstance(obj, tuple):
return all(is_deeply_hashable(item) for item in obj)
if isinstance(obj, frozenset):
return all(is_deeply_hashable(item) for item in obj)
try:
hash(obj)
return True
except TypeError:
return False
print(is_deeply_hashable((1, 2, 3)))
print(is_deeply_hashable(((1, 2), (3, 4))))
print(is_deeply_hashable((1, [2, 3])))
print(is_deeply_hashable((1, "hello", 3.14, None, True)))
print(is_deeply_hashable(((1, (2, [3])),)))
print(is_deeply_hashable({"a": 1}))
print(is_deeply_hashable(frozenset({1, 2, 3})))Solution
def is_deeply_hashable(obj):
"""Check if an object is deeply hashable (recursively)."""
# Mutable containers are never hashable
if isinstance(obj, (list, dict, set)):
return False
# Tuples: hashable only if every element is deeply hashable
if isinstance(obj, tuple):
return all(is_deeply_hashable(item) for item in obj)
# Frozensets: hashable only if every element is deeply hashable
if isinstance(obj, frozenset):
return all(is_deeply_hashable(item) for item in obj)
# Everything else: try hash()
try:
hash(obj)
return True
except TypeError:
return False
print(is_deeply_hashable((1, 2, 3))) # True
print(is_deeply_hashable(((1, 2), (3, 4)))) # True
print(is_deeply_hashable((1, [2, 3]))) # False — list inside
print(is_deeply_hashable((1, "hello", 3.14, None, True)))# True
print(is_deeply_hashable(((1, (2, [3])),))) # False — list nested 3 deep
print(is_deeply_hashable({"a": 1})) # False — dict
print(is_deeply_hashable(frozenset({1, 2, 3}))) # True
Key insight: Python's hash() on a tuple already does this check internally — it calls hash() on each element and propagates TypeError. But building it yourself reveals the recursive nature: hashability is a property of the entire object graph, not just the top-level container. This is exactly why hash((1, (2, [3]))) fails even though the outer tuple and inner tuple are both tuples.
def is_deeply_hashable(obj):
"""Check if an object is deeply hashable (recursively).
Returns True only if the object AND all nested contents
are hashable. This means:
- Tuples: hashable only if ALL elements are deeply hashable
- Lists, dicts, sets: always return False
- Frozensets: hashable only if all elements are deeply hashable
- Scalars (int, float, str, bool, None): always True
Args:
obj: any Python object
Returns:
bool: True if deeply hashable, False otherwise
"""
# TODO: implement recursive hashability check
passExpected Output
True
True
False
True
False
False
TrueHints
Hint 1: Check for tuple and frozenset first — they need recursive checking of all elements.
Hint 2: Lists, dicts, and sets are never hashable — return False immediately.
Hint 3: For everything else, try hash(obj) in a try/except TypeError block.
Hint 4: A tuple containing a list at ANY depth is not hashable: ((1, [2]),) fails.
Tuples are hashable (when their contents are), which makes them ideal as composite dictionary keys. This is the canonical pattern for 2D grid indexing.
Build a grid cache and a neighbor lookup function using (row, col) tuple keys.
def build_grid_cache(rows, cols, compute_fn):
"""Build a 2D cache using (row, col) tuple keys."""
return {(r, c): compute_fn(r, c) for r in range(rows) for c in range(cols)}
def lookup_neighbors(cache, row, col, max_row, max_col):
"""Look up 4-directional neighbors of (row, col)."""
directions = [("up", -1, 0), ("down", 1, 0), ("left", 0, -1), ("right", 0, 1)]
result = []
for name, dr, dc in directions:
nr, nc = row + dr, col + dc
if 0 <= nr < max_row and 0 <= nc < max_col:
result.append((name, cache[(nr, nc)]))
return result
cache = build_grid_cache(3, 4, lambda r, c: r + c)
print(f"cache[(0,0)] = {cache[(0,0)]}, cache[(2,3)] = {cache[(2,3)]}")
print(f"Neighbors of (1,1): {lookup_neighbors(cache, 1, 1, 3, 4)}")
print(f"Neighbors of (0,0): {lookup_neighbors(cache, 0, 0, 3, 4)}")Solution
def build_grid_cache(rows, cols, compute_fn):
return {(r, c): compute_fn(r, c) for r in range(rows) for c in range(cols)}
def lookup_neighbors(cache, row, col, max_row, max_col):
directions = [("up", -1, 0), ("down", 1, 0), ("left", 0, -1), ("right", 0, 1)]
result = []
for name, dr, dc in directions:
nr, nc = row + dr, col + dc
if 0 <= nr < max_row and 0 <= nc < max_col:
result.append((name, cache[(nr, nc)]))
return result
cache = build_grid_cache(3, 4, lambda r, c: r + c)
print(f"cache[(0,0)] = {cache[(0,0)]}, cache[(2,3)] = {cache[(2,3)]}")
# cache[(0,0)] = 0, cache[(2,3)] = 5
print(f"Neighbors of (1,1): {lookup_neighbors(cache, 1, 1, 3, 4)}")
# Neighbors of (1,1): [('up', 1), ('down', 3), ('left', 1), ('right', 3)]
print(f"Neighbors of (0,0): {lookup_neighbors(cache, 0, 0, 3, 4)}")
# Neighbors of (0,0): [('down', 1), ('right', 1)]
Key insight: Lists cannot be dict keys because they are unhashable. Tuples of integers are hashable, making (row, col) the standard Python pattern for 2D coordinate keys. This same pattern appears in graph algorithms, game boards, image processing, and sparse matrix representations. The O(1) dict lookup with tuple keys replaces O(n) scanning through a list of coordinate pairs.
def build_grid_cache(rows, cols, compute_fn):
"""Build a 2D cache using (row, col) tuple keys.
Args:
rows: number of rows
cols: number of columns
compute_fn: callable(row, col) -> value
Returns:
dict: mapping (row, col) -> computed value
"""
# TODO: build dict with tuple keys
pass
def lookup_neighbors(cache, row, col, max_row, max_col):
"""Look up the 4-directional neighbors of (row, col) in the cache.
Args:
cache: dict with (row, col) tuple keys
row, col: center position
max_row, max_col: grid dimensions
Returns:
list[tuple]: [(direction, value), ...] for valid neighbors
direction is one of "up", "down", "left", "right"
"""
# TODO: check each neighbor, return list of (direction, value) tuples
passExpected Output
cache[(0,0)] = 0, cache[(2,3)] = 5
Neighbors of (1,1): [('up', 1), ('down', 3), ('left', 1), ('right', 3)]
Neighbors of (0,0): [('down', 1), ('right', 1)]Hints
Hint 1: Use (row, col) tuples as dictionary keys — they are hashable because both elements are ints.
Hint 2: For neighbors, define offsets as tuples: (-1,0) for up, (1,0) for down, etc.
Hint 3: Check bounds before looking up: 0 <= new_row < max_row.
typing.NamedTuple creates immutable records with named fields, type annotations, and full tuple compatibility.
Define a Student named tuple and a summarize_class function.
from typing import NamedTuple
class Student(NamedTuple):
name: str
grade: int
gpa: float = 0.0
def summarize_class(students):
"""Return (top_student_name, average_gpa, count)."""
top = max(students, key=lambda s: s.gpa)
avg = round(sum(s.gpa for s in students) / len(students), 2)
return (top.name, avg, len(students))
# Test it
s = Student("Charlie", 11, 3.9)
print(s)
print(s.name)
print(s[1])
print(isinstance(s, tuple))
roster = [
Student("Alice", 10, 3.5),
Student("Bob", 10, 3.3),
Student("Charlie", 11, 3.9),
]
top_name, avg_gpa, count = summarize_class(roster)
print(f"Top: {top_name}, Avg GPA: {avg_gpa}, Count: {count}")Solution
from typing import NamedTuple
class Student(NamedTuple):
name: str
grade: int
gpa: float = 0.0
def summarize_class(students):
top = max(students, key=lambda s: s.gpa)
avg = round(sum(s.gpa for s in students) / len(students), 2)
return (top.name, avg, len(students))
s = Student("Charlie", 11, 3.9)
print(s) # Student(name='Charlie', grade=11, gpa=3.9)
print(s.name) # Charlie
print(s[1]) # 11
print(isinstance(s, tuple)) # True
roster = [
Student("Alice", 10, 3.5),
Student("Bob", 10, 3.3),
Student("Charlie", 11, 3.9),
]
top_name, avg_gpa, count = summarize_class(roster)
print(f"Top: {top_name}, Avg GPA: {avg_gpa}, Count: {count}")
# Top: Charlie, Avg GPA: 3.57, Count: 3
Key insight: typing.NamedTuple gives you type annotations (mypy knows s.name is str), default values, and method support — all while remaining a real tuple under the hood. Named access via s.name is a property descriptor that calls self[0] with zero extra memory per instance. Use _replace() to create modified copies since the original is immutable.
from typing import NamedTuple
# TODO: Define a Student NamedTuple with:
# name: str
# grade: int
# gpa: float (default 0.0)
# TODO: Define a function summarize_class that:
# - Takes a list of Student records
# - Returns a tuple of (top_student_name, average_gpa, count)
# - top_student = highest GPA
# - average_gpa = mean of all GPAs, rounded to 2 decimalsExpected Output
Student(name='Charlie', grade=11, gpa=3.9)
Charlie
11
True
Top: Charlie, Avg GPA: 3.57, Count: 3Hints
Hint 1: Use class syntax: class Student(NamedTuple): with typed fields.
Hint 2: Default values go after the type annotation: gpa: float = 0.0
Hint 3: Named tuples are real tuples — isinstance(s, tuple) is True and you can unpack them.
Hint 4: Use max() with a key function to find the top student by GPA.
This is Python's most infamous tuple gotcha. t[0] += [99] both raises TypeError AND mutates the list. Explain why and prove it.
def mutate_experiment(t):
"""Demonstrate the += mutation puzzle."""
original_len = len(t[0])
did_raise = False
try:
t[0] += [99]
except TypeError:
did_raise = True
was_mutated = len(t[0]) > original_len
return (did_raise, was_mutated, t[0])
print(mutate_experiment(([1, 2, 3],)))
print(mutate_experiment((['a', 'b'],)))Solution
def mutate_experiment(t):
original_len = len(t[0])
did_raise = False
try:
t[0] += [99]
except TypeError:
did_raise = True
was_mutated = len(t[0]) > original_len
return (did_raise, was_mutated, t[0])
print(mutate_experiment(([1, 2, 3],))) # (True, True, [1, 2, 3, 99])
print(mutate_experiment((['a', 'b'],))) # (True, True, ['a', 'b', 99])
Why both happen: t[0] += [99] compiles to three bytecode operations:
- Load
t[0](the list object) -- succeeds - Call
list.__iadd__([99])which callsextend([99])in-place on the list -- succeeds, list is now mutated - Store the result back to
t[0]viatuple.__setitem__-- raisesTypeError
Step 2 mutates the list before step 3 tries (and fails) to reassign the tuple slot. The lesson: never use += on mutable objects stored inside a tuple. Use t[0].extend([99]) directly instead — it mutates the list without triggering the assignment.
def mutate_experiment(t):
"""Demonstrate the += mutation puzzle on tuples containing lists.
Given a tuple t containing a list at index 0:
1. Try t[0] += [99] inside a try/except
2. Return a tuple of:
- did_raise: bool (True if TypeError was raised)
- was_mutated: bool (True if the list at t[0] changed)
- final_value: the current value of t[0]
Returns:
tuple: (did_raise, was_mutated, final_value)
"""
# TODO: capture the original length, try the +=, check results
passExpected Output
(True, True, [1, 2, 3, 99])
(True, True, ['a', 'b', 99])Hints
Hint 1: Save len(t[0]) before the operation to detect mutation afterward.
Hint 2: += on a list calls list.__iadd__ (in-place extend) THEN tries to assign back to t[0].
Hint 3: The mutation happens BEFORE the assignment — so the list changes even though TypeError is raised.
Hint 4: This is Python's most infamous gotcha with mutable objects inside tuples.
Tuples as dict keys enable clean multi-dimensional dispatch tables — a pattern used in state machines, parsers, and command routers.
def build_dispatch_table():
return {
("validate", "email"): lambda: "Checking email format",
("validate", "phone"): lambda: "Checking phone format",
("sanitize", "email"): lambda: "Lowercasing email",
("sanitize", "phone"): lambda: "Stripping phone non-digits",
("normalize", "email"): lambda: "Trimming email whitespace",
("normalize", "phone"): lambda: "Adding country code to phone",
}
def dispatch(table, operation, type_name):
key = (operation, type_name)
handler = table.get(key)
if handler is None:
return f"No handler for ({operation}, {type_name})"
return handler()
table = build_dispatch_table()
print(dispatch(table, "validate", "email"))
print(dispatch(table, "sanitize", "phone"))
print(dispatch(table, "normalize", "phone"))
print(dispatch(table, "delete", "email"))Solution
def build_dispatch_table():
return {
("validate", "email"): lambda: "Checking email format",
("validate", "phone"): lambda: "Checking phone format",
("sanitize", "email"): lambda: "Lowercasing email",
("sanitize", "phone"): lambda: "Stripping phone non-digits",
("normalize", "email"): lambda: "Trimming email whitespace",
("normalize", "phone"): lambda: "Adding country code to phone",
}
def dispatch(table, operation, type_name):
key = (operation, type_name)
handler = table.get(key)
if handler is None:
return f"No handler for ({operation}, {type_name})"
return handler()
table = build_dispatch_table()
print(dispatch(table, "validate", "email")) # Checking email format
print(dispatch(table, "sanitize", "phone")) # Stripping phone non-digits
print(dispatch(table, "normalize", "phone")) # Adding country code to phone
print(dispatch(table, "delete", "email")) # No handler for (delete, email)
Key insight: This replaces nested if/elif chains with O(1) dict lookups using composite tuple keys. The pattern scales cleanly: adding a new operation-type combination is one dict entry, not a new branch in a conditional chain. This is the foundation of state machine implementations where (current_state, event) tuples map to (next_state, action) handlers.
def build_dispatch_table():
"""Build a dispatch table mapping (operation, type_name) tuples to handler functions.
The table should handle these combinations:
("validate", "email") -> returns "Checking email format"
("validate", "phone") -> returns "Checking phone format"
("sanitize", "email") -> returns "Lowercasing email"
("sanitize", "phone") -> returns "Stripping phone non-digits"
("normalize", "email") -> returns "Trimming email whitespace"
("normalize", "phone") -> returns "Adding country code to phone"
Returns:
dict: mapping (str, str) -> callable that returns a string
"""
# TODO: build and return the dispatch dict
pass
def dispatch(table, operation, type_name):
"""Look up and execute the handler for (operation, type_name).
Returns:
str: handler result, or "No handler for (op, type)" if not found
"""
# TODO: look up the tuple key and call the handler
passExpected Output
Checking email format
Stripping phone non-digits
Adding country code to phone
No handler for (delete, email)Hints
Hint 1: Each dict key is a tuple of two strings: (operation, type_name).
Hint 2: Values can be lambda functions that return the appropriate string.
Hint 3: Use dict.get() with a default to handle missing combinations gracefully.
To use complex nested structures as dict keys or in sets, you need to convert them to deeply immutable equivalents. Write deep_freeze that recursively converts any structure to its hashable counterpart.
def deep_freeze(obj):
"""Recursively convert nested structure to hashable equivalent."""
if isinstance(obj, list):
return tuple(deep_freeze(item) for item in obj)
elif isinstance(obj, dict):
return tuple(sorted(
(deep_freeze(k), deep_freeze(v)) for k, v in obj.items()
))
elif isinstance(obj, set):
return frozenset(deep_freeze(item) for item in obj)
elif isinstance(obj, tuple):
return tuple(deep_freeze(item) for item in obj)
else:
return obj
print(deep_freeze([1, 2, [3, 4]]))
print(deep_freeze({"a": 1, "b": [2, 3]}))
print(deep_freeze((1, {"x": 2}, {3, 4})))
# Verify it is actually hashable
frozen = deep_freeze({"users": [1, 2], "tags": {"admin", "viewer"}})
print(isinstance(hash(frozen), int))Solution
def deep_freeze(obj):
if isinstance(obj, list):
return tuple(deep_freeze(item) for item in obj)
elif isinstance(obj, dict):
return tuple(sorted(
(deep_freeze(k), deep_freeze(v)) for k, v in obj.items()
))
elif isinstance(obj, set):
return frozenset(deep_freeze(item) for item in obj)
elif isinstance(obj, tuple):
return tuple(deep_freeze(item) for item in obj)
else:
return obj
print(deep_freeze([1, 2, [3, 4]]))
# (1, 2, (3, 4))
print(deep_freeze({"a": 1, "b": [2, 3]}))
# (('a', 1), ('b', (2, 3)))
print(deep_freeze((1, {"x": 2}, {3, 4})))
# (1, (('x', 2),), frozenset({3, 4}))
frozen = deep_freeze({"users": [1, 2], "tags": {"admin", "viewer"}})
print(isinstance(hash(frozen), int)) # True
Key insight: Deep freezing is the bridge between Python's mutable-by-default world and the hashability requirement of dicts/sets. This exact pattern appears in production when you need to cache function results that receive complex nested arguments (like JSON payloads). The functools.lru_cache decorator fails on dict/list arguments — deep_freeze is how you solve it. Note that dict keys must also be frozen, and sorting ensures deep_freeze produces the same output regardless of dict insertion order.
def deep_freeze(obj):
"""Recursively convert a nested structure to its hashable equivalent.
Conversion rules:
list -> tuple (recursively freeze contents)
dict -> tuple of sorted (frozen_key, frozen_value) pairs
set -> frozenset (recursively freeze contents)
tuple -> tuple (but recursively freeze each element)
other -> return as-is
Args:
obj: any Python object (may contain nested lists, dicts, sets)
Returns:
A deeply hashable version of the input
"""
# TODO: implement recursive deep freeze
passExpected Output
(1, 2, (3, 4))
(('a', 1), ('b', (2, 3)))
(1, (('x', 2),), frozenset({3, 4}))
TrueHints
Hint 1: Handle each mutable type: list -> tuple, dict -> tuple of sorted pairs, set -> frozenset.
Hint 2: Recurse into every container — even tuples might contain lists inside them.
Hint 3: For dicts, freeze both keys and values, then sort for consistent ordering.
Hint 4: Test with hash() on the result to verify it is truly hashable.
Build a memoization decorator that handles list and dict arguments by converting them to hashable tuple keys internally.
import functools
def make_hashable(obj):
if isinstance(obj, list):
return tuple(make_hashable(item) for item in obj)
elif isinstance(obj, dict):
return tuple(sorted((make_hashable(k), make_hashable(v)) for k, v in obj.items()))
elif isinstance(obj, set):
return frozenset(make_hashable(item) for item in obj)
elif isinstance(obj, tuple):
return tuple(make_hashable(item) for item in obj)
else:
return obj
def memo(func):
cache = {}
hits = 0
misses = 0
@functools.wraps(func)
def wrapper(*args, **kwargs):
nonlocal hits, misses
h_args = tuple(make_hashable(a) for a in args)
h_kwargs = tuple(sorted((k, make_hashable(v)) for k, v in kwargs.items()))
key = (h_args, h_kwargs)
if key in cache:
hits += 1
return cache[key]
misses += 1
result = func(*args, **kwargs)
cache[key] = result
return result
def cache_info():
return {"hits": hits, "misses": misses, "size": len(cache)}
def cache_clear():
nonlocal hits, misses
cache.clear()
hits = misses = 0
wrapper.cache_info = cache_info
wrapper.cache_clear = cache_clear
return wrapper
@memo
def filter_above(data, threshold):
print("Computing...")
return [x for x in data if x > threshold]
r1 = filter_above([1.0, 2.5, 3.0], threshold=2.0)
print(r1)
r2 = filter_above([1.0, 2.5, 3.0], threshold=2.0) # cache hit
print(r2)
info = filter_above.cache_info()
print(f"hits={info['hits']}, misses={info['misses']}, size={info['size']}")
r3 = filter_above([1.0, 2.5, 3.0], threshold=1.5) # different threshold
info = filter_above.cache_info()
print(f"hits={info['hits']}, misses={info['misses']}, size={info['size']}")Solution
import functools
def make_hashable(obj):
if isinstance(obj, list):
return tuple(make_hashable(item) for item in obj)
elif isinstance(obj, dict):
return tuple(sorted((make_hashable(k), make_hashable(v)) for k, v in obj.items()))
elif isinstance(obj, set):
return frozenset(make_hashable(item) for item in obj)
elif isinstance(obj, tuple):
return tuple(make_hashable(item) for item in obj)
else:
return obj
def memo(func):
cache = {}
hits = 0
misses = 0
@functools.wraps(func)
def wrapper(*args, **kwargs):
nonlocal hits, misses
h_args = tuple(make_hashable(a) for a in args)
h_kwargs = tuple(sorted((k, make_hashable(v)) for k, v in kwargs.items()))
key = (h_args, h_kwargs)
if key in cache:
hits += 1
return cache[key]
misses += 1
result = func(*args, **kwargs)
cache[key] = result
return result
def cache_info():
return {"hits": hits, "misses": misses, "size": len(cache)}
def cache_clear():
nonlocal hits, misses
cache.clear()
hits = misses = 0
wrapper.cache_info = cache_info
wrapper.cache_clear = cache_clear
return wrapper
@memo
def filter_above(data, threshold):
print("Computing...")
return [x for x in data if x > threshold]
r1 = filter_above([1.0, 2.5, 3.0], threshold=2.0) # Computing...
print(r1) # [2.5, 3.0]
r2 = filter_above([1.0, 2.5, 3.0], threshold=2.0) # cache hit, no print
print(r2) # [2.5, 3.0]
info = filter_above.cache_info()
print(f"hits={info['hits']}, misses={info['misses']}, size={info['size']}")
# hits=1, misses=1, size=1
r3 = filter_above([1.0, 2.5, 3.0], threshold=1.5) # Computing... new key
info = filter_above.cache_info()
print(f"hits={info['hits']}, misses={info['misses']}, size={info['size']}")
# hits=1, misses=2, size=2
Key insight: functools.lru_cache requires all arguments to be hashable, which fails for lists and dicts. This decorator solves that by converting arguments to hashable equivalents before building the cache key. The nonlocal keyword allows the inner wrapper to mutate the hits and misses counters from the enclosing scope. Sorting kwargs ensures keyword argument order does not affect the cache key. This pattern is used in production systems for caching expensive computations that receive JSON-like arguments.
import functools
def make_hashable(obj):
"""Convert an object to a hashable equivalent for cache keys.
Rules:
list -> tuple (recursively)
dict -> tuple of sorted (key, value) pairs (recursively)
set -> frozenset (recursively)
other -> return as-is
"""
# TODO: implement (similar to deep_freeze)
pass
def memo(func):
"""Memoization decorator that handles unhashable arguments.
- Converts all args/kwargs to hashable equivalents before keying
- Attaches cache_info() method returning dict with hits, misses, size
- Attaches cache_clear() method
Returns:
wrapped function with caching
"""
# TODO: implement decorator with cache dict and hit/miss counters
passExpected Output
Computing...
[2.5, 3.0]
[2.5, 3.0]
hits=1, misses=1, size=1
Computing...
hits=1, misses=2, size=2Hints
Hint 1: Use deep_freeze / make_hashable on all args and kwargs before building the cache key.
Hint 2: The cache key should be a tuple: (frozen_args, frozen_kwargs).
Hint 3: Sort kwargs by key name so f(a=1, b=2) and f(b=2, a=1) produce the same key.
Hint 4: Use functools.wraps to preserve the original function metadata.
Hint 5: Use nonlocal to mutate the hits/misses counters from inside the wrapper.
Measure the concrete memory and performance differences between tuples and lists. Demonstrate constant folding with bytecode inspection.
import sys
import dis
def compare_memory(n):
lst = list(range(n))
tup = tuple(range(n))
lb = sys.getsizeof(lst)
tb = sys.getsizeof(tup)
saved = lb - tb
pct = round(100 * saved / lb, 1)
return (lb, tb, saved, pct)
def demonstrate_constant_folding():
tuple_instrs = list(dis.get_instructions(compile("(1, 2, 3)", "<s>", "eval")))
list_instrs = list(dis.get_instructions(compile("[1, 2, 3]", "<s>", "eval")))
tuple_has_lc = any(i.opname == "LOAD_CONST" and isinstance(i.argval, tuple) for i in tuple_instrs)
list_has_bl = any(i.opname == "BUILD_LIST" for i in list_instrs)
return (tuple_has_lc, list_has_bl)
lb, tb, saved, pct = compare_memory(100)
print(f"n=100: list={lb}B, tuple={tb}B, saved={saved}B ({pct}%)")
lb, tb, saved, pct = compare_memory(1000)
print(f"n=1000: list={lb}B, tuple={tb}B, saved={saved}B ({pct}%)")
tlc, lbl = demonstrate_constant_folding()
print(f"Tuple literal uses LOAD_CONST: {tlc}")
print(f"List literal uses BUILD_LIST: {lbl}")Solution
import sys
import dis
def compare_memory(n):
lst = list(range(n))
tup = tuple(range(n))
lb = sys.getsizeof(lst)
tb = sys.getsizeof(tup)
saved = lb - tb
pct = round(100 * saved / lb, 1)
return (lb, tb, saved, pct)
def demonstrate_constant_folding():
tuple_code = compile("(1, 2, 3)", "<s>", "eval")
list_code = compile("[1, 2, 3]", "<s>", "eval")
tuple_instrs = list(dis.get_instructions(tuple_code))
list_instrs = list(dis.get_instructions(list_code))
# Tuple of constants becomes a single LOAD_CONST with the tuple as argval
tuple_has_lc = any(
i.opname == "LOAD_CONST" and isinstance(i.argval, tuple)
for i in tuple_instrs
)
# List always requires BUILD_LIST at runtime
list_has_bl = any(i.opname == "BUILD_LIST" for i in list_instrs)
return (tuple_has_lc, list_has_bl)
lb, tb, saved, pct = compare_memory(100)
print(f"n=100: list={lb}B, tuple={tb}B, saved={saved}B ({pct}%)")
lb, tb, saved, pct = compare_memory(1000)
print(f"n=1000: list={lb}B, tuple={tb}B, saved={saved}B ({pct}%)")
tlc, lbl = demonstrate_constant_folding()
print(f"Tuple literal uses LOAD_CONST: {tlc}")
print(f"List literal uses BUILD_LIST: {lbl}")
Key insight: Three distinct performance advantages of tuples:
- Memory: Tuples lack the
allocatedcapacity field and the separate pointer-array allocation. Savings are 16+ bytes per container, which matters at scale (10M coordinate pairs saves ~240 MB). - Creation speed: Constant tuple literals are folded at compile time into a single
LOAD_CONSTbytecode — typically 4-8x faster thanBUILD_LIST. Non-constant tuples (tuple(range(n))) are still slightly faster due to one fewer allocation. - Cache locality: The tuple's pointer array is inline in the struct (flexible array member), meaning one fewer pointer dereference and better CPU cache behavior for small tuples.
Note: sys.getsizeof measures only the container overhead, not the contained objects. Both a list and tuple of the same integers reference the same int objects — the savings come from the container struct itself.
import sys
def compare_memory(n):
"""Compare memory usage of tuple vs list for n integers.
Args:
n: number of elements
Returns:
tuple: (list_bytes, tuple_bytes, savings_bytes, savings_pct)
savings_pct is a float rounded to 1 decimal
"""
# TODO: create list and tuple of range(n), measure with sys.getsizeof
pass
def compare_creation_speed(n, iterations=1_000_000):
"""Compare creation speed of tuple() vs list() from a range.
Args:
n: number of elements
iterations: how many times to create
Returns:
tuple: (list_time, tuple_time, speedup_ratio)
speedup_ratio = list_time / tuple_time, rounded to 1 decimal
"""
# TODO: use timeit to measure tuple(range(n)) vs list(range(n))
pass
def demonstrate_constant_folding():
"""Show that constant tuple literals use LOAD_CONST bytecode.
Use the dis module to get bytecode for:
compile("(1, 2, 3)", "<s>", "eval")
compile("[1, 2, 3]", "<s>", "eval")
Returns:
tuple: (tuple_has_load_const, list_has_build_list)
Both are booleans.
"""
# TODO: use dis.get_instructions() to check bytecode operation names
passExpected Output
n=100: list=920B, tuple=856B, saved=64B (7.0%)
n=1000: list=8856B, tuple=8048B, saved=808B (9.1%)
Tuple literal uses LOAD_CONST: True
List literal uses BUILD_LIST: TrueHints
Hint 1: sys.getsizeof measures the shallow size of the container, not the contained objects.
Hint 2: Use timeit.timeit with a setup string to avoid measuring range() creation time.
Hint 3: dis.get_instructions() returns Instruction objects with an opname attribute.
Hint 4: Constant folding only works for tuples of literals — tuple(range(n)) is NOT constant-folded.
