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, tipoString
en Javaint
: representa un número enterofloat
: 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
class Country:
name: str
size: float # in square kilometers
def __init__(self, name: str, size: float = 100.5) -> None:
if not 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:
if not 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 atributosize
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 a100.5
. Caso contrario aname
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 operadorf
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
class Passport:
number: str
issued_by_country: Country
def __init__(self, number: str, country: Country) -> None:
if not number:
raise ValueError('number is required for Passport')
if not country or not 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
class Person:
name: str
nationality: Country
passport: Passport
can_travel: bool
def __init__(self, name: str, nationality: Country, passport: Passport = None) -> None:
if not name:
raise ValueError('name is required for Person')
if not nationality or not isinstance(nationality, Country):
raise ValueError('nationality is required for Person')
if passport and not isinstance(passport, Passport):
raise ValueError('passport is required for Person')
self.name = name
self.nationality = nationality
self.passport = passport
self.can_travel = passport is not None
def __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 method
print('Is Colombia equals to Venezuela?')
print(colombia == venezuela)
print('Is Venezuela equals to Venezuela?')
print(venezuela == venezuela)
...
$ Is Colombia equals to Venezuela?
$ False
$ Is Venezuela equals to Venezuela?
$ 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 info
first_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 info
try:
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.0
name: str
Las demás clases
from dataclasses import dataclass, field
from typing import Optional
@dataclass
class Country:
name: str
size: float = 0.0
@dataclass
class Passport:
number: str
issued_by_country: Country
@dataclass
class Person:
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 serNone
o ser una instancia de una Clase -
field
: es otro módulo dedataclasses
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 hacerfield(default_factory=list)
. Dataclasses Field documentación -
__post_init__
: como lasdataclasses
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 decan_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 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')
# Colombian info
first_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 method
print('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 equals to Venezuela?
$ False
$ Is Venezuela equals to 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 assign to 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.
Curso Básico de Python