Saltar al contenido principal

Clases de datos en Python: Un tutorial completo

Un tutorial para principiantes sobre las clases de datos de Python y cómo utilizarlas en la práctica
Actualizado 16 ene 2025  · 9 min de lectura

Las clases de datos son una de las características de Python que, después de descubrirlas, nunca volverás a la vieja usanza. Considera esta clase normal:

class Exercise:
   def __init__(self, name, reps, sets, weight):
       self.name = name
       self.reps = reps
       self.sets = sets
       self.weight = weight

Para mí, esa definición de clase es muy ineficiente: en el método __init__, repites cada parámetro al menos tres veces. Puede que esto no parezca gran cosa, pero piensa en la frecuencia con la que escribes clases a lo largo de tu vida con muchos más parámetros.

En comparación, echa un vistazo a la alternativa de clases de datos del código anterior:

from dataclasses import dataclass


@dataclass
class Exercise:
   name: str
   reps: int
   sets: int
   weight: float  # Weight in lbs

Este trozo de código de aspecto modesto es órdenes de magnitud mejor que una clase normal. El diminuto decorador @dataclass implementa las clases __init__, __repr__, __eq__ entre bastidores, lo que habría requerido al menos 20 líneas de código manualmente.

Además, muchas otras funciones, como los operadores de comparación, el ordenamiento de objetos y la inmutabilidad, están a una sola línea de ser creadas mágicamente para nuestra clase.

Así pues, el objetivo de este tutorial es mostrarte por qué las clases de datos son una de las mejores cosas que le han pasado a Python si te gusta la programación orientada a objetos.

¡Empecemos!

Conceptos básicos de las clases de datos de Python

Veamos algunos de los conceptos fundamentales de las clases de datos de Python que las hacen tan útiles.

Algunos métodos se generan automáticamente en las clases de datos

A pesar de todas sus características, las clases de datos son clases normales que necesitan mucho menos código para implementar la misma funcionalidad. Aquí tienes de nuevo la clase Exercise:

from dataclasses import dataclass


@dataclass
class Exercise:
   name: str
   reps: int
   sets: int
   weight: float


ex1 = Exercise("Bench press", 10, 3, 52.5)

# Verifying Exercise is a regular class
ex1.name
'Bench press'

En este momento, Exercise ya tiene implementados los métodos __repr__ y __eq__. Verifiquémoslo:

repr(ex1)
"Exercise(name='Bench press', reps=10, sets=3, weight=52.5)"

La representación de un objeto repr debe devolver el código que puede recrearse a sí mismo, y podemos ver que ése es exactamente el caso de ex1.

En comparación, Exercise definido de la forma antigua tendría este aspecto:

class Exercise:
   def __init__(self, name, reps, sets, weight):
       self.name = name
       self.reps = reps
       self.sets = sets
       self.weight = weight


ex3 = Exercise("Bench press", 10, 3, 52.5)

ex3
<__main__.Exercise at 0x7f6834100130>

¡Parece bastante horrible e inútil!

Verifiquemos ahora la existencia de __eq__, que es el operador de igualdad:

# Redefine the class
@dataclass
class Exercise:
   name: str
   reps: int
   sets: int
   weight: float


ex1 = Exercise("Bench press", 10, 3, 52.5)
ex2 = Exercise("Bench press", 10, 3, 52.5)

La comparación de la clase consigo misma y con otra clase con parámetros idénticos debe devolver Verdadero:

ex1 == ex2
True
ex1 == ex1
True

¡Y así es! En las clases normales, esta lógica habría sido un coñazo de escribir.

Las clases de datos requieren sugerencias de tipo

Como habrás observado, las clases de datos requieren indicaciones de tipo al definir los campos. De hecho, las clases de datos admiten cualquier tipo del módulo typing. Por ejemplo, así se crea un campo que puede aceptar el tipo de datos Any:

from typing import Any


@dataclass
class Dummy:
   attr: Any

Sin embargo, la idiosincrasia de Python es que, aunque las clases de datos requieren indicaciones de tipo, en realidad los tipos no se imponen.

