We closed module 4 with a promise: the two limitations of EasyTask —that it only stores one task and that the data of that task lives in six separate variables— are solved with data structures. This lesson tackles the first and most urgent one: how to store many values under a single name.
The tool is called a list, and it is the data structure you will use most in your life as a programmer: it holds an ordered sequence of values, grows and shrinks on the fly, is traversed with a for loop, and can be sorted and filtered. As soon as you have it, Marta will be able to juggle the book fair poster, the Solé Bakery menu and the Vidal client logo all at once. And along the way we will tie up the loose ends of 04-05: sorted(key=...), map and filter needed a collection to work on, and now we have one.
Contents
- From separate variables to the list
- Classic array and Python list
- Creating, measuring and indexing lists
- Slicing:
[start:stop:step] - Mutability, aliasing and copies
- Essential methods and functions
- Traversing:
for,enumerate,zipandin - List comprehensions
sortedwithkey,mapandfilter- EasyTask v0.8: parallel lists
- Common mistakes and tips
- Exercises
- Conclusion
- From separate variables to the list
To store the estimated days of three tasks, with what you know so far there is only one way out: days_1 = 3, days_2 = 5, days_3 = 2. It works and at the same time it is a dead end: you cannot traverse those variables with a loop (each name is different and they do not form any sequence), you cannot add a fourth task without editing the program, you cannot ask how many there are or sort them. A list turns those three variables into a single object with structure:
days = [3, 5, 2]
print(len(days)) # 3
print(sum(days)) # 10
days.append(8) # a fourth task, at run time
print(days) # [3, 5, 2, 8]The rule that follows from this holds forever: when several variables are named the same except for a number, what you actually want is a list.
- Classic array and Python list
Programming literature is full of the word array, which is not exactly the same thing as a Python list. A classic array, as it exists in C, Java or Pascal, is a contiguous block of memory with two strong restrictions —fixed size and a single type of data— in exchange for speed and minimal memory consumption. The Python list is dynamic and heterogeneous.
| Feature | Classic array | Python list |
|---|---|---|
| Size | Fixed at creation | Grows and shrinks by itself |
| Types of the elements | All the same | Any mixture |
| Access by index | Yes, from 0 | Yes, from 0 |
| Insert in the middle | Manual and costly | insert() |
| Memory consumption | Minimal | Higher: it stores references |
| In Python | array module, NumPy |
Native list type |
The fact that ["Book fair poster", 3, True, 4.5] is legal does not mean it is a good idea. A homogeneous list —all elements of the same type and with the same meaning— is much easier to traverse, because inside the for you know what you are holding. If each position means a different thing, what you are looking for is a tuple (05-04) or a dictionary (05-03). And when you need millions of numbers and memory matters, there are the array module of the standard library and the external library NumPy, widely used in numerical computing: we do not develop them here, but it is worth knowing they exist.
- Creating, measuring and indexing lists
There are three usual ways to create a list, and len() to measure it:
team = ["Marta", "Luis", "Nuria"] # literal
letters = list("Alba") # with list(): ['A', 'l', 'b', 'a']
pending = [] # empty, to fill in later
print(len(team), len(pending)) # 3 0The empty list is the starting point of the accumulator pattern from 03-02, only now you accumulate elements instead of adding up numbers. len() is the same function you used with strings: both are sequences. Every element has a position, and that position starts at zero:
titles = ["Book fair poster", "Sole Bakery menu", "Vidal client logo"]
print(titles[0]) # Book fair poster
print(titles[2]) # Vidal client logo
print(titles[3]) # IndexError: list index out of rangeA list of three elements has indices 0, 1 and 2: the last valid index is always len(list) - 1. Negative indices count from the end and save you from computing lengths: in this list, -1 is the same element as 2, -2 the same as 1 and -3 the same as 0. That is why titles[-1] is the idiomatic way of saying "the last one", far better than titles[len(titles) - 1]. There is no -0, because -0 is 0.
- Slicing:
[start:stop:step]
[start:stop:step]A slice extracts a chunk and returns a new list. The three numbers are optional. The key rule, and the eternal source of confusion: start is included, stop is NOT. The advantage of the convention is that stop - start gives you directly how many elements come out. The examples start from days = [3, 5, 2, 8, 1, 6].
| Expression | Result | Explanation |
|---|---|---|
days[1:4] |
[5, 2, 8] |
From 1 to 3; 4 stays out |
days[:3] |
[3, 5, 2] |
From the beginning |
days[3:] |
[8, 1, 6] |
To the end |
days[-2:] |
[1, 6] |
The last two |
days[::2] |
[3, 2, 1] |
Every other one, from the beginning |
days[::-1] |
[6, 1, 8, 2, 5, 3] |
Backwards |
days[:] |
[3, 5, 2, 8, 1, 6] |
Full copy |
days[4:2] |
[] |
Impossible range: empty, no error |
Two important details. A slice never raises IndexError even if you overshoot: days[2:99] returns whatever is there. And days[:] looks redundant, but it is the classic way of getting a shallow copy.
- Mutability, aliasing and copies
Lists are mutable: you can change their content without creating a new list, something you could not do with strings (02-01). priorities[1] = "high" replaces the second element and that is that. So far, convenient. Now the part that surprises everyone:
originals = ["Book fair poster", "Bakery menu"]
copy = originals # is it really a copy?
copy.append("Vidal logo")
print(originals) # ['Book fair poster', 'Bakery menu', 'Vidal logo']
print(copy is originals) # TrueWe modified copy and originals changed. The reason is the one you studied in 04-03 with references: copy = originals does not copy the list, it copies the reference; both names point to the same object, and that is called aliasing. To copy for real there are three equivalent ways, which you will see below the diagram.
graph LR
A[originals] --> L["List A in memory<br/>Book fair poster, Bakery menu"]
B[copy] --> L
C[real_copy] --> M["List B in memory<br/>Book fair poster, Bakery menu"]
copy_1 = originals.copy() # the most explicit and recommended one
copy_2 = list(originals) # constructor from the original
copy_3 = originals[:] # full slice, the classic one
print(len(originals), copy_1 is originals) # 2 FalseThis has a direct consequence on functions, and it is new with respect to 04-03. There we saw that passing an immutable argument does not let the function touch the caller's variable. With a list it is the other way round:
def add_task(tasks, title):
"""Add a title to the list received (it modifies it)."""
tasks.append(title)
def clear_wrong(tasks):
tasks = [] # reassigns the LOCAL name: not visible outside
def clear_right(tasks):
tasks.clear() # modifies the object: visible outside
agenda = ["Book fair poster"]
add_task(agenda, "Bakery menu")
print(agenda) # ['Book fair poster', 'Bakery menu']The function receives a reference to the same list, so its changes are visible outside. It is extremely powerful —that is how v0.8 will add tasks— and also dangerous: a function that silently modifies someone else's list causes surprises. Practical rule: either it modifies and says so in its name (add_task, sort_agenda), or it works on a copy and returns the result. And notice the contrast between clear_wrong and clear_right: reassigning the parameter only changes the local name, whereas calling a method changes the object both of them share.
- Essential methods and functions
Methods are called on the list with a dot and almost all of them modify it in place; built-in functions are called with the list inside the parentheses and return a value without touching it.
| Method | What it does | Returns |
|---|---|---|
.append(x) |
Adds x at the end |
None |
.insert(i, x) |
Inserts x at position i |
None |
.extend(other) |
Adds all the elements of other |
None |
.remove(x) |
Deletes the first occurrence of x (ValueError if absent) |
None |
.pop(i) |
Takes out element i (the last one if omitted) |
The element |
.clear() |
Empties the list | None |
.index(x) |
Position of the first occurrence (ValueError if absent) |
Integer |
.count(x) |
How many times x appears |
Integer |
.sort() |
Sorts in place | None |
.reverse() |
Reverses the order in place | None |
tasks = ["Book fair poster", "Bakery menu"]
tasks.insert(0, "Check email") # sneaks into first position
tasks.extend(["Vidal business cards", "Flyer"]) # adds two at once
print(tasks.index("Bakery menu"), tasks.count("Flyer")) # 2 1
last = tasks.pop() # takes out 'Flyer' and returns it
tasks.remove("Bakery menu") # deletes by value, not by position
wrong = tasks.sort() # sort() returns None
print(wrong) # None -> you have lost the listDecisive difference: pop deletes by position and returns the element; remove deletes by value and returns nothing. And the number one trap of lists is that last line: the methods that modify return None, so never store their result. Built-in functions, on the other hand, return something new and do not touch the list:
days = [3, 5, 2, 8, 1]
print(len(days), sum(days), min(days), max(days)) # 5 19 1 8
print(f"Average: {sum(days) / len(days):.2f}") # Average: 3.80
print(sorted(days), days) # [1, 2, 3, 5, 8] [3, 5, 2, 8, 1] -> untouchedThe rule for choosing: .sort() if you want the list to end up sorted; sorted() if you need to preserve the original order. Both accept reverse=True. How they sort internally and how much it costs is the subject of Sorting algorithms and Efficiency and Big-O notation; here we use them as tools of the language.
- Traversing:
for, enumerate, zip and in
for, enumerate, zip and inThe for loop from 03-02 was made for this. Traversing the list directly —for title in titles:— is the preferred form in Python: on each pass, title takes the value of one element, with no indices, no len() and no chance of IndexError. When you do need the position —to number a listing, for example— use enumerate(), which produces two values per pass:
for number, title in enumerate(titles, start=1):
print(f"{number}. {title}") # 1. Book fair poster, 2. Sole...start=1 makes the numbering the user sees begin at 1 even though the internal indices still start at 0: exactly what a menu needs. When you have two parallel lists, zip() pairs them up:
assignees = ["Marta", "Luis", "Nuria"]
for title, assignee in zip(titles, assignees):
print(f"{title:<24}{assignee:>10}")
print("Marta" in assignees) # True
print("Alba" not in assignees) # Truezip stops with the shortest list without warning: if one has an extra element, that element disappears from the traversal. The last two lines use the in operator from 02-02 to check membership. How in searches internally, and why in a long list it takes longer than in a set, is covered in Search algorithms and in Dictionaries and sets.
- List comprehensions
This pattern shows up constantly: create an empty list (doubles = []), traverse another sequence and add something on each pass (doubles.append(d * 2)). Python offers a condensed way of writing exactly that, the list comprehension, which reads from the inside out: for each d in days, compute d * 2 and put it in the list.
doubles = [d * 2 for d in days] # transform: [6, 10, 4, 16, 2]
long_ones = [d for d in days if d > 3] # filter: [5, 8]
fair = [t.upper() for t in titles if "fair" in t] # filter and transform| Form | Meaning |
|---|---|
[f(x) for x in items] |
Transform every element |
[x for x in items if cond] |
Keep only some |
[f(x) for x in items if cond] |
Filter and transform |
And now the part that is almost never told: when NOT to use them. Do not use one if the expression does not fit comfortably on a single line, if you need nested conditions, if inside you have to print or ask the user for data, or if you chain two or three for clauses. Something like [t.upper() if len(t) > 10 else t.lower() for t in titles if "e" in t] is legal and it is unreadable: it is crying out for a normal loop. The goal was never to write less, but to read better.
sorted with key, map and filter
sorted with key, map and filterIn 04-05 we left higher order over collections pending. We can close it now. sorted(items, key=function) sorts according to what function returns for each element, not according to the element itself:
titles = ["Bakery menu", "Book fair poster", "Flyer", "Vidal logo"]
print(sorted(titles, key=len))
# ['Flyer', 'Vidal logo', 'Bakery menu', 'Book fair poster']
PRIORITIES = ("high", "medium", "low")
priorities = ["low", "high", "medium", "high"]
print(sorted(priorities, key=lambda p: PRIORITIES.index(p)))
# ['high', 'high', 'medium', 'low']With a lambda you can invent any criterion you like. Sorting the priorities alphabetically would give high, low, medium, which is not their logical order; instead PRIORITIES.index(p) returns 0 for "high", 1 for "medium" and 2 for "low", and sorting by that number gives the correct order. It is exactly what we saw in 04-05: sorted knows nothing about priorities, only about sorting; the criterion is injected from outside. And key=str.lower sorts ignoring case, while reverse=True flips the result.
map(function, items) applies a function to every element and filter(function, items) keeps those for which it returns True. Both return a lazy object that has to be converted with list():
| Goal | With a comprehension | With map/filter |
|---|---|---|
| Transform | [d * 2 for d in days] |
list(map(lambda d: d * 2, days)) |
| Filter | [d for d in days if d > 3] |
list(filter(lambda d: d > 3, days)) |
In modern Python the comprehension is preferred: shorter, without list() and more readable. map and filter are still useful when the function already exists and has a name (map(str.upper, titles) is beautifully clean), and you must recognise them because they abound in other people's code.
- EasyTask v0.8: parallel lists
v0.7 stored one task in six separate variables. v0.8 stores many tasks in five parallel lists: the element at index i of each list refers to the same task.
graph LR
T["titles"] --> I0["Index 0 of the four lists<br/>describes the same task"]
R["assignees"] --> I0
P["priorities"] --> I0
C["completed"] --> I0
The input functions (ask_text, ask_option, ask_integer, confirm), the logic one (classify_urgency) and show_menu/pause are kept untouched. What changes are the action functions and main():
# easytask.py - Alba Studio / Version 0.8: many tasks in parallel lists
def show_list(titles, assignees, priorities, completed):
"""Print every task numbered from 1."""
if len(titles) == 0:
print("There are no tasks registered.")
return
for number, title in enumerate(titles, start=1):
i = number - 1
mark = "[X]" if completed[i] else "[ ]"
print(f"{number:>2}. {mark} {title:<22}{assignees[i]:>8}{priorities[i]:>7}")
print("-" * WIDTH)
print(f"Total: {len(titles)} tasks, {completed.count(True)} completed.")
def register_task(titles, assignees, priorities, days, completed):
"""Ask for a new task and add it at the end of the five lists."""
titles.append(ask_text("Title : "))
assignees.append(ask_option("Assignee : ", TEAM).capitalize())
priorities.append(ask_option("Priority : ", PRIORITIES))
days.append(ask_integer("Days (1-365) : ", 1, 365))
completed.append(False)
print(f"Task {len(titles)} registered successfully.")
def choose_index(titles):
"""Ask for a task number and return its index, or -1 if there are no tasks."""
if len(titles) == 0:
print("There are no tasks registered.")
return -1
return ask_integer(f"Task number (1-{len(titles)}): ", 1, len(titles)) - 1
def main():
"""Run the main loop of the application."""
titles, assignees, priorities, days, completed = [], [], [], [], []
while True:
show_menu()
option = ask_option("Choose an option (1-5): ", OPTIONS)
if option == "5":
if confirm("Are you sure you want to exit?"):
print("Goodbye. EasyTask is closing.")
break
elif option == "1":
register_task(titles, assignees, priorities, days, completed)
elif option == "2":
show_list(titles, assignees, priorities, completed)
elif option == "3":
i = choose_index(titles)
if i >= 0:
priorities[i] = ask_option("New priority: ", PRIORITIES)
print(f"Priority of '{titles[i]}' updated.")
elif option == "4":
i = choose_index(titles)
if i >= 0 and completed[i]:
print("That task was already completed.")
elif i >= 0:
completed[i] = True
print(f"'{titles[i]}' completed. Good work.")
pause()
if __name__ == "__main__":
main()What has changed and why:
register_taskno longer returns anything: it modifies the lists it receives. It can do so because lists are mutable (section 5); the name and the docstring announce it.choose_indextranslates the number the user sees into the internal index by subtracting 1. That- 1is the border between "first task" for Marta and "index 0" for Python, and it is best kept in a single place.enumerate(..., start=1)numbers the listing with no manual counters andcompleted.count(True)counts the finished ones with no loop. Besides, thehas_taskflag disappears:len(titles) == 0answers the same question and on top of that tells you how many there are.
And now the new problem we have created. The five lists must be in sync at all times: if somebody adds a field and forgets an append, they get misaligned and the assignee of one task shows up next to the title of another. There is no error and no warning: the program keeps working and lying. Deleting a task requires five coordinated pop calls, and adding a field forces you to touch the signature of every function. The five parallel lists are, in truth, a single list of tasks that we do not yet know how to write.
Common Mistakes and Tips
Confusing index and human position. The third element is items[2]. This off-by-one gap is the most frequent mistake in the trade: when printing for the user, enumerate(..., start=1); when accessing, subtract 1 in a single place. Expecting .sort() to return something. ordered = items.sort() leaves ordered holding None; if you need a new list, sorted(items). The same goes for append, reverse, insert and clear. And .remove(x) and .index(x) with a value that is not there raise ValueError: check first with if x in items:.
Believing that copy = items copies. It copies the reference. Use .copy(), list() or [:], and check with is if you have doubts. And do not delete elements while you traverse the list: if inside a for you call items.remove(x), the traversal skips elements because the positions shift under your feet. The safe solution is to build a new list with the ones you want to keep, usually with a comprehension.
Tip: use the direct for whenever you can. for t in titles: is clearer and safer than for i in range(len(titles)):. And keep your lists homogeneous: a list contains "many things of the same kind", not "one thing split into pieces".
Exercises
Exercise 1: Predict the output
Say what each print prints and explain why.
days = [3, 5, 2, 8, 1, 6]
print(days[2], days[-1], days[1:3])
copy = days
copy.append(4)
print(len(days))
other = days[:]
other.clear()
print(len(days), len(other))
print(days.sort(), days[0])Exercise 2: Studio statistics
With the lists below, print: the number of tasks, the total and the average number of days, the title of the longest task, the titles with priority "high" and the titles sorted by days from highest to lowest. Use comprehensions and sorted(key=...) where they fit.
titles = ["Book fair poster", "Bakery menu", "Vidal logo", "Summer flyer"]
assignees = ["Marta", "Luis", "Nuria", "Marta"]
priorities = ["high", "medium", "high", "low"]
days = [3, 5, 2, 8]Exercise 3: Deleting a task
Write remove_task(titles, assignees, priorities, days, title), which deletes from the four lists the task with that title without misaligning them and warns if it does not exist. Then explain, in two sentences, why this function is awkward to write and to maintain.
Solutions
Solution 1.
days[2] is the third element, days[-1] the last one and days[1:3] includes indices 1 and 2 but not 3. copy = days is aliasing: the append affects the only list there is and len(days) goes from 6 to 7. other = days[:] really is a copy, so emptying it does not touch the original: 7 and 0. And .sort() sorts in place returning None; after sorting, the first element is the smallest, 1.
Solution 2.
print(f"Tasks: {len(titles)}")
print(f"Total: {sum(days)} days, average {sum(days) / len(days):.2f}")
print(f"The longest: {titles[days.index(max(days))]} ({max(days)} days)")
high = [titles[i] for i in range(len(titles)) if priorities[i] == "high"]
print(f"High priority: {', '.join(high)}")
by_days = sorted(titles, key=lambda t: days[titles.index(t)], reverse=True)
print(by_days) # ['Summer flyer', 'Bakery menu', 'Book fair poster', 'Vidal logo']The result is 4 tasks, 18 days in total, an average of 4.50, "Summer flyer" as the longest and two of high priority. days.index(max(days)) gives the position of the maximum, and that position lets you read the corresponding title: that is how parallel lists work. Notice how convoluted the last line ends up: to sort by days you have to look up each title in its own list to find out its index. With a list of dictionaries (05-04) it will be sorted(agenda, key=lambda t: t["days"]) and that is that.
Solution 3.
def remove_task(titles, assignees, priorities, days, title):
"""Remove from the four lists the task with the given title."""
if title not in titles:
print(f"There is no task called '{title}'.")
return False
i = titles.index(title)
titles.pop(i)
assignees.pop(i)
priorities.pop(i)
days.pop(i)
return TrueIt is awkward for two reasons. First, it receives four lists just to delete one task: if tomorrow you add a field, you have to change the signature and every call. Second, its correctness depends on the four pop calls always running together and with the same index; forgetting just one is enough for every later task to end up scrambled with no error to give it away.
Conclusion
A list stores many values under a single name and solves what separate variables could not: counting, traversing, adding up, sorting and growing at run time. It is accessed by index from 0, with negative indices to count from the end and IndexError if you overshoot; it is cut up with slices [start:stop:step], where the stop is never included. It is mutable, and hence the aliasing: copy = items copies nothing, and to copy for real there are .copy(), list() and [:]. Its methods modify in place and return None, whereas len, sum, min, max and sorted return a new value. It is traversed with for, with enumerate when you need the position and with zip when there are several lists; in checks membership. Comprehensions condense the "create, traverse, add" pattern as long as the line stays readable. And with sorted(key=...), map and filter we have closed the higher-order chapter from 04-05.
EasyTask has reached v0.8 and at last it manages the studio's real work: it registers as many tasks as needed, lists them numbered and lets you change the priority or complete the nth one. But it has achieved this with five parallel lists that must be synchronised by hand, and you have already seen how fragile that is: one forgotten pop and the listing starts lying silently. The problem is not the list, it is that we are using it for something that is not its job: a list stores many things of the same kind, and what we need is to group the different fields of a single task under one name. That arrives in Dictionaries and sets and is rounded off in Tuples and nested structures. Before that, in Strings, we are going to take a close look at the other sequence you have been using since module 2 without seeing it as such —text— and squeeze its methods dry: the titles, the assignees and the task codes of Alba Studio are all strings, and there is a lot to get out of them.
Fundamentals of Programming
Module 1: Introduction to Programming
- What is programming?
- History of programming
- Programming languages
- Development environments
- From problem to algorithm
Module 2: Core Concepts
- Variables and data types
- Operators and expressions
- Input and output
- Type conversion and data validation
Module 3: Control Structures
Module 4: Functions and Procedures
- Defining and using functions
- Parameters and return values
- Variable scope
- Breaking a program down into functions
- Functions as values: lambda and higher order
Module 5: Data Structures
- Lists and arrays
- Strings
- Dictionaries and sets
- Tuples and nested structures
- Saving data to files: text, CSV and JSON
Module 6: Basic Algorithms
Module 7: Objects and Code Organisation
- From data to objects: classes and instances
- Attributes, methods and the constructor
- Collections of objects
- Modules, packages and imports
Module 8: Good Practices and Tools
- Documentation and comments
- Debugging and error handling
- Version control
- Automated testing
- Style, readability and refactoring
