Skip to content

Latest commit

 

History

History
2074 lines (1527 loc) · 112 KB

cap08.adoc

File metadata and controls

2074 lines (1527 loc) · 112 KB

Dicas de tipo em funções

É preciso enfatizar que Python continuará sendo uma linguagem de tipagem dinâmica, e os autores não tem qualquer intenção de algum dia tornar dicas de tipo obrigatórias, mesmo que por mera convenção.

Guido van Rossum, Jukka Lehtosalo, e Łukasz Langa, PEP 484—Type Hints PEP 484—Type Hints (EN), "Rationale and Goals"; negritos mantidos do original.

Dicas de tipo foram a maior mudança na história do Python desde a unificação de tipos e classes no Python 2.2, lançado em 2001. Entretanto, as dicas de tipo não beneficiam igualmente a todos as pessoas que usam Python. Por isso deverão ser sempre opcionais.

A PEP 484—Type Hints introduziu a sintaxe e a semântica para declarações explícitas de tipo em argumentos de funções, valores de retorno e variáveis. O objetivo é ajudar ferramentas de desenvolvimento a encontrarem bugs nas bases de código em Python através de análise estática, isto é, sem precisar efetivamente executar o código através de testes.

Os maiores beneficiários são engenheiros de software profissionais que usam IDEs (Ambientes de Desenvolvimento Integrados) e CI (Integração Contínua). A análise de custo-benefício que torna as dicas de tipo atrativas para esse grupo não se aplica a todos os usuários de Python.

A base de usuários de Python vai muito além dessa classe de profissionais. Ela inclui cientistas, comerciantes, jornalistas, artistas, inventores, analistas e estudantes de inúmeras áreas — entre outros. Para a maioria deles, o custo de aprender dicas de tipo será certamente maior — a menos que já conheçam uma outra linguagem com tipos estáticos, subtipos e tipos genéricos. Os benefícios serão menores para muitos desses usuários, dada a forma como que eles interagem com Python, o tamanho menor de suas bases de código e de suas equipes — muitas vezes "equipes de um".

A tipagem dinâmica, default do Python, é mais simples e mais expressiva quando estamos escrevendo programas para explorar dados e ideias, como é o caso em ciência de dados, computação criativa e para aprender.

Este capítulo se concentra nas dicas de tipo de Python nas assinaturas de função. [more_types_ch] explora as dicas de tipo no contexto de classes e outros recursos do módulo typing.

Os tópicos mais importantes aqui são:

  • Uma introdução prática à tipagem gradual com Mypy

  • As perspectivas complementares da duck typing (tipagem pato) e da tipagem nominal

  • A revisão para principais categorias de tipos que podem surgir em anotações — isso representa cerca de 60% do capítulo

  • Os parâmetros variádicos das dicas de tipo (*args, **kwargs)

  • As limitações e desvantagens das dicas de tipo e da tipagem estática.

Novidades nesse capítulo

Este capítulo é completamente novo. As dicas de tipo apareceram no Python 3.5, após eu ter terminado de escrever a primeira edição de Python Fluente.

Dadas as limitações de um sistema de tipagem estática, a melhor ideia da PEP 484 foi propor um sistema de tipagem gradual. Vamos começar definindo o que isso significa.

Sobre tipagem gradual

A PEP 484 introduziu no Python um sistema de tipagem gradual. Outras linguagens com sistemas de tipagem gradual são o Typescript da Microsoft, Dart (a linguagem do SDK Flutter, criado pelo Google), e o Hack (um dialeto de PHP criado para uso na máquina virtual HHVM do Facebook). O próprio verificador de tipo MyPy começou como uma linguagem: um dialeto de Python de tipagem gradual com seu próprio interpretador. Guido van Rossum convenceu o criador do MyPy, Jukka Lehtosalo, a transformá-lo em uma ferramenta para checar código Python anotado.

Eis uma função com anotações de tipos:

def tokenize(s: str) -> list[str]:
    "Convert a string into a list of tokens."
    return s.replace('(', ' ( ').replace(')', ' ) ').split()

A assinatura informa que a função tokenize recebe uma str e devolve list[str]: uma lista de strings. A utilidade dessa função será explicada no [lis_parser_ex].

Um sistema de tipagem gradual:

É opcional

Por default, o verificador de tipo não deve emitir avisos para código que não tenha dicas de tipo. Em vez disso, o verificador supõe o tipo Any quando não consegue determinar o tipo de um objeto. O tipo Any é considerado compatível com todos os outros tipos.

Não captura erros de tipagem durante a execução do código

Dicas de tipo são usadas por verificadores de tipo, analisadores de código-fonte (linters) e IDEs para emitir avisos. Eles não evitam que valores inconsistentes sejam passados para funções ou atribuídos a variáveis durante a execução. Por exemplo, nada impede que alguém chame tokenie(42), apesar da anotação de tipo do argumento s: str). A chamada ocorrerá, e teremos um erro de execução no corpo da função.

Não melhora o desempenho

Anotações de tipo fornecem dados que poderiam, em tese, permitir otimizações do bytecode gerado. Mas, até julho de 2021, tais otimizações não ocorrem em nenhum ambiente Python que eu conheça.[1]

O melhor aspecto de usabilidade da tipagem gradual é que as anotações são sempre opcionais.

Nos sistemas de tipagem estáticos, a maioria das restrições de tipo são fáceis de expressar, muitas são desajeitadas, muitas são difíceis e algumas são impossíveis: Por exemplo, em julho de 2021, tipos recursivos não tinham suporte — veja as questões #182, Define a JSON type (EN) sobre o JSON e #731, Support recursive types (EN) do MyPy.

É perfeitamente possível que você escreva um ótimo programa Python, que consiga passar por uma boa cobertura de testes, mas ainda assim não consiga acrescentar dicas de tipo que satisfaçam um verificador de tipagem. Não tem problema; esqueça as dicas de tipo problemáticas e entregue o programa!

Dicas de tipo são opcionais em todos os níveis: você pode criar ou usar pacotes inteiros sem dicas de tipo, pode silenciar o verificador ao importar um daqueles pacotes sem dicas de tipo para um módulo onde você use dicas de tipo, e você também pode adicionar comentários especiais, para fazer o verificador de tipos ignorar linhas específicas do seu código.

Tip

Tentar impor uma cobertura de 100% de dicas de tipo irá provavelmente estimular seu uso de forma impensada, apenas para satisfazer essa métrica. Isso também vai impedir equipes de aproveitarem da melhor forma possível o potencial e a flexibilidade do Python. Código sem dicas de tipo deveria ser aceito sem objeções quando anotações tornassem o uso de uma API menos amigável ou quando complicassem em demasia seu desenvolvimento.

Tipagem gradual na prática

Vamos ver como a tipagem gradual funciona na prática, começando com uma função simples e acrescentando gradativamente a ela dicas de tipo, guiados pelo Mypy.

Note

Há muitos verificadores de tipo para Python compatíveis com a PEP 484, incluindo o pytype do Google, o Pyright da Microsoft, o Pyre do Facebook — além de verificadores incluídos em IDEs como o PyCharm. Eu escolhi usar o Mypy nos exemplos por ele ser o mais conhecido. Entretanto, algum daqueles outros pode ser mais adequado para alguns projetos ou equipes. O Pytype, por exemplo, foi projetado para lidar com bases de código sem nenhuma dica de tipo e ainda assim gerar recomendações úteis. Ele é mais tolerante que o MyPy, e consegue também gerar anotações para o seu código.

Vamos anotar uma função show_count, que retorna uma string com um número e uma palavra no singular ou no plural, dependendo do número:

link:code/08-def-type-hints/messages/no_hints/messages.py[role=include]

Exemplo 1 mostra o código-fonte de show_count, sem anotações.

Exemplo 1. show_count de messages.py sem dicas de tipo.
link:code/08-def-type-hints/messages/no_hints/messages.py[role=include]

Usando o Mypy

Para começar a verificação de tipo, rodamos o comando mypy passando o módulo messages.py como parâmetro:

…/no_hints/ $ pip install mypy
[muitas mensagens omitidas...]
…/no_hints/ $ mypy messages.py
Success: no issues found in 1 source file

Na configuração default, o Mypy não encontra nenhum problema com o Exemplo 1.

Warning

Durante a revisão deste capítulo estou usando Mypy 0.910, a versão mais recente no momento (em julho de 2021). A "Introduction" (EN) do Mypy adverte que ele "é oficialmente software beta. Mudanças ocasionais irão quebrar a compatibilidade com versões mais antigas." O Mypy está gerando pelo menos um relatório diferente daquele que recebi quando escrevi o capítulo, em abril de 2020. E quando você estiver lendo essas linhas, talvez os resultados também sejam diferentes daqueles mostrados aqui.

Se a assinatura de uma função não tem anotações, Mypy a ignora por default — a menos que seja configurado de outra forma.

O Exemplo 2 também inclui testes de unidade do pytest. Este é código de messages_test.py.

Exemplo 2. messages_test.py sem dicas de tipo.
link:code/08-def-type-hints/messages/no_hints/messages_test.py[role=include]

Agora vamos acrescentar dicas de tipo, guiados pelo Mypy.

Tornando o Mypy mais rigoroso

A opção de linha de comando --disallow-untyped-defs faz o Mypy apontar todas as definições de função que não tenham dicas de tipo para todos os argumentos e para o valor de retorno.

Usando --disallow-untyped-defs com o arquivo de teste produz três erros e uma observação:

…/no_hints/ $ mypy --disallow-untyped-defs messages_test.py
messages.py:14: error: Function is missing a type annotation
messages_test.py:10: error: Function is missing a type annotation
messages_test.py:15: error: Function is missing a return type annotation
messages_test.py:15: note: Use "-> None" if function does not return a value
Found 3 errors in 2 files (checked 1 source file)

Nas primeiras etapas da tipagem gradual, prefiro usar outra opção:

--disallow-incomplete-defs.

Inicialmente o Mypy não me dá nenhuma nova informação:

…/no_hints/ $ mypy --disallow-incomplete-defs messages_test.py
Success: no issues found in 1 source file

Agora vou acrescentar apenas o tipo do retorno a show_count em messages.py:

def show_count(count, word) -> str:

Isso é suficiente para fazer o Mypy olhar para o código. Usando a mesma linha de comando anterior para verificar messages_test.py fará o Mypy examinar novamente o messages.py:

…/no_hints/ $ mypy --disallow-incomplete-defs messages_test.py
messages.py:14: error: Function is missing a type annotation
for one or more arguments
Found 1 error in 1 file (checked 1 source file)

Agora posso gradualmente acrescentar dicas de tipo, função por função, sem receber avisos sobre as funções onde ainda não adicionei anotações Essa é uma assinatura completamente anotada que satisfaz o Mypy:

def show_count(count: int, word: str) -> str:
Tip

Em vez de digitar opções de linha de comando como --disallow-incomplete-defs, você pode salvar sua configuração favorita da forma descrita na página Mypy configuration file (EN) na documentação do Mypy. Você pode incluir configurações globais e configurações específicas para cada módulo. Aqui está um mypy.ini simples, para servir de base:

[mypy]
python_version = 3.9
warn_unused_configs = True
disallow_incomplete_defs = True

Um valor default para um argumento

A função show_count no Exemplo 1 só funciona com substantivos regulares. Se o plural não pode ser composto acrescentando um 's', devemos deixar o usuário fornecer a forma plural, assim:

>>> show_count(3, 'mouse', 'mice')
'3 mice'

Vamos experimentar um pouco de "desenvolvimento orientado a tipos." Primeiro acrescento um teste usando aquele terceiro argumento. Não esqueça de adicionar a dica do tipo de retorno à função de teste, senão o Mypy não vai inspecioná-la.

def test_irregular() -> None:
    got = show_count(2, 'child', 'children')
    assert got == '2 children'

O Mypy detecta o erro:

/hints_2/ $ mypy messages_test.py
messages_test.py:22: error: Too many arguments for "show_count"
Found 1 error in 1 file (checked 1 source file)

Então edito show_count, acrescentando o argumento opcional plural no Exemplo 3.

Exemplo 3. showcount de hints_2/messages.py com um argumento opcional
link:code/08-def-type-hints/messages/hints_2/messages.py[role=include]

E agora o Mypy reporta "Success."

Warning

