trubidata
Lección 11 de 12·30 min

Programación orientada a objetos

En esta guía (15)

Hasta ahora representaste los datos con diccionarios y tuplas, y la lógica con funciones sueltas. En esta lección vas a aprender a crear tus propios tipos con clases: juntar en un mismo lugar los datos de algo (una cuenta, un producto, una tarea) y las operaciones que tienen sentido sobre esos datos. Es la base de la programación orientada a objetos (POO), y la vas a usar en el proyecto final.

Por qué clases

Imaginá que representás una cuenta bancaria con un diccionario:

python
cuenta = {"titular": "Ana", "saldo": 1000}
cuenta["saldo"] -= 5000   # nada impide dejar el saldo negativo
cuenta["sado"] = 200      # un error de tipeo crea una clave nueva sin avisar

Las funciones que operan sobre la cuenta quedan sueltas, y nada garantiza que todos los diccionarios "cuenta" tengan las mismas claves. Una clase define un tipo con una estructura fija y sus propias operaciones:

  • La clase es el molde: dice qué datos tiene y qué se puede hacer.
  • Un objeto (o instancia) es cada cosa concreta creada con ese molde.

Ya usaste objetos todo el tiempo: "hola" es una instancia de la clase str, y .upper() es una operación definida en esa clase.

Definir una clase

python
class Cuenta:
    def __init__(self, titular: str, saldo: float = 0):
        self.titular = titular
        self.saldo = saldo

    def depositar(self, monto: float) -> None:
        self.saldo += monto

    def extraer(self, monto: float) -> None:
        if monto > self.saldo:
            raise ValueError("Saldo insuficiente")
        self.saldo -= monto


cuenta_ana = Cuenta("Ana", 1000)
cuenta_bruno = Cuenta("Bruno")

cuenta_ana.depositar(500)
cuenta_ana.extraer(200)
print(cuenta_ana.titular, cuenta_ana.saldo)    # Ana 1300
print(cuenta_bruno.titular, cuenta_bruno.saldo)  # Bruno 0

Vamos por partes:

  • class Cuenta: define la clase. Por convención, los nombres de clase van en PascalCase (cada palabra con mayúscula, sin guiones bajos): Cuenta, ProductoDigital.
  • __init__ es el inicializador: se ejecuta automáticamente cuando creás un objeto con Cuenta(...). Ahí se cargan los datos iniciales.
  • self es el objeto mismo. Python lo pasa solo como primer argumento: cuando escribís cuenta_ana.depositar(500), adentro del método self es cuenta_ana y monto es 500.
  • self.titular = titular crea un atributo: un dato guardado dentro del objeto. Cada objeto tiene los suyos: el saldo de Ana y el de Bruno son independientes.
  • depositar y extraer son métodos: funciones definidas dentro de la clase que operan sobre self.

Todo lo que aprendiste de funciones (parámetros por defecto, type hints, docstrings, raise) vale para los métodos.

Atributos de clase y atributos de instancia

Los atributos que se crean con self.algo = ... son de instancia: cada objeto tiene el suyo. También podés definir variables directamente dentro de la clase, fuera de los métodos: esos son atributos de clase, y los comparten todos los objetos.

python
class Socio:
    club = "Club Atlético del Barrio"   # atributo de clase: igual para todos
    cantidad = 0                        # contador compartido

    def __init__(self, nombre: str, edad: int):
        self.nombre = nombre            # atributos de instancia: uno por objeto
        self.edad = edad
        Socio.cantidad += 1

    def presentarse(self) -> str:
        return f"Soy {self.nombre}, tengo {self.edad} años y soy socio de {self.club}"


s1 = Socio("Ana", 29)
s2 = Socio("Bruno", 41)
print(s1.presentarse())
print(s2.presentarse())
print(Socio.cantidad)
text
Soy Ana, tengo 29 años y soy socio de Club Atlético del Barrio
Soy Bruno, tengo 41 años y soy socio de Club Atlético del Barrio
2

Para modificar un atributo de clase, usá el nombre de la clase (Socio.cantidad += 1). Si escribieras self.cantidad += 1, crearías un atributo de instancia nuevo en ese objeto y el contador compartido no cambiaría. Usá atributos de clase solo para datos que de verdad son comunes a todos; casi todo lo demás va en __init__.

Un objeto con estado: una sesión de usuario

