martes, 12 de mayo de 2020

Data Classes: Clases de datos

Data Classes


Una de las características más interesantes de Python 3.7 es el soporte que proporciona el módulo dataclasses con el decorador dataclass para escribir clases de datos.

En una clase de datos se generan automáticamente algunos métodos especiales para clases simples. Los nombres de estos métodos, también llamados métodos mágicos, comienzan y finalizan con un doble subrayado como __init__(), __repr__(), __eq__(), entre otros.

Como es sabido el método __init__() se utiliza en una clase para inicializar un objeto y se invoca sin hacer una llamada específica, simplemente, cuando se instancia una clase. De ahí, que se le conozca como método constructor.

De modo que escribir una clase como la del siguiente ejemplo era lo normal hasta hace muy poco. En este caso la acción de instanciar la clase para crear un objeto lleva implícita la llamada al método __init__() que efectúa las asignaciones de nombre, altura y peso. Por ello, cuando se imprime la altura se obtiene el valor asignado sin que sea necesario hacer nada más:

class Deportista:
    def __init__(self, nombre, altura, peso):
        self.nombre = nombre
        self.altura = altura
        self.peso = peso

deportista1 = Deportista('Elena', 1.81, 64)
print(deportista1.altura)  # 1.81

Bien, la nueva característica que comentamos permite ahora escribir la clase anterior de forma más simplificada y clara:

from dataclasses import dataclass

@dataclass
class Deportista:
    nombre: str
    altura: float
    peso: float

deportista1 = Deportista('Elena', 1.81, 64)
print(deportista1.altura)  # 1.81

Como puede observarse a la clase Deportista le precede el decorador dataclass y no tiene definido el método __init__().

Una de las funciones del decorador es localizar las variables de clase que llevan anotaciones de tipos para conocer los campos que tiene la clase de datos. Después, con respecto al modo de instanciar la clase no se advierte ningún cambio con respecto al uso habitual.

Los métodos de dataclass


La magia obviamente está en el decorador de clase que ayuda a reducir el código porque no solo genera el método __init__(), también hace lo propio con los métodos __str__(), __repr__() y, opcionalmente, con algunos métodos más.

Y sabemos que el decorador genera el método __str__() (que devuelve una cadena con una representación legible de los datos) porque es llamado cuando se imprime el objeto o cuando se hace uso de la función str():

print(deportita1)  # Deportista(nombre='Elena', altura=1.81, peso=64)

atleta = str(deportista1)  
print(atleta)  # Deportista(nombre='Elena', altura=1.81, peso=64)

Algunos de estos métodos también pueden reescribirse dentro de la clase para modificar su comportamiento predeterminado. En el ejemplo siguiente el método __str__() se ha reescrito y devuelve una cadena con el siguiente formato: 'nombre: altura, peso'

@dataclass
class Deportista:
    nombre: str
    altura: float
    peso: float
    
    def __str__(self) -> str:
        return f'{self.nombre}: {self.altura}, {self.peso}'

deportista1 = Deportista('Elena', 1.81, 64)
print(str(deportista1))  # Elena: 1.81, 64

Los parámetros de dataclass


El decorador dataclass cuenta también con varios parámetros para ajustar su funcionamiento:

@dataclass(init=True, repr=True, eq=True, order=False,
           unsafe_hash=False, frozen=False)
  • init, repr y eq: Por defecto estos parámetros tienen el valor True para que el decorador genere los métodos __init__(), __repr__() y __eq__(), respectivamente, aunque si la clase los redefine serán ignorados.
  • order: Por defecto tiene el valor False pero si se establece a True el decorador generará los métodos especiales __gt__(), __ge__(), __lt__() y __le__(). En este caso no se permite la reescritura, por lo que si la clase redefine alguno de ellos se producirá una excepción.
  • unsafe_hash: Por defecto tiene el valor False y en este caso el decorador generará el método __hash__() de acuerdo a la configuración que tengan los parámetros eq y frozen.
  • frozen: Por defecto tiene el valor False pero si se establece a True cualquier intento de asignación a los campos producirá una excepción.

En el siguiente ejemplo se establece el parámetro order con el valor True para que el decorador dataclass genere los métodos __gt__(), __ge__(), __lt__() y __le__() que se corresponden con las comparaciones "mayor que", "mayor o igual que", "menor que" y "menor o igual que", respectivamente.

Las variables de clase son inicializadas cuando los objetos se crean omitiendo dichos valores. En este ejemplo se crean tres objetos asignando un valor al campo peso para realizar comparaciones y conocer si el valor del campo en un objeto es "mayor que" en otro. Y sabemos que el método __gt__() se ha generado porque es llamado cuando se comparan los objetos con el operador ">":

