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:
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: TrueEsto 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.
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
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:
-
@dataclass: Al aplicar este decorador, Python escribe automáticamente por ti los métodos__init__,__repr__y__eq__ -
__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 -
__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 -
__eq__automático: Python crea un método de comparación de igualdad que compara todos los atributos -
Convierte las anotaciones de tipo en atributos de instancia: En una clase normal, escribir
name: stren el cuerpo de la clase crea un atributo de clase. Pero el decorador@dataclasscambia este comportamiento: usa estas anotaciones de tipo para definir atributos de instancia en su lugar. Cada instancia obtiene sus propios atributosname,student_idygpa.
La diferencia clave con las clases normales:
# 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: intComprender las anotaciones de tipo en las data classes
En las data classes, las anotaciones de tipo definen los atributos y documentan sus tipos esperados:
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 strPython 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:
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:
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.0El __init__ generado es equivalente a escribir:
def __init__(self, width: float, height: float):
self.width = width
self.height = heightEsta 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:
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:
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:
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: FalseComparar 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:
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:
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: TrueLas 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:
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:
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_factoryEste 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:
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 independentEl 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:
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:00Esto 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):
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: secret123Esto 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:
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:
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 York33.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:
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.0El 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:
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 negativeEsta 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:
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.42Este 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.