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:
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
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 conCuenta(...). Ahí se cargan los datos iniciales.selfes el objeto mismo. Python lo pasa solo como primer argumento: cuando escribíscuenta_ana.depositar(500), adentro del métodoselfescuenta_anaymontoes500.self.titular = titularcrea un atributo: un dato guardado dentro del objeto. Cada objeto tiene los suyos: el saldo de Ana y el de Bruno son independientes.depositaryextraerson métodos: funciones definidas dentro de la clase que operan sobreself.
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.
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)
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:
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)
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 devuelveTrueoFalse. 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
conectarse 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:
>>> 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 usanprint()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.
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:
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.
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)
Ana: $900,000
Bruno: $950,000
class Vendedor(Empleado):indica queVendedorhereda deEmpleado.super().__init__(...)llama al__init__de la clase madre para que cargue los atributos comunes; después la hija agrega los suyos.Vendedorredefine (override) el métodosueldo.__str__no lo redefine, así que usa el deEmpleado... que a su vez llama aself.sueldo(), y comoselfes unVendedor, 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.
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:
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
@dataclassarriba 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:
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
Cuentatiene 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 (
_saldoy@property). - Herencia: una clase se construye a partir de otra (
Vendedora partir deEmpleado). - 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
selfen la definición de un método.def depositar(monto):daTypeError: 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,saldoa secas es una variable local; el atributo esself.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 = Cuentano crea un objeto:cpasa a ser la clase misma. - Olvidar
super().__init__(...)en la clase hija. Los atributos de la clase madre no se crean y aparece unAttributeErrormá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á
- Escribí una clase
Rectanguloconbaseyaltura, métodosarea()yperimetro(), un métodoes_cuadrado()y un__str__que muestre algo comoRectángulo 3 x 4. - Partiendo de la
Cuentacon_saldoy@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étodomovimientos()devuelve una copia de esa lista. - Creá una clase
Vehiculoconmarcay un métododescripcion(). Creá dos hijas:Auto, conpuertas, yBicicleta, conrodado, cada una con su propiadescripcion()que reutilice la de la madre (pista:super().descripcion()). - Con dataclasses, modelá una
Bibliotecaque tieneLibros (titulo,autor,prestado: bool = False). La biblioteca tiene que permitir agregar libros, prestar un libro por título (lanzandoValueErrorsi no existe o ya está prestado) y listar los disponibles.
Soluciones
Ejercicio 1:
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())
Rectángulo 3 x 4 12 14 False
Ejercicio 2:
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:
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())
Vehículo marca Andina, auto de 5 puertas
Vehículo marca Montaña, bicicleta rodado 29
Ejercicio 4:
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()])
'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;selfes el objeto mismo y va como primer parámetro de cada método.__str__define cómo lo muestraprint;__repr__, cómo se ve en el REPL y dentro de listas.- Un guion bajo al principio (
_saldo) marca un atributo interno;@propertypermite 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.
@dataclassgenera__init__,__repr__y__eq__a partir de los campos; usáfield(default_factory=list)para colecciones y__post_init__para validar.