3.4.6. Mutability (advanced)

LittleStop This section covers some fine points; they are important, but they will seem rather elusive to beginners.

Some Python containers are mutable; some are not. A mutable container is one whose contents can be changed after it is created. A non mutable container is one whose contents are fixed. If you have a non mutable container with 5 elements and you want the same contents with one change, you have to create a new container, copy 4 things from the first, and then add the new element.

Strings and tuples are non mutable. Trying to change their contents is a kind of error called a type error:

>>> X = 'dog'
>>> X[1] = 'i'
Traceback (most recent call last):
 File "<stdin>", line 1, in <module>
TypeError: 'str' object does not support item assignment

On the other hand, lists and dictionaries are mutable containers. Their contents can be updated to your heart’s content:

>> Y = list(X)
>>> Y
['d', 'o', 'g']
>>> Y[1] = 'i'
>>> Y
['d', 'i', 'g']

One way to get the effect of modifying one small subpart of a string is to do what we have done here: convert it into a mutable type. Then, if desired, we can convert it back to a string:

>>> X = ''.join(Y)
>>> X
'dig'

But note that what we have done here is make a new string, not modified the original string.

As the description suggests, trying to get edited versions of immutable containers is awkward and actually wasteful, if it can be avoided. If you need to frequently update the contents of a container, use a mutable container. On the other hand, if you don’t, use an immutable container, since that saves space.

Let’s take some examples. Suppose we have collected data in two immutable containers (tuples) and we wish to combine the contents into one:

>>> X
(2,3,4)
>>> Y
(5,6,7)
>>> X = X + Y

What happened? What happened is that we had two containers X and Y with 3 things each, and we made a new container with 6 things by copying the 3 from X and the 3 from Y. We stored the results in the variable X, but we still copied two tuples.

When we are in a similar sitiuation with a mutable container, there are actually 2 choices to make.

Python has two distinct functions ways of updating lists which look superficially equivalent and are very much not so:

>>> L1 = [1,2,3]
>>> L2 = L1
>>> L1 += [4]
>>> L1
[1, 2, 3, 4]
>>> L2
[1, 2, 3, 4]

The += command is equivalent to:

>>> L1.extend([4])

In both cases the list L1 names is extended by joining it to another list. Since L2 points to the same list, the result you get when you print out L2 has also changed.

On the other hand:

>>> L1 = [1,2,3]
>>> L2 = L1
>>> L1 = L1 + [4]
>>> L1
[1, 2, 3, 4]
>>> L2
[1, 2, 3]

What happened? [Explain, introducing destructive modification]

Now let’s do the same using tuples:

>>> T1 = (1,2,3)
>>> T2 = T1
>>> T1 += (4,)
>>> T1
(1, 2, 3, 4)
>>> T2
(1, 2, 3)

What happened? [Explain: With tuples there is essentially no difference between X += X + Y and X = X + Y.]

Note the difference in the following:

>>> X = []
>>> Y = []
>>> X.append('a')
>>> X
['a']
>>> Y
[]

and the following:

>>> X = []
>>> Y = X
>>> X.append('a')
>>> X
['a']
>>> Y
['a']

What happened? [Explain, reintroducing destructive modification]

There are important efficiency differences between updating by list concatenation and updating by destructive modification. Concatenating two long lists means making top-level copies of both. This takes times and space. Destructive modification avoids both kinds of expense. So when a data structure needs to be modified, particularly frequently, taking advantage of mutability is a must. On the other hand, enabling mutability in a data structure itself takes some space. So when we know a data structure needs never to be updated, it pays to choose an immutable implementation.

For this reason, Python makes available both mutable and immutable versions of sequences.

Tuples differ from lists in not being MUTABLE. There are a number of consequences. For one thing, an immutable sequence can’t have new values assigned to one of its indices. For example:

>>> X = (24, 3.14, 'w', 7) #  Tuple with 4 items
>>> X[1] = 2.7
...
TypeError: object does not support item assignment

