Clases y objetos
Agrupar datos y comportamiento en un mismo tipo, con estado propio para cada instancia
Una clase es un plano que define qué datos y qué operaciones tiene un tipo de objeto; cada instancia guarda su propio estado. Aprenderás a escribir __init__, usar self, distinguir atributos de instancia y de clase, proteger datos por convención y mostrar objetos con __repr__.
- Definir clases con atributos y métodos
- Explicar qué representa self en una llamada a un método
- Distinguir un atributo de instancia de uno de clase
- Crear objetos mutables propios en __init__ y no en el cuerpo de la clase
- Aplicar encapsulamiento por convención con nombres que empiezan por guion bajo
- Escribir un __repr__ útil para depurar
Antes necesitas: Lambda, map/filter/reduce y recursividad
En esta lección
- ¿Qué es una clase?
- ¿Para qué sirve?
- ¿Cómo funciona por dentro?
- Sintaxis: class, __init__ y métodos
- Ejemplo básico: un producto
- Ejemplo intermedio: dos carritos independientes
- Caso práctico: control de inventario
- Visualización: crear un objeto y llamar a un método
- Errores comunes
- Buenas prácticas
- Ejercicios
- Soluciones explicadas
- Resumen
- Evaluación
Hasta ahora cada dato vivía en una variable suelta y cada operación en una función que recibía esos datos por separado. Para un producto puedes tener nombre, precio y stock en tres variables sueltas, y nada garantiza que sigan siendo del mismo producto.
Una clase agrupa esos datos y las operaciones que los usan bajo un mismo tipo. Cada objeto creado a partir de la clase lleva su propio estado: dos carritos distintos tienen listas distintas, aunque compartan el mismo código. Es la base de casi todas las bibliotecas que usarás en Machine Learning.
¿Qué es una clase?
Una clase es un molde o plano: describe qué atributos tendrá un objeto y qué métodos podrá ejecutar. Un objeto (o instancia) es una copia concreta de ese molde, con valores propios. La analogía clásica funciona: la clase es el plano de una casa; cada casa construida es una instancia, y pintar una pared de una no cambia las demás.
class: la palabra que inicia la definición y el nombre del tipo, por convención enCamelCase.- Atributos: los datos que guarda cada objeto, como
self.nombreoself.precio. - Métodos: funciones definidas dentro de la clase que operan sobre el objeto.
__init__: el método especial que se ejecuta al crear una instancia y deja el objeto listo.self: el primer parámetro de los métodos; por él se accede al objeto concreto.__repr__: el método que decide cómo se muestra el objeto al imprimirlo.
¿Para qué sirve?
- Modelar entidades: un producto, un cliente, una cuenta o una transacción con sus datos y reglas.
- Mantener el estado coherente: el stock solo cambia a través de métodos que validan la cantidad.
- Evitar funciones con demasiados parámetros: el objeto lleva sus datos consigo.
- Reutilizar con herencia: un
ClientePremiumpuede partir deClientey añadir ventajas. - Integrarse con bibliotecas: en scikit-learn cada modelo es un objeto con
fitypredict. - Organizar pruebas: puedes crear instancias con estados concretos y comprobar sus métodos.
¿Cómo funciona por dentro?
Al ejecutar una class, Python crea un objeto de tipo clase y guarda dentro los métodos y los atributos definidos en el cuerpo. Ese cuerpo se ejecuta una sola vez, en el momento de la definición. Por eso un atributo asignado directamente en la clase se comparte entre todas las instancias: existe en el molde, no en cada objeto.
Cuando escribes p = Producto("mouse", 50), Python crea un objeto vacío y llama a __init__ pasándolo como primer argumento. Por eso el parámetro se llama self: dentro del método, self es esa instancia. Las asignaciones self.nombre = ... guardan los valores en el diccionario interno del objeto, visible con p.__dict__.
Al llamar p.precio_final(), Python busca el método en la clase y lo invoca con p como self: el código se comparte, el estado no. Si un atributo no está en la instancia, se busca en la clase; al asignarlo con self.x, se crea uno nuevo que oculta al de clase.
| Concepto | Dónde se define | Se comparte |
|---|---|---|
| Atributo de instancia | self.x = ... dentro de un método | No: cada objeto tiene el suyo |
| Atributo de clase | x = ... en el cuerpo de la clase | Sí: todos lo ven por defecto |
| Método | def m(self): en la clase | Sí: el código es único |
Atributo tras self.x = ... | Instancia, aunque exista en la clase | No: oculta al de clase |
Nombre con _ | Convención de uso interno | Sí, pero no lo toques desde fuera |
Sintaxis: class, __init__ y métodos
La forma canónica incluye una docstring, un atributo de clase si es común a todos, un __init__ que crea el estado y métodos que lo consultan o lo modifican. El nombre de la clase va en CamelCase; los métodos y atributos, en snake_case.
class Producto:
"""Producto del inventario con su precio final."""
igv = 0.18 # atributo de clase: igual para todos
def __init__(self, nombre, precio):
self.nombre = nombre
self.precio = precio
def precio_final(self):
return self.precio * (1 + self.igv)
def __repr__(self):
return f"Producto({self.nombre!r}, {self.precio})"
p = Producto("mouse", 50)
print(p)
print(p.precio_final())__init__no crea el objeto: lo inicializa. El objeto ya existe cuando se ejecuta.selfdebe ser el primer parámetro de cada método de instancia; el nombre es convención, pero no lo cambies.__repr__debe devolver una cadena; si no, Python lanzaTypeError.- Los métodos se llaman sin pasar
self:p.precio_final()lo añade Python por ti. - Un método que no usa
selfpuede ser una función normal o un@staticmethod; no lo fuerces a ser método.
Ejemplo básico: un producto
El bloque anterior ya es un ejemplo completo. Al crear p, __init__ guarda "mouse" y 50. precio_final lee self.precio y self.igv, que se encuentra en la clase. print(p) usa __repr__ y muestra Producto('mouse', 50) en lugar de una dirección de memoria.
El !r de la f-string aplica repr al nombre y añade las comillas, así se distingue un texto de un número. Sin __repr__, verías algo como <__main__.Producto object at 0x104f3c190>, que no dice nada.
Ejemplo intermedio: dos carritos independientes
Aquí aparece la diferencia clave entre atributo de clase y de instancia. Si declaras la lista en el cuerpo de la clase, todos los carritos comparten la misma y agregar algo en uno aparece en el otro.
class Carrito:
"""Carrito de compras con una lista propia por instancia."""
def __init__(self, cliente):
self.cliente = cliente
self.items = [] # lista nueva para cada carrito
def agregar(self, nombre, precio):
self.items.append((nombre, precio))
return len(self.items)
def total(self):
return sum(precio for _, precio in self.items)
def __repr__(self):
return f"Carrito({self.cliente!r}, {len(self.items)} items)"
ana = Carrito("Ana")
luis = Carrito("Luis")
ana.agregar("teclado", 120)
ana.agregar("mouse", 45)
luis.agregar("monitor", 300)
print(ana, ana.total())
print(luis, luis.total())La línea 6 crea una lista vacía cada vez que se ejecuta __init__, es decir, una por carrito. agregar modifica self.items y devuelve cuántos ítems hay; total recorre esa lista con una expresión generadora y suma los precios. El resultado es Carrito('Ana', 2 items) 165 y Carrito('Luis', 1 items) 300.
Fíjate en que total no recibe la lista como parámetro: la obtiene de self. Esa es la ventaja de agrupar estado y comportamiento. También podrías escribir sum(p for _, p in self.items), pero el desempaquetado en el for evita índices y se lee mejor.
Caso práctico: control de inventario
Un inventario debe impedir operaciones imposibles: no se puede sacar más de lo que hay ni ingresar una cantidad negativa. Si el stock fuera un diccionario suelto, cualquiera podría modificarlo sin validar; con una clase, los únicos caminos son entrar y salir.
class Inventario:
"""Controla el stock con operaciones validadas."""
def __init__(self):
self._stock = {}
def entrar(self, producto, cantidad):
if cantidad <= 0:
raise ValueError("La cantidad debe ser positiva")
self._stock[producto] = self._stock.get(producto, 0) + cantidad
def salir(self, producto, cantidad):
disponible = self._stock.get(producto, 0)
if cantidad > disponible:
raise ValueError(f"Stock insuficiente: hay {disponible}")
self._stock[producto] = disponible - cantidad
def consultar(self, producto):
return self._stock.get(producto, 0)
def __repr__(self):
return f"Inventario({self._stock})"
inv = Inventario()
inv.entrar("mouse", 10)
inv.salir("mouse", 3)
print(inv.consultar("mouse"))
print(inv)self._stock empieza como un diccionario vacío propio de cada inventario. entrar usa get(producto, 0) para tratar un producto nuevo como cero y suma la cantidad. salir lee lo disponible, compara y solo entonces descuenta; si no alcanza, lanza ValueError antes de dejar el stock en un estado inconsistente.
El guion bajo de _stock es una convención: indica que es un detalle interno y que no deberías leerlo ni escribirlo desde fuera. Python no lo impide, pero quien lo haga se arriesga a romper las reglas. Si necesitaras un acceso controlado, usarías una propiedad, que se estudia más adelante. El resultado es 7 y Inventario({'mouse': 7}).
Visualización: crear un objeto y llamar a un método
La traza muestra lo que ocurre al crear Producto("mouse", 50) y después llamar a con_igv: primero __init__ para llenar el objeto, luego el método para calcular. Observa cómo self aparece en cada marco apuntando a la misma instancia.
class Producto: def __init__(self, nombre, precio): self.nombre = nombre self.precio = precio def con_igv(self): return self.precio * 1.18p = Producto("mouse", 50)print(p.con_igv())Producto. Sus métodos están definidos, pero no hay ninguna instancia.—
Avanza paso a paso: observa la línea resaltada, las variables y la salida.
__init__ devuelve None siempre; si devuelves otro valor, Python lanza TypeError. Y self no es palabra reservada, pero cambiarle el nombre rompe la convención.
Errores comunes
| Mensaje o síntoma | Por qué ocurre | Cómo se arregla |
|---|---|---|
TypeError: Producto() takes no arguments | La clase no define __init__ | Añade def __init__(self, ...) |
TypeError: __init__() missing 1 required positional argument: 'precio' | Creaste la instancia sin un dato obligatorio | Pasa todos los argumentos o usa un valor por defecto |
AttributeError: 'Producto' object has no attribute 'precio' | Nunca asignaste ese atributo, o escribiste mal el nombre | Créalo en __init__ y revisa la ortografía |
| Todos los objetos comparten una lista | La lista se declaró en el cuerpo de la clase | Créala en __init__ con self.lista = [] |
NameError: name 'self' is not defined | Olvidaste self en la firma del método | Escribe def metodo(self): |
class Carrito:
items = []
def agregar(self, item):
self.items.append(item)
a = Carrito()
b = Carrito()
a.agregar("pan")
print(b.items)class Carrito:
def __init__(self):
self.items = []
def agregar(self, item):
self.items.append(item)
a = Carrito()
b = Carrito()
a.agregar("pan")
print(b.items)En la primera versión, `items` vive en la clase y `a.items.append` lo modifica sin crear un atributo nuevo en `a`; por eso `b.items` muestra `['pan']`. En la segunda, cada `__init__` crea una lista propia y `b.items` sigue vacía. La regla: los mutables siempre se crean dentro de `__init__`.
Hay una sutileza que sorprende: self.items = [] sí crea un atributo de instancia, pero self.items.append(x) no. Si el atributo no existe en la instancia, Python encuentra la lista de la clase y la muta. Esa diferencia entre asignar y mutar explica la mayoría de los bugs de estado compartido.
Buenas prácticas
- Usa
CamelCasepara clases ysnake_casepara atributos y métodos. - Crea todo el estado en
__init__; un atributo que aparece a mitad de un método es difícil de rastrear. - Valida en los métodos que cambian el estado: es el equivalente a una puerta con control de acceso.
- Marca como internos los atributos que no forman parte de la interfaz con un guion bajo:
_stock. - No uses
__atributopara «proteger»: solo cambia el nombre y complica las pruebas; reserva esa forma para evitar colisiones. - Escribe
__repr__desde el principio: depurar con<objeto en 0x...>es perder tiempo.
class Punto:
def __init__(self, x, y):
self.x = x
self.y = y
def norma(self):
return (self.x ** 2 + self.y ** 2) ** 0.5
p = Punto(3, 4)
print(p.norma())#include <cmath>
#include <iostream>
class Punto {
public:
Punto(double x, double y) : x_(x), y_(y) {}
double norma() const {
return std::sqrt(x_ * x_ + y_ * y_);
}
private:
double x_;
double y_;
};
int main() {
Punto p(3, 4);
std::cout << p.norma() << std::endl;
return 0;
}En Python `self` es explícito en cada método y los atributos se crean al asignarlos; en C++ el objeto tiene una `this` implícita y los miembros se declaran en la clase con su tipo. `private` en C++ lo comprueba el compilador; el guion bajo en Python es solo una convención que el programador respeta.
Ejercicios
Cuenta con saldo protegido
Cuenta con titular y un saldo inicial de 0 guardado en _saldo. Añade depositar(monto) y retirar(monto), que rechacen montos no positivos y retiros mayores que el saldo lanzando ValueError. Define __repr__ y comprueba depositando 100 y retirando 30.Pista
Valida antes de modificar _saldo. Para el retiro, compara con el saldo actual y resta solo si alcanza. __repr__ debe devolver una cadena.
class Perro: def __init__(, nombre): .nombre = nombre def ladrar(self): return f"{self.nombre} dice guau" print(Perro("Toby").ladrar())
El primer parámetro de un método de instancia es el objeto que se está construyendo.
Producto con historial de precios
Producto con nombre, precio y un historial vacío. El método cambiar_precio(nuevo) debe guardar el precio anterior en el historial y actualizar el actual. Crea dos productos, cambia el precio de uno y comprueba con __repr__ que el historial del otro sigue vacío.Pista
Crea self.historial = [] dentro de __init__. Antes de asignar el nuevo precio, añade el actual con append.
Soluciones explicadas
En la cuenta, la protección es una decisión de diseño, no una característica del lenguaje: c._saldo = 9999 funcionaría. Lo que ganas con los métodos es que el camino normal quede validado y que el resto del programa no necesite conocer el formato interno. Si mañana el saldo se guarda en céntimos, solo cambian los métodos.
En el historial, el error que se evita es sutil: historial parece un dato más, pero al ser mutable debe nacer con cada objeto. La misma lógica se aplica a listas de tareas, cachés, conexiones y contadores. Cuando diseñes una clase, pregúntate qué atributos son iguales para todos y cuáles deben ser propios; los mutables siempre pertenecen a la instancia.
Resumen
- Una clase es un molde; cada instancia guarda su propio estado.
__init__inicializa el objeto recién creado y siempre devuelveNone.selfes la instancia concreta; Python lo pasa como primer argumento.- Los atributos creados con
self.xson de instancia; los del cuerpo de la clase se comparten. - Los atributos mutables se crean en
__init__para que no se compartan. - El guion bajo marca uso interno por convención, no por imposición del lenguaje.
__repr__devuelve una cadena útil para depurar y reconstruir el objeto.- Los métodos validan el estado: son la puerta de entrada a los datos.
Evaluación
¿Qué es self dentro de un método?
Con class C: items = [] y dos instancias a y b, tras a.items.append(1), ¿qué vale b.items?
¿Cuál de estas afirmaciones sobre __init__ es correcta?
¿Ya lo entendiste?
Márcala como completada para llevar tu progreso y desbloquear el siguiente paso de la ruta.