Aqui está um erro de digitação que o Python não reconhece. Você consegue encontrá-lo?

def hex2rgb(color=str) -> tuple[int, int, int]:

O relatório de erros do Mypy não é muito útil:

colors.py:24: error: Function is missing a type
    annotation for one or more arguments

A dica de tipo para o argumento color deveria ser color: str. Eu escrevi color=str, que não é uma anotação: ele determina que o valor default de color é str.

Pela minha experiência, esse é um erro comum e fácil de passar desapercebido, especialmente em dicas de tipo complexas.

Os seguintes detalhes são considerados um bom estilo para dicas de tipo:

  • Sem espaço entre o nome do parâmetro e o :; um espaço após o :

  • Espaços dos dois lados do = que precede um valor default de parâmetro

Por outro lado, a PEP 8 diz que não deve haver espaço em torno de = se não há nenhuma dica de tipo para aquele parâmetro específico.

Estilo de Código: use flake8 e blue

Em vez de decorar essas regrinhas bobas, use ferramentas como flake8 e blue. O flake8 informa sobre o estilo do código e várias outras questões, enquanto o blue reescreve o código-fonte com base na (maioria) das regras prescritas pela ferramenta de formatação de código black.

Se o objetivo é impor um estilo de programação "padrão", blue é melhor que black, porque segue o estilo próprio do Python, de usar aspas simples por default e aspas duplas como alternativa.

>>> "I prefer single quotes"
'I prefer single quotes'

No CPython, a preferência por aspas simples está incorporada no repr(), entre outros lugares. O módulo doctest depende do repr() usar aspas simples por default.

Um dos autores do blue é Barry Warsaw, co-autor da PEP 8, core developer do Python desde 1994 e membro do Python’s Steering Council desde 2019. Daí estamos em ótima companhia quando escolhemos usar aspas simples.

Se você precisar mesmo usar o black, use a opção black -S. Isso deixará suas aspas intocadas.

Usando None como default

No Exemplo 3, o parâmetro plural está anotado como str, e o valor default é ''. Assim não há conflito de tipo.

Eu gosto dessa solução, mas em outros contextos None é um default melhor. Se o parâmetro opcional requer um tipo mutável, então None é o único default sensato, como vimos na [mutable_default_parameter_sec].

Com None como default para o parâmetro plural, a assinatura ficaria assim:

from typing import Optional

def show_count(count: int, singular: str, plural: Optional[str] = None) -> str:

Vamos destrinchar essa linha:

  • Optional[str] significa que plural pode ser uma str ou None.

  • É obrigatório fornecer explicitamente o valor default = None.

Se você não atribuir um valor default a plural, o runtime do Python vai tratar o parâmetro como obrigatório. Lembre-se: durante a execução do programa, as dicas de tipo são ignoradas.

Veja que é preciso importar Optional do módulo typing. Quando importamos tipos, é uma boa prática usar a sintaxe from typing import X, para reduzir o tamanho das assinaturas das funções.

Warning

Optional não é um bom nome, pois aquela anotação não torna o argumento opcional. O que o torna opcional é a atribuição de um valor default ao parâmetro. Optional[str] significa apenas: o tipo desse parâmetro pode ser str ou NoneType. Nas linguagens Haskell e Elm, um tipo parecido se chama Maybe.

Agora que tivemos um primeiro contato concreto com a tipagem gradual, vamos examinar o que o conceito de tipo significa na prática.

Tipos são definidos pelas operações possíveis

Há muitas definições do conceito de tipo na literatura. Aqui vamos assumir que tipo é um conjunto de valores e um conjunto de funções que podem ser aplicadas àqueles valores.

— PEP 483—A Teoria das Dicas de Tipo

Na prática, é mais útil considerar o conjunto de operações possíveis como a caraterística definidora de um tipo.[2]

Por exemplo, pensando nas operações possíveis, quais são os tipos válidos para x na função a seguir?

def double(x):
    return x * 2

O tipo do parâmetro x pode ser numérico (int, complex, Fraction, numpy.uint32, etc.), mas também pode ser uma sequência (str, tuple, list, array), uma numpy.array N-dimensional, ou qualquer outro tipo que implemente ou herde um método __mul__ que aceite um inteiro como argumento.

Entretanto, considere a anotação double abaixo. Ignore por enquanto a ausência do tipo do retorno, vamos nos concentrar no tipo do parâmetro:

from collections import abc

def double(x: abc.Sequence):
    return x * 2

Um verificador de tipo irá rejeitar esse código. Se você informar ao Mypy que x é do tipo abc.Sequence, ele vai marcar x * 2 como erro, pois a Sequence ABC não implementa ou herda o método __mul__. Durante a execução, o código vai funcionar com sequências concretas como str, tuple, list, array, etc., bem como com números, pois durante a execução as dicas de tipo são ignoradas. Mas o verificador de tipo se preocupa apenas com o que estiver explicitamente declarado, e abc.Sequence não suporta __mul__.

Por essa razão o título dessa seção é "Tipos São Definidos pelas Operações Possíveis." O runtime do Python aceita qualquer objeto como argumento x nas duas versões da função double. O cálculo de x * 2 pode funcionar, ou pode causar um TypeError, se a operação não for suportada por x. Por outro lado, Mypy vai marcar x * 2 como um erro quando analisar o código-fonte anotado de double, pois é uma operação não suportada pelo tipo declarado x: abc.Sequence.

Em um sistema de tipagem gradual, acontece uma interação entre duas perspectivas diferentes de tipo:

Duck typing ("tipagem pato")

A perspectiva adotada pelo Smalltalk — a primeira linguagem orientada a objetos — bem como em Python, JavaScript, e Ruby. Objetos tem tipo, mas variáveis (incluindo parâmetros) não. Na prática, não importa qual o tipo declarado de um objeto, importam apenas as operações que ele efetivamente suporta. Se eu posso invocar birdie.quack() então, nesse contexto, birdie é um pato. Por definição, duck typing só é aplicada durante a execução, quando se tenta aplicar operações sobre os objetos. Isso é mais flexível que a tipagem nominal, ao preço de permitir mais erros durante a execução.[3]

Tipagem nominal

É a perspectiva adotada em C++, Java, e C#, e suportada em Python anotado. Objetos e variáveis tem tipos. Mas objetos só existem durante a execução, e o verificador de tipo só se importa com o código-fonte, onde as variáveis (incluindo parâmetros de função) tem anotações com dicas de tipo. Se Duck é uma subclasse de Bird, você pode atribuir uma instância de Duck a um parâmetro anotado como birdie: Bird. Mas no corpo da função, o verificador considera a chamada birdie.quack() ilegal, pois birdie é nominalmente um Bird, e aquela classe não fornece o método .quack(). Não interessa que o argumento real, durante a execução, é um Duck, porque a tipagem nominal é aplicada de forma estática. O verificador de tipo não executa qualquer pedaço do programa, ele apenas lê o código-fonte. Isso é mais rígido que duck typing, com a vantagem de capturar alguns bugs durante o desenvolvimento, ou mesmo em tempo real, enquanto o código está sendo digitado em um IDE.

O Exemplo 4 é um exemplo bobo que contrapõe duck typing e tipagem nominal, bem como verificação de tipo estática e comportamento durante a execução.[4]

Exemplo 4. birds.py
link:code/08-def-type-hints/birds/birds.py[role=include]
  1. Duck é uma subclasse de Bird.

  2. alert não tem dicas de tipo, então o verificador a ignora.

  3. alert_duck aceita um argumento do tipo Duck.

  4. alert_bird aceita um argumento do tipo Bird.

Verificando birds.py com Mypy, encontramos um problema:

…/birds/ $ mypy birds.py
birds.py:16: error: "Bird" has no attribute "quack"
Found 1 error in 1 file (checked 1 source file)

Só de analisar o código fonte, Mypy percebe que alert_bird é problemático: a dica de tipo declara o parâmetro birdie como do tipo Bird, mas o corpo da função chama birdie.quack() — e a classe Bird não tem esse método.

Agora vamos tentar usar o módulo birds em daffy.py no Exemplo 5.

Exemplo 5. daffy.py
link:code/08-def-type-hints/birds/daffy.py[role=include]
  1. Chamada válida, pois alert não tem dicas de tipo.

  2. Chamada válida, pois alert_duck recebe um argumento do tipo Duck e daffy é um Duck.

  3. Chamada válida, pois alert_bird recebe um argumento do tipo Bird, e daffy também é um Bird — a superclasse de Duck.

Mypy reporta o mesmo erro em daffy.py, sobre a chamada a quack na função alert_bird definida em birds.py:

…/birds/ $ mypy daffy.py
birds.py:16: error: "Bird" has no attribute "quack"
Found 1 error in 1 file (checked 1 source file)

Mas o Mypy não vê qualquer problema com daffy.py em si: as três chamadas de função estão OK.

Agora, rodando daffy.py, o resultado é o seguinte:

…/birds/ $ python3 daffy.py
Quack!
Quack!
Quack!

Funciona perfeitamente! Viva o duck typing!

Durante a execução do programa, o Python não se importa com os tipos declarados. Ele usa apenas duck typing. O Mypy apontou um erro em alert_bird, mas a chamada da função com daffy funciona corretamente quando executada. À primeira vista isso pode surpreender muitos pythonistas: um verificador de tipo estático muitas vezes encontra erros em código que sabemos que vai funcionar quanto executado.

Entretanto, se daqui a alguns meses você for encarregado de estender o exemplo bobo do pássaro, você agradecerá ao Mypy. Observe esse módulo woody.py module, que também usa birds, no Exemplo 6.

Exemplo 6. woody.py
link:code/08-def-type-hints/birds/woody.py[role=include]

O Mypy encontra dois erros ao verificar woody.py:

…/birds/ $ mypy woody.py
birds.py:16: error: "Bird" has no attribute "quack"
woody.py:5: error: Argument 1 to "alert_duck" has incompatible type "Bird";
expected "Duck"
Found 2 errors in 2 files (checked 1 source file)

O primeiro erro é em birds.py: a chamada a birdie.quack() em alert_bird, que já vimos antes. O segundo erro é em woody.py: woody é uma instância de Bird, então a chamada alert_duck(woody) é inválida, pois aquela função exige um Duck. Todo Duck é um Bird, mas nem todo Bird é um Duck.

Durante a execução, nenhuma das duas chamadas em woody.py funcionariam. A sucessão de falhas é melhor ilustrada em uma sessão no console, através das mensagens de erro, no Exemplo 7.

Exemplo 7. Erros durante a execução e como o Mypy poderia ter ajudado
>>> from birds import *
>>> woody = Bird()
>>> alert(woody)  # (1)
Traceback (most recent call last):
  ...
AttributeError: 'Bird' object has no attribute 'quack'
>>>
>>> alert_duck(woody) # (2)
Traceback (most recent call last):
  ...
AttributeError: 'Bird' object has no attribute 'quack'
>>>
>>> alert_bird(woody)  # (3)
Traceback (most recent call last):
  ...
AttributeError: 'Bird' object has no attribute 'quack'
  1. O Mypy não tinha como detectar esse erro, pois não há dicas de tipo em alert.

  2. O Mypy avisou do problema: Argument 1 to "alert_duck" has incompatible type "Bird"; expected "Duck" (Argumento 1 para alert_duck é do tipo incompatível "Bird"; argumento esperado era "Duck")

  3. O Mypy está avisando desde o Exemplo 4 que o corpo da função alert_bird está errado: "Bird" has no attribute "quack" (Bird não tem um atributo "quack")

Este pequeno experimento mostra que o duck typing é mais fácil para o iniciante e mais flexível, mas permite que operações não suportadas causem erros durante a execução. A tipagem nominal detecta os erros antes da execução, mas algumas vezes rejeita código que seria executado sem erros - como a chamada a alert_bird(daffy) no Exemplo 5.

Mesmo que funcione algumas vezes, o nome da função alert_bird está incorreto: seu código exige um objeto que suporte o método .quack(), que não existe em Bird.

Nesse exemplo bobo, as funções tem uma linha apenas. Mas na vida real elas poderiam ser mais longas, e poderiam passar o argumento birdie para outras funções, e a origem daquele argumento poderia estar a muitas chamadas de função de distância, tornando difícil localizar a causa do erro durante a execução. O verificador de tipos impede que muitos erros como esse aconteçam durante a execução de um programa.

