Acceder a r.width y escribir r.width = 15 con la misma sintaxis que un atributo normal, mientras se ejecuta validación o cálculo por debajo: eso es lo que @property aporta. Y cuando esa lógica necesita compartirse entre varias clases, los descriptores entran en juego.
@property: getters y setters sin fricción
@property permite exponer un atributo calculado o validado con la misma sintaxis que un atributo simple.
class Rectangle:
def __init__(self, width: float, height: float):
self._width = width
self._height = height
@property
def width(self) -> float:
return self._width
@width.setter
def width(self, value: float) -> None:
if value <= 0:
raise ValueError("El ancho debe ser positivo.")
self._width = value
@property
def area(self) -> float:
return self._width * self._height
El beneficio concreto: la API pública no cambia. Añadir validación a un atributo existente no rompe ningún código llamador. @property sin setter crea un atributo de solo lectura. @nombre.deleter gestiona la eliminación con del.
@property es un descriptor
Lo que Python hace internamente con @property es crear un objeto de tipo property que implementa el protocolo descriptor. Estas dos formas son estrictamente equivalentes:
@property
def width(self):
return self._width
# equivalente exacto:
def width(self):
return self._width
width = property(width)
Un descriptor es cualquier objeto que define __get__, __set__ o __delete__, y que se asigna como atributo de clase (no de instancia). La clase property implementa los tres métodos: es un descriptor completo.
Cuando @property no es suficiente
@property funciona bien para un atributo en una clase. Cuando varias clases necesitan la misma lógica de validación, un descriptor personalizado es la herramienta adecuada. Duplicar properties no es la solución.
class PositiveNumber:
def __set_name__(self, owner, name):
self.public_name = name
self.private_name = f"_{name}"
def __get__(self, instance, owner):
if instance is None:
return self
return getattr(instance, self.private_name, None)
def __set__(self, instance, value: float) -> None:
if value <= 0:
raise ValueError(f"{self.public_name} debe ser un número positivo.")
setattr(instance, self.private_name, value)
class Rectangle:
width = PositiveNumber()
height = PositiveNumber()
def __init__(self, width: float, height: float):
self.width = width
self.height = height
class Cylinder:
radius = PositiveNumber()
height = PositiveNumber()
def __init__(self, radius: float, height: float):
self.radius = radius
self.height = height
__set_name__ se llama automáticamente al crear la clase (Python 3.6+). Le da al descriptor acceso a su propio nombre, evitando cadenas hardcodeadas.
La comprobación instance is None en __get__ es esencial: gestiona el acceso desde la clase (Rectangle.width) devolviendo el descriptor en lugar de intentar un getattr(None, ...).
Data descriptor vs non-data descriptor
Un descriptor que define __set__ o __delete__ es un data descriptor. Tiene prioridad sobre el __dict__ de la instancia, lo que garantiza que la validación no puede ser eludida por asignación directa.
Un descriptor que solo implementa __get__ es un non-data descriptor y puede ser sobreescrito. Las funciones Python funcionan así: se convierten en métodos enlazados mediante este mecanismo.
Cuándo usar cada uno
| Necesidad | Solución |
|---|---|
| Validación o cálculo en un atributo | @property |
| Misma lógica en varios atributos o clases | Descriptor personalizado |
| Atributo de solo lectura | @property sin setter |
| ORM, frameworks, metaprogramación | Descriptor personalizado |
La progresión natural: empezar con @property. Cuando la lógica se repite, extraer un descriptor.
