Skip to content

Latest commit

 

History

History
1186 lines (822 loc) · 31.8 KB

File metadata and controls

1186 lines (822 loc) · 31.8 KB

Tipado progresivo

Tipado dinámico frente a tipado estático

El tipado hace referencia a la anotación de los valores y estructuras en un lenguaje de programación con el objetivo de determinar las operaciones que pueden efectuarse sobre un valor. En el tipado estático, los tipos los asigna el programador y las operaciones se chequean "en tiempo de compilación". En un lenguaje con tipado dinámico el tipo se asigna "en tiempo de ejecución" por el programa y las operaciones se chequean durante la ejecución del programa.

Como habrás podido comprobar, Python es un lenguaje dinámicamente tipado.

Existen diferentes ventajas asociadas a cada estilo de tipado y también existe una acalorada discusión acerca de qué aproximación es mejor. Numerosas fuentes en Internet ponen empeño en defender una u otra postura:

En este curso utilizaremos el tipado estático como una herramienta de diseño opcional. Algo que es posible gracias al "tipado progresivo".

Tipado progresivo

El tipado progresivo es una forma de tipado híbrida en la que algunos valores son anotados y estáticamente tipados, antes de la ejecución, mientras que otros son anotados dinámicamente y comprobados en tiempo de ejecución.

El tipado progresivo nos permite evolucionar la rigidez de nuestras interfaces, progresivamente, a lo largo del proceso de diseño e implementación del software.

Anotaciones a partir de Python 3.7

Python 3.0 implementa el PEP-3107 que permite anotar el valor de retorno de una función y sus argumentos con una expresión arbitraria:

def add(a: int, b: int) -> int:
  return a + b

Las anotaciones de la función están disponibles a través de la propiedad __annotations__ de la función:

add.__annotations__

El PEP-484 añade el concepto de "pista de tipado" o type hint e introduce los "comentarios de tipo" (type commentaries) para anotar variables:

class Point2D:

  def __init__(self, x: int, y: int):
    self.x = x
    self.y = y

origin = Point2D(0, 0) # type: Point2D

En Python 3.6 se añadió la capacidad de anotar variables de una clase, función o módulo:

class Point2D:

  version: str = 'v1.0'

  def __init__(self, x: int, y: int):
    self.x = x
    self.y = y

origin: Point2D = Point2D(0, 0)

Sin embargo, los comentarios de tipo siguen siendo la única opción en algunas situaciónes:

name, age = 'Salva', 33  # type: str, int

Las anotaciones de una clase están disponibles a través de:

Point2D.__annotations__

Las anotaciones del módulo están disponibles a través de:

__annotations__

Se pueden obtener las anotaciones de un módulo, clase, método o función con la función typing.get_type_hints.

from typing import get_type_hints
get_type_hints(Point2D)
get_type_hints(Point2D.__init__)

mypy

Mypy no viene con Python, no es parte de la biblioteca estándar y no corre automáticamente cuando lanzamos un proyecto. Mypy es un software aparte, que debes instalar con pip. Asegúrate de que lo instalas en el entorno virtual de tu proyecto:

$ pip install mypy

También puedes instalar las extensiones a tipos para acceder a las últimas características de mypy:

$ pip install typing-extensions

Ahora puedes correr mypy sobre un fichero con:

$ mypy modulo.py

La ejecución de un módulo con Python nunca ejecutará mypy automáticamente:

$ python modulo.py

El comando anterior ejecutará modulo.py, sea su contendio erróneo o no para mypy.

PyCharm ya incluye un razonador de tipos que escanea tu código conforme escribes. Sin embargo, nosotros vamos a usar mypy así que, para esta lección, instala el plugin "mypy" de PyCharm y desactiva el comprobador de tipos (type checker) que viene de serie.

Con este plugin, los errores se señalarán en el editor. Como esta vez no estarás usando una consola interactiva, no podrás redefinir las mismas cosas dos veces porque mypy se quejará de que lo estás haciendo.

Por ello, los ejemplos de este tema son autocontenidos, y contienen todos los import necesarios, de forma que puedas reemplazar completamente el contenido de tu fichero con cada uno de ellos.