@dataclass(order=True)
class Deportista:
    nombre: str = 'Desconocido'
    altura: float = 0
    peso: float = 0

deportista1 = Deportista(peso=64)
deportista2 = Deportista(peso=62)
deportista3 = Deportista(peso=67)

print(deportista1 > deportista2)  # True
print(deportista1 > deportista3)  # False

Ahora es suficiente con cambiar el valor de order a False para verificar que en ese caso los métodos no están disponibles y que se produce una excepción porque la comparación "mayor que" no estaría soportada por la clase.

En el ejemplo siguiente se establece el parámetro frozen a True con lo cual es posible instanciar la clase para crear objetos pero no es posible asignar valores porque el objeto ha sido "congelado". El intento de asignación produce una excepción de tipo dataclasses.FrozenInstanceError:

@dataclass(frozen=True)
class Deportista:
    nombre: str = 'Desconocido'
    altura: float = 0
    peso: float = 0

deportista1 = Deportista(peso=64)
deportista1.peso = 63  # dataclasses.FrozenInstanceError

La función asdict()


La función asdict() se utiliza para convertir una instancia de clase de datos en un diccionario Python.

En el ejemplo siguiente se importa la función asdict que se emplea para convertir el objeto deportista1 en un diccionario usando los campos de la clase de datos para definir sus claves y sus valores:

from dataclasses import dataclass, asdict

@dataclass
class Deportista:
    nombre: str
    altura: float
    peso: float

deportista1 = Deportista('Elena', 1.81, 64)
dicc1 = asdict(deportista1)

if dicc1['altura'] > 1.75:
   print(dicc1['nombre'], 'supera la altura')

La función field()


La función field() permite facilitar información adicional al decorador relativa a cada campo que la utilizará en la generación de los métodos.

En el ejemplo que sigue para el atributo peso se establecen los parámetros init y repr a False. Esto indica al decorador que el objeto podrá crearse sin el atributo peso y que cuando se imprima su representación será omitida esta información. No obstante, como el atributo peso existe se le podrá asignar un valor en cualquier momento y acceder al mismo después de la asignación.

from dataclasses import dataclass, field

@dataclass
class Deportista:
    nombre: str
    altura: float
    peso: float = field(init=False, repr=False)

deportista1 = Deportista('Elena', 1.81)
deportista1.peso = 64
print(deportista1)  # Deportista(nombre='Elena', altura=1.81)
print(deportista1.peso)  # 64

Herencia


Las clases de datos también pueden heredar atributos y métodos de otras clases de datos.

En el siguiente ejemplo la clase de datos Equipo hereda de la clase Deportista sus variables y métodos aunque en esta ocasión ambas clases redefinen el método __str__() para que al ser llamado muestre información diferente en cada ámbito.

En la clase que hereda, Equipo, la variable equipo debe tener un valor por defecto para que cuando se instancie la clase Deportista no se produzca una excepción de tipo TypeError. Esto es así, aún cuando el atributo equipo queda fuera del alcance de la clase Deportista.

from dataclasses import dataclass

@dataclass
class Deportista:
    nombre: str
    altura: float = 0
    peso: float = 0

    def __str__(self) -> str:
        return f'{self.nombre}: {self.altura}, {self.peso}'

@dataclass
class Equipo(Deportista):
    equipo: str = 'desconocido'

    def __str__(self) -> str:
        return f'{self.nombre}: {self.equipo}'

# Instancia la clase Deportista para crear objeto:
deportista1 = Deportista('Elena', 1.81, 64)

# Imprime llamando al método __str__() de
# la clase Deportista:
print(deportista1)  # Elena: 1.81, 64

# Instancia la clase Equipo para crear objeto:
deportista2 = Equipo('Marta', equipo='Sevilla')

# Imprime llamando al método __str__() de
# la clase Equipo:
print(deportista2)  # Marta: Sevilla

# Asigna valores a atributos de objeto de la clase Equipo:
deportista2.altura = 1.76
deportista2.peso = 68

# Imprime representación formal de objeto de la clase Equipo:
print(repr(deportista2))

# Equipo(nombre='Marta', altura=1.76, peso=68, equipo='Sevilla')


Relacionado:

miércoles, 15 de abril de 2020

Anotaciones de tipos: typing




Una de las características del lenguaje Python es que el tipado que usa es dinámico, es decir, permite que una variable pueda cambiar su tipo durante la ejecución de un programa. La razón es que los tipos dependen del valor que tenga asignado dicha variable en un momento dado, no de una propiedad de la variable en sí.

