Python · Синтаксис · Просунутий рівень

Декоратори методів класів ООП

10 завдань

Опанування @classmethod, @staticmethod, @property, @property.setter та @property.deleter. Коли кожен доречний і як вони взаємодіють зі спадкуванням.

@classmethod та @staticmethod пояснення

#
**Навіщо ці декоратори** До появи `@classmethod` та `@staticmethod` розробники писали допоміжні функції на рівні модуля для логіки, що концептуально належала класу. Такі функції втрачали зв'язок з класом, не могли бути перевизначені в підкласах і захаращували простір імен модуля. Ці два декоратори вирішують різні версії цієї проблеми. **@classmethod — перший аргумент це сам клас** `@classmethod` отримує `cls` — клас, на якому викликається метод — замість `self`. Це робить його *підклас-чутливим*: якщо підклас успадковує метод, `cls` всередині буде підкласом, а не батьківським класом. ```python class Person: def __init__(self, name, age): self.name = name self.age = age @classmethod def from_string(cls, s): # 'Alice, 30' name, age = s.split(',') return cls(name.strip(), int(age.strip())) # cls, НЕ Person class Employee(Person): def __init__(self, name, age, team): super().__init__(name, age) self.team = team e = Employee.from_string('Bob, 25') # cls = Employee всередині from_string print(type(e)) # <class '__main__.Employee'> ``` Якби ви написали `return Person(name, age)` в `from_string`, підклас мовчки отримував би `Person` замість `Employee` — класичний баг із спадкуванням. Використання `cls(...)` уникає цього. **@staticmethod — іменована звичайна функція** `@staticmethod` не отримує ні `self`, ні `cls`. Це звичайна функція в просторі імен класу. Використовуйте коли логіка *належить класу*, але не потребує доступу до стану класу чи екземпляру: ```python class Temperature: def __init__(self, celsius): self._celsius = celsius @staticmethod def celsius_to_fahrenheit(c): return c * 9 / 5 + 32 @classmethod def from_fahrenheit(cls, f): # потребує cls для створення екземпляру return cls((f - 32) * 5 / 9) Temperature.celsius_to_fahrenheit(100) # 212.0 ``` **Як вибрати між трьома:** Потрібен `self` (читає або змінює стан екземпляру) → звичайний метод. Потрібен `cls` (створює екземпляри, читає стан класу, має бути поліморфним) → `@classmethod`. Не потрібен ні той, ні інший (чисте обчислення, утиліта) → `@staticmethod`. Підказка: якщо підклас, що викликає цей метод, повинен отримати інший результат — використовуйте `@classmethod`.

@property, @setter, @deleter — патерни та підводні камені

#
**@property — доступ у стилі атрибуту зі схованою логікою** `@property` перетворює метод на щось схоже на атрибут: ви звертаєтесь через `obj.value`, а не `obj.value()`. Причина: додати обчислення, валідацію або ліниву ініціалізацію *без зміни публічного інтерфейсу*: ```python class Circle: def __init__(self, radius): self.radius = radius # це викликає сеттер нижче! @property def radius(self): return self._radius @radius.setter def radius(self, value): if not isinstance(value, (int, float)) or value < 0: raise ValueError(f'radius must be a non-negative number, got {value!r}') self._radius = value @property def area(self): import math return math.pi * self._radius ** 2 # обчислюється, не зберігається c = Circle(5) print(c.area) # 78.53... c.radius = 10 c.radius = -1 # ValueError ``` Ключовий момент: `self.radius = radius` в `__init__` *проходить через сеттер*. Валідація активна з моменту створення об'єкта. **@property.deleter — очищення при `del`** Делетер спрацьовує при `del obj.attr`. Корисний для інвалідації кешу, очищення ресурсів або позначення значення як невстановленого: ```python class Config: def __init__(self): self._debug = False self._cache = {} @property def debug(self): return self._debug @debug.setter def debug(self, value): self._debug = bool(value) self._cache.clear() # зміна режиму інвалідує кеш @debug.deleter def debug(self): self._debug = False self._cache.clear() print('debug mode reset') cfg = Config() cfg.debug = True del cfg.debug # debug mode reset ``` **Правило іменування:** всі три методи — `@property`, `@x.setter`, `@x.deleter` — *повинні* мати однакове ім'я. Внутрішнє сховище за угодою використовує підкреслення: `self._x`. Якщо сеттер названий інакше, Python створить другий атрибут замість зв'язку — жодної помилки, тихий баг. **Ліниве кешування через @property** ```python class Report: def __init__(self, rows): self.rows = rows self._summary = None @property def summary(self): if self._summary is None: self._summary = {k: sum(r[k] for r in self.rows) for k in self.rows[0]} return self._summary r = Report([{'sales': 100, 'returns': 5}, {'sales': 200, 'returns': 10}]) print(r.summary) # обчислюється зараз print(r.summary) # з кешу ``` Python 3.8+ має `functools.cached_property` що робить те саме в один рядок, але розуміння ручного патерну пояснює як це працює.

