6.2. Aliasing and Copying#

Hide code cell source

import sys
from pathlib import Path

# Find project root by looking for _config.yml
current = Path.cwd()
for parent in [current, *current.parents]:
    if (parent / '_config.yml').exists():
        project_root = parent
        break
else:
    project_root = Path.cwd().parent.parent

# Add project root to path
sys.path.insert(0, str(project_root))

# Import shared teaching helpers and cell magics
from shared import thinkpython, diagram, jupyturtle, structshape
from shared.download import download

A variable does not hold a list directly; it holds a reference to a list object. This section looks at what that means: when two names refer to the same object, how a change made through one name shows up through the other, and how to make copies that are truly independent.

The examples use lists, but the same rules apply to every mutable collection, including sets, which you will meet in Section 7.4. Tuples, covered in Section 6.3, are immutable, so a change can never show up through an alias of a tuple itself.

The table below summarizes the three cases:

Concept

Outer Object

Inner Objects

Syntax

Changes affect original?

Aliasing

Same

Same

b = a

Yes

Shallow Copy

New

Same

[:], copy(), list()

Sometimes (when nested)

Deep Copy

New

New

copy.deepcopy()

No

6.2.1. Objects and Values#

Consider these assignment statements:

a = 'banana'
b = 'banana'

Both a and b refer to a string, but it is not obvious whether they refer to the same string. There are two possible states, shown in the following figure.

../../_images/e31f8c12938d5bd9cb50c40355d96576d2545baafcb124c02f563cee71f0022e.png

In the diagram on the left, a and b refer to two different objects that have the same value. In the diagram on the right, they refer to the same object. To check whether two variables refer to the same object, you can use the is operator.

a = 'banana'
b = 'banana'
a is b
True

In this example, Python only created one string object, and both a and b refer to it. Python sometimes reuses one object for identical short strings, an optimization called interning, so a is b happens to be True here. Don’t rely on it: use == to compare values and is only to check whether two names refer to the same object.

But when you create two lists, you get two objects.

a = [1, 2, 3]
b = [1, 2, 3]
a is b
False

So the state diagram looks like this.

../../_images/05255c53a18b2fd51a326930662d9c821ffdfb3ffe77fe6ad009cda2e33e5579.png

In this case, the two lists are equivalent, because they have the same elements, but not identical, because they are not the same object. If two objects are identical, they are also equivalent, but if they are equivalent, they are not necessarily identical. The is operator checks identity, whether two names refer to the same object, while == checks only whether the values are equivalent.

### Exercise: Objects and Values *(basic)*
x = [10, 20, 30]
y = x
z = [10, 20, 30]
# 1. Check if x and y refer to the same object using "is"
# 2. Check if x and z have the same value using "=="
# 3. Check if x and z refer to the same object using "is"
### Your code starts here.



### Your code ends here.

Hide code cell source

### Solution
x = [10, 20, 30]
y = x
z = [10, 20, 30]

same_object_xy = x is y
same_value_xz = x == z
same_object_xz = x is z

print(f"x is y: {same_object_xy}")  # True - same object (alias)
print(f"x == z: {same_value_xz}")   # True - same value (equivalent)
print(f"x is z: {same_object_xz}")  # False - different objects
x is y: True
x == z: True
x is z: False

6.2.2. Aliasing#

Aliasing occurs when two or more variables point to the same object in memory. When you do a variable assignment using = in Python, you’re not copying the object; you’re creating another variable that points to the same object. This second variable is an alias.

If a refers to an object and you assign b = a, then both variables refer to the same object.

a = [1, 2, 3]
b = a
b is a
True

So the state diagram looks like this.

../../_images/82e0247d5e2227abc5acb5443e6e29e2120afe520b3dd7cf4d599ec26934a1b7.png

The association of a variable with an object is called a reference. In this example, there are two references to the same object.

