Python з нуля · Блок 3 · Тема 13

Включення: опиши результат

Найчастіший цикл на світі — той, що бере одну колекцію й будує з неї другу. Python дозволяє записати його одним рядком. Але це не «коротший цикл»: це інший спосіб думати про задачу.

У темі 12, коли ми розбирали пастку зі зміною списку під час обходу, серед трьох правильних рецептів був такий: «збираємо новий список замість того, щоб псувати старий». І поруч стояло обіцяння: саме він у темі 13 перетвориться на один рядок. Ось той рядок. А ще звідси нарешті стане зрозумілим замовляння [[0] * 3 for _ in range(3)], яке ми двічі просили просто запамʼятати.

Наскрізний приклад лишається тим самим кошиком покупок, що й у двох попередніх темах:

покупки = ["хліб", "молоко", "яблука", "мед", "сіль"] ціни = {"хліб": 28.5, "молоко": 32.0, "яблука": 19.9, "мед": 145.0, "сіль": 12.0} кількості = [2, 1, 3, 1, 1] цінник = [28.5, 32.0, 19.9, 145.0, 12.0] # ті самі ціни списком

01 / МотивНайчастіший цикл із усіх

Придивись до циклів, які ти вже писав, і ти помітиш, що страшенно багато з них влаштовані однаково. Заводимо порожній список. Обходимо щось. На кожному кроці щось рахуємо й додаємо в кінець. Друкуємо результат.

подвоєні = [] for ціна in цінник: подвоєні.append(ціна * 2) print(подвоєні) # [57.0, 64.0, 39.8, 290.0, 24.0]

Три рядки, з яких справжня думка тільки в одному — у тому, де ціна * 2. Решта — обслуговування: порожній список, який треба не забути створити, і .append(), який щоразу однаковий. Це помітно не лише тобі: такий цикл трапляється в коді так часто, що для нього зробили окрему форму запису.

подвоєні = [ціна * 2 for ціна in цінник]

Це спискове включення (list comprehension). Результат буквально той самий список, до останнього байта — це не «майже те саме» й не «швидший варіант для простих випадків». Той самий.

Найважливіше тут — не економія двох рядків. Важливо, що саме ти описуєш. Цикл описує процес: створи ящик, візьми елемент, поклади в ящик, візьми наступний. Включення описує результат: «список подвоєних цін для кожної ціни з цінника». Перше — інструкція складальнику. Друге — визначення множини, майже як у математиці, де ми писали б {2c : c ∈ цінник}.

Через це різниця й у тому, як ти читаєш чужий код. Побачивши цикл, ти мусиш виконати його в голові, щоб зрозуміти, що вийде. Побачивши включення, ти читаєш відповідь одразу — вона стоїть першою, ще до слова for. Перемкни задачі в інтерактиві й подивись, яка частина циклу куди переїхала:

Інтерактив 1 · Цикл ↔ включення

Той самий алгоритм двома записами. Стрілки показують, що куди переїхало; кольори однакові в обох варіантах.

рядків циклом
рядків включенням
на вході
на виході
Дивись на порядок. У циклі вираз ціна * 2 стоїть останнім — він у тілі, після всього. У включенні той самий вираз стоїть першим. Це і є та перестановка, через яку включення читається як відповідь: спершу «що вийде», а вже потім «з чого».

02 / АнатоміяЧотири частини й порядок читання

Розберімо запис на складники. Частин рівно чотири, і третя з них необовʼязкова:

[ ціна * 2 for ціна in цінник if ціна < 100 ] ↑ ↑ ↑ ↑ вираз імʼя змінної джерело умова (що кладемо) (як звемо) (звідки) (необовʼязкова)

А тепер про порядок, який спершу збиває з пантелику. Пишемо ми зліва направо, а виконується це справа наліво: спершу Python бере джерело, потім відсіює за умовою, і лише для тих, хто пройшов, обчислює вираз. Тобто прочитати включення вголос правильно так:

«візьми кожну ціну з цінника, залиш ті, що менші за 100, і для кожної поклади в список ціну помножену на 2»

Це дзеркально до того, як воно записане. Незручно? Так. Але саме тому вираз стоїть попереду: у 90% випадків тебе цікавить що вийде, а не звідки. Погратись зі складниками найпростіше на конструкторі — збери свій вираз і подивись, що вийде. Зверни увагу на випадок «без умови»: if справді можна не писати взагалі, і це найчастіша форма.

