List comprehensions and generators
Problem
A report in a loop
Orders now have a city too:
class Order: def __init__(self, customer, city, total): self.customer = customer self.city = city self.total = totalorders = [ Order("Maria", "Sofia", 75.50), Order("Georgi", "Varna", 12.90), Order("Elena", "Sofia", 40.00), Order("Dimitar", "Plovdiv", 120.00), Order("Ana", "Varna", 64.00),]You want the customers with orders of at least €50, in alphabetical order:
result = []for o in orders: if o.total >= 50: result.append(o.customer)result.sort()print(result)['Ana', 'Dimitar', 'Maria']Six lines, and the question is just one. The code says how: make an empty list, loop, check, add, sort. To understand what it does, you have to run it in your head. Add "only the first three" or "no duplicates", and the loop will grow.
The idea
A list comprehension: what, not how
The same report, on one line:
result = sorted([o.customer for o in orders if o.total >= 50])print(result)['Ana', 'Dimitar', 'Maria'][o.customer for o in orders if o.total >= 50] is called a list comprehension. It reads almost like a sentence: "the customer of each order o in orders, if its total is at least 50". The parts are:
for o in orders: where the elements come from;if o.total >= 50: which ones stay. This is a condition, as infilter. It can also be left out;o.customerat the start: what each element turns into, as inmap;- the square brackets: the result is a new list.
sorted(...) sorts and also returns a new list. The list orders does not change.
More precisely, if you are curious
- The same steps also exist as functions with lambdas from the previous lesson:
filterandmap. The result is the same, but it reads from the inside out, which is why in Python you usually write a list comprehension:
result = sorted(map(lambda o: o.customer, filter(lambda o: o.total >= 50, orders)))print(result)['Ana', 'Dimitar', 'Maria']- If the expression is the only parameter of a function, the square brackets can be left out:
sorted(o.customer for o in orders if o.total >= 50)gives the same. Then the expression is a generator. The next card is about it.
See it
What gets through the filter
prices = [12, 75, 40, 130, 58]expensive = (p for p in prices if p >= 50)labels = (f"€{p}" for p in expensive)print(list(labels))Step by step, in words
- Five prices. The two generators only set up the path: nothing has gone through yet.
- 12 does not pass if p >= 50 and drops out. It never gets turned into text.
- 75 passes if, f"€{p}" turns it into the text "€75", list() collects it.
- 40 drops out too. Each element goes the whole way before the next one starts.
- 130 and 58 pass. The list prices does not change.
- The result is a new list with three strings.
The five prices go one at a time. 12 gets to the check if p >= 50 and drops out. 75 passes, the second generator turns it into the text "€75", and list() collects it. Then 40 drops out, and 130 and 58 pass.
Each element goes the whole way before the next one starts. And nothing starts until list() asks for the values.
More precisely, if you are curious
This is called lazy evaluation. The generator only records what needs to be done. See it with a print in every step:
def check(p): print("filter", p) return p >= 50
def double(p): print("map", p) return p * 2
stream = (double(p) for p in [12, 75, 40] if check(p))print("The generator is ready, nothing has gone through it yet.")print(list(stream))The generator is ready, nothing has gone through it yet.filter 12filter 75map 75filter 40[150]- The line "The generator is ready" is printed before a single element has gone through the generator.
map 75comes right afterfilter 75: the elements do not wait for all of them to pass the check.- With square brackets the same expression is a list and is computed right away, on the line where it is written:
def check(p): print("filter", p) return p >= 50
def double(p): print("map", p) return p * 2
now = [double(p) for p in [12, 75, 40] if check(p)]print("The list is ready.")print(now)filter 12filter 75map 75filter 40The list is ready.[150]- That is why you can take just the first few values from a generator without checking the rest.
islicefrom theitertoolsmodule stops as soon as it has taken as many as it needs:
from itertools import islice
def check(p): print("filter", p) return p >= 50
stream = (p for p in [75, 130, 58, 12, 99] if check(p))print(list(islice(stream, 2)))filter 75filter 130[75, 130]The idea
Sum, count, average
The result can be a number instead of a list. The sum of the even numbers:
numbers = [1, 2, 3, 4, 5]total = sum(n for n in numbers if n % 2 == 0)print(f"Sum of the even numbers: {total}")Sum of the even numbers: 6sum adds up the numbers it gets. The generator inside it has no extra brackets, because it is its only parameter.The same for the orders:
big = len([o for o in orders if o.total >= 50])revenue = sum(o.total for o in orders)average = revenue / len(orders) if orders else 0max_total = max((o.total for o in orders), default=0)
print(f"Orders over €50: {big}")print(f"Revenue: €{revenue:.2f}")print(f"Average: €{average:.2f}")print(f"Largest: €{max_total:.2f}")Orders over €50: 3Revenue: €312.40Average: €62.48Largest: €120.00lengives the count. Here we count the elements of a list comprehension.- The average is the sum divided by the count. If there are no orders, dividing by 0 stops the program, hence
if orders else 0. maxof an empty list stops with aValueError.default=0says what it should be if there is no value. That is the next idea.
More precisely, if you are curious
With an empty list:
prices = []print(sum(prices))print(max(prices, default=0))try: print(max(prices))except ValueError as e: print("ValueError:", e)try: print(sum(prices) / len(prices))except ZeroDivisionError as e: print("ZeroDivisionError:", e)00ValueError: max() iterable argument is emptyZeroDivisionError: division by zeroThe idea
Sort, cut, no duplicates
ratings = [5, 3, 5, 4, 2, 4, 5]print(sorted(set(ratings)))
top = [o.customer for o in sorted(orders, key=lambda o: o.total, reverse=True)[:3]]print(top)
for i in range(1, 4): print(f"Table {i}")[2, 3, 4, 5]['Dimitar', 'Maria', 'Ana']Table 1Table 2Table 3set(...)removes the duplicates: in a set each value is there once. The order in a set is not guaranteed, sosortedcomes after it.sorted(orders, key=..., reverse=True)sorts from the largest order, withkeyfrom the previous lesson.[:3]takes the first three elements. This is a slice: from the start up to index 3, without it. Together withsorted, that is a "top 3".range(1, 4)gives the numbers 1, 2, 3. The end is not included.
More precisely, if you are curious
If you need to remove the duplicates but keep the order of first appearance, dict.fromkeys makes a dictionary with the keys in the order they were added:
ratings = [5, 3, 5, 4, 2, 4, 5]print(list(dict.fromkeys(ratings)))[5, 3, 4, 2]Try it
What gets to the end?
What will the program print?
drinks = ["coffee", "tea", "coffee", "water", "tea", "juice"]result = [d.upper() for d in sorted(set(drinks))[:3]]print(result)set removes the duplicates: coffee, tea, water, juice. sorted sorts them alphabetically: coffee, juice, tea, water. [:3] keeps the first three, and d.upper() makes them uppercase.The idea
None: a result that may be missing
Which order is the largest? If the list is empty, there is no answer. Then the result is None: "nothing". But the caller has to remember to check. If they forget, the program stops with an AttributeError somewhere later.
Java has Optional for this: a box that either holds a value or is empty. Python has no such box. A missing value is None, and checking it is your job, at the place where the result is used:
biggest = max(orders, key=lambda o: o.total, default=None)if biggest is not None: print(f"Largest: {biggest.customer}")
huge = next((o for o in orders if o.total > 500), None)print(huge is not None)print(huge.customer if huge is not None else "no such order")Largest: DimitarFalseno such ordermax(..., default=None)returnsNoneinstead of stopping if the list is empty.next(generator, None)gives the first value of the generator, orNoneif there is none. This is "the first order over €500", without checking the rest.is not Nonechecks whether there is a value. You compare withNoneusingis, not==: that is what PEP 8 asks for.x if condition else ygives the fallback if there is no value.
Your own functions also return None when they have no answer:
def nickname(user): # Returns the nickname, or None if the person has none. return "Mimi" if user == "maria" else Nonemaria = nickname("maria")if maria is not None: print(f"Nickname: {maria}")
georgi = nickname("georgi")print(georgi if georgi is not None else "no nickname")Nickname: Mimino nicknameMore precisely, if you are curious
- If you forget the check:
huge = next((o for o in orders if o.total > 500), None)print(huge.customer)Traceback (most recent call last): ...AttributeError: 'NoneType' object has no attribute 'customer'x or yalso gives a fallback, but it treats an empty string and0as missing too. Use it only if an empty string really means missing:
print(None or "no nickname")print("" or "no nickname")print(0 or 5)no nicknameno nickname5any(...)andall(...)answer withTrueorFalsewhether any or all of the elements match the condition:
print(any(o.total > 100 for o in orders))print(all(o.total >= 10 for o in orders))TrueTrueTry it
Find the bug
Pick the line with the bug.
A generator goes through once. list(expensive) on line 3 uses it up, and on line 4 there is nothing left in it. There is no error, which is why a slip like this is hard to catch:
Count: 2Prices: []If you need two results, make a list on line 2, with square brackets: [p for p in prices if p >= 50]. You can use it as many times as you like: len(expensive) and expensive.
The idea
Grouping and joining
How many orders are there from each city, and what is the revenue?
from collections import Counter, defaultdict
count_by_city = Counter(o.city for o in orders)print(dict(sorted(count_by_city.items())))
revenue_by_city = defaultdict(float)for o in orders: revenue_by_city[o.city] += o.totalfor city, total in sorted(revenue_by_city.items()): print(f"{city}: €{total:.2f}")
customers = ", ".join(sorted(o.customer for o in orders))print(customers){'Plovdiv': 1, 'Sofia': 2, 'Varna': 2}Plovdiv: €120.00Sofia: €115.50Varna: €76.90Ana, Dimitar, Elena, Georgi, MariaCounterfrom thecollectionsmodule counts: for each city, how many times it appears. It is a dictionary.defaultdict(float)starts each new city from0.0(that is whatfloat()returns), and+=adds the total.", ".join(...)joins the strings into one, with a comma between them.
The dictionary keeps the keys in the order they were added. sorted(dictionary.items()) gives the key and value pairs sorted by key, so the report comes out alphabetically.
More precisely, if you are curious
- For each city, a list of the names:
from collections import defaultdict
names_by_city = defaultdict(list)for o in orders: names_by_city[o.city].append(o.customer)print(dict(sorted(names_by_city.items()))){'Plovdiv': ['Dimitar'], 'Sofia': ['Maria', 'Elena'], 'Varna': ['Georgi', 'Ana']}- A dictionary comprehension makes a dictionary from a list:
{key: value for ...}. If a key repeats, the last value wins, with no error (in JavatoMapthrows an exception):
last_by_city = {o.city: o.customer for o in orders}print(last_by_city){'Sofia': 'Elena', 'Varna': 'Ana', 'Plovdiv': 'Dimitar'}Exercise
Reports with list comprehensions
1. Sales by category. A class Sale with a product, a category, a quantity and a unit price, and a method revenue() (quantity times price). Add up the revenue by category with defaultdict(float), print the categories alphabetically and the total revenue. The sales: coffee beans, drinks, 12 at €18.50; tea, drinks, 30 at €4.20; chocolate, sweets, 40 at €2.80; biscuits, sweets, 25 at €3.10; honey, food, 8 at €12; coffee beans again, 5 at €18.50.
Drinks: €440.50Food: €96.00Sweets: €189.50Total: €726.002. Top 3 products. With the same sales, find the three products with the highest revenue. The coffee is in two sales, so first group by product and add up, then sort the map's entries by value. Hint: sorted(by_product.items(), key=lambda item: item[1], reverse=True)[:3]: each element of items() is a pair (product, revenue).
Coffee beans: €314.50Tea: €126.00Chocolate: €112.003. Average grade. Read one line of grades separated by spaces. Print the average, the highest and how many are excellent (5.50 and up). Hint: [float(g) for g in input().split()].
4.50 5.75 6.00 3.00 5.50Average: 4.95Highest: 6.00Excellent: 34. The words in a text. Read a line of text and print the words in it with no duplicates, in alphabetical order, in lowercase, separated by commas. Find the runs of letters with re.findall(r"[^\W\d_]+", text): \w is a letter, a digit or _, and [^\W\d_] is "a letter, but not a digit and not _".
Coffee with milk, coffee without sugar. Tea with honey!coffee, honey, milk, sugar, tea, with, withoutMore tasks
- The longest word. With
max(words, key=len), find the longest word in a sentence. What will happen for an empty line, and how doesdefaulthelp? - Average by class. Students with a name, a class and a grade: collect the grades for each class in a
defaultdict(list)and print the average for each class. - Big and small orders. With
any, check whether there is an order over €1000, and withallwhether all of them are at least €10.
Test