Note

O valor das dicas de tipo é questionável em exemplos minúsculo que cabem em um livro. Os benefícios crescem conforme o tamanho da base de código afetada. É por essa razão que empresas com milhões de linhas de código em Python - como a Dropbox, o Google e o Facebook - investiram em equipes e ferramentas para promover a adoção global de dicas de tipo internamente, e hoje tem partes significativas e crescentes de sua base de código checadas para tipo em suas linhas (pipeline) de integração contínua.

Nessa seção exploramos as relações de tipos e operações no duck typing e na tipagem nominal, começando com a função simples double() — que deixamos sem dicas de tipo. Agora vamos dar uma olhada nos tipos mais importantes ao anotar funções.

Vamos ver um bom modo de adicionar dicas de tipo a double() quando examinarmos Protocolos estáticos. Mas antes disso, há tipos mais importantes para conhecer.

Tipos próprios para anotações

Quase todos os tipos em Python podem ser usados em dicas de tipo, mas há restrições e recomendações. Além disso, o módulo typing introduziu constructos especiais com uma semântica às vezes surpreendente.

Essa seção trata de todos os principais tipos que você pode usar em anotações:

  • typing.Any

  • Tipos e classes simples

  • typing.Optional e typing.Union

  • Coleções genéricas, incluindo tuplas e mapeamentos

  • Classes base abstratas

  • Iteradores genéricos

  • Genéricos parametrizados e TypeVar

  • typing.Protocols — crucial para duck typing estático

  • typing.Callable

  • typing.NoReturn — um bom modo de encerrar essa lista.

Vamos falar de um de cada vez, começando por um tipo que é estranho, aparentemente inútil, mas de uma importância fundamental.

O tipo Any

A pedra fundamental de qualquer sistema gradual de tipagem é o tipo Any, também conhecido como o tipo dinâmico. Quando um verificador de tipo vê um função sem tipo como esta:

def double(x):
    return x * 2

ele supõe isto:

def double(x: Any) -> Any:
    return x * 2

Isso significa que o argumento x e o valor de retorno podem ser de qualquer tipo, inclusive de tipos diferentes. Assume-se que Any pode suportar qualquer operação possível.

Compare Any com object. Considere essa assinatura:

def double(x: object) -> object:

Essa função também aceita argumentos de todos os tipos, porque todos os tipos são subtipo-de object.

Entretanto, um verificador de tipo vai rejeitar essa função:

def double(x: object) -> object:
    return x * 2

O problema é que object não suporta a operação __mul__. Veja o que diz o Mypy:

…/birds/ $ mypy double_object.py
double_object.py:2: error: Unsupported operand types for * ("object" and "int")
Found 1 error in 1 file (checked 1 source file)

Tipos mais gerais tem interfaces mais restritas, isto é, eles suportam menos operações. A classe object implementa menos operações que abc.Sequence, que implementa menos operações que abc.MutableSequence, que por sua vez implementa menos operações que list.

Mas Any é um tipo mágico que reside tanto no topo quanto na base da hierarquia de tipos. Ele é simultaneamente o tipo mais geral - então um argumento n: Any aceita valores de qualquer tipo - e o tipo mais especializado, suportando assim todas as operações possíveis. Pelo menos é assim que o verificador de tipo entende Any.

Claro, nenhum tipo consegue suportar qualquer operação possível, então usar Any impede o verificador de tipo de cumprir sua missão primária: detectar operações potencialmente ilegais antes que seu programa falhe e levante uma exceção durante sua execução.

Subtipo-de versus consistente-com

Sistemas tradicionais de tipagem nominal orientados a objetos se baseiam na relação subtipo-de. Dada uma classe T1 e uma subclasse T2, então T2 é subtipo-de T1.

Observe este código:

class T1:
    ...

class T2(T1):
    ...

def f1(p: T1) -> None:
    ...

o2 = T2()

f1(o2)  # OK

A chamada f1(o2) é uma aplicação do Princípio de Substituição de Liskov (Liskov Substitution Principle—LSP).

Barbara Liskov[5] na verdade definiu é subtipo-de em termos das operações suportadas. Se um objeto do tipo T2 substitui um objeto do tipo T1 e o programa continua se comportando de forma correta, então T2 é subtipo-de T1.

Seguindo com o código visto acima, essa parte mostra uma violação do LSP:

def f2(p: T2) -> None:
    ...

o1 = T1()

f2(o1)  # type error

Do ponto de vista das operações suportadas, faz todo sentido: como uma subclasse, T2 herda e precisa suportar todas as operações suportadas por T1. Então uma instância de T2 pode ser usada em qualquer lugar onde se espera uma instância de T1. Mas o contrário não é necessariamente verdadeiro: T2 pode implementar métodos adicionais, então uma instância de T1 não pode ser usada onde se espera uma instância de T2. Este foco nas operações suportadas se reflete no nome _behavioral subtyping (subtipagem comportamental) (EN), também usado para se referir ao LSP.

Em um sistema de tipagem gradual há outra relação, consistente-com (consistent-with), que se aplica sempre que subtipo-de puder ser aplicado, com disposições especiais para o tipo Any.