Este rasgo de flexibilidad en el uso de las variables que conlleva no estar obligados a anotar los tipos son ventajas que a veces inquietan a los más ortodoxos, en especial, a los que desarrollan también con lenguajes de tipado estático como Java, C y C++. Los argumentos que aducen son la falta de claridad en el código y la perdida de tiempo intentando identificar el tipo de los valores de las variables y de otros objetos, en particular, en proyectos de cierta envergadura.

Con el paso del tiempo, después de debates no libres de controversias, estas razones han sido tomadas en consideración. Y aunque existían ya módulos externos con el mismo propósito, es a partir de Python 3.5 cuando se añade el módulo typing a la librería estándar, para permitir anotar los tipos de forma nativa (PEP 484).

De momento, anotar los tipos es opcional y la descripción que se haga no se aplica en tiempo de ejecución. Esto significa que la anotación de una variable puede indicar que es de tipo str pero terminar como float, en definitiva, algo que siempre ha podido suceder en Python.

Y entonces ¿Qué finalidad tiene anotar los tipos? Por un lado informativo, actuando como parte de la documentación que se añade al código y, por otro, como la sintaxis es estricta puede ser utilizada por herramientas de terceros con la capacidad de comprobar el uso adecuado de las variables de acuerdo a sus anotaciones de tipos, en el desarrollo de un proyecto. Entre los verificadores de tipo disponibles se encuentran Mypy, Pyre y Pytype. Mypy es la herramienta de referencia desarrollada por Dropbox.

El siguiente código Python no solo es posible, además, es aceptable. Implementarlo con lenguajes de tipado estático conlleva tener que declarar más de una variable:

repetir = 2
print(repetir)  # 2
print(type(repetir))  # class 'int'

repetir = repetir * 'Typing'
print(repetir)  # TypingTyping
print(type(repetir))  # class 'str'

A continuación, mostramos cómo se anotan los tipos más comunes en Python.


Variables y constantes


Para describir o anotar el tipo de las variables y constantes se agrega ": tipo" a continuación del nombre (el tipo puede ser str, int, float, bool, etc.) y, después, si es necesario se asigna un valor tras el signo igual '='. En el siguiente ejemplo se describen dos variables: repetir de tipo int e inicializada con el valor 3 y cadena de tipo str y no inicializada. Lo recomendado es declarar todas las variables con sus tipos al principio del código para aumentar su legibilidad:

repetir: int = 3
cadena: str

cadena = repetir * 'Hola'
print(repetir)   # 3
print(cadena)   # HolaHolaHola

El intérprete de Python almacena las anotaciones de tipo en el atributo especial __annotations__:

print(__annotations__)  # {'repetir': class int, 'cadena': class str}


Funciones


Para anotar el tipo de los argumentos de una función se agrega ": tipo" a continuación del nombre de cada argumento y, después, si es necesario se asigna el valor por defecto tras el signo '='. Para describir el tipo del valor de retorno se añade " -> tipo" después de los argumentos y antes de los dos puntos ":" del final:

def precio(entradas: int, festivo: bool=False) ->int:
    if festivo:
       return entradas * 10
    else:
       return entradas * 8

print(precio(3, True))  # 30
print(precio(4))  # 32


Clases


Para anotar los tipos en una clase se aplica a sus atributos la misma sintaxis que a las variables y a las constantes; y a sus métodos la misma que a las funciones:

class Juego:
    tiempo: int = 40
    
    def __init__(self, nom: str, nivel: int, jug: int = 1) -> None:
        self.nom= nom
        self.nivel = nivel
        self.jug = jug
                 
    def duracion(self) -> int:
        return self.jug * Juego.tiempo
    
partida = Juego('Ajedrez', 1, 2)
print(partida.duracion())  # 80


Anotaciones para tipos complejos


Para anotar tipos más complejos como listas, tuplas, diccionarios, conjuntos y otros es necesario utilizar el módulo typing e importar los tipos que correspondan, como en el ejemplo que sigue. Para describir el tipo de una lista se importa List y entre corchetes se anota el tipo "[tipo]" de los elementos a contener, en este caso str. Para el tipo del diccionario se importa Dict y entre corchetes "[tipo_clave, tipo_valor]" se indica los tipos de las claves y de los valores separados por una coma ",". Para describir un conjunto se importa Set y entre corchetes se indica el tipo "[tipo]" de los valores a contener. También, se pueden utilizar en otro ámbito, como en la función del ejemplo imprime_rios(), para indicar que el argumento rios recibe una lista de cadenas str:

from typing import List, Dict, Set

apellidos: List[str] = ['Alcantara', 'Alonso', 'Blanco']
referencias: Dict[str, int] = {'Mesa': 121, 'Silla': 485}
serie: Set[int] = {1, 1, 1, 2, 2, 3, 3, 5, 5, 5, 5, 5, 6}


