Към съдържанието
wannacode

Списъчни изрази и генератори

Проблем

Отчет в цикъл

Поръчките вече имат и град:

Order
class Order:
def __init__(self, customer, city, total):
self.customer = customer
self.city = city
self.total = total
orders = [
Order("Мария", "София", 75.50),
Order("Георги", "Варна", 12.90),
Order("Елена", "София", 40.00),
Order("Димитър", "Пловдив", 120.00),
Order("Ана", "Варна", 64.00),
]

Искаш клиентите с поръчки поне 50 €, подредени по азбучен ред:

result = []
for o in orders:
if o.total >= 50:
result.append(o.customer)
result.sort()
print(result)
Изход
['Ана', 'Димитър', 'Мария']

Шест реда, а въпросът е един. Кодът казва как: направи празен списък, обиколи, провери, добави, подреди. За да разбереш какво прави, трябва да го изпълниш наум. Добавиш ли „само първите трима“ или „без повторения“, цикълът ще расте.

Идеята

Списъчен израз: какво, а не как

Същият отчет, на един ред:

result = sorted([o.customer for o in orders if o.total >= 50])
print(result)
Изход
['Ана', 'Димитър', 'Мария']

[o.customer for o in orders if o.total >= 50] се казва списъчен израз (на английски list comprehension). Чете се почти като изречение: „клиентът на всяка поръчка o от orders, ако сумата ѝ е поне 50“. Частите са:

  • for o in orders: откъде идват елементите;
  • if o.total >= 50: кои остават. Това е условие, както в filter. Може и да липсва;
  • o.customer в началото: в какво се превръща всеки елемент, както в map;
  • квадратните скоби: резултатът е нов списък.

sorted(...) подрежда и също връща нов списък. Списъкът orders не се променя.

По-точно, за любопитните
  • Същите стъпки ги има и като функции с ламбди от предишния урок: filter и map. Резултатът е същият, но се чете отвътре навън, затова в Python обикновено се пише списъчен израз:
result = sorted(map(lambda o: o.customer, filter(lambda o: o.total >= 50, orders)))
print(result)
Изход
['Ана', 'Димитър', 'Мария']
  • Ако изразът е единственият параметър на функция, квадратните скоби може да се пропуснат: sorted(o.customer for o in orders if o.total >= 50) дава същото. Тогава изразът е генератор. За него е следващата карта.

Виж го

Какво минава през филтъра

Тук стъпките са два генератора: изрази в кръгли скоби вместо в квадратни. Генераторът не прави списък. Той дава стойностите една по една, когато някой ги поиска, и чак тогава ги смята.

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))
Стъпка по стъпка, с думи
  1. Пет цени. Двата генератора само подготвят пътя: още нищо не е минало.
  2. 12 не минава if p >= 50 и отпада. До превръщането в текст не стига.
  3. 75 минава if, f"{p} €" го превръща в текста „75 €“, list() го събира.
  4. 40 също отпада. Всеки елемент минава целия път, преди да тръгне следващият.
  5. 130 и 58 минават. Списъкът prices не се променя.
  6. Резултатът е нов списък с три текста.

Петте цени тръгват една по една. 12 стига до проверката if p >= 50 и отпада. 75 минава, вторият генератор го прави на текста „75 €“, и list() го събира. После 40 отпада, а 130 и 58 минават.

Всеки елемент изминава целия път, преди да тръгне следващият. И нищо не тръгва, докато list() не поиска стойностите.

По-точно, за любопитните

Това се казва мързеливо изпълнение. Генераторът само записва какво трябва да се направи. Виж го с отпечатване във всяка стъпка:

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("Генераторът е готов, нищо не е минало през него.")
print(list(stream))
Изход
Генераторът е готов, нищо не е минало през него.
filter 12
filter 75
map 75
filter 40
[150]
  • Редът „Генераторът е готов“ е отпечатан, преди през генератора да е минал и един елемент.
  • map 75 е веднага след filter 75: елементите не чакат всички да минат проверката.
  • С квадратни скоби същият израз е списък и се смята веднага, на реда, където е написан:
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("Списъкът е готов.")
print(now)
Изход
filter 12
filter 75
map 75
filter 40
Списъкът е готов.
[150]
  • Затова от генератор може да се вземат само първите няколко стойности, без да се проверяват останалите. islice от модула itertools спира, щом вземе колкото трябва:
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 75
filter 130
[75, 130]

Идеята

Сбор, брой, средно

Резултатът може да е число вместо списък. Сборът на четните числа:

numbers = [1, 2, 3, 4, 5]
total = sum(n for n in numbers if n % 2 == 0)
print(f"Сума на четните числа: {total}")
Изход
Сума на четните числа: 6
sum събира числата, които получи. Генераторът в нея е без допълнителни скоби, защото е единственият ѝ параметър.

Същото за поръчките:

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 0
max_total = max((o.total for o in orders), default=0)
print(f"Поръчки над 50 €: {big}")
print(f"Оборот: {revenue:.2f} €")
print(f"Средно: {average:.2f} €")
print(f"Най-голяма: {max_total:.2f} €")
Изход
Поръчки над 50 €: 3
Оборот: 312.40 €
Средно: 62.48 €
Най-голяма: 120.00 €
  • len дава броя. Тук броим елементите на списъчен израз.
  • Средното е сборът, делено на броя. Ако поръчки няма, делението на 0 спира програмата, затова if orders else 0.
  • max на празен списък спира с ValueError. default=0 казва какво да е, ако стойност няма. Това е следващата идея.
По-точно, за любопитните

С празен списък:

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)
Изход
0
0
ValueError: max() iterable argument is empty
ZeroDivisionError: division by zero

Идеята

Подреди, отрежи, без повторения

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"Маса {i}")
Изход
[2, 3, 4, 5]
['Димитър', 'Мария', 'Ана']
Маса 1
Маса 2
Маса 3
  • set(...) маха повторенията: в множеството всяка стойност е веднъж. Редът в множеството не е гарантиран, затова после идва sorted.
  • sorted(orders, key=..., reverse=True) подрежда от най-голямата поръчка, с key от предишния урок.
  • [:3] взима първите три елемента. Това е отрязък (slice): от началото до номер 3, без него. Заедно със sorted това е „топ 3“.
  • range(1, 4) дава числата 1, 2, 3. Краят не се включва.
По-точно, за любопитните

Ако трябва да махнеш повторенията, но да запазиш реда на първата поява, dict.fromkeys прави речник с ключовете в реда на добавяне:

ratings = [5, 3, 5, 4, 2, 4, 5]
print(list(dict.fromkeys(ratings)))
Изход
[5, 3, 4, 2]

Пробвай

Кое стига до края?

Какво ще отпечата програмата?

drinks = ["кафе", "чай", "кафе", "вода", "чай", "сок"]
result = [d.upper() for d in sorted(set(drinks))[:3]]
print(result)
Отговор

Идеята

None: резултат, който може да липсва

Коя е най-голямата поръчка? Ако списъкът е празен, отговор няма. Тогава резултатът е None: „нищо“. Но викащият трябва да помни да провери. Забрави ли, програмата спира с AttributeError някъде по-късно.

Java за това има Optional: кутия, в която има стойност или е празна. В Python такава кутия няма. Липсата е None, а проверката е твоя работа, на мястото, където резултатът се ползва:

biggest = max(orders, key=lambda o: o.total, default=None)
if biggest is not None:
print(f"Най-голяма: {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 "няма такава")
Изход
Най-голяма: Димитър
False
няма такава
  • max(..., default=None) връща None, вместо да спре, ако списъкът е празен.
  • next(генератор, None) дава първата стойност на генератора или None, ако такава няма. Това е „първата поръчка над 500 €“, без да се проверяват останалите.
  • is not None проверява дали има стойност. С None се сравнява с is, а не с ==: така го иска PEP 8.
  • x if условие else y дава резервата, ако стойност няма.

Твоите функции също връщат None, когато нямат отговор:

def nickname(user):
# Връща прякора или None, ако човекът няма.
return "Мими" if user == "maria" else None
maria = nickname("maria")
if maria is not None:
print(f"Прякор: {maria}")
georgi = nickname("georgi")
print(georgi if georgi is not None else "без прякор")
Изход
Прякор: Мими
без прякор
По-точно, за любопитните
  • Ако забравиш проверката:
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 y също дава резерва, но смята за липса и празния текст, и 0. Ползвай го, само ако празният текст наистина е липса:
print(None or "без прякор")
print("" or "без прякор")
print(0 or 5)
Изход
без прякор
без прякор
5
  • any(...) и all(...) отговарят с True или False дали някой или всички елементи отговарят на условието:
print(any(o.total > 100 for o in orders))
print(all(o.total >= 10 for o in orders))
Изход
True
True

Пробвай

Намери грешката

Програмата трябва да отпечата броя на скъпите цени и самите цени. Броят излиза верен, но цените са празен списък. На кой ред е грешката?

Избери реда с грешката.

Идеята

Групиране и съединяване

Колко поръчки има от всеки град и какъв е оборотът?

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.total
for city, total in sorted(revenue_by_city.items()):
print(f"{city}: {total:.2f} €")
customers = ", ".join(sorted(o.customer for o in orders))
print(customers)
Изход
{'Варна': 2, 'Пловдив': 1, 'София': 2}
Варна: 76.90 €
Пловдив: 120.00 €
София: 115.50 €
Ана, Георги, Димитър, Елена, Мария
  • Counter от модула collections брои: за всеки град колко пъти се среща. Той е речник.
  • defaultdict(float) започва всеки нов град от 0.0 (това връща float()), а += добавя сумата.
  • ", ".join(...) съединява текстовете в един, със запетая между тях.

Речникът пази ключовете в реда на добавяне. sorted(речник.items()) дава двойките ключ–стойност подредени по ключа, затова отчетът излиза по азбука.

По-точно, за любопитните
  • За всеки град списък с имената:
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())))
Изход
{'Варна': ['Георги', 'Ана'], 'Пловдив': ['Димитър'], 'София': ['Мария', 'Елена']}
  • Речников израз прави речник от списък: {ключ: стойност for ...}. Ако ключ се повтори, последната стойност печели, без грешка (в Java toMap хвърля изключение):
