Skip to content

Latest commit

 

History

History
2016 lines (1570 loc) · 83.9 KB

File metadata and controls

2016 lines (1570 loc) · 83.9 KB

Mais dicas de tipo

Aprendi uma dura lição: para programas pequenos, a tipagem dinâmica é ótima. Para programas grandes precisamos de uma abordagem mais disciplinada. E ajuda se a linguagem der a você aquela disciplina, ao invés de dizer "Bem, faça o que quiser".[1]

— Guido van Rossum
um fã do Monty Python

Este capítulo é uma continuação do [ch_type_hints_def], e fala mais sobre o sistema de tipagem gradual de Python. Os tópicos principais são:

  • Assinaturas de funções sobrecarregadas

  • typing.TypedDict: dando dicas de tipos para dicts usados como registros

  • Coerção de tipo

  • Acesso a dicas de tipo durante a execução

  • Tipos genéricos

    • Declarando uma classe genérica

    • Variância: tipos invariantes, covariantes e contravariantes

    • Protocolos estáticos genéricos

Novidades neste capítulo

Esse capítulo é inteiramente novo, escrito para essa segunda edição de Python Fluente. Vamos começar com sobrecargas.

Assinaturas sobrecarregadas

No Python, as funções podem aceitar diferentes combinações de argumentos.

O decorador @typing.overload permite anotar tais combinações. Isto é particularmente importante quando o tipo devolvido pela função depende do tipo de dois ou mais parâmetros.

Considere a função embutida sum. Esse é o texto de help(sum), traduzido:

>>> help(sum)
sum(iterable, /, start=0)
    Devolve a soma de um valor 'start' (default: 0) mais a soma dos
    números de um iterável

    Quando o iterável é vazio, devolve o valor inicial ('start').
    Esta função é direcionada especificamente para uso com valores
    numéricos e pode rejeitar tipos não-numéricos.

A função embutida sum é escrita em C, mas o typeshed tem dicas de tipos sobrecarregadas para ela, em builtins.pyi:

@overload
def sum(__iterable: Iterable[_T]) -> Union[_T, int]: ...
@overload
def sum(__iterable: Iterable[_T], start: _S) -> Union[_T, _S]: ...

Primeiro, vamos olhar a sintaxe geral das sobrecargas. Esse acima é todo o código sobre sum que você encontrará no arquivo stub (.pyi). A implementação estará em um arquivo diferente. As reticências (...) não tem qualquer função além de cumprir a exigência sintática para um corpo de função, em vez usar de pass. Assim os arquivos .pyi são arquivos Python válidos.

Como mencionado na [arbitrary_arguments_sec], os dois sublinhados prefixando __iterable são a convenção da PEP 484 para argumentos apenas posicionais, que é checada pelo Mypy. Isso significa que você pode invocar sum(my_list), mas não sum(__iterable = my_list).

O checador de tipos tenta fazer a correspondência entre os argumentos dados com cada assinatura sobrecarregada, em ordem. A chamada sum(range(100), 1000) não casa com a primeira sobrecarga, pois aquela assinatura tem apenas um parâmetro. Mas casa com a segunda.

Você pode também usar @overload em um modulo Python (.py) normal, colocando as assinaturas sobrecarregadas logo antes da assinatura real da função e de sua implementação. O mysum.py: definição da função sum com assinaturaas sobrecarregadas mostra como sum apareceria anotada e implementada em um módulo Python.

Example 1. mysum.py: definição da função sum com assinaturaas sobrecarregadas
link:../code/15-more-types/mysum.py[role=include]
  1. Precisamos deste segundo TypeVar na segunda assinatura.

  2. Essa assinatura é para o caso simples: sum(my_iterable). O tipo do resultado pode ser T—o tipo dos elementos que my_iterable produz—ou pode ser int, se o iterável for vazio, pois o valor default do parâmetro start é 0.

  3. Quando start é dado, ele pode ser de qualquer tipo S, então o tipo do resultado é Union[T, S]. É por isso que precisamos de S. Se T fosse reutilizado aqui, então o tipo de start teria que ser do mesmo tipo dos elementos de Iterable[T].

  4. A assinatura da implementação real da função não tem dicas de tipo.

São muitas linhas para anotar uma função de uma única linha. Sinto muito, mas pelo menos a função do exemplo não é foo.

Se quiser aprender sobre @overload lendo código, o typeshed tem centenas de exemplos. Quando escrevo esse capítulo, o arquivo stub do typeshed para as funções embutidas de Python tem 186 sobrecargas—mais que qualquer outro na biblioteca padrão.

Tip
Aproveite a tipagem gradual

Tentar produzir código 100% anotado pode levar a dicas de tipo que acrescentam muito ruído e pouco valor agregado. Refatoração para simplificar as dicas de tipo pode levar a APIs inconvenientes para quem vai usar. Algumas vezes é melhor ser pragmático, e deixar parte do código sem dicas de tipo.

As APIs convenientes e práticas que consideramos pythônicas são muitas vezes difíceis de anotar. Na próxima seção veremos um exemplo: são necessárias seis sobrecargas para anotar adequadamente a função embutida max, que é muito flexível nos parâmetros que aceita.

Sobrecarga máxima

É difícil acrescentar dicas de tipo a funções que usam os poderosos recursos dinâmicos de Python.

Quando estudava o typeshed, encontrei o relatório de bug #4051: Mypy não avisou que é proibido passar None como um dos argumentos para a função embutida max(), ou passar um iterável que em algum momento produz None. Nos dois casos, você recebe uma exceção como a seguinte durante a execução:

TypeError: '>' not supported between instances of 'int' and 'NoneType'

Tradução: '>' não é suportado entre instâncias de 'int' e 'NoneType'.

A documentação de max começa com a seguinte sentença:

Devolve o maior item em um iterável ou o maior de dois ou mais argumentos.

Para mim, essa é uma descrição bastante intuitiva.

Mas se eu for anotar uma função descrita nesses termos, tenho que perguntar: qual dos dois? Um iterável ou dois ou mais argumentos?

A realidade é mais complicada, porque max também pode receber dois argumentos opcionais: key e default.

Escrevi max em Python para evidenciar a relação entre o funcionamento da função e as anotações sobrecarregadas (a função embutida original é escrita em C); veja o mymax.py: Versão da função max em Python.

Example 2. mymax.py: Versão da função max em Python
# imports and definitions omitted, see next listing

MISSING = object()
EMPTY_MSG = 'max() arg is an empty sequence'

# overloaded type hints omitted, see next listing

link:../code/15-more-types/protocol/mymax/mymax.py[role=include]

O foco deste exemplo não é a lógica de max, então não vou explicar a implementação, exceto para falar sobre MISSING. A constante MISSING é uma instância única de object, usada como sentinela. É o valor default para o argumento nomeado default=, de modo que max pode aceitar default=None e ainda assim distinguir entre estas duas situações:

  1. O usuário passou None como argumento default.

  2. O usuário não passou o argumento default (neste caso seu valor fica sendo MISSING).

Quando first é um iterável vazio…​

  1. Se o usuário não forneceu um argumento para default=, então ele é MISSING, e max gera um ValueError.

  2. Se usuário forneceu um valor para default=, incluindo None, e então max devolve o valor de default.

Example 3. mymax.py: início do módulo, com importações, definições e sobrecargas
link:../code/15-more-types/protocol/mymax/mymax.py[role=include]

Minha implementação de max em Python tem mais ou menos o mesmo tamanho daquelas importações e declarações de tipo. Graças à tipagem pato, meu código não tem nenhuma checagem usando isinstance, e fornece a mesma checagem de erro daquelas dicas de tipo—mas apenas durante a execução, claro.

Uma vantagem importante de @overload é declarar o tipo devolvido da forma mais precisa possível, de acordo com os tipos dos argumentos recebidos. Veremos este vantagem a seguir, estudando as sobrecargas de max, em grupos de duas ou três por vez.

Argumentos implementando SupportsLessThan, sem key ou default
@overload
def max(__arg1: LT, __arg2: LT, *_args: LT, key: None = ...) -> LT:
    ...
# ... lines omitted ...
@overload
def max(__iterable: Iterable[LT], *, key: None = ...) -> LT:
    ...

Nestes casos, as entradas são ou argumentos separados do tipo LT que implementam SupportsLessThan, ou um Iterable de itens desse tipo. O tipo devolvido por max é do mesmo tipo dos argumentos ou itens reais, como vimos na [bounded_typevar_sec].

Amostras de chamadas que casam com essas sobrecargas:

max(1, 2, -3)  # returns 2
max(['Go', 'Python', 'Rust'])  # returns 'Rust'
Argumento key fornecido, mas default não
@overload
def max(__arg1: T, __arg2: T, *_args: T, key: Callable[[T], LT]) -> T:
    ...
# ... lines omitted ...
@overload
def max(__iterable: Iterable[T], *, key: Callable[[T], LT]) -> T:
    ...

As entradas podem ser item separados de qualquer tipo T ou um único Iterable[T], e key= deve ser um invocável que recebe um argumento do mesmo tipo T, e devolve um valor que implementa SupportsLessThan. O tipo devolvido por max é o mesmo dos argumentos reais.

Amostras de chamadas que casam com essas sobrecargas:

max(1, 2, -3, key=abs)  # returns -3
max(['Go', 'Python', 'Rust'], key=len)  # returns 'Python'
Argumento default fornecido, key não
@overload
def max(__iterable: Iterable[LT], *, key: None = ...,
        default: DT) -> Union[LT, DT]:
    ...