Інтерактив 2 · Конструктор включення

Обери джерело, перетворення й умову — вираз збереться сам, а знизу видно, що він поверне.

джерело

вираз

умова

на вході
на виході
Порядок кнопок — не порядок виконання. Ти обираєш вираз другим, а Python застосовує його останнім: спочатку джерело, потім умова, і лише потім вираз — і тільки для тих елементів, які умову пройшли. Тому важкі обчислення у виразі дешевші, ніж здається: відсіяні елементи до нього не доходять.

03 / Фільтрif у кінці: список коротшає

Умова в кінці включення робить рівно одне: вирішує, чи потрапить елемент у результат. Це прямий аналог continue зі звичайного циклу.

# цикл дешеві = [] for ціна in цінник: if ціна < 100: дешеві.append(ціна) # включення дешеві = [ціна for ціна in цінник if ціна < 100] >>> дешеві [28.5, 32.0, 19.9, 12.0] # було 5 елементів, стало 4

Тут добре видно, чому вираз і змінна циклу часто збігаються: ми нічого не перетворюємо, ми відбираємо. Запис [ціна for ціна in цінник if …] виглядає надлишковим, але він чесний — вираз є завжди, просто цього разу він тривіальний.

Умова може бути будь-якою — усе, що ми вчили в темі 11, працює тут без змін:

[н for н in покупки if len(н) > 4] # ['молоко', 'яблука'] [н for н in покупки if "м" in н] # ['молоко', 'мед'] [ц for ц in цінник if ц < 100 and ц > 20] # [28.5, 32.0] [н for н in покупки if н not in ціни] # [] — таких немає

І тут же — головна плутанина цієї теми, через яку люди годинами шукають, «чому воно не працює».

04 / ТернарПеретворення: умова на початку

Уяви іншу задачу. Не «залиш дешеві», а «обріж дорогі до 100» — тобто ціни понад сотню замінити на сотню, а решту лишити як є. Кількість позицій має лишитись тією самою: ми нічого не викидаємо з кошика.

Спроба написати це через if у кінці приречена: там if взагалі не вміє нічого замінювати, він лише впускає або не впускає. Потрібен тернарний вираз — той самий a if умова else b з теми 11. І стоїть він на місці виразу, тобто на початку:

зі_стелею = [ціна if ціна < 100 else 100 for ціна in цінник] >>> зі_стелею [28.5, 32.0, 19.9, 100, 12.0] # п'ять було, п'ять і лишилось

Два записи виглядають майже однаково, а роблять принципово різне:

записде стоїть ifщо робитьдовжина результату
[x for x in дані if умова]у кінціфільтрує: пропускає або німенша або рівна
[a if умова else b for x in дані]на початкуперетворює: обирає значеннязавжди та сама

Запамʼятати легко за одним правилом: else буває тільки в тернарі. Якщо в тебе є else — він мусить стояти перед for. Якщо else немає — if іде після for. Спроба поставити фільтр із else в кінець дає SyntaxError ще до запуску:

>>> [ціна for ціна in цінник if ціна < 100 else 0] File "<stdin>", line 1 [ціна for ціна in цінник if ціна < 100 else 0] ^^^^ SyntaxError: expected 'else' after 'if' expression

Повідомлення на перший погляд дивне («очікував else після if» — а він же й стоїть!), але воно правильне: Python побачив if і спробував прочитати його як початок тернарного виразу, а тернар не може стояти в хвості. Посунь повзунок і подивись на обидва варіанти одночасно — на тих самих даних і з тією самою межею:

Інтерактив 3 · Фільтр проти перетворення

Одна межа, одні дані, два записи. Угорі список коротшає, унизу — ні.

фільтр дав
тернар дав
відсіяно
замінено
Крайні положення повзунка — найкорисніші. На межі 10 фільтр віддає порожній список, а тернар — пʼять однакових десяток: жоден елемент не зник, просто всі замінились. На межі 150 навпаки: фільтр пропускає всіх, тернар не замінює нікого, і обидва записи дають однаковий результат — саме тому їх так легко переплутати на «зручних» даних.
Обидва разом теж можна. Запис [ц if ц < 100 else 100 for ц in цінник if ц > 15] цілком законний: перший if — тернар у виразі, другий — фільтр. Він і фільтрує, і перетворює. Але прочитати його з першого разу вже важко — і це чудовий привід перейти до розділу 07.

