Saltar al contenido
clases.py · devschool

Clases y objetos en Python

Lección 16 de 19 · 12 min de lectura · Actualizado el

En esta lección
  1. Clases y objetos
  2. __init__ y self
  3. Atributos de instancia y de clase
  4. Métodos __str__ y __repr__
  5. Encapsulación por convención
  6. @property: atributos con control
  7. @classmethod y @staticmethod
  8. Los objetos son referencias
  9. @dataclass: clases de datos sin escribir de más
  10. Ejemplo completo: una cuenta bancaria
  11. Errores frecuentes
  12. Resumen

Hasta ahora has guardado los datos en listas y diccionarios, y has escrito funciones sueltas que trabajan con ellos. Las clases te permiten juntar en una sola pieza los datos de algo (un alumno, un producto, una cuenta) y las operaciones que se pueden hacer con ello. Es la base de la programación orientada a objetos, y la verás en casi cualquier librería de Python. En esta lección aprenderás a crear tus propias clases, a proteger sus datos y a escribir menos código con @dataclass.

Clases y objetos

Una clase es un molde: describe qué datos tiene algo y qué sabe hacer. Un objeto (o instancia) es cada cosa concreta fabricada con ese molde. La clase Alumno es una sola; los alumnos Ana y Luis son dos objetos distintos.

¿Por qué no seguir con diccionarios? Con un diccionario nada impide escribir mal una clave o meter una nota negativa, y la función que calcula la media vive en otro sitio. Una clase reúne datos y comportamiento, y puede vigilar que sus datos sean válidos.

class Alumno:
    def __init__(self, nombre, curso):
        self.nombre = nombre
        self.curso = curso
        self.notas = []

    def agregar_nota(self, nota):
        self.notas.append(nota)

    def media(self):
        if not self.notas:
            return 0
        return sum(self.notas) / len(self.notas)

ana = Alumno("Ana", "1DAW")
luis = Alumno("Luis", "2DAW")
ana.agregar_nota(8)
ana.agregar_nota(9.5)

print(ana.nombre, ana.curso)   # Ana 1DAW
print(ana.media())             # 8.75
print(luis.media())            # 0
print(type(ana))               # <class '__main__.Alumno'>

Fíjate en las convenciones: los nombres de clase van en PascalCase (Alumno, CuentaBancaria) y los de métodos y atributos en snake_case, como las funciones.

__init__ y self

El método __init__

__init__ se ejecuta automáticamente cada vez que creas un objeto. Su trabajo es darle sus valores iniciales. Cuando escribes Alumno("Ana", "1DAW"), Python crea un objeto vacío y llama a __init__ pasándole esos argumentos.

Qué es self

self es el propio objeto con el que estás trabajando. Cuando llamas a ana.agregar_nota(8), Python lo traduce a Alumno.agregar_nota(ana, 8): pasa el objeto como primer argumento, y dentro del método ese objeto se llama self.

Alumno.agregar_nota(luis, 7)   # equivale a luis.agregar_nota(7)
print(luis.notas)              # [7]

Por eso todos los métodos normales llevan self como primer parámetro, y dentro de la clase escribes self.notas y no notas a secas: te refieres a las notas de ese objeto concreto. self no es una palabra reservada, pero es la convención universal.

Atributos de instancia y de clase

Los atributos de instancia se crean con self.algo = ... y cada objeto tiene los suyos. Los atributos de clase se escriben directamente dentro de la clase y los comparten todos los objetos.

class Socio:
    gimnasio = "FitCenter"   # de clase: común a todos
    total_socios = 0

    def __init__(self, nombre):
        self.nombre = nombre     # de instancia: propio de cada socio
        Socio.total_socios += 1

a = Socio("Marta")
b = Socio("Pablo")
print(a.gimnasio, b.gimnasio)   # FitCenter FitCenter
print(Socio.total_socios)       # 2

Socio.gimnasio = "FitCenter Norte"
print(a.gimnasio)               # FitCenter Norte

Cuando pides a.gimnasio, Python busca primero en el objeto y, si no lo encuentra, en la clase. Si asignas a.gimnasio = "Otro", no cambias la clase: creas un atributo nuevo solo en a que “tapa” al de la clase.

a.gimnasio = "Otro"
print(a.gimnasio, b.gimnasio)   # Otro FitCenter Norte