An object with more than one reference has more than one name, so the object is aliased. If the aliased object is mutable, a change made through one name is visible through the other. In this example, changing the list through b also changes what you see through a, because both names refer to the same list.

b[0] = 5
a
[5, 2, 3]

In other words, a “sees” the change. This behavior can be useful, but it is error-prone. In general, avoid aliasing mutable objects unless you intend to share them.

You can confirm that two names share one object with is, or by comparing their id() values, which identify an object while it exists:

# Aliasing: both variables point to the same list
original = [1, 2, 3, 4, 5]
alias = original    # NOT a copy!

alias[0] = 999      # Modify through the alias

print("Original:\t", original)  # check to see if original is modified
print("Alias:\t\t", alias)        

print("Same id?\t", id(original) == id(alias))  # True

print("Same object?\t", original is alias)  # True
Original:	 [999, 2, 3, 4, 5]
Alias:		 [999, 2, 3, 4, 5]
Same id?	 True
Same object?	 True

For immutable objects like strings, aliasing is not as much of a problem. In this example:

c = 'banana'
d = 'banana'

It almost never makes a difference whether c and d refer to the same string or not.

Reassigning a name is different from modifying an object. With immutable strings, reassigning one name creates a new object and never affects the other name:

# Contrast with immutable strings
name1 = "Chen"
name2 = name1                         # alias: both names refer to the same string
print("Same object?", name1 is name2)  # True

name2 = "Alice"                       # reassignment: name2 now refers to a new string
print(name1, name2)                   # name1 is unchanged
print("Same object?", name1 is name2)  # False
Same object? True
Chen Alice
Same object? False

Aliasing is useful for efficiency (no copying needed), but can cause unexpected behavior if you modify mutable objects, thinking you have independent copies.

### Exercise: Aliasing *(applied)*
a = [1, 2, 3]
# 1. Create an alias b that points to the same list as a
# 2. Modify the list through b by changing the first element to 99
# 3. Create a true copy c using slicing
# 4. Modify c and observe that a remains unchanged
### Your code starts here.



### Your code ends here.

Hide code cell source

### Solution
a = [1, 2, 3]
b = a  # alias
b[0] = 99

print(f"a after aliasing: {a}")
print(f"b: {b}")
print(f"Same object? {a is b}")

c = a[:]  # true copy
c[1] = 777

print(f"\na after copying: {a}")
print(f"c: {c}")
print(f"Same object? {a is c}")
a after aliasing: [99, 2, 3]
b: [99, 2, 3]
Same object? True

a after copying: [99, 2, 3]
c: [99, 777, 3]
Same object? False

6.2.3. List Arguments in Functions#

When you pass a list to a function, the function gets a reference to the list. If the function modifies the list, the caller sees the change. For example, pop_first uses the list method pop to remove the first element from a list.

def pop_first(lst):
    return lst.pop(0)

Here is how to use it:

letters = ['a', 'b', 'c']
pop_first(letters)
'a'

The return value is the first element, which has been removed from the list, as displaying the list shows:

letters
['b', 'c']

In this example, the parameter lst and the variable letters are aliases for the same object, so the state diagram looks like this:

../../_images/0763108fd4450a129c6dedd594b7e875eaa324163db38bd34bccabd01d8403e8.png

Passing a reference to an object as an argument to a function creates a form of aliasing. If the function modifies the object, those changes persist after the function is done.

### Exercise: List Arguments in Functions *(applied)*
def add_item(lst, item):
    """Add item to list and return the list"""
    lst.append(item)
    return lst

# 1. Create a list with [1, 2, 3], call it original
# 2. Call add_item with your list and the value 4, 
#    save the result in a variable called updated
# 3. Print the original list to see if it changed after calling the function
# 4. check if original and updated refer to the same object using "is"
### Your code starts here.



### Your code ends here.

Hide code cell source

### Solution
def add_item(lst, item):
    """Add item to list and return the list"""
    lst.append(item)
    return lst

original = [1, 2, 3]
print(f"Original list: {original}")

updated = add_item(original, 4)
print(f"Returned list: {updated}")
print(f"Original list after function call: {original}")
print(f"Same object? {updated is original}")
Original list: [1, 2, 3]
Returned list: [1, 2, 3, 4]
Original list after function call: [1, 2, 3, 4]
Same object? True

6.2.4. Shallow Copy#

You can make a shallow copy of a list in four ways: slicing with [:], the .copy() method, the list() function, or copy.copy() from the copy module.

import copy

a = [1, 2, 3]

b = a[:]          # list slicing
b = a.copy()      # copy() method
b = list(a)       # list() function
b = copy.copy(a)  # copy module

Each of these creates a new list object, but only the top level is copied. If the list contains mutable objects such as nested lists, the copy holds references to the same inner objects rather than copies of them.

For a flat list that holds only immutable values, such as numbers, strings, or Booleans, a shallow copy is all you need: each copy is a new list object, as the is checks below show.

# four ways to create a shallow copy
import copy

letters = ['a', 'b', 'c', 'd']

letters_copy1 = letters[:]          # list slicing
letters_copy2 = letters.copy()      # the copy() method
letters_copy3 = list(letters)       # the list function
letters_copy4 = copy.copy(letters)  # using the copy module

print(letters_copy1)
print(letters_copy2)
print(letters_copy3)
print(letters_copy4)

print("All copies have the same contents?", letters_copy1 == letters_copy2 == letters_copy3 == letters_copy4)  # True, contents are the same
print("letters_copy1 is letters?", letters_copy1 is letters)  # False, different objects in memory
print("letters_copy2 is letters?", letters_copy2 is letters)  # False, different objects in memory
print("letters_copy3 is letters?", letters_copy3 is letters)  # False, different objects in memory
print("letters_copy4 is letters?", letters_copy4 is letters)  # False, different objects in memory
['a', 'b', 'c', 'd']
['a', 'b', 'c', 'd']
['a', 'b', 'c', 'd']
['a', 'b', 'c', 'd']
All copies have the same contents? True
letters_copy1 is letters? False
letters_copy2 is letters? False
letters_copy3 is letters? False
letters_copy4 is letters? False
# a simpler example: changing a shallow copy of a flat list
original = [1, 2, 3, 4, 5]
shallow = original[:]  # Creates a new list

print("Same value?\t", original == shallow)   # True
print("Same object?\t", original is shallow)  # False

# Modify the shallow copy
shallow[4] = 999
print("update shallow:\t", shallow)

print("Original:\t", original)  # Original is unchanged
print("Shallow:\t", shallow)    # Only the copy is modified
Same value?	 True
Same object?	 False
update shallow:	 [1, 2, 3, 4, 999]
Original:	 [1, 2, 3, 4, 5]
Shallow:	 [1, 2, 3, 4, 999]

However, for nested lists (lists containing other lists), shallow copy shares references to the nested objects:

# Using copy.copy() with nested lists
import copy
original = [[1, 2], [3, 4]]
shallow = copy.copy(original)

print("original-shallow same value?\t", original == shallow)   # True - contents are the same
print("original-shallow same object?\t", id(original) == id(shallow))  # False - different list objects

shallow[0].append(99)
print("update shallow (99):\t\t", shallow)
print("is original updated?\t\t", original)  # [[1, 2, 99], [3, 4]] - nested list affected!

print("same nested object?\t\t", shallow[0] is original[0])  # True - same nested list object
original-shallow same value?	 True
original-shallow same object?	 False
update shallow (99):		 [[1, 2, 99], [3, 4]]
is original updated?		 [[1, 2, 99], [3, 4]]
same nested object?		 True

The two outer lists are different objects, but they share the same inner lists, so a change to shallow[0] also shows up in original[0]. The same thing happens when you make a shallow copy with slicing:

# Using slicing with nested lists
original = [[1, 2, 3], [4, 5, 6]]
shallow = original[:]  # or original.copy()

# Modify the nested list
shallow[0][0] = 999

print("Original:", original)  # Original is also modified!
print("Shallow:", shallow)
print("Same nested object?", shallow[0] is original[0])  # True - same nested list object
Original: [[999, 2, 3], [4, 5, 6]]
Shallow: [[999, 2, 3], [4, 5, 6]]
Same nested object? True

As you can see, modifying the nested list in shallow also affects original because they share references to the same inner lists.

6.2.5. Deep Copy#

To make a deep copy, use deepcopy() from the copy module. It copies the outer list and, recursively, every object nested inside it, so the copy is completely independent of the original.

import copy

original = [[1, 2, 3], [4, 5, 6]]
deep = copy.deepcopy(original)

print("Original:", original)  # Original is unchanged
print("Deep copy:", deep)    # Only the deep copy is modified

print("original-deep same value?", original == deep)   # True - contents are the same
print("original-deep same object?", id(original) == id(deep))  # False - different list objects
print("same nested object?", deep[0] is original[0])  # False - different nested list objects

# Modify the nested list
deep[0][0] = 999

print("Original:", original)  # Original is unchanged
print("Deep copy:", deep)    # Only the deep copy is modified
Original: [[1, 2, 3], [4, 5, 6]]
Deep copy: [[1, 2, 3], [4, 5, 6]]
original-deep same value? True
original-deep same object? False
same nested object? False
Original: [[1, 2, 3], [4, 5, 6]]
Deep copy: [[999, 2, 3], [4, 5, 6]]
# simpler example with deep copy
import copy
original = [[1, 2], [3, 4]]
deep = copy.deepcopy(original)

print(original is deep)          # False - different outer lists

deep[0].append(99)
print(original)  # [[1, 2], [3, 4]] - original unchanged!
print(deep)      # [[1, 2, 99], [3, 4]] - only deep copy changed
print(deep[0] is original[0])    # False - different nested list objects
False
[[1, 2], [3, 4]]
[[1, 2, 99], [3, 4]]
False
### Exercise: Shallow vs. Deep Copy *(applied)*
import copy
teams = [['Ann', 'Bo'], ['Cy', 'Di']]
# 1. Make a shallow copy of teams called shallow and a deep copy called deep
# 2. Append 'Ed' to the first team in shallow
# 3. Append 'Fay' to the first team in deep
# 4. Print teams, shallow, and deep.
#    Which change shows up in teams, and why?
### Your code starts here.



### Your code ends here.

Hide code cell source

### Solution
import copy
teams = [['Ann', 'Bo'], ['Cy', 'Di']]

shallow = teams.copy()
deep = copy.deepcopy(teams)

shallow[0].append('Ed')
deep[0].append('Fay')

print(f"teams:   {teams}")
print(f"shallow: {shallow}")
print(f"deep:    {deep}")

# 'Ed' shows up in teams because shallow[0] and teams[0] are the same inner list.
# 'Fay' does not, because deepcopy() copied the inner lists too.
teams:   [['Ann', 'Bo', 'Ed'], ['Cy', 'Di']]
shallow: [['Ann', 'Bo', 'Ed'], ['Cy', 'Di']]
deep:    [['Ann', 'Bo', 'Fay'], ['Cy', 'Di']]

6.2.6. Summary#

  • == compares values; is checks whether two names refer to the same object.

  • Assignment creates an alias, not a copy. When the object is mutable, a change made through one name shows up through the other.

  • Passing a list to a function passes a reference, so the function can modify the caller’s list.

  • A shallow copy (slicing, copy(), list(), or copy.copy()) creates a new outer list but shares any nested objects.

  • copy.deepcopy() also copies nested objects, so the copy is fully independent.

6.2.7. Further Reading#