There is one other very important nonmutable sequence type, strings:

>>> 'spin'[2]
'i'
>>> 'spin'[2]= 'a'
...
TypeError: object does not support item assignment

To do what is being attempted here, you have to switch to a mutable type, then switch back:

>>> L = list('spin')
>>> L
['s', 'p', 'i', 'n']
>>> L[2] = 'a'
>>> L
['s', 'p', 'a', 'n']
>>> ''.join(L)
'span'

What X.join(L) does is take a list of strings L and concatenate them together with copies of X:

>>> '-'.join(L)
's-p-a-n'

When X is the empty string, it just concatenates the elements of L together.

3.4.6.1. Consequences

Immutable objects can be used as keys in dictionaries. Mutable objects cannot. When you think about it, this is a profoundly sensible decision.

The paradigm nonmutable sequence type is of course strings. Strings can be used as dictionary keys(they are ‘hashable’):

>>> D = dict()
>>> D['spin'] = 40
>>> D
{'spin': 40}

This also means that tuples containing only immutable objects such as strings may be used as keys in dictionaries:

>>> X = (24, 3.14, 'w', 7) #  Tuple with 4 items
>>> D[X] = 41
>>> D
{(24, 3.1400000000000001, 'w', 7): 41, 'spin': 40}

Note that a single dictionary may employ an arbitrary collection of key types, as long as they are all hashable. Lists, being mutable, are not hashable:

>>> Y = [24, 3.14, 'w', 7] #  List with 4 items
>>> D[Y] = False
...
TypeError: unhashable type: 'list'

This helps us understand why the distinction between sets and frozensets is useful. So we want to store some property of a pair of strings, but that the order in which we consider the two strings is irrelevant. For example, suppose want to store distances between cities. The distance between ‘Los Angeles’ and ‘New York’ is the same as the distance between ‘New York’ and ‘Los Angeles’. So we might want to enter info into our city_distance dictionary as follows:

>>> city_distance[frozenset(['New York', 'Los Angeles'])] = 2792

Then later if we happen to ask:

>>> city_distance[frozenset(['Los Angeles', 'New York'])]
2792

we get the right answer. Notice on the other hand, trying to store the same information using a set as a dictionary key raises a TypeError, because sets are mutable:

>>> city_distance[set(['New York', 'Los Angeles'])] = 2792
...
TypeError: unhashable type: 'set'

3.4.6.2. Misunderstandings

Python beginners often think they understand mutability, but then have questions like the following.

Why isn’t ‘e’ changing to ‘P’ here when the vowel list is mutable?:

>>> vowelList = list('aeiouy')

>>> vowelList
['a', 'e', 'i', 'o', 'u', 'y']

>>> for x in vowelList:
       if x == 'e':
          x = 'P'

>>> vowelList
['a', 'e', 'i', 'o', 'u', 'y']

Sometimes questions like this come from programming beginners, but sometimes they come from people who ‘grew up’ learning a different kind of programming language. The problem here has to with the meaning of ‘assignment’ in Python. The ‘assignment’ statement:

>>> x = 'e'

says the value e has a new name, x. It does nothing to the list vowelList. In particular, it doesn’t change its second member. To do that, you have to do a list assignment, as in the examples introducing this section. To do what was intended by the example above:

>>> vowelList = list('aeiouy')

>>> for i in range(len(vowelList)):
      if vowelList[i] == 'e':
          vowelList[i] = 'P'

>>> V
['a', 'P', 'i', 'o', 'u', 'y']

For an excellent discussion of Python variables, especially helpful for those who have learned a computer language with different kinds of variables, see David Goodger’s discussion of variables (Section titled “Other languages have variables”) on his “Code like a Pythonista” page.

3.4.6.3. Pitfalls

There are times when you really want to avoid mutables, for example, as a default value for a function parameter:

>>> def foo (e,arg=[]):
      arg.append(e)
      return arg

>>> foo(1)
[1]
>>> foo(2)
[1, 2]