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