Un objeto recuerda cosas entre una llamada y otra. Este usuario sabe si está conectado y cuántos intentos de contraseña le quedan:

python
class Usuario:
    def __init__(self, nombre: str, clave: str, intentos: int = 3):
        self.nombre = nombre
        self._clave = clave
        self.conectado = False
        self.intentos_restantes = intentos

    def conectar(self, clave: str) -> bool:
        """Intenta iniciar sesión. Devuelve True si la clave es correcta."""
        if self.intentos_restantes == 0:
            raise PermissionError("Usuario bloqueado por demasiados intentos")
        if clave == self._clave:
            self.conectado = True
            return True
        self.intentos_restantes -= 1
        return False

    def desconectar(self) -> None:
        self.conectado = False

    def __str__(self) -> str:
        estado = "conectado" if self.conectado else "desconectado"
        return f"{self.nombre} ({estado})"


usuario = Usuario("ana", "Clave2026")
for intento in ["clave2026", "Clave2026"]:
    if usuario.conectar(intento):
        print("Sesión iniciada")
    else:
        print(f"Clave incorrecta. Quedan {usuario.intentos_restantes} intentos")
print(usuario)
text
Clave incorrecta. Quedan 2 intentos
Sesión iniciada
ana (conectado)

Tres decisiones de diseño que conviene copiar:

  • La clase no pide datos con input(). Recibe la clave como parámetro y devuelve True o False. Así se puede usar desde la terminal, desde un test o desde otra interfaz, y quien la llama decide cómo pedir el dato y qué mostrar.
  • Los reintentos se manejan con un bucle afuera, no haciendo que conectar se llame a sí misma: es más fácil de seguir y no tiene límite de profundidad.
  • estado = "conectado" if self.conectado else "desconectado" es un if en una línea (expresión condicional): elige entre dos valores según una condición. Úsala solo para casos así de simples.

Un aviso: guardar contraseñas tal cual, como en este ejemplo, sirve para practicar pero nunca se hace en un sistema real. Ahí se guarda un hash de la contraseña, con librerías pensadas para eso.

str y repr: cómo se muestra un objeto

Si imprimís un objeto sin más, Python muestra algo poco útil:

text
>>> print(cuenta_ana)
<__main__.Cuenta object at 0x104f2a5d0>

Dos métodos especiales controlan cómo se muestra:

  • __str__: el texto "lindo", para el usuario. Lo usan print() y las f-strings.
  • __repr__: el texto "técnico", para quien programa. Lo usa el REPL y aparece cuando el objeto está dentro de una lista. Idealmente, se parece al código que crea el objeto.
python
class Producto:
    def __init__(self, nombre: str, precio: float):
        self.nombre = nombre
        self.precio = precio

    def __str__(self) -> str:
        return f"{self.nombre} (${self.precio:,.2f})"

    def __repr__(self) -> str:
        return f"Producto({self.nombre!r}, {self.precio!r})"


yerba = Producto("Yerba", 3200)
print(yerba)              # Yerba ($3,200.00)
print(f"Compré {yerba}")  # Compré Yerba ($3,200.00)
print([yerba])            # [Producto('Yerba', 3200)]

El !r dentro de la f-string usa el repr del valor: por eso el nombre aparece con comillas. Los métodos con doble guion bajo al principio y al final (__init__, __str__, __repr__) se llaman métodos especiales (o dunder): Python los llama solo en ciertos momentos.

Encapsulamiento por convención

En la clase Cuenta, nada impide hacer cuenta_ana.saldo = -999 desde afuera y saltear la validación de extraer. Python no tiene atributos realmente privados, pero hay una convención clara: un nombre que empieza con guion bajo es interno, y no se toca desde afuera de la clase.

Para dejar leer un valor pero no modificarlo directamente, se combina con @property, que hace que un método se use como si fuera un atributo:

python
class Cuenta:
    def __init__(self, titular: str, saldo: float = 0):
        if saldo < 0:
            raise ValueError("El saldo inicial no puede ser negativo")
        self.titular = titular
        self._saldo = saldo          # interno: solo se cambia con los métodos

    @property
    def saldo(self) -> float:
        return self._saldo

    def depositar(self, monto: float) -> None:
        if monto <= 0:
            raise ValueError("El monto tiene que ser positivo")
        self._saldo += monto

    def extraer(self, monto: float) -> None:
        if monto <= 0:
            raise ValueError("El monto tiene que ser positivo")
        if monto > self._saldo:
            raise ValueError("Saldo insuficiente")
        self._saldo -= monto


