🐍 Curso Python · Nivel N2 Básico

Bloque 4: Programación orientada a objetos

Hasta ahora programábamos con funciones sueltas. La programación orientada a objetos (POO) organiza el código en clases y objetos: agrupas datos y las operaciones que los manipulan en una sola pieza. Aprenderás construcción de objetos (__init__), encapsulación, herencia y polimorfismo, los métodos mágicos y las dataclasses. Terminarás con una biblioteca digital.

⏱ ≈ 21 horas 📚 5 lecciones ✍️ 15 ejercicios guiados 📝 Examen del bloque + biblioteca digital
Lección 1 · Teoría

Clases, objetos, __init__ y self

Una clase es una plantilla (qué atributos y métodos tienen sus instancias). Un objeto (o instancia) es una materialización de esa plantilla. Solo tienes que escribir la clase una vez y luego crear tantos objetos como quieras.

primera clase
class Perro:
    def __init__(self, nombre, edad):
        self.nombre = nombre   # atributo de instancia
        self.edad = edad

    def ladrar(self):
        return f"¡Guau! soy {self.nombre}"

rex = Perro("Rex", 3)
luna = Perro("Luna", 5)

print(rex.ladrar())   # ¡Guau! soy Rex
print(luna.edad)     # 5

1.1 · self: la referencia al propio objeto

self es el primer parámetro de todos los métodos y apunta a la instancia concreta. Por eso rex.ladrar() sabe que self es rex y no luna. No lo pasas cuando llamas: Python lo rellena solo.

⚠️ Convención estricta: al definir métodos, self SIEMPRE va primero, aunque no uses nada del objeto. Se llama self por convención universal en Python.

1.2 · Atributos de clase vs de instancia

Los atributos de instancia van con self. dentro de métodos. Los de clase se declaran a nivel de clase y son compartidos por todas las instancias:

atributo de clase
class Contador:
    total = 0       # atributo de clase (compartido)

    def __init__(self, nombre):
        self.nombre = nombre   # atributo de instancia
        Contador.total += 1

a = Contador("a")
b = Contador("b")
print(Contador.total)   # 2 (compartido)
💡 Analogía: la clase es el molde de una galleta; los objetos son las galletas. La receta es única (clase) pero puedes hacer 100 galletas (objetos) con chispas distintas (atributos).
Lección 2 · Teoría

Encapsulación y propiedades

La encapsulación protege el estado interno de un objeto: que no se pueda manipular el atributo de cualquier manera. En Python los atributos «privados» se nombran con _ y se accede a ellos mediante propiedades (@property).

propiedad con validación
class Cuenta:
    def __init__(self, saldo=0):
        self._saldo = saldo

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

    @saldo.setter
    def saldo(self, valor):
        if valor < 0:
            raise ValueError("El saldo no puede ser negativo")
        self._saldo = valor

c = Cuenta()
c.saldo = 100
print(c.saldo)          # 100
⚠️ El _saldo con guion bajo es una convención: indica «privado, no tocar desde fuera». El doble guion bajo (__saldo) activa name mangling, pero se usa poco. Lo importante es la API: propiedades para leer/escribir con reglas.

2.1 · Métodos estáticos y de clase

@staticmethod no recibe ni self ni la clase: es una utilidad sin estado. @classmethod recibe la clase (cls) y se usa para constructores alternativos:

staticmethod y classmethod
class Util:
    @staticmethod
    def es_par(n):
        return n % 2 == 0

    @classmethod
    def desde_texto(cls, texto):
        return cls(texto.strip())
Lección 3 · Teoría

Herencia y super()

La herencia crea clases hijas que reutilizan (y amplían o cambian) el comportamiento de la clase padre. super() invoca el método de la clase base, por ejemplo para reutilizar su __init__.

herencia con super()
class Vehiculo:
    def __init__(self, marca, ruedas=4):
        self.marca = marca
        self.ruedas = ruedas

    def describir(self):
        return f"{self.marca} con {self.ruedas} ruedas"

