Lección 9 de 12 · Nivel 3: Avanzado

Clases y objetos en Python: programación orientada a objetos

En esta lección
  1. Clase y objeto
  2. Tu primera clase
  3. Mostrar objetos de forma legible: __str__
  4. Encapsulamiento: proteger los datos
  5. Herencia: reutilizar y especializar
  6. dataclass: clases de datos sin escribir de más
  7. ¿Cuándo usar clases?
  8. Resumen
Lo que lograrás en esta lección
  • Entender qué son las clases y los objetos, y cuándo conviene usarlos.
  • Crear tus propias clases con atributos y métodos.
  • Usar herencia y los métodos especiales __str__ y __init__.
  • Escribir clases de datos en pocas líneas con dataclass.

Bienvenido al nivel avanzado. Hasta ahora organizaste tus programas con funciones y guardaste los datos en diccionarios. Funciona, pero cuando un programa crece aparecen problemas: diccionarios con claves mal escritas, funciones sueltas que operan sobre los mismos datos, lógica repetida. La programación orientada a objetos (POO) agrupa los datos y las funciones que los manejan en una misma unidad: el objeto. Es la forma en que están escritas casi todas las librerías que usarás.

Clase y objeto

  • Una clase es un molde: define qué datos tiene algo y qué puede hacer. Por ejemplo, "Cuenta bancaria".
  • Un objeto (o instancia) es un caso concreto creado con ese molde: la cuenta de Ana, la cuenta de Luis.

De hecho, ya usaste objetos sin saberlo: cada texto es un objeto de la clase str, y por eso tiene métodos como .upper().

Tu primera clase

class CuentaBancaria:
    def __init__(self, titular, saldo=0):
        self.titular = titular
        self.saldo = saldo

    def depositar(self, monto):
        self.saldo += monto

    def retirar(self, monto):
        if monto > self.saldo:
            print("Saldo insuficiente")
            return False
        self.saldo -= monto
        return True


cuenta_ana = CuentaBancaria("Ana", 500)
cuenta_luis = CuentaBancaria("Luis")

cuenta_ana.depositar(200)
cuenta_ana.retirar(1000)       # Saldo insuficiente
print(cuenta_ana.saldo)        # 700
print(cuenta_luis.saldo)       # 0

Las piezas clave:

  • class CuentaBancaria: define la clase. Por convención, los nombres de clase van en CamelCase (palabras juntas con mayúscula inicial).
  • __init__ es el constructor: se ejecuta al crear el objeto y prepara sus datos.
  • self es el propio objeto. self.saldo es "el saldo de esta cuenta". Python lo pasa automáticamente: escribes cuenta_ana.depositar(200), no depositar(cuenta_ana, 200).
  • Atributos (titular, saldo): los datos de cada objeto.
  • Métodos (depositar, retirar): las funciones que pertenecen a la clase.

1. ¿Para qué sirve self dentro de un método?

  • Para importar módulos.
  • Para referirse al objeto concreto sobre el que se llama el método.
  • Para crear una variable global.

self.saldo es el saldo de ese objeto en particular; cada cuenta tiene el suyo.

Mostrar objetos de forma legible: __str__

Si haces print(cuenta_ana) verás algo como <__main__.CuentaBancaria object at 0x7f...>. Para controlar cómo se muestra, define __str__:

class CuentaBancaria:
    def __init__(self, titular, saldo=0):
        self.titular = titular
        self.saldo = saldo

    def __str__(self):
        return f"Cuenta de {self.titular}: {self.saldo:,.2f}"


print(CuentaBancaria("Ana", 1250.5))   # Cuenta de Ana: 1,250.50

Los métodos con doble guion bajo (dunder methods) le dicen a Python cómo debe comportarse tu objeto en operaciones especiales: mostrarse, compararse, sumarse, medirse con len()…

Encapsulamiento: proteger los datos

¿Qué impide que alguien escriba cuenta_ana.saldo = -5000? Nada, técnicamente. Python confía en convenciones: un atributo que empieza con guion bajo (_saldo) significa "es interno, no lo toques desde fuera". Y con @property puedes exponer un valor de solo lectura:

class CuentaBancaria:
    def __init__(self, titular, saldo=0):
        self.titular = titular
        self._saldo = saldo
        self._movimientos = []

    @property
    def saldo(self):
        return self._saldo

    def depositar(self, monto):
        if monto <= 0:
            raise ValueError("El depósito debe ser positivo")
        self._saldo += monto
        self._movimientos.append(("depósito", monto))


c = CuentaBancaria("Ana")
c.depositar(100)
print(c.saldo)      # 100
# c.saldo = 999     → AttributeError: no se puede asignar

Observa raise ValueError(...): en lugar de imprimir un mensaje, el método lanza una excepción, que quien lo llame puede capturar con try/except (lección 8). Es la forma profesional de señalar un error.

2. ¿Qué indica, por convención, un atributo llamado _saldo?

  • Que es una constante.
  • Que es de uso interno de la clase y no debería modificarse desde fuera.
  • Que Python lo borra al terminar.