Cuidado: nunca uses una lista o un diccionario como atributo de clase si cada objeto debe tener el suyo. Con productos = [] escrito en la clase, todos los carritos comparten la misma lista y lo que añades a uno aparece en todos. Crea la lista en __init__ con self.productos = [].

Métodos __str__ y __repr__

Si imprimes un objeto sin más, Python muestra algo poco útil como <__main__.Libro object at 0x7f75bb198490>. Puedes cambiarlo con dos métodos especiales:

  • __str__: texto amigable para el usuario. Lo usan print(), str() y los f-strings.
  • __repr__: texto técnico para el programador, idealmente con el aspecto del código que crea el objeto. Lo usan la consola interactiva, las listas al imprimirse y repr().
class Libro:
    def __init__(self, titulo, autor, paginas):
        self.titulo = titulo
        self.autor = autor
        self.paginas = paginas

    def __str__(self):
        return f"«{self.titulo}», de {self.autor}"

    def __repr__(self):
        return f"Libro({self.titulo!r}, {self.autor!r}, {self.paginas})"

libro = Libro("Niebla", "Unamuno", 280)
print(libro)                      # «Niebla», de Unamuno
print(f"Estoy leyendo {libro}")   # Estoy leyendo «Niebla», de Unamuno
print(repr(libro))                # Libro('Niebla', 'Unamuno', 280)
print([libro])                    # [Libro('Niebla', 'Unamuno', 280)]

El !r dentro del f-string aplica repr() al valor, por eso los textos salen entre comillas. Si solo defines uno, define __repr__: Python lo usa también cuando falta __str__.

Encapsulación por convención

En otros lenguajes existen private y public. Python no los tiene: confía en el programador y usa convenciones de nombre.

NombreSignificado
saldoPúblico: úsalo libremente
_saldo“Interno”: no lo toques desde fuera de la clase (se puede, pero no se debe)
__pinPython le cambia el nombre (name mangling) para evitar choques en subclases
class Cuenta:
    def __init__(self, titular, saldo):
        self.titular = titular
        self._saldo = saldo
        self.__pin = 1234

c = Cuenta("Ana", 100)
print(c._saldo)          # 100 (funciona, pero es mala práctica)
print(c.__pin)           # AttributeError: 'Cuenta' object has no attribute '__pin'
print(c._Cuenta__pin)    # 1234

__pin no es secreto de verdad: Python solo lo ha renombrado a _Cuenta__pin. En la práctica basta con el guion bajo simple.

@property: atributos con control

¿Y si quieres que un atributo se lea como siempre (producto.precio), pero que al asignarle un valor se compruebe que es válido? Para eso está @property. Convierte un método en algo que se usa como un atributo.

class Producto:
    def __init__(self, nombre, precio):
        self.nombre = nombre
        self.precio = precio   # pasa por el setter, así también se valida

    @property
    def precio(self):
        return self._precio

    @precio.setter
    def precio(self, valor):
        if valor < 0:
            raise ValueError("El precio no puede ser negativo")
        self._precio = round(valor, 2)

    @property
    def precio_con_iva(self):
        return round(self.precio * 1.21, 2)

camiseta = Producto("Camiseta", 15)
print(camiseta.precio)           # 15
camiseta.precio = 12.499
print(camiseta.precio)           # 12.5
print(camiseta.precio_con_iva)   # 15.12
camiseta.precio = -5             # ValueError: El precio no puede ser negativo

Cómo funciona:

  • El método con @property es el getter: se ejecuta al leer camiseta.precio.
  • El método con @precio.setter se ejecuta al escribir camiseta.precio = .... El valor real se guarda en _precio, con guion bajo, para no llamarse a sí mismo sin fin.
  • precio_con_iva no tiene setter: es de solo lectura y se calcula al vuelo. Si intentas asignarle algo, obtienes AttributeError: property 'precio_con_iva' of 'Producto' object has no setter.

La ventaja: puedes empezar con un atributo normal y convertirlo en propiedad más adelante sin cambiar el código que ya lo usa.

@classmethod y @staticmethod

No todos los métodos trabajan con un objeto concreto.

  • Un método de clase (@classmethod) recibe la clase como primer parámetro, llamado cls. Su uso típico es crear objetos de otra forma: son “constructores alternativos”.
  • Un método estático (@staticmethod) no recibe ni el objeto ni la clase. Es una función normal que vive dentro de la clase porque tiene que ver con ella.
