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)
A variable points at an object
This section is the heart of the lecture. If you remember one thing, remember this.
In Python a variable is not a box holding a value. It is an arrow pointing at an object. The statement x = y does not copy what is in y into x. It makes x point at whatever y is pointing at.
The built-in id() gives you an object’s identity — think of it as its address in memory. It makes the whole thing 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
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 it with a comprehension instead, so each pass creates a genuinely new list:
matrix = [[0] * 3 for _ in range(3)]
matrix[0][0] = 1
print(matrix) # only the first row changed
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)
List comprehensions
They have shown up a few times already, so here they are properly. A comprehension builds one sequence out of another:
[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))
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 variable is an arrow pointing at an object.
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 grids with a comprehension- 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.