43

Introducción a dataclasses en Python

8000Puntos

hace 2 años

En Python existe un módulo llamado dataclass que permite agregar código auto generado a los métodos especiales de la clase con una sola línea de código. Los métodos especiales son super importantes el desarrollo en Python, muchas veces los flujos son similares de una clase a otra y para seguir con el principio de “Don’t repeat yourself” las dataclasses son de mucha ayuda.

Python Dataclasses, especificación

Las dataclass hacen uso de Type Annotations.

Pero Eduardo? Tipos en Python?

Sí, en 2016 varios promotores entre ellos Guido van Rossum (creador de Python) escribieron un PEP (documento que propone mejoras en Python) que describía cuáles y cómo debían usarse los tipos en Python. Estos tipos no son usados en tiempo de ejecución sino durante la escritura del código.

Antes de dar un ejemplo de dataclasses hablemos sobre qué son los métodos especiales y type annotations en Python.

Métodos especiales

Los métodos especiales son aquellos métodos que pertenecen a una clase y que el intérprete de Python ejecuta para realizar una acción en específico. Tal vez los reconozcas porque tienen un __ al inicio de su nombre. El más conocido y usado es el método __init__ que permite definir cómo debe ser creado un objeto de determinada clase, como él están:

  • __str__: que permite retornar una cadena de caracteres para identificar al objeto (más adelante en el ejemplo lo explico)
  • __eq__: que permite comparar dos objetos de la misma clase y calcular si son equivalentes

Type Annotations

Como todo lenguaje de programación Python se compone de tipos primitivos, son aquellos tipos que le dan sentido a los datos. Entre ellos tenemos:

  • str: sirve para representar una cadena de texto, tipo String en Java
  • int: representa un número entero
  • float: representa un número con punto flotante

Los Type Annotations también permiten usar las clases para determinar el tipo de las variables. En Python existe el módulo typing que incluye tipos más avanzados y que se pueden utilizar con los anteriores para especificar mejor el tipo de la variable.

Documentación oficial de Type Annotations

El Ejemplo

Haremos un sistema super sencillo de control de pasaportes.

Cada pasaporte tiene un país emisor y un número, el país tiene nombre y tamaño; además cada pasaporte pertenece a una Persona que posee nombre, nacionalidad y se sabe si puede o no viajar si tiene un pasaporte asignado.

Country

classCountry:
    name: str
    size: float # in square kilometersdef__init__(self, name: str, size: float = 100.5) -> None:ifnot name:
            raise ValueError('name is required for Country')

        self.name = name
        self.size = size

    def__str__(self) -> str:return f'The country is {self.name}, and its size is: {self.size}'def__eq__(self, __o: object) -> bool:ifnot isinstance(__o, Country):
            raise ValueError('It is imposible to compare objects from different classes')
        return self.size == __o.size

Veamos que en la clase Country hacemos uso de tres métodos especiales: __init__, __eq__ y __str__. Analicemos cada una de ellas:

  • __init__ : nos permite escribir la lógica requerida para que un objeto Country pueda ser creado. El atributo size no es requerido porque dentro de la definición del método hay un valor por defecto100.5 así que cada país al crearse tendrá tamaño igual a 100.5. Caso contrario a name que no tiene valor por defecto y el método evalúa que exista y sea enviado, de lo contrarío lanzará una excepción.
  • __str__: transforma los atributos del objeto en una cadena de caracteres a través del operador f que funciona como un template system en Python.
  • __eq__: permite comparar dos objetos y es invocado por el intérprete de Python cuando existe un operador == , evalúa que el objeto que se quiere comparar es de la misma clase y que los atributos tengan los mismos valores

Passport

classPassport:
    number: str
    issued_by_country: Country

    def__init__(self, number: str, country: Country) -> None:ifnot number:
            raise ValueError('number is required for Passport')

        ifnot country ornot isinstance(country, Country):
            raise ValueError('country is required for Passport')

        self.number = number
        self.issued_by_country = country

Al igual que Country usa el método __init__ para agregar la lógica de creación de la instancia. Ambos atributos son requeridos.

Aquí introducimos el type annotation con clases, veamos el atributo issued_by_country al inicio de la clase, se pone que es de tipo Country, misma clase que definimos más arriba.

Person

classPerson:
    name: str
    nationality: Country
    passport: Passport
    can_travel: bool

    def__init__(self, name: str, nationality: Country, passport: Passport = None) -> None:ifnot name:
            raise ValueError('name is required for Person')
        ifnot nationality ornot isinstance(nationality, Country):
            raise ValueError('nationality is required for Person')
        if passport andnot isinstance(passport, Passport):
            raise ValueError('passport is required for Person')

        self.name = name
        self.nationality = nationality
        self.passport = passport
        self.can_travel = passport isnotNonedef__str__(self) -> str:return f'The name is: {self.name}, comes from: {self.nationality.name}, can travel? {self.can_travel}'

