Tema 1 de 8 · 5 min

¿Por qué programación orientada a objetos?

Repaso rápido del día 1

alumno = {"nombre": "Lucía", "notas": [7.5, 8.0, 6.25]}

def media(notas):
    return sum(notas) / len(notas)

print(f"{alumno['nombre']}: {media(alumno['notas']):.2f}")

Funciona, pero imagina un programa con alumnos, profesores, asignaturas y matrículas. Acabarías con decenas de diccionarios sueltos y funciones que no se sabe bien con qué datos trabajan. ¿Y si alguien escribe "nonbre" en lugar de "nombre"?

La idea

La programación orientada a objetos (POO) agrupa en una misma pieza:

  • los datos que describen algo → atributos
  • las acciones que puede hacer → métodos

Esa pieza se llama objeto, y el “molde” para crear objetos se llama clase.

Concepto Analogía Ejemplo
Clase El plano de una casa Alumno
Objeto (instancia) Cada casa construida con ese plano lucia, pablo
Atributo Características de la casa nombre, notas
Método Lo que se puede hacer con ella media(), aprobar()

¡Ya has usado objetos!

En Python todo es un objeto. Cuando hacías esto…

texto = "hola"
texto.upper()

notas = [7, 5, 9]
notas.append(8)

…texto es un objeto de la clase str y upper() es uno de sus métodos. notas es un objeto de la clase list y append() es un método. Hoy aprenderás a crear tus propias clases.

Tema 2 de 8 · 8 min

Clases y objetos

Definir una clase

class Alumno:
    def __init__(self, nombre, grado):
        self.nombre = nombre
        self.grado = grado
  • class Alumno: define la clase. Por convenio, su nombre va en CamelCase (cada palabra empieza en mayúscula).
  • __init__ es un método especial, el constructor: se ejecuta automáticamente al crear cada objeto y sirve para darle sus valores iniciales.
  • self representa al propio objeto que se está creando. self.nombre = nombre significa “guarda en este objeto un atributo nombre”.

Crear objetos (instanciar)

lucia = Alumno("Lucía", "Ingeniería Informática")
pablo = Alumno("Pablo", "Biotecnología")

print(lucia.nombre)   # Lucía
print(pablo.grado)    # Biotecnología

Cada objeto es independiente: tiene sus propios valores.

lucia.nombre = "Lucía M."
print(lucia.nombre)   # Lucía M.
print(pablo.nombre)   # Pablo (no ha cambiado)

¿Qué pasa con self?

Cuando escribes Alumno("Lucía", "Ingeniería Informática"), Python:

  1. Crea un objeto vacío.
  2. Llama a __init__(objeto_nuevo, "Lucía", "Ingeniería Informática"). Ese objeto_nuevo es el self.
  3. Te devuelve el objeto ya rellenado.

Por eso nunca pasas self a mano: Python lo hace por ti.

⚠️ Errores típicos: olvidar self como primer parámetro de los métodos, o escribir nombre = nombre en lugar de self.nombre = nombre (entonces el valor no se guarda en el objeto).

Tema 3 de 8 · 6 min

Atributos

Los atributos son las variables que pertenecen a un objeto o a una clase.

Atributos de instancia

Se crean con self. (normalmente en __init__) y cada objeto tiene los suyos:

class Alumno:
    def __init__(self, nombre, grado, curso=1):
        self.nombre = nombre
        self.grado = grado
        self.curso = curso          # valor por defecto
        self.notas = []             # empieza vacío

ana = Alumno("Ana", "Matemáticas")
ana.notas.append(9.5)

print(ana.curso)   # 1
print(ana.notas)   # [9.5]

Atributos de clase

Se definen directamente dentro de la clase (fuera de cualquier método) y son compartidos por todos los objetos:

class Alumno:
    universidad = "Universidad de León"   # atributo de clase

    def __init__(self, nombre, grado):
        self.nombre = nombre               # atributos de instancia
        self.grado = grado

ana = Alumno("Ana", "Matemáticas")
hugo = Alumno("Hugo", "Veterinaria")

print(ana.universidad)      # Universidad de León
print(hugo.universidad)     # Universidad de León
print(Alumno.universidad)   # también desde la clase
Atributo de instancia Atributo de clase
Dónde se define self.x = … dentro de un método Dentro de la clase, fuera de los métodos
¿De quién es? De cada objeto Compartido por todos
Ejemplo nombre, notas universidad, contador de alumnos

Ejemplo: contar cuántos objetos se han creado

class Alumno:
    total = 0

    def __init__(self, nombre):
        self.nombre = nombre
        Alumno.total += 1

ana = Alumno("Ana")
hugo = Alumno("Hugo")
print(Alumno.total)   # 2

💡 Convenio: un atributo que empieza por guion bajo, como self._saldo, indica “esto es interno, no lo toques desde fuera”. Python no lo impide, pero es un aviso para otros programadores.

