У попередній темі ми дописали
скрипт zvit.py і наприкінці зупинились на межі: усередині програми ходять
голі рядки, числа й словники. Поки скрипт робить одну дію, це нормально. Але щойно
зʼявляється поняття «продаж» із власними правилами — як його перевіряти, як рахувати
суму, як показувати людині — словників стає замало. Сьогодні ми виконаємо ту обіцянку
й опишемо власний тип.
Наскрізний приклад теми один: один рядок із того самого файлу продажів. Товар, кількість, ціна. Спершу подивимось, чому словник тут не витримує, а потім побудуємо клас, який витримує.
01 / БільДані є, правил немає
Один продаж у словнику виглядає бездоганно:
Це справді хороше рішення — рівно доти, доки продаж один і програма коротка. Проблеми починаються не одразу, а тоді, коли таких словників стає багато, а місць, де їх створюють і читають, — десяток. Тоді вилазять три різні болі, і всі три мають одну причину.
- Набір ключів ніхто не гарантує. Словник — універсальне сховище пар,
він прийме що завгодно. В одному місці ти написав
"кількість", у другому —"кількісь", у третьому забув"ціна"взагалі. Словник змовчить, а програма впаде зKeyErrorчерез п'ять функцій, за півгодини роботи й за три файли від місця, де насправді помилились. - Перевіркам немає де жити. Кількість не може бути відʼємною, ціна не може бути меншою за нуль. Куди це записати? Словник місця для правил не має, тому перевірку доводиться повторювати в кожній функції, яка приймає продаж. Одну з них ти обовʼязково забудеш оновити.
- Правила розповзаються по коду. Функція
сума(продаж)мусить знати ключі напамʼять. Потім зʼявитьсяз_пдв(продаж),як_рядок(продаж),перевірити(продаж)— і кожна знає внутрішній устрій словника. Варто перейменувати один ключ, і ти шукаєш його пошуком по всьому проєкту.
Причина у всіх трьох випадках однакова: словник зберігає дані, але не знає, що
вони означають. Для нього {"товар": "кава", "кількість": 3} і
{"місто": "Львів", "опади": 3} — те саме: пари ключ-значення. Він не може
відрізнити правильний продаж від зіпсованого, бо не має жодного уявлення про продажі.
Клас (class) — це спосіб сказати мові: у моїй програмі є новий тип даних, ось які поля в ньому обовʼязкові, ось за яких умов він взагалі має право існувати, і ось що з ним можна робити. Далі — та сама пара задач, розвʼязана обома способами. Перемикай дії й дивись, у чому саме різниця.
Інтерактив 1 · Словник проти класу
Одні й ті самі дані, чотири однакові дії. Ліворуч словник, праворуч власний тип.
02 / ТипКлас — опис, примірник — обʼєкт
Ось увесь наш клас. Прочитай його один раз згори вниз, а розбирати будемо по частинах:
Тут дві різні речі, і плутати їх не можна.
Клас — це опис. Він не містить жодного конкретного продажу,
як креслення не містить деталей, а форма для випікання — жодного кексу. Слово
class Продаж: створює в програмі новий тип із таким іменем — і, до речі,
створює звичайний обʼєкт: сам клас теж лежить у памʼяті, має id() і своє
імʼя-ярлик, точнісінько як усе інше з теми
03.
Примірник (instance) — це конкретний обʼєкт, зроблений за цим описом. Щоб його отримати, клас викликають, як функцію:
Зверни увагу на останній рядок: type() тепер відповідає нашим словом. Це і
є весь сенс вправи — у програмі зʼявився тип «продаж», якого раніше не було. Один клас,
скільки завгодно примірників: сто рядків журналу — це сто окремих обʼєктів типу
Продаж, кожен зі своїми даними, але з одним спільним набором правил.
Спроба створити безглуздий продаж тепер не проходить взагалі:
Це виняток із теми 18, кинутий нашою власною перевіркою. Важливий не сам факт помилки, а місце: вона трапилась у тому самому рядку, де хтось спробував створити зіпсований продаж. Не через годину, не в чужій функції, не у вигляді дивного від'ємного підсумку у звіті.
Продаж, Кошик, HttpClient — стиль
зветься CamelCase і закріплений у PEP 8. Функції та змінні лишаються малими через
підкреслення. Це не примха: побачивши Звіт(...), читач одразу розуміє, що
створюють обʼєкт, а не викликають функцію.03 / Народження__init__ — не конструктор, а ініціалізатор
Метод __init__ майже всі підручники називають конструктором. Слово
прижилось, але воно неточне й заважає зрозуміти головне: коли
__init__ починає працювати, обʼєкт уже існує. Ніхто його не
конструює — його наповнюють.
Ось що насправді відбувається за рядком Продаж("кава", 3, 90.0):
- Python бере обʼєкт класу
Продаж— той самий, що зʼявився, коли інтерпретатор прочитав словоclass. - Клас створює порожній примірник: у нього вже правильний тип, уже є
власний
id(), але жодного поля ще немає. - Одразу після цього викликається
__init__, і першим аргументом у нього передають щойно створений порожній обʼєкт. Усередині методу цей аргумент має імʼяself. - Тіло
__init__виконується згори вниз: перевірки, потім присвоєнняself.товар = товарі решта. Саме тут порожній обʼєкт наповнюється. __init__нічого не повертає (точніше — повертаєNone), а назовні віддається сам обʼєкт. Ось тепер на нього можна почепити ярлик.
Пройди ці пʼять кроків руками й подивись, у який момент обʼєкт зʼявляється, а в який — наповнюється:
Інтерактив 2 · Народження примірника
Один рядок p = Продаж("кава", 3, 90.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, область видимості.
Відмінність рівно одна — перший параметр.
Додамо до Продаж ще два методи, щоб стало видно різницю між методом, який
рахує, і методом, який змінює:
Перший рахує й повертає число, нічого не чіпаючи. Другий нічого не повертає, зате
змінює сам обʼєкт. Обидва варіанти нормальні, але називати їх треба чесно:
іменник (сума, з_пдв) натякає, що метод щось поверне;
дієслово в наказовій формі (застосувати_знижку) попереджає, що обʼєкт після
виклику стане іншим. Метод, який і рахує, і потайки міняє обʼєкт, — джерело багів,
які знаходять тижнями.
Зверни увагу на self.сума() всередині з_пдв: метод
викликає інший метод того самого обʼєкта через той самий self. Просто
сума() не спрацює — це шукало б звичайну функцію в модулі, а такої немає.
А тепер найкорисніше для розуміння: подивись, що робить Python, коли ти пишеш
продаж.сума(). Магії там немає жодної.
Інтерактив 3 · self — не магія
Той самий метод, викликаний трьома способами. Стеж за тим, звідки береться перший аргумент.
self: у першому випадку — крапка, у другому — ти сам.
Третій спосіб падає саме тому, що підставити першому параметру нема чого.сума лежить в
одному-єдиному екземплярі — у класі. Вираз Продаж.сума дає звичайну функцію,
а p.сума — звʼязаний метод (bound method): обгортку, яка
памʼятає обʼєкт і підставляє його першим аргументом. Перевірити просто:
p.сума.__self__ is p дає True, а
p.сума.__func__ is Продаж.сума — теж True. Звʼязування виконує
протокол дескрипторів: у типу функції є метод __get__, який і повертає
обгортку в момент звернення через крапку. Тому p.сума без дужок —
повноцінне значення, яке можна покласти у змінну чи передати в
sorted(key=...).05 / ІдентичністьПримірник — теж обʼєкт
Легко подумати, що власні обʼєкти живуть за якимись особливими правилами. Ні. Усе, що
ми зʼясували в темі 03, стосується їх дослівно: імʼя — це ярлик, присвоєння не копіює,
у кожного обʼєкта є id(). Примірники власних класів до того ж
змінювані — як списки й словники.
Тому такий код робить рівно те, що робив би зі списком:
Якщо потрібна саме копія, її треба попросити явно: створити новий примірник із тими
самими даними або взяти copy.copy(p) з модуля copy.
Інтерактив 4 · Два ярлики на один продаж
Ті самі чотири кроки, що й у темі 03, — тільки замість списку наш власний тип.
r — окремий продаж із точно
такими самими даними, а p == r дає False. Для власних класів
== за замовчуванням означає те саме, що is: «той самий
обʼєкт?». Навчити клас порівнюватись за вмістом можна — це робить метод
__eq__, і про нього буде тема 27.06 / СховищеДе насправді живуть атрибути
Тут ховається найприємніша несподіванка теми. Ми весь час утікали від словника — а всередині примірника лежить… звичайний словник.
Кожен примірник має атрибут __dict__ — справжній dict, у
якому й зберігаються всі його поля. Крапка — це зручний синтаксис для звернення до
цього словника: p.знижка = 0.1 буквально означає
p.__dict__["знижка"] = 0.1. Функція vars(p) повертає той
самий словник.
Із цього одразу випливають три наслідки, і всі корисні:
- Атрибут можна додати будь-коли, навіть якщо в
__init__його не було. Python не веде списку «дозволених полів». - Атрибут можна прибрати:
del p.знижкавидаляє ключ зі словника, якdelзі звичайного словника в темі 08. - Одруківка створює новий атрибут мовчки. Напишеш
p.кількіть = 7— і замість помилки отримаєш зайве поле, аp.сума()продовжить рахувати по старому значенню. Це плата за гнучкість, і це та сама історія, що з ключем словника з розділу 01.
Інтерактив 5 · __dict__ примірника
Виконуй рядки по одному й дивись, як росте словник обʼєкта. Праворуч — те, що лежить у класі.
p.__dict__. Методи там ніколи не зʼявляються: вони лежать у класі,
праворуч, в одному екземплярі на всі примірники. Останній рядок — навмисна
одруківка: словник виріс, а сума не змінилась.__slots__. Словник на кожен примірник
коштує памʼяті — десятки, а то й сотні байтів понад самі дані. Якщо обʼєктів мільйони
(точки, події, рядки журналу), допомагає рядок у тілі класу:
__slots__ = ("товар", "кількість", "ціна"). Ти обіцяєш мові, що інших полів
не буде, — і Python не заводить словника взагалі, а тримає значення у фіксованих
комірках. Виграш подвійний: памʼять падає в кілька разів, а доступ до атрибута трохи
пришвидшується. Приємний побічний ефект — дисципліна: p.кількіть = 7 тепер
падає з AttributeError замість тихого створення поля. Плата теж є:
p.__dict__ більше не існує, атрибути на льоту не додаси, і треба бути
уважним при наслідуванні. Для звичайного коду це передчасна оптимізація — але знати про
неї варто.07 / Два рівніАтрибут класу проти атрибута примірника
Досі всі поля ми створювали в __init__, тобто на примірнику. Але атрибут
можна оголосити й у тілі класу, поруч із методами:
Різниця в тому, скільки таких значень існує. ставка_пдв — рівно одне на
весь клас; товар — своє в кожного примірника. Атрибути класу зручні для
сталих, спільних для всього типу: ставка податку, назва одиниці виміру, перелік
дозволених станів.
Читання атрибута йде вгору по ланцюжку: Python спершу шукає імʼя в
p.__dict__, а якщо не знаходить — у type(p).__dict__, тобто в
класі. Саме так знаходяться методи (вони ж лежать у класі) і саме так
p.ставка_пдв дає 0.2, хоча в самому p такого поля немає.
А от запис поводиться інакше — і це найважливіше речення розділу:
p.ставка_пдв шукає значення в примірнику, потім у класі. Запис
p.ставка_пдв = 0.1 завжди кладе значення в примірник —
навіть якщо таке імʼя є в класі. Клас лишається недоторканим, а в примірника зʼявляється
власне поле, яке відтепер перекриває класове. Змінити значення для всіх можна лише
через сам клас: Продаж.ставка_пдв = 0.1.З цієї несиметричності виростає класична пастка. Спробуй записати в тіло класу змінюваний обʼєкт — список:
Виглядає розумно: «у кожного продажу буде своя історія». Насправді список
[] створюється один раз, коли Python читає тіло класу, і
назавжди лишається одним обʼєктом. А self.історія.append(...) — це
не присвоєння: воно нічого не кладе в примірник, а знаходить спільний список
угорі ланцюжка й дописує в нього. Результат: усі продажі ведуть одну спільну історію.
Якщо тобі здається, що ти це вже бачив, — так і є. Це рідна сестра пастки зі змінюваним значенням за замовчуванням із теми 15. Причина буквально та сама: обʼєкт створюється один раз, у момент читання означення, а не при кожному використанні.
Інтерактив 6 · Один клас, два примірники
Три режими. У першому список оголошено в тілі класу, у другому — в __init__, у третьому дивимось на число.
p1.історія is p2.історія дає True. У другому
__init__ створює новий список на кожен виклик — обʼєкти різні, історії
незалежні. Правило: у тілі класу — тільки незмінне.08 / ВвічливістьПриховане, підкреслення й __repr__
Частина полів обʼєкта — його внутрішня кухня: проміжні підрахунки, кеш, службові
прапорці. Інші мови вміють закрити їх від зовнішнього світу словом private.
У Python такого слова немає й не буде. Натомість є домовленість: імʼя,
що починається з одного підкреслення, вважається внутрішнім.
Мова не заважатиме прочитати p._код_рядка ззовні. Але підкреслення
означає: «автор класу не обіцяє, що це поле збережеться в наступній версії». Зламається
— сам винен. Дивно, що це працює, — а працює на диво добре: у чужому коді підкреслення
зчитується миттєво.
__потаємне
(два підкреслення на початку, не більше одного в кінці) Python усередині класу
перейменовує: воно перетворюється на _Продаж__потаємне. Це зветься
спотворенням імен (name mangling). Мета — не захист, а уникнення випадкових зіткнень:
щоб клас-нащадок із власним __потаємне не затер поле батька. Про
наслідування — тема 26.Щоб обʼєкт не виглядав як адреса
Створи продаж і надрукуй його:
Технічно чесно, практично марно: тип видно, а даних — ні. І це не косметична проблема. Список продажів у виводі перетворюється на суцільну кашу з адрес, повідомлення в журналі не каже нічого, у дебагері доводиться розкривати кожен обʼєкт руками.
Лікується одним методом:
Хороша звичка — робити __repr__ схожим на виклик, який відтворив би цей
обʼєкт. Тоді рядок можна прочитати й одразу зрозуміти, як такий продаж зробити самому.
Позначка !r у f-рядку означає «підставити через repr» — саме
завдяки їй товар зʼявляється в лапках, і видно, що це рядок, а не число.
Одного __repr__ достатньо: коли методу __str__ немає, Python
для print() і f-рядків бере __repr__. Зворотне неправда — тому
починають завжди з __repr__.
Інтерактив 7 · Без __repr__ і з ним
Той самий обʼєкт у трьох звичайних ситуаціях виводу. Поруч живе ще один продаж — q (чай, 1 шт., 45.0).
repr, тому без цього методу журнал продажів у виводі — колонка
шістнадцяткових адрес, за якими нічого не видно. Решта магічних методів
(__eq__, __len__, порівняння, арифметика) — тема 27.09 / МежаКоли клас не потрібен
Після такої лекції зʼявляється спокуса описати класом усе. Не треба. Клас коштує рядків коду й уваги читача, і ця ціна виправдана лише тоді, коли є поведінка — правила, перевірки, обчислення, які мають жити поруч із даними. Немає поведінки — не потрібен і клас.
| ситуація | що брати | чому |
|---|---|---|
| пара «ключ-значення» довільного складу | словник | ключі заздалегідь невідомі, правил немає — це і є задача словника |
| двійка чи трійка значень «на місці» | кортеж | розпакування читається краще за
.поле, і копіювати нічого не треба |
| дані з JSON, які просто пробігають наскрізь | словник | перекладати в обʼєкти й назад — робота без вигоди |
| три поля, ніякої логіки | поки що клас, далі — простіше | для таких випадків є коротший запис — тема 28 |
| один метод і жодного стану | функція | клас тут — обгортка навколо функції з зайвим кроком |
Останній рядок вартий окремої уваги, бо це найпоширеніша надмірність. Ось так робити не треба:
Обʼєкт Калькулятор() нічого не памʼятає між викликами й ні на що не
впливає — це просто функція, яку змусили одягнути піджак. Достатньо звичайної
def порахувати(продаж).
Друга надійна ознака зайвого класу — коли всі методи виявляються парами
отримати_ціну / встановити_ціну, і жоден нічого не вирішує.
У Python геттери й сетери не пишуть: атрибут читають і присвоюють напряму. А якщо колись
знадобиться перевірка при записі, її можна додати, не змінюючи жодного рядка
у тих, хто вже користується класом, — для цього є властивість (property), і про неї буде
тема 27.
10 / ПідсумокЩо забрати з теми
Уся тема тримається на одному реченні: клас — це місце, де дані живуть разом із правилами. З нього випливає решта:
- Клас — опис типу, примірник — конкретний обʼєкт. Один клас, скільки завгодно примірників, кожен зі своїми даними.
__init__— ініціалізатор. Обʼєкт на цей момент уже створений і вже маєid(); метод лише наповнює його полями й перевіряє, що дані мають сенс.self— звичайний перший параметр.p.сума()іПродаж.сума(p)— те саме; крапка просто підставляє обʼєкт за тебе.- Атрибути примірника лежать у
p.__dict__. Крапка — синтаксис для роботи з цим словником, тому нове поле створюється мовчки, у тому числі з одруківки. - Атрибут класу один на всіх. Читання йде вгору до класу, запис завжди в примірник. Змінюваний обʼєкт у тілі класу — пастка: він спільний.
- Примірник — звичайний обʼєкт. Ярлики,
id(), змінюваність працюють точно як у темі 03. _підкреслення— домовленість, а не захист, і__repr__— мінімальна ввічливість до того, хто читатиме вивід.
Тепер, коли клас Продаж нарешті є, зʼявляється наступне питання. У журналі
трапляються не лише продажі, а й повернення: майже ті самі поля, майже ті самі методи,
але сума зі знаком мінус, а перевірки трохи інші. Копіювати весь клас і правити три
рядки — очевидно погана ідея. Як описати «майже такий самий тип, але з відмінностями»,
не переписуючи спільне? Це наслідування й композиція — тема 26.
practice.ipynb ти збереш той
самий клас Продаж своїми руками: почнеш зі словника й функцій довкола нього,
переконаєшся assert-ами, що клас рахує те саме число, зловиш пастку
спільного атрибута класу власноруч, зазирнеш у __dict__ примірника й
допишеш __repr__.Далі в темі
Теорію прочитано. Тепер закріпи її на практиці.