A entrada é um iterável de itens do tipo LT que implemente SupportsLessThan. O argumento default= é o valor devolvido quando Iterable é vazio. Assim, o tipo devolvido por max deve ser uma Union do tipo LT e do tipo do argumento default.

Amostras de chamadas que casam com essas sobrecargas:

max([1, 2, -3], default=0)  # returns 2
max([], default=None)  # returns None
Argumentos key e default fornecidos
@overload
def max(__iterable: Iterable[T], *, key: Callable[[T], LT],
        default: DT) -> Union[T, DT]:
    ...

As entradas são:

  • Um Iterable de itens de qualquer tipo T

  • Invocável que recebe um argumento do tipo T e devolve um valor do tipo LT, que implementa SupportsLessThan

  • Um valor default de qualquer tipo DT

O tipo devolvido por max deve ser uma Union do tipo T e do tipo do argumento default:

max([1, 2, -3], key=abs, default=None)  # returns -3
max([], key=abs, default=None)  # returns None

Lições da sobrecarga de max

Dicas de tipo permitem ao Mypy marcar uma chamada como max([None, None]) com essa mensagem de erro:

mymax_demo.py:109: error: Value of type variable "_LT" of "max"
  cannot be "None"

Por outro lado, escrever tantas linhas para suportar o checador de tipos pode desencorajar a criação de funções convenientes e flexíveis como max. Se eu precisasse reinventar também a função min, poderia refatorar e reutilizar a maior parte da implementação de max. Mas teria que copiar e colar todas as declarações de sobrecarga—apesar delas serem idênticas para min, exceto pelo nome da função.

Meu amigo João S. O. Bueno—um dos desenvolvedores Python mais inteligentes que conheço—escreveu o seguinte tweet:

Apesar de ser difícil expressar a assinatura de max—ela se encaixa muito facilmente em nossa estrutura mental. Considero a expressividade das marcas de anotação muito limitadas, se comparadas à de Python.

Vamos agora examinar o elemento de tipagem TypedDict. Ele não é tão útil quanto imaginei inicialmente, mas tem seus usos. Experimentar com TypedDict demonstra as limitações da tipagem estática para lidar com estruturas dinâmicas, como dados em formato JSON.

TypedDict

Warning

É tentador usar TypedDict para se proteger contra erros ao tratar estruturas de dados dinâmicas como as respostas da API JSON. Mas os exemplos aqui deixam claro que o tratamento correto de JSON precisa acontecer durante a execução, e não com checagem estática de tipo. Para checar estruturas similares a JSON usando dicas de tipo durante a execução, dê uma olhada no pacote pydantic no PyPI.

Algumas vezes os dicionários de Python são usados como registros, as chaves interpretadas como nomes de campos e os valores como valores dos campos de diferentes tipos. Considere, por exemplo, um registro descrevendo um livro, em JSON ou Python:

{"isbn": "0134757599",
 "title": "Refactoring, 2e",
 "authors": ["Martin Fowler", "Kent Beck"],
 "pagecount": 478}

Antes de Python 3.8, não havia uma boa maneira de anotar um registro como esse, pois os tipos de mapeamento que vimos na [mapping_type_sec] limitam os valores a um mesmo tipo.

Aqui estão duas tentativas ruins de anotar um registro como o objeto JSON acima:

dict[str, Any]

As chaves são str mas os valores podem ser de qualquer tipo.

dict[str, str|int|list[str]]

Difícil de ler, e não preserva a relação entre os nomes dos campos e seus respectivos tipos: title deve ser uma str, ele não pode ser um int ou uma List[str].

A PEP 589—TypedDict: Type Hints for Dictionaries with a Fixed Set of Keys (TypedDict: dicas de tipo para dicionários com um conjunto fixo de chaves_) resolve este problema. O books.py: a definição de BookDict mostra um TypedDict simples.

Example 4. books.py: a definição de BookDict
link:../code/15-more-types/typeddict/books.py[role=include]

À primeira vista, typing.TypedDict pode parecer uma fábrica de classes de dados, similar a typing.NamedTuple—tratada no [ch_dataclass].

A similaridade sintática é enganosa. TypedDict é muito diferente. Ele existe apenas para orientar um checador de tipos, e não tem qualquer efeito durante a execução.

TypedDict fornece duas coisas:

  • Uma sintaxe similar à de classe para anotar um dict com dicas de tipo para os valores de cada campo identificado por um chaves.

  • Um construtor que informa que o checador de tipos deve esperar um dict com chaves e valores como especificados.

Durante a execução, um construtor de TypedDict como BookDict é um placebo: ele tem o mesmo efeito de uma chamada ao construtor de dict com os mesmos argumentos.

O fato de BookDict criar um dict simples também significa que:

  • Os "campos" na definição da pseudoclasse não criam atributos de instância.

  • Não é possível escrever inicializadores com valores default para os "campos".

  • Não é permitido definir métodos.

Vamos explorar o comportamento de um BookDict durante a execução (no Usando um BookDict, mas não exatamente como planejado).

Example 5. Usando um BookDict, mas não exatamente como planejado
>>> from books import BookDict
>>> pp = BookDict(title='Programming Pearls',  # (1)
...               authors='Jon Bentley',  # (2)
...               isbn='0201657880',
...               pagecount=256)
>>> pp  # (3)
{'title': 'Programming Pearls', 'authors': 'Jon Bentley', 'isbn': '0201657880',
 'pagecount': 256}
>>> type(pp)
<class 'dict'>
>>> pp.title  # (4)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
AttributeError: 'dict' object has no attribute 'title'
>>> pp['title']
'Programming Pearls'
>>> BookDict.__annotations__  # (5)
{'isbn': <class 'str'>, 'title': <class 'str'>, 'authors': typing.List[str],
 'pagecount': <class 'int'>}
  1. É possível invocar BookDict como um construtor de dict, com argumentos nomeados, ou passando um argumento dict—incluindo um literal dict.

  2. Ops…​ esqueci que authors deve ser uma lista. Mas não há checagem de tipos estáticos durante a execução.

  3. O resultado da chamada a BookDict é um dict simples…​

  4. …​assim não é possível ler os campos usando a notação objeto.campo.

  5. As dicas de tipo estão em BookDict.__annotations__, e não em pp.

Sem um checador de tipos, TypedDict é tão útil quanto comentários em um programa: pode ajudar a documentar o código, mas só isso. As fábricas de classes do [ch_dataclass], por outro lado, são úteis mesmo se você não usar um checador de tipos, porque durante a execução elas geram uma classe customizada que pode ser instanciada. Elas também fornecem vários métodos ou funções úteis, listadas na [dc_main_features_sec].

O demo_books.py: operações legais e ilegais em um BookDict cria um BookDict válido e tenta executar algumas operações com ele. A seguir, o Verificando os tipos em demo_books.py mostra como TypedDict permite que o Mypy encontre erros.

Example 6. demo_books.py: operações legais e ilegais em um BookDict
link:../code/15-more-types/typeddict/demo_books.py[role=include]
  1. Lembre-se de adicionar o tipo devolvido, assim o Mypy não ignora a função.

  2. Este é um BookDict válido: todas as chaves estão presentes, com valores do tipo correto.

  3. O Mypy vai inferir o tipo de authors a partir da anotação na chave 'authors' em BookDict.

  4. typing.TYPE_CHECKING só é True quando os tipos no programa estão sendo checados. Durante a execução ele é sempre falso.

  5. O if anterior evita que reveal_type(authors) seja chamado durante a execução. reveal_type não é uma função de Python disponível durante a execução, mas sim um instrumento de depuração fornecido pelo Mypy. Por isso não há um import para ela. Veja sua saída no Verificando os tipos em demo_books.py.

  6. As últimas três linhas da função demo são ilegais. Elas vão disparar mensagens de erro no Verificando os tipos em demo_books.py.

Example 7. Verificando os tipos em demo_books.py
…/typeddict/ $ mypy demo_books.py
demo_books.py:13: note: Revealed type is
                  'built-ins.list[built-ins.str]'  (1)
demo_books.py:14: error: Incompatible types in assignment
                  (expression has type "str",
                  variable has type "List[str]")  (2)
demo_books.py:15: error: TypedDict "BookDict" has no key 'weight'  (3)
demo_books.py:16: error: Key 'title' of TypedDict "BookDict"
                  cannot be deleted  (4)
Found 3 errors in 1 file (checked 1 source file)
  1. Esta observação é o resultado de reveal_type(authors).

  2. O tipo da variável authors foi inferido a partir do tipo da expressão que a inicializou, book['authors']. Você não pode atribuir uma str para uma variável do tipo List[str]. Checadores de tipo em geral não permitem que o tipo de uma variável mude.[3]

  3. Não é permitido atribuir a uma chave que não é parte da definição de BookDict.

  4. Não se pode apagar uma chave que é parte da definição de BookDict.

Vejamos agora BookDict sendo usado em assinaturas de função, para checar o tipo em chamadas de função.

Imagine que você precisa gerar XML a partir de registros de livros como esse:

<BOOK>
  <ISBN>0134757599</ISBN>
  <TITLE>Refactoring, 2e</TITLE>
  <AUTHOR>Martin Fowler</AUTHOR>
  <AUTHOR>Kent Beck</AUTHOR>
  <PAGECOUNT>478</PAGECOUNT>