class Moto(Vehiculo):
    def __init__(self, marca):
        super().__init__(marca, ruedas=2)

m = Moto("Yamaha")
print(m.describir())   # Yamaha con 2 ruedas
print(isinstance(m, Vehiculo))  # True

3.1 · Sobreescritura

Una hija puede sobreescribir un método del padre (mismo nombre). También puede extenderlo llamando a super().método() y añadiendo comportamiento:

sobreescribir y extender
class Perro:
    def sonido(self):
        return "Guau"

class Cachorro(Perro):
    def sonido(self):
        # extiende: usa lo del padre + más
        return super().sonido() + " en miniatura"
💡 Regla de oro «IS-A»: usa herencia cuando la hija ES un tipo del padre (Moto es un Vehiculo). Si solo quieres compartir código, prefiere composición (un objeto contiene a otro) antes que forzar jerarquías.
Lección 4 · Teoría

Polimorfismo, métodos mágicos y dataclasses

4.1 · Polimorfismo: misma firma, distinto comportamiento

Si varias clases comparten un método con el mismo nombre, un bucle puede llamarlo sin saber de qué clase es cada objeto. Eso es polimorfismo:

mismo método, distinto resultado
class Gato:
    def sonido(self):
        return "Miau"

class Perro:
    def sonido(self):
        return "Guau"

animales = [Gato(), Perro(), Gato()]
for a in animales:
    print(a.sonido())   # Miau, Guau, Miau

4.2 · Métodos mágicos

Los métodos con doble guion bajo (__str__, __repr__, __eq__, __len__…) definen cómo se comporta el objeto con la sintaxis nativa de Python. El más útil para empezar es __str__:

métodos mágicos
class Punto:
    def __init__(self, x, y):
        self.x, self.y = x, y

    def __str__(self):
        return f"Punto({self.x}, {self.y})"

    def __eq__(self, otro):
        return self.x == otro.x and self.y == otro.y

print(Punto(1, 2))            # Punto(1, 2)
print(Punto(1, 2) == Punto(1, 2))  # True

4.3 · dataclasses: clases sin boilerplate

El decorador @dataclass genera automáticamente __init__, __repr__ y __eq__. Ideal para clases «contenedoras de datos»:

clase de datos
from dataclasses import dataclass

@dataclass
class Libro:
    titulo: str
    autor: str
    paginas: int = 0

libro = Libro("Dune", "Frank Herbert", 680)
print(libro)          # Libro(titulo='Dune', autor='Frank Herbert', paginas=680)
print(libro.titulo)   # Dune
💡 Si tu clase acumula decenas de líneas «de relleno» solo de __init__/__eq__/__repr__, una dataclass te las regala. Úsala para modelos de datos; sigue escribiendo clases normales cuando haya lógica propia.
Lección 5 · Teoría

Excepciones propias y diseño de clases

5.1 · Levantar y capturar excepciones propias

Puedes lanzarte errores con raise y crear tus propios tipos de excepción heredando de Exception. Así el código que usa tu clase puede reaccionar a problemas específicos:

excepción de dominio
class SaldoInsuficiente(Exception):
    pass

class Cuenta:
    def __init__(self, saldo=0):
        self._saldo = saldo

    def retirar(self, monto):
        if monto > self._saldo:
            raise SaldoInsuficiente("No hay saldo suficiente")
        self._saldo -= monto

c = Cuenta(20)
try:
    c.retirar(50)
except SaldoInsuficiente as error:
    print(error)

5.2 · Diseño limpio de clases