As regras para consistente-com são:

  1. Dados T1 e um subtipo T2, então T2 é consistente-com T1 (substituição de Liskov).

  2. Todo tipo é consistente-com Any: você pode passar objetos de qualquer tipo em um argumento declarado como de tipo `Any.

  3. Any é consistente-com todos os tipos: você sempre pode passar um objeto de tipo Any onde um argumento de outro tipo for esperado.

Considerando as definições anteriores dos objetos o1 e o2, aqui estão alguns exemplos de código válido, ilustrando as regras #2 e #3:

def f3(p: Any) -> None:
    ...

o0 = object()
o1 = T1()
o2 = T2()

f3(o0)  #
f3(o1)  #  tudo certo: regra #2
f3(o2)  #

def f4():  # tipo implícito de retorno: `Any`
    ...

o4 = f4()  # tipo inferido: `Any`

f1(o4)  #
f2(o4)  #  tudo certo: regra #3
f3(o4)  #

Todo sistema de tipagem gradual precisa de um tipo coringa como Any

Tip

O verbo "inferir" é um sinônimo bonito para "adivinhar", quando usado no contexto da análise de tipos. Verificadores de tipo modernos, em Python e outras linguagens, não precisam de anotações de tipo em todo lugar porque conseguem inferir o tipo de muitas expressões. Por exemplo, se eu escrever x = len(s) * 10, o verificador não precisa de uma declaração local explícita para saber que x é um int, desde que consiga encontrar dicas de tipo para len em algum lugar.

Agora podemos explorar o restante dos tipos usados em anotações.

Tipos simples e classes

Tipos simples como int, float, str, e bytes podem ser usados diretamente em dicas de tipo. Classes concretas da biblioteca padrão, de pacotes externos ou definidas pelo usuário — FrenchDeck, Vector2d, e Duck - também podem ser usadas em dicas de tipo.

Classes base abstratas também são úteis aqui. Voltaremos a elas quando formos estudar os tipos coleção, e em Classes bases abstratas.

Para classes, consistente-com é definido como subtipo_de: uma subclasse é consistente-com todas as suas superclasses.

Entretanto, "a praticidade se sobrepõe à pureza", então há uma exceção importante, discutida em seguida.

Tip
int é Consistente-Com complex

Não há nenhuma relação nominal de subtipo entre os tipo nativos int, float e complex: eles são subclasses diretas de object. Mas a PEP 484 declara que int é consistente-com float, e float é consistente-com complex. Na prática, faz sentido: int implementa todas as operações que float implementa, e int implementa operações adicionais também - operações binárias como &, |, <<, etc. O resultado final é o seguinte: int é consistente-com complex. Para i = 3, i.real é 3 e i.imag é 0.

Os tipos Optional e Union

Nós vimos o tipo especial Optional em Usando None como default. Ele resolve o problema de ter None como default, como no exemplo daquela seção:

from typing import Optional

def show_count(count: int, singular: str, plural: Optional[str] = None) -> str:

A sintaxe Optional[str] é na verdade um atalho para Union[str, None], que significa que o tipo de plural pode ser str ou None.

Tip
Uma sintaxe melhor para Optional e Union em Python 3.10

Desde o Python 3.10 é possível escrever str | bytes em vez de Union[str, bytes]. É menos digitação, e não há necessidade de importar Optional ou Union de typing. Compare a sintaxe antiga com a nova para a dica de tipo do parâmetro plural em show_count:

plural: Optional[str] = None    # before
plural: str | None = None       # after

O operador | também funciona com isinstance e issubclass para declarar o segundo argumento: isinstance(x, int | str). Para saber mais, veja PEP 604—Complementary syntax for Union[] (EN).

A assinatura da função nativa ord é um exemplo simples de Union - ela aceita str or bytes, e retorna um int:[6]

def ord(c: Union[str, bytes]) -> int: ...

Aqui está um exemplo de uma função que aceita uma str, mas pode retornar uma str ou um float:

from typing import Union

def parse_token(token: str) -> Union[str, float]:
    try:
        return float(token)
    except ValueError:
        return token

Se possível, evite criar funções que retornem o tipo Union, pois esse tipo exige um esforço extra do usuário: pois para saber o que fazer com o valor recebido da função será necessário verificar o tipo daquele valor durante a execução. Mas a parse_token no código acima é um caso de uso razoável no contexto de interpretador de expressões simples.

Tip

Na [dual_mode_api_sec], vimos funções que aceitam tanto str quanto bytes como argumento, mas retornam uma str se o argumento for str ou bytes, se o argumento for bytes. Nesses casos, o tipo de retorno é determinado pelo tipo da entrada, então Union não é uma solução precisa. Para anotar tais funções corretamente, precisamos usar um tipo variável - apresentado em Genéricos parametrizados e TypeVar - ou sobrecarga (overloading), que veremos na [overload_sec].

Union[] exige pelo menos dois tipos. Tipos Union aninhados tem o mesmo efeito que uma Union "achatada" . Então esta dica de tipo:

Union[A, B, Union[C, D, E]]

é o mesmo que:

Union[A, B, C, D, E]

Union é mais útil com tipos que não sejam consistentes entre si. Por exemplo: Union[int, float] é redundante, pois int é consistente-com float. Se você usar apenas float para anotar o parâmetro, ele vai também aceitar valores int.

Coleções genéricas

A maioria das coleções em Python são heterogêneas.

Por exemplo, você pode inserir qualquer combinação de tipos diferentes em uma list. Entretanto, na prática isso não é muito útil: se você colocar objetos em uma coleção, você certamente vai querer executar alguma operação com eles mais tarde, e normalmente isso significa que eles precisam compartilhar pelo menos um método comum.[7]

Tipos genéricos podem ser declarados com parâmetros de tipo, para especificar o tipo de item com o qual eles conseguem trabalhar.

Por exemplo, uma list pode ser parametrizada para restringir o tipo de elemento ali contido, como se pode ver no Exemplo 8.

Exemplo 8. tokenize com dicas de tipo para Python ≥ 3.9
def tokenize(text: str) -> list[str]:
    return text.upper().split()

Em Python ≥ 3.9, isso significa que tokenize retorna uma list onde todos os elementos são do tipo str.

As anotações stuff: list e stuff: list[Any] significam a mesma coisa: stuff é uma lista de objetos de qualquer tipo.

Tip

Se você estiver usando Python 3.8 ou anterior, o conceito é o mesmo, mas você precisa de mais código para funcionar - como explicado em Suporte a tipos de coleção descontinuados.

A PEP 585—Type Hinting Generics In Standard Collections (EN) lista as coleções da biblioteca padrão que aceitam dicas de tipo genéricas. A lista a seguir mostra apenas as coleções que usam a forma mais simples de dica de tipo genérica, container[item]:

list        collections.deque        abc.Sequence   abc.MutableSequence
set         abc.Container            abc.Set        abc.MutableSet
frozenset   abc.Collection

Os tipos tuple e mapping aceitam dicas de tipo mais complexas, como veremos em suas respectivas seções.

No Python 3.10, não há uma boa maneira de anotar array.array, levando em consideração o argumento typecode do construtor, que determina se o array contém inteiros ou floats. Um problema ainda mais complicado é verificar a faixa dos inteiros, para prevenir OverflowError durante a execução, ao se adicionar novos elementos. Por exemplo, um array com typecode=B só pode receber valores int de 0 a 255. Até o Python 3.11, o sistema de tipagem estática do Python não consegue lidar com esse desafio.

Suporte a tipos de coleção descontinuados

(Você pode pular esse box se usa apenas Python 3.9 ou posterior.)

Em Python 3.7 e 3.8, você precisa importar um __future__ para fazer a notação [] funcionar com as coleções nativas, tal como list, como ilustrado no Exemplo 9.

Exemplo 9. tokenize com dicas de tipo para Python ≥ 3.7
from __future__ import annotations

def tokenize(text: str) -> list[str]:
    return text.upper().split()

O __future__ não funciona com Python 3.6 ou anterior. O Exemplo 10 mostra como anotar tokenize de uma forma que funciona com Python ≥ 3.5.

Exemplo 10. tokenize com dicas de tipo para Python ≥ 3.5
from typing import List

def tokenize(text: str) -> List[str]:
    return text.upper().split()

Para fornecer um suporte inicial a dicas de tipo genéricas, os autores da PEP 484 criaram dúzias de tipos genéricos no módulo typing. A Tabela 1 mostra alguns deles. Para a lista completa, consulte a documentação do módulo typing .

Tabela 1. Alguns tipos de coleção e seus equivalentes nas dicas de tipo
Collection Type hint equivalent

list

typing.List

set

typing.Set

frozenset

typing.FrozenSet

collections.deque

typing.Deque

collections.abc.MutableSequence

typing.MutableSequence

collections.abc.Sequence

typing.Sequence

collections.abc.Set

typing.AbstractSet

collections.abc.MutableSet

typing.MutableSet

A PEP 585—Type Hinting Generics In Standard Collections deu início a um processo de vários anos para melhorar a usabilidade das dicas de tipo genéricas. Podemos resumir esse processo em quatro etapas:

  1. Introduzir from future import annotations no Python 3.7 para permitir o uso das classes da biblioteca padrão como genéricos com a notação list[str].

  2. Tornar aquele comportamento o default a partir do Python 3.9: list[str] agora funciona sem que future precise ser importado.

  3. Descontinuar (deprecate) todos os tipos genéricos do módulo typing.[8] Avisos de descontinuação não serão emitidos pelo interpretador Python, porque os verificadores de tipo devem sinalizar os tipos descontinuados quando o programa sendo verificado tiver como alvo Python 3.9 ou posterior.

  4. Remover aqueles tipos genéricos redundantes na primeira versão de Python lançada cinco anos após o Python 3.9. No ritmo atual, esse deverá ser o Python 3.14, também conhecido como Python Pi.

Agora vamos ver como anotar tuplas genéricas.

Tipos tuple

Há três maneiras de anotar os tipos tuple.

  • Tuplas como registros (records)

  • Tuplas como registro com campos nomeados

  • Tuplas como sequências imutáveis.

Tuplas como registros

Se você está usando uma tuple como um registro, use o tipo tuple nativo e declare os tipos dos campos dentro dos [].

Por exemplo, a dica de tipo seria tuple[str, float, str] para aceitar uma tupla com nome da cidade, população e país: ('Shanghai', 24.28, 'China').

Observe uma função que recebe um par de coordenadas geográficas e retorna uma Geohash, usada assim:

>>> shanghai = 31.2304, 121.4737
>>> geohash(shanghai)
'wtw3sjq6q'

O Exemplo 11 mostra a definição da função geohash, usando o pacote geolib do PyPI.

Exemplo 11. coordinates.py com a função geohash
link:code/08-def-type-hints/coordinates/coordinates.py[role=include]
  1. Esse comentário evita que o Mypy avise que o pacote geolib não tem nenhuma dica de tipo.

  2. O parâmetro lat_lon, anotado como uma tuple com dois campos float.

Tip

Com Python < 3.9, importe e use typing.Tuple nas dicas de tipo. Este tipo está descontinuado mas permanecerá na biblioteca padrão pelo menos até 2024.

Tuplas como registros com campos nomeados

Para a anotar uma tupla com muitos campos, ou tipos específicos de tupla que seu código usa com frequência, recomendo fortemente usar typing.NamedTuple, como visto no [data_class_ch]. O Exemplo 12 mostra uma variante de Exemplo 11 com NamedTuple.

Exemplo 12. coordinates_named.py com NamedTuple, Coordinates e a função geohash
link:code/08-def-type-hints/coordinates/coordinates_named.py[role=include]

Como explicado na [data_class_overview_sec], typing.NamedTuple é uma factory de subclasses de tuple, então Coordinate é consistente-com tuple[float, float], mas o inverso não é verdadeiro - afinal, Coordinate tem métodos extras adicionados por NamedTuple, como ._asdict(), e também poderia ter métodos definidos pelo usuário.

Na prática, isso significa que é seguro (do ponto de vista do tipo de argumento) passar uma instância de Coordinate para a função display, definida assim:

link:code/08-def-type-hints/coordinates/coordinates_named.py[role=include]
Tuplas como sequências imutáveis

Para anotar tuplas de tamanho desconhecido, usadas como listas imutáveis, você precisa especificar um único tipo, seguido de uma vírgula e …​ (isto é o símbolo de reticências do Python, formado por três pontos, não o caractere Unicode U+2026HORIZONTAL ELLIPSIS).

Por exemplo, tuple[int, …​] é uma tupla com itens int.

As reticências indicam que qualquer número de elementos >= 1 é aceitável. Não há como especificar campos de tipos diferentes para tuplas de tamanho arbitrário.

As anotações stuff: tuple[Any, …​] e stuff: tuple são equivalentes: stuff é uma tupla de tamanho desconhecido contendo objetos de qualquer tipo.

Aqui temos um função columnize, que transforma uma sequência em uma tabela de colunas e células, na forma de uma lista de tuplas de tamanho desconhecido. É útil para mostrar os itens em colunas, assim:

>>> animals = 'drake fawn heron ibex koala lynx tahr xerus yak zapus'.split()
>>> table = columnize(animals)
>>> table
[('drake', 'koala', 'yak'), ('fawn', 'lynx', 'zapus'), ('heron', 'tahr'),
 ('ibex', 'xerus')]
>>> for row in table:
...     print(''.join(f'{word:10}' for word in row))
...
drake     koala     yak
fawn      lynx      zapus
heron     tahr
ibex      xerus

O Exemplo 13 mostra a implementação de columnize. Observe o tipo do retorno:

list[tuple[str, ...]]
Exemplo 13. columnize.py retorna uma lista de tuplas de strings
link:code/08-def-type-hints/columnize.py[role=include]

Mapeamentos genéricos

Tipos de mapeamento genéricos são anotados como MappingType[KeyType, ValueType]. O tipo nativo dict e os tipos de mapeamento em collections e collections.abc aceitam essa notação em Python ≥ 3.9. Para versões mais antigas, você deve usar typing.Dict e outros tipos de mapeamento no módulo typing, como discutimos em Suporte a tipos de coleção descontinuados.

O Exemplo 14 mostra um uso na prática de uma função que retorna um índice invertido para permitir a busca de caracteres Unicode pelo nome — uma variação do [ex_cfpy] mais adequada para código server-side (também chamado back-end), como veremos no [async_ch].

Dado o início e o final dos códigos de caractere Unicode, name_index retorna um dict[str, set[str]], que é um índice invertido mapeando cada palavra para um conjunto de caracteres que tem aquela palavra em seus nomes. Por exemplo, após indexar os caracteres ASCII de 32 a 64, aqui estão os conjuntos de caracteres mapeados para as palavras 'SIGN' e 'DIGIT', e a forma de encontrar o caractere chamado 'DIGIT EIGHT':

>>> index = name_index(32, 65)
>>> index['SIGN']
{'$', '>', '=', '+', '<', '%', '#'}
>>> index['DIGIT']
{'8', '5', '6', '2', '3', '0', '1', '4', '7', '9'}
>>> index['DIGIT'] & index['EIGHT']
{'8'}

O Exemplo 14 mostra o código fonte de charindex.py com a função name_index. Além de uma dica de tipo dict[], este exemplo tem três outros aspectos que estão aparecendo pela primeira vez no livro.

Exemplo 14. charindex.py
link:code/08-def-type-hints/charindex.py[role=include]
  1. tokenize é uma função geradora. [iterables2generators] é sobre geradores.

  2. A variável local index está anotada. Sem a dica, o Mypy diz: Need type annotation for 'index' (hint: "index: dict[<type>, <type>] = …​").

  3. Eu usei o operador morsa (walrus operator) := na condição do if. Ele atribui o resultado da chamada a unicodedata.name() a name, e a expressão inteira é calculada a partir daquele resultado. Quando o resultado é '', isso é falso, e o index não é atualizado.[9]

Note

Ao usar dict como um registro, é comum que todas as chaves sejam do tipo str, com valores de tipos diferentes dependendo das chaves. Isso é tratado na [typeddict_sec].

Classes bases abstratas

Seja conservador no que envia, mas liberal no que aceita.

— lei de Postel
ou o Princípio da Robustez

A Tabela 1 apresenta várias classes abstratas de collections.abc. Idealmente, uma função deveria aceitar argumentos desses tipos abstratos—​ou seus equivalentes de typing antes do Python 3.9—​e não tipos concretos. Isso dá mais flexibilidade a quem chama a função.

Considere essa assinatura de função:

from collections.abc import Mapping

def name2hex(name: str, color_map: Mapping[str, int]) -> str:

Usar abc.Mapping permite ao usuário da função fornecer uma instância de dict, defaultdict, ChainMap, uma subclasse de UserDict subclass, ou qualquer outra classe que seja um subtipo-de Mapping.

Por outro lado, veja essa assinatura:

def name2hex(name: str, color_map: dict[str, int]) -> str:

Agora color_map tem que ser um dict ou um de seus subtipos, tal como defaultdict ou OrderedDict. Especificamente, uma subclasse de collections.UserDict não passaria pela verificação de tipo para color_map, a despeito de ser a maneira recomendada de criar mapeamentos definidos pelo usuário, como vimos na [sublcassing_userdict_sec]. O Mypy rejeitaria um UserDict ou uma instância de classe derivada dele, porque UserDict não é uma subclasse de dict; eles são irmãos. Ambos são subclasses de abc.MutableMapping.[10]

Assim, em geral é melhor usar abc.Mapping ou abc.MutableMapping em dicas de tipos de parâmetros, em vez de dict (ou typing.Dict em código antigo). Se a função name2hex não precisar modificar o color_map recebido, a dica de tipo mais precisa para color_map é abc.Mapping. Desse jeito, quem chama não precisa fornecer um objeto que implemente métodos como setdefault, pop, e update, que fazem parte da interface de MutableMapping, mas não de Mapping. Isso reflete a segunda parte da lei de Postel: "[seja] liberal no que aceita."

A lei de Postel também nos diz para sermos conservadores no que enviamos. O valor de retorno de uma função é sempre um objeto concreto, então a dica de tipo do valor de saída deve ser um tipo concreto, como no exemplo em Coleções genéricas — que usa list[str]:

def tokenize(text: str) -> list[str]:
    return text.upper().split()

No verbete de typing.List (EN - Tradução abaixo não oficial), a documentação do Python diz:

Versão genérica de list. Útil para anotar tipos de retorno. Para anotar argumentos é preferível usar um tipo de coleção abstrata , tal como Sequence ou Iterable.

Comentários similares aparecem nos verbetes de typing.Dict e typing.Set.

Lembre-se que a maioria dos ABCs de collections.abc e outras classes concretas de collections, bem como as coleções nativas, suportam notação de dica de tipo genérica como collections.deque[str] desde o Python 3.9. As coleções correspondentes em typing só precisavam suportar código escrito em Python 3.8 ou anterior. A lista completa de classes que se tornaram genéricas aparece em na seção "Implementation" da PEP 585—Type Hinting Generics In Standard Collections (EN).

Para encerrar nossa discussão de ABCs em dicas de tipo, precisamos falar sobre os ABCs numbers.

A queda da torre numérica

O pacote numbers define a assim chamada torre numérica (numeric tower) descrita na PEP 3141—A Type Hierarchy for Numbers (EN). A torre é uma hierarquia linear de ABCs, com Number no topo:

  • Number

  • Complex

  • Real

  • Rational

  • Integral

Esses ABCs funcionam perfeitamente para checagem de tipo durante a execução, mas eles não são suportados para checagem de tipo estática. A seção "Numeric Tower" da PEP 484 rejeita os ABCs numbers e manda tratar os tipo nativos complex, float, e int como casos especiais, como explicado em int é Consistente-Com complex. Vamos voltar a essa questão na [numbers_abc_proto_sec], em [ifaces_prot_abc], que é dedicada a comparar protocolos e ABCs

Na prática, se você quiser anotar argumentos numéricos para checagem de tipo estática, existem algumas opções:

  1. Usar um dos tipo concretos, int, float, ou complex — como recomendado pela PEP 488.

  2. Declarar um tipo union como Union[float, Decimal, Fraction].

  3. Se você quiser evitar a codificação explícita de tipos concretos, usar protocolos numéricos como SupportsFloat, tratados na [runtime_checkable_proto_sec].

A Protocolos estáticos abaixo é um pré-requisito para entender protocolos numéricos.

Antes disso, vamos examinar um dos ABCs mais úteis para dicas de tipo: Iterable.

Iterable

A documentação de typing.List que eu citei acima recomenda Sequence e Iterable para dicas de tipo de parâmetros de função.

Esse é um exemplo de argumento Iterable, na função math.fsum da biblioteca padrão:

def fsum(__seq: Iterable[float]) -> float:
Tip
Arquivos Stub e o Projeto Typeshed

Até o Python 3.10, a biblioteca padrão não tem anotações, mas o Mypy, o PyCharm, etc, conseguem encontrar as dicas de tipo necessárias no projeto Typeshed, na forma de arquivos stub: arquivos de código-fonte especiais, com uma extensão .pyi, que contém assinaturas anotadas de métodos e funções, sem a implementação - muito parecidos com headers em C.

A assinatura para math.fsum está em /stdlib/2and3/math.pyi. Os sublinhados iniciais em __seq são uma convenção estabelecida na PEP 484 para parâmetros apenas posicionais, como explicado em Anotando parâmetros apenas posicionais e variádicos.

O Exemplo 15 é outro exemplo do uso de um parâmetro Iterable, que produz itens que são tuple[str, str]. A função é usada assim:

>>> l33t = [('a', '4'), ('e', '3'), ('i', '1'), ('o', '0')]
>>> text = 'mad skilled noob powned leet'
>>> from replacer import zip_replace
>>> zip_replace(text, l33t)
'm4d sk1ll3d n00b p0wn3d l33t'

O Exemplo 15 mostra a implementação.

Exemplo 15. replacer.py
link:code/08-def-type-hints/replacer.py[role=include]
  1. FromTo é um apelido de tipo: eu atribui tuple[str, str] a FromTo, para tornar a assinatura de zip_replace mais legível.

  2. changes tem que ser um Iterable[FromTo]; é o mesmo que escrever Iterable[tuple[str, str]], mas é mais curto e mais fácil de ler.

Tip
O TypeAlias Explícito em Python 3.10

PEP 613—Explicit Type Aliases introduziu um tipo especial, o TypeAlias, para tornar as atribuições que criam apelidos de tipos mais visíveis e mais fáceis para os verificadores de tipo. A partir do Python 3.10, esta é a forma preferencial de criar um apelidos de tipo.

from typing import TypeAlias

FromTo: TypeAlias = tuple[str, str]
abc.Iterable versus abc.Sequence

Tanto math.fsum quanto replacer.zip_replace tem que percorrer todos os argumentos do Iterable para produzir um resultado. Dado um iterável sem fim tal como o gerador itertools.cycle como entrada, essas funções consumiriam toda a memória e derrubariam o processo Python. Apesar desse perigo potencial, é muito comum no Python moderno se oferecer funções que aceitam um Iterable como argumento, mesmo se elas tem que processar a estrutura inteira para obter um resultado. Isso dá a quem chama a função a opção de fornecer um gerador como dado de entrada, em vez de uma sequência pré-construída, com uma grande economia potencial de memória se o número de itens de entrada for grande.

Por outro lado, a função columnize no Exemplo 13 requer uma Sequence, não um Iterable, pois ela precisa obter a len() do argumento para calcular previamente o número de linhas.

Assim como Sequence, o melhor uso de Iterable é como tipo de argumento. Ele é muito vago como um tipo de saída. Uma função deve ser mais precisa sobre o tipo concreto que retorna.

O tipo Iterator, usado como tipo do retorno no Exemplo 14, está intimamente relacionado a Iterable. Voltaremos a ele em [iterables2generators], que trata de geradores e iteradores clássicos.

Genéricos parametrizados e TypeVar

Um genérico parametrizado é um tipo genérico, escrito na forma list[T], onde T é um tipo variável que será vinculado a um tipo específico a cada uso. Isso permite que um tipo de parâmetro seja refletido no tipo resultante.

O Exemplo 16 define sample, uma função que recebe dois argumentos: uma Sequence de elementos de tipo T e um int. Ela retorna uma list de elementos do mesmo tipo T, escolhidos aleatoriamente do primeiro argumento.

O Exemplo 16 mostra a implementação.

Exemplo 16. sample.py
link:code/08-def-type-hints/sample.py[role=include]

Aqui estão dois exemplos do motivo de eu usar um tipo variável em sample:

  • Se chamada com uma tupla de tipo tuple[int, …​] — que é consistente-com Sequence[int] - então o tipo parametrizado é int, então o tipo de retorno é list[int].

  • Se chamada com uma str — que é consistente-com Sequence[str] — então o tipo parametrizado é str, e o tipo do retorno é list[str].

Note
Por que TypeVar é necessário?

Os autores da PEP 484 queriam introduzir dicas de tipo ao acrescentar o módulo typing, sem mudar nada mais na linguagem. Com uma metaprogramação inteligente, eles poderiam fazer o operador [] funcionar para classes como Sequence[T]. Mas o nome da variável T dentro dos colchetes precisa ser definido em algum lugar - ou o interpretador Python necessitaria de mudanças mais profundas, para suportar a notação de tipos genéricos como um caso especial de []. Por isso o construtor typing.TypeVar é necessário: para introduzir o nome da variável no namespace (espaço de nomes) corrente. Linguagens como Java, C# e TypeScript não exigem que o nome da variável seja declarado previamente, então eles não tem nenhum equivalente da classe TypeVar do Python.

Outro exemplo é a função statistics.mode da biblioteca padrão, que retorna o ponto de dado mais comum de uma série.

Aqui é uma exemplo de uso da documentação:

>>> mode([1, 1, 2, 3, 3, 3, 3, 4])
3

Sem o uso de TypeVar, mode poderia ter uma assinatura como a apresentada no Exemplo 17.

Exemplo 17. mode_float.py: mode que opera com float e seus subtipos [11]
link:code/08-def-type-hints/mode/mode_float.py[role=include]

Muitos dos usos de mode envolvem valores int ou float, mas o Python tem outros tipos numéricos, e é desejável que o tipo de retorno siga o tipo dos elementos do Iterable recebido. Podemos melhorar aquela assinatura usando TypeVar. Vamos começar com uma assinatura parametrizada simples, mas errada.

from collections.abc import Iterable
from typing import TypeVar

T = TypeVar('T')

def mode(data: Iterable[T]) -> T:

Quando aparece pela primeira vez na assinatura, o tipo parametrizado T pode ser qualquer tipo. Da segunda vez que aparece, ele vai significar o mesmo tipo que da primeira vez.

Assim, qualquer iterável é consistente-com Iterable[T], incluindo iterável de tipos unhashable que collections.Counter não consegue tratar. Precisamos restringir os tipos possíveis de se atribuir a T. Vamos ver maneiras diferentes de fazer isso nas duas seções seguintes.

TypeVar restrito

O TypeVar aceita argumentos posicionais adicionais para restringir o tipo parametrizado. Podemos melhorar a assinatura de mode para aceitar um número específico de tipos, assim:

from collections.abc import Iterable
from decimal import Decimal
from fractions import Fraction
from typing import TypeVar

NumberT = TypeVar('NumberT', float, Decimal, Fraction)

def mode(data: Iterable[NumberT]) -> NumberT:

Está melhor que antes, e era a assinatura de mode em statistics.pyi, o arquivo stub em typeshed em 25 de maio de 2020.

Entretanto, a documentação em statistics.mode inclui esse exemplo:

>>> mode(["red", "blue", "blue", "red", "green", "red", "red"])
'red'

Na pressa, poderíamos apenas adicionar str à definição de NumberT:

NumberT = TypeVar('NumberT', float, Decimal, Fraction, str)

Com certeza funciona, mas NumberT estaria muito mal batizado se aceitasse str. Mais importante, não podemos ficar listando tipos para sempre, cada vez que percebermos que mode pode lidar com outro deles. Podemos fazer com melhor com um outro recurso de TypeVar, como veremos a seguir.

TypeVar delimitada

Examinando o corpo de mode no Exemplo 17, vemos que a classe Counter é usada para classificação. Counter é baseada em dict, então o tipo do elemento do iterável data precisa ser hashable.

A princípio, essa assinatura pode parecer que funciona:

from collections.abc import Iterable, Hashable

def mode(data: Iterable[Hashable]) -> Hashable:

Agora o problema é que o tipo do item retornado é Hashable: um ABC que implementa apenas o método __hash__. Então o verificador de tipo não vai permitir que façamos nada com o valor retornado, exceto chamar seu método hash(). Não é muito útil.

A solução está em outro parâmetro opcional de TypeVar: o parâmetro representado pela palavra-chave bound. Ele estabelece um limite superior para os tipos aceitos. No Exemplo 18, temos bound=Hashable. Isso significa que o tipo do parâmetro pode ser Hashable ou qualquer subtipo-de Hashable.[12]

Exemplo 18. mode_hashable.py: igual a Exemplo 17, mas com uma assinatura mais flexível
link:code/08-def-type-hints/mode/mode_hashable.py[role=include]

Em resumo:

  • Um tipo variável restrito será concretizado em um dos tipos nomeados na declaração do TypeVar.

  • Um tipo variável delimitado será concretizado pata o tipo inferido da expressão - desde que o tipo inferido seja consistente-com o limite declarado pelo argumento bound= do TypeVar.

Note

É um pouco lamentável que a palavra-chave do argumento para declarar um TypeVar delimitado tenha sido chamado bound=, pois o verbo "to bind" (ligar ou vincular) é normalmente usado para indicar o estabelecimento do valor de uma variável, que na semântica de referência do Python é melhor descrita como vincular (bind) um nome a um valor. Teria sido menos confuso se a palavra-chave do argumento tivesse sido chamada boundary=.

O construtor de typing.TypeVar tem outros parâmetros opcionais - covariant e contravariant — que veremos em [more_types_ch], [variance_sec].

Agora vamos concluir essa introdução a TypeVar com AnyStr.

O tipo variável pré-definido AnyStr

O módulo typing inclui um TypeVar pré-definido chamado AnyStr. Ele está definido assim:

AnyStr = TypeVar('AnyStr', bytes, str)

AnyStr é usado em muitas funções que aceitam tanto bytes quanto str, e retornam valores do tipo recebido.

Agora vamos ver typing.Protocol, um novo recurso do Python 3.8, capaz de permitir um uso de dicas de tipo mais pythônico.

Protocolos estáticos

Note

Em programação orientada a objetos, o conceito de um "protocolo" como uma interface informal é tão antigo quando o Smalltalk, e foi uma parte essencial do Python desde o início. Entretanto, no contexto de dicas de tipo, um protocolo é uma subclasse de typing.Protocol, definindo uma interface que um verificador de tipo pode analisar. Os dois tipos de protocolo são tratados em [ifaces_prot_abc]. Aqui apresento apenas uma rápida introdução no contexto de anotações de função.

O tipo Protocol, como descrito em PEP 544—Protocols: Structural subtyping (static duck typing) (EN), é similar às interfaces em Go: um tipo protocolo é definido especificando um ou mais métodos, e o verificador de tipo analisa se aqueles métodos estão implementados onde um tipo daquele protocolo é usado.

Em Python, uma definição de protocolo é escrita como uma subclasse de typing.Protocol. Entretanto, classes que implementam um protocolo não precisam herdar, registrar ou declarar qualquer relação com a classe que define o protocolo. É função do verificador de tipo encontrar os tipos de protocolos disponíveis e exigir sua utilização.

Abaixo temos um problema que pode ser resolvido com a ajuda de Protocol e TypeVar. Suponha que você quisesse criar uma função top(it, n), que retorna os n maiores elementos do iterável it:

link:code/08-def-type-hints/comparable/top.py[role=include]

Um genérico parametrizado top ficaria parecido com o mostrado no Exemplo 19.

Exemplo 19. a função top function com um parâmetro de tipo T indefinido
def top(series: Iterable[T], length: int) -> list[T]:
    ordered = sorted(series, reverse=True)
    return ordered[:length]

O problema é, como restringir T? Ele não pode ser Any ou object, pois series precisa funcionar com sorted. A sorted nativa na verdade aceita Iterable[Any], mas só porque o parâmetro opcional key recebe uma função que calcula uma chave de ordenação arbitrária para cada elemento. O que acontece se você passar para sorted uma lista de objetos simples, mas não fornecer um argumento key? Vamos tentar:

>>> l = [object() for _ in range(4)]
>>> l
[<object object at 0x10fc2fca0>, <object object at 0x10fc2fbb0>,
<object object at 0x10fc2fbc0>, <object object at 0x10fc2fbd0>]
>>> sorted(l)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: '<' not supported between instances of 'object' and 'object'

A mensagem de erro mostra que sorted usa o operador < nos elementos do iterável. É só isso? Vamos tentar outro experimento rápido:[13]

>>> class Spam:
...     def __init__(self, n): self.n = n
...     def __lt__(self, other): return self.n < other.n
...     def __repr__(self): return f'Spam({self.n})'
...
>>> l = [Spam(n) for n in range(5, 0, -1)]
>>> l
[Spam(5), Spam(4), Spam(3), Spam(2), Spam(1)]
>>> sorted(l)
[Spam(1), Spam(2), Spam(3), Spam(4), Spam(5)]

Isso confirma a suspeita: eu consigo passar um lista de Spam para sort, porque Spam implementa __lt__ — o método especial subjacente ao operador <.

Então o parâmetro de tipo T no Exemplo 19 deveria ser limitado a tipos que implementam __lt__. No Exemplo 18, precisávamos de um parâmetro de tipo que implementava __hash__, para poder usar typing.Hashable como limite superior do parâmetro de tipo. Mas agora não há um tipo adequado em typing ou abc para usarmos, então precisamos criar um.

O Exemplo 20 mostra o novo tipo SupportsLessThan, um Protocol.

Exemplo 20. comparable.py: a definição de um tipo Protocol, SupportsLessThan
link:code/08-def-type-hints/comparable/comparable.py[role=include]
  1. Um protocolo é uma subclasse de typing.Protocol.

  2. O corpo do protocolo tem uma ou mais definições de método, com …​ em seus corpos.

Um tipo T é consistente-com um protocolo P se T implementa todos os métodos definido em P, com assinaturas de tipo correspondentes.

Dado SupportsLessThan, nós agora podemos definir essa versão funcional de top no Exemplo 21.

Exemplo 21. top.py: definição da função top usando uma TypeVar com bound=SupportsLessThan
link:code/08-def-type-hints/comparable/top.py[role=include]

Vamos testar top. O Exemplo 22 mostra parte de uma bateria de testes para uso com o pytest. Ele tenta chamar top primeiro com um gerador de expressões que produz tuple[int, str], e depois com uma lista de object. Com a lista de object, esperamos receber uma exceção de TypeError.

Exemplo 22. top_test.py: visão parcial da bateria de testes para top
link:code/08-def-type-hints/comparable/top_test.py[role=include]

# muitas linhas omitidas

link:code/08-def-type-hints/comparable/top_test.py[role=include]
  1. A constante typing.TYPE_CHECKING é sempre False durante a execução do programa, mas os verificadores de tipo fingem que ela é True quando estão fazendo a verificação.

  2. Declaração de tipo explícita para a variável series, para tornar mais fácil a leitura da saída do Mypy.[14]

  3. Esse if evita que as três linhas seguintes sejam executadas durante o teste.

  4. reveal_type() não pode ser chamada durante a execução, porque não é uma função regular, mas sim um mecanismo de depuração do Mypy - por isso não há import para ela. Mypy vai produzir uma mensagem de depuração para cada chamada à pseudo-função reveal_type(), mostrando o tipo inferido do argumento.

  5. Essa linha será marcada pelo Mypy como um erro.

Os testes anteriores são bem sucedidos - mas eles funcionariam de qualquer forma, com ou sem dicas de tipo em top.py. Mais precisamente, se eu verificar aquele arquivo de teste com o Mypy, verei que o TypeVar está funcionando como o esperado. Veja a saída do comando mypy no Exemplo 23.

Warning

Desde o Mypy 0.910 (julho de 2021), em alguns casos a saída de reveal_type não mostra precisamente os tipos que eu declarei, mas mostra tipos compatíveis. Por exemplo, eu não usei typing.Iterator e sim abc.Iterator. Por favor, ignore esse detalhe. O relatório do Mypy ainda é útil. Vou fingir que esse problema do Mypy já foi corrigido quando for discutir os resultados.

Exemplo 23. Saída do mypy top_test.py (linha quebradas para facilitar a leitura)
…/comparable/ $ mypy top_test.py
top_test.py:32: note:
    Revealed type is "typing.Iterator[Tuple[builtins.int, builtins.str]]" (1)
top_test.py:33: note:
    Revealed type is "builtins.list[Tuple[builtins.int, builtins.str]]"
top_test.py:34: note:
    Revealed type is "builtins.list[Tuple[builtins.int, builtins.str]]" (2)
top_test.py:41: note:
    Revealed type is "builtins.list[builtins.object*]" (3)
top_test.py:43: error:
    Value of type variable "LT" of "top" cannot be "object"  (4)
Found 1 error in 1 file (checked 1 source file)
  1. Em test_top_tuples, reveal_type(series) mostra que ele é um Iterator[tuple[int, str]]— que eu declarei explicitamente.

  2. reveal_type(result) confirma que o tipo produzido pela chamada a top é o que eu queria: dado o tipo de series, o result é list[tuple[int, str]].

  3. Em test_top_objects_error, reveal_type(series) mostra que ele é uma list[object*]. Mypy põe um * após qualquer tipo que tenha sido inferido: eu não anotei o tipo de series nesse teste.

  4. Mypy marca o erro que esse teste produz intencionalmente: o tipo dos elementos do Iterable series não pode ser object (ele tem que ser do tipo SupportsLessThan).

A principal vantagem de um tipo protocolo sobre os ABCs é que o tipo não precisa de nenhuma declaração especial para ser consistente-com um tipo protocolo. Isso permite que um protocolo seja criado aproveitando tipos pré-existentes, ou tipos implementados em bases de código que não estão sob nosso controle. Eu não tenho que derivar ou registrar str, tuple, float, set, etc. com SupportsLessThan para usá-los onde um parâmetro SupportsLessThan é esperado. Eles só precisam implementar __lt__. E o verificador de tipo ainda será capaz de realizar seu trabalho, porque SupportsLessThan está explicitamente declarado como um Protocol— diferente dos protocolos implícitos comuns no duck typing, que são invisíveis para o verificador de tipos.

A classe especial Protocol foi introduzida na PEP 544—Protocols: Structural subtyping (static duck typing). O Exemplo 21 demonstra porque esse recurso é conhecido como duck typing estático (static duck typing): a solução para anotar o parâmetro series de top era dizer "O tipo nominal de series não importa, desde que ele implemente o método __lt__." Em Python, o duck typing sempre permitiu dizer isso de forma implícita, deixando os verificadores de tipo estáticos sem ação. Um verificador de tipo não consegue ler o código fonte em C do CPython, ou executar experimentos no console para descobrir que sorted só requer que seus elementos suportem <.

Agora podemos tornar o duck typing explícito para os verificadores estáticos de tipo. Por isso faz sentido dizer que typing.Protocol nos oferece duck typing estático.[15]

Há mais para falar sobre typing.Protocol. Vamos voltar a ele na Parte IV, onde [ifaces_prot_abc] compara as abordagens da tipagem estrutural, do duck typing e dos ABCs - outro modo de formalizar protocolos. Além disso, a [overload_sec] (no [more_types_ch]) explica como declarar assinaturas de funções de sobrecarga (overload) com @typing.overload, e inclui um exemplo bastante extenso usando typing.Protocol e uma TypeVar delimitada.

Note

O typing.Protocol torna possível anotar a função double na Tipos são definidos pelas operações possíveis sem perder funcionalidade. O segredo é definir uma classe de protocolo com o método __mul__. Convido o leitor a fazer isso como um exercício. A solução está na [typed_double_sec] ([ifaces_prot_abc]).

Callable

Para anotar parâmetros de callback ou objetos callable retornados por funções de ordem superior, o módulo collections.abc oferece o tipo Callable, disponível no módulo typing para quem ainda não estiver usando Python 3.9. Um tipo Callable é parametrizado assim:

Callable[[ParamType1, ParamType2], ReturnType]

A lista de parâmetros - [ParamType1, ParamType2] — pode ter zero ou mais tipos.

Aqui está um exemplo no contexto de uma função repl, parte do interpretador iterativo simples que veremos na [pattern_matching_case_study_sec]:[16]

def repl(input_fn: Callable[[Any], str] = input]) -> None:

Durante a utilização normal, a função repl usa a input nativa do Python para ler expressões inseridas pelo usuário. Entretanto, para testagem automatizada ou para integração com outras fontes de input, repl aceita um parâmetro input_fn opcional: um Callable com o mesmo parâmetro e tipo de retorno de input.

A input nativa tem a seguinte assinatura no typeshed:

def input(__prompt: Any = ...) -> str: ...

A assinatura de input é consistente-com esta dica de tipo Callable

Callable[[Any], str]

Não existe sintaxe para a nomear tipo de argumentos opcionais ou de palavra-chave. A documentação de typing.Callable diz "tais funções são raramente usadas como tipo de callback." Se você precisar de um dica de tipo para acompanhar uma função com assinatura flexível, substitua o lista de parâmetros inteira por …​ - assim:

Callable[..., ReturnType]

A interação de parâmetros de tipo genéricos com uma hierarquia de tipos introduz um novo conceito: variância.

Variância em tipos callable

Imagine um sistema de controle de temperatura com uma função update simples, como mostrada no Exemplo 24. A função update chama a função probe para obter a temperatura atual, e chama display para mostrar a temperatura para o usuário. probe e display são ambas passadas como argumentos para update, por motivos didáticos. O objetivo do exemplo é contrastar duas anotações de Callable: uma com um tipo de retorno e outro com um tipo de parâmetro.

Exemplo 24. Ilustrando a variância.
link:code/08-def-type-hints/callable/variance.py[role=include]
  1. update recebe duas funções callable como argumentos.

  2. probe precisa ser uma callable que não recebe nenhuma argumento e retorna um float

  3. display recebe um argumento float e retorna None.

  4. probe_ok é consistente-com Callable[[], float] porque retornar um int não quebra código que espera um float.

  5. display_wrong não é consistente-com Callable[[float], None] porque não há garantia que uma função esperando um int consiga lidar com um float; por exemplo, a função hex do Python aceita um int mas rejeita um float.

  6. O Mypy marca essa linha porque display_wrong é incompatível com a dica de tipo no parâmetro display em update.

  7. display_ok é consistente_com Callable[[float], None] porque uma função que aceita um complex também consegue lidar com um argumento float.

  8. Mypy está satisfeito com essa linha.

Resumindo, não há problema em fornecer uma função de callback que retorne um int quando o código espera uma função callback que retorne um float, porque um valor int sempre pode ser usado onde um float é esperado.

Formalmente, dizemos que Callable[[], int] é subtipo-de Callable[[], float]— assim como int é subtipo-de float. Isso significa que Callable é covariante no que diz respeito aos tipos de retorno, porque a relação subtipo-de dos tipos int e float aponta na mesma direção que os tipo Callable que os usam como tipos de retorno.

Por outro lado, é um erro de tipo fornecer uma função callback que recebe um argumento int quando é necessário um callback que possa processar um float.

Formalmente, Callable[[int], None] não é subtipo-de Callable[[float], None]. Apesar de int ser subtipo-de float, no Callable parametrizado a relação é invertida: Callable[[float], None] é subtipo-de Callable[[int], None]. Assim dizemos que aquele Callable é contravariante a respeito dos tipos de parâmetros declarados.

A [variance_sec] no [more_types_ch] explica variância em mais detalhes e com exemplos de tipos invariantes, covariantes e contravariantes.

Tip

Por hora, saiba que a maioria dos tipos genéricos parametrizados são invariantes, portanto mais simples. Por exemplo, se eu declaro scores: list[float], isso me diz exatamente o que posso atribuir a scores. Não posso atribuir objetos declarados como list[int] ou list[complex]:

  • Um objeto list[int] não é aceitável porque ele não pode conter valores float que meu código pode precisar colocar em scores.

  • Um objeto list[complex] não é aceitável porque meu código pode precisar ordenar scores para encontrar a mediana, mas complex não fornece o método __lt__, então list[complex] não é ordenável.

Agora chegamos ou último tipo especial que examinaremos nesse capítulo.

NoReturn

Esse é um tipo especial usado apenas para anotar o tipo de retorno de funções que nunca retornam. Normalmente, elas existem para gerar exceções. Há dúzias dessas funções na biblioteca padrão.

Por exemplo, sys.exit() levanta SystemExit para encerrar o processo Python.

Sua assinatura no typeshed é:

def exit(__status: object = ...) -> NoReturn: ...

O parâmetro __status__ é apenas posicional, e tem um valor default. Arquivos stub não contém valores default, em vez disso eles usam …​. O tipo de __status é object, o que significa que pode também ser None, assim seria redundante escrever Optional[object].

Na [class_metaprog], o [checked_class_bottom_ex] usa NoReturn em __flag_unknown_attrs, um método projetado para produzir uma mensagem de erro completa e amigável, e então levanta um AttributeError.

A última seção desse capítulo épico é sobre parâmetros posicionais e variádicos

Anotando parâmetros apenas posicionais e variádicos

Lembra da função tag do [tagger_ex]? Da última vez que vimos sua assinatura foi em [positional_only_params]:

def tag(name, /, *content, class_=None, **attrs):

Aqui está tag, completamente anotada e ocupando várias linhas - uma convenção comum para assinaturas longas, com quebras de linha como o formatador blue faria:

from typing import Optional

def tag(
    name: str,
    /,
    *content: str,
    class_: Optional[str] = None,
    **attrs: str,
) -> str:

Observe a dica de tipo *content: str, para parâmetros posicionais arbitrários; Isso significa que todos aqueles argumentos tem que ser do tipo str. O tipo da variável local content no corpo da função será tuple[str, …​].

A dica de tipo para argumentos de palavra-chave arbitrários é attrs: str neste exemplo, portanto o tipo de attrs dentro da função será dict[str, str]. Para uma dica de tipo como attrs: float, o tipo de attrs na função seria dict[str, float].``

Se for necessário que o parâmetro attrs aceite valores de tipos diferentes, é preciso usar uma Union[] ou Any: **attrs: Any.

A notação / para parâmetros puramente posicionais só está disponível com Python ≥ 3.8. Em Python 3.7 ou anterior, isso é um erro de sintaxe. A convenção da PEP 484 é prefixar o nome cada parâmetro puramente posicional com dois sublinhados. Veja a assinatura de tag novamente, agora em duas linhas, usando a convenção da PEP 484:

from typing import Optional

def tag(__name: str, *content: str, class_: Optional[str] = None,
        **attrs: str) -> str:

O Mypy entende e aplica as duas formas de declarar parâmetros puramente posicionais.

Para encerrar esse capítulo, vamos considerar brevemente os limites das dicas de tipo e do sistema de tipagem estática que elas suportam.

Tipos imperfeitos e testes poderosos

Os mantenedores de grandes bases de código corporativas relatam que muitos bugs são encontrados por verificadores de tipo estáticos, e o custo de resolvê-los é menor que se os mesmos bugs fossem descobertos apenas após o código estar rodando em produção. Entretanto, é essencial observar que a testagem automatizada era uma prática padrão largamente adotada muito antes da tipagem estática ser introduzida nas empresas que eu conheço.

Mesmo em contextos onde ela é mais benéfica, a tipagem estática não pode ser elevada a árbitro final da correção. Não é difícil encontrar:

Falsos Positivos

Ferramentas indicam erros de tipagem em código correto.

Falsos Negativos

Ferramentas não indicam erros em código incorreto.

Além disso, se formos forçados a checar o tipo de tudo, perdemos um pouco do poder expressivo do Python:

  • Alguns recursos convenientes não podem ser checados de forma estática: por exemplo, o desempacotamento de argumentos como em config(**settings).

  • Recursos avançados como propriedades, descritores, metaclasses e metaprogramação em geral, têm suporte muito deficiente ou estão além da compreensão dos verificadores de tipo

  • Verificadores de tipo ficam obsoletos e/ou incompatíveis após o lançamento de novas versões do Python, rejeitando ou mesmo quebrando ao analisar código com novos recursos da linguagem - algumas vezes por mais de um ano.

Restrições comuns de dados não podem ser expressas no sistema de tipo - mesmo restrições simples. Por exemplo, dicas de tipo são incapazes de assegurar que "quantidade deve ser um inteiro > 0" ou que "label deve ser uma string com 6 a 12 letras em ASCII." Em geral, dicas de tipo não são úteis para localizar erros na lógica do negócio subjacente ao código.

Dadas essas ressalvas, dicas de tipo não podem ser o pilar central da qualidade do software, e torná-las obrigatórias sem qualquer exceção só amplificaria os aspectos negativos.

Considere o verificador de tipo estático como uma das ferramentas na estrutura moderna de integração de código, ao lado de testadores, analisadores de código (linters), etc. O objetivo de uma estrutura de produção de integração de código é reduzir as falhas no software, e testes automatizados podem encontrar muitos bugs que estão fora do alcance de dicas de tipo. Qualquer código que possa ser escrito em Python pode ser testado em Python - com ou sem dicas de tipo.

Note

O título e a conclusão dessa seção foram inspirados pelo artigo "Strong Typing vs. Strong Testing" (EN) de Bruce Eckel, também publicado na antologia The Best Software Writing I (EN), editada por Joel Spolsky (Apress). Bruce é um fã de Python, e autor de livros sobre C++, Java, Scala, e Kotlin. Naquele texto, ele conta como foi um defensor da tipagem estática até aprender Python, e conclui: "Se um programa em Python tem testes de unidade adequados, ele poderá ser tão robusto quanto um programa em C++, Java, ou C# com testes de unidade adequados (mas será mais rápido escrever os testes em Python).

Isso encerra nossa cobertura das dicas de tipo em Python por agora. Elas serão também o ponto central do [more_types_ch], que trata de classes genéricas, variância, assinaturas sobrecarregadas, coerção de tipos (type casting), entre outros tópicos. Até lá, as dicas de tipo aparecerão em várias funções ao longo do livro.

Resumo do capítulo

Começamos com uma pequena introdução ao conceito de tipagem gradual, depois adotamos uma abordagem prática. É difícil ver como a tipagem gradual funciona sem uma ferramenta que efetivamente leia as dicas de tipo, então desenvolvemos uma função anotada guiados pelos relatórios de erro do Mypy.

Voltando à ideia de tipagem gradual, vimos como ela é um híbrido do duck typing tradicional de Python e da tipagem nominal mais familiar aos usuários de Java, C++ e de outra linguagens de tipagem estática.

A maior parte do capítulo foi dedicada a apresentar os principais grupos de tipos usados em anotações. Muitos dos tipos discutidos estão relacionados a tipos conhecidos de objetos do Python, tais como coleções, tuplas e callables - estendidos para suportar notação genérica do tipo Sequence[float]. Muitos daqueles tipos são substitutos temporários, implementados no módulo typing antes que os tipos padrão fossem modificados para suportar genéricos, no Python 3.9.

Alguns desses tipos são entidade especiais. Any, Optional, Union, e NoReturn não tem qualquer relação com objetos reais na memória, existem apenas no domínio abstrato do sistema de tipos.

Estudamos genéricos parametrizados e variáveis de tipo, que trazem mais flexibilidade para as dicas de tipo sem sacrificar a segurança da tipagem.

Genéricos parametrizáveis se tornam ainda mais expressivos com o uso de Protocol. Como só surgiu no Python 3.8, Protocol ainda não é muito usado - mas é de uma enorme importância. Protocol permite duck typing estático: É a ponte fundamental entre o núcleo do Python, coberto pelo duck typing, e a tipagem nominal que permite a verificadores de tipo estáticos encontrarem bugs.

Ao discutir alguns desses tipos, usamos o Mypy para localizar erros de checagem de tipo e tipos inferidos, com a ajuda da função mágica reveal_type() do Mypy.

A seção final mostrou como anotar parâmetros exclusivamente posicionais e variádicos.

Dicas de tipo são um tópico complexo e em constante evolução. Felizmente elas são um recurso opcional. Vamos manter o Python acessível para a maior base de usuários possível, e parar de defender que todo código Python precisa ter dicas de tipo - como já presenciei em sermões públicos de evangelistas da tipagem.

Nosso BDFL[17] emérito liderou a movimento de inclusão de dicas de tipo em Python, então é muito justo que esse capítulo comece e termine com palavras dele.

Não gostaria de uma versão de Python na qual eu fosse moralmente obrigado a adicionar dicas de tipo o tempo todo. Eu realmente acho que dicas de tipo tem seu lugar, mas há muitas ocasiões em que elas não valem a pena, e é maravilhoso que possamos escolher usá-las.[18]

— Guido van Rossum

Para saber mais

Bernát Gábor escreveu em seu excelente post, "The state of type hints in Python" (EN):

Dicas de Tipo deveriam ser usadas sempre que valha à pena escrever testes de unidade .

Eu sou um grande fã de testes, mas também escrevo muito código exploratório. Quando estou explorando, testes e dicas de tipo não ajudam. São um entrave.

Esse post do Gábor é uma das melhores introduções a dicas de tipo em Python que eu já encontrei, junto com o texto de Geir Arne Hjelle, "Python Type Checking (Guide)" (EN). "Hypermodern Python Chapter 4: Typing" (EN), de Claudio Jolowicz, é uma introdução mas curta que também fala de validação de checagem de tipo durante a execução.

Para uma abordagem mais aprofundada, a documentação do Mypy é a melhor fonte. Ela é útil independente do verificador de tipo que você esteja usando, pois tem páginas de tutorial e de referência sobre tipagem em Python em geral - não apenas sobre o próprio Mypy.

Lá você também encontrará uma conveniente página de referência (ou _cheat sheet) (EN) e uma página muito útil sobre problemas comuns e suas soluções (EN).

A documentação do módulo typing é uma boa referência rápida, mas não entra em muitos detalhes.

A PEP 483—The Theory of Type Hints (EN) inclui uma explicação aprofundada sobre variância, usando Callable para ilustrar a contravariância. As referências definitivas são as PEP relacionadas a tipagem. Já existem mais de 20 delas. A audiência alvo das PEPs são os core developers (desenvolvedores principais da linguagem em si) e o Steering Council do Python, então elas pressupõe uma grande quantidade de conhecimento prévio, e certamente não são uma leitura leve.

Como já mencionado, o [more_types_ch] cobre outros tópicos sobre tipagem, e a [more_type_hints_further_sec] traz referências adicionais, incluindo a [typing_peps_tbl], com a lista das PEPs sobre tipagem aprovadas ou em discussão até o final de 2021.

"Awesome Python Typing" é uma ótima coleção de links para ferramentas e referências.

Ponto de vista

Apenas Pedale

Esqueça as desconfortáveis bicicletas ultraleves, as malhas brilhantes, os sapatos desajeitados que se prendem a pedais minúsculos, o esforço de quilômetros intermináveis. Em vez disso, faça como você fazia quando era criança - suba na sua bicicleta e descubra o puro prazer de pedalar.

— Grant Petersen
Just Ride: A Radically Practical Guide to Riding Your Bike (Apenas Pedale: Um Guia Radicalmente Prático sobre o Uso de sua Bicicleta) (Workman Publishing)

Se programar não é sua profissão principal, mas uma ferramenta útil no seu trabalho ou algo que você faz para aprender, experimentar e se divertir, você provavelmente não precisa de dicas de tipo mais que a maioria dos ciclistas precisa de sapatos com solas rígidas e presilhas metálicas.

Apenas programe.

O Efeito Cognitivo da Tipagem

Eu me preocupo com o efeito que as dicas de tipo terão sobre o estilo de programação em Python.

Concordo que usuários da maioria das APIs se beneficiam de dicas de tipo. Mas o Python me atraiu - entre outras razões - porque proporciona funções tão poderosas que substituem APIs inteiras, e podemos escrever nós mesmos funções poderosas similares. Considere a função nativa max(). Ela é poderosa, entretanto fácil de entender. Mas vou mostrar na [max_overload_sec] que são necessárias 14 linhas de dicas de tipo para anotar corretamente essa função - sem contar um typing.Protocol e algumas definições de TypeVar para sustentar aquelas dicas de tipo.

Me inquieta que a coação estrita de dicas de tipo em bibliotecas desencorajem programadores de sequer considerarem programar funções assim no futuro.

De acordo com o verbete em inglês na Wikipedia, "relatividade linguística" — ou a hipótese Sapir–Whorf — é um "princípio alegando que a estrutura de uma linguagem afeta a visão de mundo ou a cognição de seus falantes"

A Wikipedia continua:

  • A versão forte diz que a linguagem determina o pensamento, e que categorias linguísticas limitam e determinam as categorias cognitivas.

  • A versão fraca diz que as categorias linguísticas e o uso apenas influenciam o pensamento e as decisões.

Linguistas em geral concordam que a versão forte é falsa, mas há evidência empírica apoiando a versão fraca.

Não conheço estudos específicos com linguagens de programação, mas na minha experiência, elas tiveram grande impacto sobre a forma como eu abordo problemas. A primeira linguagem de programação que usei profissionalmente foi o Applesoft BASIC, na era dos computadores de 8 bits. Recursão não era diretamente suportada pelo BASIC - você tinha que produzir sua própria pilha de chamada (call stack) para obter recursão. Então eu nunca considerei usar algoritmos ou estruturas de dados recursivos. Eu sabia, em algum nível conceitual, que tais coisas existiam, mas elas não eram parte de meu arsenal de técnicas de resolução de problemas.

Décadas mais tarde, quando aprendi Elixir, gostei de resolver problemas com recursão e usei essa técnica além da conta - até descobrir que muitas das minhas soluções seriam mais simples se que usasse funções existentes nos módulos Enum e Stream do Elixir. Aprendi que código de aplicações em Elixir idiomático raramente contém chamadas recursivas explícitas - em vez disso, usam enums e streams que implementam recursão por trás das cortinas.

A relatividade linguística pode explicar a ideia recorrente (e também não provada) que aprender linguagens de programação diferentes torna alguém um programador melhor, especialmente quando as linguagens em questão suportam diferentes paradigmas de programação. Praticar com Elixir me tornou mais propenso a aplicar patterns funcionais quando escrevo programas em Python ou Go.

Agora voltando à Terra.

O pacote requests provavelmente teria uma API muito diferente se Kenneth Reitz estivesse decidido (ou tivesse recebido ordens de seu chefe) a anotar todas as suas funções. Seu objetivo era escrever uma API que fosse fácil de usar, flexível e poderosa. Ele conseguiu, dada a fantástica popularidade de requests - em maio de 2020, ela estava em #4 nas PyPI Stats, com 2,6 milhões de downloads diários. A #1 era a urllib3, uma dependência de requests.

Em 2017 os mantenedores de requests decidiram não perder seu tempo escrevendo dicas de tipo. Um deles, Cory Benfield, escreveu um email dizendo:

Acho que bibliotecas com APIs 'pythônicas' são as menos propensas a adotar esse sistema de tipagem, pois ele vai adicionar muito pouco valor a elas.

Naquela mensagem, Benfield incluiu esse exemplo extremo de uma tentativa de definição de tipo para o argumento nomeado files em requests.request():

Optional[
  Union[
    Mapping[
      basestring,
      Union[
        Tuple[basestring, Optional[Union[basestring, file]]],
        Tuple[basestring, Optional[Union[basestring, file]],
              Optional[basestring]],
        Tuple[basestring, Optional[Union[basestring, file]],
              Optional[basestring], Optional[Headers]]
      ]
    ],
    Iterable[
      Tuple[
        basestring,
        Union[
          Tuple[basestring, Optional[Union[basestring, file]]],
          Tuple[basestring, Optional[Union[basestring, file]],
                Optional[basestring]],
          Tuple[basestring, Optional[Union[basestring, file]],
                Optional[basestring], Optional[Headers]]
      ]
    ]
  ]
]

E isso assume essa definição:

Headers = Union[
  Mapping[basestring, basestring],
  Iterable[Tuple[basestring, basestring]],
]

Você acha que requests seria como é se os mantenedores insistissem em ter uma cobertura de dicas de tipo de 100%? SQLAlchemy é outro pacote importante que não trabalha muito bem com dicas de tipo.

O que torna essas bibliotecas fabulosas é incorporarem a natureza dinâmica do Python.

Apesar das dicas de tipo trazerem benefícios, há também um preço a ser pago.

Primeiro, há o significativo investimento em entender como o sistema de tipos funciona. Esse é um custo unitário.

Mas há também um custo recorrente, eterno.

Nós perdemos um pouco do poder expressivo do Python se insistimos que tudo precisa estar sob a checagem de tipos. Recursos maravilhosos como desempacotamento de argumentos — e.g., config(**settings)— estão além da capacidade de compreensão dos verificadores de tipo.

Se você quiser ter uma chamada como config(**settings) verificada quanto ao tipo, você precisa explicitar cada argumento. Isso me traz lembranças de programas em Turbo Pascal, que escrevi 35 anos atrás.

Bibliotecas que usam metaprogramação são difíceis ou impossíveis de anotar. Claro que a metaprogramação pode ser mal usada, mas isso também é algo que torna muitos pacotes do Python divertidos de usar.

Se dicas de tipo se tornarem obrigatórias sem exceções, por uma decisão superior em grande empresas, aposto que logo veremos pessoas usando geração de código para reduzir linhas de código padrão em programas Python - uma prática comum com linguagens menos dinâmicas.

Para alguns projetos e contextos, dicas de tipo simplesmente não fazem sentido. Mesmo em contextos onde elas fazer muito sentido, não fazem sentido o tempo todo. Qualquer política razoável sobre o uso de dicas de tipo precisa conter exceções.

Alan Kay, o recipiente do Turing Award que foi um dos pioneiros da programação orientada a objetos, certa vez disse:

Algumas pessoas são completamente religiosas no que diz respeito a sistemas de tipo, e como um matemático eu adoro a ideia de sistemas de tipos, mas ninguém até agora inventou um que tenha alcance o suficiente..[19]

Obrigado, Guido, pela tipagem opcional. Vamos usá-la como foi pensada, e não tentar anotar tudo em conformidade estrita com um estilo de programação que se parece com Java 1.5.

Duck Typing FTW

Duck typing encaixa bem no meu cérebro, e duck typing estático é um bom compromisso, permitindo checagem estática de tipo sem perder muito da flexibilidade que alguns sistemas de tipagem nominal só permitem ao custo de muita complexidade - isso quando permitem.

Antes da PEP 544, toda essa ideia de dicas de tipo me parecia completamente não-pythônica, Fiquei muito feliz quando vi typing.Protocol surgir em Python. Ele traz equilíbrio para a Força.

Genéricos ou Específicos?

De uma perspectiva de Python, o uso do termo "genérico" na tipagem é um retrocesso. Os sentidos comuns do termo "genérico" são "aplicável integralmente a um grupo ou uma classe" ou "sem uma marca distintiva."

Considere list versus list[str]. o primeiro é genérico: aceita qualquer objeto. O segundo é específico: só aceita str.

Por outro lado, o termo faz sentido em Java. Antes do Java 1.5, todas as coleções de Java (exceto a mágica array) eram "específicas": só podiam conter referência a Object, então era necessário converter os itens que saim de uma coleção antes que eles pudessem ser usados. Com Java 1.5, as coleções ganharam parâmetros de tipo, e se tornaram "genéricas."


1. Um compilador JIT ("just-in-time", compiladores que transformam o bytecode gerado pelo interpretador em código da máquina-alvo no momento da execução) como o do PyPy tem informações muito melhores que as dicas de tipo: ele monitora o programa Python durante a execução, detecta os tipos concretos em uso, e gera código de máquina otimizado para aqueles tipos concretos.
2. Em Python não há sintaxe para controlar o conjunto de possíveis valores de um tipo, exceto para tipos Enum. Por exemplo, não é possível, usando dicas de tipo, definir Quantity como um número inteiro entre 1 e 10000, ou AirportCode como uma sequência de 3 letras. O NumPy oferece uint8, int16, e outros tipos numéricos referentes à arquitetura do hardware, mas na biblioteca padrão do Python nós encontramos apenas tipos com conjuntos muitos pequenos de valores (NoneType, bool) ou conjuntos muito grandes (float, int, str, todas as tuplas possíveis, etc.).
3. Duck typing é uma forma implícita de tipagem estrutural, que o Python passou a suportar após a versão 3.8, com a introdução de typing.Protocol. Vamos falar disso mais adiante nesse capítulo - em Protocolos estáticos — e com mais detalhes em [ifaces_prot_abc].
4. Muitas vezes a herança é sobreutilizada e difícil de justificar em exemplos que, apesar de realistas, são muito simples. Então por favor aceite esse exemplo com animais como uma rápida ilustração de sub-tipagem.
5. Professora do MIT, designer de linguagens de programação e homenageada com o Turing Award em 2008. Wikipedia: Barbara Liskov.
6. Para ser mais preciso, ord só aceita str ou bytes com len(s) == 1. Mas no momento o sistema de tipagem não consegue expressar essa restrição.
7. Em ABC - a linguagem que mais influenciou o design inicial do Python - cada lista estava restrita a aceitar valores de um único tipo: o tipo do primeiro item que você colocasse ali.
8. Uma de minhas contribuições para a documentação do módulo typing foi acrescentar dúzias de avisos de descontinuação, enquanto eu reorganizava as entradas abaixo de "Conteúdo do Módulo" em subseções, sob a supervisão de Guido van Rossum.
9. Eu uso := quando faz sentido em alguns exemplos, mas não trato desse operador no livro. Veja PEP 572—Assignment Expressions para entender os detalhes dos operadores de atribuição.
10. Na verdade, dict é uma subclasse virtual de abc.MutableMapping. O conceito de subclasse virtual será explicado em [ifaces_prot_abc]. Por hora, basta saber que issubclass(dict, abc.MutableMapping) é True, apesar de dict ser implementado em C e não herdar nada de abc.MutableMapping, apenas de object.
11. A implementação aqui é mais simples que aquela do módulo statistics na biblioteca padrão do Python
12. Eu contribui com essa solução para typeshed, e em 26 de maio de 2020 mode aparecia anotado assim em statistics.pyi.
13. Não é maravilhoso poder abrir um console iterativo e contar com o duck typing para explorar recursos da linguagem, como acabei de fazer? Eu sinto muita falta deste tipo de exploração quando uso linguagem que não tem esse recurso.
14. Sem essa dica de tipo, o Mypy inferiria o tipo de series como Generator[Tuple[builtins.int, builtins.str*], None, None], que é prolixo mas consistente-com Iterator[tuple[int, str]], como veremos na [generic_iterable_types_sec].
15. Eu não sei quem inventou a expressão duck tying estático, mas ela se tornou mais popular com a linguagem Go, que tem uma semântica de interfaces que é mais parecida com os protocolos de Python que com as interfaces nominais de Java.
16. REPL significa Read-Eval-Print-Loop (Ler-Calcular-Imprimir-Recomeçar), o comportamento básico de interpretadores iterativos.
17. "Benevolent Dictator For Life." - Ditador Benevolente Vitalício. Veja Guido van van Rossum em "Origin of BDFL".
18. Do vídeo no Youtube, "Type Hints by Guido van Rossum (March 2015)" (EN). A citação começa em 13'40". Editei levemente a transcrição para manter a clareza.