The Python Memory Model: Code

As described in the handout, every value in Python is stored in memory, and variables refere to addresses in memory. For example:

In [2]:
n = 42
m = 43
print("The location of 42 in memory:", id(n))
print("The location of 43 in memory:", id(m))
The location of 42 in memory: 10107136
The location of 43 in memory: 10107168

The effect of code like n = m is to make n refere to the same location in memory as m (which means that both n and m would be referring to the address where 43 is stored)

In [3]:
n = m
print("n now refers to:", id(n), "and the value stored at that address is:", n)
print("m now refers to:", id(m), "and the value stored at that address is:", m)
n now refers to: 10107168 and the value stored at that address is: 43
m now refers to: 10107168 and the value stored at that address is: 43

Exactly the same kind of thing happens with lists, if we are careful to do exactly the same thing:

In [5]:
L = [42, 43]
M = [45, 46]
print("The location of L in memory:", id(L))
print("The location of M in memory:", id(M))
M = L
print("M now refers to:", id(M), "and the value stored at that address is:", M)
print("L now refers to:", id(L), "and the value stored at that address is:", L)
The location of L in memory: 140146497082376
The location of M in memory: 140146497048264
M now refers to: 140146497082376 and the value stored at that address is: [42, 43]
L now refers to: 140146497082376 and the value stored at that address is: [42, 43]

Note, however, that every time we have notation like [42, 43], it means that we're creating a new list:

In [6]:
L1 = [3, 4]
print("id(L1)=", id(L1))
L2 = [3, 4]
print("id(L2)=", id(L2))
id(L1)= 140146497084744
id(L2)= 140146497084360

It so happens that L1 and L2 have the same values, but they are stored at different addresses.

Back to L and M. Remember that we set them to refer to the same address. That makes the aliases. If I modify the contents of one, that modifies the contents stored at address id(L), but that means it also modifies the conents stored at address id(M) (which is the same address)

In [7]:
M[0] = 40
print("M = ", M)
print("L = ", L)
M =  [40, 43]
L =  [40, 43]

How do we avoid this? We actually already saw the answer above:

In [8]:
L = [M[0], M[1]]
L[0] = 50
print("L = ", L)
print("M = ", M)
L =  [50, 43]
M =  [40, 43]

[M[0], M[1]] created a new list, put it in a new location in memory, and then made L refere to it. Initially, since L and M both have two elements, they had the same contents. But modifying the contents of L didn't change M.

We actually already saw how to accomplish this in general (without knowing in advance how many elements there are in M):

In [9]:
L = M[:]  #shorthand for L = [M[0], M[1], ...., M[len(M)-1]]

In general, slicing is shorthand for creating a new list. For example, M[3:7] is just shorthand for [M[3], M[4], ..., M[6]]

We can get away with not doing slicing at all. The following will make sure that L and M are stored at different addresses, and have the same contents

In [10]:
L = []  #create a new empty list, and make L refer to it
for e in M:
    L.append(e)

Local variables

As discussed before, local variables only exist for the duration of the run of the function. If a local variable has the same name as a global variable, it "masks" it while we're in the function.

In [12]:
def correct_gpa(gpa):
    print("Initially, local gpa:", gpa)
    average = 99.5
    gpa = 3.99
    print("Local gpa:", gpa)
    
gpa = 3.98
correct_gpa(gpa)
print("Global gpa is still", gpa)
#print(average)  #would cause an error, average is a local variable that doesn't exist when we're not running correct_gpa
Initially, local gpa: 3.98
Local gpa: 3.99
Global gpa is still 3.98

Here, gpa is also the parameter. A parameter is a special kind of local variable. The first thing that happens when you call a function correct_gpa is <local gpa> = 3.98. That is, the local gpa gets initialized to whatever is the value that we're calling correct_gpa with

This matters when we are passing lists to functions:

In [14]:
def inflate_gpa_list(gpa_list):
    gpa_list[0] = gpa_list[0]*1.1
    gpa_list[-1] = gpa_list[-1]*1.1
    

L = [3.6, 3.7, 3.9]
inflate_gpa_list(L)
print("L is now", L)
L is now [3.9600000000000004, 3.7, 4.29]

What happenned? The first thing to happen when inflate_gpa_list was called is gpa_list = L. That means that gpa_list and L were referring to the same address. Modifying the contents of one also modified the contentsn of the other!

The issue didn't come up with integers, because we can't modify the contents of integers. The following is an example where we're not modifying the contents of lists, but rather reassigning new lists to variables that already referred to lists. The bahviour is exactly as with integers

In [15]:
def change_gpa_list(gpa_list):
    gpa_list = [4,4,4]
    
L = [3, 3, 3]
change_gpa_list(L)
print("L is still:", L)
L is still: [3, 3, 3]

So we saw how to change the contents of a list. Suppose you have an integer, and you want it to refer to something new, and that something new is computed using a function. How to do that? Using the assignment (=) operator:

In [16]:
def double_num(n):
    return n * 2

n = 5
n = double_num(n)
print("n is now", n, "(of course)")
n is now 10 (of course)

The same strategy works with lists, if you don't mind creating new lists and placing them in memory. (In many cases, there is no reason that you would mind doing that.)

In []: