Практика · Тема 25 · Класи й обʼєкти¶
Лекція: lecture.html · Тест: quiz.html · Домашнє завдання: homework.md
У лекції ми починали з болю: словник зберігає дані про продаж, але не знає про них нічого. Тут ми пройдемо той самий шлях руками — від словника з функціями довкола нього до власного типу, у якому дані й правила лежать разом.
Що зробимо:
- складемо продаж словником і подивимось, що словник не ловить;
- напишемо клас
Продажіз перевірками в__init__і методомсума(); - переконаємось
assert-ами, що клас рахує те саме число, що й наш словник; - перевіримо, що
p.сума()іПродаж.сума(p)— це одне й те саме; - подивимось на примірник як на звичайний обʼєкт:
id(), два ярлики,==протиis; - зазирнемо в
p.__dict__і побачимо, як він росте; - зловимо пастку змінюваного атрибута класу власними руками;
- допишемо
__repr__і побачимо різницю у виводі.
Усе виконується згори вниз без жодних правок. Наприкінці — завдання трьох рівнів.
1 · Продаж у словнику¶
Почнімо так, як почав би кожен: складемо один продаж зі звичайного словника, а правило «як порахувати суму» винесемо в окрему функцію. Це робочий код — і саме він поступово стане незручним.
# один рядок журналу продажів — те, з чим ми працювали в темі 24
продаж = {"товар": "кава", "кількість": 3, "ціна": 90.0}
def сума_словника(запис):
"""Правило живе ОКРЕМО від даних і мусить знати ключі напамʼять."""
return запис["кількість"] * запис["ціна"]
print("продаж :", продаж)
print("сума :", сума_словника(продаж))
продаж : {'товар': 'кава', 'кількість': 3, 'ціна': 90.0}
сума : 270.0
2 · Що словник пропускає мовчки¶
Тепер зробимо дві типові помилки. Жодна з них не зупинить програму — і в цьому вся біда: неправильні дані поїдуть далі й вилізуть десь у звіті, за кілометр від місця помилки.
зіпсований = {"товар": "кава", "кількість": -3, "ціна": 90.0}
print("відʼємна кількість прийнята:", зіпсований)
print("і сума порахувалась :", сума_словника(зіпсований))
# друга класика — одруківка в ключі
з_одруківкою = {"товар": "кава", "кількість": 3, "ціна": 90.0}
з_одруківкою["кількіть"] = 10 # мали на увазі "кількість"
print()
print("після одруківки ключів стало:", len(з_одруківкою))
print("а сума не змінилась :", сума_словника(з_одруківкою))
print("ключі :", list(з_одруківкою))
відʼємна кількість прийнята: {'товар': 'кава', 'кількість': -3, 'ціна': 90.0}
і сума порахувалась : -270.0
після одруківки ключів стало: 4
а сума не змінилась : 270.0
ключі : ['товар', 'кількість', 'ціна', 'кількіть']
3 · Той самий продаж класом¶
Опишемо власний тип. Три речі, які зʼявляються разом із класом і яких у словнику немає:
- обовʼязковий набір полів — без усіх трьох аргументів обʼєкт просто не створиться;
- місце для перевірок —
__init__єдиний, через нього проходять усі продажі; - методи поруч із даними — правило «як порахувати суму» живе в тому самому класі.
class Продаж:
"""Один рядок у журналі продажів."""
def __init__(self, товар, кількість, ціна):
# перевірки стоять ДО присвоєнь: зіпсований обʼєкт не має навіть виникнути
if кількість <= 0:
raise ValueError("кількість має бути додатною")
if ціна < 0:
raise ValueError("ціна не може бути відʼємною")
self.товар = товар
self.кількість = кількість
self.ціна = ціна
def сума(self):
"""Скільки грошей принесла ця позиція."""
return self.кількість * self.ціна
p = Продаж("кава", 3, 90.0)
print("тип обʼєкта :", type(p))
print("товар :", p.товар)
print("кількість :", p.кількість)
print("сума :", p.сума())
тип обʼєкта : <class '__main__.Продаж'> товар : кава кількість : 3 сума : 270.0
4 · Клас рахує те саме число¶
Найважливіша перевірка практики: клас нічого не змінив у математиці. Він лише переніс правило туди, де живуть дані. Порівняймо два підрахунки для того самого продажу.
через_словник = сума_словника(продаж)
через_клас = p.сума()
print("через словник:", через_словник)
print("через клас :", через_клас)
assert через_словник == через_клас, "розрахунок розійшовся!"
assert isinstance(p, Продаж), "p має бути примірником класу Продаж"
print("✅ збігається — клас переніс правило, а не змінив його")
через словник: 270.0 через клас : 270.0 ✅ збігається — клас переніс правило, а не змінив його
погані_спроби = [
("відʼємна кількість", "кава", -3, 90.0),
("нульова кількість", "чай", 0, 45.0),
("відʼємна ціна", "тістечко", 2, -10.0),
]
спіймано = 0
for опис, товар, кількість, ціна in погані_спроби:
try:
Продаж(товар, кількість, ціна)
except ValueError as помилка:
спіймано += 1
print(f"{опис:<20} → ValueError: {помилка}")
print()
print("спіймано помилок:", спіймано, "із", len(погані_спроби))
assert спіймано == len(погані_спроби), "якась перевірка не спрацювала"
print("✅ жоден зіпсований продаж не був створений")
відʼємна кількість → ValueError: кількість має бути додатною нульова кількість → ValueError: кількість має бути додатною відʼємна ціна → ValueError: ціна не може бути відʼємною спіймано помилок: 3 із 3 ✅ жоден зіпсований продаж не був створений
А ось як така помилка виглядає без try — справжній traceback. Ця клітинка навмисно
падає: у ній видно і тип помилки, і наше власне повідомлення українською.
Продаж("кава", -3, 90.0)
--------------------------------------------------------------------------- ValueError Traceback (most recent call last) Cell In[6], line 1 ----> 1 Продаж("кава", -3, 90.0) Cell In[3], line 7, in Продаж.__init__(self, товар, кількість, ціна) 4 def __init__(self, товар, кількість, ціна): 5 # перевірки стоять ДО присвоєнь: зіпсований обʼєкт не має навіть виникнути 6 if кількість <= 0: ----> 7 raise ValueError("кількість має бути додатною") 8 if ціна < 0: 9 raise ValueError("ціна не може бути відʼємною") ValueError: кількість має бути додатною
Неповний виклик клас теж не пропустить — але вже не нашою перевіркою, а самим Python:
у __init__ три обовʼязкові параметри, і бракує аргументу видно в рядку створення,
а не через півпрограми, як KeyError зі словником.
try:
Продаж("кава", 3)
except TypeError as помилка:
print("TypeError:", помилка)
# для порівняння: словник дозволяє зібрати неповний запис і мовчить
неповний = {"товар": "кава", "кількість": 3}
print("неповний словник створився:", неповний)
try:
сума_словника(неповний)
except KeyError as помилка:
print("а впало аж тут, у функції підрахунку → KeyError:", помилка)
TypeError: Продаж.__init__() missing 1 required positional argument: 'ціна'
неповний словник створився: {'товар': 'кава', 'кількість': 3}
а впало аж тут, у функції підрахунку → KeyError: 'ціна'
6 · self — не магія¶
p.сума() і Продаж.сума(p) — це буквально одна й та сама функція. Різниця лише в тому,
хто підставляє перший аргумент: крапка чи ти сам. Перевіримо це assert-ами.
через_крапку = p.сума()
через_клас_напряму = Продаж.сума(p)
print("p.сума() :", через_крапку)
print("Продаж.сума(p) :", через_клас_напряму)
assert через_крапку == через_клас_напряму, "це мала бути та сама функція!"
# звʼязаний метод памʼятає свій обʼєкт і свою функцію
print()
print("p.сума :", p.сума)
print("p.сума.__self__ is p :", p.сума.__self__ is p)
print("p.сума.__func__ is Продаж.сума:", p.сума.__func__ is Продаж.сума)
assert p.сума.__self__ is p
print("✅ self — це просто перший параметр, підставлений крапкою")
p.сума() : 270.0 Продаж.сума(p) : 270.0 p.сума : <bound method Продаж.сума of <__main__.Продаж object at 0x7f55796c99a0>> p.сума.__self__ is p : True p.сума.__func__ is Продаж.сума: True ✅ self — це просто перший параметр, підставлений крапкою
q = p # не копія! другий ярлик на той самий продаж
q.кількість = 5 # чіпаємо q...
print("p.сума():", p.сума(), "· q.сума():", q.сума()) # ...а бачить і p
print("id(p) == id(q):", id(p) == id(q), "· p is q:", p is q)
r = Продаж("кава", 5, 90.0) # окремий обʼєкт із такими самими даними
print()
print("r.сума():", r.сума())
print("p == r :", p == r, "← без спеціального методу == означає «той самий обʼєкт?»")
print("p is r :", p is r)
assert p is q, "q мав лишитись другим ярликом на p"
assert p.сума() == q.сума() == 450.0
assert p is not r and (p == r) is False
print("✅ ярлики поводяться так само, як зі списками")
p.сума(): 450.0 · q.сума(): 450.0 id(p) == id(q): True · p is q: True r.сума(): 450.0 p == r : False ← без спеціального методу == означає «той самий обʼєкт?» p is r : False ✅ ярлики поводяться так само, як зі списками
8 · Де живуть атрибути: p.__dict__¶
Атрибути примірника лежать у звичайному словнику. Крапка — просто зручний запис для роботи з ним. Подивимось, як він росте й зменшується.
print("на старті :", p.__dict__)
print("vars(p) — те саме:", vars(p) == p.__dict__)
p.знижка = 0.1 # поля не було в __init__ — і це нікого не зупинило
print("після p.знижка :", p.__dict__)
del p.знижка # ключ прибрано, як зі звичайного словника
print("після del :", p.__dict__)
p.кількіть = 7 # навмисна одруківка: створилось НОВЕ поле
print("після одруківки :", p.__dict__)
print("а сума лишилась :", p.сума(), "— рахує вона по старому ключу")
assert "кількіть" in p.__dict__, "одруківка мала мовчки створити нове поле"
assert p.сума() == 450.0, "сума не мала змінитись від одруківки"
del p.кількіть # приберемо, щоб далі працювати з чистим обʼєктом
print("✅ __dict__ — звичайний словник, тому одруківка проходить мовчки")
на старті : {'товар': 'кава', 'кількість': 5, 'ціна': 90.0}
vars(p) — те саме: True
після p.знижка : {'товар': 'кава', 'кількість': 5, 'ціна': 90.0, 'знижка': 0.1}
після del : {'товар': 'кава', 'кількість': 5, 'ціна': 90.0}
після одруківки : {'товар': 'кава', 'кількість': 5, 'ціна': 90.0, 'кількіть': 7}
а сума лишилась : 450.0 — рахує вона по старому ключу
✅ __dict__ — звичайний словник, тому одруківка проходить мовчки
Методів у p.__dict__ немає й ніколи не буде: вони лежать у класі, в одному екземплярі
на всі примірники. Переконаймося.
print("поля примірника:", list(p.__dict__))
print("що є в класі :", [імʼя for імʼя in Продаж.__dict__ if not імʼя.startswith("__")])
assert "сума" not in p.__dict__, "метод не має лежати в примірнику"
assert "сума" in Продаж.__dict__, "метод має лежати в класі"
print("✅ дані — у примірнику, поведінка — у класі")
поля примірника: ['товар', 'кількість', 'ціна'] що є в класі : ['сума'] ✅ дані — у примірнику, поведінка — у класі
9 · Пастка: змінюваний атрибут класу¶
Найкоштовніша помилка теми. Оголосимо список у тілі класу — так, як здається природним, — і подивимось, що з цього вийде. Поруч одразу напишемо правильний варіант.
class ПродажЗПасткою:
історія = [] # ← створюється ОДИН раз, разом із класом
def записати(self, подія):
self.історія.append(подія) # append — не присвоєння: піде у спільний список
перший = ПродажЗПасткою()
другий = ПродажЗПасткою()
перший.записати("знижка")
другий.записати("повернення")
print("історія першого :", перший.історія)
print("історія другого :", другий.історія)
print("це один список? :", перший.історія is другий.історія)
assert перший.історія == другий.історія == ["знижка", "повернення"]
assert перший.історія is другий.історія, "у пастці список має бути спільний"
print("❌ обидва продажі ведуть одну історію на двох")
історія першого : ['знижка', 'повернення'] історія другого : ['знижка', 'повернення'] це один список? : True ❌ обидва продажі ведуть одну історію на двох
class ПродажПравильний:
def __init__(self):
self.історія = [] # новий список НА КОЖЕН виклик __init__
def записати(self, подія):
self.історія.append(подія)
третій = ПродажПравильний()
четвертий = ПродажПравильний()
третій.записати("знижка")
четвертий.записати("повернення")
print("історія третього :", третій.історія)
print("історія четвертого:", четвертий.історія)
print("це один список? :", третій.історія is четвертий.історія)
assert третій.історія == ["знижка"]
assert четвертий.історія == ["повернення"]
assert третій.історія is not четвертий.історія, "списки мали бути різні"
print("✅ у кожного примірника своя історія")
історія третього : ['знижка'] історія четвертого: ['повернення'] це один список? : False ✅ у кожного примірника своя історія
10 · Читання йде вгору, запис — завжди в примірник¶
Незмінний атрибут класу (число, рядок, кортеж) — річ корисна: спільна стала для всього типу. Але записувати в нього через примірник не вийде: присвоєння створить нове поле в самому примірнику, а клас лишиться недоторканим.
class ПродажЗПДВ:
ставка_пдв = 0.2 # незмінне число — безпечний атрибут класу
def __init__(self, сума_без_пдв):
self.сума_без_пдв = сума_без_пдв
def з_пдв(self):
return self.сума_без_пдв * (1 + self.ставка_пдв)
перший = ПродажЗПДВ(100.0)
другий = ПродажЗПДВ(100.0)
print("обидва беруть ставку з класу:", перший.ставка_пдв, другий.ставка_пдв)
print("з ПДВ :", перший.з_пдв(), другий.з_пдв())
перший.ставка_пдв = 0.1 # це НЕ змінює клас
print()
print("після перший.ставка_пдв = 0.1")
print("перший :", перший.ставка_пдв, "← власне поле примірника")
print("другий :", другий.ставка_пдв, "← і далі читає з класу")
print("сам клас :", ПродажЗПДВ.ставка_пдв)
print("власні поля першого:", перший.__dict__)
assert "ставка_пдв" in перший.__dict__ and "ставка_пдв" not in другий.__dict__
assert ПродажЗПДВ.ставка_пдв == 0.2, "клас не мав змінитись"
ПродажЗПДВ.ставка_пдв = 0.05 # а ось так змінюється для всіх...
print()
print("після ПродажЗПДВ.ставка_пдв = 0.05")
print("другий побачив нове:", другий.ставка_пдв)
print("перший лишився зі своїм:", перший.ставка_пдв)
assert другий.ставка_пдв == 0.05 and перший.ставка_пдв == 0.1
print("✅ ...але не для тих, у кого вже зʼявилось власне поле")
обидва беруть ставку з класу: 0.2 0.2
з ПДВ : 120.0 120.0
після перший.ставка_пдв = 0.1
перший : 0.1 ← власне поле примірника
другий : 0.2 ← і далі читає з класу
сам клас : 0.2
власні поля першого: {'сума_без_пдв': 100.0, 'ставка_пдв': 0.1}
після ПродажЗПДВ.ставка_пдв = 0.05
другий побачив нове: 0.05
перший лишився зі своїм: 0.1
✅ ...але не для тих, у кого вже зʼявилось власне поле
11 · __repr__: щоб обʼєкт розповідав про себе¶
Без цього методу продаж друкується як адреса в памʼяті. Найболючіше це в списках: журнал продажів перетворюється на колонку шістнадцяткових чисел.
без_repr = [Продаж("кава", 3, 90.0), Продаж("чай", 1, 45.0)]
print("один обʼєкт :", без_repr[0])
print("цілий список:", без_repr)
print("довжина рядка з адресою:", len(repr(без_repr[0])), "символів, і жодного про товар")
один обʼєкт : <__main__.Продаж object at 0x7f557974ef90> цілий список: [<__main__.Продаж object at 0x7f557974ef90>, <__main__.Продаж object at 0x7f557974c050>] довжина рядка з адресою: 42 символів, і жодного про товар
class ПродажЗRepr:
def __init__(self, товар, кількість, ціна):
if кількість <= 0:
raise ValueError("кількість має бути додатною")
self.товар = товар
self.кількість = кількість
self.ціна = ціна
def сума(self):
return self.кількість * self.ціна
def __repr__(self):
# рядок навмисно схожий на виклик, яким такий продаж можна створити знову
return f"Продаж({self.товар!r}, {self.кількість}, {self.ціна})"
журнал = [ПродажЗRepr("кава", 3, 90.0), ПродажЗRepr("чай", 1, 45.0)]
print("один обʼєкт :", журнал[0])
print("цілий список:", журнал)
print("у f-рядку :", f"продано: {журнал[0]}") # __str__ немає — взявся __repr__
assert repr(журнал[0]) == "Продаж('кава', 3, 90.0)"
assert str(журнал[0]) == repr(журнал[0]), "без __str__ друк має брати __repr__"
print("✅ тепер у виводі видно дані, а не адресу")
один обʼєкт : Продаж('кава', 3, 90.0)
цілий список: [Продаж('кава', 3, 90.0), Продаж('чай', 1, 45.0)]
у f-рядку : продано: Продаж('кава', 3, 90.0)
✅ тепер у виводі видно дані, а не адресу
12 · Коли клас не потрібен¶
Клас коштує рядків коду й уваги читача. Якщо поведінки немає — правил, перевірок, обчислень, — вистачить кортежа чи словника. Ось чесне порівняння: для пари «широта, довгота», з якою нічого не роблять, клас лише додає слів.
# без поведінки — кортеж читається краще й розпаковується одним рядком
точка = (49.84, 24.03)
широта, довгота = точка
print("кортеж :", точка, "→ широта", широта, "· довгота", довгота)
class Точка:
"""Той самий сенс, але вчетверо більше коду й жодної нової можливості."""
def __init__(self, широта, довгота):
self.широта = широта
self.довгота = довгота
обʼєкт = Точка(49.84, 24.03)
print("клас :", обʼєкт.широта, обʼєкт.довгота)
print("рядків коду: кортеж — один, клас — чотири, можливостей — порівну")
assert (обʼєкт.широта, обʼєкт.довгота) == точка
print("✅ клас виправданий тоді, коли поруч із даними мають жити правила")
кортеж : (49.84, 24.03) → широта 49.84 · довгота 24.03 клас : 49.84 24.03 рядків коду: кортеж — один, клас — чотири, можливостей — порівну ✅ клас виправданий тоді, коли поруч із даними мають жити правила
Завдання¶
🟢 Рівень 1¶
Додай до класу Продаж метод з_пдв(self, ставка=0.2), який повертає суму разом
із податком. Перевір assert-ом, що для Продаж("кава", 3, 90.0) він дає 324.0.
🟡 Рівень 2¶
Напиши клас Кошик, який зберігає список продажів у полі self.позиції (створеному
в __init__, а не в тілі класу!) і має:
- метод
додати(self, продаж)— з перевіркою, що передали самеПродаж(isinstance), інакшеTypeError; - метод
разом(self)— сума всіх позицій; __repr__, з якого видно кількість позицій і загальну суму.
Перевір assert-ами: два різні кошики мають незалежні списки позицій, а разом()
дорівнює сумі сума() кожної позиції окремо.
🔴 Рівень 3¶
Зроби клас Продаж акуратнішим:
- Додай
__slots__ = ("товар", "кількість", "ціна")і покажиassert-ом, що тепер одруківкаp.кількіть = 7кидаєAttributeErrorзамість тихого створення поля. - Заміряй різницю в памʼяті: створи 100 000 продажів зі
__slots__і без нього, порівняй черезsys.getsizeof(vars(обʼєкт))(для версії без слотів) абоtracemalloc. Зроби письмовий висновок одним реченням. - Поясни в коментарі, чому після
__slots__перестає працюватиp.__dict__— і що через це ламається у прикладі з розділу 8.