Таблиця порівняння, помилки та приклади зі stdlib

#
**Швидке порівняння** | Декоратор | Перший арг | Доступ до | Підклас-чутливий? | Коли використовувати | |---|---|---|---|---| | метод екземпляру | `self` | екземпляр + клас | через `type(self)` | Все що читає/змінює стан екземпляру | | `@classmethod` | `cls` | лише клас | так — `cls` є підкласом | Альтернативні конструктори, фабрики | | `@staticmethod` | — | нічого | ні | Чисті утиліти в просторі імен класу | | `@property` (геттер) | `self` | екземпляр | через `type(self)` | Обчислювані атрибути, схована приватна змінна | | `@x.setter` | `self` + значення | екземпляр | — | Валідовані записи | | `@x.deleter` | `self` | екземпляр | — | Очищення при `del` | **Поширені помилки** *Хардкодинг назви класу в @classmethod:* ```python # НЕПРАВИЛЬНО — ламає підкласи @classmethod def from_string(cls, s): return Person(s) # завжди Person, ніколи підклас # ПРАВИЛЬНО @classmethod def from_string(cls, s): return cls(s) ``` *Запис до self.x всередині геттера викликає нескінченну рекурсію:* ```python # НЕПРАВИЛЬНО @property def value(self): self.value = self._value # AttributeError або RecursionError return self._value # ПРАВИЛЬНО @property def value(self): return self._value ``` *Різні імена у property і setter:* ```python # НЕПРАВИЛЬНО — два непов'язаних атрибути @property def age(self): ... @old_age.setter # має бути @age.setter def age(self, v): ... ``` *Використання @classmethod коли вистачить @staticmethod:* Якщо `cls` ніде не використовується в тілі методу — це `@staticmethod`. Невикористаний `cls` — запах коду. **У стандартній бібліотеці** `dict.fromkeys(keys, val)` — `@classmethod`, тому `OrderedDict.fromkeys(...)` повертає `OrderedDict`. `datetime.date.today()` — `@classmethod` з тієї ж причини поліморфізму. `str.maketrans(...)` — `@staticmethod` (утиліта, не потребує екземпляру чи класу).

Протокол дескрипторів: як @property працює всередині

#
**Як @property насправді працює — протокол дескрипторів** `@property` — це не магія інтерпретатора. Це звичайний клас Python що реалізує *протокол дескрипторів* — три методи, які Python викликає при доступі, записі або видаленні атрибуту об'єкта. Дескриптор — будь-який об'єкт що визначає `__get__`, `__set__` або `__delete__`. Коли Python розв'язує `obj.attr`, він перевіряє чи є `attr` на класі дескриптором і якщо так — викликає `__get__` на ньому замість повернення сирого значення: ```python # Приблизно так property реалізований всередині: class property: def __init__(self, fget=None, fset=None, fdel=None): self.fget = fget self.fset = fset self.fdel = fdel def __get__(self, obj, objtype=None): if obj is None: # доступ на класі, не на екземплярі return self return self.fget(obj) def __set__(self, obj, value): if self.fset is None: raise AttributeError("can't set attribute") self.fset(obj, value) def setter(self, fset): return type(self)(self.fget, fset, self.fdel) ``` Ви можете створювати власні дескриптори для логіки валідації яку інакше довелось би повторювати у багатьох property: ```python class PositiveNumber: def __set_name__(self, owner, name): self._name = name def __get__(self, obj, objtype=None): if obj is None: return self return obj.__dict__.get(self._name) def __set__(self, obj, value): if not isinstance(value, (int, float)) or value <= 0: raise ValueError(f'{self._name} must be positive, got {value!r}') obj.__dict__[self._name] = value class Product: price = PositiveNumber() quantity = PositiveNumber() def __init__(self, name, price, quantity): self.name = name self.price = price self.quantity = quantity p = Product('Widget', 9.99, 100) p.price = -5 # ValueError: price must be positive ``` `__set_name__` (Python 3.6+) викликається автоматично при створенні класу — він отримує ім'я атрибуту до якого призначений дескриптор. Саме цей механізм лежить в основі полів Django моделей, колонок SQLAlchemy та полів dataclass.
01

