Sign in to your Python Morsels account to save your screencast settings.
Don't have an account yet? Sign up here.
Let's talk about the problem with mutable default argument values in Python.
Function arguments in Python can have default values.
For example this greet
function's name
argument has a default value:
>>> def greet(name="World"):
... print(f"Hello, {name}!")
...
When we call this function without any arguments, the default value will be used:
>>> greet()
Hello, World!
>>>
Default values are great, but they have one gotcha that Python developers sometimes overlook.
Let's use a default value to pick a random color from a list of colors by using random.choice
:
>>> import random
>>> def pick_color(color=random.choice(['red', 'blue', 'green'])):
... return color
When we call this function without any arguments, we'll get a random color:
>>> pick_color()
'green'
But if we call it again without arguments, we'll get the same color:
>>> pick_color()
'green'
That's not a coincidence. Anytime we call this function without arguments, we'll always get the same color.
>>> pick_color()
'green'
>>> pick_color()
'green'
>>> pick_color()
'green'
Why?
Well, default argument values are only evaluated one time: when the function is defined.
Default values are only evaluated when a function is defined.
import random
def pick_color(color=random.choice(['red', 'blue', 'green'])):
return color
In fact, we can even see the default argument values that are attached to a function by looking at certain dunder attributes on that function object:
>>> pick_color.__defaults__
('green',)
Default values are evaluated when a function is defined, not when it's called.
So we should be careful when defining our default argument value, that we don't call functions that could return different values depending on when they're called.
Using a mutable object for a default function argument can also be a problem.
For example, here we have a class called TodoList
:
class TodoList:
def __init__(self, tasks=[]):
self.tasks = tasks
def add_task(self, task):
self.tasks.append(task)
This class's __init__
method will have just one value for our tasks
list that will be shared between all calls to this method.
So if we make two instances of this class:
>>> mon = TodoList()
>>> tue = TodoList()
And then we mutate the value of the tasks
list on one of these two instances:
>>> mon.add_task("Work on Python exercise")
We'll see that the tasks
list changes on both of the instances!
>>> mon.tasks
['Work on Python exercise']
>>> tue.tasks
['Work on Python exercise']
But default arguments aren't the only argument-related issue in Python.
For example, if we pass the same list to two different TodoList
objects:
>>> initial_tasks = ['Watch Python screencast', 'brush teeth']
>>> mon = TodoList(initial_tasks)
>>> tue = TodoList(initial_tasks)
And we modify the tasks
list on one of those objects:
>>> mon.add_task("Work on Python exercise")
We'll see that it will also modify on the other object:
>>> mon.tasks
['Watch Python screencast', 'brush teeth', 'Work on Python exercise']
>>> tue.tasks
['Watch Python screencast', 'brush teeth', 'Work on Python exercise']
The problem is that we're taking whatever list was passed into our class and we're storing it directly on our class instance.
We could avoid this issue by using the list copy
method:
class TodoList:
def __init__(self, tasks=[]):
self.tasks = tasks.copy()
def add_task(self, task):
self.tasks.append(task)
But if we use the built-in list
function instead, we could make a copy of our list and accept any iterable, not just lists:
class TodoList:
def __init__(self, tasks=[]):
self.tasks = list(tasks)
def add_task(self, task):
self.tasks.append(task)
Because the list
function will accept any iterable in Python, whereas the copy
method only exists on list objects.
Note that this also happens to resolve our problem with default argument values.
Two TodoList
objects without any initial tasks will always end up with separate tasks
lists:
>>> mon = TodoList()
>>> tue = TodoList()
>>> mon.add_task("Work on Python exercise")
>>> mon.tasks
['Work on Python exercise']
>>> tue.tasks
[]
So one way to avoid issues with mutable default arguments is to copy the argument that's coming into our function. This is something we should especially consider for class initializers.
Another very common way to avoid issues with default argument values is to avoid using mutable default values.
For example, it's pretty common to see None
used as a default value.
Here, whenever our class initializer doesn't have a tasks
iterable specified, it'll use an empty list:
class TodoList:
def __init__(self, tasks=None):
if tasks is None:
tasks = []
else:
self.tasks = list(tasks)
def add_task(self, task):
self.tasks.append(task)
In our case, though, there's an even simpler way we could have avoided mutable default values. We could have used a tuple:
class TodoList:
def __init__(self, tasks=()):
self.tasks = list(tasks)
def add_task(self, task):
self.tasks.append(task)
Lists are mutable sequences, and tuples are immutable sequences, and both are iterables, so the list
function will accept either one just fine.
In Python, default argument values are defined when a function is defined. So every time you call a function, it'll use the same default argument value.
You should be very cautious when using default argument values that aren't simple immutable objects.
Need to fill-in gaps in your Python skills?
Sign up for my Python newsletter where I share one of my favorite Python tips every week.
Python, like many programming languages, has functions. A function is a block of code you can call to run that code.
Python's functions have a lot of "wait I didn't know that" features. Functions can define default argument values, functions can be called with keyword arguments, and functions can be written to accept any number of arguments.
To track your progress on this Python Morsels topic trail, sign in or sign up.
Sign in to your Python Morsels account to track your progress.
Don't have an account yet? Sign up here.