En Python los atributos públicos son la norma, no la excepción. Pero a veces necesitas interceptar la lectura o escritura de un atributo —para validar, transformar o calcular— sin obligar a todo el código cliente a cambiar de obj.temperatura a obj.get_temperatura(). Ahí es donde entra property.
Un property es un descriptor: un objeto que implementa el protocolo __get__, __set__ y __delete__, y que vive en la clase (no en la instancia). Cuando accedes a obj.temperatura, Python no busca el atributo en el diccionario de la instancia primero; encuentra el descriptor en la clase y delega la operación a él. El resultado es que el caller escribe obj.temperatura = 25 y tú ejecutas lógica arbitraria por debajo, con una interfaz absolutamente idéntica a la de un atributo simple.
Esto importa porque el principio uniforme de acceso es una propiedad del lenguaje: puedes empezar con un atributo desnudo y convertirlo en property sin romper ningún contrato. No hay penalización por publicar primero self.temperatura = valor directamente. Solo añade el @property cuando tengas una razón concreta: validación, conversión de unidades, valor derivado. Hacerlo antes es el ruido que la regla YAGNI prohíbe.
Lo que sí rompe cosas es confundir la dirección de la delegación. Si defines solo el getter (solo @property) y alguien intenta asignar al atributo, obtendrá AttributeError. No es un bug; es el contrato: un property sin setter es de solo lectura. Y si usas _temperatura como almacén interno pero nombras el property igual que el atributo sin guión bajo, entras en recursión infinita porque el getter llama al propio descriptor.
from __future__ import annotations
class Sensor:
"""Sensor de temperatura con conversión automática y validación."""
def __init__(self, name: str, celsius: float = 0.0) -> None:
self.name = name
self._celsius: float = 0.0 # almacén real, separado del descriptor
self.celsius = celsius # usa el setter desde el inicio para validar
# ── getter ────────────────────────────────────────────────────────────────
@property
def celsius(self) -> float:
return self._celsius
# ── setter ────────────────────────────────────────────────────────────────
@celsius.setter
def celsius(self, value: float) -> None:
if value < -273.15:
raise ValueError(f"Temperatura imposible: {value} °C")
self._celsius = float(value)
# ── deleter ───────────────────────────────────────────────────────────────
@celsius.deleter
def celsius(self) -> None:
# "resetear" el sensor, no liberar memoria
self._celsius = 0.0
# ── atributo computado (solo lectura) ─────────────────────────────────────
@property
def fahrenheit(self) -> float:
# sin almacenamiento propio; siempre derivado de _celsius
return self._celsius * 9 / 5 + 32
@property
def kelvin(self) -> float:
return self._celsius + 273.15
def __repr__(self) -> str:
return f"Sensor({self.name!r}, {self._celsius:.2f} °C)"
# ── uso ───────────────────────────────────────────────────────────────────────
if __name__ == "__main__":
s = Sensor("exterior", celsius=20.0)
print(s.fahrenheit) # 68.0 — parece atributo, es cómputo
print(s.kelvin) # 293.15
s.celsius = 100.0
print(s) # Sensor('exterior', 100.00 °C)
try:
s.celsius = -300 # dispara ValueError
except ValueError as e:
print(e)
del s.celsius # invoca el deleter, vuelve a 0.0
print(s.celsius) # 0.0
Qué está pasando en cada decisión
El almacén es _celsius, no celsius. Si el getter leyera self.celsius, llamaría al descriptor de nuevo y entraría en recursión. El guión bajo señala “implementación interna” y rompe el ciclo. Esta es la convención universal cuando usas property.
El setter se llama desde __init__ con self.celsius = celsius, no self._celsius = celsius. Eso garantiza que la validación ocurre incluso durante la construcción. Si alguien pasa -300 al constructor, falla en el momento correcto, no silenciosamente.
fahrenheit y kelvin son properties de solo lectura —no tienen setter definido—. Intentar s.fahrenheit = 50 lanza AttributeError: can't set attribute. Es el contrato apropiado para un valor puramente derivado: no tiene sentido asignarlo.
El deleter no es el operador más usado, pero modela correctamente el comportamiento de del obj.celsius. Aquí lo usamos para resetear el estado, que es un caso razonable para hardware físico. Sin él, del s.celsius lanzaría AttributeError.
property es un descriptor de datos porque implementa tanto __get__ como __set__ (aunque el setter lance error). Los descriptores de datos tienen prioridad sobre el __dict__ de la instancia, lo que significa que no puedes “ocultar” el property añadiendo una clave celsius directamente a s.__dict__: el descriptor siempre gana.
Errores que debes conocer
Error: nombrar el almacén interno igual que el property, lo que provoca recursión infinita en el getter.
# ❌ Wrong
@property
def celsius(self) -> float:
return self.celsius # llama al descriptor → llama al getter → infinito
# ✅ Right
@property
def celsius(self) -> float:
return self._celsius # almacén con prefijo, rompe el ciclo
El prefijo _ en _celsius hace que Python busque un atributo de instancia normal, sin pasar por el descriptor.
Error: olvidar decorar el setter con @nombre.setter y usar @property de nuevo, lo que crea un segundo property independiente que oculta al primero.
# ❌ Wrong
@property
def celsius(self) -> float:
return self._celsius
@property # redefine el property, el getter anterior desaparece
def celsius(self, value: float) -> None:
self._celsius = value
# ✅ Right
@property
def celsius(self) -> float:
return self._celsius
@celsius.setter # extiende el mismo objeto property
def celsius(self, value: float) -> None:
self._celsius = value
@celsius.setter llama a celsius.setter(fn) en el objeto property existente y devuelve una copia nueva con el setter registrado; @property crearía un objeto completamente distinto.
Error: añadir getters y setters como métodos explícitos cuando no hay lógica que justifique un property, ignorando la convención de Python.
# ❌ Wrong — Java pensando en Python
class Point:
def get_x(self) -> float:
return self._x
def set_x(self, value: float) -> None:
self._x = value
# ✅ Right — atributo público directo mientras no haya razón para interceptarlo
class Point:
def __init__(self, x: float, y: float) -> None:
self.x = x
self.y = y
Si en el futuro x necesita validación, añades @property y ningún código externo se rompe; ese es exactamente el punto del principio uniforme de acceso.
N° 99