Al igual que las clases anteriores, Person también utiliza métodos especiales, la diferencia es que en Person se evalúa que exista el atributo passport para determinar si puede viajar.

Veamos cómo funciona:

Si quiero crear objetos de países escribo

...
## Countries creation
colombia = Country(name='Colombia', size=1142.00)
print(f'Colombia information: {str(colombia)} \n')
venezuela = Country(name='Venezuela')
print(f'Venezuela information: {str(venezuela)} \n')
...
Colombia information: The country is Colombia, and its size is: 1142.0 

Venezuela information: The country is Venezuela, and its size is: 100.5

Veamos que el objeto Venezuela tiene el valor por defecto de tamaño por país 100.5 porque al crearlo no le pasamos un valor y usó el predeterminado

Al comparar instancias de Country se usa el método __eq__ que escribimos en su clase

...
## Equal methodprint('Is Colombia equals to Venezuela?')print(colombia == venezuela)print('Is Venezuela equals to Venezuela?')print(venezuela == venezuela)
...
$ IsColombiaequalstoVenezuela?
$ False
$ IsVenezuelaequalstoVenezuela?
$ True

Ahora si queremos comparar instancias de diferentes clases tenemos

...
try:
    print(colombia == first_passport)
except ValueError as e:
    print(e)
...
$ It is imposible to compare objects from different classes

Utilizo el try porque en el método __eq__ hicimos que si se comparan clases diferentes se lanzara una excepción

Ahora, creemos la información para un ciudadanos colombiano, o sea, crear un Passport de país Colombia y Person de nacionalidad Colombia.

...
## Colombian infofirst_passport = Passport(number='1298348756',country=colombia)colombian_person = Person(name='Luis García', nationality=colombia,passport=first_passport)
print(colombian_person)
...
$ The name is: Luis García, comes from: Colombia, can travel? True

Lo que se imprime es lo que retorna el método __str__ de la clase Person, veamos que sí puede viajar porque tiene un pasaporte asignado.

Intentemos crear un objeto Passport con la información incorrecta.

...
## Venezuelan infotry:
		second_passport = Passport(number='AZ53426', country=first_passport)
except ValueError as e:
		print(e)
...
$ country is required for Passport

No dejará crearlo porque country necesita ser un objeto de la clase Country, no Passport.

Si creamos un objeto de tipo Persona para Colombia, pero esta vez sin pasaporte.


...
colombian_person_without_passport = Person('Pedro González', nationality=colombia)
print(colombian_person_without_passport)
...
$ The name is: Pedro González, comes from: Colombia, can travel? False

Veamos que no puede viajar porque fue creado sin pasaporte asignado.

Con dataclasses

Veamos cómo los métodos especiales en Python nos agregan una funcionalidad super valiosa a la hora de construir los casos de uso necesarios. Las dataclasses van a permitir que con muchas menos líneas de código tengamos el mismo comportamiento.

El primer paso es importar el módulo para utilizarse

from dataclasses import dataclass

El módulo dataclass se utiliza a través de un decorador en la clase que se necesita. Veamos el refactor de la clase Country con dataclasses.

@dataclass
class Country:
    name: str
    size: float = 0.0

Es casi como magia! Automáticamente el decorador de dataclass nos agrega la misma funcionalidad que tuvimos que escribir. Tenemos entonces que la clase tiene definida dentro de ella algo similar a esto:

def__init__(self, name: str, size: float = 0.0) -> None:
		...

Las dataclass siempre retornan una clase del mismo tipo que se está escribiendo, no alteran su definición más allá de agregar funcionalidad

Veamos cómo funcionan.

El orden importa

El orden con el que se escriben los atributos en una dataclass determinan en qué posición de los métodos especiales se encuentra el parámetro. El módulo también evalúa que no existan atributos con valores por defecto antes de aquellos que no están especificados, por ejemplo esto no es permitido:

@dataclass
class Country:
    size: float = 0.0name: str

Las demás clases

from dataclasses import dataclass, field
from typing import Optional

@dataclassclassCountry:
    name: str
    size: float = 0.0@dataclassclassPassport:
    number: str
    issued_by_country: Country

@dataclassclassPerson:
    name: str
    nationality: Country
    passport: Optional[Passport] = None
    can_travel: bool = field(init=False)

    def__post_init__(self):
        self.can_travel = bool(self.passport)