Una clase = un concepto. Libro no debe guardar usuarios.
Expón API mínima. Da a conocer solo los métodos necesarios; el resto interno (_).
Valida al crear y al asignar con propiedades o raise.
Métodos con nombre descriptivo (verbo): borrar(), no hacer1().
Composición antes que herencia. Un objeto «tiene» otro («HAS-A») mejor que un árbol forzado.
documenta con docstring qué hace y qué lanza.
🎉 Con clases, herencia, polimorfismo y excepciones propias ya modelas el mundo real en Python: cuentas, libros, vehículos, usuarios. Es la base que usan librerías profesionales como Django, Flask o pandas.
Manos a la obra

✍️ Los 15 ejercicios del bloque

Crea la carpeta python-b4/ y un archivo .py por ejercicio. Recuerda: cada ejercicio debe comprobarse ejecutándolo con varios casos (print()).

Sección A · Clases básicas

1 Clase Coche

Crea la clase Coche con __init__(marca, modelo, combustible="gasolina") y un método describir() que devuelva algo como "Seat Ibiza gasolina". Crea 2 coches y muéstralos.

Ver solución
class Coche:
    def __init__(self, marca, modelo, combustible="gasolina"):
        self.marca = marca
        self.modelo = modelo
        self.combustible = combustible

    def describir(self):
        return f"{self.marca} {self.modelo} {self.combustible}"

c1 = Coche("Seat", "Ibiza")
c2 = Coche("Tesla", "Model 3", "eléctrico")
print(c1.describir())
print(c2.describir())

2 Contador con clase

La clase Contador tendrá __init__ que inicializa self.valor = 0, y métodos sumar(n) y ver(). Crea un contador, suma varias veces y muestra el valor.

Ver solución
class Contador:
    def __init__(self):
        self.valor = 0

    def sumar(self, n):
        self.valor += n

    def ver(self):
        return self.valor

c = Contador()
c.sumar(3)
c.sumar(5)
print(c.ver())   # 8

3 Frigorífico con stock

Clase Frigorífico con __init__ que acepte items (dict, por defecto None → inicias a dict vacío), método meter(producto, cantidad) y total_productos() que sume las cantidades.

Ver solución
class Frigorífico:
    def __init__(self, items=None):
        self.items = items if items is not None else {}

    def meter(self, producto, cantidad):
        self.items[producto] = self.items.get(producto, 0) + cantidad

    def total_productos(self):
        return sum(self.items.values())

f = Frigorífico()
f.meter("leche", 2)
f.meter("leche", 1)
print(f.total_productos())   # 3

4 Atributo de clase compartido

Crea Venta con atributo de clase total_ventas = 0; en __init__(producto, importe) suma a Venta.total_ventas. Crea 3 ventas y muestra el total compartido.

Ver solución
class Venta:
    total_ventas = 0

    def __init__(self, producto, importe):
        self.producto = producto
        self.importe = importe
        Venta.total_ventas += 1

v1 = Venta("ratón", 12)
v2 = Venta("teclado", 45)
v3 = Venta("pad", 8)
print(Venta.total_ventas)   # 3

Sección B · Encapsulación y propiedades

5 Saldo protegido

Clase Cuenta con self._saldo, propiedad saldo de solo lectura y ingresar(monto). Crea una cuenta de 100, ingresa 50 y muestra el saldo.

Ver solución
class Cuenta:
    def __init__(self, saldo=0):
        self._saldo = saldo

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

    def ingresar(self, monto):
        self._saldo += monto

c = Cuenta(100)
c.ingresar(50)
print(c.saldo)   # 150

6 Setter con validación

Amplía la de antes: propiedad saldo con @setter que lance ValueError("El saldo no puede ser negativo") si se intenta asignar un valor negativo. Pruébalo dentro de un try/except.

Ver solución
class Cuenta:
    def __init__(self, saldo=0):
        self.saldo = saldo

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

    @saldo.setter
    def saldo(self, valor):
        if valor < 0:
            raise ValueError("El saldo no puede ser negativo")
        self._saldo = valor

c = Cuenta()
try:
    c.saldo = -5
except ValueError as e:
    print(e)

7 Métodos estáticos