Por ejemplo, crear una instancia de la clase Exercise con tipos de datos completamente incorrectos puede ejecutarse sin errores:

silly_exercise = Exercise("Bench press", "ten", "three sets", 52.5)

silly_exercise.sets

“Three sets”

Si quieres reforzar los tipos de datos, debes utilizar verificadores de tipos como Mypy.

Las clases de datos permiten valores por defecto en los campos

Hasta ahora, no hemos añadido ningún valor por defecto a nuestras clases. Arreglémoslo:

@dataclass
class Exercise:
   name: str = "Push-ups"
   reps: int = 10
   sets: int = 3
   weight: float = 0


# Now, all fields have defaults
ex5 = Exercise()
ex5
Exercise(name='Push-ups', reps=10, sets=3, weight=0)

Ten en cuenta que los campos no predeterminados no pueden seguir a los predeterminados. Por ejemplo, el código siguiente arrojará un error:

@dataclass
class Exercise:
   name: str = "Push-ups"
   reps: int = 10
   sets: int = 3
   weight: float  # NOT ALLOWED


ex5 = Exercise()
ex5
TypeError: non-default argument 'weight' follows default argument

En la práctica, rara vez definirás valores por defecto con la sintaxis name: type = value.

En su lugar, utilizarás la función field, que permite un mayor control de la definición de cada campo:

from dataclasses import field


@dataclass
class Exercise:
   name: str = field(default="Push-up")
   reps: int = field(default=10)
   sets: int = field(default=3)
   weight: float = field(default=0)


# Now, all fields have defaults
ex5 = Exercise()
ex5
Exercise(name='Push-up', reps=10, sets=3, weight=0)

La función field tiene más parámetros, como:

  • repr
  • init
  • compare
  • default_factory

etc. Hablaremos de ellas en los próximos apartados.

Las clases de datos pueden crearse con una función

Una nota final sobre los fundamentos de las clases de datos es que su definición puede ser aún más breve utilizando la función make_dataclass:

from dataclasses import make_dataclass

Exercise = make_dataclass(
   "Exercise",
   [
       ("name", str),
       ("reps", int),
       ("sets", int),
       ("weight", float),
   ],
)

ex3 = Exercise("Deadlifts", 8, 3, 69.0)
ex3
Exercise(name='Deadlifts', reps=8, sets=3, weight=69.0)

Pero sacrificarás la legibilidad, por lo que no recomiendo utilizar esta función.

Clases avanzadas de datos en Python

En esta sección, hablaremos de las características avanzadas de las clases de datos que aportan más ventajas. Una de ellas es la fábrica por defecto.

Fábricas por defecto

Para explicar las fábricas por defecto, vamos a crear otra clase llamada WorkoutSession que acepte dos campos:

from dataclasses import dataclass
from typing import List


@dataclass
class Exercise:
   name: str = "Push-ups"
   reps: int = 10
   sets: int = 3
   weight: float = 0


@dataclass
class WorkoutSession:
   exercises: List[Exercise]
   duration_minutes: int

Al utilizar el tipo List, estamos especificando que WorkoutSession acepta una lista de instancias de Exercise.

# Define the Exercise instances for HIIT training
ex1 = Exercise(name="Burpees", reps=15, sets=3)
ex2 = Exercise(name="Mountain Climbers", reps=20, sets=3)
ex3 = Exercise(name="Jump Squats", reps=12, sets=3)
exercises_monday = [ex1, ex2, ex3]

hiit_monday = WorkoutSession(exercises=exercises_monday, duration_minutes=30)

Ahora mismo, cada instancia de sesión de entrenamiento requiere que se inicialicen los ejercicios. Pero esto no refleja la forma en que la gente se ejercita: primero, inician una sesión (probablemente en una aplicación), y luego van añadiendo ejercicios a medida que se ejercitan.

Por tanto, debemos ser capaces de crear sesiones sin ejercicios y sin duración. Hagámoslo añadiendo una lista vacía como valor por defecto para exercises:

@dataclass
class WorkoutSession:
   exercises: List[Exercise] = []
   duration_minutes: int = None


hiit_monday = WorkoutSession("25-02-2024")
ValueError: mutable default <class 'list'> for field exercises is not allowed: use default_factory

Sin embargo, obtuvimos un error: resulta que las clases de datos no permiten valores por defecto mutables.

Afortunadamente, podemos solucionarlo utilizando una fábrica por defecto:

@dataclass
class WorkoutSession:
   exercises: List[Exercise] = field(default_factory=list)  # PAY ATTENTION
   duration_minutes: int = 0


hiit_monday = WorkoutSession()
hiit_monday
WorkoutSession(exercises=[], duration_minutes=0)

El parámetro default_factory acepta una función que devuelve un valor inicial para un campo de clase de datos. Esto significa que puede aceptar cualquier función arbitraria:

  • tuple
  • dict
  • set
  • Cualquier función personalizada definida por el usuario

Esto es exacto independientemente de si el resultado de la función es mutable o no.

Ahora bien, si lo pensamos bien, la mayoría de la gente empieza su entrenamiento con ejercicios de calentamiento que suelen ser similares para cualquier tipo de entrenamiento. Por lo tanto, inicializar las sesiones sin ejercicios puede no ser lo que algunas personas desean.

En su lugar, creemos una función que devuelva tres calentamientos Exercises:

def create_warmup():
   return [
       Exercise("Jumping jacks", 30, 1),
       Exercise("Squat lunges", 10, 2),
       Exercise("High jumps", 20, 1),
   ]

@dataclass
class WorkoutSession:
   exercises: List[Exercise] = field(default_factory=create_warmup)
   duration_minutes: int = 5  # Increase the default duration as well


hiit_monday = WorkoutSession()
hiit_monday

WorkoutSession(exercises=[Exercise(name='Jumping jacks', reps=30, sets=1, weight=0), Exercise(name='Squat lunges', reps=10, sets=2, weight=0), Exercise(name='High jumps', reps=20, sets=1, weight=0)], duration_minutes=5)

Ahora, cada vez que creemos una sesión, vendrá con algunos ejercicios de calentamiento ya registrados. La nueva versión de WorkoutSession tiene una duración por defecto de cinco minutos para tenerlo en cuenta.

Añadir métodos a las clases de datos

Como las clases de datos son clases normales, añadirles métodos sigue siendo lo mismo. Vamos a añadir dos métodos a nuestra clase de datos WorkoutSession:

@dataclass
class WorkoutSession:
   exercises: List[Exercise] = field(default_factory=create_warmup)
   duration_minutes: int = 5

   def add_exercise(self, exercise: Exercise):
       self.exercises.append(exercise)

   def increase_duration(self, minutes: int):
       self.duration_minutes += minutes

Utilizando estos métodos, ahora podemos registrar cualquier actividad nueva en una sesión:

hiit_monday = WorkoutSession()

# Log a new exercise
new_exercise = Exercise("Deadlifts", 6, 4, 60)

hiit_monday.add_exercise(new_exercise)
hiit_monday.increase_duration(15)

Pero hay un problema:

hiit_monday

WorkoutSession(exercises=[Exercise(name='Jumping jacks', reps=30, sets=1, weight=0), Exercise(name='Squat lunges', reps=10, sets=2, weight=0), Exercise(name='High jumps', reps=20, sets=1, weight=0), Exercise(name='Deadlifts', reps=6, sets=4, weight=60)], duration_minutes=20)

Cuando imprimimos la sesión, su representación por defecto es demasiado verbosa e ilegible, ya que contiene el código para recrear el objeto. Arreglémoslo.

__repr__ y __str__ en clases de datos

Las clases de datos implementan __repr__ automáticamente, pero no __str__. Esto hace que la clase vuelva a __repr__ cuando llamemos a imprimir en ella.

Así que vamos a anular este comportamiento definiendo __str__ por nuestra cuenta:

@dataclass
class Exercise:
   name: str = "Push-ups"
   reps: int = 10
   sets: int = 3
   weight: float = 0

   def __str__(self):
       base = f"{self.name}: {self.reps}/{self.sets}"
       if self.weight == 0:
           return base
       return base + f", {self.weight} lbs"


ex1 = Exercise(name="Burpees", reps=15, sets=3)
ex1
Exercise(name='Burpees', reps=15, sets=3, weight=0)

El __repr__ sigue siendo el mismo, pero cuando llamamos a print sobre él:

print(ex1)
Burpees: 15/3

La representación primaveral de la clase es mucho más agradable. Ahora, arreglemos también WorkoutSession:

@dataclass
class WorkoutSession:
   exercises: List[Exercise] = field(default_factory=create_warmup)
   duration_minutes: int = 5  # Increase the default duration as well

   def add_exercise(self, exercise: Exercise):
       self.exercises.append(exercise)

   def increase_duration(self, minutes: int):
       self.duration_minutes += minutes

   def __str__(self):
       base = ""

       for ex in self.exercises:
           base += str(ex) + "\n"
       base += f"\nSession duration: {self.duration_minutes} minutes."

       return base


hiit_monday = WorkoutSession()
print(hiit_monday)

Jumping jacks: 30/1
Squat lunges: 10/2
High jumps: 20/1

Session duration: 5 minutes.

Nota: Utiliza el botón "Explicar código" situado en la parte inferior del fragmento para obtener una explicación del código línea por línea.

Ahora tenemos un resultado legible y compacto.

Comparación en clases de datos

Para muchas clases, tiene sentido comparar sus objetos mediante alguna lógica. Para los entrenamientos, puede ser la duración del entrenamiento, la intensidad del ejercicio o el peso.

En primer lugar, veamos qué ocurre si intentamos comparar dos entrenamientos en el estado actual:

hiit_wednesday = WorkoutSession()

hiit_wednesday.add_exercise(Exercise("Pull-ups", 7, 3))
print(hiit_wednesday)

Jumping jacks: 30/1
Squat lunges: 10/2
High jumps: 20/1
Pull-ups: 7/3

Session duration: 5 minutes.

hiit_monday > hiit_wednesday
TypeError: '>' not supported between instances of 'WorkoutSession' and 'WorkoutSession'

Recibimos un TypeError ya que las clases de datos no implementan operadores de comparación. Pero esto se arregla fácilmente ajustando el parámetro order a True:

@dataclass(order=True)
class WorkoutSession:
   exercises: List[Exercise] = field(default_factory=create_warmup)
   duration_minutes: int = 5

   ...

hiit_monday = WorkoutSession()
# hiit_monday.add_exercise(...)
hiit_monday.increase_duration(10)

hiit_wednesday = WorkoutSession()

hiit_monday > hiit_wednesday

True

Esta vez, la comparación funciona, pero ¿qué estamos comparando?

En las clases de datos, la comparación se realiza en el orden en que están definidos los campos. Ahora mismo, las clases se comparan en función de la duración del entrenamiento, ya que el primer campo, exercises, contiene objetos no estándar.

Podemos comprobarlo aumentando la duración de la sesión del miércoles:

hiit_monday = WorkoutSession()
# hiit_monday.add_exercise(...)

hiit_wednesday = WorkoutSession()
hiit_wednesday.increase_duration(10)

hiit_monday > hiit_wednesday
False

Como era de esperar, recibimos False.

Pero, ¿qué pasaría si el primer campo de Workout fuera otro tipo de campo, por ejemplo, una cadena? Intentemos averiguarlo:

@dataclass(order=True)
class WorkoutSession:
   date: str = None  # DD-MM-YYYY
   exercises: List[Exercise] = field(default_factory=create_warmup)
   duration_minutes: int = 5

   ...

hiit_monday = WorkoutSession("25-02-2024")
hiit_monday.increase_duration(10)

hiit_wednesday = WorkoutSession("27-02-2024")

hiit_monday > hiit_wednesday
False

Aunque la sesión del lunes dura más, la comparación nos dice que es menor que la del miércoles. La razón es que "25" va antes que "27" en la comparación de cadenas de Python.

Entonces, ¿cómo mantenemos el orden de los campos y seguimos clasificando las sesiones en función de la duración del entrenamiento? Esto es fácil a través de la función field:

@dataclass(order=True)
class WorkoutSession:
   date: str = field(default=None, compare=False)
   exercises: List[Exercise] = field(default_factory=create_warmup)
   duration_minutes: int = 5

   ...

hiit_monday = WorkoutSession("25-02-2024")
hiit_monday.increase_duration(10)

hiit_wednesday = WorkoutSession("27-02-2024")

hiit_monday > hiit_wednesday
True

Al establecer compare en False para cualquier campo, lo excluimos de la ordenación, como demuestra el resultado anterior.

Manipulación del campo post-init

Ahora mismo, tenemos una duración de sesión por defecto de cinco minutos para tener en cuenta los ejercicios de calentamiento. Sin embargo, esto sólo tiene sentido si un usuario inicia una sesión con un calentamiento. ¿Y si empiezan una sesión con otros ejercicios?

new_session = WorkoutSession([Exercise("Diamond push-ups", 10, 3)])

new_session.duration_minutes
5

Para un solo ejercicio, la duración total es de cinco minutos, lo cual es ilógico. Cada sesión debe adivinar dinámicamente su duración en función del número de series de cada ejercicio. Esto significa que debemos hacer que duration_minutes dependa del campo exercises.

Pongámoslo en práctica:

@dataclass
class WorkoutSession:
   exercises: List[Exercise] = field(default_factory=create_warmup)
   duration_minutes: int = field(default=0, init=False)

   def __post_init__(self):
       set_duration = 3
       for ex in self.exercises:
           self.duration_minutes += ex.sets * set_duration

   ...

Esta vez, definimos duration_minutes con init ajustado a False para retrasar la inicialización del campo.

Luego, dentro de un método especial __post_init__, vamos actualizando su valor en función del número total de conjuntos de cada Exercise.

Ahora, cuando inicializamos WorkoutSession, el duration_minutes se incrementa dinámicamente en tres minutos para cada serie de cada ejercicio.

# Adding an exercise with three sets
hiit_friday = WorkoutSession([Exercise("Diamond push-ups", 10, 3)])

hiit_friday.duration_minutes
9

En general, si quieres definir un campo que dependa de otros campos de tu clase de datos, puedes utilizar la lógica __post_init__.

Inmutabilidad en las Clases de Datos

Nuestra clase de datos WorkoutSession está casi lista; sólo hay que protegerla. Ahora mismo, se puede estropear con bastante facilidad:

hiit_friday.duration_minutes = 1000

hiit_friday.duration_minutes
1000

del hiit_friday.exercises

Queremos proteger todos los campos de nuestras clases para que sólo se puedan modificar de la forma que nosotros queramos. Para ello, el decorador @dataclass ofrece un cómodo argumento frozen:

@dataclass(frozen=True)
class FrozenExercise:
   name: str
   reps: int
   sets: int
   weight: int | float = 0


ex1 = FrozenExercise("Muscle-ups", 5, 3)

Ahora, si queremos modificar algún campo, obtenemos un error:

ex1.sets = 5
FrozenInstanceError: cannot assign to field 'sets'

Si estableces frozen en True, añadirás automáticamente los métodos __deleteattr__ y __setattr__ para cada campo, de modo que queden protegidos de la eliminación o las actualizaciones tras la inicialización. Además, los demás tampoco podrán añadir nuevos campos:

ex1.new_field = 10
FrozenInstanceError: cannot assign to field 'new_field'

Esta funcionalidad tendría decenas de líneas de código si se tratara de clases tradicionales.

Sin embargo, ten en cuenta que no podemos hacer que nuestras clases sean realmente inmutables. Por ejemplo, reescribamos el WorkoutSession con frozen ajustado a True:

@dataclass(frozen=True)
class ImmutableWorkoutSession:
   exercises: List[Exercise] = field(default_factory=create_warmup)
   duration_minutes: int = 5


session1 = ImmutableWorkoutSession()

Como era de esperar, no podemos modificar directamente la lista de ejercicios:

session1.exercises = [Exercise()]

Sin embargo, exercises es una lista, que es totalmente mutable, lo que hace posible la siguiente operación:

# Cambiar uno de los elementos de una lista

# Changing one of the elements in a list
session1.exercises[1] = FrozenExercise("Totally new exercise", 5, 5)

print(session1)

ImmutableWorkoutSession(exercises=[Exercise(name='Jumping jacks', reps=30, sets=1, weight=0), FrozenExercise(name='Totally new exercise', reps=5, sets=5, weight=0), Exercise(name='High jumps', reps=20, sets=1, weight=0)], duration_minutes=5)

Así que, para protegerte de cambios accidentales, se recomienda utilizar objetos inmutables, como las tuplas, para los valores de los campos.

Herencia en clases de datos

Un último punto que trataremos es el orden de los campos en las clases padre e hijo.

Como las clases de datos son clases normales, la herencia funciona como de costumbre:

@dataclass(frozen=True)
class ImmutableWorkoutSession:
   exercises: List[Exercise] = field(default_factory=create_warmup)
   duration_minutes: int = 5


@dataclass(frozen=True)
class CardioWorkoutSession(ImmutableWorkoutSession):
   pass

Pero, como el último campo de la clase padre (ImmutableWorkoutSession) tiene un valor por defecto, todos los campos de las clases hijas deben tener valores por defecto.

Por ejemplo, esto no está permitido:

@dataclass(frozen=True)
class ImmutableWorkoutSession:
   exercises: List[Exercise] = field(default_factory=create_warmup)
   duration_minutes: int = 5


@dataclass(frozen=True)
class CardioWorkoutSession(ImmutableWorkoutSession):
   intensity_level: str  # Not allowed, must have a default

TypeError: non-default argument 'intensity_level' follows default argument

Desventajas de las clases de datos y otros recursos

Las clases de datos han ido mejorando constantemente desde Python 3.7 (ya eran geniales al principio) y cubren muchos casos de uso en los que podrías necesitar escribir clases. Pero pueden ser desventajosas en los siguientes supuestos:

  • Métodos personalizados __init__
  • Métodos personalizados __new__
  • Varios modelos de herencia

Y muchas más, como se comenta en este estupendo hilo de Reddit. Si quieres una explicación más detallada de por qué se introdujeron las clases de datos y por qué no son sustitutos directos de las definiciones de clases normales, lee PEP 557.

Si te interesa la programación orientada a objetos en general, aquí tienes un curso para continuar tu viaje:

Básicamente, las clases de datos son estructuras más sofisticadas para almacenar y recuperar datos de forma más eficaz. Sin embargo, Python tiene muchas otras estructuras de datos que realizan esta tarea más o menos de forma similar. Por ejemplo, puedes aprender sobre contadores, defaultdicts y namedtuples en el último capítulo del curso Tipos de Datos para la Ciencia de Datos.


Bex Tuychiev's photo
Author
Bex Tuychiev
LinkedIn

Soy un creador de contenidos de ciencia de datos con más de 2 años de experiencia y uno de los mayores seguidores en Medium. Me gusta escribir artículos detallados sobre IA y ML con un estilo un poco sarcastıc, porque hay que hacer algo para que sean un poco menos aburridos. He publicado más de 130 artículos y un curso DataCamp, y estoy preparando otro. Mi contenido ha sido visto por más de 5 millones de ojos, 20.000 de los cuales se convirtieron en seguidores tanto en Medium como en LinkedIn. 

Temas

Sigue aprendiendo Python

Certificación disponible

curso

Python intermedio

4 hr
1.2M
Mejora tus conocimientos de ciencia de datos creando visualizaciones con Matplotlib y manipulando DataFrames con pandas.
Ver detallesRight Arrow
Comienza el curso
Ver másRight Arrow