AulaML Python · C++
Python intermedio · 42 min · Programación orientada a objetos

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__.

Lección 1 de 4 del módulo
Al terminar esta lección podrás
  • 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
  1. ¿Qué es una clase?
  2. ¿Para qué sirve?
  3. ¿Cómo funciona por dentro?
  4. Sintaxis: class, __init__ y métodos
  5. Ejemplo básico: un producto
  6. Ejemplo intermedio: dos carritos independientes
  7. Caso práctico: control de inventario
  8. Visualización: crear un objeto y llamar a un método
  9. Errores comunes
  10. Buenas prácticas
  11. Ejercicios
  12. Soluciones explicadas
  13. Resumen
  14. 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 en CamelCase.
  • Atributos: los datos que guarda cada objeto, como self.nombre o self.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 ClientePremium puede partir de Cliente y añadir ventajas.
  • Integrarse con bibliotecas: en scikit-learn cada modelo es un objeto con fit y predict.
  • 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.

ConceptoDónde se defineSe comparte
Atributo de instanciaself.x = ... dentro de un métodoNo: cada objeto tiene el suyo
Atributo de clasex = ... en el cuerpo de la claseSí: todos lo ven por defecto
Métododef m(self): en la claseSí: el código es único
Atributo tras self.x = ...Instancia, aunque exista en la claseNo: oculta al de clase
Nombre con _Convención de uso internoSí, 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.

PYTHON producto.py
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.
  • self debe 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 lanza TypeError.
  • Los métodos se llaman sin pasar self: p.precio_final() lo añade Python por ti.
  • Un método que no usa self puede 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.

PYTHON carrito.py
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.

PYTHON inventario.py
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.

Ejecución visual ·Del molde a la instancia
1 / 8
1 class Producto:
2 def __init__(self, nombre, precio):
3 self.nombre = nombre
4 self.precio = precio
5
6 def con_igv(self):
7 return self.precio * 1.18
8
9
10 p = Producto("mouse", 50)
11 print(p.con_igv())
Se ejecuta el cuerpo de la clase y se crea el tipo Producto. Sus métodos están definidos, pero no hay ninguna instancia.
Variables
Producto = <class Producto>
Salida
—

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íntomaPor qué ocurreCómo se arregla
TypeError: Producto() takes no argumentsLa clase no define __init__Añade def __init__(self, ...)
TypeError: __init__() missing 1 required positional argument: 'precio'Creaste la instancia sin un dato obligatorioPasa todos los argumentos o usa un valor por defecto
AttributeError: 'Producto' object has no attribute 'precio'Nunca asignaste ese atributo, o escribiste mal el nombreCréalo en __init__ y revisa la ortografía
Todos los objetos comparten una listaLa lista se declaró en el cuerpo de la claseCréala en __init__ con self.lista = []
NameError: name 'self' is not definedOlvidaste self en la firma del métodoEscribe def metodo(self):
La lista compartida entre instancias
Incorrecto
PYTHON Atributo de clase mutable
class Carrito:
    items = []

    def agregar(self, item):
        self.items.append(item)

a = Carrito()
b = Carrito()
a.agregar("pan")
print(b.items)
Correcto
PYTHON Atributo de instancia
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 CamelCase para clases y snake_case para 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 __atributo para «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.
Una clase con método en Python y en C++
Python
PYTHON punto.py
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())
C++
CPP punto.cpp
#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

Ejercicio 1

Cuenta con saldo protegido

Escribe una clase 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.

Completar código Completa el constructor
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.

Ejercicio 2

Producto con historial de precios

Crea 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 devuelve None.
  • self es la instancia concreta; Python lo pasa como primer argumento.
  • Los atributos creados con self.x son 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

Evaluación 1

¿Qué es self dentro de un método?

Evaluación 2

Con class C: items = [] y dos instancias a y b, tras a.items.append(1), ¿qué vale b.items?

Evaluación 3

¿Cuál de estas afirmaciones sobre __init__ es correcta?

Tus notas

¿Ya lo entendiste?

Márcala como completada para llevar tu progreso y desbloquear el siguiente paso de la ruta.