Estas son todas las declaraciones de clases que necesitamos para cumplir con los mismos casos de uso que describimos al inicio, veamos que se integraron nuevas cosas:

  • Optional: es un tipo de type annotation que permite que un atributo pueda ser None o ser una instancia de una Clase

  • field: es otro módulo de dataclasses que permite extender la definición de lo que es un atributo de la clase, permite indicar si el atributo es parte del método __init__ en este caso se dice que no y que será procesado por __post_init__. También permite indicar valores iniciales en caso de que no exista un valor por defecto, por ejemplo las listas, si tuviéramos un atributo de tipo lista y queremos iniciarlo como vacío deberíamos hacer field(default_factory=list). Dataclasses Field documentación

  • __post_init__: como las dataclasses agregan el método __init__ este método permite agregar lógica adicional luego de la creación de un objeto, en este caso, movimos la lógica de can_travel para este método haciendo que se revise si Person tiene un Passport asignado.

Las instancias se hacen exactamente igual que en el caso anterior

...
## Countries creationcolombia = Country(name='Colombia',size=1142.00)
    print(f'Colombia information: {str(colombia)} \n')
    venezuela = Country(name='Venezuela')
    print(f'Venezuela information: {str(venezuela)} \n')

    # Colombian infofirst_passport = Passport(number='1298348756',country=colombia)colombian_person = Person(name='Luis García', nationality=colombia,passport=first_passport)
    print(colombian_person)
...
$ Colombia information: Country(name='Colombia',size=1142.0) 

$ Venezuela information: Country(name='Venezuela',size=0.0) 

$ Person(name='Luis García', nationality=Country(name='Colombia',size=1142.0),passport=Passport(number='1298348756',issued_by_country=Country(name='Colombia',size=1142.0)),can_travel=True)

Vemos que en este caso la impresión varía, esto porque dataclass agregó su propio método __str__ que varía del que habíamos escrito.

La equivalencia funciona igual con la diferencia de que si no son del mismo tipo retorna False.

## Equal methodprint('Is Colombia equals to Venezuela?')print(colombia == venezuela)print('Is Venezuela equals to Venezuela?')print(venezuela == venezuela)try:
		print(colombia == first_passport)
except ValueError as e:
		print(e)

$ Is Colombia equalsto Venezuela?
$ False
$ Is Venezuela equalsto Venezuela?
$ True
$ False

Inmutabilidad

Las dataclasses permiten agregar una funcionalidad para que una vez un objeto exista sus atributos no puedan ser modificados (esto no es del todo cierto, los atributos pueden seguir modificándose pero no de la manera convencional, la inmutabilidad es imposible en Python)

Podemos hacer que la clase Country sea “inmutable” de esta manera:

@dataclass(frozen=True)
class Country:
    name: str
    size: float = 0.0

Con el atributo frozen en el decorador se indica que no puede ser modificada una vez instanciada, de hacerlo se genera este error.

...
# Countries creation
colombia = Country(name='Colombia', size=1142.00)
colombia.name = 'Ecuador'
...
$ dataclasses.FrozenInstanceError: cannot assignto field 'name'

Las dataclasses permiten tener un código mucho más limpio, agregar type annotations a nuestros atributos y no cometer errores de enviar distintos tipos a través de los flujos. Esto fue solo una introducción a ellas, en la documentación oficial encontrarás más ejemplos.

Recuerda que en Platzi tenemos muchos cursos de Python para data science con los que podrás profundizar tus conocimientos y llegar hasta el espacio, como nuestro satélite y como toda una nueva generación de programadores latinoamericanos.

Eduardo
Eduardo
walis85300

8000Puntos

hace 2 años

Todas sus entradas
Escribe tu comentario
+ 2
Ordenar por:
3
16612Puntos

Desde Python 3.10 el decorador “dataclass” tiene algunas opciones muy interesantes:

  • kw_only: Por defecto seteado en False. Su mision es hacer que el constructor (__init __) de la dataclass solo permita keyword arguments:
myClass(arg1=1,arg2=2)# Permitido
myClass(1, 2)  # No permitido
  • slots: Por defecto seteado en False. Su mision que la dataclass declare el __slots __ y de esta forma nos ahorre memoria. Importante tener en cuenta que una vez que si seteamos este a True ya no podremos añadir nuevos atributos a nuestra clase en runtime, sin embargo si será posible editar los que ya tenemos.
2
8000Puntos
2 años

El tema con los __slots__ es que si queremos hacer herencia debemos garantizar que la clase padre también tenga __slots__ , algo para tener en cuenta pero los beneficios son mayores.

Excelente aporte!

2
12500Puntos

Muy interesante

1
4945Puntos

Excelente post, se esperan muchos más 😄