</BOOK>

Se você estivesse escrevendo o código em MicroPython, para ser integrado a um pequeno microcontrolador, poderia escrever uma função parecida com o books.py: a função to_xml.[4]

Example 8. books.py: a função to_xml
link:../code/15-more-types/typeddict/books.py[role=include]
  1. O principal objetivo do exemplo: usar BookDict em uma assinatura de função.

  2. Se a coleção começa vazia, o Mypy não tem como inferir o tipo dos elementos. Por isso a anotação de tipo é necessária aqui.[5]

  3. O Mypy entende testes com isinstance, e trata value como uma list neste bloco.

  4. Quando usei key == 'authors' como condição do if que guarda este bloco, o Mypy encontrou um erro nessa linha: "object" has no attribute "__iter__" ("object" não tem um atributo "__iter__" ), porque inferiu o tipo de value devolvido por book.items() como object, que não suporta o método __iter__ exigido pela expressão geradora. O teste com isinstance funciona porque garante que value é uma list neste bloco.

O books_any.py: a função from_json mostra uma função que interpreta uma str JSON e devolve um BookDict.

Example 9. books_any.py: a função from_json
link:../code/15-more-types/typeddict/books_any.py[role=include]
  1. O tipo devolvido por json.loads() é Any.[6]

  2. Posso devolver whatever—de tipo Any—porque Any é consistente-com todos os tipos, incluindo o tipo declarado do valor devolvido, BookDict.

É muito importante de ter em mente segundo ponto do books_any.py: a função from_json: o Mypy não vai apontar qualquer problema neste código, mas durante a execução o valor em whatever pode não se adequar à estrutura de BookDict—pode até não ser um dict!

Se você rodar o Mypy com --disallow-any-expr, ele vai reclamar sobre as duas linhas no corpo de from_json:

…/typeddict/ $ mypy books_any.py --disallow-any-expr
books_any.py:30: error: Expression has type "Any"
books_any.py:31: error: Expression has type "Any"
Found 2 errors in 1 file (checked 1 source file)

As linhas 30 e 31 mencionadas no trecho acima são o corpo da função from_json. Podemos silenciar o erro de tipo acrescentando uma dica de tipo à inicialização da variável whatever, como no books.py: a função from_json com uma anotação de variável.

Example 10. books.py: a função from_json com uma anotação de variável
link:../code/15-more-types/typeddict/books.py[role=include]
  1. --disallow-any-expr não gera erros quando uma expressão de tipo Any é imediatamente atribuída a uma variável com uma dica de tipo.

  2. Agora whatever é do tipo BookDict, o tipo declarado do valor devolvido.

Warning

Não se deixe enganar por uma falsa sensação de tipagem segura com o books.py: a função from_json com uma anotação de variável! Olhando o código estático, o checador de tipos não tem como prever se json.loads() irá devolver qualquer coisa parecida com um BookDict. Apenas a validação durante a execução pode garantir isso.

A checagem de tipos estática é incapaz de prevenir erros cm código inerentemente dinâmico, como json.loads(), que cria objetos Python de tipos diferentes durante a execução. O demo_not_book.py: from_json devolve um BookDict inválido, e to_xml o aceita, o Relatório do Mypy para demo_not_book.py, reformatado por legibilidade e o Resultado da execução de demo_not_book.py demonstram isso.

Example 11. demo_not_book.py: from_json devolve um BookDict inválido, e to_xml o aceita
link:../code/15-more-types/typeddict/demo_not_book.py[role=include]
  1. Essa linha não produz um BookDict válido—veja o conteúdo de NOT_BOOK_JSON.

  2. Vamos deixar o Mypy revelar alguns tipos.

  3. Isso não deve causar problemas: print consegue lidar com object e com qualquer outro tipo.

  4. BookDict não tem uma chave 'flavor', mas o fonte JSON tem…​o que acontecerá?

  5. Lembre-se da assinatura: to_xml(book: BookDict) {rt-arrow} str:

  6. Como será a saída em XML?

Agora checamos demo_not_book.py com o Mypy:

Example 12. Relatório do Mypy para demo_not_book.py, reformatado por legibilidade
…/typeddict/ $ mypy demo_not_book.py
demo_not_book.py:12: note: Revealed type is
   'TypedDict('books.BookDict', {'isbn': built-ins.str,
                                 'title': built-ins.str,
                                 'authors': built-ins.list[built-ins.str],
                                 'pagecount': built-ins.int})'  (1)
demo_not_book.py:13: note: Revealed type is 'built-ins.list[built-ins.str]'  (2)
demo_not_book.py:16: error: TypedDict "BookDict" has no key 'flavor'  (3)
Found 1 error in 1 file (checked 1 source file)
  1. O tipo revelado é o tipo estático, não o conteúdo de not_book durante a execução.

  2. De novo, este é o tipo estático de not_book['authors'], como definido em BookDict. Não o tipo durante a execução.

  3. Este erro é para a linha print(not_book['flavor']): esta chave não existe no tipo estático.

Agora vamos executar demo_not_book.py, mostrando o resultado no Resultado da execução de demo_not_book.py.

Example 13. Resultado da execução de demo_not_book.py
…/typeddict/ $ python3 demo_not_book.py
{'title': 'Andromeda Strain', 'flavor': 'pistachio', 'authors': True}  (1)
pistachio  (2)
<BOOK>  (3)
        <TITLE>Andromeda Strain</TITLE>
        <FLAVOR>pistachio</FLAVOR>
        <AUTHORS>True</AUTHORS>
</BOOK>
  1. Isso não é um BookDict de verdade.

  2. O valor de not_book['flavor'].

  3. to_xml recebe um argumento BookDict, mas não há qualquer checagem durante a execução: entra lixo, sai lixo.

O Resultado da execução de demo_not_book.py mostra que demo_not_book.py devolve bobagens, mas não há qualquer erro durante a execução. Usar um TypedDict ao tratar dados em formato JSON não resultou em uma tipagem segura.

Olhando o código de to_xml no books.py: a função to_xml do ponto de vista da tipagem pato, o argumento book deve fornecer um método .items() que devolve um iterável de tuplas na forma (chave, valor), onde:

  • chave deve ter um método .upper()

  • valor pode ser qualquer coisa.

A conclusão desta demonstração: quando estamos lidando com dados de estrutura dinâmica, tal como JSON ou XML, TypedDict não é, de forma alguma, um substituto para a validaçào de dados durante a execução. Para isso, use o pydantic.

TypedDict tem mais recursos, incluindo suporte a chaves opcionais, uma forma limitada de herança e uma sintaxe de declaração alternativa. Para saber mais sobre ele, estude a PEP 589—TypedDict: Type Hints for Dictionaries with a Fixed Set of Keys (TypedDict: dicas de tipo para dicionários com um conjunto fixo de chaves).

Vamos agora voltar nossa atenção para uma função que é melhor evitar, mas que algumas vezes é inevitável: typing.cast.

Coerção de tipo (type casting)

Nenhum sistema de tipos é perfeito, nem tampouco os checadores estáticos de tipo, as dicas de tipo no projeto typeshed ou as dicas de tipo em pacotes de terceiros, quando existem.

A função especial typing.cast() é uma forma de lidar com defeitos ou incorreções nas dicas de tipo em código que não podemos consertar. A documentação do Mypy 0.930 explica (tradução nossa):

Coerções são usadas para silenciar avisos espúrios do checador de tipos, e ajudam o checador quando ele não consegue entender o que está acontecendo.

Durante a execução, typing.cast não faz absolutamente nada. Esta é sua implementação:

def cast(typ, val):
    """Cast a value to a type.
    This returns the value unchanged.  To the type checker this
    signals that the return value has the designated type, but at
    runtime we intentionally don't check anything (we want this
    to be as fast as possible).
    """
    return val

A docstring diz: "Coage um valor para um tipo. Isto devolve o valor inalterado. Para o checador de tipos, isto sinaliza que o valor devolvido tem o tipo designado, mas na execução não fazemos nenhuma checagem (queremos que isto seja tão rápido quanto possível)".

A PEP 484 exige que os checadores de tipos "acreditem cegamente" em cast. A seção "Casts" (Coerções) da PEP 484 mostra um exemplo onde o checador precisa da orientação de cast:

link:../code/15-more-types/cast/find.py[role=include]

A chamada next() na expressão geradora vai devolver o índice de um item str ou levantar StopIteration. Assim, find_first_str vai sempre devolver uma str se não for gerada uma exceção, e str é o tipo declarado do valor devolvido.

Mas se a última linha for apenas return a[index], o Mypy inferiria o tipo devolvido como object, porque o argumento a é declarado como list[object]. Então cast() é necessário para orientar o Mypy.[7]

Aqui está outro exemplo com cast, desta vez para corrigir uma dica de tipo desatualizada na biblioteca padrão de Python. No [ex_tcp_mojifinder_main] do [ch_async], criei um objeto asyncio.Server, e queria obter o endereço onde o servidor está ouvindo (aceitando conexões). Escrevi esta linha de código:

addr = server.sockets[0].getsockname()

Mas o Mypy informou o seguinte erro:

Value of type "Optional[List[socket]]" is not indexable

A dica de tipo para Server.sockets no typeshed, em maio de 2021, é válida para Python 3.6, onde o atributo sockets podia ser None. Mas no Python 3.7, sockets se tornou uma propriedade, com um getter que sempre devolve uma list—que pode ser vazia, se o servidor não tiver um socket. E desde o Python 3.8, esse getter devolve uma tuple (usada como uma sequência imutável).

Já que não posso consertar o typeshed nesse instante,[8] acrescentei um cast, assim:

link:../code/15-more-types/cast/tcp_echo.py[role=include]

# ... muitas linhas omitidas ...

link:../code/15-more-types/cast/tcp_echo.py[role=include]

Usar cast nesse caso exigiu algumas horas para entender o problema e ler o código-fonte de asyncio, para encontrar o tipo correto para sockets: a classe TransportSocket do módulo não-documentado asyncio.trsock. Também precisei adicionar duas instruções import e mais uma linha de código para melhorar a legibilidade.[9] Mas agora o código está mais seguro.

A leitora atenta pode ter notado que sockets[0] poderia gerar um IndexError se sockets estiver vazio. Entretanto, até onde entendo o asyncio, isso não pode acontecer no [ex_tcp_mojifinder_main] do [ch_async], pois no momento em que leio o atributo sockets, o server já está pronto para aceitar conexões , portanto o atributo não estará vazio. E, de qualquer forma, IndexError ocorre durante a execução. O Mypy não consegue localizar esse problema nem mesmo em um caso trivial como print([][0]).

Warning

Não se acostume a usar cast para silenciar o Mypy toda hora, porque normalmente o Mypy está certo quando aponta um erro. Se você estiver aplicando cast com frequência, isso é um code smell (cheiro no código). Sua equipe pode estar fazendo um mau uso das dicas de tipo, ou sua base de código pode ter dependências de baixa qualidade.

Apesar de suas desvantagens, há usos válidos para cast. Eis algo que Guido van Rossum escreveu sobre isso:

O que está errado com uma ocasional chamada a cast() ou um comentário # type: ignore?[10]

É insensato banir inteiramente o uso de cast, principalmente porque as alternativas para contornar esses problemas são piores:

  • # type: ignore é menos informativo.[11]

  • Usar Any é contagioso: já que Any é consistente-com todos os tipos, seu abuso pode produzir efeitos em cascata através da inferência de tipo, minando a capacidade do checador de tipos para detectar erros em outras partes do código.

Claro, nem todos os contratempos de tipagem podem ser resolvidos com cast. Algumas vezes precisamos de # type: ignore, do Any aqui ou ali, ou mesmo deixar uma função sem dicas de tipo.

A seguir, vamos falar sobre o uso de anotações durante a execução.

Lendo dicas de tipo durante a execução

Durante a importação, Python lê as dicas de tipo em funções, classes e módulos, e as armazena em atributos chamados __annotations__. Considere, por exemplo, a função clip no clipannot.py: a assinatura anotada da função clip.[12]

Example 14. clipannot.py: a assinatura anotada da função clip
def clip(text: str, max_len: int = 80) -> str:

As dicas de tipo são armazenadas em um dict no atributo __annotations__ da função:

>>> from clip_annot import clip
>>> clip.__annotations__
{'text': <class 'str'>, 'max_len': <class 'int'>, 'return': <class 'str'>}

A chave 'return' está mapeada para a dica do tipo devolvido após o símbolo {rt-arrow} no clipannot.py: a assinatura anotada da função clip.

Observe que as anotações são avaliadas pelo interpretador no momento da importação, ao mesmo tempo em que os valores default dos parâmetros são avaliados. Por isso os valores nas anotações são as classes Python str e int, e não as strings 'str' e 'int'. A avaliação das anotações no momento da importação é o padrão desde o Python 3.10, mas isso pode mudar se a PEP 563 ou a PEP 649 se tornarem o comportamento padrão.

Problemas com anotações durante a execução

O aumento do uso de dicas de tipo gerou dois problemas:

  • Importar módulos usa mais CPU e memória quando são há dicas de tipo.

  • Referências a tipos ainda não definidos exigem o uso de strings em vez dos tipos reais.

As duas questões são relevantes. A primeira pelo que acabamos de ver: anotações são avaliadas pelo interpretador durante a importação e armazenadas no atributo __annotations__. Quando uma empresa tem milhares de servidores importanto arquivos Python, o custo pode ser significativo, mesmo considerando que a importação de cada módulo só acontece no início do processo.

Vamos nos concentrar agora no segundo problema.

Armazenar anotações como string é necessário algumas vezes, por causa do problema da referência futura (forward reference): quando uma dica de tipo precisa se referir a uma classe definida mais adiante no mesmo módulo. Entretanto uma manifestação comum desse problema no código-fonte não se parece de forma alguma com uma referência futura: quando um método devolve um novo objeto da mesma classe. Já que o objeto classe não está definido até Python terminar a avaliação do corpo da classe, as dicas de tipo precisam usar o nome da classe como string. Eis um exemplo:

class Rectangle:
    # ... lines omitted ...
    def stretch(self, factor: float) -> 'Rectangle':
        return Rectangle(width=self.width * factor)

Escrever dicas de tipo com referências futuras como strings é a prática padrão e exigida no Python 3.10. Os checadores de tipos estáticos foram projetados desde o início para lidar com esse problema.

Mas durante a execução, se você escrever código para ler a anotação return de stretch, vai receber a string 'Rectangle' em vez de uma referência ao tipo real, a classe Rectangle. E aí seu código precisa descobrir o que aquela string significa.

O módulo typing inclui três funções e uma classe categorizadas como Auxiliares de introspecção, sendo typing.get_type_hints a mais importante delas. Parte de sua documentação afirma:

get_type_hints(obj, globals=None, locals=None, include_extras=False)

[…​] Isso é muitas vezes igual a obj.__annotations__. Além disso, referências futuras codificadas como strings literais são tratadas por sua avaliação nos espaços de nomes globals e locals. […​]

Warning

Desde o Python 3.10, a nova função inspect.get_annotations(…) deve ser usada, em vez de get_type_hints. Entretanto, alguns leitores podem ainda não estar trabalhando com Python 3.10, então usarei get_type_hints nos exemplos, pois essa função está disponível desde a inclusão do módulo typing, no Python 3.5.

A PEP 563—Postponed Evaluation of Annotations (Avaliação Adiada de Anotações) foi aprovada para tornar desnecessário escrever anotações como strings, e para reduzir o custo das dicas de tipo durante a execução. A ideia principal está descrita nessas duas sentenças do Abstract:

Esta PEP propõe modificar as anotações de funções e de variáveis, de forma que elas não mais sejam avaliadas no momento da definição da função. Em vez disso, elas são preservadas em __annotations__ na forma de strings.

A partir de Python 3.7, é assim que anotações são tratadas em qualquer módulo que comece com a seguinte instrução import:

from __future__ import annotations

Para demonstrar seu efeito, coloquei a mesma função clip do clipannot.py: a assinatura anotada da função clip em um módulo clip_annot_post.py com aquela linha de importação __future__ no início.

No console, esse é o resultado de importar aquele módulo e ler as anotações de clip:

>>> from clip_annot_post import clip
>>> clip.__annotations__
{'text': 'str', 'max_len': 'int', 'return': 'str'}

Como se vê, todas as dicas de tipo são agora strings simples, apesar de não terem sido escritas como strings na definição de clip (no clipannot.py: a assinatura anotada da função clip).

A função typing.get_type_hints consegue resolver muitas dicas de tipo, incluindo essas de clip:

>>> from clip_annot_post import clip
>>> from typing import get_type_hints
>>> get_type_hints(clip)
{'text': <class 'str'>, 'max_len': <class 'int'>, 'return': <class 'str'>}

A chamada a get_type_hints nos dá os tipos reais—mesmo em alguns casos onde a dica de tipo original foi escrita como uma string. Esta é a maneira recomendada de ler dicas de tipo durante a execução.

O comportamento prescrito na PEP 563 estava previsto para se tornar o default no Python 3.10, tornando a importação com __future__ desnecessária. Entretanto, os mantenedores da FastAPI e do pydantic avisaram de que tal mudança quebraria seu código, que se baseia em dicas de tipo durante a execução e não podem usar get_type_hints de forma confiável.

Na discussão que se seguiu na lista de e-mail python-dev, Łukasz Langa—autor da PEP 563—descreveu algumas limitações daquela função:

[…​] a verdade é que typing.get_type_hints() tem limites que tornam seu uso geral custoso durante a execução e, mais importante, insuficiente para resolver todos os tipos. O exemplo mais comum se refere a contextos não-globais nos quais tipos são gerados (isto é, classes aninhadas, classes dentro de funções, etc.). Mas um dos principais exemplos de referências futuras, classes com métodos aceitando ou devolvendo objetos de seu próprio tipo, também não é tratado de forma apropriada por typing.get_type_hints() se um gerador de classes for usado. Há alguns truques que podemos usar para ligar os pontos mas, de uma forma geral, isso não é bom.[13]

O Steering Council de Python decidiu adiar a elevação da PEP 563 a comportamento padrão até Python 3.11 ou posterior, dando mais tempo aos desenvolvedores para criar uma solução para os problemas que a PEP 563 tentou resolver, sem quebrar o uso dissseminado das dicas de tipo durante a execução. A PEP 649—Deferred Evaluation Of Annotations Using Descriptors (Avaliação adiada de anotações usando descritores_) está sendo considerada como uma possível solução, mas algum outro acordo ainda pode ser alcançado.

