Python & AI Tutorials Logo
Programación Python

33. Data classes para datos estructurados simples

En el Capítulo 30, aprendimos cómo crear clases para definir nuestros propios tipos. Escribimos métodos __init__ para inicializar instancias, métodos __repr__ para mostrarlas y métodos __eq__ para compararlas. Aunque este enfoque funciona perfectamente, implica escribir mucho código repetitivo, especialmente cuando una clase existe principalmente para almacenar datos.

Las clases de datos (data classes) de Python proporcionan una forma más limpia y concisa de crear clases que son principalmente contenedores de datos. Al usar el decorador @dataclass, Python genera automáticamente métodos comunes como __init__, __repr__ y __eq__ basándose en los atributos de clase que defines. Esto reduce el código repetitivo (boilerplate) y hace que tus intenciones sean más claras.

33.1) Qué son las data classes y cuándo usarlas

Una clase de datos (data class) es una clase diseñada principalmente para almacenar valores de datos. En lugar de escribir manualmente métodos de inicialización y comparación, defines los atributos que tu clase debe tener, y Python genera automáticamente los métodos necesarios.

Por qué importan las data classes

Considera una clase normal para representar un libro:

python
class Book:
    def __init__(self, title, author, year):
        self.title = title
        self.author = author
        self.year = year
    
    def __repr__(self):
        return f"Book(title={self.title!r}, author={self.author!r}, year={self.year})"
    
    def __eq__(self, other):
        if not isinstance(other, Book):
            return False
        return (self.title == other.title and 
                self.author == other.author and 
                self.year == other.year)
 
book1 = Book("1984", "George Orwell", 1949)
print(book1)  # Output: Book(title='1984', author='George Orwell', year=1949)
 
book2 = Book("1984", "George Orwell", 1949)
print(book1 == book2)  # Output: True

Esto funciona, pero fíjate en cuánto código escribimos solo para almacenar tres piezas de información. Los métodos __init__, __repr__ y __eq__ siguen patrones predecibles: simplemente manejan los atributos que definimos.

Las data classes eliminan esta repetición. Son especialmente útiles cuando:

  • Tu clase almacena principalmente datos en lugar de implementar un comportamiento complejo
  • Necesitas métodos estándar como inicialización, representación en forma de string y comparación de igualdad
  • Quieres un código más claro y mantenible con menos boilerplate
  • Estás creando objetos de configuración, objetos de transferencia de datos o registros simples

Las data classes no reemplazan a las clases normales: las complementan. Usa clases normales cuando necesites lógica de inicialización personalizada, métodos complejos o jerarquías de herencia. Usa data classes cuando principalmente necesites un contenedor estructurado para datos relacionados.

La relación entre las data classes y las clases normales

Las data classes siguen siendo clases normales de Python. Soportan todas las características que aprendimos en los Capítulos 30-32: métodos, propiedades, herencia y métodos especiales. El decorador @dataclass simplemente automatiza la creación de métodos comunes, evitando que tengas que escribir código repetitivo.

Clase normal

init manual

repr manual

eq manual

Métodos personalizados

Clase de datos

Decorador @dataclass

init autogenerado

repr autogenerado

eq autogenerado

Métodos personalizados

33.2) Crear data classes con @dataclass

Para crear una data class, importas el decorador dataclass desde el módulo dataclasses y lo aplicas a la definición de tu clase. Dentro de la clase, defines atributos de clase con anotaciones de tipo que especifican qué datos debe contener la clase.

Sintaxis básica de una data class

python
from dataclasses import dataclass
 
@dataclass
class Student:
    name: str
    student_id: int
    gpa: float
 
# Create instances
alice = Student("Alice Johnson", 12345, 3.8)
bob = Student("Bob Smith", 12346, 3.5)
 
print(alice)  # Output: Student(name='Alice Johnson', student_id=12345, gpa=3.8)
print(bob)    # Output: Student(name='Bob Smith', student_id=12346, gpa=3.5)

Desglosemos lo que hace el decorador @dataclass:

  1. @dataclass: Al aplicar este decorador, Python escribe automáticamente por ti los métodos __init__, __repr__ y __eq__

  2. __init__ automático: Python crea un método de inicialización que acepta estos tres parámetros en el orden en que están definidos y los asigna a atributos de instancia

  3. __repr__ automático: Python crea una representación en forma de string que muestra el nombre de la clase y todos los valores de los atributos

  4. __eq__ automático: Python crea un método de comparación de igualdad que compara todos los atributos

  5. Convierte las anotaciones de tipo en atributos de instancia: En una clase normal, escribir name: str en el cuerpo de la clase crea un atributo de clase. Pero el decorador @dataclass cambia este comportamiento: usa estas anotaciones de tipo para definir atributos de instancia en su lugar. Cada instancia obtiene sus propios atributos name, student_id y gpa.

La diferencia clave con las clases normales:

python
# Clase normal: estos son atributos de clase (compartidos por todas las instancias)
class RegularStudent:
    name: str
    student_id: int
 
# Data class: estos se convierten en atributos de instancia (cada instancia tiene los suyos)
@dataclass
class DataStudent:
    name: str
    student_id: int

Comprender las anotaciones de tipo en las data classes

En las data classes, las anotaciones de tipo definen los atributos y documentan sus tipos esperados:

python
from dataclasses import dataclass
 
@dataclass
class Product:
    name: str
    price: float
    in_stock: bool
 
# Usar los tipos correctos según la documentación
laptop = Product("Laptop", 999.99, True)
print(laptop)  # Output: Product(name='Laptop', price=999.99, in_stock=True)
 
# Python no aplica los tipos: esto se ejecuta sin error
macbook = Product("Macbook", "expensive", True)
print(macbook)  # Output: Product(name='Macbook', price='expensive', in_stock=True)
 
# Pero usar tipos incorrectos causará problemas más adelante:
discounted = laptop.price * 0.9     # Works: 899.991
discounted = macbook.price * 0.9    # TypeError: can't multiply sequence by non-int of type 'float'
 
tax = laptop.price + 50             # Works: 1049.99
tax = macbook.price + 50            # TypeError: can only concatenate str (not "int") to str

Python no te impedirá pasar tipos incorrectos al crear una instancia de una data class. Las anotaciones de tipo son principalmente documentación: les dicen a otros programadores (y a herramientas de comprobación de tipos como mypy) qué tipos esperas, pero Python no las aplica en tiempo de ejecución. Esto es coherente con la filosofía de tipado dinámico de Python.

Sin embargo, seguir las anotaciones de tipo hace que tu código sea más predecible y más fácil de depurar (debugging). Cuando usas tipos incorrectos, los errores aparecerán más tarde cuando intentes usar los datos, lo que hace que los errores (bugs) sean más difíciles de rastrear. Las herramientas de comprobación de tipos pueden detectar estas discrepancias antes de que ejecutes tu código, ayudándote a encontrar problemas pronto.

Acceder y modificar atributos

Las instancias de data classes funcionan exactamente igual que las instancias de clases normales. Accedes y modificas atributos usando notación de punto:

python
from dataclasses import dataclass
 
@dataclass
class Employee:
    name: str
    position: str
    salary: float
 
emp = Employee("Sarah Chen", "Software Engineer", 95000.0)
 
# Access attributes
print(emp.name)      # Output: Sarah Chen
print(emp.position)  # Output: Software Engineer
 
# Modify attributes
emp.salary = 100000.0
emp.position = "Senior Software Engineer"
 
print(emp)  # Output: Employee(name='Sarah Chen', position='Senior Software Engineer', salary=100000.0)

Las data classes son mutables por defecto: puedes cambiar sus atributos después de crearlas. Esto es diferente de las tuplas o las named tuples, que son inmutables. Si necesitas inmutabilidad, puedes configurar la data class con frozen=True (lo exploraremos en la Sección 33.4).

33.3) Métodos generados: __init__, __repr__ y __eq__

El decorador @dataclass genera automáticamente tres métodos esenciales. Comprender qué hacen estos métodos te ayuda a usar las data classes de manera efectiva y a saber cuándo personalizarlas.

El método __init__ generado

El método __init__ inicializa una nueva instancia con los valores proporcionados. Python lo genera basándose en el orden de las definiciones de tus atributos:

python
from dataclasses import dataclass
 
@dataclass
class Rectangle:
    width: float
    height: float
 
# El __init__ generado acepta width y height en ese orden
rect = Rectangle(10.5, 5.0)
print(rect.width)   # Output: 10.5
print(rect.height)  # Output: 5.0
 
# También puedes usar argumentos con nombre
rect2 = Rectangle(height=8.0, width=12.0)
print(rect2.width)   # Output: 12.0
print(rect2.height)  # Output: 8.0

El __init__ generado es equivalente a escribir:

python
def __init__(self, width: float, height: float):
    self.width = width
    self.height = height

Esta generación automática te evita escribir código de inicialización repetitivo, especialmente para clases con muchos atributos.

El método __repr__ generado

El método __repr__ proporciona una representación en forma de string de la instancia que muestra todos los valores de los atributos. Esto es muy útil para depurar (debugging) y para hacer logging:

python
from dataclasses import dataclass
 
@dataclass
class Point:
    x: float
    y: float
    label: str
 
point = Point(3.5, 7.2, "A")
print(point)  # Output: Point(x=3.5, y=7.2, label='A')
print(repr(point))  # Output: Point(x=3.5, y=7.2, label='A')

El __repr__ generado sigue la convención de mostrar el nombre de la clase y todos los atributos en un formato que podría usarse para recrear el objeto. Esto es mucho más útil que la representación predeterminada que obtendrías sin __repr__: <__main__.Point object at 0x...>.

El método __eq__ generado

El método __eq__ permite la comparación de igualdad entre instancias. Se considera que dos instancias de una data class son iguales si todos sus atributos correspondientes son iguales:

python
from dataclasses import dataclass
 
@dataclass
class Color:
    red: int
    green: int
    blue: int
 
color1 = Color(255, 0, 0)
color2 = Color(255, 0, 0)
color3 = Color(0, 255, 0)
 
print(color1 == color2)  # Output: True (same RGB values)
print(color1 == color3)  # Output: False (different RGB values)
print(color1 is color2)  # Output: False (different objects in memory)

Esta comparación automática de igualdad se basa en igualdad por valor, no en identidad. Aunque color1 y color2 son objetos distintos en memoria (como muestra is), se consideran iguales porque sus atributos coinciden.

El método __eq__ generado compara atributos en el orden en que se definen:

python
from dataclasses import dataclass
 
@dataclass
class Book:
    title: str
    author: str
    year: int
 
book1 = Book("1984", "George Orwell", 1949)
book2 = Book("1984", "George Orwell", 1949)
book3 = Book("Animal Farm", "George Orwell", 1945)
 
print(book1 == book2)  # Output: True (all attributes match)
print(book1 == book3)  # Output: False (title and year differ)
 
# Comparison with non-Book objects returns False
print(book1 == "1984")  # Output: False
print(book1 == None)    # Output: False

Comparar métodos generados con una implementación manual

Para apreciar lo que ofrecen las data classes, comparemos la versión de data class con una implementación manual:

python
from dataclasses import dataclass
 
# Data class version (concise)
@dataclass
class PersonData:
    first_name: str
    last_name: str
    age: int
 
# Equivalent manual version (verbose)
class PersonManual:
    def __init__(self, first_name: str, last_name: str, age: int):
        self.first_name = first_name
        self.last_name = last_name
        self.age = age
    
    def __repr__(self):
        return f"PersonManual(first_name={self.first_name!r}, last_name={self.last_name!r}, age={self.age})"
    
    def __eq__(self, other):
        if not isinstance(other, PersonManual):
            return False
        return (self.first_name == other.first_name and
                self.last_name == other.last_name and
                self.age == other.age)
 
# Both work identically
p1 = PersonData("Alice", "Johnson", 30)
p2 = PersonManual("Alice", "Johnson", 30)
 
print(p1)  # Output: PersonData(first_name='Alice', last_name='Johnson', age=30)
print(p2)  # Output: PersonManual(first_name='Alice', last_name='Johnson', age=30)

La versión de data class logra la misma funcionalidad con significativamente menos código. Esta reducción de boilerplate hace que tu código sea más fácil de leer, mantener y modificar.

Añadir métodos personalizados a las data classes

Las data classes pueden tener métodos personalizados igual que las clases normales. El decorador @dataclass solo genera los métodos de inicialización, representación e igualdad: eres libre de añadir cualquier otra funcionalidad:

python
from dataclasses import dataclass
 
@dataclass
class Temperature:
    celsius: float
    
    def to_fahrenheit(self):
        """Convert temperature to Fahrenheit."""
        return (self.celsius * 9/5) + 32
    
    def to_kelvin(self):
        """Convert temperature to Kelvin."""
        return self.celsius + 273.15
    
    def is_freezing(self):
        """Check if temperature is at or below freezing point."""
        return self.celsius <= 0
 
temp = Temperature(25.0)
print(temp)  # Output: Temperature(celsius=25.0)
print(f"{temp.celsius}°C = {temp.to_fahrenheit()}°F")  # Output: 25.0°C = 77.0°F
print(f"Kelvin: {temp.to_kelvin()}")  # Output: Kelvin: 298.15
print(f"Freezing: {temp.is_freezing()}")  # Output: Freezing: False
 
cold_temp = Temperature(-5.0)
print(f"Freezing: {cold_temp.is_freezing()}")  # Output: Freezing: True

Las data classes se encargan de las partes repetitivas (inicialización, representación y comparación) mientras te permiten añadir métodos personalizados para tus necesidades específicas, como se muestra arriba con los métodos de conversión de temperatura.

33.4) Valores predeterminados y opciones de campo

Las data classes admiten valores predeterminados para atributos, lo que te permite crear instancias sin especificar cada parámetro. También puedes usar la función field() para configurar comportamientos avanzados, como excluir atributos de comparaciones o controlar cómo aparecen en la representación en forma de string.

Proporcionar valores predeterminados

Puedes asignar valores predeterminados a los atributos directamente en la definición de la clase. Los atributos con valores predeterminados deben ir después de los atributos sin valores predeterminados:

python
from dataclasses import dataclass
 
@dataclass
class User:
    username: str
    email: str
    is_active: bool = True  # Valor predeterminado
    role: str = "user"      # Valor predeterminado
 
# Create instances with and without defaults
user1 = User("alice", "alice@example.com")
print(user1)  # Output: User(username='alice', email='alice@example.com', is_active=True, role='user')
 
user2 = User("bob", "bob@example.com", False, "admin")
print(user2)  # Output: User(username='bob', email='bob@example.com', is_active=False, role='admin')
 
# Use keyword arguments to override specific defaults
user3 = User("charlie", "charlie@example.com", role="moderator")
print(user3)  # Output: User(username='charlie', email='charlie@example.com', is_active=True, role='moderator')

La regla de orden (atributos sin valores predeterminados antes de atributos con valores predeterminados) evita ambigüedad en el método __init__ generado. Este es el mismo requisito que para parámetros de función con valores predeterminados, que aprendimos en el Capítulo 20.

Valores predeterminados mutables y por qué no se permiten

Las data classes te protegen de un error común con valores predeterminados mutables. Si intentas usar un objeto mutable como una lista o un diccionario directamente como valor predeterminado, obtendrás un error:

python
from dataclasses import dataclass
 
# Esto generará un error
@dataclass
class ShoppingCart:
    customer: str
    items: list = []  # ValueError: mutable default <class 'list'> for field items is not allowed: use default_factory

Este error evita el mismo problema que vimos con los argumentos predeterminados de funciones en el Capítulo 20, donde todas las instancias compartirían el mismo objeto mutable.

Usar field() con default_factory para valores predeterminados mutables

La solución es usar la función field() con default_factory, que crea un nuevo valor predeterminado para cada instancia:

python
from dataclasses import dataclass, field
 
@dataclass
class ShoppingCart:
    customer: str
    items: list = field(default_factory=list)  # Correcto: nueva lista por instancia
 
# Ahora cada instancia obtiene su propia lista
cart1 = ShoppingCart("Alice")
cart1.items.append("Book")
print(cart1.items)  # Output: ['Book']
 
cart2 = ShoppingCart("Bob")
print(cart2.items)  # Output: [] - Bob has an empty list
 
cart2.items.append("Laptop")
print(cart1.items)  # Output: ['Book'] - Alice's cart unchanged
print(cart2.items)  # Output: ['Laptop'] - Bob's cart independent

El parámetro default_factory toma una función (como list, dict o set) que se llamará para crear un nuevo valor predeterminado cada vez que crees una instancia sin proporcionar ese atributo. Por ejemplo, default_factory=list significa que Python llamará a list() para crear una nueva lista vacía para cada instancia.

Excluir campos de la comparación

A veces quieres que ciertos atributos se excluyan de las comparaciones de igualdad. Usa field(compare=False) para esto:

python
from dataclasses import dataclass, field
from datetime import datetime
 
@dataclass
class LogEntry:
    message: str
    level: str
    timestamp: datetime = field(compare=False)  # No comparar marcas de tiempo
 
# Create two log entries with the same message but different times
entry1 = LogEntry("User logged in", "INFO", datetime(2024, 1, 15, 10, 30))
entry2 = LogEntry("User logged in", "INFO", datetime(2024, 1, 15, 10, 35))
 
# They're equal because timestamp is excluded from comparison
print(entry1 == entry2)  # Output: True
 
# But they have different timestamps
print(entry1.timestamp)  # Output: 2024-01-15 10:30:00
print(entry2.timestamp)  # Output: 2024-01-15 10:35:00

Esto es útil cuando tienes campos de metadatos (como marcas de tiempo, IDs o contadores internos) que no deberían afectar a si dos instancias se consideran iguales.

Excluir campos de la representación

También puedes excluir campos de la representación en forma de string usando field(repr=False):

python
from dataclasses import dataclass, field
 
@dataclass
class Account:
    username: str
    email: str
    password: str = field(repr=False)  # No mostrar la contraseña en repr
 
account = Account("alice", "alice@example.com", "secret123")
print(account)  # Output: Account(username='alice', email='alice@example.com')
# Password is not shown, but it's still stored
print(account.password)  # Output: secret123

Esto es particularmente útil para datos sensibles como contraseñas, claves de API o estructuras de datos grandes que saturarían la representación.

Hacer que las data classes sean inmutables con frozen=True

Por defecto, las instancias de data classes son mutables: puedes cambiar sus atributos después de crearlas. Si quieres instancias inmutables (como tuplas), usa frozen=True:

python
from dataclasses import dataclass
 
@dataclass(frozen=True)
class Point:
    x: float
    y: float
 
point = Point(3.0, 4.0)
print(point)  # Output: Point(x=3.0, y=4.0)
 
# Attempting to modify raises an error
try:
    point.x = 5.0
except AttributeError as e:
    print(f"Error: {e}")  # Output: Error: cannot assign to field 'x'

Las data classes congeladas son útiles cuando quieres garantizar la integridad de los datos o usar instancias como claves de diccionario (ya que las claves de diccionario deben ser inmutables). Cuando una data class está congelada, Python también genera un método __hash__, haciendo que las instancias sean hashables:

python
from dataclasses import dataclass
 
@dataclass(frozen=True)
class Coordinate:
    latitude: float
    longitude: float
 
# Frozen instances can be dictionary keys
locations = {
    Coordinate(40.7128, -74.0060): "New York",
    Coordinate(51.5074, -0.1278): "London",
    Coordinate(35.6762, 139.6503): "Tokyo"
}
 
nyc = Coordinate(40.7128, -74.0060)
print(locations[nyc])  # Output: New York

33.5) Inicialización personalizada con __post_init__

A veces necesitas realizar una configuración adicional después de que se ejecute el método __init__ generado. El método __post_init__ se llama automáticamente después de la inicialización, lo que te permite validar datos, calcular atributos derivados o realizar otras tareas de configuración.

Uso básico de __post_init__

El método __post_init__ se llama después de que todos los atributos han sido establecidos por el __init__ generado:

python
from dataclasses import dataclass
 
@dataclass
class Rectangle:
    width: float
    height: float
    area: float = 0.0  # Se calculará en __post_init__
    
    def __post_init__(self):
        """Calculate area after initialization."""
        self.area = self.width * self.height
 
rect = Rectangle(5.0, 3.0)
print(rect)  # Output: Rectangle(width=5.0, height=3.0, area=15.0)
print(f"Area: {rect.area}")  # Output: Area: 15.0

El método __post_init__ tiene acceso a todos los atributos de instancia que fueron establecidos durante la inicialización. Esto es útil para calcular valores derivados que dependen de múltiples atributos.

Validar datos en post_init

Un uso común de __post_init__ es validar que los datos proporcionados cumplan ciertos requisitos:

python
from dataclasses import dataclass
 
@dataclass
class BankAccount:
    account_number: str
    balance: float
    
    def __post_init__(self):
        """Validate account data."""
        if self.balance < 0:
            raise ValueError("Balance cannot be negative")
 
# Valid account
account1 = BankAccount("ACC001", 1000.0)
print(account1)  # Output: BankAccount(account_number='ACC001', balance=1000.0)
 
# Invalid account - negative balance
try:
    account2 = BankAccount("ACC002", -500.0)
except ValueError as e:
    print(f"Error: {e}")  # Output: Error: Balance cannot be negative

Esta validación asegura que las instancias siempre estén en un estado válido. Si los datos no cumplen los requisitos, la instancia nunca se crea, evitando que existan objetos inválidos en tu programa.

Usar post_init con field(init=False)

A veces quieres un atributo que se calcule en __post_init__ pero que no debería ser un parámetro en __init__. Usa field(init=False) para esto:

python
from dataclasses import dataclass, field
import math
 
@dataclass
class Circle:
    radius: float
    area: float = field(init=False)  # No es un parámetro de __init__
    circumference: float = field(init=False)
    
    def __post_init__(self):
        """Compute area and circumference from radius."""
        self.area = math.pi * self.radius ** 2
        self.circumference = 2 * math.pi * self.radius
 
# Only radius is required during initialization
circle = Circle(5.0)
print(circle)  # Output: Circle(radius=5.0, area=78.53981633974483, circumference=31.41592653589793)
print(f"Area: {circle.area:.2f}")  # Output: Area: 78.54
print(f"Circumference: {circle.circumference:.2f}")  # Output: Circumference: 31.42

Este patrón es útil cuando tienes atributos que siempre se calculan a partir de otros atributos y nunca deberían establecerse directamente durante la inicialización.


Las data classes representan una característica moderna de Python que reduce el boilerplate manteniendo toda la potencia de las clases. Son particularmente valiosas para crear código limpio y legible al trabajar con datos estructurados. A medida que sigas aprendiendo Python, verás que las data classes se convierten en una elección natural para muchas tareas de programación centradas en datos, complementando las clases normales que aprendiste en los Capítulos 30-32.


© 2025. Primesoft Co., Ltd.
support@primesoft.ai