El polimorfismo cerró con una advertencia: nada impide escribir odisea.precio = -5 o fausto.stock = -3, y a partir de ahí todos los cálculos de Papyrus mienten. En 05-01 escribiste vender() precisamente para que el stock nunca quedara negativo... pero cualquiera puede saltarse el método y tocar el atributo directamente. La encapsulación es el principio que corrige esto: cada objeto protege sus datos internos y solo permite modificarlos por vías controladas, de modo que sus invariantes — las reglas que siempre deben cumplirse, como "el stock nunca es negativo" — no puedan romperse ni por accidente. Python lo resuelve a su manera: sin candados de verdad, con convenciones claras y una herramienta elegante llamada property.

Contenido

  1. Qué es encapsular y por qué importa
  2. La convención del guion bajo: _protegido
  3. Doble guion bajo y name mangling: __privado
  4. El problema de los getters/setters al estilo Java
  5. @property: atributos calculados y validados
  6. El setter: validar sin cambiar la interfaz
  7. Producto blindado: la versión final
  8. Errores comunes y consejos
  9. Ejercicios con soluciones

Qué es encapsular y por qué importa

Encapsular es separar el qué (la interfaz pública: precio_final(), vender()) del cómo (los detalles internos: en qué atributo se guarda el stock, cómo se valida). Los beneficios son concretos:

  • Invariantes del negocio garantizadas: en Papyrus, el stock nunca es negativo, el precio nunca es negativo, un código de socio siempre tiene el formato NOMBRE-NNN. Si el objeto vigila sus propios datos, ninguna parte del programa puede corromperlos.
  • Libertad para cambiar por dentro: si mañana el stock pasa de un entero a un desglose por almacén, el resto del código no se entera mientras hay_stock() siga respondiendo igual.
  • Menos superficie de error: quien usa la clase solo ve lo que debe usar.