El guion bajo inicial es una convención de "privado". Python no lo impide, pero cualquier programador entiende el mensaje.

Herencia: reutilizar y especializar

Una clase puede heredar de otra: obtiene todos sus atributos y métodos, y puede añadir o cambiar algunos.

class CuentaAhorro(CuentaBancaria):
    def __init__(self, titular, saldo=0, tasa_anual=0.05):
        super().__init__(titular, saldo)
        self.tasa_anual = tasa_anual

    def aplicar_interes_mensual(self):
        interes = self._saldo * ((1 + self.tasa_anual) ** (1 / 12) - 1)
        self.depositar(round(interes, 2))


ahorro = CuentaAhorro("Luis", 10000, tasa_anual=0.06)
ahorro.aplicar_interes_mensual()
print(ahorro.saldo)   # 10048.68
  • class CuentaAhorro(CuentaBancaria): hereda de CuentaBancaria.
  • super().__init__(...) llama al constructor de la clase padre para no repetir código.
  • La cuenta de ahorro puede usar depositar y saldo sin haberlos escrito.

Usa la herencia cuando la relación sea "es un tipo de": una cuenta de ahorro es un tipo de cuenta bancaria. Si la relación es "tiene un" (un cliente tiene cuentas), es mejor guardar un objeto dentro de otro (composición).

3. ¿Qué hace super().__init__(titular, saldo) en la clase hija?

  • Ejecuta el constructor de la clase padre para inicializar los atributos comunes.
  • Crea un objeto nuevo de la clase padre.
  • Borra los atributos de la clase padre.

Así la clase hija reutiliza la inicialización del padre y solo añade lo suyo (tasa_anual).

dataclass: clases de datos sin escribir de más

Cuando una clase sirve sobre todo para guardar datos, el decorador @dataclass genera automáticamente __init__, una representación legible y la comparación entre objetos:

from dataclasses import dataclass
from datetime import date


@dataclass
class Gasto:
    fecha: date
    categoria: str
    descripcion: str
    monto: float

    def es_grande(self, limite=100):
        return self.monto > limite


g = Gasto(date(2026, 10, 1), "comida", "Almuerzo", 35.5)
print(g)               # Gasto(fecha=datetime.date(2026, 10, 1), categoria='comida', ...)
print(g.es_grande())   # False

Compáralo con los diccionarios del proyecto de la lección 8: ahora un error al escribir un campo (g.categria) da un error claro en lugar de pasar desapercibido, y tu editor te autocompleta los nombres.

¿Cuándo usar clases?

  • Cuando tienes varios datos que siempre van juntos y operaciones sobre ellos (una cuenta, un pedido, un producto).
  • Cuando necesitas varios objetos del mismo tipo con su propio estado.
  • Cuando quieres variantes de algo (herencia).

Y cuándo no: para un script corto que procesa un archivo, unas pocas funciones bastan. No todo tiene que ser una clase.

Reto: carrito de compras

Crea una clase Producto (con @dataclass: nombre y precio) y una clase Carrito con los métodos:

  1. agregar(producto, cantidad=1)
  2. total(), que devuelva el total
  3. aplicar_descuento(porcentaje), que lance ValueError si el porcentaje no está entre 0 y 50
  4. __str__, que muestre el detalle del carrito
Ver solución
from dataclasses import dataclass


@dataclass
class Producto:
    nombre: str
    precio: float


class Carrito:
    def __init__(self):
        self._items = []          # lista de (producto, cantidad)
        self._descuento = 0

    def agregar(self, producto, cantidad=1):
        self._items.append((producto, cantidad))

    def aplicar_descuento(self, porcentaje):
        if not 0 <= porcentaje <= 50:
            raise ValueError("Descuento no válido")
        self._descuento = porcentaje

    def total(self):
        bruto = sum(p.precio * c for p, c in self._items)
        return round(bruto * (1 - self._descuento / 100), 2)

    def __str__(self):
        lineas = [f"{c} x {p.nombre}: {p.precio * c:.2f}" for p, c in self._items]
        lineas.append(f"TOTAL: {self.total():.2f}")
        return "\n".join(lineas)


carrito = Carrito()
carrito.agregar(Producto("Teclado", 25.0), 2)
carrito.agregar(Producto("Mouse", 12.5))
carrito.aplicar_descuento(10)
print(carrito)   # ... TOTAL: 56.25

Resumen

  • Una clase es un molde; un objeto, un caso concreto con sus propios datos.
  • __init__ inicializa, self es el objeto, __str__ define cómo se muestra.
  • Usa _atributo y @property para proteger datos y raise para señalar errores.
  • Herencia para "es un tipo de"; @dataclass para clases de datos.

En la próxima lección organizarás tus programas como un profesional: módulos, paquetes, pip y entornos virtuales.

¿Quieres tu certificado gratis?

Crea tu cuenta, marca tus avances y descarga el certificado al terminar el curso.

Inscribirme gratis