Практика · Кортежі й розпакування¶

Лекція: lecture.html · Тест: quiz.html · ДЗ: homework.md

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

Що зробимо руками:

  1. створимо кортежі й переконаємось, що кортеж робить кома, а не дужки;
  2. спіймаємо власними руками TypeError і AttributeError від спроби змінити кортеж;
  3. розпакуємо дані трьома шаблонами, зокрема із зірочкою і вкладено;
  4. розберемо обмін a, b = b, a і доведемо assert-ом, що він працює;
  5. порахуємо довжину маршруту своєю формулою й звіримо з math.dist;
  6. побачимо, що незмінність кортежа діє лише на один рівень;
  7. складемо словник із кортежними ключами й зловимо unhashable type;
  8. приробимо до кортежа імена полів через namedtuple.

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

1 · Кортеж координат¶

Створимо дві точки й подивимось, що з ними можна робити на читання. Усе, що ти вже знаєш про списки, тут працює так само: індекс, відʼємний індекс, довжина, перевірка на входження, зріз.

In [1]:
київ = (50.45, 30.52)
львів = (49.84, 24.03)

print("київ            =", київ)
print("тип             =", type(київ))
print("широта  київ[0] =", київ[0])
print("довгота київ[-1]=", київ[-1])
print("довжина len()   =", len(київ))
print("зріз київ[0:1]  =", київ[0:1], "  <- зріз кортежа теж кортеж")
print("30.52 in київ   =", 30.52 in київ)
київ            = (50.45, 30.52)
тип             = <class 'tuple'>
широта  київ[0] = 50.45
довгота київ[-1]= 30.52
довжина len()   = 2
зріз київ[0:1]  = (50.45,)   <- зріз кортежа теж кортеж
30.52 in київ   = True

Скільки методів лишилося¶

У списку методів близько сорока. У кортежа — рівно два, і обидва тільки читають. Перевіримо це напряму: hasattr(обʼєкт, "імʼя") відповідає, чи є в обʼєкта метод із таким іменем.

In [2]:
покупки = [1, 2, 3]

print("append у списку  :", hasattr(покупки, "append"))
print("append у кортежа :", hasattr(київ, "append"))
print("sort   у кортежа :", hasattr(київ, "sort"))
print("count  у кортежа :", hasattr(київ, "count"))
print("index  у кортежа :", hasattr(київ, "index"))
print()
print("київ.count(50.45) =", київ.count(50.45))
print("київ.index(30.52) =", київ.index(30.52))

assert not hasattr(київ, "append"), "у кортежа не має бути append"
assert not hasattr(київ, "sort"), "і sort теж"
assert hasattr(київ, "count") and hasattr(київ, "index")
print("✅ у кортежа лишились рівно два методи: count та index")
append у списку  : True
append у кортежа : False
sort   у кортежа : False
count  у кортежа : True
index  у кортежа : True

київ.count(50.45) = 1
київ.index(30.52) = 1
✅ у кортежа лишились рівно два методи: count та index

2 · Кома важливіша за дужки¶

Головна пастка теми. Три майже однакові рядки — і два різні типи. Зверни увагу: у (5) дужки просто групують вираз, як у математиці.

In [3]:
без_коми = (50.45)      # це НЕ кортеж — просто число в дужках
з_комою = (50.45,)      # кортеж з одного елемента
без_дужок = 50.45,      # теж кортеж: дужки взагалі не потрібні
порожній = ()           # єдиний виняток: кортеж без коми

print("без_коми  =", без_коми, " тип:", type(без_коми).__name__)
print("з_комою   =", з_комою, " тип:", type(з_комою).__name__)
print("без_дужок =", без_дужок, " тип:", type(без_дужок).__name__)
print("порожній  =", порожній, " тип:", type(порожній).__name__, " довжина:", len(порожній))

assert isinstance(без_коми, float), "(50.45) мало лишитись числом"
assert з_комою == без_дужок, "кому робить кома, а не дужки — обидва мають збігтися"
assert len(з_комою) == 1
print("✅ кортеж робить кома")
без_коми  = 50.45  тип: float
з_комою   = (50.45,)  тип: tuple
без_дужок = (50.45,)  тип: tuple
порожній  = ()  тип: tuple  довжина: 0
✅ кортеж робить кома

Як це псує реальний код¶

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

In [4]:
місто = "Київ",          # ось вона, випадкова кома в кінці рядка

print("місто =", місто)
print("тип   =", type(місто).__name__)
print("а очікували ми звичайний рядок 'Київ'")
місто = ('Київ',)
тип   = tuple
а очікували ми звичайний рядок 'Київ'

Тепер спробуємо склеїти це «місто» з текстом — і побачимо traceback. Клітинка нижче має впасти: це і є навчальний матеріал. Прочитай останній рядок повідомлення — він прямо називає обидва типи.

In [5]:
print("Місто: " + місто)
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
Cell In[5], line 1
----> 1 print("Місто: " + місто)

TypeError: can only concatenate str (not "tuple") to str

3 · Що кортеж робити відмовляється¶

Дві різні помилки, і різниця між ними змістовна:

  • TypeError — операція існує, але для цього типу заборонена;
  • AttributeError — у типу взагалі немає такого методу.
In [6]:
київ[0] = 49.84
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
Cell In[6], line 1
----> 1 київ[0] = 49.84

TypeError: 'tuple' object does not support item assignment
In [7]:
київ.append(0.0)
---------------------------------------------------------------------------
AttributeError                            Traceback (most recent call last)
Cell In[7], line 1
----> 1 київ.append(0.0)

AttributeError: 'tuple' object has no attribute 'append'

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

In [8]:
київ_вище = (50.50, київ[1])          # новий кортеж із однією новою координатою

print("був  :", київ)
print("новий:", київ_вище)
print("id старого:", id(київ))
print("id нового :", id(київ_вище), " <- інший обʼєкт")

assert київ == (50.45, 30.52), "старий кортеж мав лишитись недоторканим"
print("✅ оригінал не постраждав")
був  : (50.45, 30.52)
новий: (50.5, 30.52)
id старого: 139672304993600
id нового : 139672269956416  <- інший обʼєкт
✅ оригінал не постраждав

4 · Пакування й розпакування¶

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

In [9]:
одеса = 46.48, 30.73                  # пакування: дужок немає, а кортеж є
широта, довгота = одеса               # розпакування

print("одеса   =", одеса)
print("широта  =", широта)
print("довгота =", довгота)

assert (широта, довгота) == одеса, "розпакування має бути зворотним до пакування"
print("✅ пакування й розпакування — дзеркальні дії")
одеса   = (46.48, 30.73)
широта  = 46.48
довгота = 30.73
✅ пакування й розпакування — дзеркальні дії

Кількості мусять збігтися¶

Якщо імен більше або менше, ніж значень, програма зупиниться — і зупиниться до присвоєння. Жодне імʼя не встигне отримати значення.

In [10]:
широта_1, довгота_1, висота_1 = одеса
---------------------------------------------------------------------------
ValueError                                Traceback (most recent call last)
Cell In[10], line 1
----> 1 широта_1, довгота_1, висота_1 = одеса

ValueError: not enough values to unpack (expected 3, got 2)

Зірочка забирає решту¶

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

In [11]:
запис = ("Одеса", 46.48, 30.73, "порт", "південь")

назва, *решта = запис                 # перше окремо, все інше — оптом
*початок, останнє = запис             # зірочка може стояти й на початку
місто_2, *середина, хвіст = запис     # і посередині

print("назва    =", назва, "  решта =", решта, " тип решти:", type(решта).__name__)
print("початок  =", початок, "  останнє =", останнє)
print("середина =", середина)

assert isinstance(решта, list), "зірочка завжди дає СПИСОК, а не кортеж"
assert решта == [46.48, 30.73, "порт", "південь"]
print("✅ зірочка зібрала", len(решта), "значення у список")
назва    = Одеса   решта = [46.48, 30.73, 'порт', 'південь']  тип решти: list
початок  = ['Одеса', 46.48, 30.73, 'порт']   останнє = південь
середина = [46.48, 30.73, 'порт']
✅ зірочка зібрала 4 значення у список
In [12]:
# зірочка спокійно приймає й порожню решту — це не помилка
одне_значення = ("Київ",)
назва_3, *хвіст_3 = одне_значення

print("назва_3 =", назва_3)
print("хвіст_3 =", хвіст_3, " довжина:", len(хвіст_3))

assert хвіст_3 == [], "порожній список — теж нормальний результат зірочки"
print("✅ нуль елементів — теж законний випадок")
назва_3 = Київ
хвіст_3 = []  довжина: 0
✅ нуль елементів — теж законний випадок

Вкладене розпакування¶

Шаблон ліворуч може повторювати будь-яку форму даних. Дужки навколо (ш, д) кажуть: «другий елемент теж розклади».

In [13]:
запис_києва = ("Київ", (50.45, 30.52))

назва_4, (широта_4, довгота_4) = запис_києва

print("назва_4   =", назва_4)
print("широта_4  =", широта_4)
print("довгота_4 =", довгота_4)

assert (широта_4, довгота_4) == київ, "внутрішній кортеж мав розкластись у ті самі числа"
print("✅ форма шаблону повторила форму даних")
назва_4   = Київ
широта_4  = 50.45
довгота_4 = 30.52
✅ форма шаблону повторила форму даних

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

In [14]:
_, широта_5, _ = ("Харків", 49.99, 36.23)     # цікавить лише широта

print("широта_5 =", широта_5)
print("а в _ лишилось останнє відкинуте значення:", _)
широта_5 = 49.99
а в _ лишилось останнє відкинуте значення: 36.23

5 · Обмін значень¶

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

In [15]:
перше = 1
друге = 2
print("до обміну :", "перше =", перше, " друге =", друге)

перше, друге = друге, перше           # правий бік стає кортежем (2, 1)

print("після     :", "перше =", перше, " друге =", друге)

assert (перше, друге) == (2, 1), "обмін не спрацював"
print("✅ обмін через кортеж")
до обміну : перше = 1  друге = 2
після     : перше = 2  друге = 1
✅ обмін через кортеж

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

In [16]:
третє = 1
четверте = 2
третє = четверте          # одиниця тут-таки втрачена: на неї більше ніхто не дивиться
четверте = третє          # копіює двійку саму в себе

print("третє =", третє, " четверте =", четверте)
print("очікували 2 і 1, отримали", третє, "і", четверте)

assert третє == четверте == 2, "наївний спосіб саме так і ламається"
print("⚠️ обміну не сталося — обидва імені осіли на двійці")
третє = 2  четверте = 2
очікували 2 і 1, отримали 2 і 2
⚠️ обміну не сталося — обидва імені осіли на двійці

6 · Повернення кількох значень¶

Забігання наперед: функції будуть у темі 14. Але механізм уже зрозумілий — «кілька повернень» це насправді один кортеж, який розпаковують на місці виклику. Спершу на вбудованій divmod, потім на своїй функції.

In [17]:
результат = divmod(17, 5)             # ділення з остачею

print("divmod(17, 5) =", результат, " тип:", type(результат).__name__)

частка, остача = результат            # розпаковуємо одразу
print("частка =", частка, " остача =", остача)

assert 5 * частка + остача == 17, "ділення з остачею мало зійтися"
print("✅ 5 ×", частка, "+", остача, "= 17")
divmod(17, 5) = (3, 2)  тип: tuple
частка = 3  остача = 2
✅ 5 × 3 + 2 = 17
In [18]:
# def — це тема 14; тут вона потрібна лише щоб показати кортеж на виході
def межі_маршруту(точки):
    # розпаковуємо кожну точку окремо: циклів ми ще не проходили
    широта_1, _ = точки[0]
    широта_2, _ = точки[1]
    широта_3, _ = точки[2]
    # кома -> функція повертає ОДИН кортеж із двох чисел
    return max(широта_1, широта_2, широта_3), min(широта_1, широта_2, широта_3)


маршрут = (київ, львів, одеса)
північ, південь = межі_маршруту(маршрут)   # і одразу розпаковуємо

print("маршрут:", маршрут)
print("найпівнічніша широта:", північ)
print("найпівденніша широта:", південь)

assert (північ, південь) == (50.45, 46.48)
print("✅ функція віддала кортеж, ми його розпакували")
маршрут: ((50.45, 30.52), (49.84, 24.03), (46.48, 30.73))
найпівнічніша широта: 50.45
найпівденніша широта: 46.48
✅ функція віддала кортеж, ми його розпакували

7 · Наша формула проти бібліотечної¶

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

відстань = √((ш₁ − ш₂)² + (д₁ − д₂)²)

Порахуємо її руками з розпакованих координат і звіримо з math.dist, яка приймає кортежі напряму.

In [19]:
import math

широта_а, довгота_а = київ
широта_б, довгота_б = львів

# теорема Піфагора «в лоб», без жодних бібліотек
наша_відстань = math.sqrt((широта_а - широта_б) ** 2 + (довгота_а - довгота_б) ** 2)
бібліотечна_відстань = math.dist(київ, львів)

print("наша формула :", наша_відстань)
print("math.dist    :", бібліотечна_відстань)
print("різниця      :", abs(наша_відстань - бібліотечна_відстань))

assert math.isclose(наша_відстань, бібліотечна_відстань), "розрахунок розійшовся!"
print("✅ збігається")
наша формула : 6.518604145060503
math.dist    : 6.518604145060504
різниця      : 8.881784197001252e-16
✅ збігається

Це відстань у градусах, а не в кілометрах: на сфері так рахувати не можна. Для навчальної задачі згодиться, бо нас цікавить робота з кортежами, а не картографія.

Тепер довжина всього маршруту — сума трьох відрізків. Циклів ми ще не проходили, тому просто випишемо відрізки по одному.

In [20]:
повний_маршрут = (київ, львів, одеса, київ)   # виїхали з Києва й повернулись

відрізок_1 = math.dist(повний_маршрут[0], повний_маршрут[1])
відрізок_2 = math.dist(повний_маршрут[1], повний_маршрут[2])
відрізок_3 = math.dist(повний_маршрут[2], повний_маршрут[3])
довжина_маршруту = відрізок_1 + відрізок_2 + відрізок_3

print("Київ  -> Львів:", round(відрізок_1, 3))
print("Львів -> Одеса:", round(відрізок_2, 3))
print("Одеса -> Київ :", round(відрізок_3, 3))
print("разом         :", round(довжина_маршруту, 3))

# нерівність трикутника: замкнений маршрут не коротший за подвоєну пряму Київ-Львів
assert довжина_маршруту >= 2 * math.dist(київ, львів), "замкнений шлях не може бути коротшим"
print("✅ нерівність трикутника виконується")
Київ  -> Львів: 6.519
Львів -> Одеса: 7.495
Одеса -> Київ : 3.976
разом         : 17.989
✅ нерівність трикутника виконується

8 · Незмінність лише на один рівень¶

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

In [21]:
дані = ([1, 2], "текст")
адреса_списку_до = id(дані[0])

дані[0].append(3)                     # стрілку не чіпаємо — міняємо сам список

print("дані після append :", дані)
print("id(дані[0]) до    :", адреса_списку_до)
print("id(дані[0]) після :", id(дані[0]), " <- та сама адреса!")

assert id(дані[0]) == адреса_списку_до, "слот кортежа мав лишитись на тому самому обʼєкті"
assert дані[0] == [1, 2, 3], "а от вміст списку змінився"
print("✅ слот заморожений, обʼєкт у ньому — ні")
дані після append : ([1, 2, 3], 'текст')
id(дані[0]) до    : 139672270065088
id(дані[0]) після : 139672270065088  <- та сама адреса!
✅ слот заморожений, обʼєкт у ньому — ні

А спроба перевести стрілку на інший обʼєкт падає, як і має. Клітинка нижче має впасти.

In [22]:
дані[0] = [9]
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
Cell In[22], line 1
----> 1 дані[0] = [9]

TypeError: 'tuple' object does not support item assignment

9 · Хешованість і ключі словника¶

Хеш — короткий числовий «відбиток» обʼєкта. Він має бути сталим, доки обʼєкт лежить ключем. Тому змінювані типи хеша не мають узагалі, а кортеж має його лише тоді, коли хешовані всі його елементи.

In [23]:
міста = {}                            # словники — тема 08, тут лише як демонстрація
міста[київ] = "Київ"
міста[львів] = "Львів"
міста[одеса] = "Одеса"

print("словник із кортежними ключами:")
print(міста)
print()
print("пошук за парою координат:", міста[(50.45, 30.52)])
print("hash(київ) =", hash(київ))

assert міста[київ] == "Київ", "кортеж має працювати ключем"
print("✅ кортеж — законний ключ словника")
словник із кортежними ключами:
{(50.45, 30.52): 'Київ', (49.84, 24.03): 'Львів', (46.48, 30.73): 'Одеса'}

пошук за парою координат: Київ
hash(київ) = -2200792999891765937
✅ кортеж — законний ключ словника
In [24]:
hash([50.45, 30.52])
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
Cell In[24], line 1
----> 1 hash([50.45, 30.52])

TypeError: unhashable type: 'list'

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

In [25]:
hash(([1], ))
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
Cell In[25], line 1
----> 1 hash(([1], ))

TypeError: unhashable type: 'list'

10 · namedtuple: коли поля мають імена¶

запис[2] нічого не каже читачеві. запис.довгота — каже все. Найважливіше: це справжній кортеж, з усіма його властивостями.

In [26]:
from collections import namedtuple

Точка = namedtuple("Точка", "широта довгота")
київ_іменований = Точка(50.45, 30.52)

print("сам обʼєкт        :", київ_іменований)
print("по імені  .широта :", київ_іменований.широта)
print("по індексу    [0] :", київ_іменований[0])
print("довжина           :", len(київ_іменований))
print("це кортеж?        :", isinstance(київ_іменований, tuple))

ш, д = київ_іменований                # розпакування працює як завжди
print("розпакування      :", ш, д)

assert isinstance(київ_іменований, tuple), "namedtuple мусить лишатись кортежем"
assert київ_іменований == київ, "і дорівнювати звичайному кортежу з тими самими числами"
assert hash(київ_іменований) == hash(київ), "і мати той самий хеш"
print("✅ namedtuple — це кортеж з іменами, не новий тип даних")
сам обʼєкт        : Точка(широта=50.45, довгота=30.52)
по імені  .широта : 50.45
по індексу    [0] : 50.45
довжина           : 2
це кортеж?        : True
розпакування      : 50.45 30.52
✅ namedtuple — це кортеж з іменами, не новий тип даних
In [27]:
# змінити поле не можна, але можна зробити копію зі зміненим полем
київ_вище_2 = київ_іменований._replace(широта=50.50)

print("оригінал:", київ_іменований)
print("копія   :", київ_вище_2)
print("словник :", київ_іменований._asdict())

assert київ_іменований.широта == 50.45, "оригінал мав лишитись недоторканим"
print("✅ _replace робить НОВИЙ обʼєкт, не чіпаючи старий")
оригінал: Точка(широта=50.45, довгота=30.52)
копія   : Точка(широта=50.5, довгота=30.52)
словник : {'широта': 50.45, 'довгота': 30.52}
✅ _replace робить НОВИЙ обʼєкт, не чіпаючи старий

Завдання¶

Три рівні. Розгорнуті умови й критерії «зроблено» — у homework.md, тут коротко, щоб можна було спробувати одразу в цьому ж зошиті.

🟢 Рівень 1¶

Додай до маршрут четверте місто — Харків (49.99, 36.23) — і надрукуй його координати, розпакувавши їх у два імені. Перевір assert-ом, що кортеж маршруту після цього має чотири елементи, а вихідний кортеж маршрут лишився з трьома (підказка: + створює новий кортеж).

🟡 Рівень 2¶

Напиши розпакування, яке з запису ("Харків", 49.99, 36.23, "схід", "мільйонник") дістає назву, пару координат і список усіх решти ознак — одним рядком. Перевір assert-ом тип кожного отриманого імені.

🔴 Рівень 3¶

Побудуй словник, у якому ключ — кортеж (місто_1, місто_2), а значення — відстань між ними. Переконайся, що (київ, львів) і (львів, київ) — це два різні ключі, поясни словами чому, і зроби так, щоб порядок не мав значення. Підказка: tuple(sorted(...)).