Практика · Словники¶

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

Наскрізний приклад той самий, що в лекції, — телефонна книга. Тут ми руками зробимо все, про що йшлося:

  1. створимо книгу, знайдемо, додамо й видалимо запис;
  2. подивимось, чим [] відрізняється від .get() — і зловимо справжній KeyError;
  3. побачимо, що keys() — це вікно, а не копія;
  4. порахуємо частоти слів через .get() і перевіримо себе через collections.Counter;
  5. замінимо довгий ланцюжок умов однією таблицею відповідностей;
  6. переконаємось, що список ключем бути не може, а кортеж — може;
  7. подивимось на порядок вставки й на те, як словник росте в пам'яті.

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

1 · Створюємо телефонну книгу¶

Фігурні дужки, двокрапка між ключем і значенням, кома між парами. Ключ тут — ім'я, значення — номер.

In [1]:
телефонна_книга = {
    "Аня": "067-111-22-33",
    "Богдан": "050-444-55-66",
    "Галя": "063-777-88-99",
}

print("книга:", телефонна_книга)
print("тип:", type(телефонна_книга))
print("записів:", len(телефонна_книга))
книга: {'Аня': '067-111-22-33', 'Богдан': '050-444-55-66', 'Галя': '063-777-88-99'}
тип: <class 'dict'>
записів: 3

2 · Знайти запис¶

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

In [2]:
print('телефонна_книга["Богдан"] ->', телефонна_книга["Богдан"])
print('"Галя" in телефонна_книга  ->', "Галя" in телефонна_книга)
print('"Петро" in телефонна_книга ->', "Петро" in телефонна_книга)
# in перевіряє саме КЛЮЧІ, а не значення — ось доказ
print('"067-111-22-33" in телефонна_книга ->', "067-111-22-33" in телефонна_книга)
телефонна_книга["Богдан"] -> 050-444-55-66
"Галя" in телефонна_книга  -> True
"Петро" in телефонна_книга -> False
"067-111-22-33" in телефонна_книга -> False

3 · Додати й перезаписати — це одна й та сама дія¶

У списку присвоєння за неіснуючим індексом падає. У словнику воно створює запис. А якщо ключ уже є — значення тихо замінюється, і другого «Богдана» не з'явиться ніколи.

In [3]:
телефонна_книга["Ніна"] = "096-222-33-44"      # нового ключа не було — запис створився
print("записів після додавання Ніни:", len(телефонна_книга))

телефонна_книга["Ніна"] = "096-000-00-00"      # ключ уже є — старе значення затерто
print("записів після перезапису:  ", len(телефонна_книга))
print("номер Ніни тепер:   ", телефонна_книга["Ніна"])
записів після додавання Ніни: 4
записів після перезапису:   4
номер Ніни тепер:    096-000-00-00

4 · Перша навмисна помилка: KeyError¶

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

In [4]:
телефонна_книга["Петро"]
---------------------------------------------------------------------------
KeyError                                  Traceback (most recent call last)
Cell In[4], line 1
----> 1 телефонна_книга["Петро"]

KeyError: 'Петро'

5 · .get() — те саме питання, інша реакція¶

.get() не падає. Без другого аргументу він повертає None, з другим — те, що ти передав. Важливо: словник при цьому не змінюється, значення за замовчуванням ніде не зберігається.

In [5]:
print("get('Петро')            ->", телефонна_книга.get("Петро"))
print("get('Петро', 'немає')   ->", телефонна_книга.get("Петро", "немає"))
print("get('Аня')              ->", телефонна_книга.get("Аня"))
print("а сама книга не змінилась — записів:", len(телефонна_книга),
      "· 'Петро' in книзі ->", "Петро" in телефонна_книга)
get('Петро')            -> None
get('Петро', 'немає')   -> немає
get('Аня')              -> 067-111-22-33
а сама книга не змінилась — записів: 4 · 'Петро' in книзі -> False

6 · setdefault — узяти або одразу покласти¶

Ось де плутанина з .get() найчастіша. setdefault змінює словник: якщо ключа немає, він його вставляє. Це зручно, коли треба гарантувати, що під ключем уже щось лежить.

In [6]:
запаси = {"яблука": 5}

print("setdefault('яблука', 0) ->", запаси.setdefault("яблука", 0))   # ключ був — 5
print("setdefault('груші', 0)  ->", запаси.setdefault("груші", 0))    # ключа не було — 0
print("запаси після двох викликів:", запаси)

# для порівняння: get нічого не вставляє
print("get('сливи', 0)         ->", запаси.get("сливи", 0))
print("запаси після get:", запаси)
setdefault('яблука', 0) -> 5
setdefault('груші', 0)  -> 0
запаси після двох викликів: {'яблука': 5, 'груші': 0}
get('сливи', 0)         -> 0
запаси після get: {'яблука': 5, 'груші': 0}

7 · Видалення: del і pop¶

Різниця одна: чи потрібне тобі значення, яке зникає. pop його повертає, del — ні. І тільки pop уміє не падати на відсутньому ключі.

In [7]:
del телефонна_книга["Ніна"]                     # просто прибрати
print("після del:", list(телефонна_книга))

прибраний_номер = телефонна_книга.pop("Галя")   # прибрати й забрати значення
print("pop повернув:", прибраний_номер)
print("після pop:", list(телефонна_книга))

# запасний варіант рятує від KeyError на неіснуючому ключі
print("pop('Петро', 'такого не було') ->",
      телефонна_книга.pop("Петро", "такого не було"))
після del: ['Аня', 'Богдан', 'Галя']
pop повернув: 063-777-88-99
після pop: ['Аня', 'Богдан']
pop('Петро', 'такого не було') -> такого не було

8 · keys(), values(), items() — вікна, а не копії¶

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

In [8]:
книга = {"Аня": "067-111-22-33", "Богдан": "050-444-55-66"}

представлення = книга.keys()          # вікно
копія = list(книга.keys())            # знімок, зроблений просто зараз

print("тип представлення:", type(представлення))
print("тип копії:        ", type(копія))
print()
print("до зміни · представлення:", представлення)
print("до зміни · копія:        ", копія)

книга["Галя"] = "063-777-88-99"       # міняємо словник ПІСЛЯ того, як обидва створені

print()
print("після зміни · представлення:", представлення, "<- оновилось саме")
print("після зміни · копія:        ", копія, "<- застрягла в минулому")
тип представлення: <class 'dict_keys'>
тип копії:         <class 'list'>

до зміни · представлення: dict_keys(['Аня', 'Богдан'])
до зміни · копія:         ['Аня', 'Богдан']

після зміни · представлення: dict_keys(['Аня', 'Богдан', 'Галя']) <- оновилось саме
після зміни · копія:         ['Аня', 'Богдан'] <- застрягла в минулому

Те саме з .items(): він повертає пари як кортежі. Обходити їх у циклі ми навчимося в темі 12 — поки що досить побачити, що саме там лежить.

In [9]:
print("items():", книга.items())
print("перший елемент items():", list(книга.items())[0])
print("а це вже звичайний кортеж:", type(list(книга.items())[0]))
items(): dict_items([('Аня', '067-111-22-33'), ('Богдан', '050-444-55-66'), ('Галя', '063-777-88-99')])
перший елемент items(): ('Аня', '067-111-22-33')
а це вже звичайний кортеж: <class 'tuple'>

9 · update() і об'єднання через |¶

update() змінює той словник, якому його сказали. Оператор | (з Python 3.9) нічого не чіпає — він робить новий словник. При конфлікті ключів перемагає правий операнд.

In [10]:
за_замовчуванням = {"тема": "світла", "мова": "uk", "розмір": 14}
вибір_користувача = {"мова": "en", "тема": "темна"}

підсумок = за_замовчуванням | вибір_користувача    # новий словник
print("підсумок:              ", підсумок)
print("за_замовчуванням цілий:", за_замовчуванням)

копія_налаштувань = dict(за_замовчуванням)         # щоб не псувати оригінал
копія_налаштувань.update(вибір_користувача)        # а ось це змінює на місці
print("після update:          ", копія_налаштувань)
print("update дав те саме, що | :", копія_налаштувань == підсумок)
підсумок:               {'тема': 'темна', 'мова': 'en', 'розмір': 14}
за_замовчуванням цілий: {'тема': 'світла', 'мова': 'uk', 'розмір': 14}
після update:           {'тема': 'темна', 'мова': 'en', 'розмір': 14}
update дав те саме, що | : True

10 · Підрахунок частот через .get()¶

Класична задача: скільки разів трапилось кожне слово. Ідіома тут одна на всі випадки:

лічильник[слово] = лічильник.get(слово, 0) + 1

Перше входження слова дає get -> 0, отже записується 1. Друге бачить уже 1 і робить 2. Спершу зробимо кілька кроків вручну, щоб було видно кожен.

In [11]:
лічильник = {}

# крок 1: слова «кіт» ще немає — get поверне запасний 0
лічильник["кіт"] = лічильник.get("кіт", 0) + 1
print("після 'кіт': ", лічильник)

# крок 2: нове слово
лічильник["пес"] = лічильник.get("пес", 0) + 1
print("після 'пес': ", лічильник)

# крок 3: «кіт» уже є — get поверне 1, запишемо 2
лічильник["кіт"] = лічильник.get("кіт", 0) + 1
print("після 'кіт': ", лічильник)

# крок 4: і ще раз
лічильник["кіт"] = лічильник.get("кіт", 0) + 1
print("після 'кіт': ", лічильник)
після 'кіт':  {'кіт': 1}
після 'пес':  {'кіт': 1, 'пес': 1}
після 'кіт':  {'кіт': 2, 'пес': 1}
після 'кіт':  {'кіт': 3, 'пес': 1}

Забігання наперед: той самий підрахунок у циклі¶

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

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

In [12]:
from collections import Counter

слова = "кіт пес кіт миша пес кіт риба".split()

наш_підрахунок = {}
for слово in слова:                                       # забігання наперед: тема 12
    наш_підрахунок[слово] = наш_підрахунок.get(слово, 0) + 1

бібліотечний = dict(Counter(слова))

print("наш:        ", наш_підрахунок)
print("Counter:    ", бібліотечний)

assert наш_підрахунок == бібліотечний, "наш підрахунок розійшовся з Counter!"
print("✅ збігається")
наш:         {'кіт': 3, 'пес': 2, 'миша': 1, 'риба': 1}
Counter:     {'кіт': 3, 'пес': 2, 'миша': 1, 'риба': 1}
✅ збігається

11 · Словник замість довгого ланцюжка умов¶

Умов ми ще не вчили, але вже можемо обійтися без них. Коли задача звучить як «такому-то коду відповідає такий-то текст», найкоротший і найшвидший розв'язок — таблиця відповідностей плюс одне звертання з .get().

In [13]:
пояснення_коду = {
    200: "усе гаразд",
    301: "сторінка переїхала назавжди",
    404: "сторінки немає",
    418: "я чайник",
    500: "зламався сервер",
}

# одне звертання замість п'яти перевірок; невідомий код не ламає програму
print("200 ->", пояснення_коду.get(200, "невідомий код"))
print("404 ->", пояснення_коду.get(404, "невідомий код"))
print("418 ->", пояснення_коду.get(418, "невідомий код"))
print("777 ->", пояснення_коду.get(777, "невідомий код"))

# і скільки б рядків у таблиці не було — робота та сама
print("кодів у таблиці:", len(пояснення_коду), "· кроків пошуку завжди 1")
200 -> усе гаразд
404 -> сторінки немає
418 -> я чайник
777 -> невідомий код
кодів у таблиці: 5 · кроків пошуку завжди 1

12 · Друга навмисна помилка: список ключем бути не може¶

Адреса запису обчислюється з ключа. Якщо ключ можна змінити — адреса «протухне», і значення загубиться. Python не дає цьому статись: у списку метод __hash__ дорівнює None.

In [14]:
{["Київ", "Хрещатик"]: "01001"}
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
Cell In[14], line 1
----> 1 {["Київ", "Хрещатик"]: "01001"}

TypeError: unhashable type: 'list'

13 · А кортеж — може¶

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

In [15]:
поштові_індекси = {
    ("Київ", "Хрещатик"): "01001",
    ("Львів", "Ринок"): "79008",
    ("Одеса", "Дерибасівська"): "65026",
}

print("ключ із двох частин:", поштові_індекси[("Львів", "Ринок")])
# хеш кортежа з рядками змінюється між запусками інтерпретатора — це навмисно
print("hash кортежа:", hash(("Львів", "Ринок")))
print("hash числа 42:", hash(42), "· число хешує саме себе")

# але кортеж зі списком усередині вже не хешується
try:
    hash((1, [2]))
except TypeError as помилка:
    print("hash((1, [2])) ->", type(помилка).__name__, "-", помилка)
ключ із двох частин:
 79008
hash кортежа: -4124586230098368815
hash числа 42: 42 · число хешує саме себе
hash((1, [2])) -> TypeError - unhashable type: 'list'

14 · Порядок вставки гарантований — і це саме порядок вставки¶

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

In [16]:
черга = {"Аня": 1, "Богдан": 2, "Галя": 3}
print("початок:        ", list(черга))

черга["Дмитро"] = 4
print("додали Дмитра:  ", list(черга))

del черга["Богдан"]
print("видалили Богдана:", list(черга))

черга["Богдан"] = 5
print("додали знову:   ", list(черга), "<- Богдан у кінці!")

assert list(черга) == ["Аня", "Галя", "Дмитро", "Богдан"], "порядок вставки не збігся"
print("✅ порядок саме такий, як очікували")
початок:         ['Аня', 'Богдан', 'Галя']
додали Дмитра:   ['Аня', 'Богдан', 'Галя', 'Дмитро']
видалили Богдана: ['Аня', 'Галя', 'Дмитро']
додали знову:    ['Аня', 'Галя', 'Дмитро', 'Богдан'] <- Богдан у кінці!
✅ порядок саме такий, як очікували

15 · Як словник росте в пам'яті¶

Хеш-таблиця не дає собі заповнитись більш ніж на дві третини: інакше колізії стають задовгими. Коли межу перейдено, створюється більша таблиця й усі ключі перехешовуються. sys.getsizeof показує ці стрибки прямо.

In [17]:
import sys

словник = {}
попередній_розмір = sys.getsizeof(словник)
print("порожній словник:", попередній_розмір, "байтів")

for номер in range(1, 25):                      # забігання наперед: тема 12
    словник["ключ%02d" % номер] = номер
    розмір = sys.getsizeof(словник)
    if розмір != попередній_розмір:
        подія = "перша таблиця на 8 комірок" if номер == 1 else "перехешування"
        print("на %2d-му ключі розмір став %3d байтів  <- %s"
              % (номер, розмір, подія))
        попередній_розмір = розмір

print("усього ключів:", len(словник), "· підсумковий розмір:", sys.getsizeof(словник))
порожній словник: 64 байтів
на  1-му ключі розмір став 184 байтів  <- перша таблиця на 8 комірок
на  6-му ключі розмір став 272 байтів  <- перехешування
на 11-му ключі розмір став 464 байтів  <- перехешування
на 22-му ключі розмір став 832 байтів  <- перехешування
усього ключів: 24 · підсумковий розмір: 832

16 · Дрібниця, яка ловить усіх: 1, 1.0 і True¶

Правило Python: якщо два значення рівні, їхні хеші теж рівні. А 1 == 1.0 == True. Отже для словника це один ключ, записаний трьома способами.

In [18]:
print("hash(1) =", hash(1), "· hash(1.0) =", hash(1.0), "· hash(True) =", hash(True))

плутанина = {1: "ціле"}
плутанина[1.0] = "дробове"
плутанина[True] = "логічне"

print("думали, буде три ключі, а вийшов:", плутанина)
print("довжина:", len(плутанина))
assert len(плутанина) == 1, "несподівано: ключів більше одного"
print("✅ один ключ, значення перезаписалось двічі")
hash(1) = 1 · hash(1.0) = 1 · hash(True) = 1
думали, буде три ключі, а вийшов: {1: 'логічне'}
довжина: 1
✅ один ключ, значення перезаписалось двічі

Завдання¶

🟢 Рівень 1¶

Візьми словник пояснення_коду з розділу 11 і додай до нього коди 403 («доступу немає») і 502 («поганий шлюз»). Потім видали код 418 через pop так, щоб отримати його пояснення в змінну й надрукувати. Перевір assert len(пояснення_коду) == 6.

🟡 Рівень 2¶

Дано два словники цін:

базові = {"хліб": 32, "молоко": 41, "яйця": 78}
акційні = {"молоко": 35, "сир": 210}

Зроби новий словник підсумкові, у якому акційні ціни перекривають базові, а базові лишаються недоторканими. Перевір трьома assert: що підсумкові["молоко"] == 35, що базові["молоко"] == 41 і що в підсумковому чотири позиції.

🔴 Рівень 3¶

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

  1. що станеться, якщо два різні ключі мали однакове значення;
  2. чому функція впаде, якщо серед значень є список — і як це полагодити одним словом.

Перевір на {"Аня": "067", "Богдан": "050"} і на {"а": 1, "б": 1}.