References and Grids

In the last module a list was something you built and changed. This module is about a question that had no reason to come up until lists could change: when two names refer to a list, is that two lists or one? The answer explains a family of bugs that look like magic until you can draw them, and it is what makes a grid, a list of lists, work at all.

Before this, you can: change an item of a list; build a list with append in a loop; write nested loops.

By the end you will be able to say how many lists a program has made, make a real copy when you need one, tell a function that changes your list from one that only looks at it, read and write the cells of a grid, and build a grid whose rows are separate lists.

Before you read on, decide what this prints:

a = [1, 2, 3]
b = a
b[0] = 9
print(a[0])
9

The change was made through b. No line gives anything a new value through a, and yet a[0] has changed. If you know Java you have been told why: a variable holds a reference to an object. This module is about predicting with that fact, which is harder than knowing it.

Two names for one list

A name does not contain a list. A name refers to a list, the way a label is tied to a parcel. b = a does not make a second parcel. It ties a second label to the same one.

a ──┐
    ├──▶ [1, 2, 3]
b ──┘

So there is one list here, with two names. A change made through either name is a change to that one list, and the other name sees it, because there is nothing else to see.

Why did this never matter with numbers?

a = 5
b = a
b = 9
print(a)
5

Look at what the third line does. b = 9 does not change the number 5; it moves the label b to a different value. a is still tied to 5. The same is true of lists: giving a name a new value with = only moves that label.

a = [1, 2, 3]
b = a
b = [7]
print(len(a))
3

The difference is that a list can also be changed in place, by b[0] = 9, b.append(4), b.remove(2) and the rest, and a number cannot. Changing in place changes the parcel. Assigning with = moves a label. Every puzzle in this module comes down to asking which of the two a line does.

A copy is a second list

When you want a second list, ask for one. a.copy() makes a new list holding the same items, and so does the slice a[:]:

a = [1, 2, 3]
b = a.copy()
b[0] = 9
print(a[0], b[0])
1 9
a ──▶ [1, 2, 3]

b ──▶ [9, 2, 3]

Now there are two lists, and a change to one is not a change to the other.

Stepping through a program in the lab shows this picture directly: every list gets a number, and each name shows the number of the list it refers to. Two names with the same number are one list.

Functions and your lists

A parameter is a name. Calling a function ties that name to whatever was passed in, exactly as = would. So a function that is handed your list has a second name for your list, and if it changes the list in place, you see the change:

def add_one(items):
    items.append(1)

mine = [5]
add_one(mine)
print(mine)
[5, 1]

What a function cannot do is make your name refer to something else. Assigning to the parameter only moves the function's own label:

def reset(items):
    items = []

mine = [5]
reset(mine)
print(mine)
[5]

This is the rule from Functions, seen again: giving a parameter a new value changes nothing outside. It is only changes in place that reach the caller.

Both kinds of function are useful, and a reader should be able to tell which they have. A function that changes its argument usually returns nothing, like append. A function that leaves its argument alone builds a new list and returns it:

def with_one(items):
    longer = items.copy()
    longer.append(1)
    return longer

mine = [5]
more = with_one(mine)
print(mine, more)
[5] [5, 1]

Rows of rows

A list can hold lists. A list of rows, each row a list, is a grid, and it is how Python programs hold a board, a picture or a table.

grid = [
    [1, 2, 3, 4],
    [5, 6, 7, 8],
    [9, 10, 11, 12],
]
print(len(grid))
print(grid[1])
print(len(grid[1]))
3
[5, 6, 7, 8]
4

grid holds three items, and each is a list. len(grid) is the number of rows, grid[1] is the whole second row, and len(grid[1]) is the number of cells in that row. There is no separate grid type and no declared size. The rows could even have different lengths, though in this course they will not.

Two loops, one inside the other, visit every cell:

from scimigo import canvas, rectangle

canvas(600, 400)
picture = [
    [1, 0, 1, 0],
    [0, 1, 0, 1],
    [1, 0, 1, 0],
]
for row in range(len(picture)):
    for col in range(len(picture[row])):
        if picture[row][col] == 1:
            rectangle(100 + col * 50, 80 + row * 50, 48, 48, color="navy")

Row, then column

grid[row][col] is read from left to right. grid[row] picks out a row, which is a list, and [col] picks a cell out of that list. The row comes first.

grid = [
    [1, 2, 3, 4],
    [5, 6, 7, 8],
    [9, 10, 11, 12],
]
print(grid[1][2], grid[2][0])
grid[0][3] = 0
print(grid[0])
7 9
[1, 2, 3, 0]

On the canvas it is the other way round: x, which goes across like a column, is written first. Getting grid[col][row] when you meant grid[row][col] is the most common mistake with grids, so say "row, then column" to yourself when you write one.

A cell's position is two numbers that belong together. Round brackets make them into one value, a pair, and a pair can be taken apart into two names in one line:

cell = (2, 0)
row, col = cell
print(row, col)
2 0

A pair is like a list that cannot be changed: cell[0] works, and cell[0] = 5 is an error. That makes it a good fit for a position, which is a value, not a container.

Three rows, or one row three times?

* repeats a list: [0] * 4 is a list of four zeros. That is the quick way to make a row. It is tempting to use it again to make a grid:

grid = [[0] * 3] * 3
grid[0][0] = 5
print(grid[0])
print(grid[1])
print(grid[2])
[5, 0, 0]
[5, 0, 0]
[5, 0, 0]

One cell was set and a whole column changed. Nothing is wrong with Python. [row] * 3 makes a list holding the same row three times: three names for one list, exactly as in the first section.

grid ──▶ [ ●, ●, ● ]
           │  │  │
           └──┴──┴──▶ [5, 0, 0]

To get three separate rows, make three rows. Build each one inside a loop, so that the line that makes a row runs once for each row:

grid = []
for r in range(3):
    grid.append([0] * 3)
grid[0][0] = 5
print(grid[0])
print(grid[1])
[5, 0, 0]
[0, 0, 0]

The same trap waits when you copy a grid. grid.copy() makes a new outer list, but its items are the same rows as before, so changing a cell of the copy changes the original too. To copy a grid, copy every row:

grid = [[1, 2], [3, 4]]
shallow = grid.copy()
deep = []
for row in grid:
    deep.append(row.copy())
grid[0][0] = 99
print(shallow[0][0], deep[0][0])
99 1

In the lab

You will predict what an aliased change does, count the lists a program has made, decide whether a function changed your list, read cells of a grid by row and column, draw a picture stored as a grid, repair a seating chart whose rows are all one list, and write functions that count along a row and a column and that return a mirrored copy of a grid.