Lists
A single variable cannot hold a real problem. Lists are the container you will reach for most often in Python, and also the one that trips up beginners most — because underneath, a list is a reference, not a value.
By the end of this lecture you should be able to
- Store and process a batch of data with a list instead of a hundred separate variables
- Explain the model where a variable points at an object, and confirm it with id()
- Tell the difference between changing a list and pointing a name somewhere new
- Avoid the classic traps around nested lists and shallow copies
- Judge roughly what each list operation costs
Contents
One variable per student does not scale
Say you want the average, the highest mark, and the number of failures for a class of 116 students.
With what you know so far, the only way in is this:
score1 = 87
score2 = 92
score3 = 65
# ...and 113 more
It is not just tedious. It does not work. To compute the average you would have to spell out all 116 names in one enormous sum, and if next year the class has 120 students you rewrite the whole thing.
Here is the real problem: variable names are fixed when you write the program, but how much data there is only becomes known when it runs. You need something that holds a batch of values together and lets you reach them by position rather than by name.
That is a list.
scores = [87, 92, 65, 78, 95]
print("count: ", len(scores))
print("total: ", sum(scores))
print("average:", sum(scores) / len(scores))
print("highest:", max(scores))
Creating and reading a list
Square brackets, commas between the items. The items can be of any type, and they do not have to match:
a = [1, 3, 9, 7] # four integers
b = ["Hello", "world"] # two strings
c = [1, "two", 3.0, True] # mixed, and perfectly legal
d = [] # empty
print(a, b, c, d)
print(type(a))
list() turns anything iterable into a list:
print(list("hello")) # a string, character by character
print(list(range(5))) # a range of integers
Counting starts at zero
You read an item with square brackets and a position. The first item is at position 0. This catches almost everyone at least once.
a = [10, 20, 30, 40, 50]
print(a[0]) # first
print(a[4]) # fifth, and also last
print(a[-1]) # negative counts from the right, so -1 is the last one
print(a[-2]) # second from the end
For a list of length n, the valid positions run from 0 to n - 1. Step outside that and you get an IndexError:
a = [10, 20, 30]
print(a[3])
Going through a list
scores = [87, 92, 65, 78, 95]
for s in scores:
print(s, "pass" if s >= 60 else "fail")
When you need the position as well as the item, use enumerate() rather than for i in range(len(scores)):
scores = [87, 92, 65]
for i, s in enumerate(scores):
print(f"student {i + 1}: {s}")
Is it in there?
names = ["Ada", "Alan", "Grace"]
print("Alan" in names)
print("Linus" not in names)
What a list is in memory
A list can hold values of any type, yet a[i] is equally fast however long the list is. That combination takes some doing.
An array in C or Java is one contiguous block of memory where every item has the same type and the same size, so the address of item i is simply the start address plus i times the item size. (That is also why indexing starts at 0: item 0 lives at the start address.) A Python list can mix integers, strings and other lists, all of different sizes, so that formula cannot work directly.
Python’s answer: each slot of a list holds an address, not the data itself. L3 showed that a Python name holds an object’s address; a list slot does the same. Addresses are all the same size, so the slots are uniform and indexing stays fast, while the actual objects can be anything and live elsewhere.
Two names, one list
L3 established that assignment copies an address, never an object: after b = a, both names point at the same object. With integers and strings that is harmless, because they cannot be changed. Lists can. id() makes it visible:
a = [1, 2, 3]
b = a # b now points at the same list
print("id of a:", id(a))
print("id of b:", id(b))
print("same object?", a is b)
The two ids match. a and b are two names for one list.
So a change made through b shows up through a:
a = [1, 2, 3]
b = a
b.append(4) # changes the list that b points at
print("a =", a) # a changed too
print("b =", b)
Changing versus rebinding
(The same applies to passing a list into a function: the parameter and the caller’s name point at one list, so an in-place change inside the function is visible to the caller, while reassigning the parameter is not.)
Once the reference model clicks, two operations that look similar turn out to be nothing alike.
Rebinding points the name at a new object and leaves the old one untouched:
a = [1, 2, 3]
b = a
b = [9, 9, 9] # b now points somewhere else
print("a =", a) # unchanged
print("b =", b)
Mutating changes the object itself, and every name pointing at it sees the change:
a = [1, 2, 3]
b = a
b[0] = 9 # reaches inside the list
print("a =", a) # changed
print("b =", b)
The rule of thumb: a bare name on the left of = means rebinding; anything with [...] on the left, or a method call, means mutation.
Adding, removing, changing
Lists are mutable. The methods below all change the list in place and return None.
| Method | What it does |
|---|---|
a.append(x) | add one item at the end |
a.extend(it) | add every item of an iterable at the end |
a.insert(i, x) | insert x at position i, shifting the rest right |
a.remove(x) | delete the first item equal to x; ValueError if there is none |
a.pop(i) | delete the item at i and return it; with no argument, the last one |
a.clear() | empty the list |
a.sort() | sort in place |
a.reverse() | reverse in place |
del a[i] | delete the item at i (a statement, not a method) |
a = [3, 1, 2]
a.append(4) # [3, 1, 2, 4]
a.insert(0, 0) # [0, 3, 1, 2, 4]
a.remove(3) # [0, 1, 2, 4]
last = a.pop() # takes 4 out, leaving [0, 1, 2]
a.sort()
print(a, "and we popped", last)
append and extend are easy to mix up:
a = [1, 2]
a.append([3, 4])
print("append:", a) # the whole list goes in as a single item
b = [1, 2]
b.extend([3, 4])
print("extend:", b) # the items go in one at a time
Slicing
A slice takes a stretch of a list. The syntax is a[start:stop], and it includes the start but stops before the end.
a = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
print(a[2:5]) # positions 2, 3, 4
print(a[:3]) # from the beginning up to position 2
print(a[7:]) # from position 7 to the end
print(a[:]) # the whole thing — and this gives you a new list
print(a[-3:]) # the last three
A third number sets the step:
a = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
print(a[::2]) # every other item
print(a[1::2]) # every other item, starting at position 1
print(a[::-1]) # a negative step reverses; returns a new list
A slice gives you a new list, and that matters:
a = [1, 2, 3]
b = a[:] # a fresh list
print(a is b) # False — two different objects
b[0] = 99
print("a =", a) # untouched
Where copying goes wrong
a[:] made a copy, so the problem looks solved. But it is a shallow copy: it duplicates the outer list and nothing more. Anything nested inside is still shared.
With a list of lists, that shows up immediately:
a = [[1, 2], [3, 4]]
b = a[:] # shallow copy
b[0][0] = 99 # reaches into the inner list
print("a =", a) # a changed as well
print("b =", b)
print("same inner list?", a[0] is b[0])
b really is a new outer list. But its two items are still arrows pointing at the original inner lists.
For a copy that goes all the way down, use copy.deepcopy():
import copy
a = [[1, 2], [3, 4]]
b = copy.deepcopy(a)
b[0][0] = 99
print("a =", a) # safe this time
print("b =", b)
| How you write it | How deep it copies | When to use it |
|---|---|---|
b = a | not at all — just another name | when you genuinely want one object under two names |
b = a[:] or a.copy() | the outer list only | when the items are numbers, strings, or other immutable values |
copy.deepcopy(a) | every level | when there are lists, dicts, or other mutable objects inside |
The matrix that changes three rows at once
To build a 3×3 grid of zeros, the obvious thing to write is:
matrix = [[0] * 3] * 3
print(matrix) # looks exactly right
matrix[0][0] = 1
print(matrix) # every row changed
[[0] * 3] * 3 means “take this one list [0, 0, 0] and repeat the reference three times.” All three rows are the same list.
Build a fresh row on each pass of a loop instead (_ is the conventional name for a loop variable you never use):
matrix = []
for _ in range(3):
matrix.append([0] * 3) # a new list every time
matrix[0][0] = 1
print(matrix) # only the first row changed
Mutable default values
L4 promised one more thing about default arguments, once lists had arrived:
def add_item(item, box=[]):
box.append(item)
return box
print(add_item(1))
print(add_item(2)) # you would expect [2]
The second call still carries the 1 from the first. That empty list is created when the def line runs, once for the whole program, and every call that omits box gets that same list. It is aliasing again.
The fix is to default to None and build a fresh list inside:
def add_item(item, box=None):
if box is None:
box = []
box.append(item)
return box
print(add_item(1))
print(add_item(2))
Only use immutable values as defaults: numbers, strings, None.
List comprehensions
You have now written the same loop several times: start an empty list, walk another sequence, append one computed value per pass. Python has a one-line form for exactly that:
[expression for name in iterable if condition]
It does the same job as a loop, in less space and with fewer places to slip:
# the long way
squares = []
for i in range(10):
squares.append(i ** 2)
# the comprehension
squares2 = [i ** 2 for i in range(10)]
print(squares == squares2)
print(squares2)
With a condition attached:
scores = [87, 92, 45, 78, 55, 95]
passed = [s for s in scores if s >= 60]
print("passed: ", passed)
print("pass rate:", len(passed) / len(scores))
The grid from earlier becomes [[0] * 3 for _ in range(3)].
Deleting while you iterate
The other classic. Here we delete from a list inside a for loop that is walking it:
a = [1, 2, 2, 3, 4]
for x in a:
if x == 2:
a.remove(x)
print(a) # not what you wanted
Why? The loop keeps an internal position that advances by one each time round. Removing an item shifts everything after it left by one, so some items get skipped entirely.
The fix is to stop editing in place and build a new list instead:
a = [1, 2, 2, 3, 4]
b = [x for x in a if x != 2] # a comprehension says it most clearly
print(b)
What things cost
One last piece of intuition. In memory a list is a contiguous row of slots, each holding an arrow to an item. That layout decides which operations are cheap and which are not:
| Operation | Speed | Why |
|---|---|---|
a[i] — read by position | fast | the slot’s address is one calculation away |
a.append(x) | fast | adds a slot at the end, disturbing nothing |
a.pop() — drop the last | fast | same |
a.insert(0, x) | slow | every existing item shifts one slot right |
a.remove(x) | slow | scan to find it, then shift everything after it left |
x in a | slow | compare from the front, one item at a time |
a.pop(0) — drop the first | slow | every remaining item shifts left |
“Slow” here means something specific: the longer the list, the slower, in direct proportion. On a list of a million items, a.insert(0, x) moves a million things.
Summary
- A list holds a batch of values you reach by position. Counting starts at 0;
-1is the last item - A list stores addresses.
b = adoes not copy the list; it adds a second name - Methods that mutate in place —
append,sort,reverse,extend— all returnNone a[i:j]includes the start, stops before the end, and gives you a new lista[:]copies only the outer level; for nested data usecopy.deepcopy()[[0] * n] * mmakesmreferences to one row; build each row fresh- Never use a list as a default value; default to
Noneand build one inside - Never modify a structure while iterating over it
- Cheap at the end, expensive at the front, expensive to search
Exercises
-
Without using
max(), write a function that returns the largest value in a list. ReturnNonefor an empty list. -
Write a function that removes duplicates from a list while keeping the original order. You may not use
set— it loses the ordering. -
Given a matrix as a list of lists, write a function that returns its transpose. Build the result with
[[0] * n] * mfirst and see what happens, then fix it. -
The code below is meant to double every even number in the list. It does not. Find out why and correct it:
a = [1, 2, 3, 4, 5, 6] for i in range(len(a)): if a[i] % 2 == 0: a.insert(i, a[i] * 2) -
Design a small experiment with
id()that demonstrates the following: a slice produces a new outer list, but the objects nested inside it are still shared.
The lab problems for this lecture are in Lab 5.