Saltar al contenido principal

Fase 2: Comportamiento y datos protegidos

Esta fase va al encapsulamiento: el objeto es el único dueño de sus datos y nadie desde afuera debería poder romperlo o dejarlo en un estado inconsistente.

1. Convención _atributo y por qué no es seguridad real​

En lenguajes como Java o C# existen palabras reservadas como private o public para bloquear el acceso a los datos. En Python no hay puertas con llave. Todo es público por diseño.

La comunidad de Python resuelve esto así:

  • Si escribo un guion bajo antes de un atributo, como _atributo, le estoy indicando a otros programadores que ese dato es privado y no debería usarse directamente.
  • Eso no es seguridad real. Python no me va a impedir modificar ese atributo si alguien decide hacerlo a propósito.
objeto._saldo = -5000

Python no me va a detener. La convención sirve como aviso, no como protección real.

2. @property para leer y validar al asignar​

Para crear una barrera real que proteja los datos, uso los decoradores @property y @atributo.setter.

Estas herramientas permiten que, desde afuera, el código se vea como una variable normal, pero por dentro se ejecuten validaciones y reglas de negocio.

class Producto:
def __init__(self, precio):
self.precio = precio # Python llama automáticamente al setter

@property
def precio(self):
return self._precio # Leo el dato protegido

@precio.setter
def precio(self, nuevo_valor):
if nuevo_valor < 0:
raise ValueError("¡El precio no puede ser negativo!")
self._precio = nuevo_valor

Si alguien intenta hacer esto:

p = Producto(100)
p.precio = -5

el objeto no acepta ese valor. La regla vive dentro del propio objeto.

3. Métodos especiales útiles: __eq__, __len__ y __iter__​

Estos son atajos para que mis objetos se comporten como elementos nativos de Python, como listas, números o diccionarios. En Python, estos métodos también se conocen como dunder methods.

  • __eq__(self, otro): define cuándo dos objetos son iguales.
  • __len__(self): permite usar len(objeto).
  • __iter__(self): permite recorrer el objeto con for elemento in objeto.
class Producto:
def __init__(self, id_producto, nombre, precio):
self.id_producto = id_producto
self.nombre = nombre
self.precio = precio

# Dos productos son iguales si tienen el mismo ID
def __eq__(self, otro):
if not isinstance(otro, Producto):
return False
return self.id_producto == otro.id_producto


class CarritoDeCompras:
def __init__(self):
self._productos = [] # Lista interna para guardar los objetos

def agregar(self, producto):
self._productos.append(producto)

def __len__(self):
return len(self._productos)

def __iter__(self):
return iter(self._productos)

Ahora lo pruebo de forma real:

# Creo algunos productos
p1 = Producto(101, "Laptop", 800)
p2 = Producto(101, "Laptop Pro", 1200) # Mismo ID, diferente precio y nombre
p3 = Producto(102, "Mouse", 20)

# --- Probando __eq__ ---
print(p1 == p2) # True, porque tienen el mismo ID: 101
print(p1 == p3) # False

# Creo el carrito y agrego productos
mi_carrito = CarritoDeCompras()
mi_carrito.agregar(p1)
mi_carrito.agregar(p3)

# --- Probando __len__ ---
print(len(mi_carrito)) # 2

# --- Probando __iter__ ---
for producto in mi_carrito:
print(f"Producto: {producto.nombre} - ${producto.precio}")

Esto hace que el objeto se sienta más natural dentro de Python. No tengo que acceder a estructuras internas manualmente si ya el objeto sabe cómo comportarse.

4. Operaciones de negocio dentro del objeto​

Un error muy común de principiante es sacar todos los datos del objeto, procesarlos en una función externa y luego meterlos de vuelta.

La regla de oro es esta: si la lógica necesita los datos del objeto, esa lógica pertenece al objeto. El código de afuera no debería calcular impuestos, validar stock ni formatear correos. Solo debería dar la orden: producto.aplicar_descuento().

Eso produce código más limpio, más mantenible y más seguro. La lógica vive cerca de los datos que la usan.

5. Práctica con código​

from typing import Iterator


class Tarea:
def __init__(self, titulo: str):
self.titulo = titulo
self.completada = False

def __repr__(self):
return f"Tarea('{self.titulo}', completada={self.completada})"


class Usuario:
def __init__(self, username: str, email: str):
self.username = username
self.email = email
self._tareas: list[Tarea] = []

# Protegemos la lista para que no sea manipulada desde afuera
@property
def tareas(self) -> list[Tarea]:
"""
Retorna una copia de la lista.
Así evito que hagan usuario.tareas.clear() y modifiquen la lista real.
"""
return self._tareas.copy()

# Operación de negocio segura
def asignar_tarea(self, nueva_tarea: Tarea) -> None:
"""Agrega una tarea validando las reglas del negocio."""
if not isinstance(nueva_tarea, Tarea):
raise TypeError("Solo se pueden asignar instancias válidas de la clase Tarea.")

# Validar duplicados por título
if any(t.titulo == nueva_tarea.titulo for t in self._tareas):
raise ValueError(f"La tarea '{nueva_tarea.titulo}' ya está asignada a este usuario.")

self._tareas.append(nueva_tarea)

# Métodos especiales para comportamiento nativo
def __len__(self) -> int:
return len(self._tareas)

def __iter__(self) -> Iterator[Tarea]:
return iter(self._tareas)

def __eq__(self, otro: object) -> bool:
if not isinstance(otro, Usuario):
return False
return self.username.lower() == otro.username.lower()

def __repr__(self) -> str:
return f"Usuario(username='{self.username}', tareas_totales={len(self)})"


# Demostración del comportamiento protegido
if __name__ == "__main__":
user1 = Usuario("dev_marta", "marta@code.com")
t1 = Tarea("Refactorizar API")
t2 = Tarea("Actualizar dependencias")

# 1. Mutación segura a través de métodos de negocio
user1.asignar_tarea(t1)
user1.asignar_tarea(t2)

# 2. Intento de sabotaje externo
user1.tareas.clear() # limpia la copia, no la lista real
print(f"Tareas reales después del intento de borrado: {len(user1)}")

# 3. Uso de métodos mágicos nativos
print(f"El usuario tiene {len(user1)} tareas asignadas.")

print("\nListado de tareas iterando directamente sobre el usuario:")
for tarea in user1:
print(f" - {tarea.titulo}")

# 4. Validación de duplicados (invariante del objeto)
print("\nIntentando agregar una tarea duplicada...")
try:
user1.asignar_tarea(Tarea("Refactorizar API"))
except ValueError as e:
print(f"Bloqueado por el objeto: {e}")

# 5. Comparación nativa con __eq__
user2 = Usuario("dev_marta", "otro_correo@code.com")
print(f"\n¿user1 es el mismo usuario que user2?: {user1 == user2}")

Con este tipo de diseño no solo protejo datos, sino que también construyo objetos con identidad, reglas y comportamiento claros. Eso es un paso importante hacia software más profesional y más difícil de romper.