Crea la clase Util con @staticmethod es_par(n) y @staticmethod es_primo(n). Pueba ambos con varios números.

Ver solución
class Util:
    @staticmethod
    def es_par(n):
        return n % 2 == 0

    @staticmethod
    def es_primo(n):
        if n < 2:
            return False
        for i in range(2, int(n ** 0.5) + 1):
            if n % i == 0:
                return False
        return True

print(Util.es_par(4), Util.es_par(5))
print(Util.es_primo(7), Util.es_primo(8))

8 Constructores con @classmethod

Clase Punto normal con x e y y un @classmethod desde_origen() que devuelva cls(0, 0). Crea un punto en el origen.

Ver solución
class Punto:
    def __init__(self, x, y):
        self.x, self.y = x, y

    @classmethod
    def desde_origen(cls):
        return cls(0, 0)

origen = Punto.desde_origen()
print(origen.x, origen.y)   # 0 0

Sección C · Herencia, polimorfismo y mágicos

9 Vehículo y Moto

Clase Vehiculo con __init__(marca, ruedas) y describir(). Clase Moto que hereda con ruedas fijas a 2 usando super(). Crea una moto y muéstrala.

Ver solución
class Vehiculo:
    def __init__(self, marca, ruedas):
        self.marca = marca
        self.ruedas = ruedas

    def describir(self):
        return f"{self.marca} con {self.ruedas} ruedas"

class Moto(Vehiculo):
    def __init__(self, marca):
        super().__init__(marca, ruedas=2)

m = Moto("Yamaha")
print(m.describir())

10 Sobreescritura con super()

Clase Persona con __init__(nombre) y presentarse(). Clase Estudiante que añade curso y reutiliza super().__init__(nombre), sobreescribiendo presentarse() para incluir el curso.

Ver solución
class Persona:
    def __init__(self, nombre):
        self.nombre = nombre

    def presentarse(self):
        return f"Hola, soy {self.nombre}"

class Estudiante(Persona):
    def __init__(self, nombre, curso):
        super().__init__(nombre)
        self.curso = curso

    def presentarse(self):
        return super().presentarse() + f" y estudio {self.curso}"

e = Estudiante("Ana", "Python")
print(e.presentarse())

11 Polimorfismo con animales

Crea Perro y Gato (sin herencia) con método sonido(). Pon varios en una lista y recorre con un for imprimiendo cada sonido.

Ver solución
class Perro:
    def sonido(self):
        return "Guau"

class Gato:
    def sonido(self):
        return "Miau"

animales = [Perro(), Gato(), Perro()]
for a in animales:
    print(a.sonido())

12 __str__ bonito

Clase Libro con titulo, autor y paginas, y __str__ que devuelva "Dune · Frank Herbert (680 páginas)". Crea uno y haz print(libro).

Ver solución
class Libro:
    def __init__(self, titulo, autor, paginas):
        self.titulo = titulo
        self.autor = autor
        self.paginas = paginas

    def __str__(self):
        return f"{self.titulo} · {self.autor} ({self.paginas} páginas)"

l = Libro("Dune", "Frank Herbert", 680)
print(l)

13 __eq__ de igualdad

Clase Punto con __eq__ que compare x e y. Crea pares de puntos iguales y distintos y verifica con ==.

Ver solución
class Punto:
    def __init__(self, x, y):
        self.x, self.y = x, y

    def __eq__(self, otro):
        return self.x == otro.x and self.y == otro.y

print(Punto(1, 2) == Punto(1, 2))  # True
print(Punto(1, 2) == Punto(3, 2))  # False

14 dataclass Producto

Con @dataclass define Producto(nombre: str, precio: float, stock: int = 0). Crea dos productos, muestra uno y compara con ==.

Ver solución
from dataclasses import dataclass

@dataclass
class Producto:
    nombre: str
    precio: float
    stock: int = 0

p1 = Producto("ratón", 12.5, 10)
p2 = Producto("ratón", 12.5, 10)
print(p1)
print(p1 == p2)   # True (dataclass compara por valores)

