🚀AdvancedMetaprogramming

Descriptors

The descriptor protocol: __get__, __set__, __delete__ how property, classmethod, and staticmethod are implemented under the hood.

Overview

A **descriptor** is any object that defines at least one of `__get__`, `__set__`, or `__delete__`. When a descriptor is stored as a **class attribute**, Python routes attribute access through it instead of the default `__dict__` lookup. This is the mechanism behind `property`, `classmethod`, `staticmethod`, `functools.cached_property`, and ORM fields. **Data descriptor**: defines `__set__` (and/or `__delete__`) has higher priority than instance `__dict__`. Used for validation, type-coercion. **Non-data descriptor**: defines only `__get__` instance `__dict__` can shadow it. Functions are non-data descriptors (that's why methods work). Descriptors receive the *owner class* and the *instance* (or `None` for class access) so they can behave differently depending on context. They're the most powerful low-level customisation point in Python.

Key Points

  • •`__get__(self, obj, objtype=None)` called on attribute read; `obj` is None when accessed on the class
  • •`__set__(self, obj, value)` called on attribute write; making this a data descriptor
  • •`__delete__(self, obj)` called on `del obj.attr`
  • •Data descriptors (`__set__` defined) override instance `__dict__`; non-data descriptors do not
  • •Descriptors must be stored as **class attributes** instance attributes don't trigger the protocol
  • •`property` is a built-in data descriptor; `classmethod`/`staticmethod` are non-data descriptors
  • •`__set_name__(self, owner, name)` (Python 3.6+) called when the descriptor is assigned to a class; provides the attribute name

Examples

Typed descriptor with validation

class TypedAttr:
    def __set_name__(self, owner, name):
        self.name = name
        self.storage = f"_{name}"

    def __init__(self, expected_type):
        self.expected_type = expected_type

    def __get__(self, obj, objtype=None):
        if obj is None:
            return self
        return getattr(obj, self.storage, None)

    def __set__(self, obj, value):
        if not isinstance(value, self.expected_type):
            raise TypeError(f"{self.name} must be {self.expected_type.__name__}")
        setattr(obj, self.storage, value)

class Person:
    name = TypedAttr(str)
    age  = TypedAttr(int)

    def __init__(self, name, age):
        self.name = name
        self.age  = age

p = Person("Alice", 30)
print(p.name, p.age)   # Alice 30

try:
    p.age = "old"
except TypeError as e:
    print(e)           # age must be int

Implementing property from scratch

class MyProperty:
    def __init__(self, fget=None, fset=None):
        self.fget = fget
        self.fset = fset

    def __get__(self, obj, objtype=None):
        if obj is None:
            return self
        if self.fget is None:
            raise AttributeError("unreadable attribute")
        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)

class Circle:
    def __init__(self, radius):
        self._radius = radius

    @MyProperty
    def radius(self):
        return self._radius

    @radius.setter
    def radius(self, value):
        if value < 0:
            raise ValueError("Radius must be non-negative")
        self._radius = value

c = Circle(5)
print(c.radius)   # 5
c.radius = 10
print(c.radius)   # 10

cached_property compute once, store in instance dict

import time

class CachedProperty:
    """Non-data descriptor instance __dict__ shadows it after first access."""
    def __init__(self, func):
        self.func = func
        self.attrname = None

    def __set_name__(self, owner, name):
        self.attrname = name

    def __get__(self, obj, objtype=None):
        if obj is None:
            return self
        if self.attrname not in obj.__dict__:
            obj.__dict__[self.attrname] = self.func(obj)
        return obj.__dict__[self.attrname]

class DataSet:
    def __init__(self, data):
        self.data = data

    @CachedProperty
    def average(self):
        print("computing...")
        return sum(self.data) / len(self.data)

ds = DataSet([1, 2, 3, 4, 5])
print(ds.average)   # computing... 3.0
print(ds.average)   # 3.0  (no recompute)

Common Mistakes

  • Storing the descriptor as an instance attribute it won't trigger `__get__`/`__set__`; must be a class attribute
  • Storing per-instance data on the descriptor itself (`self.value = ...`) all instances share the same descriptor object, so they'd all get the same value; use `obj.__dict__` or `setattr(obj, self.storage, ...)`
  • Forgetting `__set_name__` without it, the descriptor doesn't know its own attribute name

Note

Use `functools.cached_property` from the standard library (Python 3.8+) instead of rolling your own. It's the same non-data descriptor pattern but battle-tested and thread-aware.

Exercises

Write Python code to solve each exercise. Run it in your browser and check your output.

Loading editor…

Completed: 0 / 5 exercises

Knowledge Check

Question 1 of 4

What makes a descriptor a "data descriptor"?