last_by_city = {o.city: o.customer for o in orders}
print(last_by_city)
Изход
{'София': 'Елена', 'Варна': 'Ана', 'Пловдив': 'Димитър'}

Упражнение

Отчети със списъчни изрази

Реши задачите в редактора си.

1. Продажби по категория. Клас Sale с продукт, категория, брой и единична цена и метод revenue() (брой по цена). Сумирай оборота по категория с defaultdict(float), отпечатай категориите по азбука и общия оборот. Продажбите: кафе на зърна, напитки, 12 бр. по 18,50 €; чай, напитки, 30 по 4,20 €; шоколад, сладки, 40 по 2,80 €; бисквити, сладки, 25 по 3,10 €; мед, храни, 8 по 12 €; пак кафе на зърна, 5 по 18,50 €.

Изход
Напитки: 440.50 €
Сладки: 189.50 €
Храни: 96.00 €
Общо: 726.00 €

2. Топ 3 продукта. Със същите продажби намери трите продукта с най-голям оборот. Кафето е в две продажби, затова първо групирай по продукт и сумирай, после подреди записите на речника по стойност. Подсказка: sorted(by_product.items(), key=lambda item: item[1], reverse=True)[:3]: всеки елемент на items() е двойка (продукт, оборот).

Изход
Кафе на зърна: 314.50 €
Чай: 126.00 €
Шоколад: 112.00 €

3. Средна оценка. Прочети един ред с оценки, разделени с интервал. Отпечатай средната, най-високата и колко са отличните (5,50 и нагоре). Подсказка: [float(g) for g in input().split()].

Вход
4.50 5.75 6.00 3.00 5.50
Изход
Средно: 4.95
Най-висока: 6.00
Отлични: 3

4. Думите в текст. Прочети ред текст и отпечатай думите в него без повторения, по азбучен ред, с малки букви, разделени със запетая. Намери поредиците от букви с re.findall(r"[^\W\d_]+", text): \w е буква, цифра или _, а [^\W\d_] е „буква, но не цифра и не _“.

Вход
Кафе с мляко, кафе без захар. Чай с мед!
Изход
без, захар, кафе, мед, мляко, с, чай

Още задачи

  • Най-дългата дума. С max(words, key=len) намери най-дългата дума в изречение. Какво ще стане за празен ред и как помага default?
  • Средно по клас. Ученици с име, клас и оценка: събери оценките за всеки клас в defaultdict(list) и отпечатай средната за всеки клас.
  • Големи и малки поръчки. С any провери дали има поръчка над 1000 €, а с all дали всички са поне 10 €.

Тест

Провери се

1. Какво става със списъка orders след [o for o in orders if o.total > 50]?
2. Каква е разликата между [p * 2 for p in prices] и (p * 2 for p in prices)?
3. Какво прави max(orders, key=lambda o: o.total) с празен списък?
4. Защо отчетът по градове е с sorted(revenue_by_city.items())?
5. expensive = (p for p in prices if p >= 50); print(len(list(expensive))); print(list(expensive)). Какво отпечатва вторият ред?

Създаден от Иво Гергов като част от кампанията на Evo Tech за достъпно обучение по програмиране ·