Encapsulamiento en Python: @property, atributos privados y __slots__
- Por qué esconder información mejora el diseño
- Los tres niveles de acceso en Python
- Name mangling: cómo funciona __privado
- @property: getter, setter y deleter
- Validación y lógica en el setter
- Atributos de solo lectura y propiedades calculadas
- cached_property: calcular solo una vez
- __slots__: encapsulamiento y eficiencia de memoria
- @dataclass: encapsulamiento moderno y conciso
- Descriptores: reutilizar lógica de @property
- Programa completo: cuenta bancaria encapsulada
- Errores clásicos con encapsulamiento
- Preguntas frecuentes
El encapsulamiento es el principio que dice que un objeto debe controlar el acceso a sus propios datos. No se trata de secretismo; se trata de que el objeto sea el dueño de su estado y garantice que sus invariantes se mantengan en todo momento.
Python no tiene modificadores private ni protected forzados por el compilador. En su lugar, usa convenciones de nombrado que los desarrolladores respetan, y el mecanismo de @property, que permite implementar lógica de acceso con la sintaxis limpia de un atributo. El resultado es tan robusto como en cualquier otro lenguaje — con menos verbosidad.
🔐 Los tres niveles de acceso en Python
Python usa el nombre del atributo para señalar el nivel de acceso deseado. No hay compilador que lo enforcie (salvo en el caso del doble guión), pero la convención es universalmente respetada en código Python profesional.
class CuentaBancaria:
interes_anual = 0.04 # atributo de clase — completamente público
def __init__(self, titular, saldo_inicial=0):
self.titular = titular # público: acceso libre desde fuera
self._historial = [] # protegido: "uso interno y subclases"
self.__pin = "0000" # privado: name mangling activo
self.__saldo = saldo_inicial # privado: solo accesible via @property
cuenta = CuentaBancaria("Ana", 1000)
print(cuenta.titular) # "Ana"
print(cuenta._historial) # [] — funciona pero es convención "no tocar"
# print(cuenta.__pin) # AttributeError
print(cuenta._CuentaBancaria__pin) # "0000" — name mangling
_x dice «uso interno». El doble guión __x activa name mangling y evita colisiones en herencia. Ninguno impide el acceso — el lenguaje confía en ti.🔀 Name mangling: cómo funciona __privado
Cuando Python encuentra un atributo con doble guión inicial (__x) dentro de una clase, lo renombra automáticamente a _NombreClase__x. Su propósito original no es la seguridad, sino evitar colisiones en herencia.
class Base:
def __init__(self):
self.__id = "base" # almacenado como _Base__id
def obtener_id(self):
return self.__id # Python traduce a self._Base__id
class Hija(Base):
def __init__(self):
super().__init__()
self.__id = "hija" # almacenado como _Hija__id — distinto!
def obtener_id_hija(self):
return self.__id
h = Hija()
print(h.obtener_id()) # "base"
print(h.obtener_id_hija()) # "hija"
print(vars(h)) # {'_Base__id': 'base', '_Hija__id': 'hija'}
@property de Python funciona igual — el estado interno del objeto solo es accesible a través de la interfaz que el objeto expone, nunca directamente. Fuente: Depositphotos.⚙️ @property: getter, setter y deleter
@property es el mecanismo principal de encapsulamiento en Python. Permite que el código externo use objeto.atributo (sin paréntesis, como si fuera un campo público), mientras por debajo se ejecuta una función que puede validar, transformar o registrar el acceso.
class CuentaBancaria:
def __init__(self, titular: str, saldo: float = 0.0):
self.titular = titular
self.__saldo = saldo
@property
def saldo(self) -> float:
return self.__saldo
@saldo.setter
def saldo(self, valor: float) -> None:
if not isinstance(valor, (int, float)):
raise TypeError("El saldo debe ser numérico.")
if valor < 0:
raise ValueError(f"El saldo no puede ser negativo: {valor}")
self.__saldo = float(valor)
@saldo.deleter
def saldo(self) -> None:
print(f"Cerrando cuenta de {self.titular}.")
self.__saldo = 0.0
cuenta = CuentaBancaria("Ana", 500)
print(cuenta.saldo) # 500.0 ← getter
cuenta.saldo = 750 # setter — válido
del cuenta.saldo # deleter
# cuenta.saldo = -100 # ValueError
cuenta.saldo = 750 parece una simple asignación. Por dentro, Python ejecuta el setter con validación completa. Si mañana añades auditoría o conversión de moneda, lo haces en el setter sin cambiar ni una línea de código cliente.✅ Validación y lógica en el setter
El setter es el guardián de los invariantes del objeto. Un invariante es una condición que debe ser cierta en todo momento para que el objeto sea válido.
from datetime import date
class Persona:
def __init__(self, nombre: str, fecha_nacimiento: date):
self.nombre = nombre # usa el setter
self.fecha_nacimiento = fecha_nacimiento
@property
def nombre(self) -> str:
return self.__nombre
@nombre.setter
def nombre(self, valor: str) -> None:
valor = valor.strip()
if not valor:
raise ValueError("El nombre no puede estar vacío.")
self.__nombre = valor.title() # normalizar capitalización
@property
def fecha_nacimiento(self) -> date:
return self.__fecha_nac
@fecha_nacimiento.setter
def fecha_nacimiento(self, valor: date) -> None:
if not isinstance(valor, date):
raise TypeError("Se espera un objeto date.")
if valor > date.today():
raise ValueError("La fecha de nacimiento no puede ser futura.")
self.__fecha_nac = valor
@property
def edad(self) -> int:
hoy = date.today()
delta = hoy.year - self.__fecha_nac.year
if (hoy.month, hoy.day) < (self.__fecha_nac.month, self.__fecha_nac.day):
delta -= 1
return delta
p = Persona(" ana garcía ", date(1990, 6, 15))
print(p.nombre) # "Ana García" — normalizado por el setter
print(p.edad) # edad calculada al momento de acceso
🔒 Atributos de solo lectura y propiedades calculadas
Un atributo de solo lectura es una propiedad con getter pero sin setter. Si alguien intenta asignarle un valor, Python lanza AttributeError automáticamente.
import math
class Circulo:
def __init__(self, radio: float):
self.radio = radio # pasa por el setter
@property
def radio(self) -> float:
return self.__radio
@radio.setter
def radio(self, valor: float) -> None:
if valor <= 0:
raise ValueError(f"El radio debe ser positivo, no {valor}.")
self.__radio = float(valor)
# Solo lectura: calculadas, sin setter
@property
def area(self) -> float:
return math.pi * self.__radio ** 2
@property
def perimetro(self) -> float:
return 2 * math.pi * self.__radio
c = Circulo(5)
print(c.area) # 78.54...
print(c.perimetro) # 31.41...
c.radio = 10 # setter válido
# c.area = 100 # AttributeError: can't set attribute
⚡ cached_property: calcular solo una vez
Cuando una propiedad requiere un cálculo costoso que no cambia después de la primera llamada, @cached_property guarda el resultado en __dict__ del objeto. Las llamadas posteriores lo leen directamente, sin ejecutar el getter.
import hashlib
from functools import cached_property
class Documento:
def __init__(self, contenido: str):
self.__contenido = contenido
@property
def contenido(self) -> str:
return self.__contenido
@cached_property
def hash_sha256(self) -> str:
print(" [calculando hash...]")
return hashlib.sha256(self.__contenido.encode()).hexdigest()
@cached_property
def palabras(self) -> list[str]:
print(" [tokenizando...]")
return self.__contenido.split()
doc = Documento("Python es el mejor lenguaje del mundo")
print(doc.hash_sha256) # [calculando hash...] → hash
print(doc.hash_sha256) # sin log → desde caché
print(len(doc.palabras)) # [tokenizando...]
print(len(doc.palabras)) # sin log → desde caché
__slots__ (que elimina __dict__). Tampoco es thread-safe por defecto: dos hilos pueden calcular el valor dos veces simultáneamente. Para código multihilo, añade un lock.
📦 __slots__: encapsulamiento y eficiencia de memoria
Normalmente, cada instancia almacena sus atributos en un diccionario (__dict__). Con __slots__, declaras los atributos permitidos de forma fija. Python usa un array de referencias en lugar del diccionario: 40-60% menos memoria.
import sys
class PuntoSinSlots:
def __init__(self, x, y, z):
self.x = x; self.y = y; self.z = z
class PuntoConSlots:
__slots__ = ("x", "y", "z")
def __init__(self, x, y, z):
self.x = x; self.y = y; self.z = z
p1 = PuntoSinSlots(1, 2, 3)
p2 = PuntoConSlots(1, 2, 3)
print(sys.getsizeof(p1)) # ~152 bytes
print(sys.getsizeof(p2)) # ~64 bytes
p1.w = 4 # funciona
# p2.w = 4 # AttributeError
# Con herencia: declarar __slots__ en cada nivel
class Punto3D(PuntoConSlots):
__slots__ = ("label",) # solo los atributos nuevos
def __init__(self, x, y, z, label=""):
super().__init__(x, y, z)
self.label = label
🏗️ @dataclass: encapsulamiento moderno y conciso
@dataclass genera automáticamente __init__, __repr__ y __eq__ a partir de las anotaciones de tipo. Es la forma más concisa de definir clases de datos en Python moderno.
from dataclasses import dataclass, field
from typing import ClassVar
@dataclass
class Producto:
nombre: str
precio: float
cantidad: int = 0
tags: list[str] = field(default_factory=list)
iva: ClassVar[float] = 0.21 # atributo de clase, no de instancia
def precio_con_iva(self) -> float:
return self.precio * (1 + self.iva)
def __post_init__(self):
if self.precio < 0:
raise ValueError("El precio no puede ser negativo.")
self.nombre = self.nombre.strip().title()
# @dataclass(frozen=True) — objeto inmutable
@dataclass(frozen=True)
class Coordenada:
lat: float
lon: float
p = Producto(" laptop pro ", 1299.99, tags=["tech"])
print(p) # Producto(nombre='Laptop Pro', precio=1299.99, ...)
c = Coordenada(40.4168, -3.7038)
# c.lat = 0.0 # FrozenInstanceError
🔧 Descriptores: reutilizar lógica de @property
Cuando necesitas la misma lógica de validación en múltiples clases, un descriptor evita duplicar el código de @property.
class CampoPositivo:
"""Descriptor: cualquier campo numérico que deba ser >= 0."""
def __set_name__(self, owner, name):
self._name = f"_{name}"
def __get__(self, obj, objtype=None):
if obj is None: return self
return getattr(obj, self._name, None)
def __set__(self, obj, valor):
if not isinstance(valor, (int, float)):
raise TypeError(f"{self._name[1:]} debe ser numérico.")
if valor < 0:
raise ValueError(f"{self._name[1:]} debe ser positivo: {valor}")
setattr(obj, self._name, valor)
# Reutilizable en varias clases sin copiar la lógica:
class Producto:
precio = CampoPositivo()
stock = CampoPositivo()
def __init__(self, nombre, precio, stock):
self.nombre = nombre
self.precio = precio
self.stock = stock
class Factura:
importe = CampoPositivo()
descuento = CampoPositivo()
def __init__(self, importe, descuento=0.0):
self.importe = importe
self.descuento = descuento
🛠️ Programa completo: cuenta bancaria encapsulada
from dataclasses import dataclass, field
from datetime import datetime
from functools import cached_property
from typing import Optional
import hashlib
@dataclass
class Movimiento:
tipo: str
importe: float
concepto: str = ""
fecha: datetime = field(default_factory=datetime.now)
def __str__(self):
signo = "+" if self.tipo == "ingreso" else "-"
return f"{self.fecha.strftime('%d/%m/%Y %H:%M')} {signo}{self.importe:8.2f} € — {self.concepto}"
class CuentaBancaria:
_siguiente_id = 1
def __init__(self, titular: str, saldo_inicial: float = 0.0):
self.__id = CuentaBancaria._siguiente_id
CuentaBancaria._siguiente_id += 1
self.__titular = titular.strip().title()
self.__saldo = 0.0
self.__movimientos: list[Movimiento] = []
self.__activa = True
if saldo_inicial > 0:
self.__saldo = saldo_inicial
self.__movimientos.append(Movimiento("ingreso", saldo_inicial, "Apertura"))
@property
def id(self) -> int: return self.__id
@property
def titular(self) -> str: return self.__titular
@property
def activa(self) -> bool: return self.__activa
@property
def saldo(self) -> float: return self.__saldo
@saldo.setter
def saldo(self, _):
raise AttributeError("Usa ingresar() o retirar() para modificar el saldo.")
@cached_property
def iban_simulado(self) -> str:
raw = f"ES{self.__id:020d}{self.__titular}"
return "ES" + hashlib.md5(raw.encode()).hexdigest()[:20].upper()
def ingresar(self, importe: float, concepto: str = "Ingreso") -> None:
self._verificar_activa()
if importe <= 0: raise ValueError("El importe debe ser positivo.")
self.__saldo = round(self.__saldo + importe, 2)
self.__movimientos.append(Movimiento("ingreso", importe, concepto))
def retirar(self, importe: float, concepto: str = "Retirada") -> None:
self._verificar_activa()
if importe <= 0: raise ValueError("El importe debe ser positivo.")
if importe > self.__saldo:
raise ValueError(f"Saldo insuficiente: {self.__saldo:.2f} < {importe:.2f}")
self.__saldo = round(self.__saldo - importe, 2)
self.__movimientos.append(Movimiento("retirada", importe, concepto))
def transferir(self, destino: "CuentaBancaria", importe: float) -> None:
self.retirar(importe, f"Transferencia a {destino.titular}")
destino.ingresar(importe, f"Transferencia de {self.__titular}")
def cerrar(self) -> None:
self.__activa = False
self.__saldo = 0.0
def extracto(self, ultimos: Optional[int] = None) -> str:
movs = self.__movimientos[-ultimos:] if ultimos else self.__movimientos
lineas = [f"=== Cuenta #{self.__id} — {self.__titular} ==="]
lineas += [str(m) for m in movs]
lineas.append(f"{'Saldo actual':>40}: {self.__saldo:8.2f} €")
return "
".join(lineas)
def _verificar_activa(self) -> None:
if not self.__activa: raise RuntimeError("La cuenta está cerrada.")
def __repr__(self):
return f"CuentaBancaria(id={self.__id}, titular='{self.__titular}', saldo={self.__saldo:.2f})"
ana = CuentaBancaria("ana garcía", 1000)
luis = CuentaBancaria("luis martín", 500)
ana.ingresar(200, "Nómina")
ana.retirar(50, "Supermercado")
ana.transferir(luis, 300)
print(ana.extracto())
print(f"IBAN Ana: {ana.iban_simulado}")
print(f"IBAN Luis: {luis.iban_simulado}")
🐛 Errores clásicos con encapsulamiento
1. Olvidar field(default_factory=list) en @dataclass
# ❌ La misma lista se comparte entre TODAS las instancias
@dataclass
class Carrito:
items: list = [] # lista mutable como defecto — NUNCA hacer esto
# ✅
@dataclass
class Carrito:
items: list = field(default_factory=list)
2. Definir @property sin setter y confundirse con AttributeError
class T:
@property
def valor(self): return 42
t = T()
t.valor = 10 # AttributeError: can't set attribute — es el comportamiento esperado
# Si es intencional (solo lectura), el AttributeError es la protección deseada
3. Usar __slots__ con herencia sin declarar __slots__ en cada nivel
# ❌ Hija tendrá __dict__ de nuevo, anulando la optimización
class Base:
__slots__ = ("x",)
class Hija(Base):
pass # sin __slots__ → tiene __dict__
# ✅
class Hija(Base):
__slots__ = ("y",) # solo los atributos nuevos
4. Referencia circular en setter durante __init__
# ❌ Si el setter usa self.__saldo antes de que exista → AttributeError
class Cuenta:
@property
def saldo(self): return self.__saldo
@saldo.setter
def saldo(self, v):
if v < self.__saldo: # ← falla si __saldo no existe todavía
raise ValueError()
self.__saldo = v
def __init__(self, s):
self.saldo = s # primera llamada: self.__saldo no existe aún
# ✅ Inicializar con valor por defecto primero
def __init__(self, s):
self.__saldo = 0.0 # inicializar directamente antes del setter
self.saldo = s
✅ Resumen y próximos pasos
El encapsulamiento en Python descansa sobre dos pilares: las convenciones de nombrado (_protegido, __privado) y @property. El primero comunica intención; el segundo la implementa. __slots__ optimiza memoria al precio de perder flexibilidad dinámica. @dataclass y @dataclass(frozen=True) ofrecen encapsulamiento conciso para clases de datos. Los descriptores reutilizan lógica de validación entre clases sin duplicar código.
La siguiente lección: métodos especiales en profundidad — el protocolo completo de Python para hacer que tus objetos se integren con el lenguaje nativo.
❓ Preguntas frecuentes
❓ Preguntas frecuentes sobre Encapsulamiento en Python: @property, atributos privados y __slots__
Las dudas más comunes respondidas de forma clara y directa.
💬 Foro de discusión
¿Tienes dudas sobre Encapsulamiento en Python: @property, atributos privados y __slots__? Comparte tu pregunta con la comunidad.
Todavía no hay mensajes. ¡Sé el primero en participar!