Python 3 e Type Hints
O uso de type hints no meu dia-a-dia de desenvolvedor se deu desde que comecei a utilizar Python 3. De lá pra ca, pela falta de divulgação, infelizmente não vi muita coisa mudar com relação a adoção de uma das features que considero mais legais e úteis do Python moderno.
Na B2W Digital, onde trabalho atualmente, temos um grupo com mais ou menos 40 pessoas que usam Python para alguma coisa. Perguntei rapidamente para as pessoas quem usava ou não e quem nem sabia do que se tratava. 9 pessoas responderam e dessas, 5 não sabia do que se tratava, 3 não usam e somente 1 pessoa conhecia e usava. O cenário do meu time de 6 desenvolvedores que usam Python diariamente, para quase tudo, é bem diferente: todos conhecem e enxergam o valor. Pra tentar mudar um pouco esse cenário, resolvi fazer o mesmo que fiz com asyncio e compartilhar conteúdo em forma de texto e palestra.
Ao longo desse post, abordarei os seguintes pontos pra tentar te convencer a também adotar type hints:
- Como Python se situa com relação a tipos
- As vantagens de tipos explícitos e bem definidos
- Como usar Type Hints
- Como utilizar um verificador estático de tipos
- Ecossistema
Tipagem Estática vs Tipagem Dinâmica
Na Tipagem Estática, os tipos são verificados antes do tempo de execução. Ou seja, o programador deve especificar o tipo de cada variável ou a linguagem deve prover algum tipo de sistema de inferência.
Já na Tipagem Dinâmica, os tipos são verificados durante a execução, o que significa que os tipos são associados a valores encontrados em tempo de execução e não a nomes pré definidos no momento de escrita do código fonte.
Tipagem Forte vs Tipagem Fraca
Em linguagem com tipagem forte, todas as operações entre tipos diferentes deve ser explicitamente definidas e operações não definidas entre tipos vão resultar em um erro. Mas o que isso significa na prática? Vamos usar Python, uma linguagem fortemente tipada, para dar alguns exemplos.
Como o operador __and__ de uma string é utilizado para operações de concatenação, quando tentamos utilizar com um int, o interpretador vai retornar um erro em runtime.
>>> “5” + 3TypeError: can only concatenate str (not “int”) to str
O mesmo acontece se a gente tentar diminuir 3 de uma string ‘5’, porque em Python, a operação de subtração entre strings e inteiros simplesmente não faz sentido.
>>> “5” — 3TypeError: unsupported operand type(s) for -: ‘str’ and ‘int’
O mesmo vale para operação de subtração entre duas strings:
>>> “5” — “3”TypeError: unsupported operand type(s) for -: ‘str’ and ‘str’
Ser fortemente tipada foi uma escolha consciente de design da linguagem que preza por comportamentos explícitos. Problemas como esse são fáceis de se resolver porque uma erro é levantado exatamente no local em que uma operação inválida acontece.
Já nas linguagens fracamente tipadas, o tipo de um valor vai depender de como ele é usado. Ou seja, o compilador ou interpretador podem decidir em tempo de compilação ou tempo de execução como lidar com uma variável. Existe um conjunto de regras pra isso e mesmo sendo um sistema determinístico, isso pode gerar muita estranheza e confusão.
Para dar alguns exemplos sobre o comportamento de linguagens fracamente tipadas, vamos usar JavaScript pela popularidade. Vamos executar as mesmas operações que fizemos nos exemplos com Python e observar a diferença nos resultados.
A operação + entre uma string e um inteiro faz com que o interpretador trate o número como uma string e execute uma concatenação.
>>> “5” + 3“53”
Já numa operação — entre uma string e um inteiro, ele trata os dois como números e executa uma subtração algébrica.
>>> “5” — 32
Quando o mesmo operador é utilizado entre duas strings, ele assume que são números e também executa, sem erros, uma subtração algébrica.
>>> “5” — "3"2
A escolha de criar Javascript como uma linguagem fracamente tipada também foi consciente e normalmente tá ligada com o desejo de se escrever menos. O preço que se paga por isso é que a lógica fica mais complexa porque muitos dos comportamentos ficam escondidos em regras de conversão implícitas.
É importante que a gente tenha noção de que o conceito de forte e fraco não é um conceito binário, e sim contínuo. Um bom exemplo disso é que definimos Python como uma linguagem fortemente tipada, tem int e float como tipos primitivos, mas permite a operação de adição sem que seja necessária uma conversão explícita de tipos.
>>> 5 + 5.510.5
Para Python, o que significa ser dinamicamente tipada?
- Verificações de tipo só existem em run time: Como não precisamos definir o tipo de nada e as verificações de tipo também só existem em tempo de execução, é possível escrever código sintaticamente válido mas semanticamente incorretos. Em Python, esse tipo de erros normalmente são combatidos com testes, mas também podem ser evitados de outras formas.
- Menos verboso e menos rígido: Como linguagem, Python foi criada com o objetivo de ter uma sintaxe mais enxuta e menos rígida onde desenvolvedores se preocupariam mais com a lógica do que com as formalidades. Não se importando com as otimizações de performance e segurança possíveis em um sistema estático, se preocupando em ser uma linguagem boa não para os computadores, mas para os desenvolvedores. É de se imaginar que a definição do que é “bom para o desenvolvedor” também mude em alguns pontos e que uma linguagem viva como Python se adapte. Ao longo do tempo, Python mudou, cresceu e está em constante evolução. Hoje milhares de desenvolvedores utilizam Python no desenvolvimento de sistemas bem mais complexos e extensos do que antes.
- IDEs dependem de inferências: Um programador com background em alguma linguagem estaticamente tipada como Java, Haskell e Go que tenha que desenvolver algo maior do que um simples script em Python, vai estranhar a falta de ajuda das IDEs. Sem uma definição clara de tipos, as IDEs ficam totalmente dependentes de inferências, o que limita o seu poder de ajuda.
Vantagens de declarações explícitas de tipos
- As definições de tipos ao longo do código acabam servindo como uma forma de Documentação, especificando de forma clara determinados contratos e regras de tipos. Ao contrário da documentação que já geramos em texto usando docstrings, que inevitavelmente acabam contendo erros e podendo ficar ultrapassadas com a mesma velocidade em que a sua aplicação vai crescendo e mudando, a especificação de tipos faz parte do código e deve permanecer correta. Por exemplo: Se você mudar o tipo do argumento de uma função sem mudar a documentação relacionada ao tipo, nada vai acontecer, mas se você fizer o mesmo com type hints, o seu analisador estático vai acusar um erro.
- Também pode ser utilizada para Otimização de código pois informações de tipos são muito importantes para otimizações em tempo de compilação.
- Te dão a Segurança de que é possível analisar o programa e que o sistema de tipos vai permitir que sejam detectados códigos que não fazem sentido e são um erro. Por exemplo, é fácil detectar que 12 dividido pela string “banana” é algo que não faz sentido se não existirem regras de como dividir um inteiro por uma string.
- Code-complete melhor é um dos meus motivos preferidos. Minha memória constantemente me deixa na mão e não quero ter que depender dela ou ter que abrir um outro arquivo ou documentação para saber como devo chamar uma função ou o que posso fazer com um objeto. O mesmo acaba valendo para refatorações.
- É mais fácil de se achar em uma codebase extensa. Um bom exemplo disso é que a partir de qualquer lugar é possível navegar para a definição de uma classe ou para o contrato de um método, sem precisar saber nada da estrutura de um projeto ou de suas dependências. Essa característica também faz com que seja muito útil para etapa de code review, fazendo com que seus pares consigam se localizar rapidamente sem um contexto muito amplo da sua aplicação.
Se você chegou até aqui mesmo sem ter lido 1 linha de código com type hints é porque consegui pelo menos despertar o seu interesse em usar tipos explícitos em Python. Essa ideia não é nova e vale a pena a gente se situar com relação as discussões e o histórico:
- 2006: PEP-3107 introduziu uma sintaxe para anotações de tipos em funções
- 2008: Python 3.0 é lançado com o suporte a sintaxe de Function Annotations
- 2014: Duas PEPs são criadas tendo Guido como um dos autores: PEP-483 -The theory of Type Hints e PEP-484 — Type Hints
- 2014: Guido lança primeira versão do mypy, para análise estática de tipos.
- 2015: Se tornou parte da linguagem a partir do Python 3.5
- 2016: Python 3.6 é lançado contendo todas as features da PEP-484
Começando a usar Type Hints
Vamos começar definindo uma função soma, que espera dois argumentos, a e b e retorna a soma desses dois argumentos.
A partir do nome da função, podemos inferir que ela espera dois números e que retorna a soma algébrica desses dois números. Vamos deixar esse contrato mais explícito usando Type Hints, anotando os tipos dos argumentos e o tipo do retorno.
Vamos então escrever 3 chamadas para essa função com casos possíveis de utilização:
- Primeiro da forma como a gente espera que ela seja chamada: com dois inteiros;
soma(6, 4)
2. Depois com um dos argumentos com o tipo incorreto;
soma(6, ‘6’)
3. E então a gente tenta "somar" o retorno de uma chamada válida a essa função com uma string
soma(6, 4) + ‘Xablau’
No mesmo arquivo, ficaria assim:
Esse é um exemplo bem simples, pra que fique bem claro o que funcionaria e o que a gente sabe que vai gerar algum tipo de erro em tempo de execução. Vamos ver como a gente pode se defender para encontrar esse tipo de erro antes.
Mypy
Mypy é um analisador estático para tipos, desenvolvido inicialmente pelo Guido, que utiliza as informações de type hints para validar o seu código e procurar por bugs. Pra começar a usar o mypy é só instalá-lo como qualquer pacote python.
Com pipenv
pipenv install mypy
ou com pip
pip install mypy
Depois da instalação, você vai ter o mypy como executável em linha de comando, e vai poder rodar suas validações de tipos sem de fato executar o seu programa:
mypy typehints-exemplo3.py
O mypy vai retornar uma lista com cada um dos erros:
typehints-exemplo3.py:5: error: Argument 2 to “soma” has incompatible type “str”; expected “int”typehints-exemplo3.py:6: error: Unsupported operand types for + (“int” and “str”)
- Como chamamos a função passando uma str como segundo argumento e o argumento foi definido como int, isso é um erro.
- Um erro também é levantado quando tentamos somar o resultado da execução da função, que é um int, com uma str.
E o que aconteceria se acidentalmente a gente tivesse definido a função com o tipo de retorno errado?
mypy typehints-exemplo4.py
Não tem problema. O mypy também vai te ajudar com isso porque ele é capaz de fazer inferência de tipos e dizer que a soma do argumento a com b, não pode ser uma string. A verificação de tipos nos ajuda a evitar que erros como esses aconteçam.
typehints-exemplo4.py:2: error: Incompatible return value type (got “int”, expected “str”)
Essa inferência faz com que mesmo que você não defina o tipo de uma variável, uma vez que você a inicializa com um tipo, assume-se que ela deve continuar com esse tipo ao longo do seu ciclo de vida.
mypy typehints-exemplo5.pytypehints-exemplo5.py:2: error: Incompatible types in assignment (expression has type “str”, variable has type “int”)
O mesmo aconteceria se declarássemos explicitamente o tipo de x:
mypy typehints-exemplo8.pytypehints-exemplo8.py:2: error: Incompatible types in assignment (expression has type “str”, variable has type “int”)
A mesma lógica se aplica a tipos mais complexos e por default, Mypy também faz inferência de tipos em containers homogêneos.
Então mesmo se não declararmos explicitamente a variável points como sendo uma List[Tuple[int, int]], ele vai inferir que é o que você espera e vai ajudar a gente dizendo que muito provavelmente não queremos uma tupla de strings dentro desse container.
mypy typehints-exemplo6.pytypehints-exemplo6.py:2: error: Argument 1 to “append” of “list” has incompatible type “Tuple[str, str]”; expected “Tuple[int, int]”
Ou seja, teríamos o mesmo efeito com uma declaração explícita:
mypy typehints-exemplo7.pytypehints-exemplo7.py:5: error: Argument 1 to “append” of “list” has incompatible type “Tuple[str, str]”; expected “Tuple[int, int]”
Nesse último exemplo, usei o módulo typing para descrever esse tipo
Stubs (.pyi)
Existem alguns momentos pelos quais as vezes não é conveniente que a gente anote os tipos no mesmo arquivo que contém a implementação. É possível utilizar tudo que já mostrei até agora sem tocar no seu arquivo .py existente. Para isso, existem os arquivos .pyi, que funcionam como arquivos de header.
Esses arquivos são escritos com a sintaxe normal do Python 3, mas sem conter nenhuma lógica de inicialização de variáveis, implementação de função, ou de valores default. Valores default e implementação são substituídos pela constante Ellipsis ...
Dessa forma conseguimos anotar os tipos e usar todas as features que já mostrei, sem tocar no código existente.
__annotations__
Assim como a documentação de uma função em python é um atributo de uma função (__doc__), as anotações de tipo também ficam disponíveis em tempo de execução. Conseguimos acessar essas anotações através do atributo __annotations__
Vamos usar a função soma como exemplo, mas vamos também declarar uma variável livre x, que é uma string. Então se a gente mandar imprimir no console o valor de __annotations__.
O resultado vai ser um dicionário K->V onde os valores de K são os nomes dos parâmetros e V, as respectivas anotações. Também existe uma chave reservada return para a anotação do tipo de retorno da função.
{‘a’: <class ‘int’>, ‘b’: <class ‘int’>, ‘return’: <class ‘int’>}{‘x’: <class ‘str’>}
O mesmo atributo também existe em classes, módulos, métodos, etc. Como estamos no mesmo arquivo, utilizamos somente __annotations__ para acessar o dicionários com os tipos anotados para pegar a anotação de x.
Ter a possibilidade de acessar essas informações em runtime abre uma infinidade de possibilidades! Um bom exemplo é o pydantic.
Vale lembrar que como os arquivos .pyi não são lidos pelo interpretador, o que é definido lá, não é acessível pelo atributo __annotations__
Módulo Typing
As PEPs 483,484 e 526 definem uma sintaxe para como podemos expressar tipos possíveis em python. O módulo typing é built-in a partir do Python 3.5 e traz um conjunto de tipos para serem utilizados em definições mais complexas.
Vamos ver alguns exemplos do que a gente pode expressar com esse módulo.
class Produto(NamedTuple)
Definimos uma classe Produto, que herda de typing.NamedTuple, um tipo que se comporta como collections.namedtuple, mas em uma versão que leva consigo as informações de tipo dos atributos.
preco: Union[Decimal, float]
preco: Union[Decimal, float] significa que o atributo preco é um tipo que pode tanto ser um Decimal quanto um float.
def total_carrinho(produtos: List[Produto]) -> Union[Decimal, float]:
Então definimos uma função que dado uma lista de produtos, retorna ou Decimal ou float.
Vamos então criar uma lista de produtos e chamar essa função.
Na linha 6, fazemos uma chamada a função total_carrinho passando a lista de produtos que nós criamos.
mypy typehints-exemplo13.pytypehints-exemplo13.py:19: error: Argument 1 to “total_carrinho” has incompatible type “Tuple[Produto, …]”; expected “List[Produto]”
Já na linha 7, passamos uma tupla, com esses mesmos produtos. Como definimos na assinatura da função que esperamos uma List[Produto], essa chamada é uma chamada inválida.
return sum(p.preco for p in produtos)
Considerando a implementação da função, sabemos que não precisamos ser tão específicos e que uma chamada a função é válida tanto para List[Product] quanto para Tuple[Produto, …] ou qualquer outra coisa que seja iterável. O módulo typing oferece mecanismos para definições genéricas como Iterable, Iterator, Mapping, AsyncIterable, etc…
def total_carrinho(produtos: Iterable[Produto])-> Union[Decimal, float]:
O módulo typing também permite que a gente adicione semântica aos nossos tipos utilizando NewType.
Essas definições podem ser úteis tanto para dar mais clareza ao significado de cada tipo, quanto para garantia de regras de negócio.
typehints-exemplo14.py:11: error: Argument 1 to “velocidade” has incompatible type “int”; expected “Metros”
Nesse caso, por exemplo, o mypy nos impede de passar a distância sem a unidade.
typehints-exemplo14.py:12: error: Argument 1 to "velocidade" has incompatible type "Pes"; expected "Metros"
Assim como nos impede de passar a unidade incorreta.
Esse exemplo pode fazer a gente pensar que é muito trabalho desnecessário e na maior parte das vezes, é mesmo. Mas no contexto correto, esse tipo de checagem pode evitar muitos problemas como o da Mars Climate Orbiter, um projeto de mais de 300 milhões de dólares que caiu em marte porque parte do código usou sistema imperial e outra parte o sistema internacional de medidas.
O módulo typing é muito completo e possui uma documentação (https://docs.python.org/3/library/typing.html) com exemplos de uso claros para todos os tipos mais comuns do dia-a-dia da maior parte das pessoas e é leitura que vale muito a pena.
Ecossistema
- Mypy é essencial para verificação estática de tipos e pode/deve ser utilizado em conjunto com seus testes unitários tanto ao longo do desenvolvimento quanto no seu CI
- pytype também é um analisador estático de tipos que atualmente dá suporte para Python ≥2.7 e ≤ 3.5. É mantido pela Google e consegue gerar algumas anotações de tipos para código sem anotações.
- PyAnnotate criado por Guido e mantido pela dropbox, coleta informações de tipo em tempo de execução, observando como funções são chamadas e o que elas retornam, criando um stub (.pyi) no final.
- Pycharm é uma IDE desenvolvida pela Jetbrains que dá total suporte as PEPs de type hints do seu código e nos módulos built-in para garantir auto complete, te informar de erros, etc.
- typeshed é uma coleção de stubs para módulos built-in e de terceiros. Também foi criado pelo Guido e é mantido pela comunidade python.
- Pydantic utilizada a sintaxe de anotações de tipo para validação de esquema de dados e gerenciamento de configuração
Experimente!
Anotações de tipo são sempre opcionais, então você pode escolher anotar algumas coisas e outras não. Isso significa que o argumento de que “vai ficar verboso d+” não é verdade. Você pode usar o bom senso para julgar o que de fato é necessário e traz benefícios pra você e pra qualidade do seu código.
Se performance é algo importante pra você, fica tranquilo porque type hints não alteram em nada a performance. Ou seja, você não vai ganhar nada e nem perder nada usando type hints e o interpretador não vai utilizar essa informação pra nada. Caso você não queria nem pagar o preço de manter o dicionário de anotações em runtime, tudo bem, é só utilizar os arquivos de stubs (.pyi).
Ao longo do post, espero ter conseguido mostrar que com pouco esforço dá pra começar a utilizar type hints no seu código e começar a colher os benefícios da tipagem estática, sem abrir mão da tipagem dinâmica. Espero ter sido útil para que você escreva código mais fácil de se entender e manter.
Agradecimentos especiais pro meu querido Elias Tandel que aceitou colaborar comigo produzindo conteúdo e dando essa palestra comigo na PythonRio e PythonBrasil