Encapsulamiento en Python: @property, atributos privados y __slots__

📅 Actualizado en marzo 2026 📊 Nivel: Intermedio ⏱️ 22 min de lectura

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
🔵 La filosofía Python sobre privacidad
«Somos adultos responsables». El simple guión _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'}
Manos girando el dial de combinación de una caja fuerte personal para abrirla
Para abrir una caja fuerte no hay atajo: tienes que conocer la combinación exacta. El @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
💡 La magia de @property
Desde fuera, 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
Diagrama de encapsulamiento en Python: tres niveles de acceso público/protegido/privado; ciclo getter-setter-deleter con @property; __slots__, @dataclass y tabla de decisión
Los tres niveles de acceso (🟢 público, 🟡 _protegido, 🔴 __privado con name mangling); el ciclo completo de @property con getter, setter y deleter sobre el atributo privado real; y las herramientas avanzadas: __slots__, @dataclass, hooks __getattr__/__setattr__ y tabla de decisión. Infografía: Ciberaula.

🔒 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é
⚠️ Limitaciones de cached_property
No funciona con __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.
Panel de control portátil de maquinaria industrial con palancas, interruptores y botón de emergencia rojo
Un panel de control expone exactamente los mandos necesarios — ni uno más. El operador interactúa con palancas y botones bien definidos sin tocar jamás el cableado interior. La API pública de una clase bien encapsulada funciona igual: interfaz clara hacia fuera, implementación oculta hacia dentro. Fuente: Depositphotos.

📦 __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
Medallón de Bías de Priene, Siete Sabios de Grecia
Omnia mea mecum porto
«Todo lo mío lo llevo conmigo»
Bías de Priene · uno de los Siete Sabios de Grecia · s. VI a.C.
Un objeto bien encapsulado lleva todo lo que necesita dentro de sí mismo: su estado, sus invariantes y su lógica de acceso. No depende de que código externo recuerde «no asignar saldo negativo» — lo garantiza él mismo a través del setter. Cuando diseñes una clase, pregúntate: ¿puede este objeto quedar en un estado incorrecto si alguien accede directamente a sus atributos? Si la respuesta es sí, hay algo que encapsular.

🏗️ @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.

Ficha de referencia de encapsulamiento en Python: notación público/protegido/privado; @property con getter setter deleter; __slots__ y @dataclass con field; descriptores y cached_property
Referencia rápida — Notación de acceso con hasattr/getattr/setattr/delattr; @property completo (Temperatura con setter que valida -273.15 y fahrenheit calculado); __slots__ con comparativa de memoria; @dataclass con field y default_factory; descriptores con __set_name__/__get__/__set__; cached_property y @dataclass(frozen=True). Ficha: Ciberaula.

❓ Preguntas frecuentes

❓ Preguntas frecuentes sobre Encapsulamiento en Python: @property, atributos privados y __slots__

Las dudas más comunes respondidas de forma clara y directa.

La OOP no requiere acceso privado forzado — es una decisión de diseño, no una ley. Python adopta la filosofía de "somos adultos responsables": confía en que los desarrolladores respeten las convenciones. El prefijo __ con name mangling sí ofrece protección real contra colisiones en herencia y contra el acceso accidental no intencionado. En la práctica, el código Python bien escrito es tan encapsulado como el de Java — la diferencia es que el lenguaje no te ata las manos.
Regla práctica: si la operación parece semánticamente un atributo (sin efectos secundarios costosos, sin parámetros adicionales, devuelve un valor relacionado con el estado del objeto), usa @property. Si parece una acción o tiene parámetros adicionales, usa un método. @property es más pythónico: obj.nombre en lugar de obj.get_nombre(). Si el getter tiene lógica costosa que no quieres que se ejecute implícitamente, un método explícito es más honesto.
@property ejecuta el getter en cada acceso. Si el cálculo es costoso, se recalcula innecesariamente. cached_property calcula el valor la primera vez y lo almacena en __dict__ del objeto. Las llamadas posteriores devuelven el valor almacenado directamente. Limitación: no funciona con __slots__ (sin __dict__) ni es thread-safe sin protección adicional.
Usa __slots__ cuando: creas muchas instancias del mismo tipo (miles o millones), el objeto tiene un conjunto fijo de atributos, o la memoria es crítica. No uses __slots__ cuando: necesitas atributos dinámicos, haces introspección con pickle/deepcopy, o usas herencia múltiple compleja. El ahorro de memoria es real: 40-60% menos RAM que con __dict__.
Un descriptor es cualquier objeto que implementa __get__, __set__ o __delete__. @property es un descriptor implementado por Python. Crear descriptores propios tiene sentido cuando necesitas reutilizar la misma lógica de @property en múltiples clases. Por ejemplo, si 10 clases necesitan un campo "precio" siempre positivo, un descriptor CampoPositivo es reutilizable sin copiar el setter en cada clase.
Sí, pero con una trampa. En @dataclass, los campos del cuerpo se convierten en parámetros de __init__. Un @property no se incluye automáticamente en __init__. El patrón habitual: usar field(init=False) para el atributo privado subyacente e inicializarlo en __post_init__. @dataclass(frozen=True) lanza FrozenInstanceError si intentas asignar después de la creación, pero @property sigue funcionando para lectura.
Valora este artículo

💬 Foro de discusión

¿Tienes dudas sobre Encapsulamiento en Python: @property, atributos privados y __slots__? Comparte tu pregunta con la comunidad.

¿Tienes cuenta? o comenta como invitado ↓

Todavía no hay mensajes. ¡Sé el primero en participar!