Практика · Тема 25 · Класи й обʼєкти¶

Лекція: lecture.html · Тест: quiz.html · Домашнє завдання: homework.md

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

Що зробимо:

  1. складемо продаж словником і подивимось, що словник не ловить;
  2. напишемо клас Продаж із перевірками в __init__ і методом сума();
  3. переконаємось assert-ами, що клас рахує те саме число, що й наш словник;
  4. перевіримо, що p.сума() і Продаж.сума(p) — це одне й те саме;
  5. подивимось на примірник як на звичайний обʼєкт: id(), два ярлики, == проти is;
  6. зазирнемо в p.__dict__ і побачимо, як він росте;
  7. зловимо пастку змінюваного атрибута класу власними руками;
  8. допишемо __repr__ і побачимо різницю у виводі.

Усе виконується згори вниз без жодних правок. Наприкінці — завдання трьох рівнів.

1 · Продаж у словнику¶

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

In [1]:
# один рядок журналу продажів — те, з чим ми працювали в темі 24
продаж = {"товар": "кава", "кількість": 3, "ціна": 90.0}


def сума_словника(запис):
    """Правило живе ОКРЕМО від даних і мусить знати ключі напамʼять."""
    return запис["кількість"] * запис["ціна"]


print("продаж :", продаж)
print("сума   :", сума_словника(продаж))
продаж : {'товар': 'кава', 'кількість': 3, 'ціна': 90.0}
сума   : 270.0

2 · Що словник пропускає мовчки¶

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

In [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__ єдиний, через нього проходять усі продажі;
  • методи поруч із даними — правило «як порахувати суму» живе в тому самому класі.
In [3]:
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 · Клас рахує те саме число¶

Найважливіша перевірка практики: клас нічого не змінив у математиці. Він лише переніс правило туди, де живуть дані. Порівняймо два підрахунки для того самого продажу.

In [4]:
через_словник = сума_словника(продаж)
через_клас = p.сума()

print("через словник:", через_словник)
print("через клас   :", через_клас)

assert через_словник == через_клас, "розрахунок розійшовся!"
assert isinstance(p, Продаж), "p має бути примірником класу Продаж"
print("✅ збігається — клас переніс правило, а не змінив його")
через словник: 270.0
через клас   : 270.0
✅ збігається — клас переніс правило, а не змінив його

5 · Перевірки, яких у словника не було¶

Тепер спробуємо створити ті самі зіпсовані дані — але через клас. Ловимо виняток з теми 18 і друкуємо його текст.

In [5]:
погані_спроби = [
    ("відʼємна кількість", "кава", -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. Ця клітинка навмисно падає: у ній видно і тип помилки, і наше власне повідомлення українською.

In [6]:
Продаж("кава", -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 зі словником.

In [7]:
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-ами.

In [8]:
через_крапку = 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 — це просто перший параметр, підставлений крапкою

7 · Примірник — теж звичайний обʼєкт¶

Усе з теми 03 працює без змін: імʼя — ярлик, присвоєння не копіює, у обʼєкта є id(). А == для власного класу за замовчуванням означає те саме, що is.

In [9]:
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__¶

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

In [10]:
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__ немає й ніколи не буде: вони лежать у класі, в одному екземплярі на всі примірники. Переконаймося.

In [11]:
print("поля примірника:", list(p.__dict__))
print("що є в класі   :", [імʼя for імʼя in Продаж.__dict__ if not імʼя.startswith("__")])

assert "сума" not in p.__dict__, "метод не має лежати в примірнику"
assert "сума" in Продаж.__dict__, "метод має лежати в класі"
print("✅ дані — у примірнику, поведінка — у класі")
поля примірника: ['товар', 'кількість', 'ціна']
що є в класі   : ['сума']
✅ дані — у примірнику, поведінка — у класі

9 · Пастка: змінюваний атрибут класу¶

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

In [12]:
class ПродажЗПасткою:
    історія = []                       # ← створюється ОДИН раз, разом із класом

    def записати(self, подія):
        self.історія.append(подія)     # append — не присвоєння: піде у спільний список


перший = ПродажЗПасткою()
другий = ПродажЗПасткою()
перший.записати("знижка")
другий.записати("повернення")

print("історія першого :", перший.історія)
print("історія другого :", другий.історія)
print("це один список? :", перший.історія is другий.історія)

assert перший.історія == другий.історія == ["знижка", "повернення"]
assert перший.історія is другий.історія, "у пастці список має бути спільний"
print("❌ обидва продажі ведуть одну історію на двох")
історія першого : ['знижка', 'повернення']
історія другого : ['знижка', 'повернення']
це один список? : True
❌ обидва продажі ведуть одну історію на двох
In [13]:
class ПродажПравильний:
    def __init__(self):
        self.історія = []              # новий список НА КОЖЕН виклик __init__

    def записати(self, подія):
        self.історія.append(подія)


третій = ПродажПравильний()
четвертий = ПродажПравильний()
третій.записати("знижка")
четвертий.записати("повернення")

print("історія третього  :", третій.історія)
print("історія четвертого:", четвертий.історія)
print("це один список?   :", третій.історія is четвертий.історія)

assert третій.історія == ["знижка"]
assert четвертий.історія == ["повернення"]
assert третій.історія is not четвертий.історія, "списки мали бути різні"
print("✅ у кожного примірника своя історія")
історія третього  : ['знижка']
історія четвертого: ['повернення']
це один список?   : False
✅ у кожного примірника своя історія

10 · Читання йде вгору, запис — завжди в примірник¶

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

In [14]:
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__: щоб обʼєкт розповідав про себе¶

Без цього методу продаж друкується як адреса в памʼяті. Найболючіше це в списках: журнал продажів перетворюється на колонку шістнадцяткових чисел.

In [15]:
без_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 символів, і жодного про товар
In [16]:
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 · Коли клас не потрібен¶

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

In [17]:
# без поведінки — кортеж читається краще й розпаковується одним рядком
точка = (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¶

Зроби клас Продаж акуратнішим:

  1. Додай __slots__ = ("товар", "кількість", "ціна") і покажи assert-ом, що тепер одруківка p.кількіть = 7 кидає AttributeError замість тихого створення поля.
  2. Заміряй різницю в памʼяті: створи 100 000 продажів зі __slots__ і без нього, порівняй через sys.getsizeof(vars(обʼєкт)) (для версії без слотів) або tracemalloc. Зроби письмовий висновок одним реченням.
  3. Поясни в коментарі, чому після __slots__ перестає працювати p.__dict__ — і що через це ламається у прикладі з розділу 8.