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 Camion heredando de Vehiculo, 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 devuelve str(obj) y print(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 methodsdouble 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:

  1. Reutilización: no escribís comer en cada subclase.
  2. 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: recibe cls (la clase) en vez de self. Útil para constructores alternativos o cuando la operación es de la clase, no del objeto.
  • @staticmethod: no recibe ni self ni cls. 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:

  1. Encapsulamiento. Datos y operaciones que los manipulan, juntos. Detalles internos ocultos.
  2. Herencia. Reutilizar código mediante "es un". Cuidado con jerarquías profundas — son frágiles.
  3. Polimorfismo. Mismo código, distintos comportamientos según el tipo.
  4. 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, Pedido son dataclasses. Cada una sabe lo suyo.
  • Pupuseria orquesta todo. Su API pública es chica: vender, guardar, imprimir_menu. Lo demás es interno (_cargar_menu, _guardar_menu).
  • El programa principal main es 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 o None.

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.

✏️ Ejercicio 5.2 — Herencia

Hacé Cuadrado heredando de Rectangulo. El constructor recibe solo lado.

✏️ 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?

✏️ 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.

5.13 Cierre del libro

Llegaste al final de Programación II. Repaso:

  1. Listas y tuplas — agrupar datos del mismo "tipo".
  2. Diccionarios y conjuntos — mapeos clave→valor con búsqueda rápida.
  3. Recursión — funciones que se llaman a sí mismas.
  4. Archivos y excepciones — persistencia y manejo de errores.
  5. 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:

5.14 Para profundizar


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.