Python - mutability, immutability

What is mutation?

What is mutation? Is it just changing value? No. It is changing value, while retaining the same address in memory.

In [1]:
a = 10
print(hex(id(a)), hex(id(10)))
a = 20
print(hex(id(a)), hex(id(20)))
0x7fd7d8521a50 0x7fd7d8521a50
0x7fd7d8521b90 0x7fd7d8521b90

You see from above, when you reassign a value, Python first creates that object in memory, then assigns a reference to that object in memory to the variable. The original address was not updated, instead a new address was assigned. This is because integers are immutable.

In [3]:
a = [1,2,3]
print(hex(id(a)))
a.append(4)
print(hex(id(a)))
print(a)
0x7fd7dcd88cc0
0x7fd7dcd88cc0
[1, 2, 3, 4]

Here, the address remains the same, while the content was updated. This is mutation as lists are mutables. A new address space was not assigned. Instead, the contents in the original was updated.

In [5]:
a.append(10)
for e in a:
    print(f'{e} @ {hex(id(e))}')
1 @ 0x7fd7d8521930
2 @ 0x7fd7d8521950
3 @ 0x7fd7d8521970
4 @ 0x7fd7d8521990
10 @ 0x7fd7d8521a50

Each element in the list is a different object at a different address. When we added 10, the same address as before was assigned (note from earlier cell).

Mutable and immutable data types

You can define your classes to be either mutable or immutable.

Extending the concept from above, what happens when you assign the same value to two variables?

In [30]:
c = 20
d = 20
print(id(c) == id(d))
print(hex(id(c)), hex(id(d)))
True
0x7fd7d8521b90 0x7fd7d8521b90
In [31]:
print(c == d)
print(c is d)
True
True

Above, Python used the same address for both the objects, which is equivalent to writing c = d = 20. This is safe since integers are immutable.

In [12]:
e1 = f1 = [20,30,40]  # same address
e2 = ['a', 'b']  # different address
f2 = ['a', 'b']
print(id(e1) == id(f1))
print(hex(id(e1)), hex(id(f1)))
print(id(e2) == id(f2))
print(hex(id(e2)), hex(id(f2)))
True
0x7fd7dcd8e4c0 0x7fd7dcd8e4c0
False
0x7fd7dcd8a680 0x7fd7dcd8e980

In the code above, when you use var = var2 = value, then the same address is assigned to both variables. However, in the next pattern, even though the values are identical, Python assigned different addresses since lists are mutable and it makes sense to instantiate them separately in memory.

In [13]:
print(hex(id(e2[0])), hex(id(f2[0])))  # should be the same:
0x7fd7da0b0330 0x7fd7da0b0330

Even thought Python assigned two different lists in memory, the contents of the lists still point to the same string objects in memory as strings are immutable.

Immutable collections containing mutable elements

What happens when a tuple's elements are lists, can you edit the elements? How is mutability considered then?

In [14]:
l1 = [1,2,3]
l2 = ['a','b','c']
t1 = (l1, l2)

print(hex(id(t1)))  # print address

t1[0].append(4)     # is this a mutation? Not actually
print(hex(id(t1)))  # print address again and compare
0x7fd7dcba8cc0
0x7fd7dcba8cc0

The tuple is immutable, however, its elements can change because, the tuple's reference to the list remains the same. The list's references got changed, but that does not break the holding tuple's immutability!

Operations on mutable objects

Not all operations on mutable objects are mutable. See below:

In [20]:
l1 = [1,2,3]
print(hex(id(l1)))

l1.append(4)
print(hex(id(l1)))  # should be same as above

l1 = l1 + [5]
print(l1)
print(hex(id(l1)))  # will be different than from an append operation
0x7fd7dcd8b540
0x7fd7dcd8b540
[1, 2, 3, 4, 5]
0x7fd7dcd8b940

What happened here? When you run l1 = l1 + [val], it evaluates the expression and assigns a new memory for the result, eventhough you ask it to update the same object.

Function arguments and mutability

What happens when a function changes or modifies one of its parameters' value? Will it change it outside the function as well since python is pass by reference?

In [21]:
def modify_str(s):
    print(f'Incoming s @ : {hex(id(s))}')
    s = s + " world"
    print(f'Post OP  s @ : {hex(id(s))}')
In [22]:
s = 'hello'
print(hex(id(s)))
modify_str(s)
print(s)
print(hex(id(s)))
0x7fd7dc7000b0
Incoming s @ : 0x7fd7dc7000b0
Post OP  s @ : 0x7fd7dcd826f0
hello
0x7fd7dc7000b0

The address of s inside the func is initially the same. But when an operation was ran, since strings are immutable, a new object was created. However, outside the scope of the function, the value of s remains the same and at the same address.

In [29]:
def modify_lst(l):
    print(f'Incoming list @ : {hex(id(l))}')
    l.append(200)
    print(f'PostOp   list @ : {hex(id(l))}')

l1 = [1,2,3]
print(f'Outside list @ : {hex(id(l1))}')
modify_lst(l1)
print(l1)
print(f'Outside list @ : {hex(id(l1))}')
Outside list @ : 0x7fd7de1a7f00
Incoming list @ : 0x7fd7de1a7f00
PostOp   list @ : 0x7fd7de1a7f00
[1, 2, 3, 200]
Outside list @ : 0x7fd7de1a7f00

Thus, functions can modify the value of variables outside their scope as well, since Python is pass by reference.