cuenta = Cuenta("Ana", 1000)
cuenta.depositar(250)
print(cuenta.saldo)    # 1250 (se lee como atributo, sin paréntesis)

Si intentás cuenta.saldo = 5000, da AttributeError, porque saldo es una propiedad de solo lectura. La idea del encapsulamiento: el objeto se encarga de mantener sus datos válidos, y el resto del programa lo usa a través de sus métodos.

Herencia

La herencia permite crear una clase a partir de otra: la clase hija recibe todos los atributos y métodos de la madre, y puede agregar o cambiar lo que necesite.

python
class Empleado:
    def __init__(self, nombre: str, sueldo_base: float):
        self.nombre = nombre
        self.sueldo_base = sueldo_base

    def sueldo(self) -> float:
        return self.sueldo_base

    def __str__(self) -> str:
        return f"{self.nombre}: ${self.sueldo():,.0f}"


class Vendedor(Empleado):
    def __init__(self, nombre: str, sueldo_base: float, ventas: float):
        super().__init__(nombre, sueldo_base)
        self.ventas = ventas

    def sueldo(self) -> float:
        return self.sueldo_base + self.ventas * 0.05


equipo = [Empleado("Ana", 900_000), Vendedor("Bruno", 700_000, 5_000_000)]
for persona in equipo:
    print(persona)
text
Ana: $900,000
Bruno: $950,000
  • class Vendedor(Empleado): indica que Vendedor hereda de Empleado.
  • super().__init__(...) llama al __init__ de la clase madre para que cargue los atributos comunes; después la hija agrega los suyos.
  • Vendedor redefine (override) el método sueldo. __str__ no lo redefine, así que usa el de Empleado... que a su vez llama a self.sueldo(), y como self es un Vendedor, se usa la versión del vendedor.

Eso último es muy potente: el bucle trata a todos como empleados y cada uno calcula su sueldo a su manera. isinstance(objeto, Clase) te dice si un objeto es de una clase (o de una hija): isinstance(equipo[1], Empleado) es True.

La herencia sirve cuando la relación es "es un": un vendedor es un empleado. No la uses solo para reaprovechar código.

Composición

Muchas veces la relación no es "es un" sino "tiene": un carrito tiene productos, una biblioteca tiene libros. Eso se modela con composición: un objeto guarda otros objetos como atributos.

python
class Producto:
    def __init__(self, nombre: str, precio: float):
        self.nombre = nombre
        self.precio = precio


class Carrito:
    def __init__(self):
        self._items: list[tuple[Producto, int]] = []

    def agregar(self, producto: Producto, cantidad: int = 1) -> None:
        self._items.append((producto, cantidad))

    def total(self) -> float:
        return sum(producto.precio * cantidad for producto, cantidad in self._items)

    def __len__(self) -> int:
        return sum(cantidad for _, cantidad in self._items)


carrito = Carrito()
carrito.agregar(Producto("Yerba", 3200), 2)
carrito.agregar(Producto("Café", 4500))
print(len(carrito))      # 3
print(carrito.total())   # 10900

Carrito no hereda de nada: contiene productos y delega en ellos (cada uno sabe su precio). __len__ es otro método especial: hace que len(carrito) funcione.

Ante la duda entre herencia y composición, empezá por composición: es más flexible y más fácil de cambiar.

Dataclasses

Muchas clases son, sobre todo, contenedores de datos: un __init__ que copia parámetros a atributos y un __repr__. El módulo dataclasses de la biblioteca estándar escribe ese código por vos:

python
from dataclasses import dataclass, field


@dataclass
class Tarea:
    titulo: str
    prioridad: int = 2
    hecha: bool = False
    etiquetas: list[str] = field(default_factory=list)

    def completar(self) -> None:
        self.hecha = True


t1 = Tarea("Pagar la luz", prioridad=1)
t2 = Tarea("Ordenar el placard", etiquetas=["casa"])
t1.completar()

