Python · Syntax · Advanced

OOP Class Method Decorators

10 tasks

Master @classmethod, @staticmethod, @property, @property.setter and @property.deleter. Learn when each decorator is appropriate and how they interact with inheritance.

@classmethod and @staticmethod explained

#
**Why these decorators exist** Before `@classmethod` and `@staticmethod`, Python developers wrote module-level helper functions for logic that belonged to a class conceptually. Those functions lost their connection to the class, couldn't be overridden in subclasses, and cluttered the module namespace. These two decorators solve different versions of that problem. **@classmethod — the class itself is the first argument** A `@classmethod` receives `cls` — the class the method was called on — instead of `self`. This makes it *subclass-aware*: if a subclass inherits the method, `cls` inside it will be the subclass, not the parent. ```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, NOT Person @classmethod def from_dict(cls, d): return cls(d['name'], d['age']) class Employee(Person): def __init__(self, name, age, team): super().__init__(name, age) self.team = team # Inheriting the classmethod: e = Employee.from_string('Bob, 25') # cls = Employee inside from_string print(type(e)) # <class '__main__.Employee'> — not Person! ``` If you had written `return Person(name, age)` inside `from_string`, the subclass would silently get a `Person` back instead of an `Employee` — a classic inheritance bug. Using `cls(...)` avoids it. **@staticmethod — a namespaced plain function** `@staticmethod` gets neither `self` nor `cls`. It is a regular function that lives inside the class namespace. Use it when the logic *belongs with* the class but needs no access to class or instance state: ```python class Temperature: def __init__(self, celsius): self._celsius = celsius @staticmethod def celsius_to_fahrenheit(c): # pure conversion — no self needed return c * 9 / 5 + 32 @staticmethod def is_valid_celsius(c): return c >= -273.15 @classmethod def from_fahrenheit(cls, f): # needs cls to create an instance return cls((f - 32) * 5 / 9) Temperature.celsius_to_fahrenheit(100) # 212.0 — called on the class t = Temperature(20) t.celsius_to_fahrenheit(0) # 32.0 — also works on instances ``` **Choosing between the three:** Needs `self` (reads or writes instance data) → plain method. Needs `cls` (creates instances, reads class state, should be polymorphic) → `@classmethod`. Needs neither (pure computation, utility) → `@staticmethod`. If in doubt between `@classmethod` and `@staticmethod`: ask whether a subclass calling this method should get a different result. If yes — use `@classmethod`.

@property, @setter, @deleter — patterns and pitfalls

#
**@property — attribute-style access with hidden logic** A `@property` turns a method into something that looks like an attribute: you access it with `obj.value`, not `obj.value()`. The reason you'd do this is to add computed logic, validation, or lazy evaluation *without changing the public interface*: ```python class Circle: def __init__(self, radius): self.radius = radius # this calls the setter below! @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 # computed, not stored @property def diameter(self): return self._radius * 2 c = Circle(5) print(c.area) # 78.53... — looks like attribute, works like method c.radius = 10 # setter validates c.radius = -1 # ValueError # c.area = 50 # AttributeError — no setter defined ``` Key point: `self.radius = radius` in `__init__` *goes through the setter*. This means validation is active from the very moment the object is constructed — you don't need to call a separate `validate()` method. **@property.deleter — cleanup on `del`** The deleter fires when `del obj.attr` is called. Useful for cache invalidation, resource cleanup, or marking a value as unset: ```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() # changing debug mode invalidates cache @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 ``` **Naming rule:** all three methods — `@property`, `@x.setter`, `@x.deleter` — *must* use the same name. The internal storage conventionally uses a leading underscore: `self._x`. If you accidentally name the setter differently, Python creates a second attribute instead of linking them — no error, silent bug. **Lazy caching with @property** Properties are also the standard pattern for deferring expensive computation until first access and then caching the result: ```python class Report: def __init__(self, rows): self.rows = rows self._summary = None @property def summary(self): if self._summary is None: # expensive: compute once and cache 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) # computed now print(r.summary) # returned from cache — no recomputation ``` Python 3.8+ ships `functools.cached_property` which does the same thing in one line, but knowing the manual pattern explains how it works.

Comparison table, common mistakes, and stdlib examples

#
**Quick comparison** | Decorator | First arg | Can access | Subclass-aware? | When to use | |---|---|---|---|---| | instance method | `self` | instance + class | via `type(self)` | Everything that reads/writes instance state | | `@classmethod` | `cls` | class only | yes — `cls` is the subclass | Alternative constructors, factories, class-level operations | | `@staticmethod` | — | nothing | no | Pure utilities namespaced to the class | | `@property` (getter) | `self` | instance | via `type(self)` | Computed attributes, hiding private storage | | `@x.setter` | `self` + value | instance | — | Validated writes | | `@x.deleter` | `self` | instance | — | Cleanup on `del` | **Common mistakes** *Hardcoding the class name in @classmethod:* ```python # WRONG — breaks for subclasses @classmethod def from_string(cls, s): return Person(s) # always Person, never the actual subclass # CORRECT @classmethod def from_string(cls, s): return cls(s) # cls = whatever class this was called on ``` *Writing to self.x inside the getter causes infinite recursion:* ```python # WRONG — getter calls itself forever @property def value(self): self.value = self._value # AttributeError or RecursionError return self._value # CORRECT — getter only reads @property def value(self): return self._value ``` *Naming the setter differently from the property:* ```python # WRONG — creates two unrelated attributes @property def age(self): ... @old_age.setter # should be @age.setter def age(self, v): ... ``` *Using @classmethod when @staticmethod is enough:* If `cls` is never used inside the method, it should be `@staticmethod`. An unused `cls` is a code smell — it suggests someone copy-pasted a classmethod and stripped the body without updating the decorator. **In the standard library** `dict.fromkeys(keys, val)` — `@classmethod`, so `OrderedDict.fromkeys(...)` returns an `OrderedDict`. `datetime.date.today()` — `@classmethod` for the same polymorphism reason. `int.bit_length()` — plain instance method (reads instance data). `str.maketrans(...)` — `@staticmethod` (utility, no instance/class needed).

The descriptor protocol: how @property works under the hood

#
**How @property actually works — the descriptor protocol** `@property` is not magic built into the interpreter. It is a regular Python class that implements the *descriptor protocol* — three methods that Python calls when an attribute is accessed, set, or deleted on an object. A descriptor is any object that defines `__get__`, `__set__`, or `__delete__`. When Python resolves `obj.attr`, it checks whether `attr` on the class is a descriptor and, if so, calls `__get__` on it instead of returning the raw value: ```python # This is roughly how property is implemented internally: 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: # accessed on the class, not an instance 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 __delete__(self, obj): if self.fdel is None: raise AttributeError("can't delete attribute") self.fdel(obj) def setter(self, fset): return type(self)(self.fget, fset, self.fdel) def deleter(self, fdel): return type(self)(self.fget, self.fset, fdel) ``` You can build your own descriptors for reusable validation logic that you'd otherwise repeat across many properties: ```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 ``` The `__set_name__` method (Python 3.6+) is called automatically when the class is created — it receives the attribute name the descriptor is assigned to, so you don't have to repeat it. This is how `@property` knows what name to use in error messages, and it's the mechanism behind Django model fields, SQLAlchemy columns, and dataclass fields.
01

@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
02

@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
03

@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
04

@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
05

@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
06

@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
07

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
08

@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')
09

@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
10

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