def imprime_rios(rios: List[str]) -> None:
    for rio in rios:
        print(rio)

imprime_rios(['Guadalquivir', 'Tinto', 'Odiel', 'Segura'])


Alias


Los alias permiten la creación de anotaciones de tipo con denominaciones propias para mejorar la comprensión del código. En el ejemplo siguiente se define el tipo Color como una tupla de tres valores de tipo int para expresar la cantidad de rojo, verde y azul asociado a un determinado color. Después, en la función pintar() se anota junto al argumento color el tipo Color que se corresponde con dicha tupla:

from typing import Tuple

Color = Tuple[int, int, int]

def pintar(color: Color) -> None:
    r: int
    v: int
    a: int
    r, v, a = color
    print('Color Rojo:', r, 'Verde:', v, 'Azul:', a)

pintar((100, 200, 120))


Funciones con múltiples valores de retorno


Cuando una función devuelve múltiples valores el tipo del retorno se anota como una tupla con los tipos de sus valores separados por comas, o por un tipo personalizado basado en la misma construcción: Tuple[tipo, tipo, ...]

from typing import Tuple

Coordenada = Tuple[int, int]

def coordenada_inicial() -> Coordenada:
    return 0, 0

print(coordenada_inicial())


Anotaciones para variables con valores de distinto tipo


Para variables que pueden tener valores de distinto tipo existen las anotaciones predefinidas Optional y Union. Optional se utiliza con variables que pueden ser de un tipo concreto o de ninguno (None). Y Union es apropiado para variables cuyos valores pueden ser de tipos diferentes, excepto None.

En la función representantes() tanto el argumento votos como el valor de retorno son de tipo Optional[int]. Por ello, tanto el valor del argumento votos como el que devuelve la función pueden ser de tipo int o None.

from typing import Optional

Votos = Optional[int]
Representantes = Optional[int]

def representantes(votos: Votos) -> Representantes:
    if votos:
        return votos // 5000
    else:
        return None

print(representantes(None))  # None
print(representantes(3409))  # 0
print(representantes(11231))  # 2

En la función recuento() el valor de retorno es de tipo Optional[str, float]. Esto significa que el valor que retorna la función puede ser de tipo str o float: en el ejemplo si el valor del argumento inicio es False se considera que el escrutinio no ha comenzado y devuelve la cadena 'Escrutinio no iniciado'. En cambio, si el valor de inicio es True devuelve el porcentaje escrutado como un valor de tipo float.

from typing import Union

Recuento = Union[str, float]

def recuento(inicio: bool, actual: int, final: int) -> Recuento:
    if not inicio:
        return 'Escrutinio no iniciado'
    else:
        return round((actual * 100 / final), 2)

print(recuento(False, 0, 0))  # Escrutinio no iniciado
print(recuento(True, 4560, 9800))  # 46,53


Mypy


Entre los verificadores de anotaciones de tipo destaca Mypy, un proyecto iniciado por Jukka Lehtosalo en el que ha participado Guido Van Rossum. Esta herramienta comprueba que no existan incoherencias de tipo en uno o más archivos de código fuente. Sin más preámbulos, mostramos su uso:

Para instalar Mypy:

$ pip install mypy

Para ver cómo funciona Mypy utilizaremos el siguiente ejemplo que incluye un error en la anotación de tipo del valor de retorno:

esfera.py:

from math import pi

def vol_esfera(radio: float) -> int:
    return round(4/3 * pi * radio ** 3, 2)

print(vol_esfera(2))  # 33,51 metros cúbicos

Para comprobar el código, ejecutar:

$ mypy esfera.py

Salida:

typando1.py:4: error: Incompatible return value type (got "float", 
expected "int")
Found 1 error in 1 file (checked 1 source file).

La salida sugiere que el tipo del valor de retorno debe ser float. A continuación, realizamos este cambio en esfera.py...:

from math import pi

def vol_esfera(radio: float) -> float:
    return round(4/3 * pi * radio ** 3, 2)

print(vol_esfera(2))  # 33,51 metros cúbicos

Y volvemos a ejecutar Mypy para confirmar la solución aplicada:

$ mypy esfera.py

Salida:

Success: no issues found in 1 source file

Ya no hay errores de anotaciones de tipos en el código. Para verificar el código y ampliar la información que se ofrece utilizar el argumento -v y para conocer el resto de opciones disponibles, el argumento -h.

Para finalizar, recomendamos ver el código fuente de los módulos typing y mypy dado la cantidad y variedad de objetos que pueden describirse y consultar la documentación oficial.


Relacionado: