Posts | Tags | Categories | Archive

Dataclasses en python

Qué son las Dataclasses

Aunque para python sea algo nuevo, las dataclasses son bastante comunes en muchos lenguajes funcionales. Permiten crear tipos de datos estructurales con algunas características implementadas por defecto como la comparación, la ordenación o la descomposición.

Lamentablemente, la implementación que se incluye a partir de python 3.7 se queda algo corta y tiene pintas de que tendrá que revisarse en el futuro. Para una implementación más completa y estable se cuenta con la librería attr.s, compatible con más versiones de python, como PyPy o CPython 2.7, y cuyos desarrolladores contribuyeron a que el módulo estándar dataclasses tuviera un mínimo de usabilidad, aunque no fuera lo que hubieran deseado.

Una dataclase se puede considerar como una clase especializada en guardar estados, en vez de ser una representación de la lógica de la aplicación como siempre se ven las clases. Con las dataclases se pueden crear tipos de datos similares a los algebráicos en lo que respecta a las operaciones que se pueden hacer con ellos: comparar, ordenar, imprimir, indexar, inmutabilidad, etc. Muchas de estas características están implementadas por los llamados métodos mágicos ó métodos especiales de python (eg: __add__ para implementar la suma). Estos métodos mágicos se pueden agrupan para definir los llamados protocolos (eg: protocolo Iterador), de los que ya he hablado en algún artículo.

Comparando clases

Por empezar a ver algún ejemplo, supongamos que definimos una clase para los puntos en el plano:

class Point:
    def __init__(self, x: float, y: float):
        self.x = x
        self.y = y

Para que muestre una representación legible, implementamos el método __repr__():

class Point:
    def __init__(self, x: float, y: float):
        self.x = x
        self.y = y

    def __repr__(self):
        return f"Point(x={self.x:.1f}, y={self.y:.1f})"

Probamos:

>>> Point(1, 2)
Point(x=1.0, y=2.0)

Para poder comparar si dos puntos son iguales tenemos que añadir el método __eq__:

class Point:
    def __init__(self, x: float, y: float):
        self.x = x
        self.y = y

    def __repr__(self):
        return f"Point(x={self.x:.1f}, y={self.y:.1f})"

    def __eq__(self, other):
        return (self.x, self.y) == (other.x, other.y)

De este modo podemos hacer comprobaciones como Point(1.0, 2.0) != Point(2.0, 1.0). En realidad, para comprobar que no son iguales existe otro método específico, __ne__(), pero a falta de aquél se emplea __eq__() de modo equivalente.

Para comprobar si un punto es mayor o menor habría que implementar también los métodos __lt__(), __le__(), __gt__() y __ge__(), correspondientes a las operaciones <, <=, > y >= respectivamente. Bastaría con sólo una de estas operaciones y el método __eq__() para implementar el resto de métodos, que es precisamente lo que hace el decorador functools.total_ordering:

from functools import total_ordering

@total_ordering
class Point:
    def __init__(self, x: float, y: float):
        self.x = x
        self.y = y

    def __repr__(self):
        return f"Point(x={self.x:.1f}, y={self.y:.1f})"

    def __eq__(self, other):
        return (self.x, self.y) == (other.x, other.y)

    def __lt__(self, other):
        return (self.x, self.y) < (other.x, other.y)

El decorador total_ordering creará el resto de métodos de comparación que faltan a partir de __eq__() y __lt__(). Lamentablemente, sobrecarga bastante la clase debido a las dependencias que establece entre métodos, lo que baja bastante el rendimiento de nuestro código.

Se podrían seguir añadiendo manualmente más métodos para definir otras operaciones. El mayor incoveniente que vamos a tener, además de bajar el redimiento, es que a medida que añadimos métodos se complica más y más el mantenimiento. Si, por ejemplo, quisiéramos añadir un nuevo atributo supondría cambiar casi todos los métodos.

Dataclases

Las dataclases en python es una mejora del decorador functools.total_ordering. El decorador dataclass definirá por nosotros varios de de los métodos mágicos más comunes, pero sin establecer dependencias entre métodos tal como hacía functools.total_ordering.

Por ejemplo, la clase Point se podría haber construido de esta manera:

from dataclasses import dataclass

@dataclass(order=True)
class Point:
    x: float
    y: float

Por defecto, nos construye los métodos __init__(), __repr__() y __eq__ para inicializar, representar y comparar. Con el parámetro order=True le pedimos, además, que nos cree los métodos de ordenación, tal como hacía functools.total_ordering.

Hay más parámetros para controlar la creación de estos métodos y que conviene consultar en la documentación. Vamos a ver algunas de las facilidades que ofrece:

Atributos + Representación + Comparación

Como ya hemos comentado, por defecto se crean los métodos para inicializar, representar y comparar.

@dataclass
class Point:
    x: float
    y: float

Por defecto, se pueden reasignar el valor de los atributos (mutable) y acceder a estos atributos mediante la notación dot:

>>> c = Point(1, 2)
>>> c
Point(x=1, y=2)
>>> c.x
1
>>> c.y
2
>>> c == Point(2, 1)
False

A los atributos se pueden asignar valores por defecto y hacerlos inmutables (como si fueran propiedades) que se explicar en la documentación del módulo.

Ordenación

Como ya hemos visto, se pueden crear los métodos de ordenación que equivalen a las operaciones <, <=, > y >=:

@dataclass(order=True)
class Point:
    x: float
    y: float

Hashable y Mutable

Podemos hacer que las instancias sean hashables, o sea, que tengan un hash que las identifique (casi)unívocamente:

@dataclass(unsafe_hash=True)
class Point:
    x: float
    y: float

Como hemos dicho, una instancia dataclass es por defecto mutable, por lo que no es seguro usar este hash en ciertos usos como, por ejemplo, para índice de un diccionario.

Hashable e Immutable

Para tener un hash más seguro, podemos usar el parámetro frozen:

@dataclass(frozen=True)
class Point:
    x: float
    y: float

En este caso, las instancias son inmutables una vez que han sido creadas y se puede usar perfectamente como índices de diccionarios.

Descomposición

Tal vez sea la descomposición o desestructuración de una dataclase la característica más interesante. Si funcionara directamente podríamos hacer cosas tales como:

# OJO: ESTE CÓDIGO NO FUNCIONA
>>> p = Point(1, 2)
>>> Point(a, b) = p
>>> a
1
>>> b
2

Para tener algo “parecido”, se puede transformar la instancia dataclass en una tupla o un diccionario usando las funciones dataclasses.astuple o dataclasses.asdict y usar las asignaciones típicas de estos tipos:

>>> from dataclasses import astuple, asdict
>>> p = Point(1, 2)
>>> (a, b) = astuple(p)
>>> a
1
>>> b
2

Podemos ir más allá e implementarlo en la misma clase:

from dataclasses import dataclass, astuple

@dataclass(order=True)
class Point:
    x: float
    y: float

    def __iter__(self):
        yield from astuple(self)

    def __getitem__(self, keys):
        return iter(getattr(self, k) for k in keys)

Probamos:

>>> p = Point(1, 2)
>>> (a, b) = p
>>> a
1
>>> b
2
>>> (x, y) = p["x", "y"]
>>> (x, y)
(1, 2)

Pero esto mejora mucho a partir de python 3.10. Gracias a la nueva sentencia match se pueden hacer desestructuraciones como éstas:

# OJO: ESTE CÓDIGO SÓLO FUNCIONA A PARTIR DE PYTHON 3.10
match p:
    case Point(0, y):
        print(f"Eje de coordenadas: {y}")
    case Point(x, 0):
        print(f"Eje de abcisas: {x}")
    case Point(x, y):
        print(f"Fuera de ejes: ({x}, {y})")

Optimización

Un último truco: como todos los atributos van a estar declarados en la definición de la clase, se puede hacer uso del atributo __slots__ para evitar la creación del diccionario del objeto, lo que puede suponer un ahorro de memoria significativo en el caso de que se vaya a usar esta clase para carga masiva de datos:

from dataclasses import dataclass, astuple

@dataclass(order=True)
class SlottedPoint:
    __slots__ = ["x", "y"]
    x: float
    y: float

    def __iter__(self):
        yield from astuple(self)

Si comparamos tamaños:

>>> import sys
>>> sys.getsizeof(Point)
1064
>>> sys.getsizeof(SlottedPoint)
896

En ciertas circunstancias, el uso de __slots__ aumenta la velocidad de creación de instancias y el acceso a sus atributos. Por contra, no permite dar valores por defecto a los atributos.

© Chema Cortés. Built using Pelican. Theme is subtle by Carey Metcalfe. Based on svbhack by Giulio Fidente.