print(t1)   # Tarea(titulo='Pagar la luz', prioridad=1, hecha=True, etiquetas=[])
print(t2)   # Tarea(titulo='Ordenar el placard', prioridad=2, hecha=False, etiquetas=['casa'])
print(t1 == Tarea("Pagar la luz", 1, True))   # True
  • @dataclass arriba de la clase es un decorador: le agrega comportamiento automáticamente. Genera __init__, __repr__ y __eq__ (comparar con == por contenido).
  • Los atributos se declaran con su tipo (los type hints acá son obligatorios: así la dataclass sabe qué campos hay).
  • Los campos con valor por defecto van después de los que no tienen.
  • Para listas o diccionarios como valor por defecto se usa field(default_factory=list): es la misma trampa de los valores por defecto mutables de la lección 7, resuelta.
  • Podés agregar los métodos que quieras, como completar.

Con order=True (@dataclass(order=True)) también podés ordenar objetos con sorted, comparando campo por campo en el orden en que están declarados.

Para validar datos al crear el objeto, la dataclass permite definir __post_init__, que se ejecuta justo después del __init__ generado:

python
from dataclasses import dataclass


@dataclass
class Gasto:
    descripcion: str
    monto: float

    def __post_init__(self) -> None:
        if self.monto <= 0:
            raise ValueError("El monto tiene que ser positivo")


print(Gasto("café", 2500))   # Gasto(descripcion='café', monto=2500)
try:
    Gasto("error", -10)
except ValueError as e:
    print(e)                 # El monto tiene que ser positivo

Vas a usar exactamente esto en el proyecto final.

Los cuatro pilares

Es común que la POO se resuma en cuatro ideas. Ya las viste todas en esta lección:

  • Abstracción: representar solo lo que importa de algo. La Cuenta tiene titular y saldo, no el color de la tarjeta.
  • Encapsulamiento: el objeto protege sus datos y se usa a través de sus métodos (_saldo y @property).
  • Herencia: una clase se construye a partir de otra (Vendedor a partir de Empleado).
  • Polimorfismo: objetos de clases distintas responden al mismo método, cada uno a su manera (sueldo() en el bucle del equipo).

Cuándo usar clases

  • Diccionario: datos simples y temporales, o que vienen y van a JSON.
  • Dataclass: un registro con campos fijos que se usa en varios lugares del programa (un gasto, una tarea, un producto).
  • Clase común: cuando el objeto tiene que proteger sus datos (como la cuenta) o tiene comportamiento importante.
  • Funciones sueltas: cuando no hay datos que agrupar. No todo tiene que ser una clase.

Errores comunes

  • Olvidar self en la definición de un método. def depositar(monto): da TypeError: depositar() takes 1 positional argument but 2 were given, porque Python igual pasa el objeto como primer argumento.
  • Olvidar self. al usar un atributo. Dentro de un método, saldo a secas es una variable local; el atributo es self.saldo.
  • Escribir __init__ con un solo guion bajo (_init_) o mal escrito. No da error al definir, pero no se ejecuta al crear el objeto.
  • Llamar a la clase sin paréntesis. c = Cuenta no crea un objeto: c pasa a ser la clase misma.
  • Olvidar super().__init__(...) en la clase hija. Los atributos de la clase madre no se crean y aparece un AttributeError más adelante.
  • Usar herencia para todo. Si la relación es "tiene", usá composición.
  • Lista como valor por defecto en una dataclass. Da ValueError: mutable default ... is not allowed. Usá field(default_factory=list).

Practicá

  1. Escribí una clase Rectangulo con base y altura, métodos area() y perimetro(), un método es_cuadrado() y un __str__ que muestre algo como Rectángulo 3 x 4.
  2. Partiendo de la Cuenta con _saldo y @property, agregá un historial: cada depósito y extracción se registra como una tupla ("depósito", monto) o ("extracción", monto) en una lista interna, y un método movimientos() devuelve una copia de esa lista.
  3. Creá una clase Vehiculo con marca y un método descripcion(). Creá dos hijas: Auto, con puertas, y Bicicleta, con rodado, cada una con su propia descripcion() que reutilice la de la madre (pista: super().descripcion()).
  4. Con dataclasses, modelá una Biblioteca que tiene Libros (titulo, autor, prestado: bool = False). La biblioteca tiene que permitir agregar libros, prestar un libro por título (lanzando ValueError si no existe o ya está prestado) y listar los disponibles.

Soluciones

Ejercicio 1:

python
class Rectangulo:
    def __init__(self, base: float, altura: float):
        self.base = base
        self.altura = altura

    def area(self) -> float:
        return self.base * self.altura

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

    def es_cuadrado(self) -> bool:
        return self.base == self.altura

    def __str__(self) -> str:
        return f"Rectángulo {self.base} x {self.altura}"


