Image by author
Python is a versatile, beginner-friendly programming language known for its simplicity and readability. Its elegant syntax, however, is not immune to quirks that may surprise even experienced Python developers. And understanding them is essential to writing error-free code or, so to speak, debugging it without complications.
This tutorial explores some of these mistakes: mutable defaults, variable scope in loops and comprehensions, tuple assignment, and more. We will code simple examples to see because things work the way they do, and we also look as we can avoid them (if we really can ).
Then let's get started!
In Python, mutable defaults are common sharp corners. You will encounter unexpected behavior whenever you define a function with mutable objects, such as lists or dictionaries, as default arguments.
The default value is evaluated only once, when the function is defined, and not each time the function is called.. This can cause unexpected behavior if you modify the default argument within the function.
Let's take an example:
def add_to_cart(item, cart=()):
cart.append(item)
return cart
In this example, add_to_cart
is a function that takes an element and adds it to a list cart
. The default value of cart
is an empty list. That is, calling the function without an item to add returns an empty cart.
And here are a couple of function calls:
# User 1 adds items to their cart
user1_cart = add_to_cart("Apple")
print("User 1 Cart:", user1_cart)
This works as expected. But what happens now?
# User 2 adds items to their cart
user2_cart = add_to_cart("Cookies")
print("User 2 Cart:", user2_cart)
Output >>>
('Apple', 'Cookies') # User 2 never added apples to their cart!
Because the default argument is a list (a mutable object), it retains its state between function calls. So every time you call add_to_cart
, adds the value to the same list object created during the function definition. In this example, it is as if all users share the same cart.
How to avoid
As a workaround, you can configure cart
to None
and initialize the cart inside the function like this:
def add_to_cart(item, cart=None):
if cart is None:
cart = ()
cart.append(item)
return cart
So each user now has a separate cart.
If you need a refresher on Python functions and function arguments, read Python Function Arguments: An Ultimate Guide.
The oddities of Python's scope require a tutorial of their own. But we'll look at one of those oddities here.
Look at the following fragment:
x = 10
squares = ()
for x in range(5):
squares.append(x ** 2)
print("Squares list:", squares)
# x is accessible here and is the last value of the looping var
print("x after for loop:", x)
The variable x
is set to 10. But x
It is also the loop variable. But we would assume that the scope of the loop variable is limited to the for loop block, right?
Let's see the result:
Output >>>
Squares list: (0, 1, 4, 9, 16)
x after for loop: 4
We see that x
now it is 4, the final value it takes in the loop, and not the initial value of 10 that we assigned to it.
Now let's see what happens if we replace the for loop with a comprehension expression:
x = 10
squares = (x ** 2 for x in range(5))
print("Squares list:", squares)
# x is 10 here
print("x after list comprehension:", x)
Here, x
is 10, the value we assigned before the comprehension expression:
Output >>>
Squares list: (0, 1, 4, 9, 16)
x after list comprehension: 10
How to avoid
To avoid unexpected behavior: If you use loops, be sure not to name the loop variable the same as another variable you want to access later.
In Python we use the is
keyword to check the identity of the object. Which means it checks if two variables refer to the same object in memory. And to check equality, we use the ==
operator. Yeah?
Now, start a Python REPL and run the following code:
>>> a = 7
>>> b = 7
>>> a == 7
True
>>> a is b
True
Now run this:
Wait, why is this happening? Well, this is due to “integer caching” or “internation” in CPython, the standard Python implementation.
CPython caches entire objects in the range of -5 to 256. That is, every time you use an integer within this range, Python will use the same object in memory. Therefore, when you compare two integers within this range using the is
keyword, the result is True
Because they refer to the same object in memory.
That's why a is b
returns True
. You can also check this by printing id(a)
and id(b)
.
However, integers outside this range are not cached. And each occurrence of said integers creates a new object in memory.
So when you compare two integers outside the cached range using the is
keyword (yes, x
and y
both set to 280 in our example), the result is False
because in fact they are two different objects in memory.
How to avoid
This behavior should not be a problem unless you try to use the is
to compare the equality of two objects. So always use the ==
operator to check if two Python objects have the same value.
If you are familiar with the built-in data structures in Python, you will know that tuples are immutable. You too I can't modify them instead. On the other hand, data structures such as lists and dictionaries are mutable. That is, you can change them instead.
But what about tuples that contain one or more mutable objects?
It's helpful to start a Python REPL and run this simple example:
>>> my_tuple = ((1,2),3,4)
>>> my_tuple(0).append(3)
>>> my_tuple
((1, 2, 3), 3, 4)
Here, the first element of the tuple is a list with two elements. We tried adding 3 to the first list and it works fine! Well, did we just modify a tuple instead?
Now let's try adding two more elements to the list, but this time using the += operator:
>>> my_tuple(0) += (4,5)
Traceback (most recent call last):
File "", line 1, in
TypeError: 'tuple' object does not support item assignment
Yes, you get a TypeError saying that the tuple object does not support element assignment. Which is expected. But let's check the tuple:
>>> my_tuple
((1, 2, 3, 4, 5), 3, 4)
We see that items 4 and 5 have been added to the list! Did the program just throw an error and succeed at the same time?
Well, the += operator works internally by calling __iadd__()
method that performs in-place addition and modifies the list in-place. The assignment raises a TypeError exception, but adding elements to the end of the list has already succeeded. += is perhaps the sharpest corner!
How to avoid
To avoid these kinds of quirks in your program, try using tuples only for immutable collections. And avoid using mutable objects as tuple elements as much as possible.
Mutability has been a recurring theme in our discussion so far. So here's another one to conclude this tutorial.
Sometimes you may need to create separate copies of lists. But what happens when you create a copy using syntax similar to list2 = list1
where list1
What is the original list?
It is a superficial copy that is created. So it only copies the references to the original list elements. Modifying elements through shallow copy will affect both the original list and the superficial copy.
Let's take this example:
original_list = ((1, 2, 3), (4, 5, 6), (7, 8, 9))
# Shallow copy of the original list
shallow_copy = original_list
# Modify the shallow copy
shallow_copy(0)(0) = 100
# Print both the lists
print("Original List:", original_list)
print("Shallow Copy:", shallow_copy)
We see that changes to the shallow copy also affect the original list:
Output >>>
Original List: ((100, 2, 3), (4, 5, 6), (7, 8, 9))
Shallow Copy: ((100, 2, 3), (4, 5, 6), (7, 8, 9))
Here we modify the first element of the first nested list in the shallow copy: shallow_copy(0)(0) = 100
. But we see that the modification affects both the original list and the shallow copy.
How to avoid
To avoid this, you can create a deep copy like this:
import copy
original_list = ((1, 2, 3), (4, 5, 6), (7, 8, 9))
# Deep copy of the original list
deep_copy = copy.deepcopy(original_list)
# Modify an element of the deep copy
deep_copy(0)(0) = 100
# Print both lists
print("Original List:", original_list)
print("Deep Copy:", deep_copy)
Now, any modification to the deep copy leaves the original list unchanged.
Output >>>
Original List: ((1, 2, 3), (4, 5, 6), (7, 8, 9))
Deep Copy: ((100, 2, 3), (4, 5, 6), (7, 8, 9))
And that's a wrap! In this tutorial, we've explored various oddities in Python: from the surprising behavior of mutable defaults to the subtleties of shallow list copying. This is just an introduction to Python's oddities and is by no means an exhaustive list. You can find all the code examples. on GitHub.
As you continue coding longer in Python and understand the language better, you may come across many more of these. So, keep coding, keep exploring!
Oh, and let us know in the comments if you want to read a sequel to this tutorial.
twitter.com/balawc27″ rel=”noopener”>Bala Priya C. is a developer and technical writer from India. He enjoys working at the intersection of mathematics, programming, data science, and content creation. His areas of interest and expertise include DevOps, data science, and natural language processing. He likes to read, write, code and drink coffee! Currently, he is working to learn and share his knowledge with the developer community by creating tutorials, how-to guides, opinion pieces, and more. Bala also creates engaging resource descriptions and coding tutorials.
<script async src="//platform.twitter.com/widgets.js” charset=”utf-8″>