Tema 4 de 8 · 8 min

Métodos

Los métodos son funciones definidas dentro de una clase. Describen lo que un objeto puede hacer y trabajan con sus atributos a través de self.

class Alumno:
    def __init__(self, nombre):
        self.nombre = nombre
        self.notas = []

    def anadir_nota(self, nota):
        self.notas.append(nota)

    def media(self):
        if not self.notas:
            return 0
        return sum(self.notas) / len(self.notas)

    def esta_aprobado(self):
        return self.media() >= 5      # un método puede llamar a otro


lucia = Alumno("Lucía")
lucia.anadir_nota(7.5)
lucia.anadir_nota(4.0)
lucia.anadir_nota(9.0)

print(f"Media: {lucia.media():.2f}")       # Media: 6.83
print(lucia.esta_aprobado())               # True

Fíjate en que al definir el método escribimos self, pero al llamarlo no: lucia.media() equivale a Alumno.media(lucia).

Métodos especiales: __str__

Si haces print(lucia) verás algo poco útil como <__main__.Alumno object at 0x7f...>. El método especial __str__ define cómo se muestra el objeto como texto:

class Alumno:
    def __init__(self, nombre):
        self.nombre = nombre
        self.notas = []

    def media(self):
        return sum(self.notas) / len(self.notas) if self.notas else 0

    def __str__(self):
        return f"Alumno {self.nombre} (media {self.media():.2f})"


lucia = Alumno("Lucía")
lucia.notas = [8, 9]
print(lucia)   # Alumno Lucía (media 8.50)

Los métodos que empiezan y terminan con doble guion bajo (__init__, __str__, __len__, __eq__…) se llaman métodos mágicos o dunder: Python los llama automáticamente en ciertas situaciones.

Mini reto (2 min)

Crea una clase CuentaBancaria con un titular y un saldo (que empiece en 0), y los métodos ingresar(cantidad) y retirar(cantidad). Si no hay saldo suficiente, retirar debe mostrar un aviso.

class CuentaBancaria:
    def __init__(self, titular, saldo=0):
        # Escribe aquí tu solución
        pass


cuenta = CuentaBancaria("Lucía")
Ver solución
class CuentaBancaria:
    def __init__(self, titular, saldo=0):
        self.titular = titular
        self.saldo = saldo

    def ingresar(self, cantidad):
        self.saldo += cantidad

    def retirar(self, cantidad):
        if cantidad > self.saldo:
            print("Saldo insuficiente")
        else:
            self.saldo -= cantidad

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


cuenta = CuentaBancaria("Lucía")
cuenta.ingresar(100)
cuenta.retirar(30)
cuenta.retirar(500)   # Saldo insuficiente
print(cuenta)         # Cuenta de Lucía: 70.00 €

Tema 5 de 8 · 8 min

Herencia

La herencia permite crear una clase nueva a partir de otra existente. La clase nueva (hija o subclase) recibe todos los atributos y métodos de la original (padre o superclase), y puede añadir o modificar lo que necesite.

Sirve para no repetir código y para expresar relaciones del tipo “es un”: un alumno es una persona, un profesor es una persona.

class Persona:
    def __init__(self, nombre, edad):
        self.nombre = nombre
        self.edad = edad

    def presentarse(self):
        return f"Hola, soy {self.nombre} y tengo {self.edad} años"


class Alumno(Persona):             # Alumno hereda de Persona
    pass                           # de momento no añade nada


ana = Alumno("Ana", 19)
print(ana.presentarse())           # ¡funciona! lo ha heredado

Añadir atributos con super()

Si la clase hija necesita su propio __init__, usa super() para llamar al de la clase padre y no repetir código:

class Alumno(Persona):
    def __init__(self, nombre, edad, grado):
        super().__init__(nombre, edad)   # inicializa nombre y edad
        self.grado = grado               # y añade lo nuevo


class Profesor(Persona):
    def __init__(self, nombre, edad, departamento):
        super().__init__(nombre, edad)
        self.departamento = departamento

Sobrescribir métodos

Una clase hija puede redefinir un método del padre para que se comporte de otra forma:

class Profesor(Persona):
    def __init__(self, nombre, edad, departamento):
        super().__init__(nombre, edad)
        self.departamento = departamento

    def presentarse(self):
        saludo = super().presentarse()             # reutiliza el del padre
        return f"{saludo}. Doy clase en {self.departamento}"


profe = Profesor("Carmen", 45, "Matemáticas")
print(profe.presentarse())
# Hola, soy Carmen y tengo 45 años. Doy clase en Matemáticas

Polimorfismo

Objetos de clases distintas pueden responder al mismo método, cada uno a su manera. Así puedes tratarlos a todos por igual:

