Mostrando entradas con la etiqueta Python. Mostrar todas las entradas
Mostrando entradas con la etiqueta Python. Mostrar todas las entradas

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:



martes, 31 de marzo de 2020

Enumeraciones



Una enumeración es una agrupación de constantes que guardan alguna relación y que comparten un mismo espacio de nombres. Las enumeraciones dan consistencia a los programas porque evitan que las constantes sean objetos aislados a los que cueste identificar con el paso del tiempo. También, proporcionan una metodología para abordar con un mismo criterio de estructuración del código implementaciones que se dan con frecuencia.

Las enumeraciones se pueden utilizar para agrupar los diferentes estados de un proceso; las distintas respuestas que pueden darse en los condicionantes que determinan el flujo de un programa; las constantes matemáticas o físicas necesarias para realizar determinados cálculos en un desarrollo y los valores o características de objetos que son del mismo tipo, entre otros.


Creación de una enumeración


Para declarar una enumeración utilizaremos la clase Enum del módulo enum, un viejo conocido en Python como módulo independiente que pasó a formar parte de la librería estándar a partir de Python 3.4 y que ha sufrido alguna ampliación de sus funcionalidades en Python 3.6. A continuación, algunos ejemplos de declaración de enumeraciones:

from enum import Enum


class Respuesta(Enum):
    SI = 1
    NO = 2


class Estado(Enum):
    DETENIDO = 0
    ENESPERA = 0
    INICIADO = 1


class Andalucia(Enum):
    AL = (715993, 8775)  
    CA = (1240020, 7436)
    CO = (782516, 13771)
    GR = (914428, 12647)
    HU = (521428, 10128)
    JA = (633120, 13496)
    MA = (1660693, 7308)
    SE = (1941804, 14036)


Semana = Enum(
    value='Semana',
    names=('LU MA MI JU VI SA DO'),
)


Color = Enum(
    value='Color',
    names=[
        ('ROJO', '#FF0000'),
        ('AMARILLO', '#FFFF00'),
        ('VERDE', '#008000'),
    ],
)    

Las enumeraciones de los ejemplos muestran que existen dos modos de declararlas: como una clase un tanto especial o con el método Enum() en una simple asignación. También, puede observarse que las constantes de una misma enumeración pueden tener o no distintos valores y de diferentes tipos (int, str, tuple, dict y otros).

En la enumeración Respuesta las constantes SI y NO tienen distintos valores. Sin embargo, en Estado tanto la constante DETENIDO como ENESPERA tienen el mismo valor, 0. Esto no es ningún inconveniente, lo que no se admite es tener dos constantes con la misma denominación en una enumeración. Más adelante mostraremos también cómo forzar que las constantes tengan valores únicos.

En la enumeración Andalucia cada constante tiene asignada una tupla con dos valores que hacen referencia al número de habitantes (en 2019) y a la superficie en kilómetros cuadrados de cada provincia andaluza.

En la enumeración Semana se obtienen las constantes que son abreviaturas de los días de la semana del argumento names. Además, como no se indican valores se les asignarán de forma automática los enteros del 1 al 7.

Finalmente, en la enumeración Color las constantes se obtienen también del argumento names aunque en este caso se corresponde con una lista de tuplas con dos cadenas que hacen referencia al nombre de la constante y a su valor, un código de color en hexadecimal representado como una cadena.

La sintaxis utilizada para expresar cualquier constante facilita la legibilidad del código. Simplemente, hay que indicar en primer lugar el nombre de la enumeración, seguido de un punto y el nombre de la constante:

NombreEnumeración.CONSTANTE

Basándonos en esta notación podemos referirnos a alguna de las constantes declaradas en los ejemplos anteriores del siguiente modo:

Respuesta.SI
Semana.LU
Color.ROJO

Pero conozcamos más de cerca estos objetos y sus atributos:

# Obtener la clase de una enumeración:

type(Respuesta)  # 'enum.EnumMeta'
type(Estado)  # 'enum.EnumMeta'

# Obtener el tipo de objeto de una constante:

type(Respuesta.SI)  # enum 'Respuesta'
type(Semana.JU)  # enum 'Semana'

# Obtener el nombre de una constante

respnom = Respuesta.SI.name  # 'SI'
estnom = Estado.DETENIDO.name  # 'DETENIDO'

# Obtener el valor de una constante

respval = Respuesta.SI.value  # 1
hab, sup = Andalucia.AL.value  # hab=715993; sup=8775
diaval = Semana.DO.value  # 7
color = Color.VERDE.value  # '#008000'

# Obtener una constante por su nombre

objnom1 = Respuesta['SI']  # Respuesta.SI: 1
objnom2 = Semana['LU']  # Semana.LU: 1

# Obtener la primera constante que tiene un valor determinado