Resumindo: ler dicas de tipo durante a execução não é 100% confiável no Python 3.10 e provavelmente mudará em alguma futura versão.

Note

Empresas usando Python em escala muito grande desejam os benefícios da tipagem estática, mas não querem pagar o preço da avaliação de dicas de tipo no momento da importação. A checagem estática acontece nas estações de trabalho dos desenvolvedores e em servidores de integração contínua dedicados, mas o carregamento de módulos acontece com uma frequência e um volume muito maiores, em servidores de produção, e este custo não é desprezível em grande escala.

Isto cria uma tensão na comunidade Python, entre aqueles que querem as dicas de tipo armazenadas apenas como strings—para reduzir os custos de carregamento—versus aqueles que também querem usar as dicas de tipo durante a execução, como os criadores e os usuários do pydantic e da FastAPI, para quem seria mais fácil acessar diretamente os tipos, ao invés de precisarem analisar strings nas anotações, uma tarefa complicada.

Lidando com o problema

Dada a instabilidade da situação atual, se você precisar ler anotações durante a execução, recomendo o seguinte:

  • Evite ler __annotations__ diretamente; em vez disso, use inspect.get_annotations (desde o Python 3.10) ou typing.get_type_hints (desde o Python 3.5).

  • Escreva uma função customizada própria, como um invólucro para inspect.get_annotations ou typing.get_type_hints, e faça o restante de sua base de código chamar aquela função, de forma que mudanças futuras fiquem restritas a um único local.

Para demonstrar esse segundo ponto, aqui estão as primeiras linhas da classe Checked, que estudaremos no [ex_checked_class_top] do [ch_class_metaprog]:

class Checked:
    @classmethod
    def _fields(cls) -> dict[str, type]:
        return get_type_hints(cls)
    # ... more lines ...

O método de Checked._fields evita que outras partes do módulo dependam diretamente de typing.get_type_hints. Se get_type_hints mudar no futuro, exigindo lógica adicional, ou se eu quiser substituí-la por inspect.get_annotations, a mudança estará limitada a Checked._fields e não afetará o restante do programa.

Warning

Dadas as discussões correntes e as mudanças propostas para a inspeção de dicas de tipo durante a execução, a página da documentação oficial "Boas Práticas de Anotação" é uma leitura obrigatória, e a página deve ser atualizada até o lançamento de Python 3.11. Aquele how-to foi escrito por Larry Hastings, autor da PEP 649—Deferred Evaluation Of Annotations Using Descriptors (Avaliação Adiada de Anotações Usando Descritores), uma proposta alternativa para tratar os problemas gerados durante a execução pela PEP 563—Postponed Evaluation of Annotations (_Avaliação Adiada de Anotações).

As seções restantes desse capítulo cobrem tipos genéricos, começando pela forma de definir uma classe genérica, que pode ser parametrizada por seus usuários.

Implementando uma classe genérica

No [ex_tombola_abc] do [ch_ifaces_prot_abc] definimos a ABC Tombola: uma interface para classes que funcionam como um recipiente para sorteio de bingo. A classe LottoBlower ([ex_lotto] do [ch_ifaces_prot_abc]) é uma implementação concreta. Vamos agora estudar uma versão genérica de LottoBlower, usada da forma que aparece no generic_lotto_demo.py: usando uma classe genérica de sorteio de bingo.

Example 15. generic_lotto_demo.py: usando uma classe genérica de sorteio de bingo
link:../code/15-more-types/lotto/generic_lotto_demo.py[role=include]
  1. Para instanciar uma classe genérica, passamos a ela um parâmetro de tipo concreto, como int aqui.

  2. O Mypy irá inferir corretamente que first é um int…​

  3. …​ e que remain é uma tuple de inteiros.

Além disso, o Mypy aponta violações do tipo parametrizado com mensagens úteis, como ilustrado no generic_lotto_errors.py: erros apontados pelo Mypy.

Example 16. generic_lotto_errors.py: erros apontados pelo Mypy
link:../code/15-more-types/lotto/generic_lotto_errors.py[role=include]
  1. Na instanciação de LottoBlower[int], o Mypy marca o float.

  2. Na chamada .load('ABC'), o Mypy explica porque uma str não serve: str.__iter__ devolve um Iterator[str], mas LottoBlower[int] exige um Iterator[int].

Example 17. generic_lotto.py: uma classe genérica de sorteador de bingo
link:../code/15-more-types/lotto/generic_lotto.py[role=include]
  1. Declarações de classes genéricas muitas vezes usam herança múltipla, porque precisamos incluir a superclasse Generic para declarar os parâmetros de tipo formais—neste caso, T.

  2. O argumento items em __init__ é do tipo Iterable[T], que se torna Iterable[int] quando uma instância é declarada como LottoBlower[int].

  3. O método load é igualmente anotado.

  4. O tipo do valor devolvido T agora se torna int em um LottoBlower[int].

  5. Nenhuma variável de tipo aqui.

  6. Por fim, T define o tipo dos itens na tuple devolvida.

Tip

A seção User-defined generic types (Tipos genéricos definidos pelo usuário), na documentação do módulo typing, é curta, inclui bons exemplos e fornece alguns detalhes que não menciono aqui.

Agora que vimos como implementar um classe genérica, vamos definir a terminologia para falar sobre tipos genéricos.

Jargão básico para tipos genéricos

Aqui estão algumas definições que encontrei estudando genéricos:[14]

Tipo genérico

Um tipo declarado com uma ou mais variáveis de tipo.
Exemplos: LottoBlower[T], abc.Mapping[KT, VT]

Parâmetro de tipo formal

As variáveis de tipo que aparecem em um declaração de tipo genérica.
Exemplo: KT e VT no último exemplo: abc.Mapping[KT, VT]

Tipo parametrizado

Um tipo declarado com os parâmetros de tipo reais.
Exemplos: LottoBlower[int], abc.Mapping[str, float]

Parâmetro de tipo real

Os tipos reais passados como parâmetros quando um tipo parametrizado é declarado.
Exemplo: o int em LottoBlower[int]

O próximo tópico é sobre como tornar os tipos genéricos mais flexíveis, introduzindo os conceitos de covariância, contravariância e invariância.

Variância

Note

Dependendo de sua experiência com genéricos em outras linguagens, esta pode ser a seção mais difícil do livro. O conceito de variância é abstrato, e uma apresentação rigorosa faria essa seção se parecer com páginas de um livro de matemática.

Na prática, a variância é mais relevante para autores de bibliotecas que querem suportar novos tipos de coleções genéricas ou fornecer uma API baseada em callbacks. Mesmo nestes casos, é possível evitar muita complexidade suportando apenas coleções invariantes—que é o que temos hoje na biblioteca padrão. Então, em uma primeira leitura você pode pular toda esta seção, ou ler apenas as partes sobre tipos invariantes.

Já vimos o conceito de variância na [callable_variance_sec], aplicado a tipos genéricos Callable parametrizados. Aqui vamos expandir o conceito para abarcar tipos genéricos de coleções, usando uma analogia do "mundo real" para tornar mais concreto esse conceito abstrato.

Imagine uma cantina escolar que tenha como regra que apenas máquinas servindo sucos podem ser instaladas.[15] Máquinas de bebida genéricas não são permitidas, pois podem servir refrigerantes, que foram banidos pela direção da escola.[16]

Uma máquina de bebida invariante

Vamos tentar modelar o cenário da cantina com uma classe genérica BeverageDispenser, que pode ser parametrizada com o tipo de bebida.. Veja o invariant.py: definições de tipo e função install.

Example 18. invariant.py: definições de tipo e função install
link:../code/15-more-types/cafeteria/invariant.py[role=include]
  1. Beverage, Juice, e OrangeJuice formam uma hierarquia de tipos.

  2. Uma declaração TypeVar simples.

  3. BeverageDispenser é parametrizada pelo tipo de bebida.

  4. install é uma função global do módulo. Sua dica de tipo faz valer a regra de que apenas máquinas de suco são aceitáveis.

Dadas as definições no invariant.py: definições de tipo e função install, o seguinte código é válido:

link:../code/15-more-types/cafeteria/invariant.py[role=include]

Entretanto, isto não é válido:

link:../code/15-more-types/cafeteria/invariant.py[role=include]

Uma máquina que serve qualquer Beverage não é aceitável, pois a cantina exige uma máquina especializada em Juice.

De forma um tanto surpreendente, este código também é inválido:

link:../code/15-more-types/cafeteria/invariant.py[role=include]

Uma máquina especializada em OrangeJuice também não é permitida. Apenas BeverageDispenser[Juice] serve. No jargão da tipagem, dizemos que BeverageDispenser(Generic[T]) é invariante quando BeverageDispenser[OrangeJuice] não é compatível com BeverageDispenser[Juice]—apesar do fato de OrangeJuice ser um subtipo-de Juice.

Os tipos de coleções mutáveis de Python—tal como list e set—são invariantes. A classe LottoBlower do generic_lotto.py: uma classe genérica de sorteador de bingo também é invariante.

Uma máquina de bebida covariante

Se quisermos ser mais flexíveis, e modelar as máquinas de bebida como uma classe genérica que aceite alguma bebida e também seus subtipos, precisamos tornar a classe covariante. O covariant.py: definições de tipos e função install mostra como declararíamos BeverageDispenser.