personas = [
    Alumno("Ana", 19, "Biología"),
    Profesor("Carmen", 45, "Matemáticas"),
]

for persona in personas:
    print(persona.presentarse())   # cada uno usa su versión

💡 Esta idea es la clave del ejemplo práctico y del proyecto de hoy.

Tema 6 de 8 · 4 min

Comprobar si un objeto es de una clase

A veces necesitas saber de qué clase es un objeto antes de hacer algo con él.

isinstance(objeto, Clase)

Devuelve True si el objeto es de esa clase o de una clase hija:

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

class Alumno(Persona):
    pass

class Profesor(Persona):
    pass


ana = Alumno("Ana")

isinstance(ana, Alumno)     # True
isinstance(ana, Persona)    # True  ← un Alumno también es una Persona
isinstance(ana, Profesor)   # False

También acepta una tupla de clases:

isinstance(ana, (Alumno, Profesor))   # True si es cualquiera de las dos
isinstance(3.5, (int, float))         # True: ¡funciona con tipos básicos!

type(objeto)

Devuelve la clase exacta del objeto, sin tener en cuenta la herencia:

type(ana)               # <class '__main__.Alumno'>
type(ana) == Alumno     # True
type(ana) == Persona    # False ← aquí está la diferencia

issubclass(Clase, OtraClase)

Comprueba relaciones entre clases (no entre objetos):

issubclass(Alumno, Persona)    # True
issubclass(Persona, Alumno)    # False
Función Pregunta que responde ¿Tiene en cuenta la herencia?
isinstance(obj, C) ¿obj es un C? Sí
type(obj) == C ¿obj es exactamente un C? No
issubclass(A, B) ¿La clase A hereda de B? Sí

💡 Recomendación: usa casi siempre isinstance. Respeta la herencia, que es justo lo que suele interesar.

Ejemplo de uso

def describir(valor):
    if isinstance(valor, str):
        return f"Texto de {len(valor)} caracteres"
    elif isinstance(valor, (int, float)):
        return f"Número: {valor}"
    elif isinstance(valor, list):
        return f"Lista con {len(valor)} elementos"
    return "No sé qué es esto"


print(describir("Hola"))
print(describir(3.5))
print(describir([1, 2, 3]))
print(describir(ana))

Tema 7 de 8 · 8 min

Ejemplo práctico: Animal, Perro y Gato

Vamos a juntar todo lo visto: una clase base Animal y dos clases hijas, Perro y Gato.

Paso 1: la clase base

Todo animal tiene nombre y edad, puede presentarse y hace algún sonido. Como “un animal genérico” no tiene un sonido concreto, dejamos que cada hija lo defina:

class Animal:
    def __init__(self, nombre, edad):
        self.nombre = nombre
        self.edad = edad

    def hablar(self):
        return "..."

    def presentarse(self):
        return f"Soy {self.nombre}, tengo {self.edad} años y digo {self.hablar()}"

    def __str__(self):
        return f"{type(self).__name__} llamado {self.nombre}"

Paso 2: las clases hijas

class Perro(Animal):
    def __init__(self, nombre, edad, raza):
        super().__init__(nombre, edad)
        self.raza = raza

    def hablar(self):
        return "¡Guau!"

    def traer_pelota(self):
        return f"{self.nombre} te trae la pelota 🎾"


class Gato(Animal):
    def __init__(self, nombre, edad, es_casero=True):
        super().__init__(nombre, edad)
        self.es_casero = es_casero

    def hablar(self):
        return "¡Miau!"

    def ronronear(self):
        return f"{self.nombre} ronronea: purrr 😺"

Paso 3: probarlo

toby = Perro("Toby", 3, "Beagle")
misi = Gato("Misi", 5)

print(toby)                  # Perro llamado Toby
print(toby.presentarse())    # Soy Toby, tengo 3 años y digo ¡Guau!
print(misi.presentarse())    # Soy Misi, tengo 5 años y digo ¡Miau!
print(toby.traer_pelota())
print(misi.ronronear())

Fíjate: presentarse() está escrito una sola vez en Animal, pero llama a self.hablar(), que es distinto en cada hija. Eso es polimorfismo.

Paso 4: tratarlos a todos por igual

animales = [toby, misi, Perro("Rex", 7, "Pastor alemán"), Gato("Nube", 1, False)]

for animal in animales:
    print(animal.presentarse())

    # Algo específico según el tipo
    if isinstance(animal, Perro):
        print("  →", animal.traer_pelota())
    elif isinstance(animal, Gato):
        print("  →", animal.ronronear())

Para ampliar (en casa)

  1. Añade una clase Pajaro que sepa volar().
  2. Crea una clase Refugio que guarde una lista de animales y tenga los métodos acoger(animal), adoptar(nombre) y contar_por_tipo() (cuántos perros, gatos…).