Tipos con mypy

Todos los razonadores tienen como fin conservar la coherencia de los programas observando que las operaciones se usan sobre los tipos correctos. Un tipo correcto es el tipo declarado en la anotación o cualquiera compatible. La regla de compatibilidad es la herencia basada en el comportamiento que has estudiado. La misma de la que hablaba Liskov y que se recoge en los principios SOLID.

Fíjate que mypy asume que tus jerarquías de herencia siguen esta definición.

Python no se pensó para utilizarse con un razonador de tipos y multitud de software de terceros no está anotado. Afortunadamente, mypy viene con una copia de typeshed, una colección de anotaciones para el software más popular.

La colección typeshed no contiene una copia anotada de todo el software sino que provee de ficheros "esqueleto" o stubs que proporcionan, únicamente, las anotaciones faltantes.

Tipado dinámico o el tipo "cualquiera"

Mypy considera que una función sin anotaciones puede aceptar cualquier tipo de parámetros y devolver cualquier tipo de retorno.

  1. Una función sin anotaciones está tipada dinámicamente para mypy:

    def id(obj):
        return obj
  2. El tipo typing.Any es el equivalente explícito:

    from typing import Any
    
    def wrap(obj: Any) -> Any:
        return [obj]
  3. No te impongas el deber de anotar completamente una función. Utiliza Any para los parámetros o tipos de retorno que no tengas muy claros.

    class Shape:
        ...
    
    def draw_shape(shape: Shape, config: Any = None) -> Any:
        ...

    La gracia de usar tipado progresivo es la de añadir anotaciones conforme tengas necesidad.

El tipo "ninguno"

Una forma de expresar que "no se devuelve nada" es utilizando el tipo None:

  1. Esta función no devuelve nada:

    def draw_shape(shape: Shape, config: Any = None) -> None:
        ...
  2. Como no devuelve nada, no podemos usarla en una asignación:

    shape = Shape()
    result = draw_shape(shape)  # not OK

    Ojo, este código ejecuta sin problema en Python. Lo único que ocurre es que mypy se queja.

Tipado básico

Comencemos con una exploración de cómo se anotan los tipos más sencillos de Python:

  1. Los números se anotan utilizando sus tipos incluidos por defecto en Python:

    x: int = 1
    c: complex = 1j
    f: float = 3.5
  2. Sorprendentemente, esto funciona:

    def add(x: float, y: float) -> float:
        return x + y
    
    add(1, 1)

    Pese a que:

    assert not isinstance(1, float)

    El PEP-484 tiene una excepción para la torre numérica y no require que los tipos numéricos estén enlazados en una jerarquía.

  3. Las cadenas de texto y de bytes se anotan con str y bytes:

    some_chars: str = 'I am Ziltoid'
    some_bytes: bytes = b'I am Ziltoid'

    Existe el tipo Text, que es un alias de str en Python 3 y de unicode en Python 2, para hacer más claro que la variable contendrá texto Unicode:

    from typing import Text
    
    some_text: Text = b'I am Ziltoid' # not OK
  4. mypy permite, incluso, utilizar algunos valores como tipos, con typing_extensions.Literal. Por ejemplo:

    from typing_extensions import Literal
    
    def get_rgb_value(colorname: Literal['red', 'green', 'blue']):
        ...
    
    get_rgb_value('pink')  # not OK

Colecciones

  1. Listas, tuplas, diccionarios y conjuntos pueden anotarse con list, tuple, dict y set pero se prefiere el uso de los tipos en el módulo typing:

    from typing import List, Tuple, Dict, Set
    
    l: List = [1, 2, 3, 4]
    t: Tuple = ('red', 13)
    d: Dict = {'red': 13}
    s: Set = {1j}
  2. En este caso, List, Dict y Set representan implementaciones de una secuencia y un mapa mutables. Sus versiones abstractas serían:

    from typing import MutableSequence, MutableMapping, MutableSet
    
    mseq: MutableSequence = [1, 2, 3, 4]
    mmap: MutableMapping = {'red': 13}
    mset: MutableSet = {1j}
  3. Al tipar contenedores, conviene considerar la mutabilidad del contenedor. Las versiones inmutables de estos tipos son:

    from typing import Sequence, Mapping, Set
    
    seq: Sequence = [1, 2, 3, 4]
    map_: Mapping = {'red': 13}
    set_: Set = {1j}

    ¿Es una lista una secuencia inmutable? ¿Por qué?

    ¿Por qué el siguiente código no es coherente para mypy?

    map_['blue'] = 9  # not OK
    seq.append(4)     # not OK
  4. Los iterable se tipan con Iterable:

    from typing import Iterable
    
    iterable: Iterable = 'ABCDEFG'
    for item in iterable:
        print(item)

Tipos genéricos

Se dice que los contenedores y las funciones son "genéricos" porque pueden actuar sobre otros tipos arbitrarios. Podemos realizar anotaciones más precisas mediante el uso de la notación índice:

  1. Las tuplas pueden declarar el tipo de sus elementos:

    from typing import Tuple
    
    name_and_age: Tuple[str, int] = ('Salva', 33)
  2. Por cierto, si quieres usar una tupla con nombre (namedtuple), puedes usar el contructor de tipos que viene con typing:

    from typing import NamedTuple
    
    Profile = NamedTuple('Profile', (('name', str), ('age', int)))
    salva_info = Profile('Salva', age=33)

    O, a partir de Python 3.6, su vertiente como clase base:

    from typing import NamedTuple
    
    class Profile(NamedTuple):
        name: str
        age: int
    
    salva_info = Profile('Salva', age=33)
  3. Para indicar una tupla con un número indifinido de valores (incluyendo ningún valor):

    from typing import Tuple
    
    def do_max(l: Tuple[int, ...]) -> int:
        return max(l)
    
    do_max((1, 2))
    do_max((1, 2, 3))
    do_max(tuple())  # OK but raise ValueError at runtime
  4. El resto de contenedores tiene un comportamiento similar:

    from typing import List, Mapping, Set
    
    numbers: List[int] = [1, 2, 3]
    ratings: Mapping[str, float] = {'Good Omens': 8.5, 'Game of Thrones': 8.0}
    complex_set: Set[complex] = {1, 1j}

    Fíjate que los tipos para contenedores importados del módulo typing se parece mucho a aquellos en collections.abc. Sin embargo, los últimos no admiten la notación índice:

    from typing import Set
    from collections import abc
    
    Set[str]     # OK
    abc.Set[str] # raises TypeError

    Acuérdate de usar los del módulo typing para anotar y los del módulo collections.abc para implementar la funcionalidad.

  5. Es común utilizar diccionarios como estructuras de datos ligeras así que el módulo mypy_extensions contiene la clase TypedDict que, de manera similar a NamedTuple permite dotar de cierta estructura a los diccionarios:

    from mypy_extensions import TypedDict
    
    Identification = TypedDict('Identification', { 'name': str, 'id': int })
    ziltoid_id: Identification = {'name': 'Ziltoid'}  # not OK

    Para hacer los campos no obligatorios, tendrías hacer:

    from mypy_extensions import TypedDict
    
    Identification =
        TypedDict('Identification', { 'name': str, 'id': int }, total=False)
    ziltoid_id: Identification = {'name': 'Ziltoid'}  # OK

    Usando la notación de clase, a partir de Python 3.6:

    from mypy_extensions import TypedDict
    
    class Identification(TypedDict, total=False):
        name: str
        id: int
    
    marvin_id = Identification(id=123456)  # OK

    ¿Cuál es el tipo, en tiempo de ejecución, de marvin_id?

    • Con la notación de clase, puedes utilizar la herencia para que parte de los campos sean obligatorios y otros no.
  6. Las funciones tienen tipo Callable:

    from typing import Callable
    
    def apply_binary(
        op: Callable[[int, int], int],
        a: int, b: int) -> int:
    
        return op(a, b)
    
    def add(a: int, b: int) -> int:
        return a + b
    
    def sub(a: int, b: int) -> int:
        return a - b
    
    def neg(a: int) -> int:
        return -a
    
    apply_binary(add, 5, 10)  # OK
    apply_binary(sub, 5, 10)  # OK
    apply_binary(neg, 5, 10)  # not OK

    ¿Por qué falla la última expresión?

  7. Un generador, como viste, devuelve un iterable así que su tipo de retorno es Iterable:

    from typing import Iterable
    
    def perfect_squares(start: int = 0) -> Iterable[int]:
        current = start
        while True:
            yield current ** 2
            current += 1
  8. Recuerda la signatura de apply_binary de hace un par de ejemplos:

    from typing import Callable
    
    def apply_binary(
        op: Callable[[int, int], int],
        a: int, b: int) -> int:
    
        return op(a, b)
  9. Realmente, no hace falta restringir las operaciones binarias sobre int. Lo único que necesitas es que el tipo de los parámetros a y b sean los que acepta op; y que el tipo de retorno sea el que devuelve op. Para ello podemos declarar variables de tipo:

    from typing import TypeVar, Callable, Tuple
    
    A = TypeVar('A')
    B = TypeVar('B')
    C = TypeVar('C')
    
    def apply_binary(
        op: Callable[[A, B], C],
        a: A, b: B) -> C:
    
        return op(a, b)
    
    def make_point(a: complex, b: complex) -> Tuple[complex, complex]:
        return a, b
    
    apply_binary(make_point, 1, 1j)  # OK

    Cuando usemos TypeVar es obligatorio que el primer parámetro sea una cadena cuyo valor sea el nombre de la variable donde estemos asignando.

  10. Las variables de tipo, en principio, pueden adoptar cualquier valor aunque podemos restringirlo. Por ejemplo, salvo algunas excepciones, las operaciones sobre cadenas de texto funcionan sobre cadenas de bytes. Es normal querer decir algo como:

    from typing import TypeVar
    
    A = TypeVar('A')
    
    def concat(a: A, b: A) -> A:
        return a + b

    Donde A es o str o bytes. Podemos restringir A pasando los tipos entre los que puede elegir:

    from typing import TypeVar
    
    StrOrBytes = TypeVar('StrOrBytes', str, bytes)
    
    def concat(a: StrOrBytes, b: StrOrBytes) -> StrOrBytes:
        return a + b

    Esta construción sobre cadenas es tan común, que el módulo typing la proporciona con el nombre AnyStr.

    from typing import AnyStr
    
    def concat(a: AnyStr, b: AnyStr) -> AnyStr:
        return a + b
  11. Por último, puedes definir tipos genéricos mediante typing.Generic:

    from typing import Generic, TypeVar
    
    P = TypeVar('P')
    
    class Point2D(Generic[P]):
    
        def __init__(self, x: P, y: P):
            self.x = x
            self.y = y
    
    complex_point: Point2D[complex] = Point2D(0j, 0j)  # OK
    integer_point: Point2D[int] = Point2D(0j, 0j)      # not OK

Sobrenombres para tipos

  1. Considera este tipo para matrices:

    from typing import Sequence
    
    matrix: Sequence[Sequence[complex]] = [[1, 0], [0, 1]]
  2. Resulta algo engorroso de escribir. Por ejemplo en la función:

    from typing import Sequence
    
    def add(
        ma: Sequence[Sequence[complex]],
        mb: Sequence[Sequence[complex]],
        target: Sequence[Sequence[complex]]
        ) -> Sequence[Sequence[complex]]:
        """Add ma and mb and leaves the result in target."""
    
        return ma + mb
  3. En estos casos es mejor definir un alias o sobrenombre:

    from typing import Sequence
    
    Matrix = Sequence[Sequence[complex]]
    
    def add(ma: Matrix, mb: Matrix, target: Matrix) -> Matrix:
        ...
        return target

Uniones y opcionales

Es relativamente normal que queramos expresar que algo puede ser de uno u otro tipo.

  1. Por ejemplo para expresar una lista heterogénea de elementos de tipo cadena o entero.

    array = [1, 'a', 2, 'b', 3, 'c']

    Utilizamos Union para expresar la unión de varios tipos:

    from typing import List, Union
    
    array: List[Union[str, int]] = [1, 'a', 2, 'b', 3, 'c']
  2. La unión también se utiliza para poder asignar None. A veces queremos poder expresar "la ausencia de un valor".

    from typing import TypeVar, Callable, Iterable, Union
    
    T = TypeVar('T')
    
    def find_if(condition: Callable[[T], bool],
                haystack: Iterable[T]) -> Union[T, None]:
        """Return the first item that satisfies the condition or `None`."""
    
        for item in haystack:
            if condition(item):
                return item
    
        return None
    
    def is_perfect_square(n: float) -> bool:
        int_root = int(n ** 0.5)
        return int_root ** 2 == n
    
    assert find_if(is_perfect_square, [6, 7, 8]) is None
  3. Esta construcción Union[T, None] es tan común, que mypy nos da Optional[T] para expresar lo mismo:

    from typing import Optional, Callable, Iterable, TypeVar
    
    T = TypeVar('T')
    
    def find_if(condition: Callable[[T], bool],
                haystack: Iterable[T]) -> Optional[T]:
        """Return the first item that satisfies the condition or `None`."""
    
        for item in haystack:
            if condition(item):
                return item
    
        return None
    
    
    def is_perfect_square(n: float) -> bool:
        int_root = int(n ** 0.5)
        return int_root ** 2 == n
    
    nullable_int: Optional[int] = None  # now OK
    if ...:
        nullable_int = find_if(is_perfect_square, [6, 7, 8, 9])  # also OK

Clases definidas por el usuario

  1. Las clases definidas por el usuario son tipos y por tanto se pueden usar para anotar funciones y variables. Considera la siguiente clase:

    class Point2D:
        ...
  2. Puedes utilizar Point2D como cualquier otro tipo:

    class Point2D:
        ...
    
    def magnitude(p: Point2D) -> float:
        ...
  3. Cuando se trabaja con herencia, las clases derivadas pueden reescribir los métodos de las clases base:

    class MyMap:
    
        def get(item: Any) -> None:
            ...
    
    class FastMap(MyMap):
    
        def get(item: Any, cache: Any) -> None:
            ...

    mypy decidirá que esto es un error porque las signaturas de get en las clases base y derivada no son complatibles.

  4. Una clase puede especificar varias signaturas para un método, gracias al decorador overload:

    from typing import Optional, overload
    
    class Point2D:
    
        @overload
        def __init__(self):
            ...
    
        @overload
        def __init__(self, x: complex):
            ...
    
        def __init__(self,
                    x: Optional[complex] = None,
                    y: Optional[complex] = None):
    
            self.x: complex
            self.y: complex
    
            if x is None and y is None:
                self.x, self.y = 0j, 0j
    
            if x is not None and y is None:
                self.x, self.y = x, x
    
            if x is not None and y is not None:
                self.x, self.y = x, y

    Observa los múltiples if del ejemplo anterior. El razonador de mypy no ejecuta código pero es capaz de extraer información de las condiciones y entender qué variables pueden y no pueden ser None en cada condicional. También funciona con isinstance.

  5. Con mypy podemos anotar los miembros de la instancia, tanto en el cuerpo de la clase como en el método __init__.

  6. Podemos marcar una propiedad para que pertenezca a la clase sólamente con typing.ClassVar:

    from typing import ClassVar, Optional, overload
    
    class Point2D:
    
        version: ClassVar[str] = '1.0.0'
    
        @overload
        def __init__(self):
            ...
    
        @overload
        def __init__(self, x: complex):
            ...
    
        def __init__(self,
                     x: Optional[complex] = None,
                     y: Optional[complex] = None):
    
            self.x: complex
            self.y: complex
    
            if x is None and y is None:
                self.x, self.y = 0j, 0j
    
            if x is not None and y is None:
                self.x, self.y = x, x
    
            if x is not None and y is not None:
                self.x, self.y = x, y
    
    p = Point2D()
    p.version = ''  # not OK
  7. Si además quieres prohibir que se sobre-escriba, anota la propiedad con typing_extensions.Final:

    from typing import ClassVar, Optional, overload
    from typing_extensions import Final
    
    class Point2D:
    
        version: Final[ClassVar[str]] = '1.0.0'
    
        @overload
        def __init__(self):
            ...
    
        @overload
        def __init__(self, x: complex):
            ...
    
        def __init__(self,
                     x: Optional[complex] = None,
                     y: Optional[complex] = None):
    
            self.x: complex
            self.y: complex
    
            if x is None and y is None:
                self.x, self.y = 0j, 0j
    
            if x is not None and y is None:
                self.x, self.y = x, x
    
            if x is not None and y is not None:
                self.x, self.y = x, y
    
    p = Point2D()
    Point2D.version = '5.0.0'  # not OK

    Puedes anotar métodos con el decorador typing_extensions.final para evitar que se sobreescriban o anotar la clase entera para evitar que se puedan crear clases derivadas de ella.

  8. mypy también puede representar el tipo de una clase con typing.Type:

    from typing import TypeVar, Type
    
    class Profile:
        ...
    
    class RichProfile(Profile):
        ...
    
    TProfile = TypeVar('TProfile', bound=Profile)
    
    def new_profile(profile_cls: Type[TProfile]) -> TProfile:
        return profile_cls()
    
    new_profile(Profile)      # OK
    new_profile(RichProfile)  # OK
    new_profile(object)       # not OK

    El parámetro bound de TypeVar permite establecer un límite superior en la jerarquía de herencia.

Duck-typing y protocolos

Hasta ahora has estudiado lo que se llama especialización nominal (nominal subtyping). Una técnica que consiste en declarar qué está heredando de qué, explícitamente, la declaración explícita de las clases base. El [PEP-544](Simulación de tipos, y protocolos) formaliza el concepto de protocolo, que viste en la lección Simulación de tipos, y protocolos, y el concepto de duck-typing, nombrado a lo largo del curso en numerosas ocasiones. A este tipo de herencia, se la denomina especialización estructural (structural subtyping).

  1. Observa cómo mypy no se queja con el siguiente código:

    from typing import Iterable
    
    def print_all(collection: Iterable):
        for item in collection:
            print(item)
    
    class Rgb:
    
        def __iter__(self):
            yield 'red'
            yield 'green'
            yield 'blue'
    
    print_all(Rgb())

    Esto es así porque Iterable es un protocolo y, por tanto, mypy busca la existencia de los métodos declarados en el protocolo y nada más.

  2. Para declarar nuestros propios protocolos:

    from typing import Any
    from typing_extensions import Protocol
    
    class SupportsMod(Protocol):
    
        def __mod__(self, other: Any) -> Any:
            ...
    
    class AlwaysOdd:
    
        def __mod__(self, other: int) -> int:
            return 1
    
    def is_even(something: SupportsMod) -> Any:
        return something % 2 == 0
    
    is_even('%s')         # OK!
    is_even(2)            # OK!
    is_even(AlwaysOdd())  # OK!
    is_even({})           # not OK!

    Si no marcamos con Protocol la clase que define el protocolo, mypy no comprobará la especialización estructural y esperaría especialización nominal, con clases heredando explícitamente de SupportsMod.

Varianza, contravarianza e invarianza de tipos

La herencia de comportamiento establece ciertas restricciones cuando se componen tipos. Por ejemplo, considera la clase Point2D genérica:

from typing import Generic, TypeVar

P = TypeVar('P')

class Point2D(Generic[P]):

    def __init__(self, x: P, y: P):
        self.x = x
        self.y = y

Sabiendo que int es un subtipo de complex, ¿podrías decir que Point2D[int] es subtipo de Point2D[complex]?

Si es así, diríamos que Point2D es covariante con su tipo genérico (porque varía igual que él). La realidad es que también podría ser contravariante si la relación de herencia se diera al revés o invariante si no se pudiera afirmar que una hereda de otra.

mypy no es capaz de calcular la varianza, sino que esta se debe indicar explícitamente. En particular, para este ejemplo, Point2D es, en efecto, covariante, como demuestra:

from typing import Generic, TypeVar

P = TypeVar('P')

class Point2D(Generic[P]):

    def __init__(self, x: P, y: P):
        self.x = x
        self.y = y

def conjugated_point(point: Point2D[complex]) -> Point2D[complex]:
    return Point2D(point.x.conjugate(), point.y.conjugate())

conjugated_point(Point2D(0, 0))
  1. En general, para cualquier razonador de tipos moderno, las colecciones inmutables son covariantes:

    from typing import Sequence
    
    class Employee:
        def do_work(self):
            print('Working...')
    
    
    class Manager(Employee):
        def do_management(self):
            print('Managing stuff...')
    
        def team_size(self):
            return 10
    
    def get_last_employee(collection: Sequence[Employee]) -> Employee:
        return collection[-1]
    
    get_last_employee((Manager(), Manager()))
  2. Por otro lado, las colecciones mutables son invariantes:

    from typing import MutableSequence, List
    
    class Employee:
        def do_work(self):
            print('Working...')
    
    
    class Manager(Employee):
        def do_management(self):
            print('Managing stuff...')
    
        def team_size(self):
            return 10
    
    def add_employee(collection: MutableSequence[Employee]):
        collection.append(Employee())
    
    managers: List[Manager] = [Manager(), Manager()]
    add_employee(managers)  # Unsafe!
    managers[-1].do_management()

    Si mypy permitiera esta operación, la función add_employee cambiaría el tipo declarado de managers de List[Manager] a List[Union[Manager, Employee]].

  3. Por último, los invocables son covariantes respecto a aquello que devuelven:

    from typing import MutableSequence, Callable
    
    class Employee:
        def do_work(self):
            print('Working...')
    
    
    class Manager(Employee):
        def do_management(self):
            print('Managing stuff...')
    
        def team_size(self):
            return 10
    
    def add_employee(collection: MutableSequence[Employee]):
        collection.append(Employee())
    
    def hire_employee():
        print('Hiring Alice')
    
    def hire_manager():
        print('Hiring manager Mary')
    
    def hire(procedure: Callable[[], Employee]) -> Employee:
        return procedure()
    
    hire(hire_employee)  # OK
    hire(hire_manager)   # also OK

    Al fin y al cabo, la llamada a una función puede reemplazarse por su valor de retorno por lo que podría decirse que el tipo de una función puede reemplazarse por el tipo del valor de retorno donde operan las reglas de herencia simples, sea nominal o estructural.

  4. Sin embargo, los invocables son contravariantes con sus argumentos:

    from typing import Callable
    
    class Employee:
        def do_work(self):
            print('Working...')
    
    
    class Manager(Employee):
        def do_management(self):
            print('Managing stuff...')
    
        def team_size(self):
            return 10
    
    def employee_salary(person: Employee) -> float:
        return 2000
    
    def manager_salary(person: Manager) -> float:
        return 2000 + 100 * person.team_size()
    
    def pay(payment_calculation: Callable[[Employee], float], person: Employee):
        print(f'Paying {payment_calculation(person)}')
    
    pay(employee_salary, Employee())  # OK
    pay(manager_salary, Employee())   # unsafe!

    Si mypy permitiera este comportamiento, la función manager_salary, que espera un Manager podría utilizar algún método que solo estuviera en Manager sobre un Employee.

    Sin embargo, si hubiéramos definido pay como:

    from typing import Callable
    
    class Employee:
        def do_work(self):
            print('Working...')
    
    
    class Manager(Employee):
        def do_management(self):
            print('Managing stuff...')
    
        def team_size(self):
            return 10
    
    def employee_salary(person: Employee) -> float:
        return 2000
    
    def manager_salary(person: Manager) -> float:
        return 2000 + 100 * person.team_size()
    
    def pay(payment_calculation: Callable[[Manager], float], person: Manager):
        print(f'Paying {payment_calculation(person)}')
    
    pay(manager_salary, Manager())   # OK
    pay(employee_salary, Manager())  # OK

    Fíjate en que Manager es subclase de Employee pero es Callable[[Employee], float] la que es subclase de Callable[Manager], float.