Python з нуля · Блок 2 · Тема 07

Кома, а не дужки

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

У темі 06 ми навчилися складати багато значень в один список і крутити його як завгодно: додати, вставити, видалити, відсортувати. Тепер візьмемо структуру, яка з вигляду майже така сама — теж набір значень по порядку, теж доступ за індексом, — але не дозволяє змінити себе жодним рухом. Це кортеж (tuple), і незручність тут не вада, а весь сенс.

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

01 / ЗнайомствоЩо таке кортеж

Кортеж створюють, перелічивши значення через кому. Дужки навколо ставлять для читабельності:

>>> точка = (50.45, 30.52) >>> точка (50.45, 30.52) >>> type(точка) <class 'tuple'>

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

>>> точка[0] # доступ за індексом, від нуля 50.45 >>> точка[-1] # відʼємний індекс — з кінця 30.52 >>> len(точка) # довжина 2 >>> 50.45 in точка # перевірка на входження True >>> точка[0:1] # зріз кортежа — теж кортеж (50.45,)

А от усе, що змінює, зникло. Немає ані append, ані insert, ані remove, ані sort. Немає й присвоєння за індексом:

>>> точка[0] = 49.84 TypeError: 'tuple' object does not support item assignment >>> точка.append(0) AttributeError: 'tuple' object has no attribute 'append'

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

З методів у кортежа лишилося рівно два, і обидва тільки читають: точка.count(50.45) порахує, скільки разів значення трапляється, а точка.index(30.52) скаже, під яким індексом воно лежить. Два методи проти сорока в списку — це і є вся різниця в API.

Зробити кортеж зі списку (і навпаки) можна перетворенням:

>>> tuple([50.45, 30.52]) (50.45, 30.52) >>> list((50.45, 30.52)) [50.45, 30.52] >>> tuple("код") # будь-що ітероване ('к', 'о', 'д')

02 / СинтаксисКому вирішує кома

Тепер найпоширеніша пастка теми. Здається, що кортеж роблять круглі дужки. Це неправда. Дужки в Python — це насамперед групування виразу, те саме, що в математиці. Кортеж робить кома.

Звідси наслідок, на якому спотикаються всі: (5) — це просто число пʼять у дужках, як (2 + 3). Щоб отримати кортеж із одного елемента, потрібна кома: (5,). І навпаки, кома працює й без дужок: 5, — уже кортеж.

Порухай повзунок і подивись на тип праворуч. Вираз змінюється мінімально — на один символ, — а обʼєкт виходить принципово різний.

Інтерактив 1 · Кома проти дужок

Шість майже однакових рядків. Кома підсвічена рожевим — саме вона все й вирішує.

type(x)
len(x)
що зробило кортеж
Головне: (5) і (5,) відрізняються одним символом, а типи в них різні — int і tuple. Порожній кортеж () — єдиний виняток: там коми немає, бо й ставити її нема між чим. Зверни увагу на останнє положення: tuple([5]) теж дає (5,), але вже без жодної коми — там працює функція, а не синтаксис.
Як це псує реальний код. Зайва кома в кінці рядка не викликає помилки — вона мовчки робить кортеж: місто = "Київ",. Далі print("Місто: " + місто) падає з TypeError: can only concatenate str (not "tuple") to str, і виглядає це так, ніби зламався print. Якщо повідомлення про помилку згадує tuple там, де ти кортежа не писав, — шукай зайву кому.

03 / ВибірКортеж чи список

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

Спершу подивись, які операції з обох боків працюють однаково, а де кортеж відмовляє — і де, навпаки, відмовляє список.

Інтерактив 2 · Одна операція, дві структури

Ліворуч список = [1, 2, 3], праворуч кортеж = (1, 2, 3). Повзунок обирає дію.

список
кортеж
Що видно за вісім положень: чотири перші операції поводяться ідентично — читання, довжина, зріз, склеювання. Три наступні у кортежа падають: він не дає змінити себе. А остання, hash(), падає навпаки — у списку. Саме ця нижня сходинка й буде головною причиною брати кортеж.

Тепер про швидкість і памʼять — з числами, а не з чутками. Заміри нижче зроблені на CPython 3.12; на іншій машині абсолютні значення будуть інші, а співвідношення — такі самі.

що міряємосписоккортежвисновок
створити літерал
1 млн разів
0.057 с0.007 с кортеж швидший у 8 разів — але тільки тому, що літерал зі сталих компілятор зберігає в коді готовим, а список щоразу будує заново
обійти 1000 елементів0.139 с0.153 с різниці немає, вона в межах шуму вимірювання
памʼять, 3 елементи88 байтів64 байти список тримає запас під майбутні append
памʼять, 1000 елементів8056 байтів8040 байтів 0.2% — на великих даних економія зникає

Отже, «швидший» стосується одного вузького випадку: створення дрібного набору зі сталих значень. У циклі, який мільйон разів робить (0, 0), це відчутно. У всьому іншому ставити кортеж заради швидкості немає сенсу.

Справжні причини обрати кортеж інші, і їх три:

Практичне правило: беручи список, ти обіцяєш, що кількість елементів може змінитися; беручи кортеж — що ні.

04 / РозпакуванняПакування й розпакування

Тут кортеж перестає бути «списком, який шкода» і стає окремим інструментом.

Пакування (packing) — коли кілька значень збираються в один кортеж. Це вже було: точка = 50.45, 30.52. Розпакування (unpacking) — зворотна дія: кілька імен зліва отримують елементи по порядку.

широта, довгота = точка print(широта) # 50.45 print(довгота) # 30.52

Ліворуч не список імен і не якась спецконструкція — це той самий синтаксис коми. Python бачить кому зліва від = і розуміє: праворуч має бути щось, що розкладається на частини.

Головне обмеження — кількості мусять збігтися. Інакше програма зупиниться, і зупиниться до присвоєння: жодне імʼя не встигне отримати значення. Це важлива деталь — після невдалого розпакування в тебе не буде наполовину заповнених імен.

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

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

Інтерактив 3 · Що куди потрапляє

Угорі — дані праворуч від =. Унизу — імена ліворуч. Стрілки показують призначення.

Зверни увагу на три речі: зірочка збирає значення в список, а не в кортеж; вона може забрати й нуль елементів (положення 6) — і це не помилка; а от у помилкових положеннях 1 і 2 не присвоюється нічого — Python перевіряє кількість наперед.

Ще два правила про зірочку. По-перше, вона може бути лише одна: із двома a, *x, *y = … Python не знав би, де закінчується перша група, і відмовляється ще на етапі читання коду — SyntaxError: multiple starred expressions in assignment. По-друге, зірочка працює й посередині: перший, *середина, останній = дані.

Забігаючи наперед: як функція повертає кілька значень. Функцій ми ще не проходили (тема 14), але механізм уже зрозумілий. Коли кажуть, що функція «повертає два значення», насправді вона повертає один кортеж, який на місці виклику одразу розпаковують. Це видно на вбудованій divmod, яка ділить із остачею:
>>> divmod(17, 5) (3, 2) >>> частка, остача = divmod(17, 5) >>> частка, остача (3, 2)
Ніякої магії «кількох повернень» у мові немає — є кортеж і кома.

05 / ОбмінОбмін значень без тимчасової змінної

У темі 03 ми вже бачили рядок a, b = b, a й назвали його «магічним обміном». Тепер, коли ти знаєш про пакування, магії не лишається — розберемо механіку по кроках.

Python виконує присвоєння у два етапи, і саме порядок етапів усе рятує:

  1. Спершу повністю обчислюється права частина. Вирази b і a дають свої поточні значення, з них пакується тимчасовий кортеж (2, 1). Імена в цей момент ще не чіпали.
  2. Потім кортеж розпаковується зліва направо. Перше імʼя отримує перший елемент, друге — другий.

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

Інтерактив 4 · Куди дівається одиниця

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

a1
b2
тимчасовий кортеж
Порівняй фінали: ліворуч кортеж (2, 1) притримав обидва значення, поки імена перевʼязувались, — вийшло a = 2, b = 1. Праворуч притримувати не було чим: після a = b на одиницю не дивиться вже ніхто, і другий рядок лише копіює двійку сам у себе.
Для тих, хто загляне в байткод. Модель «зібрали кортеж — розпакували» точна, але для двох-трьох імен CPython її оптимізує: замість BUILD_TUPLE + UNPACK_SEQUENCE компілятор ставить інструкцію SWAP прямо на стеку, і жодного кортежа в памʼяті не виникає. Побачити це можна через dis.dis("a, b = b, a"). На поведінку це не впливає ніяк — але пояснює, чому обмін через кортеж не повільніший за обмін через третю змінну, а трохи швидший.

06 / ВкладеністьВкладене розпакування

Шаблон ліворуч може повторювати будь-яку форму даних, а не лише плоский ряд. Якщо праворуч кортеж, усередині якого ще кортеж, — ліворуч так само пишуть кортеж усередині кортежа:

запис = ("Київ", (50.45, 30.52)) назва, (широта, довгота) = запис print(назва) # Київ print(широта) # 50.45

Дужки ліворуч тут обовʼязкові: саме вони кажуть «другий елемент теж розкласти». Без них назва, широта, довгота = запис дасть ValueError: not enough values to unpack (expected 3, got 2) — бо верхній рівень має рівно два елементи. Останнє положення повзунка в інтерактиві 3 показує цей випадок цілком.

Розпакувати можна не тільки кортеж, а будь-що ітероване: список, рядок, а згодом — і результат роботи циклу:

>>> a, b, c = [1, 2, 3] # список >>> п, о, з = "код" # рядок теж розкладається >>> п 'к'

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

_, широта, _ = ("Київ", 50.45, 30.52) # потрібна лише широта назва, *_ = ("Київ", 50.45, 30.52) # потрібна лише назва

07 / МежаНезмінність лише на один рівень

Тепер найтонше місце теми, і його варто зрозуміти дослівно. Коли кажуть «кортеж незмінний», мають на увазі: набір посилань усередині нього зафіксовано назавжди. Слот номер 0 до кінця життя кортежа вказує на той самий обʼєкт.

Але це нічого не говорить про сам обʼєкт на іншому кінці стрілки. Якщо там лежить список — його ніхто не заморожував:

>>> дані = ([1, 2], "текст") >>> дані[0] = [9] TypeError: 'tuple' object does not support item assignment >>> дані[0].append(3) # а це працює! >>> дані ([1, 2, 3], 'текст')

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

Інтерактив 5 · Стрілки заморожені, обʼєкти — ні

Кортеж дані = ([1, 2], "текст"). Спробуй чотири дії й подивись, які з них проходять.

дані
id(дані[0])
hash(дані)
Ключ до картинки: id(дані[0]) не змінюється ніколи — слот справді заморожений. Але вміст списку за цією адресою після append уже інший. Тому й hash(дані) не працює: відбиток такого кортежа не був би сталим.
Практичний висновок. «Незмінний кортеж» — не те саме, що «незмінні дані». Якщо тобі потрібна справжня гарантія, що вміст не поїде, стеж, щоб усередині кортежа теж лежало тільки незмінне: числа, рядки, інші кортежі. Кортеж зі списком усередині виглядає надійно, а поводиться як список.

08 / ХешХешованість і чому кортеж стає ключем

Ми двічі згадали hash() — час пояснити, що це і навіщо.

Хеш — це коротке ціле число, «відбиток» обʼєкта. Обчислюється він швидко й має одну ключову властивість: рівні обʼєкти дають рівний хеш. Завдяки цьому Python може не порівнювати вміст із кожним кандидатом по черзі, а одразу порахувати число й піти за ним, як за номером комірки в камері схову.

Саме на цьому побудовані словники (тема 08) і множини (тема 09). І звідси — жорстка вимога: поки обʼєкт лежить ключем, його відбиток не має змінюватись. Інакше річ, яку поклали в комірку 17, після зміни «мала б» лежати в комірці 903 — і знайти її стане неможливо.

Тому змінювані типи хеш просто не мають. Це не забудькуватість авторів мови, а свідома заборона: hash([1, 2]) дає TypeError: unhashable type: 'list'.

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

>>> hash((1, 2)) -3550055125485641917 >>> hash(([1], )) # кортеж з одного елемента, і той список TypeError: unhashable type: 'list'

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

Інтерактив 6 · Що можна класти в ключ

Спершу вгадай сам, потім тисни «Перевірити». Зелена галочка — елемент хешований.

hash()?
ключ словника?
Правило одним рядком: кортеж хешований тоді й лише тоді, коли хешовані всі його елементи — на будь-якій глибині вкладеності. Один список усередині псує весь кортеж, хоч би де він ховався.

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

відстані = {} відстані[(50.45, 30.52)] = "Київ" відстані[(49.84, 24.03)] = "Львів" print(відстані[(50.45, 30.52)]) # Київ

09 / Імена полівКоли [0] уже незручно

У кортежа є одна справжня вада: звертання за номером. запис[2] нічого не каже читачеві. Через тиждень доведеться шукати, що ж там лежало під двійкою.

Для цього в стандартній бібліотеці є namedtuple — «кортеж з іменами полів». Це не окремий тип даних, а спосіб швидко зробити свій різновид кортежа:

from collections import namedtuple Точка = namedtuple("Точка", "широта довгота") київ = Точка(50.45, 30.52) print(київ) # Точка(широта=50.45, довгота=30.52) print(київ.широта) # 50.45 — по імені print(київ[0]) # 50.45 — і по індексу теж широта, довгота = київ # розпакування працює як завжди

Найважливіше: це справжній кортеж. Він незмінний, хешований, isinstance(київ, tuple) дає True, і будь-який код, що чекає кортеж, прийме його без питань. Просто до індексів додались імена.

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

>>> київ._replace(широта=50.50) Точка(широта=50.5, довгота=30.52) >>> київ._asdict() {'широта': 50.45, 'довгота': 30.52}

Правило вибору просте: два-три елементи, зміст яких зрозумілий із контексту, — звичайний кортеж. Чотири й більше, або поля, які легко переплутати місцями, — namedtuple. А коли знадобляться ще й методи та перевірки, у темі 28 візьмемо @dataclass(frozen=True), який робить те саме, але потужніше.

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

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

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

Далі в темі

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