r = Rectangulo(3, 4)
print(r, r.area(), r.perimetro(), r.es_cuadrado())
text
Rectángulo 3 x 4 12 14 False

Ejercicio 2:

python
class Cuenta:
    def __init__(self, titular: str, saldo: float = 0):
        self.titular = titular
        self._saldo = saldo
        self._movimientos: list[tuple[str, float]] = []

    @property
    def saldo(self) -> float:
        return self._saldo

    def depositar(self, monto: float) -> None:
        if monto <= 0:
            raise ValueError("El monto tiene que ser positivo")
        self._saldo += monto
        self._movimientos.append(("depósito", monto))

    def extraer(self, monto: float) -> None:
        if monto <= 0:
            raise ValueError("El monto tiene que ser positivo")
        if monto > self._saldo:
            raise ValueError("Saldo insuficiente")
        self._saldo -= monto
        self._movimientos.append(("extracción", monto))

    def movimientos(self) -> list[tuple[str, float]]:
        return self._movimientos.copy()


c = Cuenta("Ana", 1000)
c.depositar(500)
c.extraer(300)
print(c.saldo)         # 1200
print(c.movimientos()) # [('depósito', 500), ('extracción', 300)]

movimientos() devuelve una copia: si devolviera la lista interna, quien la recibe podría modificarla y romper el historial.

Ejercicio 3:

python
class Vehiculo:
    def __init__(self, marca: str):
        self.marca = marca

    def descripcion(self) -> str:
        return f"Vehículo marca {self.marca}"


class Auto(Vehiculo):
    def __init__(self, marca: str, puertas: int):
        super().__init__(marca)
        self.puertas = puertas

    def descripcion(self) -> str:
        return f"{super().descripcion()}, auto de {self.puertas} puertas"


class Bicicleta(Vehiculo):
    def __init__(self, marca: str, rodado: int):
        super().__init__(marca)
        self.rodado = rodado

    def descripcion(self) -> str:
        return f"{super().descripcion()}, bicicleta rodado {self.rodado}"


for v in [Auto("Andina", 5), Bicicleta("Montaña", 29)]:
    print(v.descripcion())
text
Vehículo marca Andina, auto de 5 puertas
Vehículo marca Montaña, bicicleta rodado 29

Ejercicio 4:

python
from dataclasses import dataclass, field


@dataclass
class Libro:
    titulo: str
    autor: str
    prestado: bool = False


@dataclass
class Biblioteca:
    libros: list[Libro] = field(default_factory=list)

    def agregar(self, libro: Libro) -> None:
        self.libros.append(libro)

    def prestar(self, titulo: str) -> None:
        for libro in self.libros:
            if libro.titulo.lower() == titulo.lower():
                if libro.prestado:
                    raise ValueError(f"'{libro.titulo}' ya está prestado")
                libro.prestado = True
                return
        raise ValueError(f"No existe '{titulo}'")

    def disponibles(self) -> list[Libro]:
        return [libro for libro in self.libros if not libro.prestado]


bib = Biblioteca()
bib.agregar(Libro("El río", "Autora Uno"))
bib.agregar(Libro("La montaña", "Autor Dos"))
bib.prestar("el río")

for titulo in ["El río", "El mar"]:
    try:
        bib.prestar(titulo)
    except ValueError as e:
        print(e)

print([libro.titulo for libro in bib.disponibles()])
text
'El río' ya está prestado
No existe 'El mar'
['La montaña']

Resumen

  • Una clase es un molde; cada objeto creado con ella es una instancia con sus propios atributos.
  • __init__ inicializa el objeto; self es el objeto mismo y va como primer parámetro de cada método.
  • __str__ define cómo lo muestra print; __repr__, cómo se ve en el REPL y dentro de listas.
  • Un guion bajo al principio (_saldo) marca un atributo interno; @property permite leerlo sin modificarlo.
  • La herencia (class Hija(Madre)) modela "es un"; super() llama a la versión de la clase madre.
  • La composición (un objeto que contiene otros) modela "tiene" y suele ser la opción más flexible.
  • @dataclass genera __init__, __repr__ y __eq__ a partir de los campos; usá field(default_factory=list) para colecciones y __post_init__ para validar.

← Volver al temario