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