Clases y objetos en Python
En esta lección
- Clases y objetos
- __init__ y self
- Atributos de instancia y de clase
- Métodos __str__ y __repr__
- Encapsulación por convención
- @property: atributos con control
- @classmethod y @staticmethod
- Los objetos son referencias
- @dataclass: clases de datos sin escribir de más
- Ejemplo completo: una cuenta bancaria
- Errores frecuentes
- 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__conself.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 usanprint(),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 yrepr().
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.
| Nombre | Significado |
|---|---|
saldo | Público: úsalo libremente |
_saldo | “Interno”: no lo toques desde fuera de la clase (se puede, pero no se debe) |
__pin | Python 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
@propertyes el getter: se ejecuta al leercamiseta.precio. - El método con
@precio.setterse ejecuta al escribircamiseta.precio = .... El valor real se guarda en_precio, con guion bajo, para no llamarse a sí mismo sin fin. precio_con_ivano tiene setter: es de solo lectura y se calcula al vuelo. Si intentas asignarle algo, obtienesAttributeError: 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, llamadocls. 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.
| Tipo | Decorador | Primer parámetro | Uso típico |
|---|---|---|---|
| Método normal | ninguno | self (el objeto) | Leer o cambiar los datos del objeto |
| Método de clase | @classmethod | cls (la clase) | Constructores alternativos |
| Método estático | @staticmethod | ninguno | Utilidades 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
selfen un método:def saludar():y luegoa.saludar()daTypeError: Alumno.saludar() takes 0 positional arguments but 1 was given. Python siempre pasa el objeto. - Escribir
nombre = nombreen vez deself.nombre = nombre: creas una variable local que desaparece al terminar__init__. Luegoa.nombredaAttributeError. - Faltan argumentos al crear el objeto:
Alumno("Ana")daTypeError: 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, escribirself.precio = valorvuelve a llamar al setter sin fin (RecursionError). Guarda el valor enself._precio.
Resumen
| Concepto | Ejemplo |
|---|---|
| Definir una clase | class Alumno: |
| Inicializar atributos | def __init__(self, nombre): self.nombre = nombre |
| Crear un objeto | ana = Alumno("Ana") |
| Método | def media(self): ... y ana.media() |
| Atributo de clase | total_socios = 0 dentro de la clase |
| Texto para el usuario / el programador | __str__ / __repr__ |
| Atributo interno | self._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
¿Te ha quedado claro? Márcala y verás tu progreso en el explorador.