Aprendi uma dolorosa lição: para programas pequenos, a tipagem dinâmica é ótima. Para programas grandes é necessária 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]
um fã do Monty Python
Esse capítulo é uma continuação do [type_hints_in_def_ch], e fala mais sobre o sistema de tipagem gradual do Python. Os tópicos principais são:
-
Assinaturas de funções sobrepostas
-
typing.TypedDict
: dando dicas de tipos paradicts
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
-
Esse capítulo é inteiramente novo, escrito para essa segunda edição de Python Fluente. Vamos começar com sobreposições.
No Python, funções podem aceitar diferentes combinações de argumentos.
O decorador @typing.overload
permite anotar tais combinações. Isso é 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)
.[2]:
>>> 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').
Essa 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 sobrepostas 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 sobreposições.
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, como no caso 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 é verificada pelo Mypy.
Isso significa que você pode invocar sum(my_list)
, mas não sum(__iterable = my_list)
.
O verificador de tipo tenta fazer a correspondência entre os argumentos dados com cada assinatura sobreposta, em ordem.
A chamada sum(range(100), 1000)
não casa com a primeira sobreposição, pois aquela assinatura tem apenas um parâmetro. Mas casa com a segunda.
Você pode também usar @overload
em um modulo Python regular, colocando as assinaturas sobrepostas logo antes da assinatura real da função e de sua implementação.
O Exemplo 1 mostra como sum
apareceria anotada e implementada em um módulo Python.
sum
com assinaturaas sobrepostaslink:code/15-more-types/mysum.py[role=include]
-
Precisamos deste segundo
TypeVar
na segunda assinatura. -
Essa assinatura é para o caso simples:
sum(my_iterable)
. O tipo do resultado pode serT
—o tipo dos elementos quemy_iterable
produz—ou pode serint
, se o iterável for vazio, pois o valor default do parâmetrostart
é0
. -
Quando
start
é dado, ele pode ser de qualquer tipoS
, então o tipo do resultado éUnion[T, S]
. É por isso que precisamos deS
. SeT
fosse reutilizado aqui, então o tipo destart
teria que ser do mesmo tipo dos elementos deIterable[T]
. -
A assinatura da implementação efetiva da função não tem dicas de tipo.
São muitas linhas para anotar uma função de uma única linha.
Sim, eu sei, provavelmente isso é excessivo.
Mas pelo menos a função do exemplo não é foo
.
Se você 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 do Python tem 186 sobreposições—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 pesadas. 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 sobreposições para anotar adequadamente a flexível função embutida max
.
É difícil acrescentar dicas de tipo a funções que usam os poderosos recursos dinâmicos do Python.
Quando estudava o typeshed, enconterei o relatório de bug #4051 (EN):
Mypy não avisou que é ilegal 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' [NT: TypeError: '>' 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 tornar mais fácil ver a relação entre o funcionamento da função e as anotações sobrepostas (a função embutida original é escrita em C); veja o Exemplo 2.
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 desse exemplo não é a lógica de max
, então não vou perder tempo com a implementação, exceto para explicar 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 duas situações.
Quando first
é um iterável vazio…
-
O usuário não forneceu um argumento para
default=
, então ele éMISSING
, emax
gera umValueError
. -
O usuário forneceu um valor para
default=
, incluindoNone
, e entãomax
devolve o valor dedefault
.
Para consertar o issue #4051, escrevi o código no Exemplo 3.[3]
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 ao duck typing, meu código não tem nenhuma verificação usando isinstance
, e fornece a mesma verificação de erro daquelas dicas de tipo—mas apenas durante a execução, claro.
Um benefício fundamental de @overload
é declarar o tipo devolvido da forma mais precisa possível, de acordo com os tipos dos argumentos recebidos.
Veremos esse benefício a seguir, estudando as sobreposições de max
, em grupos de duas ou três por vez.
@overload
def max(__arg1: LT, __arg2: LT, *_args: LT, key: None = ...) -> LT:
...
# ... lines omitted ...
@overload
def max(__iterable: Iterable[LT], *, key: None = ...) -> LT:
...
Nesses 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 sobreposições:
max(1, 2, -3) # returns 2
max(['Go', 'Python', 'Rust']) # returns 'Rust'
@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 sobreposições:
max(1, 2, -3, key=abs) # returns -3
max(['Go', 'Python', 'Rust'], key=len) # returns 'Python'
@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 sobreposições:
max([1, 2, -3], default=0) # returns 2
max([], default=None) # returns None
@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 tipoT
-
Invocável que recebe um argumento do tipo
T
e devolve um valor do tipoLT
, que implementaSupportsLessThan
-
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
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, ter de escrever tantas linhas para suportar o verificador de tipo 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 sobreposição—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 à do 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, tais como dados em formato JSON.
Warning
|
É tentador usar |
Algumas vezes os dicionários do 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 do 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]
-
Os valores podem ser de qualquer tipo.
Dict[str, Union[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 umastr
, ele não pode ser umint
ou umaList[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) enfrenta esse problema. O Exemplo 4 mostra um TypedDict
simples.
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 [data_class_ch].
A similaridade sintática é enganosa. TypedDict
é muito diferente.
Ele existe apenas para o benefício de verificadores de tipo, e não tem qualquer efeito durante a execução.
TypedDict
fornece duas coisas:
-
Uma sintaxe similar à de classe para anotar uma
dict
com dicas de tipo para os valores de cada "campo". -
Um construtor que informa ao verificador de tipo para 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çao da pseudoclasse não criam atributos de instância.
-
Não é possível escrever inicializadores com valores default para os "campos".
-
Definições de métodos não são permitidas.
Vamos explorar o comportamento de um BookDict
durante a execução (no Exemplo 5).
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'>}
-
É possível invocar
BookDict
como um construtor dedict
, com argumentos nomeados, ou passando um argumentodict
—incluindo um literaldict
. -
Oops…Esqueci que
authors
deve ser uma lista. Mas tipagem gradual significa que não há checagem de tipo durante a execução. -
O resultado da chamada a
BookDict
é umdict
simples… -
…assim não é possível ler os campos usando a notação
objeto.campo
. -
As dicas de tipo estão em
BookDict.__annotations__
, e não empp
.
Sem um verificador de tipo, 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 [data_class_ch], por outro lado,
são úteis mesmo se você não usar um verificador de tipo,
porque durante a execução elas geram uma classe personalizada que pode ser instanciada.
Elas também fornecem vários métodos ou funções úteis,
listadas na [builders_compared_tbl] do [data_class_ch].
O Exemplo 6 cria um BookDict
válido e tenta executar algumas operações com ele.
A seguir, o Exemplo 7 mostra como TypedDict
permite que o Mypy encontre erros.
BookDict
link:code/15-more-types/typeddict/demo_books.py[role=include]
-
Lembre-se de adicionar o tipo devolvido, assim o Mypy não ignora a função.
-
Este é um
BookDict
válido: todas as chaves estão presentes, com valores do tipo correto. -
O Mypy vai inferir o tipo de
authors
a partir da anotação na chave'authors'
emBookDict
. -
typing.TYPE_CHECKING
só éTrue
quando os tipos no programa estão sendo verificados. Durante a execução ele é sempre falso. -
O
if
anterior evita quereveal_type(authors)
seja chamado durante a execução.reveal_type
não é uma função do Python disponível durante a execução, mas sim um instrumento de depuração fornecido pelo Mypy. Por isso não há umimport
para ela. Veja sua saída no Exemplo 7. -
As últimas três linhas da função
demo
são ilegais. Elas vão causar mensagens de erro no Exemplo 7.
…/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)
-
Essa observação é o resultado de
reveal_type(authors)
. -
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 umastr
para uma variável do tipoList[str]
. Verificadores de tipo em geral não permitem que o tipo de uma variável mude.[4] -
Não é permitido atribuir a uma chave que não é parte da definição de
BookDict
. -
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 a que aparece no Exemplo 8.[5]
to_xml
link:code/15-more-types/typeddict/books.py[role=include]
-
O principal objetivo do exemplo: usar
BookDict
em uma assinatura de função. -
Se a coleção começa vazia, o Mypy não tem inferir o tipo dos elementos. Por isso a anotação de tipo é necessária aqui.[6]
-
O Mypy entende testes com
isinstance
, e tratavalue
como umalist
neste bloco. -
Quando usei
key == 'authors'
como condição doif
que guarda esse bloco, o Mypy encontrou um erro nessa linha:"object" has no attribute "__iter__"
("object" não tem um atributo "__iter__" ), porque inferiu o tipo devalue
devolvido porbook.items()
comoobject
, que não suporta o método__iter__
exigido pela expressão geradora. O teste comisinstance
funciona porque garante quevalue
é umalist
nesse bloco.
O Exemplo 9 mostra uma função que interpreta uma str
JSON e devolve um BookDict
.
from_json
link:code/15-more-types/typeddict/books_any.py[role=include]
-
O tipo devolvido por
json.loads()
éAny
.[7] -
Posso devolver
whatever
—de tipoAny
—porqueAny
é consistente-com todos os tipos, incluindo o tipo declarado do valor devolvido,BookDict
.
O segundo ponto do Exemplo 9 é muito importante de ter em mente:
O Mypy não vai apontar qualquer problema nesse código, mas durante a execução o valor em whatever
pode não se adequar à estrutura de BookDict
—na verdade, pode nem mesmo 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 Exemplo 10.
from_json
com uma anotação de variávellink:code/15-more-types/typeddict/books.py[role=include]
-
--disallow-any-expr
não gera erros quando uma expressão de tipoAny
é imediatamente atribuída a uma variável com uma dica de tipo. -
Agora
whatever
é do tipoBookDict
, o tipo declarado do valor devolvido.
Warning
|
Não se deixe enganar por uma falsa sensação de tipagem segura com o Exemplo 10!
Olhando o código estático, o verificador de tipo não tem como prever se |
A verificação de tipo 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 Exemplo 11, o Exemplo 12 e o Exemplo 13 demonstram isso.
from_json
devolve um BookDict
inválido, e to_xml
o aceitalink:code/15-more-types/typeddict/demo_not_book.py[role=include]
-
Essa linha não produz um
BookDict
válido—veja o conteúdo deNOT_BOOK_JSON
. -
Vamos deixar o Mypy revelar alguns tipos.
-
Isso não deve causar problemas:
print
consegue lidar comobject
e com qualquer outro tipo. -
BookDict
não tem uma chave'flavor'
, mas o fonte JSON tem…o que vai acontecer?? -
Lembre-se da assinatura:
def to_xml(book: BookDict) → str:
. -
Como será a saída XML?
Agora verificamos demo_not_book.py com o Mypy (no Exemplo 12).
…/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)
-
O tipo revelado é o tipo nominal, não o conteúdo de
not_book
durante a execução. -
De novo, este é o tipo nominal de
not_book['authors']
, como definido emBookDict
. Não o tipo durante a execução. -
Esse erro é para a linha
print(not_book['flavor'])
: essa chave não existe no tipo nominal.
Agora vamos executar demo_not_book.py, mostrando o resultado no Exemplo 13.
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>
-
Isso não é um
BookDict
de verdade. -
O valor de
not_book['flavor']
. -
to_xml
recebe um argumentoBookDict
, mas não há qualquer verificação durante a execução: entra lixo, sai lixo.
O Exemplo 13 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 Exemplo 8 através das lentes do duck typing, 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 (EN).
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, revise 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) (EN).
Vamos agora voltar nossas atenções para uma função que é melhor evitar, mas que algumas vezes é inevitável: typing.cast
.
Nenhum sistema de tipos é perfeito, nem tampouco os verificadores estáticos de tipo, as dicas de tipo no projeto typeshed ou as dicas de tipo em pacotes de terceiros que as oferecem.
A função especial typing.cast()
fornece 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 (EN) explica:
Coerções são usadas para silenciar avisos espúrios do verificador de tipos, e dão uma ajuda ao verificador quando ele não consegue entender direito o que está acontecendo.
Durante a execução, typing.cast
não faz absolutamente nada. Essa é 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 PEP 484 exige que os verificadores de tipo "acreditem cegamente" em cast
.
A seção "Casts" (Coerções) da PEP 484 mostra um exemplo onde o verificador 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 ou o índice de um item str
ou gerar 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 guiar o Mypy.[8]
Aqui está outro exemplo com cast
, desta vez para corrigir uma dica de tipo desatualizada na biblioteca padrão do Python.
No [tcp_mojifinder_main], criei um objeto asyncio , Server
, e queria obter o endereço que o servidor estava ouvindo.
Escrevi essa 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 o 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,[9] 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.[10] Mas agora o código está mais seguro.
O leitor atento pode ser notado que sockets[0]
poderia gerar um IndexError
se sockets
estiver vazio.
Entretanto, até onde entendo o asyncio
, isso não pode acontecer no [tcp_mojifinder_main], 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 fique muito confortável usando |
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 chamada a
cast()
ou um comentário# type: ignore
ocasionais?[11]
É insensato banir inteiramente o uso de cast
, principalmente porque as alternativas para contornar esses problemas são piores:
-
# type: ignore
é menos informativo.[12] -
Usar
Any
é contagioso: já queAny
é consistente-com todos os tipos, seu abuso pode produzir efeitos em cascata através da inferência de tipo, minando a capacidade do verificador de tipo 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
ocasional, ou mesmo deixar uma função sem dicas de tipo.
A seguir, vamos falar sobre o uso de anotações durante a execução.
Durante a importação, 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
function no Exemplo 14.[13]
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 →
no Exemplo 14.
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'
and '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.
O aumento do uso de dicas de tipo gerou dois problemas:
-
Importar módulos usa mais CPU e memória quando são usadas muitas dicas de tipo.
-
Referências a tipos ainda não definidos exigem o uso de strings em vez do 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__
.
Vamos nos concentrar agora no segundo problema.
Armazenar anotações como string é necessário algumas vezes, por causa do problema da "referência adiantada" (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 adiantada: quando um método devolve um novo objeto da mesma classe. Já que o objeto classe não está definido até o 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 adiantadas como strings é a prática padrão e exigida no Python 3.10. Os verificadores de tipo 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
Introspection helpers (Auxiliares de introspecção),
a mais importantes delas sendo typing.get_type_hints
.
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 adiantadas codificadas como strings literais são tratadas por sua avaliação nos espaços de nomesglobals
elocals
. […]
Warning
|
Desde o Python 3.10, a nova função |
A PEP 563—Postponed Evaluation of Annotations (_Avaliação Adiada de Anotações) (EN) 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" (EN):
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 do 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 Exemplo 14 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 Exemplo 14).
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 resis—mesmo em alguns casos onde a dica de tipo original foi escrita como uma string.
Essa é 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 soaram o alarme, essa 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 email 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 adiantadas, classes com métodos aceitando ou devolvendo objetos de seu próprio tipo, também não é tratado de forma apropriada portyping.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.[14]
O Steering Council do Python decidiu adiar a elevação da PEP 563 a comportamento padrão até o 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) (EN) 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 o Python em escala muito ampla 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 em uma frequência e um volume muito mais altos, em servidores de produção, e esse custo não é desprezível em grande escala. Isso 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 desafiadora. |
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, useinspect.get_annotations
(desde o Python 3.10) outyping.get_type_hints
(desde o Python 3.5). -
Escreva uma função personalizada própria, como um invólucro para
inspect.get_annotations
outyping.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
, definida no
[checked_class_top_ex], classe que estudaremos no [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 do 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) (EN), 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) (EN). |
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.
No [ex_tombola_abc], definimos a ABC Tombola
: uma interface para classes que funcionam como um recipiente para sorteio de bingo. A classe LottoBlower
do [ex_lotto] é uma implementação concreta.
Vamos agora estudar uma versão genérica de LottoBlower
, usada da forma que aparece no Exemplo 15.
link:code/15-more-types/lotto/generic_lotto_demo.py[role=include]
-
Para instanciar uma classe genérica, passamos a ela um parâmetro de tipo concreto, como
int
aqui. -
O Mypy irá inferir corretamente que
first
é umint
… -
… e que
remain
é umatuple
de inteiros.
Além disso, o Mypy aponta violações do tipo parametrizado com mensagens úteis, como ilustrado no Exemplo 16.
link:code/15-more-types/lotto/generic_lotto_errors.py[role=include]
-
Na instanciação de
LottoBlower[int]
, o Mypy marca ofloat
. -
Na chamada
.load('ABC')
, o Mypy explica porque umastr
não serve:str.__iter__
devolve umIterator[str]
, masLottoBlower[int]
exige umIterator[int]
.
O Exemplo 17 é a implementação.
link:code/15-more-types/lotto/generic_lotto.py[role=include]
-
Declarações de classes genéricas muitas vezes usam herança múltipla, porque precisamos de uma subclasse de
Generic
para declarar os parâmetros de tipo formais—nesse caso,T
. -
O argumento
items
em__init__
é do tipoIterable[T]
, que se tornaIterable[int]
quando uma instância é declarada comoLottoBlower[int]
. -
O método
load
é igualmente restrito. -
O tipo do valor devolvido
T
agora se tornaint
em umLottoBlower[int]
. -
Nenhuma variável de tipo aqui.
-
Por fim,
T
define o tipo dos itens natuple
devolvida.
Tip
|
A seção "User-defined generic types" (Tipos genéricos definidos pelo usuário) (EN), na documentação do módulo |
Agora que vimos como implementar um classe genérica, vamos definir a terminologia para falar sobre tipos genéricos.
Aqui estão algumas definições que encontrei estudando genéricos:[15]
- 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
eVT
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: oint
emLottoBlower[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.
Note
|
Dependendo de sua experiência com genéricos em outras linguagens, essa pode ser a parte 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 tiradas de um livro de matemática. Na prática, a variância é mais relevante para autores de bibliotecas que querem suportar novos tipos de contêineres genéricos ou fornecer uma API baseada em callbacks. Mesmo nesses casos, é possível evitar muita complexidade suportando apenas contêineres invariantes—que é quase só o que temos hoje na biblioteca padrão. Então, em uma primeira leitura você pode pular toda essa 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 tipo 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 ali.[16] Máquinas de bebida genéricas não são permitidas, pois podem servir refrigerantes, que foram banidos pela direção da escola.[17]
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 Exemplo 18.
install
link:code/15-more-types/cafeteria/invariant.py[role=include]
-
Beverage
,Juice
, eOrangeJuice
formam uma hierarquia de tipos. -
Uma declaração
TypeVar
simples. -
BeverageDispenser
é parametrizada pelo tipo de bebida. -
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 Exemplo 18, o seguinte código é legal:
link:code/15-more-types/cafeteria/invariant.py[role=include]
Entretanto, isso não é legal:
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 é ilegal:
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 do Python—tal como list
e set
—são invariantes.
A classe LottoBlower
do Exemplo 17 também é invariante.
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 Exemplo 19 mostra como declararíamos BeverageDispenser
.
install
functionlink:code/15-more-types/cafeteria/covariant.py[role=include]
-
Define
covariant=True
ao declarar a variável de tipo;_co
é o sufixo convencional para parâmetros de tipo covariantes no typeshed. -
Usa
T_co
para parametrizar a classe especialGeneric
. -
As dicas de tipo para
install
são as mesmas do Exemplo 18.
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.
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:
|
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 Exemplo 20.
install
link:code/15-more-types/cafeteria/contravariant.py[role=include]
-
Uma hierarquia de tipos para resíduos:
Refuse
é o tipo mais geral,Compostable
o mais específico. -
T_contra
é o nome convencional para uma variável de tipo contravariante. -
TrashCan
é contravariante ao tipo de resíduo. -
A função
deploy
exige uma lata de lixo compatível comTrashCan[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.
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.
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 do 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 argumentos 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
.
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 aB
. B <: A
-
B
é um subtipo-de ou igual aA
.
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 saídas.
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 por uma razão similar.
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 desse 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 [async_ch].
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 de "enviar" e "produzir" são explicados na [classic_coroutines_sec].
Dessas observações sobre saídas covariantes e entradas contravariantes podemos derivar algumas orientações úteis.
Por fim, aqui estão algumas regras gerais a considerar quando estamos pensando sobre variância:
-
Se um parâmetro de tipo formal define um tipo para dados que saem de um objeto, ele pode ser covariante.
-
Se um parâmetro de tipo formal define um tipo para dados que entram em um objeto, ele pode ser contravariante.
-
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.
-
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 é mais aberta 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.
Nossa discussão sobre variância continua na [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.
A biblioteca padrão do 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 Exemplo 21.
SupportsAbs
link:code/15-more-types/protocol/abs_demo.py[role=include]
-
Definir
__abs__
tornaVector2d
consistente-comSupportsAbs
. -
Parametrizar
SupportsAbs
comfloat
assegura… -
…que o Mypy aceite
abs(v)
como primeiro argumento paramath.isclose
. -
Graças a
@runtime_checkable
na definição deSupportsAbs
, essa é uma asserção válida durante a execução. -
Todo o restante do código passa pelas verificações do Mypy e pelas asserções durante a execução.
-
O tipo
int
também é consistente-comSupportsAbs
. De acordo com o typeshed,int.__abs__
devolve umint
, o que é consistente-com o parametro de tipofloat
declarado na dica de tipois_unit
para o argumentov
.
De forma similar, podemos escrever uma versão genérica do protocolo RandomPicker
, apresentado na [ex_randompick_protocol], que foi definido com um único método pick
devolvendo Any
.
O Exemplo 22 mostra como criar um RandomPicker
genérico, covariante no tipo devolvido por pick
.
RandomPicker
genéricolink:code/15-more-types/protocol/random/generic_randompick.py[role=include]
-
Declara
T_co
comocovariante
. -
Isso torna
RandomPicker
genérico, com um parâmetro de tipo formal covariante. -
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 um capítulo.
Começamos com um exemplo simples de uso de @overload
, seguido por um exemplo muito mais complexo, que estudamos em detalhes:
as assinaturas sobrecarregadas exigidas para anotar corretamente a função embutida max
.
A seguir veio o artefato especial da linguagem typing.TypedDict
.
Escolhi tratar dele aqui e não no [data_class_ch], onde vimos typing.NamedTuple
, porque TypedDict
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 queria mostrar como as verificações durante a execução e o tratamento de erros são inevitáveis quando tentamos criar registros estruturados estaticamente a partir de mapeamentos, que por natureza são dinâmicos.
Então falamos sobre typing.cast
, uma função projetada para nos permitir guiar o trabalho do verificador de tipos. É importante considerar cuidadosamente quando usar cast
, porque seu uso excessivo atrapalha o verificador 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 desenvolvedores principais do 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 para uma cantina e latas de lixo 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 do 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 [ifaces_prot_abc].
Note
|
O sistema de tipos do Python é um campo imenso e em rápida evolução. Este capítulo não é abrangente. Escolhi me concentrar em tópicos que são ou amplamente aplicáveis, ou particularmente complexos ou conceitualmente importantes, e que assim provavelmente se manterão relevantes por um longo tempo. |
O sistema de tipagem estática do Python já era complexo quando foi originalmente projetado, e tem se tornado mais complexo a cada ano. A Tabela 1 lista todas as PEPs que encontrei até maio de 2021. Seria necessário um livro inteiro para cobrir tudo.
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 do Python. Todos os textos das PEPs estão em inglês. Dados coletados em maio 2021.
A documentação oficial do Python mal consegue acompanhar tudo aquilo, então a documentação do Mypy (EN) é uma referência essencial. Robust Python (EN), de Patrick Viafore (O’Reilly), é o primeiro livro com um tratamento abrangente do sistema de tipagem estática do Python que conheço, publicado em agosto de 2021. Você pode estar lendo o segundo livro sobre o assunto nesse exato instante.
O sutil tópico da variância tem sua própria seção na PEP 484 (EN), e também é abordado na página "Generics" (Genéricos) (EN) 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 você estiver interessado na história do Python, pode gostar de saber que Guido van Rossum publicou "Adding Optional Static Typing to Python" (Acrescentando Tipagem Estática Opcional ao Python) em 23 de dezembro de 2004.
"Python 3 Types in the Wild: A Tale of Two Type Systems" (Os Tipos do Python 3 na Natureza: Um Conto de Dois Sistemas de Tipo) (EN) é 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 usam , e também que a maioria dos projetos que incluem dicas de tipo aparentemente não usam um verificador 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) (EN), 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) (EN), de Eric Meijer e Peter Drayton.[18]
Aprendi muito lendo as partes relevantes de alguns livros sobre outras linguagens que implementam algumas das mesmas ideias:
-
Atomic Kotlin (EN), de Bruce Eckel e Svetlana Isakova (Mindview)
-
Effective Java, 3rd ed., (EN), de Joshua Bloch (Addison-Wesley)
-
Programming with Types: TypeScript Examples (EN), de Vlad Riscutia (Manning)
-
Programming TypeScript (EN), de Boris Cherny (O’Reilly)
-
The Dart Programming Language (EN) de Gilad Bracha (Addison-Wesley).[19]
Para algumas visões críticas sobre os sistemas de tipagem, recomendo os posts de Victor Youdaiken "Bad ideas in type theory" (Más ideias na teoria dos tipos) (EN) e "Types considered harmful II" (Tipos considerados nocivos II) (EN).
Por fim, me surpreeendi ao encontrar "Generics Considered Harmful" (Genéricos Considerados Nocivos), de Ken Arnold, um desenvolvedor principal do Java desde o início, bem como co-autor das primeiras quatro edições do livro oficial The Java Programming Language (Addison-Wesley)—junto com James Gosling, o principal criador do Java.
Infelizmente, as críticas de Arnold também se aplicam ao sistema de tipagem estática do 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++: eu a chamo de "exceção de enésima ordem à regra de exceção". Ela soa assim: "Você pode fazer x, exceto no caso y, a menos que y faça z, caso em que você pode se…"
Felizmente, o Python tem uma vantagem crítica sobre o Java e o C++: um sistema de tipagem opcional. Podemos silenciar os verificadores de tipo e omitir as dicas de tipo quando se tornam muito incômodos.
As tocas de coelho da tipagem
Quando usamos um verificador de tipo, 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.
Usar socket.socket
em vez de TransportSocket
seria incorreto, pois esse último não é subtipo do primeiro, como explicitado em uma
docstring (EN) no código-fonte.
Caí em uma toca de coelho similar quando acrescentei dicas de tipo ao
[primes_procs_top_ex], 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
A variância é um tópico complicado, e a sintaxe das dicas de tipo do Python não é tão boa quanto poderia ser. Essa citação direta da PEP 484 evidencia isso:
Covariância ou contravariância não são propriedaades de uma variável de tipo, mas sim uma propriedade da classe genérica definida usando essa variável.[20]
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 trabalharam sob a severa restrição auto-imposta de suportar dicas de tipo sem fazer qualquer modificação no interpretador.
Isso exigiu a introdução de TypeVar
para definir variáveis de tipo,
e também levou ao abuso de []
para fornecer a sintaxe Klass[T]
para genéricos—em vez da notação Klass<T>
usada em outras linguagens populares, incluindo C#, Java, Kotlin e TypeScript.
Nenhuma dessas linguagens exige que variáveis de tipo seja 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),
segue que 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 quando out
e in
são usado nos parâmetros de tipo formais.
Isso sugere que uma boa convenção para nomenclatura de variáveis de tipo covariante e contravariantes no Python seria:
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á que é tarde demais para modificar a convenção de nomenclatura definida na PEP 484?
json.loads()
desde 2016, em Mypy issue #182: Define a JSON type (Definir um tipo JSON) (EN).
enumerate
no exemplo serve para confundir intencionalmente o verificador de tipo. 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.
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.
# 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 (EN), e aí comecei a suspeitar que o problema não estava em meu código.
clip
, mas se você tiver curiosidade, pode ler o módulo completo em clip_annot.py.