NB: Aside On Immutables

Programming for Data Science

What Immutable Means

A mutable object is a data structure whose internal values can be changed.

For example, tuples are immutable, lists are not. That is, lists are mutable.

Let’s see how this works in practice.

Here, we mutate a list by appending a value to it.

a = [1,2,3,4,5]
a.append(10)
print(a)
[1, 2, 3, 4, 5, 10]
a[0] = 5
print(a)
[5, 2, 3, 4, 5, 10]

If we try the same things with a tuple, we get an error.

b = (1,2,3,4,5)
b.append(10)
print(b)
AttributeError: 'tuple' object has no attribute 'append'
b[0] = 5
print(b)
TypeError: 'tuple' object does not support item assignment

This, on the other hand, is not mutation:

a = [1,2,3,4,5,10] # A list
b = (1,2,3,4,5,10) # A tuple
print(a)
print(b)
[1, 2, 3, 4, 5, 10]
(1, 2, 3, 4, 5, 10)

We are just re-assigning a new value to the variable.

The new value just replaces the old one.

In mutation, the same data structure remains in place but its contents are changed.

Note, however, that this works with tuples:

b += (11,)
print(b)
(1, 2, 3, 4, 5, 10, 11)

It looks like mutation, but it’s not.

This is because we are replacing b with a new tuple value.

Behavior Differences

Relatedly, mutable and immutable objects behave differently in the context of variable assignment.

For example, when you assign a variable to another variable of a mutable datatype, any changes to the data are reflected by both variables.

The new variable is just an alias for the old variable.

This is only true for mutable datatypes.

Lets explore how + operator behaves differently between mutables and immutables.

First, let’s create a function that will allow us to compare the objects as we modify them.

def compare_objects(trial:int, obj1:str, obj2:str):
    o1 = eval(obj1)
    o2 = eval(obj2)
    print(f"t{trial} {obj1} {o1} {id(o1)}")
    print(f"t{trial} {obj2} {o2} {id(o2)}")
    print(f"{obj1} == {obj2}:", o1 == o2)

List t1

We initialize a list and make a copy of it.

Note that the two variables share the same id.

a0 = [1,2,3,4,5]
a1 = a0 # Make a copy of a list
compare_objects(1, 'a0', 'a1')
t1 a0 [1, 2, 3, 4, 5] 139842806146944
t1 a1 [1, 2, 3, 4, 5] 139842806146944
a0 == a1: True

List t2

Now we add to the copy and note the effects on the original.

The original value is also changed.

This is because both variables point to the same object.

a1 += [12] # Extend the copy
compare_objects(2, 'a0', 'a1')
t2 a0 [1, 2, 3, 4, 5, 12] 139842806146944
t2 a1 [1, 2, 3, 4, 5, 12] 139842806146944
a0 == a1: True

List t3

Note, however, that if we don’t use the unary operator,
then a1 becomes a different object!

Lutz goes into the difference between the += and the + in Ch 11 pages 360-363.

image.png
a1 = a1 + [12] # Extend the copy
compare_objects(3, 'a0', 'a1')
t3 a0 [1, 2, 3, 4, 5, 12] 139842806146944
t3 a1 [1, 2, 3, 4, 5, 12, 12] 139842804350592
a0 == a1: False

List t4

Try it with a new object copy, to avoid any possible inference between t2 and t3.

a2 = a0
a2 = a2 + [12] # Extend the copy
compare_objects(4, 'a0', 'a2')
t4 a0 [1, 2, 3, 4, 5, 12] 139842806146944
t4 a2 [1, 2, 3, 4, 5, 12, 12] 139842804515456
a0 == a2: False

We get the same result.

Tuple t1

Let’s try this with a tuple.

We see again that both variables have the same id.

b0 = (1,2,3,4,5)
b1 = b0 # Make a copy of a tuple
compare_objects(1, 'b0', 'b1')
t1 b0 (1, 2, 3, 4, 5) 139842804681360
t1 b1 (1, 2, 3, 4, 5) 139842804681360
b0 == b1: True

Tuple t2

However, if extend the tuple with the unary operator, b1 becomes a new object.

Note how this differs from the list behavior.

b1 += (12,) # Extend the copy
compare_objects(2, 'b0', 'b1')
t2 b0 (1, 2, 3, 4, 5) 139842804681360
t2 b1 (1, 2, 3, 4, 5, 12) 139842804315424
b0 == b1: False

Tuple t3

If we don’t use the unary operator, the same thing happens again.

The value of b1 becomes a new object because the variable has been reassigned.

b1 = b1 + (12,) # Extend the copy
compare_objects(3, 'b0', 'b1')
t3 b0 (1, 2, 3, 4, 5) 139842804681360
t3 b1 (1, 2, 3, 4, 5, 12, 12) 139842804316096
b0 == b1: False

Let’s look at another example.

Here is a list:

foo = ['hi']
bar = foo
compare_objects(1, 'foo', 'bar')
t1 foo ['hi'] 139842804665216
t1 bar ['hi'] 139842804665216
foo == bar: True
bar += ['bye']
compare_objects(2, 'foo', 'bar')
t2 foo ['hi', 'bye'] 139842804665216
t2 bar ['hi', 'bye'] 139842804665216
foo == bar: True
bar = bar + ['bye']
compare_objects(2, 'foo', 'bar')
t2 foo ['hi', 'bye'] 139842804665216
t2 bar ['hi', 'bye', 'bye'] 139842805379520
foo == bar: False

And here is a tuple:

foo1 = ('hi')
bar1 = foo1
compare_objects(1, 'foo1', 'bar1')
t1 foo1 hi 139843849783920
t1 bar1 hi 139843849783920
foo1 == bar1: True
bar1 += ('bye')
compare_objects(2, 'foo1', 'bar1')
t2 foo1 hi 139843849783920
t2 bar1 hibye 139842806205616
foo1 == bar1: False