Python Variables in Memory Practice Problems & Exercises
Practice: Variables in Memory
← Back to lessonEasy
Write a function are_aliases(a, b) that returns True if a and b refer to the same object in memory.
x = [1, 2, 3]
y = x
z = [1, 2, 3]
print(are_aliases(x, y)) # True
print(are_aliases(x, z)) # False
Solution
def are_aliases(a, b):
return a is b
Why it works: The is operator checks whether two names point to the same object (same id()). == checks value equality — two different list objects with the same contents are == but not is.
def are_aliases(a, b):
# Return True if a and b point to the same object
passExpected Output
True\nFalseHints
Hint 1: Think about what operator checks object identity (not equality).
Hint 2: The `is` operator compares memory addresses — same as comparing `id()` values.
Predict the output of the following code, then verify by running it.
a = 100 b = 100 print(a is b) c = 1000 d = 1000 print(c is d) e = "hello" f = "hello" print(e is f)
Solution
True
False
True
a is b→True: CPython interns integers from -5 to 256, so100is the same object.c is d→False:1000is outside the interning range — two separate objects are created.e is f→True: CPython interns short string literals that look like identifiers.
Key insight: Never rely on is for value comparison. Interning is a CPython implementation detail, not a language guarantee.
Expected Output
True\nFalse\nTrueHints
Hint 1: Small integers (-5 to 256) are cached by CPython.
Hint 2: Large integers are created as new objects each time.
Predict the output. Think about what happens when you reassign an immutable value.
a = 10 b = a a = 20 print(b) print(10) print(a)
Solution
10
10
20
b = a makes b point to the same integer object 10. When a = 20 runs, a is rebound to a new integer object 20. b still points to the original 10. Integers are immutable — they can't be changed in place, only rebound.
Expected Output
10\n10\n20Hints
Hint 1: Integers are immutable — reassigning `a` creates a new object.
Hint 2: `b` still points to the original object.
Write a function classify(obj) that returns "mutable" or "immutable" based on the object's type. Handle the common built-in types.
print(classify(42)) # immutable
print(classify([1, 2])) # mutable
print(classify("hello")) # immutable
print(classify({"a": 1})) # mutable
print(classify((1, 2))) # immutable
print(classify({1, 2})) # mutable
Solution
def classify(obj):
immutable_types = (int, float, bool, str, bytes, tuple, frozenset, type(None))
return "immutable" if isinstance(obj, immutable_types) else "mutable"
Why it works: Python's mutable built-in types are list, dict, set, and bytearray. Everything else in the common built-ins is immutable. Using isinstance with a tuple of types is the clean, Pythonic check.
def classify(obj):
"""Return 'mutable' or 'immutable'."""
passExpected Output
immutable\nmutable\nimmutable\nmutable\nimmutable\nmutableHints
Hint 1: Check the type of the object. Lists, dicts, sets, and bytearrays are mutable.
Hint 2: Tuples, strings, integers, floats, booleans, bytes, and frozensets are immutable.
Medium
The function below has a classic Python bug. Each call should return a list with only the new item, but instead the list grows. Fix the bug.
def add_item(item, inventory=[]):
inventory.append(item)
return inventory
print(add_item("sword")) # Expected: ['sword']
print(add_item("shield")) # Expected: ['shield']
print(add_item("potion")) # Expected: ['potion']Solution
def add_item(item, inventory=None):
if inventory is None:
inventory = []
inventory.append(item)
return inventory
The bug: Python evaluates default argument values once at function definition time. The empty list [] is created once and shared across all calls. Every append mutates that same list object.
The fix: Use None as the sentinel default and create a fresh list inside the function body on each call.
def add_item(item, inventory=[]):
inventory.append(item)
return inventory
# This should print three separate lists
print(add_item("sword"))
print(add_item("shield"))
print(add_item("potion"))Expected Output
['sword']\n['shield']\n['potion']Hints
Hint 1: Default arguments are evaluated once — when the function is defined, not when it is called.
Hint 2: Use `None` as the default, then create a new list inside the function body.
Predict the output. Pay attention to what a shallow copy actually copies.
original = [[1, 2, 3], [4, 5, 6]] shallow = list(original) shallow[0][2] = 99 original[1] = [7, 8, 9] print(shallow) print(original)
Solution
[[1, 2, 99], [4, 5, 6]]
[[1, 2, 99], [7, 8, 9]]
shallow = list(original)creates a new outer list, but the inner lists are shared references.shallow[0][2] = 99mutates the inner list at index 0 — visible from bothshallowandoriginalbecause they share the same inner list.original[1] = [7, 8, 9]rebindsoriginal[1]to a new list —shallow[1]still points to the old[4, 5, 6].
Key insight: Shallow copy = new container, same contents. Mutations to nested objects propagate; rebinding does not.
Expected Output
[[1, 2, 99], [4, 5, 6]]\n[[1, 2, 99], [4, 5, 6]]Hints
Hint 1: `list()` and `[:]` create shallow copies — only the outer list is new.
Hint 2: Nested objects (inner lists) are still shared references.
Predict the output for each function call. Think about when mutation affects the caller vs. when it doesn't.
def modify(lst):
lst.append(4)
def replace(lst):
lst = [10, 20]
data = [1, 2, 3]
modify(data)
print(data)
data2 = [1, 2, 3, 4]
replace(data2)
print(data2)
local = [10, 20]
print(local)Solution
[1, 2, 3, 4]
[1, 2, 3, 4]
[10, 20]
modify(data):lstpoints to the same object asdata.lst.append(4)mutates that object →datasees[1, 2, 3, 4].replace(data2):lststarts pointing todata2, butlst = [10, 20]rebinds the local namelstto a new list.data2is unaffected → still[1, 2, 3, 4].
Key insight: Python is "pass-by-object-reference." The function receives a copy of the reference, not a copy of the object. Mutation through the reference affects the original; reassigning the local name does not.
Expected Output
[1, 2, 3, 4]\n[1, 2, 3, 4]\n[10, 20]Hints
Hint 1: Python passes object references by value — the function gets a copy of the reference.
Hint 2: Mutating via the reference affects the original. Rebinding the local name does not.
Implement safe_duplicate(obj) that returns a fully independent deep copy. Demonstrate that mutating the copy does not affect the original.
data = {"users": ["alice", "bob"], "settings": {"theme": "dark"}}
clone = safe_duplicate(data)
clone["users"].append("charlie")
clone["settings"]["theme"] = "light"
print("Original:", data)
print("Copy: ", clone)
Solution
import copy
def safe_duplicate(obj):
return copy.deepcopy(obj)
Why deepcopy: copy.copy() (shallow) would create a new dict, but the inner list and dict would be shared. Mutations to clone["users"] would leak into data. copy.deepcopy() recursively copies every nested object, so the two are completely independent.
Performance note: Deep copy is slower and uses more memory. Use it when you need true isolation; use shallow copy when you only need a new top-level container.
import copy
def safe_duplicate(obj):
"""Return a fully independent copy of obj.
Modifying the copy must never affect the original.
"""
passExpected Output
Original: {'users': ['alice', 'bob'], 'settings': {'theme': 'dark'}}\nCopy: {'users': ['alice', 'bob', 'charlie'], 'settings': {'theme': 'light'}}Hints
Hint 1: Think about what `copy.copy()` vs `copy.deepcopy()` actually copies.
Hint 2: `copy.deepcopy()` recursively copies all nested objects.
Predict the reference counts at each print statement, then run to verify.
import sys a = [1, 2, 3] print(sys.getrefcount(a)) # How many? b = a print(sys.getrefcount(a)) # How many now? c = [a, a, a] print(sys.getrefcount(a)) # And now? del b print(sys.getrefcount(a)) # After deleting b?
Solution
2
3
6
5
Breakdown:
- After
a = [1,2,3]: 1 reference (a) + 1 temporary fromgetrefcountarg = 2 - After
b = a:a+b+ temporary = 3 - After
c = [a, a, a]:a+b+ 3 slots inc+ temporary = 6 - After
del b: removes one reference → 5
Key insight: sys.getrefcount() always returns one extra because passing the object to the function creates a temporary reference. This is CPython's reference-counting garbage collector in action.
Expected Output
See solution for explanationHints
Hint 1: `sys.getrefcount(obj)` always returns one MORE than you expect because the function argument itself is a temporary reference.
Hint 2: Deleting a name or reassigning it decrements the reference count.
Hard
Design a SafeCache class where:
put(key, value)stores a value that cannot be mutated by the caller afterward.get(key)returns a value that cannot be used to mutate the cached copy.
cache = SafeCache()
data = [1, 2, 3]
cache.put("items", data)
data.append(999) # Caller mutates original — cache must be unaffected
print("Cached:", cache.get("items"))
result = cache.get("items")
result.append("injected") # Mutating the returned value — cache must be unaffected
print("After mutation:", cache.get("items"))
Solution
import copy
class SafeCache:
def __init__(self):
self._store = {}
def put(self, key, value):
self._store[key] = copy.deepcopy(value)
def get(self, key):
if key not in self._store:
return None
return copy.deepcopy(self._store[key])
Why deepcopy on both operations:
- On
put: Prevents the caller from mutatingdataand accidentally changing the cached value. - On
get: Prevents the caller from mutating the returned reference and corrupting the cache.
Trade-off: This is the safest but most expensive approach. In performance-critical code, you might use copy.copy() (shallow) if your values aren't nested, or return frozen/immutable types (tuples, frozensets) instead.
import copy
class SafeCache:
"""A cache that stores and returns deep copies.
Callers can never mutate cached values."""
def __init__(self):
self._store = {}
def put(self, key, value):
# Store a safe copy
pass
def get(self, key):
# Return a safe copy (or None if missing)
passExpected Output
Cached: [1, 2, 3]\nAfter mutation: [1, 2, 3]\nGet 1: [1, 2, 3]\nGet 2: [1, 2, 3, 'injected'] -- WRONG if not safe\nExpected: both gets return [1, 2, 3]Hints
Hint 1: You need to deepcopy on BOTH put and get — otherwise the caller can mutate the stored copy via the returned reference.
Hint 2: Think about what happens if you only copy on put but return the stored reference on get.
Hint 3: Consider the trade-off: extra memory and CPU for safety vs. risk of silent corruption.
Build a RefCounter class that simulates CPython's reference-counting garbage collector. It should track allocation, reference increments/decrements, and free objects when their count hits zero.
rc = RefCounter()
rc.allocate("obj_a")
rc.inc_ref("obj_a") # a = something; b = a
rc.inc_ref("obj_a") # c = a
print("obj_a count:", rc.get_count("obj_a")) # 3
rc.dec_ref("obj_a") # del c
print("obj_a count after dec:", rc.get_count("obj_a")) # 2
rc.allocate("obj_b")
rc.dec_ref("obj_b") # Only reference removed → freed
print("obj_b freed:", rc.get_count("obj_b") == 0) # True
rc.dec_ref("obj_a")
rc.dec_ref("obj_a") # Hits 0 → freed
print("obj_a count:", rc.get_count("obj_a")) # 0
print("Freed order:", rc.get_freed()) # ['obj_b', 'obj_a']
Solution
class RefCounter:
def __init__(self):
self._counts = {}
self._freed = []
def allocate(self, obj_id: str) -> None:
self._counts[obj_id] = 1
def inc_ref(self, obj_id: str) -> None:
if obj_id in self._counts:
self._counts[obj_id] += 1
def dec_ref(self, obj_id: str) -> None:
if obj_id not in self._counts:
return
self._counts[obj_id] -= 1
if self._counts[obj_id] <= 0:
del self._counts[obj_id]
self._freed.append(obj_id)
def get_count(self, obj_id: str) -> int:
return self._counts.get(obj_id, 0)
def get_freed(self) -> list:
return list(self._freed)
How it mirrors CPython:
allocate= object creation (refcount starts at 1)inc_ref= new name binding (b = a) or adding to a containerdec_ref=del name, name going out of scope, or container removal- When count hits 0, the object is immediately freed (no waiting for a GC cycle)
Limitation of real reference counting: It can't handle cyclic references (A → B → A). CPython uses a separate cycle-detecting GC for those.
class RefCounter:
"""Simulate a simple reference-counting memory manager."""
def __init__(self):
self._counts = {} # object_id -> ref count
self._freed = [] # list of freed object_ids
def allocate(self, obj_id: str) -> None:
"""Create a new object with refcount 1."""
pass
def inc_ref(self, obj_id: str) -> None:
"""Increment reference count (new name points to object)."""
pass
def dec_ref(self, obj_id: str) -> None:
"""Decrement reference count. Free if it hits 0."""
pass
def get_count(self, obj_id: str) -> int:
"""Return current reference count (0 if freed)."""
pass
def get_freed(self) -> list:
"""Return list of freed object IDs in order."""
passExpected Output
obj_a count: 3\nobj_a count after dec: 2\nobj_b freed: True\nobj_a count: 0\nFreed order: ['obj_b', 'obj_a']Hints
Hint 1: Track counts in a dictionary. Allocate sets count to 1.
Hint 2: When `dec_ref` drops count to 0, move the id to the freed list and remove from counts.
Hint 3: Handle edge cases: decrementing a freed or unknown object should be a no-op.
Create a cyclic reference between two objects, then demonstrate that Python's garbage collector can still collect them. Use weakref to verify the objects are actually freed.
# Your code should:
# 1. Create two objects that reference each other (a cycle)
# 2. Create a weak reference to observe one of them
# 3. Delete all strong references
# 4. Show the weak ref is still alive (reference counting alone can't free them)
# 5. Run gc.collect() and show the weak ref is now dead
Solution
import gc
import weakref
class Node:
def __init__(self, name):
self.name = name
self.ref = None
def demonstrate_cycle_collection():
# Disable automatic GC so we control when collection happens
gc.disable()
# Create a cycle: a -> b -> a
a = Node("A")
b = Node("B")
a.ref = b
b.ref = a
# Weak reference lets us observe without preventing collection
weak_a = weakref.ref(a)
# Delete strong references — refcount won't hit 0 due to cycle
del a
del b
print("Weak ref alive before GC:", weak_a() is not None) # True
# Manually trigger cycle-detecting GC
gc.collect()
print("Weak ref alive after GC:", weak_a() is not None) # False
print("Cycle was collected!")
gc.enable()
demonstrate_cycle_collection()
How it works:
- After
del aanddel b, the objects still exist because each has a refcount of 1 (from the other's.refattribute). Reference counting alone cannot free them. gc.collect()runs Python's cycle detector — a generational mark-and-sweep algorithm that finds groups of objects reachable only from each other (not from any root).- The weak reference confirms the objects were actually freed:
weak_a()returnsNoneafter collection.
Real-world implication: Cyclic references in long-running processes (caches, graph structures, observer patterns) can leak memory if the cycle detector is disabled or if objects define __del__ finalizers (which can prevent collection in older Python versions).
import gc
import weakref
def create_cycle():
"""Create a cyclic reference and return a weak reference
to detect when the cycle is collected."""
pass
def demonstrate_cycle_collection():
"""Show that Python's GC can collect cyclic references."""
passExpected Output
Weak ref alive before GC: True\nWeak ref alive after GC: False\nCycle was collected!Hints
Hint 1: Create two objects that reference each other: `a.ref = b` and `b.ref = a`.
Hint 2: Use `weakref.ref()` to observe the object without preventing collection.
Hint 3: After deleting all strong references, call `gc.collect()` to trigger cycle detection.
