Python · Syntax · Advanced
OOP Class Method Decorators
Master @classmethod, @staticmethod, @property, @property.setter and @property.deleter. Learn when each decorator is appropriate and how they interact with inheritance.
Quick topic start and explanations before exercises (exercises below):
@property, @setter, @deleter — patterns and pitfalls
#Comparison table, common mistakes, and stdlib examples
#The descriptor protocol: how @property works under the hood
#Exercises:
@classmethod: alternative constructor
#Add a class method `from_string(cls, s)` to `Person` that parses a string in the format `'Alice,30'` and returns a new `Person` instance. Use `@classmethod` — the first argument is `cls`, not `self`.
class Person:
def __init__(self, name, age):
self.name = name
self.age = age
@classmethod
def from_string(cls, s):
# parse 'Alice,30' and return cls(...)
pass
p = Person.from_string('Alice,30')
print(p.name) # Alice
print(p.age) # 30
Solution
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: subclass-aware factory
#Create a base class `Animal` with a `@classmethod create(cls, name)` that returns an instance of whatever class it's called on. Subclasses `Dog` and `Cat` inherit the method. Show that `Dog.create('Rex')` returns a `Dog`, not an `Animal`.
class Animal:
def __init__(self, name):
self.name = name
@classmethod
def create(cls, name):
# return an instance of cls
pass
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(isinstance(Dog.create('Rex'), Dog)) # True
Solution
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: utility method
#Add a `@staticmethod is_valid(email)` to an `EmailValidator` class that returns `True` if the string contains exactly one `@` and at least one `.` after it. Static methods belong to the class namespace but receive neither `self` nor `cls`.
class EmailValidator:
@staticmethod
def is_valid(email):
# return True if email has one '@' and '.' after it
pass
print(EmailValidator.is_valid('[email protected]')) # True
print(EmailValidator.is_valid('no-at-sign')) # False
print(EmailValidator.is_valid('two@@signs.com')) # False
print(EmailValidator.is_valid('missing-dot@com')) # False
Solution
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('two@@signs.com')) # False
print(EmailValidator.is_valid('missing-dot@com')) # False
@property: computed read-only attribute
#Add a `@property area` to `Rectangle` that returns `width * height`. The property should be computed on access, not stored. Attempting to set `rect.area = 10` must raise `AttributeError`.
class Rectangle:
def __init__(self, width, height):
self.width = width
self.height = height
@property
def area(self):
# return computed area
pass
r = Rectangle(4, 5)
print(r.area) # 20 (accessed like attribute, not r.area())
r.width = 10
print(r.area) # 50 (recomputed)
# r.area = 99 # would raise AttributeError
Solution
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: validated attribute
#Add `age` as a managed attribute to `Person` using `@property` and `@age.setter`. The setter must raise `ValueError` if the value is negative or not an integer. Store the real value in `self._age`.
class Person:
def __init__(self, name, age):
self.name = name
self.age = age # goes through setter
@property
def age(self):
return self._age
@age.setter
def age(self, value):
# validate: must be int and >= 0
pass
p = Person('Alice', 30)
print(p.age) # 30
p.age = 31
print(p.age) # 31
p.age = -1 # ValueError: age must be >= 0
Solution
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
p.age = 31
print(p.age) # 31
try:
p.age = -1
except ValueError as e:
print(e) # age must be >= 0
@property.deleter: cleanup on del
#Add a `@token.deleter` to `Session` that sets `self._token = None` and prints `'Token revoked'` when `del session.token` is called. The getter should raise `AttributeError` if the token is `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):
# revoke: set to None and print message
pass
s = Session('abc123')
print(s.token) # abc123
del s.token # Token revoked
print(s.token) # AttributeError: Session has no active token
Solution
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) # Session has no active token
Temperature class: @property with unit conversion
#Build a `Temperature` class that stores the value in Celsius internally (`self._celsius`). Expose `celsius` as a property with a setter that validates >= -273.15. Add a `fahrenheit` property (read-only) that converts: `C * 9/5 + 32`. Add a `@classmethod from_fahrenheit(cls, f)` as an alternative constructor.
class Temperature:
def __init__(self, celsius):
self.celsius = celsius # goes through setter
@property
def celsius(self):
return self._celsius
@celsius.setter
def celsius(self, value):
# validate >= -273.15
pass
@property
def fahrenheit(self):
# convert celsius to fahrenheit
pass
@classmethod
def from_fahrenheit(cls, f):
# convert f to celsius and return cls(...)
pass
t = Temperature(100)
print(t.celsius) # 100
print(t.fahrenheit) # 212.0
t2 = Temperature.from_fahrenheit(32)
print(t2.celsius) # 0.0
Solution
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)
def __repr__(self):
return f'Temperature({self._celsius}C)'
t = Temperature(100)
print(t.celsius) # 100
print(t.fahrenheit) # 212.0
t2 = Temperature.from_fahrenheit(32)
print(t2.celsius) # 0.0
@property: lazy caching
#Add a `words` property to `Document` that splits `self.text` by spaces and caches the result in `self._words`. The split must happen only on first access — subsequent accesses return the cached value. Print a message inside the getter to prove it only runs once.
class Document:
def __init__(self, text):
self.text = text
self._words = None # cache
@property
def words(self):
# compute only if not cached
pass
doc = Document('hello world foo bar')
print(doc.words) # ['hello', 'world', 'foo', 'bar'] (computed)
print(doc.words) # ['hello', 'world', 'foo', 'bar'] (cached)
Solution
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'] (no 'Computing')
@staticmethod vs @classmethod: when to use which
#Complete the `MathUtils` class. `add(a, b)` is a `@staticmethod` — pure computation, no class/instance needed. `zeros(cls, n)` is a `@classmethod` — creates a list of `n` zeros and passes it to the class constructor. Show both can be called on the class and on an instance.
class MathUtils:
def __init__(self, values):
self.values = values
@staticmethod
def add(a, b):
# pure computation, no self or cls
pass
@classmethod
def zeros(cls, n):
# create instance with [0] * n
pass
print(MathUtils.add(2, 3)) # 5
m = MathUtils.zeros(4)
print(m.values) # [0, 0, 0, 0]
print(m.add(10, 20)) # 30
Solution
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: all four decorators combined
#Build a `BankAccount` class that uses all four decorators: `@classmethod open(cls, owner, initial)` — factory that validates `initial >= 0`; `@staticmethod _validate_amount(amount)` — raises `ValueError` if amount <= 0; `@property balance` — read-only getter; `@balance.deleter` — closes the account (sets `_balance = None`). Also add `deposit(amount)` and `withdraw(amount)` as regular methods.
class BankAccount:
def __init__(self, owner, balance):
self.owner = owner
self._balance = balance
@classmethod
def open(cls, owner, initial=0):
# validate initial >= 0, return cls(...)
pass
@staticmethod
def _validate_amount(amount):
# raise ValueError if amount <= 0
pass
@property
def balance(self):
# return _balance, raise if account closed
pass
@balance.deleter
def balance(self):
# close account
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)
print(acc.balance) # 150
acc.withdraw(30)
print(acc.balance) # 120
del acc.balance # account closed
Solution
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 # Account of Alice closed. Final balance: 120