Módulo 1 · Lección 55 de 59
Encapsulación y propiedades
Por qué no todo el estado de un objeto debería tocarse desde fuera y cómo proteger o calcular atributos con property.
La clase CuentaBancaria de la lección anterior tenía métodos cuidadosos: retirar comprobaba que hubiera saldo, ingresar anotaba el movimiento. Todo ese cuidado se salta con una línea:
cuenta = CuentaBancaria("Lucía Ferrer", 100.0)
cuenta.saldo = -50000 # nadie lo impide
cuenta.saldo = "un montón" # esto tampoco
print(cuenta.saldo)un montónEl objeto ha quedado inservible y ningún método se ha enterado. Encapsular es decidir qué parte del objeto es pública, la que puede usar cualquiera, y qué parte es maquinaria interna que solo deberían tocar sus propios métodos.
La convención del guion bajo#
La forma habitual de marcar algo como interno es ponerle un guion bajo delante:
class CuentaBancaria:
def __init__(self, titular, saldo_inicial=0.0):
self.titular = titular # público: cualquiera puede leerlo y cambiarlo
self._saldo = saldo_inicial # interno: manéjalo con ingresar() y retirar()Python no hace absolutamente nada con ese guion bajo. cuenta._saldo = -50000 sigue funcionando. Es un acuerdo entre personas, y dice: "esto es interior, si lo tocas te apañas con lo que pase, y puede desaparecer en la próxima versión sin avisar".
Suena débil, pero funciona sorprendentemente bien, porque todo el mundo lo respeta y los editores y las herramientas de análisis lo entienden: cuando escribes cuenta. y pulsas el tabulador, los nombres con guion bajo no aparecen en la lista.
El doble guion bajo y el name mangling#
Si pones dos guiones bajos delante, Python sí hace algo, aunque probablemente no lo que esperas:
class CuentaBancaria:
def __init__(self, saldo=0.0):
self.__saldo = saldo
def consultar(self):
return self.__saldo
cuenta = CuentaBancaria(100.0)
print(cuenta.consultar())
print(cuenta.__saldo)100.0
Traceback (most recent call last):
File "/Users/erwin/curso-python/cuenta.py", line 11, in <module>
print(cuenta.__saldo)
^^^^^^^^^^^^^^
AttributeError: 'CuentaBancaria' object has no attribute '__saldo'Parece privado de verdad. No lo es. Lo que ha hecho Python es renombrar el atributo, un mecanismo que se llama name mangling: dentro de la clase CuentaBancaria, cualquier __saldo se convierte en _CuentaBancaria__saldo. El atributo sigue ahí y sigue siendo accesible:
cuenta = CuentaBancaria(100.0)
print(cuenta.__dict__)
print(cuenta._CuentaBancaria__saldo){'_CuentaBancaria__saldo': 100.0}
100.0Por qué Python no tiene atributos privados#
Es una decisión de diseño deliberada, y se resume en una frase que se repite mucho en la comunidad: we're all consenting adults here, todos somos personas adultas. La filosofía es que el lenguaje avisa de lo que es interno y confía en que no lo toques, en vez de montar un sistema de permisos que igualmente se puede burlar en casi todos los lenguajes que lo tienen.
A cambio se gana algo concreto: cuando una librería tiene un fallo y necesitas apañarlo, o cuando quieres inspeccionar un objeto mientras depuras, puedes. Nada te lo bloquea. La responsabilidad de no romper nada es tuya.
@property: un método que se usa como atributo#
Hay datos que no tiene sentido guardar porque se pueden calcular. El precio con IVA de un producto es el precio sin IVA por 1.21: guardarlo aparte es pedir que un día se queden descoordinados.
Podrías hacerlo un método y llamarlo con paréntesis, pero @property permite exponerlo como si fuera un atributo:
class Producto:
def __init__(self, nombre, precio_sin_iva, unidades):
self.nombre = nombre
self.precio_sin_iva = precio_sin_iva
self.unidades = unidades
@property
def precio_con_iva(self):
"""Se calcula al vuelo, no se guarda."""
return self.precio_sin_iva * 1.21
@property
def valor_en_almacen(self):
return self.precio_con_iva * self.unidades
teclado = Producto("Teclado mecánico", 79.90, 4)
print(f"{teclado.precio_con_iva:.2f} €")
print(f"{teclado.valor_en_almacen:.2f} €")
teclado.precio_sin_iva = 69.90
print(f"{teclado.precio_con_iva:.2f} €")96.68 €
386.72 €
84.58 €Fíjate en que se escribe teclado.precio_con_iva, sin paréntesis, aunque por dentro sea un método que se ejecuta en cada lectura. Por eso al cambiar precio_sin_iva el precio con IVA se actualiza solo: no había ningún valor guardado que refrescar.
El setter: validar antes de asignar#
Cuando sí quieres permitir la asignación pero con condiciones, se añade un segundo método decorado con @nombre.setter:
class Producto:
def __init__(self, nombre, precio):
self.nombre = nombre
self.precio = precio # pasa por el setter, no por _precio
@property
def precio(self):
return self._precio
@precio.setter
def precio(self, valor):
if not isinstance(valor, (int, float)):
raise TypeError("El precio tiene que ser un número")
if valor < 0:
raise ValueError("El precio no puede ser negativo")
self._precio = float(valor)
teclado = Producto("Teclado mecánico", 79.90)
print(teclado.precio)
teclado.precio = 69.90
print(teclado.precio)
try:
teclado.precio = -10
except ValueError as error:
print(f"Rechazado: {error}")
try:
teclado.precio = "barato"
except TypeError as error:
print(f"Rechazado: {error}")
print(teclado.precio)79.9
69.9
Rechazado: El precio no puede ser negativo
Rechazado: El precio tiene que ser un número
69.9Cuatro detalles de esa clase, que son los que suelen dar guerra:
- Los dos métodos se llaman igual,
precio. El decorador@precio.setterse refiere a la propiedad definida justo arriba. - El dato real se guarda en
self._precio, con guion bajo. Si lo guardaras enself.preciotendrías una recursión infinita, porque asignar apreciovolvería a llamar alsetter. - En
__init__se escribeself.precio = precio, noself._precio = precio. Así la validación también se aplica al crear el objeto, que es justo lo que quieres. - Cuando un valor no vale, el
setterlanza una excepción conraise. Ignorarlo en silencio sería peor: quien asignó se quedaría convencido de que el precio cambió.
Un ejemplo con todo junto#
class CuentaBancaria:
def __init__(self, titular, saldo_inicial=0.0):
self.titular = titular
self._saldo = saldo_inicial
self._movimientos = []
@property
def saldo(self):
"""Solo lectura: se consulta, pero no se asigna desde fuera."""
return self._saldo
@property
def movimientos(self):
return list(self._movimientos) # una copia, para que nadie toque la original
def ingresar(self, cantidad):
if cantidad <= 0:
raise ValueError("El ingreso tiene que ser positivo")
self._saldo += cantidad
self._movimientos.append(f"+{cantidad:.2f}")
def retirar(self, cantidad):
if cantidad <= 0:
raise ValueError("La retirada tiene que ser positiva")
if cantidad > self._saldo:
raise ValueError(f"Saldo insuficiente: tienes {self._saldo:.2f} €")
self._saldo -= cantidad
self._movimientos.append(f"-{cantidad:.2f}")
cuenta = CuentaBancaria("Lucía Ferrer", 500.0)
cuenta.ingresar(120.0)
try:
cuenta.retirar(1000.0)
except ValueError as error:
print(f"Operación rechazada: {error}")
print(f"Saldo: {cuenta.saldo:.2f} €")
print(cuenta.movimientos)
cuenta.movimientos.append("+1000000.00") # no afecta: era una copia
print(cuenta.movimientos)Operación rechazada: Saldo insuficiente: tienes 620.00 €
Saldo: 620.00 €
['+120.00']
['+120.00']Ahora el saldo solo cambia por las dos puertas previstas, y la lista de movimientos que se entrega es una copia, así que nadie puede falsificar el histórico desde fuera. La última línea lo demuestra: el millón de euros se añadió a una copia que se tiró a la basura.
Cuándo merece la pena#
Las propiedades son una herramienta fina y se pueden usar de más. La regla práctica:
| Situación | Qué hacer |
|---|---|
| Un dato normal, sin reglas | Atributo público y ya está |
| Un valor que se deduce de otros | @property de solo lectura |
| Un dato con reglas al asignar | @property con setter |
| Maquinaria interna | Atributo con _ delante, sin propiedad |
| Una operación que tarda o que cambia cosas | Un método normal, con paréntesis |