objconst1 = Respuesta(2)  # Respuesta.NO: 2
objconst2 = Estado(0)  # Estado.DETENIDO: 0

# Comprobar si una constante pertenece a una enumeración

isinstance(Respuesta.SI, Respuesta)  # True
isinstance(Semana.DO, Semana)  # True

# Imprimir el objeto (No se obtiene el valor)

print(Respuesta.SI)  #  Respuesta.SI
print(Color.AMARILLO)  # Color.AMARILLO

# Imprimir la cadena de representación de una constante

print(repr(Respuesta.SI))  # Respuesta.SI: 1
print(repr(Estado.DETENIDO))  # Estado.DETENIDO: 0

# Comparar constantes con is o is not 

Respuesta.SI is not Respuesta.NO  # True
Estado.DETENIDO is Estado.ENESPERA  # True

# Comparar constates con == o !=

Respuesta.SI != Respuesta.NO  # True
Estado.DETENIDO == Estado.ENESPERA  # True


Comparaciones. La clase IntEnum


Algo que no agrada a muchos es no poder comparar una constante con un valor entero determinado; pero esto es normal porque la referencia a una constante devuelve el objeto enumeración al que pertenece no un valor entero, una cadena u otro tipo de objeto. Mucho cuidado, cualquier comparación que se haga de este tipo siempre devolverá False:

Estado.DETENIDO == 0  # False
Respuesta.SI == 1  # False

Para este tipo de comparaciones podemos utilizar el atributo value que ya ha aparecido antes en los ejemplos:

Estado.DETENIDO.value == 0  # True
Respuesta.SI.value == 1  # True

No obstante, la clase IntEnum nació para permitir hacer este tipo comparaciones de forma más abreviada y directa, sin obligar a referenciar el atributo value. Para ello, tan solo tendremos que declarar las enumeraciones con valores basados en enteros del siguiente modo:

from enum import IntEnum


class Respuesta(IntEnum):
    SI = 1
    NO = 2


class Estado(IntEnum):
    DETENIDO = 0
    ENESPERA = 0
    INICIADO = 1


# Ahora es posible evaluar de forma más óptima
# el valor (entero) de una constante.

Estado.DETENIDO == 0  # True
Respuesta.SI == 1  # True


Iteraciones


Otra característica que tienen las enumeraciones, además de permitir comparaciones, consiste en que son objetos iterables. Esto significa que se pueden recorrer, una a una, todas sus constantes en el orden en que fueron declaradas:

for dia in Semana:
    print(dia.name, '->', dia.value)


# LU -> 1
# MA -> 2
# MI -> 3
# JU -> 4
# VI -> 5
# SA -> 6
# DO -> 7


Enumeraciones con constantes con valores únicos


Como se ha comentado antes, la enumeración Estado cuenta con las constantes DETENIDO y ENESPERA que tienen el mismo valor, 0. Si queremos exigir que todas las constantes tengan valores únicos tendremos que agregar el decorador @unique a la declaración de la enumeración. Si agregamos el decorador y existen valores repetidos se producirá una excepción del tipo ValueError:

from enum import Enum, unique

@unique
class Estado(Enum):
    DETENIDO = 0
    ENESPERA = 0
    INICIADO = 1


# ValueError: duplicate values found in : ENESPERA -> DETENIDO


Enumeraciones con métodos


Las enumeraciones son clases de Python y como tales permiten incorporar métodos, algunos especiales, que amplían sobremanera el potencial de este tipo de objetos. En el siguiente ejemplo se declara la enumeración Andalucia con varios métodos para obtener los valores de habitantes y superficie de las constantes en una tupla, como valores independientes o bien devolviendo el cálculo de la densidad de población.

from enum import Enum


class Andalucia(Enum):
    AL = (715993, 8775)  
    CA = (1240020, 7436)
    CO = (782516, 13771)
    GR = (914428, 12647)
    HU = (521428, 10128)
    JA = (633120, 13496)
    MA = (1660693, 7308)
    SE = (1941804, 14036)

    def __init__(self, hab, sup):
        self.hab = hab  # Núm. habitantes
        self.sup = sup  # En Km cuadrados
 
    @property       
    def habitantes(self):
        return self.hab
 
    @property       
    def superficie(self):
        return self.sup

    @property
    def densidad(self):
        return self.hab / self.sup


# Obtener información de la provincia de Córdoba (CO):

Andalucia.CO.value  # (782516, 13771)
Andalucia.CO.habitantes  # 782516
Andalucia.CO.superficie  # 13771
Andalucia.CO.densidad  # 56.823469610050104


# Obtener información de la provincia de Sevilla (SE):

Andalucia.SE.value   # (1941804, 14036)
Andalucia.SE.habitantes  # 1941804
Andalucia.SE.superficie  # 14036
Andalucia.SE.densidad  # 138.3445426047307


Relacionado: