Python з нуля · Блок 6 · Тема 25

Словник, що знає правила

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

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

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

01 / БільДані є, правил немає

Один продаж у словнику виглядає бездоганно:

продаж = {"товар": "кава", "кількість": 3, "ціна": 90.0}

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

  1. Набір ключів ніхто не гарантує. Словник — універсальне сховище пар, він прийме що завгодно. В одному місці ти написав "кількість", у другому — "кількісь", у третьому забув "ціна" взагалі. Словник змовчить, а програма впаде з KeyError через п'ять функцій, за півгодини роботи й за три файли від місця, де насправді помилились.
  2. Перевіркам немає де жити. Кількість не може бути відʼємною, ціна не може бути меншою за нуль. Куди це записати? Словник місця для правил не має, тому перевірку доводиться повторювати в кожній функції, яка приймає продаж. Одну з них ти обовʼязково забудеш оновити.
  3. Правила розповзаються по коду. Функція сума(продаж) мусить знати ключі напамʼять. Потім зʼявиться з_пдв(продаж), як_рядок(продаж), перевірити(продаж) — і кожна знає внутрішній устрій словника. Варто перейменувати один ключ, і ти шукаєш його пошуком по всьому проєкту.

Причина у всіх трьох випадках однакова: словник зберігає дані, але не знає, що вони означають. Для нього {"товар": "кава", "кількість": 3} і {"місто": "Львів", "опади": 3} — те саме: пари ключ-значення. Він не може відрізнити правильний продаж від зіпсованого, бо не має жодного уявлення про продажі.

Клас (class) — це спосіб сказати мові: у моїй програмі є новий тип даних, ось які поля в ньому обовʼязкові, ось за яких умов він взагалі має право існувати, і ось що з ним можна робити. Далі — та сама пара задач, розвʼязана обома способами. Перемикай дії й дивись, у чому саме різниця.

Інтерактив 1 · Словник проти класу

Одні й ті самі дані, чотири однакові дії. Ліворуч словник, праворуч власний тип.

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

02 / ТипКлас — опис, примірник — обʼєкт

Ось увесь наш клас. Прочитай його один раз згори вниз, а розбирати будемо по частинах:

class Продаж: """Один рядок у журналі продажів.""" def __init__(self, товар, кількість, ціна): if кількість <= 0: raise ValueError("кількість має бути додатною") if ціна < 0: raise ValueError("ціна не може бути відʼємною") self.товар = товар self.кількість = кількість self.ціна = ціна def сума(self): return self.кількість * self.ціна

Тут дві різні речі, і плутати їх не можна.

Клас — це опис. Він не містить жодного конкретного продажу, як креслення не містить деталей, а форма для випікання — жодного кексу. Слово class Продаж: створює в програмі новий тип із таким іменем — і, до речі, створює звичайний обʼєкт: сам клас теж лежить у памʼяті, має id() і своє імʼя-ярлик, точнісінько як усе інше з теми 03.

Примірник (instance) — це конкретний обʼєкт, зроблений за цим описом. Щоб його отримати, клас викликають, як функцію:

>>> продаж = Продаж("кава", 3, 90.0) >>> продаж.товар 'кава' >>> продаж.сума() 270.0 >>> type(продаж) <class '__main__.Продаж'>

Зверни увагу на останній рядок: type() тепер відповідає нашим словом. Це і є весь сенс вправи — у програмі зʼявився тип «продаж», якого раніше не було. Один клас, скільки завгодно примірників: сто рядків журналу — це сто окремих обʼєктів типу Продаж, кожен зі своїми даними, але з одним спільним набором правил.

Спроба створити безглуздий продаж тепер не проходить взагалі:

>>> Продаж("кава", -3, 90.0) ValueError: кількість має бути додатною

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

Домовленість про імена. Класи називають з великої літери й без підкреслень: Продаж, Кошик, HttpClient — стиль зветься CamelCase і закріплений у PEP 8. Функції та змінні лишаються малими через підкреслення. Це не примха: побачивши Звіт(...), читач одразу розуміє, що створюють обʼєкт, а не викликають функцію.

03 / Народження__init__ — не конструктор, а ініціалізатор

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

Ось що насправді відбувається за рядком Продаж("кава", 3, 90.0):

  1. Python бере обʼєкт класу Продаж — той самий, що зʼявився, коли інтерпретатор прочитав слово class.
  2. Клас створює порожній примірник: у нього вже правильний тип, уже є власний id(), але жодного поля ще немає.
  3. Одразу після цього викликається __init__, і першим аргументом у нього передають щойно створений порожній обʼєкт. Усередині методу цей аргумент має імʼя self.
  4. Тіло __init__ виконується згори вниз: перевірки, потім присвоєння self.товар = товар і решта. Саме тут порожній обʼєкт наповнюється.
  5. __init__ нічого не повертає (точніше — повертає None), а назовні віддається сам обʼєкт. Ось тепер на нього можна почепити ярлик.

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

Інтерактив 2 · Народження примірника

Один рядок p = Продаж("кава", 3, 90.0) — і пʼять кроків усередині.

обʼєкт існує?ні
полів у ньому
self
p
Ключовий кадр — «обʼєкт уже є, полів 0». Обʼєкт уже створений і вже має адресу, але порожній. __init__ отримує його в self і лише заповнює поля. Тому це ініціалізатор: конструюванням — власне виділенням обʼєкта — займається інший метод, __new__, якого ти майже ніколи не пишеш.

Тепер про self. Це звичайний перший параметр методу, у який приїжджає сам обʼєкт. Не ключове слово, не магія: імʼя self — лише домовленість, мова дозволила б назвати параметр як завгодно. Але не називай: усі читачі твого коду чекають саме self, і будь-яке інше імʼя коштуватиме їм секунди подиву в кожному методі.

Чому Python вимагає писати його явно, коли інші мови приховують? Тому що інакше всередині методу довелося б вгадувати, звідки взялося імʼя: чи це локальна змінна, чи поле обʼєкта, підсунуте невидимим контекстом. З явним self питання не виникає: кількість — локальна змінна, self.кількість — поле обʼєкта. Різницю видно очима, без знання правил.

Найчастіша перша помилка. Забути self в означенні методу: def сума(): замість def сума(self):. Виклик продаж.сума() тоді впаде з повідомленням TypeError: сума() takes 0 positional arguments but 1 was given. Читається дивно — «я не передавав жодного аргументу!» — але воно точне: обʼєкт передався сам, а прийняти його нікому.

04 / МетодиФункції, які отримують self

Метод — це функція, означена в тілі класу. Усе, що ти знаєш про функції з теми 14 і теми 15, працює тут без змін: параметри, значення за замовчуванням, return, область видимості. Відмінність рівно одна — перший параметр.

Додамо до Продаж ще два методи, щоб стало видно різницю між методом, який рахує, і методом, який змінює:

def з_пдв(self, ставка=0.2): """Скільки коштує продаж разом із податком.""" return self.сума() * (1 + ставка) def застосувати_знижку(self, відсоток): """Знижує ціну назавжди — обʼєкт після цього інший.""" if not 0 <= відсоток < 100: raise ValueError("відсоток має бути від 0 до 100") self.ціна = self.ціна * (1 - відсоток / 100)

Перший рахує й повертає число, нічого не чіпаючи. Другий нічого не повертає, зате змінює сам обʼєкт. Обидва варіанти нормальні, але називати їх треба чесно: іменник (сума, з_пдв) натякає, що метод щось поверне; дієслово в наказовій формі (застосувати_знижку) попереджає, що обʼєкт після виклику стане іншим. Метод, який і рахує, і потайки міняє обʼєкт, — джерело багів, які знаходять тижнями.

Зверни увагу на self.сума() всередині з_пдв: метод викликає інший метод того самого обʼєкта через той самий self. Просто сума() не спрацює — це шукало б звичайну функцію в модулі, а такої немає.

А тепер найкорисніше для розуміння: подивись, що робить Python, коли ти пишеш продаж.сума(). Магії там немає жодної.

Інтерактив 3 · self — не магія

Той самий метод, викликаний трьома способами. Стеж за тим, звідки береться перший аргумент.

self всередині
результат
Перші два способи дають однакове число. Різниця лише в тому, хто підставляє self: у першому випадку — крапка, у другому — ти сам. Третій спосіб падає саме тому, що підставити першому параметру нема чого.
Для тих, хто вже пише код. Функція сума лежить в одному-єдиному екземплярі — у класі. Вираз Продаж.сума дає звичайну функцію, а p.сумазвʼязаний метод (bound method): обгортку, яка памʼятає обʼєкт і підставляє його першим аргументом. Перевірити просто: p.сума.__self__ is p дає True, а p.сума.__func__ is Продаж.сума — теж True. Звʼязування виконує протокол дескрипторів: у типу функції є метод __get__, який і повертає обгортку в момент звернення через крапку. Тому p.сума без дужок — повноцінне значення, яке можна покласти у змінну чи передати в sorted(key=...).

05 / ІдентичністьПримірник — теж обʼєкт

Легко подумати, що власні обʼєкти живуть за якимись особливими правилами. Ні. Усе, що ми зʼясували в темі 03, стосується їх дослівно: імʼя — це ярлик, присвоєння не копіює, у кожного обʼєкта є id(). Примірники власних класів до того ж змінювані — як списки й словники.

Тому такий код робить рівно те, що робив би зі списком:

p = Продаж("кава", 3, 90.0) q = p # не копія! другий ярлик на той самий продаж q.кількість = 5 print(p.сума()) # 450.0 — змінювали q, а «постраждав» p

Якщо потрібна саме копія, її треба попросити явно: створити новий примірник із тими самими даними або взяти copy.copy(p) з модуля copy.

Інтерактив 4 · Два ярлики на один продаж

Ті самі чотири кроки, що й у темі 03, — тільки замість списку наш власний тип.

p.сума()
q.сума()
p is q
p == r
Останній крок вартий уваги: r — окремий продаж із точно такими самими даними, а p == r дає False. Для власних класів == за замовчуванням означає те саме, що is: «той самий обʼєкт?». Навчити клас порівнюватись за вмістом можна — це робить метод __eq__, і про нього буде тема 27.

06 / СховищеДе насправді живуть атрибути

Тут ховається найприємніша несподіванка теми. Ми весь час утікали від словника — а всередині примірника лежить… звичайний словник.

>>> p = Продаж("кава", 3, 90.0) >>> p.__dict__ {'товар': 'кава', 'кількість': 3, 'ціна': 90.0}

Кожен примірник має атрибут __dict__ — справжній dict, у якому й зберігаються всі його поля. Крапка — це зручний синтаксис для звернення до цього словника: p.знижка = 0.1 буквально означає p.__dict__["знижка"] = 0.1. Функція vars(p) повертає той самий словник.

Із цього одразу випливають три наслідки, і всі корисні:

Інтерактив 5 · __dict__ примірника

Виконуй рядки по одному й дивись, як росте словник обʼєкта. Праворуч — те, що лежить у класі.

полів у __dict__0
p.сума()
Два різні словники. Дані примірника — ліворуч, у p.__dict__. Методи там ніколи не зʼявляються: вони лежать у класі, праворуч, в одному екземплярі на всі примірники. Останній рядок — навмисна одруківка: словник виріс, а сума не змінилась.
Для профі: __slots__. Словник на кожен примірник коштує памʼяті — десятки, а то й сотні байтів понад самі дані. Якщо обʼєктів мільйони (точки, події, рядки журналу), допомагає рядок у тілі класу: __slots__ = ("товар", "кількість", "ціна"). Ти обіцяєш мові, що інших полів не буде, — і Python не заводить словника взагалі, а тримає значення у фіксованих комірках. Виграш подвійний: памʼять падає в кілька разів, а доступ до атрибута трохи пришвидшується. Приємний побічний ефект — дисципліна: p.кількіть = 7 тепер падає з AttributeError замість тихого створення поля. Плата теж є: p.__dict__ більше не існує, атрибути на льоту не додаси, і треба бути уважним при наслідуванні. Для звичайного коду це передчасна оптимізація — але знати про неї варто.

07 / Два рівніАтрибут класу проти атрибута примірника

Досі всі поля ми створювали в __init__, тобто на примірнику. Але атрибут можна оголосити й у тілі класу, поруч із методами:

class Продаж: ставка_пдв = 0.2 # атрибут КЛАСУ: один на всіх def __init__(self, товар, кількість, ціна): self.товар = товар # атрибут ПРИМІРНИКА: свій у кожного # і решта полів так само

Різниця в тому, скільки таких значень існує. ставка_пдв — рівно одне на весь клас; товар — своє в кожного примірника. Атрибути класу зручні для сталих, спільних для всього типу: ставка податку, назва одиниці виміру, перелік дозволених станів.

Читання атрибута йде вгору по ланцюжку: Python спершу шукає імʼя в p.__dict__, а якщо не знаходить — у type(p).__dict__, тобто в класі. Саме так знаходяться методи (вони ж лежать у класі) і саме так p.ставка_пдв дає 0.2, хоча в самому p такого поля немає.

А от запис поводиться інакше — і це найважливіше речення розділу:

Читання й запис несиметричні. Читання p.ставка_пдв шукає значення в примірнику, потім у класі. Запис p.ставка_пдв = 0.1 завжди кладе значення в примірник — навіть якщо таке імʼя є в класі. Клас лишається недоторканим, а в примірника зʼявляється власне поле, яке відтепер перекриває класове. Змінити значення для всіх можна лише через сам клас: Продаж.ставка_пдв = 0.1.

З цієї несиметричності виростає класична пастка. Спробуй записати в тіло класу змінюваний обʼєкт — список:

class Продаж: історія = [] # ← пастка def записати(self, подія): self.історія.append(подія) # ← не присвоєння!

Виглядає розумно: «у кожного продажу буде своя історія». Насправді список [] створюється один раз, коли Python читає тіло класу, і назавжди лишається одним обʼєктом. А self.історія.append(...) — це не присвоєння: воно нічого не кладе в примірник, а знаходить спільний список угорі ланцюжка й дописує в нього. Результат: усі продажі ведуть одну спільну історію.

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

Інтерактив 6 · Один клас, два примірники

Три режими. У першому список оголошено в тілі класу, у другому — в __init__, у третьому дивимось на число.

p1
p2
Порівняй два перші режими: у першому стрілки від обох примірників ведуть в один список, і p1.історія is p2.історія дає True. У другому __init__ створює новий список на кожен виклик — обʼєкти різні, історії незалежні. Правило: у тілі класу — тільки незмінне.

08 / ВвічливістьПриховане, підкреслення й __repr__

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

class Продаж: def __init__(self, товар, кількість, ціна): self.товар = товар self._код_рядка = None # службове: ззовні не чіпати

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

Два підкреслення — інша історія. Імʼя виду __потаємне (два підкреслення на початку, не більше одного в кінці) Python усередині класу перейменовує: воно перетворюється на _Продаж__потаємне. Це зветься спотворенням імен (name mangling). Мета — не захист, а уникнення випадкових зіткнень: щоб клас-нащадок із власним __потаємне не затер поле батька. Про наслідування — тема 26.

Щоб обʼєкт не виглядав як адреса

Створи продаж і надрукуй його:

>>> print(p) <__main__.Продаж object at 0x7f2c4a13a840>

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

Лікується одним методом:

def __repr__(self): return f"Продаж({self.товар!r}, {self.кількість}, {self.ціна})"

Хороша звичка — робити __repr__ схожим на виклик, який відтворив би цей обʼєкт. Тоді рядок можна прочитати й одразу зрозуміти, як такий продаж зробити самому. Позначка !r у f-рядку означає «підставити через repr» — саме завдяки їй товар зʼявляється в лапках, і видно, що це рядок, а не число.

Одного __repr__ достатньо: коли методу __str__ немає, Python для print() і f-рядків бере __repr__. Зворотне неправда — тому починають завжди з __repr__.

Інтерактив 7 · Без __repr__ і з ним

Той самий обʼєкт у трьох звичайних ситуаціях виводу. Поруч живе ще один продаж — q (чай, 1 шт., 45.0).

repr(p)
Третій рядок найпоказовіший. Список друкує свої елементи саме через repr, тому без цього методу журнал продажів у виводі — колонка шістнадцяткових адрес, за якими нічого не видно. Решта магічних методів (__eq__, __len__, порівняння, арифметика) — тема 27.

09 / МежаКоли клас не потрібен

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

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

Останній рядок вартий окремої уваги, бо це найпоширеніша надмірність. Ось так робити не треба:

class Калькулятор: def порахувати(self, продаж): return продаж.кількість * продаж.ціна # щоб цим скористатись, треба спершу створити зайвий обʼєкт: # Калькулятор().порахувати(p)

Обʼєкт Калькулятор() нічого не памʼятає між викликами й ні на що не впливає — це просто функція, яку змусили одягнути піджак. Достатньо звичайної def порахувати(продаж).

Друга надійна ознака зайвого класу — коли всі методи виявляються парами отримати_ціну / встановити_ціну, і жоден нічого не вирішує. У Python геттери й сетери не пишуть: атрибут читають і присвоюють напряму. А якщо колись знадобиться перевірка при записі, її можна додати, не змінюючи жодного рядка у тих, хто вже користується класом, — для цього є властивість (property), і про неї буде тема 27.

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

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

Тепер, коли клас Продаж нарешті є, зʼявляється наступне питання. У журналі трапляються не лише продажі, а й повернення: майже ті самі поля, майже ті самі методи, але сума зі знаком мінус, а перевірки трохи інші. Копіювати весь клас і правити три рядки — очевидно погана ідея. Як описати «майже такий самий тип, але з відмінностями», не переписуючи спільне? Це наслідування й композиція — тема 26.

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

Далі в темі

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