Programación Orientada a Objetos
Al completar este módulo serás capaz de:
- Crear clases y objetos con atributos y métodos
- Usar herencia para reutilizar y extender código
- Aplicar encapsulación para proteger datos
- Implementar polimorfismo para código flexible
- Usar dataclasses para crear clases de datos de forma sencilla
¿Por qué Programación Orientada a Objetos?
Hasta ahora hemos organizado nuestro código con funciones: pequeñas máquinas que reciben datos, los procesan y devuelven resultados. Funciona muy bien, pero imagina que estás construyendo un videojuego. Tienes jugadores, enemigos, objetos, niveles… Cada uno tiene sus propios datos (vida, posición, nombre) y sus propios comportamientos (moverse, atacar, recoger objetos).
Con solo funciones, acabarías con un montón de diccionarios y funciones sueltas que no tienen una relación clara entre sí. La Programación Orientada a Objetos (POO) te permite agrupar datos y comportamientos en una sola unidad llamada objeto.
Piensa en una clase como el plano de una casa. El plano define cuántas habitaciones tendrá, dónde estará la cocina, el tamaño del jardín… Pero el plano no es una casa, es solo la especificación.
Cuando construyes casas reales siguiendo ese plano, cada casa es un objeto (o instancia). Todas siguen el mismo diseño, pero cada una tiene su propia dirección, color de pintura y muebles.
Clases y objetos: el molde y las galletas
Una clase es como un molde para hacer galletas. Define la forma que tendrán todas las galletas. Cada objeto es una galleta individual hecha con ese molde: todas tienen la misma forma, pero cada una puede tener diferente color o decoración.
Tu primera clase
1class Perro:
2 """Representa a un perro con nombre y edad."""
3
4 # Atributo de clase (compartido por todos los perros)
5 especie = "Canis lupus familiaris"
6
7 def __init__(self, nombre, edad):
8 """Inicializa un nuevo perro."""
9 # Atributos de instancia (únicos para cada perro)
10 self.nombre = nombre
11 self.edad = edad
12
13 def ladrar(self):
14 """El perro ladra."""
15 return f"{self.nombre} dice: ¡Guau!"
16
17 def cumplir_anos(self):
18 """El perro cumple un año más."""
19 self.edad += 1
20 return f"¡Feliz cumpleaños {self.nombre}! Ahora tienes {self.edad} años."Crear objetos (instancias)
1# Creamos dos perros usando la clase como molde
2mi_perro = Perro("Max", 3)
3tu_perro = Perro("Luna", 5)
4
5# Cada perro tiene sus propios atributos
6print(mi_perro.nombre) # Max
7print(tu_perro.nombre) # Luna
8
9# Pero comparten el atributo de clase
10print(mi_perro.especie) # Canis lupus familiaris
11print(tu_perro.especie) # Canis lupus familiaris
12
13# Cada perro puede ejecutar sus métodos
14print(mi_perro.ladrar()) # Max dice: ¡Guau!
15print(tu_perro.cumplir_anos()) # ¡Feliz cumpleaños Luna! Ahora tienes 6 años.self es una referencia al objeto actual. Cuando llamas mi_perro.ladrar(), Python automáticamente pasa mi_perro como el primer argumento (self). Por eso los métodos siempre tienen self como primer parámetro: necesitan saber con qué objeto específico están trabajando.
Atributos de clase vs atributos de instancia
Imagina un edificio de apartamentos. El nombre del edificio, la dirección y las zonas comunes (piscina, gimnasio) son compartidos por todos los vecinos: son atributos de clase. Pero cada apartamento tiene su propio número de puerta, decoración y inquilinos: son atributos de instancia.
Si cambian el nombre del edificio, afecta a todos. Si un vecino pinta su salón de azul, solo cambia su apartamento.
Se definen fuera de __init__, directamente en el cuerpo de la clase. Son compartidos por todas las instancias.
1class Coche:
2 # Atributos de clase (compartidos)
3 ruedas = 4
4 fabricados = 0
5
6 def __init__(self, marca, color):
7 self.marca = marca # Atributo de instancia
8 self.color = color # Atributo de instancia
9 Coche.fabricados += 1 # Incrementamos el contador de clase
10
11# Todos los coches tienen 4 ruedas
12c1 = Coche("Toyota", "rojo")
13c2 = Coche("Ford", "azul")
14
15print(c1.ruedas) # 4
16print(c2.ruedas) # 4
17print(Coche.ruedas) # 4 (acceso desde la clase)
18print(Coche.fabricados) # 2 (contador compartido)Usos comunes:
- Contadores de instancias creadas
- Valores constantes (como
ruedas = 4) - Configuración por defecto compartida
Se definen dentro de __init__ usando self.atributo. Son únicos para cada objeto.
1class Mascota:
2 especie = "Desconocida" # Atributo de clase
3
4 def __init__(self, nombre, edad):
5 self.nombre = nombre # Atributo de instancia
6 self.edad = edad # Atributo de instancia
7
8# Cada mascota tiene su propio nombre y edad
9gato = Mascota("Michi", 3)
10perro = Mascota("Max", 5)
11
12gato.nombre = "Pelusa" # Solo cambia este gato
13print(gato.nombre) # Pelusa
14print(perro.nombre) # Max (no cambió)
15
16# Pero si cambiamos el atributo de clase...
17Mascota.especie = "Animal doméstico"
18print(gato.especie) # Animal doméstico
19print(perro.especie) # Animal doméstico (¡ambos cambiaron!)Usos comunes:
- Datos únicos de cada objeto (nombre, id, estado)
- Cualquier valor que varía entre instancias
| Característica | Atributo de clase | Atributo de instancia |
|---|---|---|
| Dónde se define | Fuera de init | Dentro de init con self. |
| Compartido | Sí, entre todas las instancias | No, único por objeto |
| Acceso | Clase.atributo o objeto.atributo | Solo objeto.atributo |
| Modificación | Afecta a todas las instancias | Solo afecta a ese objeto |
| Ejemplo típico | Contadores, constantes | Nombre, edad, estado |
Si usas una lista como atributo de clase, todas las instancias comparten la misma lista:
1class Equipo:
2 miembros = [] # ¡Peligro! Lista compartida
3
4 def agregar(self, nombre):
5 self.miembros.append(nombre)
6
7e1 = Equipo()
8e2 = Equipo()
9e1.agregar("Ana")
10print(e2.miembros) # ['Ana'] - ¡También aparece en e2!Solución: Inicializa las listas en __init__:
1class Equipo:
2 def __init__(self):
3 self.miembros = [] # Cada equipo tiene su propia listaMétodos: las habilidades del objeto
Un objeto es como un empleado que tiene un currículum (sus atributos: nombre, experiencia, habilidades) y puede realizar tareas (sus métodos: trabajar, presentarse, calcular su salario).
Tipos de métodos
Trabajan con los datos del objeto específico. Reciben self.
1class Calculadora:
2 def __init__(self, valor_inicial=0):
3 self.valor = valor_inicial
4
5 def sumar(self, n):
6 self.valor += n
7 return self # Permite encadenar
8
9 def restar(self, n):
10 self.valor -= n
11 return self
12
13calc = Calculadora(10)
14calc.sumar(5).restar(3)
15print(calc.valor) # 12No necesitan acceso al objeto ni a la clase. Son funciones normales que viven dentro de la clase por organización.
1class Matematicas:
2 @staticmethod
3 def es_par(n):
4 return n % 2 == 0
5
6 @staticmethod
7 def es_primo(n):
8 if n < 2:
9 return False
10 for i in range(2, int(n**0.5) + 1):
11 if n % i == 0:
12 return False
13 return True
14
15# Se llaman sin crear una instancia
16print(Matematicas.es_par(4)) # True
17print(Matematicas.es_primo(7)) # TrueTrabajan con la clase en sí, no con instancias. Reciben cls (la clase). Útiles para crear instancias de formas alternativas.
1class Fecha:
2 def __init__(self, dia, mes, ano):
3 self.dia = dia
4 self.mes = mes
5 self.ano = ano
6
7 @classmethod
8 def desde_string(cls, fecha_str):
9 """Crea una Fecha desde un string 'dd-mm-aaaa'."""
10 dia, mes, ano = map(int, fecha_str.split("-"))
11 return cls(dia, mes, ano)
12
13 @classmethod
14 def hoy(cls):
15 """Crea una Fecha con la fecha actual."""
16 from datetime import date
17 hoy = date.today()
18 return cls(hoy.day, hoy.month, hoy.year)
19
20# Diferentes formas de crear una Fecha
21f1 = Fecha(15, 6, 2024)
22f2 = Fecha.desde_string("25-12-2024")
23f3 = Fecha.hoy()Encapsulación: la caja fuerte
Imagina un cajero automático. Tú interactúas con una pantalla y botones (la interfaz pública), pero no tienes acceso directo al dinero ni a los mecanismos internos (los detalles privados). Esto es encapsulación: ocultar los detalles de implementación y exponer solo lo necesario.
Convenciones de privacidad en Python
Python no tiene verdadera privacidad como otros lenguajes, pero usa convenciones:
1class CuentaBancaria:
2 def __init__(self, titular, saldo_inicial):
3 self.titular = titular # Público: acceso libre
4 self._saldo = saldo_inicial # "Privado": no tocar desde fuera
5 self.__pin = "1234" # "Muy privado": name mangling
6
7 def depositar(self, cantidad):
8 if cantidad > 0:
9 self._saldo += cantidad
10 return True
11 return False
12
13 def retirar(self, cantidad):
14 if 0 < cantidad <= self._saldo:
15 self._saldo -= cantidad
16 return True
17 return False
18
19cuenta = CuentaBancaria("Ana", 1000)
20
21# Público: acceso normal
22print(cuenta.titular) # Ana
23
24# "Privado": técnicamente accesible, pero no deberías
25print(cuenta._saldo) # 1000 (funciona, pero es mala práctica)
26
27# "Muy privado": Python lo renombra internamente
28# print(cuenta.__pin) # Error: AttributeError
29print(cuenta._CuentaBancaria__pin) # 1234 (name mangling)En Python, _atributo es una señal de que es interno. __atributo hace que Python lo renombre a _Clase__atributo (name mangling), dificultando el acceso accidental. Pero recuerda: “Somos todos adultos aquí” es la filosofía de Python.
Properties: getters y setters elegantes
En lugar de acceder directamente a atributos, puedes usar properties para controlar cómo se leen y escriben:
1class Temperatura:
2 def __init__(self, celsius):
3 self._celsius = celsius
4
5 @property
6 def celsius(self):
7 """Getter: se ejecuta al leer temperatura.celsius"""
8 return self._celsius
9
10 @celsius.setter
11 def celsius(self, valor):
12 """Setter: se ejecuta al asignar temperatura.celsius = valor"""
13 if valor < -273.15:
14 raise ValueError("¡No existe temperatura menor al cero absoluto!")
15 self._celsius = valor
16
17 @property
18 def fahrenheit(self):
19 """Propiedad calculada (solo lectura)."""
20 return self._celsius * 9/5 + 32
21
22temp = Temperatura(25)
23print(temp.celsius) # 25 (usa el getter)
24print(temp.fahrenheit) # 77.0
25
26temp.celsius = 30 # Usa el setter
27print(temp.celsius) # 30
28
29# temp.celsius = -300 # ValueError: ¡No existe temperatura menor al cero absoluto!Herencia: el árbol genealógico
Un hijo hereda características de sus padres, pero puede tener las suyas propias. En POO, una clase hija hereda atributos y métodos de una clase padre, y puede añadir o modificar comportamientos.
1class Animal:
2 """Clase base para todos los animales."""
3
4 def __init__(self, nombre, edad):
5 self.nombre = nombre
6 self.edad = edad
7
8 def presentarse(self):
9 return f"Soy {self.nombre} y tengo {self.edad} años."
10
11 def hacer_sonido(self):
12 raise NotImplementedError("Las subclases deben implementar este método")
13
14
15class Perro(Animal):
16 """Un perro es un animal con raza."""
17
18 def __init__(self, nombre, edad, raza):
19 super().__init__(nombre, edad) # Llama al __init__ del padre
20 self.raza = raza
21
22 def hacer_sonido(self):
23 return "¡Guau!"
24
25 def buscar_pelota(self):
26 return f"{self.nombre} va a buscar la pelota."
27
28
29class Gato(Animal):
30 """Un gato es un animal con color de pelaje."""
31
32 def __init__(self, nombre, edad, color):
33 super().__init__(nombre, edad)
34 self.color = color
35
36 def hacer_sonido(self):
37 return "¡Miau!"
38
39 def ronronear(self):
40 return f"{self.nombre} está ronroneando..."
41
42
43# Uso
44max_perro = Perro("Max", 3, "Labrador")
45mishi = Gato("Mishi", 2, "naranja")
46
47print(max_perro.presentarse()) # Soy Max y tengo 3 años. (heredado)
48print(max_perro.hacer_sonido()) # ¡Guau! (sobrescrito)
49print(max_perro.buscar_pelota()) # Max va a buscar la pelota. (nuevo)
50
51print(mishi.presentarse()) # Soy Mishi y tengo 2 años. (heredado)
52print(mishi.hacer_sonido()) # ¡Miau! (sobrescrito)
53print(mishi.ronronear()) # Mishi está ronroneando... (nuevo)super() te permite llamar métodos de la clase padre. Es especialmente útil en __init__ para no duplicar la inicialización de atributos heredados.
Verificar herencia
1# isinstance() verifica si un objeto es de una clase (o sus padres)
2print(isinstance(max_perro, Perro)) # True
3print(isinstance(max_perro, Animal)) # True
4print(isinstance(max_perro, Gato)) # False
5
6# issubclass() verifica relación entre clases
7print(issubclass(Perro, Animal)) # True
8print(issubclass(Gato, Perro)) # FalsePolimorfismo: mismo mensaje, diferente respuesta
El polimorfismo significa “muchas formas”. Imagina un control remoto universal: el botón de “encender” funciona con cualquier televisor, pero cada marca lo implementa de forma diferente internamente.
En Python, esto se logra de forma natural gracias al duck typing: “Si camina como pato y hace cuac como pato, entonces es un pato”.
1class Forma:
2 """Clase base para formas geométricas."""
3
4 def area(self):
5 raise NotImplementedError
6
7 def perimetro(self):
8 raise NotImplementedError
9
10
11class Rectangulo(Forma):
12 def __init__(self, ancho, alto):
13 self.ancho = ancho
14 self.alto = alto
15
16 def area(self):
17 return self.ancho * self.alto
18
19 def perimetro(self):
20 return 2 * (self.ancho + self.alto)
21
22
23class Circulo(Forma):
24 def __init__(self, radio):
25 self.radio = radio
26
27 def area(self):
28 return 3.14159 * self.radio ** 2
29
30 def perimetro(self):
31 return 2 * 3.14159 * self.radio
32
33
34class Triangulo(Forma):
35 def __init__(self, base, altura, lado1, lado2, lado3):
36 self.base = base
37 self.altura = altura
38 self.lados = (lado1, lado2, lado3)
39
40 def area(self):
41 return (self.base * self.altura) / 2
42
43 def perimetro(self):
44 return sum(self.lados)
45
46
47# Polimorfismo en acción
48formas = [
49 Rectangulo(4, 5),
50 Circulo(3),
51 Triangulo(6, 4, 5, 5, 6)
52]
53
54for forma in formas:
55 # Mismo código, diferente resultado según el tipo de forma
56 print(f"Área: {forma.area():.2f}, Perímetro: {forma.perimetro():.2f}")
57
58# Salida:
59# Área: 20.00, Perímetro: 18.00
60# Área: 28.27, Perímetro: 18.85
61# Área: 12.00, Perímetro: 16.00Métodos especiales: la magia de Python
Los métodos especiales (o “dunder methods”, por los dobles guiones bajos) son como traductores que permiten a tus objetos “hablar” con el resto de Python. Cuando Python necesita imprimir un objeto, compararlo, o sumarlo, busca estos métodos.
Los más comunes
1class Vector:
2 """Un vector 2D con operaciones matemáticas."""
3
4 def __init__(self, x, y):
5 self.x = x
6 self.y = y
7
8 def __repr__(self):
9 """Representación técnica (para desarrolladores)."""
10 return f"Vector({self.x}, {self.y})"
11
12 def __str__(self):
13 """Representación amigable (para usuarios)."""
14 return f"({self.x}, {self.y})"
15
16 def __eq__(self, otro):
17 """Comparación de igualdad: v1 == v2"""
18 return self.x == otro.x and self.y == otro.y
19
20 def __add__(self, otro):
21 """Suma de vectores: v1 + v2"""
22 return Vector(self.x + otro.x, self.y + otro.y)
23
24 def __sub__(self, otro):
25 """Resta de vectores: v1 - v2"""
26 return Vector(self.x - otro.x, self.y - otro.y)
27
28 def __mul__(self, escalar):
29 """Multiplicación por escalar: v * 3"""
30 return Vector(self.x * escalar, self.y * escalar)
31
32 def __len__(self):
33 """Longitud (magnitud) del vector: len(v)"""
34 return int((self.x**2 + self.y**2)**0.5)
35
36 def __bool__(self):
37 """Valor booleano: if v:"""
38 return self.x != 0 or self.y != 0
39
40
41# Uso natural gracias a los métodos especiales
42v1 = Vector(3, 4)
43v2 = Vector(1, 2)
44
45print(v1) # (3, 4) - usa __str__
46print(repr(v1)) # Vector(3, 4) - usa __repr__
47print(v1 == v2) # False - usa __eq__
48print(v1 + v2) # (4, 6) - usa __add__
49print(v1 * 2) # (6, 8) - usa __mul__
50print(len(v1)) # 5 - usa __len__
51
52if v1: # usa __bool__
53 print("v1 no es el vector cero")Tabla de métodos especiales útiles
| Método | Operación | Ejemplo |
|---|---|---|
| init | Constructor | obj = Clase() |
| str | Conversión a string | str(obj), print(obj) |
| repr | Representación técnica | repr(obj) |
| eq | Igualdad | obj1 == obj2 |
| lt, gt | Comparación | obj1 < obj2, obj1 > obj2 |
| add, sub | Aritmética | obj1 + obj2, obj1 - obj2 |
| len | Longitud | len(obj) |
| getitem | Índice | obj[key] |
| iter | Iteración | for x in obj: |
| call | Llamada | obj() |
| contains | Pertenencia | x in obj |
Dataclasses: POO sin burocracia
Crear una clase con __init__, __repr__ y __eq__ puede ser tedioso para clases que principalmente almacenan datos. Las dataclasses (Python 3.7+) generan todo esto automáticamente.
Imagina que tienes que rellenar un formulario con nombre, dirección, teléfono… Normalmente escribirías todo a mano. Las dataclasses son como un formulario pre-rellenado donde solo tienes que especificar los campos y Python hace el resto.
Clase tradicional vs dataclass
1class ProductoTradicional:
2 def __init__(self, nombre, precio, cantidad=0):
3 self.nombre = nombre
4 self.precio = precio
5 self.cantidad = cantidad
6
7 def __repr__(self):
8 return f"Producto(nombre={self.nombre!r}, precio={self.precio}, cantidad={self.cantidad})"
9
10 def __eq__(self, otro):
11 return (self.nombre == otro.nombre and
12 self.precio == otro.precio and
13 self.cantidad == otro.cantidad) 1from dataclasses import dataclass
2
3@dataclass
4class Producto:
5 nombre: str
6 precio: float
7 cantidad: int = 0
8
9 def total(self):
10 return self.precio * self.cantidadCaracterísticas avanzadas
1from dataclasses import dataclass, field
2from typing import List
3
4@dataclass
5class Pedido:
6 cliente: str
7 productos: List[str] = field(default_factory=list) # Lista mutable
8 id_pedido: int = field(default=0, repr=False) # No se muestra en repr
9
10 def agregar_producto(self, producto):
11 self.productos.append(producto)
12
13@dataclass(frozen=True) # Inmutable (no se puede modificar después de crear)
14class Coordenada:
15 x: float
16 y: float
17
18# Uso
19pedido = Pedido("Ana")
20pedido.agregar_producto("Laptop")
21pedido.agregar_producto("Mouse")
22print(pedido) # Pedido(cliente='Ana', productos=['Laptop', 'Mouse'])
23
24coord = Coordenada(10.5, 20.3)
25# coord.x = 15 # Error: FrozenInstanceError (inmutable)¿Cuándo usar cada enfoque?
| Situación | Recomendación |
|---|---|
| Datos simples sin comportamiento | Diccionario o namedtuple |
| Datos con validación simple | @dataclass |
| Datos con comportamiento complejo | Clase completa |
| Múltiples variantes de algo similar | Herencia |
| Funciones puras sin estado | Funciones normales |
| Estado + comportamiento relacionado | Clase con métodos |
Ejercicios prácticos
Crea una clase Producto con nombre, precio y stock. Implementa métodos para:
- Vender productos (reducir stock)
- Reponer productos (aumentar stock)
- Calcular valor total del stock
Luego crea una clase Inventario que maneje una colección de productos con métodos para:
- Agregar productos
- Buscar por nombre
- Listar productos con stock bajo (< 5 unidades)
Crea una clase base Empleado con nombre y salario base. Luego crea subclases:
Desarrollador: tiene un lenguaje de programación principal y bonus del 20%Gerente: tiene un equipo de empleados a cargo y bonus del 30%Director: hereda de Gerente con bonus del 50%
Cada clase debe tener un método calcular_salario() que devuelva el salario con bonus.
Crea una clase Rectangulo que implemente:
__init__con ancho y alto__str__para representación legible__eq__para comparar dos rectángulos__add__para “sumar” rectángulos (crea uno nuevo con áreas sumadas, manteniendo proporción)__lt__y__gt__para comparar por área- Propiedades
areayperimetro