Example 19. covariant.py: definições de tipos e função install
link:../code/15-more-types/cafeteria/covariant.py[role=include]
  1. Define covariant=True ao declarar a variável de tipo; _co é o sufixo convencional para parâmetros de tipo covariantes no typeshed.

  2. Usa T_co para parametrizar a classe especial Generic.

  3. As dicas de tipo para install são as mesmas do invariant.py: definições de tipo e função install.

O código abaixo funciona porque tanto a máquina de Juice quanto a de OrangeJuice são válidas em uma BeverageDispenser covariante:

link:../code/15-more-types/cafeteria/covariant.py[role=include]

mas uma máquina de uma Beverage arbitrária não é aceitável:

link:../code/15-more-types/cafeteria/covariant.py[role=include]

Isso é uma covariância: a relação de subtipo das máquinas parametrizadas varia na mesma direção da relação de subtipo dos parâmetros de tipo.

Uma lata de lixo contravariante

Vamos agora modelar a regra da cantina para a instalação de uma lata de lixo. Vamos supor que a comida e a bebida são servidas em recipientes biodegradáveis, e as sobras e utensílios descartáveis também são biodegradáveis. As latas de lixo devem ser adequadas para resíduos biodegradáveis.

Note

Neste exemplo didático, vamos fazer algumas suposições e classificar o lixo em uma hierarquia simplificada:

  • Refuse (Resíduo) é o tipo mais geral de lixo. Todo lixo é resíduo.

  • Biodegradable (Biodegradável) é um tipo de lixo decomposto por microrganismos ao longo do tempo. Parte do Refuse não é Biodegradable.

  • Compostable (Compostável) é um tipo específico de lixo Biodegradable que pode ser transformado de em fertilizante orgânico, em um processo de compostagem. Na nossa definição, nem todo lixo Biodegradable é Compostable.

Para modelar a regra descrevendo uma lata de lixo aceitável na cantina, precisamos introduzir o conceito de "contravariância" através de um exemplo, apresentado no contravariant.py: definições de tipo e a função install.

Example 20. contravariant.py: definições de tipo e a função install
link:../code/15-more-types/cafeteria/contravariant.py[role=include]
  1. Uma hierarquia de tipos para resíduos: Refuse é o tipo mais geral, Compostable o mais específico.

  2. T_contra é o nome convencional para uma variável de tipo contravariante.

  3. TrashCan é contravariante ao tipo de resíduo.

  4. A função deploy exige uma lata de lixo compatível com TrashCan[Biodegradable].

Dadas essas definições, os seguintes tipos de lata de lixo são aceitáveis:

link:../code/15-more-types/cafeteria/contravariant.py[role=include]

A função deploy aceita uma TrashCan[Refuse], pois ela pode receber qualquer tipo de resíduo, incluindo Biodegradable. Entretanto, uma TrashCan[Compostable] não serve, pois ela não pode receber Biodegradable:

link:../code/15-more-types/cafeteria/contravariant.py[role=include]

Vamos resumir os conceitos vistos até aqui.

Revisão da variância

A variância é uma propriedade sutil. As próximas seções recapitulam o conceito de tipos invariantes, covariantes e contravariantes, e fornecem algumas regras gerais para pensar sobre eles.

Tipos invariantes

Um tipo genérico L é invariante quando não há nenhuma relação de supertipo ou subtipo entre dois tipos parametrizados, independente da relação que possa existir entre os parâmetros concretos. Em outras palavras, se L é invariante, então L[A] não é supertipo ou subtipo de L[B]. Eles são inconsistentes em ambos os sentidos.

Como mencionado, as coleções mutáveis de Python são invariantes por default. O tipo list é um bom exemplo: list[int] não é consistente-com list[float], e vice-versa.

Em geral, se um parâmetro de tipo formal aparece em dicas de tipo de argumentos a métodos, e o mesmo parâmetro aparece nos tipos devolvidos pelo método, aquele parâmetro deve ser invariante, para garantir a segurança de tipo na atualização e leitura da coleção.

Por exemplo, aqui está parte das dicas de tipo para o tipo embutido list no typeshed:

class list(MutableSequence[_T], Generic[_T]):
    @overload
    def __init__(self) -> None: ...
    @overload
    def __init__(self, iterable: Iterable[_T]) -> None: ...
    # ... lines omitted ...
    def append(self, __object: _T) -> None: ...
    def extend(self, __iterable: Iterable[_T]) -> None: ...
    def pop(self, __index: int = ...) -> _T: ...
    # etc...

Veja que _T aparece entre os parâmetros de __init__, append e extend, e como tipo devolvido por pop. Não há como tornar segura a tipagem dessa classe se ela for covariante ou contravariante em _T.

Tipos covariantes

Considere dois tipos A e B, onde B é consistente-com A, e nenhum deles é Any. Alguns autores usam os símbolos <: e :> para indicar relações de tipos como essas:

A :> B

A é um supertipo-de ou igual a B.

B <: A

B é um subtipo-de ou igual a A.

Dado A :> B, um tipo genérico C é covariante quando C[A] :> C[B].

Observe que a direção da seta no símbolo :> é a mesma nos dois casos em que A está à esquerda de B. Tipos genéricos covariantes seguem a relação de subtipo do tipo real dos parâmetros.

Contêineres imutáveis podem ser covariantes. Por exemplo, é assim que a classe typing.FrozenSet está documentada como covariante com uma variável de tipo usando o nome convencional T_co:

class FrozenSet(frozenset, AbstractSet[T_co]):

Aplicando a notação :> a tipos parametrizados, temos:

           float :> int
frozenset[float] :> frozenset[int]

Iteradores são outro exemplo de genéricos covariantes: eles não são coleções apenas para leitura como um frozenset, mas apenas produzem itens sob demanda. Qualquer código que espere um abc.Iterator[float] que produz números de ponto flutuante pode usar com segurança um abc.Iterator[int] que produz inteiros. Tipos Callable são covariantes no tipo devolvido pela mesma razão.

Tipos contravariantes

Dado A :> B, um tipo genérico K é contravariante se K[A] <: K[B].

Tipos genéricos contravariantes revertem a relação de subtipo dos tipos reais dos parâmetros.

A classe TrashCan exemplifica isso:

          Refuse :> Biodegradable
TrashCan[Refuse] <: TrashCan[Biodegradable]

Um contêiner contravariante normalmente é uma estrutura de dados só para escrita, também conhecida como "coletor" (sink). Não há exemplos de coleções deste tipo na biblioteca padrão, mas existem alguns tipos com parâmetros de tipo contravariantes.

Callable[[ParamType, …], ReturnType] é contravariante nos tipos dos parâmetros, mas covariante no ReturnType, como vimos na [callable_variance_sec]. Além disso, Generator, Coroutine, e AsyncGenerator têm um parâmetro de tipo contravariante. O tipo Generator está descrito na [generic_classic_coroutine_types_sec]; Coroutine e AsyncGenerator são descritos no [ch_async].

Para efeito da presente discussão sobre variância, o ponto principal é que parâmetros formais contravariantes definem o tipo dos argumentos usados para invocar ou enviar dados para o objeto, enquanto parâmetros formais covariantes definem os tipos de saídas produzidos pelo objeto—o tipo devolvido por uma função ou produzido por um gerador. Os significados precisos de "enviar" e "produzir" são definidos na [classic_coroutines_sec].

A partir destas observações sobre saídas covariantes e entradas contravariantes podemos derivar algumas orientações úteis.

Regras gerais de variância

Por fim, aqui estão algumas regras gerais a considerar quando estamos pensando sobre variância:

  1. Se um parâmetro de tipo formal define um tipo para dados que saem de um objeto, ele pode ser covariante.

  2. Se um parâmetro de tipo formal define um tipo para dados que entram em um objeto, ele pode ser contravariante.

  3. Se um parâmetro de tipo formal define um tipo para dados que saem de um objeto e o mesmo parâmetro define um tipo para dados que entram em um objeto, ele deve ser invariante.

  4. Na dúvida, use parâmetros de tipo formais invariantes. Não haverá prejuízo se no futuro precisar usar parâmetros de tipo covariantes ou contravariantes, pois nestes casos a tipagem ficará mais tolerante e não quebrará códigos existentes.

Callable[[ParamType, …], ReturnType] demonstra as regras 1 e 2: O ReturnType é covariante, e cada ParamType é contravariante.

Por default, TypeVar cria parâmetros formais invariantes, e é assim que as coleções mutáveis na biblioteca padrão são anotadas.

Veremos mais exemplos de variância em [generic_classic_coroutine_types_sec].

A seguir, vamos ver como definir protocolos estáticos genéricos, aplicando a ideia de covariância a alguns novos exemplos.

Implementando um protocolo estático genérico

A biblioteca padrão de Python 3.10 fornece alguns protocolos estáticos genéricos. Um deles é SupportsAbs, implementado assim no módulo typing:

@runtime_checkable
class SupportsAbs(Protocol[T_co]):
    """An ABC with one abstract method __abs__ that is covariant in its
        return type."""
    __slots__ = ()

    @abstractmethod
    def __abs__(self) -> T_co:
        pass

T_co é declarado de acordo com a convenção de nomenclatura:

T_co = TypeVar('T_co', covariant=True)

Graças a SupportsAbs, o Mypy considera válido o seguinte código, como visto no abs_demo.py: uso do protocolo genérico SupportsAbs.

