Aliasing Bugs¶
The Mental Model¶
In Python, variables are names that refer to objects, not containers that hold values. When you write b = a, you are not copying the data inside a---you are making b point to the same object that a already points to. Both names are now aliases for one underlying object.
For immutable objects (integers, strings, tuples), aliasing is invisible. You cannot change the object in place, so it does not matter how many names point to it. But for mutable objects (lists, dictionaries, sets), aliasing means that modifying the object through one name silently changes what every other alias sees. This is the root cause of an entire class of bugs.
List Aliasing¶
The simplest aliasing bug occurs with a plain assignment:
```python a = [1, 2, 3] b = a
b.append(4)
print(a) # [1, 2, 3, 4] print(b) # [1, 2, 3, 4] ```
After b = a, both a and b refer to the same list object. The append through b mutates that shared object, and the change is visible through a. You can confirm the aliasing with is and id():
python
print(a is b) # True
print(id(a) == id(b)) # True
The Multiplication Trap¶
A common source of aliasing bugs is using * to create nested structures:
```python grid = [[0]] * 3 print(grid) # [[0], [0], [0]]
grid[0].append(1) print(grid) # [[0, 1], [0, 1], [0, 1]] ```
All three inner lists are the same object. The * operator does not create three independent copies---it replicates the reference three times. Mutating one "row" mutates all of them.
Verify with id():
python
grid = [[0]] * 3
print(id(grid[0])) # 140234567890
print(id(grid[1])) # 140234567890 (same)
print(id(grid[2])) # 140234567890 (same)
The correct way to create independent rows is with a list comprehension:
```python grid = [[0] for _ in range(3)]
grid[0].append(1) print(grid) # [[0, 1], [0], [0]] ```
Each iteration of the comprehension evaluates [0] fresh, producing a distinct list object.
Dictionary and Set Aliasing¶
The same aliasing behavior applies to all mutable types:
```python original = {"x": [1, 2]} alias = original
alias["x"].append(3) print(original) # {'x': [1, 2, 3]} ```
And with sets:
python
s1 = {1, 2, 3}
s2 = s1
s2.add(4)
print(s1) # {1, 2, 3, 4}
In every case, assignment creates an alias, not a copy.
Function Argument Aliasing¶
When you pass a mutable object to a function, the parameter becomes an alias for the caller's object. Any in-place mutation inside the function is visible to the caller:
```python def remove_negatives(numbers): i = 0 while i < len(numbers): if numbers[i] < 0: numbers.pop(i) else: i += 1
data = [3, -1, 4, -2, 5] remove_negatives(data) print(data) # [3, 4, 5] ```
The caller's data list is modified because numbers inside the function is an alias for the same object. If the caller did not expect this, it is a bug.
Fixes Using Copying¶
Shallow Copy¶
A shallow copy creates a new top-level container, but the elements inside still refer to the same objects:
```python import copy
a = [1, 2, 3] b = a.copy() # or b = list(a) or b = a[:] b.append(4) print(a) # [1, 2, 3] -- unaffected print(b) # [1, 2, 3, 4] ```
For a flat list of immutable elements, a shallow copy is sufficient. But for nested structures, the inner objects are still shared:
```python a = [[1, 2], [3, 4]] b = a.copy()
b[0].append(99) print(a) # [[1, 2, 99], [3, 4]] -- inner list still aliased ```
Deep Copy¶
A deep copy recursively copies every nested object, producing a fully independent structure:
```python import copy
a = [[1, 2], [3, 4]] b = copy.deepcopy(a)
b[0].append(99) print(a) # [[1, 2], [3, 4]] -- completely independent print(b) # [[1, 2, 99], [3, 4]] ```
Protecting Function Arguments¶
To prevent a function from modifying the caller's data, copy the argument at the function boundary:
```python def sorted_without_negatives(numbers): working = numbers.copy() # Shallow copy -- caller's list is safe working = [x for x in working if x >= 0] working.sort() return working
data = [3, -1, 4, -2, 5] result = sorted_without_negatives(data) print(data) # [3, -1, 4, -2, 5] -- unchanged print(result) # [3, 4, 5] ```
Summary of Copying Methods¶
| Method | Creates new top-level? | Copies nested objects? | Use when |
|---|---|---|---|
b = a |
No (alias) | No | You want both names to share the object |
a.copy() / list(a) / a[:] |
Yes | No (shallow) | Flat structures with immutable elements |
copy.deepcopy(a) |
Yes | Yes (recursive) | Nested mutable structures |
Exercises¶
Exercise 1. Predict the output of the following code. Explain which names are aliases and which are independent.
```python x = [10, 20, 30] y = x z = x.copy()
y.append(40) z.append(50)
print(x) print(y) print(z) print(x is y) print(x is z) ```
Solution to Exercise 1
Output:
text
[10, 20, 30, 40]
[10, 20, 30, 40]
[10, 20, 30, 50]
True
False
y = x makes y an alias for the same list as x. When y.append(40) executes, the shared list becomes [10, 20, 30, 40], visible through both x and y.
z = x.copy() creates a new, independent list containing the same elements. z.append(50) modifies only z, producing [10, 20, 30, 50]. The original list (referenced by x and y) is unaffected.
x is y is True because they reference the same object. x is z is False because z is a separate object created by copy().
Exercise 2. A programmer wants to create a 3x3 grid initialized with zeros. They write:
python
grid = [[0, 0, 0]] * 3
grid[1][1] = 5
print(grid)
(a) What does print(grid) output?
(b) Why is the result surprising?
(c) Rewrite the grid creation so that modifying one row does not affect the others.
Solution to Exercise 2
(a) Output:
text
[[0, 5, 0], [0, 5, 0], [0, 5, 0]]
(b) The * operator creates three references to the same inner list [0, 0, 0]. Setting grid[1][1] = 5 modifies that single shared list, so the change appears in all three "rows". All three elements of grid have the same id().
(c) Use a list comprehension to create independent lists:
python
grid = [[0, 0, 0] for _ in range(3)]
grid[1][1] = 5
print(grid) # [[0, 0, 0], [0, 5, 0], [0, 0, 0]]
Each iteration of the comprehension evaluates [0, 0, 0] as a fresh list expression, producing three distinct list objects.
Exercise 3. Consider the following code involving nested aliasing:
```python import copy
original = {"name": "Alice", "scores": [85, 90, 78]} shallow = original.copy() deep = copy.deepcopy(original)
shallow["scores"].append(95) deep["scores"].append(100) shallow["name"] = "Bob"
print(original["name"]) print(original["scores"]) print(shallow["scores"]) print(deep["scores"]) ```
Predict all four lines of output. Explain why original["name"] is not changed to "Bob" even though shallow["name"] was reassigned, but original["scores"] is affected by the append through shallow.
Solution to Exercise 3
Output:
text
Alice
[85, 90, 78, 95]
[85, 90, 78, 95]
[85, 90, 78, 100]
shallow = original.copy() creates a new dictionary whose keys map to the same objects as original. The string "Alice" and the list [85, 90, 78] are shared.
shallow["name"] = "Bob" is a rebinding operation: it changes which object the key "name" maps to in shallow. It does not mutate the string "Alice" (strings are immutable). Since original still maps "name" to the original string, original["name"] remains "Alice".
shallow["scores"].append(95) is a mutation: it modifies the list that both original["scores"] and shallow["scores"] reference. Since both dictionaries share the same list object, the append is visible through both.
deep = copy.deepcopy(original) created a fully independent copy. The list inside deep is a separate object, so deep["scores"].append(100) only affects deep's copy, producing [85, 90, 78, 100] (the original list before the shallow copy's append, plus 100). Note that deepcopy was called before the append(95), so deep["scores"] started as [85, 90, 78].