05 / ДужкиТри дужки — три типи

Включення — це не «щось про списки». Це форма запису, і те, який тип вийде, визначають дужки та наявність двокрапки у виразі. Три варіанти:

[len(н) for н in покупки] # список list {len(н) for н in покупки} # множина set {н: len(н) for н in покупки} # словник dict

Правило просте:

Найкраще різницю видно на даних із повторами. Візьмімо список, у якому «мед» і «хліб» трапляються двічі, і застосуймо до нього той самий вираз len(н), міняючи лише дужки:

Інтерактив 4 · Три дужки — три типи

Джерело й вираз незмінні. Змінюються тільки дужки — і разом із ними тип результату.

тип
елементів
зникло через збіги
Пʼять назв на вході — і три різні відповіді. Список бере всі пʼять довжин, множина стискає їх до двох різних значень, словник — до трьох записів, бо ключі мусять бути унікальні. Жодного «видалення дублікатів» ми не писали: воно випливає з природи типу.

Словникове включення особливо корисне в парі зі zip — так із двох паралельних списків народжується словник в один рядок:

склад = {назва: скільки for назва, скільки in zip(покупки, кількості)} >>> склад {'хліб': 2, 'молоко': 1, 'яблука': 3, 'мед': 1, 'сіль': 1}

Тут працює розпакування з теми 07: після for стоять два імені, бо zip віддає пари. Так само можна перевернути наявний словник — поміняти ключі й значення місцями:

навпаки = {ціна: назва for назва, ціна in ціни.items()}
Порожні фігурні дужки — це словник. Історична несправедливість: {} означає порожній словник, а не множину, бо словники зʼявилися в мові раніше. Порожня множина — тільки set(). На включення це не впливає (там дужки ніколи не порожні), але поруч плутають постійно.

06 / ВкладеніДва for в одному включенні

У включенні може бути більше одного for. Це рівно те саме, що вкладені цикли з теми 12, і порядок for-ів такий самий, як у циклах: зліва направо = зовні всередину.

Класична задача — «розплющити» (flatten) список списків у один плаский список:

матриця = [[1, 2, 3, 4], [5, 6, 7, 8], [9, 10, 11, 12]] # цикл пласкі = [] for рядок in матриця: for значення in рядок: пласкі.append(значення) # включення — for-и в тому самому порядку пласкі = [значення for рядок in матриця for значення in рядок] >>> пласкі [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]

Поклади ці два записи поруч і прочитай зверху вниз проти зліва направо — вони збігаються слово в слово: for рядок in матриця, потім for значення in рядок, потім вираз. Різниця лише в тому, що у включенні вираз переїхав уперед.

Звідси випливає й правило, чому переставити for-и не можна. Другий for бере джерело з першого: рядок існує лише тому, що його щойно створив зовнішній for. Якщо поміняти їх місцями, ім'я рядок згадається раніше, ніж зʼявиться, — і ти отримаєш NameError. Перевір обидва режими:

Інтерактив 5 · Вкладене включення

Крок за кроком: який for зараз працює і в якому порядку заповнюється результат.

крок
додано значень
Порядок обходу — рядок за рядком. Зовнішній for робить три кроки, внутрішній на кожному з них — чотири; разом 3 × 4 = 12 значень, і всі вони лягають у результат у тому порядку, в якому їх зустріли. У другому режимі коду взагалі не існує: рядок згадано раніше, ніж створено.

Два for — це не те саме, що вкладене включення

Є ще одна форма, яку легко переплутати з попередньою. Якщо треба не розплющити матрицю, а побудувати її, включення ставлять усередину виразу:

таблиця = [[0] * 3 for _ in range(3)] >>> таблиця [[0, 0, 0], [0, 0, 0], [0, 0, 0]]

Ось воно — те замовляння з теми 06. Тепер його можна прочитати: «для кожного з трьох кроків поклади в список новий рядок [0] * 3». Ключове слово — новий: вираз обчислюється заново на кожному кроці, тому три рядки — три різні списки. А в поламаному варіанті [[0] * 3] * 3 множення копіює посилання на один-єдиний список, і зміна в одному рядку відображається в усіх трьох. Саме тому там і потрібне включення.

Підкреслення _ — це звичайне імʼя змінної, не синтаксис. Домовленість така: якщо значення нам не потрібне, а потрібна лише кількість повторень, називаємо змінну _, щоб читач одразу бачив: «сюди не дивись, воно не використовується».

07 / СмакКоли включення шкодить

Головний розділ теми — і єдиний, де немає правильної відповіді у вигляді синтаксису. Включення — інструмент читабельності. Щойно воно перестає бути читабельним, воно втрачає весь сенс і лишає тільки шкоду.

Правило, яке працює найкраще, звучить так: якщо включення не читається вголос за один подих — пиши цикл. Не «за один рядок», не «за 79 символів», а саме вголос і за один подих, бо це перевіряє те, що справді важливо: чи тримає читач у голові всю конструкцію одразу.

Три ознаки того, що межу перейдено:

  1. Довжина. Один рядок, що не влазить у ширину екрана й переноситься редактором, уже не «компактний запис».
  2. Вкладеність. Два for ще терпимо, три — майже ніколи. Включення всередині виразу іншого включення — окремий біль: зовнішнє й внутрішнє читаються в різних напрямках.
  3. Кількість рухомих частин. Порахуй разом: for-и, if-и, виклики функцій і розпакування в змінній циклу. До трьох — нормально; чотири й більше — цикл буде коротшим для розуміння, навіть якщо довшим у рядках.

Посунь повзунок складності й подивись, як той самий тип виразу з кожним кроком втрачає читабельність — а поруч стоїть чесний цикл, який усі ці кроки витримує без жодних зусиль:

Інтерактив 6 · Межа читабельності

Один повзунок нарощує складність. Нижче — той самий код звичайним циклом; його довжина росте набагато повільніше за складність.

рухомих частин
символів
for / if
рядків циклом
Зверни увагу на нижню половину. Коли включення розростається з 23 символів до 75, цикл росте всього з трьох рядків до шести — і жоден із цих рядків не стає складнішим. Ось чому «довше» й «важче» — різні речі: цикл платить рядками, включення платить щільністю.
Проміжна змінна рятує майже завжди. Замість того щоб втискати все в один рядок, розбий на два: спершу дорогі = [ц for ц in цінник if ц > 100], потім [round(ц * 1.2, 2) for ц in дорогі]. Два простих включення читаються легше за одне складне — і кожне з них має імʼя, яке пояснює, що це.

08 / Побічний ефектВключення, результат якого нікому не потрібен

Типова помилка новачка, який щойно вивчив включення й хоче застосовувати його всюди:

[print(назва) for назва in покупки]хліб молоко яблука мед сіль

Надрукувалось! Виглядає як робочий код. І він справді працює — але робить не те, що написано. Написано «збери список із результатів print(назва)». Функція print нічого не повертає, тобто повертає None, — і Python слухняно збирає список із пʼяти None, після чого викидає його геть, бо результат нікуди не присвоїли:

>>> сміття = [print(назва) for назва in покупки] хліб молоко яблука мед сіль >>> сміття [None, None, None, None, None]

Ми виділили памʼять під список, заповнили його порожнечею й одразу забули. На пʼятьох покупках це не помітно. На мільйоні рядків з файлу — це мільйон None у памʼяті, які нікому не потрібні.

Але головна шкода не в памʼяті, а в брехні. Дужки […] обіцяють читачеві, що тут будують список. Читач шукає, куди цей список подівся, — і не знаходить. Правило просте й категоричне:

Включення — щоб отримати колекцію. Якщо результат тебе не цікавить, а цікавить дія (друк, запис у файл, відправка запиту), — пиши звичайний for. Він рівно для цього й існує. Ознака помилки проста: включення, яке не стоїть праворуч від = і не передається у функцію, майже завжди зайве.
# так робити не треба [print(назва) for назва in покупки] # так треба for назва in покупки: print(назва)

09 / УсерединіОбласть видимості й швидкість

Розділ для тих, кому мало «працює — і добре». Три речі, які пояснюють, чому включення поводиться саме так.

Змінна циклу не тече назовні

У звичайному циклі змінна після завершення лишається живою — ми бачили це в темі 12. У включенні — ні:

>>> квадрати = [ч ** 2 for ч in range(3)] >>> ч Traceback (most recent call last): File "<stdin>", line 1, in <module> NameError: name 'ч' is not defined

Включення має власну область видимості: ім'я ч живе тільки всередині квадратних дужок і зникає разом із ними. Це рятує від класичної біди — випадкового затирання змінної, яка вже була в тебе під тим самим іменем.

У Python 2 було інакше: включення виконувалось у тій самій області, що й навколишній код, і змінна циклу справді витікала назовні. Це вважалось помилкою дизайну й було виправлено в Python 3. Якщо колись зустрінеш старий код, який розраховує на витік, — знай, що на сучасному Python він мовчки поводитиметься інакше.

Один виняток є. Оператор «морж» := усередині включення навмисно присвоює в зовнішню область — це його заявлена поведінка, а не витік. Але писати такий код без крайньої потреби не варто: він порушує саме ту гарантію ізоляції, заради якої область і робили.

Чому включення трохи швидше

Включення справді швидше за еквівалентний цикл із append — але йдеться про десятки відсотків, а не про рази. Заміряймо на списку з 200 000 чисел:

$ python3 -m timeit -s "дані=list(range(200000))" "[x*2 for x in дані]" 20 loops, best of 5: 10.5 msec per loop $ python3 -m timeit -s "дані=list(range(200000))" " результат=[] for x in дані: результат.append(x*2)" 20 loops, best of 5: 12.5 msec per loop

Приблизно на 20% швидше. Причина одна й дуже конкретна: у циклі на кожному елементі Python має знайти атрибут append у списку та викликати його як звичайну функцію. У включенні цієї роботи немає — компілятор ставить окрему інструкцію LIST_APPEND, яка одразу кладе значення в список без пошуку імені й без виклику.

Звідси й практичний висновок, зворотний до очікуваного: не переписуй цикли на включення заради швидкості. 20% на мільйонах елементів помітні, на сотнях — ні, а читабельність ти втратиш одразу. Швидкість тут приємний побічний ефект, а не причина.

Для тих, хто цікавиться версіями. До Python 3.11 включення компілювалось у окрему функцію, яку інтерпретатор створював і викликав щоразу, — звідси й ізольована область видимості. У Python 3.12 їх «вбудували» в навколишній код і скасували створення кадру стека: короткі включення від цього помітно пришвидшились. Ізоляція імені при цьому лишилась — її тепер забезпечують збереженням і поверненням значення, а не окремою функцією.

А якщо список не потрібен зовсім?

Останнє. Заміни квадратні дужки на круглі — і отримаєш не список, а генераторний вираз (generator expression):

>>> (ціна * 2 for ціна in цінник) <generator object <genexpr> at 0x7f24...>

Він не будує нічого одразу, а віддає значення по одному на вимогу — рівно як range з теми 12, який теж не список. Різниця у витратах драматична:

>>> import sys >>> sys.getsizeof(x * 2 for x in range(1_000_000)) 200 # байтів >>> sys.getsizeof([x * 2 for x in range(1_000_000)]) 8448728 # теж байтів

Найчастіше його пишуть просто всередині виклику функції, і тоді дужки навіть не дублюють: sum(ціна * к for ціна, к in zip(цінник, кількості)) порахує суму, жодного разу не створивши проміжного списку. Деталі — у темі 30 про ітератори й генератори; поки що достатньо знати, що кругла дужка тут не одруківка.

10 / ПідсумокЩо забрати з теми

У темі 14 ми нарешті дійдемо до функцій — і одразу побачимо, як вони змінюють усе сказане тут. Зʼявиться lambda, зʼявляться map, filter і sorted(key=…), і виникне питання, яке ділить спільноту досі: коли брати включення, а коли map. Спойлер: у Python майже завжди включення — і тепер ти вже знаєш чому.

Далі в практиці. У practice.ipynb ти перетвориш і відфільтруєш списки, збереш словник із двох списків через zip, розплющиш матрицю, а наостанок навмисно напишеш нечитабельне включення й перепишеш його циклом. Кожне включення ми звіримо з еквівалентним циклом через assert — щоб побачити на власному екрані, що це справді той самий результат.

Далі в темі

Теорію прочитано. Тепер закріпи її на практиці.