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.
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 equivalentesComo 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 Javaint
: representa un número enterofloat
: representa un número con punto flotanteLos 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
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 valoresPassport
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.
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.
Desde Python 3.10 el decorador “dataclass” tiene algunas opciones muy interesantes:
myClass(arg1=1,arg2=2)# Permitido myClass(1, 2) # No permitido
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!
Muy interesante
Excelente post, se esperan muchos más 😄