Example 21. abs_demo.py: uso do protocolo genérico SupportsAbs
link:../code/15-more-types/protocol/abs_demo.py[role=include]
  1. Definir __abs__ torna Vector2d consistente-com SupportsAbs.

  2. Parametrizar SupportsAbs com float assegura…​

  3. …​que o Mypy aceite abs(v) como primeiro argumento para math.isclose.

  4. Graças a @runtime_checkable na definição de SupportsAbs, essa é uma asserção válida durante a execução.

  5. Todo o restante do código passa pelas checagens do Mypy e pelas asserções durante a execução.

  6. O tipo int também é consistente-com SupportsAbs. De acordo com o typeshed, int.__abs__ devolve um int, o que é consistente-com o parametro de tipo float declarado na dica de tipo is_unit para o argumento v.

De forma similar, podemos escrever uma versão genérica do protocolo RandomPicker, apresentado no [ex_randompick_protocol] do [ch_ifaces_prot_abc], que foi definido com um único método pick devolvendo Any.

O generic_randompick.py: definição do RandomPicker genérico mostra como criar um RandomPicker genérico, covariante no tipo devolvido por pick.

Example 22. generic_randompick.py: definição do RandomPicker genérico
link:../code/15-more-types/protocol/random/generic_randompick.py[role=include]
  1. Declara T_co como covariante.

  2. Isso torna RandomPicker genérico, com um parâmetro de tipo formal covariante.

  3. Usa T_co como tipo do valor devolvido.

O protocolo genérico RandomPicker pode ser covariante porque seu único parâmetro formal é usado em um tipo de saída.

Com isso, podemos dizer que temos mais um capítulo.

Resumo do capítulo

Começamos com um exemplo simples de uso de @overload, seguido por um exemplo mais complexo, que estudamos em detalhes: as assinaturas sobrecarregadas exigidas para anotar corretamente a função embutida max.

A seguir veio o tipo especial typing.TypedDict. Escolhi tratar dele aqui e não no [ch_dataclass], onde vimos typing.NamedTuple, porque TypedDict parece mas não é uma fábrica de classes; ele é meramente uma forma de acrescentar dicas de tipo a uma variável ou a um argumento que exige um dict com um conjunto específico de chaves do tipo string, e tipos específicos para cada chave—algo que acontece quando usamos um dict como registro, muitas vezes no contexto do tratamento de dados JSON. Aquela seção foi um pouco mais longa porque usar TypedDict pode levar a um falso sentimento de segurança, e eu queria mostrar como as checagens durante a execução e o tratamento de erros são inevitáveis quando tentamos criar registros estruturados estaticamente a partir de mapeamentos, que são dinâmicos por natureza.

Então falamos sobre typing.cast, uma função criada para nos permitir orientar o checador de tipos. É importante considerar cuidadosamente quando usar cast, porque seu uso excessivo atrapalha o checador de tipos.

O acesso a dicas de tipo durante a execução veio em seguida. O ponto principal era usar typing.get_type_hints em vez de ler o atributo __annotations__ diretamente. Entretanto, aquela função pode não ser confiável para algumas anotações, e vimos que os mantenedores de Python ainda estão discutindo uma forma de tornar as dicas de tipo usáveis durante a execução, e ao mesmo tempo reduzir seu impacto sobre o uso de CPU e memória.

A última seção foi sobre genéricos, começando com a classe genérica LottoBlower—que mais tarde aprendemos ser uma classe genérica invariante. Aquele exemplo foi seguido pelas definições de quatro termos básicos: tipo genérico, parâmetro de tipo formal, tipo parametrizado e parâmetro de tipo real.

Continuamos pelo grande tópico da variância, usando máquinas bebidas e latas de lixo para uma cantina como exemplos da "vida real" para tipos genéricos invariantes, covariantes e contravariantes. Então revisamos, formalizamos e aplicamos aqueles conceitos a exemplos na biblioteca padrão de Python.

Por fim, vimos como é definido um protocolo estático genérico, primeiro considerando o protocolo typing.SupportsAbs, e então aplicando a mesma ideia ao exemplo do RandomPicker, tornando-o mais rigoroso que o protocolo original do [ch_ifaces_prot_abc].

Note

O sistema de tipos de Python é um campo imenso e em rápida expansão. Este capítulo não é abrangente. Escolhi me concentrar em tópicos que são amplamente aplicáveis, ou particularmente complexos, ou conceitualmente importantes, e que provavelmente serão relevantes por mais tempo.

Para saber mais

Table 1. PEPs sobre dicas de tipo, com links nos títulos. PEPs com números marcados com * são importantes o suficiente para serem mencionadas no parágrafo de abertura da documentação de typing. Pontos de interrogação na coluna Python indica PEPs em discussão ou ainda não implementadas; "n/a" aparece em PEPs informacionais sem relação com uma versão específica de Python. Dados coletados em maio de 2021.
PEP título Python ano

3107

Function Annotations (Anotações de Função)

3.0

2006

483*

The Theory of Type Hints (A Teoria das Dicas de Tipo)

n/a

2014

484*

Type Hints (Dicas de Tipo)

3.5

2014

482

Literature Overview for Type Hints (Revisão da Literatura sobre Dicas de Tipo)

n/a

2015

526*

Syntax for Variable Annotations (Sintaxe para Anotações de Variáveis)

3.6

2016

544*

Protocols: Structural subtyping (static duck typing) (Protocolos: subtipagem estrutural (duck typing estático))

3.8

2017

557

Data Classes (Classes de Dados)

3.7

2017

560

Core support for typing module and generic types (Suporte nativo para tipagem de módulos e tipos genéricos)

3.7

2017

561

Distributing and Packaging Type Information (Distribuindo e Empacotando Informação de Tipo)

3.7

2017

563

Postponed Evaluation of Annotations (Avaliação Adiada de Anotações)

3.7

2017

586*

Literal Types (Tipos Literais)

3.8

2018

585

Type Hinting Generics In Standard Collections (Dicas de Tipo para Genéricos nas Coleções Padrão)

3.9

2019

589*

TypedDict: Type Hints for Dictionaries with a Fixed Set of Keys (TypedDict: Dicas de Tipo para Dicionários com um Conjunto Fixo de Chaves)

3.8

2019

591*

Adding a final qualifier to typing (Acrescentando um qualificador final à tipagem)

3.8

2019

593

Flexible function and variable annotations (Anotações flexíveis para funções e variáveis)

?

2019

604

Allow writing union types as X | Y (Permitir a definição de tipos de união como X | Y)

3.10

2019

612

Parameter Specification Variables (Variáveis de Especificação de Parâmetros)

3.10

2019

613

Explicit Type Aliases (Aliases de Tipo Explícitos)

3.10

2020

645

Allow writing optional types as x? (Permitir a definição de tipos opcionais como x?)

?

2020

646

Variadic Generics (Genéricos Variádicos)

?

2020

647

User-Defined Type Guards (Guardas de Tipos Definidos pelo Usuário)

3.10

2021

649

Deferred Evaluation Of Annotations Using Descriptors (Avaliação Adiada de Anotações Usando Descritores)

?

2021

655

Marking individual TypedDict items as required or potentially-missing (Marcando itens individuais de TypedDict como obrigatórios ou potencialmente ausentes)

?

2021

A documentação oficial de Python mal consegue acompanhar tudo aquilo, então a documentação do Mypy é uma referência essencial. Robust Python, de Patrick Viafore (O’Reilly), é o primeiro livro com um tratamento abrangente do sistema de tipagem estática de Python que conheço, publicado em agosto de 2021. Você pode estar lendo o segundo livro sobre o assunto nesse exato instante.

O tópico sutil da variância tem sua própria seção na PEP 484, e também é abordado na página Generics do Mypy, bem como em sua inestimável página Common Issues (Problemas Comuns).

A PEP 362—Function Signature Object (O objeto assinatura de função) vale a pena ler se você pretende usar o módulo inspect, que complementa a função typing.get_type_hints.

Se tiver interesse na história de Python, saiba que Guido van Rossum publicou Adding Optional Static Typing to Python (Acrescentando tipagem estática opcional ao Python).

Python 3 Types in the Wild: A Tale of Two Type Systems (Os tipos de Python 3 na natureza: um conto de dois sistemas de tipo) é um artigo científico de Ingkarat Rak-amnouykit e outros, do Rensselaer Polytechnic Institute e do IBM TJ Watson Research Center. O artigo avalia o uso de dicas de tipo em projetos de código aberto no GitHub, mostrando que a maioria dos projetos não as usa, e também que a maioria dos projetos que têm dicas de tipo aparentemente não usa um checador de tipos. Achei particularmente interessante a discussão das semânticas diferentes do Mypy e do pytype do Google, onde os autores concluem que eles são "essencialmente dois sistemas de tipos diferentes."

Dois artigos fundamentais sobre tipagem gradual são Pluggable Type Systems (Sistemas de tipo conectáveis), de Gilad Bracha, e Static Typing Where Possible, Dynamic Typing When Needed: The End of the Cold War Between Programming Languages (Tipagem Estática Quando Possível, Tipagem Dinâmica Quando Necessário: O Fim da Guerra Fria Entre Linguagens de Programação), de Eric Meijer e Peter Drayton.[17]

Aprendi muito lendo as partes relevantes de alguns livros sobre outras linguagens que implementam algumas das mesmas ideias:

Para algumas visões críticas sobre os sistemas de tipagem, recomendo os posts de Victor Youdaiken Bad ideas in type theory (Ideias ruins em teoria dos tipos) e Types considered harmful II (Tipos considerados nocivos II).

Por fim, me surpreendi ao encontrar Generics Considered Harmful (Genéricos Considerados Nocivos), de Ken Arnold, um desenvolvedor principal de Java desde o início, bem como co-autor das primeiras quatro edições do livro oficial The Java Programming Language (Addison-Wesley)—com James Gosling, o principal criador de Java.

Infelizmente, as críticas de Arnold também se aplicam ao sistema de tipagem estática de Python. Quando leio as muitas regras e casos especiais das PEPs de tipagem, sou constantemente lembrado dessa passagem do post de Arnold:

O que nos traz ao problema que sempre cito para o C++: a "exceção de enésima ordem à regra de exceção".

É mais ou menos assim: "Você pode fazer x, exceto no caso y, a menos que y faça z, caso em que você pode se…​"

Felizmente, Python tem uma vantagem crítica sobre o Java e o C++: um sistema de tipagem opcional. Podemos silenciar os checadores de tipos e omitir as dicas de tipo quando se tornam muito inconvenientes.

Ponto de Vista

As tocas de coelho da tipagem

Quando usamos um checador de tipos, algumas vezes somos obrigados a descobrir e importar classes que não precisávamos conhecer, e que nosso código não precisa usar—exceto para escrever dicas de tipo. Tais classes não são documentadas, provavelmente porque são consideradas detalhes de implementação pelos autores dos pacotes. Aqui estão dois exemplos da biblioteca padrão.

Tive que vasculhar a imensa documentação do asyncio, e depois navegar o código-fonte de vários módulos daquele pacote para descobrir a classe não-documentada TransportSocket no módulo igualmente não documentado asyncio.trsock só para usar cast() no exemplo do server.sockets, na Coerção de tipo (type casting). Usar socket.socket em vez de TransportSocket seria incorreto, pois esse último não é subtipo do primeiro, como explicitado em uma docstring no código-fonte.

Caí em uma toca de coelho similar quando acrescentei dicas de tipo ao [ex_primes_procs_top] do [ch_concurrency_models], uma demonstração simples de multiprocessing. Aquele exemplo usa objetos SimpleQueue, obtidos invocando multiprocessing.SimpleQueue(). Entretanto, não pude usar aquele nome em uma dica de tipo, porque multiprocessing.SimpleQueue não é uma classe! É um método vinculado da classe não documentada multiprocessing.BaseContext, que cria e devolve uma instância da classe SimpleQueue, definida no módulo não-documentado multiprocessing.queues.

Em cada um desses casos, tive que gastar algumas horas até encontrar a classe não-documentada correta para importar, só para escrever uma única dica de tipo. Esse tipo de pesquisa é parte do trabalho quando você está escrevendo um livro. Mas se eu estivesse criando o código para uma aplicação, provavelmente evitaria tais caças ao tesouro por causa de uma única linha, e simplesmente colocaria # type: ignore. Algumas vezes essa é a única solução com custo-benefício positivo.

Notação de variância em outras linguagens

Notação de variância em outras linguagens

A variância é um tópico complicado, e a sintaxe das dicas de tipo de Python deixa a desejar. Esta citação direta da PEP 484 evidencia isso:

Covariância ou contravariância não são propriedades de uma variável de tipo, mas sim uma propriedade da classe genérica definida usando essa variável.[19]

Se esse é o caso, por que a covariância e a contravarância são declaradas com TypeVar e não na classe genérica?

Os autores da PEP 484 optaram por introduzir dicas de tipo sem fazer qualquer modificação no interpretador. Em Python, todo identificador aparece pela primeira vez no código-fonte de um módulo através de uma atribuição, ou uma instrução especial como import, class, ou def. Por isso tiveram que criar TypeVar para declarar uma variável de tipo através de uma atribuição:

T = TypeVar('T')

Para não mexer no parser, reutilizaram o operador [] na sintaxe Klass[T] para genéricos—em vez da notação Klass<T> usada em outras linguagens populares, incluindo C#, Java, Kotlin e TypeScript. Estas linguagens não exigem que variáveis de tipo sejam declaradas antes de serem usadas.

Além disso, a sintaxe do Kotlin e do C# torna claro se um parâmetro de tipo é covariante, contravariante ou invariante exatamente onde isso faz sentido: na declaração de classe ou interface.

Em Kotlin, poderíamos declarar a BeverageDispenser assim:

class BeverageDispenser<out T> {
    // etc...
}

O modificador out no parâmetro de tipo formal significa que T é um tipo de output (saída), e portanto BeverageDispenser é covariante. Você provavelmente consegue adivinhar como TrashCan seria declarada:

class TrashCan<in T> {
    // etc...
}

Dado T como um parâmetro de tipo formal de input (entrada), então TrashCan é contravariante. Se nem in nem out aparecem, então a classe é invariante naquele parâmetro.

É fácil lembrar das regras gerais de variância (Regras gerais de variância) quando out e in são usados nos parâmetros de tipo formais. Isso sugere uma convenção melhor para nomear de variáveis de tipo covariantes e contravariantes:

T_out = TypeVar('T_out', covariant=True)
T_in = TypeVar('T_in', contravariant=True)

Aí poderíamos definir as classes assim:

class BeverageDispenser(Generic[T_out]):
    ...

class TrashCan(Generic[T_in]):
    ...

Será tarde demais para adotar T_out e T_in em vez de T_co e T_contra que foram sugeridos na PEP 484?


1. De um vídeo no YouTube da A Language Creators' Conversation: Guido van Rossum, James Gosling, Larry Wall & Anders Hejlsberg (Uma Conversa entre Criadores de Linguagens: Guido van Rossum, James Gosling, Larry Wall & Anders Hejlsberg), transmitido em 2 de abril de 2019. A citação (editada por brevidade) começa em 1:32:05. Produzi e publiquei a transcrição completa em https://https://fpy.li/9k.
2. Agradeço a Jelle Zijlstra—um mantenedor do typeshed—que me ensinou várias coisas, incluindo como reduzir minhas nove sobrecargas originais para "apenas" seis.
3. Em maio de 2020, o pytype ainda permite isso. Mas seu FAQ diz que tal operação será proibida no futuro. Veja a pergunta Why didn’t pytype catch that I changed the type of an annotated variable? (Por que o pytype não avisou quando eu mudei o tipo de uma variável anotada?) no FAQ do pytype.
4. Prefiro usar o pacote lxml para gerar e interpretar XML: ele é fácil de começar a usar, completo e rápido. Infelizmente, nem o lxml nem o ElementTree do próprio Python cabem na RAM limitada de meu microcontrolador hipotético.
5. A documentação do Mypy discute isso na seção Types of empty collections (Tipos de coleções vazias) da página Common issues and solutions (Problemas comuns e soluções).
6. Brett Cannon, Guido van Rossum e outros vem discutindo como escrever dicas de tipo para json.loads() desde 2016, em Mypy issue #182: Define a JSON type (Definir um tipo JSON).
7. O uso de enumerate no exemplo serve para confundir intencionalmente o checador de tipos. Uma implementação mais simples, produzindo strings diretamente, sem passar pelo índice de enumerate, seria corretamente analisada pelo Mypy, e o cast() não seria necessário.
8. Relatei o problema em issue #5535 no typeshed, "Dica de tipo errada para o atributo sockets em asyncio.base_events.Server sockets attribute.", e ele foi rapidamente resolvido por Sebastian Rittau. Mas decidi manter o exemplo, pois ele ilustra um caso de uso comum para cast, e o cast que escrevi é inofensivo.
9. Na realidade, inicialmente coloquei um comentário # type: ignore às linhas com server.sockets[0] porque, após pesquisar um pouco, encontrei linhas similares na documentação do asyncio e em um caso de teste, e aí comecei a suspeitar que o problema não estava no meu código.
10. Mensagem de 18 de maio de 2020 para a lista de e-mail typing-sig.
11. A sintaxe # type: ignore[code] permite especificar qual erro do Mypy está sendo silenciado, mas os códigos nem sempre são fáceis de interpretar. Veja a página Error codes na documentação do Mypy.
12. Não vou entrar nos detalhes da implementação de clip, mas se você tiver curiosidade, pode ler o módulo completo em clip_annot.py.
13. Mensagem PEP 563 in light of PEP 649 (PEP 563 à luz da PEP 649), publicado em 16 de abril de 2021.
14. Os termos são do livro clássico de Joshua Bloch, Effective Java, 3rd ed. As traduções, definições e exemplos são meus.
15. A primeira vez que vi a analogia da cantina para variância foi no prefácio de Erik Meijer para o livro The Dart Programming Language ("A Linguagem de Programação Dart"), de Gilad Bracha (Addison-Wesley).
16. Muito melhor que banir livros!
17. O leitor de notas de rodapé se lembrará que dei o crédito a Erik Meijer pela analogia da cantina para explicar variância.
18. Esse livro foi escrito para o Dart 1. Há mudanças significativas no Dart 2, inclusive no sistema de tipos. Mesmo assim, Bracha é um pesquisador importante na área de design de linguagens de programação, e achei o livro valioso por sua perspectiva sobre o design do Dart.
19. Veja o último parágrafo da seção Covariance and Contravariance (Covariância e Contravariância) na PEP 484.