my_list = [1, 2, 3]
print("List before:", my_list)
my_list.append(4)
print("List after:", my_list)Structures Mutability
Module 2: Data Structures
Review previous sessions here!
Data Structures: Functions and Mutability
In our last class, we went over fundamental data structures in Python: lists, tuples, sets, and dictionaries. Today, we’ll explore a crucial concept: how functions can affect mutable objects.
Remember, some objects in Python are mutable (like lists and dictionaries) while others are immutable (like integers, strings, and tuples). This distinction becomes particularly important when these objects interact with functions.
Review of Methods
First, it is important for us to remember how a method can change a list.
The majority of list methods do not return new list! They instead update the original list in place.
my_list = [1, 2, 3]
print("List before:", my_list)
new_list = my_list.append(4)
print("New list?", new_list)
# None because "append()" does not return a value!
print("List after:", my_list)
# Instead, "append()" is changing the ORIGINAL "my_list"There is a different between applying a method and an operation
Methods change mutable objects in place.
Operations create new objects.
my_list = [1, 2, 3]
print("List before:", my_list)
new_list = my_list.append(4)
print("New list:", new_list) # Method returns None
print("List after:", my_list) # Method modifies the original listmy_list = [1, 2, 3]
print("List before:", my_list)
new_list = my_list + [4]
print("New list:", new_list) # Operation returns a new list
print("List after:", my_list) # Operation does not modify the original listHow Functions Can Change Mutable Objects
So what happens when we use a method inside a function?
def add_element(my_list: list) -> list:
my_list.append("New Element")
return my_list
sample_list = [1, 2, 3]
print("Original list before function:", sample_list)
modified_list = add_element(sample_list)
print("Modified list:", modified_list)
print("Original list after function:", sample_list)You might expect that sample_list remains [1, 2, 3], but if you check its value after calling the function, you’ll find that it has changed. This is because lists are mutable, and the function modifies the list in-place.
What will be the outputs of this cell?
def add_element(my_list: list) -> list:
my_list.append("New Element")
sample_list = [1, 2, 3]
print("Original list before function:", sample_list)
modified_list = add_element(sample_list)
print("Modified list:", modified_list)
print("Original list after function:", sample_list)See the difference: add_element() does not have a return statement.
When we do x = fun(), if the function lacks a return, then x = None.
my_list is still being changed. The function add_element() applies the method append() to my_list.
The same logic applies to sets and dictionaries. And with any other mutable object.
def expand_set(my_set: set, element) -> set:
my_set.add(element)
sample_set = {1, 2, 3}
print("Original set before function:", sample_set)
modified_set = expand_set(sample_set, 4)
print("Modified set:", modified_set)
print("Original set after function:", sample_set)def update_dict(my_dict: dict, key, value) -> dict:
my_dict[key] = value
sample_dict = {"a": 1, "b": 2, "c": 3}
print("Original dict before function:", sample_dict)
modified_dict = update_dict(sample_dict, "d", 4)
print("Modified dict:", modified_dict)
print("Original dict after function:", sample_dict)Protecting Mutable Objects
If you don’t want a function to modify a mutable object, you need to copy that object inside the function.
For lists, you can use the copy method:
def add_element(my_list: list) -> list:
# We perform a copy to avoid modifying the original input
copied_list = my_list.copy()
copied_list.append("New Element")
return copied_list
sample_list = [1, 2, 3]
print("Original list before function:", sample_list)
modified_list = add_element(sample_list)
print("Modified list:", modified_list)
print("Original list after function:", sample_list)What will be the outputs of this cell?
def add_element(my_list: list) -> list:
# We perform a copy to avoid modifying the original input
copied_list = my_list.copy()
copied_list.append("New Element")
sample_list = [1, 2, 3]
print("Original list before function:", sample_list)
modified_list = add_element(sample_list)
print("Modified list:", modified_list)
print("Original list after function:", sample_list)Mutable Objects as Default Parameters: Problem
When a function defines a mutable object as a default parameter, the object is created once and shared across all subsequent calls to the function that don’t specify a different object for that parameter. This can lead to unexpected behaviors, as changes to the mutable object persist across function calls.
Consider the following function which appends a value to a list:
def add_to_list(value, default_list: list=[]) -> list:
default_list.append(value)
return default_listNow, let’s see how this behaves:
# First call
print(add_to_list(1)) # Expected [1], Actual [1]
# Second call
print(add_to_list(2)) # Expected [2], Actual [1, 2]
# Third call
print(add_to_list(3, default_list=[])) # Expected [3], Actual [3]
# Fourth call
print(add_to_list(4)) # Expected [4], Actual [1, 2, 4]As seen in the example above, the default_list keeps accumulating values across function calls. The default list created during the function’s definition gets modified with every function call that doesn’t specify a separate list.
How many different keys will dict_animals have?
def update_dict(key, value, dict_base: dict={}) -> dict:
"""
Update a dictionary with a key-value pair.
If no dictionary is provided, create a new one.
"""
dict_base[key] = value
return dict_base
# First test: Create a dictionary with clotes
dict_clothes = update_dict("shirt", "blue")
dict_clothes = update_dict("pants", "green", dict_clothes)
print("Dictionary with clothes")
print(dict_clothes)
# Second test: Create a dictionary with animals
dict_animals = update_dict("dog", "woof")
dict_animals = update_dict("cat", "meow", dict_animals)
print("Dictionary with animals")
print(dict_animals)How long will be the set st_evens?
def add_even_numbers(n: int, myset: set = {}) -> set:
"""
Adds `n` even numbers higher than any number in the original set.
If no set is provided, creates a new one starting by 0.
"""
if len(myset) == 0:
maxnum = 0
else:
maxnum = max(myset)
for i in range(maxnum, maxnum + n):
myset.add(i * 2)
return myset
# First test: Add two even numbers to an existing set
st_numbers = {1, 2}
st_evens = add_even_numbers(2, st_numbers)
print("Set with 2 even numbers:", st_evens)
# Second test: The function creates a new set with five numbers
st_evens = add_even_numbers(5)
print("Set with 5 even numbers:", st_evens)
# Third test: The function creates a new set with ten numbers
st_evens = add_even_numbers(10)
print("Set with 10 even numbers:", st_evens)Mutable Objects as Default Parameters: Solution
One of the common patterns to prevent this behavior is to use None as the default value and then instantiate the mutable object within the function:
def add_to_list(value, default_list: list=None) -> list:
# If no input list is given, initialize a new one
if default_list is None:
# This code will run when the user provides no list
# Ensures that a new list is created every time
default_list = []
# We then add the value to the list
default_list.append(value)
return default_listWith this revised function, each call to add_to_list without a specific list will use a new, empty list:
print(add_to_list(1)) # [1]
print(add_to_list(2)) # [2]
print(add_to_list(3)) # [3]
print(add_to_list(4)) # [4]Fix the function update_dict().