Python · Синтаксис · Продвинутый уровень
Декораторы методов классов ООП
Освоение @classmethod, @staticmethod, @property, @property.setter и @property.deleter. Когда каждый уместен и как они взаимодействуют с наследованием.
Краткое введение в тему и пояснения перед упражнениями (упражнения ниже):
@property, @setter, @deleter — паттерны и подводные камни
#Таблица сравнения, ошибки и примеры из stdlib
#Протокол дескрипторов: как @property работает под капотом
#Упражнения:
@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
@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
@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
@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
@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
@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)
Класс 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
@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')
@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
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