@classmethod: альтернативний конструктор

#

Додайте метод класу `from_string(cls, s)` до `Person`, що розбирає рядок у форматі `'Alice,30'` та повертає новий екземпляр `Person`. Використовуйте `@classmethod` — перший аргумент `cls`, а не `self`.

class Person:
    def __init__(self, name, age):
        self.name = name
        self.age = age

    @classmethod
    def from_string(cls, s):
        # розберіть 'Alice,30' та поверніть cls(...)
        pass

p = Person.from_string('Alice,30')
print(p.name)  # Alice
print(p.age)   # 30
Рішення
class Person:
    def __init__(self, name, age):
        self.name = name
        self.age = int(age)

    @classmethod
    def from_string(cls, s):
        name, age = s.split(',')
        return cls(name.strip(), int(age.strip()))

p = Person.from_string('Alice,30')
print(p.name)  # Alice
print(p.age)   # 30
02

@classmethod: фабрика з урахуванням підкласу

#

Створіть базовий клас `Animal` з `@classmethod create(cls, name)`, що повертає екземпляр того класу на якому викликається. Підкласи `Dog` та `Cat` наслідують метод. Покажіть що `Dog.create('Rex')` повертає `Dog`, а не `Animal`.

class Animal:
    def __init__(self, name):
        self.name = name

    @classmethod
    def create(cls, name):
        # поверніть екземпляр cls
        pass

    def __repr__(self):
        return f'{type(self).__name__}({self.name!r})'

class Dog(Animal): pass
class Cat(Animal): pass

print(Dog.create('Rex'))   # Dog('Rex')
print(isinstance(Dog.create('Rex'), Dog))  # True
Рішення
class Animal:
    def __init__(self, name):
        self.name = name

    @classmethod
    def create(cls, name):
        return cls(name)

    def __repr__(self):
        return f'{type(self).__name__}({self.name!r})'

class Dog(Animal): pass
class Cat(Animal): pass

print(Animal.create('Generic'))  # Animal('Generic')
print(Dog.create('Rex'))         # Dog('Rex')
print(Cat.create('Whiskers'))    # Cat('Whiskers')
print(isinstance(Dog.create('Rex'), Dog))  # True
03

@staticmethod: утилітарний метод

#

Додайте `@staticmethod is_valid(email)` до класу `EmailValidator`, що повертає `True` якщо рядок містить рівно один `@` та хоча б одну `.` після нього. Статичні методи належать простору імен класу але не отримують ні `self`, ні `cls`.

class EmailValidator:
    @staticmethod
    def is_valid(email):
        # True якщо email має один '@' та '.' після нього
        pass

print(EmailValidator.is_valid('[email protected]'))  # True
print(EmailValidator.is_valid('no-at-sign'))         # False
print(EmailValidator.is_valid('missing-dot@com'))    # False
Рішення
class EmailValidator:
    @staticmethod
    def is_valid(email):
        parts = email.split('@')
        if len(parts) != 2:
            return False
        return '.' in parts[1]

print(EmailValidator.is_valid('[email protected]'))  # True
print(EmailValidator.is_valid('no-at-sign'))         # False
print(EmailValidator.is_valid('missing-dot@com'))    # False
04

@property: обчислюваний атрибут лише для читання

#

Додайте `@property area` до `Rectangle`, що повертає `width * height`. Властивість має обчислюватись при зверненні, а не зберігатись. Спроба присвоїти `rect.area = 10` має кидати `AttributeError`.

class Rectangle:
    def __init__(self, width, height):
        self.width = width
        self.height = height

    @property
    def area(self):
        # поверніть обчислену площу
        pass

r = Rectangle(4, 5)
print(r.area)   # 20
r.width = 10
print(r.area)   # 50
Рішення
class Rectangle:
    def __init__(self, width, height):
        self.width = width
        self.height = height

    @property
    def area(self):
        return self.width * self.height

r = Rectangle(4, 5)
print(r.area)   # 20
r.width = 10
print(r.area)   # 50
05

@property + @setter: атрибут з валідацією

#

Додайте `age` як керований атрибут до `Person` за допомогою `@property` та `@age.setter`. Сеттер має кидати `ValueError` якщо значення від'ємне або не є цілим числом. Зберігайте справжнє значення в `self._age`.

class Person:
    def __init__(self, name, age):
        self.name = name
        self.age = age  # проходить через сеттер

    @property
    def age(self):
        return self._age

    @age.setter
    def age(self, value):
        # валідація: int та >= 0
        pass

p = Person('Alice', 30)
print(p.age)  # 30
p.age = -1    # ValueError
Рішення
class Person:
    def __init__(self, name, age):
        self.name = name
        self.age = age

    @property
    def age(self):
        return self._age

    @age.setter
    def age(self, value):
        if not isinstance(value, int):
            raise ValueError(f'age must be int, got {type(value).__name__}')
        if value < 0:
            raise ValueError('age must be >= 0')
        self._age = value

p = Person('Alice', 30)
print(p.age)   # 30
try:
    p.age = -1
except ValueError as e:
    print(e)   # age must be >= 0
06

@property.deleter: очищення при del

#

Додайте `@token.deleter` до `Session`, що встановлює `self._token = None` та виводить `'Token revoked'` при виклику `del session.token`. Геттер має кидати `AttributeError` якщо токен `None`.

class Session:
    def __init__(self, token):
        self._token = token

    @property
    def token(self):
        if self._token is None:
            raise AttributeError('Session has no active token')
        return self._token

    @token.deleter
    def token(self):
        # відкличте: встановіть None та виведіть повідомлення
        pass

s = Session('abc123')
print(s.token)  # abc123
del s.token     # Token revoked
print(s.token)  # AttributeError
Рішення
class Session:
    def __init__(self, token):
        self._token = token

    @property
    def token(self):
        if self._token is None:
            raise AttributeError('Session has no active token')
        return self._token

    @token.deleter
    def token(self):
        self._token = None
        print('Token revoked')

s = Session('abc123')
print(s.token)    # abc123
del s.token       # Token revoked
try:
    print(s.token)
except AttributeError as e:
    print(e)
07

Клас Temperature: @property з конвертацією одиниць

#

Побудуйте клас `Temperature`, що зберігає значення в Цельсіях (`self._celsius`). Виставте `celsius` як property з сеттером, що валідує >= -273.15. Додайте property `fahrenheit` (лише читання): `C * 9/5 + 32`. Додайте `@classmethod from_fahrenheit(cls, f)` як альтернативний конструктор.

class Temperature:
    def __init__(self, celsius):
        self.celsius = celsius

    @property
    def celsius(self):
        return self._celsius

    @celsius.setter
    def celsius(self, value):
        # валідація >= -273.15
        pass

    @property
    def fahrenheit(self):
        # конвертуйте до Фаренгейта
        pass

    @classmethod
    def from_fahrenheit(cls, f):
        # конвертуйте та поверніть cls(...)
        pass

t = Temperature(100)
print(t.celsius)     # 100
print(t.fahrenheit)  # 212.0
t2 = Temperature.from_fahrenheit(32)
print(t2.celsius)    # 0.0
Рішення
class Temperature:
    ABSOLUTE_ZERO = -273.15

    def __init__(self, celsius):
        self.celsius = celsius

    @property
    def celsius(self):
        return self._celsius

    @celsius.setter
    def celsius(self, value):
        if value < self.ABSOLUTE_ZERO:
            raise ValueError(f'Temperature below absolute zero: {value}')
        self._celsius = value

    @property
    def fahrenheit(self):
        return self._celsius * 9 / 5 + 32

    @classmethod
    def from_fahrenheit(cls, f):
        return cls((f - 32) * 5 / 9)

t = Temperature(100)
print(t.celsius)     # 100
print(t.fahrenheit)  # 212.0
t2 = Temperature.from_fahrenheit(32)
print(t2.celsius)    # 0.0
08

@property: ліниве кешування

#

Додайте property `words` до `Document`, що розбиває `self.text` пробілами та кешує результат у `self._words`. Розбивка має відбуватись лише при першому зверненні — наступні повертають кешоване. Виведіть повідомлення в геттері щоб довести що він запускається лише раз.

class Document:
    def __init__(self, text):
        self.text = text
        self._words = None

    @property
    def words(self):
        # обчисліть лише якщо не закешовано
        pass

doc = Document('hello world foo bar')
print(doc.words)  # ['hello', 'world', 'foo', 'bar']  (обчислено)
print(doc.words)  # ['hello', 'world', 'foo', 'bar']  (з кешу)
Рішення
class Document:
    def __init__(self, text):
        self.text = text
        self._words = None

    @property
    def words(self):
        if self._words is None:
            print('Computing words...')
            self._words = self.text.split()
        return self._words

doc = Document('hello world foo bar')
print(doc.words)  # Computing words... ['hello', 'world', 'foo', 'bar']
print(doc.words)  # ['hello', 'world', 'foo', 'bar']  (без 'Computing')
09

@staticmethod vs @classmethod: коли що використовувати

#

Доповніть клас `MathUtils`. `add(a, b)` — `@staticmethod`: чисте обчислення, клас/екземпляр не потрібні. `zeros(cls, n)` — `@classmethod`: створює список з `n` нулів і передає в конструктор класу. Покажіть що обидва можна викликати і на класі, і на екземплярі.

class MathUtils:
    def __init__(self, values):
        self.values = values

    @staticmethod
    def add(a, b):
        # чисте обчислення
        pass

    @classmethod
    def zeros(cls, n):
        # створіть екземпляр з [0] * n
        pass

print(MathUtils.add(2, 3))    # 5
m = MathUtils.zeros(4)
print(m.values)               # [0, 0, 0, 0]
Рішення
class MathUtils:
    def __init__(self, values):
        self.values = values

    @staticmethod
    def add(a, b):
        return a + b

    @classmethod
    def zeros(cls, n):
        return cls([0] * n)

print(MathUtils.add(2, 3))    # 5
m = MathUtils.zeros(4)
print(m.values)               # [0, 0, 0, 0]
print(m.add(10, 20))          # 30
10

BankAccount: всі чотири декоратори разом

#

Побудуйте клас `BankAccount` з усіма чотирма декораторами: `@classmethod open(cls, owner, initial)` — фабрика з валідацією `initial >= 0`; `@staticmethod _validate_amount(amount)` — кидає `ValueError` якщо amount <= 0; `@property balance` — геттер лише для читання; `@balance.deleter` — закриває рахунок (`_balance = None`). Додайте `deposit(amount)` та `withdraw(amount)` як звичайні методи.

class BankAccount:
    def __init__(self, owner, balance):
        self.owner = owner
        self._balance = balance

    @classmethod
    def open(cls, owner, initial=0):
        pass

    @staticmethod
    def _validate_amount(amount):
        pass

    @property
    def balance(self):
        pass

    @balance.deleter
    def balance(self):
        pass

    def deposit(self, amount):
        self._validate_amount(amount)
        self._balance += amount

    def withdraw(self, amount):
        self._validate_amount(amount)
        if amount > self._balance:
            raise ValueError('Insufficient funds')
        self._balance -= amount

acc = BankAccount.open('Alice', 100)
print(acc.balance)   # 100
acc.deposit(50)
acc.withdraw(30)
del acc.balance
Рішення
class BankAccount:
    def __init__(self, owner, balance):
        self.owner = owner
        self._balance = balance

    @classmethod
    def open(cls, owner, initial=0):
        if initial < 0:
            raise ValueError('Initial balance cannot be negative')
        return cls(owner, initial)

    @staticmethod
    def _validate_amount(amount):
        if amount <= 0:
            raise ValueError(f'Amount must be positive, got {amount}')

    @property
    def balance(self):
        if self._balance is None:
            raise AttributeError('Account is closed')
        return self._balance

    @balance.deleter
    def balance(self):
        print(f'Account of {self.owner} closed. Final balance: {self._balance}')
        self._balance = None

    def deposit(self, amount):
        self._validate_amount(amount)
        self._balance += amount

    def withdraw(self, amount):
        self._validate_amount(amount)
        if amount > self._balance:
            raise ValueError('Insufficient funds')
        self._balance -= amount

acc = BankAccount.open('Alice', 100)
print(acc.balance)   # 100
acc.deposit(50)
print(acc.balance)   # 150
acc.withdraw(30)
print(acc.balance)   # 120
del acc.balance