class Pajaro(Animal):
    # Escribe aquí tu solución
    pass

Tema 8 de 8 · 13 min

Proyecto: formas geométricas

Enunciado

Crea una clase base Forma y tres clases hijas: Circulo, Rectangulo y Triangulo. Cada una debe calcular su área y su perímetro:

Forma Datos que recibe Área Perímetro
Círculo diámetro π · r² (con r = diámetro / 2) π · diámetro
Rectángulo base y altura base · altura 2 · (base + altura)
Triángulo (isósceles) base y altura base · altura / 2 base + 2 · lado

Para el perímetro del triángulo necesitamos sus lados. Si suponemos que es isósceles (los dos lados iguales), por el teorema de Pitágoras cada lado mide √((base/2)² + altura²).

Pista: import math te da math.pi y math.sqrt(). Es tu primer contacto con la biblioteca estándar: módulos que vienen con Python pero hay que importar.

Intenta hacerlo por tu cuenta antes de mirar la solución. Empieza por la clase Forma: ¿qué tienen en común todas las formas?

Pasos sugeridos

  1. Define Forma con un atributo nombre y los métodos area() y perimetro(). Como no sabemos calcularlos para una forma genérica, que lancen NotImplementedError.
  2. Añade a Forma un método describir() que devuelva un texto con el nombre, el área y el perímetro (redondeados a 2 decimales).
  3. Crea las tres clases hijas, cada una con su __init__ (usando super()) y sus fórmulas.
  4. Crea una lista con varias formas, recórrela y muestra la descripción de cada una.
  5. Extra: muestra cuál es la forma con mayor área.
import math


class Forma:
    def __init__(self, nombre):
        self.nombre = nombre

    def area(self):
        raise NotImplementedError("Cada forma debe implementar area()")

    def perimetro(self):
        raise NotImplementedError("Cada forma debe implementar perimetro()")

    def describir(self):
        # Devuelve un texto con el nombre, el área y el perímetro
        pass


class Circulo(Forma):
    def __init__(self, diametro):
        super().__init__("Círculo")
        self.diametro = diametro

    # Completa area() y perimetro()


# Crea aquí las clases Rectangulo y Triangulo


formas = [Circulo(10)]

for forma in formas:
    print(forma.describir())
Ver solución
import math


class Forma:
    def __init__(self, nombre):
        self.nombre = nombre

    def area(self):
        raise NotImplementedError("Cada forma debe implementar area()")

    def perimetro(self):
        raise NotImplementedError("Cada forma debe implementar perimetro()")

    def describir(self):
        return (
            f"{self.nombre}: área = {self.area():.2f}, "
            f"perímetro = {self.perimetro():.2f}"
        )


class Circulo(Forma):
    def __init__(self, diametro):
        super().__init__("Círculo")
        self.diametro = diametro

    def radio(self):
        return self.diametro / 2

    def area(self):
        return math.pi * self.radio() ** 2

    def perimetro(self):
        return math.pi * self.diametro


class Rectangulo(Forma):
    def __init__(self, base, altura):
        super().__init__("Rectángulo")
        self.base = base
        self.altura = altura

    def area(self):
        return self.base * self.altura

    def perimetro(self):
        return 2 * (self.base + self.altura)


class Triangulo(Forma):
    """Triángulo isósceles definido por su base y su altura."""

    def __init__(self, base, altura):
        super().__init__("Triángulo")
        self.base = base
        self.altura = altura

    def lado(self):
        return math.sqrt((self.base / 2) ** 2 + self.altura ** 2)

    def area(self):
        return self.base * self.altura / 2

    def perimetro(self):
        return self.base + 2 * self.lado()


formas = [
    Circulo(10),
    Rectangulo(4, 6),
    Triangulo(6, 4),
]

for forma in formas:
    print(forma.describir())

mayor = max(formas, key=lambda f: f.area())
print(f"La forma con mayor área es: {mayor.nombre}")

Salida:

Círculo: área = 78.54, perímetro = 31.42
Rectángulo: área = 24.00, perímetro = 20.00
Triángulo: área = 12.00, perímetro = 16.00
La forma con mayor área es: Círculo

Retos extra

  • Añade una clase Cuadrado que herede de Rectangulo (¡solo necesita un dato!).
  • Haz que el programa pida al usuario qué forma quiere y sus medidas con input().
  • Valida los datos: si alguna medida es negativa o cero, lanza un ValueError.

Usa las clases que ya has ejecutado más arriba:

# Escribe aquí tus retos (por ejemplo, la clase Cuadrado)

🔗 Para mañana: fíjate en que nuestras clases tienen atributos con tipos concretos (números, textos…). Mañana usaremos Pydantic, una librería que crea clases así y además valida automáticamente los datos. Es justo lo que necesitaremos para que una IA nos devuelva información ordenada.