Clases y programación orientada a objetos
"La OOP no es la única forma de programar. Pero es la forma más usada en software del mundo real, y la que tu primer empleo va a esperar que conozcas."
Qué vas a aprender en este capítulo
Tus programas hasta ahora son colecciones de funciones que comparten datos por argumentos. Eso funciona hasta que el sistema crece a 1000 líneas y se vuelve laberinto. La programación orientada a objetos (OOP) ofrece una alternativa: agrupar datos y funciones que los manipulan en clases, simulando objetos del mundo real. Vas a aprender a definir clases, instanciar objetos, aplicar herencia para reutilizar código, polimorfismo para escribir código flexible, y al final vas a refactorizar la pupusería entera con clases.
5.1 La idea: objetos que saben hacer cosas
💡 Intuición
Pensá en un carro. Tiene datos: marca, modelo, color, kilometraje. Y comportamientos: arrancar, frenar, acelerar.
Si lo programás con funciones sueltas:
carro1 = {"marca": "Toyota", "modelo": "Hilux", "km": 5000}
def arrancar(carro):
print(f"{carro['marca']} arrancó")
def acelerar(carro, kmh):
print(f"{carro['marca']} acelera a {kmh} km/h")
Funciona. Pero a medida que crece (varias decenas de funciones, varios tipos de carros), se vuelve disperso. Y vos como programador tenés que acordarte siempre de pasar el dict correcto a la función correcta.
Una clase es un molde que junta datos (atributos) y comportamientos (métodos) que actúan sobre esos datos.
class Carro:
def __init__(self, marca, modelo):
self.marca = marca
self.modelo = modelo
self.km = 0
def arrancar(self):
print(f"{self.marca} arrancó")
def acelerar(self, kmh):
print(f"{self.marca} acelera a {kmh} km/h")
mi_carro = Carro("Toyota", "Hilux")
mi_carro.arrancar()
mi_carro.acelerar(80)
mi_carro es un objeto — una instancia de la clase Carro. Lleva sus propios datos dentro y los métodos que lo manipulan están adentro de la clase, no sueltos.
Por qué importa:
- Cohesión. Lo que va junto, queda junto.
- Encapsulamiento. Detalles internos ocultos detrás de métodos.
- Reutilización. Crear
Camionheredando deVehiculo, no copiando código. - Modelo mental. Pensás en términos de "objetos" como en el mundo real.
5.2 Anatomía de una clase
📐 Fundamento
class Pupusa:
"""Representa una pupusa con sabor y precio."""
# --- atributo de clase (compartido por todas las instancias) ---
impuesto = 0.13
# --- constructor ---
def __init__(self, sabor, precio):
# atributos de instancia (uno por objeto)
self.sabor = sabor
self.precio = precio
# --- métodos de instancia ---
def precio_con_iva(self):
return self.precio * (1 + self.impuesto)
def __str__(self):
return f"Pupusa de {self.sabor} (${self.precio:.2f})"
Las piezas:
class Pupusa:— declaración. Convención: nombre de clase en CamelCase (Pupusa,MiClase,ProcesadorDePagos).- Docstring opcional pero recomendado.
__init__— el constructor. Se llama al crear:Pupusa("queso", 0.50)→ llama a__init__(self, "queso", 0.50).self— referencia al objeto actual. Primer parámetro de cada método, automáticamente.- Atributos de instancia:
self.algo = .... Cada objeto tiene los suyos. - Atributos de clase: definidos al nivel de la clase, compartidos. Buenos para constantes.
- Métodos: funciones definidas adentro de la clase. Reciben
self. __str__— método especial: lo que devuelvestr(obj)yprint(obj).
Crear y usar:
p = Pupusa("queso", 0.50)
print(p.sabor) # "queso"
print(p.precio_con_iva()) # 0.5650
print(p) # "Pupusa de queso ($0.50)"
p.metodo() es azúcar sintáctica para Pupusa.metodo(p). Por eso self aparece como primer parámetro.
Métodos especiales (dunder)
__init__, __str__ son métodos especiales (también llamados dunder methods — double underscore). Definirlos cambia cómo Python interactúa con tu objeto.
| Método | Cuándo se llama |
|---|---|
__init__ |
Al crear objeto |
__str__ |
str(obj), print(obj) |
__repr__ |
repr(obj), debug |
__eq__ |
obj1 == obj2 |
__lt__ |
obj1 < obj2 (y sorted) |
__len__ |
len(obj) |
__getitem__ |
obj[key] |
__add__ |
obj1 + obj2 |
__call__ |
obj() |
Implementarlos bien hace que tus objetos se sientan nativos en Python.
5.3 Encapsulamiento
📐 Fundamento
Encapsulamiento = los detalles internos de una clase no deberían depender de los detalles de fuera. La clase expone métodos y oculta datos privados.
Convenciones de visibilidad en Python:
| Convención | Significado |
|---|---|
nombre |
Público, libre de usar. |
_nombre |
"Privado por convención". Vos podés usarlo, pero indica "no lo toqués". |
__nombre |
"Privado más fuerte". Python le aplica name mangling — internamente se llama _ClaseActual__nombre. Más una protección que ocultar. |
Python no tiene private/public reales como Java o C++. El lenguaje confía en el programador. La filosofía: "Todos somos adultos consintientes".
class CuentaBancaria:
def __init__(self, saldo_inicial):
self._saldo = saldo_inicial # no toques esto desde afuera
def depositar(self, monto):
if monto <= 0:
raise ValueError("Monto debe ser positivo")
self._saldo += monto
def retirar(self, monto):
if monto > self._saldo:
raise ValueError("Saldo insuficiente")
self._saldo -= monto
def saldo(self):
return self._saldo
Por qué esconder _saldo: mañana podés cambiar la representación interna (un int → un Decimal, o guardar en disco) sin que nadie afuera tenga que cambiar nada.
Properties
A veces querés que un atributo se vea como atributo pero ejecute código:
class Carro:
def __init__(self, km):
self._km = km
@property
def km(self):
return self._km
@km.setter
def km(self, valor):
if valor < self._km:
raise ValueError("Los km no pueden bajar")
self._km = valor
c = Carro(0)
c.km = 100 # llama al setter
print(c.km) # llama al getter
c.km = 50 # ValueError
Útil para validar al setear o calcular al consultar. Mantenés sintaxis simple (c.km) con lógica detrás.
5.4 Herencia
📐 Fundamento
Herencia = una clase hereda atributos y métodos de otra. Modela la relación "X es un Y".
class Animal:
def __init__(self, nombre):
self.nombre = nombre
def comer(self):
print(f"{self.nombre} come")
def hablar(self):
print(f"{self.nombre} hace ruido")
class Perro(Animal):
def hablar(self):
# sobrescribe el método de Animal
print(f"{self.nombre} ladra: ¡guau!")
class Gato(Animal):
def hablar(self):
print(f"{self.nombre} maúlla: miau")
a = Perro("Rex")
a.comer() # "Rex come" (heredado de Animal)
a.hablar() # "Rex ladra: ¡guau!" (sobrescrito en Perro)
class Perro(Animal): — Perro hereda de Animal.
Beneficios:
- Reutilización: no escribís
comeren cada subclase. - Polimorfismo: podés tener una lista de animales y llamar
hablar()a cada uno; ejecuta el método específico de su clase real.
super()
Cuando una subclase quiere extender un método del padre (no reemplazarlo):
class PerroEntrenado(Perro):
def __init__(self, nombre, comandos):
super().__init__(nombre) # llama al __init__ del padre
self.comandos = comandos
def hablar(self):
super().hablar() # primero el ladrido normal
print(f" (sabe: {', '.join(self.comandos)})")
super() referencia a la clase padre. Pattern muy común en __init__: inicializás lo del padre y agregás lo tuyo.
Herencia múltiple
Python permite heredar de varias clases:
class Volador:
def volar(self):
print(f"{self.nombre} vuela")
class Pajaro(Animal, Volador):
pass
pato = Pajaro("Donald")
pato.comer() # de Animal
pato.volar() # de Volador
Cuidado. Herencia múltiple puede llevar al diamond problem (cuando dos padres tienen métodos del mismo nombre). Python lo resuelve con MRO (Method Resolution Order — algoritmo C3 linearization). Por defecto andá fácil — usá herencia múltiple solo cuando la jerarquía es clara.
5.5 Polimorfismo
📐 Fundamento
Polimorfismo = "muchas formas". El mismo código funciona con objetos de tipos distintos, siempre que respeten la misma interfaz.
animales = [Perro("Rex"), Gato("Mishi"), Pajaro("Donald")]
for a in animales:
a.hablar() # cada uno usa SU método
Duck typing — Python lo lleva al extremo. "If it walks like a duck and quacks like a duck, it's a duck."
class Robot:
def __init__(self, nombre):
self.nombre = nombre
def hablar(self):
print(f"{self.nombre}: BEEP")
# Robot NO hereda de Animal
animales = [Perro("Rex"), Robot("R2D2")]
for a in animales:
a.hablar() # funciona perfectamente
A Python no le importa el tipo, solo si tiene .hablar(). Esa flexibilidad es muy poderosa pero requiere disciplina (los errores aparecen en runtime, no compilando).
Para añadir rigor: Protocols (Python 3.8+) y abstract base classes (abc) declaran formalmente qué métodos debe tener un tipo. Lo dejamos para profundizar en la siguiente materia.
5.6 Atributos de clase vs instancia
⚠️ Trampa común
Atributos definidos a nivel de clase son compartidos por todas las instancias. Atributos definidos en __init__ (con self.x = ...) son propios de cada instancia.
class Contador:
total = 0 # de clase, compartido
def __init__(self):
self.local = 0 # de instancia
a = Contador()
b = Contador()
a.local += 1
print(a.local, b.local) # 1 0 (correcto, son distintos)
Contador.total += 1
print(a.total, b.total) # 1 1 (compartido)
Trampa: mutables como atributos de clase.
class Caja:
items = [] # ¡NO!
def agregar(self, x):
self.items.append(x)
a = Caja(); b = Caja()
a.agregar(1)
print(b.items) # [1] — porque comparten la lista
Arreglo: inicializar lista en __init__.
class Caja:
def __init__(self):
self.items = []
Ahora cada caja tiene su propia lista.
5.7 Dataclasses
📐 Fundamento
Para clases que son principalmente contenedores de datos, hay una forma más limpia: @dataclass.
from dataclasses import dataclass
@dataclass
class Producto:
nombre: str
precio: float
stock: int = 0 # default opcional
p = Producto("Pupusa de queso", 0.50, 100)
print(p) # Producto(nombre='Pupusa...', precio=0.5, stock=100)
print(p == Producto("Pupusa de queso", 0.50, 100)) # True
@dataclass genera automáticamente:
__init__con los campos.__repr__legible.__eq__que compara por valor.- Opcionalmente:
__hash__,<, etc.
Equivalente a esto, escrito a mano:
class Producto:
def __init__(self, nombre, precio, stock=0):
self.nombre = nombre
self.precio = precio
self.stock = stock
def __repr__(self):
return f"Producto({self.nombre!r}, {self.precio}, {self.stock})"
def __eq__(self, other):
return (self.nombre, self.precio, self.stock) == (other.nombre, ...)
Mucho más limpio con @dataclass. Para entidades simples (Producto, Cliente, Pedido, Coordenada) — usá dataclasses.
Opciones útiles:
@dataclass(frozen=True) # inmutable; hashable; no podés modificar campos
@dataclass(order=True) # genera <, >, etc.
5.8 Métodos de clase y estáticos
📐 Fundamento
Aparte de los métodos de instancia (con self), hay otros tipos:
class Pupusa:
impuesto = 0.13
def __init__(self, sabor, precio):
self.sabor = sabor
self.precio = precio
@classmethod
def desde_string(cls, s):
"""Constructor alternativo: 'queso:0.50' → Pupusa('queso', 0.50)"""
sabor, precio = s.split(":")
return cls(sabor, float(precio))
@staticmethod
def es_sabor_valido(sabor):
"""No usa self ni cls."""
return sabor in {"queso", "revuelta", "frijol", "chicharron"}
p = Pupusa.desde_string("queso:0.50") # constructor alternativo
Pupusa.es_sabor_valido("queso") # True (sin instancia)
@classmethod: recibecls(la clase) en vez deself. Útil para constructores alternativos o cuando la operación es de la clase, no del objeto.@staticmethod: no recibe niselfnicls. Es como una función "encerrada" en la clase por organización.
Convención: si tu staticmethod no usa nada de la clase, probablemente debería ser una función suelta, no un método.
5.9 Los cuatro pilares de OOP
📐 Fundamento
Tradicionalmente la OOP se resume en cuatro principios:
- Encapsulamiento. Datos y operaciones que los manipulan, juntos. Detalles internos ocultos.
- Herencia. Reutilizar código mediante "es un". Cuidado con jerarquías profundas — son frágiles.
- Polimorfismo. Mismo código, distintos comportamientos según el tipo.
- Abstracción. Modelar las cosas con la complejidad necesaria y ocultar el resto.
Crítica moderna. OOP fue dogma en los 90-2000. Hoy se reconoce que:
- Composición > herencia. Tener un objeto adentro de otro suele ser mejor que heredar de él.
- Inmutabilidad simplifica concurrencia. Programación funcional ganó terreno.
- No todo es objeto. Funciones puras, dataclasses simples, módulos — a veces alcanzan.
Conclusión pragmática: usá OOP cuando el problema lo justifique. No metas clases para procesos lineales simples — un script con funciones es más legible.
5.10 Proyecto: pupusería como objetos
🏗️ Avance del proyecto
Refactorizamos por última vez. Toda la lógica vuelve a vivir como objetos que se conocen entre sí.
# pupuseria_v5.py - OOP completo
import json
from pathlib import Path
from datetime import datetime
from dataclasses import dataclass, field, asdict
ARCHIVO_MENU = Path("menu.json")
ARCHIVO_PEDIDOS = Path("pedidos.json")
# ---------------------- excepciones ----------------------
class CodigoInvalido(Exception):
pass
class StockInsuficiente(Exception):
def __init__(self, codigo, pedido, disponible):
super().__init__(f"{codigo}: pedidas {pedido}, hay {disponible}")
self.codigo, self.pedido, self.disponible = codigo, pedido, disponible
# ---------------------- modelos ----------------------
@dataclass
class Producto:
codigo: str
nombre: str
precio: float
stock: int = 0
def descontar(self, cantidad: int) -> None:
if cantidad > self.stock:
raise StockInsuficiente(self.codigo, cantidad, self.stock)
self.stock -= cantidad
def importe(self, cantidad: int) -> float:
return self.precio * cantidad
@dataclass
class LineaPedido:
producto: Producto
cantidad: int
@property
def importe(self) -> float:
return self.producto.importe(self.cantidad)
@dataclass
class Pedido:
fecha: str = field(default_factory=lambda: datetime.now().isoformat(timespec="seconds"))
lineas: list = field(default_factory=list)
iva: float = 0.13
def agregar(self, linea: LineaPedido) -> None:
self.lineas.append(linea)
@property
def subtotal(self) -> float:
return sum(l.importe for l in self.lineas)
@property
def impuesto(self) -> float:
return self.subtotal * self.iva
@property
def total(self) -> float:
return self.subtotal + self.impuesto
def imprimir(self) -> None:
print(f"\n=== Pedido {self.fecha} ===")
for l in self.lineas:
print(f" {l.cantidad:3}× {l.producto.nombre:25} ${l.importe:>6.2f}")
print(f" Subtotal{'':30} ${self.subtotal:>6.2f}")
print(f" IVA (13%){'':29} ${self.impuesto:>6.2f}")
print(f" TOTAL{'':33} ${self.total:>6.2f}")
# ---------------------- pupusería ----------------------
class Pupuseria:
"""Orquesta el catálogo, los pedidos y la persistencia."""
def __init__(self, archivo_menu: Path, archivo_pedidos: Path):
self.archivo_menu = archivo_menu
self.archivo_pedidos = archivo_pedidos
self.productos = self._cargar_menu()
# ---- persistencia ----
def _cargar_menu(self) -> dict[str, Producto]:
try:
data = json.loads(self.archivo_menu.read_text(encoding="utf-8"))
return {c: Producto(**p) for c, p in data.items()}
except FileNotFoundError:
default = self._default_menu()
self._guardar_menu(default)
return default
except (json.JSONDecodeError, TypeError) as e:
raise RuntimeError(f"Menu corrupto: {e}")
@staticmethod
def _default_menu() -> dict[str, Producto]:
return {
"Q": Producto("Q", "Pupusa de queso", 0.50, 100),
"R": Producto("R", "Pupusa revuelta", 0.60, 80),
"F": Producto("F", "Pupusa de frijol", 0.55, 60),
}
def _guardar_menu(self, productos: dict[str, Producto]) -> None:
data = {c: asdict(p) for c, p in productos.items()}
self.archivo_menu.write_text(
json.dumps(data, indent=2, ensure_ascii=False),
encoding="utf-8",
)
def guardar(self) -> None:
self._guardar_menu(self.productos)
def registrar_pedido(self, pedido: Pedido) -> None:
historial = []
try:
historial = json.loads(self.archivo_pedidos.read_text(encoding="utf-8"))
except (FileNotFoundError, json.JSONDecodeError):
pass
historial.append({
"fecha": pedido.fecha,
"lineas": [
{"codigo": l.producto.codigo, "cantidad": l.cantidad,
"importe": l.importe}
for l in pedido.lineas
],
"subtotal": pedido.subtotal,
"iva": pedido.impuesto,
"total": pedido.total,
})
self.archivo_pedidos.write_text(
json.dumps(historial, indent=2, ensure_ascii=False),
encoding="utf-8",
)
# ---- venta ----
def vender(self, codigo: str, cantidad: int) -> LineaPedido:
if codigo not in self.productos:
raise CodigoInvalido(f"No existe el código '{codigo}'")
producto = self.productos[codigo]
producto.descontar(cantidad)
return LineaPedido(producto, cantidad)
def imprimir_menu(self) -> None:
print("\n=== Menú ===")
for codigo, p in self.productos.items():
print(f" [{codigo:3}] {p.nombre:25} ${p.precio:.2f} stock: {p.stock}")
print()
# ---------------------- programa principal ----------------------
def main():
pup = Pupuseria(ARCHIVO_MENU, ARCHIVO_PEDIDOS)
pup.imprimir_menu()
pedido = Pedido()
while True:
codigo = input("Código (o 'fin'): ").strip().upper()
if codigo == "FIN":
break
cantidad_s = input(" Cantidad: ").strip()
try:
cantidad = int(cantidad_s)
linea = pup.vender(codigo, cantidad)
except CodigoInvalido as e:
print(f" ✗ {e}")
continue
except StockInsuficiente as e:
print(f" ✗ {e}")
continue
except ValueError:
print(" ✗ Cantidad inválida")
continue
pedido.agregar(linea)
print(f" ✓ Subtotal hasta acá: ${pedido.subtotal:.2f}")
if not pedido.lineas:
print("Sin pedido.")
return
pedido.imprimir()
try:
pup.guardar()
pup.registrar_pedido(pedido)
print("\n✓ Guardado")
except OSError as e:
print(f"\n✗ Error al guardar: {e}")
if __name__ == "__main__":
main()
Comparación con la versión anterior:
Producto,LineaPedido,Pedidoson dataclasses. Cada una sabe lo suyo.Pupuseriaorquesta todo. Su API pública es chica:vender,guardar,imprimir_menu. Lo demás es interno (_cargar_menu,_guardar_menu).- El programa principal
maines ahora un guión que cuenta una historia: "creá una pupusería, mostrá menú, recibí pedidos, registrá". - Las excepciones de dominio (
CodigoInvalido,StockInsuficiente) viajan limpias, no como tuplas oNone.
Esa es la magnitud del cambio que la OOP te da en código de tamaño mediano. Si querés agregar mañana una clase Promocion, la metés sin reescribir todo.
5.11 Resumen visual
| Concepto | Una línea |
|---|---|
| Clase | Molde para crear objetos |
| Objeto / instancia | Una "ejemplar" de la clase |
__init__ |
Constructor |
self |
El objeto actual |
| Atributo de clase | Compartido por todos |
| Atributo de instancia | Propio de cada uno |
| Encapsulamiento | Ocultar detalles, exponer interfaz |
_x / __x |
Convenciones de privacidad |
| Property | Atributo computado / validado |
| Herencia | class Hija(Padre): |
super() |
Llamar al padre |
| Polimorfismo | Mismo método, distintos tipos |
@dataclass |
Clase auto-generada para datos |
@classmethod, @staticmethod |
Métodos sin instancia |
5.12 Ejercicios
✏️ Ejercicio 5.1 — Clase básica
Definí una clase Rectangulo con atributos ancho y alto, y métodos area() y perimetro(). Probala.
Solución
class Rectangulo:
def __init__(self, ancho, alto):
self.ancho = ancho
self.alto = alto
def area(self):
return self.ancho * self.alto
def perimetro(self):
return 2 * (self.ancho + self.alto)
r = Rectangulo(4, 5)
print(r.area()) # 20
print(r.perimetro()) # 18
✏️ Ejercicio 5.2 — Herencia
Hacé Cuadrado heredando de Rectangulo. El constructor recibe solo lado.
Solución
class Cuadrado(Rectangulo):
def __init__(self, lado):
super().__init__(lado, lado)
c = Cuadrado(5)
print(c.area()) # 25
(Históricamente, "Cuadrado hereda de Rectángulo" es un ejemplo controversial — porque cambia las invariantes. Si después agregás un setter de ancho que actualiza solo ese lado, rompe la cuadratidad. Pero como ejemplo de sintaxis funciona.)
✏️ Ejercicio 5.3 — Dataclass
Definí dataclass Estudiante con nombre, carrera y notas (lista). Agregá método promedio(). Intentá poner dos Estudiante con mismos datos en un set — ¿qué pasa?
Solución
from dataclasses import dataclass, field
@dataclass
class Estudiante:
nombre: str
carrera: str
notas: list = field(default_factory=list)
def promedio(self):
if not self.notas:
return 0
return sum(self.notas) / len(self.notas)
a = Estudiante("Ana", "ISC", [7, 8, 9])
print(a.promedio()) # 8.0
b = Estudiante("Ana", "ISC", [7, 8, 9])
print(a == b) # True (dataclass genera __eq__)
# set([a, b]) # TypeError: unhashable type
Por defecto @dataclass no genera __hash__ (porque sería confuso con mutables). Si querés hashable y comparable: @dataclass(frozen=True).
✏️ Ejercicio 5.4 — Polimorfismo
Definí una jerarquía de figuras: Figura (abstracta), Circulo, Rectangulo, Triangulo. Cada una con area(). Probá un loop que calcula el área total de una lista mixta.
Solución
from abc import ABC, abstractmethod
import math
class Figura(ABC):
@abstractmethod
def area(self):
pass
class Circulo(Figura):
def __init__(self, radio):
self.radio = radio
def area(self):
return math.pi * self.radio ** 2
class Rectangulo(Figura):
def __init__(self, ancho, alto):
self.ancho, self.alto = ancho, alto
def area(self):
return self.ancho * self.alto
class Triangulo(Figura):
def __init__(self, base, altura):
self.base, self.altura = base, altura
def area(self):
return self.base * self.altura / 2
figuras = [Circulo(5), Rectangulo(3, 4), Triangulo(6, 8)]
total = sum(f.area() for f in figuras)
print(total)
abstractmethod impide instanciar Figura directamente y obliga a las hijas a implementar area().
5.13 Cierre del libro
Llegaste al final de Programación II. Repaso:
- Listas y tuplas — agrupar datos del mismo "tipo".
- Diccionarios y conjuntos — mapeos clave→valor con búsqueda rápida.
- Recursión — funciones que se llaman a sí mismas.
- Archivos y excepciones — persistencia y manejo de errores.
- Clases y OOP — organizar código grande con objetos.
Con esto podés escribir programas reales. No "juguetes", sino aplicaciones de cientos o miles de líneas, con datos persistentes, manejo de errores y diseño limpio.
El siguiente paso natural es:
- Estructuras de Datos y Algoritmos (PRO315): listas enlazadas, árboles, grafos, complejidad. La teoría detrás de las estructuras.
- Bases de Datos I (BDD315): persistencia seria, SQL, transacciones.
- Análisis y Diseño de Sistemas (ADS415): cómo diseñar antes de codificar — UML, patrones, requerimientos.
5.14 Para profundizar
- Fluent Python (Luciano Ramalho), capítulos sobre clases, dunder methods, decoradores. Avanzado pero genial.
- Python Tricks (Dan Bader). Capítulos cortos y útiles sobre OOP en Python.
- Documentación oficial: https://docs.python.org/es/3/tutorial/classes.html
Definiciones nuevas: clase, instancia, objeto, atributo, método, self, __init__, encapsulamiento, herencia, super(), polimorfismo, dataclass, classmethod, staticmethod, property, dunder method, abstract base class, duck typing.