Ahora bien, una diferencia cultural importante: Python no tiene atributos privados de verdad (como private en Java o C#). Su filosofía se resume en la frase "somos todos adultos que consienten": el lenguaje señala qué es interno, y confía en que lo respetes.

La convención del guion bajo: _protegido

Un guion bajo inicial marca un atributo o método como interno: "no lo toques desde fuera; puede cambiar sin previo aviso". Es solo una convención — Python no lo impide — pero toda la comunidad la respeta.

class Socio:
    """Un socio del club de Papyrus."""

    def __init__(self, nombre, codigo):
        self.nombre = nombre
        self._codigo = codigo          # interno: no manipular desde fuera
        self._compras = []             # interno: historial de compras

    def registrar_compra(self, importe):
        self._compras.append(importe)  # única vía legítima de modificarlo

    def total_gastado(self):
        return round(sum(self._compras), 2)

luis = Socio("Luis", "LUIS-001")
luis.registrar_compra(12.35)
luis._compras.append(-999)   # Python NO lo impide... pero está mal visto

La última línea funciona, y ahí está la clave: _compras es una señal de tráfico, no un muro. Los buenos linters y revisores de código la marcarán, y ese contrato social es sorprendentemente eficaz.

Doble guion bajo y name mangling: __privado

Dos guiones bajos iniciales (sin dos finales) activan el name mangling (retorcimiento de nombres): Python renombra el atributo internamente a _NombreClase__atributo.

class Socio:
    def __init__(self, nombre, codigo):
        self.nombre = nombre
        self.__codigo = codigo

luis = Socio("Luis", "LUIS-001")
print(luis.__codigo)           # AttributeError: 'Socio' object has no attribute '__codigo'
print(luis._Socio__codigo)     # LUIS-001  → ¡sigue siendo accesible!

Puntos que debes tener claros:

  • No es seguridad real: cualquiera que conozca el truco accede con _Socio__codigo. Es ocultación superficial, no cifrado ni protección.
  • Su propósito genuino es evitar colisiones de nombres en herencia: si Producto usa __contador y una subclase define otro __contador, el mangling los mantiene separados (_Producto__contador vs _Libro__contador).
  • En el día a día, la comunidad Python prefiere el guion bajo simple. Reserva __privado para cuando escribas clases pensadas para ser heredadas por desconocidos.
Notación Nombre real Significado ¿Bloquea el acceso?
precio precio Público: úsalo con libertad No
_precio _precio Interno por convención No (contrato social)
__precio _Clase__precio Interno + antichoque en herencia No (solo lo disfraza)
__precio__ __precio__ Reservado para métodos mágicos (05-05) No los inventes tú

El problema de los getters/setters al estilo Java

En lenguajes con privacidad estricta, la receta clásica es: atributo privado + método get + método set. Trasladada literalmente a Python queda así:

class LibroJavero:
    def __init__(self, titulo, precio):
        self._titulo = titulo
        self._precio = precio

    def get_precio(self):
        return self._precio

    def set_precio(self, valor):
        if valor < 0:
            raise ValueError("El precio no puede ser negativo")
        self._precio = valor

libro = LibroJavero("Hamlet", 9.95)
libro.set_precio(10.50)          # verboso
print(libro.get_precio())        # y poco natural en Python

Valida, sí, pero al precio de una interfaz fea y de romper todo el código existente que hacía libro.precio. Python tiene algo mejor.

@property: atributos calculados y validados

Una property es un método disfrazado de atributo: se lee como libro.precio (sin paréntesis), pero por debajo ejecuta código. Es un decorador de la biblioteca estándar (los decoradores en general se estudian en el módulo 8; aquí basta con saber usarlo).

class Libro:
    def __init__(self, titulo, precio, stock=0):
        self.titulo = titulo
        self._precio = precio      # el dato real vive en el atributo interno
        self._stock = stock

    @property
    def precio(self):
        """Precio base del libro, en euros."""
        return self._precio

odisea = Libro("La Odisea", 12.50, 4)
print(odisea.precio)       # 12.50 — se LEE como atributo, sin ()
odisea.precio = 13.00      # AttributeError: property 'precio' has no setter

Sin setter, la property crea un atributo de solo lectura: ya nadie puede asignar un precio por las bravas. Las properties también sirven para atributos calculados que no se almacenan:

    @property
    def precio_con_iva(self):
        return round(self._precio * 1.04, 2)

print(odisea.precio_con_iva)   # 13.00 — calculado al vuelo, siempre coherente

El setter: validar sin cambiar la interfaz

Para permitir la asignación con control, se añade el setter con @nombre.setter:

    @precio.setter
    def precio(self, valor):
        if valor < 0:
            raise ValueError(f"Precio negativo no permitido: {valor}")
        self._precio = valor

Ahora la validación es automática y la sintaxis sigue siendo natural:

odisea.precio = 13.50     # OK: pasa por el setter y valida
odisea.precio = -5        # ValueError: Precio negativo no permitido: -5

raise ValueError(...) detiene el programa con un mensaje claro; ya lo usaste conceptualmente al validar entradas, y el módulo 7 enseñará a capturar estos errores con try/except. Lo importante hoy: el objeto rechaza el dato inválido en el momento exacto en que intenta entrar.

Estilo Java en Python Estilo pythónico (@property)
Leer libro.get_precio() libro.precio
Escribir libro.set_precio(13.5) libro.precio = 13.5
Validación Sí, en set_precio Sí, en el setter
¿Rompe el código que usaba el atributo? Sí, hay que reescribirlo No: misma sintaxis de siempre
Solo lectura Omitir set_precio (pero _precio sigue a la vista) Property sin setter
Veredicto Evitar Preferir

La última fila de "¿rompe el código?" esconde la gran ventaja estratégica: puedes empezar con un atributo público normal (self.precio) y convertirlo en property años después, cuando haga falta validar, sin tocar ni una línea del código cliente. Por eso en Python no se escriben getters/setters preventivos "por si acaso", como exige la ortodoxia Java.

Producto blindado: la versión final

Aplicamos todo a la clase base de la jerarquía (05-02). Invariantes a proteger: precio ≥ 0, stock entero ≥ 0.

class Producto:
    """Artículo de Papyrus con invariantes protegidas."""

    IVA_LIBROS = 0.04
    DESCUENTO_SOCIO = 0.05

    def __init__(self, titulo, precio, stock=0):
        self.titulo = titulo
        self.precio = precio     # ¡pasa por el setter también desde __init__!
        self.stock = stock       # ídem: valida desde el primer momento

    @property
    def precio(self):
        return self._precio

    @precio.setter
    def precio(self, valor):
        if valor < 0:
            raise ValueError(f"Precio negativo no permitido: {valor}")
        self._precio = valor

    @property
    def stock(self):
        return self._stock

    @stock.setter
    def stock(self, valor):
        if not isinstance(valor, int):
            raise ValueError(f"El stock debe ser un entero, no {type(valor).__name__}")
        if valor < 0:
            raise ValueError(f"Stock negativo no permitido: {valor}")
        self._stock = valor

    def vender(self, unidades=1):
        """Única vía recomendada para descontar stock."""
        if unidades > self._stock:
            return False
        self._stock -= unidades
        return True

    def precio_final(self, socio=False):
        descuento = Producto.DESCUENTO_SOCIO if socio else 0
        return round(self._precio * (1 - descuento) * (1 + Producto.IVA_LIBROS), 2)

Dos sutilezas de oro en este código:

  • __init__ asigna a self.precio, no a self._precio: así la validación se aplica también al construir. Producto("Fantasma", -3, 1) falla inmediatamente, que es donde debe fallar.
  • Las subclases heredan el blindaje gratis: Libro, Revista y LibroDigital no cambian ni una línea y sus precios y stocks quedan protegidos, porque sus super().__init__(...) desembocan en estos setters.
fausto = Libro("Fausto", "Goethe", 21.00, 0)
fausto.stock = -3        # ValueError: Stock negativo no permitido: -3
fausto.stock = 2.5       # ValueError: El stock debe ser un entero, no float
fausto.stock = 5         # OK: llegaron ejemplares — los de la cola de reservas, atentos
fausto.vender(2)         # True
print(fausto.stock)      # 3

Errores Comunes y Consejos

  • Recursión infinita en la property: escribir return self.precio dentro del getter de precio (o self.precio = valor en su setter) llama a la property una y otra vez hasta RecursionError. Dentro del getter/setter, usa siempre el atributo interno self._precio.
  • Validar en el setter pero asignar self._precio en __init__: el objeto nace sin validar. Asigna en __init__ al nombre público (self.precio = precio) para que el setter actúe desde el primer segundo.
  • Creer que __privado protege datos sensibles: el name mangling se salta con obj._Clase__atributo; no es un mecanismo de seguridad, solo de higiene de nombres en herencia.
  • Escribir getters/setters get_x()/set_x() preventivos para todo: es ruido heredado de Java. Atributo público primero; property cuando (y solo cuando) necesites validar o calcular.
  • Olvidar el @property encima del getter y dejar solo @precio.setter: NameError, porque el setter se define a partir de la property existente. El orden es: primero @property, después @nombre.setter.
  • Consejo: decide qué es interfaz pública (lo que documentas: titulo, precio, stock, vender(), precio_final()) y marca el resto con _. Una clase con pocas puertas de entrada es una clase fácil de razonar y de mantener.

Ejercicios

Ejercicio 1: título de solo lectura

En Papyrus, el título de un producto no debe cambiar una vez creado (es su identidad en el catálogo). Convierte titulo en una property sin setter que además valide en __init__ (a través de un método interno o del propio guardado) que no sea una cadena vacía tras strip(). Comprueba que odisea.titulo = "Otro" falla y que Producto(" ", 5.0) lanza ValueError.

Ejercicio 2: el código de socio blindado

Crea la clase Socio con nombre público y una property codigo con getter y setter. El setter debe validar el formato NOMBRE-NNN de Papyrus: la parte izquierda alfabética y en mayúsculas, un guion, y tres dígitos (repasa split(), isalpha(), isdigit(), isupper() de 04-05). "LUIS-001" y "MARTA-002" deben pasar; "luis-001", "LUIS001" y "LUIS-1" deben lanzar ValueError.

Ejercicio 3: reservas encapsuladas

Añade a Producto un atributo interno _reservas (lista) y los métodos reservar(cliente) y atender_reserva(). reservar solo se permite si NO hay stock (si hay, que devuelva el aviso "Hay stock: compra directa"); atender_reserva saca al primer cliente de la lista (orden de llegada) o devuelve None si no hay reservas. Simula: Fausto con stock 0, reservan Júlia y Omar, llega stock, se atiende a Júlia.

Soluciones

Solución 1:

class Producto:
    def __init__(self, titulo, precio, stock=0):
        titulo = titulo.strip()
        if not titulo:                       # truthiness (02-01): "" es falso
            raise ValueError("El título no puede estar vacío")
        self._titulo = titulo
        self.precio = precio
        self.stock = stock

    @property
    def titulo(self):
        return self._titulo
    # ... resto de properties como en la lección ...

odisea = Producto("La Odisea", 12.50, 4)
odisea.titulo = "Otro"    # AttributeError: property 'titulo' has no setter
Producto("   ", 5.0)      # ValueError: El título no puede estar vacío

Sin setter, la property es de solo lectura: la identidad del producto queda sellada al nacer.

Solución 2:

class Socio:
    def __init__(self, nombre, codigo):
        self.nombre = nombre
        self.codigo = codigo          # pasa por el setter

    @property
    def codigo(self):
        return self._codigo

    @codigo.setter
    def codigo(self, valor):
        partes = valor.split("-")
        valido = (
            len(partes) == 2
            and partes[0].isalpha() and partes[0].isupper()
            and len(partes[1]) == 3 and partes[1].isdigit()
        )
        if not valido:
            raise ValueError(f"Código de socio inválido: {valor!r} (formato NOMBRE-NNN)")
        self._codigo = valor

Socio("Luis", "LUIS-001")    # OK
Socio("Luis", "luis-001")    # ValueError (minúsculas)
Socio("Luis", "LUIS-1")      # ValueError (faltan dígitos)

Es la misma regla que es_socio_valido() de papyrus_utils.py, pero ahora vive donde debe: en el objeto que guarda el dato, imposible de olvidar.

Solución 3:

class Producto:
    def __init__(self, titulo, precio, stock=0):
        self.titulo = titulo
        self.precio = precio
        self.stock = stock
        self._reservas = []           # interno: solo se toca vía métodos

    def reservar(self, cliente):
        if self.stock > 0:
            return "Hay stock: compra directa"
        self._reservas.append(cliente)
        return f"{cliente} apuntado ({len(self._reservas)} en cola)"

    def atender_reserva(self):
        if self._reservas:
            return self._reservas.pop(0)   # primero en llegar, primero servido
        return None

fausto = Producto("Fausto", 21.00, 0)
print(fausto.reservar("Júlia"))    # Júlia apuntado (1 en cola)
print(fausto.reservar("Omar"))     # Omar apuntado (2 en cola)
fausto.stock = 3                   # llegan ejemplares (validado por el setter)
print(fausto.atender_reserva())    # Júlia

La lista _reservas nunca se manipula desde fuera: el orden de llegada está garantizado por construcción. (Para colas largas, recuerda que deque de 04-06 hace popleft() en tiempo constante; aquí pop(0) basta.)

Conclusión

La encapsulación ha cerrado la grieta que el polimorfismo dejó a la vista: _interno señala lo que no debe tocarse, __privado añade name mangling (higiene de nombres, nunca seguridad real), y @property con su @setter consigue lo mejor de dos mundos — la sintaxis natural de un atributo con la validación de un método. La clase Producto de Papyrus ya no admite precios negativos ni stocks imposibles, y todas sus subclases heredan el blindaje sin cambiar una línea. Con datos unidos a su comportamiento (05-01), organizados en jerarquía (05-02), polimórficos (05-03) y protegidos (05-04), solo falta que tus objetos hablen el idioma nativo de Python: que print(odisea) muestre algo digno, que sorted(catalogo) ordene libros sin key=, que libro in carrito simplemente funcione. Todo eso lo conceden los métodos mágicos, y con ellos saldaremos por fin el gancho pendiente desde 05-01: el print "feo". Es la próxima lección.

Curso de Programación en Python

Módulo 1: Introducción a Python

Módulo 2: Estructuras de Control

Módulo 3: Funciones y Módulos

Módulo 4: Estructuras de Datos

Módulo 5: Programación Orientada a Objetos

Módulo 6: Manejo de Archivos

Módulo 7: Manejo de Errores y Excepciones

Módulo 8: Temas Avanzados

Módulo 9: Pruebas y Depuración

Módulo 10: Desarrollo Web con Python

Módulo 11: Ciencia de Datos con Python

Módulo 12: Proyecto Final

© Copyright 2026. Todos los derechos reservados