Skillversitas

Properties and Descriptors

A property turns a method pair into attribute syntax: obj.celsius reads, obj.celsius = x writes, and the methods in between can validate, compute, cache, or convert. It is Python's answer to the encapsulation question from chapter 6 - when an attribute needs rules on access, the property owns them while keeping the clean obj.x interface. Under the hood, properties are the first concrete example of the descriptor protocol, the mechanism that also powers methods, classmethods, and staticmethods - understanding it once explains all of them.

The Basic Property

class Temperature:
    def __init__(self, celsius=0.0):
        self.celsius = celsius          # the setter runs on this assignment

    @property
    def celsius(self):
        return self._celsius

    @celsius.setter
    def celsius(self, value):
        if value < -273.15:
            raise ValueError("below absolute zero")
        self._celsius = float(value)

    @property
    def fahrenheit(self):
        # read-only derived value: a property with no setter
        return self._celsius * 9 / 5 + 32

t = Temperature(25)
print(t.fahrenheit)      # 77.0 - computed on read, stored nowhere
t.celsius = 100
print(t.celsius)         # 100.0
try:
    t.celsius = -300
except ValueError as e:
    print(e)             # below absolute zero
try:
    t.fahrenheit = 212   # no setter defined
except AttributeError as e:
    print(e)             # can't set attribute

The structure: a getter (@property), an optional setter (@name.setter_celsius), because a setter that assigns to its own name would recurse. Two flavors appear above: a validating property (celsius, which checks and stores) and a computed property (fahrenheit, which derives on every read and has no setter, so assignment raises AttributeError). The computed kind is the honest answer to "should this be a method or an attribute?" when the value is cheap to derive and attribute syntax reads better - t.fahrenheit over t.get_fahrenheit().

Setters, Deleters, and the Full Protocol

class Cache:
    def __init__(self):
        self._value = None

    @property
    def value(self):
        return self._value

    @value.setter
    def value(self, v):
        self._value = v

    @value.deleter
    def value(self):
        # del obj.value triggers this instead of vanishing silently
        self._value = None
        print("cleared")

c = Cache()
c.value = 42
print(c.value)     # 42
del c.value        # 'cleared' - the deleter ran

The third piece of the protocol is @name.deleter, invoked by del obj.name. Most properties define only the getter and setter; the deleter appears when "deleting" the attribute means doing something (invalidating a cache, closing a resource). The complete descriptor protocol has three hooks - __get__, __set__, __delete__ - and property is a ready-made descriptor built from exactly those three. Knowing the mapping is the shortcut: when property is not enough (shared state across instances, type-coercing attributes, cached descriptors), you write the __get__/__set__ pair yourself.

The Descriptor Protocol: What Methods Really Are

class Logged:
    """A descriptor: defines __get__ and __set__, so it manages
    the attribute it is assigned to on the class."""

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

    def __get__(self, instance, owner):
        if instance is None:
            return self
        return instance.__dict__.get(self.name, "empty")

    def __set__(self, instance, value):
        print(f"{self.name} set to {value!r}")
        instance.__dict__[self.name] = value


class Record:
    status = Logged()

r = Record()
r.status = "active"     # 'status set to active' - the descriptor's __set__
print(r.status)         # 'active' - the descriptor's __get__

A descriptor is any class with __get__ (and optionally __set__/__delete__) that is assigned as a class attribute. When you access the attribute on an instance, Python calls the descriptor's __get__ instead of returning a plain value - with the instance passed in. This is exactly what property does, and it is also what makes methods work: a function is a descriptor, and its __get__ is what binds self to produce the bound method (chapter 5's binding, revealed). __set_name__ (shown above) lets a descriptor learn the name it was assigned - the modern replacement for passing the name through __init__. Descriptors are the load-bearing wall of Python OOP: properties, methods, classmethods, staticmethods, and dataclass fields are all applications of the same three-hook protocol.

The Evolution Path: Attribute to Property

# Step 1: a plain attribute (the start)
class Account:
    def __init__(self, owner):
        self.owner = owner
        self.balance = 0.0

# Step 2: a rule appears - balance must not go negative.
# Convert to a property; the public name is UNCHANGED, so callers are untouched.
class Account:
    def __init__(self, owner):
        self.owner = owner
        self.balance = 0.0            # setter validates from day one

    @property
    def balance(self):
        return self._balance

    @balance.setter
    def balance(self, value):
        if value < 0:
            raise ValueError("balance cannot be negative")
        self._balance = value

The design path is the lesson: start with a plain attribute, and when a real rule appears, promote it to a property without renaming. Callers still write acct.balance = x; the behavior now lives in the setter, where it is testable. This avoids both failure modes - pre-emptively wrapping every attribute in a property (noise, before any rule exists) and discovering a rule after the attribute is public (a breaking rename). The property is the migration path that keeps the interface stable while the implementation gains teeth.

Summary

Use @property with an optional @name.setter (and rare @name.deleter) to put validation and computation behind attribute syntax, keeping the backing store in a private _name to avoid recursion. Computed properties (no setter) are the right answer for cheaply-derived values that read better as attributes than methods. Under the hood, a property is a descriptor - an object with __get__/__set__/__delete__ assigned as a class attribute - which is the same protocol that binds self to methods and powers classmethod/staticmethod; write your own descriptor when you need shared state, coercion, or caching that property does not express. And follow the evolution path: plain attribute until a rule appears, then promote in place so the public name never changes.