15 🏆 Proyecto integrador: «biblioteca digital»

Crea biblioteca.py: un mini sistema con modelos y préstamos. Requisitos:

  • Clase Libro con titulo, autor y __str__.
  • Clase Biblioteca que guarde _libros (lista) y _prestados (set de títulos).
  • Métodos agregar(libro: Libro), prestar(titulo) (con raise ValueError si no existe o ya está prestado) y devolver(titulo).
  • Herencia: clase LibroDigital(Libro) con atributo formato="epub" por defecto.
  • Polimorfismo: método mostrar() en las dos clases y un bucle que lo llame a todas.
  • Excepción propia LibroNoDisponible(Exception).
  • Un try/except de demostración capturando LibroNoDisponible.
Ver una solución posible
class LibroNoDisponible(Exception):
    pass

class Libro:
    def __init__(self, titulo, autor):
        self.titulo = titulo
        self.autor = autor

    def mostrar(self):
        return f"{self.titulo} · {self.autor}"

class LibroDigital(Libro):
    def __init__(self, titulo, autor, formato="epub"):
        super().__init__(titulo, autor)
        self.formato = formato

    def mostrar(self):
        return super().mostrar() + f" [{self.formato}]"

class Biblioteca:
    def __init__(self):
        self._libros = []
        self._prestados = set()

    def agregar(self, libro):
        self._libros.append(libro)

    def prestar(self, titulo):
        disponible = any(l.titulo == titulo for l in self._libros)
        if not disponible or titulo in self._prestados:
            raise LibroNoDisponible(f"No se puede prestar: {titulo}")
        self._prestados.add(titulo)

    def devolver(self, titulo):
        self._prestados.discard(titulo)

libro1 = Libro("Dune", "Frank Herbert")
libro2 = LibroDigital("El Quijote", "Cervantes")
biblio = Biblioteca()
biblio.agregar(libro1)
biblio.agregar(libro2)

for l in biblio._libros:
    print(l.mostrar())

biblio.prestar("Dune")
try:
    biblio.prestar("Dune")
except LibroNoDisponible as e:
    print(e)
Evaluación del bloque

📝 Examen del Bloque 4

Dos partes: un test teórico autoevaluable (mínimo 70 %) y un reto práctico de autocorrección con laboratorio.

Parte A · Test teórico (aprueba con 11/15)

1 · ¿Qué palabra clave define una clase?
2 · ¿Qué es self?
3 · ¿Para qué sirve __init__?
4 · ¿Qué decorador convierte un atributo privado en una propiedad de lectura?
5 · ¿Qué hace super() dentro de una clase hija?
6 · ¿Qué es un método estático?
7 · ¿Cuál es la ventaja del polimorfismo?
8 · ¿Qué devuelve el método mágico __str__?
9 · ¿Qué genera automáticamente @dataclass?
10 · ¿Cómo indicas en Python que un atributo es «privado» por convención?
11 · ¿Cómo creas una excepción propia?
12 · ¿Qué palabra lanza una excepción?
13 · ¿Cuándo conviene usar composición en vez de herencia?
14 · ¿Qué comprueba isinstance(x, Clase)?
15 · ¿Qué hace @classmethod con cls?

Parte B · Reto «biblioteca en miniatura»

Crea en biblioteca_mini.py: una clase Libro, una clase hija LibroDigital, una clase Estantería que los contenga, un método estático o de clase útil y una excepción propia. Valida tú mismo/a estos criterios. Mínimo para aprobar: 6 de 8 casillas.

Cumplidos: 0 / 8

🎉 ¿Superaste las dos partes? Bloque 4 completado. Ya modelas el mundo con clases: datos + comportamiento en un mismo lugar, reutilización con herencia y flexibilidad con polimorfismo. En el bloque 5 expondrás la biblioteca estándar de Python al máximo. 🐍🔥