class Fecha:
    def __init__(self, dia, mes, anio):
        self.dia = dia
        self.mes = mes
        self.anio = anio

    @classmethod
    def desde_texto(cls, texto):
        dia, mes, anio = texto.split("/")
        return cls(int(dia), int(mes), int(anio))

    @staticmethod
    def es_bisiesto(anio):
        return anio % 4 == 0 and (anio % 100 != 0 or anio % 400 == 0)

    def __str__(self):
        return f"{self.dia:02d}/{self.mes:02d}/{self.anio}"

f = Fecha.desde_texto("7/3/2026")
print(f)                        # 07/03/2026
print(Fecha.es_bisiesto(2024))  # True

¿Por qué cls(...) y no Fecha(...)? Porque si otra clase hereda de Fecha, desde_texto creará objetos de esa clase hija. Lo verás en herencia. Y para fechas reales, Python ya trae el módulo datetime.

TipoDecoradorPrimer parámetroUso típico
Método normalningunoself (el objeto)Leer o cambiar los datos del objeto
Método de clase@classmethodcls (la clase)Constructores alternativos
Método estático@staticmethodningunoUtilidades relacionadas con la clase

Los objetos son referencias

Una variable no “contiene” el objeto: apunta a él, igual que pasaba con las listas. Si asignas un objeto a otra variable, las dos apuntan al mismo.

a = Alumno("Ana", "1DAW")
b = a
b.nombre = "Ana María"
print(a.nombre)    # Ana María
print(a is b)      # True

c = Alumno("Ana María", "1DAW")
print(a is c, a == c)   # False False

Dos cosas importantes:

  • Al pasar un objeto a una función, la función recibe el mismo objeto, así que puede modificarlo.
  • Por defecto, == en tus clases compara si son el mismo objeto, no si tienen los mismos datos. Para cambiarlo hay que definir __eq__ (lo verás en herencia y métodos especiales) o usar @dataclass.

Si necesitas una copia independiente, usa copy.copy(objeto) o copy.deepcopy(objeto) del módulo copy.

@dataclass: clases de datos sin escribir de más

Muchas clases solo guardan datos. Escribir su __init__, su __repr__ y su __eq__ a mano es repetitivo. El decorador @dataclass los genera por ti a partir de los atributos anotados con su tipo:

from dataclasses import dataclass, field

@dataclass
class Producto:
    nombre: str
    precio: float
    stock: int = 0          # valor por defecto

p1 = Producto("Taza", 6.5, 10)
p2 = Producto("Taza", 6.5, 10)
print(p1)          # Producto(nombre='Taza', precio=6.5, stock=10)
print(p1 == p2)    # True: compara los datos

Una dataclass es una clase normal: puedes añadirle métodos. Para atributos que son listas usa field(default_factory=list); Python no te deja poner = [] directamente (da ValueError) precisamente para evitar la lista compartida que viste antes.

@dataclass
class Pedido:
    cliente: str
    lineas: list = field(default_factory=list)

    def total(self):
        return sum(p.precio for p in self.lineas)

pedido = Pedido("Luis")
pedido.lineas.append(Producto("Taza", 6.5))
pedido.lineas.append(Producto("Libreta", 3.25))
print(pedido.total())   # 9.75

Con @dataclass(frozen=True) el objeto no se puede modificar después de crearlo: intentarlo lanza FrozenInstanceError.

Consejo: las anotaciones de tipo (str, float) son informativas; Python no comprueba que el valor sea de ese tipo. Si necesitas validar, añade un método __post_init__ o usa una propiedad.

Ejemplo completo: una cuenta bancaria

Juntamos todo: atributo de clase, saldo protegido con una propiedad de solo lectura, validación con excepciones, __str__ y __repr__.

class Cuenta:
    """Cuenta bancaria con saldo protegido e historial de movimientos."""

    comision_transferencia = 0.5   # atributo de clase, igual para todas

    def __init__(self, titular, saldo=0):
        self.titular = titular
        self._saldo = 0
        self._movimientos = []
        if saldo:
            self.ingresar(saldo)

    @property
    def saldo(self):
        return self._saldo

    def ingresar(self, cantidad):
        if cantidad <= 0:
            raise ValueError("La cantidad debe ser positiva")
        self._saldo += cantidad
        self._movimientos.append(("ingreso", cantidad))

    def retirar(self, cantidad):
        if cantidad <= 0:
            raise ValueError("La cantidad debe ser positiva")
        if cantidad > self._saldo:
            raise ValueError(f"Saldo insuficiente: tienes {self._saldo} €")
        self._saldo -= cantidad
        self._movimientos.append(("retirada", cantidad))

    def transferir(self, destino, cantidad):
        self.retirar(cantidad + Cuenta.comision_transferencia)
        destino.ingresar(cantidad)

    def extracto(self):
        for tipo, cantidad in self._movimientos:
            signo = "+" if tipo == "ingreso" else "-"
            print(f"  {signo}{cantidad:>8.2f} € {tipo}")
        print(f"  Saldo: {self._saldo:.2f} €")

    def __str__(self):
        return f"Cuenta de {self.titular}: {self._saldo:.2f} €"

    def __repr__(self):
        return f"Cuenta({self.titular!r}, {self._saldo})"


ana = Cuenta("Ana", 200)
luis = Cuenta("Luis")
ana.retirar(30)
ana.transferir(luis, 50)

print(ana)       # Cuenta de Ana: 119.50 €
print(luis)      # Cuenta de Luis: 50.00 €
ana.extracto()
#   +  200.00 € ingreso
#   -   30.00 € retirada
#   -   50.50 € retirada
#   Saldo: 119.50 €

try:
    luis.retirar(100)
except ValueError as e:
    print("Error:", e)   # Error: Saldo insuficiente: tienes 50 €

print([ana, luis])       # [Cuenta('Ana', 119.5), Cuenta('Luis', 50)]

El saldo solo cambia a través de ingresar y retirar, que validan y registran cada movimiento. Como saldo es una propiedad sin setter, ana.saldo = 1_000_000 lanza AttributeError. Eso es encapsulación: el objeto se encarga de que sus datos sigan siendo coherentes.

Errores frecuentes

  • Olvidar self en un método: def saludar(): y luego a.saludar() da TypeError: Alumno.saludar() takes 0 positional arguments but 1 was given. Python siempre pasa el objeto.
  • Escribir nombre = nombre en vez de self.nombre = nombre: creas una variable local que desaparece al terminar __init__. Luego a.nombre da AttributeError.
  • Faltan argumentos al crear el objeto: Alumno("Ana") da TypeError: Alumno.__init__() missing 1 required positional argument: 'curso'.
  • Listas como atributo de clase: todos los objetos las comparten. Créalas en __init__.
  • Llamar al atributo y a la propiedad igual: dentro del setter de precio, escribir self.precio = valor vuelve a llamar al setter sin fin (RecursionError). Guarda el valor en self._precio.

Resumen

ConceptoEjemplo
Definir una claseclass Alumno:
Inicializar atributosdef __init__(self, nombre): self.nombre = nombre
Crear un objetoana = Alumno("Ana")
Métododef media(self): ... y ana.media()
Atributo de clasetotal_socios = 0 dentro de la clase
Texto para el usuario / el programador__str__ / __repr__
Atributo internoself._saldo
Propiedad con validación@property + @nombre.setter
Constructor alternativo@classmethod con cls
Utilidad sin objeto@staticmethod
Clase de datos@dataclass + atributos con tipo

Con las clases puedes modelar casi cualquier cosa. El siguiente paso es reutilizarlas y hacer que se comporten como los tipos de Python: lo verás en herencia y métodos especiales.

Pon a prueba lo que has aprendido

[Python] ¿Qué imprime este código?
class Contador:
    total = 0
    def __init__(self):
        self.total += 1

a = Contador()
b = Contador()
print(Contador.total, a.total)

[Python] Llamas a ana.saludar() y obtienes: TypeError: Alumno.saludar() takes 0 positional arguments but 1 was given. ¿Cuál es la causa más probable?

[Python] Quieres que producto.precio se lea como un atributo, pero que al asignarle un valor negativo se lance un error. ¿Qué usas?

[Python] ¿Qué imprime este código?
from dataclasses import dataclass

@dataclass
class Punto:
    x: int
    y: int = 0

print(Punto(3) == Punto(3, 0))

¿Te ha quedado claro? Márcala y verás tu progreso en el explorador.