[Updated: October 25, 2021]
The third article of the Python vs C++ Series is about immutability – an object cannot be modified after it is created.
(Note that the Python code in the series assumes Python 3.7 or newer)
Const and Constexpr in C++
C++ supports two notions of immutability: const and constexpr. To declare an object is immutable, we use either const or constexpr when defining an object. Of course, there are more details than this when we consider the immutability in C++, but in general, all objects are changeable by default.
void main()
{
int nonConstVariable = 0; // Non-const object
const int constVariable = 0; // Const object
constexpr int secondsPerHour = 60 * 60; // Const expression
}
This article uses the word mutable in general terms, so don’t be confused with the C++ mutable keyword, which allows a class member to be changeable even if its class instance is const or allows a class member modified by a const method.
class MyClass
{
public:
int variable1 = 0;
mutable int variable2 = 0;
};
void main()
{
const MyClass myClass; // Const object
myClass.variable2 = 10; // Ok because variable2 is mutable
myClass.variable1 = 10; // Error; myClass object is const
}
Mutability and Immutability in Python
Unlike C++, where every object is mutable by default, Python objects’ immutability is determined by their type. The list below summarises some common mutable and immutable data types.
- Immutable types include numeric types (e.g., int and float), string, tuple, and frozenset.
- Mutable types include list, dictionary, set, and custom classes.
(See Built-in Types for more detail)
What does immutable mean in Python?
When we are new to Python, we might think everything is mutable in Python because we can update whatever objects we want. For example, the following code will work without an issue.
variable = 10
variable = "string"
variable = 2.0
variable = [1, 2, 3]
However, the meaning of updating objects is different between immutable objects and mutable objects. When assigning an object to a variable, we can think that the variable is a named pointer pointing to the object (we will talk more about this in the Copy Assignment section). If the object is immutable when we update the variable, we actually point it to another object, and Python’s garbage collection will recycle the original object if it is no longer used. On the contrary, if a variable points to a mutable object, the mutable object will be modified when we update the variable.
We can use a built-in function id to verify if an object we updated is still the same object. The id function returns the identity (the object’s address in memory) of an object. The following example shows how immutability behaves in Python and how we use the id function to check objects’ identities. Also, we use the hex function to convert the id output to hexadecimal format, so it looks more like a memory address.
# int is immutable type
integer = 10
print(f"integer: {integer}; address: {hex(id(integer))}")
# integer: 10; address: 0x7f7a7b35fa50
integer = 20
print(f"integer: {integer}; address: {hex(id(integer))}")
# integer: 20; address: 0x7f7a7b35fb90
# str is immutable type
string = "hello"
print(f"string: {string}; address: {hex(id(string))}")
# string: hello; address: 0x7f7a7b205370
string = "world"
print(f"string: {string}; address: {hex(id(string))}")
# string: world; address: 0x7f7a7b205470
# list is mutable type
list_var = [1, 2, 3]
print(f"list_var: {list_var}; address: {hex(id(list_var))}")
# list_var: [1, 2, 3]; address: 0x7f7a7b259840
list_var.append(4)
print(f"list_var: {list_var}; address: {hex(id(list_var))}")
# list_var: [1, 2, 3, 4]; address: 0x7f7a7b259840
# dictionary is mutable type
dict_var = {"key1": "value1"}
print(f"dict_var: {dict_var}; address: {hex(id(dict_var))}")
# dict_var: {'key1': 'value1'}; address: 0x7f7a7b2cf500
dict_var["key2"] = "value2"
print(f"dict_var: {dict_var}; address: {hex(id(dict_var))}")
# dict_var: {'key1': 'value1', 'key2': 'value2'}; address: 0x7f7a7b2cf500
We can see that the address of variables integer and string is different before and after updating them, which means these two variables point to new objects (20 and world respectively) after updating. On the contrary, list_var and dict_var are mutable, so their addresses remain the same before and after updating them. Therefore, they still point to the same objects.
Why knowing Python objects’ mutability and immutability is essential?
That’s because if we are blind to Python objects’ immutability, we may be surprised by their behavior, and sometimes working with mutable objects without care leads to bugs. The following subsections will discuss a few scenarios that may not be intuitive for people from C++ backgrounds.
Use Mutable Value as Default Function Parameters
Python’s default argument feature allows us to provide default values for function arguments when defining a function. However, if our default values are mutable type, their behavior may not be desired. See an example below.
# Use an empty list as the default value.
def my_function_1(parameter: List = []) -> None:
parameter.append(10)
print(parameter)
In this example, we define a function (my_function_1) and use an empty list as the default value. And then we try to call this function without providing an parameter several times.
my_function_1()
# [10]
my_function_1()
# [10, 10]
my_function_1()
# [10, 10, 10]
If we run the code, we will notice that the parameter keeps the value (i.e., 10) we append every time. Therefore, in the third time, the output becomes [10, 10, 10]. The behavior is actually similar to that we define a static variable in a function in C++ – the static variable is initialized only one time and holds the value even through function calls.
How to avoid this situation?
If using mutable type, such as list, is necessary (which is common), we should use None as the default value and Optional for type checking (Using Optional tells type checkers the value could be either None or the desired type). This way guarantees the parameter is new whenever we call the function without providing a parameter. See an example below.
# Use None as the default value. And use Optional for type checking
def my_function_2(parameter: Optional[List] = None) -> None:
if parameter:
parameter.append(10)
else:
parameter = [10]
print(parameter)
This time, if we call the function (my_function_2) without providing a parameter several times, the parameter will not be hold the value from the previous call.
my_function_2()
# [10]
my_function_2()
# [10]
In short, do not use mutable type as the default value of a function parameter. If the parameter needs to be mutable type, use None as the default value.
The Behavior of Class Variable
The second scenario happens when we use a mutable variable as a class variable. A class variable in Python is shared by all instances. The scenario is similar to the C++ class variable (i.e., declare a class variable with static). To define a class variable in Python, define a variable inside the class but outside any method. In the example below, we define a class (MyClass) with a mutable variable (mutable_member), an immutable variable (immutable_member), and a couple of instance variables.
from typing import List
class MyClass:
# Mutable class variable. Shared by all instances
mutable_member: List = []
# Immutable class variable.
# Shared by all instances unless an instance binds
# this variable to something else.
immutable_member: int = 0
def __init__(self) -> None:
# Instance variables are unique to each instance.
self.immutable_instance_variable: int = 0
self.mutable_instance_variable: List = []
Since class variable is shared by all instances, if the variable is mutable type, the change of the variable will affect all instances of the class. Before we update the class variables, let’s check their value and memory address.
print(f"MyClass.mutable_member: {MyClass.mutable_member}")
# MyClass.mutable_member: []
print(f"MyClass.mutable_member address: {hex(id(MyClass.mutable_member))}")
# MyClass.mutable_member address: 0x7f0f7092fe40
print(f"MyClass.immutable_member address: {hex(id(MyClass.immutable_member))}")
# MyClass.immutable_member address: 0x7f0f70b34910
class1 = MyClass()
print(f"class1.mutable_member: {class1.mutable_member}")
# class1.mutable_member: []
print(f"class1.mutable_member address: {hex(id(class1.mutable_member))}")
# class1.mutable_member address: 0x7f0f7092fe40
print(f"class1.immutable_member address: {hex(id(class1.immutable_member))}")
# class1.immutable_member address: 0x7f0f70b34910
class2 = MyClass()
print(f"class2.mutable_member: {class2.mutable_member}")
# class2.mutable_member: []
print(f"class2.mutable_member address: {hex(id(class2.mutable_member))}")
# class2.mutable_member address: 0x7f0f7092fe40
print(f"class2.immutable_member address: {hex(id(class2.immutable_member))}")
# class2.immutable_member address: 0x7f0f70b34910
Here, we can see both mutable_member and immutable_member of class1 and class2 point to the same objects, which are the same as MyClass.
Now, let’s update the mutable class variable, and print out their values.
# Update the mutable class variable
class1.mutable_member.append(10)
print(f"class1.mutable_member: {class1.mutable_member}")
# class1.mutable_member: [10]
print(f"class2.mutable_member: {class2.mutable_member}")
# class2.mutable_member: [10]
The update affects all instances of MyClass.
How about the immutable class variable?
The behavior is a little bit different when we update an immutable class variable. Now, let’s update the immutable_member from class1, and print out the address of both instances class1 and class2.
# Update the immutable class variable
class1.immutable_member = 20
print(f"class1.immutable_member: {class1.immutable_member}")
# class1.immutable_member: 20
print(f"class1.immutable_member address: {hex(id(class1.immutable_member))}")
# class1.immutable_member address: 0x7f0f70b34b90
print(f"class2.immutable_member: {class2.immutable_member}")
# class2.immutable_member: 0
print(f"class2.immutable_member address: {hex(id(class2.immutable_member))}")
# class2.immutable_member address: 0x7f0f70b34910
The output shows that class1.immutable_member no longer binds to the MyClass.immutable_member.
If we create a new MyClass instance class3, its class variables are still binds to MyClass’ class variables.
class3 = MyClass()
print(f"class3.immutable_member: {class3.immutable_member}")
# class3.immutable_member: 0
print(f"class3.immutable_member address: {hex(id(class3.immutable_member))}")
# class3.immutable_member address: 0x7f0f70b34910
print(f"MyClass.immutable_member address: {hex(id(MyClass.immutable_member))}")
# MyClass.immutable_member address: 0x7f0f70b34910
Hence, class variables are shared by all instances. For a mutable class variable, if we modify it, the change will affect all instances, whereas, for an immutable class variable, if we change it from a class instance, the variable of this instance no longer binds to the original class variable.
Also, it is worth mentioning that instance variables are unique to each class instance regardless its mutability.
# Instance variables are unique to each instance
class1.mutable_instance_variable.append(30)
print(f"class1.mutable_instance_variable: {class1.mutable_instance_variable}")
# class1.mutable_instance_variable: [30]
print(
f"class1.mutable_instance_variable address: "
f"{hex(id(class1.mutable_instance_variable))}"
)
# class1.mutable_instance_variable address: 0x7f0f709e6140
print(f"class2.mutable_instance_variable: {class2.mutable_instance_variable}")
# class2.mutable_instance_variable: []
print(
f"class2.mutable_instance_variable address: "
f"{hex(id(class2.mutable_instance_variable))}"
)
# class2.mutable_instance_variable address: 0x7f0f709e6180
Copy Assignment
Using the assignment operator (e.g., x = 10) in Python does not create copies of objects. Instead, it establishes a binding between the variables and the objects. The behavior is not a problem when working with immutable objects. The assignment operator will create a new binding between the variable and the new target for immutable objects. We can verify it using the is operator to check if two objects are the same object (we can also use the id function as we did in the previous examples). The following example shows the assignment operation behavior on immutable objects and the usage of the is operator.
# Copy an immutable object
my_string = "hello"
my_string_copy = my_string
# They both bind to the same object.
print(my_string is my_string_copy)
# True
# After we update one variable, they bind to two different objects.
my_string_copy += " world"
print(my_string is my_string_copy)
# False
# Of course, their values are different.
print(f"my_string: {my_string}")
# my_string: hello
print(f"my_string_copy: {my_string_copy}")
# my_string_copy: hello world
Copy Module and Shallow Copy
We know copy assignment only creates a binding between the object and the target, so how do we create a copy of an object? Python provides a copy module that offers shallow and deep copy operations. The following example uses copy.copy function to create a copy (value_copy) from a mutable object (value).
import copy
# Define a mutable object.
value = [1, 2, 3]
print(hex(id(value)))
# 0x7fc0df486380
# Copy assignment just creates binding.
value_bind = value
print(hex(id(value_bind)))
# 0x7fc0df486380
# Use copy.copy function to perform shallow copy.
value_copy = copy.copy(value)
print(hex(id(value_copy)))
# 0x7fc0df4af740
# Update the copied variable.
value_copy.append(4)
print(value_copy)
# [1, 2, 3, 4]
# The update does not affect the original variable.
print(value)
# [1, 2, 3]
From this example, the value_copy variable is independent of the value variable. Also, worth mentioning that the copy.copy function still creates a binding between the object and the target for an immutable object.
Shallow copy on mutable compound objects or immutable compound objects contains mutable compound objects
Shallow copy happens in C++ when coping with a compound object (e.g., a class) containing pointers; the copy copies the pointers but not the objects the pointers point to (See the diagram below).
Therefore, the object that the pointers of Object A and Object B point to become shared.
Since the copy.copy function in Python performs shallow copy, the situation mentioned above happens when copying a compound object with mutable compound objects, whether the top-level compound object is mutable or immutable. The following example demonstrates the shallow copy scenario.
import copy
# Create an object with an mutable object,
# and print out its memory address.
compound_object = {"key1": 123, "key2": [1, 2, 3]}
print(hex(id(compound_object)))
# 0x7fbd2b61d480
# Use copy.copy to create a copy of the compound_object.
# and print out its memory address.
compound_object_copy = copy.copy(compound_object)
print(hex(id(compound_object_copy)))
# 0x7fbd2b61d540
# The address shows compound_object_copy is a different object
# from compound_object.
# However, if we print out the address of the key2 value
# from both compound_object and compound_object_copy,
# they are the same object.
print(hex(id(compound_object["key2"])))
# 0x7fbd2b5561c0
print(hex(id(compound_object_copy["key2"])))
# 0x7fbd2b5561c0
# Since key2 is shared, if we update it, the change will
# affect both compound_object and compound_object_copy.
compound_object_copy["key2"].append(4)
print(compound_object_copy)
# {'key1': 123, 'key2': [1, 2, 3, 4]}
print(compound_object)
# {'key1': 123, 'key2': [1, 2, 3, 4]}
Use copy.deepcopy function to perform a deep copy
If we want to perform deep copy, we should use copy.deepcopy instead. See the following example.
import copy
# Create an object with an mutable object,
# and print out its memory address.
compound_object = {"key1": 123, "key2": [1, 2, 3]}
print(hex(id(compound_object)))
# 0x7fbba9d11480
# Use copy.deepcopy to create a copy of the compound_object.
# and print out its memory address.
compound_object_copy = copy.deepcopy(compound_object)
print(hex(id(compound_object_copy)))
# 0x7fbba9c3b600
# Also print out the address of the key2 value from both
# compound_object and compound_object_copy, and they are
# different objects.
print(hex(id(compound_object["key2"])))
# 0x7fbba9c4a180
print(hex(id(compound_object_copy["key2"])))
# 0x7fbba9c6d9c0
# Therefore, if we update the key2 value from compound_object_copy,
# it does not affect compound_object.
compound_object_copy["key2"].append(4)
print(compound_object_copy)
# {'key1': 123, 'key2': [1, 2, 3, 4]}
print(compound_object)
# {'key1': 123, 'key2': [1, 2, 3]}
Conclusion
Python objects’ immutability is defined by their types. Knowing which data types are mutable, which are not and the behavior when using mutable objects in certain situations is critical to avoid writing bug code.
(All the example code is also available at mutable_immutable_and_copy_assignment)
1 thought on “Python vs C++ Series: Mutable, Immutable, and Copy Assignment”