Para Marta, com todo o meu amor.

Prefácio

Eis um plano: se uma pessoa usar um recurso que você não entende, mate-a. É mais fácil que aprender algo novo, e em pouco tempo os únicos programadores sobreviventes usarão apenas um subconjunto minúsculo e fácil de entender do Python 0.9.6 <piscadela marota>.[1]

— Tim Peters
lendário colaborador do CPython e autor do Zen do Python

"Python é uma linguagem fácil de aprender e poderosa." Essas são as primeiras palavras do tutorial oficial do Python 3.10. Isso é verdade, mas há uma pegadinha: como a linguagem é fácil de entender e de começar a usar, muitos programadores praticantes do Python se contentam apenas com uma fração de seus poderosos recursos.

Uma programadora experiente pode começar a escrever código Python útil em questão de horas. Conforme as primeiras horas produtivas se tornam semanas e meses, muitos desenvolvedores continuam escrevendo código Python com um forte sotaque das linguagens que aprenderam antes. Mesmo se o Python for sua primeira linguagem, muitas vezes ela é apresentada nas universidades e em livros introdutórios evitando deliberadamente os recursos específicos da linguagem.

Como professor, ensinando Python para programadores experientes em outras linguagens, vejo outro problema: só sentimos falta daquilo que conhecemos. Vindo de outra linguagem, qualquer um é capaz de imaginar que o Python suporta expressões regulares, e procurar esse tema na documentação. Mas se você nunca viu desempacotamento de tuplas ou descritores de atributos, talvez nunca procure por eles, e pode acabar não usando esses recursos, só por que são novos para você.

Este livro não é uma referência exaustiva do Python de A a Z. A ênfase está em recursos da linguagem característicos do Python ou incomuns em outras linguagens populares. Vamos nos concentrar principalmente nos aspectos centrais da linguagem e pacotes essenciais da biblioteca padrão. Apenas alguns exemplos mostram o uso de pacotes externos como FastAPI, httpx, e Curio.

Para quem é esse livro

Escrevi este livro para programadores que já usam Python e desejem se tornar fluentes em Python 3 moderno. Testei os exemplos em Python 3.10—e a maioria também em Python 3.9 e 3.8. Os exemplos que exigem especificamente Python 3.10 estão indicados.

Caso não tenha certeza se conhece Python o suficiente para acompanhar o livro, revise o tutorial oficial do Python. Tópicos tratados no tutorial não serão explicados aqui, exceto por alguns recursos mais novos.

Para quem esse livro não é

Se está começando a estudar Python, poderá achar difícil acompanhar este livro. Mais ainda, se você o ler muito cedo em sua jornada pela linguagem, pode ficar com a impressão que todo script Python precisa se valer de métodos especiais e truques de metaprogramação. Abstração prematura é tão ruim quanto otimização prematura.

Para quem está aprendendo a programar, recomendo o livro Pense em Python de Allen Downey, disponível na Web.

Se já sabe programar e está aprendendo Python, o tutorial oficial do Python foi traduzido pela comunidade Python brasileira.

Como ler este livro

Recomendo que todos leiam o Capítulo 1. Após a leitura do capítulo "O modelo de dados do Python", o público principal deste livro não terá problema em pular diretamente para qualquer outra parte, mas muitas vezes assumo que você leu os capítulos precedentes de cada parte específica. Pense nas partes Parte I: Estruturas de dados até a Parte V: Metaprogramação como cinco livros dentro do livro.

Tentei enfatizar o uso de classes e módulos que já existem antes de discutir como criar seus próprios. Por exemplo, na Parte I: Estruturas de dados, o Capítulo 2 trata dos tipos de sequências que estão prontas para serem usadas, incluindo algumas que não recebem muita atenção, como collections.deque. Criar sequências definidas pelo usuário só é discutido na Parte III: Classes e protocolos, onde também vemos como usar as classes base abstratas (ABCs) de collections.abc. Criar suas próprias ABCs é discutido ainda mais tarde, na Parte III: Classes e protocolos, pois acredito na importância de estar confortável usando uma ABC antes de escrever uma.

Essa abordagem tem algumas vantagens. Primeiro, saber o que está disponivel para uso imediato pode evitar que você reinvente a roda. Usamos as classes de coleções existentes com mais frequência que implementamos nossas próprias coleções, e podemos prestar mais atenção ao uso avançado de ferramentas prontas adiando a discussão sobre a criação de novas ferramentas. Também é mais provável herdamos de ABCs existentes que criar uma nova ABC do zero. E, finalmente, acredito ser mais fácil entender as abstrações após vê-las em ação.

A desvantagem dessa estratégia são as referências a pontos futuros espalhadas pelo livro. Acredito que isso é mais fácil de tolerar agora que você sabe porque escolhi esse caminho.

Cinco livros em um

Aqui estão os principais tópicos de cada parte do livro:

Parte I: Estruturas de dados

O Capítulo 1 introduz o Modelo de Dados do Python e explica porque os métodos especiais (por exemplo, __repr__) são a chave do comportamento consistente de objetos de todos os tipos. Os métodos especiais são tratados em maiores detalhes ao longo do livro. Os capítulos restantes dessa parte cobrem o uso de tipos coleção: sequências, mapeamentos e conjuntos, bem como a separação de str e bytes--causa de muitas celebrações entre usuários do Python 3, e de muita dor para usuários de Python 2 obrigados a migrar suas bases de código. Também são abordadas as fábricas de classe de alto nível na biblioteca padrão: fábricas de tuplas nomeadas e o decorador @dataclass. Pattern matching ("Correspondência de padrões")—novidade no Python 3.10—é tratada em seções do Capítulo 2, do Capítulo 3 e do Capítulo 5, que discutem padrões para sequências, padrões para mapeamentos e padrões para instâncias de classes. O último capítulo na Parte I: Estruturas de dados versa sobre o ciclo de vida dos objetos: referências, mutabilidade e coleta de lixo (garbage collection).

Parte II: Funções como objetos

Aqui falamos sobre funções como objetos de primeira classe na linguagem: o significado disso, como isso afeta alguns padrões de projetos populares e como aproveitar as clausuras para implementar decoradores de função. Também são vistos aqui o conceito geral de invocáveis no Python, atributos de função, introspecção, anotação de parâmetros e a nova declaração nonlocal no Python 3. O Capítulo 8 introduz um novo tópico importante, dicas de tipo em assinaturas de função.

Parte III: Classes e protocolos

Agora o foco se volta para a criação "manual" de classes—em contraste com o uso de fábricas de classe vistas no Capítulo 5. Como qualquer linguagem orientada a objetos, Python tem seu conjunto particular de recursos que podem ou não estar presentes na linguagem na qual você ou eu aprendemos programação baseada em classes. Os capítulos explicam como criar suas próprias coleções, classes base abstratas (ABCs) e protocolos, bem como as formas de lidar com herança múltipla e como implementar a sobrecarga de operadores, quando fizer sentido.O Capítulo 15 continua a conversa sobre dicas de tipo.

Parte IV: Controle de fluxo

Nesta parte são tratados os mecanismos da linguagem e as bibliotecas que vão além do controle de fluxo tradicional, com condicionais, laços e sub-rotinas. Começamos com os geradores, visitamos a seguir os gerenciadores de contexto e as corrotinas, incluindo a desafiadora mas poderosa sintaxe do yield from. O Capítulo 18 inclui um exemplo significativo, usando pattern matching em um interpretador de linguagem simples mas funcional. O Capítulo 19 é novo, apresentando uma visão geral das alternativas para processamento concorrente e paralelo no Python, suas limitações, e como a arquitetura de software permite ao Python operar na escala da Web. Reescrevi o capítulo sobre programação assíncrona, para enfatizar os recursos centrais da linguagem—por exemplo, await, async def, async for e async with, e mostrar como eles são usados com asyncio e outros frameworks.

Parte V: Metaprogramação

Essa parte começa com uma revisão de técnicas para criação de classes com atributos criados dinamicamente para lidar com dados semi-estruturados, tal como conjuntos de dados JSON. A seguir tratamos do mecanismo familiar das propriedades, antes de mergulhar no funcionamento do acesso a atributos de objetos no Python em um nível mais baixo, usando descritores. A relação entre funções, métodos e descritores é explicada. Por toda a Parte V: Metaprogramação, a implementação passo a passo de uma biblioteca de validação de campos revela questões sutis, levando às ferramentas avançadas do capítulo final: decoradores de classes e metaclasses.

Abordagem "mão na massa"

Frequentemente usaremos o console interativo do Python para explorar a linguagem e as bibliotecas. Acho isso importante para enfatizar o poder dessa ferramenta de aprendizagem, especialmente para quem teve mais experiência com linguagens estáticas compiladas, que não oferecem um REPL.[2]

Um dos pacotes padrão de testagem do Python, o doctest, funciona simulando sessões de console e verificando se as expressões resultam nas resposta exibidas. Usei doctest para verificar a maior parte do código desse livro, incluindo as listagens do console. Não é necessário usar ou sequer saber da existência do doctest para acompanhar o texto: a principal característica dos doctests é que eles imitam transcrições de sessões interativas no console do Python, assim qualquer pessoa pode reproduzir as demonstrações facilmente.

Algumas vezes vou explicar o que queremos realizar mostrando um doctest antes do código que implementa a solução. Estabelecer precisamente o quê deve ser feito, antes de pensar sobre como fazer, ajuda a focalizar nosso esforço de codificação. Escrever os testes previamente é a base de desenvolvimento dirigido por testes (TDD, test-driven development), e também acho essa técnica útil para ensinar.

Também escrevi testes de unidade para alguns dos exemplos maiores usando pytest—que acho mais fácil de usar e mais poderoso que o módulo unittest da bibliotexa padrão. Você vai descobrir que pode verificar a maior parte do código do livro digitando python3 -m doctest example_script.py ou pytest no console de seu sistema operacional. A configuração do pytest.ini, na raiz do repositório do código de exemplo, assegura que doctests são coletados e executados pelo comando pytest.

Ponto de vista: minha perspectiva pessoal

Venho usando, ensinando e debatendo Python desde 1998, e gosto de estudar e comparar linguagens de programação, seus projetos e a teoria por trás delas. Ao final de alguns capítulos acrescentei uma seção "Ponto de vista", apresentando minha perspectiva sobre o Python e outras linguagens. Você pode pular essas partes, se não tiver interesse em tais discussões. Seu conteúdo é inteiramente opcional.

Conteúdo na na Web

Criei dois sites para este livro:

https://pythonfluente.com

O texto integral em português traduzido por Paulo Candido de Oliveira filho. É que você está lendo agora.

https://fluentpython.com

Contém textos em inglês para ambas edições do livro, além de um glossário. É um material que eu cortei para não ultrapassar o limite de 1.000 páginas.

O repositório de exemplos de código está no GitHub.

Convenções usadas no livro

As seguintes convenções tipográficas são usadas neste livro:

Itálico

Indica novos termos, URLs, endereços de email, nomes e extensões de arquivos [3].

Espaçamento constante

Usado para listagens de programas, bem como dentro de parágrafos para indicar elementos programáticos tais como nomes de variáveis ou funções, bancos de dados, tipos de dados, variáveis do ambiente, instruções e palavras-chave.

Observe que quando uma quebra de linha cai dentro de um termo de espaçamento constante, o hífen não é utilizado—​pois ele poderia ser erroneamente entendido como parte do termo.

Espaçamento constante em negrito

Mostra comandos oi outro texto que devem ser digitados literalmente pelo usuário.

Espaçamento constante em itálico

Mostra texto que deve ser substituído por valores fornecidos pelo usuário ou por valores determinados pelo contexto.

👉 Dica

Esse elemento é uma dica ou sugestão.

✒️ Nota

Este elemento é uma nota ou observação.

⚠️ Aviso

Este elemento é um aviso ou alerta.

Usando os exemplos de código

Todos os scripts e a maior parte dos trechos de código que aparecem no livro estão disponíveis no repositório de código do Python Fluente, no GitHub.

Se você tiver uma questão técnica ou algum problema para usar o código, por favor mande um email para .

Esse livro existe para ajudar você a fazer seu trabalho. Em geral, se o código exemplo está no livro, você pode usá-lo em seus programas e na sua documentação. Não é necessário nos contactar para pedir permissão, a menos que você queira reproduzir uma parte significativa do código. Por exemplo, escrever um programa usando vários pedaços de código deste livro não exige permissão. Vender ou distribuir exemplos de livros da O’Reilly exige permissão. Responder uma pergunta citando este livro e código exemplo daqui não exige permissão. Incorporar uma parte significativa do código exemplo do livro na documentação de seu produto exige permissão.

Gostamos, mas em geral não exigimos, atribuição da fonte. Isto normalmente inclui o título, o autor, a editora e o ISBN. Por exemplo, “Python Fluente, 2ª ed., de Luciano Ramalho. Copyright 2022 Luciano Ramalho, 978-1-492-05635-5.”

Se você achar que seu uso dos exemplo de código está fora daquilo previsto na lei ou das permissões dadas acima, por favor entre em contato com .

O’Reilly Online Learning

✒️ Nota

Por mais de 40 anos, O’Reilly Media tem oferecido treinamento, conhecimento e ideias sobre tecnologia e negócios, ajudando empresas serem bem sucedidas.

Nossa rede sem igual de especialistas e inovadores compartilha conhecimento e sabedoria através de livros, artigos e de nossa plataforma online de aprendizagem. A plataforma de aprendizagem online da O’Reilly’s oferece acesso sob demanda a treinamentos ao vivo, trilhas de aprendizagem profunda, ambientes interativos de programação e uma imensa coleção de textos e vídeos da O’Reilly e de mais de 200 outras editoras. Para maiores informações, visite http://oreilly.com.

Como entrar em contato

Por favor, envie comentários e perguntas sobre esse livro para o editor:

  • O’Reilly Media, Inc.
  • 1005 Gravenstein Highway North
  • Sebastopol, CA 95472
  • 800-998-9938 (in the United States or Canada)
  • 707-829-0515 (international or local)
  • 707-829-0104 (fax)

Há uma página online para este livro, com erratas, exemplos e informação adicional, que pode ser acessada aqui: https://fpy.li/p-4.

Envie email para , com comentários ou dúvidas técnicas sobre o livro.

Novidades e informações sobre nossos livros e cursos podem ser encontradas em http://oreilly.com.

Agradecimentos

Eu não esperava que atualizar um livro de Python cinco anos depois fosse um empreendimento de tal magnitude. Mas foi. Marta Mello, minha amada esposa, sempre esteve ao meu lado quando precisei. Meu querido amigo Leonardo Rochael me ajudou desde os primeiros rascunhos até a revisão técnica final, incluindo consolidar e revisar as sugestões dos outros revisores técnicos, de leitores e de editores. Honestamente, não sei se teria conseguido sem seu apoio, Marta e Leo. Muito, muito grato!

Jürgen Gmach, Caleb Hattingh, Jess Males, Leonardo Rochael e Miroslav Šedivý formaram a fantástica equipe de revisores técnicos da segunda edição. Eles revisaram o livro inteiro. Bill Behrman, Bruce Eckel, Renato Oliveira e Rodrigo Bernardo Pimentel revisaram capítulos específicos. Suas inúmeras sugestões, vindas de diferentes perspectivas, tornaram o livro muito melhor.

Muitos leitores me enviaram correções ou fizeram outras contribuições durante o pré-lançamento, incluindo: Guilherme Alves, Christiano Anderson, Konstantin Baikov, K. Alex Birch, Michael Boesl, Lucas Brunialti, Sergio Cortez, Gino Crecco, Chukwuerika Dike, Juan Esteras, Federico Fissore, Will Frey, Tim Gates, Alexander Hagerman, Chen Hanxiao, Sam Hyeong, Simon Ilincev, Parag Kalra, Tim King, David Kwast, Tina Lapine, Wanpeng Li, Guto Maia, Scott Martindale, Mark Meyer, Andy McFarland, Chad McIntire, Diego Rabatone Oliveira, Francesco Piccoli, Meredith Rawls, Michael Robinson, Federico Tula Rovaletti, Tushar Sadhwani, Arthur Constantino Scardua, Randal L. Schwartz, Avichai Sefati, Guannan Shen, William Simpson, Vivek Vashist, Jerry Zhang, Paul Zuradzki—e outros que pediram para não ter seus nomes mencionados, enviaram correções após a entrega da versão inicial ou foram omitidos porque eu não registri seus nomes—mil desculpas.

Durante minha pesquisa, aprendi sobre tipagem, concorrência, pattern matching e metaprogramação interagindo com Michael Albert, Pablo Aguilar, Kaleb Barrett, David Beazley, J. S. O. Bueno, Bruce Eckel, Martin Fowler, Ivan Levkivskyi, Alex Martelli, Peter Norvig, Sebastian Rittau, Guido van Rossum, Carol Willing e Jelle Zijlstra.

Os editores da O’Reilly Jeff Bleiel, Jill Leonard e Amelia Blevins fizeram sugestões que melhoraram o fluxo do texto em muitas partes. Jeff Bleiel e o editor de produção Danny Elfanbaum me apoiaram durante essa longa maratona.

As ideias e sugestões de cada um deles tornaram o livro melhor e mais preciso. Inevitavelmente, vão restar erros de minha própria criação no produto final. Me desculpo antecipadamente.

Por fim gostaria de estender meus sinceros agradecimento a meus colegas na Thoughtworks Brasil—e especialmente a meu mentor, Alexey Bôas—que apoiou este projeto de muitas formas até o fim.

Claro, todos os que me ajudaram a entender o Python e a escrever a primeira edição merecem agora agradecimentos em dobro. Não haveria segunda edição sem o sucesso da primeira.

Agradecimentos da primeira edição

O tabuleiro e as peças de xadrez Bauhaus, criadas por Josef Hartwig, são um exemplo de um excelente design: belo, simples e claro. Guido van Rossum, filho de um arquiteto e irmão de projetista de fonte magistral, criou um obra prima de design de linguagens. Adoro ensinar Python porque ele é belo, simples e claro.

Alex Martelli e Anna Ravenscroft foram os primeiros a verem o esquema desse livro, e me encorajaram a submetê-lo à O’Reilly para publicação. Seus livros me ensinaram o Python idiomático e são modelos de clareza, precisão e profundidade em escrita técnica. Os 6,200+ posts de Alex no Stack Overflow (EN) são uma fonte de boas ideias sobre a linguagem e seu uso apropriado.

Martelli e Ravenscroft foram também revisores técnicos deste livro, juntamente com Lennart Regebro e Leonardo Rochael. Todos nesta proeminente equipe de revisão técnica têm pelo menos 15 anos de experiência com Python, com muitas contribuições a projetos Python de alto impacto, em contato constante com outros desenvolvedores da comunidade. Em conjunto, eles me enviaram centenas de correções, sugestões, questões e opiniões, acrescentando imenso valor ao livro. Victor Stinner gentilmente revisou o Capítulo 21, trazendo seu conhecimento especializado, como um dos mantenedores do asyncio, para a equipe de revisão técnica. Foi um grande privilégio e um prazer colaborar com eles por estes muitos meses.

A editora Meghan Blanchette foi uma fantástica mentora, e me ajudou a melhorar a organização e o fluxo do texto do livro, me mostrando que partes estavam monótonas e evitando que eu atrasasse o projeto ainda mais. Brian MacDonald editou os capítulo na Parte II: Funções como objetos quando Meghan estava ausente. Adorei trabalhar com eles e com todos na O’Reilly, incluindo a equipe de suporte e desenvolvimento do Atlas (Atlas é a plataforma de publicação de livros da O’Reilly, que eu tive a felicidade de usar para escrever esse livro).

Mario Domenech Goulart deu sugestões numerosas e detalhadas, desde a primeira versão do livro. Também recebi muitas sugestões e comentários de Dave Pawson, Elias Dorneles, Leonardo Alexandre Ferreira Leite, Bruce Eckel, J. S. Bueno, Rafael Gonçalves, Alex Chiaranda, Guto Maia, Lucas Vido e Lucas Brunialti.

Ao longo dos anos, muitas pessoas me encorajaram a me tornar um autor, mas os mais persuasivos foram Rubens Prates, Aurelio Jargas, Rudá Moura e Rubens Altimari. Mauricio Bussab me abriu muitas portas, incluindo minha primeira experiência real na escrita de um livro. Renzo Nuccitelli apoiou este projeto de escrita o tempo todo, mesmo quando significou iniciar mais lentamente nossa parceria no python.pro.br.

A maravilhosa comunidade brasileira de Python é inteligente, generosa e divertida. O The Python Brasil group tem milhares de membros, e nossas conferências nacionais reúnem centenas de pessoas. Mas os mais influemtes em minha jornada como pythonista foram Leonardo Rochael, Adriano Petrich, Daniel Vainsencher, Rodrigo RBP Pimentel, Bruno Gola, Leonardo Santagada, Jean Ferri, Rodrigo Senra, J. S. Bueno, David Kwast, Luiz Irber, Osvaldo Santana, Fernando Masanori, Henrique Bastos, Gustavo Niemayer, Pedro Werneck, Gustavo Barbieri, Lalo Martins, Danilo Bellini, e Pedro Kroger.

Dorneles Tremea foi um grande amigo, (e incrivelmente generoso com seu tempo e seu conhecimento), um hacker fantástico e o mais inspirador líder da Associação Python Brasil. Ele nos deixou cedo demais.

Meus estudantes, ao longo desses anos, me ensinaram muito através de suas perguntas, ideias, feedbacks e soluções criativas para problemas. Érico Andrei e a Simples Consultoria tornaram possível que eu me concentrasse em ser um professor de Python pela primeira vez.

Martijn Faassen foi meu mentor de Grok e compartilhou ideias valiosas sobre o Python e os neandertais. Seu trabalho e o de Paul Everitt, Chris McDonough, Tres Seaver, Jim Fulton, Shane Hathaway, Lennart Regebro, Alan Runyan, Alexander Limi, Martijn Pieters, Godefroid Chapelle e outros, dos planetas Zope, Plone e Pyramid, foram decisivos para minha carreira. Graças ao Zope e a surfar na primeira onda da web, pude começar a ganhar a vida com Python em 1998. José Octavio Castro Neves foi meu sócio na primeira software house baseada em Python do Brasil.

Tenho gurus demais na comunidade Python como um todo para listar todos aqui, mas além daqueles já mencionados, eu tenho uma dívida com Steve Holden, Raymond Hettinger, A.M. Kuchling, David Beazley, Fredrik Lundh, Doug Hellmann, Nick Coghlan, Mark Pilgrim, Martijn Pieters, Bruce Eckel, Michele Simionato, Wesley Chun, Brandon Craig Rhodes, Philip Guo, Daniel Greenfeld, Audrey Roy e Brett Slatkin, por me ensinarem novas e melhores formas de ensinar Python.

A maior parte dessas páginas foi escrita no meu home office e em dois laboratórios: o CoffeeLab e o Garoa Hacker Clube. O CoffeeLab é o quartel general dos geeks cafeinados na Vila Madalena, em São Paulo, Brasil. O Garoa Hacker Clube é um espaço hacker aberto a todos: um laboratório comunitário onde qualquer um é livre para tentar novas ideias.

A comunidade Garoa me forneceu inspiração, infraestrutura e distração. Acho que Aleph gostaria desse liro.

Minha mãe, Maria Lucia, e meu pai, Jairo, sempre me apoiaram de todas as formas. Gostaria que ele estivesse aqui para ver esse livro; e fico feliz de poder compartilhá-lo com ela.

Minha esposa, Marta Mello, suportou 15 meses de um marido que estava sempre trabalhando, mas continuou me apoiando e me guiando através dos momentos mais críticos do projeto, quando temi que poderia abandonar a maratona.

Agradeço a todos vocês, por tudo.

Sobre esta tradução

Python Fluente, Segunda Edição é uma tradução direta de Fluent Python, Second Edition (O’Reilly, 2022). Não é uma obra derivada de Python Fluente (Novatec, 2015).

A presente tradução foi autorizada pela O’Reilly Media para distribuição nos termos da licença CC BY-NC-ND. Os arquivos-fonte em formato Asciidoc estão no repositório público https://github.com/pythonfluente/pythonfluente2e.

Enquanto publicávamos a tradução ao longo de 2023, muitas correções foram enviadas por leitores como issues (defeitos) ou pull requests (correções) no repositório. Agradeceço a todas as pessoas que colaboraram!

✒️ Nota

Se um link aparece entre colchetes [assim], ele não funciona porque é uma referência para uma seção não identificada. Precisamos corrigir.

Correções e sugestões de melhorias são bem vindas! Para contribuir, veja os issues no repositório https://github.com/pythonfluente/pythonfluente2e.

Contamos com sua colaboração. 🙏

Histórico das traduções

Escrevi a primeira e a segunda edições deste livro originalmente em inglês, para serem mais facilmente distribuídas no mercado internacional.

Cedi os direitos exclusivos para a O’Reilly Media, nos termos usuais de contratos com editoras famosas: elas ficam com a maior parte do lucro, o direito de publicar, e o direito de vender licenças para tradução em outros idiomas.

Até 2022, a primeira edição foi publicada nesses idiomas:

  1. inglês,

  2. português brasileiro,

  3. chinês simplificado (China),

  4. chinês tradicional (Taiwan),

  5. japonês,

  6. coreano,

  7. russo,

  8. francês,

  9. polonês.

A ótima tradução PT-BR foi produzida e publicada no Brasil pela Editora Novatec em 2015, sob licença da O’Reilly.

Entre 2020 e 2022, atualizei e expandi bastante o livro para a segunda edição. Sou muito grato à liderança da Thoughtworks Brasil por terem me apoiado enquanto passei a maior parte de 2020 e 2021 pesquisando, escrevendo, e revisando esta edição.

Quando entreguei o manuscrito para a O’Reilly, negociei um adendo contratual para liberar a tradução da segunda edição em PT-BR com uma licença livre, como uma contribuição para comunidade Python lusófona.

A O’Reilly autorizou que essa tradução fosse publicada sob a licença CC BY-NC-ND: Creative Commons — Atribuição-NãoComercial-SemDerivações 4.0 Internacional. Com essa mudança contratual, a Editora Novatec não teve interesse em traduzir e publicar a segunda edição.

Felizmente encontrei meu querido amigo Paulo Candido de Oliveira Filho (PC). Fomos colegas do ensino fundamental ao médio, e depois trabalhamos juntos como programadores em diferentes momentos e empresas. Hoje ele presta serviços editoriais, inclusive faz traduções com a excelente qualidade desta aqui.

Contratei PC para traduzir. Estou fazendo a revisão técnica, gerando os arquivos HTML com Asciidoctor e publicando em https://PythonFluente.com. Estamos trabalhando diretamente a partir do Fluent Python, Second Edition da O’Reilly, sem aproveitar a tradução da primeira edição, cujo copyright pertence à Novatec.

O copyright desta tradução pertence a mim.

Luciano Ramalho, São Paulo, 13 de março de 2023

Parte I: Estruturas de dados

1. O modelo de dados do Python

O senso estético de Guido para o design de linguagens é incrível. Conheci muitos projetistas capazes de criar linguagens teoricamente lindas, que ninguém jamais usaria. Mas Guido é uma daquelas raras pessoas capaz de criar uma linguagem só um pouco menos teoricamente linda que, por isso mesmo, é uma delícia para programar.

Jim Hugunin, criador do Jython, co-criador do AspectJ, arquiteto do DLR (Dynamic Language Runtime) do .Net. "Story of Jython" (_A História do Jython_) (EN), escrito como prefácio ao Jython Essentials (EN), de Samuele Pedroni e Noel Rappin (O'Reilly).

Uma das melhores qualidades do Python é sua consistência. Após trabalhar com Python por algum tempo é possível intuir, de uma maneira informada e correta, o funcionamento de recursos que você acabou de conhecer.

Entretanto, se você aprendeu outra linguagem orientada a objetos antes do Python, pode achar estranho usar len(collection) em vez de collection.len(). Essa aparente esquisitice é a ponta de um iceberg que, quando compreendido de forma apropriada, é a chave para tudo aquilo que chamamos de pythônico. O iceberg se chama o Modelo de Dados do Python, e é a API que usamos para fazer nossos objetos lidarem bem com os aspectos mais idiomáticos da linguagem.

É possível pensar no modelo de dados como uma descrição do Python na forma de um framework. Ele formaliza as interfaces dos elementos constituintes da própria linguagem, como sequências, funções, iteradores, corrotinas, classes, gerenciadores de contexto e assim por diante.

Quando usamos um framework, gastamos um bom tempo programando métodos que são chamados por ela. O mesmo acontece quando nos valemos do Modelo de Dados do Python para criar novas classes. O interpretador do Python invoca métodos especiais para realizar operações básicas sobre os objetos, muitas vezes acionados por uma sintaxe especial. Os nomes dos métodos especiais são sempre precedidos e seguidos de dois sublinhados. Por exemplo, a sintaxe obj[key] está amparada no método especial __getitem__. Para resolver my_collection[key], o interpretador chama my_collection.__getitem__(key).

Implementamos métodos especiais quando queremos que nossos objetos suportem e interajam com elementos fundamentais da linguagem, tais como:

  • Coleções

  • Acesso a atributos

  • Iteração (incluindo iteração assíncrona com async for)

  • Sobrecarga (overloading) de operadores

  • Invocação de funções e métodos

  • Representação e formatação de strings

  • Programação assíncrona usando await

  • Criação e destruição de objetos

  • Contextos gerenciados usando as instruções with ou async with

✒️ Nota
Mágica e o "dunder"

O termo método mágico é uma gíria usada para se referir aos métodos especiais, mas como falamos de um método específico, por exemplo __getitem__? Aprendi a dizer "dunder-getitem" com o autor e professor Steve Holden. "Dunder" é uma contração da frase em inglês "double underscore before and after" (sublinhado duplo antes e depois). Por isso os métodos especiais são também conhecidos como métodos dunder. O capítulo "Análise Léxica" de A Referência da Linguagem Python adverte que "Qualquer uso de nomes no formato __*__ que não siga explicitamente o uso documentado, em qualquer contexto, está sujeito a quebra sem aviso prévio."

1.1. Novidades nesse capítulo

Esse capítulo sofreu poucas alterações desde a primeira edição, pois é uma introdução ao Modelo de Dados do Python, que é muito estável. As mudanças mais significativas foram:

  • Métodos especiais que suportam programação assíncrona e outras novas funcionalidades foram acrescentados às tabelas em Seção 1.4.

  • A Figura 2, mostrando o uso de métodos especiais em Seção 1.3.4, incluindo a classe base abstrata collections.abc.Collection, introduzida no Python 3.6.

Além disso, aqui e por toda essa segunda edição, adotei a sintaxe f-string, introduzida no Python 3.6, que é mais legível e muitas vezes mais conveniente que as notações de formatação de strings mais antigas: o método str.format() e o operador %.

👉 Dica

Existe ainda uma razão para usar my_fmt.format(): quando a definição de my_fmt precisa vir de um lugar diferente daquele onde a operação de formatação precisa acontecer no código. Por exemplo, quando my_fmt tem múltiplas linhas e é melhor definida em uma constante, ou quando tem de vir de um arquivo de configuração ou de um banco de dados. Essas são necessidades reais, mas não acontecem com frequência.

1.2. Um baralho pythônico

O Exemplo 1 é simples, mas demonstra as possibilidades que se abrem com a implementação de apenas dois métodos especiais, __getitem__ e __len__.

Exemplo 1. Um baralho como uma sequência de cartas
import collections

Card = collections.namedtuple('Card', ['rank', 'suit'])

class FrenchDeck:
    ranks = [str(n) for n in range(2, 11)] + list('JQKA')
    suits = 'spades diamonds clubs hearts'.split()

    def __init__(self):
        self._cards = [Card(rank, suit) for suit in self.suits
                                        for rank in self.ranks]

    def __len__(self):
        return len(self._cards)

    def __getitem__(self, position):
        return self._cards[position]

A primeira coisa a se observar é o uso de collections.namedtuple para construir uma classe simples representando cartas individuais. Usamos namedtuple para criar classes de objetos que são apenas um agrupamento de atributos, sem métodos próprios, como um registro de banco de dados. Neste exemplo, a utilizamos para fornecer uma boa representação para as cartas em um baralho, como mostra a sessão no console:

>>> beer_card = Card('7', 'diamonds')
>>> beer_card
Card(rank='7', suit='diamonds')

Mas a parte central desse exemplo é a classe FrenchDeck. Ela é curta, mas poderosa. Primeiro, como qualquer coleção padrão do Python, uma instância de FrenchDeck responde à função len(), devolvendo o número de cartas naquele baralho:

>>> deck = FrenchDeck()
>>> len(deck)
52

Ler cartas específicas do baralho é fácil, graças ao método __getitem__. Por exemplo, a primeira e a última carta:

>>> deck[0]
Card(rank='2', suit='spades')
>>> deck[-1]
Card(rank='A', suit='hearts')

Deveríamos criar um método para obter uma carta aleatória? Não é necessário. O Python já tem uma função que devolve um item aleatório de uma sequência: random.choice. Podemos usá-la em uma instância de FrenchDeck:

>>> from random import choice
>>> choice(deck)
Card(rank='3', suit='hearts')
>>> choice(deck)
Card(rank='K', suit='spades')
>>> choice(deck)
Card(rank='2', suit='clubs')

Acabamos de ver duas vantagens de usar os métodos especiais no contexto do Modelo de Dados do Python.

  • Os usuários de suas classes não precisam memorizar nomes arbitrários de métodos para operações comuns ("Como descobrir o número de itens? Seria .size(), .length() ou alguma outra coisa?")

  • É mais fácil de aproveitar a rica biblioteca padrão do Python e evitar reinventar a roda, como no caso da função random.choice.

Mas fica melhor.

Como nosso __getitem__ usa o operador [] de self._cards, nosso baralho suporta fatiamento automaticamente. Podemos olhar as três primeiras cartas no topo de um novo baralho, e depois pegar apenas os ases, iniciando com o índice 12 e pulando 13 cartas por vez:

>>> deck[:3]
[Card(rank='2', suit='spades'), Card(rank='3', suit='spades'),
Card(rank='4', suit='spades')]
>>> deck[12::13]
[Card(rank='A', suit='spades'), Card(rank='A', suit='diamonds'),
Card(rank='A', suit='clubs'), Card(rank='A', suit='hearts')]

E como já temos o método especial __getitem__, nosso baralho é um objeto iterável, ou seja, pode ser percorrido em um laço for:

>>> for card in deck:  # doctest: +ELLIPSIS
...   print(card)
Card(rank='2', suit='spades')
Card(rank='3', suit='spades')
Card(rank='4', suit='spades')
...

Também podemos iterar sobre o baralho na ordem inversa:

>>> for card in reversed(deck):  # doctest: +ELLIPSIS
...   print(card)
Card(rank='A', suit='hearts')
Card(rank='K', suit='hearts')
Card(rank='Q', suit='hearts')
...
✒️ Nota
Reticências nos doctests

Sempre que possível, extraí as listagens do console do Python usadas neste livro com o doctest, para garantir a precisão. Quando a saída era grande demais, a parte omitida está marcada por reticências (…​), como na última linha do trecho de código anterior.

Nesse casos, usei a diretiva # doctest: +ELLIPSIS para fazer o doctest funcionar. Se você estiver tentando rodar esses exemplos no console iterativo, pode simplesmente omitir todos os comentários de doctest.

A iteração muitas vezes é implícita. Se uma coleção não fornecer um método __contains__, o operador in realiza uma busca sequencial. No nosso caso, in funciona com nossa classe FrenchDeck porque ela é iterável. Veja a seguir:

>>> Card('Q', 'hearts') in deck
True
>>> Card('7', 'beasts') in deck
False

E o ordenamento? Um sistema comum de ordenar cartas é por seu valor numérico (ases sendo os mais altos) e depois por naipe, na ordem espadas (o mais alto), copas, ouros e paus (o mais baixo). Aqui está uma função que ordena as cartas com essa regra, devolvendo 0 para o 2 de paus e 51 para o às de espadas.

suit_values = dict(spades=3, hearts=2, diamonds=1, clubs=0)

def spades_high(card):
    rank_value = FrenchDeck.ranks.index(card.rank)
    return rank_value * len(suit_values) + suit_values[card.suit]

Podemos agora listar nosso baralho em ordem crescente de usando spades_high como critério de ordenação:

>>> for card in sorted(deck, key=spades_high):  # doctest: +ELLIPSIS
...      print(card)
Card(rank='2', suit='clubs')
Card(rank='2', suit='diamonds')
Card(rank='2', suit='hearts')
... (46 cards omitted)
Card(rank='A', suit='diamonds')
Card(rank='A', suit='hearts')
Card(rank='A', suit='spades')

Apesar da FrenchDeck herdar implicitamente da classe object, a maior parte de sua funcionalidade não é herdada, vem do uso do modelo de dados e de composição. Ao implementar os métodos especiais __len__ e __getitem__, nosso FrenchDeck se comporta como uma sequência Python padrão, podendo assim se beneficiar de recursos centrais da linguagem (por exemplo, iteração e fatiamento), e da biblioteca padrão, como mostramos nos exemplos usando random.choice, reversed, e sorted. Graças à composição, as implementações de __len__ e __getitem__ podem delegar todo o trabalho para um objeto list, especificamente self._cards.

✒️ Nota
E como embaralhar as cartas?

Como foi implementado até aqui, um FrenchDeck não pode ser embaralhado, porque as cartas e suas posições não podem ser alteradas, exceto violando o encapsulamento e manipulando o atributo _cards diretamente. Em Capítulo 13 vamos corrigir isso acrescentando um método __setitem__ de uma linha. Você consegue imaginar como ele seria implementado?

1.3. Como os métodos especiais são utilizados

A primeira coisa para se saber sobre os métodos especiais é que eles foram feitos para serem chamados pelo interpretador Python, e não por você. Você não escreve my_object.__len__(). Escreve len(my_object) e, se my_object é uma instância de uma classe definida pelo usuário, então o Python chama o método __len__ que você implementou.

Mas o interpretador pega um atalho quando está lidando com um tipo embutido como list, str, bytearray, ou extensões como os arrays do NumPy. As coleções de tamanho variável do Python escritas em C incluem uma struct[4] chamada PyVarObject, com um campo ob_size que mantém o número de itens na coleção. Então, se my_object é uma instância de algum daqueles tipos embutidos, len(my_object) lê o valor do campo ob_size, e isso é muito mais rápido que chamar um método.

Na maior parte das vezes, a chamada a um método especial é implícita. Por exemplo, o comando for i in x: na verdade gera uma invocação de iter(x), que por sua vez pode chamar x.__iter__() se esse método estiver disponível, ou usar x.__getitem__(), como no exemplo do FrenchDeck.

Em condições normais, seu código não deveria conter muitas chamadas diretas a métodos especiais. A menos que você esteja fazendo muita metaprogramação, implementar métodos especiais deve ser muito mais frequente que invocá-los explicitamente. O único método especial que é chamado frequentemente pelo seu código é __init__, para invocar o método de inicialização da superclasse na implementação do seu próprio __init__.

Geralmente, se você precisa invocar um método especial, é melhor chamar a função embutida relacionada (por exemplo, len, iter, str, etc.). Essas funções chamam o método especial correspondente, mas também fornecem outros serviços e—para tipos embutidos—são mais rápidas que chamadas a métodos. Veja, por exemplo, Seção 17.3.1 no Capítulo 17.

Na próxima seção veremos alguns dos usos mais importantes dos métodos especiais:

  • Emular tipos numéricos

  • Representar objetos na forma de strings

  • Determinar o valor booleano de um objeto

  • Implementar coleções

1.3.1. Emulando tipos numéricos

Vários métodos especiais permitem que objetos criados pelo usuário respondam a operadores como +. Vamos tratar disso com mais detalhes no Capítulo 16. Aqui nosso objetivo é continuar ilustrando o uso dos métodos especiais, através de outro exemplo simples.

Vamos implementar uma classe para representar vetores bi-dimensionais—isto é, vetores euclidianos como aqueles usados em matemática e física (veja a Figura 1).

👉 Dica

O tipo embutido complex pode ser usado para representar vetores bi-dimensionais, mas nossa classe pode ser estendida para representar vetores n-dimensionais. Faremos isso em Capítulo 17.

vetores 2D
Figura 1. Exemplo de adição de vetores bi-dimensionais; Vector(2, 4) + Vector(2, 1) resulta em Vector(4, 5).

Vamos começar a projetar a API para essa classe escrevendo em uma sessão de console simulada, que depois podemos usar como um doctest. O trecho a seguir testa a adição de vetores ilustrada na Figura 1:

>>> v1 = Vector(2, 4)
>>> v2 = Vector(2, 1)
>>> v1 + v2
Vector(4, 5)

Observe como o operador + produz um novo objeto Vector(4, 5).

A função embutida abs devolve o valor absoluto de números inteiros e de ponto flutuante, e a magnitude de números complex. Então, por consistência, nossa API também usa abs para calcular a magnitude de um vetor:

>>> v = Vector(3, 4)
>>> abs(v)
5.0

Podemos também implementar o operador *, para realizar multiplicação escalar (isto é, multiplicar um vetor por um número para obter um novo vetor de mesma direção e magnitude multiplicada):

>>> v * 3
Vector(9, 12)
>>> abs(v * 3)
15.0

O Exemplo 2 é uma classe Vector que implementa as operações descritas acima, usando os métodos especiais __repr__, __abs__, __add__, e __mul__.

Exemplo 2. A simple two-dimensional vector class
"""
vector2d.py: a simplistic class demonstrating some special methods

It is simplistic for didactic reasons. It lacks proper error handling,
especially in the ``__add__`` and ``__mul__`` methods.

This example is greatly expanded later in the book.

Addition::

    >>> v1 = Vector(2, 4)
    >>> v2 = Vector(2, 1)
    >>> v1 + v2
    Vector(4, 5)

Absolute value::

    >>> v = Vector(3, 4)
    >>> abs(v)
    5.0

Scalar multiplication::

    >>> v * 3
    Vector(9, 12)
    >>> abs(v * 3)
    15.0

"""


import math

class Vector:

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

    def __repr__(self):
        return f'Vector({self.x!r}, {self.y!r})'

    def __abs__(self):
        return math.hypot(self.x, self.y)

    def __bool__(self):
        return bool(abs(self))

    def __add__(self, other):
        x = self.x + other.x
        y = self.y + other.y
        return Vector(x, y)

    def __mul__(self, scalar):
        return Vector(self.x * scalar, self.y * scalar)

Implementamos cinco métodos especiais, além do costumeiro __init__. Veja que nenhum deles é chamado diretamente dentro da classe ou durante seu uso normal, ilustrado pelos doctests. Como mencionado antes, o interpretador Python é o único usuário frequente da maioria dos métodos especiais.

O Exemplo 2 implementa dois operadores: + e *, para demonstrar o uso básico de __add__ e __mul__. No dois casos, os métodos criam e devolvem uma nova instância de Vector, e não modificam nenhum dos operandos: self e other são apenas lidos. Esse é o comportamento esperado de operadores infixos: criar novos objetos e não tocar em seus operandos. Vou falar muito mais sobre esse tópico no Capítulo 16.

⚠️ Aviso

Da forma como está implementado, o Exemplo 2 permite multiplicar um Vector por um número, mas não um número por um Vector, violando a propriedade comutativa da multiplicação escalar. Vamos consertar isso com o método especial __rmul__ no Capítulo 16.

Nas seções seguintes vamos discutir os outros métodos especiais em Vector.

1.3.2. Representação de strings

O método especial __repr__ é chamado pelo repr embutido para obter a representação do objeto como string, para inspeção. Sem um __repr__ personalizado, o console do Python mostraria uma instância de Vector como <Vector object at 0x10e100070>.

O console iterativo e o depurador chamam repr para exibir o resultado das expressões. O repr também é usado:

  • Pelo marcador posicional %r na formatação clássica com o operador %. Ex.: '%r' % my_obj

  • Pelo sinalizador de conversão !r na nova sintaxe de strings de formato usada nas f-strings e no método str.format. Ex: f'{my_obj!r}'

Note que a f-string no nosso __repr__ usa !r para obter a representação padrão dos atributos a serem exibidos. Isso é uma boa prática, pois durante uma seção de depuração podemos ver a diferença entre Vector(1, 2) e Vector('1', '2'). Este segundo objeto não funcionaria no contexto desse exemplo, porque nosso código espera que os argumentos do construtor sejam números, não str.

A string devolvida por __repr__ não deve ser ambígua e, se possível, deve corresponder ao código-fonte necessário para recriar o objeto representado. É por isso que nossa representação de Vector se parece com uma chamada ao construtor da classe, por exemplo Vector(3, 4).

Por outro lado, __str__ é chamado pelo método embutido str() e usado implicitamente pela função print. Ele deve devolver uma string apropriada para ser exibida aos usuários finais.

Algumas vezes a própria string devolvida por __repr__ é adequada para exibir ao usuário, e você não precisa programar __str__, porque a implementação herdada da classe object chama __repr__ como alternativa. O Exemplo 2 é um dos muitos exemplos neste livro com um __str__ personalizado.

👉 Dica

Programadores com experiência anterior em linguagens que contém o método toString tendem a implementar __str__ e não __repr__. Se você for implementar apenas um desses métodos especiais, escolha __repr__.

"What is the difference between __str__ and __repr__ in Python?" (Qual a diferença entre __str__ e __repr__ em Python?) (EN) é uma questão no Stack Overflow com excelentes contribuições dos pythonistas Alex Martelli e Martijn Pieters.

1.3.3. O valor booleano de um tipo personalizado

Apesar do Python ter um tipo bool, ele aceita qualquer objeto em um contexto booleano, tal como as expressões controlando uma instrução if ou while, ou como operandos de and, or e not. Para determinar se um valor x é verdadeiro ou falso, o Python invoca bool(x), que devolve True ou False.

Por default, instâncias de classes definidas pelo usuário são consideradas verdadeiras, a menos que __bool__ ou __len__ estejam implementadas. Basicamente, bool(x) chama x.__bool__() e usa o resultado. Se __bool__ não está implementado, o Python tenta invocar x.__len__(), e se esse último devolver zero, bool devolve False. Caso contrário, bool devolve True.

Nossa implementação de __bool__ é conceitualmente simples: ela devolve False se a magnitude do vetor for zero, caso contrário devolve True. Convertemos a magnitude para um valor booleano usando bool(abs(self)), porque espera-se que __bool__ devolva um booleano. Fora dos métodos __bool__, raramente é necessário chamar bool() explicitamente, porque qualquer objeto pode ser usado em um contexto booleano.

Observe que o método especial __bool__ permite que seus objetos sigam as regras de teste do valor verdade definidas no capítulo "Tipos Embutidos" da documentação da Biblioteca Padrão do Python.

✒️ Nota

Essa é uma implementação mais rápida de Vector.__bool__:

    def __bool__(self):
        return bool(self.x or self.y)

Isso é mais difícil de ler, mas evita a jornada através de abs, __abs__, os quadrados, e a raiz quadrada. A conversão explícita para bool é necessária porque __bool__ deve devolver um booleano, e or devolve um dos seus operandos no formato original: x or y resulta em x se x for verdadeiro, caso contrário resulta em y, qualquer que seja o valor deste último.

1.3.4. A API de Collection

A Figura 2 documenta as interfaces dos tipos de coleções essenciais na linguagem. Todas as classes no diagrama são ABCs—classes base abstratas (ABC é a sigla para a mesma expressão em inglês, Abstract Base Classes). As ABCs e o módulo collections.abc são tratados no Capítulo 13. O objetivo dessa pequena seção é dar uma visão panorâmica das interfaces das coleções mais importantes do Python, mostrando como elas são criadas a partir de métodos especiais.

Diagram de classes UML com todas as superclasses e algumas subclasses de `abc.Collection`
Figura 2. Diagrama de classes UML com os tipos fundamentais de coleções. Métodos como nome em itálico são abstratos, então precisam ser implementados pelas subclasses concretas, tais como list e dict. O restante dos métodos tem implementações concretas, então as subclasses podem herdá-los.

Cada uma das ABCs no topo da hierarquia tem um único método especial. A ABC Collection (introduzida no Python 3.6) unifica as três interfaces essenciais, que toda coleção deveria implementar:

  • Iterable, para suportar for, desempacotamento, e outras formas de iteração

  • Sized para suportar a função embutida len

  • Container para suportar o operador in

Na verdade, o Python não exige que classes concretas herdem de qualquer dessas ABCs. Qualquer classe que implemente __len__ satisfaz a interface Sized.

Três especializações muito importantes de Collection são:

  • Sequence, formalizando a interface de tipos embutidos como list e str

  • Mapping, implementado por dict, collections.defaultdict, etc.

  • Set, a interface dos tipos embutidos set e frozenset

Apenas Sequence é Reversible, porque sequências suportam o ordenamento arbitrário de seu conteúdo, ao contrário de mapeamentos(mappings) e conjuntos(sets).

✒️ Nota

Desde o Python 3.7, o tipo dict é oficialmente "ordenado", mas isso só quer dizer que a ordem de inserção das chaves é preservada. Você não pode rearranjar as chaves em um dict da forma que quiser.

Todos os métodos especiais na ABC Set implementam operadores infixos. Por exemplo, a & b calcula a intersecção entre os conjuntos a e b, e é implementada no método especial __and__.

Os próximos dois capítulos vão tratar em detalhes das sequências, mapeamentos e conjuntos da biblioteca padrão.

Agora vamos considerar as duas principais categorias dos métodos especiais definidos no Modelo de Dados do Python.

1.4. Visão geral dos métodos especiais

O capítulo "Modelo de Dados" de A Referência da Linguagem Python lista mais de 80 nomes de métodos especiais. Mais da metade deles implementa operadores aritméticos, bit a bit, ou de comparação. Para ter uma visão geral do que está disponível, veja tabelas a seguir.

A Tabela 1 mostra nomes de métodos especiais, excluindo aqueles usados para implementar operadores infixos ou funções matemáticas fundamentais como abs. A maioria desses métodos será tratado ao longo do livro, incluindo as adições mais recentes: métodos especiais assíncronos como __anext__ (acrescentado no Python 3.5), e o método de personalização de classes, __init_subclass__ (do Python 3.6).

Tabela 1. Nomes de métodos especiais (excluindo operadores)
Categoria Nomes dos métodos

Representação de string/bytes

__repr__ __str__ __format__ __bytes__ __fspath__

Conversão para número

__bool__ __complex__ __int__ __float__ __hash__ __index__

Emulação de coleções

__len__ __getitem__ __setitem__ __delitem__ __contains__

Iteração

__iter__ __aiter__ __next__ __anext__ __reversed__

Execução de chamável ou corrotina

__call__ __await__

Gerenciamento de contexto

__enter__ __exit__ __aexit__ __aenter__

Criação e destruição de instâncias

__new__ __init__ __del__

Gerenciamento de atributos

__getattr__ __getattribute__ __setattr__ __delattr__ __dir__

Descritores de atributos

__get__ __set__ __delete__ __set_name__

Classes base abstratas

__instancecheck__ __subclasscheck__

Metaprogramação de classes

__prepare__ __init_subclass__ __class_getitem__ __mro_entries__

Operadores infixos e numéricos são suportados pelos métodos especiais listados na Tabela 2. Aqui os nomes mais recentes são __matmul__, __rmatmul__, e __imatmul__, adicionados no Python 3.5 para suportar o uso de @ como um operador infixo de multiplicação de matrizes, como veremos no Capítulo 16.

Tabela 2. Nomes e símbolos de métodos especiais para operadores
Categoria do operador Símbolos Nomes de métodos

Unário numérico

- + abs()

__neg__ __pos__ __abs__

Comparação rica

< <= == != > >=

__lt__ __le__ __eq__ __ne__ __gt__ __ge__

Aritmético

+ - * / // % @ divmod() round() ** pow()

__add__ __sub__ __mul__ __truediv__ __floordiv__ __mod__ __matmul__ __divmod__ __round__ __pow__

Aritmética reversa

operadores aritméticos com operandos invertidos)

__radd__ __rsub__ __rmul__ __rtruediv__ __rfloordiv__ __rmod__ __rmatmul__ __rdivmod__ __rpow__

Atribuição aritmética aumentada

+= -= *= /= //= %= @= **=

__iadd__ __isub__ __imul__ __itruediv__ __ifloordiv__ __imod__ __imatmul__ __ipow__

Bit a bit

& | ^ << >> ~

__and__ __or__ __xor__ __lshift__ __rshift__ __invert__

Bit a bit reversa

(operadores bit a bit com os operandos invertidos)

__rand__ __ror__ __rxor__ __rlshift__ __rrshift__

Atribuição bit a bit aumentada

&= |= ^= <⇐ >>=

__iand__ __ior__ __ixor__ __ilshift__ __irshift__

✒️ Nota

O Python invoca um método especial de operador reverso no segundo argumento quando o método especial correspondente não pode ser usado no primeiro operando. Atribuições aumentadas são atalho combinando um operador infixo com uma atribuição de variável, por exemplo a += b.

O Capítulo 16 explica em detalhes os operadores reversos e a atribuição aumentada.

1.5. Por que len não é um método?

Em 2013, fiz essa pergunta a Raymond Hettinger, um dos desenvolvedores principais do Python, e o núcleo de sua resposta era uma citação do "The Zen of Python" (O Zen do Python) (EN): "a praticidade vence a pureza." Em Seção 1.3, descrevi como len(x) roda muito rápido quando x é uma instância de um tipo embutido. Nenhum método é chamado para os objetos embutidos do CPython: o tamanho é simplesmente lido de um campo em uma struct C. Obter o número de itens em uma coleção é uma operação comum, e precisa funcionar de forma eficiente para tipos tão básicos e diferentes como str, list, memoryview, e assim por diante.

Em outras palavras, len não é chamado como um método porque recebe um tratamento especial como parte do Modelo de Dados do Python, da mesma forma que abs. Mas graças ao método especial __len__, também é possível fazer len funcionar com nossos objetos personalizados. Isso é um compromisso justo entre a necessidade de objetos embutidos eficientes e a consistência da linguagem. Também de "O Zen do Python": "Casos especiais não são especiais o bastante para quebrar as regras."

✒️ Nota

Pensar em abs e len como operadores unários nos deixa mais inclinados a perdoar seus aspectos funcionais, contrários à sintaxe de chamada de método que esperaríamos em uma linguagem orientada a objetos. De fato, a linguagem ABC—uma ancestral direta do Python, que antecipou muitas das funcionalidades desta última—tinha o operador #, que era o equivalente de len (se escrevia #s). Quando usado como operador infixo, x#s contava as ocorrências de x em s, que em Python obtemos com s.count(x), para qualquer sequência s.

1.6. Resumo do capítulo

Ao implementar métodos especiais, seus objetos podem se comportar como tipos embutidos, permitindo o estilo de programação expressivo que a comunidade considera pythônico.

Uma exigência básica para um objeto Python é fornecer strings representando a si mesmo que possam ser usadas, uma para depuração e registro (log), outra para apresentar aos usuários finais. É para isso que os métodos especiais __repr__ e __str__ existem no modelo de dados.

Emular sequências, como mostrado com o exemplo do FrenchDeck, é um dos usos mais comuns dos métodos especiais. Por exemplo, bibliotecas de banco de dados frequentemente devolvem resultados de consultas na forma de coleções similares a sequências. Tirar o máximo proveito dos tipos de sequências existentes é o assunto do Capítulo 2. Como implementar suas próprias sequências será visto na Capítulo 12, onde criaremos uma extensão multidimensional da classe Vector.

Graças à sobrecarga de operadores, o Python oferece uma rica seleção de tipos numéricos, desde os tipos embutidos até decimal.Decimal e fractions.Fraction, todos eles suportando operadores aritméticos infixos. As bibliotecas de ciência de dados NumPy suportam operadores infixos com matrizes e tensores. A implementação de operadores—incluindo operadores reversos e atribuição aumentada—será vista no Capítulo 16, usando melhorias do exemplo Vector.

Também veremos o uso e a implementação da maioria dos outros métodos especiais do Modelo de Dados do Python ao longo deste livro.

1.7. Para saber mais

O capítulo "Modelo de Dados" em A Referência da Linguagem Python é a fonte canônica para o assunto desse capítulo e de uma boa parte deste livro.

Python in a Nutshell, 3rd ed. (EN), de Alex Martelli, Anna Ravenscroft, e Steve Holden (O’Reilly) tem uma excelente cobertura do modelo de dados. Sua descrição da mecânica de acesso a atributos é a mais competente que já vi, perdendo apenas para o próprio código-fonte em C do CPython. Martelli também é um contribuidor prolífico do Stack Overflow, com mais de 6200 respostas publicadas. Veja seu perfil de usuário no Stack Overflow.

David Beazley tem dois livros tratando do modelo de dados em detalhes, no contexto do Python 3: Python Essential Reference (EN), 4th ed. (Addison-Wesley), e Python Cookbook, 3rd ed. (EN) (O’Reilly), com a co-autoria de Brian K. Jones.

O The Art of the Metaobject Protocol (EN) (MIT Press) de Gregor Kiczales, Jim des Rivieres, e Daniel G. Bobrow explica o conceito de um protocolo de metaobjetos, do qual o Modelo de Dados do Python é um exemplo.

Ponto de Vista

Modelo de dados ou modelo de objetos?

Aquilo que a documentação do Python chama de "Modelo de Dados do Python", a maioria dos autores diria que é o "Modelo de objetos do Python"

O Python in a Nutshell, 3rd ed. de Martelli, Ravenscroft, e Holden, e o Python Essential Reference, 4th ed., de David Beazley são os melhores livros sobre o Modelo de Dados do Python, mas se referem a ele como o "modelo de objetos." Na Wikipedia, a primeira definição de "modelo de objetos" (EN) é: "as propriedades dos objetos em geral em uma linguagem de programação de computadores específica." É disso que o Modelo de Dados do Python trata. Neste livro, usarei "modelo de dados" porque a documentação prefere este termo ao se referir ao modelo de objetos do Python, e porque esse é o título do capítulo de A Referência da Linguagem Python mais relevante para nossas discussões.

Métodos de "trouxas"

O The Original Hacker’s Dictionary (Dicionário Hacker Original) (EN) define mágica como "algo ainda não explicado ou muito complicado para explicar" ou "uma funcionalidade, em geral não divulgada, que permite fazer algo que de outra forma seria impossível."

A comunidade Ruby chama o equivalente dos métodos especiais naquela linguagem de métodos mágicos. Muitos integrantes da comunidade Python também adotam esse termo. Eu acredito que os métodos especiais são o contrário de mágica. O Python e o Ruby oferecem a seus usuários um rico protocolo de metaobjetos integralmente documentado, permitindo que "trouxas" como você e eu possam emular muitas das funcionalidades disponíveis para os desenvolvedores principais que escrevem os interpretadores daquelas linguagens.

Por outro lado, pense no Go. Alguns objetos naquela linguagem tem funcionalidades que são mágicas, no sentido de não poderem ser emuladas em nossos próprios objetos definidos pelo usuário. Por exemplo, os arrays, strings e mapas do Go suportam o uso de colchetes para acesso a um item, na forma a[i]. Mas não há como fazer a notação [] funcionar com um novo tipo de coleção definida por você. Pior ainda, o Go não tem o conceito de uma interface iterável ou um objeto iterador ao nível do usuário, daí sua sintaxe para for/range estar limitada a suportar cinco tipos "mágicos" embutidos, incluindo arrays, strings e mapas.

Talvez, no futuro, os projetistas do Go melhorem seu protocolo de metaobjetos. Em 2021, ele ainda é muito mais limitado do que Python, Ruby, e JavaScript oferecem.

Metaobjetos

The Art of the Metaobject Protocol (AMOP) (A Arte do protocolo de metaobjetos) é meu título favorito entre livros de computação. Mas o menciono aqui porque o termo protocolo de metaobjetos é útil para pensar sobre o Modelo de Dados do Python, e sobre recursos similares em outras linguagens. A parte metaobjetos se refere aos objetos que são os componentes essenciais da própria linguagem. Nesse contexto, protocolo é sinônimo de interface. Assim, um protocolo de metaobjetos é um sinônimo chique para modelo de objetos: uma API para os elementos fundamentais da linguagem.

Um protocolo de metaobjetos rico permite estender a linguagem para suportar novos paradigmas de programação. Gregor Kiczales, o primeiro autor do AMOP, mais tarde se tornou um pioneiro da programação orientada a aspecto, e o autor inicial do AspectJ, uma extensão de Java implementando aquele paradigma. A programação orientada a aspecto é muito mais fácil de implementar em uma linguagem dinâmica como Python, e alguns frameworks fazem exatamente isso. O exemplo mais importante é a zope.interface (EN), parte do framework sobre a qual o sistema de gerenciamento de conteúdo Plone é construído.

2. Uma coleção de sequências

Como vocês podem ter notado, várias das operações mencionadas funcionam da mesma forma com textos, listas e tabelas. Coletivamente, textos, listas e tabelas são chamados de 'trens' (trains). [...] O comando `FOR` também funciona, de forma geral, em trens.

Leo Geurts, Lambert Meertens, e Steven Pembertonm, ABC Programmer's Handbook, p. 8. (Bosko Books)

Antes de criar o Python, Guido foi um dos desenvolvedores da linguagem ABC—um projeto de pesquisa de 10 anos para criar um ambiente de programação para iniciantes. A ABC introduziu várias ideias que hoje consideramos "pythônicas": operações genéricas com diferentes tipos de sequências, tipos tupla e mapeamento embutidos, estrutura [do código] por indentação, tipagem forte sem declaração de variáveis, entre outras. O Python não é assim tão amigável por acidente.

O Python herdou da ABC o tratamento uniforme de sequências. Strings, listas, sequências de bytes, arrays, elementos XML e resultados vindos de bancos de dados compartilham um rico conjunto de operações comuns, incluindo iteração, fatiamento, ordenação e concatenação.

Entender a variedade de sequências disponíveis no Python evita que reinventemos a roda, e sua interface comum nos inspira a criar APIs que suportem e se aproveitem de forma apropriada dos tipos de sequências existentes e futuras.

A maior parte da discussão deste capítulo se aplica às sequências em geral, desde a conhecida list até os tipos str e bytes, adicionados no Python 3. Tópicos específicos sobre listas, tuplas, arrays e filas também foram incluídos, mas os detalhes sobre strings Unicode e sequências de bytes são tratados no Capítulo 4. Além disso, a ideia aqui é falar sobre os tipos de sequências prontas para usar. A criação de novos tipos de sequência é o tema do Capítulo 12.

Os principais tópicos cobertos neste capítulo são:

  • Compreensão de listas e os fundamentos das expressões geradoras.

  • O uso de tuplas como registros versus o uso de tuplas como listas imutáveis

  • Desempacotamento de sequências e padrões de sequências.

  • Lendo de fatias e escrevendo em fatias

  • Tipos especializados de sequências, tais como arrays e filas

2.1. Novidades neste capítulo

A atualização mais importante desse capítulo é a Seção 2.6, primeira abordagem das instruções match/case introduzidas no Python 3.10.

As outras mudanças não são atualizações e sim aperfeiçoamentos da primeira edição:

  • Um novo diagrama e uma nova descrição do funcionamento interno das sequências, contrastando contêineres e sequências planas.

  • Uma comparação entre list e tuple quanto ao desempenho e ao armazenamento.

  • Ressalvas sobre tuplas com elementos mutáveis, e como detectá-los se necessário.

Movi a discussão sobre tuplas nomeadas para a Seção 5.3 no Capítulo 5, onde elas são comparadas com typing.NamedTuple e @dataclass.

✒️ Nota

Para abrir espaço para conteúdo novo mantendo o número de páginas dentro do razoável, a seção "Managing Ordered Sequences with Bisect" ("Gerenciando sequências ordenadas com bisect") da primeira edição agora é um artigo (EN) no site que complementa o livro, fluentpython.com.

2.2. Uma visão geral das sequências embutidas

A biblioteca padrão oferece uma boa seleção de tipos de sequências, implementadas em C:

Sequências contêiner

Podem armazenar itens de tipos diferentes, incluindo contêineres aninhados e objetos de qualquer tipo. Alguns exemplos: list, tuple, e collections.deque.

Sequências planas

Armazenam itens de algum tipo simples, mas não outras coleções ou referências a objetos. Alguns exemplos: str, bytes, e array.array.

Uma sequência contêiner mantém referências para os objetos que contém, que podem ser de qualquer tipo, enquanto uma sequência plana armazena o valor de seu conteúdo em seu próprio espaço de memória, e não como objetos Python distintos. Veja a Figura 1.

Diagrama de memória simplificado de um `array` e de uma `tuple`
Figura 1. Diagramas de memória simplificados mostrando uma tuple e um array, cada uma com três itens. As células em cinza representam o cabeçalho de cada objeto Python na memória. A tuple tem um array de referências para seus itens. Cada item é um objeto Python separado, possivelmente contendo também referências aninhadas a outros objetos Python, como aquela lista de dois itens. Por outro lado, um array Python é um único objeto, contendo um array da linguagem C com três números de ponto flutuante`.

Dessa forma, sequências planas são mais compactas, mas estão limitadas a manter valores primitivos como bytes e números inteiros e de ponto flutuante.

✒️ Nota

Todo objeto Python na memória tem um cabeçalho com metadados. O objeto Python mais simples, um float, tem um campo de valor e dois campos de metadados:

  • ob_refcnt: a contagem de referências ao objeto

  • ob_type: um ponteiro para o tipo do objeto

  • ob_fval: um double de C mantendo o valor do float

No Python 64-bits, cada um desses campos ocupa 8 bytes. Por isso um array de números de ponto flutuante é muito mais compacto que uma tupla de números de ponto flutuante: o array é um único objeto contendo apenas o valor dos números, enquanto a tupla consiste de vários objetos—a própria tupla e cada objeto float que ela contém.

Outra forma de agrupar as sequências é por mutabilidade:

Sequências mutáveis

Por exemplo, list, bytearray, array.array e collections.deque.

Sequências imutáveis

Por exemplo, tuple, str, e bytes.

A Figura 2 ajuda a visualizar como as sequências mutáveis herdam todos os métodos das sequências imutáveis e implementam vários métodos adicionais. Os tipos embutidos concretos de sequências na verdade não são subclasses das classes base abstratas (ABCs) Sequence e MutableSequence, mas sim subclasses virtuais registradas com aquelas ABCs—como veremos no Capítulo 13. Por serem subclasses virtuais, tuple e list passam nesses testes:

>>> from collections import abc
>>> issubclass(tuple, abc.Sequence)
True
>>> issubclass(list, abc.MutableSequence)
True
Diagrama de classe UML para `Sequence` e `MutableSequence`
Figura 2. Diagrama de classe UML simplificado para algumas classes de collections.abc (as superclasses estão à esquerda; as setas de herança apontam das subclasses para as superclasses; nomes em itálico indicam classes e métodos abstratos).

Lembre-se dessas características básicas: mutável versus imutável; contêiner versus plana. Elas ajudam a extrapolar o que se sabe sobre um tipo de sequência para outros tipos.

O tipo mais fundamental de sequência é a lista: um contêiner mutável. Espero que você já esteja muito familiarizada com listas, então vamos passar diretamente para a compreensão de listas, uma forma potente de criar listas que algumas vezes é subutilizada por sua sintaxe parecer, a princípio, estranha. Dominar as compreensões de listas abre as portas para expressões geradoras que—entre outros usos—podem produzir elementos para preencher sequências de qualquer tipo. Ambas são temas da próxima seção.

2.3. Compreensões de listas e expressões geradoras

Um jeito rápido de criar uma sequência é usando uma compreensão de lista (se o alvo é uma list) ou uma expressão geradora (para outros tipos de sequências). Se você não usa essas formas sintáticas diariamente, aposto que está perdendo oportunidades de escrever código mais legível e, muitas vezes, mais rápido também.

Se você duvida de minha alegação, sobre essas formas serem "mais legíveis", continue lendo. Vou tentar convencer você.

👉 Dica

Por comodidade, muitos programadores Python se referem a compreensões de listas como listcomps, e a expressões geradoras como genexps. Usarei também esses dois termos.

2.3.1. Compreensões de lista e legibilidade

Aqui está um teste: qual dos dois você acha mais fácil de ler, o Exemplo 1 ou o Exemplo 2?

Exemplo 1. Cria uma lista de pontos de código Unicode a partir de uma string
>>> symbols = '$¢£¥€¤'
>>> codes = []
>>> for symbol in symbols:
...     codes.append(ord(symbol))
...
>>> codes
[36, 162, 163, 165, 8364, 164]
Exemplo 2. Cria uma lista de pontos de código Unicode a partir de uma string, usando uma listcomp
>>> symbols = '$¢£¥€¤'
>>> codes = [ord(symbol) for symbol in symbols]
>>> codes
[36, 162, 163, 165, 8364, 164]

Qualquer um que saiba um pouco de Python consegue ler o Exemplo 1. Entretanto, após aprender sobre as listcomps, acho o Exemplo 2 mais legível, porque deixa sua intenção explícita.

Um loop for pode ser usado para muitas coisas diferentes: percorrer uma sequência para contar ou encontrar itens, computar valores agregados (somas, médias), ou inúmeras outras tarefas. O código no Exemplo 1 está criando uma lista. Uma listcomp, por outro lado, é mais clara. Seu objetivo é sempre criar uma nova lista.

Naturalmente, é possível abusar das compreensões de lista para escrever código verdadeiramente incompreensível. Já vi código Python usando listcomps apenas para repetir um bloco de código por seus efeitos colaterais. Se você não vai fazer alguma coisa com a lista criada, não deveria usar essa sintaxe. Além disso, tente manter o código curto. Se uma compreensão ocupa mais de duas linhas, provavelmente seria melhor quebrá-la ou reescrevê-la como um bom e velho loop for. Avalie qual o melhor caminho: em Python, como em português, não existem regras absolutas para se escrever bem.

👉 Dica
Dica de sintaxe

No código Python, quebras de linha são ignoradas dentro de pares de [], {}, ou (). Então você pode usar múltiplas linhas para criar listas, listcomps, tuplas, dicionários, etc., sem necessidade de usar o marcador de continuação de linha \, que não funciona se após o \ você acidentalmente digitar um espaço. Outro detalhe, quando aqueles pares de delimitadores são usados para definir um literal com uma série de itens separados por vírgulas, uma vírgula solta no final será ignorada. Daí, por exemplo, quando se codifica uma lista a partir de um literal com múltiplas linhas, é de bom tom deixar uma vírgula após o último item. Isso torna um pouco mais fácil ao próximo programador acrescentar mais um item àquela lista, e reduz o ruído quando se lê os diffs.

Escopo local dentro de compreensões e expressões geradoras

No Python 3, compreensões de lista, expressões geradoras, e suas irmãs, as compreensões de set e de dict, tem um escopo local para manter as variáveis criadas na condição for. Entretanto, variáveis atribuídas com o "operador morsa" ("Walrus operator"), :=, continuam acessíveis após aquelas compreensões ou expressões retornarem—diferente das variáveis locais em uma função. A PEP 572—Assignment Expressions (EN) define o escopo do alvo de um := como a função à qual ele pertence, exceto se houver uma declaração global ou nonlocal para aquele alvo.[5]

>>> x = 'ABC'
>>> codes = [ord(x) for x in x]
>>> x  (1)
'ABC'
>>> codes
[65, 66, 67]
>>> codes = [last := ord(c) for c in x]
>>> last  (2)
67
>>> c  (3)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
NameError: name 'c' is not defined
  1. x não foi sobrescrito: continua vinculado a 'ABC'.

  2. last permanece.

  3. c desapareceu; ele só existiu dentro da listcomp.

Compreensões de lista criam listas a partir de sequências ou de qualquer outro tipo iterável, filtrando e transformando os itens. As funções embutidas filter e map podem fazer o mesmo, mas perde-se alguma legibilidade, como veremos a seguir.

2.3.2. Listcomps versus map e filter

Listcomps fazem tudo que as funções map e filter fazem, sem os malabarismos exigidos pela funcionalidade limitada do lambda do Python.

Considere o Exemplo 3.

Exemplo 3. A mesma lista, criada por uma listcomp e por uma composição de map/filter
>>> symbols = '$¢£¥€¤'
>>> beyond_ascii = [ord(s) for s in symbols if ord(s) > 127]
>>> beyond_ascii
[162, 163, 165, 8364, 164]
>>> beyond_ascii = list(filter(lambda c: c > 127, map(ord, symbols)))
>>> beyond_ascii
[162, 163, 165, 8364, 164]

Eu acreditava que map e filter eram mais rápidas que as listcomps equivalentes, mas Alex Martelli assinalou que não é o caso—pelo menos não nos exemplos acima. O script listcomp_speed.py no repositório de código do Python Fluente é um teste de velocidade simples, comparando listcomp com filter/map.

Vou falar mais sobre map e filter no Capítulo 7. Vamos agora ver o uso de listcomps para computar produtos cartesianos: uma lista contendo tuplas criadas a partir de todos os itens de duas ou mais listas.

2.3.3. Produtos cartesianos

Listcomps podem criar listas a partir do produto cartesiano de dois ou mais iteráveis. Os itens resultantes de um produto cartesiano são tuplas criadas com os itens de cada iterável na entrada, e a lista resultante tem o tamanho igual ao produto da multiplicação dos tamanhos dos iteráveis usados. Veja a Figura 3.

Diagrama do produto cartesiano
Figura 3. O produto cartesiano de 3 valores de cartas e 4 naipes é uma sequência de 12 parâmetros.

Por exemplo, imagine que você precisa produzir uma lista de camisetas disponíveis em duas cores e três tamanhos. O Exemplo 4 mostra como produzir tal lista usando uma listcomp. O resultado tem seis itens.

Exemplo 4. Produto cartesiano usando uma compreensão de lista
>>> colors = ['black', 'white']
>>> sizes = ['S', 'M', 'L']
>>> tshirts = [(color, size) for color in colors for size in sizes]  (1)
>>> tshirts
[('black', 'S'), ('black', 'M'), ('black', 'L'), ('white', 'S'),
 ('white', 'M'), ('white', 'L')]
>>> for color in colors:  (2)
...     for size in sizes:
...         print((color, size))
...
('black', 'S')
('black', 'M')
('black', 'L')
('white', 'S')
('white', 'M')
('white', 'L')
>>> tshirts = [(color, size) for size in sizes      (3)
...                          for color in colors]
>>> tshirts
[('black', 'S'), ('white', 'S'), ('black', 'M'), ('white', 'M'),
 ('black', 'L'), ('white', 'L')]
  1. Isso gera uma lista de tuplas ordenadas por cor, depois por tamanho.

  2. Observe que a lista resultante é ordenada como se os loops for estivessem aninhados na mesma ordem que eles aparecem na listcomp.

  3. Para ter os itens ordenados por tamanho e então por cor, apenas rearranje as cláusulas for; adicionar uma quebra de linha listcomp torna mais fácil ver como o resultado será ordenado.

No Exemplo 1 (em Capítulo 1), usei a seguinte expressão para inicializar um baralho de cartas com uma lista contendo 52 cartas de todos os 13 valores possíveis para cada um dos quatro naipes, ordenada por naipe e então por valor:

        self._cards = [Card(rank, suit) for suit in self.suits
                                        for rank in self.ranks]

Listcomps são mágicos de um só truque: elas criam listas. Para gerar dados para outros tipos de sequências, uma genexp é o caminho. A próxima seção é uma pequena incursão às genexps, no contexto de criação de sequências que não são listas.

2.3.4. Expressões geradoras

Para inicializar tuplas, arrays e outros tipos de sequências, você também poderia começar de uma listcomp, mas uma genexp (expressão geradora) economiza memória, pois ela produz itens um de cada vez usando o protocolo iterador, em vez de criar uma lista inteira apenas para alimentar outro construtor.

As genexps usam a mesma sintaxe das listcomps, mas são delimitadas por parênteses em vez de colchetes.

O Exemplo 5 demonstra o uso básico de genexps para criar uma tupla e um array.

Exemplo 5. Inicializando uma tupla e um array a partir de uma expressão geradora
>>> symbols = '$¢£¥€¤'
>>> tuple(ord(symbol) for symbol in symbols)  (1)
(36, 162, 163, 165, 8364, 164)
>>> import array
>>> array.array('I', (ord(symbol) for symbol in symbols))  (2)
array('I', [36, 162, 163, 165, 8364, 164])
  1. Se a expressão geradora é o único argumento em uma chamada de função, não há necessidade de duplicar os parênteses circundantes.

  2. O construtor de array espera dois argumentos, então os parênteses em torno da expressão geradora são obrigatórios. O primeiro argumento do construtor de array define o tipo de armazenamento usado para os números no array, como veremos na Seção 2.10.1.

O Exemplo 6 usa uma genexp com um produto cartesiano para gerar uma relação de camisetas de duas cores em três tamanhos. Diferente do Exemplo 4, aquela lista de camisetas com seis itens nunca é criada na memória: a expressão geradora alimenta o loop for produzindo um item por vez. Se as duas listas usadas no produto cartesiano tivessem mil itens cada uma, usar uma função geradora evitaria o custo de construir uma lista com um milhão de itens apenas para passar ao loop for.

Exemplo 6. Produto cartesiano em uma expressão geradora
>>> colors = ['black', 'white']
>>> sizes = ['S', 'M', 'L']
>>> for tshirt in (f'{c} {s}' for c in colors for s in sizes):  (1)
...     print(tshirt)
...
black S
black M
black L
white S
white M
white L
  1. A expressão geradora produz um item por vez; uma lista com todas as seis variações de camisetas nunca aparece neste exemplo.

✒️ Nota

O Capítulo 17 explica em detalhes o funcionamento de geradoras. A ideia aqui é apenas mostrar o uso de expressões geradores para inicializar sequências diferentes de listas, ou produzir uma saída que não precise ser mantida na memória.

Vamos agora estudar outra sequência fundamental do Python: a tupla.

2.4. Tuplas não são apenas listas imutáveis

Alguns textos introdutórios de Python apresentam as tuplas como "listas imutáveis", mas isso é subestimá-las. Tuplas tem duas funções: elas podem ser usada como listas imutáveis e também como registros sem nomes de campos. Esse uso algumas vezes é negligenciado, então vamos começar por ele.

2.4.1. Tuplas como registros

Tuplas podem conter registros: cada item na tupla contém os dados de um campo, e a posição do item indica seu significado.

Se você pensar em uma tupla apenas como uma lista imutável, a quantidade e a ordem dos elementos pode ou não ter alguma importância, dependendo do contexto. Mas quando usamos uma tupla como uma coleção de campos, o número de itens em geral é fixo e sua ordem é sempre importante.

O Exemplo 7 mostras tuplas usadas como registros. Observe que, em todas as expressões, ordenar a tupla destruiria a informação, pois o significado de cada campo é dado por sua posição na tupla.

Exemplo 7. Tuplas usadas como registros
>>> lax_coordinates = (33.9425, -118.408056)  (1)
>>> city, year, pop, chg, area = ('Tokyo', 2003, 32_450, 0.66, 8014)  (2)
>>> traveler_ids = [('USA', '31195855'), ('BRA', 'CE342567'),  (3)
...     ('ESP', 'XDA205856')]
>>> for passport in sorted(traveler_ids):  (4)
...     print('%s/%s' % passport)   (5)
...
BRA/CE342567
ESP/XDA205856
USA/31195855
>>> for country, _ in traveler_ids:  (6)
...     print(country)
...
USA
BRA
ESP
  1. Latitude e longitude do Aeroporto Internacional de Los Angeles.

  2. Dados sobre Tóquio: nome, ano, população (em milhares), crescimento populacional (%) e área (km²).

  3. Uma lista de tuplas no formato (código_de_país, número_do_passaporte).

  4. Iterando sobre a lista, passport é vinculado a cada tupla.

  5. O operador de formatação % entende as tuplas e trata cada item como um campo separado.

  6. O loop for sabe como recuperar separadamente os itens de uma tupla—isso é chamado "desempacotamento" ("unpacking"). Aqui não estamos interessados no segundo item, então o atribuímos a _, uma variável descartável, usada apenas para coletar valores que não serão usados.

👉 Dica

Em geral, usar _ como variável descartável (dummy variable) é só uma convenção. É apenas um nome de variável estranho mas válido. Entretanto, em uma instrução match/case, o _ é um coringa que corresponde a qualquer valor, mas não está vinculado a um valor. Veja a Seção 2.6. E no console do Python, o resultado do comando anterior é atribuído a _—a menos que o resultado seja None.

Muitas vezes pensamos em registros como estruturas de dados com campos nomeados. O Capítulo 5 apresenta duas formas de criar tuplas com campos nomeados.

Mas muitas vezes não é preciso se dar ao trabalho de criar uma classe apenas para nomear os campos, especialmente se você aproveitar o desempacotamento e evitar o uso de índices para acessar os campos. No Exemplo 7, atribuímos ('Tokyo', 2003, 32_450, 0.66, 8014) a city, year, pop, chg, area em um único comando. E daí o operador % atribuiu cada item da tupla passport para a posição correspondente da string de formato no argumento print. Esses foram dois exemplos de desempacotamento de tuplas.

✒️ Nota

O termo "desempacotamento de tuplas" (tuple unpacking) é muito usado entre os pythonistas, mas desempacotamento de iteráveis é mais preciso e está ganhando popularidade, como no título da PEP 3132 — Extended Iterable Unpacking (Desempacotamento Estendido de Iteráveis).

A Seção 2.5 fala muito mais sobre desempacotamento, não apenas de tuplas, mas também de sequências e iteráveis em geral.

Agora vamos considerar o uso da classe tuple como uma variante imutável da classe list.

2.4.2. Tuplas como listas imutáveis

O interpretador Python e a biblioteca padrão fazem uso extensivo das tuplas como listas imutáveis, e você deveria seguir o exemplo. Isso traz dois benefícios importantes:

Clareza

Quando você vê uma tuple no código, sabe que seu tamanho nunca mudará.

Desempenho

Uma tuple usa menos memória que uma list de mesmo tamanho, e permite ao Python realizar algumas otimizações.

Entretanto, lembre-se que a imutabilidade de uma tuple só se aplica às referências ali contidas. Referências em uma tupla não podem ser apagadas ou substituídas. Mas se uma daquelas referências apontar para um objeto mutável, e aquele objeto mudar, então o valor da tuple muda. O próximo trecho de código ilustra esse fato criando duas tuplas—a e b— que inicialmente são iguais. A Figura 4 representa a disposição inicial da tupla b na memória.

Diagram de referências para uma tupla com três itens
Figura 4. O conteúdo em si da tupla é imutável, mas isso significa apenas que as referências mantidas pela tupla vão sempre apontar para os mesmos objetos. Entretanto, se um dos objetos referenciados for mutável—uma lista, por exemplo—seu conteúdo pode mudar.

Quando o último item em b muda, b e a se tornam diferentes:

>>> a = (10, 'alpha', [1, 2])
>>> b = (10, 'alpha', [1, 2])
>>> a == b
True
>>> b[-1].append(99)
>>> a == b
False
>>> b
(10, 'alpha', [1, 2, 99])

Tuplas com itens mutáveis podem ser uma fonte de bugs. Se uma tupla contém qualquer item mutável, ela não pode ser usada como chave em um dict ou como elemento em um set. O motivo será explicado em Seção 3.4.1.

Se você quiser determinar explicitamente se uma tupla (ou qualquer outro objeto) tem um valor fixo, pode usar a função embutida hash para criar uma função fixed, assim:

>>> def fixed(o):
...     try:
...         hash(o)
...     except TypeError:
...         return False
...     return True
...
>>> tf = (10, 'alpha', (1, 2))
>>> tm = (10, 'alpha', [1, 2])
>>> fixed(tf)
True
>>> fixed(tm)
False

Vamos aprofundar essa questão em Seção 6.3.2.

Apesar dessa ressalva, as tuplas são frequentemente usadas como listas imutáveis. Elas oferecem algumas vantagens de desempenho, explicadas por uma dos desenvolvedores principais do Python, Raymond Hettinger, em uma resposta à questão "Are tuples more efficient than lists in Python?" (As tuplas são mais eficientes que as listas no Python?) no StackOverflow. Em resumo, Hettinger escreveu:

  • Para avaliar uma tupla literal, o compilador Python gera bytecode para uma constante tupla em uma operação; mas para um literal lista, o bytecode gerado insere cada elemento como uma constante separada no stack de dados, e então cria a lista.

  • Dada a tupla t, tuple(t) simplesmente devolve uma referência para a mesma t. Não há necessidade de cópia. Por outro lado, dada uma lista l, o construtor list(l) precisa criar uma nova cópia de l.

  • Devido a seu tamanho fixo, uma instância de tuple tem alocado para si o espaço exato de memória que precisa. Em contrapartida, instâncias de list tem alocadas para si memória adicional, para amortizar o custo de acréscimos futuros.

  • As referências para os itens em uma tupla são armazenadas em um array na struct da tupla, enquanto uma lista mantém um ponteiro para um array de referências armazenada em outro lugar. Essa indireção é necessária porque, quando a lista cresce além do espaço alocado naquele momento, o Python precisa realocar o array de referências para criar espaço. A indireção adicional torna o cache da CPU menos eficiente.

2.4.3. Comparando os métodos de tuplas e listas

Quando usamos uma tupla como uma variante imutável de list, é bom saber o quão similares são suas APIs. Como se pode ver na Tabela 3, tuple suporta todos os métodos de list que não envolvem adicionar ou remover itens, com uma exceção—tuple não possui o método __reversed__. Entretanto, isso é só uma otimização; reversed(my_tuple) funciona sem esse método.

Tabela 3. Métodos e atributos encontrados em list ou tuple (os métodos implementados por object foram omitidos para economizar espaço)
list tuple  

s.__add__(s2)

s + s2—concatenação

s.__iadd__(s2)

s += s2—concatenação no mesmo lugar

s.append(e)

Acrescenta um elemento após o último

s.clear()

Apaga todos os itens

s.__contains__(e)

e in s

s.copy()

Cópia rasa da lista

s.count(e)

Conta as ocorrências de um elemento

s.__delitem__(p)

Remove o item na posição p

s.extend(it)

Acrescenta itens do iterável it

s.__getitem__(p)

s[p]—obtém o item na posição p

s.__getnewargs__()

Suporte a serialização otimizada com pickle

s.index(e)

Encontra a posição da primeira ocorrência de e

s.insert(p, e)

Insere elemento e antes do item na posição p

s.__iter__()

Obtém o iterador

s.__len__()

len(s)—número de itens

s.__mul__(n)

s * n—concatenação repetida

s.__imul__(n)

s *= n—concatenação repetida no mesmo lugar

s.__rmul__(n)

n * s—concatenação repetida inversa[6]

s.pop([p])

Remove e devolve o último item ou o item na posição opcional p

s.remove(e)

Remove a primeira ocorrência do elemento e, por valor

s.reverse()

Reverte, no lugar, a ordem dos itens

s.__reversed__()

Obtém iterador para examinar itens, do último para o primeiro

s.__setitem__(p, e)

s[p] = e—coloca e na posição p, sobrescrevendo o item existente[7]

s.sort([key], [reverse])

Ordena os itens no lugar, com os argumentos nomeados opcionais key e reverse

Vamos agora examinar um tópico importante para a programação Python idiomática: tuplas, listas e desempacotamento iterável.

2.5. Desempacotando sequências e iteráveis

O desempacotamento é importante porque evita o uso de índices para extrair elementos de sequências, um processo desnecessário e vulnerável a erros. Além disso, o desempacotamento funciona tendo qualquer objeto iterável como fonte de dados—incluindo iteradores, que não suportam a notação de índice ([]). O único requisito é que o iterável produza exatamente um item por variável na ponta de recebimento, a menos que você use um asterisco (*) para capturar os itens em excesso, como explicado na Seção 2.5.1.

A forma mais visível de desempacotamento é a atribuição paralela; isto é, atribuir itens de um iterável a uma tupla de variáveis, como vemos nesse exemplo:

>>> lax_coordinates = (33.9425, -118.408056)
>>> latitude, longitude = lax_coordinates  # unpacking
>>> latitude
33.9425
>>> longitude
-118.408056

Uma aplicação elegante de desempacotamento é permutar os valores de variáveis sem usar uma variável temporária:

>>> b, a = a, b

Outro exemplo de desempacotamento é prefixar um argumento com * ao chamar uma função:

>>> divmod(20, 8)
(2, 4)
>>> t = (20, 8)
>>> divmod(*t)
(2, 4)
>>> quotient, remainder = divmod(*t)
>>> quotient, remainder
(2, 4)

O código acima mostra outro uso do desempacotamento: permitir que funções devolvam múltiplos valores de forma conveniente para quem as chama. Em ainda outro exemplo, a função os.path.split() cria uma tupla (path, last_part) a partir de um caminho do sistema de arquivos:

>>> import os
>>> _, filename = os.path.split('/home/luciano/.ssh/id_rsa.pub')
>>> filename
'id_rsa.pub'

Outra forma de usar apenas alguns itens quando desempacotando é com a sintaxe *, que veremos a seguir.

2.5.1. Usando * para recolher itens em excesso

Definir parâmetros de função com *args para capturar argumentos arbitrários em excesso é um recurso clássico do Python.

No Python 3, essa ideia foi estendida para se aplicar também à atribuição paralela:

>>> a, b, *rest = range(5)
>>> a, b, rest
(0, 1, [2, 3, 4])
>>> a, b, *rest = range(3)
>>> a, b, rest
(0, 1, [2])
>>> a, b, *rest = range(2)
>>> a, b, rest
(0, 1, [])

No contexto da atribuição paralela, o prefixo * pode ser aplicado a exatamente uma variável, mas pode aparecer em qualquer posição:

>>> a, *body, c, d = range(5)
>>> a, body, c, d
(0, [1, 2], 3, 4)
>>> *head, b, c, d = range(5)
>>> head, b, c, d
([0, 1], 2, 3, 4)

2.5.2. Desempacotando com * em chamadas de função e sequências literais

A PEP 448—Additional Unpacking Generalizations (Generalizações adicionais de desempacotamento) (EN) introduziu uma sintaxe mais flexível para desempacotamento iterável, melhor resumida em "O que há de novo no Python 3.5" (EN).

Em chamadas de função, podemos usar * múltiplas vezes:

>>> def fun(a, b, c, d, *rest):
...     return a, b, c, d, rest
...
>>> fun(*[1, 2], 3, *range(4, 7))
(1, 2, 3, 4, (5, 6))

O * pode também ser usado na definição de literais list, tuple, ou set, como visto nesses exemplos de "O que há de novo no Python 3.5" (EN):

>>> *range(4), 4
(0, 1, 2, 3, 4)
>>> [*range(4), 4]
[0, 1, 2, 3, 4]
>>> {*range(4), 4, *(5, 6, 7)}
{0, 1, 2, 3, 4, 5, 6, 7}

A PEP 448 introduziu uma nova sintaxe similar para **, que veremos na Seção 3.2.2.

Por fim, outro importante aspecto do desempacotamento de tuplas: ele funciona com estruturas aninhadas.

2.5.3. Desempacotamento aninhado

O alvo de um desempacotamento pode usar aninhamento, por exemplo (a, b, (c, d)). O Python fará a coisa certa se o valor tiver a mesma estrutura aninhada. O Exemplo 8 mostra o desempacotamento aninhado em ação.

Exemplo 8. Desempacotando tuplas aninhadas para acessar a longitude
metro_areas = [
    ('Tokyo', 'JP', 36.933, (35.689722, 139.691667)),  # (1)
    ('Delhi NCR', 'IN', 21.935, (28.613889, 77.208889)),
    ('Mexico City', 'MX', 20.142, (19.433333, -99.133333)),
    ('New York-Newark', 'US', 20.104, (40.808611, -74.020386)),
    ('São Paulo', 'BR', 19.649, (-23.547778, -46.635833)),
]

def main():
    print(f'{"":15} | {"latitude":>9} | {"longitude":>9}')
    for name, _, _, (lat, lon) in metro_areas:  # (2)
        if lon <= 0:  # (3)
            print(f'{name:15} | {lat:9.4f} | {lon:9.4f}')

if __name__ == '__main__':
    main()
  1. Cada tupla contém um registro com quatro campos, o último deles um par de coordenadas.

  2. Ao atribuir o último campo a uma tupla aninhada, desempacotamos as coordenadas.

  3. O teste lon ⇐ 0: seleciona apenas cidades no hemisfério ocidental.

A saída do Exemplo 8 é:

                |  latitude | longitude
Mexico City     |   19.4333 |  -99.1333
New York-Newark |   40.8086 |  -74.0204
São Paulo       |  -23.5478 |  -46.6358

O alvo da atribuição de um desempacotamento pode também ser uma lista, mas bons casos de uso aqui são raros. Aqui está o único que conheço: se você tem uma consulta de banco de dados que devolve um único registro (por exemplo, se o código SQL tem a instrução LIMIT 1), daí é possível desempacotar e ao mesmo tempo se assegurar que há apenas um resultado com o seguinte código:

>>> [record] = query_returning_single_row()

Se o registro contiver apenas um campo, é possível obtê-lo diretamente, assim:

>>> [[field]] = query_returning_single_row_with_single_field()

Ambos os exemplos acima podem ser escritos com tuplas, mas não esqueça da peculiaridade sintática, tuplas com um único item devem ser escritas com uma vírgula final. Então o primeiro alvo seria (record,) e o segundo ((field,),). Nos dois casos, esquecer aquela vírgula causa um bug silencioso.[8]

Agora vamos estudar pattern matching, que suporta maneiras ainda mais poderosas para desempacotar sequências.

2.6. Pattern matching com sequências

O novo recurso mais visível do Python 3.10 é o pattern matching (casamento de padrões) com a instrução match/case, proposta na PEP 634—Structural Pattern Matching: Specification (Casamento Estrutural de Padrões: Especificação) (EN).

✒️ Nota

Carol Willing, uma das desenvolvedoras principais do Python, escreveu uma excelente introdução ao pattern matching na seção "Correspondência de padrão estrutural"[9] em "O que há de novo no Python 3.10". Você pode querer ler aquela revisão rápida. Neste livro, optei por dividir o tratamento da correspondência de padrões em diferentes capítulos, dependendo dos tipos de padrão: Na Seção 3.3 e na Seção 5.8. E há um exemplo mais longo na Seção 18.3.

Vamos ao primeiro exemplo do tratamento de sequências com match/case.

Imagine que você está construindo um robô que aceita comandos, enviados como sequências de palavras e números, como BEEPER 440 3. Após separar o comando em partes e analisar os números, você teria uma mensagem como ['BEEPER', 440, 3]. Então, você poderia usar um método assim para interpretar mensagens naquele formato:

Exemplo 9. Método de uma classe Robot imaginária
    def handle_command(self, message):
        match message:  # (1)
            case ['BEEPER', frequency, times]:  # (2)
                self.beep(times, frequency)
            case ['NECK', angle]:  # (3)
                self.rotate_neck(angle)
            case ['LED', ident, intensity]:  # (4)
                self.leds[ident].set_brightness(ident, intensity)
            case ['LED', ident, red, green, blue]:  # (5)
                self.leds[ident].set_color(ident, red, green, blue)
            case _:  # (6)
                raise InvalidCommand(message)
  1. A expressão após a palavra-chave match é o sujeito (subject). O sujeito contém os dados que o Python vai comparar aos padrões em cada instrução case.

  2. Esse padrão casa com qualquer sujeito que seja uma sequência de três itens. O primeiro item deve ser a string BEEPER. O segundo e o terceiro itens podem ser qualquer coisa, e serão vinculados às variáveis frequency e times, nessa ordem.

  3. Isso casa com qualquer sujeito com dois itens, se o primeiro for 'NECK'.

  4. Isso vai casar com uma sujeito de três itens começando com LED. Se o número de itens não for correspondente, o Python segue para o próximo case.

  5. Outro padrão de sequência começando com 'LED', agora com cinco itens—incluindo a constante 'LED'.

  6. Esse é o case default. Vai casar com qualquer sujeito que não tenha sido capturado por um dos padrões precedentes. A variável _ é especial, como logo veremos.

Olhando superficialmente, match/case se parece instrução switch/case da linguagem C—mas isso é só uma pequena parte da sua funcionalidade.[10] Uma melhoria fundamental do match sobre o switch é a desestruturação—uma forma mais avançada de desempacotamento. Desestruturação é uma palavra nova no vocabulário do Python, mas é usada com frequência na documentação de linguagens que suportam o pattern matching—como Scala e Elixir.

Como um primeiro exemplo de desestruturação, o Exemplo 10 mostra parte do Exemplo 8 reescrito com match/case.

Exemplo 10. Desestruturando tuplas aninhadas—requer Python ≥ 3.10
metro_areas = [
    ('Tokyo', 'JP', 36.933, (35.689722, 139.691667)),
    ('Delhi NCR', 'IN', 21.935, (28.613889, 77.208889)),
    ('Mexico City', 'MX', 20.142, (19.433333, -99.133333)),
    ('New York-Newark', 'US', 20.104, (40.808611, -74.020386)),
    ('São Paulo', 'BR', 19.649, (-23.547778, -46.635833)),
]

def main():
    print(f'{"":15} | {"latitude":>9} | {"longitude":>9}')
    for record in metro_areas:
        match record:  # (1)
            case [name, _, _, (lat, lon)] if lon <= 0:  # (2)
                print(f'{name:15} | {lat:9.4f} | {lon:9.4f}')
  1. O sujeito desse match é record—isto é, cada uma das tuplas em metro_areas.

  2. Uma instrução case tem duas partes: um padrão e uma guarda opcional, com a palavra-chave if.

Em geral, um padrão de sequência casa com o sujeito se estas três condições forem verdadeiras:

  1. O sujeito é uma sequência, e

  2. O sujeito e o padrão tem o mesmo número de itens, e

  3. Cada item correspondente casa, incluindo os itens aninhados.

Por exemplo, o padrão [name, _, _, (lat, lon)] no Exemplo 10 casa com uma sequência de quatro itens, e o último item tem que ser uma sequência de dois itens.

Padrões de sequência podem ser escritos como tuplas e listas, mas a sintaxe usada não faz diferença: em um padrão de sequência, colchetes e parênteses tem o mesmo significado. Escrevi o padrão como uma lista com uma tupla aninhada de dois itens para evitar a repetição de colchetes ou parênteses no Exemplo 10.

Um padrão de sequência pode casar com instâncias da maioria das subclasses reais ou virtuais de collections.abc.Sequence, com a exceção de str, bytes, e bytearray.

⚠️ Aviso

Instâncias de str, bytes, e bytearray não são tratadas como sequências no contexto de um match/case. Um sujeito de match de um desses tipos é tratado como um valor "atômico"—assim como o inteiro 987 é tratado como um único valor, e não como uma sequência de dígitos. Tratar aqueles três tipos como sequências poderia causar bugs devido a casamentos não intencionais. Se você quer usar um objeto daqueles tipos como um sujeito sequência, converta-o na instrução match. Por exemplo, veja tuple(phone) no trecho abaixo, que poderia ser usado para separar números de telefone por regiões do mundo com base no prefixo DDI:

    match tuple(phone):
        case ['1', *rest]:  # North America and Caribbean
            ...
        case ['2', *rest]:  # Africa and some territories
            ...
        case ['3' | '4', *rest]:  # Europe
            ...

Na biblioteca padrão, os seguintes tipos são compatíveis com padrões de sequência:

list     memoryview    array.array
tuple    range         collections.deque

Ao contrário do desempacotamento, padrões não desestruturam iteráveis que não sejam sequências (tal como os iteradores).

O símbolo _ é especial nos padrões: ele casa com qualquer item naquela posição, mas nunca é vinculado ao valor daquele item. O valor é descartado. Além disso, o _ é a única variável que pode aparecer mais de uma vez em um padrão.

Você pode vincular qualquer parte de um padrão a uma variável usando a palavra-chave as:

        case [name, _, _, (lat, lon) as coord]:

Dado o sujeito ['Shanghai', 'CN', 24.9, (31.1, 121.3)], o padrão anterior vai casar e atribuir valores às seguintes variáveis:

Variável Valor atribuído

name

'Shanghai'

lat

31.1

lon

121.3

coord

(31.1, 121.3)

Podemos tornar os padrões mais específicos, incluindo informação de tipo. Por exemplo, o seguinte padrão casa com a mesma estrutura de sequência aninhada do exemplo anterior, mas o primeiro item deve ser uma instância de str, e ambos os itens da tupla devem ser instâncias de float:

        case [str(name), _, _, (float(lat), float(lon))]:
👉 Dica

As expressões str(name) e float(lat) se parecem com chamadas a construtores, que usaríamos para converter name e lat para str e float. Mas no contexto de um padrão, aquela sintaxe faz uma verificação de tipo durante a execução do programa: o padrão acima vai casar com uma sequência de quatro itens, na qual o item 0 deve ser uma str e o item 3 deve ser um par de números de ponto flutuante. Além disso, a str no item 0 será vinculada à variável name e os números no item 3 serão vinculados a lat e lon, respectivamente. Assim, apesar de imitar a sintaxe de uma chamada de construtor, o significado de str(name) é totalmente diferente no contexto de um padrão. O uso de classes arbitrárias em padrões será tratado na Seção 5.8.

Por outro lado, se queremos casar qualquer sujeito sequência começando com uma str e terminando com uma sequência aninhada com dois números de ponto flutuante, podemos escrever:

        case [str(name), *_, (float(lat), float(lon))]:

O *_ casa com qualquer número de itens, sem vinculá-los a uma variável. Usar *extra em vez de *_ vincularia os itens a extra como uma list com 0 ou mais itens.

A instrução de guarda opcional começando com if só é avaliada se o padrão casar, e pode se referir a variáveis vinculadas no padrão, como no Exemplo 10:

        match record:
            case [name, _, _, (lat, lon)] if lon <= 0:
                print(f'{name:15} | {lat:9.4f} | {lon:9.4f}')

O bloco aninhado com o comando print só será executado se o padrão casar e a expressão guarda for verdadeira.

👉 Dica

A desestruturação com padrões é tão expressiva que, algumas vezes, um match com um único case pode tornar o código mais simples. Guido van Rossum tem uma coleção de exemplos de case/match, incluindo um que ele chamou de "A very deep iterable and type match with extraction" (Um match de iterável e tipo muito profundo, com extração) (EN).

O Exemplo 10 não melhora o Exemplo 8. É apenas um exemplo para contrastar duas formas de fazer a mesma coisa. O próximo exemplo mostra como o pattern matching contribui para a criação de código claro, conciso e eficaz.

2.6.1. Casando padrões de sequência em um interpretador

Peter Norvig, da Universidade de Stanford, escreveu o lis.py: um interpretador de um subconjunto do dialeto Scheme da linguagem de programação Lisp, em 132 belas linhas de código Python legível. Peguei o código fonte de Norvig (publicado sob a licença MIT) e o atualizei para o Python 3.10, para exemplificar o pattern matching. Nessa seção, vamos comparar uma parte fundamental do código de Norvig—que usa if/elif e desempacotamento—com uma nova versão usando match/case.

As duas funções principais do lis.py são parse e evaluate.[11] O parser (analisador sintático) recebe as expressões entre parênteses do Scheme e devolve listas Python. Aqui estão dois exemplos:

>>> parse('(gcd 18 45)')
['gcd', 18, 45]
>>> parse('''
... (define double
...     (lambda (n)
...         (* n 2)))
... ''')
['define', 'double', ['lambda', ['n'], ['*', 'n', 2]]]

O avaliador recebe listas como essas e as executa. O primeiro exemplo está chamando uma função gcd com 18 e 45 como argumentos. Quando executada, ela computa o maior divisor comum (gcd são as iniciais do termo em inglês, _greatest common divisor) dos argumentos (que é 9). O segundo exemplo está definindo uma função chamada double com um parâmetro n. O corpo da função é a expressão (* n 2). O resultado da chamada a uma função em Scheme é o valor da última expressão no corpo da função chamada.

Nosso foco aqui é a desestruturação de sequências, então não vou explicar as ações do avaliador. Veja a Seção 18.3 para aprender mais sobre o funcionamento do lis.py.

O Exemplo 11 mostra o avaliador de Norvig com algumas pequenas modificações, e abreviado para mostrar apenas os padrões de sequência.

Exemplo 11. Casando padrões sem match/case
def evaluate(exp: Expression, env: Environment) -> Any:
    "Evaluate an expression in an environment."
    if isinstance(exp, Symbol):      # variable reference
        return env[exp]
    # ... lines omitted
    elif exp[0] == 'quote':          # (quote exp)
        (_, x) = exp
        return x
    elif exp[0] == 'if':             # (if test conseq alt)
        (_, test, consequence, alternative) = exp
        if evaluate(test, env):
            return evaluate(consequence, env)
        else:
            return evaluate(alternative, env)
    elif exp[0] == 'lambda':         # (lambda (parm…) body…)
        (_, parms, *body) = exp
        return Procedure(parms, body, env)
    elif exp[0] == 'define':
        (_, name, value_exp) = exp
        env[name] = evaluate(value_exp, env)
    # ... more lines omitted

Observe como cada instrução elif verifica o primeiro item da lista, e então desempacota a lista, ignorando o primeiro item. O uso extensivo do desempacotamento sugere que Norvig é um fã do pattern matching, mas ele originalmente escreveu aquele código em Python 2 (apesar de agora ele funcionar com qualquer Python 3)

Usando match/case em Python ≥ 3.10, podemos refatorar evaluate, como mostrado no Exemplo 12.

Exemplo 12. Pattern matching com match/case—requer Python ≥ 3.10
def evaluate(exp: Expression, env: Environment) -> Any:
    "Evaluate an expression in an environment."
    match exp:
    # ... lines omitted
        case ['quote', x]:  # (1)
            return x
        case ['if', test, consequence, alternative]:  # (2)
            if evaluate(test, env):
                return evaluate(consequence, env)
            else:
                return evaluate(alternative, env)
        case ['lambda', [*parms], *body] if body:  # (3)
            return Procedure(parms, body, env)
        case ['define', Symbol() as name, value_exp]:  # (4)
            env[name] = evaluate(value_exp, env)
        # ... more lines omitted
        case _:  # (5)
            raise SyntaxError(lispstr(exp))
  1. Casa se o sujeito for uma sequência de dois itens começando com 'quote'.

  2. Casa se o sujeito for uma sequência de quatro itens começando com 'if'.

  3. Casa se o sujeito for uma sequência com três ou mais itens começando com 'lambda'. A guarda assegura que body não esteja vazio.

  4. Casa se o sujeito for uma sequência de três itens começando com 'define', seguido de uma instância de Symbol.

  5. é uma boa prática ter um case para capturar todo o resto. Neste exemplo, se exp não casar com nenhum dos padrões, a expressão está mal-formada, então gera um SyntaxError.

Sem o último case, para pegar tudo que tiver passado pelos anteriores, todo o bloco match não faz nada quando o sujeito não casa com algum case—e isso pode ser uma falha silenciosa.

Norvig deliberadamente evitou a checagem e o tratamento de erros em lis.py, para manter o código fácil de entender. Com pattern matching, podemos acrescentar mais verificações e ainda manter o programa legível. Por exemplo, no padrão 'define', o código original não se assegura que name é uma instância de Symbol—isso exigiria um bloco if, uma chamada a isinstance, e mais código. O Exemplo 12 é mais curto e mais seguro que o Exemplo 11.

Padrões alternativos para lambda

Essa é a sintaxe de lambda no Scheme, usando a convenção sintática onde o sufixo significa que o elemento pode aparecer zero ou mais vezes:

(lambda (parms…) body1 body2…)

Um padrão simples para o case de 'lambda' seria esse:

       case ['lambda', parms, *body] if body:

Entretanto, isso casa com qualquer valor na posição parms, incluindo o primeiro x nesse sujeito inválido:

['lambda', 'x', ['*', 'x', 2]]

A lista aninhada após a palavra-chave lambda do Scheme contém os nomes do parâmetros formais da função, e deve ser uma lista mesmo que contenha apenas um elemento. Ela pode também ser uma lista vazia, se função não receber parâmetros—como a random.random() do Python.

No Exemplo 12, tornei o padrão de 'lambda' mais seguro usando um padrão de sequência aninhado:

        case ['lambda', [*parms], *body] if body:
            return Procedure(parms, body, env)

Em um padrão de sequência, o * pode aparecer apenas uma vez por sequência. Aqui temos duas sequências: a externa e a interna.

Acrescentando os caracteres [*] em torno de parms fez o padrão mais parecido com a sintaxe do Scheme da qual ele trata, e nos deu uma verificação estrutural adicional.

Sintaxe abreviada para definição de função

O Scheme tem uma sintaxe alternativa de define, para criar uma função nomeada sem usar um lambda aninhado. Tal sintaxe funciona assim:

(define (name parm…) body1 body2…)

A palavra-chave define é seguida por uma lista com o name da nova função e zero ou mais nomes de parâmetros. Após a lista vem o corpo da função, com uma ou mais expressões.

Acrescentar essas duas linhas ao match cuida da implementação:

        case ['define', [Symbol() as name, *parms], *body] if body:
            env[name] = Procedure(parms, body, env)

Eu colocaria esse case após o case da outra forma de define no Exemplo 12. A ordem desses cases de define é irrelevante nesse exemplo, pois nenhum sujeito pode casar com esses dois padrões: o segundo elemento deve ser um Symbol na forma original de define, mas deve ser uma sequência começando com um Symbol no atalho de define para definição de função.

Agora pense em quanto trabalho teríamos para adicionar o suporte a essa segunda sintaxe de define sem a ajuda do pattern matching no Exemplo 11. A instrução match faz muito mais que o switch das linguagens similares ao C.

O pattern matching é um exemplo de programação declarativa: o código descreve "o que" você quer casar, em vez de "como" casar. A forma do código segue a forma dos dados, como ilustra a Tabela 4.

Tabela 4. Algumas formas sintáticas do Scheme e os padrões de case para tratá-las
Sintaxe do Scheme Padrão de sequência

(quote exp)

['quote', exp]

(if test conseq alt)

['if', test, conseq, alt]

(lambda (parms…) body1 body2…)

['lambda', [*parms], *body] if body

(define name exp)

['define', Symbol() as name, exp]

(define (name parms…) body1 body2…)

['define', [Symbol() as name, *parms], *body] if body

Espero que a refatoração do evaluate de Norvig com pattern matching tenha convencido você que match/case pode tornar seu código mais legível e mais seguro.

✒️ Nota

Veremos mais do lis.py na Seção 18.3, quando vamos revisar o exemplo completo de match/case em evaluate. Se você quiser aprender mais sobre o lys.py de Norvig, leia seu maravilhoso post "(How to Write a (Lisp) Interpreter (in Python))" (Como Escrever um Interpretador (Lisp) em (Python)).

Isso conclui nossa primeira passagem por desempacotamento, desestruturação e pattern matching com sequências. Vamos tratar de outros tipos de padrões mais adiante, em outros capítulos.

Todo programador Python sabe que sequências podem ser fatiadas usando a sintaxe s[a:b]. Vamos agora examinar alguns fatos menos conhecidos sobre fatiamento.

2.7. Fatiamento

Um recurso comum a list, tuple, str, e a todos os tipos de sequência em Python, é o suporte a operações de fatiamento, que são mais potentes do que a maioria das pessoas percebe.

Nesta seção descrevemos o uso dessas formas avançadas de fatiamento. Sua implementação em uma classe definida pelo usuário será tratada no Capítulo 12, mantendo nossa filosofia de tratar de classes prontas para usar nessa parte do livro, e da criação de novas classes na Parte III: Classes e protocolos.

2.7.1. Por que fatias e faixas excluem o último item?

A convenção pythônica de excluir o último item em fatias e faixas funciona bem com a indexação iniciada no zero usada no Python, no C e em muitas outras linguagens. Algumas características convenientes da convenção são:

  • É fácil ver o tamanho da fatia ou da faixa quando apenas a posição final é dada: tanto range(3) quanto my_list[:3] produzem três itens.

  • É fácil calcular o tamanho de uma fatia ou de uma faixa quando o início e o fim são dados: basta subtrair fim-início.

  • É fácil cortar uma sequência em duas partes em qualquer índice x, sem sobreposição: simplesmente escreva my_list[:x] e my_list[x:]. Por exemplo:

    >>> l = [10, 20, 30, 40, 50, 60]
    >>> l[:2]  # split at 2
    [10, 20]
    >>> l[2:]
    [30, 40, 50, 60]
    >>> l[:3]  # split at 3
    [10, 20, 30]
    >>> l[3:]
    [40, 50, 60]

Os melhores argumentos a favor desta convenção foram escritos pelo cientista da computação holandês Edsger W. Dijkstra (veja a última referência na Seção 2.12).

Agora vamos olhar mais de perto a forma como o Python interpreta a notação de fatiamento.

2.7.2. Objetos fatia

Isso não é segredo, mas vale a pena repetir, só para ter certeza: s[a:b:c] pode ser usado para especificar um passo ou salto c, fazendo com que a fatia resultante pule itens. O passo pode ser também negativo, devolvendo os itens em ordem inversa. Três exemplos esclarecem a questão:

>>> s = 'bicycle'
>>> s[::3]
'bye'
>>> s[::-1]
'elcycib'
>>> s[::-2]
'eccb'

Vimos outro exemplo no Capítulo 1, quando usamos deck[12::13] para obter todos os ases de uma baralho não embaralhado:

>>> deck[12::13]
[Card(rank='A', suit='spades'), Card(rank='A', suit='diamonds'),
Card(rank='A', suit='clubs'), Card(rank='A', suit='hearts')]

A notação a:b:c só é válida entre [] quando usada como operador de indexação ou de subscrição (subscript), e produz um objeto fatia (slice object): slice(a, b, c). Como veremos na Seção 12.5.1, para avaliar a expressão seq[start:stop:step], o Python chama seq.__getitem__(slice(start, stop, step)). Mesmo se você não for implementar seus próprios tipos de sequência, saber dos objetos fatia é útil, porque eles permitem que você atribua nomes às fatias, da mesma forma que planilhas permitem dar nomes a faixas de células.

Suponha que você precise analisar um arquivo de dados como a fatura mostrada na Exemplo 13. Em vez de encher seu código de fatias explícitas fixas, você pode nomeá-las. Veja como isso torna legível o loop for no final do exemplo.

Exemplo 13. Itens de um arquivo tabular de fatura
>>> invoice = """
... 0.....6.................................40........52...55........
... 1909  Pimoroni PiBrella                     $17.50    3    $52.50
... 1489  6mm Tactile Switch x20                 $4.95    2     $9.90
... 1510  Panavise Jr. - PV-201                 $28.00    1    $28.00
... 1601  PiTFT Mini Kit 320x240                $34.95    1    $34.95
... """
>>> SKU = slice(0, 6)
>>> DESCRIPTION = slice(6, 40)
>>> UNIT_PRICE = slice(40, 52)
>>> QUANTITY =  slice(52, 55)
>>> ITEM_TOTAL = slice(55, None)
>>> line_items = invoice.split('\n')[2:]
>>> for item in line_items:
...     print(item[UNIT_PRICE], item[DESCRIPTION])
...
    $17.50   Pimoroni PiBrella
     $4.95   6mm Tactile Switch x20
    $28.00   Panavise Jr. - PV-201
    $34.95   PiTFT Mini Kit 320x240

Voltaremos aos objetos slice quando formos discutir a criação de suas próprias coleções, na Seção 12.5. Enquanto isso, do ponto de vista do usuário, o fatiamento tem recursos adicionais, tais como fatias multidimensionais e a notação de reticências (...). Siga comigo.

2.7.3. Fatiamento multidimensional e reticências

O operador [] pode também receber múltiplos índices ou fatias separadas por vírgulas. Os métodos especiais __getitem__ e __setitem__, que tratam o operador [], apenas recebem os índices em a[i, j] como uma tupla. Em outras palavras, para avaliar a[i, j], o Python chama a.__getitem__((i, j)).

Isso é usado, por exemplo, no pacote externo NumPy, onde itens de uma numpy.ndarray bi-dimensional podem ser recuperados usando a sintaxe a[i, j], e uma fatia bi-dimensional é obtida com uma expressão como a[m:n, k:l]. O Exemplo 22, abaixo nesse mesmo capítulo, mostra o uso dessa notação.

Exceto por memoryview, os tipos embutidos de sequência do Python são uni-dimensionais, então aceitam apenas um índice ou fatia, e não uma tupla de índices ou fatias.[12]

As reticências—escritas como três pontos finais (...) e não como (Unicode U+2026)—são reconhecidas como um símbolo pelo parser do Python. Esse símbolo é um apelido para o objeto Ellipsis, a única instância da classe ellipsis.[13] Dessa forma, ele pode ser passado como argumento para funções e como parte da especificação de uma fatia, como em f(a, ..., z) ou a[i:...]. O NumPy usa ... como atalho ao fatiar arrays com muitas dimensões; por exemplo, se x é um array com quatro dimensões, x[i, ...] é um atalho para x[i, :, :, :,]. Veja "NumPy quickstart" (EN) para saber mais sobre isso.

No momento em que escrevo isso, desconheço usos de Ellipsis ou de índices multidimensionais na biblioteca padrão do Python. Se você souber de algum, me avise. Esses recursos sintáticos existem para suportar tipos definidos pelo usuário ou extensões como o NumPy.

Fatias não são úteis apenas para extrair informações de sequências; elas podem também ser usadas para modificar sequências mutáveis no lugar—isto é, sem precisar reconstruí-las do zero.

2.7.4. Atribuindo a fatias

Sequências mutáveis podem ser transplantadas, extirpadas e, de forma geral, modificadas no lugar com o uso da notação de fatias no lado esquerdo de um comando de atribuição ou como alvo de um comando del. Os próximos exemplos dão uma ideia do poder dessa notação:

>>> l = list(range(10))
>>> l
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
>>> l[2:5] = [20, 30]
>>> l
[0, 1, 20, 30, 5, 6, 7, 8, 9]
>>> del l[5:7]
>>> l
[0, 1, 20, 30, 5, 8, 9]
>>> l[3::2] = [11, 22]
>>> l
[0, 1, 20, 11, 5, 22, 9]
>>> l[2:5] = 100  (1)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: can only assign an iterable
>>> l[2:5] = [100]
>>> l
[0, 1, 100, 22, 9]
  1. Quando o alvo de uma atribuição é uma fatia, o lado direito deve ser um objeto iterável, mesmo que tenha apenas um item.

Todo programador sabe que a concatenação é uma operação frequente com sequências. Tutoriais introdutórios de Python explicam o uso de + e * para tal propósito, mas há detalhes sutis em seu funcionamento, como veremos a seguir.

2.8. Usando + e * com sequências

Programadores Python esperam que sequências suportem + e *. Em geral, os dois operandos de + devem ser sequências do mesmo tipo, e nenhum deles é modificado, uma nova sequência daquele mesmo tipo é criada como resultado da concatenação.

Para concatenar múltiplas cópias da mesma sequência basta multiplicá-la por um inteiro. E da mesma forma, uma nova sequência é criada:

>>> l = [1, 2, 3]
>>> l * 5
[1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3]
>>> 5 * 'abcd'
'abcdabcdabcdabcdabcd'

Tanto + quanto * sempre criam um novo objetos, e nunca modificam seus operandos.

⚠️ Aviso

Tenha cuidado com expressões como a * n quando a é uma sequência contendo itens mutáveis, pois o resultado pode ser surpreendente. Por exemplo, tentar inicializar uma lista de listas como my_list = [[]] * 3 vai resultar em uma lista com três referências para a mesma lista interna, que provavelmente não é o quê você quer.

A próxima seção fala das armadilhas ao se tentar usar * para inicializar uma lista de listas.

2.8.1. Criando uma lista de listas

Algumas vezes precisamos inicializar uma lista com um certo número de listas aninhadas—para, por exemplo, distribuir estudantes em uma lista de equipes, ou para representar casas no tabuleiro de um jogo. A melhor forma de fazer isso é com uma compreensão de lista, como no Exemplo 14.

Exemplo 14. Uma lista com três listas de tamanho 3 pode representar um tabuleiro de jogo da velha
>>> board = [['_'] * 3 for i in range(3)]  (1)
>>> board
[['_', '_', '_'], ['_', '_', '_'], ['_', '_', '_']]
>>> board[1][2] = 'X'  (2)
>>> board
[['_', '_', '_'], ['_', '_', 'X'], ['_', '_', '_']]
  1. Cria uma lista de três listas, cada uma com três itens. Inspeciona a estrutura criada.

  2. Coloca um "X" na linha 1, coluna 2, e verifica o resultado.

Um atalho tentador mas errado seria fazer algo como o Exemplo 15.

Exemplo 15. Uma lista com três referências para a mesma lista é inútil
>>> weird_board = [['_'] * 3] * 3  (1)
>>> weird_board
[['_', '_', '_'], ['_', '_', '_'], ['_', '_', '_']]
>>> weird_board[1][2] = 'O' (2)
>>> weird_board
[['_', '_', 'O'], ['_', '_', 'O'], ['_', '_', 'O']]
  1. A lista externa é feita de três referências para a mesma lista interna. Enquanto ela não é modificada, tudo parece correr bem.

  2. Colocar um "O" na linha 1, coluna 2, revela que todas as linhas são apelidos do mesmo objeto.

O problema com o Exemplo 15 é que ele se comporta, essencialmente, como o código abaixo:

row = ['_'] * 3
board = []
for i in range(3):
    board.append(row)  (1)
  1. A mesma row é anexada três vezes ao board.

Por outro lado, a compreensão de lista no Exemplo 14 equivale ao seguinte código:

>>> board = []
>>> for i in range(3):
...     row = ['_'] * 3  # (1)
...     board.append(row)
...
>>> board
[['_', '_', '_'], ['_', '_', '_'], ['_', '_', '_']]
>>> board[2][0] = 'X'
>>> board  # (2)
[['_', '_', '_'], ['_', '_', '_'], ['X', '_', '_']]
  1. Cada iteração cria uma nova row e a acrescenta ao board.

  2. Como esperado, apenas a linha 2 é modificada.

👉 Dica

Se o problema ou a solução mostrados nessa seção não estão claros para você, não se preocupe. O Capítulo 6 foi escrito para esclarecer a mecânica e os perigos das referências e dos objetos mutáveis.

Até aqui discutimos o uso dos operadores simples + e * com sequências, mas existem também os operadores += e *=, que produzem resultados muito diferentes, dependendo da mutabilidade da sequência alvo. A próxima seção explica como eles funcionam.

2.8.2. Atribuição aumentada com sequências

Os operadores de atribuição aumentada += e *= se comportam de formas muito diferentes, dependendo do primeiro operando. Para simplificar a discussão, vamos primeiro nos concentrar na adição aumentada (+=), mas os conceitos se aplicam a *= e a outros operadores de atribuição aumentada.

O método especial que faz += funcionar é __iadd__ (significando "in-place addition", _adição no mesmo lugar_).

Entretanto, se __iadd__ não estiver implementado, o Python chama __add__ como fallback. Considere essa expressão simples:

>>> a += b

Se a implementar __iadd__, esse método será chamado. No caso de sequências mutáveis (por exemplo, list, bytearray, array.array), a será modificada no lugar (isto é, o efeito ser similar a a.extend(b)). Porém, quando a não implementa __iadd__, a expressão a = b` tem o mesmo efeito de `a = a + b`: a expressão `a + b` é avaliada antes, produzindo um novo objeto, que então é vinculado a `a`. Em outras palavras, a identidade do objeto vinculado a `a` pode ou não mudar, dependendo da disponibilidade de `+__iadd__.

Em geral, para sequências mutáveis, é razoável supor que __iadd__ está implementado e que += acontece no mesmo lugar. Para sequências imutáveis, obviamente não há forma disso acontecer.

Isso que acabei de escrever sobre =` também se aplica a `*=`, que é implementado via `+__imul__. Os métodos especiais __iadd__ e __imul__ são tratados no Capítulo 16. Aqui está uma demonstração de *= com uma sequência mutável e depois com uma sequência imutável:

>>> l = [1, 2, 3]
>>> id(l)
4311953800  (1)
>>> l *= 2
>>> l
[1, 2, 3, 1, 2, 3]
>>> id(l)
4311953800  (2)
>>> t = (1, 2, 3)
>>> id(t)
4312681568  (3)
>>> t *= 2
>>> id(t)
4301348296  (4)
  1. O ID da lista inicial.

  2. Após a multiplicação, a lista é o mesmo objeto, com novos itens anexados.

  3. O ID da tupla inicial.

  4. Após a multiplicação, uma nova tupla foi criada.

A concatenação repetida de sequências imutáveis é ineficiente, pois ao invés de apenas acrescentar novos itens, o interpretador tem que copiar toda a sequência alvo para criar um novo objeto com os novos itens concatenados.[14]

Vimos casos de uso comuns para +=. A próxima seção mostra um caso lateral intrigante, que realça o real significado de "imutável" no contexto das tuplas.

2.8.3. Um quebra-cabeça com a atribuição +=

Tente responder sem usar o console: qual o resultado da avaliação das duas expressões no Exemplo 16?[15]

Exemplo 16. Um enigma
>>> t = (1, 2, [30, 40])
>>> t[2] += [50, 60]

O que acontece a seguir? Escolha a melhor alternativa:

  1. t se torna (1, 2, [30, 40, 50, 60]).

  2. É gerado um TypeError com a mensagem 'tuple' object does not support item assignment (o objeto tupla não suporta atribuição de itens).

  3. Nenhuma das alternativas acima..

  4. Ambas as alternativas, A e B.

Quando vi isso, tinha certeza que a resposta era B, mas, na verdade é D, "Ambas as alternativas, A e B"! O Exemplo 17 é a saída real em um console rodando Python 3.10.[16]

Exemplo 17. O resultado inesperado: o item t2 é modificado e uma exceção é gerada
>>> t = (1, 2, [30, 40])
>>> t[2] += [50, 60]
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: 'tuple' object does not support item assignment
>>> t
(1, 2, [30, 40, 50, 60])

O Online Python Tutor (EN) é uma ferramenta online fantástica para visualizar em detalhes o funcionamento do Python. A Figura 5 é uma composição de duas capturas de tela, mostrando os estados inicial e final da tupla t do Exemplo 17.

Diagrama de referência
Figura 5. Estados inicial e final do enigma da atribuição de tuplas (diagrama gerado pelo Online Python Tutor).

Se olharmos o bytecode gerado pelo Python para a expressão s[a] += b (Exemplo 18), fica claro como isso acontece.

Exemplo 18. Bytecode para a expressão s[a] += b
>>> dis.dis('s[a] += b')
  1           0 LOAD_NAME                0 (s)
              3 LOAD_NAME                1 (a)
              6 DUP_TOP_TWO
              7 BINARY_SUBSCR                      (1)
              8 LOAD_NAME                2 (b)
             11 INPLACE_ADD                        (2)
             12 ROT_THREE
             13 STORE_SUBSCR                       (3)
             14 LOAD_CONST               0 (None)
             17 RETURN_VALUE
  1. Coloca o valor de s[a] no TOS (Top Of Stack, topo da pilha de execução_).

  2. Executa TOS += b. Isso é bem sucedido se TOS se refere a um objeto mutável (no Exemplo 17 é uma lista).

  3. Atribui s[a] = TOS. Isso falha se s é imutável (a tupla t no Exemplo 17).

Esse exemplo é um caso raro—em meus 20 anos usando Python, nunca vi esse comportamento estranho estragar o dia de alguém.

Há três lições para tirar daqui:

  • Evite colocar objetos mutáveis em tuplas.

  • A atribuição aumentada não é uma operação atômica—acabamos de vê-la gerar uma exceção após executar parte de seu trabalho.

  • Inspecionar o bytecode do Python não é muito difícil, e pode ajudar a ver o que está acontecendo por debaixo dos panos.

Após testemunharmos as sutilezas do uso de + e * para concatenação, podemos mudar de assunto e tratar de outra operação essencial com sequências: ordenação.

2.9. list.sort versus a função embutida sorted

O método list.sort ordena uma lista no mesmo lugar—isto é, sem criar uma cópia. Ele devolve None para nos lembrar que muda a própria instância e não cria uma nova lista. Essa é uma convenção importante da API do Python: funções e métodos que mudam um objeto no mesmo lugar deve devolver None, para deixar claro a quem chamou que o receptor[17] foi modificado, e que nenhum objeto novo foi criado. Um comportamento similar pode ser observado, por exemplo, na função random.shuffle(s), que devolve None após embaralhar os itens de uma sequência mutável in-place (no lugar), isto é, mudando a posição dos itens dentro da própria sequência.

✒️ Nota

A convenção de devolver None para sinalizar mudanças no mesmo lugar tem uma desvantagem: não podemos cascatear chamadas a esses métodos. Em contraste, métodos que devolvem novos objetos (todos os métodos de str, por exemplo) podem ser cascateados no estilo de uma interface fluente. Veja o artigo "Fluent interface" (EN) da Wikipedia em inglês para uma descrição mais detalhada deste tópico.

A função embutida sorted, por outro lado, cria e devolve uma nova lista. Ela aceita qualquer objeto iterável como um argumento, incluindo sequências imutáveis e geradores (veja o Capítulo 17). Independente do tipo do iterável passado a sorted, ela sempre cria e devolve uma nova lista.

Tanto list.sort quanto sorted podem receber dois argumentos de palavra-chave opcionais:

reverse

Se True, os itens são devolvidos em ordem decrescente (isto é, invertendo a comparação dos itens). O default é False.

key

Uma função com um argumento que será aplicada a cada item, para produzir sua chave de ordenação. Por exemplo, ao ordenar uma lista de strings, key=str.lower pode ser usada para realizar uma ordenação sem levar em conta maiúsculas e minúsculas, e key=len irá ordenar as strings pela quantidade de caracteres. O default é a função identidade (isto é, os itens propriamente ditos são comparados).

👉 Dica

Também se pode usar o parâmetro de palavra-chave opcional key com as funções embutidas min() e max(), e com outras funções da biblioteca padrão (por exemplo, itertools.groupby() e heapq.nlargest()).

Aqui estão alguns exemplos para esclarecer o uso dessas funções e dos argumentos de palavra-chave. Os exemplos também demonstram que o algoritmo de ordenação do Python é estável (isto é, ele preserva a ordem relativa de itens que resultam iguais na comparação):[18]

>>> fruits = ['grape', 'raspberry', 'apple', 'banana']
>>> sorted(fruits)
['apple', 'banana', 'grape', 'raspberry']  (1)
>>> fruits
['grape', 'raspberry', 'apple', 'banana']  (2)
>>> sorted(fruits, reverse=True)
['raspberry', 'grape', 'banana', 'apple']  (3)
>>> sorted(fruits, key=len)
['grape', 'apple', 'banana', 'raspberry']  (4)
>>> sorted(fruits, key=len, reverse=True)
['raspberry', 'banana', 'grape', 'apple']  (5)
>>> fruits
['grape', 'raspberry', 'apple', 'banana']  (6)
>>> fruits.sort()                          (7)
>>> fruits
['apple', 'banana', 'grape', 'raspberry']  (8)
  1. Isso produz uma lista de strings ordenadas alfabeticamente.[19]

  2. Inspecionando a lista original, vemos que ela não mudou.

  3. Isso é a ordenação "alfabética" anterior, invertida.

  4. Uma nova lista de strings, agora ordenada por tamanho. Como o algoritmo de ordenação é estável, "grape" e "apple," ambas com tamanho 5, estão em sua ordem original.

  5. Essas são strings ordenadas por tamanho em ordem descendente. Não é o inverso do resultado anterior porque a ordenação é estável e então, novamente, "grape" aparece antes de "apple."

  6. Até aqui, a ordenação da lista fruits original não mudou.

  7. Isso ordena a lista no mesmo lugar, devolvendo None (que o console omite).

  8. Agora fruits está ordenada.

⚠️ Aviso

Por default, o Python ordena as strings lexicograficamente por código de caractere. Isso quer dizer que as letras maiúsculas ASCII virão antes das minúsculas, e que os caracteres não-ASCII dificilmente serão ordenados de forma razoável. A Seção 4.8 trata de maneiras corretas de ordenar texto da forma esperada por seres humanos.

Uma vez ordenadas, podemos realizar buscas em nossas sequências de forma muito eficiente. Um algoritmo de busca binária já é fornecido no módulo bisect da biblioteca padrão do Python. Aquele módulo também inclui a função bisect.insort, que você pode usar para assegurar que suas sequências ordenadas permaneçam ordenadas. Há uma introdução ilustrada ao módulo bisect no post "Managing Ordered Sequences with Bisect" (Gerenciando Sequências Ordenadas com Bisect) (EN) em fluentpython.com, o website que complementa este livro.

Muito do que vimos até aqui neste capítulo se aplica a sequências em geral, não apenas a listas ou tuplas. Programadores Python às vezes usam excessivamente o tipo list, por ele ser tão conveniente—eu mesmo já fiz isso. Por exemplo, se você está processando grandes listas de números, deveria considerar usar arrays em vez de listas. O restante do capítulo é dedicado a alternativas a listas e tuplas.

2.10. Quando uma lista não é a resposta

O tipo list é flexível e fácil de usar mas, dependendo dos requerimentos específicos, há opções melhores. Por exemplo, um array economiza muita memória se você precisa manipular milhões de valores de ponto flutuante. Por outro lado, se você está constantemente acrescentando e removendo itens das pontas opostas de uma lista, é bom saber que um deque (uma fila com duas pontas) é uma estrutura de dados FIFO[20] mais eficiente.

👉 Dica

Se seu código frequentemente verifica se um item está presente em uma coleção (por exemplo, item in my_collection), considere usar um set para my_collection, especialmente se ela contiver um número grande de itens. Um set é otimizado para verificação rápida de presença de itens. Eles também são iteráveis, mas não são coleções, porque a ordenação de itens de sets não é especificada. Vamos falar deles no Capítulo 3.

O restante desse capítulo discute tipos mutáveis de sequências que, em muitos casos, podem substituir as listas. Começamos pelos arrays.

2.10.1. Arrays

Se uma lista contém apenas números, uma array.array é um substituto mais eficiente. Arrays suportam todas as operações das sequências mutáveis (incluindo .pop, .insert, e .extend), bem como métodos adicionais para carregamento e armazenamento rápidos, tais como .frombytes e .tofile.

Um array do Python quase tão enxuto quanto um array do C. Como mostrado na Figura 1, um array de valores float não mantém instâncias completas de float, mas apenas pacotes de bytes representando seus valores em código de máquina—de forma similar a um array de double na linguagem C. Ao criar um array, você fornece um código de tipo (typecode), uma letra que determina o tipo C subjacente usado para armazenar cada item no array. Por exemplo, b é o código de tipo para o que o C chama de signed char, um inteiro variando de -128 a 127. Se você criar uma array('b'), então cada item será armazenado em um único byte e será interpretado como um inteiro. Para grandes sequências de números, isso economiza muita memória. E o Python não permite que você insira qualquer número que não corresponda ao tipo do array.

O Exemplo 19 mostra a criação, o armazenamento e o carregamento de um array de 10 milhões de números de ponto flutuante aleatórios.

Exemplo 19. Criando, armazenando e carregando uma grande array de números de ponto flutuante.
>>> from array import array  (1)
>>> from random import random
>>> floats = array('d', (random() for i in range(10**7)))  (2)
>>> floats[-1]  (3)
0.07802343889111107
>>> fp = open('floats.bin', 'wb')
>>> floats.tofile(fp)  (4)
>>> fp.close()
>>> floats2 = array('d')  (5)
>>> fp = open('floats.bin', 'rb')
>>> floats2.fromfile(fp, 10**7)  (6)
>>> fp.close()
>>> floats2[-1]  (7)
0.07802343889111107
>>> floats2 == floats  (8)
True
  1. Importa o tipo array.

  2. Cria um array de números de ponto flutuante de dupla precisão (código de tipo 'd') a partir de qualquer objeto iterável—nesse caso, uma expressão geradora.

  3. Inspeciona o último número no array.

  4. Salva o array em um arquivo binário.

  5. Cria um array vazio de números de ponto flutuante de dupla precisão

  6. Lê 10 milhões de números do arquivo binário.

  7. Inspeciona o último número no array.

  8. Verifica a igualdade do conteúdo dos arrays

Como você pode ver, array.tofile e array.fromfile são fáceis de usar. Se você rodar o exemplo, verá que são também muito rápidos. Um pequeno experimento mostra que array.fromfile demora aproximadamente 0,1 segundos para carregar 10 milhões de números de ponto flutuante de dupla precisão de um arquivo binário criado com array.tofile. Isso é quase 60 vezes mais rápido que ler os números de um arquivo de texto, algo que também exige passar cada linha para a função embutida float. Salvar o arquivo com array.tofile é umas sete vezes mais rápido que escrever um número de ponto flutuante por vez em um arquivo de texto. Além disso, o tamanho do arquivo binário com 10 milhões de números de dupla precisão é de 80.000.000 bytes (8 bytes por número, zero excesso), enquanto o arquivo de texto ocupa 181.515.739 bytes para os mesmos dados.

Para o caso específico de arrays numéricas representando dados binários, tal como bitmaps de imagens, o Python tem os tipos bytes e bytearray, discutidos na Capítulo 4.

Vamos encerrar essa seção sobre arrays com a Tabela 5, comparando as características de list e array.array.

Tabela 5. Métodos e atributos encontrados em list ou array (os métodos descontinuados de array e aqueles implementados também pir object foram omitidos para preservar espaço)
list array  

s.__add__(s2)

s + s2—concatenação

s.__iadd__(s2)

s += s2—concatenação no mesmo lugar

s.append(e)

Acrescenta um elemento após o último

s.byteswap()

Permuta os bytes de todos os itens do array para conversão de endianness (ordem de interpretação bytes)

s.clear()

Apaga todos os itens

s.__contains__(e)

e in s

s.copy()

Cópia rasa da lista

s.__copy__()

Suporte a copy.copy

s.count(e)

Conta as ocorrências de um elemento

s.__deepcopy__()

Suporte otimizado a copy.deepcopy

s.__delitem__(p)

Remove item na posição p

s.extend(it)

Acrescenta itens a partir do iterável it

s.frombytes(b)

Acrescenta itens de uma sequência de bytes, interpretada como valores em código de máquina empacotados

s.fromfile(f, n)

Acrescenta n itens de um arquivo binário f, interpretado como valores em código de máquina empacotados

s.fromlist(l)

Acrescenta itens de lista; se um deles causar um TypeError, nenhum item é acrescentado

s.__getitem__(p)

s[p]—obtém o item ou fatia na posição

s.index(e)

Encontra a posição da primeira ocorrência de e

s.insert(p, e)

Insere elemento e antes do item na posição p

s.itemsize

Tamanho em bytes de cada item do array

s.__iter__()

Obtém iterador

s.__len__()

len(s)—número de itens

s.__mul__(n)

s * n—concatenação repetida

s.__imul__(n)

s *= n—concatenação repetida no mesmo lugar

s.__rmul__(n)

n * s—concatenação repetida invertida[21]

s.pop([p])

Remove e devolve o item na posição p (default: o último)

s.remove(e)

Remove a primeira ocorrência do elemento e por valor

s.reverse()

Reverte a ordem dos itens no mesmo lugar

s.__reversed__()

Obtém iterador para percorrer itens do último até o primeiro

s.__setitem__(p, e)

s[p] = e—coloca e na posição p, sobrescrevendo item ou fatia existente

s.sort([key], [reverse])

Ordena itens no mesmo lugar, com os argumentos de palavra-chave opcionais key e reverse

s.tobytes()

Devolve itens como pacotes de valores em código de máquina em um objeto bytes

s.tofile(f)

Grava itens como pacotes de valores em código de máquina no arquivo binário f

s.tolist()

Devolve os itens como objetos numéricos em uma list

s.typecode

String de um caractere identificando o tipo em C dos itens

👉 Dica

Até o Python 3.10, o tipo array ainda não tem um método sort equivalente a list.sort(), que reordena os elementos na própria estrutura de dados, sem copiá-la. Se você precisa ordenar um array, use a função embutida sorted para reconstruir o array:

a = array.array(a.typecode, sorted(a))

Para manter a ordem de um array ordenado ao acrescentar novos itens, use a função bisect.insort.

Se você trabalha muito com arrays e não conhece memoryview, está perdendo oportunidades. Veja o próximo tópico.

2.10.2. Views de memória

A classe embutida memoryview é um tipo sequência de memória compartilhada, que permite manipular fatias de arrays sem copiar bytes. Ela foi inspirada pela biblioteca NumPy (que discutiremos brevemente, na Seção 2.10.3). Travis Oliphant, autor principal da NumPy, responde assim à questão "When should a memoryview be used?" Quando se deve usar uma memoryview?:

Uma memoryview é essencialmente uma estrutura de array Numpy generalizada dentro do próprio Python (sem a matemática). Ela permite compartilhar memória entre estruturas de dados (coisas como imagens PIL, bancos de dados SQLite, arrays da NumPy, etc.) sem copiar antes. Isso é muito importante para conjuntos grandes de dados.

Usando uma notação similar ao módulo array, o método memoryview.cast permite mudar a forma como múltiplos bytes são lidos ou escritos como unidades, sem a necessidade de mover os bits. memoryview.cast devolve ainda outro objeto memoryview, sempre compartilhando a mesma memória.

O Exemplo 20 mostra como criar views alternativas da mesmo array de 6 bytes, para operar com ele como uma matriz de 2x3 ou de 3x2.

Exemplo 20. Manipular 6 bytes de memória como views de 1×6, 2×3, e 3×2
>>> from array import array
>>> octets = array('B', range(6))  # (1)
>>> m1 = memoryview(octets)  # (2)
>>> m1.tolist()
[0, 1, 2, 3, 4, 5]
>>> m2 = m1.cast('B', [2, 3])  # (3)
>>> m2.tolist()
[[0, 1, 2], [3, 4, 5]]
>>> m3 = m1.cast('B', [3, 2])  # (4)
>>> m3.tolist()
[[0, 1], [2, 3], [4, 5]]
>>> m2[1,1] = 22  # (5)
>>> m3[1,1] = 33  # (6)
>>> octets  # (7)
array('B', [0, 1, 2, 33, 22, 5])
  1. Cria um array de 6 bytes (código de tipo 'B').

  2. Cria uma memoryview a partir daquele array, e a exporta como uma lista.

  3. Cria uma nova memoryview a partir da anterior, mas com 2 linhas e 3 colunas.

  4. Ainda outra memoryview, agora com 3 linhas e 2 colunas.

  5. Sobrescreve o byte em m2, na linha 1, coluna 1 com 22.

  6. Sobrescreve o byte em m3, na linha 1, coluna 1 com 33.

  7. Mostra o array original, provando que a memória era compartilhada entre octets, m1, m2, e m3.

O fantástico poder de memoryview também pode ser usado para o mal. O Exemplo 21 mostra como mudar um único byte de um item em um array de inteiros de 16 bits.

Exemplo 21. Mudando o valor de um item em um array de inteiros de 16 bits trocando apenas o valor de um de seus bytes
>>> numbers = array.array('h', [-2, -1, 0, 1, 2])
>>> memv = memoryview(numbers)  (1)
>>> len(memv)
5
>>> memv[0]  (2)
-2
>>> memv_oct = memv.cast('B')  (3)
>>> memv_oct.tolist()  (4)
[254, 255, 255, 255, 0, 0, 1, 0, 2, 0]
>>> memv_oct[5] = 4  (5)
>>> numbers
array('h', [-2, -1, 1024, 1, 2])  (6)
  1. Cria uma memoryview a partir de um array de 5 inteiros com sinal de 16 bits (código de tipo 'h').

  2. memv vê os mesmos 5 itens no array.

  3. Cria memv_oct, transformando os elementos de memv em bytes (código de tipo 'B').

  4. Exporta os elementos de memv_oct como uma lista de 10 bytes, para inspeção.

  5. Atribui o valor 4 ao byte com offset 5.

  6. Observe a mudança em numbers: um 4 no byte mais significativo de um inteiro de 2 bytes sem sinal é 1024.

✒️ Nota

Você pode ver um exemplo de inspeção de uma memoryview com o pacote struct em fluentpython.com: "Parsing binary records with struct" Analisando registros binários com struct (EN).

Enquanto isso, se você está fazendo processamento numérico avançado com arrays, deveria estar usando as bibliotecas NumPy. Vamos agora fazer um breve passeio por elas.

2.10.3. NumPy

Por todo esse livro, procuro destacar o que já existe na biblioteca padrão do Python, para que você a aproveite ao máximo. Mas a NumPy é tão maravilhosa que exige um desvio.

Por suas operações avançadas de arrays e matrizes, o Numpy é a razão pela qual o Python se tornou uma das principais linguagens para aplicações de computação científica. A Numpy implementa tipos multidimensionais e homogêneos de arrays e matrizes, que podem conter não apenas números, mas também registros definidos pelo usuário. E fornece operações eficientes ao nível desses elementos.

A SciPy é uma biblioteca criada usando a NumPy, e oferece inúmeros algoritmos de computação científica, incluindo álgebra linear, cálculo numérico e estatística. A SciPy é rápida e confiável porque usa a popular base de código C e Fortran do Repositório Netlib. Em outras palavras, a SciPy dá a cientistas o melhor de dois mundos: um prompt iterativo e as APIs de alto nível do Python, junto com funções estáveis e de eficiência comprovada para processamento de números, otimizadas em C e Fortran

O Exemplo 22, uma amostra muito rápida da Numpy, demonstra algumas operações básicas com arrays bi-dimensionais.

Exemplo 22. Operações básicas com linhas e colunas em uma numpy.ndarray
>>> import numpy as np (1)
>>> a = np.arange(12)  (2)
>>> a
array([ 0,  1,  2,  3,  4,  5,  6,  7,  8,  9, 10, 11])
>>> type(a)
<class 'numpy.ndarray'>
>>> a.shape  (3)
(12,)
>>> a.shape = 3, 4  (4)
>>> a
array([[ 0,  1,  2,  3],
       [ 4,  5,  6,  7],
       [ 8,  9, 10, 11]])
>>> a[2]  (5)
array([ 8,  9, 10, 11])
>>> a[2, 1]  (6)
9
>>> a[:, 1]  (7)
array([1, 5, 9])
>>> a.transpose()  (8)
array([[ 0,  4,  8],
       [ 1,  5,  9],
       [ 2,  6, 10],
       [ 3,  7, 11]])
  1. Importa a NumPy, que precisa ser instalada previamente (ela não faz parte da biblioteca padrão do Python). Por convenção, numpy é importada como np.

  2. Cria e inspeciona uma numpy.ndarray com inteiros de 0 a 11.

  3. Inspeciona as dimensões do array: essa é um array com uma dimensão e 12 elementos.

  4. Muda o formato do array, acrescentando uma dimensão e depois inspecionando o resultado.

  5. Obtém a linha no índice 2

  6. Obtém elemento na posição 2, 1.

  7. Obtém a coluna no índice 1

  8. Cria um novo array por transposição (permutando as colunas com as linhas)

A NumPy também suporta operações de alto nível para carregar, salvar e operar sobre todos os elementos de uma numpy.ndarray:

>>> import numpy
>>> floats = numpy.loadtxt('floats-10M-lines.txt')  (1)
>>> floats[-3:]  (2)
array([ 3016362.69195522,   535281.10514262,  4566560.44373946])
>>> floats *= .5  (3)
>>> floats[-3:]
array([ 1508181.34597761,   267640.55257131,  2283280.22186973])
>>> from time import perf_counter as pc (4)
>>> t0 = pc(); floats /= 3; pc() - t0 (5)
0.03690556302899495
>>> numpy.save('floats-10M', floats)  (6)
>>> floats2 = numpy.load('floats-10M.npy', 'r+')  (7)
>>> floats2 *= 6
>>> floats2[-3:]  (8)
memmap([ 3016362.69195522,   535281.10514262,  4566560.44373946])
  1. Carrega 10 milhões de números de ponto flutuante de um arquivo de texto.

  2. Usa a notação de fatiamento de sequência para inspecionar os três últimos números.

  3. Multiplica cada elemento no array floats por .5 e inspeciona novamente os três últimos elementos.

  4. Importa o cronômetro de medida de tempo em alta resolução (disponível desde o Python 3.3).

  5. Divide cada elemento por 3; o tempo decorrido para dividir os 10 milhões de números de ponto flutuante é menos de 40 milissegundos.

  6. Salva o array em um arquivo binário .npy.

  7. Carrega os dados como um arquivo mapeado na memória em outro array; isso permite o processamento eficiente de fatias do array, mesmo que ele não caiba inteiro na memória.

  8. Inspeciona os três últimos elementos após multiplicar cada elemento por 6.

Mas isso foi apenas um aperitivo.

A NumPy e a SciPy são bibliotecas formidáveis, e estão na base de outras ferramentas fantásticas, como a Pandas (EN)—que implementa tipos eficientes de arrays capazes de manter dados não-numéricos, e fornece funções de importação/exportação em vários formatos diferentes, como .csv, .xls, dumps SQL, HDF5, etc.—e a scikit-learn (EN), o conjunto de ferramentas para Aprendizagem de Máquina mais usado atualmente. A maior parte das funções da NumPy e da SciPy são implementadas em C ou C++, e conseguem aproveitar todos os núcleos de CPU disponíveis, pois podem liberar a GIL (Global Interpreter Lock, Trava Global do Interpretador) do Python. O projeto Dask suporta a paralelização do processamento da NumPy, da Pandas e da scikit-learn para grupos (clusters) de máquinas. Esses pacotes merecem livros inteiros. Este não é um desses livros, mas nenhuma revisão das sequências do Python estaria completa sem pelo menos uma breve passagem pelos arrays da NumPy.

Tendo olhado as sequências planas—arrays padrão e arrays da NumPy—vamos agora nos voltar para um grupo completamente diferentes de substitutos para a boa e velha list: filas (queues).

2.10.4. Deques e outras filas

Os métodos .append e .pop tornam uma list usável como uma pilha (stack) ou uma fila (queue) (usando .append e .pop(0), se obtém um comportamento FIFO). Mas inserir e remover da cabeça de uma lista (a posição com índice 0) é caro, pois a lista toda precisa ser deslocada na memória.

A classe collections.deque é uma fila de duas pontas e segura para usar com threads, projetada para inserção e remoção rápida nas duas pontas. É também a estrutura preferencial se você precisa manter uma lista de "últimos itens vistos" ou coisa semelhante, pois um deque pode ser delimitado—isto é, criado com um tamanho máximo fixo. Se um deque delimitado está cheio, quando se adiciona um novo item, o item na ponta oposta é descartado. O Exemplo 23 mostra algumas das operações típicas com um deque.

Exemplo 23. Usando um deque
>>> from collections import deque
>>> dq = deque(range(10), maxlen=10)  (1)
>>> dq
deque([0, 1, 2, 3, 4, 5, 6, 7, 8, 9], maxlen=10)
>>> dq.rotate(3)  (2)
>>> dq
deque([7, 8, 9, 0, 1, 2, 3, 4, 5, 6], maxlen=10)
>>> dq.rotate(-4)
>>> dq
deque([1, 2, 3, 4, 5, 6, 7, 8, 9, 0], maxlen=10)
>>> dq.appendleft(-1)  (3)
>>> dq
deque([-1, 1, 2, 3, 4, 5, 6, 7, 8, 9], maxlen=10)
>>> dq.extend([11, 22, 33])  (4)
>>> dq
deque([3, 4, 5, 6, 7, 8, 9, 11, 22, 33], maxlen=10)
>>> dq.extendleft([10, 20, 30, 40])  (5)
>>> dq
deque([40, 30, 20, 10, 3, 4, 5, 6, 7, 8], maxlen=10)
  1. O argumento opcional maxlen determina o número máximo de itens permitidos nessa instância de deque; isso estabelece o valor de um atributo de instância maxlen, somente de leitura.

  2. Rotacionar com n > 0 retira itens da direita e os recoloca pela esquerda; quando n < 0, os itens são retirados pela esquerda e anexados pela direita.

  3. Acrescentar itens a um deque cheio (len(d) == d.maxlen) elimina itens da ponta oposta. Observe, na linha seguinte, que o 0 foi descartado.

  4. Acrescentar três itens à direita derruba -1, 1, e 2 da extremidade esquerda.

  5. Observe que extendleft(iter) acrescenta cada item sucessivo do argumento iter do lado esquerdo do deque, então a posição final dos itens é invertida.

A Tabela 6 compara os métodos específicos de list e deque (omitindo aqueles que também aparecem em object).

Veja que deque implementa a maioria dos métodos de list, acrescentando alguns específicos ao seu modelo, como popleft e rotate. Mas há um custo oculto: remover itens do meio de um deque não é rápido. A estrutura é realmente otimizada para acréscimos e remoções pelas pontas.

As operações append e popleft são atômicas, então deque pode ser usado de forma segura como uma fila FIFO em aplicações multithread sem a necessidade de travas.

Tabela 6. Métodos implementados em list ou deque (aqueles também implementados por object foram omitidos para preservar espaço)
list deque  

s.__add__(s2)

s + s2—concatenação

s.__iadd__(s2)

s += s2—concatenação no mesmo lugar

s.append(e)

Acrescenta um elemento à direita (após o último)

s.appendleft(e)

Acrescenta um elemento à esquerda (antes do primeiro)

s.clear()

Apaga todos os itens

s.__contains__(e)

e in s

s.copy()

Cópia rasa da lista

s.__copy__()

Suporte a copy.copy (cópia rasa)

s.count(e)

Conta ocorrências de um elemento

s.__delitem__(p)

Remove item na posição p

s.extend(i)

Acrescenta item do iterável i pela direita

s.extendleft(i)

Acrescenta item do iterável i pela esquerda

s.__getitem__(p)

s[p]—obtém item ou fatia na posição

s.index(e)

Encontra a primeira ocorrência de e

s.insert(p, e)

Insere elemento e antes do item na posição p

s.__iter__()

Obtém iterador

s.__len__()

len(s)—número de itens

s.__mul__(n)

s * n—concatenação repetida

s.__imul__(n)

s *= n—concatenação repetida no mesmo lugar

s.__rmul__(n)

n * s—concatenação repetida invertida[22]

s.pop()

Remove e devolve último item[23]

s.popleft()

Remove e devolve primeiro item

s.remove(e)

Remove primeira ocorrência do elemento e por valor

s.reverse()

Inverte a ordem do itens no mesmo lugar

s.__reversed__()

Obtém iterador para percorrer itens, do último para o primeiro

s.rotate(n)

Move n itens de um lado para o outro

s.__setitem__(p, e)

s[p] = e—coloca e na posição p, sobrescrevendo item ou fatia existentes

s.sort([key], [reverse])

Ordena os itens no mesmo lugar, com os argumentos de palavra-chave opcionais key e reverse

Além de deque, outros pacotes da biblioteca padrão do Python implementam filas:

queue

Fornece as classes sincronizadas (isto é, seguras para se usar com múltiplas threads) SimpleQueue, Queue, LifoQueue, e PriorityQueue. Essas classes podem ser usadas para comunicação segura entre threads. Todas, exceto SimpleQueue, podem ser delimitadas passando um argumento maxsize maior que 0 ao construtor. Entretanto, elas não descartam um item para abrir espaço, como faz deque. Em vez disso, quando a fila está lotada, a inserção de um novo item bloqueia quem tentou inserir—isto é, ela espera até alguma outra thread criar espaço retirando um item da fila, algo útil para limitar o número de threads ativas.

multiprocessing

Implementa sua própria SimpleQueue, não-delimitada, e Queue, delimitada, muito similares àquelas no pacote queue, mas projetadas para comunicação entre processos. Uma fila especializada, multiprocessing.JoinableQueue, é disponibilizada para gerenciamento de tarefas.

asyncio

Fornece Queue, LifoQueue, PriorityQueue, e JoinableQueue com APIs inspiradas pelas classes nos módulos queue e multiprocessing, mas adaptadas para gerenciar tarefas em programação assíncrona.

heapq

Diferente do últimos três módulos, heapq não implementa a classe queue, mas oferece funções como heappush e heappop, que permitem o uso de uma sequência mutável como uma fila do tipo heap ou como uma fila de prioridade.

Aqui termina nossa revisão das alternativas ao tipo list, e também nossa exploração dos tipos sequência em geral—exceto pelas especificidades de str e das sequências binárias, que tem seu próprio capítulo (Capítulo 4).

2.11. Resumo do capítulo

Dominar o uso dos tipos sequência da biblioteca padrão é um pré-requisito para escrever código Python conciso, eficiente e idiomático.

As sequências do Python são geralmente categorizadas como mutáveis ou imutáveis, mas também é útil considerar um outro eixo: sequências planas e sequências contêiner. As primeiras são mais compactas, mais rápidas e mais fáceis de usar, mas estão limitadas a armazenar dados atômicos como números, caracteres e bytes. As sequências contêiner são mais flexíveis, mas podem surpreender quando contêm objetos mutáveis. Então, quando armazenando estruturas de dados aninhadas, é preciso ter cuidado para usar tais sequências da forma correta.

Infelizmente o Python não tem um tipo de sequência contêiner imutável infalível: mesmo as tuplas "imutáveis" podem ter seus valores modificados quando contêm itens mutáveis como listas ou objetos definidos pelo usuário.

Compreensões de lista e expressões geradoras são notações poderosas para criar e inicializar sequências. Se você ainda não se sente confortável com essas técnicas, gaste o tempo necessário para aprender seu uso básico. Não é difícil, e você logo vai estar gostando delas.

As tuplas no Python tem dois papéis: como registros de campos sem nome e como listas imutáveis. Ao usar uma tupla como uma lista imutável, lembre-se que só é garantido que o valor de uma tupla será fixo se todos os seus itens também forem imutáveis. Chamar hash(t) com a tupla como argumento é uma forma rápida de se assegurar que seu valor é fixo. Se t contiver itens mutáveis, um TypeError é gerado.

Quando uma tupla é usada como registro, o desempacotamento de tuplas é a forma mais segura e legível de extrair seus campos. Além das tuplas, * funciona com listas e iteráveis em vários contextos, e alguns de seus casos de uso apareceram no Python 3.5 com a PEP 448—Additional Unpacking Generalizations (Generalizações de Desempacotamento Adicionais) (EN). O Python 3.10 introduziu o pattern matching com match/case, suportando um tipo de desempacotamento mais poderoso, conhecido como desestruturação.

Fatiamento de sequências é um dos recursos de sintaxe preferidos do Python, e é ainda mais poderoso do que muita gente pensa. Fatiamento multidimensional e a notação de reticências (...), como usados no NumPy, podem também ser suportados por sequências definidas pelo usuário. Atribuir a fatias é uma forma muito expressiva de editar sequências mutáveis.

Concatenação repetida, como em seq * n, é conveniente e, tomando cuidado, pode ser usada para inicializar listas de listas contendo itens imutáveis. Atribuição aumentada com += e *= se comporta de forma diferente com sequências mutáveis e imutáveis. No último caso, esses operadores necessariamente criam novas sequências. Mas se a sequência alvo é mutável, ela em geral é modificada no lugar—mas nem sempre, depende de como a sequência é implementada.

O método sort e a função embutida sorted são fáceis de usar e flexíveis, graças ao argumento opcional key: uma função para calcular o critério de ordenação. E aliás, key também pode ser usado com as funções embutidas min e max.

Além de listas e tuplas, a biblioteca padrão do Python oferece array.array. Apesar da NumPy e da SciPy não serem parte da biblioteca padrão, se você faz qualquer tipo de processamento numérico em grandes conjuntos de dados, estudar mesmo uma pequena parte dessas bibliotecas pode levar você muito longe.

Terminamos com uma visita à versátil collections.deque, também segura para usar com threads. Comparamos sua API com a de list na Tabela 6 e mencionamos as outras implementações de filas na biblioteca padrão.

2.12. Leitura complementar

O capítulo 1, "Data Structures" (Estruturas de Dados) do Python Cookbook, 3rd ed. (EN) (O’Reilly), de David Beazley e Brian K. Jones, traz muitas receitas usando sequências, incluindo a "Recipe 1.11. Naming a Slice" (Receita 1.11. Nomeando uma Fatia), onde aprendi o truque de atribuir fatias a variáveis para melhorar a legibilidade, como ilustrado no nosso Exemplo 13.

A segunda edição do Python Cookbook foi escrita para Python 2.4, mas a maior parte de seu código funciona com Python 3, e muitas das receitas dos capítulos 5 e 6 lidam com sequências. O livro foi editado por Alex Martelli, Anna Ravenscroft, e David Ascher, e inclui contribuições de dúzias de pythonistas. A terceira edição foi reescrita do zero, e se concentra mais na semântica da linguagem—especialmente no que mudou no Python 3—enquanto o volume mais antigo enfatiza a pragmática (isto é, como aplicar a linguagem a problemas da vida real). Apesar de algumas das soluções da segunda edição não serem mais a melhor abordagem, eu honestamente acho que vale a pena ter à mão as duas edições do Python Cookbook.

O "HowTo - Ordenação" oficial do Python tem vários exemplos de técnicas avançadas de uso de sorted e list.sort.

A PEP 3132—​Extended Iterable Unpacking (Desempacotamento Iterável Estendido) (EN) é a fonte canônica para ler sobre o novo uso da sintaxe *extra no lado esquerdo de atribuições paralelas. Se você quiser dar uma olhada no processo de evolução do Python, "Missing *-unpacking generalizations" (As generalizações esquecidas de * no desempacotamento) (EN) é um tópico do bug tracker propondo melhorias na notação de desempacotamento iterável. PEP 448—​Additional Unpacking Generalizations (Generalizações de Desempacotamento Adicionais) (EN) foi o resultado de discussões ocorridas naquele tópico.

Como mencionei na Seção 2.6, o texto introdutório "Correspondência de padrão estrutural", de Carol Willing, no "O que há de novo no Python 3.10", é uma ótima introdução a esse novo grande recurso, em mais ou menos 1.400 palavras (isso é menos de 5 páginas quando o Firefox converte o HTML em PDF). A PEP 636—Structural Pattern Matching: Tutorial (Casamento de Padrões Estrutural: Tutorial) (EN) também é boa, mas mais longa. A mesma PEP 636 inclui o "Appendix A—Quick Intro" (Apêndice A-Introdução Rápida) (EN). Ele é menor que a introdução de Willing, porque omite as considerações gerais sobre os motivos pelos quais o pattern matching é bom para você. SE você precisar de mais argumentos para se convencer ou convencer outros que o pattern matching é bom para o Python, leia as 22 páginas de PEP 635—Structural Pattern Matching: Motivation and Rationale (_Casamento de Padrões Estrutural: Motivação e Justificativa) (EN).

Há muitos livros tratando da NumPy no mercado, e muitos não mencionam "NumPy" no título. Dois exemplos são o Python Data Science Handbook, escrito por Jake VanderPlas e de acesso aberto, e a segunda edição do Python for Data Analysis, de Wes McKinney.

"A Numpy é toda sobre vetorização". Essa é a frase de abertura do livro de acesso aberto From Python to NumPy, de Nicolas P. Rougier. Operações vetorizadas aplicam funções matemáticas a todos os elementos de um array sem um loop explícito escrito em Python. Elas podem operar em paralelo, usando instruções especiais de vetor presentes em CPUs modernas, tirando proveito de múltiplos núcleos ou delegando para a GPU, dependendo da biblioteca. O primeiro exemplo no livro de Rougier mostra um aumento de velocidade de 500 vezes, após a refatoração de uma bela classe pythônica, usando um método gerador, em uma pequena e feroz função que chama um par de funções de vetor da NumPy.

Para aprender a usar deque (e outras coleções), veja os exemplos e as receitas práticas em "Tipos de dados de contêineres", na documentação do Python.

A melhor defesa da convenção do Python de excluir o último item em faixas e fatias foi escrita pelo próprio Edsger W. Dijkstra, em uma nota curta intitulada "Why Numbering Should Start at Zero" (Porque a Numeração Deve Começar em Zero). O assunto da nota é notação matemática, mas ela é relevante para o Python porque Dijkstra explica, com humor e rigor, porque uma sequência como 2, 3, …​, 12 deveria sempre ser expressa como 2 ≤ i < 13. Todas as outras convenções razoáveis são refutadas, bem como a ideia de deixar cada usuário escolher uma convenção. O título se refere à indexação baseada em zero, mas a nota na verdade é sobre porque é desejável que 'ABCDE'[1:3] signifique 'BC' e não 'BCD', e porque faz todo sentido escrever range(2, 13) para produzir 2, 3, 4, …​, 12. E, por sinal, a nota foi escrita à mão, mas é linda e totalmente legível. A letra de Dijkstra é tão cristalina que alguém criou uma fonte a partir de suas anotações.

Ponto de Vista

A natureza das tuplas

Em 2012, apresentei um poster sobre a linguagem ABC na PyCon US. Antes de criar o Python, Guido van Rossum tinha trabalhado no interpretador ABC, então ele veio ver meu pôster. Entre outras coisas, falamos sobre como os compounds (compostos) da ABC, que são claramente os predecessores das tuplas do Python. Compostos também suportam atribuição paralela e são usados como chaves compostas em dicionários (ou tabelas, no jargão da ABC). Entretanto, compostos não são sequências, Eles não são iteráveis, e não é possível obter um campo por índice, muitos menos fatiá-los. Ou você manuseia o composto inteiro ou extrai os campos individuais usando atribuição paralela, e é isso.

Disse a Guido que essas limitações tornavam muito claro o principal propósito dos compostos: ele são apenas registros sem campos nomeados. Sua resposta: "Fazer as tuplas se comportarem como sequências foi uma gambiarra."

Isso ilustra a abordagem pragmática que tornou o Python mais prático e mais bem sucedido que a ABC. Da perspectiva de um implementador de linguagens, fazer as tuplas se comportarem como sequências custa pouco. Como resultado, o principal caso de uso de tuplas como registros não é tão óbvio, mas ganhamos listas imutáveis—mesmo que seu tipo não seja tão claramente nomeado como o de frozenlist.

Sequências planas versus sequências contêineres

Para realçar os diferentes modelos de memória dos tipos de sequências usei os termos sequência contêiner e sequência plana. A palavra "contêiner" vem da própria documentação do "Modelo de Dados":

Alguns objetos contêm referências a outros objetos; eles são chamados de contêineres.

Usei o termo "sequência contêiner" para ser específico, porque existem contêineres em Python que não são sequências, como dict e set. Sequências contêineres podem ser aninhadas porque elas podem conter objetos de qualquer tipo, incluindo seu próprio tipo.

Por outro lado, sequências planas são tipos de sequências que não podem ser aninhadas, pois só podem conter valores atômicos como inteiros, números de ponto flutuante ou caracteres.

Adotei o termo sequência plana porque precisava de algo para contrastar com "sequência contêiner."

Apesar dos uso anterior da palavra "containers" na documentação oficial, há uma classe abstrata em collections.abc chamada Container. Aquela ABC tem apenas um método, __contains__—o método especial por trás do operador in. Isso significa que arrays e strings, que não são contêineres no sentido tradicional, são subclasses virtuais de Container, porque implementam __contains__. Isso é só mais um exemplo de humanos usando uma mesma palavra para significar coisas diferentes. Nesse livro, vou escrever "contêiner" com minúscula e em português para "um objeto que contém referências para outros objetos" e Container com a inicial maiúscula em fonte mono espaçada para me referir a collections.abc.Container.

Listas bagunçadas

Textos introdutórios de Python costumam enfatizar que listas podem conter objetos de diferentes tipos, mas na prática esse recurso não é muito útil: colocamos itens em uma lista para processá-los mais tarde, o que implica o suporte, da parte de todos os itens, a pelo menos alguma operação em comum (isto é, eles devem todos "grasnar", independente de serem ou não 100% patos, geneticamente falando), Por exemplo, não é possível ordenar uma lista em Python 3 a menos que os itens ali contidos sejam comparáveis:

>>> l = [28, 14, '28', 5, '9', '1', 0, 6, '23', 19]
>>> sorted(l)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: unorderable types: str() < int()

Diferente das listas, as tuplas muitas vezes mantêm itens de tipos diferentes. Isso é natural: se cada item em uma tupla é um campo, então cada campo pode ter um tipo diferente.

'key' é brilhante

O argumento opcional key de list.sort, sorted, max, e min é uma grande ideia. Outras linguagens forçam você a fornecer uma função de comparação com dois argumentos, como a função descontinuada do Python 2 cmp(a, b). Usar key é mais simples e mais eficiente. É mais simples porque basta definir uma função de um único argumento que recupera ou calcula o critério a ser usado para ordenar seus objetos; isso é mais fácil que escrever uma função de dois argumentos para devolver –1, 0, 1. Também é mais eficiente, porque a função key é invocada apenas uma vez por item, enquanto a comparação de dois argumentos é chamada a cada vez que o algoritmo de ordenação precisa comparar dois itens. Claro, o Python também precisa comparar as chaves ao ordenar, mas aquela comparação é feita em código C otimizado, não em uma função Python escrita por você.

Por sinal, usando key podemos ordenar uma lista bagunçada de números e strings "parecidas com números". Só precisamos decidir se queremos tratar todos os itens como inteiros ou como strings:

>>> l = [28, 14, '28', 5, '9', '1', 0, 6, '23', 19]
>>> sorted(l, key=int)
[0, '1', 5, 6, '9', 14, 19, '23', 28, '28']
>>> sorted(l, key=str)
[0, '1', 14, 19, '23', 28, '28', 5, 6, '9']

A Oracle, o Google, e a Conspiração Timbot

O algoritmo de ordenação usado em sorted e list.sort é o Timsort, um algoritmo adaptativo que troca de estratégia de ordenação ( entre merge sort e insertion sort), dependendo de quão ordenados os dados já estão. Isso é eficiente porque dados reais tendem a ter séries de itens ordenados. Há um artigo da Wikipedia sobre ele.

O Timsort foi usado no CPython pela primeira vez em 2002. Desde 2009, o Timsort também é usado para ordenar arrays tanto no Java padrão quanto no Android, um fato que ficou muito conhecido quando a Oracle usou parte do código relacionado ao Timsort como evidência da violação da propriedade intelectual da Sun pelo Google. Por exemplo, veja essa ordem do Juiz William Alsup (EN) de 2012. Em 2021, a Suprema Corte dos Estados Unidos decidiu que o uso do código do Java pelo Google é "fair use"[24]

O Timsort foi inventado por Tim Peters, um dos desenvolvedores principais do Python, e tão produtivo que se acredita que ele seja uma inteligência artificial, o Timbot. Você pode ler mais sobre essa teoria da conspiração em "Python Humor" (EN). Tim também escreveu "The Zen of Python": import this.

3. Dicionários e conjuntos

O Python é feito basicamente de dicionários cobertos por muitas camadas de açúcar sintático

— Lalo Martins
pioneiro do nomadismo digital e pythonista

Usamos dicionários em todos os nossos programas Python. Se não diretamente em nosso código, então indiretamente, pois o tipo dict é um elemento fundamental da implementação do Python. Atributos de classes e de instâncias, espaços de nomes de módulos e argumentos nomeados de funções são alguns dos elementos fundamentais do Python representados na memória por dicionários. O __builtins__.__dict__ armazena todos os tipos, funções e objetos embutidos.

Por seu papel crucial, os dicts do Python são extremamente otimizados—e continuam recebendo melhorias. As Tabelas de hash são o motor por trás do alto desempenho dos dicts do Python.

Outros tipos embutidos baseados em tabelas de hash são set e frozenset. Eles oferecem uma API mais completa e operadores mais robustos que os conjuntos que você pode ter encontrado em outras linguagens populares. Em especial, os conjuntos do Python implementam todas as operações fundamentais da teoria dos conjuntos, como união, intersecção, testes de subconjuntos, etc. Com eles, podemos expressar algoritmos de forma mais declarativa, evitando o excesso de loops e condicionais aninhados.

Aqui está um breve esquema do capítulo:

  • A sintaxe moderna para criar e manipular dicts e mapeamentos, incluindo desempacotamento aumentado e pattern matching (casamento de padrões)

  • Métodos comuns dos tipos de mapeamentos

  • Tratamento especial para chaves ausentes

  • Variantes de dict na biblioteca padrão

  • Os tipos set e frozenset

  • As implicações das tabelas de hash no comportamento de conjuntos e dicionários

3.1. Novidades nesse capítulo

A maior parte das mudanças nessa segunda edição se concentra em novos recursos relacionados a tipos de mapeamento:

  • A Seção 3.2 fala da sintaxe aperfeiçoada de desempacotamento e de diferentes maneiras de mesclar mapeamentos—incluindo os operadores | e |=, suportados pelos dicts desde o Python 3.9.

  • A Seção 3.3 ilustra o manuseio de mapeamentos com match/case, recurso que surgiu no Python 3.10.

  • A Seção 3.6.1 agora se concentra nas pequenas mas ainda relevantes diferenças entre dict e OrderedDict—levando em conta que, desde o Python 3.6, dict passou a manter a ordem de inserção das chaves.

  • Novas seções sobre os objetos view devolvidos por dict.keys, dict.items, e dict.values: a Seção 3.8 e a Seção 3.12.

A implementação interna de dict e set ainda está alicerçada em tabelas de hash, mas o código de dict teve duas otimizações importantes, que economizam memória e preservam o ordem de inserção das chaves. As seções Seção 3.9 e Seção 3.11 resumem o que você precisa saber sobre isso para usar bem as estruturas efetadas.

✒️ Nota

Após acrescentar mais de 200 páginas a essa segunda edição, transferi a seção opcional "Internals of sets and dicts" (As entranhas dos sets e dos dicts) (EN) para o fluentpython.com, o site que complementa o livro. O post de 18 páginas (EN) foi atualizado e expandido, e inclui explicações e diagramas sobre:

  • O algoritmo de tabela de hash e as estruturas de dados, começando por seu uso em set, que é mais simples de entender.

  • A otimização de memória que preserva a ordem de inserção de chaves em instâncias de dict (desde o Python 3.6) .

  • O layout do compartilhamento de chaves em dicionários que mantêm atributos de instância—o __dict__ de objetos definidos pelo usuário (otimização implementada no Python 3.3).

3.2. A sintaxe moderna dos dicts

As próximas seções descrevem os recursos avançados de sintaxe para criação, desempacotamento e processamento de mapeamentos. Alguns desses recursos não são novos na linguagem, mas podem ser novidade para você. Outros requerem Python 3.9 (como o operador |) ou Python 3.10 (como match/case). Vamos começar por um dos melhores e mais antigos desses recursos.

3.2.1. Compreensões de dict

Desde o Python 2.7, a sintaxe das listcomps e genexps foi adaptada para compreensões de dict (e também compreensões de set, que veremos em breve). Uma dictcomp (compreensão de dict) cria uma instância de dict, recebendo pares key:value de qualquer iterável. O Exemplo 1 mostra o uso de compreensões de dict para criar dois dicionários a partir de uma mesma lista de tuplas.

Exemplo 1. Exemplos de compreensões de dict
>>> dial_codes = [                                                  # (1)
...     (880, 'Bangladesh'),
...     (55,  'Brazil'),
...     (86,  'China'),
...     (91,  'India'),
...     (62,  'Indonesia'),
...     (81,  'Japan'),
...     (234, 'Nigeria'),
...     (92,  'Pakistan'),
...     (7,   'Russia'),
...     (1,   'United States'),
... ]
>>> country_dial = {country: code for code, country in dial_codes}  # (2)
>>> country_dial
{'Bangladesh': 880, 'Brazil': 55, 'China': 86, 'India': 91, 'Indonesia': 62,
'Japan': 81, 'Nigeria': 234, 'Pakistan': 92, 'Russia': 7, 'United States': 1}
>>> {code: country.upper()                                          # (3)
...     for country, code in sorted(country_dial.items())
...     if code < 70}
{55: 'BRAZIL', 62: 'INDONESIA', 7: 'RUSSIA', 1: 'UNITED STATES'}
  1. Um iterável de pares chave-valor como dial_codes pode ser passado diretamente para o construtor de dict, mas…​

  2. …​aqui permutamos os pares: country é a chave, e code é o valor.

  3. Ordenando country_dial por nome, revertendo novamente os pares, colocando os valores em maiúsculas e filtrando os itens com code < 70.

Se você já está acostumada com as listcomps, as dictcomps são um próximo passo natural. Caso contrário, a propagação da sintaxe de compreensão mostra que agora é mais valioso que nunca se tornar fluente nessa técnica.

3.2.2. Desempacotando mapeamentos

A PEP 448—Additional Unpacking Generalizations (Generalizações de Desempacotamento Adicionais) melhorou o suporte ao desempacotamento de mapeamentos de duas formas, desde o Python 3.5.

Primeiro, podemos aplicar ** a mais de um argumento em uma chamada de função. Isso funciona quando todas as chaves são strings e únicas, para todos os argumentos (porque argumentos nomeados duplicados são proibidos):

>>> def dump(**kwargs):
...     return kwargs
...
>>> dump(**{'x': 1}, y=2, **{'z': 3})
{'x': 1, 'y': 2, 'z': 3}

Em segundo lugar, ** pode ser usado dentro de um literal dict—também múltiplas vezes:

>>> {'a': 0, **{'x': 1}, 'y': 2, **{'z': 3, 'x': 4}}
{'a': 0, 'x': 4, 'y': 2, 'z': 3}

Nesse caso, chaves duplicadas são permitidas. Cada ocorrência sobrescreve ocorrências anteriores—observe o valor mapeado para x no exemplo.

Essa sintaxe também pode ser usada para mesclar mapas, mas isso pode ser feito de outras formas. Siga comigo.

3.2.3. Fundindo mapeamentos com |

Desde a versão 3.9, Python suporta o uso de | e |= para mesclar mapeamentos. Isso faz todo sentido, já que estes são também os operadores de união de conjuntos.

O operador | cria um novo mapeamento:

>>> d1 = {'a': 1, 'b': 3}
>>> d2 = {'a': 2, 'b': 4, 'c': 6}
>>> d1 | d2
{'a': 2, 'b': 4, 'c': 6}

O tipo do novo mapeamento normalmente será o mesmo do operando da esquerda—no exemplo, d1—mas ele pode ser do tipo do segundo operando se tipos definidos pelo usuário estiverem envolvidos na operação, dependendo das regras de sobrecarga de operadores, que exploraremos no Capítulo 16.

Para atualizar mapeamentos existentes no mesmo lugar, use |=. Retomando o exemplo anterior, ali d1 não foi modificado. Mas aqui sim:

>>> d1
{'a': 1, 'b': 3}
>>> d1 |= d2
>>> d1
{'a': 2, 'b': 4, 'c': 6}
👉 Dica

Se você precisa manter código rodando no Python 3.8 ou anterior, a seção "Motivation" (Motivação) (EN) da PEP 584—Add Union Operators To dict (Acrescentar Operadores de União a dict) (EN) inclui um bom resumo das outras formas de mesclar mapeamentos.

Agora vamos ver como o pattern matching se aplica aos mapeamentos.

3.3. Pattern matching com mapeamentos

A instrução match/case suporta sujeitos que sejam objetos mapeamento. Padrões para mapeamentos se parecem com literais dict, mas podem casar com instâncias de qualquer subclasse real ou virtual de collections.abc.Mapping.[25]

No Capítulo 2 nos concentramos apenas nos padrões de sequência, mas tipos diferentes de padrões podem ser combinados e aninhados. Graças à desestruturação, o pattern matching é uma ferramenta poderosa para processar registros estruturados como sequências e mapeamentos aninhados, que frequentemente precisamos ler de APIs JSON ou bancos de dados com schemas semi-estruturados, como o MongoDB, o EdgeDB, ou o PostgreSQL. O Exemplo 2 demonstra isso.

As dicas de tipo simples em get_creators tornam claro que ela recebe um dict e devolve uma list.

Exemplo 2. creator.py: get_creators() extrai o nome dos criadores em registros de mídia
def get_creators(record: dict) -> list:
    match record:
        case {'type': 'book', 'api': 2, 'authors': [*names]}:  # (1)
            return names
        case {'type': 'book', 'api': 1, 'author': name}:  # (2)
            return [name]
        case {'type': 'book'}:  # (3)
            raise ValueError(f"Invalid 'book' record: {record!r}")
        case {'type': 'movie', 'director': name}:  # (4)
            return [name]
        case _:  # (5)
            raise ValueError(f'Invalid record: {record!r}')
  1. Casa com qualquer mapeamento na forma 'type': 'book', 'api' :2, e uma chave 'authors' mapeada para uma sequência. Devolve os itens da sequência, como uma nova list.

  2. Casa com qualquer mapeamento na forma 'type': 'book', 'api' :1, e uma chave 'author' mapeada para qualquer objeto. Devolve aquele objeto dentro de uma list.

  3. Qualquer outro mapeamento na forma 'type': 'book' é inválido e gera um ValueError.

  4. Casa qualquer mapeamento na forma 'type': 'movie' e uma chave 'director' mapeada para um único objeto. Devolve o objeto dentro de uma list.

  5. Qualquer outro sujeito é inválido e gera um ValueError.

O Exemplo 2 mostra algumas práticas úteis para lidar com dados semi-estruturados, tais como registros JSON:

  • Incluir um campo descrevendo o tipo de registro (por exemplo, 'type': 'movie')

  • Incluir um campo identificando a versão do schema (por exemplo, 'api': 2'), para permitir evoluções futuras das APIs públicas.

  • Ter cláusulas case para processar registros inválidos de um tipo específico (por exemplo, 'book'), bem como um case final para capturar tudo que tenha passado pelas condições anteriores.

Agora vamos ver como get_creators se comporta com alguns doctests concretos:

>>> b1 = dict(api=1, author='Douglas Hofstadter',
...         type='book', title='Gödel, Escher, Bach')
>>> get_creators(b1)
['Douglas Hofstadter']
>>> from collections import OrderedDict
>>> b2 = OrderedDict(api=2, type='book',
...         title='Python in a Nutshell',
...         authors='Martelli Ravenscroft Holden'.split())
>>> get_creators(b2)
['Martelli', 'Ravenscroft', 'Holden']
>>> get_creators({'type': 'book', 'pages': 770})
Traceback (most recent call last):
    ...
ValueError: Invalid 'book' record: {'type': 'book', 'pages': 770}
>>> get_creators('Spam, spam, spam')
Traceback (most recent call last):
    ...
ValueError: Invalid record: 'Spam, spam, spam'

Observe que a ordem das chaves nos padrões é irrelevante, mesmo se o sujeito for um OrderedDict como b2.

Diferente de patterns de sequência, patterns de mapeamento funcionam com matches parciais. Nos doctests, os sujeitos b1 e b2 incluem uma chave 'title', que não aparece em nenhum padrão 'book', mas mesmo assim casam.

Não há necessidade de usar **extra para casar pares chave-valor adicionais, mas se você quiser capturá-los como um dict, pode prefixar uma variável com **. Ela precisa ser a última do padrão, e **_ é proibido, pois seria redundante. Um exemplo simples:

>>> food = dict(category='ice cream', flavor='vanilla', cost=199)
>>> match food:
...     case {'category': 'ice cream', **details}:
...         print(f'Ice cream details: {details}')
...
Ice cream details: {'flavor': 'vanilla', 'cost': 199}

Na Seção 3.5, vamos estudar o defaultdict e outros mapeamentos onde buscas com chaves via __getitem__ (isto é, d[chave]) funcionam porque itens ausentes são criados na hora. No contexto do pattern matching, um match é bem sucedido apenas se o sujeito já possui as chaves necessárias no início do bloco match.

👉 Dica

O tratamento automático de chaves ausentes não é acionado porque o pattern matching sempre usa o método d.get(key, sentinel)—onde o sentinel default é um marcador com valor especial, que não pode aparecer nos dados do usuário.

Vistas a sintaxe e a estrutura, vamos estudar a API dos mapeamentos.

3.4. A API padrão dos tipos de mapeamentos

O módulo collections.abc contém as ABCs Mapping e MutableMapping, descrevendo as interfaces de dict e de tipos similares. Veja a Figura 1.

A maior utilidade dessas ABCs é documentar e formalizar as interfaces padrão para os mapeamentos, e servir e critério para testes com isinstance em código que precise suportar mapeamentos de forma geral:

>>> my_dict = {}
>>> isinstance(my_dict, abc.Mapping)
True
>>> isinstance(my_dict, abc.MutableMapping)
True
👉 Dica

Usar isinstance com uma ABC é muitas vezes melhor que verificar se um argumento de função é do tipo concreto dict, porque daí tipos alternativos de mapeamentos podem ser usados. Vamos discutir isso em detalhes no Capítulo 13.

Diagrama de classes UML para `Mapping` e `MutableMapping`
Figura 1. Diagrama de classe simplificado para MutableMapping e suas superclasses de collections.abc (as setas de herança apontam das subclasses para as superclasses; nomes em itálico indicam classes e métodos abstratos

Para implementar uma mapeamento personalizado, é mais fácil estender collections.UserDict, ou envolver um dict por composição, ao invés de criar uma subclasse dessas ABCs. A classe collections.UserDict e todas as classes concretas de mapeamentos da biblioteca padrão encapsulam o dict básico em suas implementações, que por sua vez é criado sobre uma tabela de hash. Assim, todas elas compartilham a mesma limitação, as chaves precisam ser hashable (os valores não precisam ser hashable, só as chaves). Se você precisa de uma recapitulação, a próxima seção explica isso.

3.4.1. O que é hashable?

Aqui está parte da definição de hashable, adaptado do Glossário do Python:

Um objeto é hashable se tem um código de hash que nunca muda durante seu ciclo de vida (precisa ter um método hash()) e pode ser comparado com outros objetos (precisa ter um método eq()). Objetos hashable que são comparados como iguais devem ter o mesmo código de hash.[26]

Tipos numéricos e os tipos planos imutáveis str e bytes são todos hashable. Tipos contêineres são hashable se forem imutáveis e se todos os objetos por eles contidos forem também hashable. Um frozenset é sempre hashable, pois todos os elementos que ele contém devem ser, por definição, hashable. Uma tuple é hashable apenas se todos os seus itens também forem. Observe as tuplas tt, tl, and tf:

>>> tt = (1, 2, (30, 40))
>>> hash(tt)
8027212646858338501
>>> tl = (1, 2, [30, 40])
>>> hash(tl)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: unhashable type: 'list'
>>> tf = (1, 2, frozenset([30, 40]))
>>> hash(tf)
-4118419923444501110

O código de hash de um objeto pode ser diferente dependendo da versão do Python, da arquitetura da máquina, e pelo sal acrescentado ao cálculo do hash por razões de segurança.[27] O código de hash de um objeto corretamente implementado tem a garantia de ser constante apenas dentro de um processo Python.

Tipos definidos pelo usuário são hashble por default, pois seu código de hash é seu id(), e o método __eq__() herdado da classe objetct apenas compara os IDs dos objetos. Se um objeto implementar seu próprio __eq__(), que leve em consideração seu estado interno, ele será hashable apenas se seu __hash__() sempre devolver o mesmo código de hash. Na prática, isso exige que __eq__() e __hash__() levem em conta apenas atributos de instância que nunca mudem durante a vida do objeto.

Vamos agora revisar a API dos tipos de mapeamento mais comumente usado no Python: dict, defaultdict, e OrderedDict.

3.4.2. Revisão dos métodos mais comuns dos mapeamentos

A API básica para mapeamentos é muito variada. A Tabela 7 mostra os métodos implementados por dict e por duas variantes populares: defaultdict e OrderedDict, ambas classes definidas no módulo collections.

Tabela 7. Métodos do tipos de mapeamento dict, collections.defaultdict, e collections.OrderedDict (métodos comuns de object omitidos por concisão); argumentos opcionais então entre […]
dict defaultdict OrderedDict  

d.clear()

Remove todos os itens.

d.__contains__(k)

k in d.

d.copy()

Cópia rasa.

d.__copy__()

Suporte a copy.copy(d).

d.default_factory

Chamável invocado por __missing__ para definir valores ausentes.[28]

d.__delitem__(k)

del d[k]—remove item com chave k

d.fromkeys(it, [initial])

Novo mapeamento com chaves no iterável it, com um valor inicial opcional (o default é None).

d.get(k, [default])

Obtém item com chave k, devolve default ou None se k não existir.

d.__getitem__(k)

d[k]—obtém item com chave k.

d.items()

Obtém uma view dos itens—pares (chave, valor).

d.__iter__()

Obtém iterador das chaves.

d.keys()

Obtém view das chaves.

d.__len__()

len(d)—número de itens.

d.__missing__(k)

Chamado quando __getitem__ não consegue encontrar a chave.

d.move_to_end(k, [last])

Move k para primeira ou última posição (last é True por default).

d.__or__(other)

Suporte a d1 | d2 para criar um novo `dict`, fundindo d1 e d2 (Python ≥ 3.9).

d.__ior__(other)

Suporte a d1 |= d2 para atualizar d1 com d2 (Python ≥ 3.9).

d.pop(k, [default])

Remove e devolve valor em k, ou default ou None, se k não existir.

d.popitem()

Remove e devolve, na forma (chave, valor), o último item inserido.[29]

d.__reversed__()

Suporte a reverse(d)—devolve um iterador de chaves, da última para a primeira a serem inseridas.

d.__ror__(other)

Suporte a other | dd—operador de união invertido (Python ≥ 3.9)[30]

d.setdefault(k, [default])

Se k in d, devolve d[k]; senão, atribui d[k] = default e devolve isso.

d.__setitem__(k, v)

d[k] = v—coloca v em k

d.update(m, [**kwargs])

Atualiza d com itens de um mapeamento ou iterável de pares (chave, valor).

d.values()

Obtém uma view dos valores.

A forma como d.update(m) lida com seu primeiro argumento, m, é um excelente exemplo de duck typing (tipagem pato): ele primeiro verifica se m possui um método keys e, em caso afirmativo, assume que m é um mapeamento. Caso contrário, update() reverte para uma iteração sobre m, presumindo que seus item são pares (chave, valor). O construtor da maioria dos mapeamentos do Python usa internamente a lógica de update(), o que quer dizer que eles podem ser inicializados por outros mapeamentos ou a partir de qualquer objeto iterável que produza pares (chave, valor).

Um método sutil dos mapeamentos é setdefault(). Ele evita buscas redundantes de chaves quando precisamos atualizar o valor em um item no mesmo lugar. A próxima seção mostra como ele pode ser usado.

3.4.3. Inserindo ou atualizando valores mutáveis

Alinhada à filosofia de falhar rápido do Python, a consulta a um dict com d[k] gera um erro quando k não é uma chave existente. Pythonistas sabem que d.get(k, default) é uma alternativa a d[k] sempre que receber um valor default é mais conveniente que tratar um KeyError. Entretanto, se você está buscando um valor mutável e quer atualizá-lo, há um jeito melhor.

Considere um script para indexar texto, produzindo um mapeamento no qual cada chave é uma palavra, e o valor é uma lista das posições onde aquela palavra ocorre, como mostrado no Exemplo 3.

Exemplo 3. Saída parcial do Exemplo 4 processando o texto "Zen of Python"; cada linha mostra uma palavra e uma lista de ocorrências na forma de pares (line_number, column_number) (número da linha, _número da coluna)
$ python3 index0.py zen.txt
a [(19, 48), (20, 53)]
Although [(11, 1), (16, 1), (18, 1)]
ambiguity [(14, 16)]
and [(15, 23)]
are [(21, 12)]
aren [(10, 15)]
at [(16, 38)]
bad [(19, 50)]
be [(15, 14), (16, 27), (20, 50)]
beats [(11, 23)]
Beautiful [(3, 1)]
better [(3, 14), (4, 13), (5, 11), (6, 12), (7, 9), (8, 11), (17, 8), (18, 25)]
...

O Exemplo 4 é um script aquém do ideal, para mostrar um caso onde dict.get não é a melhor maneira de lidar com uma chave ausente. Ele foi adaptado de um exemplo de Alex Martelli.[31]

Exemplo 4. index0.py usa dict.get para obter e atualizar uma lista de ocorrências de palavras de um índice (uma solução melhor é apresentada no Exemplo 5)
"""Build an index mapping word -> list of occurrences"""

import re
import sys

WORD_RE = re.compile(r'\w+')

index = {}
with open(sys.argv[1], encoding='utf-8') as fp:
    for line_no, line in enumerate(fp, 1):
        for match in WORD_RE.finditer(line):
            word = match.group()
            column_no = match.start() + 1
            location = (line_no, column_no)
            # this is ugly; coded like this to make a point
            occurrences = index.get(word, [])  # (1)
            occurrences.append(location)       # (2)
            index[word] = occurrences          # (3)

# display in alphabetical order
for word in sorted(index, key=str.upper):      # (4)
    print(word, index[word])
  1. Obtém a lista de ocorrências de word, ou [] se a palavra não for encontrada.

  2. Acrescenta uma nova localização a occurrences.

  3. Coloca a occurrences modificada no dict index; isso exige uma segunda busca em index.

  4. Não estou chamando str.upper no argumento key= de sorted, apenas passando uma referência àquele método, para que a função sorted possa usá-lo para normalizar as palavras antes de ordená-las.[32]

As três linhas tratando de occurrences no Exemplo 4 podem ser substituídas por uma única linha usando dict.setdefault. O Exemplo 5 fica mais próximo do código apresentado por Alex Martelli.

Exemplo 5. index.py usa dict.setdefault para obter e atualizar uma lista de ocorrências de uma palavra em uma única linha de código; compare com o Exemplo 4
"""Build an index mapping word -> list of occurrences"""

import re
import sys

WORD_RE = re.compile(r'\w+')

index = {}
with open(sys.argv[1], encoding='utf-8') as fp:
    for line_no, line in enumerate(fp, 1):
        for match in WORD_RE.finditer(line):
            word = match.group()
            column_no = match.start() + 1
            location = (line_no, column_no)
            index.setdefault(word, []).append(location)  # (1)

# display in alphabetical order
for word in sorted(index, key=str.upper):
    print(word, index[word])
  1. Obtém a lista de ocorrências de word, ou a define como [], se não for encontrada; setdefault devolve o valor, então ele pode ser atualizado sem uma segunda busca.

Em outras palavras, o resultado final desta linha…​

my_dict.setdefault(key, []).append(new_value)

…​é o mesmo que executar…​

if key not in my_dict:
    my_dict[key] = []
my_dict[key].append(new_value)

…​exceto que este último trecho de código executa pelo menos duas buscas por key—três se a chave não for encontrada—enquanto setdefault faz tudo isso com uma única busca.

Uma questão relacionada, o tratamento de chaves ausentes em qualquer busca (e não apenas para inserção de valores), é o assunto da próxima seção.

3.5. Tratamento automático de chaves ausentes

Algumas vezes é conveniente que os mapeamentos devolvam algum valor padronizado quando se busca por uma chave ausente. Há duas abordagem principais para esse fim: uma é usar um defaultdict em vez de um dict simples. A outra é criar uma subclasse de dict ou de qualquer outro tipo de mapeamento e acrescentar um método __missing__. Vamos ver as duas soluções a seguir.

3.5.1. defaultdict: outra perspectiva sobre as chaves ausentes

Uma instância de collections.defaultdict cria itens com um valor default sob demanda, sempre que uma chave ausente é buscada usando a sintaxe d[k]. O Exemplo 6 usa defaultdict para fornecer outra solução elegante para o índice de palavras do Exemplo 5.

Funciona assim: ao instanciar um defaultdict, você fornece um chamável que produz um valor default sempre que __getitem__ recebe uma chave inexistente como argumento.

Por exemplo, dado um defaultdict criado por dd = defaultdict(list), se 'new-key' não estiver em dd, a expressão dd['new-key'] segue os seguintes passos:

  1. Chama list() para criar uma nova lista.

  2. Insere a lista em dd usando 'new-key' como chave.

  3. Devolve uma referência para aquela lista.

O chamável que produz os valores default é mantido em um atributo de instância chamado default_factory.

Exemplo 6. index_default.py: usando um defaultdict em vez do método setdefault
"""Build an index mapping word -> list of occurrences"""

import collections
import re
import sys

WORD_RE = re.compile(r'\w+')

index = collections.defaultdict(list)     # (1)
with open(sys.argv[1], encoding='utf-8') as fp:
    for line_no, line in enumerate(fp, 1):
        for match in WORD_RE.finditer(line):
            word = match.group()
            column_no = match.start() + 1
            location = (line_no, column_no)
            index[word].append(location)  # (2)

# display in alphabetical order
for word in sorted(index, key=str.upper):
    print(word, index[word])
  1. Cria um defaultdict com o construtor de list como default_factory.

  2. Se word não está inicialmente no index, o default_factory é chamado para produzir o valor ausente, que neste caso é uma list vazia, que então é atribuída a index[word] e devolvida, de forma que a operação .append(location) é sempre bem sucedida.

Se nenhum default_factory é fornecido, o KeyError usual é gerado para chaves ausente.

⚠️ Aviso

O default_factory de um defaultdict só é invocado para fornecer valores default para chamadas a __getitem__, não para outros métodos. Por exemplo, se dd é um defaultdict e k uma chave ausente, dd[k] chamará default_factory para criar um valor default, mas dd.get(k) vai devolver None e k in dd é False.

O mecanismo que faz defaultdict funcionar, chamando default_factory, é o método especial __missing__, um recurso discutido a seguir.

3.5.2. O método __missing__

Por trás da forma como os mapeamentos lidam com chaves ausentes está o método muito apropriadamente chamado __missing__.[33]. Esse método não é definido na classe base dict, mas dict está ciente de sua possibilidade: se você criar uma subclasse de dict e incluir um método __missing__, o dict.__getitem__ padrão vai chamar seu método sempre que uma chave não for encontrada, em vez de gerar um KeyError.

Suponha que você queira um mapeamento onde as chaves são convertidas para str quando são procuradas. Um caso de uso concreto seria uma biblioteca para dispositivos IoT (Internet of Things, Internet das Coisas)[34], onde uma placa programável com portas genéricas programáveis (por exemplo, uma Raspberry Pi ou uma Arduino) é representada por uma classe "Placa" com um atributo minha_placa.portas, que é uma mapeamento dos identificadores das portas físicas para objetos de software portas. O identificador da porta física pode ser um número ou uma string como "A0" ou "P9_12". Por consistência, é desejável que todas as chaves em placa.portas seja strings, mas também é conveniente buscar uma porta por número, como em meu-arduino.porta[13], para evitar que iniciantes tropecem quando quiserem fazer piscar o LED na porta 13 de seus Arduinos. O Exemplo 7 mostra como tal mapeamento funcionaria.

Exemplo 7. Ao buscar por uma chave não-string, StrKeyDict0 a converte para str quando ela não é encontrada
Tests for item retrieval using `d[key]` notation::

    >>> d = StrKeyDict0([('2', 'two'), ('4', 'four')])
    >>> d['2']
    'two'
    >>> d[4]
    'four'
    >>> d[1]
    Traceback (most recent call last):
      ...
    KeyError: '1'

Tests for item retrieval using `d.get(key)` notation::

    >>> d.get('2')
    'two'
    >>> d.get(4)
    'four'
    >>> d.get(1, 'N/A')
    'N/A'


Tests for the `in` operator::

    >>> 2 in d
    True
    >>> 1 in d
    False

O Exemplo 8 implementa a classe StrKeyDict0, que passa nos doctests acima.

👉 Dica

Uma forma melhor de criar uma mapeamento definido pelo usuário é criar uma subclasse de collections.UserDict em vez de dict (como faremos no Exemplo 9). Aqui criamos uma subclasse de dict apenas para mostrar que __missing__ é suportado pelo método embutido dict.__getitem__.

Exemplo 8. StrKeyDict0 converte chaves não-string para string no momento da consulta (vejas os testes no Exemplo 7)
class StrKeyDict0(dict):  # (1)

    def __missing__(self, key):
        if isinstance(key, str):  # (2)
            raise KeyError(key)
        return self[str(key)]  # (3)

    def get(self, key, default=None):
        try:
            return self[key]  # (4)
        except KeyError:
            return default  # (5)

    def __contains__(self, key):
        return key in self.keys() or str(key) in self.keys()  # (6)
  1. StrKeyDict0 herda de dict.

  2. Verifica se key já é uma str. Se é, e está ausente, gera um KeyError.

  3. Cria uma str de key e a procura.

  4. O método get delega para __getitem__ usando a notação self[key]; isso dá oportunidade para nosso __missing__ agir.

  5. Se um KeyError foi gerado, __missing__ já falhou, então devolvemos o default.

  6. Procura pela chave não-modificada (a instância pode conter chaves não-str), depois por uma str criada a partir da chave.

Considere por um momento o motivo do teste isinstance(key, str) ser necessário na implementação de __missing__.

Sem aquele teste, nosso método __missing__ funcionaria bem com qualquer chave kstr ou não—sempre que str(k) produzisse uma chave existente. Mas se str(k) não for uma chave existente, teríamos uma recursão infinita. Na última linha de __missing__, self[str(key)] chamaria __getitem__, passando aquela chave str, e __getitem__, por sua vez, chamaria __missing__ novamente.

O método __contains__ também é necessário para que o comportamento nesse exemplo seja consistente, pois a operação k in d o chama, mas o método herdado de dict não invoca __missing__ com chaves ausentes. Há um detalhe sutil em nossa implementação de __contains__: não verificamos a existência da chave da forma pythônica normal—k in d—porque str(key) in self chamaria __contains__ recursivamente. Evitamos isso procurando a chave explicitamente em self.keys().

Uma busca como k in my_dict.keys() é eficiente em Python 3 mesmo para mapeamentos muito grandes, porque dict.keys() devolve uma view, que é similar a um set, como veremos na Seção 3.12. Entretanto, lembre-se que k in my_dict faz o mesmo trabalho, e é mais rápido porque evita a busca nos atributos para encontrar o método .keys.

Eu tinha uma razão específica para usar self.keys() no método __contains__ do Exemplo 8. A verificação da chave não-modificada key in self.keys() é necessária por correção, pois StrKeyDict0 não obriga todas as chaves no dicionário a serem do tipo str. Nosso único objetivo com esse exemplo simples foi fazer a busca "mais amigável", e não forçar tipos.

⚠️ Aviso

Classes definidas pelo usuário derivadas de mapeamentos da biblioteca padrão podem ou não usar __missing__ como alternativa em sua implementação de __getitem__, get, ou __contains__, como explicado na próxima seção.

3.5.3. O uso inconsistente de __missing__ na biblioteca padrão

Considere os seguintes cenários, e como eles afetam a busca de chaves ausentes:

subclasse de dict

Uma subclasse de dict que implemente apenas __missing__ e nenhum outro método. Nesse caso, __missing__ pode ser chamado apenas em d[k], que usará o __getitem__ herdado de dict.

subclasse de collections.UserDict

Da mesma forma, uma subclasse de UserDict que implemente apenas __missing__ e nenhum outro método. O método get herdado de UserDict chama __getitem__. Isso significa que __missing__ pode ser chamado para tratar de consultas com d[k] e com d.get(k).

subclasse de abc.Mapping com o __getitem__ mais simples possível

Uma subclasse mínima de abc.Mapping, implementando __missing__ e os métodos abstratos obrigatórios, incluindo uma implementação de __getitem__ que não chama __missing__. O método __missing__ nunca é acionado nessa classe.

subclasse de abc.Mapping com __getitem__ chamando __missing__

Uma subclasse mínima de abc.Mapping, implementando __missing__ e os métodos abstratos obrigatórios, incluindo uma implementação de __getitem__ que chama __missing__. O método __missing__ é acionado nessa classe para consultas por chaves ausentes feitas com d[k], d.get(k), e k in d.

Veja missing.py no repositório de exemplos de código para demonstrações dos cenários descritos acima.

Os quatro cenários que acabo de descrever supõem implementações mínimas. Se a sua subclasse implementa __getitem__, get, e __contains__, então você pode ou não fazer tais métodos usarem __missing__, dependendo de suas necessidades. O ponto aqui é mostrar que é preciso ter cuidado ao criar subclasses dos mapeamentos da biblioteca padrão para usar __missing__, porque as classes base suportam comportamentos default diferentes. Não se esqueça que o comportamento de setdefault e update também é afetado pela consulta de chaves. E por fim, dependendo da lógica de seu __missing__, pode ser necessário implementar uma lógica especial em __setitem__, para evitar inconsistências ou comportamentos surpreeendentes. Veremos um exemplo disso na Seção 3.6.5.

Até aqui tratamos dos tipos de mapeamentos dict e defaultdict, mas a biblioteca padrão traz outras implementações de mapeamentos, que discutiremos a seguir.

3.6. Variações de dict

Nessa seção falaremos brevemente sobre os tipos de mapeamentos incluídos na biblioteca padrão diferentes de defaultdict, já visto na Seção 3.5.1.

3.6.1. collections.OrderedDict

Agora que o dict embutido também mantém as chaves ordenadas (desde o Python 3.6), o motivo mais comum para usar OrderedDict é escrever código compatível com versões anteriores do Python. Dito isso, a documentação lista algumas diferenças entre dict e OrderedDict que ainda persistem e que cito aqui—apenas reordenando os itens conforme sua relevância no uso diário:

  • A operação de igualdade para OrderedDict verifica a igualdade da ordenação.

  • O método popitem() de OrderedDict tem uma assinatura diferente, que aceita um argumento opcional especificando qual item será devolvido.

  • OrderedDict tem um método move_to_end(), que reposiciona de um elemento para uma ponta do dicionário de forma eficiente.

  • O dict comum foi projetado para ser muito bom nas operações de mapeamento. Monitorar a ordem de inserção era uma preocupação secundária.

  • OrderedDict foi projetado para ser bom em operações de reordenamento. Eficiência espacial, velocidade de iteração e o desempenho de operações de atualização eram preocupações secundárias.

  • Em termos do algoritmo, um OrderedDict lida melhor que um dict com operações frequentes de reordenamento. Isso o torna adequado para monitorar acessos recentes (em um cache LRU[35], por exemplo).

3.6.2. collections.ChainMap

Uma instância de ChainMap mantém uma lista de mapeamentos que podem ser consultados como se fossem um mapeamento único. A busca é realizada em cada mapa incluído, na ordem em que eles aparecem na chamada ao construtor, e é bem sucedida assim que a chave é encontrada em um daqueles mapeamentos. Por exemplo:

>>> d1 = dict(a=1, b=3)
>>> d2 = dict(a=2, b=4, c=6)
>>> from collections import ChainMap
>>> chain = ChainMap(d1, d2)
>>> chain['a']
1
>>> chain['c']
6

A instância de ChainMap não cria cópias dos mapeamentos, mantém referências para eles. Atualizações ou inserções a um ChainMap afetam apenas o primeiro mapeamento passado. Continuando do exemplo anterior:

>>> chain['c'] = -1
>>> d1
{'a': 1, 'b': 3, 'c': -1}
>>> d2
{'a': 2, 'b': 4, 'c': 6}

Um ChainMap é útil na implementação de linguagens com escopos aninhados, onde cada mapeamento representa um contexto de escopo, desde o escopo aninhado mais interno até o mais externo. A seção "Objetos ChainMap", na documentação de collections, apresenta vários exemplos do uso de Chainmap, incluindo esse trecho inspirado nas regras básicas de consulta de variáveis no Python:

import builtins
pylookup = ChainMap(locals(), globals(), vars(builtins))

O Exemplo 14 mostra uma subclasse de ChainMap usada para implementar um interpretador parcial da linguagem de programação Scheme.

3.6.3. collections.Counter

Um mapeamento que mantém uma contagem inteira para cada chave. Atualizar uma chave existente adiciona à sua contagem. Isso pode ser usado para contar instâncias de objetos hashable ou como um multiset ("conjunto múltiplo"), discutido adiante nessa seção. Counter implementa os operadores + e - para combinar contagens, e outros métodos úteis tal como o most_common([n]), que devolve uma lista ordenada de tuplas com os n itens mais comuns e suas contagens; veja a documentação.

Aqui temos um Counter usado para contar as letras em palavras:

>>> ct = collections.Counter('abracadabra')
>>> ct
Counter({'a': 5, 'b': 2, 'r': 2, 'c': 1, 'd': 1})
>>> ct.update('aaaaazzz')
>>> ct
Counter({'a': 10, 'z': 3, 'b': 2, 'r': 2, 'c': 1, 'd': 1})
>>> ct.most_common(3)
[('a', 10), ('z', 3), ('b', 2)]

Observe que as chaves 'b' e 'r' estão empatadas em terceiro lugar, mas ct.most_common(3) mostra apenas três contagens.

Para usar collections.Counter como um conjunto múltiplo, trate cada chave como um elemento de um conjunto, e a contagem será o número de ocorrências daquele elemento no conjunto.

3.6.4. shelve.Shelf

O módulo shelve na biblioteca padrão fornece armazenamento persistente a um mapeamento de chaves em formato string para objetos Python serializados no formato binário pickle. O nome curioso, shelve, faz sentido quando você percebe que potes de pickle são armazenadas em prateleiras.[36]

A função de módulo shelve.open devolve uma instância de shelve.Shelf—um banco de dados DBM simples de chave-valor, baseado no módulo dbm, com as seguintes características:

  • shelve.Shelf é uma subclasse de abc.MutableMapping, então fornece os métodos essenciais esperados de um tipo mapeamento.

  • Além disso, shelve.Shelf fornece alguns outros métodos de gerenciamento de E/S, como sync e close.

  • Uma instância de Shelf é um gerenciador de contexto, então é possível usar um bloco with para garantir que ela seja fechada após o uso.

  • Chaves e valores são salvos sempre que um novo valor é atribuído a uma chave.

  • As chaves devem ser strings.

  • Os valores devem ser objetos que o módulo pickle possa serializar.

A documentação para os módulos shelve, dbm (EN), e pickle traz mais detalhes e também algumas ressalvas.

⚠️ Aviso

O pickle do Python é fácil de usar nos caso mais simples, mas tem vários inconvenientes. Leia o "Pickle’s nine flaws", de Ned Batchelder, antes de adotar qualquer solução envolvendo pickle. Em seu post, Ned menciona outros formatos de serialização que podem ser considerados como alternativas.

As classes OrderedDict, ChainMap, Counter, e Shelf podem ser usadas diretamente, mas também podem ser personalizadas por subclasses. UserDict, por outro lado, foi planejada apenas como uma classe base a ser estendida.

3.6.5. Criando subclasses de UserDict em vez de dict

É melhor criar um novo tipo de mapeamento estendendo collections.UserDict em vez de dict. Percebemos isso quando tentamos estender nosso StrKeyDict0 do Exemplo 8 para assegurar que qualquer chave adicionada ao mapeamento seja armazenada como str.

A principal razão pela qual é melhor criar uma subclasse de UserDict em vez de dict é que o tipo embutido tem alguns atalhos de implementação, que acabam nos obrigando a sobrepor métodos que poderíamos apenas herdar de UserDict sem maiores problemas.[37]

Observe que UserDict não herda de dict, mas usa uma composição: a classe tem uma instância interna de dict, chamada data, que mantém os itens propriamente ditos. Isso evita recursão indesejada quando escrevemos métodos especiais, como __setitem__, e simplifica a programação de __contains__, quando comparado com o Exemplo 8.

Graças a UserDict, o StrKeyDict (Exemplo 9) é mais conciso que o StrKeyDict0 (Exemplo 8), mais ainda faz melhor: ele armazena todas as chaves como str, evitando surpresas desagradáveis se a instância for criada ou atualizada com dados contendo chaves de outros tipos (que não string).

Exemplo 9. StrKeyDict sempre converte chaves que não sejam strings para str na inserção, atualização e busca
import collections


class StrKeyDict(collections.UserDict):  # (1)

    def __missing__(self, key):  # (2)
        if isinstance(key, str):
            raise KeyError(key)
        return self[str(key)]

    def __contains__(self, key):
        return str(key) in self.data  # (3)

    def __setitem__(self, key, item):
        self.data[str(key)] = item   # (4)
  1. StrKeyDict estende UserDict.

  2. __missing__ é exatamente igual ao do Exemplo 8.

  3. __contains__ é mais simples: podemos assumir que todas as chaves armazenadas são str, e podemos operar sobre self.data em vez de invocar self.keys(), como fizemos em StrKeyDict0.

  4. __setitem__ converte qualquer key para uma str. Esse método é mais fácil de sobrepor quando podemos delegar para o atributo self.data.

Como UserDict estende abc.MutableMapping, o restante dos métodos que fazem de StrKeyDict uma mapeamento completo são herdados de UserDict, MutableMapping, ou Mapping. Estes últimos contém vários métodos concretos úteis, apesar de serem classes base abstratas (ABCs). Os seguinte métodos são dignos de nota:

MutableMapping.update

Esse método poderoso pode ser chamado diretamente, mas também é usado por __init__ para criar a instância a partir de outros mapeamentos, de iteráveis de pares (chave, valor), e de argumentos nomeados. Como usa self[chave] = valor para adicionar itens, ele termina por invocar nossa implementação de __setitem__.

Mapping.get

No StrKeyDict0 (Exemplo 8), precisamos codificar nosso próprio get para devolver os mesmos resultados de __getitem__, mas no Exemplo 9 herdamos Mapping.get, que é implementado exatamente como StrKeyDict0.get (consulte o código-fonte do Python).

👉 Dica

Antoine Pitrou escreveu a PEP 455—​Adding a key-transforming dictionary to collections (Acrescentando um dicionário com transformação de chaves a collections) (EN) e um patch para aperfeiçoar o módulo collections com uma classe TransformDict, que é mais genérico que StrKeyDict e preserva as chaves como fornecidas antes de aplicar a transformação. A PEP 455 foi rejeitada em maio de 2015—veja a mensagem de rejeição (EN) de Raymond Hettinger. Para experimentar com a TransformDict, extraí o patch de Pitrou do issue18986 (EN) para um módulo independente (03-dict-set/transformdict.py disponível no repositório de código da segunda edição do Fluent Python).

Sabemos que existem tipos de sequências imutáveis, mas e mapeamentos imutáveis? Bem, não há um tipo real desses na biblioteca padrão, mas um substituto está disponível. É o que vem a seguir.

3.7. Mapeamentos imutáveis

Os tipos de mapeamentos disponíveis na biblioteca padrão são todos mutáveis, mas pode ser necessário impedir que os usuários mudem um mapeamento por acidente. Um caso de uso concreto pode ser encontrado, novamente, em uma biblioteca de programação de hardware como a Pingo, mencionada na Seção 3.5.2: o mapeamento board.pins representa as portas de GPIO (General Purpose Input/Output, Entrada/Saída Genérica) em um dispositivo. Dessa forma, seria útil evitar atualizações descuidadas de board.pins, pois o hardware não pode ser modificado via software: qualquer mudança no mapeamento o tornaria inconsistente com a realidade física do dispositivo.

O módulo types oferece uma classe invólucro (wrapper) chamada MappingProxyType que, dado um mapeamento, devolve uma instância de mappingproxy, que é um proxy somente para leitura (mas dinâmico) do mapeamento original. Isso significa que atualizações ao mapeamento original são refletidas no mappingproxy, mas nenhuma mudança pode ser feita através desse último. Veja uma breve demonstração no Exemplo 10.

Exemplo 10. MappingProxyType cria uma instância somente de leitura de mappingproxy a partir de um dict
>>> from types import MappingProxyType
>>> d = {1: 'A'}
>>> d_proxy = MappingProxyType(d)
>>> d_proxy
mappingproxy({1: 'A'})
>>> d_proxy[1]  (1)
'A'
>>> d_proxy[2] = 'x'  (2)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: 'mappingproxy' object does not support item assignment
>>> d[2] = 'B'
>>> d_proxy  (3)
mappingproxy({1: 'A', 2: 'B'})
>>> d_proxy[2]
'B'
>>>
  1. Os items em d podem ser vistos através de d_proxy.

  2. Não é possível fazer modificações através de d_proxy.

  3. d_proxy é dinâmica: qualquer mudança em d é refletida ali.

Isso pode ser usado assim na prática, no cenário da programação de hardware: o construtor em uma subclasse concreta Board preencheria um mapeamento privado com os objetos porta, e o exporia aos clientes da API via um atributo público .portas, implementado como um mappingproxy. Dessa forma os clientes não poderiam acrescentar, remover ou modificar as portas por acidente.

A seguir veremos views—que permitem operações de alto desempenho em um dict, sem cópias desnecessárias dos dados.

3.8. Views de dicionários

Os métodos de instância de dict .keys(), .values(), e .items() devolvem instâncias de classes chamadas dict_keys, dict_values, e dict_items, respectivamente. Essas views de dicionário são projeções somente para leitura de estruturas de dados internas usadas na implemetação de dict. Elas evitam o uso de memória adicional dos métodos equivalentes no Python 2, que devolviam listas, duplicando dados já presentes no dict alvo. E também substituem os métodos antigos que devolviam iteradores.

O Exemplo 11 mostra algumas operações básicas suportadas por todas as views de dicionários.

Exemplo 11. O método .values() devolve uma view dos valores em um dict
>>> d = dict(a=10, b=20, c=30)
>>> values = d.values()
>>> values
dict_values([10, 20, 30])  (1)
>>> len(values)  (2)
3
>>> list(values)  (3)
[10, 20, 30]
>>> reversed(values)  (4)
<dict_reversevalueiterator object at 0x10e9e7310>
>>> values[0] (5)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: 'dict_values' object is not subscriptable
  1. O repr de um objeto view mostra seu conteúdo.

  2. Podemos consultar a len de uma view.

  3. Views são iteráveis, então é fácil criar listas a partir delas.

  4. Views implementam __reversed__, devolvendo um iterador personalizado.

  5. Não é possível usar [] para obter itens individuais de uma view.

Um objeto view é um proxy dinâmico. Se o dict fonte é atualizado, as mudanças podem ser vistas imediatamente através de uma view existente. Continuando do Exemplo 11:

>>> d['z'] = 99
>>> d
{'a': 10, 'b': 20, 'c': 30, 'z': 99}
>>> values
dict_values([10, 20, 30, 99])

As classes dict_keys, dict_values, e dict_items são internas: elas não estão disponíveis via __builtins__ ou qualquer módulo da biblioteca padrão, e mesmo que você obtenha uma referência para uma delas, não pode usar essa referência para criar uma view do zero no seu código Python:

>>> values_class = type({}.values())
>>> v = values_class()
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: cannot create 'dict_values' instances

A classe dict_values é a view de dicionário mais simples—ela implementa apenas os métodos especiais __len__, __iter__, e __reversed__. Além desses métodos, dict_keys e dict_items implementam vários métodos dos sets, quase tantos quanto a classe frozenset. Após vermos os conjuntos (sets), teremos mais a dizer sobre dict_keys e dict_items, na Seção 3.12.

Agora vamos ver algumas regras e dicas baseadas na forma como dict é implementado debaixo dos panos.

3.9. Consequências práticas da forma como dict funciona

A implementação da tabela de hash do dict do Python é muito eficiente, mas é importante entender os efeitos práticos desse design:

  • Chaves devem ser objetos hashable. Eles devem implementar métodos __hash__ e __eq__ apropriados, como descrito na Seção 3.4.1.

  • O acesso aos itens através da chave é muito rápido. Mesmo que um dict tenha milhões de chaves, o Python pode localizar uma chave diretamente, computando o código hash da chave e derivando um deslocamento do índice na tabela de hash, com um possível ônus de um pequeno número de tentativas até encontrar a entrada correspondente.

  • A ordenação das chaves é preservada, como efeito colateral de um layout de memória mais compacto para dict no CPython 3.6, que se tornou um recurso oficial da linguagem no 3.7.

  • Apesar de seu novo layout compacto, os dicts apresentam, inevitavelmente, um uso adicional significativo de memória. A estrutura de dados interna mais compacta para um contêiner seria um array de ponteiros para os itens.[38] Comparado a isso, uma tabela de hash precisa armazenar mais dados para cada entrada e, para manter a eficiência, o Python precisa manter pelo menos um terço das linhas da tabela de hash vazias.

  • Para economizar memória, evite criar atributos de instância fora do método __init__.

Essa última dica, sobre atributos de instância, é consequência do comportamento default do Python, de armazenar atributos de instância em um atributo __dict__ especial, que é um dict vinculado a cada instância.[39] Desde a implementação da PEP 412—Key-Sharing Dictionary (Dicionário de Compartilhamento de Chaves) (EN), no Python 3.3, instâncias de uma classe podem compartilhar uma tabela de hash comum, armazenada com a classe. Essa tabela de hash comum é compartilhada pelo __dict__ de cada nova instância que, quando __init__ retorna, tenha os mesmos nomes de atributos que a primeira instância daquela classe a ser criada. O __dict__ de cada instância então pode manter apenas seus próprios valores de atributos como uma simples array de ponteiros. Acrescentar um atributo de instância após o __init__ obriga o Python a criar uma nova tabela de hash só para o __dict__ daquela instância (que era o comportamento default antes do Python 3.3). De acordo com a PEP 412, essa otimização reduz o uso da memória entre 10% e 20% em programas orientados as objetos. Os detalhes das otimizações do layout compacto e do compartilhamento de chaves são bastante complexos. Para saber mais, por favor leio o texto "Internals of sets and dicts" (EN) em fluentpython.com.

Agora vamos estudar conjuntos(sets).

3.10. Teoria dos conjuntos

Conjuntos não são novidade no Python, mais ainda são um tanto subutilizados. O tipo set e seu irmão imutável, frozenset, surgiram inicialmente como módulos, na biblioteca padrão do Python 2.3, e foram promovidos a tipos embutidos no Python 2.6.

✒️ Nota

Nesse livro, uso a palavra "conjunto" para me referir tanto a set quanto a frozenset. Quando falo especificamente sobre a classe set, uso a fonte de espaçamento constante: set.

Um conjunto é uma coleção de objetos únicos. Um caso de uso básico é a remoção de itens duplicados:

>>> l = ['spam', 'spam', 'eggs', 'spam', 'bacon', 'eggs']
>>> set(l)
{'eggs', 'spam', 'bacon'}
>>> list(set(l))
['eggs', 'spam', 'bacon']
👉 Dica

Para remover elementos duplicados preservando a ordem da primeira ocorrência de cada item, você pode fazer isso com um dict simples, assim:

>>> dict.fromkeys(l).keys()
dict_keys(['spam', 'eggs', 'bacon'])
>>> list(dict.fromkeys(l).keys())
['spam', 'eggs', 'bacon']

Elementos de um conjunto devem ser hashable. O tipo set não é hashable, então não é possível criar um set com instâncias aninhadas de set. Mas frozenset é hashable, então você pode ter elementos frozenset dentro de um set.

Além de impor a unicidade de cada elemento, os tipos conjunto implementam muitas operações entre conjuntos como operadores infixos. Assim, dados dois conjuntos a e b, a | b devolve sua união, a & b calcula a intersecção, a - b a diferença, e a ^ b a diferença simétrica. Usadas com sabedoria, as operações de conjuntos podem reduzir tanto a contagem de linhas quanto o tempo de execução de programas Python, ao mesmo tempo em que tornam o código mais legível e mais fácil de entender e debater—pela remoção de loops e da lógica condicional.

Por exemplo, imagine que você tem um grande conjunto de endereços de email (o palheirohaystack) e um conjunto menor de endereços (as agulhasneedles), e precisa contar quantas agulhas existem no palheiro. Graças à interseção de set (o operador &), é possível programar isso em uma única linha (veja o Exemplo 12).

Exemplo 12. Conta as ocorrências de agulhas (needles) em um palheiro (haystack), ambos do tipo set
found = len(needles & haystack)

Sem o operador de intersecção, seria necessário escrever o Exemplo 13 para realizar a mesma tarefa executa pelo Exemplo 12.

Exemplo 13. Conta as ocorrências de agulhas (needles) em um palheiro (haystack); mesmo resultado final do Exemplo 12
found = 0
for n in needles:
    if n in haystack:
        found += 1

O Exemplo 12 é um pouco mais rápido que o Exemplo 13. Por outros lado, o Exemplo 13 funciona para quaisquer objetos iteráveis needles e haystack, enquanto o Exemplo 12 exige que ambos sejam conjuntos. Mas se você não tem conjuntos à mão, pode sempre criá-los na hora, como mostra o Exemplo 14.

Exemplo 14. Conta as ocorrências de agulhas (needles) em um palheiro (haystack); essas linhas funcionam para qualquer tipo iterável
found = len(set(needles) & set(haystack))

# another way:
found = len(set(needles).intersection(haystack))

Claro, há o custo extra envolvido na criação dos conjuntos no Exemplo 14, mas se ou as needles ou o haystack já forem um set, a alternativa no Exemplo 14 pode ser mais barata que o Exemplo 13.

Qualquer dos exemplos acima é capaz de buscar 1000 elementos em um haystack de 10,000,000 de itens em cerca de 0,3 milisegundos—isso é próximo de 0,3 microsegundos por elemento.

Além do teste de existência extremamente rápido (graças à tabela de hash subjacente), os tipos embutidos set e frozenset oferecem uma rica API para criar novos conjuntos ou, no caso de set, para modificar conjuntos existentes. Vamos discutir essas operações em breve, após uma observação sobre sintaxe.

3.10.1. Sets literais

A sintaxe de literais set{1}, {1, 2}, etc.—parece exatamente igual à notação matemática, mas tem uma importante exceção: não há notação literal para o set vazio, então precisamos nos lembrar de escrever set().

⚠️ Aviso
Peculiaridade sintática

Não esqueça que, para criar um set vazio, é preciso usar o construtor sem argumentos: set(). Se você escrever {}, vai criar um dict vazio—isso não mudou no Python 3.

No Python 3, a representação padrão dos sets como strings sempre usa a notação {…}, exceto para o conjunto vazio:

>>> s = {1}
>>> type(s)
<class 'set'>
>>> s
{1}
>>> s.pop()
1
>>> s
set()

A sintaxe do set literal, como {1, 2, 3}, é mais rápida e mais legível que uma chamada ao construtor (por exemplo, set([1, 2, 3])). Essa última forma é mais lenta porque, para avaliá-la, o Python precisa buscar o nome set para obter seu construtor, daí criar uma lista e, finalmente, passá-la para o construtor. Por outro lado, para processar um literal como {1, 2, 3}, o Python roda um bytecode especializado, BUILD_SET.[40]

Não há sintaxe especial para representar literais frozenset—eles devem ser criados chamando seu construtor. Sua representação padrão como string no Python 3 se parece com uma chamada ao construtor de frozenset. Observe a saída na sessão de console a seguir:

>>> frozenset(range(10))
frozenset({0, 1, 2, 3, 4, 5, 6, 7, 8, 9})

E por falar em sintaxe, a ideia das listcomps foi adaptada para criar conjuntos também.

3.10.2. Compreensões de conjuntos

Compreensões de conjuntos (setcomps) apareceram há bastante tempo, no Python 2.7, junto com as dictcomps que vimos na Seção 3.2.1. O Exemplo 15 mostra procedimento.

Exemplo 15. Cria um conjunto de caracteres Latin-1 que tenham a palavra "SIGN" em seus nomes Unicode
>>> from unicodedata import name  (1)
>>> {chr(i) for i in range(32, 256) if 'SIGN' in name(chr(i),'')}  (2)
{'§', '=', '¢', '#', '¤', '<', '¥', 'µ', '×', '$', '¶', '£', '©',
'°', '+', '÷', '±', '>', '¬', '®', '%'}
  1. Importa a função name de unicodedata para obter os nomes dos caracteres.

  2. Cria um conjunto de caracteres com códigos entre 32 e 255 que contenham a palavra 'SIGN' em seus nomes.

A ordem da saída muda a cada processo Python, devido ao hash "salgado", mencionado na Seção 3.4.1.

Questões de sintaxe à parte, vamos considerar agora o comportamento dos conjuntos.

3.11. Consequências práticas da forma de funcionamento dos conjuntos

Os tipos set e frozenset são ambos implementados com um tabela de hash. Isso tem os seguintes efeitos:

  • Elementos de conjuntos tem que ser objetos hashable. Eles precisam implementar métodos __hash__ e __eq__ adequados, como descrido na Seção 3.4.1.

  • O teste de existência de um elemento é muito eficiente. Um conjunto pode ter milhões de elementos, mas um elemento pode ser localizado diretamente, computando o código hash da chave e derivando um deslocamento do índice, com o possível ônus de um pequeno número de tentativas até encontrar a entrada correspondente ou exaurir a busca.

  • Conjuntos usam mais memória se comparados aos simples ponteiros de um array para seus elementos—que é uma estrutura mais compacta, mas também muito mais lenta para buscas se seu tamanho cresce além de uns poucos elementos.

  • A ordem dos elementos depende da ordem de inserção, mas não de forma útil ou confiável. Se dois elementos são diferentes mas tem o mesmo código hash, sua posição depende de qual elemento foi inserido primeiro.

  • Acrescentar elementos a um conjunto muda a ordem dos elementos existentes. Isso ocorre porque o algoritmo se torna menos eficiente se a tabela de hash estiver com mais de dois terços de ocupação, então o Python pode ter que mover e redimensionar a tabela conforme ela cresce. Quando isso acontece, os elementos são reinseridos e sua ordem relativa pode mudar.

Veja o post "Internals of sets and dicts" (EN) no fluentpython.com para maiores detalhes.

Agora vamos revisar a vasta seleção de operações oferecidas pelos conjuntos.

3.11.1. Operações de conjuntos

A Figura 2 dá uma visão geral dos métodos disponíveis em conjuntos mutáveis e imutáveis. Muitos deles são métodos especiais que sobrecarregam operadores, tais como & and >=. A Tabela 8 mostra os operadores matemáticos de conjuntos que tem operadores ou métodos correspondentes no Python. Note que alguns operadores e métodos realizam mudanças no mesmo lugar sobre o conjunto alvo (por exemplo, &=, difference_update, etc.). Tais operações não fazem sentido no mundo ideal dos conjuntos matemáticos, e também não são implementadas em frozenset.

👉 Dica

Os operadores infixos na Tabela 8 exigem que os dois operandos sejam conjuntos, mas todos os outros métodos recebem um ou mais argumentos iteráveis. Por exemplo, para produzir a união de quatro coleções, a, b, c, e d, você pode chamar a.union(b, c, d), onde a precisa ser um set, mas b, c, e d podem ser iteráveis de qualquer tipo que produza itens hashable. Para criar um novo conjunto com a união de quatro iteráveis, desde o Python 3.5 você pode escrever {*a, *b, *c, *d} ao invés de atualizar um conjunto existente, graças à PEP 448—Additional Unpacking Generalizations (Generalizações de Desempacotamento Adicionais).

Diagrama de classe UML para `Set` e `MutableSet`
Figura 2. Diagrama de classes UML simplificado para MutableSet e suas superclasses em collections.abc (nomes em itálico são classes e métodos abstratos; métodos de operadores reversos foram omitidos por concisão).
Tabela 8. Operações matemáticas com conjuntos: esses métodos ou produzem um novo conjunto ou atualizam o conjunto alvo no mesmo lugar, se ele for mutável
Math symbol Python operator Method Description

S ∩ Z

s & z

s.__and__(z)

Intersecção de s e z

z & s

s.__rand__(z)

Operador & invertido

s.intersection(it, …)

Intersecção de s e todos os conjuntos construídos a partir de iteráveis it, etc.

s &= z

s.__iand__(z)

s atualizado com a intersecção de s e z

s.intersection_update(it, …)

s atualizado com a intersecção de s e todos os conjuntos construídos a partir de iteráveis it, etc.

S ∪ Z

s | z

s.__or__(z)

União de s e z

z | s

s.__ror__(z)

| invertido

s.union(it, …)

União de s e todos os conjuntos construídos a partir de iteráveis it, etc.

s |= z

s.__ior__(z)

s atualizado com a união de s e z

s.update(it, …)

s atualizado com a união de s e todos os conjuntos construídos a partir de iteráveis it, etc.

S \ Z

s - z

s.__sub__(z)

Complemento relativo ou diferença entre s e z

z - s

s.__rsub__(z)

Operador - invertido

s.difference(it, …)

Diferença entre s e todos os conjuntos construídos a partir de iteráveis it, etc.

s -= z

s.__isub__(z)

s atualizado com a diferença entre s e z

s.difference_update(it, …)

s atualizado com a diferença entre s e todos os conjuntos construídos a partir de iteráveis it, etc.

S ∆ Z

s ^ z

s.__xor__(z)

Diferença simétrica (o complemento da intersecção s & z)

z ^ s

s.__rxor__(z)

Operador ^ invertido

s.symmetric_difference(it)

Complemento de s & set(it)

s ^= z

s.__ixor__(z)

s atualizado com a diferença simétrica de s e z

s.symmetric_difference_update(it, …)

s atualizado com a diferença simétrica de s e todos os conjuntos construídos a partir de iteráveis it, etc.

A Tabela 9 lista predicados de conjuntos: operadores e métodos que devolvem True ou False.

Tabela 9. Operadores e métodos de comparação de conjuntos que devolvem um booleano
Math symbol Python operator Method Description

S ∩ Z = ∅

s.isdisjoint(z)

s e z são disjuntos (não tem elementos em comum)

e ∈ S

e in s

s.__contains__(e)

Elemento e é membro de s

S ⊆ Z

s <= z

s.__le__(z)

s é um subconjunto do conjunto z

s.issubset(it)

s é um subconjunto do conjunto criado a partir do iterável it

S ⊂ Z

s < z

s.__lt__(z)

s é um subconjunto próprio[41] do conjunto z

S ⊇ Z

s >= z

s.__ge__(z)

s é um superconjunto do conjunto z

s.issuperset(it)

s é um superconjunto do conjunto criado a partir do iterável it

S ⊃ Z

s > z

s.__gt__(z)

s é um superconjunto próprio do conjunto z

Além de operadores e métodos derivados da teoria matemática dos conjuntos, os tipos conjunto implementam outros métodos para tornar seu uso prático, resumidos na Tabela 10.

Tabela 10. Métodos adicionais de conjuntos
set frozenset  

s.add(e)

Adiciona elemento e a s

s.clear()

Remove todos os elementos de s

s.copy()

Cópia rasa de s

s.discard(e)

Remove elemento e de s, se existir

s.__iter__()

Obtém iterador de s

s.__len__()

len(s)

s.pop()

Remove e devolve um elemento de s, gerando um KeyError se s estiver vazio

s.remove(e)

Remove elemento e de s, gerando um KeyError se e não existir em s

Isso encerra nossa visão geral dos recursos dos conjuntos. Como prometido na Seção 3.8, vamos agora ver como dois dos tipos de views de dicionários se comportam de forma muito similar a um frozenset.

3.12. Operações de conjuntos em views de dict

A Tabela 11 mostra como os objetos view devolvidos pelos métodos .keys() e .items() de dict são notavelmente similares a um frozenset.

Tabela 11. Métodos implementados por frozenset, dict_keys, e dict_items
frozenset dict_keys dict_items Description

s.__and__(z)

s & z (interseção de s e z)

s.__rand__(z)

operador & invertido

s.__contains__()

e in s

s.copy()

Cópia rasa de s

s.difference(it, …)

Diferença entre s e os iteráveis it, etc.

s.intersection(it, …)

Intersecção de s e dos iteráveis it, etc.

s.isdisjoint(z)

s e z são disjuntos (não tem elementos em comum)

s.issubset(it)

s é um subconjunto do iterável it

s.issuperset(it)

s é um superconjunto do iterável it

s.__iter__()

obtém iterador para s

s.__len__()

len(s)

s.__or__(z)

s | z (união de s e z)

s.__ror__()

Operador | invertido

s.__reversed__()

Obtém iterador para s com a ordem invertida

s.__rsub__(z)

Operador - invertido

s.__sub__(z)

s - z (diferença entre s e z)

s.symmetric_difference(it)

Complemento de s & set(it)

s.union(it, …)

União de s e dos iteráveis`it`, etc.

s.__xor__()

s ^ z (diferença simétrica de s e z)

s.__rxor__()

Operador ^ invertido

Especificamente, dict_keys e dict_items implementam os métodos especiais para suportar as poderosas operações de conjuntos & (intersecção), | (união), - (diferença), and ^ (diferença simétrica).

Por exemplo, usando & é fácil obter as chaves que aparecem em dois dicionários:

>>> d1 = dict(a=1, b=2, c=3, d=4)
>>> d2 = dict(b=20, d=40, e=50)
>>> d1.keys() & d2.keys()
{'b', 'd'}

Observe que o valor devolvido por & é um set. Melhor ainda: os operadores de conjuntos em views de dicionários são compatíveis com instâncias de set. Veja isso:

>>> s = {'a', 'e', 'i'}
>>> d1.keys() & s
{'a'}
>>> d1.keys() | s
{'a', 'c', 'b', 'd', 'i', 'e'}
⚠️ Aviso

Uma view obtida de dict_items só funciona como um conjunto se todos os valores naquele dict são hashable. Tentar executar operações de conjuntos sobre uma view devolvida por dict_items que contenha valores não-hashable gera um TypeError: unhashable type 'T', sendo T o tipo do valor incorreto.

Por outro lado, uma view devolvida por dict_keys sempre pode ser usada como um conjunto, pois todas as chaves são hashable—por definição.

Usar operações de conjunto com views pode evitar a necessidade de muitos loops e ifs quando seu código precisa inspecionar o conteúdo de dicionários. Deixe a eficiente implementação do Python em C trabalhar para você!

Com isso, encerramos esse capítulo.

3.13. Resumo do capítulo

Dicionários são a pedra fundamental do Python. Ao longo dos anos, a sintaxe literal familiar, {k1: v1, k2: v2}, foi aperfeiçoada para suportar desempacotamento com ** e pattern matching, bem como com compreensões de dict.

Além do dict básico, a biblioteca padrão oferece mapeamentos práticos prontos para serem usados, como o defaultdict, o ChainMap, e o Counter, todos definidos no módulo collections. Com a nova implementação de dict, o OrderedDict não é mais tão útil quanto antes, mas deve permanecer na biblioteca padrão para manter a compatibilidade retroativa—e por suas características específicas ausentes em dict, tal como a capacidade de levar em consideração o ordenamento das chaves em uma comparação ==. Também no módulo collections está o UserDict, uma classe base fácil de usar na criação de mapeamentos personalizados.

Dois métodos poderosos disponíveis na maioria dos mapeamentos são setdefault e update. O método setdefault pode atualizar itens que mantenham valores mutáveis—por exemplo, em um dict de valores list—evitando uma segunda busca pela mesma chave. O método update permite inserir ou sobrescrever itens em massa a partir de qualquer outro mapeamento, desde iteráveis que forneçam pares (chave, valor) até argumentos nomeados. Os construtores de mapeamentos também usam update internamente, permitindo que instâncias sejam inicializadas a partir de outros mapeamentos, de iteráveis e de argumentos nomeados. Desde o Python 3.9 também podemos usar o operador |= para atualizar uma mapeamento e o operador | para criar um novo mapeamento a partir a união de dois mapeamentos.

Um gancho elegante na API de mapeamento é o método __missing__, que permite personalizar o que acontece quando uma chave não é encontrada ao se usar a sintaxe d[k] syntax, que invoca __getitem__.

O módulo collections.abc oferece as classes base abstratas Mapping e MutableMapping como interfaces padrão, muito úteis para checagem de tipo durante a execução. O MappingProxyType, do módulo types, cria uma fachada imutável para um mapeamento que você precise proteger de modificações acidentais. Existem também ABCs para Set e MutableSet.

Views de dicionários foram uma grande novidade no Python 3, eliminando o uso desnecessário de memória dos métodos .keys(), .values(), e .items() do Python 2, que criavam listas duplicando os dados na instância alvo de dict. Além disso, as classes dict_keys e dict_items suportam os operadores e métodos mais úteis de frozenset.

3.14. Leitura complementar

Na documentação da Biblioteca Padrão do Python, a seção "collections—Tipos de dados de contêineres" inclui exemplos e receitas práticas para vários tipos de mapeamentos. O código-fonte do Python para o módulo, Lib/collections/__init__.py, é uma excelente referência para qualquer um que deseje criar novos tipos de mapeamentos ou entender a lógica dos tipos existentes. O capítulo 1 do Python Cookbook, 3rd ed. (O’Reilly), de David Beazley e Brian K. Jones traz 20 receitas práticas e perpicazes usando estruturas de dados—a maioria mostrando formas inteligentes de usar dict.

Greg Gandenberger defende a continuidade do uso de collections.OrderedDict, com os argumentos de que "explícito é melhor que implícito," compatibilidade retroativa, e o fato de algumas ferramentas e bibliotecas presumirem que a ordenação das chaves de um dict é irrelevante—nesse post: "Python Dictionaries Are Now Ordered. Keep Using OrderedDict" (Os dicionários do Python agora são ordenados. Continue a usar OrderedDict) (EN).

A PEP 3106—​Revamping dict.keys(), .values() and .items() (Renovando dict.keys(), .values() e .items()) (EN) foi onde Guido van Rossum apresentou o recurso de views de dicionário para o Python 3. No resumo, ele afirma que a ideia veio da Java Collections Framework.

O PyPy foi o primeiro interpretador Python a implementar a proposta de Raymond Hettinger de dicts compactos, e eles escreverem em seu blog sobre isso, em "Faster, more memory efficient and more ordered dictionaries on PyPy" (Dicionários mais rápidos, mais eficientes em termos de memória e mais ordenados no PyPy) (EN), reconhecendo que um layout similar foi adotado no PHP 7, como descrito em PHP’s new hashtable implementation (A nova implementação de tabelas de hash do PHP) (EN). É sempre muito bom quando criadores citam trabalhos anteriores de outros.

Na PyCon 2017, Brandon Rhodes apresentou "The Dictionary Even Mightier" (O dicionário, ainda mais poderoso) (EN), uma continuação de sua apresentação animada clássica "The Mighty Dictionary" (O poderoso dicionário) (EN)—incluindo colisões de hash animadas! Outro vídeo atual mas mais aprofundado sobre o funcionamento interno do dict do Python é "Modern Dictionaries" (Dicionários modernos) (EN) de Raymond Hettinger, onde ele nos diz que após inicialmente fracassar em convencer os desenvolvedores principais do Python sobre os dicts compactos, ele persuadiu a equipe do PyPy, eles os adotaram, a ideia ganhou força, e finalmente foi adicionada ao CPython 3.6 por INADA Naoki. Para saber todos os detalhes, dê uma olhada nos extensos comentários no código-fonte do CPython para Objects/dictobject.c (EN) e no documento de design em Objects/dictnotes.txt (EN).

A justificativa para a adição de conjuntos ao Python está documentada na PEP 218—​Adding a Built-In Set Object Type (Adicionando um objeto embutido de tipo conjunto). Quando a PEP 218 foi aprovada, nenhuma sintaxe literal especial foi adotada para conjuntos. Os literais set foram criados para o Python 3 e implementados retroativamente no Python 2.7, assim como as compreensões de dict e set. Na PyCon 2019, eu apresentei "Set Practice: learning from Python’s set types" (A Prática dos Conjuntos: aprendendo com os tipos conjunto do Python) (EN), descrevendo casos de uso de conjuntos em programas reais, falando sobre o design de sua API, e sobre a implementação da uintset, uma classe de conjunto para elementos inteiros, usando um vetor de bits ao invés de uma tabela de hash, inspirada por um exemplo do capítulo 6 do excelente The Go Programming Language (A Linguagem de Programação Go) (EN), de Alan Donovan e Brian Kernighan (Addison-Wesley).

A revista Spectrum, do IEEE, tem um artigo sobre Hans Peter Luhn, um prolífico inventor, que patenteou um conjunto de cartões interligados que permitiam selecionar receitas de coquetéis a partir dos ingredientes disponíveis, entre inúmeras outras invenções, incluindo…​ tabelas de hash! Veja "Hans Peter Luhn and the Birth of the Hashing Algorithm" (Hans Peter Luhn e o Nascimento do Algoritmo de Hash).

Ponto de Vista

Açúcar sintático

Meu amigo Geraldo Cohen certa vez observou que o Python é "simples e correto."

Puristas de linguagens de programação gostam de desprezar a sintaxe como algo desimportante.

Syntactic sugar causes cancer of the semicolon.[42]

— Alan Perlis

A sintaxe é a interface de usuário de uma linguagem de programação, então tem muita importância na prática.

Antes de encontrar o Python, fiz um pouco de programação para a web usando Perl e PHP. A sintaxe para mapeamentos nessas linguagens é muito útil, e eu sinto imensa saudade dela sempre que tenho que usar Java ou C.

Uma boa sintaxe para mapeamentos literais é muito conveniente para configuração, para implementações guiadas por tabelas, e para conter dados para prototipagem e testes. Essa foi uma das lições que os projetistas do Go aprenderam com as linguagens dinâmicas. A falta de uma boa forma de expressar dados estruturados no código empurrou a comunidade Java a adotar o prolixo e excessivamente complexo XML como formato de dados.

JSON foi proposto como "The Fat-Free Alternative to XML" (A alternativa sem gordura ao XML) e se tornou um imenso sucesso, substituindo o XML em vários contextos. Uma sintaxe concisa para listas e dicionários resulta em um excelente formato para troca de dados.

O PHP e o Ruby imitaram a sintaxe de hash do Perl, usando => para ligar chaves a valores. O JavaScript usa : como o Python. Por que usar dois caracteres, quando um já é legível o bastante?

O JSON veio do JavaScript, mas por acaso também é quase um subconjunto exato da sintaxe do Python. O JSON é compatível com o Python, exceto pela grafia dos valores true, false, e null.

Armin Ronacher tuitou que gosta de brincar com o espaço de nomes global do Python, para acrescentar apelidos compatíveis com o JSON para o True, o False, e o None do Python, pois daí ele pode colar trechos de JSON diretamente no console. Sua ideia básica:

>>> true, false, null = True, False, None
>>> fruit = {
...     "type": "banana",
...     "avg_weight": 123.2,
...     "edible_peel": false,
...     "species": ["acuminata", "balbisiana", "paradisiaca"],
...     "issues": null,
... }
>>> fruit
{'type': 'banana', 'avg_weight': 123.2, 'edible_peel': False,
'species': ['acuminata', 'balbisiana', 'paradisiaca'], 'issues': None}

A sintaxe que todo mundo agora usa para trocar dados é a sintaxe de dict e list do Python. E então temos uma sintaxe agradável com a conveniência da preservação da ordem de inserção.

Simples e correto.

4. Texto em Unicode versus Bytes

Humanos usam texto. Computadores falam em bytes.[43]

— Esther Nam e Travis Fischer

O Python 3 introduziu uma forte distinção entre strings de texto humano e sequências de bytes puros. A conversão automática de sequências de bytes para texto Unicode ficou para trás no Python 2. Este capítulo trata de strings Unicode, sequências de bytes, e das codificações usadas para converter umas nas outras.

Dependendo do que você faz com o Python, pode achar que entender o Unicode não é importante. Isso é improvável, mas mesmo que seja o caso, não há como escapar da separação entre str e bytes, que agora exige conversões explícitas. Como um bônus, você descobrirá que os tipos especializados de sequências binárias bytes e bytearray oferecem recursos que a classe str "pau para toda obra" do Python 2 não oferecia.

Nesse capítulo, veremos os seguintes tópicos:

  • Caracteres, pontos de código e representações binárias

  • Recursos exclusivos das sequências binárias: bytes, bytearray, e memoryview

  • Codificando para o Unicode completo e para conjuntos de caracteres legados

  • Evitando e tratando erros de codificação

  • Melhores práticas para lidar com arquivos de texto

  • A armadilha da codificação default e questões de E/S padrão

  • Comparações seguras de texto Unicode com normalização

  • Funções utilitárias para normalização, case folding (equiparação maiúsculas/minúsculas) e remoção de sinais diacríticos por força bruta

  • Ordenação correta de texto Unicode com locale e a biblioteca pyuca

  • Metadados de caracteres do banco de dados Unicode

  • APIs duais, que processam str e bytes

4.1. Novidades nesse capítulo

O suporte ao Unicode no Python 3 sempre foi muito completo e estável, então o acréscimo mais notável é a Seção 4.9.1, descrevendo um utilitário de linha de comando para busca no banco de dados Unicode—uma forma de encontrar gatinhos sorridentes ou hieróglifos do Egito antigo.

Vale a pena mencionar que o suporte a Unicode no Windows ficou melhor e mais simples desde o Python 3.6, como veremos na Seção 4.6.1.

Vamos começar então com os conceitos não-tão-novos mas fundamentais de caracteres, pontos de código e bytes.

✒️ Nota

Para essa segunda edição, expandi a seção sobre o módulo struct e o publiquei online em "Parsing binary records with struct" (Analisando registros binários com struct), (EN) no fluentpython.com, o website que complementa o livro.

Lá você também vai encontrar o "Building Multi-character Emojis" (Criando emojis multi-caractere) (EN), descrevendo como combinar caracteres Unicode para criar bandeiras de países, bandeiras de arco-íris, pessoas com tonalidades de pele diferentes e ícones de diferentes tipos de famílias.

4.2. Questões de caracteres

O conceito de "string" é bem simples: uma string é uma sequência de caracteres. O problema está na definição de "caractere".

Em 2023, a melhor definição de "caractere" que temos é um caractere Unicode. Consequentemente, os itens que compõe um str do Python 3 são caracteres Unicode, como os itens de um objeto unicode no Python 2. Em contraste, os itens de uma str no Python 2 são bytes, assim como os itens num objeto bytes do Python 3.

O padrão Unicode separa explicitamente a identidade dos caracteres de representações binárias específicas:

  • A identidade de um caractere é chamada de ponto de código (code point). É um número de 0 a 1.114.111 (na base 10), representado no padrão Unicode na forma de 4 a 6 dígitos hexadecimais precedidos pelo prefixo "U+", de U+0000 a U+10FFFF. Por exemplo, o ponto de código da letra A é U+0041, o símbolo do Euro é U+20AC, e o símbolo musical da clave de sol corresponde ao ponto de código U+1D11E. Cerca de 13% dos pontos de código válidos tem caracteres atribuídos a si no Unicode 13.0.0, a versão do padrão usada no Python 3.10.

  • Os bytes específicos que representam um caractere dependem da codificação (encoding) usada. Uma codificação, nesse contexto, é um algoritmo que converte pontos de código para sequências de bytes, e vice-versa. O ponto de código para a letra A (U+0041) é codificado como um único byte, \x41, na codificação UTF-8, ou como os bytes \x41\x00 na codificação UTF-16LE. Em um outro exemplo, o UTF-8 exige três bytes para codificar o símbolo do Euro (U+20AC): \xe2\x82\xac. Mas no UTF-16LE o mesmo ponto de código é U+20AC representado com dois bytes: \xac\x20.

Converter pontos de código para bytes é codificar; converter bytes para pontos de código é decodificar. Veja o Exemplo 1.

Exemplo 1. Codificando e decodificando
>>> s = 'café'
>>> len(s)  # (1)
4
>>> b = s.encode('utf8')  # (2)
>>> b
b'caf\xc3\xa9'  # (3)
>>> len(b)  # (4)
5
>>> b.decode('utf8')  # (5)
'café'
  1. A str 'café' tem quatro caracteres Unicode.

  2. Codifica str para bytes usando a codificação UTF-8.

  3. bytes literais são prefixados com um b.

  4. bytes b tem cinco bytes (o ponto de código para "é" é codificado com dois bytes em UTF-8).

  5. Decodifica bytes para str usando a codificação UTF-8.

👉 Dica

Um jeito fácil de sempre lembrar a distinção entre .decode() e .encode() é se convencer que sequências de bytes podem ser enigmáticos dumps de código de máquina, ao passo que objetos str Unicode são texto "humano". Daí que faz sentido decodificar bytes em str, para obter texto legível por seres humanos, e codificar str em bytes, para armazenamento ou transmissão.

Apesar do str do Python 3 ser quase o tipo unicode do Python 2 com um novo nome, o bytes do Python 3 não é meramente o velho str renomeado, e há também o tipo estreitamente relacionado bytearray. Então vale a pena examinar os tipos de sequências binárias antes de avançar para questões de codificação/decodificação.

4.3. Os fundamentos do byte

Os novos tipos de sequências binárias são diferentes do str do Python 2 em vários aspectos. A primeira coisa importante é que existem dois tipos embutidos básicos de sequências binárias: o tipo imutável bytes, introduzido no Python 3, e o tipo mutável bytearray, introduzido há tempos, no Python 2.6[44]. A documentação do Python algumas vezes usa o termo genérico "byte string" (string de bytes, na documentação em português) para se referir a bytes e bytearray.

Cada item em bytes ou bytearray é um inteiro entre 0 e 255, e não uma string de um caractere, como no str do Python 2. Entretanto, uma fatia de uma sequência binária sempre produz uma sequência binária do mesmo tipo—incluindo fatias de tamanho 1. Veja o Exemplo 2.

Exemplo 2. Uma sequência de cinco bytes, como bytes e como `bytearray
>>> cafe = bytes('café', encoding='utf_8')  (1)
>>> cafe
b'caf\xc3\xa9'
>>> cafe[0]  (2)
99
>>> cafe[:1]  (3)
b'c'
>>> cafe_arr = bytearray(cafe)
>>> cafe_arr  (4)
bytearray(b'caf\xc3\xa9')
>>> cafe_arr[-1:]  (5)
bytearray(b'\xa9')
  1. bytes pode ser criado a partir de uma str, dada uma codificação.

  2. Cada item é um inteiro em range(256).

  3. Fatias de bytes também são bytes—mesmo fatias de um único byte.

  4. Não há uma sintaxe literal para bytearray: elas aparecem como bytearray() com um literal bytes como argumento.

  5. Uma fatia de bytearray também é uma bytearray.

⚠️ Aviso

O fato de my_bytes[0] obter um int mas my_bytes[:1] devolver uma sequência de bytes de tamanho 1 só é surpreeendente porque estamos acostumados com o tipo str do Python, onde s[0] == s[:1]. Para todos os outros tipos de sequência no Python, um item não é o mesmo que uma fatia de tamanho 1.

Apesar de sequências binárias serem na verdade sequências de inteiros, sua notação literal reflete o fato delas frequentemente embutirem texto ASCII. Assim, quatro formas diferentes de apresentação são utilizadas, dependendo do valor de cada byte:

  • Para bytes com código decimais de 32 a 126—do espaço ao ~ (til)—é usado o próprio caractere ASCII.

  • Para os bytes correspondendo ao tab, à quebra de linha, ao carriage return (CR) e à \, são usadas as sequências de escape \t, \n, \r, e \\.

  • Se os dois delimitadores de string, ' e ", aparecem na sequência de bytes, a sequência inteira é delimitada com ', e qualquer ' dentro da sequência é precedida do caractere de escape, assim \'.[45]

  • Para qualquer outro valor do byte, é usada uma sequência de escape hexadecimal (por exemplo, \x00 é o byte nulo).

É por isso que no Exemplo 2 vemos b’caf\xc3\xa9': os primeiros três bytes, b’caf', estão na faixa de impressão do ASCII, ao contrário dos dois últimos.

Tanto bytes quanto bytearray suportam todos os métodos de str, exceto aqueles relacionados a formatação (format, format_map) e aqueles que dependem de dados Unicode, incluindo casefold, isdecimal, isidentifier, isnumeric, isprintable, e encode. Isso significa que você pode usar os métodos conhecidos de string, como endswith, replace, strip, translate, upper e dezenas de outros, com sequências binárias—mas com argumentos bytes em vez de str. Além disso, as funções de expressões regulares no módulo re também funcionam com sequências binárias, se a regex for compilada a partir de uma sequência binária ao invés de uma str. Desde o Python 3.5, o operador % voltou a funcionar com sequências binárias.[46]

As sequências binárias tem um método de classe que str não possui, chamado fromhex, que cria uma sequência binária a partir da análise de pares de dígitos hexadecimais, separados opcionalmente por espaços:

>>> bytes.fromhex('31 4B CE A9')
b'1K\xce\xa9'

As outras formas de criar instâncias de bytes ou bytearray são chamadas a seus construtores com:

  • Uma str e um argumento nomeado encoding

  • Um iterável que forneça itens com valores entre 0 e 255

  • Um objeto que implemente o protocolo de buffer (por exemplo bytes, bytearray, memoryview, array.array), que copia os bytes do objeto fonte para a recém-criada sequência binária

⚠️ Aviso

Até o Python 3.5, era possível chamar bytes ou bytearray com um único inteiro, para criar uma sequência daquele tamanho inicializada com bytes nulos. Essa assinatura for descontinuada no Python 3.5 e removida no Python 3.6. Veja a PEP 467—​Minor API improvements for binary sequences (Pequenas melhorias na API para sequências binárias) (EN).

Criar uma sequência binária a partir de um objeto tipo buffer é uma operação de baixo nível que pode envolver conversão de tipos. Veja uma demonstração no Exemplo 3.

Exemplo 3. Inicializando bytes a partir de dados brutos de um array
>>> import array
>>> numbers = array.array('h', [-2, -1, 0, 1, 2])  (1)
>>> octets = bytes(numbers)  (2)
>>> octets
b'\xfe\xff\xff\xff\x00\x00\x01\x00\x02\x00'  (3)
  1. O typecode 'h' cria um array de short integers (inteiros de 16 bits).

  2. octets mantém uma cópia dos bytes que compõem numbers.

  3. Esses são os 10 bytes que representam os 5 inteiros pequenos.

Criar um objeto bytes ou bytearray a partir de qualquer fonte tipo buffer vai sempre copiar os bytes. Já objetos memoryview permitem compartilhar memória entre estruturas de dados binários, como vimos na Seção 2.10.2.

Após essa exploração básica dos tipos de sequências de bytes do Python, vamos ver como eles são convertidos de e para strings.

4.4. Codificadores/Decodificadores básicos

A distribuição do Python inclui mais de 100 codecs (encoders/decoders, _codificadores/decodificadores) para conversão de texto para bytes e vice-versa. Cada codec tem um nome, como 'utf_8', e muitas vezes apelidos, tais como 'utf8', 'utf-8', e 'U8', que você pode usar como o argumento de codificação em funções como open(), str.encode(), bytes.decode(), e assim por diante. O Exemplo 4 mostra o mesmo texto codificado como três sequências de bytes diferentes.

Exemplo 4. A string "El Niño" codificada com três codecs, gerando sequências de bytes muito diferentes
>>> for codec in ['latin_1', 'utf_8', 'utf_16']:
...     print(codec, 'El Niño'.encode(codec), sep='\t')
...
latin_1 b'El Ni\xf1o'
utf_8   b'El Ni\xc3\xb1o'
utf_16  b'\xff\xfeE\x00l\x00 \x00N\x00i\x00\xf1\x00o\x00'

A Figura 1 mostra um conjunto de codecs gerando bytes a partir de caracteres como a letra "A" e o símbolo musical da clave de sol. Observe que as últimas três codificações tem bytes múltiplos e tamanho variável.

Tabela de demonstração de codificações
Figura 1. Doze caracteres, seus pontos de código, e sua representação binária (em hexadecimal) em 7 codificações diferentes (asteriscos indicam que o caractere não pode ser representado naquela codificação).

Aqueles asteriscos todos na Figura 1 deixam claro que algumas codificações, como o ASCII e mesmo o multi-byte GB2312, não conseguem representar todos os caracteres Unicode. As codificações UTF, por outro lado, foram projetadas para lidar com todos os pontos de código do Unicode.

As codificações apresentadas na Figura 1 foram escolhidas para montar uma amostra representativa:

latin1 a.k.a. iso8859_1

Importante por ser a base de outras codificações,tal como a cp1252 e o próprio Unicode (observe que os valores binários do latin1 aparecem nos bytes do cp1252 e até nos pontos de código).

cp1252

Um superconjunto útil de latin1, criado pela Microsoft, acrescentando símbolos convenientes como as aspas curvas e o € (euro); alguns aplicativos de Windows chamam essa codificação de "ANSI", mas ela nunca foi um padrão ANSI real.

cp437

O conjunto de caracteres original do IBM PC, com caracteres de desenho de caixas. Incompatível com o latin1, que surgiu depois.

gb2312

Padrão antigo para codificar ideogramas chineses simplificados usados na República da China; uma das várias codificações muito populares para línguas asiáticas.

utf-8

De longe a codificação de 8 bits mais comum na web. Em julho de 2021, o "W3Techs: Usage statistics of character encodings for websites" afirma que 97% dos sites usam UTF-8, um grande avanço sobre os 81,4% de setembro de 2014, quando escrevi este capítulo na primeira edição.

utf-16le

Uma forma do esquema de codificação UTF de 16 bits; todas as codificações UTF-16 suportam pontos de código acima de U+FFFF, através de sequências de escape chamadas "pares substitutos".

⚠️ Aviso

A UTF-16 sucedeu a codificação de 16 bits original do Unicode 1.0—a UCS-2—há muito tempo, em 1996. A UCS-2 ainda é usada em muitos sistemas, apesar de ter sido descontinuada ainda no século passado, por suportar apenas ponto de código até U+FFFF. Em 2021, mas de 57% dos pontos de código alocados estava acima de U+FFFF, incluindo os importantíssimos emojis.

Após completar essa revisão das codificações mais comuns, vamos agora tratar das questões relativas a operações de codificação e decodificação.

4.5. Entendendo os problemas de codificação/decodificação

Apesar de existir uma exceção genérica, UnicodeError, o erro relatado pelo Python em geral é mais específico: ou é um UnicodeEncodeError (ao converter uma str para sequências binárias) ou é um UnicodeDecodeError (ao ler uma sequência binária para uma str). Carregar módulos do Python também pode geram um SyntaxError, quando a codificação da fonte for inesperada. Vamos ver como tratar todos esses erros nas próximas seções.

👉 Dica

A primeira coisa a observar quando aparece um erro de Unicode é o tipo exato da exceção. É um UnicodeEncodeError, um UnicodeDecodeError, ou algum outro erro (por exemplo, SyntaxError) mencionando um problema de codificação? Para resolver o problema, você primeiro precisa entendê-lo.

4.5.1. Tratando o UnicodeEncodeError

A maioria dos codecs não-UTF entendem apenas um pequeno subconjunto dos caracteres Unicode. Ao converter texto para bytes, um UnicodeEncodeError será gerado se um caractere não estiver definido na codificação alvo, a menos que seja fornecido um tratamento especial, passando um argumento errors para o método ou função de codificação. O comportamento para tratamento de erro é apresentado no Exemplo 5.

Exemplo 5. Encoding to bytes: success and error handling
>>> city = 'São Paulo'
>>> city.encode('utf_8')  (1)
b'S\xc3\xa3o Paulo'
>>> city.encode('utf_16')
b'\xff\xfeS\x00\xe3\x00o\x00 \x00P\x00a\x00u\x00l\x00o\x00'
>>> city.encode('iso8859_1')  (2)
b'S\xe3o Paulo'
>>> city.encode('cp437')  (3)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "/.../lib/python3.4/encodings/cp437.py", line 12, in encode
    return codecs.charmap_encode(input,errors,encoding_map)
UnicodeEncodeError: 'charmap' codec can't encode character '\xe3' in
position 1: character maps to <undefined>
>>> city.encode('cp437', errors='ignore')  (4)
b'So Paulo'
>>> city.encode('cp437', errors='replace')  (5)
b'S?o Paulo'
>>> city.encode('cp437', errors='xmlcharrefreplace')  (6)
b'S&#227;o Paulo'
  1. As codificações UTF lidam com qualquer str

  2. iso8859_1 também funciona com a string 'São Paulo'.

  3. cp437 não consegue codificar o 'ã' ("a" com til). O método default de tratamento de erro, — 'strict'—gera um UnicodeEncodeError.

  4. O método de tratamento errors='ignore' pula os caracteres que não podem ser codificados; isso normalmente é uma péssima ideia, levando a perda silenciosa de informação.

  5. Ao codificar, errors='replace' substitui os caracteres não-codificáveis por um '?'; aqui também há perda de informação, mas os usuários recebem um alerta de que algo está faltando.

  6. 'xmlcharrefreplace' substitui os caracteres não-codificáveis por uma entidade XML. Se você não pode usar UTF e não pode perder informação, essa é a única opção.

✒️ Nota

O tratamento de erros de codecs é extensível. Você pode registrar novas strings para o argumento errors passando um nome e uma função de tratamento de erros para a função codecs.register_error function. Veja documentação de codecs.register_error (EN).

O ASCII é um subconjunto comum a todas as codificações que conheço, então a codificação deveria sempre funcionar se o texto for composto exclusivamente por caracteres ASCII. O Python 3.7 trouxe um novo método booleano, str.isascii(), para verificar se seu texto Unicode é 100% ASCII. Se for, você deve ser capaz de codificá-lo para bytes em qualquer codificação sem gerar um UnicodeEncodeError.

4.5.2. Tratando o UnicodeDecodeError

Nem todo byte contém um caractere ASCII válido, e nem toda sequência de bytes é um texto codificado em UTF-8 ou UTF-16 válidos; assim, se você presumir uma dessas codificações ao converter um sequência binária para texto, pode receber um UnicodeDecodeError, se bytes inesperados forem encontrados.

Por outro lado, várias codificações de 8 bits antigas, como a 'cp1252', a 'iso8859_1' e a 'koi8_r' são capazes de decodificar qualquer série de bytes, incluindo ruído aleatório, sem reportar qualquer erro. Portanto, se seu programa presumir a codificação de 8 bits errada, ele vai decodificar lixo silenciosamente.

👉 Dica

Caracteres truncados ou distorcidos são conhecidos como "gremlins" ou "mojibake" (文字化け—"texto modificado" em japonês).

O Exemplo 6 ilustra a forma como o uso do codec errado pode produzir gremlins ou um UnicodeDecodeError.

Exemplo 6. Decodificando de str para bytes: sucesso e tratamento de erro
>>> octets = b'Montr\xe9al'  (1)
>>> octets.decode('cp1252')  (2)
'Montréal'
>>> octets.decode('iso8859_7')  (3)
'Montrιal'
>>> octets.decode('koi8_r')  (4)
'MontrИal'
>>> octets.decode('utf_8')  (5)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
UnicodeDecodeError: 'utf-8' codec can't decode byte 0xe9 in position 5:
invalid continuation byte
>>> octets.decode('utf_8', errors='replace')  (6)
'Montr�al'
  1. A palavra "Montréal" codificada em latin1; '\xe9' é o byte para "é".

  2. Decodificar com Windows 1252 funciona, pois esse codec é um superconjunto de latin1.

  3. ISO-8859-7 foi projetado para a língua grega, então o byte '\xe9' é interpretado de forma incorreta, e nenhum erro é gerado.

  4. KOI8-R é foi projetado para o russo. Agora '\xe9' significa a letra "И" do alfabeto cirílico.

  5. O codec 'utf_8' detecta que octets não é UTF-8 válido, e gera um UnicodeDecodeError.

  6. Usando 'replace' para tratamento de erro, o \xe9 é substituído por "�" (ponto de código #U+FFFD), o caractere oficial do Unicode chamado REPLACEMENT CHARACTER, criado exatamente para representar caracteres desconhecidos.

4.5.3. O SyntaxError ao carregar módulos com codificação inesperada

UTF-8 é a codificação default para fontes no Python 3, da mesma forma que ASCII era o default no Python 2. Se você carregar um módulo .py contendo dados que não estejam em UTF-8, sem declaração codificação, receberá uma mensagem como essa:

SyntaxError: Non-UTF-8 code starting with '\xe1' in file ola.py on line
  1, but no encoding declared; see https://python.org/dev/peps/pep-0263/
  for details

Como o UTF-8 está amplamente instalado em sistemas GNU/Linux e macOS, um cenário onde isso tem mais chance de ocorrer é na abertura de um arquivo .py criado no Windows, com cp1252. Observe que esse erro ocorre mesmo no Python para Windows, pois a codificação default para fontes de Python 3 é UTF-8 em todas as plataformas.

Para resolver esse problema, acrescente o comentário mágico coding no início do arquivo, como no Exemplo 7.

Exemplo 7. 'ola.py': um "Hello, World!" em português
# coding: cp1252

print('Olá, Mundo!')
👉 Dica

Agora que o código fonte do Python 3 não está mais limitado ao ASCII, e por default usa a excelente codificação UTF-8, a melhor "solução" para código fonte em codificações antigas como 'cp1252' é converter tudo para UTF-8 de uma vez, e não se preocupar com os comentários coding. E se seu editor não suporta UTF-8, é hora de trocar de editor.

Suponha que você tem um arquivo de texto, seja ele código-fonte ou poesia, mas não sabe qual codificação foi usada. Como detectar a codificação correta? Respostas na próxima seção.

4.5.4. Como descobrir a codificação de uma sequência de bytes

Como descobrir a codificação de uma sequência de bytes? Resposta curta: não é possível. Você precisa ser informado.

Alguns protocolos de comunicação e formatos de arquivo, como o HTTP e o XML, contêm cabeçalhos que nos dizem explicitamente como o conteúdo está codificado. Você pode ter certeza que algumas sequências de bytes não estão em ASCII, pois elas contêm bytes com valores acima de 127, e o modo como o UTF-8 e o UTF-16 são construídos também limita as sequências de bytes possíveis.

O hack do Leo para adivinhar uma decodificação UTF-8

(Os próximos parágrafos vieram de uma nota escrita pelo revisor técnico Leonardo Rochael no rascunho desse livro.)

Pela forma como o UTF-8 foi projetado, é quase impossível que uma sequência aleatória de bytes, ou mesmo uma sequência não-aleatória de bytes de uma codificação diferente do UTF-8, seja acidentalmente decodificada como lixo no UTF-8, ao invés de gerar um UnicodeDecodeError.

As razões para isso são que as sequências de escape do UTF-8 nunca usam caracteres ASCII, e tais sequências de escape tem padrões de bits que tornam muito difícil que dados aleatórioas sejam UTF-8 válido por acidente.

Portanto, se você consegue decodificar alguns bytes contendo códigos > 127 como UTF-8, a maior probabilidade é de sequência estar em UTF-8.

Trabalhando com os serviços online brasileiros, alguns dos quais alicerçados em back-ends antigos, ocasionalmente precisei implementar uma estratégia de decodificação que tentava decodificar via UTF-8, e tratava um UnicodeDecodeError decodificando via cp1252. Uma estratégia feia, mas efetiva.

Entretanto, considerando que as linguagens humanas também tem suas regras e restrições, uma vez que você supõe que uma série de bytes é um texto humano simples, pode ser possível intuir sua codificação usando heurística e estatística. Por exemplo, se bytes com valor b'\x00' bytes forem comuns, é provável que seja uma codificação de 16 ou 32 bits, e não um esquema de 8 bits, pois caracteres nulos em texto simples são erros. Quando a sequência de bytes `b'\x20\x00'` aparece com frequência, é mais provável que esse seja o caractere de espaço (U+0020) na codificação UTF-16LE, e não o obscuro caractere U+2000 (EN QUAD)—seja lá o que for isso.

É assim que o pacote "Chardet—​The Universal Character Encoding Detector (Chardet—O Detector Universal de Codificações de Caracteres)" trabalha para descobrir cada uma das mais de 30 codificações suportadas. Chardet é uma biblioteca Python que pode ser usada em seus programas, mas que também inclui um utilitário de comando de linha, chardetect. Aqui está a forma como ele analisa o código fonte desse capítulo:

$ chardetect 04-text-byte.asciidoc
04-text-byte.asciidoc: utf-8 with confidence 0.99

Apesar de sequências binárias de texto codificado normalmente não trazerem dicas sobre sua codificação, os formatos UTF podem preceder o conteúdo textual por um marcador de ordem dos bytes. Isso é explicado a seguir.

4.5.5. BOM: um gremlin útil

No Exemplo 4, você pode ter notado um par de bytes extra no início de uma sequência codificada em UTF-16. Aqui estão eles novamente:

>>> u16 = 'El Niño'.encode('utf_16')
>>> u16
b'\xff\xfeE\x00l\x00 \x00N\x00i\x00\xf1\x00o\x00'

Os bytes são b'\xff\xfe'. Isso é um BOM—sigla para byte-order mark (marcador de ordem de bytes)—indicando a ordenação de bytes "little-endian" da CPU Intel onde a codificação foi realizada.

Em uma máquina little-endian, para cada ponto de código, o byte menos significativo aparece primeiro: a letra 'E', ponto de código U+0045 (decimal 69), é codificado nas posições 2 e 3 dos bytes como 69 e 0:

>>> list(u16)
[255, 254, 69, 0, 108, 0, 32, 0, 78, 0, 105, 0, 241, 0, 111, 0]

Em uma CPU big-endian, a codificação seria invertida; 'E' seria codificado como 0 e 69.

Para evitar confusão, a codificação UTF-16 precede o texto a ser codificado com o caractere especial invisível ZERO WIDTH NO-BREAK SPACE (U+FEFF). Em um sistema little-endian, isso é codificado como b'\xff\xfe' (decimais 255, 254). Como, por design, não existe um caractere U+FFFE em Unicode, a sequência de bytes b'\xff\xfe' tem que ser o ZERO WIDTH NO-BREAK SPACE em uma codificação little-endian, e então o codec sabe qual ordenação de bytes usar.

Há uma variante do UTF-16—​o UTF-16LE—​que é explicitamente little-endian, e outra que é explicitamente big-endian, o UTF-16BE. Se você usá-los, um BOM não será gerado:

>>> u16le = 'El Niño'.encode('utf_16le')
>>> list(u16le)
[69, 0, 108, 0, 32, 0, 78, 0, 105, 0, 241, 0, 111, 0]
>>> u16be = 'El Niño'.encode('utf_16be')
>>> list(u16be)
[0, 69, 0, 108, 0, 32, 0, 78, 0, 105, 0, 241, 0, 111]

Se o BOM estiver presente, supõe-se que ele será filtrado pelo codec UTF-16, então recebemos apenas o conteúdo textual efetivo do arquivo, sem o ZERO WIDTH NO-BREAK SPACE inicial.

O padrão Unicode diz que se um arquivo é UTF-16 e não tem um BOM, deve-se presumir que ele é UTF-16BE (big-endian). Entretanto, a arquitetura x86 da Intel é little-endian, daí que há uma grande quantidade de UTF-16 little-endian e sem BOM no mundo.

Toda essa questão de ordenação dos bytes (endianness) só afeta codificações que usam palavras com mais de um byte, como UTF-16 e UTF-32. Uma grande vantagem do UTF-8 é produzir a mesma sequência independente da ordenação dos bytes, então um BOM não é necessário. No entanto, algumas aplicações Windows (em especial o Notepad) mesmo assim acrescentam o BOM a arquivos UTF-8—e o Excel depende do BOM para detectar um arquivo UTF-8, caso contrário ele presume que o conteúdo está codificado com uma página de código do Windows. Essa codificação UTF-8 com BOM é chamada UTF-8-SIG no registro de codecs do Python. O caractere U+FEFF codificado em UTF-8-SIG é a sequência de três bytes b'\xef\xbb\xbf'. Então, se um arquivo começa com aqueles três bytes, é provavelmente um arquivo UTF-8 com um BOM.

👉 Dica
A dica de Caleb sobre o UTF-8-SIG

Caleb Hattingh—um dos revisores técnicos—sugere sempre usar o codec UTF-8-SIG para ler arquivos UTF-8. Isso é inofensivo, pois o UTF-8-SIG lê corretamente arquivos com ou sem um BOM, e não devolve o BOM propriamente dito. Para escrever arquivos, recomendo usar UTF-8, para interoperabilidade integral. Por exemplo, scripts Python podem ser tornados executáveis em sistemas Unix, se começarem com o comentário: #!/usr/bin/env python3. Os dois primeiros bytes do arquivo precisam ser b'#!' para isso funcionar, mas o BOM rompe essa convenção. Se você tem o requerimento específico de exportar dados para aplicativos que precisam do BOM, use o UTF-8-SIG, mas esteja ciente do que diz a documentação sobre codecs (EN) do Python: "No UTF-8, o uso do BOM é desencorajado e, em geral, deve ser evitado."

Vamos agora ver como tratar arquivos de texto no Python 3.

4.6. Processando arquivos de texto

A melhor prática para lidar com E/S de texto é o "Sanduíche de Unicode" (Unicode sandwich) (Figura 2).[47] Isso significa que os bytes devem ser decodificados para str o mais cedo possível na entrada (por exemplo, ao abrir um arquivo para leitura). O "recheio" do sanduíche é a lógica do negócio de seu programa, onde o tratamento do texto é realizado exclusivamente sobre objetos str. Você nunca deveria codificar ou decodificar no meio de outro processamento. Na saída, as str são codificadas para bytes o mais tarde possível. A maioria dos frameworks web funciona assim, e raramente tocamos em bytes ao usá-los. No Django, por exemplo, suas views devem produzir str em Unicode; o próprio Django se encarrega de codificar a resposta para bytes, usando UTF-8 como default.

O Python 3 torna mais fácil seguir o conselho do sanduíche de Unicode, pois o embutido open() executa a decodificação necessária na leitura e a codificação ao escrever arquivos em modo texto. Dessa forma, tudo que você recebe de my_file.read() e passa para my_file.write(text) são objetos str.

Assim, usar arquivos de texto é aparentemente simples. Mas se você confiar nas codificações default, pode acabar levando uma mordida.

Diagrama do sanduíche de Unicode
Figura 2. O sanduíche de Unicode: melhores práticas atuais para processamento de texto.

Observe a sessão de console no Exemplo 8. Você consegue ver o erro?

Exemplo 8. Uma questão de plataforma na codificação (você pode ou não ver o problema se tentar isso na sua máquina)
>>> open('cafe.txt', 'w', encoding='utf_8').write('café')
4
>>> open('cafe.txt').read()
'café'

O erro: especifiquei a codificação UTF-8 ao escrever o arquivo, mas não fiz isso na leitura, então o Python assumiu a codificação de arquivo default do Windows—página de código 1252—e os bytes finais foram decodificados como os caracteres 'é' ao invés de 'é'.

Executei o Exemplo 8 no Python 3.8.1, 64 bits, no Windows 10 (build 18363). Os mesmos comandos rodando em um GNU/Linux ou um macOS recentes funcionam perfeitamente, pois a codificação default desses sistemas é UTF-8, dando a falsa impressão que tudo está bem. Se o argumento de codificação fosse omitido ao abrir o arquivo para escrita, a codificação default do locale seria usada, e poderíamos ler o arquivo corretamente usando a mesma codificação. Mas aí o script geraria arquivos com conteúdo binário diferente dependendo da plataforma, ou mesmo das configurações do locale na mesma plataforma, criando problemas de compatibilidade.

👉 Dica

Código que precisa rodar em múltiplas máquinas ou múltiplas ocasiões não deveria jamais depender de defaults de codificação. Sempre passe um argumento encoding= explícito ao abrir arquivos de texto, pois o default pode mudar de uma máquina para outra ou de um dia para o outro.

Um detalhe curioso no Exemplo 8 é que a função write na primeira instrução informa que foram escritos quatro caracteres, mas na linha seguinte são lidos cinco caracteres. O Exemplo 9 é uma versão estendida do Exemplo 8, e explica esse e outros detalhes.

Exemplo 9. Uma inspeção mais atenta do Exemplo 8 rodando no Windows revela o bug e a solução do problema
>>> fp = open('cafe.txt', 'w', encoding='utf_8')
>>> fp  # (1)
<_io.TextIOWrapper name='cafe.txt' mode='w' encoding='utf_8'>
>>> fp.write('café')  # (2)
4
>>> fp.close()
>>> import os
>>> os.stat('cafe.txt').st_size  # (3)
5
>>> fp2 = open('cafe.txt')
>>> fp2  # (4)
<_io.TextIOWrapper name='cafe.txt' mode='r' encoding='cp1252'>
>>> fp2.encoding  # (5)
'cp1252'
>>> fp2.read() # (6)
'café'
>>> fp3 = open('cafe.txt', encoding='utf_8')  # (7)
>>> fp3
<_io.TextIOWrapper name='cafe.txt' mode='r' encoding='utf_8'>
>>> fp3.read() # (8)
'café'
>>> fp4 = open('cafe.txt', 'rb')  # (9)
>>> fp4                           # (10)
<_io.BufferedReader name='cafe.txt'>
>>> fp4.read()  # (11)
b'caf\xc3\xa9'
  1. Por default, open usa o modo texto e devolve um objeto TextIOWrapper com uma codificação específica.

  2. O método write de um TextIOWrapper devolve o número de caracteres Unicode escritos.

  3. os.stat diz que o arquivo tem 5 bytes; o UTF-8 codifica 'é' com 2 bytes, 0xc3 e 0xa9.

  4. Abrir um arquivo de texto sem uma codificação explícita devolve um TextIOWrapper com a codificação configurada para um default do locale.

  5. Um objeto TextIOWrapper tem um atributo de codificação que pode ser inspecionado: neste caso, cp1252.

  6. Na codificação cp1252 do Windows, o byte 0xc3 é um "Ã" (A maiúsculo com til), e 0xa9 é o símbolo de copyright.

  7. Abrindo o mesmo arquivo com a codificação correta.

  8. O resultado esperado: os mesmo quatro caracteres Unicode para 'café'.

  9. A flag 'rb' abre um arquivo para leitura em modo binário.

  10. O objeto devolvido é um BufferedReader, e não um TextIOWrapper.

  11. Ler do arquivo obtém bytes, como esperado.

👉 Dica

Não abra arquivos de texto no modo binário, a menos que seja necessário analisar o conteúdo do arquivo para determinar sua codificação—e mesmo assim, você deveria estar usando o Chardet em vez de reinventar a roda (veja a Seção 4.5.4).

Programas comuns só deveriam usar o modo binário para abrir arquivos binários, como arquivos de imagens raster ou bitmaps.

O problema no Exemplo 9 vem de se confiar numa configuração default ao se abrir um arquivo de texto. Há várias fontes de tais defaults, como mostra a próxima seção.

4.6.1. Cuidado com os defaults de codificação

Várias configurações afetam os defaults de codificação para E/S no Python. Veja o script default_encodings.py script no Exemplo 10.

Exemplo 10. Explorando os defaults de codificação
import locale
import sys

expressions = """
        locale.getpreferredencoding()
        type(my_file)
        my_file.encoding
        sys.stdout.isatty()
        sys.stdout.encoding
        sys.stdin.isatty()
        sys.stdin.encoding
        sys.stderr.isatty()
        sys.stderr.encoding
        sys.getdefaultencoding()
        sys.getfilesystemencoding()
    """

my_file = open('dummy', 'w')

for expression in expressions.split():
    value = eval(expression)
    print(f'{expression:>30} -> {value!r}')

A saída do Exemplo 10 no GNU/Linux (Ubuntu 14.04 a 19.10) e no macOS (10.9 a 10.14) é idêntica, mostrando que UTF-8 é usado em toda parte nesses sistemas:

$ python3 default_encodings.py
 locale.getpreferredencoding() -> 'UTF-8'
                 type(my_file) -> <class '_io.TextIOWrapper'>
              my_file.encoding -> 'UTF-8'
           sys.stdout.isatty() -> True
           sys.stdout.encoding -> 'utf-8'
            sys.stdin.isatty() -> True
            sys.stdin.encoding -> 'utf-8'
           sys.stderr.isatty() -> True
           sys.stderr.encoding -> 'utf-8'
      sys.getdefaultencoding() -> 'utf-8'
   sys.getfilesystemencoding() -> 'utf-8'

No Windows, porém, a saída é o Exemplo 11.

Exemplo 11. Codificações default, no PowerShell do Windows 10 (a saída é a mesma no cmd.exe)
> chcp  (1)
Active code page: 437
> python default_encodings.py  (2)
 locale.getpreferredencoding() -> 'cp1252'  (3)
                 type(my_file) -> <class '_io.TextIOWrapper'>
              my_file.encoding -> 'cp1252'  (4)
           sys.stdout.isatty() -> True      (5)
           sys.stdout.encoding -> 'utf-8'   (6)
            sys.stdin.isatty() -> True
            sys.stdin.encoding -> 'utf-8'
           sys.stderr.isatty() -> True
           sys.stderr.encoding -> 'utf-8'
      sys.getdefaultencoding() -> 'utf-8'
   sys.getfilesystemencoding() -> 'utf-8'
  1. chcp mostra a página de código ativa para o console: 437.

  2. Executando default_encodings.py, com a saída direcionada para o console.

  3. locale.getpreferredencoding() é a configuração mais importante.

  4. Arquivos de texto usam`locale.getpreferredencoding()` por default.

  5. A saída está direcionada para o console, então sys.stdout.isatty() é True.

  6. Agora, sys.stdout.encoding não é a mesma que a página de código informada por chcp!

O suporte a Unicode no próprio Windows e no Python para Windows melhorou desde que escrevi a primeira edição deste livro. O Exemplo 11 costumava informar quatro codificações diferentes no Python 3.4 rodando no Windows 7. As codificações para stdout, stdin, e stderr costumavam ser iguais à da página de código ativa informada pelo comando chcp, mas agora são todas utf-8, graças à PEP 528—​Change Windows console encoding to UTF-8 (Mudar a codificação do console no Windows para UTF-8) (EN), implementada no Python 3.6, e ao suporte a Unicode no PowerShell do cmd.exe (desde o Windows 1809, de outubro de 2018).[48] É esquisito que o chcp e o sys.stdout.encoding reportem coisas diferentes quando o stdout está escrevendo no console, mas é ótimo podermos agora escrever strings Unicode sem erros de codificação no Windows—a menos que o usuário redirecione a saída para um arquivo, como veremos adiante. Isso não significa que todos os seus emojis favoritos vão aparecer: isso também depende da fonte usada pelo console.

Outra mudança foi a PEP 529—​Change Windows filesystem encoding to UTF-8 (Mudar a codificação do sistema de arquivos do Windows para UTF-8), também implementada no Python 3.6, que modificou a codificação do sistema de arquivos (usada para representar nomes de diretórios e de arquivos), da codificação proprietária MBCS da Microsoft para UTF-8.

Entretanto, se a saída do Exemplo 10 for redirecionada para um arquivo, assim…​

Z:\>python default_encodings.py > encodings.log

…​aí o valor de sys.stdout.isatty() se torna False, e sys.stdout.encoding é determinado por locale.getpreferredencoding(), 'cp1252' naquela máquina—mas sys.stdin.encoding e sys.stderr.encoding seguem como utf-8.

👉 Dica

No Exemplo 12, usei a expressão de escape '\N{}' para literais Unicode, escrevendo o nome oficial do caractere dentro do \N{}. Isso é bastante prolixo, mas explícito e seguro: o Python gera um SyntaxError se o nome não existir—bem melhor que escrever um número hexadecimal que pode estar errado, mas isso só será descoberto muito mais tarde. De qualquer forma, você provavelmente vai querer escrever um comentário explicando os códigos dos caracteres, então a verbosidade do \N{} é fácil de aceitar.

Isso significa que um script como o Exemplo 12 funciona quando está escrevendo no console, mas pode falhar quando a saída é redirecionada para um arquivo.

Exemplo 12. stdout_check.py
import sys
from unicodedata import name

print(sys.version)
print()
print('sys.stdout.isatty():', sys.stdout.isatty())
print('sys.stdout.encoding:', sys.stdout.encoding)
print()

test_chars = [
    '\N{HORIZONTAL ELLIPSIS}',       # exists in cp1252, not in cp437
    '\N{INFINITY}',                  # exists in cp437, not in cp1252
    '\N{CIRCLED NUMBER FORTY TWO}',  # not in cp437 or in cp1252
]

for char in test_chars:
    print(f'Trying to output {name(char)}:')
    print(char)

O Exemplo 12 mostra o resultado de uma chamada a sys.stdout.isatty(), o valor de sys.​stdout.encoding, e esses três caracteres:

  • '…' HORIZONTAL ELLIPSIS—existe no CP 1252 mas não no CP 437.

  • '∞' INFINITY—existe no CP 437 mas não no CP 1252.

  • '㊷' CIRCLED NUMBER FORTY TWO—não existe nem no CP 1252 nem no CP 437.

Quando executo o stdout_check.py no PowerShell ou no cmd.exe, funciona como visto na Figura 3.

Captura de tela do `stdout_check.py` no PowerShell
Figura 3. Executando stdout_check.py no PowerShell.

Apesar de chcp informar o código ativo como 437, sys.stdout.encoding é UTF-8, então tanto HORIZONTAL ELLIPSIS quanto INFINITY são escritos corretamente. O CIRCLED NUMBER FORTY TWO é substituído por um retângulo, mas nenhum erro é gerado. Presume-se que ele seja reconhecido como um caractere válido, mas a fonte do console não tem o glifo para mostrá-lo.

Entretanto, quando redireciono a saída de stdout_check.py para um arquivo, o resultado é o da Figura 4.

Captura de teal do `stdout_check.py` no PowerShell, redirecionando a saída
Figura 4. Executanto stdout_check.py no PowerShell, redirecionando a saída.

O primeiro problema demonstrado pela Figura 4 é o UnicodeEncodeError mencionando o caractere '\u221e', porque sys.stdout.encoding é 'cp1252'—uma página de código que não tem o caractere INFINITY.

Lendo out.txt com o comando type—ou um editor de Windows como o VS Code ou o Sublime Text—mostra que, ao invés do HORIZONTAL ELLIPSIS, consegui um 'à' (LATIN SMALL LETTER A WITH GRAVE). Acontece que o valor binário 0x85 no CP 1252 significa '…', mas no CP 437 o mesmo valor binário representa o 'à'. Então, pelo visto, a página de código ativa tem alguma importância, não de uma forma razoável ou útil, mas como uma explicação parcial para uma experiência ruim com o Unicode.

✒️ Nota

Para realizar esses experimentos, usei um laptop configurado para o mercado norte-americano, rodando Windows 10 OEM. Versões de Windows localizadas para outros países podem ter configurações de codificação diferentes. No Brasil, por exemplo, o console do Windows usa a página de código 850 por default—​e não a 437.

Para encerrar esse enlouquecedor tópico de codificações default, vamos dar uma última olhada nas diferentes codificações no Exemplo 11:

  • Se você omitir o argumento encoding ao abrir um arquivo, o default é dado por locale.getpreferredencoding() ('cp1252' no Exemplo 11).

  • Antes do Python 3.6, a codificação de sys.stdout|stdin|stderr costumava ser determinada pela variável do ambiente PYTHONIOENCODING—agora essa variável é ignorada, a menos que PYTHONLEGACYWINDOWSSTDIO seja definida como uma string não-vazia. Caso contrário, a codificação da E/S padrão será UTF-8 para E/S interativa, ou definida por locale.getpreferredencoding(), se a entrada e a saída forem redirecionadas para ou de um arquivo.

  • sys.getdefaultencoding() é usado internamente pelo Python em conversões implícitas de dados binários de ou para str. Não há suporte para mudar essa configuração.

  • sys.getfilesystemencoding() é usado para codificar/decodificar nomes de arquivo (mas não o conteúdo dos arquivos). Ele é usado quando open() recebe um argumento str para um nome de arquivo; se o nome do arquivo é passado como um argumento bytes, ele é entregue sem modificação para a API do sistema operacional.

✒️ Nota

Já faz muito anos que, no GNU/Linux e no macOS, todas essas codificações são definidas como UTF-8 por default, então a E/S entende e exibe todos os caracteres Unicode. No Windows, não apenas codificações diferentes são usadas no mesmo sistema, elas também são, normalmente, páginas de código como 'cp850' ou 'cp1252', que suportam só o ASCII com 127 caracteres adicionais (que por sua vez são diferentes de uma codificação para a outra). Assim, usuários de Windows tem muito mais chances de cometer erros de codificação, a menos que sejam muito cuidadosos.

Resumindo, a configuração de codificação mais importante devolvida por locale.getpreferredencoding() é a default para abrir arquivos de texto e para sys.stdout/stdin/stderr, quando eles são redirecionados para arquivos. Entretanto, a documentação diz (em parte):

locale.getpreferredencoding(do_setlocale=True)

Retorna a codificação da localidade usada para dados de texto, de acordo com as preferências do usuário. As preferências do usuário são expressas de maneira diferente em sistemas diferentes e podem não estar disponíveis programaticamente em alguns sistemas, portanto, essa função retorna apenas uma estimativa. […​]

Assim, o melhor conselho sobre defaults de codificação é: não confie neles.

Você evitará muitas dores de cabeça se seguir o conselho do sanduíche de Unicode, e sempre tratar codificações de forma explícita em seus programas. Infelizmente, o Unicode é trabalhoso mesmo se você converter seus bytes para str corretamente. As duas próximas seções tratam de assuntos que são simples no reino do ASCII, mas ficam muito complexos no planeta Unicode: normalização de texto (isto é, transformar o texto em uma representação uniforme para comparações) e ordenação.

4.7. Normalizando o Unicode para comparações confiáveis

Comparações de strings são dificultadas pelo fato do Unicode ter combinações de caracteres: sinais diacríticos e outras marcações que são anexadas aos caractere anterior, ambos aparecendo juntos como um só caractere quando impressos.

Por exemplo, a palavra "café" pode ser composta de duas formas, usando quatro ou cinco pontos de código, mas o resultado parece exatamente o mesmo:

>>> s1 = 'café'
>>> s2 = 'cafe\N{COMBINING ACUTE ACCENT}'
>>> s1, s2
('café', 'café')
>>> len(s1), len(s2)
(4, 5)
>>> s1 == s2
False

Colocar COMBINING ACUTE ACCENT (U+0301) após o "e" resulta em "é". No padrão Unicode, sequências como 'é' e 'e\u0301' são chamadas de "equivalentes canônicas", e se espera que as aplicações as tratem como iguais. Mas o Python vê duas sequências de pontos de código diferentes, e não as considera iguais.

A solução é a unicodedata.normalize(). O primeiro argumento para essa função é uma dessas quatro strings: 'NFC', 'NFD', 'NFKC', e 'NFKD'. Vamos começar pelas duas primeiras.

A Forma Normal C (NFC) combina os ponto de código para produzir a string equivalente mais curta, enquanto a NFD decompõe, expandindo os caracteres compostos em caracteres base e separando caracteres combinados. Ambas as normalizações fazem as comparações funcionarem da forma esperada, como mostra o próximo exemplo:

>>> from unicodedata import normalize
>>> s1 = 'café'
>>> s2 = 'cafe\N{COMBINING ACUTE ACCENT}'
>>> len(s1), len(s2)
(4, 5)
>>> len(normalize('NFC', s1)), len(normalize('NFC', s2))
(4, 4)
>>> len(normalize('NFD', s1)), len(normalize('NFD', s2))
(5, 5)
>>> normalize('NFC', s1) == normalize('NFC', s2)
True
>>> normalize('NFD', s1) == normalize('NFD', s2)
True

Drivers de teclado normalmente geram caracteres compostos, então o texto digitado pelos usuários estará na NFC por default. Entretanto, por segurança, pode ser melhor normalizar as strings com normalize('NFC', user_text) antes de salvá-las. A NFC também é a forma de normalização recomendada pelo W3C em "Character Model for the World Wide Web: String Matching and Searching" (Um Modelo de Caracteres para a World Wide Web: Correspondência de Strings e Busca) (EN).

Alguns caracteres singulares são normalizados pela NFC em um outro caractere singular. O símbolo para o ohm (Ω), a unidade de medida de resistência elétrica, é normalizado para a letra grega ômega maiúscula. Eles são visualmente idênticos, mas diferentes quando comparados, então a normalizaçào é essencial para evitar surpresas:

>>> from unicodedata import normalize, name
>>> ohm = '\u2126'
>>> name(ohm)
'OHM SIGN'
>>> ohm_c = normalize('NFC', ohm)
>>> name(ohm_c)
'GREEK CAPITAL LETTER OMEGA'
>>> ohm == ohm_c
False
>>> normalize('NFC', ohm) == normalize('NFC', ohm_c)
True

As outras duas formas de normalização são a NFKC e a NFKD, a letra K significando "compatibilidade". Essas são formas mais fortes de normalizaçào, afetando os assim chamados "caracteres de compatibilidade". Apesar de um dos objetivos do Unicode ser a existência de um único ponto de código "canônico" para cada caractere, alguns caracteres aparecem mais de uma vez, para manter compatibilidade com padrões pré-existentes. Por exemplo, o MICRO SIGN, µ (U+00B5), foi adicionado para permitir a conversão bi-direcional com o latin1, que o inclui, apesar do mesmo caractere ser parte do alfabeto grego com o ponto de código U+03BC (GREEK SMALL LETTER MU). Assim, o símbolo de micro é considerado um "caractere de compatibilidade".

Nas formas NFKC e NFKD, cada caractere de compatibilidade é substituído por uma "decomposição de compatibilidade" de um ou mais caracteres, que é considerada a representação "preferencial", mesmo se ocorrer alguma perda de formatação—idealmente, a formatação deveria ser responsabilidade de alguma marcação externa, não parte do Unicode. Para exemplificar, a decomposição de compatibilidade da fração um meio, '½' (U+00BD), é a sequência de três caracteres '1/2', e a decomposição de compatibilidade do símbolo de micro, 'µ' (U+00B5), é o mu minúsculo, 'μ' (U+03BC).[49]

É assim que a NFKC funciona na prática:

>>> from unicodedata import normalize, name
>>> half = '\N{VULGAR FRACTION ONE HALF}'
>>> print(half)
½
>>> normalize('NFKC', half)
'1⁄2'
>>> for char in normalize('NFKC', half):
...     print(char, name(char), sep='\t')
...
1	DIGIT ONE
⁄	FRACTION SLASH
2	DIGIT TWO
>>> four_squared = '4²'
>>> normalize('NFKC', four_squared)
'42'
>>> micro = 'µ'
>>> micro_kc = normalize('NFKC', micro)
>>> micro, micro_kc
('µ', 'μ')
>>> ord(micro), ord(micro_kc)
(181, 956)
>>> name(micro), name(micro_kc)
('MICRO SIGN', 'GREEK SMALL LETTER MU')

Ainda que '1⁄2' seja um substituto razoável para '½', e o símbolo de micro ser realmente a letra grega mu minúscula, converter '4²' para '42' muda o sentido. Uma aplicação poderia armazenar '4²' como '4<sup>2</sup>', mas a função normalize não sabe nada sobre formatação. Assim, NFKC ou NFKD podem perder ou distorcer informações, mas podem produzir representações intermediárias convenientes para buscas ou indexação.

Infelizmente, com o Unicode tudo é sempre mais complicado do que parece à primeira vista. Para o VULGAR FRACTION ONE HALF, a normalização NFKC produz 1 e 2 unidos pelo FRACTION SLASH, em vez do SOLIDUS, também conhecido como "barra" ("slash" em inglês)—o familiar caractere com código decimal 47 em ASCII. Portanto, buscar pela sequência ASCII de três caracteres '1/2' não encontraria a sequência Unicode normalizada.

⚠️ Aviso

As normalizações NFKC e NFKD causam perda de dados e devem ser aplicadas apenas em casos especiais, como busca e indexação, e não para armazenamento permanente do texto.

Ao preparar texto para busca ou indexação, há outra operação útil: case folding [50], nosso próximo assunto.

4.7.1. Case Folding

Case folding é essencialmente a conversão de todo o texto para minúsculas, com algumas transformações adicionais. A operação é suportada pelo método str.casefold().

Para qualquer string s contendo apenas caracteres latin1, s.casefold() produz o mesmo resultado de s.lower(), com apenas duas exceções—o símbolo de micro, 'µ', é trocado pela letra grega mu minúscula (que é exatamente igual na maioria das fontes) e a letra alemã Eszett (ß), também chamada "s agudo" (scharfes S) se torna "ss":

>>> micro = 'µ'
>>> name(micro)
'MICRO SIGN'
>>> micro_cf = micro.casefold()
>>> name(micro_cf)
'GREEK SMALL LETTER MU'
>>> micro, micro_cf
('µ', 'μ')
>>> eszett = 'ß'
>>> name(eszett)
'LATIN SMALL LETTER SHARP S'
>>> eszett_cf = eszett.casefold()
>>> eszett, eszett_cf
('ß', 'ss')

Há quase 300 pontos de código para os quais str.casefold() e str.lower() devolvem resultados diferentes.

Como acontece com qualquer coisa relacionada ao Unicode, case folding é um tópico complexo, com muitos casos linguísticos especiais, mas o grupo central de desenvolvedores do Python fez um grande esforço para apresentar uma solução que, espera-se, funcione para a maioria dos usuários.

Nas próximas seções vamos colocar nosso conhecimento sobre normalização para trabalhar, desenvolvendo algumas funções utilitárias.

4.7.2. Funções utilitárias para correspondência de texto normalizado

Como vimos, é seguro usar a NFC e a NFD, e ambas permitem comparações razoáveis entre strings Unicode. A NFC é a melhor forma normalizada para a maioria das aplicações, e str.casefold() é a opção certa para comparações indiferentes a maiúsculas/minúsculas.

Se você precisa lidar com texto em muitas línguas diferentes, seria muito útil acrescentar às suas ferramentas de trabalho um par de funções como nfc_equal e fold_equal, do Exemplo 13.

Exemplo 13. normeq.py: normalized Unicode string comparison
"""
Utility functions for normalized Unicode string comparison.

Using Normal Form C, case sensitive:

    >>> s1 = 'café'
    >>> s2 = 'cafe\u0301'
    >>> s1 == s2
    False
    >>> nfc_equal(s1, s2)
    True
    >>> nfc_equal('A', 'a')
    False

Using Normal Form C with case folding:

    >>> s3 = 'Straße'
    >>> s4 = 'strasse'
    >>> s3 == s4
    False
    >>> nfc_equal(s3, s4)
    False
    >>> fold_equal(s3, s4)
    True
    >>> fold_equal(s1, s2)
    True
    >>> fold_equal('A', 'a')
    True

"""

from unicodedata import normalize

def nfc_equal(str1, str2):
    return normalize('NFC', str1) == normalize('NFC', str2)

def fold_equal(str1, str2):
    return (normalize('NFC', str1).casefold() ==
            normalize('NFC', str2).casefold())

Além da normalização e do case folding do Unicode—ambos partes desse padrão—algumas vezes faz sentido aplicar transformações mais profundas, como por exemplo mudar 'café' para 'cafe'. Vamos ver quando e como na próxima seção.

4.7.3. "Normalização" extrema: removendo sinais diacríticos

O tempero secreto da busca do Google inclui muitos truques, mas um deles aparentemente é ignorar sinais diacríticos (acentos e cedilhas, por exemplo), pelo menos em alguns contextos. Remover sinais diacríticos não é uma forma regular de normalização, pois muitas vezes muda o sentido das palavras e pode produzir falsos positivos em uma busca. Mas ajuda a lidar com alguns fatos da vida: as pessoas às vezes são preguiçosas ou desconhecem o uso correto dos sinais diacríticos, e regras de ortografia mudam com o tempo, levando acentos a desaparecerem e reaparecerem nas línguas vivas.

Além do caso da busca, eliminar os acentos torna as URLs mais legíveis, pelo menos nas línguas latinas. Veja a URL do artigo da Wikipedia sobre a cidade de São Paulo:

https://en.wikipedia.org/wiki/S%C3%A3o_Paulo

O trecho %C3%A3 é a renderização em UTF-8 de uma única letra, o "ã" ("a" com til). A forma a seguir é muito mais fácil de reconhecer, mesmo com a ortografia incorreta:

https://en.wikipedia.org/wiki/Sao_Paulo

Para remover todos os sinais diacríticos de uma str, você pode usar uma função como a do Exemplo 14.

Exemplo 14. simplify.py: função para remover todas as marcações combinadas
import unicodedata
import string


def shave_marks(txt):
    """Remove all diacritic marks"""
    norm_txt = unicodedata.normalize('NFD', txt)  # (1)
    shaved = ''.join(c for c in norm_txt
                     if not unicodedata.combining(c))  # (2)
    return unicodedata.normalize('NFC', shaved)  # (3)
  1. Decompõe todos os caracteres em caracteres base e marcações combinadas.

  2. Filtra e retira todas as marcações combinadas.

  3. Recompõe todos os caracteres.

Exemplo 15 mostra alguns usos para shave_marks.

Exemplo 15. Dois exemplos de uso da shave_marks do Exemplo 14
>>> order = '“Herr Voß: • ½ cup of Œtker™ caffè latte • bowl of açaí.”'
>>> shave_marks(order)
'“Herr Voß: • ½ cup of Œtker™ caffe latte • bowl of acai.”'  (1)
>>> Greek = 'Ζέφυρος, Zéfiro'
>>> shave_marks(Greek)
'Ζεφυρος, Zefiro'  (2)
  1. Apenas as letras "è", "ç", e "í" foram substituídas.

  2. Tanto "έ" quando "é" foram substituídas.

A função shave_marks do Exemplo 14 funciona bem, mas talvez vá longe demais. Frequentemente, a razão para remover os sinais diacríticos é transformar texto de uma língua latina para ASCII puro, mas shave_marks também troca caracteres não-latinos—​como letras gregas—​que nunca se tornarão ASCII apenas pela remoção de seus acentos. Então faz sentido analisar cada caractere base e remover as marcações anexas apenas se o caractere base for uma letra do alfabeto latino. É isso que o Exemplo 16 faz.

Exemplo 16. Função para remover marcações combinadas de caracteres latinos (comando de importação omitidos, pois isso é parte do módulo simplify.py do Exemplo 14)
def shave_marks_latin(txt):
    """Remove all diacritic marks from Latin base characters"""
    norm_txt = unicodedata.normalize('NFD', txt)  # (1)
    latin_base = False
    preserve = []
    for c in norm_txt:
        if unicodedata.combining(c) and latin_base:   # (2)
            continue  # ignore diacritic on Latin base char
        preserve.append(c)                            # (3)
        # if it isn't a combining char, it's a new base char
        if not unicodedata.combining(c):              # (4)
            latin_base = c in string.ascii_letters
    shaved = ''.join(preserve)
    return unicodedata.normalize('NFC', shaved)   # (5)
  1. Decompõe todos os caracteres em caracteres base e marcações combinadas.

  2. Pula as marcações combinadas quando o caractere base é latino.

  3. Caso contrário, mantém o caractere original.

  4. Detecta um novo caractere base e determina se ele é latino.

  5. Recompõe todos os caracteres.

Um passo ainda mais radical substituiria os símbolos comuns em textos de línguas ocidentais (por exemplo, aspas curvas, travessões, os círculos de bullet points, etc) em seus equivalentes ASCII. É isso que a função asciize faz no Exemplo 17.

Exemplo 17. Transforma alguns símbolos tipográficos ocidentais em ASCII (este trecho também é parte do simplify.py do Exemplo 14)
single_map = str.maketrans("""‚ƒ„ˆ‹‘’“”•–—˜›""",  # (1)
                           """'f"^<''""---~>""")

multi_map = str.maketrans({  # (2)
    '€': 'EUR',
    '…': '...',
    'Æ': 'AE',
    'æ': 'ae',
    'Œ': 'OE',
    'œ': 'oe',
    '™': '(TM)',
    '‰': '<per mille>',
    '†': '**',
    '‡': '***',
})

multi_map.update(single_map)  # (3)


def dewinize(txt):
    """Replace Win1252 symbols with ASCII chars or sequences"""
    return txt.translate(multi_map)  # (4)


def asciize(txt):
    no_marks = shave_marks_latin(dewinize(txt))     # (5)
    no_marks = no_marks.replace('ß', 'ss')          # (6)
    return unicodedata.normalize('NFKC', no_marks)  # (7)
  1. Cria uma tabela de mapeamento para substituição de caractere para caractere.

  2. Cria uma tabela de mapeamento para substituição de string para caractere.

  3. Funde as tabelas de mapeamento.

  4. dewinize não afeta texto em ASCII ou latin1, apenas os acréscimos da Microsoft ao latin1 no cp1252.

  5. Aplica dewinize e remove as marcações de sinais diacríticos.

  6. Substitui o Eszett por "ss" (não estamos usando case folding aqui, pois queremos preservar maiúsculas e minúsculas).

  7. Aplica a normalização NFKC para compor os caracteres com seus pontos de código de compatibilidade.

O Exemplo 18 mostra a asciize em ação.

Exemplo 18. Dois exemplos usando asciize, do Exemplo 17
>>> order = '“Herr Voß: • ½ cup of Œtker™ caffè latte • bowl of açaí.”'
>>> dewinize(order)
'"Herr Voß: - ½ cup of OEtker(TM) caffè latte - bowl of açaí."'  (1)
>>> asciize(order)
'"Herr Voss: - 1⁄2 cup of OEtker(TM) caffe latte - bowl of acai."'  (2)
  1. dewinize substitui as aspas curvas, os bullets, e o ™ (símbolo de marca registrada).

  2. asciize aplica dewinize, remove os sinais diacríticos e substitui o 'ß'.

⚠️ Aviso

Cada língua tem suas próprias regras para remoção de sinais diacríticos. Por exemplo, os alemães trocam o 'ü' por 'ue'. Nossa função asciize não é tão refinada, então pode ou não ser adequada para a sua língua. Contudo, ela é aceitável para o português.

Resumindo, as funções em simplify.py vão bem além da normalização padrão, e realizam uma cirurgia profunda no texto, com boas chances de mudar seu sentido. Só você pode decidir se deve ir tão longe, conhecendo a língua alvo, os seus usuários e a forma como o texto transformado será utilizado.

Isso conclui nossa discussão sobre normalização de texto Unicode.

Vamos agora ordenar nossos pensamentos sobre ordenação no Unicode.

4.8. Ordenando texto Unicode

O Python ordena sequências de qualquer tipo comparando um por um os itens em cada sequência. Para strings, isso significa comparar pontos de código. Infelizmente, isso produz resultados inaceitáveis para qualquer um que use caracteres não-ASCII.

Considere ordenar uma lista de frutas cultivadas no Brazil:

>>> fruits = ['caju', 'atemoia', 'cajá', 'açaí', 'acerola']
>>> sorted(fruits)
['acerola', 'atemoia', 'açaí', 'caju', 'cajá']

As regras de ordenação variam entre diferentes locales, mas em português e em muitas línguas que usam o alfabeto latino, acentos e cedilhas raramente fazem diferença na ordenação.[51] Então "cajá" é lido como "caja," e deve vir antes de "caju."

A lista fruits ordenada deveria ser:

['açaí', 'acerola', 'atemoia', 'cajá', 'caju']

O modo padrão de ordenar texto não-ASCII em Python é usar a função locale.strxfrm que, de acordo com a documentação do módulo locale, "Transforma uma string em uma que pode ser usada em comparações com reconhecimento de localidade."

Para poder usar locale.strxfrm, você deve primeiro definir um locale adequado para sua aplicação, e rezar para que o SO o suporte. A sequência de comando no Exemplo 19 pode funcionar para você.

Exemplo 19. locale_sort.py: Usando a função locale.strxfrm como chave de ornenamento
import locale
my_locale = locale.setlocale(locale.LC_COLLATE, 'pt_BR.UTF-8')
print(my_locale)
fruits = ['caju', 'atemoia', 'cajá', 'açaí', 'acerola']
sorted_fruits = sorted(fruits, key=locale.strxfrm)
print(sorted_fruits)

Executando o Exemplo 19 no GNU/Linux (Ubuntu 19.10) com o locale pt_BR.UTF-8 instalado, consigo o resultado correto:

'pt_BR.UTF-8'
['açaí', 'acerola', 'atemoia', 'cajá', 'caju']

Portanto, você precisa chamar setlocale(LC_COLLATE, «your_locale») antes de usar locale.strxfrm como a chave de ordenação.

Porém, aqui vão algumas ressalvas:

  • Como as configurações de locale são globais, não é recomendado chamar setlocale em uma biblioteca. Sua aplicação ou framework deveria definir o locale no início do processo, e não mudá-lo mais depois disso.

  • O locale desejado deve estar instalado no SO, caso contrário setlocale gera uma exceção de locale.Error: unsupported locale setting.

  • Você tem que saber como escrever corretamente o nome do locale.

  • O locale precisa ser corretamente implementado pelos desenvolvedores do SO. Tive sucesso com o Ubuntu 19.10, mas não no macOS 10.14. No macOS, a chamada setlocale(LC_COLLATE, 'pt_BR.UTF-8') devolve a string 'pt_BR.UTF-8' sem qualquer reclamação. Mas sorted(fruits, key=locale.strxfrm) produz o mesmo resultado incorreto de sorted(fruits). Também tentei os locales fr_FR, es_ES, e de_DE no macOS, mas locale.strxfrm nunca fez seu trabalho direito.[52]

Portanto, a solução da biblioteca padrão para ordenação internacionalizada funciona, mas parece ter suporte adequado apenas no GNU/Linux (talvez também no Windows, se você for um especialista). Mesmo assim, ela depende das configurações do locale, criando dores de cabeça na implantação.

Felizmente, há uma solução mais simples: a biblioteca pyuca, disponível no PyPI.

4.8.1. Ordenando com o Algoritmo de Ordenação do Unicode

James Tauber, contribuidor muito ativo do Django, deve ter sentido essa nossa mesma dor, e criou a pyuca, uma implementação integralmente em Python do Algoritmo de Ordenação do Unicode (UCA, sigla em inglês para Unicode Collation Algorithm). O Exemplo 20 mostra como ela é fácil de usar.

Exemplo 20. Utilizando o método pyuca.Collator.sort_key
>>> import pyuca
>>> coll = pyuca.Collator()
>>> fruits = ['caju', 'atemoia', 'cajá', 'açaí', 'acerola']
>>> sorted_fruits = sorted(fruits, key=coll.sort_key)
>>> sorted_fruits
['açaí', 'acerola', 'atemoia', 'cajá', 'caju']

Isso é simples e funciona no GNU/Linux, no macOS, e no Windows, pelo menos com a minha pequena amostra.

A pyuca não leva o locale em consideração. Se você precisar personalizar a ordenação, pode fornecer um caminho para uma tabela própria de ordenação para o construtor Collator(). Sem qualquer configuração adicional, a biblioteca usa o allkeys.txt, incluído no projeto. Esse arquivo é apenas uma cópia da Default Unicode Collation Element Table (Tabela Default de Ordenação de Elementos Unicode) do Unicode.org .

👉 Dica
PyICU: A recomendação do Miro para ordenação com Unicode

(O revisor técnico Miroslav Šedivý é um poliglota e um especialista em Unicode. Eis o que ele escreveu sobre a pyuca.)

A pyuca tem um algoritmo de ordenação que não respeita o padrão de ordenação de linguagens individuais. Por exemplo, [a letra] Ä em alemão fica entre o A e o B, enquanto em sueco ela vem depois do Z. Dê uma olhada na PyICU, que funciona como locale sem modificar o locale do processo. Ela também é necessária se você quiser mudar a capitalização de iİ/ıI em turco. A PyICU inclui uma extensão que precisa ser compilada, então pode ser mais difícil de instalar em alguns sistemas que a pyuca, que é toda feita em Python.

E por sinal, aquela tabela de ordenação é um dos muitos arquivos de dados que formam o banco de dados do Unicode, nosso próximo assunto.

4.9. O banco de dados do Unicode

O padrão Unicode fornece todo um banco de dados—na forma de vários arquivos de texto estruturados—que inclui não apenas a tabela mapeando pontos de código para nomes de caracteres, mas também metadados sobre os caracteres individuais e como eles se relacionam. Por exemplo, o banco de dados do Unicode registra se um caractere pode ser impresso, se é uma letra, um dígito decimal ou algum outro símbolo numérico. É assim que os métodos de str isalpha, isprintable, isdecimal e isnumeric funcionam. str.casefold também usa informação de uma tabela do Unicode.

✒️ Nota

A função unicodedata.category(char) devolve uma categoria de char com duas letras, do banco de dados do Unicode. Os métodos de alto nível de str são mais fáceis de usar. Por exemplo, label.isalpha() devolve True se todos os caracteres em label pertencerem a uma das seguintes categorias: Lm, Lt, Lu, Ll, or Lo. Para descobrir o que esses códigos significam, veja "General Category" (EN) no artigo "Unicode character property" (EN) da Wikipedia em inglês.

4.9.1. Encontrando caracteres por nome

O módulo unicodedata tem funções para obter os metadados de caracteres, incluindo unicodedata.name(), que devolve o nome oficial do caractere no padrão. A Figura 5 demonstra essa função.[53]

Explorando `unicodedata.name()` no console do Python
Figura 5. Explorando unicodedata.name() no console do Python.

Você pode usar a função name() para criar aplicações que permitem aos usuários buscarem caracteres por nome. A Figura 6 demonstra o script de comando de linha cf.py, que recebe como argumentos uma ou mais palavras, e lista os caracteres que tem aquelas palavras em seus nomes Unicode oficiais. O código fonte completo de cf.py aparece no Exemplo 21.

Usando _cf.py_ para encontrar gatos sorridentes.
Figura 6. Usando cf.py para encontrar gatos sorridentes.
⚠️ Aviso

O suporte a emojis varia muito entre sistemas operacionais e aplicativos. Nos últimos anos, o terminal do macOS tem oferecido o melhor suporte para emojis, seguido por terminais gráficos GNU/Linux modernos. O cmd.exe e o PowerShell do Windows agora suportam saída Unicode, mas enquanto escrevo essa seção, em janeiro de 2020, eles ainda não mostram emojis—pelo menos não sem configurações adicionais. O revisor técnico Leonardo Rochael me falou sobre um novo terminal para Windows da Microsoft, de código aberto, que pode ter um suporte melhor a Unicode que os consoles antigos da Microsoft. Não tive tempo de testar.

No Exemplo 21, observe que o comando if, na função find, usa o método .issubset() para testar rapidamente se todas as palavras no conjunto query aparecem na lista de palavras criada a partir do nome do caractere. Graças à rica API de conjuntos do Python, não precisamos de um loop for aninhado e de outro if para implementar essa verificação

Exemplo 21. cf.py: o utilitário de busca de caracteres
#!/usr/bin/env python3
import sys
import unicodedata

START, END = ord(' '), sys.maxunicode + 1           # (1)

def find(*query_words, start=START, end=END):       # (2)
    query = {w.upper() for w in query_words}        # (3)
    for code in range(start, end):
        char = chr(code)                            # (4)
        name = unicodedata.name(char, None)         # (5)
        if name and query.issubset(name.split()):   # (6)
            print(f'U+{code:04X}\t{char}\t{name}')  # (7)

def main(words):
    if words:
        find(*words)
    else:
        print('Please provide words to find.')

if __name__ == '__main__':
    main(sys.argv[1:])
  1. Configura os defaults para a faixa de pontos de código da busca.

  2. find aceita query_words e somente argumentos nomeados (opcionais) para limitar a faixa da busca, facilitando os testes.

  3. Converte query_words em um conjunto de strings capitalizadas.

  4. Obtém o caractere Unicode para code.

  5. Obtém o nome do caractere, ou None se o ponto de código não estiver atribuído a um caractere.

  6. Se há um nome, separa esse nome em uma lista de palavras, então verifica se o conjunto query é um subconjunto daquela lista.

  7. Mostra uma linha com o ponto de código no formato U+9999, o caractere e seu nome.

O módulo unicodedata tem outras funções interessantes. A seguir veremos algumas delas, relacionadas a obter informação de caracteres com sentido numérico.

4.9.2. O sentido numérico de caracteres

O módulo unicodedata inclui funções para determinar se um caractere Unicode representa um número e, se for esse o caso, seu valor numérico em termos humanos—em contraste com o número de seu ponto de código.

O Exemplo 22 demonstra o uso de unicodedata.name() e unicodedata.numeric(), junto com os métodos .isdecimal() e .isnumeric() de str.

Exemplo 22. Demo do banco de dados Unicode de metadados de caracteres numéricos (as notas explicativas descrevem cada coluna da saída)
import unicodedata
import re

re_digit = re.compile(r'\d')

sample = '1\xbc\xb2\u0969\u136b\u216b\u2466\u2480\u3285'

for char in sample:
    print(f'U+{ord(char):04x}',                       # (1)
          char.center(6),                             # (2)
          're_dig' if re_digit.match(char) else '-',  # (3)
          'isdig' if char.isdigit() else '-',         # (4)
          'isnum' if char.isnumeric() else '-',       # (5)
          f'{unicodedata.numeric(char):5.2f}',        # (6)
          unicodedata.name(char),                     # (7)
          sep='\t')
  1. Ponto de código no formato U+0000.

  2. O caractere, centralizado em uma str de tamanho 6.

  3. Mostra re_dig se o caractere casa com a regex r'\d'.

  4. Mostra isdig se char.isdigit() é True.

  5. Mostra isnum se char.isnumeric() é True.

  6. Valor numérico formatado com tamanho 5 e duas casa decimais.

  7. O nome Unicode do caractere.

Executar o Exemplo 22 gera a Figura 7, se a fonte do seu terminal incluir todos aqueles símbolos.

Captura de tela de caracteres numéricos
Figura 7. Terminal do macOS mostrando os caracteres numéricos e metadados correspondentes; re_dig significa que o caractere casa com a expressão regular r'\d'.

A sexta coluna da Figura 7 é o resultado da chamada a unicodedata.numeric(char) com o caractere. Ela mostra que o Unicode sabe o valor numérico de símbolos que representam números. Assim, se você quiser criar uma aplicação de planilha que suporta dígitos tamil ou numerais romanos, vá fundo!

A Figura 7 mostra que a expressão regular r'\d' casa com o dígito "1" e com o dígito devanágari 3, mas não com alguns outros caracteres considerados dígitos pela função isdigit. O módulo re não é tão conhecedor de Unicode quanto deveria ser. O novo módulo regex, disponível no PyPI, foi projetado para um dia substituir o re, e fornece um suporte melhor ao Unicode.[54] Voltaremos ao módulo re na próxima seção.

Ao longo desse capítulo, usamos várias funções de unicodedata, mas há muitas outras que não mencionamos. Veja a documentação da biblioteca padrão para o módulo unicodedata.

A seguir vamos dar uma rápida passada pelas APIs de modo dual, com funções que aceitam argumentos str ou bytes e dão a eles tratamento especial dependendo do tipo.

4.10. APIs de modo dual para str e bytes

A biblioteca padrão do Python tem funções que aceitam argumentos str ou bytes e se comportam de forma diferente dependendo do tipo recebido. Alguns exemplos podem ser encontrados nos módulos re e os.

4.10.1. str versus bytes em expressões regulares

Se você criar uma expressão regular com bytes, padrões tal como \d e \w vão casar apenas com caracteres ASCII; por outro lado, se esses padrões forem passados como str, eles vão casar com dígitos Unicode ou letras além do ASCII. O Exemplo 23 e a Figura 8 comparam como letras, dígitos ASCII, superescritos e dígitos tamil casam em padrões str e bytes.

Exemplo 23. ramanujan.py: compara o comportamento de expressões regulares simples como str e como bytes
import re

re_numbers_str = re.compile(r'\d+')     # (1)
re_words_str = re.compile(r'\w+')
re_numbers_bytes = re.compile(rb'\d+')  # (2)
re_words_bytes = re.compile(rb'\w+')

text_str = ("Ramanujan saw \u0be7\u0bed\u0be8\u0bef"  # (3)
            " as 1729 = 1³ + 12³ = 9³ + 10³.")        # (4)

text_bytes = text_str.encode('utf_8')  # (5)

print(f'Text\n  {text_str!r}')
print('Numbers')
print('  str  :', re_numbers_str.findall(text_str))      # (6)
print('  bytes:', re_numbers_bytes.findall(text_bytes))  # (7)
print('Words')
print('  str  :', re_words_str.findall(text_str))        # (8)
print('  bytes:', re_words_bytes.findall(text_bytes))    # (9)
  1. As duas primeiras expressões regulares são do tipo str.

  2. As duas últimas são do tipo bytes.

  3. Texto Unicode para ser usado na busca, contendo os dígitos tamil para 1729 (a linha lógica continua até o símbolo de fechamento de parênteses).

  4. Essa string é unida à anterior no momento da compilação (veja "2.4.2. String literal concatenation" (Concatenação de strings literais) em A Referência da Linguagem Python).

  5. Uma string bytes é necessária para a busca com as expressões regulares bytes.

  6. O padrão str r'\d+' casa com os dígitos ASCII e tamil.

  7. O padrão bytes rb'\d+' casa apenas com os bytes ASCII para dígitos.

  8. O padrão str r'\w+' casa com letras, superescritos e dígitos tamil e ASCII.

  9. O padrão bytes rb'\w+' casa apenas com bytes ASCII para letras e dígitos.

Saída de ramanujan.py
Figura 8. Captura de tela da execução de ramanujan.py do Exemplo 23.

O Exemplo 23 é um exemplo trivial para destacar um ponto: você pode usar expressões regulares com str ou bytes, mas nesse último caso os bytes fora da faixa do ASCII são tratados como caracteres que não representam dígitos nem palavras.

Para expressões regulares str, há uma marcação re.ASCII, que faz \w, \W, \b, \B, \d, \D, \s, e \S executarem um casamento apenas com ASCII. Veja a documentaçào do módulo re para maiores detalhes.

Outro módulo importante é o os.

4.10.2. str versus bytes nas funções de os

O kernel do GNU/Linux não conhece Unicode então, no mundo real, você pode encontrar nomes de arquivo compostos de sequências de bytes que não são válidas em nenhum esquema razoável de codificação, e não podem ser decodificados para str. Servidores de arquivo com clientes usando uma variedade de diferentes SOs são particularmente inclinados a apresentar esse cenário.

Para mitigar esse problema, todas as funções do módulo os que aceitam nomes de arquivo ou caminhos podem receber seus argumentos como str ou bytes. Se uma dessas funções é chamada com um argumento str, o argumento será automaticamente convertido usando o codec informado por sys.getfilesystemencoding(), e a resposta do SO será decodificada com o mesmo codec. Isso é quase sempre o que se deseja, mantendo a melhor prática do sanduíche de Unicode.

Mas se você precisa lidar com (e provavelmente corrigir) nomes de arquivo que não podem ser processados daquela forma, você pode passar argumentos bytes para as funções de os, e receber bytes de volta. Esse recurso permite que você processe qualquer nome de arquivo ou caminho, independende de quantos gremlins encontrar. Veja o Exemplo 24.

Exemplo 24. listdir com argumentos str e bytes, e os resultados
>>> os.listdir('.')  # (1)
['abc.txt', 'digits-of-π.txt']
>>> os.listdir(b'.')  # (2)
[b'abc.txt', b'digits-of-\xcf\x80.txt']
  1. O segundo nome de arquivo é "digits-of-π.txt" (com a letra grega pi).

  2. Dado um argumento byte, listdir devolve nomes de arquivos como bytes: b'\xcf\x80' é a codificação UTF-8 para a letra grega pi.

Para ajudar no processamento manual de sequências str ou bytes que são nomes de arquivos ou caminhos, o módulo os fornece funções especiais de codificação e decodificação, os.fsencode(name_or_path) e os.fsdecode(name_or_path). Ambas as funções aceitam argumentos dos tipos str, bytes ou, desde o Python 3.6, um objeto que implemente a interface os.PathLike.

O Unicode é um buraco de coelho bem fundo. É hora de encerrar nossa exploração de str e bytes.

4.11. Resumo do capítulo

Começamos o capítulo descartando a noção de que 1 caractere == 1 byte. A medida que o mundo adota o Unicode, precisamos manter o conceito de strings de texto separado das sequências binárias que as representam em arquivos, e o Python 3 aplica essa separação.

Após uma breve passada pelos tipos de dados sequências binárias—bytes, bytearray, e memoryview—, mergulhamos na codificação e na decodificação, com uma amostragem dos codecs importantes, seguida por abordagens para prevenir ou lidar com os abomináveis UnicodeEncodeError, UnicodeDecodeError e os SyntaxError causados pela codificação errada em arquivos de código-fonte do Python.

A seguir consideramos a teoria e a prática de detecção de codificação na ausência de metadados: em teoria, não pode ser feita, mas na prática o pacote Chardet consegue realizar esse feito para uma grande quantidade de codificações populares. Marcadores de ordem de bytes foram apresentados como a única dica de codificação encontrada em arquivos UTF-16 e UTF-32—​algumas vezes também em arquivos UTF-8.

Na seção seguinte, demonstramos como abrir arquivos de texto, uma tarefa fácil exceto por uma armadilha: o argumento nomeado encoding= não é obrigatório quando se abre um arquivo de texto, mas deveria ser. Se você não especificar a codificação, terminará com um programa que consegue produzir "texto puro" que é incompatível entre diferentes plataformas, devido a codificações default conflitantes. Expusemos então as diferentes configurações de codificação usadas pelo Python, e como detectá-las.

Uma triste realidade para usuários de Windows é o fato dessas configurações muitas vezes terem valores diferentes dentro da mesma máquina, e desses valores serem mutuamente incompatíveis; usuários do GNU/Linux e do macOS, por outro lado, vivem em um lugar mais feliz, onde o UTF-8 é o default por (quase) toda parte.

O Unicode fornece múltiplas formas de representar alguns caracteres, então a normalização é um pré-requisito para a comparação de textos. Além de explicar a normalização e o case folding, apresentamos algumas funções úteis que podem ser adaptadas para as suas necessidades, incluindo transformações drásticas como a remoção de todos os acentos. Vimos como ordenar corretamente texto Unicode, usando o módulo padrão locale—com algumas restrições—e uma alternativa que não depende de complexas configurações de locale: a biblioteca externa pyuca.

Usamos o banco de dados do Unicode para programar um utilitário de comando de linha que busca caracteres por nome—​em 28 linhas de código, graças ao poder do Python. Demos uma olhada em outros metadados do Unicode, e vimos rapidamente as APIs de modo dual, onde algumas funções podem ser chamadas com argumentos str ou bytes, produzindo resultados diferentes.

4.12. Leitura complementar

A palestra de Ned Batchelder na PyCon US 2012, "Pragmatic Unicode, or, How Do I Stop the Pain?" (Unicode Pragmático, ou, Como Eu Fiz a Dor Sumir?) (EN), foi marcante. Ned é tão profissional que forneceu uma transcrição completa da palestra, além dos slides e do vídeo.

"Character encoding and Unicode in Python: How to (╯°□°)╯︵ ┻━┻ with dignity" (Codificação de caracteres e o Unicode no Python: como (╯°□°)╯︵ ┻━┻ com dignidade) (slides, vídeo) (EN) foi uma excelente palestra de Esther Nam e Travis Fischer na PyCon 2014, e foi onde encontrei a concisa epígrafe desse capítulo: "Humanos usam texto. Computadores falam em bytes."

Lennart Regebro—​um dos revisores técnicos da primeira edição desse livro—​compartilha seu "Useful Mental Model of Unicode (UMMU)" (Modelo Mental Útil do Unicode) em um post curto, "Unconfusing Unicode: What Is Unicode?" (Desconfundindo o Unicode: O Que É O Unicode?) (EN). O Unicode é um padrão complexo, então o UMMU de Lennart é realmente um ponto de partida útil.

O "Unicode HOWTO" oficial na documentação do Python aborda o assunto por vários ângulos diferentes, de uma boa introdução histórica a detalhes de sintaxe, codecs, expressões regulares, nomes de arquivo, e boas práticas para E/S sensível ao Unicode (isto é, o sanduíche de Unicode), com vários links adicionais de referências em cada seção.

O Chapter 4, "Strings" (Capítulo 4, "Strings"), do maravilhosos livro Dive into Python 3 (EN), de Mark Pilgrim (Apress), também fornece uma ótima introdução ao suporte a Unicode no Python 3. No mesmo livro, o Capítulo 15 descreve como a biblioteca Chardet foi portada do Python 2 para o Python 3, um valioso estudo de caso, dado que a mudança do antigo tipo str para o novo bytes é a causa da maioria das dores da migração, e esta é uma preocupação central em uma biblioteca projetada para detectar codificações.

Se você conhece Python 2 mas é novo no Python 3, o artigo "What’s New in Python 3.0" (O quê há de novo no Python 3.0) (EN), de Guido van Rossum, tem 15 pontos resumindo as mudanças, com vários links. Guido inicia com uma afirmação brutal: "Tudo o que você achava que sabia sobre dados binários e Unicode mudou". O post de Armin Ronacher em seu blog, "The Updated Guide to Unicode on Python" O Guia Atualizado do Unicode no Python, é bastante profundo e realça algumas das armadilhas do Unicode no Python (Armin não é um grande fã do Python 3).

O capítulo 2 ("Strings and Text" Strings e Texto) do Python Cookbook, 3rd ed. (EN) (O’Reilly), de David Beazley e Brian K. Jones, tem várias receitas tratando de normalização de Unicode, sanitização de texto, e execução de operações orientadas para texto em sequências de bytes. O capítulo 5 trata de arquivos e E/S, e inclui a "Recipe 5.17. Writing Bytes to a Text File" (Receita 5.17. Escrevendo Bytes em um Arquivo de Texto), mostrando que sob qualquer arquivo de texto há sempre uma sequência binária que pode ser acessada diretamente quando necessário. Mais tarde no mesmo livro, o módulo struct é usado em "Recipe 6.11. Reading and Writing Binary Arrays of Structures" (Receita 6.11. Lendo e Escrevendo Arrays Binárias de Estruturas).

O blog "Python Notes" de Nick Coghlan tem dois posts muito relevantes para esse capítulo: "Python 3 and ASCII Compatible Binary Protocols" (Python 3 e os Protocolos Binários Compatíveis com ASCII) (EN) e "Processing Text Files in Python 3" (Processando Arquivos de Texto em Python 3) (EN). Fortemente recomendado.

Uma lista de codificações suportadas pelo Python está disponível em "Standard Encodings" (EN), na documentação do módulo codecs. Se você precisar obter aquela lista de dentro de um programa, pode ver como isso é feito no script /Tools/unicode/listcodecs.py, que acompanha o código-fonte do CPython.

Os livros Unicode Explained (Unicode Explicado) (EN), de Jukka K. Korpela (O’Reilly) e Unicode Demystified (Unicode Desmistificado), de Richard Gillam (Addison-Wesley) não são específicos sobre o Python, nas foram muito úteis para meu estudo dos conceitos do Unicode. Programming with Unicode (Programando com Unicode), de Victor Stinner, é um livro gratuito e publicado pelo próprio autor (Creative Commons BY-SA) tratando de Unicode em geral, bem como de ferramentas e APIs no contexto dos principais sistemas operacionais e algumas linguagens de programação, incluindo Python.

As páginas do W3C "Case Folding: An Introduction" (Case Folding: Uma Introdução) (EN) e "Character Model for the World Wide Web: String Matching" (O Modelo de Caracteres para a World Wide Web: Correspondência de Strings) (EN) tratam de conceitos de normalização, a primeira uma suave introdução e a segunda uma nota de um grupo de trabalho escrita no seco jargão dos padrões—o mesmo tom do "Unicode Standard Annex #15—​Unicode Normalization Forms" (Anexo 15 do Padrão Unicode—Formas de Normalização do Unicode) (EN). A seção "Frequently Asked Questions, Normalization" (Perguntas Frequentes, Normalização) (EN) do Unicode.org é mais fácil de ler, bem como o "NFC FAQ" (EN) de Mark Davis—​autor de vários algoritmos do Unicode e presidente do Unicode Consortium quando essa seção foi escrita.

Em 2016, o Museu de Arte Moderna (MoMA) de New York adicionou à sua coleção o emoji original (EN), os 176 emojis desenhados por Shigetaka Kurita em 1999 para a NTT DOCOMO—a provedora de telefonia móvel japonesa. Indo mais longe no passado, a Emojipedia (EN) publicou o artigo "Correcting the Record on the First Emoji Set" (Corrigindo o Registro [Histórico] sobre o Primeiro Conjunto de Emojis) (EN), atribuindo ao SoftBank do Japão o mais antigo conjunto conhecido de emojis, implantado em telefones celulares em 1997. O conjunto do SoftBank é a fonte de 90 emojis que hoje fazem parte do Unicode, incluindo o U+1F4A9 (PILE OF POO). O emojitracker.com, de Matthew Rothenberg, é um painel ativo mostrando a contagem do uso de emojis no Twitter, atualizado em tempo real. Quando escrevo isso, FACE WITH TEARS OF JOY (U+1F602) é o emoji mais popular no Twitter, com mais de 3.313.667.315 ocorrências registradas.

Ponto de vista

Nomes não-ASCII no código-fonte: você deveria usá-los?

O Python 3 permite identificadores não-ASCII no código-fonte:

>>> ação = 'PBR'  # ação = stock
>>> ε = 10**-6    # ε = epsilon

Algumas pessoas não gostam dessa ideia. O argumento mais comum é que se limitar aos caracteres ASCII torna a leitura e a edição so código mais fácil para todo mundo. Esse argumento erra o alvo: você quer que seu código-fonte seja legível e editável pela audiência pretendida, e isso pode não ser "todo mundo". Se o código pertence a uma corporação multinacional, ou se é um código aberto e você deseja contribuidores de todo o mundo, os identificadores devem ser em inglês, e então tudo o que você precisa é do ASCII.

Mas se você é uma professora no Brasil, seus alunos vão achar mais fácil ler código com variáveis e nomes de função em português, e escritos corretamente. E eles não terão nenhuma dificuldade para digitar as cedilhas e as vogais acentuadas em seus teclados localizados.

Agora que o Python pode interpretar nomes em Unicode, e que o UTF-8 é a codificação padrão para código-fonte, não vejo motivo para codificar identificadores em português sem acentos, como fazíamos no Python 2, por necessidade—a menos que seu código tenha que rodar também no Python 2. Se os nomes estão em português, excluir os acentos não vai tornar o código mais legível para ninguém.

Esse é meu ponto de vista como um brasileiro falante de português, mas acredito que se aplica além de fronteiras e a outras culturas: escolha a linguagem humana que torna o código mais legível para sua equipe, e então use todos os caracteres necessários para a ortografia correta.

O que é "texto puro"?

Para qualquer um que lide diariamente com texto em línguas diferentes do inglês, "texto puro" não significa "ASCII". O Glossário do Unicode (EN) define texto puro dessa forma:

Texto codificado por computador que consiste apenas de uma sequência de pontos de código de um dado padrão, sem qualquer outra informação estrutural ou de formatação.

Essa definição começa muito bem, mas não concordo com a parte após a vírgula. HTML é um ótimo exemplo de um formato de texto puro que inclui informação estrutural e de formatação. Mas ele ainda é texto puro, porque cada byte em um arquivo desse tipo está lá para representar um caractere de texto, em geral usando UTF-8. Não há bytes com significado não-textual, como você encontra em documentos .png ou .xls, onde a maioria dos bytes representa valores binários empacotados, como valores RGB ou números de ponto flutuante. No texto puro, números são representados como sequências de caracteres de dígitos.

Estou escrevendo esse livro em um formato de texto puro chamado—ironicamente— AsciiDoc, que é parte do conjunto de ferramentas do excelente Atlas book publishing platform (plataforma de publicação de livros Atlas) da O’Reilly. Os arquivos fonte de AsciiDoc são texto puro, mas são UTF-8, e não ASCII. Se fosse o contrário, escrever esse capítulo teria sido realmente doloroso. Apesar do nome, o AsciiDoc é muito bom.

O mundo do Unicode está em constante expansão e, nas margens, as ferramentas de apoio nem sempre existem. Nem todos os caracteres que eu queria exibir estavam disponíveis nas fontes usadas para renderizar o livro. Por isso tive que usar imagens em vez de listagens em vários exemplos desse capítulo. Por outro lado, os terminais do Ubuntu e do macOS exibem a maioria do texto Unicode muito bem—incluindo os caracteres japoneses para a palavra "mojibake": 文字化け.

Como os ponto de código numa str são representados na RAM?

A documentação oficial do Python evita falar sobre como os pontos de código de uma str são armazenados na memória. Realmente, é um detalhe de implementação. Em teoria, não importa: qualquer que seja a representação interna, toda str precisa ser codificada para bytes na saída.

Na memória, o Python 3 armazena cada str como uma sequência de pontos de código, usando um número fixo de bytes por ponto de código, para permitir um acesso direto eficiente a qualquer caractere ou fatia.

Desde o Python 3.3, ao criar um novo objeto str o interpretador verifica os caracteres no objeto, e escolhe o layout de memória mais econômico que seja adequado para aquela str em particular: se existirem apenas caracteres na faixa latin1, aquela str vai usar apenas um byte por ponto de código. Caso contrário, podem ser usados dois ou quatro bytes por ponto de código, dependendo da str. Isso é uma simplificação; para saber todos os detalhes, dê uma olhada an PEP 393—​Flexible String Representation (Representação Flexível de Strings) (EN).

A representação flexível de strings é similar à forma como o tipo int funciona no Python 3: se um inteiro cabe em uma palavra da máquina, ele será armazenado em uma palavra da máquina. Caso contrário, o interpretador muda para uma representação de tamanho variável, como aquela do tipo long do Python 2. É bom ver as boas ideias se espalhando.

Entretanto, sempre podemos contar com Armin Ronacher para encontrar problemas no Python 3. Ele me explicou porque, na prática, essa não é uma ideia tão boa assim: basta um único RAT (U+1F400) para inflar um texto, que de outra forma seria inteiramente ASCII, e transformá-lo em um array sugadora de memória, usando quatro bytes por caractere, quando um byte seria o suficiente para todos os caracteres exceto o RAT. Além disso, por causa de todas as formas como os caracteres Unicode se combinam, a capacidade de buscar um caractere arbitrário pela posição é superestimada—e extrair fatias arbitrárias de texto Unicode é no mínimo ingênuo, e muitas vezes errado, produzindo mojibake. Com os emojis se tornando mais populares, esses problemas vão só piorar.

5. Fábricas de classes de dados

Classes de dados são como crianças. São boas como um ponto de partida mas, para participarem como um objeto adulto, precisam assumir alguma responsabilidade.

Martin Fowler and Kent Beck em Refactoring, primeira edição, Capítulo 3, seção "Bad Smells in Code, Data Class" (Mau cheiro no código, classe de dados), página 87 (Addison-Wesley).

O Python oferece algumas formas de criar uma classe simples, apenas uma coleção de campos, com pouca ou nenhuma funcionalidade adicional. Esse padrão é conhecido como "classe de dados"—e dataclasses é um dos pacotes que suporta tal modelo. Este capítulo trata de três diferentes fábricas de classes que podem ser utilizadas como atalhos para escrever classes de dados:

collections.namedtuple

A forma mais simples—disponível desde o Python 2.6.

typing.NamedTuple

Uma alternativa que requer dicas de tipo nos campos—desde o Python 3.5, com a sintaxe class adicionada no 3.6.

@dataclasses.dataclass

Um decorador de classe que permite mais personalização que as alternativas anteriores, acrescentando várias opções e, potencialmente, mais complexidade—desde o Python 3.7.

Após falar sobre essas fábricas de classes, vamos discutir o motivo de classe de dados ser também o nome um code smell: um padrão de programação que pode ser um sintoma de um design orientado a objetos ruim.

✒️ Nota

A classe typing.TypedDict pode parecer apenas outra fábrica de classes de dados. Ela usa uma sintaxe similar, e é descrita pouco após typing.NamedTuple na documentação do módulo typing (EN) do Python 3.11.

Entretanto, TypedDict não cria classes concretas que possam ser instanciadas. Ela é apenas a sintaxe para escrever dicas de tipo para parâmetros de função e variáveis que aceitarão valores de mapeamentos como registros, enquanto suas chaves serão os nomes dos campos. Nós veremos mais sobre isso na Seção 15.3 do Capítulo 15.

5.1. Novidades nesse capítulo

Este capítulo é novo, aparece nessa segunda edição do Python Fluente. A Seção 5.3 era parte do capítulo 2 da primeira edição, mas o restante do capítulo é inteiramente inédito.

Vamos começar por uma visão geral, por alto, das três fábricas de classes.

5.2. Visão geral das fábricas de classes de dados

Considere uma classe simples, representando um par de coordenadas geográficas, como aquela no Exemplo 1.

Exemplo 1. class/coordinates.py
class Coordinate:

    def __init__(self, lat, lon):
        self.lat = lat
        self.lon = lon

A tarefa da classe Coordinate é manter os atributos latitude e longitude. Escrever o __init__ padrão fica cansativo muito rápido, especialmente se sua classe tiver mais que alguns poucos atributos: cada um deles é mencionado três vezes! E aquele código repetitivo não nos dá sequer os recursos básicos que esperamos de um objeto Python:

>>> from coordinates import Coordinate
>>> moscow = Coordinate(55.76, 37.62)
>>> moscow
<coordinates.Coordinate object at 0x107142f10>  (1)
>>> location = Coordinate(55.76, 37.62)
>>> location == moscow  (2)
False
>>> (location.lat, location.lon) == (moscow.lat, moscow.lon)  (3)
True
  1. O __repr__ herdado de object não é muito útil.

  2. O == não faz sentido; o método __eq__ herdado de object compara os IDs dos objetos.

  3. Comparar duas coordenadas exige a comparação explícita de cada atributo.

As fábricas de classes de dados tratadas nesse capítulo fornecem automaticamente os métodos __init__, __repr__, e __eq__ necessários, além alguns outros recursos úteis.

✒️ Nota

Nenhuma das fábricas de classes discutidas aqui depende de herança para funcionar. Tanto collections.namedtuple quanto typing.NamedTuple criam subclasses de tuple. O @dataclass é um decorador de classe, não afeta de forma alguma a hierarquia de classes. Cada um deles utiliza técnicas diferentes de metaprogramação para injetar métodos e atributos de dados na classe em construção.

Aqui está uma classe Coordinate criada com uma namedtuple—uma função fábrica que cria uma subclasse de tuple com o nome e os campos especificados:

>>> from collections import namedtuple
>>> Coordinate = namedtuple('Coordinate', 'lat lon')
>>> issubclass(Coordinate, tuple)
True
>>> moscow = Coordinate(55.756, 37.617)
>>> moscow
Coordinate(lat=55.756, lon=37.617)  (1)
>>> moscow == Coordinate(lat=55.756, lon=37.617)  (2)
True
  1. Um __repr__ útil.

  2. Um __eq__ que faz sentido.

A typing.NamedTuple, mais recente, oferece a mesma funcionalidade e acrescenta anotações de tipo a cada campo:

>>> import typing
>>> Coordinate = typing.NamedTuple('Coordinate',
...     [('lat', float), ('lon', float)])
>>> issubclass(Coordinate, tuple)
True
>>> typing.get_type_hints(Coordinate)
{'lat': <class 'float'>, 'lon': <class 'float'>}
👉 Dica

Uma tupla nomeada e com dicas de tipo pode também ser construída passando os campos como argumentos nomeados, assim:

Coordinate = typing.NamedTuple('Coordinate', lat=float, lon=float)

Além de ser mais legível, essa forma permite fornecer o mapeamento de campos e tipos como **fields_and_types.

Desde o Python 3.6, typing.NamedTuple pode também ser usada em uma instrução class, com as anotações de tipo escritas como descrito na PEP 526—Syntax for Variable Annotations (Sintaxe para Anotações de Variáveis) (EN). É muito mais legível, e torna fácil sobrepor métodos ou acrescentar métodos novos. O Exemplo 2 é a mesma classe Coordinate, com um par de atributos float e um __str__ personalziado, para mostrar a coordenada no formato 55.8°N, 37.6°E.

Exemplo 2. typing_namedtuple/coordinates.py
from typing import NamedTuple

class Coordinate(NamedTuple):
    lat: float
    lon: float

    def __str__(self):
        ns = 'N' if self.lat >= 0 else 'S'
        we = 'E' if self.lon >= 0 else 'W'
        return f'{abs(self.lat):.1f}°{ns}, {abs(self.lon):.1f}°{we}'
⚠️ Aviso

Apesar de NamedTuple aparecer na declaração class como uma superclasse, não é esse o caso. typing.NamedTuple usa a funcionalidade avançada de uma metaclasse[55] para personalizar a criação da classe do usuário. Veja isso:

>>> issubclass(Coordinate, typing.NamedTuple)
False
>>> issubclass(Coordinate, tuple)
True

No método __init__ gerado por typing.NamedTuple, os campos aparecem como parâmetros e na mesma ordem em que aparecem na declaração class.

Assim como typing.NamedTuple, o decorador dataclass suporta a sintaxe da PEP 526 (EN) para declarar atributos de instância. O decorador lê as anotações das variáveis e gera métodos automaticamente para sua classe. Como comparação, veja a classe Coordinate equivante escrita com a ajuda do decorador dataclass, como mostra o Exemplo 3.

Exemplo 3. dataclass/coordinates.py
from dataclasses import dataclass

@dataclass(frozen=True)
class Coordinate:
    lat: float
    lon: float

    def __str__(self):
        ns = 'N' if self.lat >= 0 else 'S'
        we = 'E' if self.lon >= 0 else 'W'
        return f'{abs(self.lat):.1f}°{ns}, {abs(self.lon):.1f}°{we}'

Observe que o corpo das classes no Exemplo 2 e no Exemplo 3 são idênticos—a diferença está na própria declaração class. O decorador @dataclass não depende de herança ou de uma metaclasse, então não deve interferir no uso desses mecanismos pelo usuário.[56] A classe Coordinate no Exemplo 3 é uma subclasse de object.

5.2.1. Principais recursos

As diferentes fábricas de classes de dados tem muito em comum, como resume a Tabela 12.

Tabela 12. Recursos selecionados, comparando as três fábricas de classes de dados; x é uma instância de uma classe de dados daquele tipo
namedtuple NamedTuple dataclass

instâncias mutáveis

NÃO

NÃO

SIM

sintaxe de declaração de classe

NÃO

SIM

SIM

criar um dict

x._asdict()

x._asdict()

dataclasses.asdict(x)

obter nomes dos campos

x._fields

x._fields

[f.name for f in dataclasses.fields(x)]

obter defaults

x._field_defaults

x._field_defaults

[f.default for f in dataclasses.fields(x)]

obter tipos dos campos

N/A

x.__annotations__

x.__annotations__

nova instância com modificações

x._replace(…)

x._replace(…)

dataclasses.replace(x, …)

nova classe durante a execução

namedtuple(…)

NamedTuple(…)

dataclasses.make_dataclass(…)

⚠️ Aviso

As classes criadas por typing.NamedTuple e @dataclass tem um atributo __annotations__, contendo as dicas de tipo para os campos. Entretanto, ler diretamente de __annotations__ não é recomendado. Em vez disso, a melhor prática recomendada para obter tal informação é chamar inspect.get_annotations(MyClass) (a partir do Python 3.10—EN) ou typing.​get_​type_​hints(MyClass) (Python 3.5 a 3.9—EN). Isso porque tais funções fornecem serviços adicionais, como a resolução de referências futuras nas dicas de tipo. Voltaremos a isso bem mais tarde neste livro, na Seção 15.5.1.

Vamos agora detalhar aqueles recursos principais.

Instâncias mutáveis

A diferença fundamental entre essas três fábricas de classes é que collections.namedtuple e typing.NamedTuple criam subclasses de tuple, e portanto as instâncias são imutáveis. Por default, @dataclass produz classes mutáveis. Mas o decorador aceita o argumento nomeado frozen—que aparece no Exemplo 3. Quando frozen=True, a classe vai gerar uma exceção se você tentar atribuir um valor a um campo após a instância ter sido inicializada.

Sintaxe de declaração de classe

Apenas typing.NamedTuple e dataclass suportam a sintaxe de declaração de class regular, tornando mais fácil acrescentar métodos e docstrings à classe que está sendo criada.

Construir um dict

As duas variantes de tuplas nomeadas fornecem um método de instância (._asdict), para construir um objeto dict a partir dos campos de uma instância de classe de dados. O módulo dataclasses fornece uma função para fazer o mesmo: dataclasses.asdict.

Obter nomes dos campos e valores default

Todas as três fábricas de classes permitem que você obtenha os nomes dos campos e os valores default (que podem ser configurados para cada campo). Nas classes de tuplas nomeadas, aqueles metadados estão nos atributos de classe ._fields e ._fields_defaults. Você pode obter os mesmos metadados em uma classe decorada com dataclass usando a função fields do módulo dataclasses. Ele devolve uma tupla de objetos Field com vários atributos, incluindo name e default.

Obter os tipos dos campos

Classes definidas com a ajuda de typing.NamedTuple e @dataclass contêm um mapeamento dos nomes dos campos para seus tipos, o atributo de classe __annotations__. Como já mencionado, use a função typing.get_type_hints em vez de ler diretamente de __annotations__.

Nova instância com modificações

Dada uma instância de tupla nomeada x, a chamada x._replace(**kwargs) devolve uma nova instância com os valores de alguns atributos modificados, de acordo com os argumentos nomeados incluídos na chamada. A função de módulo dataclasses.replace(x, **kwargs) faz o mesmo para uma instância de uma classe decorada com dataclass.

Nova classe durante a execução

Apesar da sintaxe de declaração de classe ser mais legível, ela é fixa no código. Um framework pode ter a necessidade de criar classes de dados durante a execução. Para tanto, podemos usar a sintaxe default de chamada de função de collections.namedtuple, que também é suportada por typing.NamedTuple. O módulo dataclasses oferece a função make_dataclass, com o mesmo propósito.

Após essa visão geral dos principais recursos das fábricas de classes de dados, vamos examinar cada uma delas mais de perto, começando pela mais simples.

5.3. Tuplas nomeadas clássicas

A função collections.namedtuple é uma fábrica que cria subclasses de tuple, acrescidas de nomes de campos, um nome de classe, e um __repr__ informativo. Classes criadas com namedtuple podem ser usadas onde quer que uma tupla seja necessária. Na verdade, muitas funções da biblioteca padrão, que antes devolviam tuplas agora devolvem, por conveniência, tuplas nomeadas, sem afetar de forma alguma o código do usuário.

👉 Dica

Cada instância de uma classe criada por namedtuple usa exatamente a mesma quantidade de memória usada por uma tupla, pois os nomes dos campos são armazenados na classe.

O Exemplo 4 mostra como poderíamos definir uma tupla nomeada para manter informações sobre uma cidade.

Exemplo 4. Definindo e usando um tipo tupla nomeada
>>> from collections import namedtuple
>>> City = namedtuple('City', 'name country population coordinates')  (1)
>>> tokyo = City('Tokyo', 'JP', 36.933, (35.689722, 139.691667))  (2)
>>> tokyo
City(name='Tokyo', country='JP', population=36.933, coordinates=(35.689722,
139.691667))
>>> tokyo.population  (3)
36.933
>>> tokyo.coordinates
(35.689722, 139.691667)
>>> tokyo[1]
'JP'
  1. São necessários dois parâmetros para criar uma tupla nomeada: um nome de classe e uma lista de nomes de campos, que podem ser passados como um iterável de strings ou como uma única string com os nomes delimitados por espaços.

  2. Na inicialização de uma instância, os valores dos campos devem ser passados como argumentos posicionais separados (uma tuple, por outro lado, é inicializada com um único iterável)

  3. É possível acessar os campos por nome ou por posição.

Como uma subclasse de tuple, City herda métodos úteis, tal como __eq__ e os métodos especiais para operadores de comparação—incluindo __lt__, que permite ordenar listas de instâncias de City.

Uma tupla nomeada oferece alguns atributos e métodos além daqueles herdados de tuple. O Exemplo 5 demonstra os mais úteis dentre eles: o atributo de classe _fields, o método de classe _make(iterable), e o método de instância _asdict().

Exemplo 5. Atributos e métodos das tuplas nomeadas (continuando do exenplo anterior)
>>> City._fields  (1)
('name', 'country', 'population', 'location')
>>> Coordinate = namedtuple('Coordinate', 'lat lon')
>>> delhi_data = ('Delhi NCR', 'IN', 21.935, Coordinate(28.613889, 77.208889))
>>> delhi = City._make(delhi_data)  (2)
>>> delhi._asdict()  (3)
{'name': 'Delhi NCR', 'country': 'IN', 'population': 21.935,
'location': Coordinate(lat=28.613889, lon=77.208889)}
>>> import json
>>> json.dumps(delhi._asdict())  (4)
'{"name": "Delhi NCR", "country": "IN", "population": 21.935,
"location": [28.613889, 77.208889]}'
  1. ._fields é uma tupla com os nomes dos campos da classe.

  2. ._make() cria uma City a partir de um iterável; City(*delhi_data) faria o mesmo.

  3. ._asdict() devolve um dict criado a partir da instância de tupla nomeada.

  4. ._asdict() é útil para serializar os dados no formato JSON, por exemplo.

⚠️ Aviso

Até o Python 3.7, o método _asdict devolvia um OrderedDict. Desde o Python 3.8, ele devolve um dict simples—o que não causa qualquer problema, agora que podemos confiar na ordem de inserção das chaves. Se você precisar de um OrderedDict, a documentação do _asdict (EN) recomenda criar um com o resultado: OrderedDict(x._asdict()).

Desde o Python 3.7, a namedtuple aceita o argumento nomeado defaults, fornecendo um iterável de N valores default para cada um dos N campos mais à direita na definição da classe. O Exemplo 6 mostra como definir uma tupla nomeada Coordinate com um valor default para o campo reference.

Exemplo 6. Atributos e métodos das tuplas nomeadas, continuando do Exemplo 5
>>> Coordinate = namedtuple('Coordinate', 'lat lon reference', defaults=['WGS84'])
>>> Coordinate(0, 0)
Coordinate(lat=0, lon=0, reference='WGS84')
>>> Coordinate._field_defaults
{'reference': 'WGS84'}

Na Seção 5.2.1.2, mencionei que é mais fácil programar métodos com a sintaxe de classe suportada por typing.NamedTuple and @dataclass. Você também pode acrescentar métodos a uma namedtuple, mas é um remendo. Pule a próxima caixinha se você não estiver interessada em gambiarras.

Remendando uma tupla nomeada para injetar um método

Lembre como criamos a classe Card class no Exemplo 1 do Capítulo 1:

Card = collections.namedtuple('Card', ['rank', 'suit'])

Mas tarde no Capítulo 1, escrevi uma função spades_high, para ordenação. Seria bom que aquela lógica estivesse encapsulada em um método de Card, mas acrescentar spades_high a Card sem usar uma declaração class exige um remendo rápido: definir a função e então atribuí-la a um atributo de classe. O Exemplo 7 mostra como isso é feito:

Exemplo 7. frenchdeck.doctest: Acrescentando um atributo de classe e um método a Card, a namedtuple da Seção 1.2
>>> Card.suit_values = dict(spades=3, hearts=2, diamonds=1, clubs=0)  # (1)
>>> def spades_high(card):                                            # (2)
...     rank_value = FrenchDeck.ranks.index(card.rank)                
...     suit_value = card.suit_values[card.suit]
...     return rank_value * len(card.suit_values) + suit_value
...
>>> Card.overall_rank = spades_high                                   # (3)
>>> lowest_card = Card('2', 'clubs')
>>> highest_card = Card('A', 'spades')
>>> lowest_card.overall_rank()                                        # (4)
0
>>> highest_card.overall_rank()
51
  1. Acrescenta um atributo de classe com valores para cada naipe.

  2. A função spades_high vai se tornar um método; o primeiro argumento não precisa ser chamado de self. Como um método, ela de qualquer forma terá acesso à instância que recebe a chamada.

  3. Anexa a função à classe Card como um método chamado overall_rank.

  4. Funciona!

Para uma melhor legibilidade e para ajudar na manutenção futura, é muito melhor programar métodos dentro de uma declaração class. Mas é bom saber que essa gambiarra é possível, pois às vezes pode ser útil.[57]

Isso foi apenas um pequeno desvio para demonstrar o poder de uma linguagem dinâmica.

Agora vamos ver a variante typing.NamedTuple.

5.4. Tuplas nomeadas com tipo

A classe Coordinate com um campo default, do Exemplo 6, pode ser escrita usando typing.NamedTuple, como se vê no Exemplo 8.

Exemplo 8. typing_namedtuple/coordinates2.py
from typing import NamedTuple

class Coordinate(NamedTuple):
    lat: float                # (1)
    lon: float
    reference: str = 'WGS84'  # (2)
  1. Todo campo de instância precisa ter uma anotação de tipo.

  2. O campo de instância reference é anotado com um tipo e um valor default.

As classes criadas por typing.NamedTuple não tem qualquer método além daqueles que collections.namedtuple também gera—e aquele herdados de tuple. A única diferença é a presença do atributo de classe __annotations__—que o Python ignora completamente durante a execução do programa.

Dado que o principal recurso de typing.NamedTuple são as anotações de tipo, vamos dar uma rápida olhada nisso antes de continuar nossa exploração das fábricas de classes de dados.

5.5. Introdução às dicas de tipo

Dicas de tipo—também chamadas anotações de tipo—são formas de declarar o tipo esperado dos argumentos, dos valores devolvidos, das variáveis e dos atributos de funções.

A primeira coisa que você precisa saber sobre dicas de tipo é que elas não são impostas de forma alguma pelo compilador de bytecode ou pelo interpretador do Python.

✒️ Nota

Essa é uma introdução muito breve sobre dicas de tipo, suficiente apenas para que a sintaxe e o propósito das anotações usadas nas declarações de typing.NamedTuple e @dataclass façam sentido. Vamos trata de anotações de tipo nas assinaturas de função no Capítulo 8 e de anotações mais avançadas no Capítulo 15. Aqui vamos ver principalmente dicas com tipos embutidos simples, tais como str, int, e float, que são provavelmente os tipos mais comuns usados para anotar campos em classes de dados.

5.5.1. Nenhum efeito durante a execução

Pense nas dicas de tipo do Python como "documentação que pode ser verificada por IDEs e verificadores de tipo".

Isso porque as dicas de tipo não tem qualquer impacto sobre o comportamento de programas em Python durante a execução. Veja o Exemplo 9.

Exemplo 9. O Python não exige dicas de tipo durante a execução de um programa
>>> import typing
>>> class Coordinate(typing.NamedTuple):
...     lat: float
...     lon: float
...
>>> trash = Coordinate('Ni!', None)
>>> print(trash)
Coordinate(lat='Ni!', lon=None)    # (1)
  1. Eu avisei: não há verificação de tipo durante a execução!

Se você incluir o código do Exemplo 9 em um módulo do Python, ela vai rodar e exibir uma Coordinate sem sentido, e sem gerar qualquer erro ou aviso:

$ python3 nocheck_demo.py
Coordinate(lat='Ni!', lon=None)

O objetivo primário das dicas de tipo é ajudar os verificadores de tipo externos, como o Mypy ou o verificador de tipo embutido do PyCharm IDE. Essas são ferramentas de análise estática: elas verificam código-fonte Python "parado", não código em execução.

Para observar o efeito das dicas de tipo, é necessário executar umas dessas ferramentas sobre seu código—como um linter (analisador de código). Por exemplo, eis o quê o Mypy tem a dizer sobre o exemplo anterior:

$ mypy nocheck_demo.py
nocheck_demo.py:8: error: Argument 1 to "Coordinate" has
incompatible type "str"; expected "float"
nocheck_demo.py:8: error: Argument 2 to "Coordinate" has
incompatible type "None"; expected "float"

Como se vê, dada a definição de Coordinate, o Mypy sabe que os dois argumentos para criar um instância devem ser do tipo float, mas atribuição a trash usa uma str e None.[58]

Vamos falar agora sobre a sintaxe e o significado das dicas de tipo.

5.5.2. Sintaxe de anotação de variáveis

Tanto typing.NamedTuple quanto @dataclass usam a sintaxe de anotações de variáveis definida na PEP 526 (EN). Vamos ver aqui uma pequena introdução àquela sintaxe, no contexto da definição de atributos em declarações class.

A sintaxe básica da anotação de variáveis é :

var_name: some_type

A seção "Acceptable type hints" (_Dicas de tipo aceitáveis), na PEP 484, explica o que são tipo aceitáveis. Porém, no contexto da definição de uma classe de dados, os tipos mais úteis geralmente serão os seguintes:

  • Uma classe concreta, por exemplo str ou FrenchDeck.

  • Um tipo de coleção parametrizada, como list[int], tuple[str, float], etc.

  • typing.Optional, por exemplo Optional[str]—para declarar um campo que pode ser uma str ou None.

Você também pode inicializar uma variável com um valor. Em uma declaração de typing.NamedTuple ou @dataclass, aquele valor se tornará o default daquele atributo quando o argumento correspondente for omitido na chamada de inicialização:

var_name: some_type = a_value

5.5.3. O significado das anotações de variáveis

Vimos, no tópico Seção 5.5.1, que dicas de tipo não tem qualquer efeito durante a execução de um programa. Mas no momento da importação—quando um módulo é carregado—o Python as lê para construir o dicionário __annotations__, que typing.NamedTuple e @dataclass então usam para aprimorar a classe.

Vamos começar essa exploração no Exemplo 10, com uma classe simples, para mais tarde ver que recursos adicionais são acrescentados por typing.NamedTuple e @dataclass.

Exemplo 10. meaning/demo_plain.py: uma classe básica com dicas de tipo
class DemoPlainClass:
    a: int           # (1)
    b: float = 1.1   # (2)
    c = 'spam'       # (3)
  1. a se torna um registro em __annotations__, mas é então descartada: nenhum atributo chamado a é criado na classe.

  2. b é salvo como uma anotação, e também se torna um atributo de classe com o valor 1.1.

  3. c é só um bom e velho atributo de classe básico, sem uma anotação.

Podemos checar isso no console, primeiro lendo o __annotations__ da DemoPlainClass, e daí tentando obter os atributos chamados a, b, e c:

>>> from demo_plain import DemoPlainClass
>>> DemoPlainClass.__annotations__
{'a': <class 'int'>, 'b': <class 'float'>}
>>> DemoPlainClass.a
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
AttributeError: type object 'DemoPlainClass' has no attribute 'a'
>>> DemoPlainClass.b
1.1
>>> DemoPlainClass.c
'spam'

Observe que o atributo especial __annotations__ é criado pelo interpretador para registrar dicas de tipo que aparecem no código-fonte—mesmo em uma classe básica.

O a sobrevive apenas como uma anotação, não se torna um atributo da classe, porque nenhum valor é atribuído a ele.[59] O b e o c são armazenados como atributos de classe porque são vinculados a valores.

Nenhum desses três atributos estará em uma nova instância de DemoPlainClass. Se você criar um objeto o = DemoPlainClass(), o.a vai gerar um AttributeError, enquanto o.b e o.c vão obter os atributos de classe com os valores 1.1 e 'spam'—que é apenas o comportamento normal de um objeto Python.

Inspecionando uma typing.NamedTuple

Agora vamos examinar uma classe criada com typing.NamedTuple (Exemplo 11), usando os mesmos atributos e anotações da DemoPlainClass do Exemplo 10.

Exemplo 11. meaning/demo_nt.py: uma classe criada com typing.NamedTuple
import typing

class DemoNTClass(typing.NamedTuple):
    a: int           # (1)
    b: float = 1.1   # (2)
    c = 'spam'       # (3)
  1. a se torna uma anotação e também um atributo de instância.

  2. b é outra anotação, mas também se torna um atributo de instância com o valor default 1.1.

  3. c é só um bom e velho atributo de classe comum; não será mencionado em nenhuma anotação.

Inspecionando a DemoNTClass, temos o seguinte:

>>> from demo_nt import DemoNTClass
>>> DemoNTClass.__annotations__
{'a': <class 'int'>, 'b': <class 'float'>}
>>> DemoNTClass.a
<_collections._tuplegetter object at 0x101f0f940>
>>> DemoNTClass.b
<_collections._tuplegetter object at 0x101f0f8b0>
>>> DemoNTClass.c
'spam'

Aqui vemos as mesmas anotações para a e b que vimos no Exemplo 10. Mas typing.NamedTuple cria os atributos de classe a e b. O atributo c é apenas um atributo de classe simples, com o valor 'spam'.

Os atributos de classe a e b são descritores (descriptors)—um recurso avançado tratado no Capítulo 23. Por ora, pense neles como similares a um getter de propriedades do objeto[60]: métodos que não exigem o operador explícito de chamada () para obter um atributo de instância. Na prática, isso significa que a e b vão funcionar como atributos de instância somente para leitura—o que faz sentido, se lembrarmos que instâncias de DemoNTClass são apenas tuplas chiques, e tuplas são imutáveis.

A DemoNTClass também recebe uma docstring personalizada:

>>> DemoNTClass.__doc__
'DemoNTClass(a, b)'

Vamos examinar uma instância de DemoNTClass:

>>> nt = DemoNTClass(8)
>>> nt.a
8
>>> nt.b
1.1
>>> nt.c
'spam'

Para criar nt, precisamos passar pelo menos o argumento a para DemoNTClass. O construtor também aceita um argumento b, mas como este último tem um valor default (de 1.1), ele é opcional. Como esperado, o objeto nt possui os atributos a e b; ele não tem um atributo c, mas o Python obtém c da classe, como de hábito.

Se você tentar atribuir valores para nt.a, nt.b, nt.c, ou mesmo para nt.z, vai gerar uma exceção Attribute​Error, com mensagens de erro sutilmente distintas. Tente fazer isso, e reflita sobre as mensagens.

Inspecionando uma classe decorada com dataclass

Vamos agora examinar o Exemplo 12.

Exemplo 12. meaning/demo_dc.py: uma classe decorada com @dataclass
from dataclasses import dataclass

@dataclass
class DemoDataClass:
    a: int           # (1)
    b: float = 1.1   # (2)
    c = 'spam'       # (3)
  1. a se torna uma anotação, e também um atributo de instância controlado por um descritor.

  2. b é outra anotação, e também se torna um atributo de instância com um descritor e um valor default de 1.1.

  3. c é apenas um atributo de classe comum; nenhuma anotação se refere a ele.

Podemos então verificar o __annotations__, o __doc__, e os atributos a, b, c no Demo​DataClass:

>>> from demo_dc import DemoDataClass
>>> DemoDataClass.__annotations__
{'a': <class 'int'>, 'b': <class 'float'>}
>>> DemoDataClass.__doc__
'DemoDataClass(a: int, b: float = 1.1)'
>>> DemoDataClass.a
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
AttributeError: type object 'DemoDataClass' has no attribute 'a'
>>> DemoDataClass.b
1.1
>>> DemoDataClass.c
'spam'

O __annotations__ e o __doc__ não guardam surpresas. Entretanto, não há um atributo chamado a em DemoDataClass—diferente do que ocorre na DemoNTClass do Exemplo 11, que inclui um descritor para obter a das instâncias da classe, como atributos somente para leitura (aquele misterioso <_collections.tuplegetter>). Isso ocorre porque o atributo a só existirá nas instâncias de DemoDataClass. Será um atributo público, que poderemos obter e definir, a menos que a classe seja frozen. Mas b e c existem como atributos de classe, com b contendo o valor default para o atributo de instância b, enquanto c é apenas um atributo de classe que não será vinculado a instâncias.

Vejamos como se parece uma instância de DemoDataClass:

>>> dc = DemoDataClass(9)
>>> dc.a
9
>>> dc.b
1.1
>>> dc.c
'spam'

Novamente, a e b são atributos de instância, e c é um atributo de classe obtido através da instância.

Como mencionado, instâncias de DemoDataClass são mutáveis—e nenhuma verificação de tipo é realizada durante a execução:

>>> dc.a = 10
>>> dc.b = 'oops'

Podemos fazer atribuições ainda mais ridículas:

>>> dc.c = 'whatever'
>>> dc.z = 'secret stash'

Agora a instância dc tem um atributo c—mas isso não muda o atributo de classe c. E podemos adicionar um novo atributo z. Isso é o comportamento normal do Python: instâncias regulares podem ter seus próprios atributos, que não aparecem na classe.[61]

5.6. Mais detalhes sobre @dataclass

Até agora, só vimos exemplos simples do uso de @dataclass. Esse decorador aceita vários argumentos nomeados. Esta é sua assinatura:

@dataclass(*, init=True, repr=True, eq=True, order=False,
              unsafe_hash=False, frozen=False)

O * na primeira posição significa que os parâmetros restantes são todos parâmetros nomeados. A Tabela 13 os descreve.

Tabela 13. Parâmetros nomeados aceitos pelo decorador @dataclass
Option Meaning Default Notes

init

Gera o __init__

True

Ignorado se o __init__ for implementado pelo usuário.

repr

Gera o __repr__

True

Ignorado se o __repr__ for implementado pelo usuário.

eq

Gera o __eq__

True

Ignorado se o __eq__ for implementado pelo usuário.

order

Gera __lt__, __le__, __gt__, __ge__

False

Se True, causa uma exceção se eq=False, ou se qualquer dos métodos de comparação que seriam gerados estiver definido ou for herdado.

unsafe_hash

Gera o __hash__

False

Semântica complexa e várias restrições—veja a: documentação de dataclass.

frozen

Cria instâncias "imutáveis"

False

As instâncias estarão razoavelmente protegidas contra mudanças acidentais, mas não serão realmente imutáveis.[62]

Os defaults são, de fato, as configurações mais úteis para os casos de uso mais comuns. As opções mais prováveis de serem modificadas de seus defaults são:

frozen=True

Protege as instâncias da classe de modificações acidentais.

order=True

Permite ordenar as instâncias da classe de dados.

Dada a natureza dinâmica de objetos Python, não é muito difícil para um programador curioso contornar a proteção oferecida por frozen=True. Mas os truques necessários são fáceis de perceber em uma revisão do código.

Se tanto o argumento eq quanto o frozen forem True, @dataclass produz um método __hash__ adequado, e daí as instâncias serão hashable. O __hash__ gerado usará dados de todos os campos que não forem individualmente excluídos usando uma opção de campo, que veremos na Seção 5.6.1. Se frozen=False (o default), @dataclass definirá __hash__ como None, sinalizando que as instâncias não são hashable, e portanto sobrepondo o __hash__ de qualquer superclasse.

A PEP 557—Data Classes (Classe de Dados) (EN) diz o seguinte sobre unsafe_hash:

Apesar de não ser recomendado, você pode forçar Classes de Dados a criarem um método __hash__ com unsafe_hash=True. Pode ser esse o caso, se sua classe for logicamente imutável e mesmo assim possa ser modificada. Este é um caso de uso especializado e deve ser considerado com cuidado.

Deixo o unsafe_hash por aqui. Se você achar que precisa usar essa opção, leia a documentação de dataclasses.dataclass.

Outras personalizações da classe de dados gerada podem ser feitas no nível dos campos.

5.6.1. Opções de campo

Já vimos a opção de campo mais básica: fornecer (ou não) um valor default junto com a dica de tipo. Os campos de instância declarados se tornarão parâmetros no __init__ gerado. O Python não permite parâmetros sem um default após parâmetros com defaults. Então, após declarar um campo com um valor default, cada um dos campos seguintes deve também ter um default.

Valores default mutáveis são a fonte mais comum de bugs entre desenvolvedores Python iniciantes. Em definições de função, um valor default mutável é facilmente corrompido, quando uma invocação da função modifica o default, mudando o comportamento nas invocações posteriores—um tópico que vamos explorar na Seção 6.5.1 (no Capítulo 6). Atributos de classe são frequentemente usados como valores default de atributos para instâncias, inclusive em classes de dados. E o @dataclass usa os valores default nas dicas de tipo para gerar parâmetros com defaults no __init__. Para prevenir bugs, o @dataclass rejeita a definição de classe que aparece no Exemplo 13.

Exemplo 13. dataclass/club_wrong.py: essa classe gera um ValueError
@dataclass
class ClubMember:
    name: str
    guests: list = []

Se você carregar o módulo com aquela classe ClubMember, o resultado será esse:

$ python3 club_wrong.py
Traceback (most recent call last):
  File "club_wrong.py", line 4, in <module>
    class ClubMember:
  ...several lines omitted...
ValueError: mutable default <class 'list'> for field guests is not allowed:
use default_factory

A mensagem do ValueError explica o problema e sugere uma solução: usar a default_factory. O Exemplo 14 mostra como corrigir a ClubMember.

Exemplo 14. dataclass/club.py: essa definição de ClubMember funciona
from dataclasses import dataclass, field

@dataclass
class ClubMember:
    name: str
    guests: list = field(default_factory=list)

No campo guests do Exemplo 14, em vez de uma lista literal, o valor default é definido chamando a função dataclasses.field com default_factory=list.

O parâmetro default_factory permite que você forneça uma função, classe ou qualquer outro invocável, que será chamado com zero argumentos, para gerar um valor default a cada vez que uma instância da classe de dados for criada. Dessa forma, cada instância de ClubMember terá sua própria list—ao invés de todas as instâncias compartilharem a mesma list da classe, que raramente é o que queremos, e muitas vezes é um bug.

⚠️ Aviso

É bom que @dataclass rejeite definições de classe com uma list default em um campo. Entretanto, entenda que isso é uma solução parcial, que se aplica apenas a list, dict e set. Outros valores mutáveis usados como default não serão apontados por @dataclass. É sua responsabilidade entender o problema e se lembrar de usar uma factory default para definir valores default mutáveis.

Se você estudar a documentação do módulo dataclasses, verá um campo list definido com uma sintaxe nova, como no Exemplo 15.

Exemplo 15. dataclass/club_generic.py: essa definição de ClubMember é mais precisa
from dataclasses import dataclass, field

@dataclass
class ClubMember:
    name: str
    guests: list[str] = field(default_factory=list)  # (1)
  1. list[str] significa "uma lista de str."

A nova sintaxe list[str] é um tipo genérico parametrizado: desde o Python 3.9, o tipo embutido list aceita aquela notação com colchetes para especificar o tipo dos itens da lista.

⚠️ Aviso

Antes do Python 3.9, as coleções embutidas não suportavam a notação de tipagem genérica. Como uma solução temporária, há tipos correspondentes de coleções no módulo typing. Se você precisa de uma dica de tipo para uma list parametrizada no Python 3.8 ou anterior, você tem que importar e usar o tipo List de typing: List[str]. Leia mais sobre isso na caixa Suporte a tipos de coleção descontinuados.

Vamos tratar dos tipos genéricos no Capítulo 8. Por ora, observe que o Exemplo 14 e o Exemplo 15 estão ambos corretos, e que o verificador de tipagem Mypy não reclama de nenhuma das duas definições de classe.

A diferença é que aquele guests: list significa que guests pode ser uma list de objetos de qualquer natureza, enquanto guests: list[str] diz que guests deve ser uma list na qual cada item é uma str. Isso permite que o verificador de tipos encontre (alguns) bugs em código que insira itens inválidos na lista, ou que leia itens dali.

A default_factory é possivelmente a opção mais comum da função field, mas há várias outras, listadas na Tabela 14.

Tabela 14. Argumentos nomeados aceitos pela função field
Option Meaning Default

default

Valor default para o campo

_MISSING_TYPE [63]

default_factory

função com 0 parâmetros usada para produzir um valor default

_MISSING_TYPE

init

Incluir o campo nos parâmetros de __init__

True

repr

Incluir o campo em __repr__

True

compare

Usar o campo nos métodos de comparação __eq__, __lt__, etc.

True

hash

Incluir o campo no cálculo de __hash__

None[64]

metadata

Mapeamento com dados definidos pelo usuário; ignorado por @dataclass

None

A opção default existe porque a chamada a field toma o lugar do valor default na anotação do campo. Se você quisesse criar um campo athlete com o valor default False, e também omitir aquele campo do método __repr__, escreveria o seguinte:

@dataclass
class ClubMember:
    name: str
    guests: list = field(default_factory=list)
    athlete: bool = field(default=False, repr=False)

5.6.2. Processamento pós-inicialização

O método __init__ gerado por @dataclass apenas recebe os argumentos passados e os atribui—ou seus valores default, se o argumento não estiver presente—aos atributos de instância, que são campos da instância. Mas pode ser necessário fazer mais que isso para inicializar a instância. Se for esse o caso, você pode fornecer um método __post_init__. Quando esse método existir, @dataclass acrescentará código ao __init__ gerado para invocar __post_init__ como o último passo da inicialização.

Casos de uso comuns para __post_init__ são validação e o cálculo de valores de campos baseado em outros campos. Vamos estudar um exemplo simples, que usa __post_init__ pelos dois motivos.

Primeiro, dê uma olhada no comportamento esperado de uma subclasse de ClubMember, chamada HackerClubMember, como descrito por doctests no Exemplo 16.

Exemplo 16. dataclass/hackerclub.py: doctests para HackerClubMember
"""
``HackerClubMember`` objects accept an optional ``handle`` argument::

    >>> anna = HackerClubMember('Anna Ravenscroft', handle='AnnaRaven')
    >>> anna
    HackerClubMember(name='Anna Ravenscroft', guests=[], handle='AnnaRaven')

If ``handle`` is omitted, it's set to the first part of the member's name::

    >>> leo = HackerClubMember('Leo Rochael')
    >>> leo
    HackerClubMember(name='Leo Rochael', guests=[], handle='Leo')

Members must have a unique handle. The following ``leo2`` will not be created,
because its ``handle`` would be 'Leo', which was taken by ``leo``::

    >>> leo2 = HackerClubMember('Leo DaVinci')
    Traceback (most recent call last):
      ...
    ValueError: handle 'Leo' already exists.

To fix, ``leo2`` must be created with an explicit ``handle``::

    >>> leo2 = HackerClubMember('Leo DaVinci', handle='Neo')
    >>> leo2
    HackerClubMember(name='Leo DaVinci', guests=[], handle='Neo')
"""

Observe que precisamos fornecer handle como um argumento nomeado, pois HackerClubMember herda name e guests de ClubMember, e acrescenta o campo handle. A docstring gerada para HackerClubMember mostra a ordem dos campos na chamada de inicialização:

>>> HackerClubMember.__doc__
"HackerClubMember(name: str, guests: list = <factory>, handle: str = '')"

Aqui <factory> é um caminho mais curto para dizer que algum invocável vai produzir o valor default para guests (no nosso caso, a fábrica é a classe list). O ponto é o seguinte: para fornecer um handle mas não um guests, precisamos passar handle como um argumento nomeado.

A seção "Herança na documentação do módulo dataclasses explica como a ordem dos campos é analisada quando existem vários níveis de herança.

✒️ Nota

No Capítulo 14 vamos falar sobre o uso indevido da herança, especialmente quando as superclasses não são abstratas. Criar uma hierarquia de classes de dados é, em geral, uma má ideia, mas nos serviu bem aqui para tornar o Exemplo 17 mais curto, e permitir que nos concentrássemos na declaração do campo handle e na validação com __post_init__.

O Exemplo 17 mostra a implementação.

Exemplo 17. dataclass/hackerclub.py: código para HackerClubMember
from dataclasses import dataclass
from club import ClubMember

@dataclass
class HackerClubMember(ClubMember):                         # (1)
    all_handles = set()                                     # (2)
    handle: str = ''                                        # (3)

    def __post_init__(self):
        cls = self.__class__                                # (4)
        if self.handle == '':                               # (5)
            self.handle = self.name.split()[0]
        if self.handle in cls.all_handles:                  # (6)
            msg = f'handle {self.handle!r} already exists.'
            raise ValueError(msg)
        cls.all_handles.add(self.handle)                    # (7)
  1. HackerClubMember estende ClubMember.

  2. all_handles é um atributo de classe.

  3. handle é um campo de instância do tipo str, com uma string vazia como valor default; isso o torna opcional.

  4. Obtém a classe da instância.

  5. Se self.handle é a string vazia, a define como a primeira parte de name.

  6. Se self.handle está em cls.all_handles, gera um ValueError.

  7. Insere o novo handle em cls.all_handles.

O Exemplo 17 funciona como esperado, mas não é satisfatório pra um verificador estático de tipos. A seguir veremos a razão disso, e como resolver o problema.

5.6.3. Atributos de classe tipados

Se verificarmos os tipos de Exemplo 17 com o Mypy, seremos repreendidos:

$ mypy hackerclub.py
hackerclub.py:37: error: Need type annotation for "all_handles"
(hint: "all_handles: Set[<type>] = ...")
Found 1 error in 1 file (checked 1 source file)

Infelizmente, a dica fornecida pelo Mypy (versão 0.910 quando essa seção foi revisada) não é muito útil no contexto do uso de @dataclass. Primeiro, ele sugere usar Set, mas desde o Python 3.9 podemos usar set—sem a necessidade de importar Set de typing. E mais importante, se acrescentarmos uma dica de tipo como set[…] a all_handles, @dataclass vai encontrar essa anotação e transformar all_handles em um campo de instância. Vimos isso acontecer na Seção 5.5.3.2.

A forma de contornar esse problema definida na PEP 526—Syntax for Variable Annotations (Sintaxe para Anotações de Variáveis) (EN) é horrível. Para criar uma variável de classe com uma dica de tipo, precisamos usar um pseudo-tipo chamado typing.ClassVar, que aproveita a notação de tipos genéricos ([]) para definir o tipo da variável e também para declará-la como um atributo de classe.

Para fazer felizes tanto o verificador de tipos quando o @dataclass, deveríamos declarar o all_handles do Exemplo 17 assim:

    all_handles: ClassVar[set[str]] = set()

Aquela dica de tipo está dizendo o seguinte:

all_handles é um atributo de classe do tipo set-de-str, com um set vazio como valor default.

Para escrever aquela anotação precisamos também importar ClassVar do módulo typing.

O decorador @dataclass não se importa com os tipos nas anotações, exceto em dois casos, e este é um deles: se o tipo for ClassVar, um campo de instância não será gerado para aquele atributo.

O outro caso onde o tipo de um campo é relevante para @dataclass é quando declaramos variáveis apenas de inicialização, nosso próximo tópico.

5.6.4. Variáveis de inicialização que não são campos

Algumas vezes pode ser necessário passar para __init__ argumentos que não são campos de instância. Tais argumentos são chamados "argumentos apenas de inicialização" (init-only variables) pela documentação de dataclasses. Para declarar um argumento desses, o módulo dataclasses oferece o pseudo-tipo InitVar, que usa a mesma sintaxe de typing.ClassVar. O exemplo dados na documentação é uma classe de dados com um campo inicializado a partir de um banco de dados, e o objeto banco de dados precisa ser passado para o __init__.

O Exemplo 18 mostra o código que ilustra a seção "Variáveis de inicialização apenas".

Exemplo 18. Exemplo da documentação do módulo dataclasses
@dataclass
class C:
    i: int
    j: int | None = None
    database: InitVar[DatabaseType | None] = None

    def __post_init__(self, database):
        if self.j is None and database is not None:
            self.j = database.lookup('j')

c = C(10, database=my_database)

Veja como o atributo database é declarado. InitVar vai evitar que @dataclass trate database como um campo regular. Ele não será definido como um atributo de instância, e a função dataclasses.fields não vai listá-lo. Entretanto, database será um dos argumentos aceitos pelo __init__ gerado, e também será passado para o __post_init__. Ao escrever aquele método é preciso adicionar o argumento correspondente à sua assinatura, como mostra o Exemplo 18.

Esse longo tratamento de @dataclass cobriu os recursos mais importantes desse decorador—alguns deles apareceram em seções anteriores, como na Seção 5.2.1, onde falamos em paralelo das três fábricas de classes de dados. A documentação de dataclasses e a PEP 526—​Syntax for Variable Annotations (Sintaxe para Anotações de Variáveis) (EN) têm todos os detalhes.

Na próxima seção apresento um exemplo mais completo com o @dataclass.

5.6.5. Exemplo de @dataclass: o registro de recursos do Dublin Core

Frequentemente as classes criadas com o @dataclass vão ter mais campos que os exemplos muito curtos apresentados até aqui. O Dublin Core (EN) oferece a fundação para um exemplo mais típico de @dataclass.

O Dublin Core é um esquema de metadados que visa descrever objetos digitais, tais como, videos, sons, imagens, textos e sites na web. Aplicações de Dublin Core utilizam XML e o RDF (Resource Description Framework).[65]

— Dublin Core na Wikipedia

O padrão define 15 campos opcionais; a classe Resource, no Exemplo 19, usa 8 deles.

Exemplo 19. dataclass/resource.py: código de Resource, uma classe baseada nos termos do Dublin Core
from dataclasses import dataclass, field
from typing import Optional
from enum import Enum, auto
from datetime import date


class ResourceType(Enum):  # (1)
    BOOK = auto()
    EBOOK = auto()
    VIDEO = auto()


@dataclass
class Resource:
    """Media resource description."""
    identifier: str                                    # (2)
    title: str = '<untitled>'                          # (3)
    creators: list[str] = field(default_factory=list)
    date: Optional[date] = None                        # (4)
    type: ResourceType = ResourceType.BOOK             # (5)
    description: str = ''
    language: str = ''
    subjects: list[str] = field(default_factory=list)
  1. Esse Enum vai fornecer valores de um tipo seguro para o campo Resource.type.

  2. identifier é o único campo obrigatório.

  3. title é o primeiro campo com um default. Isso obriga todos os campos abaixo dele a fornecerem defaults.

  4. O valor de date pode ser uma instância de datetime.date ou None.

  5. O default do campo type é ResourceType.BOOK.

O Exemplo 20 mostra um doctest, para demonstrar como um registro Resource aparece no código.

Exemplo 20. dataclass/resource.py: código de Resource, uma classe baseada nos termos do Dublin Core
    >>> description = 'Improving the design of existing code'
    >>> book = Resource('978-0-13-475759-9', 'Refactoring, 2nd Edition',
    ...     ['Martin Fowler', 'Kent Beck'], date(2018, 11, 19),
    ...     ResourceType.BOOK, description, 'EN',
    ...     ['computer programming', 'OOP'])
    >>> book  # doctest: +NORMALIZE_WHITESPACE
    Resource(identifier='978-0-13-475759-9', title='Refactoring, 2nd Edition',
    creators=['Martin Fowler', 'Kent Beck'], date=datetime.date(2018, 11, 19),
    type=<ResourceType.BOOK: 1>, description='Improving the design of existing code',
    language='EN', subjects=['computer programming', 'OOP'])

O __repr__ gerado pelo @dataclass é razoável, mas podemos torná-lo mais legível. Esse é o formato que queremos para repr(book):

    >>> book  # doctest: +NORMALIZE_WHITESPACE
    Resource(
        identifier = '978-0-13-475759-9',
        title = 'Refactoring, 2nd Edition',
        creators = ['Martin Fowler', 'Kent Beck'],
        date = datetime.date(2018, 11, 19),
        type = <ResourceType.BOOK: 1>,
        description = 'Improving the design of existing code',
        language = 'EN',
        subjects = ['computer programming', 'OOP'],
    )

O Exemplo 21 é o código para o __repr__, produzindo o formato que aparece no trecho anterior. Esse exemplo usa dataclass.fields para obter os nomes dos campos da classe de dados.

Exemplo 21. dataclass/resource_repr.py: código para o método __repr__, implementado na classe Resource do Exemplo 19
    def __repr__(self):
        cls = self.__class__
        cls_name = cls.__name__
        indent = ' ' * 4
        res = [f'{cls_name}(']                            # (1)
        for f in fields(cls):                             # (2)
            value = getattr(self, f.name)                 # (3)
            res.append(f'{indent}{f.name} = {value!r},')  # (4)

        res.append(')')                                   # (5)
        return '\n'.join(res)                             # (6)
  1. Dá início à lista res, para criar a string de saída com o nome da classe e o parênteses abrindo.

  2. Para cada campo f na classe…​

  3. …​obtém o atributo nomeado da instância.

  4. Anexa uma linha indentada com o nome do campo e repr(value)—é isso que o !r faz.

  5. Acrescenta um parênteses fechando.

  6. Cria uma string de múltiplas linhas a partir de res, e devolve essa string.

Com esse exemplo, inspirado pelo espírito de Dublin, Ohio, concluímos nosso passeio pelas fábricas de classes de dados do Python.

Classes de dados são úteis, mas podem estar sendo usadas de forma excessiva em seu projeto. A próxima seção explica isso.

5.7. A classe de dados como cheiro no código

Independente de você implementar uma classe de dados escrevendo todo o código ou aproveitando as facilidades oferecidas por alguma das fábricas de classes descritas nesse capítulo, fique alerta: isso pode sinalizar um problema em seu design.

No Refactoring: Improving the Design of Existing Code (Refatorando: Melhorando o Design de Código Existente), 2nd ed. (Addison-Wesley), Martin Fowler e Kent Beck apresentam um catálogo de "cheiros no código"[66]—padrões no código que podem indicar a necessidade de refatoração. O verbete entitulado "Data Class" (Classe de Dados) começa assim:

Essas são classes que tem campos, métodos para obter e definir os campos, e nada mais. Tais classes são recipientes burros de dados, e muitas vezes são manipuladas de forma excessivamente detalhada por outras classes.

No site pessoal de Fowler, há um post muito esclarecedor chamado "Code Smell" (Cheiro no Código) (EN). Esse texto é muito relevante para nossa discussão, pois o autor usa a classe de dados como um exemplo de cheiro no código, e sugere alternativas para lidar com ele. Abaixo está a tradução integral daquele artigo.[67]

Cheiros no Código

De Martin Fowler

Um cheiro no código é um indicação superficial que frequentemente corresponde a um problema mais profundo no sistema. O termo foi inventado por Kent Beck, enquanto ele me ajudava com meu livro, Refactoring.

A rápida definição acima contém um par de detalhes sutis. Primeiro, um cheiro é, por definição, algo rápido de detectar—é "cheiroso", como eu disse recentemente. Um método longo é um bom exemplo disso—basta olhar o código e ver mais de uma dúzia de linhas de Java para meu nariz se contrair.

O segundo detalhe é que cheiros nem sempre indicam um problema. Alguns métodos longos são bons. É preciso ir mais fundo para ver se há um problema subjacente ali. Cheiros não são inerentemente ruins por si só—eles frequentemente são o indicador de um problema, não o problema propriamente dito.

Os melhores cheiros são algo fácil de detectar e que, na maioria das vezes, leva a problemas realmente interessantes. Classes de dados (classes contendo só dados e nenhum comportamento [próprio]) são um bom exemplo. Você olha para elas e se pergunta que comportamento deveria fazer parte daquela classe. Então você começa a refatorar, para incluir ali aquele comportamento. Muitas vezes, algumas perguntas simples e essas refatorações iniciais são um passo vital para transformar um objeto anêmico em alguma coisa que realmente tenha classe.

Uma coisa boa sobre cheiros é sua facilidade de detecção por pessoas inexperientes, mesmo aquelas pessoas que não conhecem o suficiente para avaliar se há mesmo um problema ou , se existir, para corrigi-lo. Soube de um líder de uma equipe de desenvolvimento que elege um "cheiro da semana", e pede às pessoas que procurem aquele cheiro e o apresentem para colegas mais experientes. Fazer isso com um cheiro por vez é uma ótima maneira de ensinar gradualmente os membros da equipe a serem programadores melhores.

A principal ideia da programação orientada a objetos é manter o comportamento e os dados juntos, na mesma unidade de código: uma classe. Se uma classe é largamente utilizada mas não tem qualquer comportamento próprio significativo, é bem provável que o código que interage com as instâncias dessa classe esteja espalhado (ou mesmo duplicado) em métodos e funções ao longo de todo o sistema—uma receita para dores de cabeça na manutenção. Por isso, as refatorações de Fowler para lidar com uma classe de dados envolvem trazer responsabilidades de volta para a classe.

Levando o que foi dito acima em consideração, há alguns cenários comuns onde faz sentido ter um classe de dados com pouco ou nenhum comportamento.

5.7.1. A classe de dados como um esboço

Nesse cenário, a classe de dados é uma implementação simplista inicial de uma classe, para dar início a um novo projeto ou módulo. Com o tempo, a classe deve ganhar seus próprios métodos, deixando de depender de métodos de outras classes para operar sobre suas instâncias. O esboço é temporário; ao final do processo, sua classe pode se tornar totalmente independente da fábrica usada inicialmente para criá-la.

O Python também é muito usado para resolução rápida de problemas e para experimentaçào, e nesses casos é aceitável deixar o esboço pronto para uso.

5.7.2. A classe de dados como representação intermediária

Uma classe de dados pode ser útil para criar registros que serão exportados para o JSON ou algum outro formato de intercomunicação, ou para manter dados que acabaram de ser importados, cruzando alguma fronteira do sistema. Todas as fábricas de classes de dados do Python oferecem um método ou uma função para converter uma instância em um dict simples, e você sempre pode invocar o construtor com um dict, usado para passar argumentos nomeados expandidos com **. Um dict desses é muito similar a um registro JSON.

Nesse cenário, as instâncias da classe de dados devem ser tratadas como objetos imutáveis—mesmo que os campos sejam mutáveis, não deveriam ser modificados nessa forma intermediária. Mudá-los significa perder o principal benefício de manter os dados e o comportamento próximos. Quando o processo de importação/exportação exigir mudança nos valores, você deve implementar seus próprios métodos de fábrica, em vez de usar os métodos "as dict" existentes ou os construtores padrão.

Vamos agora mudar de assunto e aprender como escrever padrões que "casam" com instâncias de classes arbitrárias, não apenas com as sequências e mapeamentos que vimos nas seções Seção 2.6 e Seção 3.3.

5.8. Pattern Matching com instâncias de classes

Padrões de classe são projetados para "casar" com instâncias de classes por tipo e—opcionalmente—por atributos. O sujeito de um padrão de classe pode ser uma instância de qualquer classe, não apenas instâncias de classes de dados.[68]

Há três variantes de padrões de classes: simples, nomeado e posicional. Vamos estudá-las nessa ordem.

5.8.1. Padrões de classe simples

Já vimos um exemplo de padrões de classe simples usados como sub-padrões na Seção 2.6:

        case [str(name), _, _, (float(lat), float(lon))]:

Aquele padrão "casa" com uma sequência de quatro itens, onde o primeiro item deve ser uma instância de str e o último item deve ser um tupla de dois elementos, com duas instâncias de float.

A sintaxe dos padrões de classe se parece com a invocação de um construtor. Abaixo temos um padrão de classe que "casa" com valores float sem vincular uma variável (o corpo do case pode ser referir a x diretamente, se necessário):

    match x:
        case float():
            do_something_with(x)

Mas isso aqui possivelmente será um bug no seu código:

    match x:
        case float:  # DANGER!!!
            do_something_with(x)

No exemplo anterior, case float: "casa" com qualquer sujeito, pois o Python entende float como uma variável, que é então vinculada ao sujeito.

A sintaxe float(x) do padrão simples é um caso especial que se aplica apenas a onze tipos embutidos "abençoados", listados no final da seção "Class Patterns" (Padrões de Classe) (EN) da PEP 634—Structural Pattern Matching: Specification ((Pattern Matching Estrutural: Especificação):

bool   bytearray   bytes   dict   float   frozenset   int   list   set   str   tuple

Nessas classes, a variável que parece um argumento do construtor—por exemplo, o x em float(x)—é vinculada a toda a instância do sujeito ou à parte do sujeito que "casa" com um sub-padrão, como exemplificado por str(name) no padrão de sequência que vimos antes:

        case [str(name), _, _, (float(lat), float(lon))]:

Se a classe não de um daqueles onze tipos embutidos "abençoados", então essas variáveis parecidas com argumentos representam padrões a serem testados com atributos de uma instância daquela classe.

5.8.2. Padrões de classe nomeados

Para entender como usar padrões de classe nomeados, observe a classe City e suas cinco instâncias no Exemplo 22, abaixo.

Exemplo 22. A classe City e algumas instâncias
import typing

class City(typing.NamedTuple):
    continent: str
    name: str
    country: str


cities = [
    City('Asia', 'Tokyo', 'JP'),
    City('Asia', 'Delhi', 'IN'),
    City('North America', 'Mexico City', 'MX'),
    City('North America', 'New York', 'US'),
    City('South America', 'São Paulo', 'BR'),
]

Dadas essas definições, a seguinte função devolve uma lista de cidades asiáticas:

def match_asian_cities():
    results = []
    for city in cities:
        match city:
            case City(continent='Asia'):
                results.append(city)
    return results

O padrão City(continent='Asia') encontra qualquer instância de City onde o atributo continent seja igual a 'Asia', independente do valor dos outros atributos.

Para coletar o valor do atributo country, você poderia escrever:

def match_asian_countries():
    results = []
    for city in cities:
        match city:
            case City(continent='Asia', country=cc):
                results.append(cc)
    return results

O padrão City(continent='Asia', country=cc) encontra as mesmas cidades asiáticas, como antes, mas agora a variável cc está vinculada ao atributo country da instância. Isso inclusive funciona se a variável do padrão também se chamar country:

        match city:
            case City(continent='Asia', country=country):
                results.append(country)

Padrões de classe nomeados são bastante legíveis, e funcionam com qualquer classe que possua atributos de instância públicos. Mas eles são um tanto prolixos.

Padrões de classe posicionais são mais convenientes em alguns casos, mas exigem suporte explícito da classe do sujeito, como veremos a seguir.

5.8.3. Padrões de classe posicionais

Dadas as definições do Exemplo 22, a seguinte função devolveria uma lista de cidades asiáticas, usando um padrão de classe posicional:

def match_asian_cities_pos():
    results = []
    for city in cities:
        match city:
            case City('Asia'):
                results.append(city)
    return results

O padrão City('Asia') encontra qualquer instância de City na qual o valor do primeiro atributo seja Asia, independente do valor dos outros atributos.

Se você quiser obter o valor do atributo country, poderia escrever:

def match_asian_countries_pos():
    results = []
    for city in cities:
        match city:
            case City('Asia', _, country):
                results.append(country)
    return results

O padrão City('Asia', _, country) encontra as mesmas cidades de antes, mas agora variável country está vinculada ao terceiro atributo da instância.

Eu falei do "primeiro" ou do "terceiro" atributos, mas o quê isso realmente significa?

City (ou qualquer classe) funciona com padrões posicionais graças a um atributo de classe especial chamado __match_args__, que as fábricas de classe vistas nesse capítulo criam automaticamente. Esse é o valor de __match_args__ na classe City:

>>> City.__match_args__
('continent', 'name', 'country')

Como se vê, __match_args__ declara os nomes dos atributos na ordem em que eles serão usados em padrões posicionais.

Na Seção 11.8 vamos escrever código para definir __match_args__ em uma classe que criaremos sem a ajuda de uma fábrica de classes.

👉 Dica

Você pode combinar argumentos nomeados e posicionais em um padrão. Alguns, mas não todos, os atributos de instância disponíveis para o match podem estar listados no __match_args__. Dessa forma, algumas vezes pode ser necessário usar argumentos nomeados em um padrão, além dos argumentos posicionais.

Hora de um resumo de capítulo.

5.9. Resumo do Capítulo

O tópico principal desse capítulo foram as fábricas de classes de dados collections.namedtuple, typing.NamedTuple, e dataclasses.dataclass. Vimos como cada uma delas gera classes de dados a partir de descrições, fornecidas como argumentos a uma função fábrica ou, no caso das duas últimas, a partir de uma declaração class com dicas de tipo. Especificamente, ambas as variantes de tupla produzem subclasses de tuple, acrescentando apenas a capacidade de acessar os campos por nome, e criando também um atributo de classe _fields, que lista os nomes dos campos na forma de uma tupla de strings.

A seguir colocamos lado a lado os principais recursos de cada uma das três fábricas de classes, incluindo como extrair dados da instância como um dict, como obter os nomes e valores default dos campos, e como criar uma nova instância a partir de uma instância existente.

Isso levou ao nosso primeiro contato com dicas de tipo, especialmente aquelas usadas para anotar atributos em uma declaração class, usando a notação introduzida no Python 3.6 com a PEP 526—Syntax for Variable Annotations (Sintaxe para Anotações de Variáveis) (EN). O aspecto provavelmente mais surpreeendente das dicas de tipo em geral é o fato delas não terem qualquer efeito durante a execução. O Python continua sendo uma linguagem dinâmica. Ferramentas externas, como o Mypy, são necessárias para aproveitar a informação de tipagem na detecção de erros via análise estática do código-fonte. Após um resumo básico da sintaxe da PEP 526, estudamos os efeitos das anotações em uma classe simples e em classes criadas por typing.NamedTuple e por @dataclass.

A seguir falamos sobre os recursos mais usados dentre os oferecidos por @dataclass, e sobre a opção default_factory da função dataclasses.field. Também demos uma olhada nas dicas de pseudo-tipo especiais typing.ClassVar e dataclasses.InitVar, importantes no contexto das classes de dados. Esse tópico central foi concluído com um exemplo baseado no schema Dublin Core, ilustrando como usar dataclasses.fields para iterar sobre os atributos de uma instância de Resource em um __repr__ personalizado.

Então alertamos contra os possíveis usos abusivos das classes de dados, frustrando um princípio básico da programação orientada a objetos: os dados e as funções que acessam os dados devem estar juntos na mesma classe. Classes sem uma lógica podem ser um sinal de uma lógica fora de lugar.

Na última seção, vimos como o pattern matching funciona com instâncias de qualquer classe como sujeitos—e não apenas das classes criadas com as fábricas apresentadas nesse capítulo.

5.10. Leitura complementar

A documentação padrão do Python para as fábricas de classes de dados vistas aqui é muito boa, e inclui muitos pequenos exemplos.

Em especial para @dataclass, a maior parte da PEP 557—Data Classes (Classes de Dados) (EN) foi copiada para a documentação do módulo dataclasses . Entretanto, algumas seções informativas da PEP 557 não foram copiadas, incluindo "Why not just use namedtuple?" (Por que simplesmente não usar namedtuple?), "Why not just use typing.NamedTuple?" (Por que simplesmente não usar typing.NamedTuple?), e a seção "Rationale" (Justificativa), que termina com a seguinte Q&A:

Quando não é apropriado usar Classes de Dados?

Quando for exigida compatibilidade da API com tuplas de dicts. Quando for exigida validação de tipo além daquela oferecida pelas PEPs 484 e 526 , ou quando for exigida validação ou conversão de valores.

— Eric V. Smith
PEP 557 "Justificativa"

Para mais recursos e funcionalidade avançada, incluindo validação, o projeto attrs (EN), liderado por Hynek Schlawack, surgiu anos antes de dataclasses e oferece mais facilidades, com a promessa de "trazer de volta a alegria de criar classes, liberando você do tedioso trabalho de implementar protocolos de objeto (também conhecidos como métodos dunder)".

A influência do attrs sobre o @dataclass é reconhecida por Eric V. Smith na PEP 557. Isso provavelmente inclui a mais importante decisão de Smith sobre a API: o uso de um decorador de classe em vez de uma classe base ou de uma metaclasse para realizar a tarefa.

Glyph—fundador do projeto Twisted—escreveu uma excelente introdução à attrs em "The One Python Library Everyone Needs" (A Biblioteca Python que Todo Mundo Precisa Ter) (EN). A documentação da attrs inclui uma discussão aobre alternativas.

O autor de livros, instrutor e cientista maluco da computação Dave Beazley escreveu o cluegen, um outro gerador de classes de dados. Se você já assistiu alguma palestra do David, sabe que ele é um mestre na metaprogramação Python a partir de princípios básicos. Então achei inspirador descobrir, no arquivo README.md do cluegen, o caso de uso concreto que o motivou a criar uma alternativa ao @dataclass do Python, e sua filosofia de apresentar uma abordagem para resolver o problema, ao invés de fornecer uma ferramenta: a ferramenta pode inicialmente ser mais rápida de usar , mas a abordagem é mais flexível e pode ir tão longe quanto você deseje.

Sobre a classe de dados como um cheiro no código, a melhor fonte que encontrei foi livro de Martin Fowler, Refactoring ("Refatorando"), 2ª ed. A versão mais recente não traz a citação da epígrafe deste capitulo, "Classes de dados são como crianças…​", mas apesar disso é a melhor edição do livro mais famoso de Fowler, em especial para pythonistas, pois os exemplos são em JavaScript moderno, que é mais próximo do Python que do Java—a linguagem usada na primeira edição.

Ponto de vista

O verbete para "Guido" no "The Jargon File" (EN) é sobre Guido van Rossum. Entre outras coisa, ele diz:

Diz a lenda que o atributo mais importante de Guido, além do próprio Python, é a máquina do tempo de Guido, um aparelho que se diz que ele possui por causa da frequência irritante com que pedidos de usuários por novos recursos recebem como resposta "Eu implementei isso noite passada mesmo…​"

Por um longo tempo, uma das peças ausentes da sintaxe do Python foi uma forma rápida e padronizada de declarar atributos de instância em uma classe. Muitas linguagens orientadas a objetos incluem esse recurso. Aqui está parte da definição da classe Point em Smalltalk:

Object subclass: #Point
    instanceVariableNames: 'x y'
    classVariableNames: ''
    package: 'Kernel-BasicObjects'

A segunda linha lista os nomes dos atributos de instância x e y. Se existissem atributos de classe, eles estariam na terceira linha.

O Python sempre teve uma forma fácil de declarar um atributo de classe, se ele tiver um valor inicial. Mas atributos de instância são muito mais comuns, e os programadores Python tem sido obrigados a olhar dentro do método __init__ para encontrá-los, sempre temerosos que podem existir atributos de instância sendo criados em outro lugar na classe—ou mesmo por funções e métodos de outras classes.

Agora temos o @dataclass, viva!

Mas ele traz seus próprios problemas

Primeiro, quando você usa @dataclass, dicas de tipo não são opcionais. Pelos últimos sete anos, desde a PEP 484—Type Hints (Dicas de Tipo) (EN), nos prometeram que elas sempre seriam opcionais. Agora temos um novo recurso importante na linguagem que exige dicas de tipo. Se você não gosta de toda essa tendência de tipagem estática, pode querer usar a attrs no lugar do @dataclass.

Em segundo lugar, a sintaxe da PEP 526 (EN) para anotar atributos de instância e de classe inverte a convenção consagrada para declarações de classe: tudo que era declarado no nível superior de um bloco class era um atributo de classe (métodos também são atributos de classe). Com a PEP 526 e o @dataclass, qualquer atributo declarado no nível superior com uma dica de tipo se torna um atributo de instância:

    @dataclass
    class Spam:
        repeat: int  # instance attribute

Aqui, repeat também é um atributo de instância:

    @dataclass
    class Spam:
        repeat: int = 99  # instance attribute

Mas se não houver dicas de tipo, subitamente estamos de volta os bons velhos tempos quando declarações no nível superior da classe pertencem apenas à classe:

    @dataclass
    class Spam:
        repeat = 99  # class attribute!

Por fim, se você desejar anotar aquele atributo de classe com um tipo, não pode usar tipos regulares, porque então ele se tornará um atributo de instância. Você tem que recorrer a aquela anotação usando o pseudo-tipo ClassVar:

    @dataclass
    class Spam:
        repeat: ClassVar[int] = 99  # aargh!

Aqui estamos falando sobre uma exceçao da exceção da regra. Me parece algo muito pouco pythônico.

Não tomei parte nas discussões que levaram à PEP 526 ou à PEP 557—Data Classes (Classes de Dados), mas aqui está uma sintaxe alternativa que eu gostaria de ver:

@dataclass
class HackerClubMember:
    .name: str                                   # (1)
    .guests: list = field(default_factory=list)
    .handle: str = ''

    all_handles = set()                          # (2)
  1. Atributos de instância devem ser declarados com um prefixo ..

  2. Qualquer nome de atributo que não tenha um prefixo . é um atributo de classe (como sempre foram).

A gramática da linguagem teria que mudar para acomodar isso. Mas acho essa forma muito legível, e ela evita o problema da exceção-da-exceção.

Queria poder pegar a máquina do tempo de Gudo emprestada e voltar a 2017, para convencer os desenvolvedores principais a aceitarem essa ideia.

6. Referências, mutabilidade, e memória

“Você está triste,” disse o Cavaleiro em um tom de voz ansioso: “deixe eu cantar para você uma canção reconfortante. […] O nome da canção se chama ‘OLHOS DE HADOQUE’.”

“Oh, esse é o nome da canção?,” disse Alice, tentando parecer interessada.

“Não, você não entendeu,” retorquiu o Cavaleiro, um pouco irritado. “É assim que o nome É CHAMADO. O nome na verdade é ‘O ENVELHECIDO HOMEM VELHO.‘”

— Adaptado de “Alice Através do Espelho e o que Ela Encontrou Lá”
de Lewis Caroll

Alice e o Cavaleiro dão o tom do que veremos nesse capítulo. O tema é a distinção entre objetos e seus nomes; um nome não é o objeto; o nome é algo diferente.

Começamos o capítulo apresentando uma metáfora para variáveis em Python: variáveis são rótulos, não caixas. Se variáveis de referência não são novidade para você, a analogia pode ainda ser útil para ilustrar questões de aliasing (“apelidamento”) para alguém.

Depois discutimos os conceitos de identidade, valor e apelidamento de objetos. Uma característica surpreendente das tuplas é revelada: elas são imutáveis, mas seus valores podem mudar. Isso leva a uma discussão sobre cópias rasas e profundas. Referências e parâmetros de funções são o tema seguinte: o problema com parâmetros mutáveis por default e formas seguras de lidar com argumentos mutáveis passados para nossas funções por clientes.

As últimas seções do capítulo tratam de coleta de lixo (“garbage collection”), o comando del e de algumas peças que o Python prega com objetos imutáveis.

É um capítulo bastante árido, mas os tópicos tratados podem causar muitos bugs sutis em programas reais em Python.

6.1. Novidades nesse capítulo

Os tópicos tratados aqui são muito estáveis e fundamentais. Não foi introduzida nenhuma mudança digna de nota nesta segunda edição.

Acrescentei um exemplo usando is para testar a existência de um objeto sentinela, e um aviso sobre o mau uso do operador is no final de Seção 6.3.1.

Este capítulo estava na Parte IV, mas decidi abordar esses temas mais cedo, pois eles funcionam melhor como o encerramento da Parte II, “Estruturas de Dados”, que como abertura de “Práticas de Orientação a Objetos"

✒️ Nota

A seção sobre “Referências Fracas” da primeira edição deste livro agora é um post em fluentpython.com.

Vamos começar desaprendendo que uma variável é como uma caixa onde você guarda dados.

6.2. Variáveis não são caixas

Em 1997, fiz um curso de verão sobre Java no MIT. A professora, Lynn Stein [69] , apontou que a metáfora comum, de “variáveis como caixas”, na verdade, atrapalha o entendimento de variáveis de referência em linguagens orientadas a objetos. As variáveis em Python são como variáveis de referência em Java; uma metáfora melhor é pensar em uma variável como um rótulo (ou etiqueta) que associa um nome a um objeto. O exemplo e a figura a seguir ajudam a entender o motivo disso.

Exemplo 1 é uma interação simples que não pode ser explicada por “variáveis como caixas”. A Figura 1 ilustra o motivo de metáfora da caixa estar errada em Python, enquanto etiquetas apresentam uma imagem mais útil para entender como variáveis funcionam.

Exemplo 1. As variáveis a e b mantém referências para a mesma lista, não cópias da lista.
>>> a = [1, 2, 3]  (1)
>>> b = a          (2)
>>> a.append(4)    (3)
>>> b              (4)
[1, 2, 3, 4]
  1. Cria uma lista [1, 2, 3] e a vincula à variável a.

  2. Vincula a variável b ao mesmo valor referenciado por a.

  3. Modifica a lista referenciada por a, anexando um novo item.

  4. É possível ver o efeito através da variável b. Se você pensar em b como uma caixa que guardava uma cópia de [1, 2, 3] da caixa a, este comportamento não faz sentido.

Boxes and labels diagram
Figura 1. Se você imaginar variáveis como caixas, não é possível entender a atribuição em Python; por outro lado, pense em variáveis como etiquetas autocolantes e Exemplo 1 é facilmente explicável.

Assim, a instrução b = a não copia o conteúdo de uma caixa a para uma caixa b. Ela cola uma nova etiqueta b no objeto que já tem a etiqueta a.

A professora Stein também falava sobre atribuição de uma maneira bastante específica. Por exemplo, quando discutia sobre um objeto representando uma gangorra em uma simulação, ela dizia: “A variável g foi atribuída à gangorra”, mas nunca “A gangorra foi atribuída à variável g”. Com variáveis de referência, faz muito mais sentido dizer que a variável é atribuída a um objeto, não o contrário. Afinal, o objeto é criado antes da atribuição. Exemplo 2 prova que o lado direito de uma atribuição é processado primeiro.

Já que o verbo “atribuir” é usado de diferentes maneiras, uma alternativa útil é “vincular”: a declaração de atribuição em Python x = … vincula o nome x ao objeto criado ou referenciado no lado direito. E o objeto precisa existir antes que um nome possa ser vinculado a ele, como demonstra Exemplo 2.

Exemplo 2. Variáveis são vinculadas a objetos somente após os objetos serem criados
>>> class Gizmo:
...    def __init__(self):
...         print(f'Gizmo id: {id(self)}')
...
>>> x = Gizmo()
Gizmo id: 4301489152  (1)
>>> y = Gizmo() * 10  (2)
Gizmo id: 4301489432  (3)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: unsupported operand type(s) for *: 'Gizmo' and 'int'
>>>
>>> dir()  (4)
['Gizmo', '__builtins__', '__doc__', '__loader__', '__name__',
'__package__', '__spec__', 'x']
  1. A saída Gizmo id: … é um efeito colateral da criação de uma instância de Gizmo.

  2. Multiplicar uma instância de Gizmo levanta uma exceção.

  3. Aqui está a prova que um segundo Gizmo foi de fato instanciado antes que a multiplicação fosse tentada.

  4. Mas a variável y nunca foi criada, porque a exceção aconteceu enquanto a parte direita da atribuição estava sendo executada.

👉 Dica

Para entender uma atribuição em Python, leia primeiro o lado direito: é ali que o objeto é criado ou recuperado. Depois disso, a variável do lado esquerdo é vinculada ao objeto, como uma etiqueta colada a ele. Esqueça as caixas.

Como variáveis são apenas meras etiquetas, nada impede que um objeto tenha várias etiquetas vinculadas a si. Quando isso acontece, você tem apelidos (aliases), nosso próximo tópico.

6.3. Identidade, igualdade e apelidos

Lewis Carroll é o pseudônimo literário do Prof. Charles Lutwidge Dodgson. O Sr. Carroll não é apenas igual ao Prof. Dodgson, eles são exatamente a mesma pessoa. Exemplo 3 expressa essa ideia em Python.

Exemplo 3. charles e lewis se referem ao mesmo objeto
>>> charles = {'name': 'Charles L. Dodgson', 'born': 1832}
>>> lewis = charles  (1)
>>> lewis is charles
True
>>> id(charles), id(lewis)  (2)
(4300473992, 4300473992)
>>> lewis['balance'] = 950  (3)
>>> charles
{'name': 'Charles L. Dodgson', 'born': 1832, 'balance': 950}
  1. lewis é um apelido para charles.

  2. O operador is e a função id confirmam essa afirmação.

  3. Adicionar um item a lewis é o mesmo que adicionar um item a charles.

Entretanto, suponha que um impostor—vamos chamá-lo de Dr. Alexander Pedachenko—diga que é o verdadeiro Charles L. Dodgson, nascido em 1832. Suas credenciais podem ser as mesmas, mas o Dr. Pedachenko não é o Prof. Dodgson. Figura 2 ilustra esse cenário.

Alias x copy diagram
Figura 2. charles e lewis estão vinculados ao mesmo objeto; alex está vinculado a um objeto diferente de valor igual.

Exemplo 4 implementa e testa o objeto alex como apresentado em Figura 2.

Exemplo 4. alex e charles são iguais quando comparados, mas alex não é charles
>>> alex = {'name': 'Charles L. Dodgson', 'born': 1832, 'balance': 950}  (1)
>>> alex == charles  (2)
True
>>> alex is not charles  (3)
True
  1. alex é uma referência a um objeto que é uma réplica do objeto vinculado a charles.

  2. Os objetos são iguais quando comparados devido à implementação de __eq__ na classe dict.

  3. Mas são objetos distintos. Essa é a forma pythônica de escrever a negação de uma comparação de identidade: a is not b.

Exemplo 3 é um exemplo de apelidamento (aliasing). Naquele código, lewis e charles são apelidos: duas variáveis vinculadas ao mesmo objeto. Por outro lado, alex não é um apelido para charles: essas variáveis estão vinculadas a objetos diferentes. Os objetos vinculados a alex e charles tem o mesmo valor  — é isso que == compara — mas tem identidades diferentes.

Na The Python Language Reference (Referência da Linguagem Python), https://docs.python.org/pt-br/3/reference/datamodel.html#objects-values-and-types está escrito:

A identidade de um objeto nunca muda após ele ter sido criado; você pode pensar nela como o endereço do objeto na memória. O operador is compara a identidade de dois objetos; a função id() retorna um inteiro representando essa identidade.

O verdadeiro significado do ID de um objeto depende da implementação da linguagem. Em CPython, id() retorna o endereço de memória do objeto, mas outro interpretador Python pode retornar algo diferente. O ponto fundamental é que o ID será sempre um valor numérico único, e ele jamais mudará durante a vida do objeto.

Na prática, nós raramente usamos a função id() quando programamos. A verificação de identidade é feita, na maior parte das vezes, com o operador is, que compara os IDs dos objetos, então nosso código não precisa chamar id() explicitamente. A seguir vamos falar sobre is versus ==.

👉 Dica

Para o revisor técnico Leonardo Rochael, o uso mais frequente de id() ocorre durante o processo de debugging, quando o repr() de dois objetos são semelhantes, mas você precisa saber se duas referências são apelidos ou apontam para objetos diferentes. Se as referências estão em contextos diferentes — por exemplo, em stack frames diferentes — pode não ser viável usar is.

6.3.1. Escolhendo Entre == e is

O operador == compara os valores de objetos (os dados que eles contêm), enquanto is compara suas identidades.

Quando estamos programando, em geral, nos preocupamos mais com os valores que com as identidades dos objetos, então == aparece com mais frequência que is em programas Python.

Entretanto, se você estiver comparando uma variável com um singleton (um objeto único) faz mais sentido usar is. O caso mais comum, de longe, é verificar se a variável está vinculada a None. Esta é a forma recomendada de fazer isso:

x is None

E a forma apropriada de escrever sua negação é:

x is not None

None é o singleton mais comum que testamos com is. Objetos sentinela são outro exemplo de singletons que testamos com is. Veja um modo de criar e testar um objeto sentinela:

END_OF_DATA = object()
# ... many lines
def traverse(...):
    # ... more lines
    if node is END_OF_DATA:
        return
    # etc.

O operador is é mais rápido que ==, pois não pode ser sobrecarregado. Daí o Python não precisa encontrar e invocar métodos especiais para calcular seu resultado, e o processamento é tão simples quanto comparar dois IDs inteiros. Por outro lado, a == b é açúcar sintático para a.__eq__(b). O método __eq__, herdado de object, compara os IDs dos objetos, então produz o mesmo resultado de is. Mas a maioria dos tipos embutidos sobrepõe __eq__ com implementações mais úteis, que levam em consideração os valores dos atributos dos objetos. A determinação da igualdade pode envolver muito processamento—​por exemplo, quando se comparam coleções grandes ou estruturas aninhadas com muitos níveis.

⚠️ Aviso

Normalmente estamos mais interessados na igualdade que na identidade de objetos. Checar se o objeto é None é o único caso de uso comum do operador is. A maioria dos outros usos que eu vejo quando reviso código estão errados. Se você não estiver seguro, use ==. Em geral, é o que você quer, e ele também funciona com None, ainda que não tão rápido.

Para concluir essa discussão de identidade versus igualdade, vamos ver como o tipo notoriamente imutável tuple não é assim tão invariável quanto você poderia supor.

6.3.2. A imutabilidade relativa das tuplas

As tuplas, como a maioria das coleções em Python — lists, dicts, sets, etc..— são contêiners: eles armazenam referências para objetos.[70]

Se os itens referenciados forem mutáveis, eles poderão mudar, mesmo que tupla em si não mude. Em outras palavras, a imutabilidade das tuplas, na verdade, se refere ao conteúdo físico da estrutura de dados tupla (isto é, as referências que ela mantém), e não se estende aos objetos referenciados.

Exemplo 5 ilustra uma situação em que o valor de uma tupla muda como resultado de mudanças em um objeto mutável ali referenciado. O que não pode nunca mudar em uma tupla é a identidade dos itens que ela contém.

Exemplo 5. t1 e t2 inicialmente são iguais, mas a mudança em um item mutável dentro da tupla t1 as torna diferentes
>>> t1 = (1, 2, [30, 40])  (1)
>>> t2 = (1, 2, [30, 40])  (2)
>>> t1 == t2  (3)
True
>>> id(t1[-1])  (4)
4302515784
>>> t1[-1].append(99)  (5)
>>> t1
(1, 2, [30, 40, 99])
>>> id(t1[-1])  (6)
4302515784
>>> t1 == t2  (7)
False
  1. t1 é imutável, mas t1[-1] é mutável.

  2. Cria a tupla t2, cujos itens são iguais àqueles de t1.

  3. Apesar de serem objetos distintos, quando comparados t1 e t2 são iguais, como esperado.

  4. Obtém o ID da lista na posição t1[-1].

  5. Modifica diretamente a lista t1[-1].

  6. O ID de t1[-1] não mudou, apenas seu valor.

  7. t1 e t2 agora são diferentes

Essa imutabilidade relativa das tuplas está por trás do enigma Seção 2.8.3. Essa também é razão pela qual não é possível gerar o hash de algumas tuplas, como vimos em Seção 3.4.1.

A distinção entre igualdade e identidade tem outras implicações quando você precisa copiar um objeto. Uma cópia é um objeto igual com um ID diferente. Mas se um objeto contém outros objetos, é preciso que a cópia duplique os objetos internos ou eles podem ser compartilhados? Não há uma resposta única. A seguir discutimos esse ponto.

6.4. A princípio, cópias são rasas

A forma mais fácil de copiar uma lista (ou a maioria das coleções mutáveis nativas) é usando o construtor padrão do próprio tipo. Por exemplo:

>>> l1 = [3, [55, 44], (7, 8, 9)]
>>> l2 = list(l1)  (1)
>>> l2
[3, [55, 44], (7, 8, 9)]
>>> l2 == l1  (2)
True
>>> l2 is l1  (3)
False
  1. list(l1) cria uma cópia de l1.

  2. As cópias são iguais…​

  3. …​mas se referem a dois objetos diferentes.

Para listas e outras sequências mutáveis, o atalho l2 = l1[:] também cria uma cópia.

Contudo, tanto o construtor quanto [:] produzem uma cópia rasa (shallow copy). Isto é, o contêiner externo é duplicado, mas a cópia é preenchida com referências para os mesmos itens contidos no contêiner original. Isso economiza memória e não causa qualquer problema se todos os itens forem imutáveis. Mas se existirem itens mutáveis, isso pode gerar surpresas desagradáveis.

Em Exemplo 6 criamos uma lista contendo outra lista e uma tupla, e então fazemos algumas mudanças para ver como isso afeta os objetos referenciados.

👉 Dica

Se você tem um computador conectado à internet disponível, recomendo fortemente que você assista à animação interativa do Exemplo 6 em Online Python Tutor. No momento em que escrevo, o link direto para um exemplo pronto no pythontutor.com não estava funcionando de forma estável. Mas a ferramenta é ótima, então vale a pena gastar seu tempo copiando e colando o código.

Exemplo 6. Criando uma cópia rasa de uma lista contendo outra lista; copie e cole esse código para vê-lo animado no Online Python Tutor
l1 = [3, [66, 55, 44], (7, 8, 9)]
l2 = list(l1)      # (1)
l1.append(100)     # (2)
l1[1].remove(55)   # (3)
print('l1:', l1)
print('l2:', l2)
l2[1] += [33, 22]  # (4)
l2[2] += (10, 11)  # (5)
print('l1:', l1)
print('l2:', l2)
  1. l2 é uma cópia rasa de l1. Este estado está representado em Figura 3.

  2. Concatenar 100 a l1 não tem qualquer efeito sobre l2.

  3. Aqui removemos 55 da lista interna l1[1]. Isso afeta l2, pois l2[1] está associado à mesma lista em l1[1].

  4. Para um objeto mutável como a lista referida por l2[1], o operador += altera a lista diretamente. Essa mudança é visível em l1[1], que é um apelido para l2[1].

  5. += em uma tupla cria uma nova tupla e reassocia a variável l2[2] a ela. Isso é equivalente a fazer l2[2] = l2[2] + (10, 11). Agora as tuplas na última posição de l1 e l2 não são mais o mesmo objeto. Veja Figura 4.

References diagram
Figura 3. Estado do programa imediatamente após a atribuição l2 = list(l1) em Exemplo 6. l1 e l2 se referem a listas diferentes, mas as listas compartilham referências para um mesmo objeto interno, a lista [66, 55, 44] e para a tupla (7, 8, 9). (Diagrama gerado pelo Online Python Tutor)

A saída de Exemplo 6 é Exemplo 7, e o estado final dos objetos está representado em Figura 4.

Exemplo 7. Saída de Exemplo 6
l1: [3, [66, 44], (7, 8, 9), 100]
l2: [3, [66, 44], (7, 8, 9)]
l1: [3, [66, 44, 33, 22], (7, 8, 9), 100]
l2: [3, [66, 44, 33, 22], (7, 8, 9, 10, 11)]
References diagram
Figura 4. Estado final de l1 e l2: elas ainda compartilham referências para o mesmo objeto lista, que agora contém [66, 44, 33, 22], mas a operação l2[2] += (10, 11) criou uma nova tupla com conteúdo (7, 8, 9, 10, 11), sem relação com a tupla (7, 8, 9) referenciada por l1[2]. (Diagram generated by the Online Python Tutor.)

Já deve estar claro que cópias rasas são fáceis de criar, mas podem ou não ser o que você quer. Nosso próximo tópico é a criação de cópias profundas.

6.4.1. Cópias profundas e cópias rasas

Trabalhar com cópias rasas nem sempre é um problema, mas algumas vezes você vai precisar criar cópias profundas (isto é, cópias que não compartilham referências de objetos internos). O módulo copy oferece as funções deepcopy e copy, que retornam cópias profundas e rasas de objetos arbitrários.

Para ilustrar o uso de copy() e deepcopy(), Exemplo 8 define uma classe simples, Bus, representando um ônibus escolar que é carregado com passageiros, e então pega ou deixa passageiros ao longo de sua rota.

Exemplo 8. Bus pega ou deixa passageiros
class Bus:

    def __init__(self, passengers=None):
        if passengers is None:
            self.passengers = []
        else:
            self.passengers = list(passengers)

    def pick(self, name):
        self.passengers.append(name)

    def drop(self, name):
        self.passengers.remove(name)

Agora, no Exemplo 9 interativo, vamos criar um objeto bus (bus1) e dois clones—uma cópia rasa (bus2) e uma cópia profunda (bus3)—para ver o que acontece quando bus1 deixa um passageiro.

Exemplo 9. Os efeitos do uso de copy versus deepcopy
>>> import copy
>>> bus1 = Bus(['Alice', 'Bill', 'Claire', 'David'])
>>> bus2 = copy.copy(bus1)
>>> bus3 = copy.deepcopy(bus1)
>>> id(bus1), id(bus2), id(bus3)
(4301498296, 4301499416, 4301499752)  (1)
>>> bus1.drop('Bill')
>>> bus2.passengers
['Alice', 'Claire', 'David']          (2)
>>> id(bus1.passengers), id(bus2.passengers), id(bus3.passengers)
(4302658568, 4302658568, 4302657800)  (3)
>>> bus3.passengers
['Alice', 'Bill', 'Claire', 'David']  (4)
  1. Usando copy e deepcopy, criamos três instâncias distintas de Bus.

  2. Após bus1 deixar 'Bill', ele também desaparece de bus2.

  3. A inspeção do atributo dos passengers mostra que bus1 e bus2 compartilham o mesmo objeto lista, pois bus2 é uma cópia rasa de bus1.

  4. bus3 é uma cópia profunda de bus1, então seu atributo passengers se refere a outra lista.

Observe que, em geral, criar cópias profundas não é uma questão simples. Objetos podem conter referências cíclicas que fariam um algoritmo ingênuo entrar em um loop infinito. A função 'deepcopy' lembra dos objetos já copiados, de forma a tratar referências cíclicas de modo elegante. Isso é demonstrado em Exemplo 10.

Exemplo 10. Referências cíclicas: b tem uma referência para a e então é concatenado a a; ainda assim, deepcopy consegue copiar a.
>>> a = [10, 20]
>>> b = [a, 30]
>>> a.append(b)
>>> a
[10, 20, [[...], 30]]
>>> from copy import deepcopy
>>> c = deepcopy(a)
>>> c
[10, 20, [[...], 30]]

Além disso, algumas vezes uma cópia profunda pode ser profunda demais. Por exemplo, objetos podem ter referências para recursos externos ou para singletons (objetos únicos) que não devem ser copiados. Você pode controlar o comportamento de copy e de deepcopy implementando os métodos especiais __copy__ e __deepcopy__, como descrito em https://docs.python.org/pt-br/3/library/copy.html [documentação do módulo copy]

O compartilhamento de objetos através de apelidos também explica como a passagens de parâmetros funciona em Python, e o problema do uso de tipos mutáveis como parâmetros default. Vamos falar sobre essas questões a seguir.

6.5. Parâmetros de função como referências

O único modo de passagem de parâmetros em Python é a chamada por compartilhamento (call by sharing). É o mesmo modo usado na maioria das linguagens orientadas a objetos, incluindo Javascript, Ruby e Java (em Java isso se aplica aos tipos de referência; tipos primitivos usam a chamada por valor). Chamada por compartilhamento significa que cada parâmetro formal da função recebe uma cópia de cada referência nos argumentos. Em outras palavras, os parâmetros dentro da função se tornam apelidos dos argumentos.

O resultado desse esquema é que a função pode modificar qualquer objeto mutável passado a ela como parâmetro, mas não pode mudar a identidade daqueles objetos (isto é, ela não pode substituir integralmente um objeto por outro). Exemplo 11 mostra uma função simples usando += com um de seus parâmetros. Quando passamos números, listas e tuplas para a função, os argumentos originais são afetados de maneiras diferentes.

Exemplo 11. Uma função pode mudar qualquer objeto mutável que receba
>>> def f(a, b):
...     a += b
...     return a
...
>>> x = 1
>>> y = 2
>>> f(x, y)
3
>>> x, y  (1)
(1, 2)
>>> a = [1, 2]
>>> b = [3, 4]
>>> f(a, b)
[1, 2, 3, 4]
>>> a, b  (2)
([1, 2, 3, 4], [3, 4])
>>> t = (10, 20)
>>> u = (30, 40)
>>> f(t, u)  (3)
(10, 20, 30, 40)
>>> t, u
((10, 20), (30, 40))
  1. O número x não se altera.

  2. A lista a é alterada.

  3. A tupla t não se altera.

Outra questão relacionada a parâmetros de função é o uso de valores mutáveis como defaults, discutida a seguir.

6.5.1. Porque evitar tipos mutáveis como default em parâmetros

Parâmetros opcionais com valores default são um ótimo recurso para definição de funções em Python, permitindo que nossas APIs evoluam mantendo a compatibilidade com versões anteriores. Entretanto, você deve evitar usar objetos mutáveis como valores default em parâmetros.

Para ilustrar esse ponto, em Exemplo 12, modificamos o método __init__ da classe Bus de Exemplo 8 para criar HauntedBus. Tentamos ser espertos: em vez do valor default passengers=None, temos passengers=[], para evitar o if do __init__ anterior. Essa "esperteza" causa problemas.

Exemplo 12. Uma classe simples ilustrando o perigo de um default mutável
class HauntedBus:
    """A bus model haunted by ghost passengers"""

    def __init__(self, passengers=[]):  # (1)
        self.passengers = passengers  # (2)

    def pick(self, name):
        self.passengers.append(name)  # (3)

    def drop(self, name):
        self.passengers.remove(name)
  1. Quando o argumento passengers não é passado, esse parâmetro é vinculado ao objeto lista default, que inicialmente está vazio.

  2. Essa atribuição torna self.passengers um apelido de passengers, que por sua vez é um apelido para a lista default, quando um argumento passengers não é passado para a função.

  3. Quando os métodos .remove() e .append() são usados com self.passengers, estamos, na verdade, mudando a lista default, que é um atributo do objeto-função.

Exemplo 13 mostra o comportamento misterioso de HauntedBus.

Exemplo 13. Ônibus assombrados por passageiros fantasmas
>>> bus1 = HauntedBus(['Alice', 'Bill'])  (1)
>>> bus1.passengers
['Alice', 'Bill']
>>> bus1.pick('Charlie')
>>> bus1.drop('Alice')
>>> bus1.passengers  (2)
['Bill', 'Charlie']
>>> bus2 = HauntedBus()  (3)
>>> bus2.pick('Carrie')
>>> bus2.passengers
['Carrie']
>>> bus3 = HauntedBus()  (4)
>>> bus3.passengers  (5)
['Carrie']
>>> bus3.pick('Dave')
>>> bus2.passengers  (6)
['Carrie', 'Dave']
>>> bus2.passengers is bus3.passengers  (7)
True
>>> bus1.passengers  (8)
['Bill', 'Charlie']
  1. bus1 começa com uma lista de dois passageiros.

  2. Até aqui, tudo bem: nenhuma surpresa em bus1.

  3. bus2 começa vazio, então a lista vazia default é vinculada a self.passengers.

  4. bus3 também começa vazio, e novamente a lista default é atribuída.

  5. A lista default não está mais vazia!

  6. Agora Dave, pego pelo bus3, aparece no bus2.

  7. O problema: bus2.passengers e bus3.passengers se referem à mesma lista.

  8. Mas bus1.passengers é uma lista diferente.

O problema é que instâncias de HauntedBus que não recebem uma lista de passageiros inicial acabam todas compartilhando a mesma lista de passageiros entre si.

Este tipo de bug pode ser muito sutil. Como Exemplo 13 demonstra, quando HauntedBus recebe uma lista com passageiros como parâmetro, ele funciona como esperado. As coisas estranhas acontecem somente quando HauntedBus começa vazio, pois aí self.passengers se torna um apelido para o valor default do parâmetro passengers. O problema é que cada valor default é processado quando a função é definida — i.e., normalmente quando o módulo é carregado — e os valores default se tornam atributos do objeto-função. Assim, se o valor default é um objeto mutável e você o altera, a alteração vai afetar todas as futuras chamadas da função.

Após executar as linhas do exemplo em Exemplo 13, você pode inspecionar o objeto HauntedBus.__init__ e ver os estudantes fantasma assombrando o atributo __defaults__:

>>> dir(HauntedBus.__init__)  # doctest: +ELLIPSIS
['__annotations__', '__call__', ..., '__defaults__', ...]
>>> HauntedBus.__init__.__defaults__
(['Carrie', 'Dave'],)

Por fim, podemos verificar que bus2.passengers é um apelido vinculado ao primeiro elemento do atributo HauntedBus.__init__.__defaults__:

>>> HauntedBus.__init__.__defaults__[0] is bus2.passengers
True

O problema com defaults mutáveis explica porque None é normalmente usado como valor default para parâmetros que podem receber valores mutáveis. Em Exemplo 8, __init__ checa se o argumento passengers é None. Se for, self.passengers é vinculado a uma nova lista vazia. Se passengers não for None, a implementação correta vincula uma cópia daquele argumento a self.passengers. A próxima seção explica porque copiar o argumento é uma boa prática.

6.5.2. Programação defensiva com argumentos mutáveis

Ao escrever uma função que recebe um argumento mutável, você deve considerar com cuidado se o cliente que chama sua função espera que o argumento passado seja modificado.

Por exemplo, se sua função recebe um dict e precisa modificá-lo durante seu processamento, esse efeito colateral deve ou não ser visível fora da função? A resposta, na verdade, depende do contexto. É tudo uma questão de alinhar as expectativas do autor da função com as do cliente da função.

O último exemplo com ônibus neste capítulo mostra como o TwilightBus viola as expectativas ao compartilhar sua lista de passageiros com seus clientes. Antes de estudar a implementação, veja como a classe TwilightBus funciona pela perspectiva de um cliente daquela classe, em Exemplo 14.

Exemplo 14. Passageiros desaparecem quando são deixados por um TwilightBus
>>> basketball_team = ['Sue', 'Tina', 'Maya', 'Diana', 'Pat']  (1)
>>> bus = TwilightBus(basketball_team)  (2)
>>> bus.drop('Tina')  (3)
>>> bus.drop('Pat')
>>> basketball_team  (4)
['Sue', 'Maya', 'Diana']
  1. basketball_team contém o nome de cinco estudantes.

  2. Um TwilightBus é carregado com o time.

  3. O bus deixa uma estudante, depois outra.

  4. As passageiras desembarcadas desapareceram do time de basquete!

TwilightBus viola o "Princípio da Menor Surpresa Possível", uma boa prática do design de interfaces.[71] É certamente espantoso que quando o ônibus deixa uma estudante, seu nome seja removido da escalação do time de basquete.

Exemplo 15 é a implementação de TwilightBus e uma explicação do problema.

Exemplo 15. Uma classe simples mostrando os perigos de mudar argumentos recebidos
class TwilightBus:
    """A bus model that makes passengers vanish"""

    def __init__(self, passengers=None):
        if passengers is None:
            self.passengers = []  # (1)
        else:
            self.passengers = passengers  #(2)

    def pick(self, name):
        self.passengers.append(name)

    def drop(self, name):
        self.passengers.remove(name)  # (3)
  1. Aqui nós cuidadosamente criamos uma lista vazia quando passengers é None.

  2. Entretanto, esta atribuição transforma self.passengers em um apelido para passengers, que por sua vez é um apelido para o argumento efetivamente passado para __init__ (i.e. basketball_team em Exemplo 14).

  3. Quando os métodos .remove() e .append() são usados com self.passengers, estamos, na verdade, modificando a lista original recebida como argumento pelo construtor.

O problema aqui é que o ônibus está apelidando a lista passada para o construtor. Ao invés disso, ele deveria manter sua própria lista de passageiros. A solução é simples: em __init__, quando o parâmetro passengers é fornecido, self.passengers deveria ser inicializado com uma cópia daquela lista, como fizemos, de forma correta, em Exemplo 8:

    def __init__(self, passengers=None):
        if passengers is None:
            self.passengers = []
        else:
            self.passengers = list(passengers) (1)
  1. Cria uma cópia da lista passengers, ou converte o argumento para list se ele não for uma lista.

Agora nossa manipulação interna da lista de passageiros não afetará o argumento usado para inicializar o ônibus. E com uma vantagem adicional, essa solução é mais flexível: agora o argumento passado no parâmetro passengers pode ser uma tupla ou qualquer outro tipo iterável, como set ou mesmo resultados de uma consulta a um banco de dados, pois o construtor de list aceita qualquer iterável. Ao criar nossa própria lista, estamos também assegurando que ela suporta os métodos necessários, .remove() e .append(), operações que usamos nos métodos .pick() e .drop().

👉 Dica

A menos que um método tenha o objetivo explícito de alterar um objeto recebido como argumento, você deveria pensar bem antes de apelidar tal objeto e simplesmente vinculá-lo a uma variável interna de sua classe. Quando em dúvida, crie uma cópia. Os clientes de sua classe ficarão mais felizes. Claro, criar uma cópia não é grátis: há um custo de processamento e uso de memória. Entretanto, uma API que causa bugs sutis é, em geral, um problema bem maior que uma que seja um pouco mais lenta ou que use mais recursos.

Agora vamos conversar sobre um dos comandos mais incompreendidos em Python: del.

6.6. del e coleta de lixo

Os objetos nunca são destruídos explicitamente; no entanto, quando eles se tornam inacessíveis, eles podem ser coletados como lixo.

— “Modelo de Dados” capítulo de A Referência da Linguagem Python

A primeira estranheza sobre del é ele não ser uma função, mas um comando.

Escrevemos del x e não del(x) — apesar dessa última forma funcionar também, mas apenas porque as expressões x e (x) em geral terem o mesmo significado em Python.

O segundo aspecto surpreendente é que del apaga referências, não objetos. A coleta de lixo pode eliminar um objeto da memória como resultado indireto de del, se a variável apagada for a última referência ao objeto. Reassociar uma variável também pode reduzir a zero o número de referências a um objeto, causando sua destruição.

>>> a = [1, 2]  (1)
>>> b = a       (2)
>>> del a       (3)
>>> b           (4)
[1, 2]
>>> b = [3]     (5)
  1. Cria o objeto [1, 2] e vincula a a ele.

  2. Vincula b ao mesmo objeto [1, 2].

  3. Apaga a referência a.

  4. [1, 2] não é afetado, pois b ainda aponta para ele.

  5. Reassociar b a um objeto diferente remove a última referência restante a [1, 2]. Agora o coletor de lixo pode descartar aquele objeto.

⚠️ Aviso

Existe um método especial __del__, mas ele não causa a remoção de uma instância e não deve ser usado em seu código. __del__ é invocado pelo interpretador Python quando a instância está prestes a ser destruída, para dar a ela a chance de liberar recursos externos. É muito raro ser preciso implementar __del__ em seu código, mas ainda assim alguns programadores Python perdem tempo codando este método sem necessidade. O uso correto de __del__ é bastante complexo. Consulte __del__ no capítulo "Modelo de Dados" em A Referência da Linguagem Python.

Em CPython, o algoritmo primário de coleta de lixo é a contagem de referências. Essencialmente, cada objeto mantém uma contagem do número de referências apontando para si. Assim que a contagem chega a zero, o objeto é imediatamente destruído: CPython invoca o método __del__ no objeto (se definido) e daí libera a memória alocada para aquele objeto. Em CPython 2.0, um algoritmo de coleta de lixo geracional foi acrescentado, para detectar grupos de objetos envolvidos em referências cíclicas — grupos que pode ser inacessíveis mesmo que existam referências restantes, quando todas as referências mútuas estão contidas dentro daquele grupo. Outras implementações de Python tem coletores de lixo mais sofisticados, que não se baseiam na contagem de referências, o que significa que o método __del__ pode não ser chamado imediatamente quando não existem mais referências ao objeto. Veja "PyPy, Garbage Collection, and a Deadlock" (EN) by A. Jesse Jiryu Davis para uma discussão sobre os usos próprios e impróprios de __del__.

Para demonstrar o fim da vida de um objeto, Exemplo 16 usa weakref.finalize para registrar uma função callback a ser chamada quando o objeto é destruído.

Exemplo 16. Assistindo o fim de um objeto quando não resta nenhuma referência apontando para ele
>>> import weakref
>>> s1 = {1, 2, 3}
>>> s2 = s1         (1)
>>> def bye():      (2)
...     print('...like tears in the rain.')
...
>>> ender = weakref.finalize(s1, bye)  (3)
>>> ender.alive  (4)
True
>>> del s1
>>> ender.alive  (5)
True
>>> s2 = 'spam'  (6)
...like tears in the rain.
>>> ender.alive
False
  1. s1 e s2 são apelidos do mesmo conjunto, {1, 2, 3}.

  2. Essa função não deve ser um método associado ao objeto prestes a ser destruído, nem manter uma referência para o objeto.

  3. Registra o callback bye no objeto referenciado por s1.

  4. O atributo .alive é True antes do objeto finalize ser chamado.

  5. Como vimos, del não apaga o objeto, apenas a referência s1 a ele.

  6. Reassociar a última referência, s2, torna {1, 2, 3} inacessível. Ele é destruído, o callback bye é invocado, e ender.alive se torna False.

O ponto principal de Exemplo 16 é mostrar explicitamente que del não apaga objetos, mas que objetos podem ser apagados como uma consequência de se tornarem inacessíveis após o uso de del.

Você pode estar se perguntando porque o objeto {1, 2, 3} foi destruído em Exemplo 16. Afinal, a referência s1 foi passada para a função finalize, que precisa tê-la mantido para conseguir monitorar o objeto e invocar o callback. Isso funciona porque finalize mantém uma referência fraca (weak reference) para {1, 2, 3}. Referências fracas não aumentam a contagem de referências de um objeto. Assim, uma referência fraca não evita que o objeto alvo seja destruído pelo coletor de lixo. Referências fracas são úteis em cenários de caching, pois não queremos que os objetos "cacheados" sejam mantidos vivos apenas por terem uma referência no cache.

✒️ Nota

Referências fracas são um tópico muito especializado, então decidi retirá-lo dessa segunda edição. Em vez disso, publiquei a nota "Weak References" em fluentpython.com.

6.7. Peças que Python prega com imutáveis

✒️ Nota

Esta seção opcional discute alguns detalhes que, na verdade, não são muito importantes para usuários de Python, e que podem não se aplicar a outras implementações da linguagem ou mesmo a futuras versões de CPython. Entretanto, já vi muita gente tropeçar nesses casos laterais e daí passar a usar o operador is de forma incorreta, então acho que vale a pena mencionar esses detalhes.

Eu fiquei surpreso em descobrir que, para uma tupla t, a chamada t[:] não cria uma cópia, mas devolve uma referência para o mesmo objeto. Da mesma forma, tuple(t) também retorna uma referência para a mesma tupla.[72]

Exemplo 17 demonstra esse fato.

Exemplo 17. Uma tupla construída a partir de outra é, na verdade, exatamente a mesma tupla.
>>> t1 = (1, 2, 3)
>>> t2 = tuple(t1)
>>> t2 is t1  (1)
True
>>> t3 = t1[:]
>>> t3 is t1  (2)
True
  1. t1 e t2 estão vinculadas ao mesmo objeto

  2. Assim como t3.

O mesmo comportamento pode ser observado com instâncias de str, bytes e frozenset. Observe que frozenset não é uma sequência, então fs[:] não funciona se fs é um frozenset. Mas fs.copy() tem o mesmo efeito: ele trapaceia e retorna uma referência ao mesmo objeto, e não uma cópia, como mostra Exemplo 18.[73]

Exemplo 18. Strings literais podem criar objetos compartilhados.
>>> t1 = (1, 2, 3)
>>> t3 = (1, 2, 3)  # (1)
>>> t3 is t1  # (2)
False
>>> s1 = 'ABC'
>>> s2 = 'ABC'  # (3)
>>> s2 is s1 # (4)
True
  1. Criando uma nova tupla do zero.

  2. t1 e t3 são iguais, mas não são o mesmo objeto.

  3. Criando uma segunda str do zero.

  4. Surpresa: a e b se referem à mesma str!

O compartilhamento de strings literais é uma técnica de otimização chamada internalização (interning). O CPython usa uma técnica similar com inteiros pequenos, para evitar a duplicação desnecessária de números que aparecem com muita frequência em programas, como 0, 1, -1, etc. Observe que o CPython não internaliza todas as strings e inteiros, e o critério pelo qual ele faz isso é um detalhe de implementação não documentado.

⚠️ Aviso

Nunca dependa da internalização de str ou int! Sempre use == em vez de is para verificar a igualdade de strings ou inteiros. A internalização é uma otimização para uso interno do interpretador Python.

Os truques discutidos nessa seção, incluindo o comportamento de frozenset.copy(), são mentiras inofensivas que economizam memória e tornam o interpretador mais rápido. Não se preocupe, elas não trarão nenhum problema, pois se aplicam apenas a tipos imutáveis. Provavelmente, o melhor uso para esse tipo de detalhe é ganhar apostas contra outros Pythonistas.[74]

6.8. Resumo do capítulo

Todo objeto em Python tem uma identidade, um tipo e um valor. Apenas o valor do objeto pode mudar ao longo do tempo.[75]

Se duas variáveis se referem a objetos imutáveis de igual valor (a == b is True), na prática, dificilmente importa se elas se referem a cópias de mesmo valor ou são apelidos do mesmo objeto, porque o valor de objeto imutável não muda, com uma exceção. A exceção são as coleções imutáveis, como as tuplas: se uma coleção imutável contém referências para itens mutáveis, então seu valor pode de fato mudar quando o valor de um item mutável for modificado. Na prática, esse cenário não é tão comum. O que nunca muda numa coleção imutável são as identidades dos objetos mantidos ali. A classe frozenset não sofre desse problema, porque ela só pode manter elementos hashable, e o valor de um objeto hashable não pode mudar nunca, por definição.

O fato de variáveis manterem referências tem muitas consequências práticas para a programação em Python:

  • Uma atribuição simples não cria cópias.

  • Uma atribuição composta com += ou *= cria novos objetos se a variável à esquerda da atribuição estiver vinculada a um objeto imutável, mas pode modificar um objeto mutável diretamente.

  • Atribuir um novo valor a uma variável existente não muda o objeto previamente vinculado à variável. Isso se chama reassociar (rebinding); a variável está agora associada a um objeto diferente. Se aquela variável era a última referência ao objeto anterior, aquele objeto será eliminado pela coleta de lixo.

  • Parâmetros de função são passados como apelidos, o que significa que a função pode alterar qualquer objeto mutável recebido como argumento. Não há como evitar isso, exceto criando cópias locais ou usando objetos imutáveis (i.e., passando uma tupla em vez de uma lista)

  • Usar objetos mutáveis como valores default de parâmetros de função é perigoso, pois se os parâmetros forem modificados pela função, o default muda, afetando todas as chamadas posteriores que usem o default.

Em CPython, os objetos são descartados assim que o número de referências a eles chega a zero. Eles também podem ser descartados se formarem grupos com referências cíclicas sem nenhuma referência externa ao grupo.

Em algumas situações, pode ser útil manter uma referência para um objeto que não irá — por si só — manter o objeto vivo. Um exemplo é uma classe que queira manter o registro de todas as suas instâncias atuais. Isso pode ser feito com referências fracas, um mecanismo de baixo nível encontrado nas úteis coleções WeakValueDictionary, WeakKeyDictionary, WeakSet, e na função finalize do módulo weakref.

Para saber mais, leia "Weak References" em fluentpython.com.

6.9. Para saber mais

O capítulo "Modelo de Dados" de A Referência da Linguagem Python inicia com uma explicação bastante clara sobre identidades e valores de objetos.

Wesley Chun, autor da série Core Python, apresentou Understanding Python’s Memory Model, Mutability, and Methods (EN) na EuroPython 2011, discutindo não apenas o tema desse capítulo como também o uso de métodos especiais.

Doug Hellmann escreveu os posts "copy – Duplicate Objects" (EN) e "weakref—Garbage-Collectable References to Objects" (EN), cobrindo alguns dos tópicos que acabamos de tratar.

Você pode encontrar mais informações sobre o coletor de lixo geracional do CPython em the gc — Interface para o coletor de lixo¶, que começa com a frase "Este módulo fornece uma interface para o opcional garbage collector". O adjetivo "opcional" usado aqui pode ser surpreendente, mas o capítulo "Modelo de Dados" também afirma:

Uma implementação tem permissão para adiar a coleta de lixo ou omiti-la completamente — é uma questão de detalhe de implementação como a coleta de lixo é implementada, desde que nenhum objeto que ainda esteja acessível seja coletado.

Pablo Galindo escreveu um texto mais aprofundado sobre o Coletor de Lixo em Python, em "Design of CPython’s Garbage Collector" (EN) no Python Developer’s Guide, voltado para contribuidores novos e experientes da implementação CPython.

O coletor de lixo do CPython 3.4 aperfeiçoou o tratamento de objetos contendo um método __del__, como descrito em PEP 442—​Safe object finalization (EN).

A Wikipedia tem um artigo sobre string interning (EN), que menciona o uso desta técnica em várias linguagens, incluindo Python.

A Wikipedia também tem um artigo sobre "Haddocks' Eyes", a canção de Lewis Carroll que mencionei no início deste capítulo. Os editores da Wikipedia escreveram que a letra é usada em trabalhos de lógica e filosofia "para elaborar o status simbólico do conceito de 'nome': um nome como um marcador de identificação pode ser atribuído a qualquer coisa, incluindo outro nome, introduzindo assim níveis diferentes de simbolização."

Ponto de vista

Tratamento igual para todos os objetos

Eu aprendi Java antes de conhecer Python. O operador == em Java nunca me pareceu funcionar corretamente. É muito mais comum que programadores estejam preocupados com a igualdade que com a identidade. Mas para objetos (não tipos primitivos), o == em Java compara referências, não valores dos objetos. Mesmo para algo tão básico quanto comparar strings, Java obriga você a usar o método .equals. E mesmo assim, há outro problema: se você escrever a.equals(b) e a for null, você causa uma null pointer exception (exceção de ponteiro nulo). Os projetistas do Java sentiram necessidade de sobrecarregar + para strings; por que não mantiveram essa ideia e sobrecarregaram == também?

Python faz melhor. O operador == compara valores de objetos; is compara referências. E como Python permite sobrecarregar operadores, == funciona de forma sensata com todos os objetos na biblioteca padrão, incluindo None, que é um objeto verdadeiro, ao contrário do Null de Java.

E claro, você pode definir __eq__ nas suas próprias classes para controlar o que == significa para suas instâncias. Se você não sobrecarregar __eq__, o método herdado de object compara os IDs dos objetos, então a regra básica é que cada instância de uma classe definida pelo usuário é considerada diferente.

Estas são algumas das coisas que me fizeram mudar de Java para Python assim que terminei de ler The Python Tutorial em uma tarde de setembro de 1998.

Mutabilidade

Este capítulo não seria necessário se todos os objetos em Python fossem imutáveis. Quando você está lidando com objetos imutáveis, não faz diferença se as variáveis guardam os objetos em si ou referências para objetos compartilhados.

Se a == b é verdade, e nenhum dos dois objetos pode mudar, eles podem perfeitamente ser o mesmo objeto. Por isso a internalização de strings é segura. A identidade dos objetos ser torna importante apenas quando esses objetos podem mudar.

Em programação funcional "pura", todos os dados são imutáveis: concatenar algo a uma coleção, na verdade, cria uma nova coleção. Elixir é uma linguagem funcional prática e fácil de aprender, na qual todos os tipos nativos são imutáveis, incluindo as listas.

Python, por outro lado, não é uma linguagem funcional, menos uma ainda uma linguagem pura. Instâncias de classes definidas pelo usuário são mutáveis por default em Python — como na maioria das linguagens orientadas a objetos. Ao criar seus próprios objetos, você tem que tomar o cuidado adicional de torná-los imutáveis, se este for um requisito. Cada atributo do objeto precisa ser também imutável, senão você termina criando algo como uma tupla: imutável quanto ao ID do objeto, mas seu valor pode mudar se a tupla contiver um objeto mutável.

Objetos mutáveis também são a razão pela qual programar com threads é tão difícil: threads modificando objetos sem uma sincronização apropriada podem corromper dados. Sincronização excessiva, por outro lado, causa deadlocks. A linguagem e a plataforma Erlang — que inclui Elixir — foi projetada para maximizar o tempo de execução em aplicações distribuídas de alta concorrência, tais como aplicações de controle de telecomunicações. Naturalmente, eles escolheram tornar os dados imutáveis por default.

Destruição de objetos e coleta de lixo

Não há qualquer mecanismo em Python para destruir um objeto diretamente, e essa omissão é, na verdade, uma grande qualidade: se você pudesse destruir um objeto a qualquer momento, o que aconteceria com as referências que apontam para ele?

A coleta de lixo em CPython é feita principalmente por contagem de referências, que é fácil de implementar, mas vulnerável a vazamentos de memória (memory leaks) quando existem referências cíclicas. Assim, com a versão 2.0 (de outubro de 2000), um coletor de lixo geracional foi implementado, e ele consegue dispor de objetos inatingíveis que foram mantidos vivos por ciclos de referências.

Mas a contagem de referências ainda está lá como mecanismo básico, e ela causa a destruição imediata de objetos com zero referências. Isso significa que, em CPython — pelo menos por hora — é seguro escrever:

open('test.txt', 'wt', encoding='utf-8').write('1, 2, 3')

Este código é seguro porque a contagem de referências do objeto file será zero após o método write retornar. Entretanto, a mesma linha não é segura em Jython ou IronPython, que usam o coletor de lixo dos runtimes de seus ambientes (a Java VM e a .NET CLR, respectivamente), que são mais sofisticados, mas não se baseiam em contagem de referências, e podem demorar mais para destruir o objeto e fechar o arquivo. Em todos os casos, incluindo em CPython, a melhor prática é fechar o arquivo explicitamente, e a forma mais confiável de fazer isso é usando o comando with, que garante o fechamento do arquivo mesmo se acontecerem exceções enquanto ele estiver aberto. Usando with, a linha anterior se torna:

with open('test.txt', 'wt', encoding='utf-8') as fp:
    fp.write('1, 2, 3')

Se você estiver interessado no assunto de coletores de lixo, você talvez queira ler o artigo de Thomas Perl, "Python Garbage Collector Implementations: CPython, PyPy and GaS" (EN), onde eu aprendi esses detalhes sobre a segurança de open().write() em CPython.

Passagem de parâmetros: chamada por compartilhamento

Uma maneira popular de explicar como a passagem de parâmetros funciona em Python é a frase: "Parâmetros são passados por valor, mas os valores são referências." Isso não está errado, mas causa confusão porque os modos mais comuns de passagem de parâmetros nas linguagens antigas são chamada por valor (a função recebe uma cópia dos argumentos) e chamada por referência (a função recebe um ponteiro para o argumento). Em Python, a função recebe uma cópia dos argumentos, mas os argumentos são sempre referências. Então o valor dos objetos referenciados podem ser alterados pela função, se eles forem mutáveis, mas sua identidade não. Além disso, como a função recebe uma cópia da referência em um argumento, reassociar essa referência no corpo da função não tem qualquer efeito fora da função. Adotei o termo chamada por compartilhamento depois de ler sobre esse assunto em Programming Language Pragmatics, 3rd ed., de Michael L. Scott (Morgan Kaufmann), section "8.3.1: Parameter Modes."

Parte II: Funções como objetos

7. Funções como objetos de primeira classe

Nunca achei que o Python tenha sido fortemente influenciado por linguagens funcionais, independente do que outros digam ou pensem. Eu estava muito mais familiarizado com linguagens imperativas, como o C e o Algol e, apesar de ter tornado as funções objetos de primeira classe, não via o Python como uma linguagem funcional.[76][77]

— Guido van Rossum
BDFL do Python

No Python, funções são objetos de primeira classe. Estudiosos de linguagens de programação definem um "objeto de primeira classe" como uma entidade programática que pode ser:

  • Criada durante a execução de um programa

  • Atribuída a uma variável ou a um elemento em uma estrutura de dados

  • Passada como argumento para uma função

  • Devolvida como o resultado de uma função

Inteiros, strings e dicionários são outros exemplos de objetos de primeira classe no Python—nada de incomum aqui. Tratar funções como objetos de primeira classe é um recurso essencial das linguagens funcionais, tais como Clojure, Elixir e Haskell. Entretanto, funções de primeira classe são tão úteis que foram adotadas por linguagens muito populares, como o Javascript, o Go e o Java (desde o JDK 8), nenhuma das quais alega ser uma "linguagem funcional".

Esse capítulo e quase toda a Parte III do livro exploram as aplicações práticas de se tratar funções como objetos.

👉 Dica

O termo "funções de primeira classe" é largamente usado como uma forma abreviada de "funções como objetos de primeira classe". Ele não é ideal, por sugerir a existência de uma "elite" entre funções. No Python, todas as funções são de primeira classe.

7.1. Novidades nesse capítulo

A seção "Os nove sabores de objetos invocáveis" (Seção 7.5) se chamava "Sete sabores de objetos invocáveis" na primeira edição deste livro. Os novos invocáveis são corrotinas nativas e geradores assíncronos, introduzidos no Python 3.5 e 3.6, respectivamente. Ambos serão estudados no Capítulo 21, mas são mencionados aqui ao lado dos outros invocáveis.

A Seção 7.7.1 é nova, e fala de um recurso que surgiu no Python 3.8.

Transferi a discussão sobre acesso a anotações de funções durante a execução para a Seção 15.5. Quando escrevi a primeira edição, a PEP 484—Type Hints (Dicas de Tipo) (EN) ainda estava sendo considerada, e as anotações eram usadas de várias formas diferentes. Desde o Python 3.5, anotações precisam estar em conformidade com a PEP 484. Assim, o melhor lugar para falar delas é durante a discussão das dicas de tipo.

✒️ Nota

A primeira edição desse livro continha seções sobre a introspecção de objetos função, que desciam a detalhes de baixo nível e distraiam o leitor do assunto principal do capítulo. Fundi aquelas seções em um post entitulado "Introspection of Function Parameters" (Introspecção de Parâmetros de Funções), no fluentpython.com.

Agora vamos ver porque as funções do Python são objetos completos.

7.2. Tratando uma função como um objeto

A sessão de console no Exemplo 1 mostra que funções do Python são objetos. Ali criamos uma função, a chamamos, lemos seu atributo __doc__ e verificamos que o próprio objeto função é uma instância da classe function.

Exemplo 1. Cria e testa uma função, e então lê seu __doc__ e verifica seu tipo
>>> def factorial(n):  (1)
...     """returns n!"""
...     return 1 if n < 2 else n * factorial(n - 1)
...
>>> factorial(42)
1405006117752879898543142606244511569936384000000000
>>> factorial.__doc__  (2)
'returns n!'
>>> type(factorial)  (3)
<class 'function'>
  1. Isso é uma sessão do console, então estamos criando uma função "durante a execução".

  2. __doc__ é um dos muitos atributos de objetos função.

  3. factorial é um instância da classe function.

O atributo __doc__ é usado para gerar o texto de ajuda de um objeto. No console do Python, o comando help(factorial) mostrará uma tela como a da Figura 1.

Tela de ajuda da função factorial
Figura 1. Tela de ajuda para factorial; o texto é criado a partir do atributo __doc__ da função.

O Exemplo 2 mostra a natureza de "primeira classe" de um objeto função. Podemos atribuir tal objeto a uma variável fact e invocá-lo por esse nome. Podemos também passar factorial como argumento para a função map. Invocar map(function, iterable) devolve um iterável no qual cada item é o resultado de uma chamada ao primeiro argumento (uma função) com elementos sucessivos do segundo argumento (um iterável), range(11) no exemplo.

Exemplo 2. Usa factorial usando de um nome diferentes, e passa factorial como um argumento
>>> fact = factorial
>>> fact
<function factorial at 0x...>
>>> fact(5)
120
>>> map(factorial, range(11))
<map object at 0x...>
>>> list(map(factorial, range(11)))
[1, 1, 2, 6, 24, 120, 720, 5040, 40320, 362880, 3628800]

Ter funções de primeira classe permite programar em um estilo funcional. Um dos marcos da programação funcional é o uso de funções de ordem superior, nosso próximo tópico.

7.3. Funções de ordem superior

Uma função que recebe uma função como argumento ou devolve uma função como resultado é uma função de ordem superior. Uma dessas funções é map, usada no Exemplo 2. Outra é a função embutida sorted: o argumento opcional key permite fornecer uma função, que será então aplicada na ordenação de cada item, como vimos na Seção 2.9. Por exemplo, para ordenar uma lista de palavras por tamanho, passe a função len como key, como no Exemplo 3.

Exemplo 3. Ordenando uma lista de palavras por tamanho
>>> fruits = ['strawberry', 'fig', 'apple', 'cherry', 'raspberry', 'banana']
>>> sorted(fruits, key=len)
['fig', 'apple', 'cherry', 'banana', 'raspberry', 'strawberry']
>>>

Qualquer função com um argumento pode ser usada como chave. Por exemplo, para criar um dicionário de rimas pode ser útil ordenar cada palavra escrita ao contrário. No Exemplo 4, observe que as palavras na lista não são modificadas de forma alguma; apenas suas versões escritas na ordem inversa são utilizadas como critério de ordenação. Por isso as berries aparecem juntas.

Exemplo 4. Ordenando uma lista de palavras pela ordem inversa de escrita
>>> def reverse(word):
...     return word[::-1]
>>> reverse('testing')
'gnitset'
>>> sorted(fruits, key=reverse)
['banana', 'apple', 'fig', 'raspberry', 'strawberry', 'cherry']
>>>

No paradigma funcional de programação, algumas das funções de ordem superior mais conhecidas são map, filter, reduce, e apply. A função apply foi descontinuada no Python 2.3 e removida no Python 3, por não ser mais necessária. Se você precisar chamar uma função com um conjuntos dinâmico de argumentos, pode escrever fn(*args, **kwargs) no lugar de apply(fn, args, kwargs).

As funções de ordem superior map, filter, e reduce ainda estão por aí, mas temos alternativas melhores para a maioria de seus casos de uso, como mostra a próxima seção.

7.3.1. Substitutos modernos para map, filter, e reduce

Linguagens funcionais normalmente oferecem as funções de ordem superior map, filter, and reduce (algumas vezes com nomes diferentes). As funções map e filter ainda estão embutidas no Python mas, desde a introdução das compreensões de lista e das expressões geradoras, não são mais tão importantes. Uma listcomp ou uma genexp fazem o mesmo que map e filter combinadas, e são mais legíveis. Considere o Exemplo 5.

Exemplo 5. Listas de fatoriais produzidas com map e filter, comparadas com alternativas escritas com compreensões de lista
>>> list(map(factorial, range(6)))  (1)
[1, 1, 2, 6, 24, 120]
>>> [factorial(n) for n in range(6)]  (2)
[1, 1, 2, 6, 24, 120]
>>> list(map(factorial, filter(lambda n: n % 2, range(6))))  (3)
[1, 6, 120]
>>> [factorial(n) for n in range(6) if n % 2]  (4)
[1, 6, 120]
>>>
  1. Cria uma lista de fatoriais de 0! a 5!.

  2. Mesma operação, com uma compreensão de lista.

  3. Lista de fatoriais de números ímpares até 5!, usando map e filter.

  4. A compreensão de lista realiza a mesma tarefa, substituindo map e filter, e tornando lambda desnecessário.

No Python 3, map e filter devolvem geradores—uma forma de iterador—então sua substituta direta é agora uma expressão geradora (no Python 2, essas funções devolviam listas, então sua alternativa mais próxima era a compreensão de lista).

A função reduce foi rebaixada de função embutida, no Python 2, para o módulo functools no Python 3. Seu caso de uso mais comum, a soma, é melhor servido pela função embutida sum, disponível desde que o Python 2.3 (lançado em 2003). E isso é uma enorme vitória em termos de legibilidade e desempenho (veja Exemplo 6 abaixo).

Exemplo 6. Soma de inteiros até 99, realizada com reduce e sum
>>> from functools import reduce  (1)
>>> from operator import add  (2)
>>> reduce(add, range(100))  (3)
4950
>>> sum(range(100))  (4)
4950
>>>
  1. A partir do Python 3.0, reduce deixou de ser uma função embutida.

  2. Importa add para evitar a criação de uma função apenas para somar dois números.

  3. Soma os inteiros até 99.

  4. Mesma operação, com sum—não é preciso importar nem chamar reduce e add.

✒️ Nota

A ideia comum de sum e reduce é aplicar alguma operação sucessivamente a itens em uma série, acumulando os resultados anteriores, reduzindo assim uma série de valores a um único valor.

Outras funções de redução embutidas são all e any:

all(iterable)

Devolve True se não há nenhum elemento falso no iterável; all([]) devolve True.

any(iterable)

Devolve True se qualquer elemento do iterable for verdadeiro; any([]) devolve False.

Dou um explicação mais completa sobre reduce na Seção 12.7, onde um exemplo mais longo, atravessando várias seções, cria um contexto significativo para o uso dessa função. As funções de redução serão resumidas mais à frente no livro, na Seção 17.10, quando estivermos tratando dos iteráveis. Para usar uma função de ordem superior, às vezes é conveniente criar um pequena função, que será usada apenas uma vez. As funções anônimas existem para isso. Vamos falar delas a seguir.

7.4. Funções anônimas

A palavra reservada lambda cria uma função anônima dentro de uma expressão Python.

Entretanto, a sintaxe simples do Python força os corpos de funções lambda a serem expressões puras. Em outras palavras, o corpo não pode conter outras instruções Python como while, try, etc. A atribuição com = também é uma instrução, então não pode ocorrer em um lambda. A nova sintaxe da expressão de atribuição, usando :=, pode ser usada. Porém, se você precisar dela, seu lambda provavelmente é muito complicado e difícil de ler, e deveria ser refatorado para um função regular usando def.

O melhor uso das funções anônimas é no contexto de uma lista de argumentos para uma função de ordem superior. Por exemplo, o Exemplo 7 é o exemplo do dicionário de rimas do Exemplo 4 reescrito com lambda, sem definir uma função reverse.

Exemplo 7. Ordenando uma lista de palavras escritas na ordem inversa usando lambda
>>> fruits = ['strawberry', 'fig', 'apple', 'cherry', 'raspberry', 'banana']
>>> sorted(fruits, key=lambda word: word[::-1])
['banana', 'apple', 'fig', 'raspberry', 'strawberry', 'cherry']
>>>

Fora do contexto limitado dos argumentos das funções de ordem superior, funções anônimas raramente são úteis no Python. As restrições sintáticas tendem a tornar ilegíveis ou intratáveis as lambdas não-triviais. Se uma lambda é difícil de ler, aconselho fortemente seguir o conselho de Fredrik Lundh sobre refatoração.

A receita de Fredrik Lundh para refatoração de lambdas

Se você encontrar um trecho de código difícil de entender por causa de uma lambda, Fredrik Lundh sugere o seguinte procedimento de refatoração:

  1. Escreva um comentário explicando o que diabos aquela lambda faz.

  2. Estude o comentário por algum tempo, e pense em um nome que traduza sua essência.

  3. Converta a lambda para uma declaração def, usando aquele nome.

  4. Remova o comentário.

Esse passos são uma citação do "Programação Funcional—COMO FAZER), uma leitura obrigatória.

A sintaxe lambda é apenas açúcar sintático: uma expressão lambda cria um objeto função, exatamente como a declaração def. Esse é apenas um dos vários tipos de objetos invocáveis no Python. Na próxima seção revisamos todos eles.

7.5. Os nove sabores de objetos invocáveis

O operador de invocação () pode ser aplicado a outros objetos além de funções. Para determinar se um objeto é invocável, use a função embutida callable(). No Python 3.9, a documentação do modelo de dados lista nove tipos invocáveis:

Funções definidas pelo usuário

Criadas com comandos def ou expressões lambda.

Funções embutidas

Uma funções implementadas em C (no CPython), como len ou time.strftime.

Métodos embutidos

Métodos implementados em C, como dict.get.

Métodos

Funções definidas no corpo de uma classe.

Classes

Quando invocada, uma classe executa seu método __new__ para criar uma instância, e a seguir __init__, para inicializá-la. Então a instância é devolvida ao usuário. Como não existe um operador new no Python, invocar uma classe é como invocar uma função.[78]

Instâncias de classe

Se uma classe define um método __call__, suas instâncias podem então ser invocadas como funções—esse é o assunto da próxima seção.

Funções geradoras

Funções ou métodos que usam a palavra reservada yield em seu corpo. Quando chamadas, devolvem um objeto gerador.

Funções de corrotinas nativas

Funções ou métodos definidos com async def. Quando chamados, devolvem um objeto corrotina. Introduzidas no Python 3.5.

Funções geradoras assíncronas

Funções ou métodos definidos com async def, contendo yield em seu corpo. Quando chamados, devolvem um gerador assíncrono para ser usado com async for. Introduzidas no Python 3.6.

Funções geradoras, funções de corrotinas nativas e geradoras assíncronas são diferentes de outros invocáveis: os valores devolvidos tais funções nunca são dados da aplicação, mas objetos que exigem processamento adicional, seja para produzir dados da aplicação, seja para realizar algum trabalho útil. Funções geradoras devolvem iteradores. Ambos são tratados no Capítulo 17. Funções de corrotinas nativas e funções geradoras assíncronas devolvem objetos que só funcionam com a ajuda de um framework de programação assíncrona, tal como asyncio. Elas são o assunto do Capítulo 21.

👉 Dica

Dada a variedade dos tipos de invocáveis existentes no Python, a forma mais segura de determinar se um objeto é invocável é usando a função embutida callable():

>>> abs, str, 'Ni!'
(<built-in function abs>, <class 'str'>, 'Ni!')
>>> [callable(obj) for obj in (abs, str, 'Ni!')]
[True, True, False]

Vamos agora criar instâncias de classes que funcionam como objetos invocáveis.

7.6. Tipos invocáveis definidos pelo usuário

Não só as funções Python são objetos reais, também é possível fazer com que objetos Python arbitrários se comportem como funções. Para isso basta implementar o método de instância __call__.

O Exemplo 8 implementa uma classe BingoCage. Uma instância é criada a partir de qualquer iterável, e mantém uma list interna de itens, em ordem aleatória. Invocar a instância extrai um item.[79]

Exemplo 8. bingocall.py: Uma BingoCage faz apenas uma coisa: escolhe itens de uma lista embaralhada
import random

class BingoCage:

    def __init__(self, items):
        self._items = list(items)  # (1)
        random.shuffle(self._items)  # (2)

    def pick(self):  # (3)
        try:
            return self._items.pop()
        except IndexError:
            raise LookupError('pick from empty BingoCage')  # (4)

    def __call__(self):  # (5)
        return self.pick()
  1. __init__ aceita qualquer iterável; criar uma cópia local evita efeitos colaterais inesperados sobre qualquer list passada como argumento.

  2. shuffle sempre vai funcionar, pois self._items é uma list.

  3. O método principal.

  4. Se self._items está vazia, gera uma exceção com uma mensagem apropriada.

  5. Atalho para bingo.pick(): bingo().

Aqui está uma demonstração simples do Exemplo 8. Observe como uma instância de bingo pode ser invocada como uma função, e como a função embutida callable() a reconhece como um objeto invocável:

>>> bingo = BingoCage(range(3))
>>> bingo.pick()
1
>>> bingo()
0
>>> callable(bingo)
True

Uma classe que implemente __call__ é uma forma fácil de criar objetos similares a funções, com algum estado interno que precisa ser mantido de uma invocação para outra, como os itens restantes na BingoCage. Outro bom caso de uso para __call__ é a implementação de decoradores. Decoradores devem ser invocáveis, e muitas vezes é conveniente "lembrar" algo entre chamadas ao decorador (por exemplo, para memoization—a manutenção dos resultados de algum processamento complexo e/ou demorado para uso posterior) ou para separar uma implementação complexa por diferentes métodos.

A abordagem funcional para a criação de funções com estado interno é através do uso de clausuras (closures). Clausuras e decoradores são o assunto do Capítulo 9.

Vamos agora explorar a poderosa sintaxe oferecida pelo Python para declarar parâmetros de funções, e para passar argumentos para elas.

7.7. De parâmetros posicionais a parâmetros somente nomeados

Um dos melhores recursos das funções Python é seu mecanismo extremamente flexível de tratamento de parâmetros. Intimamente relacionados a isso são os usos de * e ** para desempacotar iteráveis e mapeamentos em argumentos separados quando chamamos uma função. Para ver esses recursos em ação, observe o código do Exemplo 9 e os testes mostrando seu uso no Exemplo 10.

Exemplo 9. tag gera elementos HTML; um argumento somente nomeado class_ é usado para passar atributos "class"; o _ é necessário porque class é uma palavra reservada no Python
def tag(name, *content, class_=None, **attrs):
    """Generate one or more HTML tags"""
    if class_ is not None:
        attrs['class'] = class_
    attr_pairs = (f' {attr}="{value}"' for attr, value
                    in sorted(attrs.items()))
    attr_str = ''.join(attr_pairs)
    if content:
        elements = (f'<{name}{attr_str}>{c}</{name}>'
                    for c in content)
        return '\n'.join(elements)
    else:
        return f'<{name}{attr_str} />'

A função tag pode ser invocada de muitas formas, como demonstra o Exemplo 10.

Exemplo 10. Algumas das muitas formas de invocar a função tag do Exemplo 9
>>> tag('br')  # (1)
'<br />'
>>> tag('p', 'hello')  # (2)
'<p>hello</p>'
>>> print(tag('p', 'hello', 'world'))
<p>hello</p>
<p>world</p>
>>> tag('p', 'hello', id=33)  # (3)
'<p id="33">hello</p>'
>>> print(tag('p', 'hello', 'world', class_='sidebar'))  # (4)
<p class="sidebar">hello</p>
<p class="sidebar">world</p>
>>> tag(content='testing', name="img")  # (5)
'<img content="testing" />'
>>> my_tag = {'name': 'img', 'title': 'Sunset Boulevard',
...           'src': 'sunset.jpg', 'class': 'framed'}
>>> tag(**my_tag)  # (6)
'<img class="framed" src="sunset.jpg" title="Sunset Boulevard" />'
  1. Um argumento posicional único produz uma tag vazia com aquele nome.

  2. Quaisquer argumentos após o primeiro serão capturados por *content na forma de uma tuple.

  3. Argumentos nomeados que não são mencionados explicitamente na assinatura de tag são capturados por **attrs como um dict.

  4. O parâmetro class_ só pode ser passado como um argumento nomeado.

  5. O primeiro argumento posicional também pode ser passado como argumento nomeado.

  6. Prefixar o dict my_tag com ** passa todos os seus itens como argumentos separados, que são então vinculados aos parâmetros nomeados, com o restante sendo capturado por **attrs. Nesse caso podemos ter um nome 'class' no dict de argumentos, porque ele é uma string, e não colide com a palavra reservada class.

Argumentos somente nomeados são um recurso do Python 3. No Exemplo 9, o parâmetro class_ só pode ser passado como um argumento nomeado—ele nunca captura argumentos posicionais não-nomeados. Para especificar argumentos somente nomeados ao definir uma função, eles devem ser nomeados após o argumento prefixado por *. Se você não quer incluir argumentos posicionais variáveis, mas ainda assim deseja incluir argumentos somente nomeados, coloque um * sozinho na assinatura, assim:

>>> def f(a, *, b):
...     return a, b
...
>>> f(1, b=2)
(1, 2)
>>> f(1, 2)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: f() takes 1 positional argument but 2 were given

Observe que argumentos somente nomeados não precisam ter um valor default: eles podem ser obrigatórios, como o b no exemplo acima.

7.7.1. Parâmetros somente posicionais

Desde o Python 3.8, assinaturas de funções definidas pelo usuário podem especificar parâmetros somente posicionais. Esse recurso sempre existiu para funções embutidas, tal como divmod(a, b), que só pode ser chamada com parâmetros posicionais, e não na forma divmod(a=10, b=4).

Para definir uma função que requer parâmetros somente posicionais, use / na lista de parâmetros.

Esse exemplo, de "O que há de novo no Python 3.8", mostra como emular a função embutida divmod:

def divmod(a, b, /):
    return (a // b, a % b)

Todos os argumentos à esquerda da / são somente posicionais. Após a /, você pode especificar outros argumentos, que funcionam como da forma usual.

⚠️ Aviso

Uma / na lista de parâmetros é um erro de sintaxe no Python 3.7 ou anteriores.

Por exemplo, considere a função tag do Exemplo 9. Se quisermos que o parâmetro name seja somente posicional, podemos acrescentar uma / após aquele parâmetro na assinatura da função, assim:

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

Você pode encontrar outros exemplos de parâmetros somente posicionais no já citado "O que há de novo no Python 3.8" e na PEP 570.

Após esse mergulho nos recursos flexíveis de declaração de argumentos no Python, o resto desse capítulo trata dos pacotes da biblioteca padrão mais úteis para programar em um estilo funcional.

7.8. Pacotes para programação funcional

Apesar de Guido deixar claro que não projetou o Python para ser uma linguagem de programação funcional, o estilo de programação funcional pode ser amplamente utilizado, graças a funções de primeira classe, pattern matching e o suporte de pacotes como operator e functools, dos quais falaremos nas próximas duas seções..

7.8.1. O módulo operator

Na programação funcional, é muitas vezes conveniente usar um operador aritmético como uma função. Por exemplo, suponha que você queira multiplicar uma sequência de números para calcular fatoriais, mas sem usar recursão. Para calcular a soma, podemos usar sum, mas não há uma função equivalente para multiplicação. Você poderia usar reduce—como vimos na Seção 7.3.1—mas isso exige um função para multiplicar dois itens da sequência. O Exemplo 11 mostra como resolver esse problema usando lambda.

Exemplo 11. Fatorial implementado com `reduce`e uma função anônima
from functools import reduce

def factorial(n):
    return reduce(lambda a, b: a*b, range(1, n+1))

O módulo operator oferece funções equivalentes a dezenas de operadores, para você não precisar escrever funções triviais como lambda a, b: a*b. Com ele, podemos reescrever o Exemplo 11 como o Exemplo 12.

Exemplo 12. Fatorial implementado com reduce e operator.mul
from functools import reduce
from operator import mul

def factorial(n):
    return reduce(mul, range(1, n+1))

Outro grupo de "lambdas de um só truque" que operator substitui são funções para extrair itens de sequências ou para ler atributos de objetos: itemgetter e attrgetter são fábricas que criam funções personalizadas para fazer exatamente isso.

O Exemplo 13 mostra um uso frequente de itemgetter: ordenar uma lista de tuplas pelo valor de um campo. No exemplo, as cidades são exibidas por ordem de código de país (campo 1). Essencialmente, itemgetter(1) cria uma função que, dada uma coleção, devolve o item no índice 1. Isso é mais fácil de escrever e ler que lambda fields: fields[1], que faz a mesma coisa.

Exemplo 13. Demonstração de itemgetter para ordenar uma lista de tuplas (mesmos dados do Exemplo 8)
>>> metro_data = [
...     ('Tokyo', 'JP', 36.933, (35.689722, 139.691667)),
...     ('Delhi NCR', 'IN', 21.935, (28.613889, 77.208889)),
...     ('Mexico City', 'MX', 20.142, (19.433333, -99.133333)),
...     ('New York-Newark', 'US', 20.104, (40.808611, -74.020386)),
...     ('São Paulo', 'BR', 19.649, (-23.547778, -46.635833)),
... ]
>>>
>>> from operator import itemgetter
>>> for city in sorted(metro_data, key=itemgetter(1)):
...     print(city)
...
('São Paulo', 'BR', 19.649, (-23.547778, -46.635833))
('Delhi NCR', 'IN', 21.935, (28.613889, 77.208889))
('Tokyo', 'JP', 36.933, (35.689722, 139.691667))
('Mexico City', 'MX', 20.142, (19.433333, -99.133333))
('New York-Newark', 'US', 20.104, (40.808611, -74.020386))

Se você passar múltiplos argumentos de indice para itemgetter, a função criada por ela vai devolver tuplas com os valores extraídos, algo que pode ser útil para ordenar usando chaves múltiplas:

>>> cc_name = itemgetter(1, 0)
>>> for city in metro_data:
...     print(cc_name(city))
...
('JP', 'Tokyo')
('IN', 'Delhi NCR')
('MX', 'Mexico City')
('US', 'New York-Newark')
('BR', 'São Paulo')
>>>

Como itemgetter usa o operador [], ela suporta não apenas sequências, mas também mapeamentos e qualquer classe que implemente __getitem__.

Uma irmã de itemgetter é attrgetter, que cria funções para extrair atributos por nome. Se você passar os nomes de vários atributos como argumentos para attrgetter, ela vai devolver um tupla de valores. Além disso, se o nome de qualquer argumento contiver um . (ponto), attrgetter navegará por objetos aninhados para encontrar o atributo. Esses comportamento são apresentados no Exemplo 14. Não é exatamente uma sessão de console curta, pois precisamos criar uma estrutura aninhada para demonstrar o tratamento de atributos com . por attrgetter.

Exemplo 14. Demonstração de attrgetter para processar uma lista previamente definida de namedtuple chamada metro_data (a mesma lista que aparece no Exemplo 13)
>>> from collections import namedtuple
>>> LatLon = namedtuple('LatLon', 'lat lon')  # (1)
>>> Metropolis = namedtuple('Metropolis', 'name cc pop coord')  # (2)
>>> metro_areas = [Metropolis(name, cc, pop, LatLon(lat, lon))  # (3)
...     for name, cc, pop, (lat, lon) in metro_data]
>>> metro_areas[0]
Metropolis(name='Tokyo', cc='JP', pop=36.933, coord=LatLon(lat=35.689722,
lon=139.691667))
>>> metro_areas[0].coord.lat  # (4)
35.689722
>>> from operator import attrgetter
>>> name_lat = attrgetter('name', 'coord.lat')  # (5)
>>>
>>> for city in sorted(metro_areas, key=attrgetter('coord.lat')):  # (6)
...     print(name_lat(city))  # (7)
...
('São Paulo', -23.547778)
('Mexico City', 19.433333)
('Delhi NCR', 28.613889)
('Tokyo', 35.689722)
('New York-Newark', 40.808611)
  1. Usa namedtuple para definir LatLon.

  2. Também define Metropolis.

  3. Cria a lista metro_areas com instâncias de Metropolis; observe o desempacotamento da tupla aninhada para extrair (lat, lon) e usá-los para criar o LatLon do atributo coord de Metropolis.

  4. Obtém a latitude de dentro de metro_areas[0] .

  5. Define um attrgetter para obter name e o atributo aninhado coord.lat.

  6. Usa attrgetter novamente para ordenar uma lista de cidades pela latitude.

  7. Usa o attrgetter definido em 5 para exibir apenas o nome e a latitude da cidade.

Abaixo está uma lista parcial das funções definidas em operator (nomes iniciando com _ foram omitidos por serem, em sua maioria, detalhes de implementação):

>>> [name for name in dir(operator) if not name.startswith('_')]
['abs', 'add', 'and_', 'attrgetter', 'concat', 'contains',
'countOf', 'delitem', 'eq', 'floordiv', 'ge', 'getitem', 'gt',
'iadd', 'iand', 'iconcat', 'ifloordiv', 'ilshift', 'imatmul',
'imod', 'imul', 'index', 'indexOf', 'inv', 'invert', 'ior',
'ipow', 'irshift', 'is_', 'is_not', 'isub', 'itemgetter',
'itruediv', 'ixor', 'le', 'length_hint', 'lshift', 'lt', 'matmul',
'methodcaller', 'mod', 'mul', 'ne', 'neg', 'not_', 'or_', 'pos',
'pow', 'rshift', 'setitem', 'sub', 'truediv', 'truth', 'xor']

A maior parte dos 54 nomes listados é auto-evidente. O grupo de nomes formados por um i inicial e o nome de outro operador—por exemplo iadd, iand, etc—correspondem aos operadores de atribuição aumentada—por exemplo, +=, &=, etc. Essas funções mudam seu primeiro argumento no mesmo lugar, se o argumento for mutável; se não, funcionam como seus pares sem o prefixo i: simplemente devolvem o resultado da operação.

Das funções restantes de operator, methodcaller será a última que veremos. Ela é algo similar a attrgetter e itemgetter, no sentido de criarem uma função durante a execução. A função criada invoca por nome um método do objeto passado como argumento, como mostra o Exemplo 15.

Exemplo 15. Demonstração de methodcaller: o segundo teste mostra a vinculação de argumentos adicionais
>>> from operator import methodcaller
>>> s = 'The time has come'
>>> upcase = methodcaller('upper')
>>> upcase(s)
'THE TIME HAS COME'
>>> hyphenate = methodcaller('replace', ' ', '-')
>>> hyphenate(s)
'The-time-has-come'

O primeiro teste no Exemplo 15 está ali apenas para mostrar o funcionamento de methodcaller; se você precisa usar str.upper como uma função, basta chamá-lo na classe str, passando uma string como argumento, assim:

>>> str.upper(s)
'THE TIME HAS COME'

O segundo teste do Exemplo 15 mostra que methodcaller pode também executar uma aplicação parcial para fixar alguns argumentos, como faz a função functools.partial. Esse é nosso próximo tópico.

7.8.2. Fixando argumentos com functools.partial

O módulo functools oferece várias funções de ordem superior. Já vimos reduce na Seção 7.3.1. Uma outra é partial: dado um invocável, ela produz um novo invocável com alguns dos argumentos do invocável original vinculados a valores pré-determinados. Isso é útil para adaptar uma função que recebe um ou mais argumentos a uma API que requer uma função de callback com menos argumentos. O Exemplo 16 é uma demonstração trivial.

Exemplo 16. Empregando partial para usar uma função com dois argumentos onde é necessário um invocável com apenas um argumento
>>> from operator import mul
>>> from functools import partial
>>> triple = partial(mul, 3)  (1)
>>> triple(7)  (2)
21
>>> list(map(triple, range(1, 10)))  (3)
[3, 6, 9, 12, 15, 18, 21, 24, 27]
  1. Cria uma nova função triple a partir de mul, vinculando o primeiro argumento posicional a 3.

  2. Testa a função.

  3. Usa triple com map; mul não funcionaria com map nesse exemplo.

Um exemplo mais útil envolve a função unicode.normalize, que vimos na Seção 4.7. Se você trabalha com texto em muitas línguas diferentes, pode querer aplicar unicode.normalize('NFC', s) a qualquer string s, antes de compará-la ou armazená-la. Se você precisa disso com frequência, é conveninete ter uma função nfc para executar essa tarefa, como no Exemplo 17.

Exemplo 17. Criando uma função conveniente para normalizar Unicode com partial
>>> import unicodedata, functools
>>> nfc = functools.partial(unicodedata.normalize, 'NFC')
>>> s1 = 'café'
>>> s2 = 'cafe\u0301'
>>> s1, s2
('café', 'café')
>>> s1 == s2
False
>>> nfc(s1) == nfc(s2)
True

partial recebe um invocável como primeiro argumento, seguido de um número arbitrário de argumentos posicionais e nomeados para vincular.

O Exemplo 18 mostra o uso de partial com a função tag (do Exemplo 9), para fixar um argumento posicional e um argumento nomeado.

Exemplo 18. Demonstração de partial aplicada à função tag, do Exemplo 9
>>> from tagger import tag
>>> tag
<function tag at 0x10206d1e0>  (1)
>>> from functools import partial
>>> picture = partial(tag, 'img', class_='pic-frame')  (2)
>>> picture(src='wumpus.jpeg')
'<img class="pic-frame" src="wumpus.jpeg" />'  (3)
>>> picture
functools.partial(<function tag at 0x10206d1e0>, 'img', class_='pic-frame')  (4)
>>> picture.func  (5)
<function tag at 0x10206d1e0>
>>> picture.args
('img',)
>>> picture.keywords
{'class_': 'pic-frame'}
  1. Importa tag do Exemplo 9 e mostra seu ID.

  2. Cria a função picture a partir de tag, fixando o primeiro argumento posicional em 'img' e o argumento nomeado class_ em 'pic-frame'.

  3. picture funciona como esperado.

  4. partial() devolve um objeto functools.partial.[80]

  5. Um objeto functools.partial tem atributos que fornecem acesso à função original e aos argumentos fixados.

A função functools.partialmethod faz o mesmo que partial, mas foi projetada para trabalhar com métodos.

O módulo functools também inclui funções de ordem superior para serem usadas como decoradores de função, tais como cache e singledispatch, entre outras. Essas funções são tratadas no Capítulo 9, que também explica como implementar decoradores personalizados.

7.9. Resumo do capítulo

O objetivo deste capítulo foi explorar a natureza das funções como objetos de primeira classe no Python. As principais consequências disso são a possibilidade de atribuir funções a variáveis, passá-las para outras funções, armazená-las em estruturas de dados e acessar os atributos de funções, permitindo que frameworks e ferramentas usem essas informações.

Funções de ordem superior, parte importante da programação funcional, são comuns no Python. As funções embutidas sorted, min e max, além de functools.partial, são exemplos de funções de ordem superior muito usadas na linguagem. O uso de map, filter e reduce já não é tão frequente como costumava ser, graças às compreensões de lista (e estruturas similares, como as expressões geradoras) e à adição de funções embutidas de redução como sum, all e any.

Desde o Python 3.6, existem nove sabores de invocáveis, de funções simples criadas com lambda a instâncias de classes que implementam __call__. Geradoras e corrotinas também são invocáveis, mas seu comportamento é muito diferente daquele de outros invocáveis. Todos os invocáveis podem ser detectados pela função embutida callable(). Invocáveis oferecem uma rica sintaxe para declaração de parâmetros formais, incluindo parâmetros nomeados, parâmetros somente posicionais e anotações.

Por fim, vimos algumas funções do módulo operator e functools.partial, que facilitam a programação funcional, minimizando a necessidade de uso da sintaxe funcionalmente inepta de lambda.

7.10. Leitura complementar

Nos próximos capítulos, continuaremos nossa jornada pela programação com objetos função. O Capítulo 8 é dedicado às dicas de tipo nos parâmetros de função e nos valores devolvidos por elas. O Capítulo 9 mergulha nos decoradores de função—um tipo especial de função de ordem superior—e no mecanismo de clausura (closure) que os faz funcionar. O Capítulo 10 mostra como as funções de primeira classe podem simplificar alguns padrões clássicos de projetos (design patterns) orientados a objetos.

Em A Referência da Linguagem Python, a seção "3.2. A hierarquia de tipos padrão" mostra os noves tipos invocáveis, juntamente com todos os outros tipos embutidos.

O capítulo 7 do Python Cookbook (EN), 3ª ed. (O’Reilly), de David Beazley e Brian K. Jones, é um excelente complemento a esse capítulo, bem como ao Capítulo 9, tratando basicamente dos mesmos conceitos, mas com uma abordagem diferente.

Veja a PEP 3102—​Keyword-Only Arguments (Argumentos somente nomeados) (EN) se você estiver interessada na justificativa e nos casos desse recurso.

Uma ótima introdução à programação funcional em Python é o "Programação Funcional COMO FAZER", de A. M. Kuchling. O principal foco daquele texto, entretanto, é o uso de iteradores e geradoras, assunto do Capítulo 17.

A questão no StackOverflow, "Python: Why is functools.partial necessary?" (Python: Por que functools.partial é necessária?) (EN), tem uma resposta muito informativa (e engraçada) escrita por Alex Martelli, co-autor do clássico Python in a Nutshell (O’Reilly).

Refletindo sobre a pergunta "Seria o Python uma linguagem funcional?", criei uma de minhas palestras favoritas, "Beyond Paradigms" ("Para Além dos Paradigmas"), que apresentei na PyCaribbean, na PyBay e na PyConDE. Veja os slides (EN) e o vídeo (EN) da apresentação em Berlim—onde conheci Miroslav Šedivý e Jürgen Gmach, dois dos revisores técnicos desse livro.

Ponto de vista

O Python é uma linguagem funcional?

Em algum momento do ano 2000, eu estava participando de uma oficina de Zope na Zope Corporation, nos EUA, quando Guido van Rossum entrou na sala (ele não era o instrutor). Na seção de perguntas e respostas que se seguiu, alguém perguntou quais recursos do Python ele tinha trazido de outras linguagens. A resposta de Guido: "Tudo que é bom no Python foi roubado de outras linguagens."

Shriram Krishnamurthi, professor de Ciência da Computação na Brown University, inicia seu artigo, "Teaching Programming Languages in a Post-Linnaean Age" (Ensinando Linguagens de Programação em uma Era Pós-Taxonomia-de-Lineu) (EN), assim:

Os "paradigmas" de linguagens de programação são um legado moribundo e tedioso de uma era passada. Os atuais projetistas de linguagens não tem qualquer respeito por eles, então por que nossos cursos aderem servilmente a tais "paradigmas"?

Nesse artigo, o Python é mencionado nominalmente na seguinte passagem:

E como descrever linguagens como Python, Ruby, ou Perl? Seus criadores não tem paciência com as sutilezas dessas nomenclaturas de Lineu; eles pegam emprestados todos os recursos que desejam, criando misturas que desafiam totalmente uma caracterização.

Krishnamurthi argumenta que, ao invés de tentar classificar as linguagens com alguma taxonomia, seria mais útil olhar para elas como agregados de recursos. Suas ideias inspiraram minha palestra "Beyond Paradigms" ("Para Além dos Paradigmas"), mencionada no final da Seção 7.10.

Mesmo se esse não fosse o objetivo de Guido, dotar o Python de funções de primeira classe abriu as portas para a programação funcional. Em seu post, "Origins of Python’s 'Functional' Features" (_As Origens dos Recursos 'Funcionais' dp Python) (EN), ele afirma que map, filter, e reduce foram a primeira motivação para a inclusão do lambda ao Python. Todos esses recursos foram adicionados juntos ao Python 1.0 em 1994, por Amrit Prem , de acordo com o Misc/HISTORY (EN) no código-fonte do CPython.

Funções como map, filter, e reduce surgiram inicialmente no Lisp, a linguagem funcional original. O Lisp, entretanto, não limita o que pode ser feito dentro de uma lambda, pois tudo em List é uma expressão. O Python usa uma sintaxe orientada a comandos, na qual as expressões não podem conter comandos, e muitas das estruturas da linguagem são comandos—​incluindo`try/catch`, que é o que eu mais sinto falta quando escrevo uma lambda. É o preço a pagar pela sintaxe extremamente legível do Python.[81] O Lisp tem muitas virtudes, mas legibilidade não é uma delas.

Ironicamente, roubar a sintaxe de compreensão de lista de outra linguagem funcional—Haskell—reduziu significativamente a necessidade de usar map e filter, e também lambda.

Além da sintaxe limitada das funções anônimas, o maior obstáculo para uma adoção mais ampla de idiomas de programação funcional no Python é a ausência da eliminação de chamadas de cauda, uma otimização que permite o processamento, de forma eficiente em termos de memória, de uma função que faz uma chamada recursiva na "cauda" de seu corpo. Em outro post de blog, "Tail Recursion Elimination" (Eliminação de Recursão de Cauda) (EN), Guido apresenta várias razões pelas quais tal otimização não é adequada ao Python. O post é uma ótima leitura por seus argumentos técnicos, mas mais ainda pelas primeiras três e mais importantes razões dadas serem questões de usabilidade. O Python não é gostoso de usar, aprender e ensinar por acidente. Guido o fez assim.

Então cá estamos: o Python não é, por projeto, uma linguagem funcional—seja lá o quê isso signifique. O Python só pega emprestadas algumas boas ideias de linguagens funcionais.

O problema das funções anônimas

Além das restrições sintáticas específicas do Python, funções anônimas tem uma séria desvantagem em qualquer linguagem: elas não tem nome.

Estou brincando, mas não muito. Os stack traces são mais fáceis de ler quando as funções tem nome. Funções anônimas são um atalho conveniente, nos divertimos programando com elas, mas algumas vezes elas são levadas longe demais—especialmente se a linguagem e o ambiente encorajam o aninhamento profundo de funções anônimas, com faz o Javascript combinado com o Node.js. Ter muitas funções anônimas aninhadas torna a depuração e o tratamento de erros mais difíceis. A programação assíncrona no Python é mais estruturada, talvez pela sintaxe limitada do lambda impedir seu abuso e forçar uma abordagem mais explícita. Promessas, futuros e diferidos são conceitos usados nas APIs assíncronas modernas. Prometo escrever mais sobre programação assíncrona no futuro, mas esse assunto será diferido[82] até o Capítulo 21.

8. 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. Capítulo 15 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.

8.1. 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.

8.2. 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 Exemplo 13.

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.[83]

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.

👉 Dica

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.

8.3. 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.

✒️ Nota

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:

>>> show_count(99, 'bird')
'99 birds'
>>> show_count(1, 'bird')
'1 bird'
>>> show_count(0, 'bird')
'no birds'

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

Exemplo 19. show_count de messages.py sem dicas de tipo.
def show_count(count, word):
    if count == 1:
        return f'1 {word}'
    count_str = str(count) if count else 'no'
    return f'{count_str} {word}s'

8.3.1. 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 19.

⚠️ Aviso

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 20 também inclui testes de unidade do pytest. Este é código de messages_test.py.

Exemplo 20. messages_test.py sem dicas de tipo.
from pytest import mark

from messages import show_count

@mark.parametrize('qty, expected', [
    (1, '1 part'),
    (2, '2 parts'),
])
def test_show_count(qty, expected):
    got = show_count(qty, 'part')
    assert got == expected

def test_show_count_zero():
    got = show_count(0, 'part')
    assert got == 'no parts'

Agora vamos acrescentar dicas de tipo, guiados pelo Mypy.

8.3.2. 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:
👉 Dica

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

8.3.3. Um valor default para um argumento

A função show_count no Exemplo 19 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 21.

Exemplo 21. showcount de hints_2/messages.py com um argumento opcional
def show_count(count: int, singular: str, plural: str = '') -> str:
    if count == 1:
        return f'1 {singular}'
    count_str = str(count) if count else 'no'
    if not plural:
        plural = singular + 's'
    return f'{count_str} {plural}'

E agora o Mypy reporta "Success."

⚠️ Aviso

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.

8.3.4. Usando None como default

No Exemplo 21, 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 Seção 6.5.1.

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.

⚠️ Aviso

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.

8.4. 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.[84]

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.[85]

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 22 é 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.[86]

Exemplo 22. birds.py
class Bird:
    pass

class Duck(Bird):  # (1)
    def quack(self):
        print('Quack!')

def alert(birdie):  # (2)
    birdie.quack()

def alert_duck(birdie: Duck) -> None:  # (3)
    birdie.quack()

def alert_bird(birdie: Bird) -> None:  # (4)
    birdie.quack()
  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 23.

Exemplo 23. daffy.py
from birds import *

daffy = Duck()
alert(daffy)       # (1)
alert_duck(daffy)  # (2)
alert_bird(daffy)  # (3)
  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 24.

Exemplo 24. woody.py
from birds import *

woody = Bird()
alert(woody)
alert_duck(woody)
alert_bird(woody)

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 25.

Exemplo 25. 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 22 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 23.

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.

✒️ Nota

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 Seção 8.5.10. Mas antes disso, há tipos mais importantes para conhecer.

8.5. 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.

8.5.1. 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[87] 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

👉 Dica

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.

8.5.2. 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 Seção 8.5.7.

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.

👉 Dica
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.

8.5.3. Os tipos Optional e Union

Nós vimos o tipo especial Optional em Seção 8.3.4. 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.

👉 Dica
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:[88]

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.

👉 Dica

Na Seção 4.10, 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 Seção 8.5.9 - ou sobrecarga (overloading), que veremos na Seção 15.2.

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.

8.5.4. 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.[89]

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 26.

Exemplo 26. 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.

👉 Dica

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 27.

Exemplo 27. 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 28 mostra como anotar tokenize de uma forma que funciona com Python ≥ 3.5.

Exemplo 28. 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 15 mostra alguns deles. Para a lista completa, consulte a documentação do módulo typing .

Tabela 15. 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.[90] 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.

8.5.5. 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 29 mostra a definição da função geohash, usando o pacote geolib do PyPI.

Exemplo 29. coordinates.py com a função geohash
from geolib import geohash as gh  # type: ignore  # (1)

PRECISION = 9

def geohash(lat_lon: tuple[float, float]) -> str:  # (2)
    return gh.encode(*lat_lon, PRECISION)
  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.

👉 Dica

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 Capítulo 5. O Exemplo 30 mostra uma variante de Exemplo 29 com NamedTuple.

Exemplo 30. coordinates_named.py com NamedTuple, Coordinates e a função geohash
from typing import NamedTuple

from geolib import geohash as gh  # type: ignore

PRECISION = 9

class Coordinate(NamedTuple):
    lat: float
    lon: float

def geohash(lat_lon: Coordinate) -> str:
    return gh.encode(*lat_lon, PRECISION)

Como explicado na Seção 5.2, 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:

def display(lat_lon: tuple[float, float]) -> str:
    lat, lon = lat_lon
    ns = 'N' if lat >= 0 else 'S'
    ew = 'E' if lon >= 0 else 'W'
    return f'{abs(lat):0.1f}°{ns}, {abs(lon):0.1f}°{ew}'
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 31 mostra a implementação de columnize. Observe o tipo do retorno:

list[tuple[str, ...]]
Exemplo 31. columnize.py retorna uma lista de tuplas de strings
from collections.abc import Sequence

def columnize(
    sequence: Sequence[str], num_columns: int = 0
) -> list[tuple[str, ...]]:
    if num_columns == 0:
        num_columns = round(len(sequence) ** 0.5)
    num_rows, reminder = divmod(len(sequence), num_columns)
    num_rows += bool(reminder)
    return [tuple(sequence[i::num_rows]) for i in range(num_rows)]

8.5.6. 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 32 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 Exemplo 21 mais adequada para código server-side (também chamado back-end), como veremos no Capítulo 21.

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 32 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 32. charindex.py
import sys
import re
import unicodedata
from collections.abc import Iterator

RE_WORD = re.compile(r'\w+')
STOP_CODE = sys.maxunicode + 1

def tokenize(text: str) -> Iterator[str]:  # (1)
    """return iterable of uppercased words"""
    for match in RE_WORD.finditer(text):
        yield match.group().upper()

def name_index(start: int = 32, end: int = STOP_CODE) -> dict[str, set[str]]:
    index: dict[str, set[str]] = {}  # (2)
    for char in (chr(i) for i in range(start, end)):
        if name := unicodedata.name(char, ''):  # (3)
            for word in tokenize(name):
                index.setdefault(word, set()).add(char)
    return index
  1. tokenize é uma função geradora. Capítulo 17 é 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.[91]

✒️ Nota

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 Seção 15.3.

8.5.7. Classes bases abstratas

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

— lei de Postel
ou o Princípio da Robustez

A Tabela 15 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 Seção 3.6.5. 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.[92]

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 Seção 8.5.4 — 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 Seção 13.6.8, em Capítulo 13, 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 Seção 13.6.2.

A Seção 8.5.10 abaixo é um pré-requisito para entender protocolos numéricos.

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

8.5.8. 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:
👉 Dica
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 Seção 8.6.

O Exemplo 33 é 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 33 mostra a implementação.

Exemplo 33. replacer.py
from collections.abc import Iterable

FromTo = tuple[str, str]  # (1)

def zip_replace(text: str, changes: Iterable[FromTo]) -> str:  # (2)
    for from_, to in changes:
        text = text.replace(from_, to)
    return text
  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.

👉 Dica
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 31 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 32, está intimamente relacionado a Iterable. Voltaremos a ele em Capítulo 17, que trata de geradores e iteradores clássicos.

8.5.9. 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 34 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 34 mostra a implementação.

Exemplo 34. sample.py
from collections.abc import Sequence
from random import shuffle
from typing import TypeVar

T = TypeVar('T')

def sample(population: Sequence[T], size: int) -> list[T]:
    if size < 1:
        raise ValueError('size must be >= 1')
    result = list(population)
    shuffle(result)
    return result[:size]

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].

✒️ Nota
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 35.

Exemplo 35. mode_float.py: mode que opera com float e seus subtipos [93]
from collections import Counter
from collections.abc import Iterable

def mode(data: Iterable[float]) -> float:
    pairs = Counter(data).most_common(1)
    if len(pairs) == 0:
        raise ValueError('no mode for empty data')
    return pairs[0][0]

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 35, 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 36, temos bound=Hashable. Isso significa que o tipo do parâmetro pode ser Hashable ou qualquer subtipo-de Hashable.[94]

Exemplo 36. mode_hashable.py: igual a Exemplo 35, mas com uma assinatura mais flexível
from collections import Counter
from collections.abc import Iterable, Hashable
from typing import TypeVar

HashableT = TypeVar('HashableT', bound=Hashable)

def mode(data: Iterable[HashableT]) -> HashableT:
    pairs = Counter(data).most_common(1)
    if len(pairs) == 0:
        raise ValueError('no mode for empty data')
    return pairs[0][0]

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.

✒️ Nota

É 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 Capítulo 15, Seção 15.7.

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.

8.5.10. Protocolos estáticos

✒️ Nota

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 Capítulo 13. 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:

>>> top([4, 1, 5, 2, 6, 7, 3], 3)
[7, 6, 5]
>>> l = 'mango pear apple kiwi banana'.split()
>>> top(l, 3)
['pear', 'mango', 'kiwi']
>>>
>>> l2 = [(len(s), s) for s in l]
>>> l2
[(5, 'mango'), (4, 'pear'), (5, 'apple'), (4, 'kiwi'), (6, 'banana')]
>>> top(l2, 3)
[(6, 'banana'), (5, 'mango'), (5, 'apple')]

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

Exemplo 37. 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:[95]

>>> 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 37 deveria ser limitado a tipos que implementam __lt__. No Exemplo 36, 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 38 mostra o novo tipo SupportsLessThan, um Protocol.

Exemplo 38. comparable.py: a definição de um tipo Protocol, SupportsLessThan
from typing import Protocol, Any

class SupportsLessThan(Protocol):  # (1)
    def __lt__(self, other: Any) -> bool: ...  # (2)
  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 39.

Exemplo 39. top.py: definição da função top usando uma TypeVar com bound=SupportsLessThan
from collections.abc import Iterable
from typing import TypeVar

from comparable import SupportsLessThan

LT = TypeVar('LT', bound=SupportsLessThan)

def top(series: Iterable[LT], length: int) -> list[LT]:
    ordered = sorted(series, reverse=True)
    return ordered[:length]

Vamos testar top. O Exemplo 40 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 40. top_test.py: visão parcial da bateria de testes para top
from collections.abc import Iterator
from typing import TYPE_CHECKING  # (1)

import pytest

from top import top

# muitas linhas omitidas

def test_top_tuples() -> None:
    fruit = 'mango pear apple kiwi banana'.split()
    series: Iterator[tuple[int, str]] = (  # (2)
        (len(s), s) for s in fruit)
    length = 3
    expected = [(6, 'banana'), (5, 'mango'), (5, 'apple')]
    result = top(series, length)
    if TYPE_CHECKING:  # (3)
        reveal_type(series)  # (4)
        reveal_type(expected)
        reveal_type(result)
    assert result == expected

# intentional type error
def test_top_objects_error() -> None:
    series = [object() for _ in range(4)]
    if TYPE_CHECKING:
        reveal_type(series)
    with pytest.raises(TypeError) as excinfo:
        top(series, 3)  # (5)
    assert "'<' not supported" in str(excinfo.value)
  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.[96]

  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 41.

⚠️ Aviso

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 41. 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 39 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.[97]

Há mais para falar sobre typing.Protocol. Vamos voltar a ele na Parte IV, onde Capítulo 13 compara as abordagens da tipagem estrutural, do duck typing e dos ABCs - outro modo de formalizar protocolos. Além disso, a Seção 15.2 (no Capítulo 15) 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.

✒️ Nota

O typing.Protocol torna possível anotar a função double na Seção 8.4 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 Seção 13.6.1 (Capítulo 13).

8.5.11. 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 Seção 18.3:[98]

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 42. 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 42. Ilustrando a variância.
from collections.abc import Callable

def update(  # (1)
        probe: Callable[[], float],  # (2)
        display: Callable[[float], None]  # (3)
    ) -> None:
    temperature = probe()
    # imagine lots of control code here
    display(temperature)

def probe_ok() -> int:  # (4)
    return 42

def display_wrong(temperature: int) -> None:  # (5)
    print(hex(temperature))

update(probe_ok, display_wrong)  # type error  # (6)

def display_ok(temperature: complex) -> None:  # (7)
    print(temperature)

update(probe_ok, display_ok)  # OK  # (8)
  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 Seção 15.7 no Capítulo 15 explica variância em mais detalhes e com exemplos de tipos invariantes, covariantes e contravariantes.

👉 Dica

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.

8.5.12. 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 Capítulo 24, o Exemplo 6 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

8.6. Anotando parâmetros apenas posicionais e variádicos

Lembra da função tag do Exemplo 9? Da última vez que vimos sua assinatura foi em Seção 7.7.1:

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.

8.7. 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.

✒️ Nota

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 Capítulo 15, 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.

8.8. 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[99] 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.[100]

— Guido van Rossum

8.9. 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 Capítulo 15 cobre outros tópicos sobre tipagem, e a Seção 15.10 traz referências adicionais, incluindo a Tabela 16, 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 Seção 15.2.1 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..[101]

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."

9. Decoradores e Clausuras

Houve uma certa quantidade de reclamações sobre a escolha do nome "decorador" para esse recurso. A mais frequente foi sobre o nome não ser consistente com seu uso no livro da GoF.[102] O nome decorator provavelmente se origina de seu uso no âmbito dos compiladores—​uma árvore sintática é percorrida e anotada.

— PEP 318—Decorators for Functions and Methods ("Decoradores para Funções e Métodos"—EN)

Decoradores de função nos permitem "marcar" funções no código-fonte, para aprimorar de alguma forma seu comportamento. É um mecanismo muito poderoso. Por exemplo, o decorador @functools.cache armazena um mapeamento de argumentos para resultados, e depois usa esse mapeamento para evitar computar novamente o resultado quando a função é chamada com argumentos já vistos. Isso pode acelerar muito uma aplicação.

Mas para dominar esse recurso é preciso antes entender clausuras (closures)—o nome dado à estrutura onde uma função captura variáveis presentes no escopo onde a função é definida, necessárias para a execução da função futuramente.[103]

A palavra reservada mais obscura do Python é nonlocal, introduzida no Python 3.0. É perfeitamente possível ter uma vida produtiva e lucrativa programando em Python sem jamais usá-la, seguindo uma dieta estrita de orientação a objetos centrada em classes. Entretanto, caso queira implementar seus próprios decoradores de função, precisa entender clausuras, e então a necessidade de nonlocal fica evidente.

Além de sua aplicação aos decoradores, clausuras também são essenciais para qualquer tipo de programação utilizando callbacks, e para codar em um estilo funcional quando isso fizer sentido.

O objetivo último deste capítulo é explicar exatamente como funcionam os decoradores de função, desde simples decoradores de registro até os complicados decoradores parametrizados. Mas antes de chegar a esse objetivo, precisamos tratar de:

  • Como o Python analisa a sintaxe de decoradores

  • Como o Python decide se uma variável é local

  • Porque clausuras existem e como elas funcionam

  • Qual problema é resolvido por nonlocal

Após criar essa base, poderemos então enfrentar os outros tópicos relativos aos decoradores:

  • A implementação de um decorador bem comportado

  • Os poderosos decoradores na biblioteca padrão: @cache, @lru_cache, e @singledispatch

  • A implementação de um decorador parametrizado

9.1. Novidades nesse capítulo

O decorador de caching functools.cache—introduzido no Python 3.9—é mais simples que o tradicional functools.lru_cache, então falo primeiro daquele. Este último é tratado na Seção 9.9.2, incluindo a forma simplificada introduzida no Python 3.8.

A Seção 9.9.3 foi expandida e agora inclui dicas de tipo, a forma recomendada de usar functools.singledispatch desde o Python 3.7.

A Seção 9.10 agora inclui um exemplo baseado em classes, o Exemplo 27.

Transferi o #rethinking_design_patterns para o final da Parte II: Funções como objetos, para melhorar a fluidez do livro. E a Seção 10.3 também aparece agora naquele capítulo, juntamente com outras variantes do padrão de projeto Estratégia usando invocáveis.

Começamos com uma introdução muito suave aos decoradores, e dali seguiremos para o restante dos tópicos listados no início do capítulo.

9.2. Introdução aos decoradores

Um decorador é um invocável que recebe outra função como um argumento (a função decorada).

Um decorador pode executar algum processamento com a função decorada, e ou a devolve ou a substitui por outra função ou por um objeto invocável.[104]

Em outras palavras, supondo a existência de um decorador chamado decorate, esse código:

@decorate
def target():
    print('running target()')

tem o mesmo efeito de:

def target():
    print('running target()')

target = decorate(target)

O resultado final é o mesmo: após a execução de qualquer dos dois trechos, o nome target está vinculado a qualquer que seja a função devolvida por decorate(target)—que tanto pode ser a função inicialmente chamada target quanto uma outra função diferente.

Para confirmar que a função decorada é substituída, veja a sessão de console no Exemplo 1.

Exemplo 1. Um decorador normalmente substitui uma função por outra, diferente
>>> def deco(func):
...     def inner():
...         print('running inner()')
...     return inner  (1)
...
>>> @deco
... def target():  (2)
...     print('running target()')
...
>>> target()  (3)
running inner()
>>> target  (4)
<function deco.<locals>.inner at 0x10063b598>
  1. deco devolve seu objeto função inner.

  2. target é decorada por deco.

  3. Invocar a target decorada causa, na verdade, a execução de inner.

  4. A inspeção revela que target é agora uma referência a inner.

Estritamente falando, decoradores são apenas açúcar sintático. Como vimos, é sempre possível chamar um decorador como um invocável normal, passando outra função como parâmetro. Algumas vezes isso inclusive é conveniente, especialmente quando estamos fazendo metaprogramação—mudando o comportamento de um programa durante a execução.

Três fatos essenciais nos dão um bom resumo dos decoradores:

  • Um decorador é uma função ou outro invocável.

  • Um decorador pode substituir a função decorada por outra, diferente.

  • Decoradores são executados imediatamente quando um módulo é carregado.

Vamos agora nos concentrar nesse terceiro ponto.

9.3. Quando o Python executa decoradores

Uma característica fundamental dos decoradores é serem executados logo após a função decorada ser definida. Isso normalmente acontece no tempo de importação (isto é, quando um módulo é carregado pelo Python). Observe registration.py no Exemplo 2.

Exemplo 2. O módulo registration.py
registry = []  # (1)

def register(func):  # (2)
    print(f'running register({func})')  # (3)
    registry.append(func)  # (4)
    return func  # (5)

@register  # (6)
def f1():
    print('running f1()')

@register
def f2():
    print('running f2()')

def f3():  # (7)
    print('running f3()')

def main():  # (8)
    print('running main()')
    print('registry ->', registry)
    f1()
    f2()
    f3()

if __name__ == '__main__':
    main()  # (9)
  1. registry vai manter referências para funções decoradas por @register.

  2. register recebe uma função como argumento.

  3. Exibe a função que está sendo decorada, para fins de demonstração.

  4. Insere func em registry.

  5. Devolve func: precisamos devolver uma função; aqui devolvemos a mesma função recebida como argumento.

  6. f1 e f2 são decoradas por @register.

  7. f3 não é decorada.

  8. main mostra registry, depois chama f1(), f2(), e f3().

  9. main() só é invocada se registration.py for executado como um script.

O resultado da execução de registration.py se parece com isso:

$ python3 registration.py
running register(<function f1 at 0x100631bf8>)
running register(<function f2 at 0x100631c80>)
running main()
registry -> [<function f1 at 0x100631bf8>, <function f2 at 0x100631c80>]
running f1()
running f2()
running f3()

Observe que register roda (duas vezes) antes de qualquer outra função no módulo. Quando register é chamada, ela recebe o objeto função a ser decorado como argumento—por exemplo, <function f1 at 0x100631bf8>.

Após o carregamento do módulo, a lista registry contém referências para as duas funções decoradas: f1 e f2. Essa funções, bem como f3, são executadas apenas quando chamadas explicitamente por main.

Se registration.py for importado (e não executado como um script), a saída é essa:

>>> import registration
running register(<function f1 at 0x10063b1e0>)
running register(<function f2 at 0x10063b268>)

Nesse momento, se você inspecionar registry, verá isso:

>>> registration.registry
[<function f1 at 0x10063b1e0>, <function f2 at 0x10063b268>]

O ponto central do Exemplo 2 é enfatizar que decoradores de função são executados assim que o módulo é importado, mas as funções decoradas só rodam quando são invocadas explicitamente. Isso ressalta a diferença entre o que pythonistas chamam de tempo de importação e tempo de execução.

9.4. Decoradores de registro

Considerando a forma como decoradores são normalmente usados em código do mundo real, o Exemplo 2 é incomum por duas razões:

  • A função do decorador é definida no mesmo módulo das funções decoradas. Em geral, um decorador real é definido em um módulo e aplicado a funções de outros módulos.

  • O decorador register devolve a mesma função recebida como argumento. Na prática, a maior parte dos decoradores define e devolve uma função interna.

Apesar do decorador register no Exemplo 2 devolver a função decorada inalterada, a técnica não é inútil. Decoradores parecidos são usados por muitos frameworks Python para adicionar funções a um registro central—por exemplo, um registro mapeando padrões de URLs para funções que geram respostas HTTP. Tais decoradores de registro podem ou não modificar as funções decoradas.

Vamos ver um decorador de registro em ação na Seção 10.3 (do Capítulo 10).

A maioria dos decoradores modificam a função decorada. Eles normalmente fazem isso definindo e devolvendo uma função interna para substituir a função decorada. Código que usa funções internas quase sempre depende de clausuras para operar corretamente. Para entender as clausuras, precisamos dar um passo atrás e revisar como o escopo de variáveis funciona no Python.

9.5. Regras de escopo de variáveis

No Exemplo 3, definimos e testamos uma função que lê duas variáveis: uma variável local a—definida como parâmetro de função—e a variável b, que não é definida em lugar algum na função.

Exemplo 3. Função lendo uma variável local e uma variável global
>>> def f1(a):
...     print(a)
...     print(b)
...
>>> f1(3)
3
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "<stdin>", line 3, in f1
NameError: global name 'b' is not defined

O erro obtido não é surpreendente. Continuando do Exemplo 3, se atribuirmos um valor a um b global e então chamarmos f1, funciona:

>>> b = 6
>>> f1(3)
3
6

Agora vamos ver um exemplo que pode ser surpreendente.

Dê uma olhada na função f2, no Exemplo 4. As primeiras duas linhas são as mesmas da f1 do Exemplo 3, e então ela faz uma atribuição a b. Mas para com um erro no segundo print, antes da atribuição ser executada.

Exemplo 4. A variável b é local, porque um valor é atribuído a ela no corpo da função
>>> b = 6
>>> def f2(a):
...     print(a)
...     print(b)
...     b = 9
...
>>> f2(3)
3
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "<stdin>", line 3, in f2
UnboundLocalError: local variable 'b' referenced before assignment

Observe que o a saída começa com 3, provando que o comando print(a) foi executado. Mas o segundo, print(b), nunca roda. Quando vi isso pela primeira vez me espantei, pensava que o 6 deveria ser exibido, pois há uma variável global b, e a atribuição para a b local ocorre após print(b).

Mas o fato é que, quando o Python compila o corpo da função, ele decide que b é uma variável local, por ser atribuída dentro da função. O bytecode gerado reflete essa decisão, e tentará obter b no escopo local. Mais tarde, quando a chamada f2(3) é realizada, o corpo de f2 obtém e exibe o valor da variável local a, mas ao tentar obter o valor da variável local b, descobre que b não está vinculado a nada.

Isso não é um bug, mas uma escolha de projeto: o Python não exige que você declare variáveis, mas assume que uma variável atribuída no corpo de uma função é local. Isso é muito melhor que o comportamento do Javascript, que também não requer declarações de variáveis, mas se você esquecer de declarar uma variável como local (com var), pode acabar alterando uma variável global sem nem saber.

Se queremos que o interpretador trate b como uma variável global e também atribuir um novo valor a ela dentro da função, usamos a declaração global:

>>> b = 6
>>> def f3(a):
...     global b
...     print(a)
...     print(b)
...     b = 9
...
>>> f3(3)
3
6
>>> b
9

Nos exemplos anteriores, vimos dois escopos em ação:

O escopo global de módulo

Composto por nomes atribuídos a valores fora de qualquer bloco de classe ou função.

O escopo local da função f3

Composto por nomes atribuídos a valores como parâmetros, ou diretamente no corpo da função.

Há um outro escopo de onde variáveis podem vir, chamado nonlocal, e ele é fundamental para clausuras; vamos tratar disso em breve.

Após ver mais de perto como o escopo de variáveis funciona no Python, podemos enfrentar as clausuras na próxima seção, Seção 9.6. Se você tiver curiosidade sobre as diferenças no bytecode das funções no Exemplo 3 e no Exemplo 4, veja o quadro a seguir.

Comparando bytecodes

O módulo dis module oferece uma forma fácil de descompilar o bytecode de funções do Python. Leia no Exemplo 5 e no Exemplo 6 os bytecodes de f1 e f2, do Exemplo 3 e do Exemplo 4, respectivamente.

Exemplo 5. Bytecode da função f1 do Exemplo 3
>>> from dis import dis
>>> dis(f1)
  2           0 LOAD_GLOBAL              0 (print)  (1)
              3 LOAD_FAST                0 (a)  (2)
              6 CALL_FUNCTION            1 (1 positional, 0 keyword pair)
              9 POP_TOP

  3          10 LOAD_GLOBAL              0 (print)
             13 LOAD_GLOBAL              1 (b)  (3)
             16 CALL_FUNCTION            1 (1 positional, 0 keyword pair)
             19 POP_TOP
             20 LOAD_CONST               0 (None)
             23 RETURN_VALUE
  1. Carrega o nome global print.

  2. Carrega o nome local a.

  3. Carrega o nome global b.

Compare o bytecode de f1, visto no Exemplo 5 acima, com o bytecode de f2 no Exemplo 6.

Exemplo 6. Bytecode da função f2 do Exemplo 4
>>> dis(f2)
  2           0 LOAD_GLOBAL              0 (print)
              3 LOAD_FAST                0 (a)
              6 CALL_FUNCTION            1 (1 positional, 0 keyword pair)
              9 POP_TOP

  3          10 LOAD_GLOBAL              0 (print)
             13 LOAD_FAST                1 (b)  (1)
             16 CALL_FUNCTION            1 (1 positional, 0 keyword pair)
             19 POP_TOP

  4          20 LOAD_CONST               1 (9)
             23 STORE_FAST               1 (b)
             26 LOAD_CONST               0 (None)
             29 RETURN_VALUE
  1. Carrega o nome local b. Isso mostra que o compilador considera b uma variável local, mesmo com uma atribuição a b ocorrendo mais tarde, porque a natureza da variável—se ela é ou não local—não pode mudar no corpo da função.

A máquina virtual (VM) do CPython que executa o bytecode é uma máquina de stack, então as operações LOAD e POP se referem ao stack. A descrição mais detalhada dos opcodes do Python está além da finalidade desse livro, mas eles estão documentados junto com o módulo, em "dis—Disassembler de bytecode do Python".

9.6. Clausuras

Na blogosfera, as clausuras são algumas vezes confundidas com funções anônimas. Muita gente confunde os dois conceitos por causa da história paralela dos dois recursos: definir funções dentro de outras funções não é tão comum ou conveniente, até existirem funções anônimas. E clausuras só importam a partir do momento em que você tem funções aninhadas. Daí que muitos aprendem as duas ideias ao mesmo tempo.

Na verdade, uma clausura é uma função—vamos chamá-la de f—com um escopo estendido, incorporando variáveis referenciadas no corpo de f que não são nem variáveis globais nem variáveis locais de f. Tais variáveis devem vir do escopo local de uma função externa que englobe f.

Não interessa aqui se a função é anônima ou não; o que importa é que ela pode acessar variáveis não-globais definidas fora de seu corpo.

É um conceito difícil de entender, melhor ilustrado por um exemplo.

Imagine uma função avg, para calcular a média de uma série de valores que cresce continuamente; por exemplo, o preço de fechamento de uma commodity através de toda a sua história. A cada dia, um novo preço é acrescentado, e a média é computada levando em conta todos os preços até ali.

Começando do zero, avg poderia ser usada assim:

>>> avg(10)
10.0
>>> avg(11)
10.5
>>> avg(12)
11.0

Da onde vem avg, e onde ela mantém o histórico com os valores anteriores?

Para começar, o Exemplo 7 mostra uma implementação baseada em uma classe.

Exemplo 7. average_oo.py: uma classe para calcular uma média contínua
class Averager():

    def __init__(self):
        self.series = []

    def __call__(self, new_value):
        self.series.append(new_value)
        total = sum(self.series)
        return total / len(self.series)

A classe Averager cria instâncias invocáveis:

>>> avg = Averager()
>>> avg(10)
10.0
>>> avg(11)
10.5
>>> avg(12)
11.0

O Exemplo 8, a seguir, é uma implementação funcional, usando a função de ordem superior make_averager.

Exemplo 8. average.py: uma função de ordem superior para a clacular uma média contínua
def make_averager():
    series = []

    def averager(new_value):
        series.append(new_value)
        total = sum(series)
        return total / len(series)

    return averager

Quando invocada, make_averager devolve um objeto função averager. Cada vez que um averager é invocado, ele insere o argumento recebido na série, e calcula a média atual, como mostra o Exemplo 9.

Exemplo 9. Testando o Exemplo 8
>>> avg = make_averager()
>>> avg(10)
10.0
>>> avg(11)
10.5
>>> avg(15)
12.0

Note as semelhanças entre os dois exemplos: chamamos Averager() ou make_averager() para obter um objeto invocável avg, que atualizará a série histórica e calculará a média atual. No Exemplo 7, avg é uma instância de Averager, no Exemplo 8 é a função interna averager. Nos dois casos, basta chamar avg(n) para incluir n na série e obter a média atualizada.

É óbvio onde o avg da classe Averager mantém o histórico: no atributo de instância self.series. Mas onde a função avg no segundo exemplo encontra a series?

Observe que series é uma variável local de make_averager, pois a atribuição series = [] acontece no corpo daquela função. Mas quando avg(10) é chamada, make_averager já retornou, e seu escopo local há muito deixou de existir.

Dentro de averager, series é uma variável livre. Esse é um termo técnico para designar uma variável que não está vinculada no escopo local. Veja a Figura 1.

Diagrama de uma clausura
Figura 1. A clausura para averager estende o escopo daquela função para incluir a vinculação da variável livre series.

Inspecionar o objeto averager devolvido mostra como o Python mantém os nomes de variáveis locais e livres no atributo __code__, que representa o corpo compilado da função. O Exemplo 10 demonstra isso.

Exemplo 10. Inspecionando a função criada por make_averager no Exemplo 8
>>> avg.__code__.co_varnames
('new_value', 'total')
>>> avg.__code__.co_freevars
('series',)

O valor de series é mantido no atributo __closure__ da função devolvida, avg. Cada item em avg.__closure__ corresponde a um nome em __code__. Esses itens são cells, e tem um atributo chamado cell_contents, onde o valor real pode ser encontrado. O Exemplo 11 mostra esses atributos.

Exemplo 11. Continuando do Exemplo 9
>>> avg.__code__.co_freevars
('series',)
>>> avg.__closure__
(<cell at 0x107a44f78: list object at 0x107a91a48>,)
>>> avg.__closure__[0].cell_contents
[10, 11, 12]

Resumindo: uma clausura é uma função que retém os vínculos das variáveis livres que existem quando a função é definida, de forma que elas possam ser usadas mais tarde, quando a função for invocada mas o escopo de sua definição não estiver mais disponível.

Note que a única situação na qual uma função pode ter de lidar com variáveis externas não-globais é quando ela estiver aninhada dentro de outra função, e aquelas variáveis sejam parte do escopo local da função externa.

9.7. A declaração nonlocal

Nossa implementação anterior de make_averager não era eficiente. No Exemplo 8, armazenamos todos os valores na série histórica e calculamos sua sum cada vez que averager é invocada. Uma implementação melhor armazenaria apenas o total e número de itens até aquele momento, e calcularia a média com esses dois números.

O Exemplo 12 é uma implementação errada, apenas para ilustrar um ponto. Você consegue ver onde o código quebra?

Exemplo 12. Um função de ordem superior incorreta para calcular um média contínua sem manter todo o histórico
def make_averager():
    count = 0
    total = 0

    def averager(new_value):
        count += 1
        total += new_value
        return total / count

    return averager

Se você testar o Exemplo 12, eis o resultado:

>>> avg = make_averager()
>>> avg(10)
Traceback (most recent call last):
  ...
UnboundLocalError: local variable 'count' referenced before assignment
>>>

O problema é que a instrução count += 1 significa o mesmo que count = count + 1, quando count é um número ou qualquer tipo imutável. Então estamos efetivamente atribuindo um valor a count no corpo de averager, e isso a torna uma variável local. O mesmo problema afeta a variável total.

Não tivemos esse problema no Exemplo 8, porque nunca atribuimos nada ao nome series; apenas chamamos series.append e invocamos sum e len nele. Nos valemos, então, do fato de listas serem mutáveis.

Mas com tipos imutáveis, como números, strings, tuplas, etc., só é possível ler, nunca atualizar. Se você tentar revinculá-las, como em count = count + 1, estará criando implicitamente uma variável local count. Ela não será mais uma variável livre, e assim não será armazenada na clausura.

A palavra reservada nonlocal foi introduzida no Python 3 para contornar esse problema. Ela permite declarar uma variável como variável livre, mesmo quando ela for atribuída dentro da função. Se um novo valor é atribuído a uma variável nonlocal, o vínculo armazenado na clausura é modificado. Uma implemetação correta da nossa última versão de make_averager se pareceria com o Exemplo 13.

Exemplo 13. Calcula uma média contínua sem manter todo o histórico (corrigida com o uso de nonlocal)
def make_averager():
    count = 0
    total = 0

    def averager(new_value):
        nonlocal count, total
        count += 1
        total += new_value
        return total / count

    return averager

Após estudar o nonlocal, podemos resumir como a consulta de variáveis funciona no Python.

9.7.1. A lógica da consulta de variáveis

Quando uma função é definida, o compilador de bytecode do Python determina como encontrar uma variável x que aparece na função, baseado nas seguintes regras:[105]

  • Se há uma declaração global x, x vem de e é atribuída à variável global x do módulo.[106]

  • Se há uma declaração nonlocal x, x vem de e atribuída à variável local x na função circundante mais próxima de onde x for definida.

  • Se x é um parâmetro ou tem um valor atribuído a si no corpo da função, então x é uma variável local.

  • Se x é referenciada mas não atribuída, e não é um parâmetro:

    • x será procurada nos escopos locais do corpos das funções circundantes (os escopos nonlocal).

    • Se x não for encontrada nos escopos circundantes, será lida do escopo global do módulo.

    • Se x não for encontrada no escopo global, será lida de __builtins__.__dict__.

Tendo visto as clausuras do Python, podemos agora de fato implementar decoradores com funções aninhadas.

9.8. Implementando um decorador simples

O Exemplo 14 é um decorador que cronometra cada invocação da função decorada e exibe o tempo decorrido, os argumentos passados, e o resultado da chamada.

Exemplo 14. clockdeco0.py: decorador simples que mostra o tempo de execução de funções
import time


def clock(func):
    def clocked(*args):  # (1)
        t0 = time.perf_counter()
        result = func(*args)  # (2)
        elapsed = time.perf_counter() - t0
        name = func.__name__
        arg_str = ', '.join(repr(arg) for arg in args)
        print(f'[{elapsed:0.8f}s] {name}({arg_str}) -> {result!r}')
        return result
    return clocked  # (3)
  1. Define a função interna clocked para aceitar qualquer número de argumentos posicionais.

  2. Essa linha só funciona porque a clausura para clocked engloba a variável livre func.

  3. Devolve a função interna para substituir a função decorada.

O Exemplo 15 demonstra o uso do decorador clock.

Exemplo 15. Usando o decorador clock
import time
from clockdeco0 import clock

@clock
def snooze(seconds):
    time.sleep(seconds)

@clock
def factorial(n):
    return 1 if n < 2 else n*factorial(n-1)

if __name__ == '__main__':
    print('*' * 40, 'Calling snooze(.123)')
    snooze(.123)
    print('*' * 40, 'Calling factorial(6)')
    print('6! =', factorial(6))

O resultado da execução do Exemplo 15 é o seguinte:

$ python3 clockdeco_demo.py
**************************************** Calling snooze(.123)
[0.12363791s] snooze(0.123) -> None
**************************************** Calling factorial(6)
[0.00000095s] factorial(1) -> 1
[0.00002408s] factorial(2) -> 2
[0.00003934s] factorial(3) -> 6
[0.00005221s] factorial(4) -> 24
[0.00006390s] factorial(5) -> 120
[0.00008297s] factorial(6) -> 720
6! = 720

9.8.1. Como isso funciona

Lembre-se que esse código:

@clock
def factorial(n):
    return 1 if n < 2 else n*factorial(n-1)

na verdade faz isso:

def factorial(n):
    return 1 if n < 2 else n*factorial(n-1)

factorial = clock(factorial)

Então, nos dois exemplos, clock recebe a função factorial como seu argumento func (veja o Exemplo 14). Ela então cria e devolve a função clocked, que o interpretador Python atribui a factorial (no primeiro exemplo, por baixo dos panos). De fato, se você importar o módulo clockdeco_demo e verificar o __name__ de factorial, verá isso:

>>> import clockdeco_demo
>>> clockdeco_demo.factorial.__name__
'clocked'
>>>

Então factorial agora mantém uma referência para a função clocked. Daqui por diante, cada vez que factorial(n) for chamada, clocked(n) será executada. Essencialmente, clocked faz o seguinte:

  1. Registra o tempo inicial t0.

  2. Chama a função factorial original, salvando o resultado.

  3. Computa o tempo decorrido.

  4. Formata e exibe os dados coletados.

  5. Devolve o resultado salvo no passo 2.

Esse é o comportamento típico de um decorador: ele substitui a função decorada com uma nova função que aceita os mesmos argumentos e (normalmente) devolve o que quer que a função decorada deveria devolver, enquanto realiza também algum processamento adicional.

👉 Dica

Em Padrões de Projetos, de Gamma et al., a descrição curta do padrão decorador começa com: "Atribui dinamicamente responsabilidades adicionais a um objeto." Decoradores de função se encaixam nessa descrição. Mas, no nível da implementação, os decoradores do Python guardam pouca semelhança com o decorador clássico descrito no Padrões de Projetos original. O Ponto de vista fala um pouco mais sobre esse assunto.

O decorador clock implementado no Exemplo 14 tem alguns defeitos: ele não suporta argumentos nomeados, e encobre o __name__ e o __doc__ da função decorada. O Exemplo 16 usa o decorador functools.wraps para copiar os atributos relevantes de func para clocked. E nessa nova versão os argumentos nomeados também são tratados corretamente.

Exemplo 16. clockdeco.py: um decorador clock melhora
import time
import functools


def clock(func):
    @functools.wraps(func)
    def clocked(*args, **kwargs):
        t0 = time.perf_counter()
        result = func(*args, **kwargs)
        elapsed = time.perf_counter() - t0
        name = func.__name__
        arg_lst = [repr(arg) for arg in args]
        arg_lst.extend(f'{k}={v!r}' for k, v in kwargs.items())
        arg_str = ', '.join(arg_lst)
        print(f'[{elapsed:0.8f}s] {name}({arg_str}) -> {result!r}')
        return result
    return clocked

O functools.wraps é apenas um dos decoradores prontos para uso da biblioteca padrão. Na próxima seção veremos o decorador mais impressionante oferecido por functools: cache.

9.9. Decoradores na biblioteca padrão

O Python tem três funções embutidas projetadas para decorar métodos: property, classmethod e staticmethod. Vamos discutir property na Seção 22.4 e os outros na Seção 11.5.

No Exemplo 16 vimos outro decorador importante: functools.wraps, um auxiliar na criação de decoradores bem comportados. Três dos decoradores mais interessantes da biblioteca padrão são cache, lru_cache e singledispatch—todos do módulo functools. Falaremos deles a seguir.

9.9.1. Memoização com functools.cache

O decorador functools.cache implementa memoização:[107] uma técnica de otimização que funciona salvando os resultados de invocações anteriores de uma função dispendiosa, evitando repetir o processamento para argumentos previamente utilizados.

👉 Dica

O functools.cache foi introduzido no Python 3.9. Se você precisar rodar esses exemplo no Python 3.8, substitua @cache por @lru_cache. Em versões anteriores é preciso invocar o decorador, escrevendo @lru_cache(), como explicado na Seção 9.9.2.

Uma boa demonstração é aplicar @cache à função recursiva, e dolorosamente lenta, que gera o enésimo número da sequência de Fibonacci, como mostra o Exemplo 17.

Exemplo 17. O modo recursivo e extremamente dispendioso de calcular o enésimo número na série de Fibonacci
from clockdeco import clock


@clock
def fibonacci(n):
    if n < 2:
        return n
    return fibonacci(n - 2) + fibonacci(n - 1)


if __name__ == '__main__':
    print(fibonacci(6))

Aqui está o resultado da execução de fibo_demo.py. Exceto pela última linha, toda a saída é produzida pelo decorador clock:

$ python3 fibo_demo.py
[0.00000042s] fibonacci(0) -> 0
[0.00000049s] fibonacci(1) -> 1
[0.00006115s] fibonacci(2) -> 1
[0.00000031s] fibonacci(1) -> 1
[0.00000035s] fibonacci(0) -> 0
[0.00000030s] fibonacci(1) -> 1
[0.00001084s] fibonacci(2) -> 1
[0.00002074s] fibonacci(3) -> 2
[0.00009189s] fibonacci(4) -> 3
[0.00000029s] fibonacci(1) -> 1
[0.00000027s] fibonacci(0) -> 0
[0.00000029s] fibonacci(1) -> 1
[0.00000959s] fibonacci(2) -> 1
[0.00001905s] fibonacci(3) -> 2
[0.00000026s] fibonacci(0) -> 0
[0.00000029s] fibonacci(1) -> 1
[0.00000997s] fibonacci(2) -> 1
[0.00000028s] fibonacci(1) -> 1
[0.00000030s] fibonacci(0) -> 0
[0.00000031s] fibonacci(1) -> 1
[0.00001019s] fibonacci(2) -> 1
[0.00001967s] fibonacci(3) -> 2
[0.00003876s] fibonacci(4) -> 3
[0.00006670s] fibonacci(5) -> 5
[0.00016852s] fibonacci(6) -> 8
8

O desperdício é óbvio: fibonacci(1) é chamada oito vezes, fibonacci(2) cinco vezes, etc. Mas acrescentar apenas duas linhas, para usar cache, melhora muito o desempenho. Veja o Exemplo 18.

Exemplo 18. Implementação mais rápida, usando caching
import functools

from clockdeco import clock


@functools.cache  # (1)
@clock  # (2)
def fibonacci(n):
    if n < 2:
        return n
    return fibonacci(n - 2) + fibonacci(n - 1)


if __name__ == '__main__':
    print(fibonacci(6))
  1. Essa linha funciona com Python 3.9 ou posterior. Veja a Seção 9.9.2 para uma alternativa que suporta versões anteriores do Python.

  2. Esse é um exemplo de decoradores empilhados: @cache é aplicado à função devolvida por @clock.

👉 Dica
Decoradore empilhados

Para entender os decoradores empilhados, lembre-se que a @ é açúcar sintático para indicar a aplicação da função decoradora à função abaixo dela. Se houver mais de um decorador, eles se comportam como chamadas a funções aninhadas. Isso:

@alpha
@beta
def my_fn():
    ...

é o mesmo que isso:

my_fn = alpha(beta(my_fn))

Em outras palavras, o decorador beta é aplicado primeiro, e a função devolvida por ele é então passada para alpha.

Usando o cache no Exemplo 18, a função fibonacci é chamada apenas uma vez para cada valor de n:

$ python3 fibo_demo_lru.py
[0.00000043s] fibonacci(0) -> 0
[0.00000054s] fibonacci(1) -> 1
[0.00006179s] fibonacci(2) -> 1
[0.00000070s] fibonacci(3) -> 2
[0.00007366s] fibonacci(4) -> 3
[0.00000057s] fibonacci(5) -> 5
[0.00008479s] fibonacci(6) -> 8
8

Em outro teste, para calcular fibonacci(30), o Exemplo 18 fez as 31 chamadas necessárias em 0,00017s (tempo total), enquanto o Exemplo 17 sem cache, demorou 12,09s em um notebook Intel Core i7, porque chamou fibonacci(1) 832.040 vezes, para um total de 2.692.537 chamadas.

Todos os argumentos recebidos pela função decorada devem ser hashable, pois o lru_cache subjacente usa um dict para armazenar os resultados, e as chaves são criadas a partir dos argumentos posicionais e nomeados usados nas chamados.

Além de tornar viáveis esses algoritmos recursivos tolos, @cache brilha de verdade em aplicações que precisam buscar informações de APIs remotas.

⚠️ Aviso

O functools.cache pode consumir toda a memória disponível, se houver um número muito grande de itens no cache. Eu o considero mais adequado para scripts rápidos de linha de comando. Para processos de longa duração, recomendo usar functools.lru_cache com um parâmetro maxsize adequado, como explicado na próxima seção.

9.9.2. Usando o lru_cache

O decorador functools.cache é, na realidade, um mero invólucro em torno da antiga função functools.lru_cache, que é mais flexível e também compatível com o Python 3.8 e outras versões anteriores.

A maior vantagem de @lru_cache é a possibilidade de limitar seu uso de memória através do parâmetro maxsize, que tem um default bastante conservador de 128—significando que o cache pode manter no máximo 128 registros simultâneos.

LRU é a sigla de Least Recently Used (literalmente "Usado Menos Recentemente"). Significa que registros que há algum tempo não são lidos, são descartados para dar lugar a novos itens.

Desde o Python 3.8, lru_cache pode ser aplicado de duas formas. Abaixo vemos o modo mais simples em uso:

@lru_cache
def costly_function(a, b):
    ...

A outra forma—disponível desde o Python 3.2—é invocá-lo como uma função, com ():

@lru_cache()
def costly_function(a, b):
    ...

Nos dois casos, os parâmetros default seriam utilizados. São eles:

maxsize=128

Estabelece o número máximo de registros a serem armazenados. Após o cache estar cheio, o registro menos recentemente usado é descartado, para dar lugar a cada novo item. Para um desempenho ótimo, maxsize deve ser uma potência de 2. Se você passar maxsize=None, a lógica LRU é desabilitada e o cache funciona mais rápido, mas os itens nunca são descartados, podendo levar a um consumo excessivo de memória. É assim que o @functools.cache funciona.

typed=False

Determina se os resultados de diferentes tipos de argumentos devem ser armazenados separadamente, Por exemplo, na configuração default, argumentos inteiros e de ponto flutuante considerados iguais são armazenados apenas uma vez. Assim, haverá apenas uma entrada para as chamadas f(1) e f(1.0). Se typed=True, aqueles argumentos produziriam registros diferentes, possivelmente armazenando resultados distintos.

Eis um exemplo de invocação de @lru_cache com parâmetros diferentes dos defaults:

@lru_cache(maxsize=2**20, typed=True)
def costly_function(a, b):
    ...

Vamos agora examinar outro decorador poderoso: functools.singledispatch.

9.9.3. Funções genéricas com despacho único

Imagine que estamos criando uma ferramenta para depurar aplicações web. Queremos gerar código HTML para tipos diferentes de objetos Python.

Poderíamos começar com uma função como essa:

import html

def htmlize(obj):
    content = html.escape(repr(obj))
    return f'<pre>{content}</pre>'

Isso funcionará para qualquer tipo do Python, mas agora queremos estender a função para gerar HTML específico para determinados tipos. Alguns exemplos seriam:

str

Substituir os caracteres de mudança de linha na string por '<br/>\n' e usar tags <p> tags em vez de <pre>.

int

Mostrar o número em formato decimal e hexadecimal (com um caso especial para bool).

list

Gerar uma lista em HTML, formatando cada item de acordo com seu tipo.

float e Decimal

Mostrar o valor como de costume, mas também na forma de fração (por que não?).

O comportamento que desejamos aparece no Exemplo 19.

Exemplo 19. htmlize() gera HTML adaptado para diferentes tipos de objetos
>>> htmlize({1, 2, 3})  # (1)
'<pre>{1, 2, 3}</pre>'
>>> htmlize(abs)
'<pre>&lt;built-in function abs&gt;</pre>'
>>> htmlize('Heimlich & Co.\n- a game')  # (2)
'<p>Heimlich &amp; Co.<br/>\n- a game</p>'
>>> htmlize(42)  # (3)
'<pre>42 (0x2a)</pre>'
>>> print(htmlize(['alpha', 66, {3, 2, 1}]))  # (4)
<ul>
<li><p>alpha</p></li>
<li><pre>66 (0x42)</pre></li>
<li><pre>{1, 2, 3}</pre></li>
</ul>
>>> htmlize(True)  # (5)
'<pre>True</pre>'
>>> htmlize(fractions.Fraction(2, 3))  # (6)
'<pre>2/3</pre>'
>>> htmlize(2/3)   # (7)
'<pre>0.6666666666666666 (2/3)</pre>'
>>> htmlize(decimal.Decimal('0.02380952'))
'<pre>0.02380952 (1/42)</pre>'
  1. A função original é registrada para object, então ela serve para capturar e tratar todos os tipos de argumentos que não foram capturados pelas outras implementações.

  2. Objetos str também passam por escape de HTML, mas são cercados por <p></p>, com quebras de linha <br/> inseridas antes de cada '\n'.

  3. Um int é exibido nos formatos decimal e hexadecimal, dentro de um bloco <pre></pre>.

  4. Cada item na lista é formatado de acordo com seu tipo, e a sequência inteira é apresentada como uma lista HTML.

  5. Apesar de ser um subtipo de int, bool recebe um tratamento especial.

  6. Mostra Fraction como uma fração.

  7. Mostra float e Decimal com a fração equivalemte aproximada.

Despacho único de funções

Como não temos no Python a sobrecarga de métodos ao estilo do Java, não podemos simplesmente criar variações de htmlize com assinaturas diferentes para cada tipo de dado que queremos tratar de forma distinta. Uma solução possível em Python seria transformar htmlize em uma função de despacho, com uma cadeia de if/elif/… ou match/case/… chamando funções especializadas como htmlize_str, htmlize_int, etc. Isso não é extensível pelos usuários de nosso módulo, e é desajeitado: com o tempo, a despachante htmlize de tornaria grande demais, e o acoplamento entre ela e as funções especializadas seria excessivamente sólido.

O decorador functools.singledispatch permite que diferentes módulos contribuam para a solução geral, e que você forneça facilmente funções especializadas, mesmo para tipos pertencentes a pacotes externos que não possam ser editados. Se você decorar um função simples com @singledispatch, ela se torna o ponto de entrada para uma função genérica: Um grupo de funções que executam a mesma operação de formas diferentes, dependendo do tipo do primeiro argumento. É isso que signifca o termo despacho único. Se mais argumentos fossem usados para selecionar a função específica, teríamos um despacho múltiplo. O Exemplo 20 mostra como funciona.

⚠️ Aviso

functools.singledispatch existe desde o python 3.4, mas só passou a suportar dicas de tipo no Python 3.7. As últimas duas funções no Exemplo 20 ilustram a sintaxe que funciona em todas as versões do Python desde a 3.4.

Exemplo 20. @singledispatch cria uma @htmlize.register personalizada, para empacotar várias funções em uma função genérica
from functools import singledispatch
from collections import abc
import fractions
import decimal
import html
import numbers

@singledispatch  # (1)
def htmlize(obj: object) -> str:
    content = html.escape(repr(obj))
    return f'<pre>{content}</pre>'

@htmlize.register  # (2)
def _(text: str) -> str:  # (3)
    content = html.escape(text).replace('\n', '<br/>\n')
    return f'<p>{content}</p>'

@htmlize.register  # (4)
def _(seq: abc.Sequence) -> str:
    inner = '</li>\n<li>'.join(htmlize(item) for item in seq)
    return '<ul>\n<li>' + inner + '</li>\n</ul>'

@htmlize.register  # (5)
def _(n: numbers.Integral) -> str:
    return f'<pre>{n} (0x{n:x})</pre>'

@htmlize.register  # (6)
def _(n: bool) -> str:
    return f'<pre>{n}</pre>'

@htmlize.register(fractions.Fraction)  # (7)
def _(x) -> str:
    frac = fractions.Fraction(x)
    return f'<pre>{frac.numerator}/{frac.denominator}</pre>'

@htmlize.register(decimal.Decimal)  # (8)
@htmlize.register(float)
def _(x) -> str:
    frac = fractions.Fraction(x).limit_denominator()
    return f'<pre>{x} ({frac.numerator}/{frac.denominator})</pre>'
  1. @singledispatch marca a função base, que trata o tipo object.

  2. Cada função especializada é decorada com @«base».register.

  3. O tipo do primeiro argumento passado durante a execução determina quando essa definição de função em particular será utilizada. O nome das funções especializadas é irrelevante; _ é uma boa escolha para deixar isso claro.[108]

  4. Registra uma nova função para cada tipo que precisa de tratamento especial, com uma dica de tipo correspondente no primeiro parâmetro.

  5. As ABCs em numbers são úteis para uso em conjunto com singledispatch.[109]

  6. bool é um subtipo-de numbers.Integral, mas a lógica de singledispatch busca a implementação com o tipo correspondente mais específico, independente da ordem na qual eles aparecem no código.

  7. Se você não quiser ou não puder adicionar dicas de tipo à função decorada, você pode passar o tipo para o decorador @«base».register. Essa sintaxe funciona em Python 3.4 ou posterior.

  8. O decorador @«base».register devolve a função sem decoração, então é possível empilhá-los para registrar dois ou mais tipos na mesma implementação.[110]

Sempre que possível, registre as funções especializadas para tratar ABCs (classes abstratas), tais como numbers.Integral e abc.MutableSequence, ao invés das implementações concretas como int e list. Isso permite ao seu código suportar uma variedade maior de tipos compatíveis. Por exemplo, uma extensão do Python pode fornecer alternativas para o tipo int com número fixo de bits como subclasses de numbers.Integral.[111]

👉 Dica

Usar ABCs ou typing.Protocol com @singledispatch permite que seu código suporte classes existentes ou futuras que sejam subclasses reais ou virtuais daquelas ABCs, ou que implementem aqueles protocolos. O uso de ABCs e o conceito de uma subclasse virtual são assuntos do Capítulo 13.

Uma qualidade notável do mecanismo de singledispatch é que você pode registrar funções especializadas em qualquer lugar do sistema, em qualquer módulo. Se mais tarde você adicionar um módulo com um novo tipo definido pelo usuário, é fácil acrescentar uma nova função específica para tratar aquele tipo. E é possível também escrever funções personalizadas para classes que você não escreveu e não pode modificar.

O singledispatch foi uma adição muito bem pensada à biblioteca padrão, e oferece muitos outros recursos que não me cabe descrever aqui. Uma boa referência é a PEP 443—​Single-dispatch generic functions (EN) mas ela não menciona o uso de dicas de tipo, acrescentado posteriormente. A documentação do módulo functools foi aperfeiçoada e oferece um tratamento mais atualizado, com vários exemplos na seção referente ao singledispatch (EN).

✒️ Nota

O @singledispatch não foi criado para trazer para o Python a sobrecarga de métodos no estilo do Java. Uma classe única com muitas variações sobrecarregadas de um método é melhor que uma única função com uma longa sequênca de blocos if/elif/elif/elif. Mas as duas soluções são incorretas, pois concentram responsabilidade excessiva em uma única unidade de código—a classe ou a função. A vantagem de @singledispatch é seu suporte à extensão modular: cada módulo pode registrar uma função especializada para cada tipo suportado. Em um caso de uso realista, as implementações das funções genéricas não estariam todas no mesmo módulo, como ocorre no Exemplo 20.

Vimos alguns decoradores recebendo argumentos, por exemplo @lru_cache() e o htmlize.register(float) criado por @singledispatch no Exemplo 20. A próxima seção mostra como criar decoradores que aceitam parâmetros.

9.10. Decoradores parametrizados

Ao analisar um decorador no código-fonte, o Python passa a função decorada como primeiro argumento para a função do decorador. Mas como fazemos um decorador aceitar outros argumentos? A resposta é: criar uma fábrica de decoradores que recebe aqueles argumentos e devolve um decorador, que é então aplicado à função a ser decorada. Confuso? Com certeza. Vamos começar com um exemplo baseado no decorador mais simples que vimos: register no Exemplo 21.

Exemplo 21. O módulo registration.py resumido, do Exemplo 2, repetido aqui por conveniência
registry = []

def register(func):
    print(f'running register({func})')
    registry.append(func)
    return func

@register
def f1():
    print('running f1()')

print('running main()')
print('registry ->', registry)
f1()

9.10.1. Um decorador de registro parametrizado

Para tornar mais fácil a habilitação ou desabilitação do registro executado por register, faremos esse último aceitar um parâmetro opcional active que, se False, não registra a função decorada. Conceitualmente, a nova função register não é um decorador mas uma fábrica de decoradores. Quando chamada, ela devolve o decorador que será realmente aplicado à função alvo.

Exemplo 22. Para aceitar parâmetros, o novo decorador register precisa ser invocado como uma função
registry = set()  # (1)

def register(active=True):  # (2)
    def decorate(func):  # (3)
        print('running register'
              f'(active={active})->decorate({func})')
        if active:   # (4)
            registry.add(func)
        else:
            registry.discard(func)  # (5)

        return func  # (6)
    return decorate  # (7)

@register(active=False)  # (8)
def f1():
    print('running f1()')

@register()  # (9)
def f2():
    print('running f2()')

def f3():
    print('running f3()')
  1. registry é agora um set, tornando mais rápido acrescentar ou remover funções.

  2. register recebe um argumento nomeado opcional.

  3. A função interna decorate é o verdadeiro decorador; observe como ela aceita uma função como argumento.

  4. Registra func apenas se o argumento active (obtido da clausura) for True.

  5. Se not active e func in registry, remove a função.

  6. Como decorate é um decorador, ele deve devolver uma função.

  7. register é nossa fábrica de decoradores, então devolve decorate.

  8. A fábrica @register precisa ser invocada como uma função, com os parâmetros desejados.

  9. Mesmo se nenhum parâmetro for passado, ainda assim register deve ser chamado como uma função—@register()—isto é, para devolver o verdadeiro decorador, decorate.

O ponto central aqui é que register() devolve decorate, que então é aplicado à função decorada.

O código do Exemplo 22 está em um módulo registration_param.py. Se o importarmos, veremos o seguinte:

>>> import registration_param
running register(active=False)->decorate(<function f1 at 0x10063c1e0>)
running register(active=True)->decorate(<function f2 at 0x10063c268>)
>>> registration_param.registry
[<function f2 at 0x10063c268>]

Veja como apenas a função f2 aparece no registry; f1 não aparece porque active=False foi passado para a fábrica de decoradores register, então o decorate aplicado a f1 não adiciona essa função a registry.

Se, ao invés de usar a sintaxe @, usarmos register como uma função regular, a sintaxe necessária para decorar uma função f seria register()(f), para inserir f ao registry, ou register(active=False)(f), para não inseri-la (ou removê-la). Veja o Exemplo 23 para uma demonstração da adição e remoção de funções do registry.

Exemplo 23. Usando o módulo registration_param listado no Exemplo 22
>>> from registration_param import *
running register(active=False)->decorate(<function f1 at 0x10073c1e0>)
running register(active=True)->decorate(<function f2 at 0x10073c268>)
>>> registry  # (1)
{<function f2 at 0x10073c268>}
>>> register()(f3)  # (2)
running register(active=True)->decorate(<function f3 at 0x10073c158>)
<function f3 at 0x10073c158>
>>> registry  # (3)
{<function f3 at 0x10073c158>, <function f2 at 0x10073c268>}
>>> register(active=False)(f2)  # (4)
running register(active=False)->decorate(<function f2 at 0x10073c268>)
<function f2 at 0x10073c268>
>>> registry  # (5)
{<function f3 at 0x10073c158>}
  1. Quando o módulo é importado, f2 é inserida no registry.

  2. A expressão register() devolve decorate, que então é aplicado a f3.

  3. A linha anterior adicionou f3 ao registry.

  4. Essa chamada remove f2 do registry.

  5. Confirma que apenas f3 permanece no registry.

O funcionamento de decoradores parametrizados é bastante complexo, e esse que acabamos de discutir é mais simples que a maioria. Decoradores parametrizados em geral substituem a função decorada, e sua construção exige um nível adicional de aninhamento. Vamos agora explorar a arquitetura de uma dessas pirâmides de funções.

9.10.2. Um decorador parametrizado de cronometragem

Nessa seção vamos revisitar o decorador clock, acrescentando um recurso: os usuários podem passar uma string de formatação, para controlar a saída do relatório sobre função cronometrada. Veja o Exemplo 24.

✒️ Nota

Para simplificar, o Exemplo 24 está baseado na implementação inicial de clock no Exemplo 14, e não na versão aperfeiçoada do Exemplo 16 que usa @functools.wraps, acrescentando assim mais uma camada de função.

Exemplo 24. Módulo clockdeco_param.py: o decorador clock parametrizado
import time

DEFAULT_FMT = '[{elapsed:0.8f}s] {name}({args}) -> {result}'

def clock(fmt=DEFAULT_FMT):  # (1)
    def decorate(func):      # (2)
        def clocked(*_args): # (3)
            t0 = time.perf_counter()
            _result = func(*_args)  # (4)
            elapsed = time.perf_counter() - t0
            name = func.__name__
            args = ', '.join(repr(arg) for arg in _args)  # (5)
            result = repr(_result)  # (6)
            print(fmt.format(**locals()))  # (7)
            return _result  # (8)
        return clocked  # (9)
    return decorate  # (10)

if __name__ == '__main__':

    @clock()  # (11)
    def snooze(seconds):
        time.sleep(seconds)

    for i in range(3):
        snooze(.123)
  1. clock é a nossa fábrica de decoradores parametrizados.

  2. decorate é o verdadeiro decorador.

  3. clocked envolve a função decorada.

  4. _result é o resultado efetivo da função decorada.

  5. _args mantém os verdadeiros argumentos de clocked, enquanto args é a str usada para exibição.

  6. result é a str que representa _result, para exibição.

  7. Usar **locals() aqui permite que qualquer variável local de clocked seja referenciada em fmt.[112]

  8. clocked vai substituir a função decorada, então ela deve devolver o mesmo que aquela função devolve.

  9. decorate devolve clocked.

  10. clock devolve decorate.

  11. Nesse auto-teste, clock() é chamado sem argumentos, então o decorador aplicado usará o formato default, str.

Se você rodar o Exemplo 24 no console, o resultado é o seguinte:

$ python3 clockdeco_param.py
[0.12412500s] snooze(0.123) -> None
[0.12411904s] snooze(0.123) -> None
[0.12410498s] snooze(0.123) -> None

Para exercitar a nova funcionalidade, vamos dar uma olhada em dois outros módulos que usam o clockdeco_param, o Exemplo 25 e o Exemplo 26, e nas saídas que eles geram.

Exemplo 25. clockdeco_param_demo1.py
import time
from clockdeco_param import clock

@clock('{name}: {elapsed}s')
def snooze(seconds):
    time.sleep(seconds)

for i in range(3):
    snooze(.123)

Saída do Exemplo 25:

$ python3 clockdeco_param_demo1.py
snooze: 0.12414693832397461s
snooze: 0.1241159439086914s
snooze: 0.12412118911743164s
Exemplo 26. clockdeco_param_demo2.py
import time
from clockdeco_param import clock

@clock('{name}({args}) dt={elapsed:0.3f}s')
def snooze(seconds):
    time.sleep(seconds)

for i in range(3):
    snooze(.123)

Saída do Exemplo 26:

$ python3 clockdeco_param_demo2.py
snooze(0.123) dt=0.124s
snooze(0.123) dt=0.124s
snooze(0.123) dt=0.124s
✒️ Nota

Lennart Regebro—um dos revisores técnicos da primeira edição—argumenta seria melhor programar decoradores como classes implementando __call__, e não como funções (caso dos exemplos nesse capítulo). Concordo que aquela abordagem é melhor para decoradores não-triviais. Mas para explicar a ideia básica desse recurso da linguagem, funções são mais fáceis de entender. Para técnicas robustas de criação de decoradores, veja as referências na Seção 9.12, especialmente o blog de Graham Dumpleton e o módulo wrapt, .

A próxima seção traz um exemplo no estilo recomendado por Regebro e Dumpleton.

9.10.3. Um decorador de cronometragem em forma de classe

Como um último exemplo, o Exemplo 27 mostra a implementação de um decorador parametrizado clock, programado como uma classe com __call__. Compare o Exemplo 24 com o Exemplo 27. Qual você prefere?

Exemplo 27. Módulo clockdeco_cls.py: decorador parametrizado clock, implementado como uma classe
import time

DEFAULT_FMT = '[{elapsed:0.8f}s] {name}({args}) -> {result}'

class clock:  # (1)

    def __init__(self, fmt=DEFAULT_FMT):  # (2)
        self.fmt = fmt

    def __call__(self, func):  # (3)
        def clocked(*_args):
            t0 = time.perf_counter()
            _result = func(*_args)  # (4)
            elapsed = time.perf_counter() - t0
            name = func.__name__
            args = ', '.join(repr(arg) for arg in _args)
            result = repr(_result)
            print(self.fmt.format(**locals()))
            return _result
        return clocked
  1. Ao invés de uma função externa clock, a classe clock é nossa fábrica de decoradores parametrizados. A nomeei com um c minúsculo, para deixar claro que essa implementação é uma substituta direta para aquela no Exemplo 24.

  2. O argumento passado em clock(my_format) é atribuído ao parâmetro fmt aqui. O construtor da classe devolve uma instância de clock, com my_format armazenado em self.fmt.

  3. __call__ torna a instância de clock invocável. Quando chamada, a instância substitui a função decorada com clocked.

  4. clocked envolve a função decorada.

Isso encerra nossa exploração dos decoradores de função. Veremos os decoradores de classe no Capítulo 24.

9.11. Resumo do capítulo

Percorremos um terreno acidentado nesse capítulo. Tentei tornar a jornada tão suave quanto possível, mas entramos definitivamente nos domínios da meta-programação.

Partimos de um decorador simples @register, sem uma função interna, e terminamos com um @clock() parametrizado envolvendo dois níveis de funções aninhadas.

Decoradores de registro, apesar de serem essencialmente simples, tem aplicações reais nos frameworks Python. Vamos aplicar a ideia de registro em uma implementação do padrão de projeto Estratégia, no Capítulo 10.

Entender como os decoradores realmente funcionam exigiu falar da diferença entre tempo de importação e tempo de execução. Então mergulhamos no escopo de variáveis, clausuras e a nova declaração nonlocal. Dominar as clausuras e nonlocal é valioso não apenas para criar decoradores, mas também para escrever programas orientados a eventos para GUIs ou E/S assíncrona com callbacks, e para adotar um estilo funcional quando fizer sentido.

Decoradores parametrizados quase sempre implicam em pelo menos dois níveis de funções aninhadas, talvez mais se você quiser usar @functools.wraps, e produzir um decorador com um suporte melhor a técnicas mais avançadas. Uma dessas técnicas é o empilhamento de decoradores, que vimos no Exemplo 18. Para decoradores mais sofisticados, uma implementação baseada em classes pode ser mais fácil de ler e manter.

Como exemplos de decoradores parametrizados na biblioteca padrão, visitamos os poderosos @cache e @singledispatch, do módulo functools.

9.12. Leitura complementar

O item #26 do livro Effective Python, 2nd ed. (EN) (Addison-Wesley), de Brett Slatkin, trata das melhores práticas para decoradores de função, e recomenda sempre usar functools.wraps—que vimos no Exemplo 16.[113]

Graham Dumpleton tem, em seu blog, uma série de posts abrangentes (EN) sobre técnicas para implementar decoradores bem comportados, começando com "How you implemented your Python decorator is wrong" (A forma como você implementou seu decorador em Python está errada). Seus conhecimentos profundos sobre o assunto também estão muito bem demonstrados no módulo wrapt, que Dumpleton escreveu para simplificar a implementação de decoradores e invólucros (wrappers) dinâmicos de função, que suportam introspecção e se comportam de forma correta quando decorados novamente, quando aplicados a métodos e quando usados como descritores de atributos. O Capítulo 23 na Parte III: Classes e protocolos é sobre descritores.

"Metaprogramming" (Metaprogramação) (EN), o capítulo 9 do Python Cookbook, 3ª ed. de David Beazley e Brian K. Jones (O’Reilly), tem várias receitas ilustrando desde decoradores elementares até alguns muito sofisticados, incluindo um que pode ser invocado como um decorador regular ou como uma fábrica de decoradores, por exemplo, @clock ou @clock(). É a "Recipe 9.6. Defining a Decorator That Takes an Optional Argument" (Receita 9.6. Definindo um Decorador Que Recebe um Argumento Opcional) desse livro de receitas.

Michele Simionato criou um pacote com objetivo de "simplificar o uso de decoradores para o programador comum, e popularizar os decoradores através da apresentação de vários exemplos não-triviais", de acordo com a documentação. Ele está disponível no PyPI, em decorator package (pacote decorador) (EN).

Criada quando os decoradores ainda eram um recurso novo no Python, a página wiki Python Decorator Library (EN) tem dezenas de exemplos. Como começou há muitos anos, algumas das técnicas apresentadas foram suplantadas, mas ela ainda é uma excelente fonte de inspiração.

"Closures in Python" (Clausuras em Python) (EN) é um post de blog curto de Fredrik Lundh, explicando a terminologia das clausuras.

A PEP 3104—​Access to Names in Outer Scopes (Acesso a Nomes em Escopos Externos) (EN) descreve a introdução da declaração nonlocal, para permitir a re-vinculação de nomes que não são nem locais nem globais. Ela também inclui uma excelente revisão de como essa questão foi resolvida em outras linguagens dinâmicas (Perl, Ruby, JavaScript, etc.) e os prós e contras das opções de design disponíveis para o Python.

Em um nível mais teórico, a PEP 227—​Statically Nested Scopes (Escopos Estaticamente Aninhados) (EN) documenta a introdução do escopo léxico como um opção no Python 2.1 e como padrão no Python 2.2, explicando a justificativa e as opções de design para a implementação de clausuras no Python.

A PEP 443 (EN) traz a justificativa e uma descrição detalhada do mecanismo de funções genéricas de despacho único. Um post de Guido van Rossum de março de 2005 "Five-Minute Multimethods in Python" (Multi-métodos em Python em Cinco Minutos) (EN), mostra os passos para uma implementação de funcões genéricas (também chamadas multi-métodos) usando decoradores. O código de multi-métodos de Guido é interessante, mas é apenas um exemplo didático. Para conhecer uma implementação de funções genéricas de despacho múltiplo moderna e pronta para uso em produção, veja a Reg de Martijn Faassen–autor de Morepath, um framework web guiado por modelos e orientado a REST.

Ponto de vista

Escopo dinâmico versus escopo léxico

O projetista de qualquer linguagem que contenha funções de primeira classe se depara com essa questão: sendo um objeto de primeira classe, uma função é definida dentro de um determinado escopo, mas pode ser invocada em outros escopos. A pergunta é: como avaliar as variáveis livres? A resposta inicial e mais simples é "escopo dinâmico". Isso significa que variáveis livres são avaliadas olhando para dentro do ambiente onde a funcão é invocada.

Se o Python tivesse escopo dinâmico e não tivesse clausuras, poderíamos improvisar avg—similar ao Exemplo 8—assim:

>>> ### this is not a real Python console session! ###
>>> avg = make_averager()
>>> series = []  # (1)
>>> avg(10)
10.0
>>> avg(11)  # (2)
10.5
>>> avg(12)
11.0
>>> series = [1]  # (3)
>>> avg(5)
3.0
  1. Antes de usar avg, precisamos definir por nós mesmos series = [], então precisamos saber que averager (dentro de make_averager) se refere a uma lista chamada series.

  2. Por trás da cortina, series acumula os valores cuja média será calculada.

  3. Quando series = [1] é executada, a lista anterior é perdida. Isso poderia ocorrer por acidente, ao se tratar duas médias continuas independentes ao mesmo tempo.

Funções deveriam ser opacas, sua implementação invisível para os usuários. Mas com escopo dinâmico, se a função usa variáveis livres, o programador precisa saber do funcionamento interno da função, para ser capaz de configurar um ambiente onde ela execute corretamente. Após anos lutando com a linguagem de preparação de documentos LaTeX, o excelente livro Practical LaTeX (LaTeX Prático), de George Grätzer (Springer), me ensinou que as variáveis no LaTeX usam escopo dinâmico. Por isso me confundiam tanto!

O Lisp do Emacs também usa escopo dinâmico, pelo menos como default. Veja "Dynamic Binding" (Vinculação Dinâmica) no manual do Emacs para uma breve explicação.

O escopo dinâmico é mais fácil de implementar, e essa foi provavelmente a razão de John McCarthy ter tomado esse caminho quando criou o Lisp, a primeira linguagem a ter funções de primeira classe. O texto de Paul Graham, "The Roots of Lisp" (As Raízes do Lisp) é uma explicação acessível do artigo original de John McCarthy sobre a linguagem Lisp, "Recursive Functions of Symbolic Expressions and Their Computation by Machine, Part I" (Funções Recursivas de Expressões Simbólicas e Sua Computação via Máquina). O artigo de McCarthy é uma obra prima no nível da Nona Sinfonia de Beethoven. Paul Graham o traduziu para o resto de nós, da matemática para o inglês e o código executável.

O comentário de Paul Graham explica como o escopo dinâmico é complexo. Citando o "The Roots of Lisp":

É um testemunho eloquente dos perigos do escopo dinâmico, que mesmo o primeiro exemplo de funções de ordem superior em Lisp estivesse errado por causa dele. Talvez, em 1960, McCarthy não estivesse inteiramente ciente das implicações do escopo dinâmico, que continuou presente nas implementações de Lisp por um tempo surpreendentemente longo—até Sussman e Steele desenvolverem o Scheme, em 1975. O escopo léxico não complica demais a definição de eval, mas pode tornar mais difícil escrever compiladores.

Hoje em dia o escopo léxico é o padrão: variáveis livres são avaliadas considerando o ambiente onde a função foi definida. O escopo léxico complica a implementação de linguagens com funções de primeira classe, pois requer o suporte a clausuras. Por outro lado, o escopo léxico torna o código-fonte mais fácil de ler. A maioria das linguagens inventadas desde o Algol tem escopo léxico. Uma exceção notável é o JavaScript, onde a variável especial this é confusa, pois pode ter escopo léxico ou dinâmico, dependendo da forma como o código for escrito (EN).

Por muitos anos, o lambda do Python não ofereceu clausuras, contribuindo para a má fama deste recurso entre os fãs da programação funcional na blogosfera. Isso foi resolvido no Python 2.2 (de dezembro de 2001), mas a blogosfera tem uma memória muito boa. Desde então, lambda é embaraçoso apenas por sua sintaxe limitada.

O decoradores do Python e o padrão de projeto Decorador

Os decoradores de função do Python se encaixam na descrição geral dos decoradores de Gamma et al. em Padrões de Projeto: "Acrescenta responsabilidades adicionais a um objeto de forma dinâmica. Decoradores fornecem uma alternativa flexível à criação de subclasses para estender funcionalidade."

Ao nível da implementação, os decoradores do Python não lembram o padrão de projeto decorador clássico, mas é possível fazer uma analogia.

No padrão de projeto, Decorador e Componente são classes abstratas. Uma instância de um decorador concreto envolve uma instância de um componente concreto para adicionar comportamentos a ela. Citando Padrões de Projeto:

O decorador se adapta à interface do componente decorado, assim sua presença é transparente para os clientes do componente. O decorador encaminha requisições para o componente e pode executar ações adicionais (tal como desenhar uma borda) antes ou depois do encaminhamento. A transparência permite aninhar decoradores de forma recursiva, possibilitando assim um número ilimitado de responsabilidades adicionais. (p. 175 da edição em inglês)

No Python, a funcão decoradora faz o papel de uma subclasse concreta de Decorador, e a função interna que ela devolve é uma instância do decorador. A função devolvida envolve a função a ser decorada, que é análoga ao componente no padrão de projeto. A função devolvida é transparente, pois se adapta à interface do componente (ao aceitar os mesmos argumentos). Pegando emprestado da citação anterior, podemos adaptar a última frase para dizer que "A transparência permite empilhar decoradores, possibilitando assim um número ilimitado de comportamentos adicionais".

Veja que não estou sugerindo que decoradores de função devam ser usados para implementar o padrão decorador em programas Python. Apesar disso ser possível em situações específicas, em geral o padrão decorador é melhor implementado com classes representando o decorador e os componentes que ela vai envolver.

10. Padrões de projetos com funções de primeira classe

Conformidade a padrões não é uma medida de virtude.[114]

— Ralph Johnson
co-autor do clássico "Padrões de Projetos"

Em engenharia de software, um padrão de projeto é uma receita genérica para solucionar um problema de design frequente. Não é preciso conhecer padrões de projeto para acompanhar esse capítulo, vou explicar os padrões usados nos exemplos.

O uso de padrões de projeto em programação foi popularizado pelo livro seminal Padrões de Projetos: Soluções Reutilizáveis de Software Orientados a Objetos (Addison-Wesley), de Erich Gamma, Richard Helm, Ralph Johnson e John Vlissides—também conhecidos como "the Gang of Four" (A Gangue dos Quatro). O livro é um catálogo de 23 padrões, cada um deles composto por arranjos de classes e exemplificados com código em C++, mas assumidos como úteis também em outras linguagens orientadas a objetos.

Apesar dos padrões de projeto serem independentes da linguagem, isso não significa que todo padrão se aplica a todas as linguagens. Por exemplo, o Capítulo 17 vai mostrar que não faz sentido emular a receita do padrão Iterator (Iterador) (EN) no Python, pois esse padrão está embutido na linguagem e pronto para ser usado, na forma de geradores—que não precisam de classes para funcionar, e exigem menos código que a receita clássica.

Os autores de Padrões de Projetos reconhecem, na introdução, que a linguagem usada na implementação determina quais padrões são relevantes:

A escolha da linguagem de programação é importante, pois ela influencia nosso ponto de vista. Nossos padrões supõe uma linguagem com recursos equivalentes aos do Smalltalk e do C++—e essa escolha determina o que pode e o que não pode ser facilmente implementado. Se tivéssemos presumido uma linguagem procedural, poderíamos ter incluído padrões de projetos chamados "Herança", "Encapsulamento" e "Polimorfismo". Da mesma forma, alguns de nossos padrões são suportados diretamente por linguagens orientadas a objetos menos conhecidas. CLOS, por exemplo, tem multi-métodos, reduzindo a necessidade de um padrão como o Visitante.[115]

Em sua apresentação de 1996, "Design Patterns in Dynamic Languages" (Padrões de Projetos em Linguagens Dinâmicas) (EN), Peter Norvig afirma que 16 dos 23 padrões no Padrões de Projeto original se tornam "invisíveis ou mais simples" em uma linguagem dinâmica (slide 9). Ele está falando das linguagens Lisp e Dylan, mas muitos dos recursos dinâmicos relevantes também estão presentes no Python. Em especial, no contexto de linguagens com funções de primeira classe, Norvig sugere repensar os padrões clássicos conhecidos como Estratégia (Strategy), Comando (Command), Método Template (Template Method) e Visitante (Visitor).

O objetivo desse capítulo é mostrar como—em alguns casos—as funções podem realizar o mesmo trabalho das classes, com um código mais legível e mais conciso. Vamos refatorar uma implementaçao de Estratégia usando funções como objetos, removendo muito código redundante. Vamos também discutir uma abordagem similar para simplificar o padrão Comando.

10.1. Novidades nesse capítulo

Movi este capítulo para o final da Parte II, para poder então aplicar o decorador de registro na Seção 10.3, e também usar dicas de tipo nos exemplos. A maior parte das dicas de tipo usadas nesse capítulo não são complicadas, e ajudam na legibilidade.

10.2. Estudo de caso: refatorando Estratégia

Estratégia é um bom exemplo de um padrão de projeto que pode ser mais simples em Python, usando funções como objetos de primeira classe. Na próxima seção vamos descrever e implementar Estratégia usando a estrutura "clássica" descrita em Padrões de Projetos. Se você estiver familiarizado com o padrão clássico, pode pular direto para Seção 10.2.2, onde refatoramos o código usando funções, reduzindo significativamente o número de linhas.

10.2.1. Estratégia clássica

O diagrama de classes UML na Figura 1 retrata um arranjo de classes exemplificando o padrão Estratégia.

Cálculos de desconto de um pedido como estratégias
Figura 1. Diagrama de classes UML para o processamento de descontos em um pedido, implementado com o padrão de projeto Estratégia.

O padrão Estratégia é resumido assim em Padrões de Projetos:

Define uma família de algoritmos, encapsula cada um deles, e os torna intercambiáveis. Estratégia permite que o algoritmo varie de forma independente dos clientes que o usam.

Um exemplo claro de Estratégia, aplicado ao domínio do ecommerce, é o cálculo de descontos em pedidos de acordo com os atributos do cliente ou pela inspeção dos itens do pedido.

Considere uma loja online com as seguintes regras para descontos:

  • Clientes com 1.000 ou mais pontos de fidelidade recebem um desconto global de 5% por pedido.

  • Um desconto de 10% é aplicado a cada item com 20 ou mais unidades no mesmo pedido.

  • Pedidos com pelo menos 10 itens diferentes recebem um desconto global de 7%.

Para simplificar, vamos assumir que apenas um desconto pode ser aplicado a cada pedido.

O diagrama de classes UML para o padrão Estratégia aparece na Figura 1. Seus participantes são:

Contexto (Context)

Oferece um serviço delegando parte do processamento para componentes intercambiáveis, que implementam algoritmos alternativos. No exemplo de ecommerce, o contexto é uma classe Order, configurada para aplicar um desconto promocional de acordo com um de vários algoritmos.

Estratégia (Strategy)

A interface comum dos componentes que implementam diferentes algoritmos. No nosso exemplo, esse papel cabe a uma classe abstrata chamada Promotion.

Estratégia concreta (Concrete strategy)

Cada uma das subclasses concretas de Estratégia. FidelityPromo, BulkPromo, e LargeOrderPromo são as três estratégias concretas implementadas.

O código no Exemplo 1 segue o modelo da Figura 1. Como descrito em Padrões de Projetos, a estratégia concreta é escolhida pelo cliente da classe de contexto. No nosso exemplo, antes de instanciar um pedido, o sistema deveria, de alguma forma, selecionar o estratégia de desconto promocional e passá-la para o construtor de Order. A seleção da estratégia está fora do escopo do padrão.

Exemplo 1. Implementação da classe Order com estratégias de desconto intercambiáveis
from abc import ABC, abstractmethod
from collections.abc import Sequence
from decimal import Decimal
from typing import NamedTuple, Optional


class Customer(NamedTuple):
    name: str
    fidelity: int


class LineItem(NamedTuple):
    product: str
    quantity: int
    price: Decimal

    def total(self) -> Decimal:
        return self.price * self.quantity


class Order(NamedTuple):  # the Context
    customer: Customer
    cart: Sequence[LineItem]
    promotion: Optional['Promotion'] = None

    def total(self) -> Decimal:
        totals = (item.total() for item in self.cart)
        return sum(totals, start=Decimal(0))

    def due(self) -> Decimal:
        if self.promotion is None:
            discount = Decimal(0)
        else:
            discount = self.promotion.discount(self)
        return self.total() - discount

    def __repr__(self):
        return f'<Order total: {self.total():.2f} due: {self.due():.2f}>'


class Promotion(ABC):  # the Strategy: an abstract base class
    @abstractmethod
    def discount(self, order: Order) -> Decimal:
        """Return discount as a positive dollar amount"""


class FidelityPromo(Promotion):  # first Concrete Strategy
    """5% discount for customers with 1000 or more fidelity points"""

    def discount(self, order: Order) -> Decimal:
        rate = Decimal('0.05')
        if order.customer.fidelity >= 1000:
            return order.total() * rate
        return Decimal(0)


class BulkItemPromo(Promotion):  # second Concrete Strategy
    """10% discount for each LineItem with 20 or more units"""

    def discount(self, order: Order) -> Decimal:
        discount = Decimal(0)
        for item in order.cart:
            if item.quantity >= 20:
                discount += item.total() * Decimal('0.1')
        return discount


class LargeOrderPromo(Promotion):  # third Concrete Strategy
    """7% discount for orders with 10 or more distinct items"""

    def discount(self, order: Order) -> Decimal:
        distinct_items = {item.product for item in order.cart}
        if len(distinct_items) >= 10:
            return order.total() * Decimal('0.07')
        return Decimal(0)

Observe que no Exemplo 1, programei Promotion como uma classe base abstrata (ABC), para usar o decorador @abstractmethod e deixar o padrão mais explícito.

O Exemplo 2 apresenta os doctests usados para demonstrar e verificar a operação de um módulo implementando as regras descritas anteriormente.

Exemplo 2. Amostra de uso da classe Order com a aplicação de diferentes promoções
    >>> joe = Customer('John Doe', 0)  # (1)
    >>> ann = Customer('Ann Smith', 1100)
    >>> cart = (LineItem('banana', 4, Decimal('.5')),  # (2)
    ...         LineItem('apple', 10, Decimal('1.5')),
    ...         LineItem('watermelon', 5, Decimal(5)))
    >>> Order(joe, cart, FidelityPromo())  # (3)
    <Order total: 42.00 due: 42.00>
    >>> Order(ann, cart, FidelityPromo())  # (4)
    <Order total: 42.00 due: 39.90>
    >>> banana_cart = (LineItem('banana', 30, Decimal('.5')),  # (5)
    ...                LineItem('apple', 10, Decimal('1.5')))
    >>> Order(joe, banana_cart, BulkItemPromo())  # (6)
    <Order total: 30.00 due: 28.50>
    >>> long_cart = tuple(LineItem(str(sku), 1, Decimal(1)) # (7)
    ...                  for sku in range(10))
    >>> Order(joe, long_cart, LargeOrderPromo())  # (8)
    <Order total: 10.00 due: 9.30>
    >>> Order(joe, cart, LargeOrderPromo())
    <Order total: 42.00 due: 42.00>
  1. Dois clientes: joe tem 0 pontos de fidelidade, ann tem 1.100.

  2. Um carrinho de compras com três itens.

  3. A promoção FidelityPromo não dá qualquer desconto para joe.

  4. ann recebe um desconto de 5% porque tem pelo menos 1.000 pontos.

  5. O banana_cart contém 30 unidade do produto "banana" e 10 maçãs.

  6. Graças à BulkItemPromo, joe recebe um desconto de $1,50 no preço das bananas.

  7. O long_cart tem 10 itens diferentes, cada um custando $1,00.

  8. joe recebe um desconto de 7% no pedido total, por causa da LargerOrderPromo.

O Exemplo 1 funciona perfeitamente bem, mas a mesma funcionalidade pode ser implementada com menos linhas de código em Python, se usarmos funções como objetos. Veremos como fazer isso na próxima seção.

10.2.2. Estratégia baseada em funções

Cada estratégia concreta no Exemplo 1 é uma classe com um único método, discount. Além disso, as instâncias de estratégia não tem nenhum estado (nenhum atributo de instância). Você poderia dizer que elas se parecem muito com funções simples, e estaria certa. O Exemplo 3 é uma refatoração do Exemplo 1, substituindo as estratégias concretas por funções simples e removendo a classe abstrata Promo. São necessários apenas alguns pequenos ajustes na classe Order.[116]

Exemplo 3. A classe Order com as estratégias de descontos implementadas como funções
from collections.abc import Sequence
from dataclasses import dataclass
from decimal import Decimal
from typing import Optional, Callable, NamedTuple


class Customer(NamedTuple):
    name: str
    fidelity: int


class LineItem(NamedTuple):
    product: str
    quantity: int
    price: Decimal

    def total(self):
        return self.price * self.quantity

@dataclass(frozen=True)
class Order:  # the Context
    customer: Customer
    cart: Sequence[LineItem]
    promotion: Optional[Callable[['Order'], Decimal]] = None  # (1)

    def total(self) -> Decimal:
        totals = (item.total() for item in self.cart)
        return sum(totals, start=Decimal(0))

    def due(self) -> Decimal:
        if self.promotion is None:
            discount = Decimal(0)
        else:
            discount = self.promotion(self)  # (2)
        return self.total() - discount

    def __repr__(self):
        return f'<Order total: {self.total():.2f} due: {self.due():.2f}>'


# (3)


def fidelity_promo(order: Order) -> Decimal:  # (4)
    """5% discount for customers with 1000 or more fidelity points"""
    if order.customer.fidelity >= 1000:
        return order.total() * Decimal('0.05')
    return Decimal(0)


def bulk_item_promo(order: Order) -> Decimal:
    """10% discount for each LineItem with 20 or more units"""
    discount = Decimal(0)
    for item in order.cart:
        if item.quantity >= 20:
            discount += item.total() * Decimal('0.1')
    return discount


def large_order_promo(order: Order) -> Decimal:
    """7% discount for orders with 10 or more distinct items"""
    distinct_items = {item.product for item in order.cart}
    if len(distinct_items) >= 10:
        return order.total() * Decimal('0.07')
    return Decimal(0)
  1. Essa dica de tipo diz: promotion pode ser None, ou pode ser um invocável que recebe uma Order como argumento e devolve um Decimal.

  2. Para calcular o desconto, chama o invocável self.promotion, passando self como um argumento. Veja a razão disso logo abaixo.

  3. Nenhuma classe abstrata.

  4. Cada estratégia é uma função.

👉 Dica
Por que self.promotion(self)?

Na classe Order, promotion não é um método. É um atributo de instância que por acaso é invocável. Então a primeira parte da expressão, self.promotion, busca aquele invocável. Mas, ao invocá-lo, precisamos fornecer uma instância de Order, que neste caso é self. Por isso self aparece duas vezes na expressão.

A Seção 23.4 vai explicar o mecanismo que vincula automaticamente métodos a instâncias. Mas isso não se aplica a promotion, pois ela não é um método.

O código no Exemplo 3 é mais curto que o do Exemplo 1. Usar a nova Order é também um pouco mais simples, como mostram os doctests no Exemplo 4.

Exemplo 4. Amostra do uso da classe Order com as promoções como funções
    >>> joe = Customer('John Doe', 0)  # (1)
    >>> ann = Customer('Ann Smith', 1100)
    >>> cart = [LineItem('banana', 4, Decimal('.5')),
    ...         LineItem('apple', 10, Decimal('1.5')),
    ...         LineItem('watermelon', 5, Decimal(5))]
    >>> Order(joe, cart, fidelity_promo)  # (2)
    <Order total: 42.00 due: 42.00>
    >>> Order(ann, cart, fidelity_promo)
    <Order total: 42.00 due: 39.90>
    >>> banana_cart = [LineItem('banana', 30, Decimal('.5')),
    ...                LineItem('apple', 10, Decimal('1.5'))]
    >>> Order(joe, banana_cart, bulk_item_promo)  # (3)
    <Order total: 30.00 due: 28.50>
    >>> long_cart = [LineItem(str(item_code), 1, Decimal(1))
    ...               for item_code in range(10)]
    >>> Order(joe, long_cart, large_order_promo)
    <Order total: 10.00 due: 9.30>
    >>> Order(joe, cart, large_order_promo)
    <Order total: 42.00 due: 42.00>
  1. Mesmos dispositivos de teste do Exemplo 1.

  2. Para aplicar uma estratégia de desconto a uma Order, basta passar a função de promoção como argumento.

  3. Uma função de promoção diferente é usada aqui e no teste seguinte.

Observe os textos explicativos do Exemplo 4—não há necessidade de instanciar um novo objeto promotion com cada novo pedido: as funções já estão disponíveis para serem usadas.

É interessante notar que no Padrões de Projetos, os autores sugerem que: "Objetos Estratégia muitas vezes são bons "peso mosca" (flyweight)".[117] Uma definição do padrão Peso Mosca em outra parte daquele texto afirma: "Um peso mosca é um objeto compartilhado que pode ser usado em múltiplos contextos simultaneamente."[118] O compartilhamento é recomendado para reduzir o custo da criação de um novo objeto concreto de estratégia, quando a mesma estratégia é aplicada repetidamente a cada novo contexto—no nosso exemplo, a cada nova instância de Order. Então, para contornar uma desvantagem do padrão Estratégia—seu custo durante a execução—os autores recomendam a aplicação de mais outro padrão. Enquanto isso, o número de linhas e custo de manutenção de seu código vão se acumulando.

Um caso de uso mais espinhoso, com estratégias concretas complexas mantendo estados internos, pode exigir a combinação de todas as partes dos padrões de projeto Estratégia e Peso Mosca. Muitas vezes, porém, estratégias concretas não tem estado interno; elas lidam apenas com dados vindos do contexto. Neste caso, não tenha dúvida, use as boas e velhas funções ao invés de escrever classes de um só metodo implementando uma interface de um só método declarada em outra classe diferente. Uma função pesa menos que uma instância de uma classe definida pelo usuário, e não há necessidade do Peso Mosca, pois cada função da estratégia é criada apenas uma vez por processo Python, quando o módulo é carregado. Uma função simples também é um "objeto compartilhado que pode ser usado em múltiplos contextos simultaneamente".

Uma vez implementado o padrão Estratégia com funções, outras possibilidades nos ocorrem. Suponha que você queira criar uma "meta-estratégia", que seleciona o melhor desconto disponível para uma dada Order. Nas próximas seções vamos estudar as refatorações adicionais para implementar esse requisito, usando abordagens que se valem de funções e módulos vistos como objetos.

10.2.3. Escolhendo a melhor estratégia: uma abordagem simples

Dados os mesmos clientes e carrinhos de compras dos testes no Exemplo 4, vamos agora acrescentar três testes adicionais ao Exemplo 5.

Exemplo 5. A funcão best_promo aplica todos os descontos e devolve o maior
    >>> Order(joe, long_cart, best_promo)  # (1)
    <Order total: 10.00 due: 9.30>
    >>> Order(joe, banana_cart, best_promo)  # (2)
    <Order total: 30.00 due: 28.50>
    >>> Order(ann, cart, best_promo)  # (3)
    <Order total: 42.00 due: 39.90>
  1. best_promo selecionou a larger_order_promo para o cliente joe.

  2. Aqui joe recebeu o desconto de bulk_item_promo, por comprar muitas bananas.

  3. Encerrando a compra com um carrinho simples, best_promo deu à cliente fiel ann o desconto da fidelity_promo.

A implementação de best_promo é muito simples. Veja o Exemplo 6.

Exemplo 6. best_promo encontra o desconto máximo iterando sobre uma lista de funções
promos = [fidelity_promo, bulk_item_promo, large_order_promo]  # (1)


def best_promo(order: Order) -> Decimal:  # (2)
    """Compute the best discount available"""
    return max(promo(order) for promo in promos)  # (3)
  1. promos: lista de estratégias implementadas como funções.

  2. best_promo recebe uma instância de Order como argumento, como as outras funções *_promo.

  3. Usando uma expressão geradora, aplicamos cada uma das funções de promos a order, e devolvemos o maior desconto encontrado.

O Exemplo 6 é bem direto: promos é uma list de funções. Depois que você se acostuma à ideia de funções como objetos de primeira classe, o próximo passo é notar que construir estruturas de dados contendo funções muitas vezes faz todo sentido.

Apesar do Exemplo 6 funcionar e ser fácil de ler, há alguma duplicação que poderia levar a um bug sutil: para adicionar uma nova estratégia, precisamos escrever a função e lembrar de incluí-la na lista promos. De outra forma a nova promoção só funcionará quando passada explicitamente como argumento para Order, e não será considerada por best_promotion.

Vamos examinar algumas soluções para essa questão.

10.2.4. Encontrando estratégias em um módulo

Módulos também são objetos de primeira classe no Python, e a biblioteca padrão oferece várias funções para lidar com eles. A função embutida globals é descrita assim na documentação do Python:

globals()

Devolve um dicionário representando a tabela de símbolos globais atual. Isso é sempre o dicionário do módulo atual (dentro de uma função ou método, esse é o módulo onde a função ou método foram definidos, não o módulo de onde são chamados).

O Exemplo 7 é uma forma um tanto hacker de usar globals para ajudar best_promo a encontrar automaticamente outras funções *_promo disponíveis.

Exemplo 7. A lista promos é construída a partir da introspecção do espaço de nomes global do módulo
from decimal import Decimal
from strategy import Order
from strategy import (
    fidelity_promo, bulk_item_promo, large_order_promo  # (1)
)

promos = [promo for name, promo in globals().items()  # (2)
                if name.endswith('_promo') and        # (3)
                   name != 'best_promo'               # (4)
]


def best_promo(order: Order) -> Decimal:              # (5)
    """Compute the best discount available"""
    return max(promo(order) for promo in promos)
  1. Importa as funções de promoções, para que fiquem disponíveis no espaço de nomes global.[119]

  2. Itera sobre cada item no dict devolvido por globals().

  3. Seleciona apenas aqueles valores onde o nome termina com o sufixo _promo e…​

  4. …​filtra e remove a própria best_promo, para evitar uma recursão infinita quando best_promo for invocada.

  5. Nenhuma mudança em best_promo.

Outra forma de coletar as promoções disponíveis seria criar um módulo e colocar nele todas as funções de estratégia, exceto best_promo.

No Exemplo 8, a única mudança significativa é que a lista de funções de estratégia é criada pela introspecção de um módulo separado chamado promotions. Veja que o Exemplo 8 depende da importação do módulo promotions bem como de inspect, que fornece funções de introspecção de alto nível.

Exemplo 8. A lista promos é construída a partir da introspecção de um novo módulo, promotions
from decimal import Decimal
import inspect

from strategy import Order
import promotions


promos = [func for _, func in inspect.getmembers(promotions, inspect.isfunction)]


def best_promo(order: Order) -> Decimal:
    """Compute the best discount available"""
    return max(promo(order) for promo in promos)

A função inspect.getmembers devolve os atributos de um objeto—neste caso, o módulo promotions—opcionalmente filtrados por um predicado (uma função booleana). Usamos inspect.isfunction para obter apenas as funções do módulo.

O Exemplo 8 funciona independente dos nomes dados às funções; tudo o que importa é que o módulo promotions contém apenas funções que, dado um pedido, calculam os descontos. Claro, isso é uma suposição implícita do código. Se alguém criasse uma função com uma assinatura diferente no módulo promotions, best_promo geraria um erro ao tentar aplicá-la a um pedido.

Poderíamos acrescentar testes mais estritos para filtrar as funções, por exemplo inspecionando seus argumentos. O ponto principal do Exemplo 8 não é oferecer uma solução completa, mas enfatizar um uso possível da introspecção de módulo.

Uma alternativa mais explícita para coletar dinamicamente as funções de desconto promocional seria usar um decorador simples. É nosso próximo tópico.

10.3. Padrão Estratégia aperfeiçoado com um decorador

Lembre-se que nossa principal objeção ao Exemplo 6 foi a repetição dos nomes das funções em suas definições e na lista promos, usada pela função best_promo para determinar o maior desconto aplicável. A repetição é problemática porque alguém pode acrescentar uma nova função de estratégia promocional e esquecer de adicioná-la manualmente à lista promos—caso em que best_promo vai silenciosamente ignorar a nova estratégia, introduzindo no sistema um bug sutil. O Exemplo 9 resolve esse problema com a técnica vista na Seção 9.4.

Exemplo 9. A lista promos é preenchida pelo decorador promotion
Promotion = Callable[[Order], Decimal]

promos: list[Promotion] = []  # (1)


def promotion(promo: Promotion) -> Promotion:  # (2)
    promos.append(promo)
    return promo


def best_promo(order: Order) -> Decimal:
    """Compute the best discount available"""
    return max(promo(order) for promo in promos)  # (3)


@promotion  # (4)
def fidelity(order: Order) -> Decimal:
    """5% discount for customers with 1000 or more fidelity points"""
    if order.customer.fidelity >= 1000:
        return order.total() * Decimal('0.05')
    return Decimal(0)


@promotion
def bulk_item(order: Order) -> Decimal:
    """10% discount for each LineItem with 20 or more units"""
    discount = Decimal(0)
    for item in order.cart:
        if item.quantity >= 20:
            discount += item.total() * Decimal('0.1')
    return discount


@promotion
def large_order(order: Order) -> Decimal:
    """7% discount for orders with 10 or more distinct items"""
    distinct_items = {item.product for item in order.cart}
    if len(distinct_items) >= 10:
        return order.total() * Decimal('0.07')
    return Decimal(0)
  1. A lista promos é global no módulo, e começa vazia.

  2. promotion é um decorador de registro: ele devolve a função promo inalterada, após inserí-la na lista promos.

  3. Nenhuma mudança é necessária em best_promo, pois ela se baseia na lista promos.

  4. Qualquer função decorada com @promotion será adicionada a promos.

Essa solução tem várias vantagens sobre aquelas apresentadas anteriormente:

  • As funções de estratégia de promoção não precisam usar nomes especiais—não há necessidade do sufixo _promo.

  • O decorador @promotion realça o propósito da função decorada, e também torna mais fácil desabilitar temporariamente uma promoção: basta transformar a linha do decorador em comentário.

  • Estratégias de desconto promocional podem ser definidas em outros módulos, em qualquer lugar do sistema, desde que o decorador @promotion seja aplicado a elas.

Na próxima seção vamos discutir Comando (Command)—outro padrão de projeto que é algumas vezes implementado via classes de um só metodo, quando funções simples seriam suficientes.

10.4. O padrão Comando

Comando é outro padrão de projeto que pode ser simplificado com o uso de funções passadas como argumentos. A Figura 2 mostra o arranjo das classes nesse padrão.

Aplicação do padrão Comando a um editor de texto
Figura 2. Diagrama de classes UML para um editor de texto controlado por menus, implementado com o padrão de projeto Comando. Cada comando pode ter um receptor (receiver) diferente: o objeto que implementa a ação. Para PasteCommand, o receptor é Document. Para OpenCommand, o receptor á a aplicação.

O objetivo de Comando é desacoplar um objeto que invoca uma operação (o invoker ou remetente) do objeto fornecedor que implementa aquela operação (o receiver ou receptor). No exemplo em Padrões de Projetos, cada remetente é um item de menu em uma aplicação gráfica, e os receptors são o documento sendo editado ou a própria aplicação.

A ideia é colocar um objeto Command entre os dois, implementando uma interface com um único método, execute, que chama algum método no receptor para executar a operação desejada. Assim, o remetente não precisa conhecer a interface do receptor, e receptors diferentes podem ser adaptados com diferentes subclasses de Command. O remetente é configurado com um comando concreto, e o opera chamando seu método execute. Observe na Figura 2 que MacroCommand pode armazenar um sequência de comandos; seu método execute() chama o mesmo método em cada comando armazenado.

Citando Padrões de Projetos, "Comandos são um substituto orientado a objetos para callbacks." A pergunta é: precisamos de um substituto orientado a objetos para callbacks? Algumas vezes sim, mas nem sempre.

Em vez de dar ao remetente uma instância de Command, podemos simplesmente dar a ele uma função. Em vez de chamar command.execute(), o remetente pode apenas chamar command(). O MacroCommand pode ser programado como uma classe que implementa __call__. Instâncias de MacroCommand seriam invocáveis, cada uma mantendo uma lista de funções para invocação futura, como implementado no Exemplo 10.

Exemplo 10. Cada instância de MacroCommand tem uma lista interna de comandos
class MacroCommand:
    """A command that executes a list of commands"""

    def __init__(self, commands):
        self.commands = list(commands)  # (1)

    def __call__(self):
        for command in self.commands:  # (2)
            command()
  1. Criar uma nova lista com os itens do argumento commands garante que ela seja iterável e mantém uma cópia local de referências a comandos em cada instância de MacroCommand.

  2. Quando uma instância de MacroCommand é invocada, cada comando em self.commands é chamado em sequência.

Usos mais avançados do padrão Comando—para implementar "desfazer", por exemplo—podem exigir mais que uma simples função de callback. Mesmo assim, o Python oferece algumas alternativas que merecem ser consideradas:

  • Uma instância invocável como MacroCommand no Exemplo 10 pode manter qualquer estado que seja necessário, e oferecer outros métodos além de __call__.

  • Uma clausura pode ser usada para manter o estado interno de uma função entre invocações.

Isso encerra nossa revisão do padrão Comando usando funções de primeira classe. Por alto, a abordagem aqui foi similar à que aplicamos a Estratégia: substituir as instâncias de uma classe participante que implementava uma interface de método único por invocáveis. Afinal, todo invocável do Python implementa uma interface de método único, e esse método se chama __call__.

10.5. Resumo do Capítulo

Como apontou Peter Norvig alguns anos após o surgimento do clássico Padrões de Projetos, "16 dos 23 padrões tem implementações qualitativamente mais simples em Lisp ou Dylan que em C++, pelo menos para alguns usos de cada padrão" (slide 9 da apresentação de Norvig, "Design Patterns in Dynamic Languages" presentation (Padrões de Projetos em Linguagens Dinâmicas)). O Python compartilha alguns dos recursos dinâmicos das linguagens Lisp e Dylan, especialmente funções de primeira classe, nosso foco nesse capítulo.

Na mesma palestra citada no início deste capítulo, refletindo sobre o 20º aniversário de Padrões de Projetos: Soluções Reutilizáveis de Software Orientados a Objetos, Ralph Johnson afirmou que um dos defeitos do livro é: "Excesso de ênfase nos padrões como linhas de chegada, em vez de como etapas em um processo de design".[120] Neste capítulo usamos o padrão Estratégia como ponto de partida: uma solução que funcionava, mas que simplificamos usando funções de primeira classe.

Em muitos casos, funções ou objetos invocáveis oferecem um caminho mais natural para implementar callbacks em Python que a imitação dos padrões Estratégia ou Comando como descritos por Gamma, Helm, Johnson, e Vlissides em Padrões de Projetos. A refatoração de Estratégia e a discussão de Comando nesse capítulo são exemplos de uma ideia mais geral: algumas vezes você pode encontrar uma padrão de projeto ou uma API que exigem que seus componentes implementem uma interface com um único método, e aquele método tem um nome que soa muito genérico, como "executar", "rodar" ou "fazer". Tais padrões ou APIs podem frequentemente ser implementados em Python com menos código repetitivo, usando funções como objetos de primeira classe.

10.6. Leitura complementar

A "Receita 8.21. Implementando o Padrão Visitante" (Receipt 8.21. Implementing the Visitor Pattern) no Python Cookbook, 3ª ed. (EN), mostra uma implementação elegante do padrão Visitante, na qual uma classe NodeVisitor trata métodos como objetos de primeira classe.

Sobre o tópico mais geral de padrões de projetos, a oferta de leituras para o programador Python não é tão numerosa quando aquela disponível para as comunidades de outras linguagens.

Learning Python Design Patterns ("Aprendendo os Padrões de Projeto do Python"), de Gennadiy Zlobin (Packt), é o único livro inteiramente dedicado a padrões em Python que encontrei. Mas o trabalho de Zlobin é muito breve (100 páginas) e trata de apenas 8 dos 23 padrões de projeto originais.

Expert Python Programming ("Programação Avançada em Python"), de Tarek Ziadé (Packt), é um dos melhores livros de Python de nível intermediário, e seu capítulo final, "Useful Design Patterns" (Padrões de Projetos Úteis), apresenta vários dos padrões clássicos de uma perspectiva pythônica.

Alex Martelli já apresentou várias palestras sobre padrões de projetos em Python. Há um vídeo de sua apresentação na EuroPython (EN) e um conjunto de slides em seu site pessoal (EN). Ao longo dos anos, encontrei diferentes jogos de slides e vídeos de diferentes tamanhos, então vale a pena tentar uma busca mais ampla com o nome dele e as palavras "Python Design Patterns". Um editor me contou que Martelli está trabalhando em um livro sobre esse assunto. Eu certamente comprarei meu exemplar assim que estiver disponível.

Há muitos livros sobre padrões de projetos no contexto do Java mas, dentre todos eles, meu preferido é Head First Design Patterns ("Mergulhando de Cabeça nos Padrões de Projetos"), 2ª ed., de Eric Freeman e Elisabeth Robson (O’Reilly). Esse volume explica 16 dos 23 padrões clássicos. Se você gosta do estilo amalucado da série Head First e precisa de uma introdução a esse tópico, vai adorar esse livro. Ele é centrado no Java, mas a segunda edição foi atualizada para refletir a introdução de funções de primeira classe naquela linguagem, tornando alguns dos exemplos mais próximos de código que escreveríamos em Python.

Para um olhar moderno sobre padrões, do ponto de vista de uma linguagem dinâmica com duck typing e funções de primeira classe, Design Patterns in Ruby ("Padrões de Projetos em Ruby") de Russ Olsen (Addison-Wesley) traz muitas ideias aplicáveis também ao Python. A despeito de suas muitas diferenças sintáticas, no nível semântico o Python e o Ruby estão mais próximos entre si que do Java ou do C++.

Em "Design Patterns in Dynamic Languages" (Padrões de Projetos em Linguagens Dinâmicas) (slides), Peter Norvig mostra como funções de primeira classe (e outros recursos dinâmicos) tornam vários dos padrões de projeto originais mais simples ou mesmo desnecessários.

A "Introdução" do Padrões de Projetos original, de Gamma et al. já vale o preço do livro—mais até que o catálogo de 23 padrões, que inclui desde receitas muito importantes até algumas raramente úteis. Alguns princípios de projetos de software muito citados, como "Programe para uma interface, não para uma implementação" e "Prefira a composição de objetos à herança de classe", vem ambos daquela introdução.

A aplicação de padrões a projetos se originou com o arquiteto Christopher Alexander et al., e foi apresentada no livro A Pattern Language ("Uma Linguagem de Padrões") (Oxford University Press). A ideia de Alexander é criar um vocabulário padronizado, permitindo que equipes compartilhem decisões comuns em projetos de edificações. M. J. Dominus wrote “‘Design Patterns’ Aren’t” (Padrões de Projetos Não São), uma curiosa apresentação de slides acompanhada de um texto, argumentando que a visão original de Alexander sobre os padrões é mais profunda e mais humanista e também aplicável à engenharia de software.

Ponto de vista

O Python tem funções de primeira classe e tipos de primeira classe, e Norvig afima que esses recursos afetam 10 dos 23 padrões (no slide 10 de "Design Patterns in Dynamic Languages" (Padrões de Projetos em Linguagens Dinâmicas)). No Capítulo 9, vimos que o Python também tem funções genéricas (na Seção 9.9.3), uma forma limitada dos multi-métodos do CLOS, que Gamma et al. sugerem como uma maneira mais simples de implementar o padrão clássico Visitante (Visitor). Norvig, por outro lado, diz (no slide 10) que os multi-métodos simplificam o padrão Construtor (Builder). Ligar padrões de projetos a recursos de linguagens não é uma ciência exata.

Em cursos a redor do mundo todo, padrões de projetos são frequentemente ensinados usando exemplos em Java. Ouvi mais de um estudante dizer que eles foram levados a crer que os padrões de projeto originais são úteis qualquer que seja a linguagem usada na implementação. A verdade é que os 23 padrões "clássicos" de Padrões de Projetos se aplicam muito bem ao Java, apesar de terem sido apresentados principalmente no contexto do C++—no livro, alguns deles tem exemplos em Smalltalk. Mas isso não significa que todos aqueles padrões podem ser aplicados de forma igualmente satisfatória a qualquer linguagem. Os autores dizem explicitamente, logo no início de seu livro, que "alguns de nossos padrões são suportados diretamente por linguagens orientadas a objetos menos conhecidas" (a citação completa apareceu na primeira página deste capítulo).

A bibliografia do Python sobre padrões de projetos é muito pequena, se comparada à existente para Java, C++ ou Ruby. Na Seção 10.6, mencionei Learning Python Design Patterns ("Aprendendo Padrões de Projeto do Python"), de Gennadiy Zlobin, que foi publicado apenas em novembro de 2013. Para se ter uma ideia, Design Patterns in Ruby ("Padrões de Projetos em Ruby"), de Russ Olsen, foi publicado em 2007 e tem 384 páginas—284 a mais que a obra de Zlobin.

Agora que o Python está se tornando cada vez mais popular no ambiente acadêmico, podemos esperar que novos livros sobre padrões de projetos sejam escritos no contexto de nossa linguagem. Além disso, o Java 8 introduziu referências a métodos e funções anônimas, e esses recursos muito esperados devem incentivar o surgimento de novas abordagens aos padrões em Java—reconhecendo que, à medida que as linguagens evoluem, nosso entendimento sobre a forma de aplicação dos padrões de projetos clássicos deve também evoluir.

O call selvagem

Enquanto trabalhávamos juntos para dar os toques finais a este livro, o revisor técnico Leonardo Rochael pensou:

Se funções tem um método __call__, e métodos também são invocáveis, será que os métodos __call__ também tem um método __call__?

Não sei se a descoberta dele tem alguma utilidade, mas eis um fato engraçado:

>>> def turtle():
...     return 'eggs'
...
>>> turtle()
'eggs'
>>> turtle.__call__()
'eggs'
>>> turtle.__call__.__call__()
'eggs'
>>> turtle.__call__.__call__.__call__()
'eggs'
>>> turtle.__call__.__call__.__call__.__call__()
'eggs'
>>> turtle.__call__.__call__.__call__.__call__.__call__()
'eggs'
>>> turtle.__call__.__call__.__call__.__call__.__call__.__call__()
'eggs'
>>> turtle.__call__.__call__.__call__.__call__.__call__.__call__.__call__()
'eggs'

Parte III: Classes e protocolos

11. Um objeto pythônico

Para uma biblioteca ou framework, ser pythônica significa tornar tão fácil e tão natural quanto possível que uma programadora Python descubra como realizar uma tarefa.[122]

— Martijn Faassen
criador de frameworks Python e JavaScript

Graças ao Modelo de Dados do Python, nossos tipos definidos pelo usuário podem se comportar de forma tão natural quanto os tipos embutidos. E isso pode ser realizado sem herança, no espírito do duck typing: implemente os métodos necessários e seus objetos se comportarão da forma esperada.

Nos capítulos anteriores, estudamos o comportamento de vários objetos embutidos. Vamos agora criar classes definidas pelo usuário que se portam como objetos Python reais. As classes na sua aplicação provavelmente não precisam nem devem implementar tantos métodos especiais quanto os exemplos nesse capítulo. Mas se você estiver escrevendo uma biblioteca ou um framework, os programadores que usarão suas classes talvez esperem que elas se comportem como as classes fornecidas pelo Python. Satisfazer tal expectativa é um dos jeitos de ser "pythônico".

Esse capítulo começa onde o Capítulo 1 terminou, mostrando como implementar vários métodos especiais comumente vistos em objetos Python de diferentes tipos.

Veremos como:

  • Suportar as funções embutidas que convertem objetos para outros tipos (por exemplo, repr(), bytes(), complex(), etc.)

  • Implementar um construtor alternativo como um método da classe

  • Estender a mini-linguagem de formatação usada pelas f-strings, pela função embutida format() e pelo método str.format()

  • Fornecer acesso a atributos apenas para leitura

  • Tornar um objetos hashable, para uso em sets e como chaves de dict

  • Economizar memória com __slots__

Vamos fazer tudo isso enquanto desenvolvemos Vector2d, um tipo simples de vetor euclidiano bi-dimensional. No Capítulo 12, o mesmo código servirá de base para uma classe de vetor N-dimensional.

A evolução do exemplo será interrompida para discutirmos dois tópicos conceituais:

  • Como e quando usar os decoradores @classmethod e @staticmethod

  • Atributos privados e protegidos no Python: uso, convenções e limitações

11.1. Novidades nesse capítulo

Acrescentei uma nova epígrafe e também algumas palavras ao segundo parágrafo do capítulo, para falar do conceito de "pythônico"—que na primeira edição era discutido apenas no final do livro.

A Seção 11.6 foi atualizada para mencionar as f-strings, introduzidas no Python 3.6. É uma mudança pequena, pois as f-strings suportam a mesma mini-linguagem de formatação que a função embutida format() e o método str.format(), então quaisquer métodos __format__ implementados antes vão funcionar também com as f-strings.

O resto do capítulo quase não mudou—os métodos especiais são praticamente os mesmos desde o Python 3.0, e as ideias centrais apareceram no Python 2.2.

Vamos começar pelos métodos de representação de objetos.

11.2. Representações de objetos

Todas as linguagens orientadas a objetos tem pelo menos uma forma padrão de se obter uma representação de qualquer objeto como uma string. O Python tem duas formas:

repr()

Devolve uma string representando o objeto como o desenvolvedor quer vê-lo. É o que aparece quando o console do Python ou um depurador mostram um objeto.

str()

Devolve uma string representando o objeto como o usuário quer vê-lo. É o que aparece quando se passa um objeto como argumento para print().

Os métodos especiais __repr__ e __str__ suportam repr() e str(), como vimos no Capítulo 1.

Existem dois métodos especiais adicionais para suportar representações alternativas de objetos, __bytes__ e __format__. O método __bytes__ é análogo a __str__: ele é chamado por bytes() para obter um objeto representado como uma sequência de bytes. Já __format__ é usado por f-strings, pela função embutida format() e pelo método str.format(). Todos eles chamam obj.format(format_spec) para obter versões de exibição de objetos usando códigos de formatação especiais. Vamos tratar de __bytes__ na próxima seção e de __format__ logo depois.

⚠️ Aviso

Se você está vindo do Python 2, lembre-se que no Python 3 __repr__, __str__ e __format__ devem sempre devolver strings Unicode (tipo str). Apenas __bytes__ deveria devolver uma sequência de bytes (tipo bytes).

11.3. A volta da classe Vector

Para demonstrar os vários métodos usados para gerar representações de objetos, vamos criar uma classe Vector2d, similar à que vimos no Capítulo 1. O Exemplo 1 ilustra o comportamento básico que esperamos de uma instância de Vector2d.

Exemplo 1. Instâncias de Vector2d têm várias representações
    >>> v1 = Vector2d(3, 4)
    >>> print(v1.x, v1.y)  # (1)
    3.0 4.0
    >>> x, y = v1  # (2)
    >>> x, y
    (3.0, 4.0)
    >>> v1  # (3)
    Vector2d(3.0, 4.0)
    >>> v1_clone = eval(repr(v1))  # (4)
    >>> v1 == v1_clone  # (5)
    True
    >>> print(v1)  # (6)
    (3.0, 4.0)
    >>> octets = bytes(v1)  # (7)
    >>> octets
    b'd\\x00\\x00\\x00\\x00\\x00\\x00\\x08@\\x00\\x00\\x00\\x00\\x00\\x00\\x10@'
    >>> abs(v1)  # (8)
    5.0
    >>> bool(v1), bool(Vector2d(0, 0))  # (9)
    (True, False)
  1. Os componentes de um Vector2d podem ser acessados diretamente como atributos (não é preciso invocar métodos getter).

  2. Um Vector2d pode ser desempacotado para uma tupla de variáveis.

  3. O repr de um Vector2d emula o código-fonte usado para construir a instância.

  4. Usar eval aqui mostra que o repr de um Vector2d é uma representação fiel da chamada a seu construtor.[123]

  5. Vector2d suporta a comparação com ==; isso é útil para testes.

  6. print chama str, que no caso de Vector2d exibe um par ordenado.

  7. bytes usa o método __bytes__ para produzir uma representação binária.

  8. abs usa o método __abs__ para devolver a magnitude do Vector2d.

  9. bool usa o método __bool__ para devolver False se o Vector2d tiver magnitude zero, caso contrário esse método devolve True.

O Vector2d do Exemplo 1 é implementado em vector2d_v0.py (no Exemplo 2). O código está baseado no Exemplo 2, exceto pelos métodos para os operadores + e *, que veremos mais tarde no Capítulo 16. Vamos acrescentar o método para ==, já que ele é útil para testes. Nesse ponto, Vector2d usa vários métodos especiais para oferecer operações que um pythonista espera encontrar em um objeto bem projetado.

Exemplo 2. vector2d_v0.py: todos os métodos até aqui são métodos especiais
from array import array
import math


class Vector2d:
    typecode = 'd'  # (1)

    def __init__(self, x, y):
        self.x = float(x)    # (2)
        self.y = float(y)

    def __iter__(self):
        return (i for i in (self.x, self.y))  # (3)

    def __repr__(self):
        class_name = type(self).__name__
        return '{}({!r}, {!r})'.format(class_name, *self)  # (4)

    def __str__(self):
        return str(tuple(self))  # (5)

    def __bytes__(self):
        return (bytes([ord(self.typecode)]) +  # (6)
                bytes(array(self.typecode, self)))  # (7)

    def __eq__(self, other):
        return tuple(self) == tuple(other)  # (8)

    def __abs__(self):
        return math.hypot(self.x, self.y)  # (9)

    def __bool__(self):
        return bool(abs(self))  # (10)
  1. typecode é um atributo de classe, usado na conversão de instâncias de Vector2d de/para bytes.

  2. Converter x e y para float em __init__ captura erros mais rápido, algo útil quando Vector2d é chamado com argumentos inadequados.

  3. __iter__ torna um Vector2d iterável; é isso que faz o desempacotamento funcionar (por exemplo, x, y = my_vector). Vamos implementá-lo aqui usando uma expressão geradora para produzir os componentes, um após outro.[124]

  4. O __repr__ cria uma string interpolando os componentes com {!r}, para obter seus repr; como Vector2d é iterável, *self alimenta format com os componentes x e y.

  5. Dado um iterável Vector2d, é fácil criar uma tuple para exibição como um par ordenado.

  6. Para gerar bytes, convertemos o typecode para bytes e concatenamos…​

  7. …​bytes convertidos a partir de um array criada iterando sobre a instância.

  8. Para comparar rapidamente todos os componentes, cria tuplas a partir dos operandos. Isso funciona para operandos que sejam instâncias de Vector2d, mas tem problemas. Veja o alerta abaixo.

  9. A magnitude é o comprimento da hipotenusa do triângulo retângulo de catetos formados pelos componentes x e y.

  10. __bool__ usa abs(self) para computar a magnitude, então a converte para bool; assim, 0.0 se torna False, qualquer valor diferente de zero é True.

⚠️ Aviso

O método __eq__ no Exemplo 2 funciona para operandos Vector2d, mas também devolve True ao comparar instâncias de Vector2d a outros iteráveis contendo os mesmos valores numéricos (por exemplo, Vector(3, 4) == [3, 4]). Isso pode ser considerado uma característica ou um bug. Essa discussão terá que esperar até o Capítulo 16, onde falamos de sobrecarga de operadores.

Temos um conjunto bastante completo de métodos básicos, mas ainda precisamos de uma maneira de reconstruir um Vector2d a partir da representação binária produzida por bytes().

11.4. Um construtor alternativo

Já que podemos exportar um Vector2d na forma de bytes, naturalmente precisamos de um método para importar um Vector2d de uma sequência binária. Procurando na biblioteca padrão por algo similar, descobrimos que array.array tem um método de classe chamado .frombytes, adequado a nossos propósitos—​já o vimos na Seção 2.10.1. Adotamos o mesmo nome e usamos sua funcionalidade em um método de classe para Vector2d em vector2d_v1.py (no Exemplo 3).

Exemplo 3. Parte de vector2d_v1.py: esse trecho mostra apenas o método de classe frombytes, acrescentado à definição de Vector2d em vector2d_v0.py (no Exemplo 2)
    @classmethod  # (1)
    def frombytes(cls, octets):  # (2)
        typecode = chr(octets[0])  # (3)
        memv = memoryview(octets[1:]).cast(typecode)  # (4)
        return cls(*memv)  # (5)
  1. O decorador classmethod modifica um método para que ele possa ser chamado diretamente em uma classe.

  2. Nenhum argumento self; em vez disso, a própria classe é passada como primeiro argumento—por convenção chamado cls.

  3. Lê o typecode do primeiro byte.

  4. Cria uma memoryview a partir da sequência binária octets, e usa o typecode para convertê-la.[125]

  5. Desempacota a memoryview resultante da conversão no par de argumentos necessários para o construtor.

Acabei de usar um decorador classmethod, e ele é muito específico do Python. Vamos então falar um pouco disso.

11.5. classmethod versus staticmethod

O decorador classmethod não é mencionado no tutorial do Python, nem tampouco o staticmethod. Qualquer um que tenha aprendido OO com Java pode se perguntar porque o Python tem esses dois decoradores, e não apenas um deles.

Vamos começar com classmethod. O Exemplo 3 mostra seu uso: definir um método que opera na classe, e não em suas instâncias. O classmethod muda a forma como o método é chamado, então recebe a própria classe como primeiro argumento, em vez de uma instância. Seu uso mais comum é em construtores alternativos, como frombytes no Exemplo 3. Observe como a última linha de frombytes de fato usa o argumento cls, invocando-o para criar uma nova instância: cls(*memv).

O decorador staticmethod, por outro lado, muda um método para que ele não receba qualquer primeiro argumento especial. Essencialmente, um método estático é apenas uma função simples que por acaso mora no corpo de uma classe, em vez de ser definida no nível do módulo. O Exemplo 4 compara a operação de classmethod e staticmethod.

Exemplo 4. Comparando o comportamento de classmethod e staticmethod
>>> class Demo:
...     @classmethod
...     def klassmeth(*args):
...         return args  # (1)
...     @staticmethod
...     def statmeth(*args):
...         return args  # (2)
...
>>> Demo.klassmeth()  # (3)
(<class '__main__.Demo'>,)
>>> Demo.klassmeth('spam')
(<class '__main__.Demo'>, 'spam')
>>> Demo.statmeth()   # (4)
()
>>> Demo.statmeth('spam')
('spam',)
  1. klassmeth apenas devolve todos os argumentos posicionais.

  2. statmeth faz o mesmo.

  3. Não importa como ele seja invocado, Demo.klassmeth recebe sempre a classe Demo como primeiro argumento.

  4. Demo.statmeth se comporta exatamente como uma boa e velha função.

✒️ Nota

O decorador classmethod é obviamente útil mas, em minha experiência, bons casos de uso para staticmethod são muito raros. Talvez a função, mesmo sem nunca tocar na classe, seja intimamente relacionada a essa última. Daí você pode querer que ela fique próxima no seu código. E mesmo assim, definir a função logo antes ou logo depois da classe, no mesmo módulo, é perto o suficiente na maioria dos casos.[126]

Agora que vimos para que serve o classmethod (e que o staticmethod não é muito útil), vamos voltar para a questão da representação de objetos e entender como gerar uma saída formatada.

11.6. Exibição formatada

As f-strings, a função embutida format() e o método str.format() delegam a formatação efetiva para cada tipo, chamando seu método .__format__(format_spec). O format_spec especifica a formatação desejada, e é:

  • O segundo argumento em format(my_obj, format_spec), ou

  • O que quer que apareça após os dois pontos (:) em um campo de substituição delimitado por {} dentro de uma f-string ou o fmt em fmt.str.format()

Por exemplo:

>>> brl = 1 / 4.82  # BRL to USD currency conversion rate
>>> brl
0.20746887966804978
>>> format(brl, '0.4f')  # (1)
'0.2075'
>>> '1 BRL = {rate:0.2f} USD'.format(rate=brl)  # (2)
'1 BRL = 0.21 USD'
>>> f'1 USD = {1 / brl:0.2f} BRL'  # (3)
'1 USD = 4.82 BRL'
  1. A formatação especificada é '0.4f'.

  2. A formatação especificada é '0.2f'. O rate no campo de substituição não é parte da especificação de formato. Ele determina qual argumento nomeado de .format() entra no campo de substituição.

  3. Novamente, a especificação é '0.2f'. A expressão 1 / brl não é parte dela.

O segundo e o terceiro textos explicativos apontam um fato importante: uma string de formatação tal como’{0.mass:5.3e}'` na verdade usa duas notações separadas. O '0.mass' à esquerda dos dois pontos é a parte field_name da sintaxe de campo de substituição, e pode ser uma expressão arbitrária em uma f-string. O '5.3e' após os dois pontos é a especificação do formato. A notação usada na especificação do formato é chamada Mini-Linguagem de Especificação de Formato.

👉 Dica

Se f-strings, format() e str.format() são novidades para você, minha experiência como professor me informa que é melhor estudar primeiro a função embutida format(), que usa apenas a Mini-Linguagem de Especificação de Formato. Após pegar o jeito desta última, leia "Literais de string formatados" e "Sintaxe das string de formato", para aprender sobre a notação de campo de substituição ({:}), usada em f-strings e no método str.format() (incluindo os marcadores de conversão !s, !r, e !a). F-strings não tornam str.format() obsoleto: na maioria dos casos f-strings resolvem o problema, mas algumas vezes é melhor especificar a string de formatação em outro lugar (e não onde ela será renderizada).

Alguns tipos embutidos tem seus próprios códigos de apresentação na Mini-Linguagem de Especificação de Formato. Por exemplo—entre muitos outros códigos—o tipo int suporta b e x, para saídas em base 2 e base 16, respectivamente, enquanto float implementa f, para uma exibição de ponto fixo, e %, para exibir porcentagens:

>>> format(42, 'b')
'101010'
>>> format(2 / 3, '.1%')
'66.7%'

A Mini-Linguagem de Especificação de Formato é extensível, porque cada classe interpreta o argumento format_spec como quiser. Por exemplo, as classes no módulo datetime usam os mesmos códigos de formatação nas funções strftime() e em seus métodos __format__. Veja abaixo alguns exemplos de uso da função format() e do método str.format():

>>> from datetime import datetime
>>> now = datetime.now()
>>> format(now, '%H:%M:%S')
'18:49:05'
>>> "It's now {:%I:%M %p}".format(now)
"It's now 06:49 PM"

Se a classe não possuir um __format__, o método herdado de object devolve str(my_object). Como Vector2d tem um __str__, isso funciona:

>>> v1 = Vector2d(3, 4)
>>> format(v1)
'(3.0, 4.0)'

Entretanto, se você passar um especificador de formato, object.__format__ gera um TypeError:

>>> format(v1, '.3f')
Traceback (most recent call last):
  ...
TypeError: non-empty format string passed to object.__format__

Vamos corrigir isso implementando nossa própria mini-linguagem de formatação. O primeiro passo será presumir que o especificador de formato fornecido pelo usuário tem por objetivo formatar cada componente float do vetor. Esse é o resultado esperado:

>>> v1 = Vector2d(3, 4)
>>> format(v1)
'(3.0, 4.0)'
>>> format(v1, '.2f')
'(3.00, 4.00)'
>>> format(v1, '.3e')
'(3.000e+00, 4.000e+00)'

O Exemplo 5 implementa __format__ para produzir as formatações vistas acima.

Exemplo 5. O método Vector2d.__format__, versão #1
    # inside the Vector2d class

    def __format__(self, fmt_spec=''):
        components = (format(c, fmt_spec) for c in self)  # (1)
        return '({}, {})'.format(*components)  # (2)
  1. Usa a função embutida format para aplicar o fmt_spec a cada componente do vetor, criando um iterável de strings formatadas.

  2. Insere as strings formatadas na fórmula '(x, y)'.

Agora vamos acrescentar um código de formatação personalizado à nossa mini-linguagem: se o especificador de formato terminar com 'p', vamos exibir o vetor em coordenadas polares: <r, θ>, onde r é a magnitute e θ (theta) é o ângulo em radianos. O restante do especificador de formato (o que quer que venha antes do 'p') será usado como antes.

👉 Dica

Ao escolher a letra para um código personalizado de formato, evitei sobrepor códigos usados por outros tipos. Na Mini-Linguagem de Especificação de Formato vemos que inteiros usam os códigos 'bcdoxXn', floats usam 'eEfFgGn%' e strings usam 's'. Então escolhi 'p' para coordenadas polares. Como cada classe interpreta esses códigos de forma independente, reutilizar uma letra em um formato personalizado para um novo tipo não é um erro, mas pode ser confuso para os usuários.

Para gerar coordenadas polares, já temos o método __abs__ para a magnitude. Vamos então escrever um método angle simples, usando a função math.atan2(), para obter o ângulo. Eis o código:

    # inside the Vector2d class

    def angle(self):
        return math.atan2(self.y, self.x)

Com isso, podemos agora aperfeiçoar nosso __format__ para gerar coordenadas polares. Veja o Exemplo 6.

Exemplo 6. O método Vector2d.__format__, versão #2, agora com coordenadas polares
    def __format__(self, fmt_spec=''):
        if fmt_spec.endswith('p'):  # (1)
            fmt_spec = fmt_spec[:-1]  # (2)
            coords = (abs(self), self.angle())  # (3)
            outer_fmt = '<{}, {}>'  # (4)
        else:
            coords = self  # (5)
            outer_fmt = '({}, {})'  # (6)
        components = (format(c, fmt_spec) for c in coords)  # (7)
        return outer_fmt.format(*components)  # (8)
  1. O formato termina com 'p': usa coordenadas polares.

  2. Remove o sufixo 'p' de fmt_spec.

  3. Cria uma tuple de coordenadas polares: (magnitude, angle).

  4. Configura o formato externo com chaves de ângulo.

  5. Caso contrário, usa os componentes x, y de self para coordenadas retângulares.

  6. Configura o formato externo com parênteses.

  7. Gera um iterável cujos componentes são strings formatadas.

  8. Insere as strings formatadas no formato externo.

Com o Exemplo 6, obtemos resultados como esses:

>>> format(Vector2d(1, 1), 'p')
'<1.4142135623730951, 0.7853981633974483>'
>>> format(Vector2d(1, 1), '.3ep')
'<1.414e+00, 7.854e-01>'
>>> format(Vector2d(1, 1), '0.5fp')
'<1.41421, 0.78540>'

Como mostrou essa seção, não é difícil estender a Mini-Linguagem de Especificação de Formato para suportar tipos definidos pelo usuário.

Vamos agora passar a um assunto que vai além das aparências: tornar nosso Vector2d hashable, para podermos criar conjuntos de vetores ou usá-los como chaves em um dict.

11.7. Um Vector2d hashable

Da forma como ele está definido até agora, as instâncias de nosso Vector2d não são hashable, então não podemos colocá-las em um set:

>>> v1 = Vector2d(3, 4)
>>> hash(v1)
Traceback (most recent call last):
  ...
TypeError: unhashable type: 'Vector2d'
>>> set([v1])
Traceback (most recent call last):
  ...
TypeError: unhashable type: 'Vector2d'

Para tornar um Vector2d hashable, precisamos implementar __hash__ (__eq__ também é necessário, mas já temos esse método). Além disso, precisamos tornar imutáveis as instâncias do vetor, como vimos na Seção 3.4.1.

Nesse momento, qualquer um pode fazer v1.x = 7, e não há nada no código sugerindo que é proibido modificar um Vector2d. O comportamento que queremos é o seguinte:

>>> v1.x, v1.y
(3.0, 4.0)
>>> v1.x = 7
Traceback (most recent call last):
  ...
AttributeError: can't set attribute

Faremos isso transformando os componentes x e y em propriedades apenas para leitura no Exemplo 7.

Exemplo 7. vector2d_v3.py: apenas as mudanças necessárias para tornar Vector2d imutável são exibidas aqui; a listagem completa está no Exemplo 11
class Vector2d:
    typecode = 'd'

    def __init__(self, x, y):
        self.__x = float(x)  # (1)
        self.__y = float(y)

    @property  # (2)
    def x(self):  # (3)
        return self.__x  # (4)

    @property  # (5)
    def y(self):
        return self.__y

    def __iter__(self):
        return (i for i in (self.x, self.y))  # (6)

    # remaining methods: same as previous Vector2d
  1. Usa exatamente dois sublinhados como prefixo (com zero ou um sublinhado como sufixo), para tornar um atributo privado.[127]

  2. O decorador @property marca o método getter de uma propriedade.

  3. O método getter é nomeado de acordo com o nome da propriedade pública que ele expõe: x.

  4. Apenas devolve self.__x.

  5. Repete a mesma fórmula para a propriedade y.

  6. Todos os métodos que apenas leem os componentes x e y podem permanecer como estavam, lendo as propriedades públicas através de self.x e self.y em vez de usar os atributos privados. Então essa listagem omite o restante do código da classe.

✒️ Nota

Vector.x e Vector.y são exemplos de propriedades apenas para leitura. Propriedades para leitura/escrita serão tratadas no Capítulo 22, onde mergulhamos mais fundo no decorador @property.

Agora que nossos vetores estão razoavelmente protegidos contra mutação acidental, podemos implementar o método __hash__. Ele deve devolver um int e, idealmente, levar em consideração os hashs dos atributos do objeto usados também no método __eq__, pois objetos que são considerados iguais ao serem comparados devem ter o mesmo hash. A documentação do método especial __hash__ sugere computar o hash de uma tupla com os componentes, e é isso que fazemos no Exemplo 8.

Exemplo 8. vector2d_v3.py: implementação de hash
    # inside class Vector2d:

    def __hash__(self):
        return hash((self.x, self.y))

Com o acréscimo do método __hash__, temos agora vetores hashable:

>>> v1 = Vector2d(3, 4)
>>> v2 = Vector2d(3.1, 4.2)
>>> hash(v1), hash(v2)
(1079245023883434373, 1994163070182233067)
>>> {v1, v2}
{Vector2d(3.1, 4.2), Vector2d(3.0, 4.0)}
👉 Dica

Não é estritamente necessário implementar propriedades ou proteger de alguma forma os atributos de instância para criar um tipo hashable. Só é necessário implementar corretamente __hash__ e __eq__. Mas, supostamente, o valor de um objeto hashable nunca deveria mudar, então isso me dá uma boa desculpa para falar sobre propriedades apenas para leitura.

Se você estiver criando um tipo com um valor numérico escalar que faz sentido, você pode também implementar os métodos __int__ e __float__, invocados pelos construtores int() e float(), que são usados, em alguns contextos, para coerção de tipo. Há também o método __complex__, para suportar o construtor embutido complex(). Talvez Vector2d pudesse oferecer o __complex__, mas deixo isso como um exercício para vocês.

11.8. Suportando o pattern matching posicional

Até aqui, instâncias de Vector2d são compatíveis com o pattern matching com instâncias de classe—vistos na Seção 5.8.2.

No Exemplo 9, todos aqueles padrões nomeados funcionam como esperado.

Exemplo 9. Padrões nomeados para sujeitos Vector2d—requer Python 3.10
def keyword_pattern_demo(v: Vector2d) -> None:
    match v:
        case Vector2d(x=0, y=0):
            print(f'{v!r} is null')
        case Vector2d(x=0):
            print(f'{v!r} is vertical')
        case Vector2d(y=0):
            print(f'{v!r} is horizontal')
        case Vector2d(x=x, y=y) if x==y:
            print(f'{v!r} is diagonal')
        case _:
            print(f'{v!r} is awesome')

Entretanto, se tentamos usar um padrão posicional, como esse:

        case Vector2d(_, 0):
            print(f'{v!r} is horizontal')

o resultado é esse:

TypeError: Vector2d() accepts 0 positional sub-patterns (1 given)

Para fazer Vector2d funcionar com padrões posicionais, precisamos acrescentar um atributo de classe chamado __match_args__, listando os atributos de instância na ordem em que eles serão usados no pattern matching posicional.

class Vector2d:
    __match_args__ = ('x', 'y')

    # etc...

Agora podemos economizar alguma digitação ao escrever padrões para usar contra sujeitos Vector2d, como se vê no Exemplo 10.

Exemplo 10. Padrões posicionais para sujeitos Vector2d—requer Python 3.10
def positional_pattern_demo(v: Vector2d) -> None:
    match v:
        case Vector2d(0, 0):
            print(f'{v!r} is null')
        case Vector2d(0):
            print(f'{v!r} is vertical')
        case Vector2d(_, 0):
            print(f'{v!r} is horizontal')
        case Vector2d(x, y) if x==y:
            print(f'{v!r} is diagonal')
        case _:
            print(f'{v!r} is awesome')

O atributo de classe __match_args__ não precisa incluir todos os atributos públicos de instância. Em especial, se o __init__ da classe tem argumentos obrigatórios e opcionais, que são depois vinculados a atributos de instância, pode ser razoável nomear apenas os argumentos obrigatórios em __match_args__, omitindo os opcionais.

Vamos dar um passo atrás e revisar tudo o que programamos até aqui no Vector2d.

11.9. Listagem completa Vector2d, versão 3

Já estamos trabalhando no Vector2d há algum tempo, mostrando apenas trechos isolados. O Exemplo 11 é uma listagem completa e consolidada de vector2d_v3.py, incluindo os doctests que usei durante o desenvolvimento.

Exemplo 11. vector2d_v3.py: o pacote completo
"""
A two-dimensional vector class

    >>> v1 = Vector2d(3, 4)
    >>> print(v1.x, v1.y)
    3.0 4.0
    >>> x, y = v1
    >>> x, y
    (3.0, 4.0)
    >>> v1
    Vector2d(3.0, 4.0)
    >>> v1_clone = eval(repr(v1))
    >>> v1 == v1_clone
    True
    >>> print(v1)
    (3.0, 4.0)
    >>> octets = bytes(v1)
    >>> octets
    b'd\\x00\\x00\\x00\\x00\\x00\\x00\\x08@\\x00\\x00\\x00\\x00\\x00\\x00\\x10@'
    >>> abs(v1)
    5.0
    >>> bool(v1), bool(Vector2d(0, 0))
    (True, False)


Test of ``.frombytes()`` class method:

    >>> v1_clone = Vector2d.frombytes(bytes(v1))
    >>> v1_clone
    Vector2d(3.0, 4.0)
    >>> v1 == v1_clone
    True


Tests of ``format()`` with Cartesian coordinates:

    >>> format(v1)
    '(3.0, 4.0)'
    >>> format(v1, '.2f')
    '(3.00, 4.00)'
    >>> format(v1, '.3e')
    '(3.000e+00, 4.000e+00)'


Tests of the ``angle`` method::

    >>> Vector2d(0, 0).angle()
    0.0
    >>> Vector2d(1, 0).angle()
    0.0
    >>> epsilon = 10**-8
    >>> abs(Vector2d(0, 1).angle() - math.pi/2) < epsilon
    True
    >>> abs(Vector2d(1, 1).angle() - math.pi/4) < epsilon
    True


Tests of ``format()`` with polar coordinates:

    >>> format(Vector2d(1, 1), 'p')  # doctest:+ELLIPSIS
    '<1.414213..., 0.785398...>'
    >>> format(Vector2d(1, 1), '.3ep')
    '<1.414e+00, 7.854e-01>'
    >>> format(Vector2d(1, 1), '0.5fp')
    '<1.41421, 0.78540>'


Tests of `x` and `y` read-only properties:

    >>> v1.x, v1.y
    (3.0, 4.0)
    >>> v1.x = 123
    Traceback (most recent call last):
      ...
    AttributeError: can't set attribute 'x'


Tests of hashing:

    >>> v1 = Vector2d(3, 4)
    >>> v2 = Vector2d(3.1, 4.2)
    >>> len({v1, v2})
    2

"""

from array import array
import math

class Vector2d:
    __match_args__ = ('x', 'y')

    typecode = 'd'

    def __init__(self, x, y):
        self.__x = float(x)
        self.__y = float(y)

    @property
    def x(self):
        return self.__x

    @property
    def y(self):
        return self.__y

    def __iter__(self):
        return (i for i in (self.x, self.y))

    def __repr__(self):
        class_name = type(self).__name__
        return '{}({!r}, {!r})'.format(class_name, *self)

    def __str__(self):
        return str(tuple(self))

    def __bytes__(self):
        return (bytes([ord(self.typecode)]) +
                bytes(array(self.typecode, self)))

    def __eq__(self, other):
        return tuple(self) == tuple(other)

    def __hash__(self):
        return hash((self.x, self.y))

    def __abs__(self):
        return math.hypot(self.x, self.y)

    def __bool__(self):
        return bool(abs(self))

    def angle(self):
        return math.atan2(self.y, self.x)

    def __format__(self, fmt_spec=''):
        if fmt_spec.endswith('p'):
            fmt_spec = fmt_spec[:-1]
            coords = (abs(self), self.angle())
            outer_fmt = '<{}, {}>'
        else:
            coords = self
            outer_fmt = '({}, {})'
        components = (format(c, fmt_spec) for c in coords)
        return outer_fmt.format(*components)

    @classmethod
    def frombytes(cls, octets):
        typecode = chr(octets[0])
        memv = memoryview(octets[1:]).cast(typecode)
        return cls(*memv)

Recordando, nessa seção e nas anteriores vimos alguns dos métodos especiais essenciais que você pode querer implementar para obter um objeto completo.

✒️ Nota

Você deve implementar esses métodos especiais apenas se sua aplicação precisar deles. Os usuários finais não se importam se os objetos que compõem uma aplicação são pythônicos ou não.

Por outro lado, se suas classes são parte de uma biblioteca para ser usada por outros programadores Python, você não tem como adivinhar como eles vão usar seus objetos. E eles estarão esperando ver esses comportamentos pythônicos que descrevemos aqui.

Como programado no Exemplo 11, Vector2d é um exemplo didático com uma lista extensiva de métodos especiais relacionados à representação de objetos, não um modelo para qualquer classe definida pelo usuário.

Na próxima seção, deixamos o Vector2d de lado por um tempo para discutir o design e as desvantagens do mecanismo de atributos privados no Python—o prefixo de duplo sublinhado em self.__x.

11.10. Atributos privados e "protegidos" no Python

Em Python, não há como criar variáveis privadas como as criadas com o modificador private no Java. O que temos no Python é um mecanismo simples para prevenir que um atributo "privado" em uma subclasse seja acidentalmente sobrescrito.

Considere o seguinte cenário: alguém escreveu uma classe chamada Dog, que usa um atributo de instância mood internamente, sem expô-lo. Você precisa criar a uma subclasse Beagle de Dog. Se você criar seu próprio atributo de instância mood, sem saber da colisão de nomes, vai afetar o atributo mood usado pelos métodos herdados de Dog. Isso seria bem complicado de depurar.

Para prevenir esse tipo de problema, se você nomear o atributo de instância no formato __mood (dois sublinhados iniciais e zero ou no máximo um sublinhado no final), o Python armazena o nome no __dict__ da instância, prefixado com um sublinhado seguido do nome da classe. Na classe Dog, por exemplo, __mood se torna _Dog__mood e em Beagle ele será _Beagle__mood.

Esse recurso da linguagem é conhecido pela encantadora alcunha de desfiguração de nome ("name mangling").

O Exemplo 12 mostra o resultado na classe Vector2d do Exemplo 7.

Exemplo 12. Nomes de atributos privados são "desfigurados", prefixando-os com o _ e o nome da classe
>>> v1 = Vector2d(3, 4)
>>> v1.__dict__
{'_Vector2d__y': 4.0, '_Vector2d__x': 3.0}
>>> v1._Vector2d__x
3.0

A desfiguração do nome é sobre alguma proteção, não sobre segurança: ela foi projetada para evitar acesso acidental, não ataques maliciosos. A Figura 1 ilustra outro dispositivo de proteção.

Qualquer um que saiba como os nomes privados são modificados pode ler o atributo privado diretamente, como mostra a última linha do Exemplo 12—isso na verdade é útil para depuração e serialização. Isso também pode ser usado para atribuir um valor a um componente privado de um Vector2d, escrevendo v1._Vector2d__x = 7. Mas se você estiver fazendo isso com código em produção, não poderá reclamar se alguma coisa explodir.

A funcionalidade de desfiguração de nomes não é amada por todos os pythonistas, nem tampouco a aparência estranha de nomes escritos como self.__x. Muitos preferem evitar essa sintaxe e usar apenas um sublinhado no prefixo para "proteger" atributos da forma convencional (por exemplo, self._x). Críticos da desfiguração automática com o sublinhado duplo sugerem que preocupações com modificações acidentais a atributos deveriam ser tratadas através de convenções de nomenclatura. Ian Bicking—criador do pip, do virtualenv e de outros projetos—escreveu:

Nunca, de forma alguma, use dois sublinhados como prefixo. Isso é irritantemente privado. Se colisão de nomes for uma preocupação, use desfiguração explícita de nomes em seu lugar (por exemplo,_MyThing_blahblah). Isso é essencialmente a mesma coisa que o sublinhado duplo, mas é transparente onde o sublinhado duplo é obscuro.[128]

interruptores com coberturas de proteção
Figura 1. Uma cobertura sobre um interruptor é um dispositivo de proteção, não de segurança: ele previne acidentes, não sabotagem

O prefixo de sublinhado único não tem nenhum significado especial para o interpretador Python, quando usado em nomes de atributo. Mas essa é uma convenção muito presente entre programadores Python: tais atributos não devem ser acessados de fora da classe.[129] É fácil respeitar a privacidade de um objeto que marca seus atributos com um único _, da mesma forma que é fácil respeitar a convenção de tratar como constantes as variáveis com nomes inteiramente em maiúsculas.

Atributos com um único _ como prefixo são chamados "protegidos" em algumas partes da documentação do Python.[130] A prática de "proteger" atributos por convenção com a forma self._x é muito difundida, mas chamar isso de atributo "protegido" não é tão comum. Alguns até falam em atributo "privado" nesses casos.

Concluindo: os componentes de Vector2d são "privados" e nossas instâncias de Vector2d são "imutáveis"—com aspas irônicas—pois não há como tornar uns realmente privados e outras realmente imutáveis.[131]

Vamos agora voltar à nossa classe Vector2d. Na próxima seção trataremos de um atributo (e não um método) especial que afeta o armazenamento interno de um objeto, com um imenso impacto potencial sobre seu uso de memória, mas pouco efeito sobre sua interface pública: __slots__.

11.11. Economizando memória com __slots__

Por default, o Python armazena os atributos de cada instância em um dict chamado __dict__. Como vimos em Seção 3.9, um dict ocupa um espaço significativo de memória, mesmo com as otimizações mencionadas naquela seção. Mas se você definir um atributo de classe chamado __slots__, que mantém uma sequência de nomes de atributos, o Python usará um modelo alternativo de armazenamento para os atributos de instância: os atributos nomeados em __slots__ serão armazenados em um array de referências oculto, que usa menos memória que um dict. Vamos ver como isso funciona através de alguns exemplos simples, começando pelo Exemplo 13.

Exemplo 13. A classe Pixel usa __slots__
>>> class Pixel:
...     __slots__ = ('x', 'y')  # (1)
...
>>> p = Pixel()  # (2)
>>> p.__dict__  # (3)
Traceback (most recent call last):
  ...
AttributeError: 'Pixel' object has no attribute '__dict__'
>>> p.x = 10  # (4)
>>> p.y = 20
>>> p.color = 'red'  # (5)
Traceback (most recent call last):
  ...
AttributeError: 'Pixel' object has no attribute 'color'
  1. __slots__ deve estar presente quando a classe é criada; acrescentá-lo ou modificá-lo posteriormente não tem qualquer efeito. Os nomes de atributos podem estar em uma tuple ou em uma list. Prefiro usar uma tuple, para deixar claro que não faz sentido modificá-la.

  2. Cria uma instância de Pixel, pois os efeitos de __slots__ são vistos nas instâncias.

  3. Primeiro efeito: instâncias de Pixel não têm um __dict__.

  4. Define normalmente os atributos p.x e p.y.

  5. Segundo efeito: tentar definir um atributo não listado em __slots__ gera um AttributeError.

Até aqui, tudo bem. Agora vamos criar uma subclasse de Pixel, no Exemplo 14, para ver o lado contraintuitivo de __slots__.

Exemplo 14. OpenPixel é uma subclasse de Pixel
>>> class OpenPixel(Pixel):  # (1)
...     pass
...
>>> op = OpenPixel()
>>> op.__dict__  # (2)
{}
>>> op.x = 8  # (3)
>>> op.__dict__  # (4)
{}
>>> op.x  # (5)
8
>>> op.color = 'green'  # (6)
>>> op.__dict__  # (7)
{'color': 'green'}
  1. OpenPixel não declara qualquer atributo próprio.

  2. Surpresa: instâncias de OpenPixel têm um __dict__.

  3. Se você definir o atributo x (nomeado no __slots__ da classe base Pixel)…​

  4. …​ele não será armazenado no __dict__ da instância…​

  5. …​mas sim no array oculto de referências na instância.

  6. Se você definir um atributo não nomeado no __slots__…​

  7. …​ele será armazenado no __dict__ da instância.

O Exemplo 14 mostra que o efeito de __slots__ é herdado apenas parcialmente por uma subclasse. Para se assegurar que instâncias de uma subclasse não tenham o __dict__, é preciso declarar __slots__ novamente na subclasse.

Se você declarar __slots__ = () (uma tupla vazia), as instâncias da subclasse não terão um __dict__ e só aceitarão atributos nomeados no __slots__ da classe base.

Se você quiser que uma subclasse tenha atributos adicionais, basta nomeá-los em __slots__, como mostra o Exemplo 15.

Exemplo 15. The ColorPixel, another subclass of Pixel
>>> class ColorPixel(Pixel):
...    __slots__ = ('color',)  # (1)
>>> cp = ColorPixel()
>>> cp.__dict__  # (2)
Traceback (most recent call last):
  ...
AttributeError: 'ColorPixel' object has no attribute '__dict__'
>>> cp.x = 2
>>> cp.color = 'blue'  # (3)
>>> cp.flavor = 'banana'
Traceback (most recent call last):
  ...
AttributeError: 'ColorPixel' object has no attribute 'flavor'
  1. Em resumo, o __slots__ da superclasse é adicionado ao __slots__ da classe atual. Não esqueça que tuplas com um único elemento devem ter uma vírgula no final.

  2. Instâncias de ColorPixel não tem um __dict__.

  3. Você pode definir atributos declarados no __slots__ dessa classe e nos de suas superclasses, mas nenhum outro.

Curiosamente, também é possível colocar o nome '__dict__' em __slots__. Neste caso, as instâncias vão manter os atributos nomeados em __slots__ num array de referências da instância, mas também vão aceitar atributos criados dinamicamente, que serão armazenados no habitual __dict__. Isso é necessário para usar o decorador @cached_property (tratado na Seção 22.3.5).

Naturalmente, incluir __dict__ em __slots__ pode desviar completamente do objetivo deste último, dependendo do número de atributos estáticos e dinâmicos em cada instância, e de como eles são usados. Otimização descuidada é pior que otimização prematura: adiciona complexidade sem colher qualquer benefício.

Outro atributo de instância especial que você pode querer manter é __weakref__, necessário para que objetos suportem referências fracas (mencionadas brevemente na Seção 6.6). Esse atributo existe por default em instâncias de classes definidas pelo usuário. Entretanto, se a classe define __slots__, e é necessário que as instâncias possam ser alvo de referências fracas, então é preciso incluir __weakref__ entre os atributos nomeados em __slots__.

Vejamos agora o efeito da adição de __slots__ a Vector2d.

11.11.1. Uma medida simples da economia gerada por __slots__

Exemplo 16 mostra a implementação de __slots__ em Vector2d.

Exemplo 16. vector2d_v3_slots.py: o atributo __slots__ é a única adição a Vector2d
class Vector2d:
    __match_args__ = ('x', 'y')  # (1)
    __slots__ = ('__x', '__y')  # (2)

    typecode = 'd'
    # methods are the same as previous version
  1. __match_args__ lista os nomes dos atributos públicos, para pattern matching posicional.

  2. __slots__, por outro lado, lista os nomes dos atributos de instância, que neste caso são atributos privados.

Para medir a economia de memória, escrevi o script mem_test.py. Ele recebe, como argumento de linha de comando, o nome de um módulo com uma variante da classe Vector2d, e usa uma compreensão de lista para criar uma list com 10.000.000 de instâncias de Vector2d. Na primeira execução, vista no Exemplo 17, usei vector2d_v3.Vector2d (do Exemplo 7); na segunda execução usei a versão com __slots__ do Exemplo 16.

Exemplo 17. mem_test.py cria 10 milhões de instâncias de Vector2d, usando a classe definida no módulo nomeado
$ time python3 mem_test.py vector2d_v3
Selected Vector2d type: vector2d_v3.Vector2d
Creating 10,000,000 Vector2d instances
Initial RAM usage:      6,983,680
  Final RAM usage:  1,666,535,424

real	0m11.990s
user	0m10.861s
sys	0m0.978s
$ time python3 mem_test.py vector2d_v3_slots
Selected Vector2d type: vector2d_v3_slots.Vector2d
Creating 10,000,000 Vector2d instances
Initial RAM usage:      6,995,968
  Final RAM usage:    577,839,104

real	0m8.381s
user	0m8.006s
sys	0m0.352s

Como revela o Exemplo 17, o uso de RAM do script cresce para 1,55 GB quando o __dict__ de instância é usado em cada uma das 10 milhões de instâncias de Vector2d, mas isso se reduz a 551 MB quando Vector2d tem um atributo __slots__. A versão com __slots__ também é mais rápida. O script mem_test.py neste teste lida basicamente com o carregamento do módulo, a medição da memória utilizada e a formatação de resultados. O código-fonte pode ser encontrado no repositório fluentpython/example-code-2e.

👉 Dica

Se você precisa manipular milhões de objetos com dados numéricos, deveria na verdade estar usando os arrays do NumPy (veja a Seção 2.10.3), que são eficientes no de uso de memória, e também tem funções para processamento numérico extremamente otimizadas, muitas das quais operam sobre o array inteiro ao mesmo tempo. Projetei a classe Vector2d apenas como um contexto para a discussão de métodos especiais, pois sempre que possível tento evitar exemplos vagos com Foo e Bar.

11.11.2. Resumindo os problemas com __slots__

O atributo de classe __slots__ pode proporcionar uma economia significativa de memória se usado corretamente, mas existem algumas ressalvas:

  • É preciso lembrar de redeclarar __slots__ em cada subclasse, para evitar que suas instâncias tenham um __dict__.

  • Instâncias só poderão ter os atributos listados em __slots__, a menos que __dict__ seja incluído em __slots__ (mas isso pode anular a economia de memória).

  • Classe que usam __slots__ não podem usar o decorador @cached_property, a menos que nomeiem __dict__ explicitamente em __slots__.

  • Instâncias não podem ser alvo de referências fracas, a menos que __weakref__ seja incluído em __slots__.

O último tópico do capítulo trata da sobreposição de um atributo de classe em instâncias e subclasses.

11.12. Sobrepondo atributos de classe

Um recurso característico do Python é a forma como atributos de classe podem ser usados como valores default para atributos de instância. Vector2d contém o atributo de classe typecode. Ele é usado duas vezes no método __bytes__, mas é lido intencionalmente como self.typecode. As instâncias de Vector2d são criadas sem um atributo typecode próprio, então self.typecode vai, por default, se referir ao atributo de classe Vector2d.typecode.

Mas se incluirmos um atributo de instância que não existe, estamos criando um novo atributo de instância—por exemplo, um atributo de instância typecode—e o atributo de classe com o mesmo nome permanece intocado. Entretanto, daí em diante, sempre que algum código referente àquela instância contiver self.typecode, o typecode da instância será usado, na prática escondendo o atributo de classe de mesmo nome. Isso abre a possibilidade de personalizar uma instância individual com um typecode diferente.

O Vector2d.typecode default é 'd': isso significa que cada componente do vetor será representado como um número de ponto flutuante de dupla precisão e 8 bytes de tamanho quando for exportado para bytes. Se definirmos o typecode de uma instância Vector2d como 'f' antes da exportação, cada componente será exportado como um número de ponto flutuante de precisão simples e 4 bytes de tamanho.. O Exemplo 18 demonstra isso.

✒️ Nota

Estamos falando do acréscimo de um atributo de instância, assim o Exemplo 18 usa a implementação de Vector2d sem __slots__, como aparece no Exemplo 11.

Exemplo 18. Personalizando uma instância pela definição do atributo typecode, que antes era herdado da classe
>>> from vector2d_v3 import Vector2d
>>> v1 = Vector2d(1.1, 2.2)
>>> dumpd = bytes(v1)
>>> dumpd
b'd\x9a\x99\x99\x99\x99\x99\xf1?\x9a\x99\x99\x99\x99\x99\x01@'
>>> len(dumpd)  # (1)
17
>>> v1.typecode = 'f'  # (2)
>>> dumpf = bytes(v1)
>>> dumpf
b'f\xcd\xcc\x8c?\xcd\xcc\x0c@'
>>> len(dumpf)  # (3)
9
>>> Vector2d.typecode  # (4)
'd'
  1. A representação default em bytes tem 17 bytes de comprimento.

  2. Define typecode como 'f' na instância v1.

  3. Agora bytes tem 9 bytes de comprimento.

  4. Vector2d.typecode não foi modificado; apenas a instância v1 usa o typecode 'f'.

Isso deixa claro porque a exportação para bytes de um Vector2d tem um prefixo typecode: queríamos suportar diferentes formatos de exportação.

Para modificar um atributo de classe, é preciso redefini-lo diretamente na classe, e não através de uma instância. Poderíamos modificar o typecode default para todas as instâncias (que não tenham seu próprio typecode) assim:

>>> Vector2d.typecode = 'f'

Porém, no Python, há uma maneira idiomática de obter um efeito mais permanente, e de ser mais explícito sobre a modificação. Como atributos de classe são públicos, eles são herdados por subclasses. Então é uma prática comum fazer a subclasse personalizar um atributo da classe. As views baseadas em classes do Django usam amplamente essa técnica. O Exemplo 19 mostra como se faz.

Exemplo 19. O ShortVector2d é uma subclasse de Vector2d, que apenas sobrepõe o typecode default
>>> from vector2d_v3 import Vector2d
>>> class ShortVector2d(Vector2d):  # (1)
...     typecode = 'f'
...
>>> sv = ShortVector2d(1/11, 1/27)  # (2)
>>> sv
ShortVector2d(0.09090909090909091, 0.037037037037037035)  # (3)
>>> len(bytes(sv))  # (4)
9
  1. Cria ShortVector2d como uma subclasse de Vector2d apenas para sobrepor o atributo de classe typecode.

  2. Cria sv, uma instância de ShortVector2d, para demonstração.

  3. Verifica o repr de sv.

  4. Verifica que a quantidade de bytes exportados é 9, e não 17 como antes.

Esse exemplo também explica porque não escrevi explicitamente o class_name em Vector2d.​__repr__, optando por obtê-lo de type(self).__name__, assim:

    # inside class Vector2d:

    def __repr__(self):
        class_name = type(self).__name__
        return '{}({!r}, {!r})'.format(class_name, *self)

Se eu tivesse escrito o class_name explicitamente, subclasses de Vector2d como ShortVector2d teriam que sobrescrever __repr__ só para mudar o class_name. Lendo o nome do type da instância, tornei __repr__ mais seguro de ser herdado.

Aqui termina nossa conversa sobre a criação de uma classe simples, que se vale do modelo de dados para se adaptar bem ao restante do Python: oferecendo diferentes representações do objeto, fornecendo um código de formatação personalizado, expondo atributos somente para leitura e suportando hash() para se integrar a conjuntos e mapeamentos.

11.13. Resumo do capítulo

O objetivo desse capítulo foi demonstrar o uso dos métodos especiais e as convenções na criação de uma classe pythônica bem comportada.

Será vector2d_v3.py (do Exemplo 11) mais pythônica que vector2d_v0.py (do Exemplo 2)? A classe Vector2d em vector2d_v3.py com certeza utiliza mais recursos do Python. Mas decidir qual das duas implementações de Vector2d é mais adequada, a primeira ou a última, depende do contexto onde a classe será usada. O "Zen of Python" (Zen do Python), de Tim Peter, diz:

Simples é melhor que complexo.

Um objeto deve ser tão simples quanto seus requerimentos exigem—e não um desfile de recursos da linguagem. Se o código for parte de uma aplicação, ele deveria se concentrar naquilo que for necessário para suportar os usuários finais, e nada mais. Se o código for parte de uma biblioteca para uso por outros programadores, então é razoável implementar métodos especiais que suportam comportamentos esperados por pythonistas. Por exemplo, __eq__ pode não ser necessário para suportar um requisito do negócio, mas torna a classe mais fácil de testar.

Minha meta, ao expandir o código do Vector2d, foi criar um contexto para a discussão dos métodos especiais e das convenções de programação em Python. Os exemplos neste capítulo demonstraram vários dos métodos especiais vistos antes na Tabela 1 (do Capítulo 1):

  • Métodos de representação de strings e bytes: __repr__, __str__, __format__ e __bytes__

  • Métodos para reduzir um objeto a um número: __abs__, __bool__ e __hash__

  • O operador __eq__, para suportar testes e hashing (juntamente com __hash__)

Quando suportamos a conversão para bytes, também implementamos um construtor alternativo, Vector2d.frombytes(), que nos deu um contexto para falar dos decoradores @classmethod (muito conveniente) e @staticmethod (não tão útil: funções a nível do módulo são mais simples). O método frombytes foi inspirado pelo método de mesmo nome na classe array.array.

Vimos que a Mini-Linguagem de Especificação de Formato é extensível, ao implementarmos um método __format__ que analisa uma format_spec fornecida à função embutida format(obj, format_spec) ou dentro de campos de substituição '{:«format_spec»}' em f-strings ou ainda strings usadas com o método str.format().

Para preparar a transformação de instâncias de Vector2d em hashable, fizemos um esforço para torná-las imutáveis, ao menos prevenindo modificações acidentais, programando os atributos x e y como privados, e expondo-os como propriedades apenas para leitura. Nós então implementamos __hash__ usando a técnica recomendada, aplicar o operador xor aos hashes dos atributos da instância.

Discutimos a seguir a economia de memória e as ressalvas de se declarar um atributo __slots__ em Vector2d. Como o uso de __slots__ tem efeitos colaterais, ele só faz real sentido quando é preciso processar um número muito grande de instâncias—pense em milhões de instâncias, não apenas milhares. Em muitos destes casos, usar a pandas pode ser a melhor opção.

O último tópico tratado foi a sobreposição de um atributo de classe acessado através das instâncias (por exemplo, self.typecode). Fizemos isso primeiro criando um atributo de instância, depois criando uma subclasse e sobrescrevendo o atributo no nível da classe.

Por todo o capítulo, apontei como escolhas de design nos exemplos foram baseadas no estudo das APIs dos objetos padrão do Python. Se esse capítulo pode ser resumido em uma só frase, seria essa:

Para criar objetos pythônicos, observe como se comportam objetos reais do Python.

— Antigo provérbio chinês

11.14. Leitura complementar

Este capítulo tratou de vários dos métodos especiais do modelo de dados, então naturalmente as referências primárias são as mesmas do Capítulo 1, onde tivemos uma ideia geral do mesmo tópico. Por conveniência, vou repetir aquelas quatro recomendações anteriores aqui, e acrescentar algumas outras:

O capítulo "Modelo de Dados" em A Referência da Linguagem Python

A maioria dos métodos usados nesse capítulo estão documentados em "3.3.1. Personalização básica".

Python in a Nutshell, 3ª ed., de Alex Martelli, Anna Ravenscroft, e Steve Holden

Trata com profundidade dos métodos especiais .

Python Cookbook, 3ª ed., de David Beazley e Brian K. Jones

Práticas modernas do Python demonstradas através de receitas. Especialmente o Capítulo 8, "Classes and Objects" (Classes e Objetos), que contém várias receitas relacionadas às discussões deste capítulo.

Python Essential Reference, 4ª ed., de David Beazley

Trata do modelo de dados em detalhes, apesar de falar apenas do Python 2.6 e do 3.0 (na quarta edição). Todos os conceitos fundamentais são os mesmos, e a maior parte das APIs do Modelo de Dados não mudou nada desde o Python 2.2, quando os tipos embutidos e as classes definidas pelo usuário foram unificados.

Em 2015—o ano que terminei a primeira edição de Python Fluente—Hynek Schlawack começou a desenvolver o pacote attrs. Da documentação de attrs:

attrs é um pacote Python que vai trazer de volta a alegria de criar classes, liberando você do tedioso trabalho de implementar protocolos de objeto (também conhecidos como métodos dunder)

Mencionei attrs como uma alternativa mais poderosa ao @dataclass na Seção 5.10. As fábricas de classes de dados do Capítulo 5, assim como attrs, equipam suas classes automaticamente com vários métodos especiais. Mas saber como programar métodos especiais ainda é essencial para entender o que aqueles pacotes fazem, para decidir se você realmente precisa deles e para—quando necessário—sobrescrever os métodos que eles geram.

Vimos neste capítulo todos os métodos especiais relacionados à representação de objetos, exceto __index__ e __fspath__. Discutiremos __index__ no Capítulo 12, na Seção 12.5.2. Não vou tratar de __fspath__. Para aprender sobre esse método, veja a PEP 519—Adding a file system path protocol (Adicionando um protocolo de caminho de sistema de arquivos) (EN).

Uma percepção precoce da necessidade de strings de representação diferentes para objetos apareceu no Smalltalk. O artigo de 1996 "How to Display an Object as a String: printString and displayString" (Como Mostrar um Objeto como uma String: printString and displayString) (EN), de Bobby Woolf, discute a implementação dos métodos printString e displayString naquela linguagem. Foi desse artigo que peguei emprestado as expressivas descrições "como o desenvolvedor quer vê-lo" e "como o usuário quer vê-lo" para definir repr() e str(), na Seção 11.2.

Ponto de Vista

Propriedades ajudam a reduzir custos iniciais

Nas primeiras versões de Vector2d, os atributos x e y eram públicos, como são, por default, todos os atributos de instância e classe no Python. Naturalmente, os usuários de vetores precisam acessar seus componentes. Apesar de nossos vetores serem iteráveis e poderem ser desempacotados em um par de variáveis, também é desejável poder escrever my_vector.x e my_vector.y para obter cada componente.

Quando sentimos a necessidade de evitar modificações acidentais dos atributos x e y, implementamos propriedades, mas nada mudou no restante do código ou na interface pública de Vector2d, como se verifica através dos doctests. Continuamos podendo acessar my_vector.x and my_vector.y.

Isso mostra que podemos sempre iniciar o desenvolvimento de nossas classes da maneira mais simples possível, com atributos públicos, pois quando (ou se) nós mais tarde precisarmos impor mais controle, com getters e setters, estes métodos podem ser implementados usando propriedades, sem mudar nada no código que já interage com nossos objetos através dos nomes que eram, inicialmente, simples atributos públicos (x e y, por exemplo).

Essa abordagem é o oposto daquilo que é encorajado pela linguagem Java: um programador Java não pode começar com atributos públicos simples e apenas mais tarde, se necessário, implementar propriedades, porque elas não existem naquela linguagem. Portanto, escrever getters e setters é a regra em Java—mesmo quando esses métodos não fazem nada de útil—porque a API não pode evoluir de atributos públicos simples para getters e setters sem quebrar todo o código que já use aqueles atributos.

Além disso, como Martelli, Ravenscroft e Holden observam no Python in a Nutshell, 3rd ed., digitar chamadas a getters e setters por toda parte é patético. Você é obrigado a escrever coisas como:

>>> my_object.set_foo(my_object.get_foo() + 1)

Apenas para fazer isso:

>>> my_object.foo += 1

Ward Cunningham, inventor do wiki e um pioneiro da Programação Extrema (Extreme Programming), recomenda perguntar: "Qual a coisa mais simples que tem alguma chance de funcionar?" A ideia é se concentrar no objetivo.[132] Implementar setters e getters desde o início é uma distração em relação ao objetivo Em Python, podemos simplesmente usar atributos públicos, sabendo que podemos transformá-los mais tarde em propriedades, se essa necessidade surgir .

Proteção versus segurança em atributos privados

O Perl não tem nenhum amor por privacidade forçada. Ele preferiria que você não entrasse em sua sala de estar [apenas] por não ter sido convidado, e não porque ele tem uma espingarda.

— Larry Wall
criador do Perl

O Python e o Perl estão em polos opostos em vários aspectos, mas Guido e Larry parecem concordar sobre a privacidade de objetos.

Ensinando Python para muitos programadores Java ao longo do anos, descobri que muitos deles tem uma fé excessiva nas garantias de privacidade oferecidas pelo Java. E na verdade, os modificadores private e protected do Java normalmente fornecem defesas apenas contra acidentes (isto é, proteção). Eles só oferecem segurança contra ataques mal-intencionados se a aplicação for especialmente configurada e implantada sob um SecurityManager (EN) do Java, e isso raramente acontece na prática, mesmo em configurações corporativas atentas à segurança.

Para provar meu argumento, gostaria de mostrar essa classe Java (o Exemplo 20).

Exemplo 20. Confidential.java: uma classe Java com um campo privado chamado secret
public class Confidential {

    private String secret = "";

    public Confidential(String text) {
        this.secret = text.toUpperCase();
    }
}

No Exemplo 20, armazeno o text no campo secret após convertê-lo todo para caixa alta, só para deixar óbvio que qualquer coisa que esteja naquele campo estará escrito inteiramente em maiúsculas.

A verdadeira demonstração consiste em rodar expose.py com Jython. Aquele script usa introspecção ("reflexão"—reflection—no jargão do Java) para obter o valor de um campo privado. O código aparece no Exemplo 21.

Exemplo 21. expose.py: código em Jython para ler o conteúdo de um campo privado em outra classe
#!/usr/bin/env jython
# NOTE: Jython is still Python 2.7 in late2020

import Confidential

message = Confidential('top secret text')
secret_field = Confidential.getDeclaredField('secret')
secret_field.setAccessible(True)  # break the lock!
print 'message.secret =', secret_field.get(message)

Executando o Exemplo 21, o resultado é esse:

$ jython expose.py
message.secret = TOP SECRET TEXT

A string 'TOP SECRET TEXT' foi lida do campo privado secret da classe Confidential.

Não há magia aqui: expose.py usa a API de reflexão do Java para obter uma referência para o campo privado chamado 'secret', e então chama secret_field.setAccessible(True) para tornar acessível seu conteúdo. A mesma coisa pode ser feita com código Java, claro (mas exige mais que o triplo de linhas; veja o arquivo Expose.java no repositório de código do Python Fluente).

A chamada .setAccessible(True) só falhará se o script Jython ou o programa principal em Java (por exemplo, a Expose.class) estiverem rodando sob a supervisão de um SecurityManager (EN). Mas, no mundo real, aplicações Java raramente são implantadas com um SecurityManager—com a exceção das applets Java, quando elas ainda era suportadas pelos navegadores.

Meu ponto: também em Java, os modificadores de controle de acesso são principalmente sobre proteção e não segurança, pelo menos na prática. Então relaxe e aprecie o poder dado a você pelo Python. E use esse poder com responsabilidade.

12. Métodos especiais para sequências

Não queira saber se aquilo é-um pato: veja se ele grasna-como-um pato, anda-como-um pato, etc., etc., dependendo de qual subconjunto exato de comportamentos de pato você precisa para usar em seus jogos de linguagem. (comp.lang.python, Jul. 26, 2000)

— Alex Martelli

Neste capítulo, vamos criar uma classe Vector, para representar um vetor multidimensional—um avanço significativo sobre o Vector2D bidimensional do Capítulo 11. Vector vai se comportar como uma simples sequência imutável padrão do Python. Seus elementos serão números de ponto flutuante, e ao final do capítulo a classe suportará o seguinte:

  • O protocolo de sequência básico: __len__ e __getitem__

  • Representação segura de instâncias com muitos itens

  • Suporte adequado a fatiamento, produzindo novas instâncias de Vector

  • Hashing agregado, levando em consideração o valor de cada elemento contido na sequência

  • Um extensão personalizada da linguagem de formatação

Também vamos implementar, com __getattr__, o acesso dinâmico a atributos, como forma de substituir as propriedades apenas para leitura que usamos no Vector2d—apesar disso não ser típico de tipos sequência.

Nossa apresentação voltada para o código será interrompida por uma discussão conceitual sobre a ideia de protocolos como uma interface informal. Vamos discutir a relação entre protocolos e duck typing, e as implicações práticas disso na criação de seus próprios tipos

12.1. Novidades nesse capítulo

Não ocorreu qualquer grande modificação neste capítulo. Há uma breve discussão nova sobre o typing.Protocol em um quadro de dicas, no final da Seção 12.4.

Na Seção 12.5.2, a implementação do __getitem__ no Exemplo 6 está mais concisa e robusta que o exemplo na primeira edição, graças ao duck typing e ao operator.index. Essa mudança foi replicada para as implementações seguintes de Vector aqui e no Capítulo 16.

Vamos começar.

12.2. Vector: Um tipo sequência definido pelo usuário

Nossa estratégia na implementação de Vector será usar composição, não herança. Vamos armazenar os componentes em um array de números de ponto flutuante, e implementar os métodos necessários para que nossa classe Vector se comporte como uma sequência plana imutável.

Mas antes de implementar os métodos de sequência, vamos desenvolver uma implementação básica de Vector compatível com nossa classe Vector2d, vista anteriormente—​exceto onde tal compatibilidade não fizer sentido.

Aplicações de vetores além de três dimensões

Quem precisa de vetores com 1.000 dimensões? Vetores N-dimensionais (com valores grandes de N) são bastante utilizados em recuperação de informação, onde documentos e consultas textuais são representados como vetores, com uma dimensão para cada palavra. Isso se chama Modelo de Espaço Vetorial (EN). Nesse modelo, a métrica fundamental de relevância é a similaridade de cosseno—o cosseno do ângulo entre o vetor representando a consulta e o vetor representando o documento. Conforme o ângulo diminui, o valor do cosseno aumenta, indicando a relevância do documento para aquela consulta: cosseno próximo de 0 significa pouca relevância; próximo de 1 indica alta relevância.

Dito isto, a classe Vector nesse capítulo é um exemplo didático. O objetivo é apenas demonstrar alguns métodos especiais do Python no contexto de um tipo sequência, sem grandes conceitos matemáticos.

A NumPy e a SciPy são as ferramentas que você precisa para fazer cálculos vetoriais em aplicações reais. O pacote gensim do PyPi, de Radim Řehůřek, implementa a modelagem de espaço vetorial para processamento de linguagem natural e recuperação de informação, usando a NumPy e a SciPy.

12.3. Vector versão #1: compatível com Vector2d

A primeira versão de Vector deve ser tão compatível quanto possível com nossa classe Vector2d desenvolvida anteriormente.

Entretanto, pela própria natureza das classes, o construtor de Vector não é compatível com o construtor de Vector2d. Poderíamos fazer Vector(3, 4) e Vector(3, 4, 5) funcionarem, recebendo argumentos arbitrários com *args em __init__. Mas a melhor prática para um construtor de sequências é receber os dados através de um argumento iterável, como fazem todos os tipos embutidos de sequências. O Exemplo 1 mostra algumas maneiras de instanciar objetos do nosso novo Vector.

Exemplo 1. Testes de Vector.__init__ e Vector.__repr__
>>> Vector([3.1, 4.2])
Vector([3.1, 4.2])
>>> Vector((3, 4, 5))
Vector([3.0, 4.0, 5.0])
>>> Vector(range(10))
Vector([0.0, 1.0, 2.0, 3.0, 4.0, ...])

Exceto pela nova assinatura do construtor, me assegurei que todos os testes realizados com Vector2d (por exemplo, Vector2d(3, 4)) são bem sucedidos e produzem os mesmos resultados com um Vector de dois componentes, como Vector([3, 4]).

⚠️ Aviso

Quando um Vector tem mais de seis componentes, a string produzida por repr() é abreviada com …​, como visto na última linha do Exemplo 1. Isso é fundamental para qualquer tipo de coleção que possa conter um número grande de itens, pois repr é usado na depuração—e você não quer que um único objeto grande ocupe milhares de linhas em seu console ou arquivo de log. Use o módulo reprlib para produzir representações de tamanho limitado, como no Exemplo 2. O módulo reprlib se chamava repr no Python 2.7.

O Exemplo 2 lista a implementação de nossa primeira versão de Vector (esse exemplo usa como base o código mostrado no #ex_vector2d_v0 e no #ex_vector2d_v1 do Capítulo 11).

Exemplo 2. vector_v1.py: derived from vector2d_v1.py
from array import array
import reprlib
import math


class Vector:
    typecode = 'd'

    def __init__(self, components):
        self._components = array(self.typecode, components)  # (1)

    def __iter__(self):
        return iter(self._components)  # (2)

    def __repr__(self):
        components = reprlib.repr(self._components)  # (3)
        components = components[components.find('['):-1]  # (4)
        return f'Vector({components})'

    def __str__(self):
        return str(tuple(self))

    def __bytes__(self):
        return (bytes([ord(self.typecode)]) +
                bytes(self._components))  # (5)

    def __eq__(self, other):
        return tuple(self) == tuple(other)

    def __abs__(self):
        return math.hypot(*self)  # (6)

    def __bool__(self):
        return bool(abs(self))

    @classmethod
    def frombytes(cls, octets):
        typecode = chr(octets[0])
        memv = memoryview(octets[1:]).cast(typecode)
        return cls(memv)  # (7)
  1. O atributo de instância "protegido" self._components vai manter um array com os componentes do Vector.

  2. Para permitir iteração, devolvemos um itereador sobre self._components.[133]

  3. Usa reprlib.repr() para obter um representação de tamanho limitado de self._components (por exemplo, array('d', [0.0, 1.0, 2.0, 3.0, 4.0, …​])).

  4. Remove o prefixo array('d', e o ) final, antes de inserir a string em uma chamada ao construtor de Vector.

  5. Cria um objeto bytes diretamente de self._components.

  6. Desde o Python 3.8, math.hypot aceita pontos N-dimensionais. Já usei a seguinte expressão antes: math.sqrt(sum(x * x for x in self)).

  7. A única mudança necessária no frombytes anterior é na última linha: passamos a memoryview diretamente para o construtor, sem desempacotá-la com *, como fazíamos antes.

O modo como usei reprlib.repr pede alguma elaboração. Essa função produz representações seguras de estruturas grandes ou recursivas, limitando a tamanho da string devolvida e marcando o corte com '…​'. Eu queria que o repr de um Vector se parecesse com Vector([3.0, 4.0, 5.0]) e não com Vector(array('d', [3.0, 4.0, 5.0])), porque a existência de um array dentro de um Vector é um detalhe de implementação. Como essas chamadas ao construtor criam objetos Vector idênticos, preferi a sintaxe mais simples, usando um argumento list.

Ao escrever o __repr__, poderia ter produzido uma versão para exibição simplificada de components com essa expressão: reprlib.repr(list(self._components)). Isso, entretanto, geraria algum desperdício, pois eu estaria copiando cada item de self._components para uma list apenas para usar a list no repr. Em vez disso, decidi aplicar reprlib.repr diretamente no array self._components, e então remover os caracteres fora dos []. É isso o que faz a segunda linha do __repr__ no Exemplo 2.

👉 Dica

Por seu papel na depuração, chamar repr() em um objeto não deveria nunca gerar uma exceção. Se alguma coisa der errado dentro de sua implementação de __repr__, você deve lidar com o problema e fazer o melhor possível para produzir uma saída aproveitável, que dê ao usuário uma chance de identificar o objeto receptor (self).

Observe que os métodos __str__, __eq__, e __bool__ são idênticos a suas versões em Vector2d, e apenas um caractere mudou em frombytes (um * foi removido na última linha). Isso é um dos benefícios de termos tornado o Vector2d original iterável.

Aliás, poderíamos ter criado Vector como uma subclasse de Vector2d, mas escolhi não fazer isso por duas razões. Em primeiro lugar, os construtores incompatíveis de fato tornam a relação de super/subclasse desaconselhável. Eu até poderia contornar isso como um tratamento engenhoso dos parâmetros em __init__, mas a segunda razão é mais importante: queria que Vector fosse um exemplo independente de uma classe que implementa o protocolo de sequência. É o que faremos a seguir, após uma discussão sobre o termo protocolo.

12.4. Protocolos e o duck typing

Já no Capítulo 1, vimos que não é necessário herdar de qualquer classe em especial para criar um tipo sequência completamente funcional em Python; basta implementar os métodos que satisfazem o protocolo de sequência. Mas de que tipo de protocolo estamos falando?

No contexto da programação orientada a objetos, um protocolo é uma interface informal, definida apenas na documentação (e não no código). Por exemplo, o protocolo de sequência no Python implica apenas no métodos __len__ e __getitem__. Qualquer classe Spam, que implemente esses métodos com a assinatura e a semântica padrões, pode ser usada em qualquer lugar onde uma sequência for esperada. É irrelevante se Spam é uma subclasse dessa ou daquela outra classe; tudo o que importa é que ela fornece os métodos necessários. Vimos isso no Exemplo 1, reproduzido aqui no Exemplo 3.

Exemplo 3. Código do Exemplo 1, reproduzido aqui por conveniência
import collections

Card = collections.namedtuple('Card', ['rank', 'suit'])

class FrenchDeck:
    ranks = [str(n) for n in range(2, 11)] + list('JQKA')
    suits = 'spades diamonds clubs hearts'.split()

    def __init__(self):
        self._cards = [Card(rank, suit) for suit in self.suits
                                        for rank in self.ranks]

    def __len__(self):
        return len(self._cards)

    def __getitem__(self, position):
        return self._cards[position]

A classe FrenchDeck, no Exemplo 3, pode tirar proveito de muitas facilidades do Python por implementar o protocolo de sequência, mesmo que isso não esteja declarado em qualquer ponto do código. Um programador Python experiente vai olhar para ela e entender que aquilo é uma sequência, mesmo sendo apenas uma subclasse de object. Dizemos que ela é uma sequênca porque ela se comporta como uma sequência, e é isso que importa.

Isso ficou conhecido como duck typing (literalmente "tipagem pato"), após o post de Alex Martelli citado no início deste capítulo.

Como protocolos são informais e não obrigatórios, muitas vezes é possível resolver nosso problema implementando apenas parte de um protocolo, se sabemos o contexto específico em que a classe será utilizada. Por exemplo, apenas __getitem__ basta para suportar iteração; não há necessidade de fornecer um __len__.

👉 Dica

Com a PEP 544—Protocols: Structural subtyping (static duck typing) (Protocolos:sub-tipagem estrutural (duck typing estático)) (EN), o Python 3.8 suporta classes protocolo: subclasses de typing.Protocol, que estudamos na Seção 8.5.10. Esse novo uso da palavra protocolo no Python tem um significado relacionado, mas diferente. Quando preciso diferenciá-los, escrevo protocolo estático para me referir aos protocolos formalizados em classes protocolo (subclasses de typing.Protocol), e protocolos dinâmicos para o sentido tradicional. Uma diferença fundamental é que implementações de um protocolo estático precisam oferecer todos os métodos definidos na classe protocolo. A Seção 13.3 no Capítulo 13 traz maiores detalhes.

Vamos agora implementar o protocolo sequência em Vector, primeiro sem suporte adequado ao fatiamento, que acrescentaremos mais tarde.

12.5. Vector versão #2: Uma sequência fatiável

Como vimos no exemplo da classe FrenchDeck, suportar o protocolo de sequência é muito fácil se você puder delegar para um atributo sequência em seu objeto, como nosso array self._components. Esses __len__ e __getitem__ de uma linha são um bom começo:

class Vector:
    # many lines omitted
    # ...

    def __len__(self):
        return len(self._components)

    def __getitem__(self, index):
        return self._components[index]

Após tais acréscimos, agora todas as seguintes operações funcionam:

>>> v1 = Vector([3, 4, 5])
>>> len(v1)
3
>>> v1[0], v1[-1]
(3.0, 5.0)
>>> v7 = Vector(range(7))
>>> v7[1:4]
array('d', [1.0, 2.0, 3.0])

Como se vê, até o fatiamento é suportado—mas não muito bem. Seria melhor se uma fatia de um Vector fosse também uma instância de Vector, e não um array. A antiga classe FrenchDeck tem um problema similar: quando ela é fatiada, o resultado é uma list. No caso de Vector, muito da funcionalidade é perdida quando o fatiamento produz arrays simples.

Considere os tipos sequência embutidos: cada um deles, ao ser fatiado, produz uma nova instância de seu próprio tipo, e não de algum outro tipo.

Para fazer Vector produzir fatias como instâncias de Vector, não podemos simplesmente delegar o fatiamento para array. Precisamos analisar os argumentos recebidos em __getitem__ e fazer a coisa certa.

Vejamos agora como o Python transforma a sintaxe my_seq[1:3] em argumentos para my_seq.__getitem__(...).

12.5.1. Como funciona o fatiamento

Uma demonstração vale mais que mil palavras, então dê uma olhada no Exemplo 4.

Exemplo 4. Examinando o comportamento de __getitem__ e fatias
>>> class MySeq:
...     def __getitem__(self, index):
...         return index  # (1)
...
>>> s = MySeq()
>>> s[1]  # (2)
1
>>> s[1:4]  # (3)
slice(1, 4, None)
>>> s[1:4:2]  # (4)
slice(1, 4, 2)
>>> s[1:4:2, 9]  # (5)
(slice(1, 4, 2), 9)
>>> s[1:4:2, 7:9]  # (6)
(slice(1, 4, 2), slice(7, 9, None))
  1. Para essa demonstração, o método __getitem__ simplesmente devolve o que for passado a ele.

  2. Um único índice, nada de novo.

  3. A notação 1:4 se torna slice(1, 4, None).

  4. slice(1, 4, 2) significa comece em 1, pare em 4, ande de 2 em 2.

  5. Surpresa: a presença de vírgulas dentro do [] significa que __getitem__ recebe uma tupla.

  6. A tupla pode inclusive conter vários objetos slice.

Vamos agora olhar mais de perto a própria classe slice, no Exemplo 5.

Exemplo 5. Inspecionando os atributos da classe slice
>>> slice  # (1)
<class 'slice'>
>>> dir(slice) # (2)
['__class__', '__delattr__', '__dir__', '__doc__', '__eq__',
 '__format__', '__ge__', '__getattribute__', '__gt__',
 '__hash__', '__init__', '__le__', '__lt__', '__ne__',
 '__new__', '__reduce__', '__reduce_ex__', '__repr__',
 '__setattr__', '__sizeof__', '__str__', '__subclasshook__',
 'indices', 'start', 'step', 'stop']
  1. slice é um tipo embutido (que já vimos antes na Seção 2.7.2).

  2. Inspecionando uma slice descobrimos os atributos de dados start, stop, e step, e um método indices.

No Exemplo 5, a chamada dir(slice) revela um atributo indices, um método pouco conhecido mas muito interessante. Eis o que diz help(slice.indices):

S.indices(len) → (start, stop, stride)

Supondo uma sequência de tamanho len, calcula os índices start (início) e stop (fim), e a extensão do stride (passo) da fatia estendida descrita por S. Índices fora dos limites são recortados, exatamente como acontece em uma fatia normal.

Em outras palavras, indices expõe a lógica complexa implementada nas sequências embutidas, para lidar graciosamente com índices inexistentes ou negativos e com fatias maiores que a sequência original. Esse método produz tuplas "normalizadas" com os inteiros não-negativos start, stop, e stride ajustados para uma sequência de um dado tamanho.

Aqui estão dois exemplos, considerando uma sequência de len == 5, por exemplo 'ABCDE':

>>> slice(None, 10, 2).indices(5)  # (1)
(0, 5, 2)
>>> slice(-3, None, None).indices(5)  # (2)
(2, 5, 1)
  1. 'ABCDE'[:10:2] é o mesmo que 'ABCDE'[0:5:2].

  2. 'ABCDE'[-3:] é o mesmo que 'ABCDE'[2:5:1].

No código de nosso Vector não vamos precisar do método slice.indices(), pois quando recebermos uma fatia como argumento vamos delegar seu tratamento para o array interno _components. Mas quando você não puder contar com os serviços de uma sequência subjacente, esse método ajuda evita a necessidade de implementar uma lógica sutil.

Agora que sabemos como tratar fatias, vamos ver a implementação aperfeiçoada de Vector.__getitem__.

12.5.2. Um __getitem__ que trata fatias

O Exemplo 6 lista os dois métodos necessários para fazer Vector se comportar como uma sequência: __len__ e __getitem__ (com o último implementado para tratar corretamente o fatiamento).

Exemplo 6. Parte de vector_v2.py: métodos __len__ e __getitem__ adicionados à classe Vector, de vector_v1.py (no Exemplo 2)
    def __len__(self):
        return len(self._components)

    def __getitem__(self, key):
        if isinstance(key, slice):  # (1)
            cls = type(self)  # (2)
            return cls(self._components[key])  # (3)
        index = operator.index(key)  # (4)
        return self._components[index]  # (5)
  1. Se o argumento key é uma slice…​

  2. …​obtém a classe da instância (isto é, Vector) e…​

  3. …​invoca a classe para criar outra instância de Vector a partir de uma fatia do array _components.

  4. Se podemos obter um index de key…​

  5. …​devolve o item específico de _components.

A função operator.index() chama o método especial __index__. A função e o método especial foram definidos na PEP 357—Allowing Any Object to be Used for Slicing (Permitir que Qualquer Objeto seja Usado para Fatiamento) (EN), proposta por Travis Oliphant, para permitir que qualquer um dos numerosos tipos de inteiros na NumPy fossem usados como argumentos de índices e fatias. A diferença principal entre operator.index() e int() é que o primeiro foi projetado para esse propósito específico. Por exemplo, int(3.14) devolve 3, mas operator.index(3.14) gera um TypeError, porque um float não deve ser usado como índice.

✒️ Nota

O uso excessivo de isinstance pode ser um sinal de design orientado a objetos ruim, mas tratar fatias em __getitem__ é um caso de uso justificável. Na primeira edição, também usei um teste isinstance com key, para verificar se esse argumento era um inteiro. O uso de operator.index evita esse teste, e gera um Type​Error com uma mensagem muito informativa, se não for possível obter o index a partir de key. Observe a última mensagem de erro no Exemplo 7, abaixo.

Após a adição do código do Exemplo 6 à classe Vector class, temos o comportamento apropriado para fatiamento, como demonstra o Exemplo 7 .

Exemplo 7. Testes do Vector.__getitem__ aperfeiçoado, do Exemplo 6
    >>> v7 = Vector(range(7))
    >>> v7[-1]  # (1)
    6.0
    >>> v7[1:4]  # (2)
    Vector([1.0, 2.0, 3.0])
    >>> v7[-1:]  # (3)
    Vector([6.0])
    >>> v7[1,2]  # (4)
    Traceback (most recent call last):
      ...
    TypeError: 'tuple' object cannot be interpreted as an integer
  1. Um índice inteiro recupera apenas o valor de um componente, um float.

  2. Uma fatia como índice cria um novo Vector.

  3. Um fatia de len == 1 também cria um Vector.

  4. Vector não suporta indexação multidimensional, então tuplas de índices ou de fatias geram um erro.

12.6. Vector versão #3: acesso dinâmico a atributos

Ao evoluir Vector2d para Vector, perdemos a habilidade de acessar os componentes do vetor por nome (por exemplo, v.x, v.y). Agora estamos trabalhando com vetores que podem ter um número grande de componentes. Ainda assim, pode ser conveniente acessar os primeiros componentes usando letras como atalhos, algo como x, y, z em vez de v[0], v[1], and v[2].

Aqui está a sintaxe alternativa que queremos oferecer para a leitura dos quatro primeiros componentes de um vetor:

>>> v = Vector(range(10))
>>> v.x
0.0
>>> v.y, v.z, v.t
(1.0, 2.0, 3.0)

No Vector2d, oferecemos acesso somente para leitura a x e y através do decorador @property (veja o Exemplo 7). Poderíamos incluir quatro propriedades no Vector, mas isso seria tedioso. O método especial __getattr__ nos fornece uma opção melhor.

O método __getattr__ é invocado pelo interpretador quando a busca por um atributo falha. Simplificando, dada a expressão my_obj.x, o Python verifica se a instância de my_obj tem um atributo chamado x; em caso negativo, a busca passa para a classe (my_obj.__class__) e depois sobe pelo diagrama de herança.[134] Se por fim o atributo x não for encontrado, o método __getattr__, definido na classe de my_obj, é chamado com self e o nome do atributo em formato de string (por exemplo, 'x').

O Exemplo 8 lista nosso método __getattr__. Ele basicamente verifica se o atributo desejado é uma das letras xyzt. Em caso positivo, devolve o componente correspondente do vetor.

Exemplo 8. Parte de vector_v3.py: método __getattr__ acrescentado à classe Vector
    __match_args__ = ('x', 'y', 'z', 't')  # (1)

    def __getattr__(self, name):
        cls = type(self)  # (2)
        try:
            pos = cls.__match_args__.index(name)  # (3)
        except ValueError:  # (4)
            pos = -1
        if 0 <= pos < len(self._components):  # (5)
            return self._components[pos]
        msg = f'{cls.__name__!r} object has no attribute {name!r}'  # (6)
        raise AttributeError(msg)
  1. Define __match_args__ para permitir pattern matching posicional sobre os atributos dinâmicos suportados por __getattr__.[135]

  2. Obtém a classe de Vector, para uso posterior.

  3. Tenta obter a posição de name em __match_args__.

  4. .index(name) gera um ValueError quando name não é encontrado; define pos como -1. (Eu preferiria usar algo como str.find aqui, mas tuple não implementa esse método.)

  5. Se pos está dentro da faixa de componentes disponíveis, devolve aquele componente.

  6. Se chegamos até aqui, gera um AttributeError com uma mensagem de erro padrão.

Não é difícil implementar __getattr__, mas neste caso não é o suficiente. Observe a interação bizarra no Exemplo 9.

Exemplo 9. Comportamento inapropriado: realizar uma atribuição a v.x não gera um erro, mas introduz uma inconsistência
>>> v = Vector(range(5))
>>> v
Vector([0.0, 1.0, 2.0, 3.0, 4.0])
>>> v.x  # (1)
0.0
>>> v.x = 10  # (2)
>>> v.x  # (3)
10
>>> v
Vector([0.0, 1.0, 2.0, 3.0, 4.0])  # (4)
  1. Acessa o elemento v[0] como v.x.

  2. Atribui um novo valor a v.x. Isso deveria gera uma exceção.

  3. Ler v.x obtém o novo valor, 10.

  4. Entretanto, os componentes do vetor não mudam.

Você consegue explicar o que está acontecendo? Em especial, por que v.x devolve 10 na segunda consulta (<3>), se aquele valor não está presente no array de componentes do vetor? Se você não souber responder de imediato, estude a explicação de __getattr__ que aparece logo antes do Exemplo 8. A razão é um pouco sutil, mas é um alicerce fundamental para entender grande parte do que veremos mais tarde no livro.

Após pensar um pouco sobre essa questão, siga em frente e leia a explicação para o que aconteceu.

A inconsistência no Exemplo 9 ocorre devido à forma como __getattr__ funciona: o Python só chama esse método como último recurso, quando o objeto não contém o atributo nomeado. Entretanto, após atribuirmos v.x = 10, o objeto v agora contém um atributo x, e então __getattr__ não será mais invocado para obter v.x: o interpretador vai apenas devolver o valor 10, que agora está vinculado a v.x. Por outro lado, nossa implementação de __getattr__ não leva em consideração qualquer atributo de instância diferente de self._components, de onde ele obtém os valores dos "atributos virtuais" listados em __match_args__.

Para evitar essa inconsistência, precisamos modificar a lógica de definição de atributos em nossa classe Vector.

Como você se lembra, nos nossos últimos exemplos de Vector2d no Capítulo 11, tentar atribuir valores aos atributos de instância .x ou .y gerava um AttributeError. Em Vector, queremos produzir a mesma exceção em resposta a tentativas de atribuição a qualquer nome de atributo com um única letra, só para evitar confusão. Para fazer isso, implementaremos __setattr__, como listado no Exemplo 10.

Exemplo 10. Parte de vector_v3.py: o método __setattr__ na classe Vector
    def __setattr__(self, name, value):
        cls = type(self)
        if len(name) == 1:  # (1)
            if name in cls.__match_args__:  # (2)
                error = 'readonly attribute {attr_name!r}'
            elif name.islower():  # (3)
                error = "can't set attributes 'a' to 'z' in {cls_name!r}"
            else:
                error = ''  # (4)
            if error:  # (5)
                msg = error.format(cls_name=cls.__name__, attr_name=name)
                raise AttributeError(msg)
        super().__setattr__(name, value)  # (6)
  1. Tratamento especial para nomes de atributos com uma única letra.

  2. Se name está em __match_args__, configura mensagens de erro específicas.

  3. Se name é uma letra minúscula, configura a mensagem de erro sobre todos os nomes de uma única letra.

  4. Caso contrário, configura uma mensagem de erro vazia.

  5. Se existir uma mensagem de erro não-vazia, gera um AttributeError.

  6. Caso default: chama __setattr__ na superclasse para obter o comportamento padrão.

👉 Dica

A função super() fornece uma maneira de acessar dinamicamente métodos de superclasses, uma necessidade em uma linguagem dinâmica que suporta herança múltipla, como o Python. Ela é usada para delegar alguma tarefa de um método em uma subclasse para um método adequado em uma superclasse, como visto no Exemplo 10. Falaremos mais sobre super na Seção 14.4.

Ao escolher a menssagem de erro para mostrar com AttributeError, primeiro eu verifiquei o comportamento do tipo embutido complex, pois ele é imutável e tem um par de atributos de dados real and imag. Tentar mudar qualquer um dos dois em uma instância de complex gera um AttributeError com a mensagem "can’t set attribute" ("não é possível [re]-definir o atributo"). Por outro lado, a tentativa de modificar um atributo protegido por uma propriedade, como fizemos no Seção 11.7, produz a mensagem "read-only attribute" ("atributo apenas para leitura"). Eu me inspirei em ambas as frases para definir a string error em __setitem__, mas fui mais explícito sobre os atributos proibidos.

Observe que não estamos proibindo a modificação de todos os atributos, apenas daqueles com nomes compostos por uma única letra minúscula, para evitar conflitos com os atributos apenas para leitura suportados, x, y, z, e t.

⚠️ Aviso

Sabendo que declarar __slots__ no nível da classe impede a definição de novos atributos de instância, é tentador usar esse recurso em vez de implementar __setattr__ como fizemos. Entretanto, por todas as ressalvas discutidas na Seção 11.11.2, usar __slots__ apenas para prevenir a criação de atributos de instância não é recomendado. __slots__ deve ser usado apenas para economizar memória, e apenas quando isso for um problema real.

Mesmo não suportando escrita nos componentes de Vector, aqui está uma lição importante deste exemplo: muitas vezes, quando você implementa __getattr__, é necessário também escrever o __setattr__, para evitar comportamentos inconsistentes em seus objetos.

Para permitir a modificação de componentes, poderíamos implementar __setitem__, para permitir v[0] = 1.1, e/ou __setattr__, para fazer v.x = 1.1 funcionar. Mas Vector permanecerá imutável, pois queremos torná-lo hashable, na próxima seção.

12.7. Vector versão #4: o hash e um == mais rápido

Vamos novamente implementar um método __hash__. Juntamente com o __eq__ existente, isso tornará as instâncias de Vector hashable.

O __hash__ do Vector2d (no Exemplo 8) computava o hash de uma tuple construída com os dois componentes, self.x and self.y. Nós agora podemos estar lidando com milhares de componentes, então criar uma tuple pode ser caro demais. Em vez disso, vou aplicar sucessivamente o operador ^ (xor) aos hashes de todos os componentes, assim: v[0] ^ v[1] ^ v[2]. É para isso que serve a função functools.reduce. Anteriormente afirmei que reduce não é mais tão popular quanto antes,[136] mas computar o hash de todos os componentes do vetor é um bom caso de uso para ela. A Figura 1 ilustra a ideia geral da função reduce.

Diagrama de reduce
Figura 1. Funções de redução—reduce, sum, any, all—produzem um único resultado agregado a partir de uma sequência ou de qualquer objeto iterável finito.

Até aqui vimos que functools.reduce() pode ser substituída por sum(). Vamos agora explicar exatamente como ela funciona. A ideia chave é reduzir uma série de valores a um valor único. O primeiro argumento de reduce() é uma função com dois argumentos, o segundo argumento é um iterável. Vamos dizer que temos uma função fn, que recebe dois argumentos, e uma lista lst. Quando chamamos reduce(fn, lst), fn será aplicada ao primeiro par de elementos de lstfn(lst[0], lst[1])—produzindo um primeiro resultado, r1. Então fn é aplicada a r1 e ao próximo elemento—fn(r1, lst[2])—produzindo um segundo resultado, r2. Agora fn(r2, lst[3]) é chamada para produzir r3 …​ e assim por diante, até o último elemento, quando finalmente um único elemento, rN, é produzido e devolvido.

Aqui está como reduce poderia ser usada para computar 5! (o fatorial de 5):

>>> 2 * 3 * 4 * 5  # the result we want: 5! == 120
120
>>> import functools
>>> functools.reduce(lambda a,b: a*b, range(1, 6))
120

Voltando a nosso problema de hash, o Exemplo 11 demonstra a ideia da computação de um xor agregado, fazendo isso de três formas diferente: com um loop for e com dois modos diferentes de usar reduce.

Exemplo 11. Três maneiras de calcular o xor acumulado de inteiros de 0 a 5
>>> n = 0
>>> for i in range(1, 6):  # (1)
...     n ^= i
...
>>> n
1
>>> import functools
>>> functools.reduce(lambda a, b: a^b, range(6))  # (2)
1
>>> import operator
>>> functools.reduce(operator.xor, range(6))  # (3)
1
  1. xor agregado com um loop for e uma variável de acumulação.

  2. functools.reduce usando uma função anônima.

  3. functools.reduce substituindo a lambda personalizada por operator.xor.

Das alternativas apresentadas no Exemplo 11, a última é minha favorita, e o loop for vem a seguir. Qual sua preferida?

Como visto na Seção 7.8.1, operator oferece a funcionalidade de todos os operadores infixos do Python em formato de função, diminuindo a necessidade do uso de lambda.

Para escrever Vector.__hash__ no meu estilo preferido precisamos importar os módulos functools e operator. Exemplo 12 apresenta as modificações relevantes.

Exemplo 12. Parte de vector_v4.py: duas importações e o método __hash__ adicionados à classe Vector de vector_v3.py
from array import array
import reprlib
import math
import functools  # (1)
import operator  # (2)


class Vector:
    typecode = 'd'

    # many lines omitted in book listing...

    def __eq__(self, other):  # (3)
        return tuple(self) == tuple(other)

    def __hash__(self):
        hashes = (hash(x) for x in self._components)  # (4)
        return functools.reduce(operator.xor, hashes, 0)  # (5)

    # more lines omitted...
  1. Importa functools para usar reduce.

  2. Importa operator para usar xor.

  3. Não há mudanças em __eq__; listei-o aqui porque é uma boa prática manter __eq__ e __hash__ próximos no código-fonte, pois eles precisam trabalhar juntos.

  4. Cria uma expressão geradora para computar sob demanda o hash de cada componente.

  5. Alimenta reduce com hashes e a função xor, para computar o código hash agregado; o terceiro argumento, 0, é o inicializador (veja o próximo aviso).

⚠️ Aviso

Ao usar reduce, é uma boa prática fornecer o terceiro argumento, reduce(function, iterable, initializer), para prevenir a seguinte exceção: TypeError: reduce() of empty sequence with no initial value ("TypeError: reduce() de uma sequência vazia sem valor inicial"— uma mensagem excelente: explica o problema e diz como resolvê-lo) . O initializer é o valor devolvido se a sequência for vazia e é usado como primeiro argumento no loop de redução, e portanto deve ser o elemento neutro da operação. Assim, o initializer para +, |, ^ deve ser 0, mas para * e & deve ser 1.

Da forma como está implementado, o método __hash__ no Exemplo 12 é um exemplo perfeito de uma computação de map-reduce (mapeia e reduz). Veja a (Figura 2).

Diagrama de map-reduce
Figura 2. Map-reduce: aplica uma função a cada item para gerar uma nova série (map), e então computa o agregado (reduce).

A etapa de mapeamento produz um hash para cada componente, e a etapa de redução agrega todos os hashes com o operador xor. Se usarmos map em vez de uma genexp, a etapa de mapeamento fica ainda mais visível:

    def __hash__(self):
        hashes = map(hash, self._components)
        return functools.reduce(operator.xor, hashes)
👉 Dica

A solução com map seria menos eficiente no Python 2, onde a função map cria uma nova list com os resultados. Mas no Python 3, map é preguiçosa (lazy): ela cria um gerador que produz os resultados sob demanda, e assim economiza memória—exatamente como a expressão geradora que usamos no método __hash__ do Exemplo 8.

E enquanto estamos falando de funções de redução, podemos substituir nossa implementação apressada de __eq__ com uma outra, menos custosa em termos de processamento e uso de memória, pelo menos para vetores grandes. Como visto no Exemplo 2, temos esta implementação bastante concisa de __eq__:

    def __eq__(self, other):
        return tuple(self) == tuple(other)

Isso funciona com Vector2d e com Vector—e até considera Vector([1, 2]) igual a (1, 2), o que pode ser um problema, mas por ora vamos ignorar esta questão[137]. Mas para instâncias de Vector, que podem ter milhares de componentes, esse método é muito ineficiente. Ele cria duas tuplas copiando todo o conteúdo dos operandos, apenas para usar o __eq__ do tipo tuple. Para Vector2d (com apenas dois componentes), é um bom atalho. Mas não para grandes vetores multidimensionais. Uma forma melhor de comparar um Vector com outro Vector ou iterável seria o código do Exemplo 13.

Exemplo 13. A implementação de Vector.__eq__ usando zip em um loop for, para uma comparação mais eficiente
    def __eq__(self, other):
        if len(self) != len(other):  # (1)
            return False
        for a, b in zip(self, other):  # (2)
            if a != b:  # (3)
                return False
        return True  # (4)
  1. Se as len dos objetos são diferentes, eles não são iguais.

  2. zip produz um gerador de tuplas criadas a partir dos itens em cada argumento iterável. Veja a caixa O fantástico zip, se zip for novidade para você. Em 1, a comparação com len é necessária porque zip para de produzir valores sem qualquer aviso quando uma das fontes de entrada se exaure.

  3. Sai assim que dois componentes sejam diferentes, devolvendo False.

  4. Caso contrário, os objetos são iguais.

👉 Dica

O nome da função zip vem de zíper, pois esse objeto físico funciona engatando pares de dentes tomados dos dois lados do zíper, uma boa analogia visual para o que faz zip(left, right). Nenhuma relação com arquivos comprimidos.

O Exemplo 13 é eficiente, mas a função all pode produzir a mesma computação de um agregado do loop for em apenas uma linha: se todas as comparações entre componentes correspoendentes nos operandos forem True, o resultado é True. Assim que uma comparação é False, all devolve False. O Exemplo 14 mostra um __eq__ usando all.

Exemplo 14. A implementação de Vector.__eq__ usando zip e all: mesma lógica do Exemplo 13
    def __eq__(self, other):
        return len(self) == len(other) and all(a == b for a, b in zip(self, other))

Observe que primeiro comparamos o len() dos operandos porque se os tamanhos são diferentes é desnecessário comparar os itens.

O Exemplo 14 é a implementação que escolhemos para __eq__ em vector_v4.py.

O fantástico zip

Ter um loop for que itera sobre itens sem perder tempo com variáveis de índice é muito bom e evita muitos bugs, mas exige algumas funções utilitárias especiais. Uma delas é a função embutida zip, que facilita a iteração em paralelo sobre dois ou mais iteráveis, devolvendo tuplas que você pode desempacotar em variáveis, uma para cada item nas entradas paralelas. Veja o Exemplo 15.

Exemplo 15. A função embutida zip trabalhando
>>> zip(range(3), 'ABC')  # (1)
<zip object at 0x10063ae48>
>>> list(zip(range(3), 'ABC'))  # (2)
[(0, 'A'), (1, 'B'), (2, 'C')]
>>> list(zip(range(3), 'ABC', [0.0, 1.1, 2.2, 3.3]))  # (3)
[(0, 'A', 0.0), (1, 'B', 1.1), (2, 'C', 2.2)]
>>> from itertools import zip_longest  # (4)
>>> list(zip_longest(range(3), 'ABC', [0.0, 1.1, 2.2, 3.3], fillvalue=-1))
[(0, 'A', 0.0), (1, 'B', 1.1), (2, 'C', 2.2), (-1, -1, 3.3)]
  1. zip devolve um gerador que produz tuplas sob demanda.

  2. Cria uma list apenas para exibição; nós normalmente iteramos sobre o gerador.

  3. zip para sem aviso quando um dos iteráveis é exaurido.

  4. A função itertools.zip_longest se comporta de forma diferente: ela usa um fillvalue opcional (por default None) para preencher os valores ausentes, e assim consegue gerar tuplas até que o último iterável seja exaurido.

✒️ Nota
A nova opção de zip() no Python 3.10

Escrevi na primeira edição deste livro que zip encerrar silenciosamente ao final do iterável mais curto era surpreendente—e não era uma boa característica em uma API. Ignorar parte dos dados de entrada sem qualquer alerta pode levar a bugs sutis. Em vez disso, zip deveria gerar um ValueError se os iteráveis não forem todos do mesmo tamanho, como acontece quando se desempacota um iterável para uma tupla de variáveis de tamanho diferente—alinhado à política de falhar rápido do Python. A PEP 618—Add Optional Length-Checking To zip acrescentou um argumento opcional strict à função zip, para fazê-la de comportar dessa forma. Isso foi implementado no Python 3.10.

A função zip pode também ser usada para transpor uma matriz, representada como iteráveis aninhados. Por exemplo:

>>> a = [(1, 2, 3),
...      (4, 5, 6)]
>>> list(zip(*a))
[(1, 4), (2, 5), (3, 6)]
>>> b = [(1, 2),
...      (3, 4),
...      (5, 6)]
>>> list(zip(*b))
[(1, 3, 5), (2, 4, 6)]

Se você quiser entender zip, passe algum tempo descobrindo como esses exemplos funcionam.

A função embutida enumerate é outra função geradora usada com frequência em loops for, para evitar manipulação direta de variáveis índice. Quem não estiver familiarizado com enumerate deve estudar a seção dedicada a ela na documentação das "Funções embutidas". As funções embutidas zip e enumerate, bem como várias outras funções geradores na biblioteca padrão, são tratadas na Seção 17.9.

Vamos encerrar esse capítulo trazendo de volta o método __format__ do Vector2d para o Vector.

12.8. Vector versão #5: Formatando

O método __format__ de Vector será parecido com o mesmo método em Vector2d, mas em vez de fornecer uma exibição personalizada em coordenadas polares, Vector usará coordenadas esféricas—também conhecidas como coordendas "hiperesféricas", pois agora suportamos n dimensões, e as esferas são "hiperesferas", em 4D e além[138]. Como consequência, mudaremos também o sufixo do formato personalizado de 'p' para 'h'.

👉 Dica

Como vimos na Seção 11.6, ao estender a Minilinguagem de especificação de formato é melhor evitar a reutilização dos códigos de formato usados por tipos embutidos. Especialmente, nossa minilinguagens estendida também usa os códigos de formato dos números de ponto flutuante ('eEfFgGn%'), em seus significados originais, então devemos certamente evitar qualquer um daqueles. Inteiros usam 'bcdoxXn' e strings usam 's'. Escolhi 'p' para as coordenadas polares de Vector2d. O código 'h' para coordendas hiperesféricas é uma boa opção.

Por exemplo, dado um objeto Vector em um espaço 4D (len(v) == 4), o código 'h' irá produzir uma linha como <r, Φ₁, Φ₂, Φ₃>, onde r é a magnitude (abs(v)), e o restante dos números são os componentes angulares Φ₁, Φ₂, Φ₃.

Aqui estão algumas amostras do formato de coordenadas esféricas em 4D, retiradas dos doctests de vector_v5.py (veja o Exemplo 16):

>>> format(Vector([-1, -1, -1, -1]), 'h')
'<2.0, 2.0943951023931957, 2.186276035465284, 3.9269908169872414>'
>>> format(Vector([2, 2, 2, 2]), '.3eh')
'<4.000e+00, 1.047e+00, 9.553e-01, 7.854e-01>'
>>> format(Vector([0, 1, 0, 0]), '0.5fh')
'<1.00000, 1.57080, 0.00000, 0.00000>'

Antes de podermos implementar as pequenas mudanças necessárias em __format__, precisamos escrever um par de métodos de apoio: angle(n), para computar uma das coordenadas angulares (por exemplo, Φ₁), e angles(), para devolver um iterável com todas as coordenadas angulares. Não vou descrever a matemática aqui; se você tiver curiosidade, a página n-sphere” (EN: ver Nota 6) da Wikipedia contém as fórmulas que usei para calcular coordenadas esféricas a partir das coordendas cartesianas no array de componentes de Vector.

O Exemplo 16 é a listagem completa de vector_v5.py, consolidando tudo que implementamos desde a Seção 12.3, e acrescentando a formatação personalizada

Exemplo 16. vector_v5.py: doctests e todo o código da versão final da classe Vector; as notas explicativas enfatizam os acréscimos necessários para suportar __format__
"""
A multidimensional ``Vector`` class, take 5

A ``Vector`` is built from an iterable of numbers::

    >>> Vector([3.1, 4.2])
    Vector([3.1, 4.2])
    >>> Vector((3, 4, 5))
    Vector([3.0, 4.0, 5.0])
    >>> Vector(range(10))
    Vector([0.0, 1.0, 2.0, 3.0, 4.0, ...])


Tests with two dimensions (same results as ``vector2d_v1.py``)::

    >>> v1 = Vector([3, 4])
    >>> x, y = v1
    >>> x, y
    (3.0, 4.0)
    >>> v1
    Vector([3.0, 4.0])
    >>> v1_clone = eval(repr(v1))
    >>> v1 == v1_clone
    True
    >>> print(v1)
    (3.0, 4.0)
    >>> octets = bytes(v1)
    >>> octets
    b'd\\x00\\x00\\x00\\x00\\x00\\x00\\x08@\\x00\\x00\\x00\\x00\\x00\\x00\\x10@'
    >>> abs(v1)
    5.0
    >>> bool(v1), bool(Vector([0, 0]))
    (True, False)


Test of ``.frombytes()`` class method:

    >>> v1_clone = Vector.frombytes(bytes(v1))
    >>> v1_clone
    Vector([3.0, 4.0])
    >>> v1 == v1_clone
    True


Tests with three dimensions::

    >>> v1 = Vector([3, 4, 5])
    >>> x, y, z = v1
    >>> x, y, z
    (3.0, 4.0, 5.0)
    >>> v1
    Vector([3.0, 4.0, 5.0])
    >>> v1_clone = eval(repr(v1))
    >>> v1 == v1_clone
    True
    >>> print(v1)
    (3.0, 4.0, 5.0)
    >>> abs(v1)  # doctest:+ELLIPSIS
    7.071067811...
    >>> bool(v1), bool(Vector([0, 0, 0]))
    (True, False)


Tests with many dimensions::

    >>> v7 = Vector(range(7))
    >>> v7
    Vector([0.0, 1.0, 2.0, 3.0, 4.0, ...])
    >>> abs(v7)  # doctest:+ELLIPSIS
    9.53939201...


Test of ``.__bytes__`` and ``.frombytes()`` methods::

    >>> v1 = Vector([3, 4, 5])
    >>> v1_clone = Vector.frombytes(bytes(v1))
    >>> v1_clone
    Vector([3.0, 4.0, 5.0])
    >>> v1 == v1_clone
    True


Tests of sequence behavior::

    >>> v1 = Vector([3, 4, 5])
    >>> len(v1)
    3
    >>> v1[0], v1[len(v1)-1], v1[-1]
    (3.0, 5.0, 5.0)


Test of slicing::

    >>> v7 = Vector(range(7))
    >>> v7[-1]
    6.0
    >>> v7[1:4]
    Vector([1.0, 2.0, 3.0])
    >>> v7[-1:]
    Vector([6.0])
    >>> v7[1,2]
    Traceback (most recent call last):
      ...
    TypeError: 'tuple' object cannot be interpreted as an integer


Tests of dynamic attribute access::

    >>> v7 = Vector(range(10))
    >>> v7.x
    0.0
    >>> v7.y, v7.z, v7.t
    (1.0, 2.0, 3.0)

Dynamic attribute lookup failures::

    >>> v7.k
    Traceback (most recent call last):
      ...
    AttributeError: 'Vector' object has no attribute 'k'
    >>> v3 = Vector(range(3))
    >>> v3.t
    Traceback (most recent call last):
      ...
    AttributeError: 'Vector' object has no attribute 't'
    >>> v3.spam
    Traceback (most recent call last):
      ...
    AttributeError: 'Vector' object has no attribute 'spam'


Tests of hashing::

    >>> v1 = Vector([3, 4])
    >>> v2 = Vector([3.1, 4.2])
    >>> v3 = Vector([3, 4, 5])
    >>> v6 = Vector(range(6))
    >>> hash(v1), hash(v3), hash(v6)
    (7, 2, 1)


Most hash codes of non-integers vary from a 32-bit to 64-bit CPython build::

    >>> import sys
    >>> hash(v2) == (384307168202284039 if sys.maxsize > 2**32 else 357915986)
    True


Tests of ``format()`` with Cartesian coordinates in 2D::

    >>> v1 = Vector([3, 4])
    >>> format(v1)
    '(3.0, 4.0)'
    >>> format(v1, '.2f')
    '(3.00, 4.00)'
    >>> format(v1, '.3e')
    '(3.000e+00, 4.000e+00)'


Tests of ``format()`` with Cartesian coordinates in 3D and 7D::

    >>> v3 = Vector([3, 4, 5])
    >>> format(v3)
    '(3.0, 4.0, 5.0)'
    >>> format(Vector(range(7)))
    '(0.0, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0)'


Tests of ``format()`` with spherical coordinates in 2D, 3D and 4D::

    >>> format(Vector([1, 1]), 'h')  # doctest:+ELLIPSIS
    '<1.414213..., 0.785398...>'
    >>> format(Vector([1, 1]), '.3eh')
    '<1.414e+00, 7.854e-01>'
    >>> format(Vector([1, 1]), '0.5fh')
    '<1.41421, 0.78540>'
    >>> format(Vector([1, 1, 1]), 'h')  # doctest:+ELLIPSIS
    '<1.73205..., 0.95531..., 0.78539...>'
    >>> format(Vector([2, 2, 2]), '.3eh')
    '<3.464e+00, 9.553e-01, 7.854e-01>'
    >>> format(Vector([0, 0, 0]), '0.5fh')
    '<0.00000, 0.00000, 0.00000>'
    >>> format(Vector([-1, -1, -1, -1]), 'h')  # doctest:+ELLIPSIS
    '<2.0, 2.09439..., 2.18627..., 3.92699...>'
    >>> format(Vector([2, 2, 2, 2]), '.3eh')
    '<4.000e+00, 1.047e+00, 9.553e-01, 7.854e-01>'
    >>> format(Vector([0, 1, 0, 0]), '0.5fh')
    '<1.00000, 1.57080, 0.00000, 0.00000>'
"""

from array import array
import reprlib
import math
import functools
import operator
import itertools  # (1)


class Vector:
    typecode = 'd'

    def __init__(self, components):
        self._components = array(self.typecode, components)

    def __iter__(self):
        return iter(self._components)

    def __repr__(self):
        components = reprlib.repr(self._components)
        components = components[components.find('['):-1]
        return f'Vector({components})'

    def __str__(self):
        return str(tuple(self))

    def __bytes__(self):
        return (bytes([ord(self.typecode)]) +
                bytes(self._components))

    def __eq__(self, other):
        return (len(self) == len(other) and
                all(a == b for a, b in zip(self, other)))

    def __hash__(self):
        hashes = (hash(x) for x in self)
        return functools.reduce(operator.xor, hashes, 0)

    def __abs__(self):
        return math.hypot(*self)

    def __bool__(self):
        return bool(abs(self))

    def __len__(self):
        return len(self._components)

    def __getitem__(self, key):
        if isinstance(key, slice):
            cls = type(self)
            return cls(self._components[key])
        index = operator.index(key)
        return self._components[index]

    __match_args__ = ('x', 'y', 'z', 't')

    def __getattr__(self, name):
        cls = type(self)
        try:
            pos = cls.__match_args__.index(name)
        except ValueError:
            pos = -1
        if 0 <= pos < len(self._components):
            return self._components[pos]
        msg = f'{cls.__name__!r} object has no attribute {name!r}'
        raise AttributeError(msg)

    def angle(self, n):  # (2)
        r = math.hypot(*self[n:])
        a = math.atan2(r, self[n-1])
        if (n == len(self) - 1) and (self[-1] < 0):
            return math.pi * 2 - a
        else:
            return a

    def angles(self):  # (3)
        return (self.angle(n) for n in range(1, len(self)))

    def __format__(self, fmt_spec=''):
        if fmt_spec.endswith('h'):  # hyperspherical coordinates
            fmt_spec = fmt_spec[:-1]
            coords = itertools.chain([abs(self)],
                                     self.angles())  # (4)
            outer_fmt = '<{}>'  # (5)
        else:
            coords = self
            outer_fmt = '({})'  # (6)
        components = (format(c, fmt_spec) for c in coords)  # (7)
        return outer_fmt.format(', '.join(components))  # (8)

    @classmethod
    def frombytes(cls, octets):
        typecode = chr(octets[0])
        memv = memoryview(octets[1:]).cast(typecode)
        return cls(memv)
  1. Importa itertools para usar a função chain em __format__.

  2. Computa uma das coordendas angulares, usando fórmulas adaptadas do artigo n-sphere (EN: ver Nota 6) na Wikipedia.

  3. Cria uma expressão geradora para computar sob demanda todas as coordenadas angulares.

  4. Produz uma genexp usando itertools.chain, para iterar de forma contínua sobre a magnitude e as coordenadas angulares.

  5. Configura uma coordenada esférica para exibição, com os delimitadores de ângulo (< e >).

  6. Configura uma coordenda cartesiana para exibição, com parênteses.

  7. Cria uma expressão geradoras para formatar sob demanda cada item de coordenada.

  8. Insere componentes formatados, separados por vírgulas, dentro de delimitadores ou parênteses.

✒️ Nota

Estamos fazendo uso intensivo de expressões geradoras em __format__, angle, e angles, mas nosso foco aqui é fornecer um __format__ para levar Vector ao mesmo nível de implementação de Vector2d. Quando tratarmos de geradores, no Capítulo 17, vamos usar parte do código de Vector nos exemplos, e lá os recursos dos geradores serão explicados em detalhes.

Isso conclui nossa missão nesse capítulo. A classe Vector será aperfeiçoada com operadores infixos no Capítulo 16. Nosso objetivo aqui foi explorar técnicas para programação de métodos especiais que são úteis em uma grande variedade de classes de coleções.

12.9. Resumo do capítulo

A classe Vector, o exemplo que desenvolvemos nesse capítulo, foi projetada para ser compatível com Vector2d, exceto pelo uso de uma assinatura de construtor diferente, aceitando um único argumento iterável, como fazem todos os tipos embutidos de sequências. O fato de Vector se comportar como uma sequência apenas por implementar __getitem__ e __len__ deu margem a uma discussão sobre protocolos, as interfaces informais usadas em linguagens com duck typing.

A seguir vimos como a sintaxe my_seq[a:b:c] funciona por baixo dos panos, criando um objeto slice(a, b, c) e entregando esse objeto a __getitem__. Armados com esse conhecimento, fizemos Vector responder corretamente ao fatiamento, devolvendo novas instâncias de Vector, como se espera de qualquer sequência pythônica.

O próximo passo foi fornecer acesso somente para leitura aos primeiros componentes de Vector, usando uma notação do tipo my_vec.x. Fizemos isso implementando __getattr__. Fazer isso abriu a possibilidade de incentivar o usuário a atribuir àqueles componentes especiais, usando a forma my_vec.x = 7, revelando um possível bug. Consertamos o problema implementando também __setattr__, para barrar a atribuição de valores a atributos cujos nomes tenham apenas uma letra. É comum, após escrever um __getattr__, ser necessário adicionar também __setattr__, para evitar comportamento inconsistente.

Implementar a função __hash__ nos deu um contexto perfeito para usar functools.reduce, pois precisávamos aplicar o operador xor (^) sucessivamente aos hashes de todos os componentes de Vector, para produzir um código de hash agregado referente a todo o Vector. Após aplicar reduce em __hash__, usamos a função embutida de redução all, para criar um método __eq__ mais eficiente.

O último aperfeiçoamento a Vector foi reimplementar o método __format__ de Vector2d, para suportar coordenadas esféricas como alternativa às coordenadas cartesianas default. Usamos bastante matemática e vários geradores para programar __format__ e suas funções auxiliares, mas esses são detalhes de implementação—e voltaremos aos geradores no Capítulo 17. O objetivo daquela última seção foi suportar um formato personalizado, cumprindo assim a promessa de um Vector capaz de fazer tudo que um Vector2d faz e algo mais.

Como fizemos no Capítulo 11, muitas vezes aqui olhamos como os objetos padrão do Python se comportam, para emulá-los e dar a Vector uma aparência "pythônica".

No Capítulo 16 vamos implemenar vários operadores infixos em Vector. A matemática será muito mais simples que aquela no método angle() daqui, mas explorar como os operadores infixos funcionam no Python é uma grande lição sobre design orientado a objetos. Mas antes de chegar à sobrecarga de operadores, vamos parar um pouco de trabalhar com uma única classe e olhar para a organização de múltiplas classes com interfaces e herança, os assuntos dos capítulos #ifaces_prot_abc e #inheritance.

12.10. Leitura complementar

A maioria dos métodos especiais tratados no exemplo de Vector também apareceram no exemplo do Vector2d, no Capítulo 11, então as referências na Seção 11.14 ali são todas relevantes aqui também.

A poderosa função de ordem superior reduce também é conhecida como fold (dobrar), accumulate (acumular), aggregate (agregar), compress (comprimir), e inject (injetar). Para mais informações, veja o artigo "Fold (higher-order function)" ("Dobrar (função de ordem superior)") (EN), que apresenta aplicações daquela função de ordem superior, com ênfase em programação funcional com estruturas de dados recursivas. O artigo também inclui uma tabela mostrando funções similares a fold em dezenas de linguagens de programação.

Em "What’s New in Python 2.5" (Novidades no Python 2.5) (EN) há uma pequena explicação sobre __index__, projetado para suportar métodos __getitem__, como vimos na Seção 12.5.2. A PEP 357—Allowing Any Object to be Used for Slicing (Permitir que Qualquer Objeto seja Usado para Fatiamento) detalha a necessidade daquele método especial na perspectiva de um implementador de uma extensão em C—Travis Oliphant, o principal criador da NumPy. As muitas contribuições de Oliphant tornaram o Python uma importante linguagem para computação científica, que por sua vez posicionou a linguagem como a escolha preferencial para aplicações de aprendizagem de máquina.

Ponto de vista

Protocolos como interfaces informais

Protocolos não são uma invenção do Python. Os criadores do Smalltalk, que também cunharam a expressão "orientado a objetos", usavam "protocolo" como um sinônimo para aquilo que hoje chamamos de interfaces. Alguns ambientes de programação Smalltalk permitiam que os programadores marcassem um grupo de métodos como um protocolo, mas isso era meramente um artefato de documentação e navegação, e não era imposto pela linguagem. Por isso acredito que "interface informal" é uma explicação curta razoável para "protocolo" quando falo para uma audiência mais familiar com interfaces formais (e impostas pelo compilador).

Protocolos bem estabelecidos ou consagrados evoluem naturalmente em qualquer linguagem que usa tipagem dinâmica (isto é, quando a verificação de tipo acontece durante a execução), porque não há informação estática de tipo em assinaturas de métodos e em variáveis. Ruby é outra importante linguagem orientada a objetos que tem tipagem dinâmica e usa protocolos.

Na documentação do Python, muitas vezes podemos perceber que um protocolo está sendo discutido pelo uso de linguagem como "um objeto similar a um arquivo". Isso é uma forma abreviada de dizer "algo que se comporta como um arquivo, implementando as partes da interface de arquivo relevantes ao contexto".

Você poderia achar que implementar apenas parte de um protocolo é um desleixo, mas isso tem a vantagem de manter as coisas simples. A Seção 3.3 do capítulo "Modelo de Dados" na documentação do Python sugere que:

Ao implementar uma classe que emula qualquer tipo embutido, é importante que a emulação seja implementada apenas na medida em que faça sentido para o objeto que está sendo modelado. Por exemplo, algumas sequências podem funcionar bem com a recuperação de elementos individuais, mas extrair uma fatia pode não fazer sentido.

Quando não precisamos escrever métodos inúteis apenas para cumprir o contrato de uma interface excessivamente detalhista e para manter o compilador feliz, fica mais fácil seguir o princípio KISS.

Por outro lado, se você quiser usar um verificador de tipo para checar suas implementações de protocolos, então uma definição mais estrita de "protocolo" é necessária. É isso que typing.Protocol nos fornece.

Terei mais a dizer sobre protocolos e interfaces no Capítulo 13, onde esses conceitos são o assunto principal.

As origens do duck typing

Creio que a comunidade Ruby, mais que qualquer outra, ajudou a popularizar o termo "duck typing" (tipagem pato), ao pregar para as massas de usuários de Java. Mas a expressão já era usada nas discussões do Python muito antes do Ruby ou do Python se tornarem "populares". De acordo com a Wikipedia, um dos primeiros exemplos de uso da analogia do pato, no contexto da programação orientada a objetos, foi uma mensagem para Python-list (EN), escrita por Alex Martelli e datada de 26 de julho de 2000: "polymorphism (was Re: Type checking in python?)" (polimorfismo (era Re: Verificação de tipo em python?)). Foi dali que veio a citação no início desse capítulo. Se você tiver curiosidade sobre as origens literárias do termo "duck typing", e a aplicação desse conceito de orientação a objetos em muitas linguagens, veja a página "Duck typing" na Wikipedia.

Um __format__ seguro, com usabilidade aperfeiçoada

Ao implementar __format__, não tomei qualquer precaução a respeito de instâncias de Vector com um número muito grande de componentes, como fizemos no __repr__ usando reprlib. A justificativa é que repr() é usado para depuração e registro de logs, então precisa sempre gerar uma saída minimamente aproveitável, enquanto __format__ é usado para exibir resultados para usuários finais, que presumivelmente desejam ver o Vector inteiro. Se isso for considerado inconveniente, então seria legal implementar um nova extensão à Minilinguagem de especificação de formato.

O quê eu faria: por default, qualquer Vector formatado mostraria um número razoável mas limitado de componentes, digamos uns 30. Se existirem mais elementos que isso, o comportamento default seria similar ao de reprlib: cortar o excesso e colocar …​ em seu lugar. Entretanto, se o especificador de formato terminar com um código especial *, significando "all" (todos), então a limitação de tamanho seria desabilitada. Assim, um usuário ignorante do problema de exibição de vetores muito grandes não será acidentalmente penalizado. Mas se a limitação default se tornar incômoda, a presença das …​ iria incentivar o usuário a consultar a documentação e descobrir o código de formatação * .

A busca por uma soma pythônica

Não há uma resposta única para a "O que é pythônico?", da mesma forma que não há uma resposta única para "O que é belo?". Dizer, como eu mesmo muitas vezes faço, que significa usar "Python idiomático" não é 100% satisfatório, porque talvez o que é "idiomático" para você não seja para mim. Sei de uma coisa: "idiomático" não significa o uso dos recursos mais obscuros da linguagem.

Na Python-list (EN), há uma thread de abril de 2003 chamada "Pythonic Way to Sum n-th List Element?" (A forma pythônica de somar os "n" elementos de uma lista). Ela é relevante para nossa discussão de reduce acima nesse capítulo.

O autor original, Guy Middleton, pediu melhorias para essa solução, afirmando não gostar de usar lambda:[139]

>>> my_list = [[1, 2, 3], [40, 50, 60], [9, 8, 7]]
>>> import functools
>>> functools.reduce(lambda a, b: a+b, [sub[1] for sub in my_list])
60

Esse código usa muitos idiomas: lambda, reduce e uma compreensão de lista. Ele provavelmente ficaria em último lugar em um concurso de popularidade, pois ofende quem odeia lambda e também aqueles que desprezam as compreensões de lista—praticamente os dois lados de uma disputa.

Se você vai usar lambda, provavelmente não há razão para usar uma compreensão de lista—exceto para filtragem, que não é o caso aqui.

Aqui está uma solução minha que agradará os amantes de lambda:

>>> functools.reduce(lambda a, b: a + b[1], my_list, 0)
60

Não tomei parte na discussão original, e não usaria o trecho acima em código real, pois eu também não gosto muito de lambda. Mas eu queria mostrar um exemplo sem uma compreensão de lista.

A primeira resposta veio de Fernando Perez, criador do IPython, e realça como o NumPy suporta arrays n-dimensionais e fatiamento n-dimensional:

>>> import numpy as np
>>> my_array = np.array(my_list)
>>> np.sum(my_array[:, 1])
60

Acho a solução de Perez boa, mas Guy Middleton elegiou essa próxima solução, de Paul Rubin e Skip Montanaro:

>>> import operator
>>> functools.reduce(operator.add, [sub[1] for sub in my_list], 0)
60

Então Evan Simpson perguntou, "Há algo errado com isso?":

>>> total = 0
>>> for sub in my_list:
...     total += sub[1]
...
>>> total
60

Muitos concordaram que esse código era bastante pythônico. Alex Martelli chegou a dizer que provavelmente seria assim que Guido escreveria a solução.

Gosto do código de Evan Simpson, mas também gosto do comentário de David Eppstein sobre ele:

Se você quer a soma de uma lista de itens, deveria escrever isso para se parecer com "a soma de uma lista de itens", não para se parecer com "faça um loop sobre esses itens, mantenha uma outra variável t, execute uma sequência de adições". Por que outra razão temos linguagens de alto nível, senão para expressar nossas intenções em um nível mais alto e deixar a linguagem se preocupar com as operações de baixo nível necessárias para implementá-las?

E daí Alex Martelli voltou para sugerir:

A soma é necessária com tanta frequência que eu não me importaria de forma alguma se o Python a tornasse uma função embutida. Mas "reduce(operator.add, …​" não é mesmo uma boa maneira de expressar isso, na minha opinião (e vejam que, como um antigo APLista[140] e um apreciador da FP[141], eu deveria gostar daquilo, mas não gosto.).

Martelli então sugere uma função sum(), que ele mesmo programa e propõe para o Python. Ela se torna uma função embutida no Python 2.3, lançado apenas três meses após aquela conversa na lista. E a sintaxe preferida de Alex se torna a regra:

>>> sum([sub[1] for sub in my_list])
60

No final do ano seguinte (novembro de 2004), o Python 2.4 foi lançado e incluía expressões geradoras, fornecendo o que agora é, na minha opinião, a resposta mais pythônica para a pergunta original de Guy Middleton:

>>> sum(sub[1] for sub in my_list)
60

Isso não só é mais legível que reduce, também evita a armadilha da sequência vazia: sum([]) é 0, simples assim.

Na mesma conversa, Alex Martelli sugeriu que a função embutida reduce do Python 2 trazia mais problemas que soluções, porque encorajava idiomas de programação difíceis de explicar. Ele foi bastante convincente: a função foi rebaixada para o módulo functools no Python 3.

Ainda assim, functools.reduce tem seus usos. Ela resolveu o problema de nosso Vector.__hash__ de uma forma que eu chamaria de pythônica.

13. Interfaces, protocolos, e ABCs

Programe mirando uma interface, não uma implementação.

Gamma, Helm, Johnson, Vlissides, First Principle of Object-Oriented Design Design Patterns: Elements of Reusable Object-Oriented Software, "Introduction," p. 18.

A programação orientada a objetos tem tudo a ver com interfaces. A melhor abordagem para entender um tipo em Python é conhecer os métodos que aquele tipo oferece—sua interface—como discutimos na Seção 8.4 do (Capítulo 8).

Dependendo da linguagem de programação, temos uma ou mais maneiras de definir e usar interfaces. Desde o Python 3.8, temos quatro maneiras. Elas estão ilustradas no Mapa de Sistemas de Tipagem (Figura 1). Podemos resumi-las assim:

Duck typing (tipagem pato)

A abordagem default do Python para tipagem desde o início. Estamos estudando duck typing desde Capítulo 1.

Goose typing (tipagem ganso)

A abordagem suportada pelas classes base abstratas (ABCs, sigla em inglês para Abstract Base Classes) desde o Python 2.6, que depende de verificações dos objetos como as ABCs durante a execução. A tipagem ganso é um dos principais temas desse capítulo.

Tipagem estática

A abordagem tradicional das linguagens de tipos estáticos como C e Java; suportada desde o Python 3.5 pelo módulo typing, e aplicada por verificadores de tipo externos compatíveis com a PEP 484—Type Hints. Este não é o foco desse capítulo. A maior parte do Capítulo 8 e do Capítulo 15 mais adiante são sobre tipagem estática.

Duck typing estática

Uma abordagem popularizada pela linguagem Go; suportada por subclasses de typing.Protocol—lançada no Python 3.8 e também aplicada com o suporte de verificadores de tipo externos. Tratamos desse tema pela primeira vez em Seção 8.5.10 (Capítulo 8), e continuamos nesse capítulo.

13.1. O mapa de tipagem

As quatro abordagens retratadas na Figura 1 são complementares: elas tem diferentes prós e contras. Não faz sentido descartar qualquer uma delas.

Quatro abordagens para verificação de tipo
Figura 1. A metade superior descreve abordagens de checagem de tipo durante a execução usando apenas o interpretador Python; a metade inferior requer um verificador de tipo estático externo, como o Mypy ou um IDE como o PyCharm. Os quadrantes da esquerda se referem a tipagem baseada na estrutura do objeto - isto é, dos métodos oferecidos pelo objeto, independente do nome de sua classe ou superclasses; os quadrantes da direita dependem dos objetos terem tipos explicitamente nomeados: o nome da classe do objeto, ou o nome de suas superclasses.

Cada uma dessas quatro abordagens dependem de interfaces para funcionarem, mas a tipagem estática pode ser implementada de forma limitada usando apenas tipos concretos em vez de abstrações de interfaces como protocolos e classes base abstratas. Este capítulo é sobre duck typing, goose typing (tipagem ganso), e duck typing estática - disciplinas de tipagem com foco em interfaces.

O capítulo está dividido em quatro seções principais, tratando de três dos quatro quadrantes no Mapa de Sistemas de Tipagem. (Figura 1):

  • Seção 13.3 compara duas formas de tipagem estrutural com protocolos - isto é, o lado esquerdo do Mapa.

  • Seção 13.4 se aprofunda no já familiar duck typing do Python, incluindo como fazê-lo mais seguro e ao mesmo tempo preservar sua melhor qualidade: a flexibilidade.

  • Seção 13.5 explica o uso de ABCs para um checagem de tipo mais estrita durante a execução do código. É a seção mais longa, não por ser a mais importante, mas porque há mais seções sobre duck typing, duck typing estático e tipagem estática em outras partes do livro.

  • Seção 13.6 cobre o uso, a implementação e o design de subclasses de typing.Protocol — úteis para checagem de tipo estática e durante a execução.

13.2. Novidades nesse capítulo

Este capítulo foi bastante modificado, e é cerca de 24% mais longo que o capítulo correspondente (o capítulo 11) na primeira edição de Python Fluente. Apesar de algumas seções e muitos parágrafos serem idênticos, há muito conteúdo novo. Estes são os principais acréscimos e modificações:

  • A introdução do capítulo e o Mapa de Sistemas de Tipagem (Figura 1) são novos. Essa é a chave da maior parte do conteúdo novo - e de todos os outros capítulos relacionados à tipagem em Python ≥ 3.8.

  • Seção 13.3 explica as semelhanças e diferenças entre protocolos dinâmicos e estáticos.

  • Seção 13.4.3 praticamente reproduz o conteúdo da primeira edição, mas foi atualizada e agora tem um título de seção que enfatiza sua importância.

  • Seção 13.6 é toda nova. Ela se apoia na apresentação inicial em Seção 8.5.10 (Capítulo 8).

  • Os diagramas de classe de collections.abc nas Figuras #sequence_uml_repeat, #mutablesequence_uml, and #collections_uml foram atualizados para incluir a Collection ABC, do Python 3.6.

A primeira edição de Python Fluente tinha uma seção encorajando o uso das ABCs numbers para goose typing. Na Seção 13.6.8 eu explico porque, em vez disso, você deve usar protocolos numéricos estáticos do módulo typing se você planeja usar verificadores de tipo estáticos, ou checagem durante a execução no estilo da goose typing.

13.3. Dois tipos de protocolos

A palavra protocolo tem significados diferentes na ciência da computação, dependendo do contexto. Um protocolo de rede como o HTTP especifica comandos que um cliente pode enviar para um servidor, tais como GET, PUT e HEAD.

Vimos na Seção 12.4 que um objeto protocolo especifica métodos que um objeto precisa oferecer para cumprir um papel.

O exemplo FrenchDeck no Capítulo 1 demonstra um objeto protocolo, o protocolo de sequência: os métodos que permitem a um objeto Python se comportar como uma sequência.

Implementar um protocolo completo pode exigir muitos métodos, mas muitas vezes não há problema em implementar apenas parte dele. Considere a classe Vowels no Exemplo 1.

Exemplo 1. Implementação parcial do protocolo de sequência usando __getitem__
>>> class Vowels:
...     def __getitem__(self, i):
...         return 'AEIOU'[i]
...
>>> v = Vowels()
>>> v[0]
'A'
>>> v[-1]
'U'
>>> for c in v: print(c)
...
A
E
I
O
U
>>> 'E' in v
True
>>> 'Z' in v
False

Implementar __getitem__ é o suficiente para obter itens pelo índice, e também para permitir iteração e o operador in. O método especial __getitem__ é de fato o ponto central do protocolo de sequência.

int PySequence_Check(PyObject *o)

Retorna 1 se o objeto oferecer o protocolo de sequência, caso contrário retorna 0. Observe que ela retorna 1 para classes Python com um método __getitem__, a menos que sejam subclasses de dict […​]

Esperamos que uma sequência também suporte len(), através da implementação de __len__. Vowels não tem um método __len__, mas ainda assim se comporta como uma sequência em alguns contextos. E isso pode ser o suficiente para nossos propósitos. Por isso que gosto de dizer que um protocolo é uma "interface informal." Também é assim que protocolos são entendidos em Smalltalk, o primeiro ambiente de programação orientado a objetos a usar esse termo.

Exceto em páginas sobre programação de redes, a maioria dos usos da palavra "protocolo" na documentação do Python se refere a essas interfaces informais.

Agora, com a adoção da PEP 544—Protocols: Structural subtyping (static duck typing) (EN) no Python 3.8, a palavra "protocolo" ganhou um novo sentido em Python - um sentido próximo, mas diferente. Como vimos na Seção 8.5.10 (Capítulo 8), a PEP 544 nos permite criar subclasses de typing.Protocol para definir um ou mais métodos que uma classe deve implementar (ou herdar) para satisfazer um verificador de tipo estático.

Quando precisar ser específico, vou adotar os seguintes termos:

Protocolo dinâmico

Os protocolos informais que o Python sempre teve. Protocolos dinâmicos são implícitos, definidos por convenção e descritos na documentação. Os protocolos dinâmicos mais importantes do Python são mantidos pelo próprio interpretador, e documentados no capítulo "Modelo de Dados" em A Referência da Linguagem Python.

Protocolo estático

Um protocolo como definido pela PEP 544—Protocols: Structural subtyping (static duck typing), a partir do Python 3.8. Um protocolo estático tem um definição explícita: uma subclasse de typing.Protocol.

Há duas diferenças fundamentais entre eles:

  • Um objeto pode implementar apenas parte de um protocolo dinâmico e ainda assim ser útil; mas para satisfazer um protocolo estático, o objeto precisa oferecer todos os métodos declarados na classe do protocolo, mesmo se seu programa não precise de todos eles.

  • Protocolos estáticos podem ser inspecionados por verificadores de tipo estáticos, protocolos dinâmicos não.

Os dois tipos de protocolo compartilham um característica essencial, uma classe nunca precisa declarar que suporta um protocolo pelo nome, isto é, por herança.

Além de protocolos estáticos, o Python também oferece outra forma de definir uma interface explícita no código: uma classe base abstrata (ABC).

O restante deste capítulo trata de protocolos dinâmicos e estáticos, bem como das ABCs.

13.4. Programando patos

Vamos começar nossa discussão de protocolos dinâmicos com os dois mais importantes em Python: o protocolo de sequência e o iterável. O interpretador faz grandes esforços para lidar com objetos que fornecem mesmo uma implementação mínima desses protocolos, como explicado na próxima seção.

13.4.1. O Python curte sequências

A filosofia do Modelo de Dados do Python é cooperar o máximo possível com os protocolos dinâmicos essenciais. Quando se trata de sequências, o Python faz de tudo para lidar mesmo com as mais simples implementações.

A Figura 2 mostra como a interface Sequence está formalizada como uma ABC. O interpretador Python e as sequências embutidas como list, str, etc., não dependem de forma alguma daquela ABC. Só estou usando a figura para descrever o que uma Sequence completa deve oferecer.

UML class diagram for `Sequence`
Figura 2. Diagrama de classe UML para a ABC Sequence e classes abstratas relacionadas de collections.abc. As setas de herança apontam de uma subclasse para suas superclasses. Nomes em itálico são métodos abstratos. Antes do Python 3.6, não existia uma ABC Collection - Sequence era uma subclasse direta de Container, Iterable e Sized.
👉 Dica

A maior parte das ABCs no módulo collections.abc existem para formalizar interfaces que são implementadas por objetos nativos e são implicitamente suportadas pelo interpretador - objetos e suporte que existem desde antes das próprias ABCs. As ABCs são úteis como pontos de partida para novas classes, e para permitir checagem de tipo explícita durante a execução (também conhecida como goose typing), bem como para servirem de dicas de tipo para verificadores de tipo estáticos.

Estudando a Figura 2, vemos que uma subclasse correta de Sequence deve implementar __getitem__ e __len__ (de Sized). Todos os outros métodos Sequence são concretos, então as subclasses podem herdar suas implementações - ou fornecer versões melhores.

Agora, lembre-se da classe Vowels no Exemplo 1. Ela não herda de abc.Sequence e implementa apenas __getitem__.

Não há um método __iter__, mas as instâncias de Vowels são iteráveis porque - como alternativa - se o Python encontra um método __getitem__, tenta iterar sobre o object chamando aquele método com índices inteiros começando de 0. Da mesma forma que o Python é esperto o suficiente para iterar sobre instâncias de Vowels, ele também consegue fazer o operador in funcionar mesmo quando o método __contains__ não existe: ele faz uma busca sequencial para verificar se o item está presente.

Em resumo, dada a importância de estruturas como a sequência, o Python consegue fazer a iteração e o operador in funcionarem invocando __getitem__ quando __iter__ e __contains__ não estão presentes.

O FrenchDeck original de Capítulo 1 também não é subclasse de abc.Sequence, mas ele implementa os dois métodos do protocolo de sequência: __getitem__ e __len__. Veja o Exemplo 2.

Exemplo 2. Um deque como uma sequência de cartas (igual ao Exemplo 1)
import collections

Card = collections.namedtuple('Card', ['rank', 'suit'])

class FrenchDeck:
    ranks = [str(n) for n in range(2, 11)] + list('JQKA')
    suits = 'spades diamonds clubs hearts'.split()

    def __init__(self):
        self._cards = [Card(rank, suit) for suit in self.suits
                                        for rank in self.ranks]

    def __len__(self):
        return len(self._cards)

    def __getitem__(self, position):
        return self._cards[position]

Muitos dos exemplos no Capítulo 1 funcionam por causa do tratamento especial que o Python dá a qualquer estrutura vagamente semelhante a uma sequência. O protocolo iterável em Python representa uma forma extrema de duck typing: o interpretador tenta dois métodos diferentes para iterar sobre objetos.

Para deixar mais claro, os comportamentos que que descrevi nessa seção estão implementados no próprio interpretador, na maioria dos casos em C. Eles não dependem dos métodos da ABC Sequence. Por exemplo, os métodos concretos __iter__ e __contains__ na classe Sequence emulam comportamentos internos do interpretador Python. Se tiver curiosidade, veja o código-fonte destes métodos em Lib/_collections_abc.py.

Agora vamos estudar outro exemplo que enfatiza a natureza dinâmica dos protocolos - e mostra porque verificadores de tipo estáticos não tem como lidar com eles.

13.4.2. Monkey patching: Implementando um Protocolo durante a Execução

Monkey patching é a ação de modificar dinamicamente um módulo, uma classe ou uma função durante a execução do código, para acrescentar funcionalidade ou corrigir bugs. Por exemplo, a biblioteca de rede gevent faz um "monkey patch" em partes da biblioteca padrão do Python, para permitir concorrência com baixo impacto, sem threads ou async/await.[142]

A classe FrenchDeck do Exemplo 2 não tem uma funcionalidade essencial: ela não pode ser embaralhada. Anos atrás, quando escrevi pela primeira vez o exemplo FrenchDeck, implementei um método shuffle. Depois tive um insight pythônico: se um FrenchDeck age como uma sequência, então ele não precisa de seu próprio método shuffle, pois já existe um random.shuffle, documentado como "Embaralha a sequência x internamente."

A função random.shuffle padrão é usada assim:

>>> from random import shuffle
>>> l = list(range(10))
>>> shuffle(l)
>>> l
[5, 2, 9, 7, 8, 3, 1, 4, 0, 6]
👉 Dica

Quando você segue protocolos estabelecidos, você melhora suas chances de aproveitar o código já existente na biblioteca padrão e em bibliotecas de terceiros, graças ao duck typing.

Entretanto, se tentamos usar shuffle com uma instância de FrenchDeck ocorre uma exceção, como visto no Exemplo 3.

Exemplo 3. random.shuffle cannot handle FrenchDeck
>>> from random import shuffle
>>> from frenchdeck import FrenchDeck
>>> deck = FrenchDeck()
>>> shuffle(deck)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File ".../random.py", line 265, in shuffle
    x[i], x[j] = x[j], x[i]
TypeError: 'FrenchDeck' object does not support item assignment

A mensagem de erro é clara: O objeto 'FrenchDeck' não suporta a atribuição de itens. O problema é que shuffle opera internamente, trocando os itens de lugar dentro da coleção, e FrenchDeck só implementa o protocolo de sequência imutável. Sequências mutáveis precisam também oferecer um método __setitem__.

Como o Python é dinâmico, podemos consertar isso durante a execução, até mesmo no console interativo. O Exemplo 4 mostra como fazer isso.

Exemplo 4. "Monkey patching" o FrenchDeck para torná-lo mutável e compatível com random.shuffle (continuação do Exemplo 3)
>>> def set_card(deck, position, card):  (1)
...     deck._cards[position] = card
...
>>> FrenchDeck.__setitem__ = set_card  (2)
>>> shuffle(deck)  (3)
>>> deck[:5]
[Card(rank='3', suit='hearts'), Card(rank='4', suit='diamonds'), Card(rank='4',
suit='clubs'), Card(rank='7', suit='hearts'), Card(rank='9', suit='spades')]
  1. Cria uma função que recebe deck, position, e card como argumentos.

  2. Atribui aquela função a um atributo chamado __setitem__ na classe FrenchDeck.

  3. deck agora pode ser embaralhado, pois acrescentei o método necessário do protocolo de sequência mutável.

A assinatura do método especial __setitem__ está definida na A Referência da Linguagem Python em "3.3.6. Emulando de tipos contêineres". Aqui nomeei os argumentos deck, position, card—e não self, key, value como na referência da linguagem - para mostrar que todo método Python começa sua vida como uma função comum, e nomear o primeiro argumento self é só uma convenção. Isso está bom para uma sessão no console, mas em um arquivo de código-fonte de Python é muito melhor usar self, key, e value, seguindo a documentação.

O truque é que set_card sabe que o deck tem um atributo chamado cards, e _cards tem que ser uma sequência mutável. A função set_cards é então anexada à classe FrenchDeck class como o método especial __setitem__. Isso é um exemplo de _monkey patching: modificar uma classe ou módulo durante a execução, sem tocar no código finte. O "monkey patching" é poderoso, mas o código que efetivamente executa a modificação está muito intimamente ligado ao programa sendo modificado, muitas vezes trabalhando com atributos privados e não-documentados.

Além de ser um exemplo de "monkey patching", o Exemplo 4 enfatiza a natureza dinâmica dos protocolos no duck typing dinâmico: random.shuffle não se importa com a classe do argumento, ela só precisa que o objeto implemente métodos do protocolo de sequência mutável. Não importa sequer se o objeto "nasceu" com os métodos necessários ou se eles foram de alguma forma adquiridos depois.

O duck typing não precisa ser loucamente inseguro ou difícil de depurar e manter. A próxima seção mostra alguns padrões de programação úteis para detectar protocolos dinâmicos sem recorrer a verificações explícitas.

13.4.3. Programação defensiva e "falhe rápido"

Programação defensiva é como direção defensiva: um conjunto de práticas para melhorar a segurança, mesmo quando defrontando programadores (ou motoristas) negligentes.

Muitos bugs não podem ser encontrados exceto durante a execução - mesmo nas principais linguagens de tipagem estática.[143] Em uma linguagem de tipagem dinâmica, "falhe rápido" é um conselho excelente para gerar programas mais seguros e mais fáceis de manter. Falhar rápido significa provocar erros de tempo de execução o mais cedo possível. Por exemplo, rejeitando argumentos inválidos no início do corpo de uma função.

Aqui está um exemplo: quando você escreve código que aceita uma sequência de itens para processar internamente como uma list, não imponha um argumento list através de checagem de tipo. Em vez disso, receba o argumento e construa imediatamente uma list a partir dele. Um exemplo desse padrão de programação é o método __init__ no Exemplo 10, visto mais à frente nesse capítulo:

    def __init__(self, iterable):
        self._balls = list(iterable)

Dessa forma você torna seu código mais flexível, pois o construtor de list() processa qualquer iterável que caiba na memória. Se o argumento não for iterável, a chamada vai falhar rapidamente com uma exceção de TypeError bastante clara, no exato momento em que o objeto for inicializado. Se você quiser ser mais explícito, pode envelopar a chamada a list() em um try/except, para adequar a mensagem de erro - mas eu usaria aquele código extra apenas em uma API externa, pois o problema ficaria mais visível para os mantenedores da base de código. De toda forma, a chamada errônea vai aparecer perto do final do traceback, tornando-a fácil de corrigir. Se você não barrar o argumento inválido no construtor da classe, o programa vai quebrar mais tarde, quando algum outro método da classe precisar usar a variável self.balls e ela não for uma list. Então a causa primeira do problema será mais difícil de encontrar.

Naturalmente, seria ruim passar o argumento para list() se os dados não devem ser copiados, ou por seu tamanho ou porque quem chama a função, por projeto, espera que os itens sejam modificados internamente, como no caso de random.shuffle. Neste caso, uma verificação durante a execução como isinstance(x, abc.MutableSequence) seria a melhor opção,

Se você estiver com receio de produzir um gerador infinito - algo que não é um problema muito comum - pode começar chamando len() com o argumento. Isso rejeitaria iteradores, mas lidaria de forma segura com tuplas, arrays e outras classes existentes ou futuras que implementem a interface Sequence completa. Chamar len() normalmente não custa muito, e um argumento inválido gerará imediatamente um erro.

Por outro lado, se um iterável for aceitável, chame iter(x) assim que possível, para obter um iterador, como veremos na Seção 17.3. E novamente, se x não for iterável, isso falhará rapidamente com um exceção fácil de depurar.

Nos casos que acabei de descrever, uma dica de tipo poderia apontar alguns problemas mais cedo, mas não todos os problemas. Lembre-se que o tipo Any é consistente-com qualquer outro tipo. Inferência de tipo pode fazer com que uma variável seja marcada com o tipo Any. Quando isso acontece, o verificador de tipo se torna inútil. Além disso, dicas de tipo não são aplicadas durante a execução. Falhar rápido é a última linha de defesa.

Código defensivo usando tipos "duck" também podem incluir lógica para lidar com tipos diferentes sem usar testes com isinstance() e hasattr().

Um exemplo é como poderíamos emular o modo como collections.namedtuple lida com o argumento field_names: field_names aceita um única string, com identificadores separados por espaços ou vírgulas, ou uma sequência de identificadores. O Exemplo 5 mostra como eu faria isso usando duck typing.

Exemplo 5. Duck typing para lidar com uma string ou um iterável de strings
    try:  (1)
        field_names = field_names.replace(',', ' ').split()  (2)
    except AttributeError:  (3)
        pass  (4)
    field_names = tuple(field_names)  (5)
    if not all(s.isidentifier() for s in field_names):  (6)
        raise ValueError('field_names must all be valid identifiers')
  1. Supõe que é uma string (MFPP - mais fácil pedir perdão que permissão).

  2. Converte vírgulas em espaços e divide o resultado em uma lista de nomes.

  3. Desculpe, field_names não grasna como uma str: não tem .replace, ou retorna algo que não conseguimos passar para .split

  4. Se um AttributeError aconteceu, então field_names não é uma str. Supomos que já é um iterável de nomes.

  5. Para ter certeza que é um iterável e para manter nossas própria cópia, criamos uma tupla com o que temos. Uma tuple é mais compacta que uma list, e também impede que meu código troque os nomes por engano.

  6. Usamos str.isidentifier para se assegurar que todos os nomes são válidos.

O Exemplo 5 mostra uma situação onde o duck typing é mais expressivo que dicas de tipo estáticas. Não há como escrever uma dica de tipo que diga "`field_names` deve ser uma string de identificadores separados por espaços ou vírgulas." Essa é a parte relevante da assinatura de namedtuple no typeshed (veja o código-fonte completo em stdlib/3/collections/__init__.pyi):

    def namedtuple(
        typename: str,
        field_names: Union[str, Iterable[str]],
        *,
        # rest of signature omitted

Como se vê, field_names está anotado como Union[str, Iterable[str]], que serve para seus propósitos, mas não é suficiente para evitar todos os problemas possíveis.

Após revisar protocolos dinâmicos, passamos para uma forma mais explícita de checagem de tipo durante a execução: goose typing.

13.5. Goose typing

Uma classe abstrata representa uma interface.

Bjarne Stroustrup, criador do C++. Bjarne Stroustrup, The Design and Evolution of C++, p. 278 (Addison-Wesley).

O Python não tem uma palavra-chave interface. Usamos classes base abstratas (ABCs) para definir interfaces passíveis de checagem explícita de tipo durante a execução - também suportado por verificadores de tipo estáticos.

O verbete para classe base abstrata no Glossário da Documentação do Python tem uma boa explicação do valor dessas estruturas para linguagens que usam duck typing:

Classes bases abstratas complementam [a] tipagem pato, fornecendo uma maneira de definir interfaces quando outras técnicas, como hasattr(), seriam desajeitadas ou sutilmente erradas (por exemplo, com métodos mágicos). CBAs introduzem subclasses virtuais, classes que não herdam de uma classe mas ainda são reconhecidas por isinstance() e issubclass(); veja a documentação do módulo abc.[144]

A goose typing é uma abordagem à checagem de tipo durante a execução que se apoia nas ABCs. Vou deixar que Alex Martelli explique, no Pássaros aquáticos e as ABCs.

✒️ Nota

Eu sou muito agradecido a meus amigos Alex MArtekli e Anna Ravenscroft. Mostrei a eles o primeiro rescunho do Python Fluente na OSCON 2013, e eles me encorajaram a submeter à O’Reilly para publicação. Mais tarde os dois contribuíram com revisões técnicas minuciosas. Alex já era a pessoa mais citada nesse livro, e então se ofereceu para escrever esse ensaio. Segue daí, Alex!

Pássaros aquáticos e as ABCs

By Alex Martelli

Eu recebi créditos na Wikipedia por ter ajudado a popularizar o útil meme e a frase de efeito "duck typing" (isto é, ignorar o tipo efetivo de um objeto, e em vez disso se dedicar a assegurar que o objeto implementa os nomes, assinaturas e semântica dos métodos necessários para o uso pretendido).

Em Python, isso essencialmente significa evitar o uso de isinstance para verificar o tipo do objeto (sem nem mencionar a abordagem ainda pior de verificar, por exemplo, se type(foo) is bar—que é corretamente considerado um anátema, pois inibe até as formas mais simples de herança!).

No geral, a abordagem da duck typing continua muito útil em inúmeros contextos - mas em muitos outros, um nova abordagem muitas vezes preferível evoluiu ao longo do tempo. E aqui começa nossa história…​

Em gerações recentes, a taxinomia de gênero e espécies (incluindo, mas não limitada à família de pássaros aquáticos conhecida como Anatidae) foi guiada principalmente pela fenética - uma abordagem focalizada nas similaridades de morfologia e comportamento…​ principalmente traços observáveis. A analogia com o "duck typing" era patente.

Entretanto, a evolução paralela muitas vezes pode produzir características similares, tanto morfológicas quanto comportamentais, em espécies sem qualquer relação de parentesco, que apenas calharam de evoluir em nichos ecológicos similares, porém separados. "Similaridades acidentais" parecidas acontecem também em programação - por exemplo, considere o [seguinte] exemplo clássico de programação orientada a objetos:

class Artist:
    def draw(self): ...

class Gunslinger:
    def draw(self): ...

class Lottery:
    def draw(self): ...

Obviamente, a mera existência de um método chamado draw, chamado sem argumentos, está longe de ser suficiente para garantir que dois objetos x e y, da forma como x.draw() e y.draw() podem ser chamados, são de qualquer forma intercambiáveis ou abstratamente equivalentes — nada sobre a similaridade da semântica resultante de tais chamadas pode ser inferido. Na verdade, é necessário um programador inteligente para, de alguma forma, assegurar positivamente que tal equivalência é verdadeira em algum nível.

Em biologia (e outras disciplinas), este problema levou à emergência (e, em muitas facetas, à dominância) de uma abordagem alternativa à fenética, conhecida como cladística — que baseia as escolhas taxinômicas em características herdadas de ancestrais comuns em vez daquelas que evoluíram de forma independente (o sequenciamento de DNA cada vez mais barato e rápido vem tornando a cladística bastante prática em mais casos).

Por exemplo, os Chloephaga, gênero de gansos sul-americanos (antes classificados como próximos a outros gansos) e as tadornas (gênero de patos sul-americanos) estão agora agrupados juntos na subfamília Tadornidae (sugerindo que eles são mais próximos entre si que de qualquer outro Anatidae, pois compartilham um ancestral comum mais próximo). Além disso, a análise de DNA mostrou que o Asarcornis (pato da floresta ou pato de asas brancas) não é tão próximo do Cairina moschata (pato-do-mato), esse último uma tadorna, como as similaridades corporais e comportamentais sugeriram por tanto tempo - então o pato da floresta foi reclassificado em um gênero próprio, inteiramente fora da subfamília!

Isso importa? Depende do contexto! Para o propósito de decidir como cozinhar uma ave depois de caçá-la, por exemplo, características observáveis específicas (mas nem todas - a plumagem, por exemplo, é de mínima importância nesse contexto), especialmente textura e sabor (a boa e velha fenética), podem ser muito mais relevantes que a cladística. Mas para outros problemas, tal como a suscetibilidade a diferentes patógenos (se você estiver tentando criar aves aquáticas em cativeiro, ou preservá-las na natureza), a proximidade do DNA por ser muito mais crucial.

Então, a partir dessa analogia bem frouxa com as revoluções taxonômicas no mundo das aves aquáticas, estou recomendando suplementar (não substitui inteiramente - em determinados contexto ela ainda servirá) a boa e velha duck typing com…​ a goose typing (tipagem ganso)!

A goose typing significa o seguinte: isinstance(obj, cls) agora é plenamente aceitável…​ desde que cls seja uma classe base abstrata - em outras palavras, a metaclasse de cls é abc.ABCMeta.

Você vai encontrar muitas classes abstratas prontas em collections.abc (e outras no módulo numbers da Biblioteca Padrão do Python)[145]

Dentre as muitas vantagens conceituais das ABCs sobre classes concretas (e.g., Scott Meyer’s “toda classe não-final ("não-folha") deveria ser abstrata”; veja o Item 33 de seu livro, More Effective C++, Addison-Wesley), as ABCs do Python acrescentam uma grande vantagem prática: o método de classe register, que permite ao código do usuário final "declarar" que determinada classe é uma subclasse "virtual" de uma ABC (para este propósito, a classe registrada precisa cumprir os requerimentos de nome de métodos e assinatura da ABC e, mais importante, o contrato semântico subjacente - mas não precisa ter sido desenvolvida com qualquer conhecimento da ABC, e especificamente não precisa herdar dela!). Isso é um longo caminho andado na direção de quebrar a rigidez e o acoplamento forte que torna herança algo para ser usado com muito mais cautela que aquela tipicamente praticada pela maioria do programadores orientados a .objetos.

Em algumas ocasiões você sequer precisa registrar uma classe para que uma ABC a reconheça como uma subclasse!

Esse é o caso para as ABCs cuja essência se resume em alguns métodos especiais. Por exemplo:

>>> class Struggle:
...     def __len__(self): return 23
...
>>> from collections import abc
>>> isinstance(Struggle(), abc.Sized)
True

Como se vê, abc.Sized reconhece Struggle como uma subclasse, sem necessidade de registro, já que implementar o método especial chamado __len__ é o suficiente (o método deve ser implementado com a sintaxe e semântica corretas - deve poder ser chamado sem argumentos e retornar um inteiro não-negativo indicando o "comprimento" do objeto; mas qualquer código que implemente um método com nome especial, como __len__, com uma sintaxe e uma semântica arbitrárias e incompatíveis tem problemas muitos maiores que esses).

Então, aqui está minha mensagem de despedida: sempre que você estiver implementando uma classe que incorpore qualquer dos conceitos representados nas ABCs de number, collections.abc ou em outro framework que estiver usando, se assegure (caso necessário) de ser uma subclasse ou de registrar sua classe com a ABC correspondente. No início de seu programa usando uma biblioteca ou framework que definam classes que omitiram esse passo, registre você mesmo as classes. Daí, quando precisar verificar se (tipicamente) um argumento é, por exemplo, "uma sequência", verifique se:

isinstance(the_arg, collections.abc.Sequence)

E não defina ABCs personalizadas (ou metaclasses) em código de produção. Se você sentir uma forte necessidade de fazer isso, aposto que é um caso da síndrome de "todos os problemas se parecem com um prego" em alguém que acabou de ganhar um novo martelo brilhante - você ( e os futuros mantenedores de seu código) serão muito mais felizes se limitando a código simples e direto, e evitando tais profundezas. Valē!

Em resumo, goose typing implica:

  • Criar subclasses de ABCs, para tornar explícito que você está implementando uma interface previamente definida.

  • Checagem de tipo durante a execução usando as ABCs em vez de classes concretas como segundo argumento para isinstance e issubclass.

Alex também aponta que herdar de uma ABC é mais que implementar os métodos necessários: é também uma declaração de intenções clara da parte do desenvolvedor. A intenção também pode ficar explícita através do registro de uma subclasse virtual.

✒️ Nota

Detalhes sobre o uso de register são tratados em Seção 13.5.6, mais adiante nesse mesmo capítulo. Por hora, aqui está um pequeno exemplo: dada a classe FrenchDeck, se eu quiser que ela passe em uma verificação como (FrenchDeck, Sequence), posso torná-la uma subclasse virtual da ABC Sequence com as seguintes linhas:

from collections.abc import Sequence
Sequence.register(FrenchDeck)

O uso de isinstance e issubclass se torna mais aceitável se você está verificando ABCs em vez de classes concretas. Se usadas com classes concretas, verificações de tipo limitam o polimorfismo - um recurso essencial da programação orientada a objetos. Mas com ABCs esses testes são mais flexíveis. Afinal, se um componente não implementa uma ABC sendo uma subclasse - mas implementa os métodos necessários - ele sempre pode ser registrado posteriormente e passar naquelas verificações de tipo explícitas.

Entretanto, mesmo com ABCs, você deve se precaver contra o uso excessivo de verificações com isinstance, pois isso poder ser um code smell— sintoma de um design ruim.

Normalmente não é bom ter uma série de if/elif/elif com verificações de isinstance executando ações diferentes, dependendo do tipo de objeto: nesse caso você deveria estar usando polimorfismo - isto é, projetando suas classes para permitir ao interpretador enviar chamadas para os métodos corretos, em vez de codificar diretamente a lógica de envio em blocos if/elif/elif.

Por outro lado, não há problema em executar uma verificação com isinstance contra uma ABC se você quer garantir um contrato de API: "Cara, você tem que implementar isso se quiser me chamar," como costuma dizer o revisor técnico Lennart Regebro. Isso é especialmente útil em sistemas com arquitetura plug-in. Fora dos frameworks, duck typing é muitas vezes mais simples e flexível que verificações de tipo.

Por fim, em seu ensaio Alex reforça mais de uma vez a necessidade de coibir a criação de ABCs. Uso excessivo de ABCs imporia cerimônia a uma linguagem que se tornou popular por ser prática e pragmática. Durante o processo de revisão do Python Fluente, Alex me enviou uma email:

ABCs servem para encapsular conceitos muito genéricos, abstrações introduzidos por um framework - coisa como "uma sequência" e "um número exato". [Os leitores] quase certamente não precisam escrever alguma nova ABC, apenas usar as já existentes de forma correta, para obter 99% dos benefícios sem qualquer risco sério de design mal-feito.

Agora vamos ver a goose typing na prática.

13.5.1. Criando uma Subclasse de uma ABC

Seguindo o conselho de Martelli, vamos aproveitar uma ABC existente, collections.MutableSequence, antes de ousar inventar uma nova. No Exemplo 6, FrenchDeck2 é explicitamente declarada como subclasse de collections.MutableSequence.

Exemplo 6. frenchdeck2.py: FrenchDeck2, uma subclasse de collections.MutableSequence
from collections import namedtuple, abc

Card = namedtuple('Card', ['rank', 'suit'])

class FrenchDeck2(abc.MutableSequence):
    ranks = [str(n) for n in range(2, 11)] + list('JQKA')
    suits = 'spades diamonds clubs hearts'.split()

    def __init__(self):
        self._cards = [Card(rank, suit) for suit in self.suits
                                        for rank in self.ranks]

    def __len__(self):
        return len(self._cards)

    def __getitem__(self, position):
        return self._cards[position]

    def __setitem__(self, position, value):  # (1)
        self._cards[position] = value

    def __delitem__(self, position):  # (2)
        del self._cards[position]

    def insert(self, position, value):  # (3)
        self._cards.insert(position, value)
  1. __setitem__ é tudo que precisamos para possibilitar o embaralhamento…​

  2. …​mas uma subclasse de MutableSequence é forçada a implementar __delitem__, um método abstrato daquela ABC.

  3. Também precisamos implementar insert, o terceiro método abstrato de MutableSequence.

O Python não verifica a implementação de métodos abstratos durante a importação (quando o módulo frenchdeck2.py é carregado na memória e compilado), mas apenas durante a execução, quando nós tentamos de fato instanciar FrenchDeck2. Ali, se deixamos de implementar qualquer dos métodos abstratos, recebemos uma exceção de TypeError com uma mensagem como "Can't instantiate abstract class FrenchDeck2 with abstract methods __delitem__, insert" ("Impossível instanciar a classe abstrata FrenchDeck2 com os métodos abstratos __delitem__, insert"). Por isso precisamos implementar __delitem__ e insert, mesmo se nossos exemplos usando FrenchDeck2 não precisem desses comportamentos: a ABC MutableSequence os exige.

Como Figura 3 mostra, nem todos os métodos das ABCs Sequence e MutableSequence ABCs são abstratos.

Diagrama de classe UML para `Sequence` e `MutableSequence`
Figura 3. Diagrama de classe UML para a ABC MutableSequence e suas superclasses em collections.abc (as setas de herança apontam das subclasses para as ancestrais; nomes em itálico são classes e métodos abstratos).

Para escrever FrenchDeck2 como uma subclasse de MutableSequence, tive que pagar o preço de implementar __delitem__ e insert, desnecessários em meus exemplos. Em troca, FrenchDeck2 herda cinco métodos concretos de Sequence: __contains__, __iter__, __reversed__, index, e count. De MutableSequence, ela recebe outros seis métodos: append, reverse, extend, pop, remove, e __iadd__— que suporta o operador += para concatenação direta.

Os métodos concretos em cada ABC de collections.abc são implementados nos termos da interface pública da classe, então funcionam sem qualquer conhecimento da estrutura interna das instâncias.

👉 Dica

Como programador de uma subclasse concreta, é possível sobrepor os métodos herdados das ABCs com implementações mais eficientes. Por exemplo, __contains__ funciona executando uma busca sequencial, mas se a sua classe de sequência mantém os itens ordenados, você pode escrever um __contains__ que executa uma busca binária usando a função bisect da biblioteca padrão.

Veja "Managing Ordered Sequences with Bisect" (EN) em fluentpython.com para conhecer mais sobre esse método.

Para usar bem as ABCs, você precisa saber o que está disponível. Vamos então revisar as ABCs de collections a seguir.

13.5.2. ABCs na Biblioteca Padrão

Desde o Python 2.6, a biblioteca padrão oferece várias ABCs. A maioria está definida no módulo collections.abc, mas há outras. Você pode encontrar ABCs nos pacotes io e numbers, por exemplo. Mas a maioria das mais usadas estão em collections.abc.

👉 Dica

Há dois módulos chamados abc na biblioteca padrão. Aqui nós estamos falando sobre o collections.abc. Para reduzir o tempo de carregamento, desde o Python 3.4 aquele módulo é implementado fora do pacote collections — em Lib/_collections_abc.py — então é importado separado de collections. O outro módulo abc é apenas abc (i.e., Lib/abc.py), onde a classe abc.ABC é definida. Toda ABC depende do módulo abc, mas não precisamos importá-lo nós mesmos, exceto para criar um nova ABC.

A Figura 4 é um diagrama de classe resumido (sem os nomes dos atributos) das 17 ABCs definidas em collections.abc. A documentação de collections.abc inclui uma ótima tabela resumindo as ABCs, suas relações e seus métodos abstratos e concretos (chamados "métodos mixin"). Há muita herança múltipla acontecendo na Figura 4. Vamos dedicar a maior parte de [herança] à herança múltipla, mas por hora é suficiente dizer que isso normalmente não causa problemas no caso das ABCs.[146]

UML for collections.abc
Figura 4. Diagrama de classes UML para as ABCs em collections.abc.

Vamos revisar os grupos em Figura 4:

Iterable, Container, Sized

Toda coleção deveria ou herdar dessas ABCs ou implementar protocolos compatíveis. Iterable oferece iteração com __iter__, Container oferece o operador in com __contains__, e Sized oferece len() with __len__.

Collection

Essa ABC não tem nenhum método próprio, mas foi acrescentada no Python 3.6 para facilitar a criação de subclasses de Iterable, Container, e Sized.

Sequence, Mapping, Set

Esses são os principais tipos de coleções imutáveis, e cada um tem uma subclasse mutável. Um diagrama detalhado de MutableSequence é apresentado em Figura 3; para MutableMapping e MutableSet, veja as Figuras #mapping_uml e #set_uml em Capítulo 3.

MappingView

No Python 3, os objetos retornados pelos métodos de mapeamentos .items(), .keys(), e .values() implementam as interfaces definidas em ItemsView, KeysView, e ValuesView, respectivamente. Os dois primeiros também implementam a rica interface de Set, com todos os operadores que vimos na Seção 3.11.1.

Iterator

Observe que iterator é subclasse de Iterable. Discutimos melhor isso adiante, em Capítulo 17.

Callable, Hashable

Essas não são coleções, mas collections.abc foi o primeiro pacote a definir ABCs na biblioteca padrão, e essas duas foram consideradas importante o suficiente para serem incluídas. Elas suportam a verificação de tipo de objetos que precisam ser "chamáveis" ou hashable.

Para a detecção de 'callable', a função nativa callable(obj) é muito mais conveniente que insinstance(obj, Callable).

Se insinstance(obj, Hashable) retornar False, você pode ter certeza que obj não é hashable. Mas se ela retornar True, pode ser um falso positivo. Isso é explicado no box seguinte.

isinstance com Hashable e Iterable pode enganar você

É fácil interpretar errado os resultados de testes usando isinstance e issubclass com as ABCs Hashable and Iterable. Se isinstance(obj, Hashable) retorna True, is significa apenas que a classe de obj implementa ou herda __hash__. Mas se obj é uma tupla contendo itens unhashable, então obj não é hashable, apesar do resultado positivo da verificação com isinstance. O revisor técnico Jürgen Gmach esclareceu que o duck typing fornece a forma mais precisa de determinar se uma instância é hashable: chamar hash(obj). Essa chamada vai levantar um TypeError se obj não for hashable.

Por outro lado, mesmo quando isinstance(obj, Iterable) retorna False, o Python ainda pode ser capaz de iterar sobre obj usando __getitem__ com índices baseados em 0, como vimos em Capítulo 1 e na Seção 13.4.1. A documentação de collections.abc.Iterable afirma:

A única maneira confiável de determinar se um objeto é iterável é chamar iter(obj).

Após vermos algumas das ABCs existentes, vamos praticar goose typing implementando uma ABC do zero, e a colocando em uso. O objetivo aqui não é encorajar todo mundo a ficar criando ABCs a torto e a direito, mas aprender como ler o código-fonte das ABCs encontradas na biblioteca padrão e em outros pacotes.

13.5.3. Definindo e usando uma ABC

Essa advertência estava no capítulo "Interfaces" da primeira edição de Python Fluente:

ABCs, como os descritores e as metaclasses, são ferramentas para criar frameworks, Assim, apenas uma pequena minoria dos desenvolvedores Python podem criar ABCs sem impor limitações pouco razoáveis e trabalho desnecessário a seus colegas programadores.

Agora ABCs tem mais casos de uso potenciais, em dicas de tipo para permitir tipagem estática. Como discutido na Seção 8.5.7, usar ABCs em vez de tipo concretos em dicas de tipos de argumentos de função dá mais flexibilidade a quem chama a função.

Para justificar a criação de uma ABC, precisamos pensar em um contexto para usá-la como um ponto de extensão em um framework. Então aqui está nosso contexto: imagine que você precisa exibir publicidade em um site ou em uma app de celular, em ordem aleatória, mas sem repetir um anúncio antes que o inventário completo de anúncios tenha sido exibido. Agora vamos presumir que estamos desenvolvendo um gerenciador de publicidade chamado ADAM. Um dos requerimentos é permitir o uso de classes de escolha aleatória não repetida fornecidas pelo usuário.[147] Para deixar claro aos usuário do ADAM o que se espera de um componente de "escolha aleatória não repetida", vamos definir uma ABC.

Na bibliografia sobre estruturas de dados, "stack" e "queue" descrevem interfaces abstratas em termos dos arranjos físicos dos objetos. Vamos seguir o mesmo caminho e usar uma metáfora do mundo real para batizar nossa ABC: gaiolas de bingo e sorteadores de loteria são máquinas projetadas para escolher aleatoriamente itens de um conjunto, finito sem repetições, até o conjunto ser exaurido. Vamos chamar a ABC de Tombola, seguindo o nome italiano do bingo, e do recipiente giratório que mistura os números.

A ABC Tombola tem quatro métodos. Os dois métodos abstratos são:

.load(…)

Coloca itens no container.

.pick()

Remove e retorna um item aleatório do container.

Os métodos concretos são:

.loaded()

Retorna True se existir pelo menos um item no container.

.inspect()

Retorna uma tuple construída a partir dos itens atualmente no container, sem modificar o conteúdo (a ordem interna não é preservada).

A Figura 5 mostra a ABC Tombola e três implementações concretas.

UML for Tombola
Figura 5. Diagrama UML para uma ABC e três subclasses. O nome da ABC Tombola e de seus métodos abstratos estão escritos em itálico, segundo as convenções da UML. A seta tracejada é usada para implementações de interface - as estou usando aqui para mostrar que TomboList implementa não apenas a interface Tombola, mas também está registrada como uma subclasse virtual de Tombola - como veremos mais tarde nesse capítulo.«registrada» and «subclasse virtual» não são termos da UML padrão. Estão sendo usados para representar uma relação de classe específica do Python.

O Exemplo 7 mostra a definição da ABC Tombola.

Exemplo 7. tombola.py: Tombola é uma ABC com dois métodos abstratos e dois métodos concretos.
import abc

class Tombola(abc.ABC):  # (1)

    @abc.abstractmethod
    def load(self, iterable):  # (2)
        """Add items from an iterable."""

    @abc.abstractmethod
    def pick(self):  # (3)
        """Remove item at random, returning it.

        This method should raise `LookupError` when the instance is empty.
        """

    def loaded(self):  # (4)
        """Return `True` if there's at least 1 item, `False` otherwise."""
        return bool(self.inspect())  # (5)

    def inspect(self):
        """Return a sorted tuple with the items currently inside."""
        items = []
        while True:  # (6)
            try:
                items.append(self.pick())
            except LookupError:
                break
        self.load(items)  # (7)
        return tuple(items)
  1. Para definir uma ABC, crie uma subclasse de abc.ABC.

  2. Um método abstrato é marcado com o decorador @abstractmethod, e muitas vezes seu corpo é vazio, exceto por uma docstring.[148]

  3. A docstring instrui os implementadores a levantarem LookupError se não existirem itens para escolher.

  4. Uma ABC pode incluir métodos concretos.

  5. Métodos concretos em uma ABC devem depender apenas da interface definida pela ABC (isto é, outros métodos concretos ou abstratos ou propriedades da ABC).

  6. Não sabemos como as subclasses concretas vão armazenar os itens, mas podemos escrever o resultado de inspect esvaziando a Tombola com chamadas sucessivas a .pick()…​

  7. …​e então usando .load(…) para colocar tudo de volta.

👉 Dica

Um método abstrato na verdade pode ter uma implementação. Mas mesmo que tenha, as subclasses ainda são obrigadas a sobrepô-lo, mas poderão invocar o método abstrato com super(), acrescentando funcionalidade em vez de implementar do zero. Veja a documentação do módulo abc para os detalhes do uso de @abstractmethod.

O código para o método .inspect() é simplório, mas mostra que podemos confiar em .pick() e .load(…) para inspecionar o que está dentro de Tombola, puxando e devolvendo os itens - sem saber como eles são efetivamente armazenados. O objetivo desse exemplo é ressaltar que não há problema em oferecer métodos concretos em ABCs, desde que eles dependam apenas de outros métodos na interface. Conhecendo suas estruturas de dados internas, as subclasses concretas de Tombola podem sempre sobrepor .inspect() com uma implementação mais adequada, mas não são obrigadas a fazer isso.

O método .loaded() no Exemplo 7 tem uma linha, mas é custoso: ele chama .inspect() para criar a tuple apenas para aplicar bool() nela. Funciona, mas subclasses concretas podem fazer bem melhor, como veremos.

Observe que nossa implementação tortuosa de .inspect() exige a captura de um LookupError lançado por self.pick(). O fato de self.pick() poder disparar um LookupError também é parte de sua interface, mas não há como tornar isso explícito em Python, exceto na documentação (veja a docstring para o método abstrato pick no Exemplo 7).

Eu escolhi a exceção LookupError por sua posição na hierarquia de exceções em relação a IndexError e KeyError, as exceções mais comuns de ocorrerem nas estruturas de dados usadas para implementar uma Tombola concreta. Dessa forma, as implementações podem lançar LookupError, IndexError, KeyError, ou uma subclasse personalizada de LookupError para atender à interface. Veja a Figura 6.

Árvore de ponta cabeça, com BaseException no topo, e quatro ramos principais, incluindo Exception.
Figura 6. Parte da hierarquia da classe Exception.[149]

LookupError é a exceção que tratamos em Tombola.inspect.

IndexError é a subclasse de LookupError gerada quando tentamos acessar um item em uma sequência usando um índice além da última posição.

KeyError ocorre quando usamos uma chave inexistente para acessar um item em um mapeamento (dict etc.).

Agora temos nossa própria ABC Tombola. Para observar a checagem da interface feita por uma ABC, vamos tentar enganar Tombola com uma implementação defeituosa no Exemplo 8.

Exemplo 8. Uma Tombola falsa não passa desapercebida
>>> from tombola import Tombola
>>> class Fake(Tombola):  # (1)
...     def pick(self):
...         return 13
...
>>> Fake  # (2)
<class '__main__.Fake'>
>>> f = Fake()  # (3)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: Can't instantiate abstract class Fake with abstract method load
  1. Declara Fake como subclasse de Tombola.

  2. A classe é criada, nenhum erro até agora.

  3. Um TypeError é sinalizado quando tentamos instanciar Fake. A mensagem é bastante clara: Fake é considerada abstrata porque deixou de implementar load, um dos métodos abstratos declarados na ABC Tombola.

Então definimos nossa primeira ABC, e a usamos para validar uma classe. Logo vamos criar uma subclasse de Tombola, mas primeiro temos que falar sobre algumas regras para a programação de ABCs.

13.5.4. Detalhes da Sintaxe das ABCs

A forma padrão de declarar uma ABC é criar uma subclasse de abc.ABC ou de alguma outra ABC.

Além da classe base ABC e do decorador @abstractmethod, o módulo abc define os decoradores @abstractclassmethod, @abstractstaticmethod, and @abstractproperty. Entretanto, os três últimos foram descontinuados no Python 3.3, quando se tornou possível empilhar decoradores sobre @abstractmethod, tornando os outros redundantes. Por exemplo, a maneira preferível de declarar um método de classe abstrato é:

class MyABC(abc.ABC):
    @classmethod
    @abc.abstractmethod
    def an_abstract_classmethod(cls, ...):
        pass
⚠️ Aviso

A ordem dos decoradores de função empilhados importa, e no caso de @abstractmethod, a documentação é explícita:

Quando @abstractmethod é aplicado em combinação com outros descritores de método, ele deve ser aplicado como o decorador mais interno…​[150]

Em outras palavras, nenhum outro decorador pode aparecer entre @abstractmethod e o comando def.

Agora que abordamos essas questões de sintaxe das ABCs, vamos colocar Tombola em uso, implementando dois descendentes concretos dessa classe.

13.5.5. Criando uma subclasse de ABC

Dada a ABC Tombola, vamos agora desenvolver duas subclasses concretas que satisfazem a interface. Essas classes estão ilustradas na Figura 5, junto com a subclasse virtual que será discutida na seção seguinte.

A classe BingoCage no Exemplo 9 é uma variação da Exemplo 8 usando um randomizador melhor. BingoCage implementa os métodos abstratos obrigatórios load e pick.

Exemplo 9. bingo.py: BingoCage é uma subclasse concreta de Tombola
import random

from tombola import Tombola


class BingoCage(Tombola):  # (1)

    def __init__(self, items):
        self._randomizer = random.SystemRandom()  # (2)
        self._items = []
        self.load(items)  # (3)

    def load(self, items):
        self._items.extend(items)
        self._randomizer.shuffle(self._items)  # (4)

    def pick(self):  # (5)
        try:
            return self._items.pop()
        except IndexError:
            raise LookupError('pick from empty BingoCage')

    def __call__(self):  # (6)
        self.pick()
  1. Essa classe BingoCage estende Tombola explicitamente.

  2. Finja que vamos usar isso para um jogo online. random.SystemRandom implementa a API random sobre a função os.urandom(…), que fornece bytes aleatórios "adequados para uso em criptografia", segundo a documentação do módulo os.

  3. Delega o carregamento inicial para o método .load()

  4. Em vez da função random.shuffle() normal, usamos o método .shuffle() de nossa instância de SystemRandom.

  5. pick é implementado como em Exemplo 8.

  6. __call__ também é de Exemplo 8. Ele não é necessário para satisfazer a interface de Tombola, mas não há nenhum problema em adicionar métodos extra.

BingoCage herda o custoso método loaded e o tolo inspect de Tombola. Ambos poderiam ser sobrepostos com métodos de uma linha muito mais rápidos, como no Exemplo 10. A questão é: podemos ser preguiçosos e escolher apenas herdar os método concretos menos que ideais de uma ABC. Os métodos herdados de Tombola não são tão rápidos quanto poderia ser em BingoCage, mas fornecem os resultados esperados para qualquer subclasse de Tombola que implemente pick e load corretamente.

O Exemplo 10 mostra uma implementação muito diferente mas igualmente válida da interface de Tombola. Em vez de misturar as "bolas" e tirar a última, LottoBlower tira um item de uma posição aleatória..

Exemplo 10. lotto.py: LottoBlower é uma subclasse concreta que sobrecarrega os métodos inspect e loaded de Tombola
import random

from tombola import Tombola


class LottoBlower(Tombola):

    def __init__(self, iterable):
        self._balls = list(iterable)  # (1)

    def load(self, iterable):
        self._balls.extend(iterable)

    def pick(self):
        try:
            position = random.randrange(len(self._balls))  # (2)
        except ValueError:
            raise LookupError('pick from empty LottoBlower')
        return self._balls.pop(position)  # (3)

    def loaded(self):  # (4)
        return bool(self._balls)

    def inspect(self):  # (5)
        return tuple(self._balls)
  1. O construtor aceita qualquer iterável: o argumento é usado para construir uma lista.

  2. a função random.randrange(…) levanta um ValueError se a faixa de valores estiver vazia, então capturamos esse erro e trocamos por LookupError, para ser compatível com Tombola.

  3. Caso contrário, o item selecionado aleatoriamente é retirado de self._balls.

  4. Sobrepõe loaded para evitar a chamada a inspect (como Tombola.loaded faz no Exemplo 7). Podemos fazer isso mais rápido rápida trabalhando diretamente com self._balls — não há necessidade de criar toda uma nova tuple.

  5. Sobrepõe inspect com uma linha de código.

O Exemplo 10 ilustra um idioma que vale a pena mencionar: em __init__, self._balls armazena list(iterable), e não apenas uma referência para iterable (isto é, nós não meramente atribuímos self._balls = iterable, apelidando o argumento). Como mencionado na Seção 13.4.3, isso torna nossa LottoBlower flexível, pois o argumento iterable pode ser de qualquer tipo iterável. Ao mesmo tempo, garantimos que os itens serão armazenados em uma list, da onde podemos pop os itens. E mesmo se nós sempre recebêssemos listas no argumento iterable, list(iterable) produz uma cópia do argumento, o que é uma boa prática, considerando que vamos remover itens dali, e o cliente pode não estar esperando que a lista passada seja modificada.[151]

Chegamos agora à característica dinâmica crucial da goose typing: declarar subclasses virtuais com o método register

13.5.6. Uma subclasse virtual de uma ABC

Uma característica essencial da goose typing - e uma razão pela qual ela merece um nome de ave aquática - é a habilidade de registrar uma classe como uma subclasse virtual de uma ABC, mesmo se a classe não herde da ABC. Ao fazer isso, prometemos que a classe implementa fielmente a interface definida na ABC - e o Python vai acreditar em nós sem checar. Se mentirmos, vamos ser capturados pelas exceções de tempo de execução conhecidas.

Isso é feito chamando um método de classe register da ABC, e será reconhecido assim por issubclass, mas não implica na herança de qualquer método ou atributo da ABC.

⚠️ Aviso

Subclasses virtuais não herdam da ABC na qual se registram, e sua conformidade com a interface da ABC nunca é checada, nem quando são instanciadas. E mais, neste momento verificadores de tipo estáticos não conseguem tratar subclasses virtuais. Mais detalhes em Mypy issue 2922—ABCMeta.register support.

O método register normalmente é invocado como uma função comum (veja Seção 13.5.7), mas também pode ser usado como decorador. No Exemplo 11, usamos a sintaxe de decorador e implementamos TomboList, uma subclasse virtual de Tombola, ilustrada em Figura 7.

UML for TomboList
Figura 7. Diagrama de classe UML para TomboList, subclasse real de list e subclassse virtual de Tombola.
Exemplo 11. tombolist.py: a classe TomboList é uma subclasse virtual de Tombola
from random import randrange

from tombola import Tombola

@Tombola.register  # (1)
class TomboList(list):  # (2)

    def pick(self):
        if self:  # (3)
            position = randrange(len(self))
            return self.pop(position)  # (4)
        else:
            raise LookupError('pop from empty TomboList')

    load = list.extend  # (5)

    def loaded(self):
        return bool(self)  # (6)

    def inspect(self):
        return tuple(self)

# Tombola.register(TomboList)  # (7)
  1. TomboList é registrada como subclasse virtual de Tombola.

  2. TomboList estende list.

  3. TomboList herda seu comportamento booleano de list, e isso retorna True se a lista não estiver vazia.

  4. Nosso pick chama self.pop, herdado de list, passando um índice aleatório para um item.

  5. TomboList.load é o mesmo que list.extend.

  6. loaded delega para bool.[152]

  7. É sempre possível chamar register dessa forma, e é útil fazer assim quando você precisa registrar uma classe que você não mantém, mas que implementa a interface.

Note que, por causa do registro, as funções issubclass e isinstance agem como se TomboList fosse uma subclasse de Tombola:

>>> from tombola import Tombola
>>> from tombolist import TomboList
>>> issubclass(TomboList, Tombola)
True
>>> t = TomboList(range(100))
>>> isinstance(t, Tombola)
True

Entretanto, a herança é guiada por um atributo de classe especial chamado __mro__—a Ordem de Resolução do Método (mro é a sigla de Method Resolution Order). Esse atributo basicamente lista a classe e suas superclasses na ordem que o Python usa para procurar métodos.[153] Se você inspecionar o __mro__ de TomboList, verá que ele lista apenas as superclasses "reais" - list e object:

>>> TomboList.__mro__
(<class 'tombolist.TomboList'>, <class 'list'>, <class 'object'>)

Tombola não está em TomboList.__mro__, então TomboList não herda nenhum método de Tombola.

Isso conclui nosso estudo de caso da ABC Tombola. Na próxima seção, vamos falar sobre como a função register das ABCs é usada na vida real.

13.5.7. O Uso de register na Prática

No Exemplo 11, usamos Tombola.register como um decorador de classe. Antes do Python 3.3, register não podia ser usado dessa forma - ele tinha que ser chamado, como uma função normal, após a definição da classe, como sugerido pelo comentário no final do Exemplo 11. Entretanto, ainda hoje ele mais usado como uma função para registrar classes definidas em outro lugar. Por exemplo, no código-fonte do módulo collections.abc, os tipos nativos tuple, str, range, e memoryview são registrados como subclasses virtuais de Sequence assim:

Sequence.register(tuple)
Sequence.register(str)
Sequence.register(range)
Sequence.register(memoryview)

Vários outros tipo nativos estão registrados com as ABCs em _collections_abc.py. Esses registros ocorrem apenas quando aquele módulo é importado, o que não causa problema, pois você terá mesmo que importar o módulo para obter as ABCs. Por exemplo, você precisa importar MutableMapping de collections.abc para verificar algo como isinstance(my_dict, MutableMapping).

Criar uma subclasse de uma ABC ou se registrar com uma ABC são duas maneiras explícitas de fazer nossas classes passarem verificações com issubclass e isinstance (que também se apoia em issubclass). Mas algumas ABCs também suportam tipagem estrutural. A próxima seção explica isso.

13.5.8. Tipagem estrutural com ABCs

As ABCs são usadas principalmente com tipagem nominal.

Quando uma classe Sub herda explicitamente de AnABC, ou está registrada com AnABC, o nome de AnABC fica ligado ao da classe Sub— e é assim que, durante a execução, issubclass(AnABC, Sub) retorna True.

Em contraste, a tipagem estrutural diz respeito a olhar para a estrutura da interface pública de um objeto para determinar seu tipo: um objeto é consistente-com um tipo se implementa os métodos definidos no tipo.[154] O duck typing estático e o dinâmico são duas abordagens à tipagem estrutural.

E ocorre que algumas ABCs também suportam tipagem estrutural, Em seu ensaio, Pássaros aquáticos e as ABCs, Alex mostra que uma classe pode ser reconhecida como subclasse de uma ABC mesmo sem registro. Aqui está novamente o exemplo dele, com um teste adicional usando issubclass:

>>> class Struggle:
...     def __len__(self): return 23
...
>>> from collections import abc
>>> isinstance(Struggle(), abc.Sized)
True
>>> issubclass(Struggle, abc.Sized)
True

A classe Struggle é considerada uma subclasse de abc.Sized pela função issubclass (e, consequentemente, também por isinstance) porque abc.Sized implementa um método de classe especial chamado __subclasshook__.

O __subclasshook__ de Sized verifica se o argumento classe tem um atributo chamado __len__. Se tiver, então a classe é considerada uma subclasse virtual de Sized. Veja Exemplo 12.

Exemplo 12. Definição de Sized no código-fonte de Lib/_collections_abc.py
class Sized(metaclass=ABCMeta):

    __slots__ = ()

    @abstractmethod
    def __len__(self):
        return 0

    @classmethod
    def __subclasshook__(cls, C):
        if cls is Sized:
            if any("__len__" in B.__dict__ for B in C.__mro__):  # (1)
                return True  # (2)
        return NotImplemented  # (3)
  1. Se há um atributo chamado __len__ no __dict__ de qualquer classe listada em C.__mro__ (isto é, C e suas superclasses)…​

  2. …​retorna True, sinalizando que C é uma subclasse virtual de Sized.

  3. Caso contrário retorna NotImplemented, para permitir que a verificação de subclasse continue.

✒️ Nota

Se você tiver interesse nos detalhes da verificação de subclasse, estude o código-fonte do método ABCMeta.__subclasscheck__ no Python 3.6: Lib/abc.py. Cuidado: ele tem muitos ifs e duas chamadas recursivas. No Python 3.7, Ivan Levkivskyi and Inada Naoki reescreveram em C a maior parte da lógica do módulo abc, para melhorar o desempenho. Veja Python issue #31333. A implementação atual de ABCMeta.__subclasscheck__ simplesmente chama abc_subclasscheck. O código-fonte em C relevante está em _cpython/Modules/_abc.c#L605.

É assim que __subclasshook__ permite às ABCs suportarem a tipagem estrutural. Você pode formalizar uma interface com uma ABC, você pode fazer isinstance verificar com a ABC, e ainda ter um classe sem qualquer relação passando uma verificação de issubclass porque ela implementa um certo método. (ou porque ela faz o que quer que seja necessário para convencer um __subclasshook__ a dar a ela seu aval).

É uma boa ideia implementar __subclasshook__ em nossas próprias ABCs? Provavelmente não. Todas as implementações de __subclasshook__ que eu vi no código-fonte do Python estão em ABCs como Sized, que declara apenas um método especial, e elas simplesmente verificam a presença do nome daquele método especial. Dado seu status "especial", é quase certeza que qualquer método chamado __len__ faz o que se espera. Mas mesmo no reino dos métodos especiais e ABCs fundamentais, pode ser arriscado fazer tais suposições. Por exemplo, mapeamentos implementam __len__, __getitem__, e __iter__, mas corretamente não são considerados subtipos de Sequence, pois você não pode recuperar itens usando deslocamentos inteiros ou faixas. Por isso a classe abc.Sequence não implementa __subclasshook__.

Para ABCs que você ou eu podemos escrever, um __subclasshook__ seria ainda menos confiável. Não estou preparado para acreditar que qualquer classe chamada Spam que implemente ou herde load, pick, inspect, e loaded vai necessariamente se comportar como uma Tombola. É melhor deixar o programador afirmar isso, fazendo de Spam uma subclasse de Tombola, ou registrando a classe com Tombola.register(Spam). Claro, o seu __subclasshook__ poderia também verificar assinaturas de métodos e outras características, mas não creio que valha o esforço.

13.6. Protocolos estáticos

✒️ Nota

Vimos algo sobre protocolos estáticos em Seção 8.5.10 (Capítulo 8). Até considerei deixar toda a discussão sobre protocolos para esse capítulo, mas decidi que a apresentação inicial de dicas de tipo em funções precisava incluir protocolos, pois o duck typing é uma parte essencial do Python, e verificação de tipo estática sem protocolos não consegue lidar muito bem com as APIs pythônicas.

Vamos encerrar esse capítulo ilustrando os protocolos estáticos com dois exemplos simples, e uma discussão sobre as ABCs numéricas e protocolos. Começaremos mostrando como um protocolo estático torna possível anotar e verificar tipos na função double(), que vimos antes na Seção 8.4.

13.6.1. A função double tipada

Quando eu apresento Python para programadores mais acostumados com uma linguagem de tipagem estática, um de meus exemplos favoritos é essa função double simples:

>>> def double(x):
...     return x * 2
...
>>> double(1.5)
3.0
>>> double('A')
'AA'
>>> double([10, 20, 30])
[10, 20, 30, 10, 20, 30]
>>> from fractions import Fraction
>>> double(Fraction(2, 5))
Fraction(4, 5)

Antes da introdução dos protocolos estáticos, não havia uma forma prática de acrescentar dicas de tipo a double sem limitar seus usos possíveis.[155]

Graças ao duck typing, double funciona mesmo com tipos do futuro, tal como a classe Vector aprimorada que veremos no Seção 16.5 (Capítulo 16):

>>> from vector_v7 import Vector
>>> double(Vector([11.0, 12.0, 13.0]))
Vector([22.0, 24.0, 26.0])

A implementação inicial de dicas de tipo no Python era um sistema de tipos nominal: o nome de um tipo em uma anotação tinha que corresponder ao nome do tipo do argumento real - ou com o nome de uma de suas superclasses. Como é impossível nomear todos os tipos que implementam um protocolo (suportando as operações requeridas), a duck typing não podia ser descrita por dicas de tipo antes do Python 3.8.

Agora, com typing.Protocol, podemos informar ao Mypy que double recebe um argumento x que suporta x * 2.

O Exemplo 13 mostra como.

Exemplo 13. double_protocol.py: a definição de double usando um Protocol.
from typing import TypeVar, Protocol

T = TypeVar('T')  # (1)

class Repeatable(Protocol):
    def __mul__(self: T, repeat_count: int) -> T: ...  # (2)

RT = TypeVar('RT', bound=Repeatable)  # (3)

def double(x: RT) -> RT:  # (4)
    return x * 2
  1. Vamos usar esse T na assinatura de __mul__.

  2. __mul__ é a essência do protocolo Repeatable. O parâmetro self normalmente não é anotado - presume-se que seu tipo seja a classe. Aqui usamos T para assegurar que o tipo do resultado é o mesmo tipo de self. Além disso observe que repeat_count está limitado nesse protocolo a int.

  3. A variável de tipo RT é vinculada pelo protocolo Repeatable: o verificador de tipo vai exigir que o tipo efetivo implemente Repeatable.

  4. Agora o verificador de tipo pode verificar que o parâmetro x é um objeto que pode ser multiplicado por um inteiro, e que o valor retornado tem o mesmo tipo que x.

Este exemplo mostra porque o título da PEP 544 é "Protocols: Structural subtyping (static duck typing). (Protocolos: Subtipagem estrutural (duck typing estático))." O tipo nominal de x, argumento efetivamente passado a double, é irrelevante, desde que grasne - ou seja, desde que implemente __mul__.

13.6.2. Protocolos estáticos checados durante a Execução

No Mapa de Tipagem (Figura 1), typing.Protocol aparece na área de verificação estática - a metade inferior do diagrama. Entretanto, ao definir uma subclasse de typing.Protocol, você pode usar o decorador @runtime_checkable para fazer aquele protocolo aceitar verificações com isinstance/issubclass durante a execução. Isso funciona porque typing.Protocol é uma ABC, assim suporta o __subclasshook__ que vimos na Seção 13.5.8.

No Python 3.9, o módulo typing inclui sete protocolos prontos para uso que são verificáveis durante a execução. Aqui estão dois deles, citados diretamente da documentação de typing:

class typing.SupportsComplex

An ABC with one abstract method, __complex__. ("Uma ABC com um método abstrato, __complex__.")

class typing.SupportsFloat

An ABC with one abstract method, __float__. ("Uma ABC com um método abstrato, __float__.")

Esse protocolos foram projetados para verificar a "convertibilidade" de tipos numéricos: se um objeto o implementa __complex__, então deveria ser possível obter um complex invocando complex(o)— pois o método especial __complex__ existe para suportar a função embutida complex().

Exemplo 14 mostra o código-fonte do protocolo typing.SupportsComplex.

Exemplo 14. código-fonte do protocolo typing.SupportsComplex
@runtime_checkable
class SupportsComplex(Protocol):
    """An ABC with one abstract method __complex__."""
    __slots__ = ()

    @abstractmethod
    def __complex__(self) -> complex:
        pass

A chave é o método abstrato __complex__.[156] Durante a checagem de tipo estática, um objeto será considerado consistente-com o protocolo SupportsComplex se implementar um método __complex__ que recebe apenas self e retorna um complex.

Graças ao decorador de classe @runtime_checkable, aplicado a SupportsComplex, aquele protocolo também pode ser utilizado em verificações com isinstance no Exemplo 15.

Exemplo 15. Usando SupportsComplex durante a execução
>>> from typing import SupportsComplex
>>> import numpy as np
>>> c64 = np.complex64(3+4j)  # (1)
>>> isinstance(c64, complex)   # (2)
False
>>> isinstance(c64, SupportsComplex)  # (3)
True
>>> c = complex(c64)  # (4)
>>> c
(3+4j)
>>> isinstance(c, SupportsComplex) # (5)
False
>>> complex(c)
(3+4j)
  1. complex64 é um dos cinco tipos de números complexos fornecidos pelo NumPy.

  2. Nenhum dos tipos complexos do NumPy é subclasse do complex embutido.

  3. Mas os tipos complexos de NumPy implementam __complex__, então cumprem o protocolo SupportsComplex.

  4. Portanto, você pode criar objetos complex a partir deles.

  5. Infelizmente, o tipo complex embutido não implementa __complex__, apesar de complex(c) funcionar sem problemas se c for um complex.

Como consequência deste último ponto, se você quiser testar se um objeto c é um complex ou SupportsComplex, você pode passar uma tupla de tipos como segundo argumento para isinstance, assim:

isinstance(c, (complex, SupportsComplex))

Uma outra alternativa seria usar a ABC Complex, definida no módulo numbers. O tipo embutido complex e os tipos complex64 e complex128 do NumPy são todos registrados como subclasses virtuais de numbers.Complex, então isso aqui funciona:

>>> import numbers
>>> isinstance(c, numbers.Complex)
True
>>> isinstance(c64, numbers.Complex)
True

Na primeira edição de Python Fluente eu recomendava o uso das ABCs de numbers, mas agora esse não é mais um bom conselho, pois aquelas ABCs não são reconhecidas pelos verificadores de tipo estáticos, como veremos na Seção 13.6.8.

Nessa seção eu queria demonstrar que um protocolo verificável durante a execução funciona com isinstance, mas na verdade esse exemplo não é um caso de uso particularmente bom de isinstance, como a barra lateral O Duck Typing É Seu Amigo explica.

👉 Dica

Se você estiver usando um verificador de tipo externo, há uma vantagem nas verificações explícitas com isinstance: quando você escreve um comando if onde a condição é isinstance(o, MyType), então o Mypy pode inferir que dentro do bloco if, o tipo do objeto o é consistente-com MyType.

O Duck Typing É Seu Amigo

Durante a execução, muitas vezes o duck typing é a melhor abordagem para verificação de tipo: em vez de chamar isinstance ou hasattr, apenas tente realizar as operações que você precisa com o objeto, e trate as exceções conforme necessário. Aqui está um exemplo concreto:

Continuando a discussão anterior: dado um objeto o que eu preciso usar como número complexo, essa seria uma abordagem:

if isinstance(o, (complex, SupportsComplex)):
    # do something that requires `o` to be convertible to complex
else:
    raise TypeError('o must be convertible to complex')

A abordagem da goose typing seria usar a ABC numbers.Complex:

if isinstance(o, numbers.Complex):
    # do something with `o`, an instance of `Complex`
else:
    raise TypeError('o must be an instance of Complex')

Eu, entretanto, prefiro aproveitar o duck typing e fazer isso usando o princípio do MFDP - mais fácil pedir desculpas que permissão:

try:
    c = complex(o)
except TypeError as exc:
    raise TypeError('o must be convertible to complex') from exc

E se de qualquer forma tudo que você vai fazer é levantar um TypeError, eu então omitiria o bloco try/except/raise e escreveria apenas isso:

c = complex(o)

Nesse último caso, se o não for de um tipo aceitável, o Python vai levantar uma exceção com uma mensagem bem clara. Por exemplo, se o for uma tuple, esse é o resultado:

TypeError: complex() first argument must be a string or a number, not 'tuple' ("O primeiro argumento de complex() deve ser uma string ou um número, não 'tuple'")

Acho a abordagem duck typing muito melhor nesse caso.

Agora que vimos como usar protocolos estáticos durante a execução com tipos pré-existentes como complex e numpy.complex64, precisamos discutir as limitações de protocolos verificáveis durante a execução.

13.6.3. Limitações das verificações de protocolo durante a execução

Vimos que dicas de tipo são geralmente ignoradas durante a execução, e isso também afeta o uso de verificações com isinstance or issubclass com protocolos estáticos.

Por exemplo, qualquer classe com um método __float__ é considerada - durante a execução - uma subclasse virtual de SupportsFloat, mesmo se seu método __float__ não retorne um float.

Veja essa sessão no console:

>>> import sys
>>> sys.version
'3.9.5 (v3.9.5:0a7dcbdb13, May 3 2021, 13:17:02) \n[Clang 6.0 (clang-600.0.57)]'
>>> c = 3+4j
>>> c.__float__
<method-wrapper '__float__' of complex object at 0x10a16c590>
>>> c.__float__()
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: can't convert complex to float

Em Python 3.9, o tipo complex tem um método __float__, mas ele existe apenas para gerar TypeError com uma mensagem de erro explícita. Se aquele método __float__ tivesse anotações, o tipo de retorno seria NoReturn— que vimos na Seção 8.5.12.

Mas incluir dicas de tipo em complex.__float__ no typeshed não resolveria esse problema, porque o interpretador Python em geral ignora dicas de tipo—e também não acessa os arquivos stub do typeshed.

Continuando da sessão anterior de Python 3.9:

>>> from typing import SupportsFloat
>>> c = 3+4j
>>> isinstance(c, SupportsFloat)
True
>>> issubclass(complex, SupportsFloat)
True

Então temos resultados enganosos: as verificações durante a execução usando SupportsFloat sugerem que você pode converter um complex para float, mas na verdade isso gera um erro de tipo.

⚠️ Aviso

O problema específico com o tipo complex foi resolvido no Python 3.10.0b4, com a remoção do método complex.__float__.

Mas o problema geral persiste: Verificações com isinstance/issubclass só olham para a presença ou ausência de métodos, sem checar sequer suas assinaturas, muito menos suas anotações de tipo. E isso não vai mudar tão cedo, porque este tipo de verificação de tipo durante a execução traria um custo de processamento inaceitável.[157]

Agora veremos como implementar um protocolo estático em uma classe definida pelo usuário.

13.6.4. Suportando um protocolo estático

Lembra da classe Vector2d, que desenvolvemos em Capítulo 11? Dado que tanto um número complex quanto uma instância de Vector2d consistem em um par de números de ponto flutuante, faz sentido suportar a conversão de Vector2d para complex.

O Exemplo 16 mostra a implementação do método __complex__, para melhorar a última versão de Vector2d, vista no Exemplo 11. Para deixar o serviço completo, podemos suportar a operação inversa, com um método de classe fromcomplex, que constrói um Vector2d a partir de um complex.

Exemplo 16. vector2d_v4.py: métodos para conversão de e para complex
    def __complex__(self):
        return complex(self.x, self.y)

    @classmethod
    def fromcomplex(cls, datum):
        return cls(datum.real, datum.imag)  # (1)
  1. Presume que datum tem atributos .real e .imag. Veremos uma implementação melhor no Exemplo 17.

Dado o código acima, e o método __abs__ que o Vector2d já tinha em Exemplo 11, temos o seguinte:

>>> from typing import SupportsComplex, SupportsAbs
>>> from vector2d_v4 import Vector2d
>>> v = Vector2d(3, 4)
>>> isinstance(v, SupportsComplex)
True
>>> isinstance(v, SupportsAbs)
True
>>> complex(v)
(3+4j)
>>> abs(v)
5.0
>>> Vector2d.fromcomplex(3+4j)
Vector2d(3.0, 4.0)

Para verificação de tipo durante a execução, o Exemplo 16 serve bem, mas para uma cobertura estática e relatório de erros melhores com o Mypy, os métodos __abs__, __complex__, e fromcomplex deveriam receber dicas de tipo, como mostrado no Exemplo 17.

Exemplo 17. vector2d_v5.py: acrescentando anotações aos métodos mencionados
    def __abs__(self) -> float:  # (1)
        return math.hypot(self.x, self.y)

    def __complex__(self) -> complex:  # (2)
        return complex(self.x, self.y)

    @classmethod
    def fromcomplex(cls, datum: SupportsComplex) -> Vector2d:  # (3)
        c = complex(datum)  # (4)
        return cls(c.real, c.imag)
  1. A anotação de retorno float é necessária, senão o Mypy infere Any, e não verifica o corpo do método.

  2. Mesmo sem a anotação, o Mypy foi capaz de inferir que isso retorna um complex. A anotação evita um aviso, dependendo da sua configuração do Mypy.

  3. Aqui SupportsComplex garante que datum é conversível.

  4. Essa conversão explícita é necessária, pois o tipo SupportsComplex não declara os atributos .real e .img, usados na linha seguinte. Por exemplo, Vector2d não tem esses atributos, mas implementa __complex__.

O tipo de retorno de fromcomplex pode ser Vector2d se a linha from future import annotations aparecer no início do módulo. Aquela importação faz as dicas de tipo serem armazenadas como strings, sem serem processadas durante a importação, quando as definições de função são tratadas. Sem o __future__ import of annotations, Vector2d é uma referência inválida neste momento (a classe não está inteiramente definida ainda) e deveria ser escrita como uma string: 'Vector2d', como se fosse uma referência adiantada. Essa importação de __future__ foi introduzida na PEP 563—Postponed Evaluation of Annotations, implementada no Python 3.7. Aquele comportamento estava marcado para se tornar default no 3.10, mas a mudança foi adiada para uma versão futura.[158] Quando isso acontecer, a importação será redundante mas inofensiva.

Agora vamos criar - e depois estender - um novo protocolo estático.

13.6.5. Projetando um protocolo estático

Quando estudamos goose typing, vimos a ABC Tombola em Seção 13.5.3. Aqui vamos ver como definir uma interface similar usando um protocolo estático.

A ABC Tombola especifica dois métodos: pick e load. Poderíamos também definir um protocolo estático com esses dois métodos, mas aprendi com a comunidade Go que protocolos de apenas um método tornam o duck typing estático mais útil e flexível. A biblioteca padrão do Go tem inúmeras interfaces, como Reader, uma interface para I/O que requer apenas um método read. Após algum tempo, se você entender que um protocolo mais complexo é necessário, você pode combinar dois ou mais protocolos para definir um novo.

Usar um container que escolhe itens aleatoriamente pode ou não exigir o recarregamento do container, mas ele certamente precisa de um método para fazer a efetiva escolha do item, então o método pick será o escolhido para o protocolo mínimo RandomPicker. O código do protocolo está no Exemplo 18, e seu uso é demonstrado por testes no Exemplo 19.

Exemplo 18. randompick.py: definition of RandomPicker
from typing import Protocol, runtime_checkable, Any

@runtime_checkable
class RandomPicker(Protocol):
    def pick(self) -> Any: ...
✒️ Nota

O método pick retorna Any. Em Seção 15.8 veremos como tornar RandomPicker um tipo genérico, com um parâmetro que permite aos usuários do protocolo especificarem o tipo de retorno do método pick.

Exemplo 19. randompick_test.py: RandomPicker em uso
import random
from typing import Any, Iterable, TYPE_CHECKING

from randompick import RandomPicker  # (1)

class SimplePicker:  # (2)
    def __init__(self, items: Iterable) -> None:
        self._items = list(items)
        random.shuffle(self._items)

    def pick(self) -> Any:  # (3)
        return self._items.pop()

def test_isinstance() -> None:  # (4)
    popper: RandomPicker = SimplePicker([1])  # (5)
    assert isinstance(popper, RandomPicker)  # (6)

def test_item_type() -> None:  # (7)
    items = [1, 2]
    popper = SimplePicker(items)
    item = popper.pick()
    assert item in items
    if TYPE_CHECKING:
        reveal_type(item)  # (8)
    assert isinstance(item, int)
  1. Não é necessário importar um protocolo estático para definir uma classe que o implementa, Aqui eu importei RandomPicker apenas para usá-lo em test_isinstance mais tarde.

  2. SimplePicker implementa RandomPicker — mas não é uma subclasse dele. Isso é o duck typing estático em ação.

  3. Any é o tipo de retorno default, então essa anotação não é estritamente necessária, mas deixa mais claro que estamos implementando o protocolo RandomPicker, como definido em Exemplo 18.

  4. Não esqueça de acrescentar dicas → None aos seus testes, se você quiser que o Mypy olhe para eles.

  5. Acrescentei uma dica de tipo para a variável popper, para mostrar que o Mypy entende que o SimplePicker é consistente-com.

  6. Esse teste prova que uma instância de SimplePicker também é uma instância de RandomPicker. Isso funciona por causa do decorador @runtime_checkable aplicado a RandomPicker, e porque o SimplePicker tem um método pick, como exigido.

  7. Esse teste invoca o método pick de SimplePicker, verifica que ele retorna um dos itens dados a SimplePicker, e então realiza testes estáticos e de execução sobre o item obtido.

  8. Essa linha gera uma obervação no relatório do Mypy.

Como vimos no Exemplo 40, reveal_type é uma função "mágica" reconhecida pelo Mypy. Por isso ela não é importada e nós só conseguimos chamá-la de dentro de blocos if protegidos por typing.TYPE_CHECKING, que só é True para os olhos de um verificador de tipo estático, mas é False durante a execução.

Os dois testes em Exemplo 19 passam. O Mypy também não vê nenhum erro naquele código, e mostra o resultado de reveal_type sobre o item retornado por pick:

$ mypy randompick_test.py
randompick_test.py:24: note: Revealed type is 'Any'

Tendo criado nosso primeiro protocolo, vamos estudar algumas recomendações sobre essa prática.

13.6.6. Melhores práticas no desenvolvimento de protocolos

Após 10 anos de experiência com duck typing estático em Go, está claro que protocolos estreitos são mais úteis - muitas vezes tais protocolos tem um único método, raramente mais que um par de métodos. Martin Fowler descreve uma boa ideia para se ter em mente ao desenvolver protocolos: a Role Interface, (interface papel[159]). A ideia é que um protocolo deve ser definido em termos de um papel que um objeto pode desempenhar, e não em termos de uma classe específica.

Além disso, é comum ver um protocolo definido próximo a uma função que o usa-ou seja, definido em "código do cliente" em vez de ser definido em uma biblioteca separada. Isso torna mais fácil criar novos tipos para chamar aquela função, bom para a extensibilidade e para testes com simulações ou protótipos.

Ambas as práticas, protocolos estreitos e protocolos em código cliente, evitam um acoplamento muito firme, em acordo com o Princípio da Segregação de Interface, que podemos resumir como "Clientes não devem ser forçados a depender de interfaces que não usam."

A página "Contributing to typeshed" (EN) recomenda a seguinte convenção de nomenclatura para protocolos estáticos (os três pontos a seguir foram traduzidos o mais fielmente possível):

  • Use nomes simples para protocolos que representam um conceito claro (e.g., Iterator, Container).

  • Use SupportsX para protocolos que oferecem métodos que podem ser chamados (e.g., SupportsInt, SupportsRead, SupportsReadSeek).[160]

  • Use HasX para protocolos que tem atributos que podem ser lidos ou escritos, ou métodos getter/setter(e.g., HasItems, HasFileno).

A biblioteca padrão do Go tem uma convenção de nomenclatura que gosto: para protocolos de método único, se o nome do método é um verbo, acrescente o sufixo adequado (em inglês, "-er" ou "-or", em geral) para torná-lo um substantivo. Por exemplo, em vez de SupportsRead, temos Reader. Outros exemplos incluem Formatter, Animator, e Scanner. Para se inspirar, veja "Go (Golang) Standard Library Interfaces (Selected)" (EN) de Asuka Kenji.

Uma boa razão para se criar protocolos minimalistas é a habilidade de estendê-los posteriormente, se necessário. Veremos a seguir que não é difícil criar um protocolo derivado com um método adicional

13.6.7. Estendendo um Protocolo

Como mencionei na seção anterior, os desenvolvedores Go defendem que, quando em dúvida, melhor escolher o minimalismo ao definir interfaces - o nome usado para protocolos estáticos naquela linguagem. Muitas das interfaces Go mais usadas tem um único método.

Quando a prática revela que um protocolo com mais métodos seria útil, em vezz de adicionar métodos ao protocolo original, é melhor derivar dali um novo protocolo. Estender um protocolo estático em Python tem algumas ressalvas, como mostra o Exemplo 20 shows.

Exemplo 20. randompickload.py: estendendo RandomPicker
from typing import Protocol, runtime_checkable
from randompick import RandomPicker

@runtime_checkable  # (1)
class LoadableRandomPicker(RandomPicker, Protocol):  # (2)
    def load(self, Iterable) -> None: ...  # (3)
  1. Se você quer que o protocolo derivado possa ser verificado durante a execução, você precisa aplicar o decorador novamente - seu comportamento não é herdado.[161]

  2. Todo protocolo deve nomear explicitamente typing.Protocol como uma de suas classes base, além do protocolo que estamos estendendo. Isso é diferente da forma como herança funciona em Python.[162]

  3. De volta à programação orientada a objetos "normal": só precisamos declarar o método novo no protocolo derivado. A declaração do método pick é herdada de RandomPicker.

Isso conclui o último exemplo sobre definir e usar um protocolo estático neste capítulo.

Para encerrar o capítulo, vamos olhar as ABCs numéricas e sua possível substituição por protocolos numéricos.

13.6.8. As ABCs em numbers e os novod protocolos numéricos

Como vimos em Seção 8.5.7.1, as ABCs no pacote numbers da biblioteca padrão funcionam bem para verificação de tipo durante a execução.

Se você precisa verificar um inteiro, pode usar isinstance(x, numbers.Integral) para aceitar int, bool (que é subclasse de int) ou outros tipos inteiros oferecidos por bibliotecas externas que registram seus tipos como subclasses virtuais das ABCs de numbers. Por exemplo, o NumPy tem 21 tipos inteiros — bem como várias variações de tipos de ponto flutuante registrados como numbers.Real, e números complexos com várias amplitudes de bits, registrados como numbers.Complex.

👉 Dica

De forma algo surpreendente, decimal.Decimal não é registrado como uma subclasse virtual de numbers.Real. A razão para isso é que, se você precisa da precisão de Decimal no seu programa, então você quer estar protegido da mistura acidental de números decimais e de números de ponto flutuante (que são menos precisos).

Infelizmente, a torre numérica não foi projetada para checagem de tipo estática. A ABC raiz - numbers.Number - não tem métodos, então se você declarar x: Number, o Mypy não vai deixar você fazer operações aritméticas ou chamar qualquer método com X.

Se as ABCs de numbers não tem suporte, quais as opções?

Um bom lugar para procurar soluções de tipagem é no projeto typeshed. Como parte da biblioteca padrão do Python, o módulo statistics tem um arquivo stub correspondente no typeshed com dicas de tipo, o statistics.pyi,

Lá você encontrará as seguintes definições, que são usadas para anotar várias funções:

_Number = Union[float, Decimal, Fraction]
_NumberT = TypeVar('_NumberT', float, Decimal, Fraction)

Essa abordagem está correta, mas é limitada. Ela não suporta tipos numéricos fora da biblioteca padrão, que as ABCs de numbers suportam durante a execução - quando tipos numéricos são registrados como subclasses virtuais.

A tendência atual é recomendar os protocolos numéricos fornecidos pelo módulo typing, que discutimos na Seção 13.6.2.

Infelizmente, durante a execução os protocolos numéricos podem deixar você na mão. Como mencionado em Seção 13.6.3, o tipo complex no Python 3.9 implementa __float__, mas o método existe apenas para lançar uma TypeError com uma mensagem explícita: "can’t convert complex to float." ("não é possível converter complex para float") Por alguma razão, ele também implementa __int__. A presença desses métodos faz isinstance produzir resultados enganosos no Python 3.9. No Python 3.10, os métodos de complex que geravam TypeError incondicionalmente foram removidos.[163]

Por outro lado, os tipos complexos do NumPy implementam métodos __float__ e __int__ que funcionam, emitindo apenas um aviso quando cada um deles é usado pela primeira vez:

>>> import numpy as np
>>> cd = np.cdouble(3+4j)
>>> cd
(3+4j)
>>> float(cd)
<stdin>:1: ComplexWarning: Casting complex values to real
discards the imaginary part
3.0

O problema oposto também acontece: Os tipos embutidos complex, float, e int, além numpy.float16 e numpy.uint8, não tem um método __complex__, então isinstance(x, SupportsComplex) retorna False para eles.[164] Os tipo complexos do NumPy, tal como np.complex64, implementam __complex__ para conversão em um complex embutido.

Entretanto, na prática, o construtor embutido complex() trabalha com instâncias de todos esses tipos sem erros ou avisos.

>>> import numpy as np
>>> from typing import SupportsComplex
>>> sample = [1+0j, np.complex64(1+0j), 1.0, np.float16(1.0), 1, np.uint8(1)]
>>> [isinstance(x, SupportsComplex) for x in sample]
[False, True, False, False, False, False]
>>> [complex(x) for x in sample]
[(1+0j), (1+0j), (1+0j), (1+0j), (1+0j), (1+0j)]

Isso mostra que verificações de SupportsComplex com isinstance sugerem que todas aquelas conversões para complex falhariam, mas ela são bem sucedidas. Na mailing list typing-sig, Guido van Rossum indicou que o complex embutido aceita um único argumento, e essa é a razão daquelas conversões funcionarem.

Por outro lado, o Mypy aceita argumentos de todos esses seis tipos em uma chamada à função to_complex(), definida assim:

def to_complex(n: SupportsComplex) -> complex:
    return complex(n)

No momento em que escrevo isso, o NumPy não tem dicas de tipo, então seus tipos numéricos são todos Any.[165] Por outro lado, o Mypy de alguma maneira "sabe" que o int e o float embutidos podem ser convertidos para complex, apesar de, no typeshed, apenas a classe embutida complex ter o método __complex__.[166]

Concluindo, apesar da impressão que a verificação de tipo para tipos numéricos não deveria ser difícil, a situação atual é a seguinte: as dicas de tipo da PEP 484 evitam (EN) a torre numérica e recomendam implicitamente que os verificadores de tipo codifiquem explicitamente as relações de tipo entre os complex, float, e int embutidos. O Mypy faz isso, e também, pragmaticamente, aceita que int e float são consistente-com SupportsComplex, apesar deles não implementarem __complex__.

👉 Dica

Eu só encontrei resultados inesperados usando verificações com isinstance em conjunto com os protocolos numéricos Supports* quando fiz experiências de conversão de ou para complex. Se você não usa números complexos, pode confiar naqueles protocolos em vez das ABCs de numbers.

As principais lições dessa seção são:

  • As ABCs de numbers são boas para verificação de tipo durante a execução, mas inadequadas para tipagem estática.

  • Os protocolos numéricos estáticos SupportsComplex, SupportsFloat, etc. funcionam bem para tipagem estática, mas são pouco confiáveis para verificação de tipo durante a execução se números complexos estiverem envolvidos.

Estamos agora prontos para uma rápida revisão do que vimos nesse capítulo.

13.7. Resumo do capítulo

O Mapa de Tipagem (Figura 1) é a chave para entender esse capítulo. Após uma breve introdução às quatro abordagens da tipagem, comparamos protocolos dinâmicos e estáticos, os quais suportam duck typing e duck typing estático, respectivamente. Os dois tipos de protocolo compartilham uma característica essencial, nunca é exigido de uma classe que ela declare explicitamente o suporte a qualquer protocolo específico. Uma classe suporta um protocolo simplesmente implementando os métodos necessários.

A próxima grande seção foi a Seção 13.4, onde exploramos os esforços que interpretador Python faz para que os protocolos dinâmicos de sequência e iterável funcionem, incluindo a implementação parcial de ambos. Então vimos como fazer uma classe implementar um protocolo durante a execução, através da adição de métodos extra via monkey patching. A seção sobre duck typing terminou com sugestões de programação defensiva, incluindo a detecção de tipos estruturais sem verificações explícitas com isinstance ou hasattr, usando try/except e falhando rápido.

Após Alex Martelli introduzir o goose typing em Pássaros aquáticos e as ABCs, vimos como criar subclasses de ABCs existentes, examinamos algumas ABCs importantes da biblioteca padrão, e criamos uma ABC do zero, que nós então implementamos da forma tradicional, criando subclasses, e por registro. Finalizamos aquela seção vendo como o método especial __subclasshook__ permite às ABCs suportarem a tipagem estrutural, pelo reconhecimento de classes não-relacionadas, mas que fornecem os métodos que preenchem os requisitos da interface definida na ABC.

A última grande seção foi a Seção 13.6, onde retomamos o estudo do duck typing estático, que havia começado no Capítulo 8, em Seção 8.5.10. Vimos como o decorador @runtime_checkable também aproveita o __subclasshook__ para suportar tipagem estrutural durante a execução - mesmo que o melhor uso dos protocolos estáticos seja com verificadores de tipo estáticos, que podem levar em consideração as dicas de tipo, tornando a tipagem estrutural mais confiável. Então falamos sobre o projeto e a codificação de um protocolo estático e como estendê-lo. O capítulo terminou com Seção 13.6.8, que conta a triste história do abandono da torre numérica e das limitações da alternativa proposta: os protocolos numéricos estáticos tal como SupportsFloat e outros, adicionados ao módulo typing no Python 3.8.

A mensagem principal desse capítulo é que temos quatro maneiras complementares de programar com interfaces no Python moderno, cada uma com diferentes vantagens e deficiências. Você possivelmente encontrará casos de uso adequados para cada esquema de tipagem em qualquer base de código de Python moderno de tamanho significativo. Rejeitar qualquer dessas abordagens tornará seu trabalho como programador Python mais difícil que o necessário.

Dito isso, o Python ganhou sua enorme popularidade enquanto suportava apenas duck typing. Outras linguagens populares, como Javascript, PHP e Ruby, bem como Lisp, Smalltalk, Erlang e Clojure - essas últimas não muito populares mas extremamente influentes - são todas linguagens que tinham e ainda tem um impacto tremendo aproveitando o poder e a simplicidade do duck typing.

13.8. Para saber mais

Para uma rápida revisão do prós e contras da tipagem, bem como da importância de typing.Protocol para a saúde de bases de código verificadas estaticamente, eu recomendo fortemente o post de Glyph Lefkowitz "I Want A New Duck: typing.Protocol and the future of duck typing" (EN).("Eu Quero Um Novo Pato: typing.Protocol e o futuro do duck typing`"). Eu também aprendi bastante em seu post "Interfaces and Protocols" (EN) ("Interfaces e Protocolos"), comparando typing.Protocol com zope.interface — um mecanismo mais antigo para definir interfaces em sistemas plug-in fracamente acoplados, usado no Plone CMS, na Pyramid web framework, e no framework de programação assíncrona Twisted, um projeto fundado por Glyph.[167]

Ótimos livros sobre Python tem - quase que por definição - uma ótima cobertura de duck typing. Dois de meus livros favoritos de Python tiveram atualizações lançadas após a primeira edição de Python Fluente: The Quick Python Book, 3rd ed., (Manning), de Naomi Ceder; e Python in a Nutshell, 3rd ed., de Alex Martelli, Anna Ravenscroft, e Steve Holden (O’Reilly).

Para uma discussão sobre os prós e contras da tipagem dinâmica, veja a entrevista de Guido van Rossum com Bill Venners em "Contracts in Python: A Conversation with Guido van Rossum, Part IV" (EN) ("Contratos em Python: Uma Conversa com Guido van Rossum, Parte IV"). O post "Dynamic Typing" (EN) ("Tipagem Dinâmica"), de Martin Fowler, traz uma avaliação perspicaz e equilibrada deste debate. Ele também escreveu "Role Interface" (EN) ("Interface Papel"), que mencionei na Seção 13.6.6. Apesar de não ser sobre duck typing, aquele post é altamente relevante para o projeto de protocolos em Python, pois ele contrasta as estreitas interfaces papel com as interfaces públicas bem mais abrangentes de classes em geral.

A documentação do Mypy é, muitas vezes, a melhor fonte de informação sobre qualquer coisa relacionada a tipagem estática em Python, incluindo duck typing estático, tratado no capítulo "Protocols and structural subtyping" (EN) ("Protocolos e subtipagem estrutural").

As referências restantes são todas sobre goose typing.

Beazley and Jones’s Python Cookbook, 3rd ed. (O’Reilly) tem uma seção sobre como definir uma ABC (Recipe 8.12). O livro foi escrito antes do Python 3.4, então eles não usam a atual sintaxe preferida para declarar ABCs, criar uma subclasse de abc.ABC (em vez disso, eles usam a palavra-chave metaclass, da qual nós só vamos precisar mesmo emCapítulo 24). Tirando esse pequeno detalhe, a receita cobre os principais recursos das ABCs muito bem.

The Python Standard Library by Example by Doug Hellmann (Addison-Wesley), tem um capítulo sobre o módulo abc. Ele também esta disponível na web, no excelente site do Doug PyMOTW—Python Module of the Week (EN). Hellmann também usa a declaração de ABC no estilo antigo:`PluginBase(metaclass=abc.ABCMeta)` em vez do mais simples PluginBase(abc.ABC), disponível desde o Python 3.4.

Quando usamos ABCs, herança múltipla não é apenas comum mas praticamente inevitável, porque cada uma das ABCs fundamentais de coleções — Sequence, Mapping, e Set— estendem Collection, que por sua vez estende múltiplas ABCs (veja Figura 4). Assim, [herança] é um importante tópico complementar a esse.

A PEP 3119—​Introducing Abstract Base Classes (EN) apresenta a justificativa para as ABCs. A PEP 3141—​A Type Hierarchy for Numbers (EN) apresenta as ABCs do módulo numbers, mas a discussão no Mypy issue #3186 "int is not a Number?" inclui alguns argumentos sobre a razão da torre numérica ser inadequada para verificação estática de tipo. Alex Waygood escreveu uma resposta abrangente no StackOverflow, discutindo formas de anotar tipos numéricos.

Vou continuar monitorando o Mypy issue #3186 para os próximos capítulos dessa saga, na esperança de um final feliz que torne a tipagem estática e o goose typing compatíveis, como eles deveriam ser.

Ponto de vista

A Jornada PMV da tipagem estática em Python

Eu trabalho para a Thoughtworks, uma líder global em desenvolvimento de software ágil. Na Thoughtworks, muitas vezes recomendamos a nossos clientes que procurem criar e implanta PMVs: produtos mínimos viáveis, "uma versão simples de um produto, que é disponibilizada para os usuários com o objetivo de validar hipóteses centrais do negócio," como definido or meu colega Paulo Caroli in "Lean Inception", um post no Martin Fowler’s collective blog.

Guido van Rossum e os outros core developers que projetaram e implementaram a tipagem estática tem seguido a estratégia do PMV desde 2006. Primeiro, a PEP 3107—Function Annotations foi implementada no Python 3.0 com uma semântica bastante limitada: apenas sintaxe para anexar anotações a parâmetros e retornos de funções. Isso foi feito para explicitamente permitir experimentação e receber feedback - os principais benefícios de um PMV.

Oito anos depois, a PEP 484—Type Hints foi proposta e aprovada. Sua implementação, no Python 3.5, não exigiu mudanças na linguagem ou na biblioteca padrão - exceto a adição do módulo typing, do qual nenhuma outra parte da biblioteca padrão dependia. A PEP 484 suportava apenas tipos nominais com genéricos - similar ao Java - mas com a verificação estática efetiva sendo executada por ferramentas externas. Recursos importantes não existiam, como anotações de variáveis, tipos embutidos genéricos, e protocolos. Apesar dessas limitações, esse PMV de tipagem foi bem sucedida o suficiente para atrair investimento e adoção por parte de empresas com enormes bases de código em Python, como a Dropbox, o Google e o Facebook, bem como apoio de IDEs profissionais como o PyCharm, o Wing, e o VS Code.

A PEP 526—Syntax for Variable Annotations foi o primeiro passo evolutivo que exigiu mudanças no interpretador, no Python 3.6. Mais mudanças no interpretador do Python 3.7 foram feitas para suportar a PEP 563—Postponed Evaluation of Annotations e a PEP 560—Core support for typing module and generic types, que permitiram que coleções embutidas e da biblioteca padrão aceitem dicas de tipo genéricas "de fábrica" no Python 3.9, graças à PEP 585—Type Hinting Generics In Standard Collections.

Durante todos esses anos, alguns usuários de Python - incluindo este autor - ficaram desapontados com o suporte à tipagem. Após aprender Go, a ausência de duck typing estático em Python era incompreensível, em uma linguagem onde o duck typing havia sempre sido uma força central.

Mas essa é a natureza dos PMVs: eles podem não satisfazer todos os usuários em potencial, mas exigem menos esforço de implementação, e guiam o desenvolvimento posterior com o feedback do uso em situações reais.

Se há uma coisa que todos aprendemos com o Python 3, é que progresso incremental é mais seguro que lançamentos estrondosos. Estou contente que não tivemos que esperar pelo Python 4 - se é que existirá - para tornar o Python mais atrativo par grandes empresas, onde os benefícios da tipagem estática superam a complexidade adicional.

Abordagens à tipagem em linguagens populares

A Figura 8 é uma variação do Mapa de Tipagem(Figura 1) com os nomes de algumas linguagem populares que suportam cada um dos modos de tipagem.

Quatro abordagens para verificação de tipo e algumas linguagens que as usam.
Figura 8. Quatro abordagens para verificação de tipo e algumas linguagens que as usam.

TypeScript e o Python ≥ 3.8 são as únicas linguagem em minha pequena e arbitrária amostra que suportam todas as quatro abordagens.

Go é claramente uma linguagem de tipo estáticos na tradição do Pascal, mas ela foi a pioneira do duck typing estático - pelo menos entre as linguagens mais usadas hoje. Eu também coloquei Go no quadrante do goose typing por causa de suas declarações (assertions) de tipo, que permitem a verificação e adaptação a diferentes tipos durante a execução.

Se eu tivesse que desenhar um diagrama similar no ano 2000, apenas os quadrantes do duck typing e da tipagem estática teriam linguagens. Não conheço nenhuma linguagem que suportava duck typing estático ou goose typing 20 anos atrás. O fato de cada um dos quatro quadrantes ter pelo menos três linguagens populares sugere que muita gente vê benefícios em cada uma das quatro abordagens à tipagem.

Monkey patching

Monkey patching tem uma reputação ruim. Se usado com exagero, pode gerar sistemas difíceis de entender e manter. A correção está normalmente intimamente ligada a seu alvo, tornando-se frágil. Outro problema é que duas bibliotecas que aplicam correções deste tipo durante a execução podem pisar nos pés uma da outra, com a segunda biblioteca a rodar destruindo as correções da primeira.

Mas o monkey patching pode também ser útil, por exemplo, para fazer uma classe implementar um protocolo durante a execução. O design pattern Adaptador resolve o mesmo problema através da implementação de uma nova classe inteira.

É fácil usar monkey patching em código Python, mas há limitações. Ao contrário de Ruby e Javascript, o Python não permite modificações de tipos embutidos durante a execução. Eu na verdade considero isso uma vantagem, pois dá a certeza que um objeto str vai sempre ter os mesmos métodos. Essa limitação reduz a chance de bibliotecas externas aplicarem correções conflitantes.

Metáforas e idiomas em interfaces

Uma metáfora promove o entendimento tornando restrições e acessos visíveis. Esse é o valor das palavras "stack" (pilha) e "queue" (fila) para descrever estruturas de dados fundamentais: elas tornam claras aa operações permitidas, isto é, como os itens podem ser adicionados ou removidos. Por outro lado, Alan Cooper et al. escrevem em About Face, the Essentials of Interaction Design, 4th ed. (Wiley):

Fidelidade estrita a metáforas liga interfaces de forma desnecessariamente firme aos mecanismos do mundo físico.

Eles está falando de interface de usuário, mas a advertência se aplica também a APIs. Mas Cooper admite que quando uma metáfora "verdadeiramente apropriada" "cai no nosso colo," podemos usá-la (ele escreve "cai no nosso colo" porque é tão difícil encontrar metáforas adequadas que ninguém deveria perder tempo tentando encontrá-las ativamente). Acredito que a imagem da máquina de bingo que usei nesse capítulo é apropriada e eu a defenderei.

About Face é, de longe, o melhor livro sobre design de UI que eu já li - e eu li uns tantos. Abandonar as metáforas como paradigmas de design, as substituindo por "interfaces idiomáticas", foi a lição mais valiosa que aprendi com o trabalho de Cooper.

Em About Face, Cooper não lida com APIs, mas quanto mais penso em suas ideias, mais vejo como se aplicam ao Python. Os protocolos fundamentais da linguagem são o que Cooper chama de "idiomas." Uma vez que aprendemos o que é uma "sequência", podemos aplicar esse conhecimento em diferentes contextos. Esse é o tema principal de Python Fluente: ressaltar os idiomas fundamentais da linguagem, para que o seu código seja conciso, efetivo e legível - para um Pythonista fluente.

14. Herança: para o bem ou para o mal

[…​] precisávamos de toda uma teoria melhor sobre herança (e ainda precisamos). Por exemplo, herança e instanciação (que é um tipo de herança) embaralham tanto a pragmática (tal como fatorar o código para economizar espaço) quanto a semântica (usada para um excesso de tarefas tais como: especialização, generalização, especiação, etc.).[168]

— Alan Kay
Os Primórdios do Smalltalk

Esse capítulo é sobre herança e criação de subclasses. Vou presumir um entendimento básico desses conceitos, que você pode ter aprendido lendo O Tutorial do Python, ou por experiências com outra linguagem orientada a objetos popular, tal como Java, C# ou C++. Aqui vamos nos concentrar em quatro características do Python:

  • A função super()

  • As armadilhas na criação de subclasses de tipos embutidos

  • Herança múltipla e a ordem de resolução de métodos

  • Classes mixin

Herança múltipla acontece quando uma classe tem mais de uma classe base. O C++ a suporta; Java e C# não. Muitos consideram que a herança múltipla não vale a quantidade de problemas que causa. Ela foi deliberadamente deixada de fora do Java, após seu aparente abuso nas primeiras bases de código C++.

Esse capítulo introduz a herança múltipla para aqueles que nunca a usaram, e oferece orientações sobre como lidar com herança simples ou múltipla, se você precisar usá-la.

Em 2021, quando escrevo essas linhas, há uma forte reação contra o uso excessivo de herança em geral—não apenas herança múltipla—porque superclasses e subclasses são fortemente acopladas, ou seja, interdependentes. Esse acoplamento forte significa que modificações em uma classe pode ter efeitos inesperados e de longo alcance em suas subclasses, tornando os sistemas frágeis e difíceis de entender.

Entretanto, ainda temos que manter os sistemas existentes, que podem ter complexas hierarquias de classe, ou trabalhar com frameworks que nos obrigam a usar herança—algumas vezes até herança múltipla.

Vou ilustrar as aplicações práticas da herança múltipla com a biblioteca padrão, o framework para programação web Django e o toolkit para programação de interface gráfica Tkinter.

14.1. Novidades nesse capítulo

Não há nenhum recurso novo no Python no que diz respeito ao assunto desse capítulo, mas fiz inúmeras modificações baseadas nos comentários dos revisores técnicos da segunda edição, especialmente Leonardo Rochael e Caleb Hattingh.

Escrevi uma nova seção de abertura, tratando especificamente da função embutida super(), e mudei os exemplos na Seção 14.4, para explorar mais profundamente a forma como super() suporta a herança múltipla cooperativa.

A Seção 14.5 também é nova. A Seção 14.6 foi reorganizada, e apresenta exemplos mais simples de mixin vindos da bilbioteca padrão, antes de apresentar o exemplos com o framework Django e as complicadas hierarquias do Tkinter.

Como o próprio título sugere, as ressalvas à herança sempre foram um dos temas principais desse capítulo. Mas como cada vez mais desenvolvedores consideram essa técnica problemática, acrescentei alguns parágrafos sobre como evitar a herança no final da Seção 14.8 e da Seção 14.9.

Vamos começar com uma revisão da enigmática função super().

14.2. A função super()

O uso consistente da função embutida super() é essencial na criação de programas Python orientados a objetos fáceis de manter.

Quando uma subclasse sobrepõe um método de uma superclasse, esse novo método normalmente precisa invocar o método correspondente na superclasse. Aqui está o modo recomendado de fazer isso, tirado de um exemplo da documentação do módulo collections, na seção "OrderedDict Examples and Recipes" (OrderedDict: Exemplos e Receitas) (EN).:[169]

class LastUpdatedOrderedDict(OrderedDict):
    """Armazena itens mantendo por ordem de atualização."""

    def __setitem__(self, key, value):
        super().__setitem__(key, value)
        self.move_to_end(key)

Para executar sua tarefa, LastUpdatedOrderedDict sobrepõe __setitem__ para:

  1. Usar super().__setitem__, invocando aquele método na superclasse e permitindo que ele insira ou atualize o par chave/valor.

  2. Invocar self.move_to_end, para garantir que a key atualizada esteja na última posição.

Invocar um __init__ sobreposto é particulamente importante, para permitir que a superclasse execute sua parte na inicialização da instância.

👉 Dica

Se você aprendeu programação orientada a objetos com Java, com certeza se lembra que, naquela linguagem, um método construtor invoca automaticamente o construtor sem argumentos da superclasse. O Python não faz isso. Se acostume a escrever o seguinte código padrão:

    def __init__(self, a, b) :
        super().__init__(a, b)
        ...  # more initialization code

Você pode já ter visto código que não usa super(), e em vez disso chama o método na superclasse diretamente, assim:

class NotRecommended(OrderedDict):
    """Isto é um contra-exemplo!"""

    def __setitem__(self, key, value):
        OrderedDict.__setitem__(self, key, value)
        self.move_to_end(key)

Essa alternativa até funciona nesse caso em particular, mas não é recomendado por duas razões. Primeiro, codifica a superclasse explicitamente. O nome OrderedDict aparece na declaração class e também dentro de __setitem__. Se, no futuro, alguém modificar a declaração class para mudar a classe base ou adicionar outra, pode se esquecer de atualizar o corpo de __setitem__, introduzindo um bug.

A segunda razão é que super implementa lógica para tratar hierarquias de classe com herança múltipla. Voltaremos a isso na Seção 14.4. Para concluir essa recapitulação de super, é útil rever como essa função era invocada no Python 2, porque a assinatura antiga, com dois argumentos, é reveladora:

class LastUpdatedOrderedDict(OrderedDict):
    """Funciona igual em Python 2 e Python 3"""

    def __setitem__(self, key, value):
        super(LastUpdatedOrderedDict, self).__setitem__(key, value)
        self.move_to_end(key)

Os dois argumento de super são agora opcionais. O compilador de bytecode do Python 3 obtém e fornece ambos examinando o contexto circundante, quando super() é invocado dentro de um método. Os argumentos são:

type

O início do caminho para a superclasse que implementa o método desejado. Por default, é a classe que possui o método onde a chamada a super() aparece.

object_or_type

O objeto (para chamadas a métodos de instância) ou classe (para chamadas a métodos de classe) que será o receptor da chamada ao método.[170] Por default, é self se a chamada super() acontece no corpo de um método de instância.

Independente desses argumentos serem fornecidos por você ou pelo compilador, a chamada a super() devolve um objeto proxy dinâmico que encontra um método (tal como __setitem__ no exemplo) em uma superclasse do parâmetro type e a vincula ao object_or_type, de modo que não precisamos passar explicitamente o receptor (self) quando invocamos o método.

No Python 3, ainda é permitido passar explicitamente o primeiro e o segundo argumentos a super().[171] Mas eles são necessários apenas em casos especiais, tal como pular parte do MRO (sigla de Method Resolution Order—Ordem de Resolução de Métodos), para testes ou depuração, ou para contornar algum comportamento indesejado em uma superclasse.

Vamos agora discutir as ressalvas à criação de subclasses de tipos embutidos.

14.3. É complicado criar subclasses de tipos embutidos

Nas primeiras versões do Python não era possível criar subclasses de tipos embutidos como list ou dict. Desde o Python 2.2 isso é possível, mas há restrição importante: o código (escrito em C) dos tipos embutidos normalmente não chama os métodos sobrepostos por classes definidas pelo usuário. Há uma boa descrição curta do problema na documentação do PyPy, na seção "Differences between PyPy and CPython" ("Diferenças entre o PyPy e o CPython"), "Subclasses of built-in types" (Subclasses de tipos embutidos):

Oficialmente, o CPython não tem qualquer regra sobre exatamente quando um método sobreposto de subclasses de tipos embutidos é ou não invocado implicitamente. Como uma aproximação, esses métodos nunca são chamados por outros métodos embutidos do mesmo objeto. Por exemplo, um __getitem__ sobreposto em uma subclasse de dict nunca será invocado pelo método get() do tipo embutido.

O Exemplo 21 ilustra o problema.

Exemplo 21. Nossa sobreposição de __setitem__ é ignorado pelos métodos __init__ e __update__ to tipo embutido dict
>>> class DoppelDict(dict):
...     def __setitem__(self, key, value):
...         super().__setitem__(key, [value] * 2)  # (1)
...
>>> dd = DoppelDict(one=1)  # (2)
>>> dd
{'one': 1}
>>> dd['two'] = 2  # (3)
>>> dd
{'one': 1, 'two': [2, 2]}
>>> dd.update(three=3)  # (4)
>>> dd
{'three': 3, 'one': 1, 'two': [2, 2]}
  1. DoppelDict.__setitem__ duplica os valores ao armazená-los (por nenhuma razão em especial, apenas para termos um efeito visível). Ele funciona delegando para a superclasse.

  2. O método __init__, herdado de dict, claramente ignora que __setitem__ foi sobreposto: o valor de 'one' não foi duplicado.

  3. O operador [] chama nosso __setitem__ e funciona como esperado: 'two' está mapeado para o valor duplicado [2, 2].

  4. O método update de dict também não usa nossa versão de __setitem__: o valor de 'three' não foi duplicado.

Esse comportamento dos tipos embutidos é uma violação de uma regra básica da programação orientada a objetos: a busca por métodos deveria sempre começar pela classe do receptor (self), mesmo quando a chamada ocorre dentro de um método implementado na superclasse. Isso é o que se chama "vinculação tardia" ("late binding"), que Alan Kay—um dos criadores do Smalltalk—considera ser uma característica básica da programação orientada a objetos: em qualquer chamada na forma x.method(), o método exato a ser chamado deve ser determinado durante a execução, baseado na classe do receptor x.[172] Este triste estado de coisas contribui para os problemas que vimos na Seção 3.5.3.

O problema não está limitado a chamadas dentro de uma instância—saber se self.get() chama self.getitem()—mas também acontece com métodos sobrepostos de outras classes que deveriam ser chamados por métodos embutidos. O Exemplo 22 foi adaptado da documentação do PyPy (EN).

Exemplo 22. O __getitem__ de AnswerDict é ignorado por dict.update
>>> class AnswerDict(dict):
...     def __getitem__(self, key):  # (1)
...         return 42
...
>>> ad = AnswerDict(a='foo')  # (2)
>>> ad['a']  # (3)
42
>>> d = {}
>>> d.update(ad)  # (4)
>>> d['a']  # (5)
'foo'
>>> d
{'a': 'foo'}
  1. AnswerDict.__getitem__ sempre devolve 42, independente da chave.

  2. ad é um AnswerDict carregado com o par chave-valor ('a', 'foo').

  3. ad['a'] devolve 42, como esperado.

  4. d é uma instância direta de dict, que atualizamos com ad.

  5. O método dict.update ignora nosso AnswerDict.__getitem__.

⚠️ Aviso

Criar subclasses diretamente de tipos embutidos como dict ou list ou str é um processo propenso ao erro, pois os métodos embutidos quase sempre ignoram as sobreposições definidas pelo usuário. Em vez de criar subclasses de tipos embutidos, derive suas classes do módulo collections, usando as classes UserDict, UserList, e UserString, que foram projetadas para serem fáceis de estender.

Se você criar uma subclasse de collections.UserDict em vez de dict, os problemas expostos no Exemplo 21 e no Exemplo 22 desaparecem. Veja o Exemplo 23.

Exemplo 23. DoppelDict2 and AnswerDict2 funcionam como esperado, porque estendem UserDict e não dict
>>> import collections
>>>
>>> class DoppelDict2(collections.UserDict):
...     def __setitem__(self, key, value):
...         super().__setitem__(key, [value] * 2)
...
>>> dd = DoppelDict2(one=1)
>>> dd
{'one': [1, 1]}
>>> dd['two'] = 2
>>> dd
{'two': [2, 2], 'one': [1, 1]}
>>> dd.update(three=3)
>>> dd
{'two': [2, 2], 'three': [3, 3], 'one': [1, 1]}
>>>
>>> class AnswerDict2(collections.UserDict):
...     def __getitem__(self, key):
...         return 42
...
>>> ad = AnswerDict2(a='foo')
>>> ad['a']
42
>>> d = {}
>>> d.update(ad)
>>> d['a']
42
>>> d
{'a': 42}

Como um experimento, para medir o trabalho extra necessário para criar uma subclasse de um tipo embutido, reescrevi a classe StrKeyDict do Exemplo 9, para torná-la uma subclasse de dict em vez de UserDict. Para fazê-la passar pelo mesmo banco de testes, tive que implementar __init__, get, e update, pois as versões herdadas de dict se recusaram a cooperar com os métodos sobrepostos __missing__, __contains__ e __setitem__. A subclasse de UserDict no Exemplo 9 tem 16 linhas, enquanto a subclasse experimental de dict acabou com 33 linhas.[173]

Para deixar claro: essa seção tratou de um problema que se aplica apenas à delegação a métodos dentro do código em C dos tipos embutidos, e afeta apenas classes derivadas diretamente daqueles tipos. Se você criar uma subclasse de uma classe escrita em Python, tal como UserDict ou MutableMapping, não vai encontrar esse problema.[174]

Vamos agora examinar uma questão que aparece na herança múltipla: se uma classe tem duas superclasses, como o Python decide qual atributo usar quando invocamos super().attr, mas ambas as superclasses tem um atributo com esse nome?

14.4. Herança múltipla e a Ordem de Resolução de Métodos

Qualquer linguagem que implemente herança múltipla precisa lidar com o potencial conflito de nomes, quando superclasses contêm métodos com nomes iguais. Isso é chamado "o problema do diamante", ilustrado na Figura 9 e no Exemplo 24.

UML do problema do diamante
Figura 9. Esquerda: Sequência de ativação para a chamada leaf1.ping(). Direita: Sequência de ativação para a chamada leaf1.pong().
Exemplo 24. diamond.py: classes Leaf, A, B, Root formam o grafo na Figura 9
class Root:  # (1)
    def ping(self):
        print(f'{self}.ping() in Root')

    def pong(self):
        print(f'{self}.pong() in Root')

    def __repr__(self):
        cls_name = type(self).__name__
        return f'<instance of {cls_name}>'


class A(Root):  # (2)
    def ping(self):
        print(f'{self}.ping() in A')
        super().ping()

    def pong(self):
        print(f'{self}.pong() in A')
        super().pong()


class B(Root):  # (3)
    def ping(self):
        print(f'{self}.ping() in B')
        super().ping()

    def pong(self):
        print(f'{self}.pong() in B')


class Leaf(A, B):  # (4)
    def ping(self):
        print(f'{self}.ping() in Leaf')
        super().ping()
  1. Root fornece ping, pong, e __repr__ (para facilitar a leitura da saída).

  2. Os métodos ping e pong na classe A chamam super().

  3. Apenas o método ping na classe B chama super().

  4. A classe Leaf implementa apenas ping, e chama super().

Vejamos agora o efeito da invocação dos métodos ping e pong em uma instância de Leaf (Exemplo 25).

Exemplo 25. Doctests para chamadas a ping e pong em um objeto Leaf
    >>> leaf1 = Leaf()  # (1)
    >>> leaf1.ping()    # (2)
    <instance of Leaf>.ping() in Leaf
    <instance of Leaf>.ping() in A
    <instance of Leaf>.ping() in B
    <instance of Leaf>.ping() in Root

    >>> leaf1.pong()   # (3)
    <instance of Leaf>.pong() in A
    <instance of Leaf>.pong() in B
  1. leaf1 é uma instância de Leaf.

  2. Chamar leaf1.ping() ativa os métodos ping em Leaf, A, B, e Root, porque os métodos ping nas três primeiras classes chamam super().ping().

  3. Chamar leaf1.pong() ativa pong em A através da herança, que por sua vez chama super.pong(), ativando B.pong.

As sequências de ativação que aparecem no Exemplo 25 e na Figura 9 são determinadas por dois fatores:

  • A ordem de resolução de métodos da classe Leaf.

  • O uso de super() em cada método.

Todas as classes possuem um atributo chamado __mro__, que mantém uma tupla de referências a superclasses, na ordem de resolução dos métodos, indo desde a classe corrente até a classe object.[175] Para a classe Leaf class, o __mro__ é o seguinte:

>>> Leaf.__mro__  # doctest:+NORMALIZE_WHITESPACE
    (<class 'diamond1.Leaf'>, <class 'diamond1.A'>, <class 'diamond1.B'>,
     <class 'diamond1.Root'>, <class 'object'>)
✒️ Nota

Olhando para a Figura 9, pode parecer que a MRO descreve uma busca em largura (ou amplitude), mas isso é apenas uma coincidência para essa hierarquia de classes em particular. A MRO é computada por um algoritmo conhecido, chamado C3. Seu uso no Python está detalhado no artigo "The Python 2.3 Method Resolution Order" (A Ordem de Resolução de Métodos no Python 2.3), de Michele Simionato. É um texto difícil, mas Simionato escreve: "…​a menos que você faça amplo uso de herança múltipla e mantenha hierarquias não-triviais, não é necessário entender o algoritmo C3, e você pode facilmente ignorar este artigo."

A MRO determina apenas a ordem de ativação, mas se um método específico será ou não ativado em cada uma das classes vai depender de cada implementação chamar ou não super().

Considere o experimento com o método pong. A classe Leaf não sobrepõe aquele método, então a chamada leaf1.pong() ativa a implementação na próxima classe listada em Leaf.__mro__: a classe A. O método A.pong chama super().pong(). A classe B class é e próxima na MRO, portanto B.pong é ativado. Mas aquele método não chama super().pong(), então a sequência de ativação termina ali.

Além do grafo de herança, a MRO também leva em consideração a ordem na qual as superclasses aparecem na declaração da uma subclasse. Em outras palavras, se em diamond.py (no Exemplo 24) a classe Leaf fosse declarada como Leaf(B, A), daí a classe B apareceria antes de A em Leaf.__mro__. Isso afetaria a ordem de ativação dos métodos ping, e também faria leaf1.pong() ativar B.pong através da herança, mas A.pong e Root.pong nunca seriam executados, porque B.pong não chama super().

Quando um método invoca super(), ele é um método cooperativo. Métodos cooperativos permitem a herança múltipla cooperativa. Esses termos são intencionais: para funcionar, a herança múltipla no Python exige a cooperação ativa dos métodos envolvidos. Na classe B, ping coopera, mas pong não.

⚠️ Aviso

Um método não-cooperativo pode ser a causa de bugs sutis. Muitos programadores, lendo o Exemplo 24, poderiam esperar que, quando o método A.pong invoca super.pong(), isso acabaria por ativar Root.pong. Mas se B.pong for ativado antes, ele deixa a bola cair. Por isso, é recomendado que todo método m de uma classe não-base chame super().m().

Métodos cooperativos devem ter assinaturas compatíveis, porque nunca se sabe se A.ping será chamado antes ou depois de B.ping. A sequência de ativação depende da ordem de A e B na declaração de cada subclasse que herda de ambos.

O Python é uma linguagem dinâmica, então a interação de super() com a MRO também é dinâmica. O Exemplo 26 mostra um resultado surpreendente desse comportamento dinâmico.

Exemplo 26. diamond2.py: classes para demonstrar a natureza dinâmica de super()
from diamond import A  # (1)

class U():  # (2)
    def ping(self):
        print(f'{self}.ping() in U')
        super().ping()  # (3)

class LeafUA(U, A):  # (4)
    def ping(self):
        print(f'{self}.ping() in LeafUA')
        super().ping()
  1. A classe A vem de diamond.py (no Exemplo 24).

  2. A classe U não tem relação com A ou`Root` do módulo diamond.

  3. O que super().ping() faz? Resposta: depende. Continue lendo.

  4. LeafUA é subclasse de U e A, nessa ordem.

Se você criar uma instância de U e tentar chamar ping, ocorre um erro:

    >>> u = U()
    >>> u.ping()
    Traceback (most recent call last):
      ...
    AttributeError: 'super' object has no attribute 'ping'

O objeto 'super' devolvido por super() não tem um atributo 'ping', porque o MRO de U tem duas classes: U e object, e este último não tem um atributo chamado 'ping'.

Entretanto, o método U.ping não é inteiramente sem solução. Veja isso:

    >>> leaf2 = LeafUA()
    >>> leaf2.ping()
    <instance of LeafUA>.ping() in LeafUA
    <instance of LeafUA>.ping() in U
    <instance of LeafUA>.ping() in A
    <instance of LeafUA>.ping() in Root
    >>> LeafUA.__mro__  # doctest:+NORMALIZE_WHITESPACE
    (<class 'diamond2.LeafUA'>, <class 'diamond2.U'>,
     <class 'diamond.A'>, <class 'diamond.Root'>, <class 'object'>)

A chamada super().ping() em LeafUA ativa U.ping, que também coopera chamando super().ping(), ativando A.ping e, por fim, Root.ping.

Observe que as clsses base de LeafUA são (U, A), nessa ordem. Se em vez disso as bases fossem (A, U), daí leaf2.ping() nunca chegaria a U.ping, porque o super().ping() em A.ping ativaria Root.ping, e esse último não chama super().

Em um programa real, uma classe como U poderia ser uma classe mixin: uma classe projetada para ser usada junto com outras classes em herança múltipla, fornecendo funcionalidade adicional. Vamos estudar isso em breve, na Seção 14.5.

Para concluir essa discussão sobre a MRO, a Figura 10 ilustra parte do complexo grafo de herança múltipla do toolkit de interface gráfica Tkinter, da biblioteca padrão do Python.

UML do componente Text do Tkinter
Figura 10. Esquerda: diagrama UML da classe e das superclasses do componente Text do Tkinter. Direita: O longo e sinuoso caminho de Text.__mro__, desenhado com as setas pontilhadas.

Para estudar a figura, comece pela classe Text, na parte inferior. A classe Text implementa um componente de texto completo, editável e com múltiplas linhas. Ele sozinho fornece muita funcionalidade, mas também herda muitos métodos de outras classes. A imagem à esquerda mostra um diagrama de classe UML simples. À direita, a mesma imagem é decorada com setas mostrando a MRO, como listada no Exemplo 27 com a ajuda de uma função de conveniência print_mro.

Exemplo 27. MRO de tkinter.Text
>>> def print_mro(cls):
...     print(', '.join(c.__name__ for c in cls.__mro__))
>>> import tkinter
>>> print_mro(tkinter.Text)
Text, Widget, BaseWidget, Misc, Pack, Place, Grid, XView, YView, object

Vamos agora falar sobre mixins.

14.5. Classes mixin

Uma classe mixin é projetada para ser herdada em conjunto com pelo menos uma outra classe, em um arranjo de herança múltipla. Uma mixin não é feita para ser a única classe base de uma classe concreta, pois não fornece toda a funcionalidade para um objeto concreto, apenas adicionando ou personalizando o comportamento de classes filhas ou irmãs.

✒️ Nota

Classes mixin são uma convenção sem qualquer suporte explícito no Python e no C++. O Ruby permite a definição explícita e o uso de módulos que funcionam como mixins—coleções de métodos que podem ser incluídas para adicionar funcionalidade a uma classe. C#, PHP, e Rust implementam traits (características ou traços ou aspectos), que são também uma forma explícita de mixin.

Vamos ver um exemplo simples mas conveniente de uma classe mixin.

14.5.1. Mapeamentos maiúsculos

O Exemplo 28 mostra a UpperCaseMixin, uma classe criada para fornecer acesso indiferente a maiúsculas/minúsculas para mapeamentos com chaves do tipo string, convertendo todas as chaves para maiúsculas quando elas são adicionadas ou consultadas.

Exemplo 28. uppermixin.py: UpperCaseMixin suporta mapeamentos indiferentes a maiúsculas/minúsculas
import collections

def _upper(key):  # (1)
    try:
        return key.upper()
    except AttributeError:
        return key

class UpperCaseMixin:  # (2)
    def __setitem__(self, key, item):
        super().__setitem__(_upper(key), item)

    def __getitem__(self, key):
        return super().__getitem__(_upper(key))

    def get(self, key, default=None):
        return super().get(_upper(key), default)

    def __contains__(self, key):
        return super().__contains__(_upper(key))
  1. Essa função auxiliar recebe uma key de qualquer tipo e tenta devolver key.upper(); se isso falha, devolve a key inalterada.

  2. A mixin implementa quatro métodos essenciais de mapeamentos, sempre chamando `super()`com a chave em maiúsculas, se possível.

Como todos os métodos de UpperCaseMixin chamam super(), esta mixin depende de uma classe irmã que implemente ou herde métodos com a mesma assinatura. Para dar sua contribuição, uma mixin normalmente precisa aparecer antes de outras classes na MRO de uma subclasse que a use. Na prática, isso significa que mixins devem aparecer primeiro na tupla de classes base em uma declaração de classe. O Exemplo 29 apresenta dois exemplos.

Exemplo 29. uppermixin.py: duas classes que usam UpperCaseMixin
class UpperDict(UpperCaseMixin, collections.UserDict):  # (1)
    pass

class UpperCounter(UpperCaseMixin, collections.Counter):  # (2)
    """Specialized 'Counter' that uppercases string keys"""  # (3)
  1. UpperDict não precisa de qualquer implementação própria, mas UpperCaseMixin deve ser a primeira classe base, caso contrário os métodos chamados seriam os de UserDict.

  2. UpperCaseMixin também funciona com Counter.

  3. Em vez de pass, é melhor fornecer uma docstring para satisfazer a necessidade sintática de um corpo não-vazio na declaração class.

Aqui estão alguns doctests de uppermixin.py, para UpperDict:

    >>> d = UpperDict([('a', 'letter A'), (2, 'digit two')])
    >>> list(d.keys())
    ['A', 2]
    >>> d['b'] = 'letter B'
    >>> 'b' in d
    True
    >>> d['a'], d.get('B')
    ('letter A', 'letter B')
    >>> list(d.keys())
    ['A', 2, 'B']

E uma rápida demonstração de UpperCounter:

    >>> c = UpperCounter('BaNanA')
    >>> c.most_common()
    [('A', 3), ('N', 2), ('B', 1)]

UpperDict e UpperCounter parecem quase mágica, mas tive que estudar cuidadosamente o código de UserDict e Counter para fazer UpperCaseMixin trabalhar com eles.

Por exemplo, minha primeira versão de UpperCaseMixin não incluía o método get. Aquela versão funcionava com UserDict, mas não com Counter. A classe UserDict herda get de collections.abc.Mapping, e aquele get chama __getitem__, que implementei. Mas as chaves não eram transformadas em maiúsculas quando uma UpperCounter era carregada no __init__. Isso acontecia porque Counter.__init__ usa Counter.update, que por sua vez recorre ao método get herdado de dict. Entretanto, o método get na classe dict não chama __getitem__. Esse é o núcleo do problema discutido na Seção 3.5.3. É também uma dura advertência sobre a natureza frágil e intrincada de programas que se apoiam na herança, mesmo nessa pequena escala.

A próxima seção apresenta vários exemplos de herança múltipla, muitas vezes usando classes mixin.

14.6. Herança múltipla no mundo real

No livro Design Patterns ("Padrões de Projetos"),[176] quase todo o código está em C++, mas o único exemplo de herança múltipla é o padrão Adapter ("Adaptador"). Em Python a herança múltipla também não é regra, mas há exemplos importantes, que comentarei nessa seção.

14.6.1. ABCs também são mixins

Na biblioteca padrão do Python, o uso mais visível de herança múltipla é o pacote collections.abc. Nenhuma controvérsia aqui: afinal, até o Java suporta herança múltipla de interfaces, e ABCs são declarações de interface que podem, opcionalmente, fornecer implementações concretas de métodos.[177]

A documentação oficial do Python para collections.abc (EN) usa o termo mixin method ("método mixin") para os métodos concretos implementados em muitas das coleções nas ABCs. As ABCs que oferecem métodos mixin cumprem dois papéis: elas são definições de interfaces e também classes mixin. Por exemplo, a implementação de collections.UserDict (EN) recorre a vários dos métodos mixim fornecidos por collections.abc.MutableMapping.

14.6.2. ThreadingMixIn e ForkingMixIn

O pacote http.server inclui as classes HTTPServer e ThreadingHTTPServer. Essa última foi adicionada ao Python 3.7. Sua documentação diz:

classe http.server.ThreadingHTTPServer(server_address, RequestHandlerClass)

Essa classe é idêntica a HTTPServer, mas trata requisições com threads, usando a ThreadingMixIn. Isso é útil para lidar com navegadores web que abrem sockets prematuramente, situação na qual o HTTPServer esperaria indefinidamente.

Este é o código-fonte completo da classe ThreadingHTTPServer no Python 3.10:

class ThreadingHTTPServer(socketserver.ThreadingMixIn, HTTPServer):
    daemon_threads = True

O código-fonte de socketserver.ThreadingMixIn tem 38 linhas, incluindo os comentários e as docstrings. O Exemplo 30 apresenta um resumo de sua implementação.

Exemplo 30. Parte de Lib/socketserver.py no Python 3.10
class ThreadingMixIn:
    """Mixin class to handle each request in a new thread."""

    # 8 lines omitted in book listing

    def process_request_thread(self, request, client_address):  # (1)
        ... # 6 lines omitted in book listing

    def process_request(self, request, client_address):  # (2)
        ... # 8 lines omitted in book listing

    def server_close(self):  # (3)
        super().server_close()
        self._threads.join()
  1. process_request_thread não chama super() porque é um método novo, não uma sobreposição. Sua implementação chama três métodos de instância que HTTPServer oferece ou herda.

  2. Isso sobrepõe o método process_request, que HTTPServer herda de socketserver.BaseServer, iniciando uma thread e delegando o trabalho efetivo para a process_request_thread que roda naquela thread. O método não chama super().

  3. server_close chama super().server_close() para parar de receber requisições, e então espera que as threads iniciadas por process_request terminem sua execução.

A ThreadingMixIn aparece junto com ForkingMixIn na documentação do módulo socketserver. Essa última classe foi projetada para suportar servidores concorrentes baseados na os.fork(), uma API para iniciar processos filhos, disponível em sistemas Unix (ou similares) compatíveis com a POSIX.

14.6.3. Mixins de views genéricas no Django

✒️ Nota

Não é necessário entender de Django para acompanhar essa seção. Uso uma pequena parte do framework como um exemplo prático de herança múltipla, e tentarei fornecer todo o pano de fundo necessário (supondo que você tenha alguma experiência com desenvolvimento web no lado servidor, com qualquer linguagem ou framework).

No Django, uma view é um objeto invocável que recebe um argumento request—um objeto representando uma requisição HTTP—e devolve um objeto representando uma resposta HTTP. Nosso interesse aqui são as diferentes respostas. Elas podem ser tão simples quanto um redirecionamento, sem nenhum conteúdo em seu corpo, ou tão complexas quando uma página de catálogo de uma loja online, renderizada a partir de uma template HTML e listando múltiplas mercadorias, com botões de compra e links para páginas com detalhes.

Originalmente, o Django oferecia uma série de funções, chamadas views genéricas, que implementavam alguns casos de uso comuns. Por exemplo, muitos sites precisam exibir resultados de busca que incluem dados de inúmeros itens, com listagens ocupando múltiplas páginas, cada resultado contendo também um link para uma página de informações detalhadas sobre aquele item. No Django, uma view de lista e uma view de detalhes são feitas para funcionarem juntas, resolvendo esse problema: uma view de lista renderiza resultados de busca , e uma view de detalhes produz uma página para cada item individual.

Entretanto, as views genéricas originais eram funções, então não eram extensíveis. Se quiséssemos algo algo similar mas não exatamente igual a uma view de lista genérica, era preciso começar do zero.

O conceito de views baseadas em classes foi introduzido no Django 1.3, juntamente com um conjunto de classes de views genéricas divididas em classes base, mixins e classes concretas prontas para o uso. No Django 3.2, as classes base e as mixins estão no módulo base do pacote django.views.generic, ilustrado na Figura 11. No topo do diagrama vemos duas classes que se encarregam de responsabilidades muito diferentes: View e TemplateResponseMixin.

Diagrama de classes UML do módulo `django.views.generic.base`.
Figura 11. Diagrama de classes UML do módulo django.views.generic.base.
👉 Dica

Um recurso fantástico para estudar essas classes é o site Classy Class-Based Views (EN), onde se pode navegar por elas facilmente, ver todos os métodos em cada classe (métodos herdados, sobrepostos e adicionados), os diagramas de classes, consultar sua documentação e estudar seu código-fonte no GitHub.

View é a classe base de todas as views (ela poderia ser uma ABC), e oferece funcionalidade essencial como o método dispatch, que delega para métodos de "tratamento" como get, head, post, etc., implementados por subclasses concretas para tratar os diversos verbos HTTP.[178] A classe RedirectView herda apenas de View, e podemos ver que ela implementa get, head, post, etc.

Se é esperado que as subclasses concretas de View implementem os métodos de tratamento, por que aqueles métodos não são parte da interface de View? A razão: subclasses são livres para implementar apenas os métodos de tratamento que querem suportar. Uma TemplateView é usada apenas para exibir conteúdo, então ela implementa apenas get. Se uma requisição HTTP POST é enviada para uma TemplateView, o método herdado View.dispatch verifica que não há um método de tratamento para post, e produz uma resposta HTTP 405 Method Not Allowed.[179]

A TemplateResponseMixin fornece funcionalidade que interessa apenas a views que precisam usar uma template. Uma RedirectView, por exemplo, não tem qualquer conteúdo em seu corpo, então não precisa de uma template e não herda dessa mixin. TemplateResponseMixin fornece comportamentos para TemplateView e outras views que renderizam templates, tal como ListView, DetailView, etc., definidas nos sub-pacotes de django.views.generic. A Figura 12 mostra o módulo django.views.generic.list e parte do módulo base.

Para usuários do Django, a classe mais importante na Figura 12 é ListView, uma classe agregada sem qualquer código (seu corpo é apenas uma docstring). Quando instanciada, uma ListView tem um atributo de instância object_list, através do qual a template pode interagir para mostrar o conteúdo da página, normalmente o resultado de uma consulta a um banco de dados, composto de múltiplos objetos. Toda a funcionalidade relacionada com a geração desse iterável de objetos vem da MultipleObjectMixin. Essa mixin também oferece uma lógica complexa de paginação—para exibir parte dos resultados em uma página e links para mais páginas.

Suponha que você queira criar uma view que não irá renderizar uma template, mas sim produzir uma lista de objetos em formato JSON. Para isso existe BaseListView. Ela oferece um ponto inicial de extensão fácil de usar, unindo a funcionalidade de View e de MultipleObjectMixin, mas sem a sobrecarga do mecanismo de templates.

A API de views baseadas em classes do Django é um exemplo melhor de herança múltipla que o Tkinter. Em especial, é fácil entender suas classes mixin: cada uma tem um propósito bem definido, e todos os seus nomes contêm o sufixo …Mixin.

Diagram de classe para `django.views.generic.list`
Figura 12. Diagrama de classe UML para o módulo django.views.generic.list. Aqui as três classes do módulo base aparecem recolhidas (veja a Figura 11). A classe ListView não tem métodos ou atributos: é uma classe agregada.

Views baseadas em classes não são universalmente aceitas por usuários do Django. Muitos as usam de forma limitada, como caixas opacas. Mas quando é necessário criar algo novo, muitos programadores Django continuam criando funções monolíticas de views, para abarcar todas aquelas responsabilidades, ao invés de tentar reutilizar as views base e as mixins.

Demora um certo tempo para aprender a usar as views baseadas em classes e a forma de estendê-las para suprir necessidades específicas de uma aplicação, mas considero que vale a pena estudá-las. Elas eliminam muito código repetitivo, tornam mais fácil reutilizar soluções, e melhoram até a comunicação das equipes—por exemplo, pela definição de nomes padronizados para as templates e para as variáveis passadas para contextos de templates. Views baseadas em classes são views do Django "on rails"[180].

14.6.4. Herança múltipla no Tkinter

Um exemplo extremo de herança múltipla na biblioteca padrão do Python é o toolkit de interface gráfica Tkinter. Usei parte da hierarquia de componentes do Tkinter para ilustrar a MRO na Figura 10. A Figura 13 mostra todos as classes de componentes no pacote base tkinter (há mais componentes gráficos no subpacote tkinter.ttk).

Diagrama de classes UML dos componentes do Tkinter
Figura 13. Diagrama de classes resumido da hierarquia de classes de interface gráfica do Tkinter; classes etiquetadas com «mixin» são projetadas para oferecer metodos concretos a outras classes, através de herança múltipla.

No momento em que escrevo essa seção, o Tkinter já tem 25 anos de idade. Ele não é um exemplo das melhores práticas atuais. Mas mostra como a herança múltipla era usada quando os programadores ainda não conheciam suas desvantagens. E vai nos servir de contra-exemplo, quando tratarmos de algumas boas práticas, na próxima seção.

Considere as seguintes classes na Figura 13:

Toplevel: A classe de uma janela principal em um aplicação Tkinter.

Widget: A superclasse de todos os objetos visíveis que podem ser colocados em uma janela.

Button: Um componente de botão simples.

Entry: Um campo de texto editável de uma única linha.

Text: Um campo de texto editável de múltiplas linhas.

Aqui estão as MROs dessas classes, como exibidas pela função print_mro do Exemplo 27:

>>> import tkinter
>>> print_mro(tkinter.Toplevel)
Toplevel, BaseWidget, Misc, Wm, object
>>> print_mro(tkinter.Widget)
Widget, BaseWidget, Misc, Pack, Place, Grid, object
>>> print_mro(tkinter.Button)
Button, Widget, BaseWidget, Misc, Pack, Place, Grid, object
>>> print_mro(tkinter.Entry)
Entry, Widget, BaseWidget, Misc, Pack, Place, Grid, XView, object
>>> print_mro(tkinter.Text)
Text, Widget, BaseWidget, Misc, Pack, Place, Grid, XView, YView, object
✒️ Nota

Pelos padrões atuais, a hierarquia de classes do Tkinter é muito profunda. Poucas partes da bilbioteca padrão do Python tem mais que três ou quatro níveis de classes concretas, e o mesmo pode ser dito da biblioteca de classes do Java. Entretanto, é interessante observar que algumas das hierarquias mais profundas da biblioteca de classes do Java são precisamente os pacotes relacionados à programação de interfaces gráficas: java.awt e javax.swing. O Squeak, uma versão moderna e aberta do Smalltalk, inclui o poderoso e inovador toolkit de interface gráfica Morphic, também com uma hierarquia de classes profunda. Na minha experiência, é nos toolkits de interface gráfica que a herança é mais útil.

Observe como essas classes se relacionam com outras:

  • Toplevel é a única classe gráfica que não herda de Widget, porque ela é a janela primária e não se comporta como um componente; por exemplo, ela não pode ser anexada a uma janela ou moldura (frame). Toplevel herda de Wm, que fornece funções de acesso direto ao gerenciador de janelas do ambiente, para tarefas como definir o título da janela e configurar suas bordas.

  • Widget herda diretamente de BaseWidget e de Pack, Place, e Grid. As últimas três classes são gerenciadores de geometria: são responsáveis por organizar componentes dentro de uma janela ou moldura. Cada uma delas encapsula uma estratégia de layout e uma API de colocação de componentes diferente.

  • Button, como a maioria dos componentes, descende diretamente apenas de Widget, mas indiretamente de Misc, que fornece dezenas de métodos para todos os componentes.

  • Entry é subclasse de Widget e XView, que suporta rolagem horizontal.

  • Text é subclasse de Widget, XView e YView (para rolagem vertical).

Vamos agora discutir algumas boas práticas de herança múltipla e examinar se o Tkinter as segue.

14.7. Lidando com a herança

Aquilo que Alan Kay escreveu na epígrafe continua sendo verdade: ainda não existe um teoria geral sobre herança que possa guiar os programadores. O que temos são regras gerais, padrões de projetos, "melhores práticas", acrônimos perspicazes, tabus, etc. Alguns desses nos dão orientações úteis, mas nenhum deles é universalmente aceito ou sempre aplicável.

É fácil criar designs frágeis e incompreensíveis usando herança, mesmo sem herança múltipla. Como não temos uma teoria abrangente, aqui estão algumas dicas para evitar grafos de classes parecidos com espaguete.

14.7.1. Prefira a composição de objetos à herança de classes

O título dessa subseção é o segundo princípio do design orientado a objetos, do livro Padrões de Projetos,[181] e é o melhor conselho que posso oferecer aqui. Uma vez que você se sinta confortável com a herança, é fácil usá-la em excesso. Colocar objetos em uma hierarquia elegante apela para nosso senso de ordem; programadores fazem isso por pura diversão.

Preferir a composição leva a designs mais flexíveis. Por exemplo, no caso da classe tkinter.Widget, em vez de herdar os métodos de todos os gerenciadores de geometria, instâncias do componente poderiam manter uma referência para um gerenciador de geometria, e invocar seus métodos. Afinal, um Widget não deveria "ser" um gerenciador de geometria, mas poderia usar os serviços de um deles por delegação. E daí você poderia adicionar um novo gerenciador de geometria sem afetar a hierarquia de classes do componente e sem se preocupar com colisões de nomes. Mesmo com herança simples, este princípio aumenta a flexibilidade, porque a subclasses são uma forma de acoplamento forte, e árvores de herança muito altas tendem a ser frágeis.

A composição e a delegação podem substituir o uso de mixins para tornar comportamentos disponíveis para diferentes classes, mas não podem substituir o uso de herança de interfaces para definir uma hierarquia de tipos.

14.7.2. Em cada caso, entenda o motivo do uso da herança

Ao lidarmos com herança múltipla, é útil ter claras as razões pelas quais subclasses são criadas em cada caso específico. As principais razões são:

  • Herança de interface cria um subtipo, implicando em uma relação "é-um". A melhor forma de fazer isso é usando ABCs.

  • Herança de implementação evita duplicação de código pela reutilização. Mixins podem ajudar nisso.

Na prática, frequentemente ambos os usos são simultâneos, mas sempre que você puder tornar a intenção clara, vá em frente. Herança para reutilização de código é um detalhe de implementação, e muitas vezes pode ser substituída por composição e delegação. Por outro lado, herança de interfaces é o fundamento de qualquer framework. Se possível, a herança de interfaces deveria usar apenas ABCs como classes base.

14.7.3. Torne a interface explícita com ABCs

No Python moderno, se uma classe tem por objetivo definir uma interface, ela deveria ser explicitamente uma ABC ou uma subclasse de typing.Protocol. Uma ABC deveria ser subclasse apenas de abc.ABC ou de outras ABCs. A herança múltipla de ABCs não é problemática.

14.7.4. Use mixins explícitas para reutilizar código

Se uma classe é projetada para fornecer implementações de métodos para reutilização por múltiplas subclasses não relacionadas, sem implicar em uma relação do tipo "é-uma", ele deveria ser uma classe mixin explícita. Conceitualmente, uma mixin não define um novo tipo; ela simplesmente empacota métodos para reutilização. Uma mixin não deveria nunca ser instanciada, e classes concretas não devem herdar apenas de uma mixin. Cada mixin deveria fornecer um único comportamento específico, implementando poucos métodos intimamente relacionados. Mixins devem evitar manter qualquer estado interno; isto é, uma classe mixin não deve ter atributos de instância.

No Python, não há uma maneira formal de declarar uma classe como mixin. Assim, é fortemente recomendado que seus nomes incluam o sufixo Mixin.

14.7.5. Ofereça classes agregadas aos usuários

Uma classe construída principalmente herdando de mixins, sem adicionar estrutura ou comportamento próprios, é chamada de classe agregada.[182]

— Grady Booch et al.
Object-Oriented Analysis and Design with Applications

Se alguma combinação de ABCs ou mixins for especialmente útil para o código cliente, ofereça uma classe que una essas funcionalidades de uma forma sensata.

Por exemplo, aqui está o código-fonte completo da classe ListView do Django, do canto inferior direito da Figura 12:

class ListView(MultipleObjectTemplateResponseMixin, BaseListView):
    """
    Render some list of objects, set by `self.model` or `self.queryset`.
    `self.queryset` can actually be any iterable of items, not just a queryset.
    """

O corpo de ListView é vazio[183], mas a classe fornece um serviço útil: ela une uma mixin e uma classe base que devem ser usadas em conjunto.

Outro exemplo é tkinter.Widget, que tem quatro classes base e nenhum método ou atributo próprios—apenas uma docstring. Graças à classe agregada Widget, podemos criar um novo componente com as mixins necessárias, sem precisar descobrir em que ordem elas devem ser declaradas para funcionarem como desejado.

Observe que classes agregadas não precisam ser inteiramente vazias (mas frequentemente são).

14.7.6. Só crie subclasses de classes criadas para serem herdadas

Em um comentário sobre esse capítulo, o revisor técnico Leonardo Rochael sugeriu o alerta abaixo.

⚠️ Aviso

Criar subclasses e sobrepor métodos de qualquer classe complexa é um processo muito suscetível a erros, porque os métodos da superclasse podem ignorar as sobreposições da subclasse de formas inesperadas. Sempre que possível, evite sobrepor métodos, ou pelo menos se limite a criar subclasses de classes projetadas para serem facilmente estendidas, e apenas daquelas formas pelas quais a classe foi desenhada para ser estendida.

É um ótimo conselho, mas como descobrimos se uma classe foi projetada para ser estendida?

A primeira resposta é a documentação (algumas vezes na forma de docstrings ou até de comentários no código). Por exemplo, o pacote socketserver (EN) do Python é descrito como "um framework para servidores de rede". Sua classe BaseServer (EN) foi projetada para a criação de subclasses, como o próprio nome sugere. E mais importante, a documentação e a docstring (EN) no código-fonte da classe informa explicitamente quais de seus métodos foram criados para serem sobrepostos por subclasses.

No Python ≥ 3.8 uma nova forma de tornar tais restrições de projeto explícitas foi oferecida pela PEP 591—Adding a final qualifier to typing (Acrescentando um qualificador "final" à tipagem) (EN). A PEP introduz um decorador @final, que pode ser aplicado a classes ou a métodos individuais, de forma que IDEs ou verificadores de tipo podem identificar tentativas equivocadas de criar subclasses daquelas classes ou de sobrepor aqueles métodos.[184]

14.7.7. Evite criar subclasses de classes concretas

Criar subclasses de classes concretas é mais perigoso que criar subclasses de ABCs e mixins, pois instâncias de classes concretas normalmente tem um estado interno, que pode ser facilmente corrompido se sobrepusermos métodos que dependem daquele estado. Mesmo se nossos métodos cooperarem chamando super(), e o estado interno seja mantido através da sintaxe __x, restarão ainda inúmeras formas pelas quais a sobreposição de um método pode introduzir bugs.

No Pássaros aquáticos e as ABCs, Alex Martelli cita More Effective C++, de Scott Meyer, que diz: "toda classe não-final (não-folha) deveria ser abstrata". Em outras palavras, Meyer recomenda que subclasses deveriam ser criadas apenas a partir de classes abstratas.

Se você precisar usar subclasses para reutilização de código, então o código a ser reutilizado deve estar em métodos mixin de ABCs, ou em classes mixin explicitamente nomeadas.

Vamos agora analisar o Tkinter do ponto de vista dessas recomendações

14.7.8. Tkinter: O bom, o mau e o feio

A[185] maioria dos conselhos da seção anterior não são seguidos pelo Tkinter, com a notável excessão de "Seção 14.7.5". E mesmo assim, esse não é um grande exemplo, pois a composição provavelmente funcionaria melhor para integrar os gerenciadores de geometria a Widget, como discutido na Seção 14.7.1.

Mas lembre-se que o Tkinter é parte da biblioteca padrão desde o Python 1.1, lançado em 1994. O Tkinter é uma camada sobreposta ao excelente toolkit Tk GUI, da linguagem Tcl. O combo Tcl/Tk não é, na origem, orientado a objetos, então a API Tk é basicamente um imenso catálogo de funções. Entretanto, o toolkit é orientado a objetos por projeto, apesar de não o ser em sua implementação Tcl original.

A docstring de tkinter.Widget começa com as palavras "Internal class" (Classe interna). Isso sugere que Widget deveria provavelmente ser uma ABC. Apesar da classe Widget não ter métodos próprios, ela define uma interface. Sua mensagem é: "Você pode contar que todos os componentes do Tkinter vão oferecer os métodos básicos de componente (__init__, destroy, e dezenas de funções da API Tk), além dos métodos de todos os três gerenciadores de geometria". Vamos combinar que essa não é uma boa definição de interface (é abrangente demais), mas ainda assim é uma interface, e Widget a "define" como a união das interfaces de suas superclasses.

A classe Tk, qie encapsula a lógica da aplicação gráfica, herda de Wm e Misc, nenhuma das quais é abstrata ou mixin (Wm não é uma mixin adequada, porque TopLevel é subclasse apenas dela). O nome da classe Misc é, por sí só, um mau sinal. Misc tem mais de 100 métodos, e todos os componentes herdam dela. Por que é necessário que cada um dos componentes tenham métodos para tratamento do clipboard, seleção de texto, gerenciamento de timer e coisas assim? Não é possível colar algo em um botão ou selecionar texto de uma barra de rolagem. Misc deveria ser dividida em várias classes mixin especializadas, e nem todos os componentes deveriam herdar de todas aquelas mixins.

Para ser justo, como usuário do Tkinter você não precisa, de forma alguma, entender ou usar herança múltipla. Ela é um detalhe de implementação, oculto atrás das classes de componentes que serão instanciadas ou usadas como base para subclasses em seu código. Mas você sofrerá as consequências da herança múltipla excessiva quando digitar dir(tkinter.Button) e tentar encontrar um método específico em meio aos 214 atributos listados. E terá que enfrentar a complexidade, caso decida implementar um novo componente Tk.

👉 Dica

Apesar de ter problemas, o Tkinter é estável, flexível, e fornece um visual moderno se você usar o pacote tkinter.ttk e seus componentes tematizados. Além disso, alguns dos componentes originais, como Canvas e Text, são incrivelmente poderosos. Em poucas horas é possível transformar um objeto Canvas em uma aplicação de desenho razoavelmente completa. Se você se interessa pela programação de interfaces gráficas, com certeza vale a pena considerar o Tkinter e o Tcl/Tk.

Aqui termina nossa viagem através do labirinto da herança.

14.8. Resumo do capítulo

Esse capítulo começou com uma revisão da função super() no contexto de herança simples. Daí discutimos o problema da criação de subclasses de tipos embutidos: seus métodos nativos, implementados em C, não invocam os métodos sobrepostos em subclasses, exceto em uns poucos casos especiais. É por isso que, quando precisamos de tipos list, dict, ou str personalizados, é mais fácil criar subclasses de UserList, UserDict, ou UserString—todos definidos no módulo collections—, que na verdade encapsulam os tipos embutidos correspondentes e delegam operações para aqueles—três exemplos a favor da composição sobre a herança na biblioteca padrão. Se o comportamento desejado for muito diferente daquilo que os tipos embutidos oferecem, pode ser mais fácil criar uma subclasse da ABC apropriada em collections.abc, e escrever sua própria implementação.

O restante do capítulo foi dedicado à faca de dois gumes da herança múltipla. Primeiro vimos como a ordem de resolução de métodos, definida no atributo de classe __mro__, trata o problema de conflitos potenciais de nomes em métodos herdados. Também examinamos como a função embutida super() se comporta em hierarquias com herança múltipla, e como ela algumas vezes se comporta de forma inesperada. O comportamento de super() foi projetado para suportar classes mixin, que estudamos usando o exemplo simples de UpperCaseMixin (para mapeamentos indiferentes a maiúsculas/minúsculas).

Exploramos como a herança múltipla e os métodos mixin são usados nas ABCs do Python, bem como nos mixins de threading e forking de socketserver. Usos mais complexos de herança múltipla foram exemplificados com as views baseadas em classes do Django e com o toolkit de interface gráfica Tkinter. Apesar do Tkinter não ser um exemplo das melhores práticas modernas, é um exemplo de hierarquias de classe complexas que podemos encontrar em sistemas legados.

Encerrando o capítulo, apresentamos sete recomendações para lidar com herança, e aplicamos alguns daqueles conselhos em um comentário sobre a hierarquia de classes do Tkinter.

Rejeitar a herança—mesmo a herança simples—é uma tendência moderna. Go é uma das mais bem sucedidas linguagens criadas no século 21. Ela não inclui um elemento chamado "classe", mas você pode construir tipos que são estruturas (structs) de campos encapsulados, e anexar métodos a essas estruturas. Em Go é possível definir interfaces, que são verificadas pelo compilador usando tipagem estrutural, também conhecida como duck typing estática—algo muito similar ao que temos com os tipos protocolo desde o Python 3.8. Essa linguagem também tem uma sintaxe especial para a criação de tipos e interfaces por composição, mas não há suporte a herança—nem entre interfaces.

Então talvez o melhor conselho sobre herança seja: evite-a se puder. Mas, frequentemente, não temos essa opção: os frameworks que usamos nos impõe suas escolhas de design.

14.9. Leitura complementar

No que diz respeito à legibilidade, composição feita de forma adequada é superior a herança. Como é muito mais frequente ler o código que escrevê-lo, como regra geral evite subclasses, mas em especial não misture os vários tipos de herança e não crie subclasses para compartilhar código.

— Hynek Schlawack
Subclassing in Python Redux

Durante a revisão final desse livro, o revisor técnico Jürgen Gmach recomendou o post "Subclassing in Python Redux" (O ressurgimento das subclasses em Python), de Hynek Schlawack—a fonte da citação acima. Schlawack é o autor do popular pacote attrs, e foi um dos principais contribuidores do framework de programação assíncrona Twisted, um projeto criado por Glyph Lefkowitz em 2002. De acordo com Schlawack, após algum tempo os desenvolvedores perceberam que tinham usado subclasses em excesso no projeto. O post é longo, e cita outros posts e palestras importantes. Muito recomendado.

Naquela mesma conclusão, Hynek Schlawack escreve: "Não esqueça que, na maioria dos casos, tudo o que você precisa é de uma função." Concordo, e é precisamente por essa razão que Python Fluente trata em detalhes das funções, antes de falar de classes e herança. Meu objetivo foi mostrar o quanto você pode alcançar com funções se valendo das classes na biblioteca padrão, antes de criar suas próprias classes.

A criação de subclasses de tipos embutidos, a função super, e recursos avançados como descritores e metaclasses, foram todos introduzidos no artigo "Unifying types and classes in Python 2.2" (Unificando tipos e classes em Python 2.2) (EN), de Guido van Rossum. Desde então, nada realmente importante mudou nesses recursos. O Python 2.2 foi uma proeza fantástica de evolução da linguagem, adicionando vários novos recursos poderosos em um todo coerente, sem quebrar a compatibilidade com versões anteriores. Os novo recursos eram 100% opcionais. Para usá-los, bastava programar explicitamente uma subclasse de object—direta ou indiretamente—, para criar uma assim chamada "classe no novo estilo". No Python 3, todas as classes são subclasses de object.

O Python Cookbook, 3ª ed., de David Beazley e Brian K. Jones (O’Reilly) inclui várias receitas mostrando o uso de super() e de classes mixin. Você pode começar pela esclarecedora seção "8.7. Calling a Method on a Parent Class" (Invocando um Método em uma Superclasse), e seguir as referências internas a partir dali.

O post "Python’s super() considered super!" (O super() do Python é mesmo super!) (EN), de Raymond Hettinger, explica o funcionamento de super e a herança múltipla de uma perspectiva positiva. Ele foi escrito em resposta a "Python’s Super is nifty, but you can’t use it (Previously: Python’s Super Considered Harmful)" O Super do Python é bacana, mas você não deve usá-lo (Antes: Super do Python Considerado Nocivo) (EN), de James Knight. A resposta de Martijn Pieters a "How to use super() with one argument?" (Como usar super() com um só argumento?) (EN) inclui uma explicação concisa e aprofundada de super, incluindo sua relação com descritores, um conceito que estudaremos apenas no Capítulo 23. Essa é a natureza de super. Ele é simples de usar em casos de uso básicos, mas é uma ferramenta poderosa e complexa, que alcança alguns dos recursos dinâmicos mais avançados do Python, raramente encontrados em outras linguagens.

Apesar dos títulos daqueles posts, o problema não é exatamente com a função embutida super—que no Python 3 não é tão feia quanto era no Python 2. A questão real é a herança múltipla, algo inerentemente complicado e traiçoeiro. Michele Simionato vai além da crítica, e de fato oferece uma solução em seu "Setting Multiple Inheritance Straight" (Colocando a Herança Múltipla em seu Lugar) (EN): ele implementa traits ("traços"), uma forma explícita de mixin originada na linguagem Self. Simionato escreveu, em seu blog, uma longa série de posts sobre herança múltipla em Python, incluindo "The wonders of cooperative inheritance, or using super in Python 3" (As maravilhas da herança cooperativa, ou usando super em Python 3) (EN); "Mixins considered harmful," part 1 (Mixins consideradas nocivas) (EN) e part 2 (EN); e "Things to Know About Python Super," part 1 (O que você precisa saber sobre o super do Python) (EN), part 2 (EN), e part 3 (EN). Os posts mais antigos usam a sintaxe de super do Python 2, mas ainda são relevantes.

Eu li a primeira edição do Object-Oriented Analysis and Design, 3ª ed., de Grady Booch et al., e o recomendo fortemente como uma introdução geral ao pensamento orientado a objetos, independente da linguagem de programação. É um dos raros livros que trata da herança múltipla sem ideias pré-concebidas.

Hoje, mais que nunca, é de bom tom evitar a herança, então cá estão duas referências sobre como fazer isso. Brandon Rhodes escreveu "The Composition Over Inheritance Principle" (O Princípio da Composição Antes da Herança) (EN), parte de seu excelente guia Python Design Patterns (Padrões de Projetos no Python). Augie Fackler e Nathaniel Manista apresentaram "The End Of Object Inheritance & The Beginning Of A New Modularity" (O Fim da Herança de Objetos & O Início de Uma Nova Modularidade) na PyCon 2013. Fackler e Manista falam sobre organizar sistemas em torno de interfaces e das funções que lidam com os objetos que implementam aquelas interfaces, evitando o acoplamento estreito e os pontos de falha de classes e da herança. Isso me lembra muito a maneira de pensar do Go, mas aqui os autores a defendem para o Python.

Soapbox

Pense nas classes realmente necessárias

[Nós] começamos a defender a ideia de herança como uma maneira de permitir que iniciantes pudessem construir [algo] a partir de frameworks que só poderiam ser projetadas por especialistas[186].

— Alan Kay
The Early History of Smalltalk ("Os Primórdios do Smalltalk")

A imensa maioria dos programadores escreve aplicações, não frameworks. Mesmo aqueles que escrevem frameworks provavelmente passam muito (ou a maior parte) de seu tempo escrevendo aplicações. Quando escrevemos aplicações, normalmente não precisamos criar hierarquias de classes. No máximo escrevemos classes que são subclasses de ABCs ou de outras classes oferecidas pelo framework. Como desenvolvedores de aplicações, é muito raro precisarmos escrever uma classe que funcionará como superclasse de outra. As classes que escrevemos são, quase sempre, "classes folha" (isto é, folhas na árvore de herança).

Se, trabalhando como desenvolvedor de aplicações, você se pegar criando hierarquias de classe de múltiplos níveis, quase certamente uma ou mais das seguintes alternativas se aplica:

  • Você está reinventando a roda. Procure um framework ou biblioteca que forneça componentes que possam ser reutilizados em sua aplicação.

  • Você está usando um framework mal projetada. Procure uma alternativa.

  • Você está complicando demais o processo. Lembre-se do Princípio KISS.

  • Você ficou entediado programando aplicações e decidiu criar um novo framework. Parabéns e boa sorte!

Também é possível que todas as alternativas acima se apliquem à sua situação: você ficou entediado e decidiu reinventar a roda, escrevendo seu próprio framework mal projetado e excessivamente complexo, e está sendo forçado a programar classe após classe para resolver problemas triviais. Espero que você esteja se divertindo, ou pelo menos que esteja sendo pago para fazer isso.

Tipos embutidos mal-comportados: bug ou feature?

Os tipos embutidos dict, list, e str são blocos básicos essenciais do próprio Python, então precisam ser rápidos—qualquer problema de desempenho ali teria severos impactos em praticamente todo o resto. É por isso que o CPython adotou atalhos que fazem com que métodos embutidos se comportem mal, ao não cooperarem com os métodos sobrepostos por subclasses. Um caminho possível para sair desse dilema seria oferecer duas implementações para cada um desses tipos: um "interno", otimizado para uso pelo interpretador, e um externo, facilmente extensível.

Mas isso nós já temos: UserDict, UserList, e UserString não são tão rápidos quanto seus equivalentes embutidos, mas são fáceis de estender. A abordagem pragmática tomada pelo CPython significa que nós também podemos usar, em nossas próprias aplicações, as implementações altamente otimizadas mas difíceis estender. E isso faz sentido, considerando que não é tão frequente precisarmos de um mapeamento, uma lista ou uma string customizados, mas usamos dict, list, e str diariamente. Só precisamos estar cientes dos compromissos envolvidos.

Herança através das linguagens

Alan Kay criou o termo "orientado a objetos", e o Smalltalk tinha apenas herança simples, apesar de existirem versões com diferentes formas de suporte a herança múltipla, incluindo os dialetos modernos de Smalltalk, Squeak e Pharo, que suportam traits ("traços")—um dispositivo de linguagem que pode substituir classes mixin, mas evita alguns dos problemas da herança múltipla.

A primeira linguagem popular a implementar herança múltipla foi o C++, e esse recurso foi abusado o suficiente para que o Java—criado para ser um substituto do C++—fosse projetado sem suporte a herança múltipla de implementação (isto é, sem classes mixin). Quer dizer, isso até o Java 8 introduzir os métodos default, que tornam interfaces muito similares às classes abstratas usadas para definir interfaces em C++ e em Python. Depois do Java, a linguagem da JVM mais usada é provavelmente o Scala, que implementa traits.

Outras linguagens que suportam traits são a última versão estável do PHP e do Groovy, bem como o Rust e o Raku—a linguagem antes conhecida como Perl 6.[187] Então podemos dizer que traits estão na moda em 2021.

O Ruby traz uma perspectiva original para a herança múltipla: não a suporta, mas introduz mixins como um recurso explícito da linguagem. Uma classe Ruby pode incluir um módulo em seu corpo, e aí os métodos definidos no módulo se tornam parte da implementação da classe. Essa é uma forma "pura" de mixin, sem herança envolvida, e está claro que uma mixin Ruby não tem qualquer influência sobre o tipo da classe onde ela é usada. Isso oferece os benefícios das mixins, evitando muitos de seus problemas mais comuns.

Duas novas linguagens orientadas a objetos que estão recebendo muita atenção limitam severamente a herança: Go e Julia. Ambas giram em torno de programar "objetos" implementando "métodos", e suportam polimorfismo, mas evitam o termo "classe".

Go não tem qualquer tipo de herança, mas oferece uma sintaxe que facilita a composição. Julia tem uma hierarquia de tipos, mas subtipos não podem herdar estrutura, apenas comportamentos, e só é permitido criar subtipos de tipos abstratos. Além disso, os métodos de Julia são implementados com despacho múltiplo—uma forma mais avançada do mecanismo que vimos na Seção 9.9.3.

15. Mais dicas de tipo

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

— Guido van Rossum
um fã do Monty Python

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

  • Assinaturas de funções sobrepostas

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

  • Coerção de tipo

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

  • Tipos genéricos

    • Declarando uma classe genérica

    • Variância: tipos invariantes, covariantes e contravariantes

    • Protocolos estáticos genéricos

15.1. Novidades nesse capítulo

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

15.2. Assinaturas sobrepostas

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

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

Considere a função embutida sum. Esse é o texto de help(sum).[189]:

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

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

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

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

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

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

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

Você pode também usar @overload em um modulo Python regular, colocando as assinaturas sobrepostas logo antes da assinatura real da função e de sua implementação. O Exemplo 1 mostra como sum apareceria anotada e implementada em um módulo Python.

Exemplo 1. mysum.py: definição da função sum com assinaturaas sobrepostas
import functools
import operator
from collections.abc import Iterable
from typing import overload, Union, TypeVar

T = TypeVar('T')
S = TypeVar('S')  # (1)

@overload
def sum(it: Iterable[T]) -> Union[T, int]: ...  # (2)
@overload
def sum(it: Iterable[T], /, start: S) -> Union[T, S]: ...  # (3)
def sum(it, /, start=0):  # (4)
    return functools.reduce(operator.add, it, start)
  1. Precisamos deste segundo TypeVar na segunda assinatura.

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

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

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

São muitas linhas para anotar uma função de uma única linha. Sim, eu sei, provavelmente isso é excessivo. Mas pelo menos a função do exemplo não é foo.

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

👉 Dica
Aproveite a tipagem gradual

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

As APIs convenientes e práticas que consideramos pythônicas são muitas vezes difíceis de anotar. Na próxima seção veremos um exemplo: são necessárias seis sobreposições para anotar adequadamente a flexível função embutida max.

15.2.1. Sobreposição máxima

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

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

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

[NT: TypeError: '>' não é suportado entre instâncias de 'int' e 'NoneType']

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

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

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

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

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

Escrevi max em Python para tornar mais fácil ver a relação entre o funcionamento da função e as anotações sobrepostas (a função embutida original é escrita em C); veja o Exemplo 2.

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

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

# overloaded type hints omitted, see next listing

def max(first, *args, key=None, default=MISSING):
    if args:
        series = args
        candidate = first
    else:
        series = iter(first)
        try:
            candidate = next(series)
        except StopIteration:
            if default is not MISSING:
                return default
            raise ValueError(EMPTY_MSG) from None
    if key is None:
        for current in series:
            if candidate < current:
                candidate = current
    else:
        candidate_key = key(candidate)
        for current in series:
            current_key = key(current)
            if candidate_key < current_key:
                candidate = current
                candidate_key = current_key
    return candidate

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

Quando first é um iterável vazio…​

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

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

Para consertar o issue #4051, escrevi o código no Exemplo 3.[190]

Exemplo 3. mymax.py: início do módulo, com importações, definições e sobreposições
from collections.abc import Callable, Iterable
from typing import Protocol, Any, TypeVar, overload, Union

class SupportsLessThan(Protocol):
    def __lt__(self, other: Any) -> bool: ...

T = TypeVar('T')
LT = TypeVar('LT', bound=SupportsLessThan)
DT = TypeVar('DT')

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

@overload
def max(__arg1: LT, __arg2: LT, *args: LT, key: None = ...) -> LT:
    ...
@overload
def max(__arg1: T, __arg2: T, *args: T, key: Callable[[T], LT]) -> T:
    ...
@overload
def max(__iterable: Iterable[LT], *, key: None = ...) -> LT:
    ...
@overload
def max(__iterable: Iterable[T], *, key: Callable[[T], LT]) -> T:
    ...
@overload
def max(__iterable: Iterable[LT], *, key: None = ...,
        default: DT) -> Union[LT, DT]:
    ...
@overload
def max(__iterable: Iterable[T], *, key: Callable[[T], LT],
        default: DT) -> Union[T, DT]:
    ...

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

Um benefício fundamental de @overload é declarar o tipo devolvido da forma mais precisa possível, de acordo com os tipos dos argumentos recebidos. Veremos esse benefício a seguir, estudando as sobreposições de max, em grupos de duas ou três por vez.

Argumentos implementando SupportsLessThan, mas key e default não são fornecidos
@overload
def max(__arg1: LT, __arg2: LT, *_args: LT, key: None = ...) -> LT:
    ...
# ... lines omitted ...
@overload
def max(__iterable: Iterable[LT], *, key: None = ...) -> LT:
    ...

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

Amostras de chamadas que casam com essas sobreposições:

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

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

Amostras de chamadas que casam com essas sobreposições:

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

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

Amostras de chamadas que casam com essas sobreposições:

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

As entradas são:

  • Um Iterable de itens de qualquer tipo T

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

  • Um valor default de qualquer tipo DT

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

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

15.2.2. Lições da sobreposição de max

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

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

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

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

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

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

15.3. TypedDict

⚠️ Aviso

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

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

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

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

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

Dict[str, Any]

Os valores podem ser de qualquer tipo.

Dict[str, Union[str, int, List[str]]]

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

Exemplo 4. books.py: a definição de BookDict
from typing import TypedDict

class BookDict(TypedDict):
    isbn: str
    title: str
    authors: list[str]
    pagecount: int

À primeira vista, typing.TypedDict pode parecer uma fábrica de classes de dados, similar a typing.NamedTuple—tratada no Capítulo 5.

A similaridade sintática é enganosa. TypedDict é muito diferente. Ele existe apenas para o benefício de verificadores de tipo, e não tem qualquer efeito durante a execução.

TypedDict fornece duas coisas:

  • Uma sintaxe similar à de classe para anotar uma dict com dicas de tipo para os valores de cada "campo".

  • Um construtor que informa ao verificador de tipo para esperar um dict com chaves e valores como especificados.

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

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

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

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

  • Definições de métodos não são permitidas.

Vamos explorar o comportamento de um BookDict durante a execução (no Exemplo 5).

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

  2. Oops…​Esqueci que authors deve ser uma lista. Mas tipagem gradual significa que não há checagem de tipo durante a execução.

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

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

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

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

O Exemplo 6 cria um BookDict válido e tenta executar algumas operações com ele. A seguir, o Exemplo 7 mostra como TypedDict permite que o Mypy encontre erros.

Exemplo 6. demo_books.py: operações legais e ilegais em um BookDict
from books import BookDict
from typing import TYPE_CHECKING

def demo() -> None:  # (1)
    book = BookDict(  # (2)
        isbn='0134757599',
        title='Refactoring, 2e',
        authors=['Martin Fowler', 'Kent Beck'],
        pagecount=478
    )
    authors = book['authors'] # (3)
    if TYPE_CHECKING:  # (4)
        reveal_type(authors)  # (5)
    authors = 'Bob'  # (6)
    book['weight'] = 4.2
    del book['title']


if __name__ == '__main__':
    demo()
  1. Lembre-se de adicionar o tipo devolvido, assim o Mypy não ignora a função.

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

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

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

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

  6. As últimas três linhas da função demo são ilegais. Elas vão causar mensagens de erro no Exemplo 7.

Verificando a tipagem em demo_books.py, do Exemplo 6, obtemos o Exemplo 7.

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

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

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

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

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

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

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

Se você estivesse escrevendo o código em MicroPython, para ser integrado a um pequeno microcontrolador, poderia escrever uma função parecida com a que aparece no Exemplo 8.[192]

Exemplo 8. books.py: a função to_xml
AUTHOR_ELEMENT = '<AUTHOR>{}</AUTHOR>'

def to_xml(book: BookDict) -> str:  # (1)
    elements: list[str] = []  # (2)
    for key, value in book.items():
        if isinstance(value, list):  # (3)
            elements.extend(
                AUTHOR_ELEMENT.format(n) for n in value)  # (4)
        else:
            tag = key.upper()
            elements.append(f'<{tag}>{value}</{tag}>')
    xml = '\n\t'.join(elements)
    return f'<BOOK>\n\t{xml}\n</BOOK>'
  1. O principal objetivo do exemplo: usar BookDict em uma assinatura de função.

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

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

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

O Exemplo 9 mostra uma função que interpreta uma str JSON e devolve um BookDict.

Exemplo 9. books_any.py: a função from_json
def from_json(data: str) -> BookDict:
    whatever = json.loads(data)  # (1)
    return whatever  # (2)
  1. O tipo devolvido por json.loads() é Any.[194]

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

O segundo ponto do Exemplo 9 é muito importante de ter em mente: O Mypy não vai apontar qualquer problema nesse código, mas durante a execução o valor em whatever pode não se adequar à estrutura de BookDict—na verdade, pode nem mesmo ser um dict!

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

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

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

Exemplo 10. books.py: a função from_json com uma anotação de variável
def from_json(data: str) -> BookDict:
    whatever: BookDict = json.loads(data)  # (1)
    return whatever  # (2)
  1. --disallow-any-expr não gera erros quando uma expressão de tipo Any é imediatamente atribuída a uma variável com uma dica de tipo.

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

⚠️ Aviso

Não se deixe enganar por uma falsa sensação de tipagem segura com o Exemplo 10! Olhando o código estático, o verificador de tipo não tem como prever se json.loads() irá devolver qualquer coisa parecida com um BookDict. Apenas a validação durante a execução pode garantir isso.

A verificação de tipo estática é incapaz de prevenir erros cm código inerentemente dinâmico, como json.loads(), que cria objetos Python de tipos diferentes durante a execução. O Exemplo 11, o Exemplo 12 e o Exemplo 13 demonstram isso.

Exemplo 11. demo_not_book.py: from_json devolve um BookDict inválido, e to_xml o aceita
from books import to_xml, from_json
from typing import TYPE_CHECKING

def demo() -> None:
    NOT_BOOK_JSON = """
        {"title": "Andromeda Strain",
         "flavor": "pistachio",
         "authors": true}
    """
    not_book = from_json(NOT_BOOK_JSON)  # (1)
    if TYPE_CHECKING:  # (2)
        reveal_type(not_book)
        reveal_type(not_book['authors'])

    print(not_book)  # (3)
    print(not_book['flavor'])  # (4)

    xml = to_xml(not_book)  # (5)
    print(xml)  # (6)


if __name__ == '__main__':
    demo()
  1. Essa linha não produz um BookDict válido—veja o conteúdo de NOT_BOOK_JSON.

  2. Vamos deixar o Mypy revelar alguns tipos.

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

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

  5. Lembre-se da assinatura: def to_xml(book: BookDict) → str:.

  6. Como será a saída XML?

Agora verificamos demo_not_book.py com o Mypy (no Exemplo 12).

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

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

  3. Esse erro é para a linha print(not_book['flavor']): essa chave não existe no tipo nominal.

Agora vamos executar demo_not_book.py, mostrando o resultado no Exemplo 13.

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

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

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

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

Olhando o código de to_xml no Exemplo 8 através das lentes do duck typing, o argumento book deve fornecer um método .items() que devolve um iterável de tuplas na forma (chave, valor), onde:

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

  • valor pode ser qualquer coisa

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

TypedDict tem mais recursos, incluindo suporte a chaves opcionais, uma forma limitada de herança e uma sintaxe de declaração alternativa. Para saber mais sobre ele, revise a PEP 589—TypedDict: Type Hints for Dictionaries with a Fixed Set of Keys (TypedDict: Dicas de Tipo para Dicionários com um Conjunto Fixo de Chaves) (EN).

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

15.4. Coerção de Tipo

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

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

Coerções são usadas para silenciar avisos espúrios do verificador de tipos, e dão uma ajuda ao verificador quando ele não consegue entender direito o que está acontecendo.

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

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

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

from typing import cast

def find_first_str(a: list[object]) -> str:
    index = next(i for i, x in enumerate(a) if isinstance(x, str))
    # We only get here if there's at least one string
    return cast(str, a[index])

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

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

Aqui está outro exemplo com cast, desta vez para corrigir uma dica de tipo desatualizada na biblioteca padrão do Python. No Exemplo 12, criei um objeto asyncio , Server, e queria obter o endereço que o servidor estava ouvindo. Escrevi essa linha de código:

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

Mas o Mypy informou o seguinte erro:

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

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

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

from asyncio.trsock import TransportSocket
from typing import cast

# ... muitas linhas omitidas ...

    socket_list = cast(tuple[TransportSocket, ...], server.sockets)
    addr = socket_list[0].getsockname()

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

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

⚠️ Aviso

Não fique muito confortável usando cast para silenciar o Mypy, porque normalmente o Mypy está certo quando aponta um erro. Se você estiver usando cast com frequência, isso é um code smell (cheiro no código) (EN). Sua equipe pode estar fazendo um mau uso das dicas de tipo, ou sua base de código pode ter dependências de baixa qualidaade.

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

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

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

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

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

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

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

15.5. Lendo dicas de tipo durante a execução

Durante a importação, o Python lê as dicas de tipo em funções, classes e módulos, e as armazena em atributos chamados __annotations__. Considere, por exemplo, a função clip function no Exemplo 14.[200]

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

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

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

A chave 'return' está mapeada para a dica do tipo devolvido após o símbolo no Exemplo 14.

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

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

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

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

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

As duas questões são relevantes. A primeira pelo que acabamos de ver: anotações são avaliadas pelo interpretador durante a importação e armazenadas no atributo __annotations__. Vamos nos concentrar agora no segundo problema.

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

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

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

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

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

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

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

⚠️ Aviso

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

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

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

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

from __future__ import annotations

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

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

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

Como se vê, todas as dicas de tipo são agora strings simples, apesar de não terem sido escritas como strings na definição de clip (no Exemplo 14).

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

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

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

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

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

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

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

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

✒️ Nota

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

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

15.5.2. Lidando com o problema

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

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

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

Para demonstrar esse segundo ponto, aqui estão as primeiras linhas da classe Checked, definida no Exemplo 5, classe que estudaremos no Capítulo 24:

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

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

⚠️ Aviso

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

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

15.6. Implementando uma classe genérica

No Exemplo 7, definimos a ABC Tombola: uma interface para classes que funcionam como um recipiente para sorteio de bingo. A classe LottoBlower do Exemplo 10 é uma implementação concreta. Vamos agora estudar uma versão genérica de LottoBlower, usada da forma que aparece no Exemplo 15.

Exemplo 15. generic_lotto_demo.py: usando uma classe genérica de sorteio de bingo
from generic_lotto import LottoBlower

machine = LottoBlower[int](range(1, 11))  # (1)

first = machine.pick()  # (2)
remain = machine.inspect()  # (3)
  1. Para instanciar uma classe genérica, passamos a ela um parâmetro de tipo concreto, como int aqui.

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

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

Além disso, o Mypy aponta violações do tipo parametrizado com mensagens úteis, como ilustrado no Exemplo 16.

Exemplo 16. generic_lotto_errors.py: erros apontados pelo Mypy
from generic_lotto import LottoBlower

machine = LottoBlower[int]([1, .2])
## error: List item 1 has incompatible type "float";  # (1)
##        expected "int"

machine = LottoBlower[int](range(1, 11))

machine.load('ABC')
## error: Argument 1 to "load" of "LottoBlower"  # (2)
##        has incompatible type "str";
##        expected "Iterable[int]"
## note:  Following member(s) of "str" have conflicts:
## note:      Expected:
## note:          def __iter__(self) -> Iterator[int]
## note:      Got:
## note:          def __iter__(self) -> Iterator[str]
  1. Na instanciação de LottoBlower[int], o Mypy marca o float.

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

O Exemplo 17 é a implementação.

Exemplo 17. generic_lotto.py: uma classe genérica de sorteador de bingo
import random

from collections.abc import Iterable
from typing import TypeVar, Generic

from tombola import Tombola

T = TypeVar('T')

class LottoBlower(Tombola, Generic[T]):  # (1)

    def __init__(self, items: Iterable[T]) -> None:  # (2)
        self._balls = list[T](items)

    def load(self, items: Iterable[T]) -> None:  # (3)
        self._balls.extend(items)

    def pick(self) -> T:  # (4)
        try:
            position = random.randrange(len(self._balls))
        except ValueError:
            raise LookupError('pick from empty LottoBlower')
        return self._balls.pop(position)

    def loaded(self) -> bool:  # (5)
        return bool(self._balls)

    def inspect(self) -> tuple[T, ...]:  # (6)
        return tuple(self._balls)
  1. Declarações de classes genéricas muitas vezes usam herança múltipla, porque precisamos de uma subclasse de Generic para declarar os parâmetros de tipo formais—nesse caso, T.

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

  3. O método load é igualmente restrito.

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

  5. Nenhuma variável de tipo aqui.

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

👉 Dica

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

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

15.6.1. Jargão básico para tipos genéricos

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

Tipo genérico

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

Parâmetro de tipo formal

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

Tipo parametrizado

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

Parâmetro de tipo real

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

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

15.7. Variância

✒️ Nota

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

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

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

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

15.7.1. Uma máquina de bebida invariante

Vamos tentar modelar o cenário da cantina com uma classe genérica BeverageDispenser, que pode ser parametrizada com o tipo de bebida.. Veja o Exemplo 18.

Exemplo 18. invariant.py: definições de tipo e função install
from typing import TypeVar, Generic

class Beverage:  # (1)
    """Any beverage."""

class Juice(Beverage):
    """Any fruit juice."""

class OrangeJuice(Juice):
    """Delicious juice from Brazilian oranges."""

T = TypeVar('T')  # (2)

class BeverageDispenser(Generic[T]):  # (3)
    """A dispenser parameterized on the beverage type."""
    def __init__(self, beverage: T) -> None:
        self.beverage = beverage

    def dispense(self) -> T:
        return self.beverage

def install(dispenser: BeverageDispenser[Juice]) -> None:  # (4)
    """Install a fruit juice dispenser."""
  1. Beverage, Juice, e OrangeJuice formam uma hierarquia de tipos.

  2. Uma declaração TypeVar simples.

  3. BeverageDispenser é parametrizada pelo tipo de bebida.

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

Dadas as definições no Exemplo 18, o seguinte código é legal:

juice_dispenser = BeverageDispenser(Juice())
install(juice_dispenser)

Entretanto, isso não é legal:

beverage_dispenser = BeverageDispenser(Beverage())
install(beverage_dispenser)
## mypy: Argument 1 to "install" has
## incompatible type "BeverageDispenser[Beverage]"
##          expected "BeverageDispenser[Juice]"

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

De forma um tanto surpreendente, este código também é ilegal:

orange_juice_dispenser = BeverageDispenser(OrangeJuice())
install(orange_juice_dispenser)
## mypy: Argument 1 to "install" has
## incompatible type "BeverageDispenser[OrangeJuice]"
##          expected "BeverageDispenser[Juice]"

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

Os tipos de coleções mutáveis do Python—tal como list e set—são invariantes. A classe LottoBlower do Exemplo 17 também é invariante.

15.7.2. Uma máquina de bebida covariante

Se quisermos ser mais flexíveis, e modelar as máquinas de bebida como uma classe genérica que aceite alguma bebida e também seus subtipos, precisamos tornar a classe covariante. O Exemplo 19 mostra como declararíamos BeverageDispenser.

Exemplo 19. covariant.py: type definitions and install function
T_co = TypeVar('T_co', covariant=True)  # (1)


class BeverageDispenser(Generic[T_co]):  # (2)
    def __init__(self, beverage: T_co) -> None:
        self.beverage = beverage

    def dispense(self) -> T_co:
        return self.beverage

def install(dispenser: BeverageDispenser[Juice]) -> None:  # (3)
    """Install a fruit juice dispenser."""
  1. Define covariant=True ao declarar a variável de tipo; _co é o sufixo convencional para parâmetros de tipo covariantes no typeshed.

  2. Usa T_co para parametrizar a classe especial Generic.

  3. As dicas de tipo para install são as mesmas do Exemplo 18.

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

juice_dispenser = BeverageDispenser(Juice())
install(juice_dispenser)

orange_juice_dispenser = BeverageDispenser(OrangeJuice())
install(orange_juice_dispenser)

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

beverage_dispenser = BeverageDispenser(Beverage())
install(beverage_dispenser)
## mypy: Argument 1 to "install" has
## incompatible type "BeverageDispenser[Beverage]"
##          expected "BeverageDispenser[Juice]"

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

15.7.3. Uma lata de lixo contravariante

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

✒️ Nota

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

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

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

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

Para modelar a regra descrevendo uma lata de lixo aceitável na cantina, precisamos introduzir o conceito de "contravariância" através de um exemplo, apresentado no Exemplo 20.

Exemplo 20. contravariant.py: definições de tipo e a função install
from typing import TypeVar, Generic

class Refuse:  # (1)
    """Any refuse."""

class Biodegradable(Refuse):
    """Biodegradable refuse."""

class Compostable(Biodegradable):
    """Compostable refuse."""

T_contra = TypeVar('T_contra', contravariant=True)  # (2)

class TrashCan(Generic[T_contra]):  # (3)
    def put(self, refuse: T_contra) -> None:
        """Store trash until dumped."""

def deploy(trash_can: TrashCan[Biodegradable]):  # (4)
    """Deploy a trash can for biodegradable refuse."""
  1. Uma hierarquia de tipos para resíduos: Refuse é o tipo mais geral, Compostable o mais específico.

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

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

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

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

bio_can: TrashCan[Biodegradable] = TrashCan()
deploy(bio_can)

trash_can: TrashCan[Refuse] = TrashCan()
deploy(trash_can)

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

compost_can: TrashCan[Compostable] = TrashCan()
deploy(compost_can)
## mypy: Argument 1 to "deploy" has
## incompatible type "TrashCan[Compostable]"
##          expected "TrashCan[Biodegradable]"

Vamos resumir os conceitos vistos até aqui.

15.7.4. Revisão da variância

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

Tipos invariantes

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

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

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

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

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

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

Tipos covariantes

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

A :> B

A é um supertipo-de ou igual a B.

B <: A

B é um subtipo-de ou igual a A.

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

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

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

class FrozenSet(frozenset, AbstractSet[T_co]):

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

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

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

Tipos contravariantes

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

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

A classe TrashCan exemplifica isso:

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

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

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

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

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

Regras gerais de variância

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

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

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

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

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

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

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

Nossa discussão sobre variância continua na Seção 17.13.3.

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

15.8. Implementando um protocolo estático genérico

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

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

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

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

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

Graças a SupportsAbs, o Mypy considera válido o seguinte código, como visto no Exemplo 21.

Exemplo 21. abs_demo.py: uso do protocolo genérico SupportsAbs
import math
from typing import NamedTuple, SupportsAbs

class Vector2d(NamedTuple):
    x: float
    y: float

    def __abs__(self) -> float:  # (1)
        return math.hypot(self.x, self.y)

def is_unit(v: SupportsAbs[float]) -> bool:  # (2)
    """'True' if the magnitude of 'v' is close to 1."""
    return math.isclose(abs(v), 1.0)  # (3)

assert issubclass(Vector2d, SupportsAbs)  # (4)

v0 = Vector2d(0, 1)  # (5)
sqrt2 = math.sqrt(2)
v1 = Vector2d(sqrt2 / 2, sqrt2 / 2)
v2 = Vector2d(1, 1)
v3 = complex(.5, math.sqrt(3) / 2)
v4 = 1  # (6)

assert is_unit(v0)
assert is_unit(v1)
assert not is_unit(v2)
assert is_unit(v3)
assert is_unit(v4)

print('OK')
  1. Definir __abs__ torna Vector2d consistente-com SupportsAbs.

  2. Parametrizar SupportsAbs com float assegura…​

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

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

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

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

De forma similar, podemos escrever uma versão genérica do protocolo RandomPicker, apresentado na Exemplo 18, que foi definido com um único método pick devolvendo Any.

O Exemplo 22 mostra como criar um RandomPicker genérico, covariante no tipo devolvido por pick.

Exemplo 22. generic_randompick.py: definição do RandomPicker genérico
from typing import Protocol, runtime_checkable, TypeVar

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

@runtime_checkable
class RandomPicker(Protocol[T_co]):  # (2)
    def pick(self) -> T_co: ...  # (3)
  1. Declara T_co como covariante.

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

  3. Usa T_co como tipo do valor devolvido.

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

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

15.9. Resumo do capítulo

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

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

Então falamos sobre typing.cast, uma função projetada para nos permitir guiar o trabalho do verificador de tipos. É importante considerar cuidadosamente quando usar cast, porque seu uso excessivo atrapalha o verificador de tipos.

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

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

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

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

✒️ Nota

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

15.10. Leitura complementar

O sistema de tipagem estática do Python já era complexo quando foi originalmente projetado, e tem se tornado mais complexo a cada ano. A Tabela 16 lista todas as PEPs que encontrei até maio de 2021. Seria necessário um livro inteiro para cobrir tudo.

Tabela 16. PEPs sobre dicas de tipo, com links nos títulos. PEPs com números marcados com * são importantes o suficiente para serem mencionadas no parágrafo de abertura da documentação de typing. Pontos de interrogação na coluna Python indica PEPs em discussão ou ainda não implementadas; "n/a" aparece em PEPs informacionais sem relação com uma versão específica do Python. Todos os textos das PEPs estão em inglês. Dados coletados em maio 2021.
PEP Title Python Year

3107

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

3.0

2006

483*

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

n/a

2014

484*

Type Hints (Dicas de Tipo)

3.5

2014

482

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

n/a

2015

526*

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

3.6

2016

544*

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

3.8

2017

557

Data Classes (Classes de Dados)

3.7

2017

560

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

3.7

2017

561

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

3.7

2017

563

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

3.7

2017

586*

Literal Types (Tipos Literais)

3.8

2018

585

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

3.9

2019

589*

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

3.8

2019

591*

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

3.8

2019

593

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

?

2019

604

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

3.10

2019

612

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

3.10

2019

613

Explicit Type Aliases (Aliases de Tipo Explícitos)

3.10

2020

645

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

?

2020

646

Variadic Generics (Genéricos Variádicos)

?

2020

647

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

3.10

2021

649

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

?

2021

655

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

?

2021

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

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

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

Se você estiver interessado na história do Python, pode gostar de saber que Guido van Rossum publicou "Adding Optional Static Typing to Python" (Acrescentando Tipagem Estática Opcional ao Python) em 23 de dezembro de 2004.

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

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

Para algumas visões críticas sobre os sistemas de tipagem, recomendo os posts de Victor Youdaiken "Bad ideas in type theory" (Más ideias na teoria dos tipos) (EN) e "Types considered harmful II" (Tipos considerados nocivos II) (EN).

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

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

O que nos traz ao problema que sempre cito para o C++: eu a chamo de "exceção de enésima ordem à regra de exceção". Ela soa assim: "Você pode fazer x, exceto no caso y, a menos que y faça z, caso em que você pode se…​"

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

Ponto de Vista

As tocas de coelho da tipagem

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

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

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

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

Notação de variância em outras linguagens

A variância é um tópico complicado, e a sintaxe das dicas de tipo do Python não é tão boa quanto poderia ser. Essa citação direta da PEP 484 evidencia isso:

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

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

Os autores da PEP 484 trabalharam sob a severa restrição auto-imposta de suportar dicas de tipo sem fazer qualquer modificação no interpretador. Isso exigiu a introdução de TypeVar para definir variáveis de tipo, e também levou ao abuso de [] para fornecer a sintaxe Klass[T] para genéricos—em vez da notação Klass<T> usada em outras linguagens populares, incluindo C#, Java, Kotlin e TypeScript. Nenhuma dessas linguagens exige que variáveis de tipo seja declaradas antes de serem usadas.

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

Em Kotlin, poderíamos declarar a BeverageDispenser assim:

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

O modificador out no parâmetro de tipo formal significa que T é um tipo de output (saída)), e portanto BeverageDispenser é covariante.

Você provavelmente consegue adivinhar como TrashCan seria declarada:

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

Dado T como um parâmetro de tipo formal de input (entrada), segue que TrashCan é contravariante.

Se nem in nem out aparecem, então a classe é invariante naquele parâmetro.

É fácil lembrar das Seção 15.7.4.4 quando out e in são usado nos parâmetros de tipo formais.

Isso sugere que uma boa convenção para nomenclatura de variáveis de tipo covariante e contravariantes no Python seria:

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

Aí poderíamos definir as classes assim:

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

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

Será que é tarde demais para modificar a convenção de nomenclatura definida na PEP 484?

16. Sobrecarga de operadores

Existem algumas coisas que me deixam meio dividido, como a sobrecarga de operadores. Deixei a sobrecarga de operadores de fora em uma decisão bastante pessoal, pois tinha visto gente demais abusar [desse recurso] no C++.[208]

— James Gosling
Criador do Java

Em Python, podemos calcular juros compostos usando uma fórmula escrita assim:

interest = principal * ((1 + rate) ** periods - 1)

Operadores que aparecem entre operandos, como em 1 + rate, são operadores infixos. No Python, operadores infixos podem lidar com qualquer tipo arbitrário. Assim, se você está trabalhando com dinheiro real, pode se assegurar que principal, rate, e periods sejam números exatos—instâncias da classe decimal.Decimal do Python—e a fórmula vai funcionar como está escrita, produzindo um resultado exato.

Mas em Java, se você mudar de float para BigDecimal, para obter resultados exatos, não é mais possível usar operadores infixos, porque naquela linguagem eles só funcionam com tipos primitivos. Abaixo vemos a mesma fórmula escrita em Java para funcionar com números BigDecimal:

BigDecimal interest = principal.multiply(BigDecimal.ONE.add(rate)
                        .pow(periods).subtract(BigDecimal.ONE));

Está claro que operadores infixos tornam as fórmulas mais legíveis. A sobrecarga de operadores é necessária para suportar a notação infixa de operadores com tipos definidos pelo usuário ou estendidos, tal como os arrays do NumPy. Oferecer a sobrecarga de operadores em uma linguagem de alto nível e fácil de usar foi provavelmente uma das principais razões do imenso sucesso do Python na ciência de dados, incluindo as aplicações financeiras e científicas.

Na Seção 1.3.1 (do Capítulo 1) vimos algumas implementações triviais de operadores em uma classe básica Vector. Os métodos __add__ e __mul__ no Exemplo 2 foram escritos para demonstrar como os métodos especiais suportam a sobrecarga de operadores, mas deixamos passar problemas sutis naquelas implementações. Além disso, no Exemplo 2 notamos que o método Vector2d.__eq__ considera True a seguinte expressão: Vector(3, 4) == [3, 4]—algo que pode ou não fazer sentido. Nesse capítulo vamos cuidar desses problemas, e falaremos também de:

  • Como um método de operador infixo deveria indicar que não consegue tratar um operando

  • O uso de duck typing ou goose typing para lidar com operandos de vários tipos

  • O comportamento especial dos operadores de comparação cheia (e.g., ==, >, , etc.)

  • O tratamento default de operadores de atribuição aumentada tal como +=, e como sobrecarregá-los

16.1. Novidades nesse capítulo

O goose typing é uma parte fundamental do Python, mas as ABCs numbers não são suportadas na tipagem estática. Então modifiquei o Exemplo 11 para usar duck typing, em vez de uma verificação explícita usando isinstance contra numbers.Real.[209]

Na primeira edição do Python Fluente, tratei do operador de multiplicação de matrizes @ como uma mudança futura, quando o Python 3.5 ainda estava em sua versão alfa. Agora o @ está integrado ao fluxo do capítulo na Seção 16.6. Aproveitei o goose typing para tornar a implementação de __matmul__ aqui mais segura que a da primeira edição, sem comprometer sua flexibilidade.

A Seção 16.11 agora inclui algumas novas referências—incluindo um post de blog de Guido van Rossum. Também adicionei menções a duas bibliotecas que demonstram um uso efetivo da sobrecarga de operadores fora do domínio da matemática: pathlib e Scapy.

16.2. Introdução à sobrecarga de operadores

A sobrecarga de operadores permite que objetos definidos pelo usuário interoperem com operadores infixos tais como + e |, ou com operadores unários como - e ~. No Python, de uma perspectiva mais geral, a invocação de funções (()), o acesso a atributos (.) e o acesso a itens e o fatiamento ([]) também são operadores, mas este capítulo trata dos operadores unários e infixos.

A sobrecarga de operadores tem má-fama em certos círculos. É um recurso da linguagem que pode ser (e tem sido) abusado, resultando em programadores confusos, bugs, e gargalos de desempenho inesperados. Mas se bem utilizada, possibilita APIs agradáveis de usar e código legível. O Python alcança um bom equilíbrio entre flexibilidade, usabilidade e segurança, pela imposição de algumas limitações:

  • Não é permitido modificar o significado dos operadores para os tipos embutidos.

  • Não é permitido criar novos operadores, apenas sobrecarregar os existentes.

  • Alguns poucos operadores não podem ser sobrecarregados: is, and, or e not (mas os operadores ==, &, |, e ~ podem).

No Capítulo 12, na classe Vector, já apresentamos um operador infixo: ==, suportado pelo método __eq__. Nesse capítulo, vamos melhorar a implementação de __eq__ para lidar melhor com operandos de outros tipos além de Vector. Entretanto, os operadores de comparação cheia (==, !=, >, <, >=, ) são casos especiais de sobrecarga de operadores, então começaremos sobrecarregando quatro operadores aritméticos em Vector: os operadores unários - e `, seguido pelos infixos ` e *.

Vamos começar pelo tópico mais fácil: operadores unários.

16.3. Operadores unários

A seção "6.5. Unary arithmetic and bitwise operations" (Aritmética unária e operações binárias) (EN), de A Referência da Linguagem Python, elenca três operações unárias, listadas abaixo juntamente com seus métodos especiais associados:

-, implementado por __neg__

Negativo aritmético unário. Se x é -2 então -x == 2.

+, implementado por __pos__

Positivo aritmético unário. De forma geral, x == +x, mas há alguns poucos casos onde isso não é verdadeiro. Veja a Quando x e +x não são iguais, se estiver curioso.

~, implementado por __invert__

Negação binária, ou inversão binária de um inteiro, definida como ~x == -(x+1). Se x é 2 então ~x == -3.[210]

O capítulo "Modelo de Dados" de A Referência da Linguagem Python também inclui a função embutida abs() como um operador unário. O método especial associado é __abs__, como já vimos.

É fácil suportar operadores unários. Basta implementar o método especial apropriado, que receberá apenas um argumento: self. Use a lógica que fizer sentido na sua classe, mas se atenha à regra geral dos operadores: sempre devolva um novo objeto. Em outras palavras, não modifique o receptor (self), crie e devolva uma nova instância do tipo adequado.

No caso de - e `, o resultado será provavelmente uma instância da mesma classe de `self`. Para o ` unário, se o receptor for imutável você deveria devolver self; caso contrário, devolva uma cópia de self. Para abs(), o resultado deve ser um número escalar.

Já no caso de ~, é difícil determinar o que seria um resultado razoável se você não estiver lidando com bits de um número inteiro. No pacote de análise de dados pandas, o til nega condições booleanas de filtragem; veja exemplos na documentação do pandas, em "Boolean indexing" (_Indexação booleana) (EN).

Como prometido acima, vamos implementar vários novos operadores na classe Vector, do Capítulo 12. O Exemplo 1 mostra o método __abs__, que já estava no Exemplo 16, e os novos métodos __neg__ e __pos__ para operadores unários.

Exemplo 1. vector_v6.py: unary operators - and + added to Exemplo 16
    def __abs__(self):
        return math.hypot(*self)

    def __neg__(self):
        return Vector(-x for x in self)  # (1)

    def __pos__(self):
        return Vector(self)  # (2)
  1. Para computar -v, cria um novo Vector com a negação de cada componente de self.

  2. Para computar +v, cria um novo Vector com cada componente de self.

Lembre-se que instâncias de Vector são iteráveis, e o Vector.__init__ recebe um argumento iterável, e daí as implementações de __neg__ e __pos__ são curtas e rápidas.

Não vamos implementar __invert__. Se um usuário tentar escrever ~v para uma instância de Vector, o Python vai gerar um TypeError com uma mensagem clara: “bad operand type for unary ~: 'Vector'” (_operando inválido para o ~ unário: 'Vector').

O quadro a seguir trata de uma curiosidade que algum dia poderá ajudar você a ganhar uma aposta sobre o + unário .

Quando x e +x não são iguais

Todo mumdo espera que x == +x, e isso é verdade no Python quase todo o tempo, mas encontrei dois casos na biblioteca padrão onde x != +x.

O primeiro caso envolve a classe decimal.Decimal. Você pode obter x != +x se x é uma instância de Decimal, criada em um dado contexto aritmético e +x for então avaliada em um contexto com definições diferentes. Por exemplo, x é calculado em um contexto com uma determinada precisão, mas a precisão do contexto é modificada e daí +x é avaliado. Veja a uma demonstração no Exemplo 2.

Exemplo 2. Uma mudança na precisão do contexto aritmético pode fazer x se tornar diferente de +x
>>> import decimal
>>> ctx = decimal.getcontext()  # (1)
>>> ctx.prec = 40  # (2)
>>> one_third = decimal.Decimal('1') / decimal.Decimal('3')  # (3)
>>> one_third  # (4)
Decimal('0.3333333333333333333333333333333333333333')
>>> one_third == +one_third  # (5)
True
>>> ctx.prec = 28  # (6)
>>> one_third == +one_third  # (7)
False
>>> +one_third  # (8)
Decimal('0.3333333333333333333333333333')
  1. Obtém uma referência ao contexto aritmético global atual.

  2. Define a precisão do contexto aritmético em 40.

  3. Computa 1/3 usando a precisão atual.

  4. Inspeciona o resultado; há 40 dígitos após o ponto decimal.

  5. one_third == +one_third é True.

  6. Diminui a precisão para 28—a precisão default para aritmética com Decimal.

  7. Agora one_third == +one_third é False.

  8. Inspeciona +one_third; aqui há 28 dígitos após o '.' .

O fato é que cada ocorrência da expressão +one_third produz uma nova instância de Decimal a partir do valor de one_third, mas usando a precisão do contexto aritmético atual.

Podemos encontrar o segundo caso onde x != x` na https://docs.python.org/pt-br/3/library/collections.html#collections.Counter[documentação] de `collections.Counter`. A classe `Counter` implementa vários operadores aritméticos, incluindo o ` infixo, para somar a contagem de duas instâncias de Counter. Entretanto, por razões práticas, a adição em Counter descarta do resultado qualquer item com contagem negativa ou zero. E o prefixo + é um atalho para somar um Counter vazio, e portanto produz um novo Counter, preservando apenas as contagens maiores que zero. Veja o Exemplo 3.

Exemplo 3. O + unário produz um novo `Counter`sem as contagens negativas ou zero
>>> ct = Counter('abracadabra')
>>> ct
Counter({'a': 5, 'r': 2, 'b': 2, 'd': 1, 'c': 1})
>>> ct['r'] = -3
>>> ct['d'] = 0
>>> ct
Counter({'a': 5, 'b': 2, 'c': 1, 'd': 0, 'r': -3})
>>> +ct
Counter({'a': 5, 'b': 2, 'c': 1})

Como se vê, +ct devolve um contador onde todas as contagens são maiores que zero.

Agora voltamos à nossa programação normal.

16.4. Sobrecarregando + para adição de Vector

A classe Vector é um tipo sequência, e a seção "3.3.7. Emulando de tipos contêineres" do capítulo "Modelo de Dados", na documentação oficial do Python, diz que sequências devem suportar o operador ` para concatenação e o `*` para repetição. Entretanto, aqui vamos implementar ` e * como operações matemáticas de vetores, algo um pouco mais complicado mas mais significativo para um tipo Vector.

👉 Dica

Usuários que desejem concatenar ou repetir instâncias de Vector podem convertê-las para tuplas ou listas, aplicar o operador e convertê-las de volta—graças ao fato de Vector ser iterável e poder ser criado a partir de um iterável:

>>> v_concatenated = Vector(list(v1) + list(v2))
>>> v_repeated = Vector(tuple(v1) * 5)

Somar dois vetores euclidianos resulta em um novo vetor no qual os componentes são as somas pareadas dos componentes dos operandos. Ilustrando:

>>> v1 = Vector([3, 4, 5])
>>> v2 = Vector([6, 7, 8])
>>> v1 + v2
Vector([9.0, 11.0, 13.0])
>>> v1 + v2 == Vector([3 + 6, 4 + 7, 5 + 8])
True

E o que acontece se tentarmos somar duas instâncias de Vector de tamanhos diferentes? Poderíamos gerar um erro, mas considerando as aplicações práticas (tal como recuperação de informação), é melhor preencher o Vector menor com zeros. Esse é o resultado que queremos:

>>> v1 = Vector([3, 4, 5, 6])
>>> v3 = Vector([1, 2])
>>> v1 + v3
Vector([4.0, 6.0, 5.0, 6.0])

Dados esses requerimentos básicos, podemos implementar __add__ como no Exemplo 4.

Exemplo 4. Método Vector.__add__, versão #1
    # inside the Vector class

    def __add__(self, other):
        pairs = itertools.zip_longest(self, other, fillvalue=0.0)  # (1)
        return Vector(a + b for a, b in pairs)  # (2)
  1. pairs é um gerador que produz tuplas (a, b), onde a vem de self e b de other. Se self e other tiverem tamanhos diferentes, fillvalue fornece os valores ausentes para o iterável mais curto.

  2. Um novo Vector é criado a partir de uma expressão geradora, produzindo uma soma para cada (a, b) de pairs.

Observe como __add__ devolve uma nova instância de Vector, sem modificar self ou other.

⚠️ Aviso

Métodos especiais implementando operadores unários ou infixos não devem nunca modificar o valor dos operandos. Se espera que expressões com tais operandos produzam resultados criando novos objetos. Apenas operadores de atribuição aumentada podem modidifcar o primeiro operando (self), como discutido na Seção 16.9.

O Exemplo 4 permite somar um Vector a um Vector2d, e Vector a uma tupla ou qualquer iterável que produza números, como prova o Exemplo 5.

Exemplo 5. Nossa versão #1 de Vector.__add__ também aceita objetos diferentes de Vector
>>> v1 = Vector([3, 4, 5])
>>> v1 + (10, 20, 30)
Vector([13.0, 24.0, 35.0])
>>> from vector2d_v3 import Vector2d
>>> v2d = Vector2d(1, 2)
>>> v1 + v2d
Vector([4.0, 6.0, 5.0])

Os dois usos de ` no <<ex_vector_add_demo_mixed_ok>> funcionam porque `+__add__ usa zip_longest(…), capaz de consumir qualquer iterável, e a expressão geradora que cria um novo Vector simplemente efetua a operação a + b com os pares produzidos por zip_longest(…), então um iterável que produza quaisquer itens numéricos servirá.

Entretanto, se trocarmos a ordem dos operandos (no Exemplo 6), a soma de tipos diferentes falha.

Exemplo 6. A versão #1 de Vector.__add__ falha com se o operador da esquerda não for um `Vector
>>> v1 = Vector([3, 4, 5])
>>> (10, 20, 30) + v1
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: can only concatenate tuple (not "Vector") to tuple
>>> from vector2d_v3 import Vector2d
>>> v2d = Vector2d(1, 2)
>>> v2d + v1
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: unsupported operand type(s) for +: 'Vector2d' and 'Vector'

Para suportar operações envolvendo objetos de tipos diferentes, o Python implementa um mecanismo especial de despacho para os métodos especiais de operadores infixos. Dada a expressão a + b, o interpretador vai executar as seguintes etapas (veja também a Figura 1):

  1. Se a implementa __add__, invoca a.__add__(b) e devolve o resultado, a menos que seja NotImplemented.

  2. Se a não implementa __add__, ou a chamada devolve NotImplemented, verifica se b implementa __radd__, e então invoca b.__radd__(a) e devolve o resultado, a menos que seja NotImplemented.

  3. Se b não implementa __radd__, ou a chamada devolve NotImplemented, gera um TypeError com a mensagem 'unsupported operand types' (tipos de operandos não suportados).

👉 Dica

O método __radd__ é chamado de variante "reversa" ou "refletida" de __add__. Adotei o termo geral "métodos especiais reversos".[211]

Fluxograma de operador
Figura 1. Fluxograma para computar a + b com __add__ e __radd__.

Assim, para fazer as somas de tipos diferentes no Exemplo 6 funcionarem, precisamos implementar o método Vector.__radd__, que o Python vai invocar como alternativa, se o operando à esquerda não implementar __add__, ou se implementar mas devolver NotImplemented, indicando que não sabe como tratar o operando à direita.

⚠️ Aviso

Não confunda NotImplemented com NotImplementedError. O primeiro é um valor singleton especial, que um método especial de operador infixo deve devolver para informar o interpretador que não consegue tratar um dado operando. NotImplementedError, por outro lado, é um exceção que métodos stub em classes abstratas podem gerar, para avisar que subclasses devem implementar tais métodos.

A implementação viável mais simples de __radd__ aparece no Exemplo 7.

Exemplo 7. Os métodos __add__ e __radd__ de Vector
    # inside the Vector class

    def __add__(self, other):  # (1)
        pairs = itertools.zip_longest(self, other, fillvalue=0.0)
        return Vector(a + b for a, b in pairs)

    def __radd__(self, other):  # (2)
        return self + other
  1. Nenhuma mudança no __add__ do Exemplo 4; ele é listado aqui porque é usado por __radd__.

  2. __radd__ apenas delega para __add__.

Muitas vezes, __radd__ pode ser simples assim: apenas a invocação do operador apropriado, delegando para __add__ neste caso. Isso se aplica para qualquer operador comutativo; + é comutativo quando lida com números ou com nossos vetores, mas não é comutativo ao concatenar sequências no Python.

Se __radd__ apenas invoca __add__, aqui está outra forma de obter o mesmo efeito:

    def __add__(self, other):
        pairs = itertools.zip_longest(self, other, fillvalue=0.0)
        return Vector(a + b for a, b in pairs)

    __radd__ = __add__

Os métodos no Exemplo 7 funcionam com objetos Vector ou com qualquer iterável com itens numéricos, tal como um Vector2d, uma tuple de inteiros ou um array de números de ponto flutuante. Mas se alimentado com um objeto não-iterável, __add__ gera uma exceção com uma mensagem não muito útil, como no Exemplo 8.

Exemplo 8. O método Vector.__add__ precisa de operandos iteráveis
>>> v1 + 1
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "vector_v6.py", line 328, in __add__
    pairs = itertools.zip_longest(self, other, fillvalue=0.0)
TypeError: zip_longest argument #2 must support iteration

E pior ainda, recebemos uma mensagem enganosa se um operando for iterável mas seus itens não puderem ser somados aos itens float no Vector. Veja o Exemplo 9.

Exemplo 9. O método Vector.__add__ precisa de um iterável com itens numéricos
>>> v1 + 'ABC'
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "vector_v6.py", line 329, in __add__
    return Vector(a + b for a, b in pairs)
  File "vector_v6.py", line 243, in __init__
    self._components = array(self.typecode, components)
  File "vector_v6.py", line 329, in <genexpr>
    return Vector(a + b for a, b in pairs)
TypeError: unsupported operand type(s) for +: 'float' and 'str'

Tentei somar um Vector a uma str, mas a mensagem reclama de float e str.

Na verdade, os problemas no Exemplo 8 e no Exemplo 9 são mais profundos que meras mensagens de erro obscuras: se um método especial de operando não é capaz de devolver um resultado válido por incompatibilidade de tipos, ele deverua devolver NotImplemented e não gerar um TypeError. Ao devolver NotImplemented, a porta fica aberta para a implementação do operando do outro tipo executar a operação, quando o Python tentar invocar o método reverso.

No espírito do duck typing, vamos nos abster de testar o tipo do operando other ou o tipo de seus elementos. Vamos capturar as exceções e devolver NotImplemented. Se o interpretador ainda não tiver invertido os operandos, tentará isso agora. Se a invocação do método reverso devolver NotImplemented, então o Python irá gerar um TypeError com uma mensagem de erro padrão "unsupported operand type(s) for +: 'Vector' and 'str'” (tipos de operandos não suportados para +: Vector e `str`)

A implementação final dos métodos especiais de adição de Vector está no Exemplo 10.

Exemplo 10. vector_v6.py: métodos do operador + adicionados a vector_v5.py (no Exemplo 16)
    def __add__(self, other):
        try:
            pairs = itertools.zip_longest(self, other, fillvalue=0.0)
            return Vector(a + b for a, b in pairs)
        except TypeError:
            return NotImplemented

    def __radd__(self, other):
        return self + other

Observe que agora __add__ captura um TypeError e devolve NotImplemented.

⚠️ Aviso

Se um método de operador infixo gera uma exceção, ele interrompe o algoritmo de despacho do operador. No caso específico de TypeError, geralmente é melhor capturar essa exceção e devolver NotImplemented. Isso permite que o interpretador tente chamar o método reverso do operador, que pode tratar corretamente a operação com operadores invertidos, se eles forem de tipos diferentes.

Agora que já sobrecarregamos o operador ` com segurança, implementando `+__add__ e __radd__, vamos enfrentar outro operador infixo: *.

16.5. Sobrecarregando * para multiplicação escalar

O que significa Vector([1, 2, 3]) * x? Se x é um número, isso seria um produto escalar, e o resultado seria um novo Vector com cada componente multiplicado por x—também conhecida como multiplicação elemento a elemento (elementwise multiplication):

>>> v1 = Vector([1, 2, 3])
>>> v1 * 10
Vector([10.0, 20.0, 30.0])
>>> 11 * v1
Vector([11.0, 22.0, 33.0])
✒️ Nota

Outro tipo de produto envolvendo operandos de Vector seria o dot product (produto vetorial) de dois vetores—ou multiplicação de matrizes, se tomarmos um vetor como uma matriz de 1 × N e o outro como uma matriz de N × 1. Vamos implementar esse operador em nossa classe Vector na Seção 16.6.

De volta a nosso produto escalar, começamos novamente com os métodos __mul__ e __rmul__ mais simples possíveis que possam funcionar:

    # inside the Vector class

    def __mul__(self, scalar):
        return Vector(n * scalar for n in self)

    def __rmul__(self, scalar):
        return self * scalar

Esses métodos funcionam, exceto quando recebem operandos incompatíveis. O argumento scalar precisa ser um número que, quando multiplicado por um float, produz outro float (porque nossa classe Vector usa, internamente, um array de números de ponto flutuante). Então um número complex não serve, mas o escalar pode ser um int, um bool (porque bool é subclasse de int) ou mesmo uma instância de fractions.Fraction. No Exemplo 11, o método __mul__ não faz qualquer verificação de tipo explícita com scalar. Em vez disso, o converte em um float, e devolve NotImplemented se a conversão falha. Esse é um exemplo claro de duck typing.

Exemplo 11. vector_v7.py: métodos do operador * adicionados
class Vector:
    typecode = 'd'

    def __init__(self, components):
        self._components = array(self.typecode, components)

    # many methods omitted in book listing, see vector_v7.py
    # in https://github.com/fluentpython/example-code-2e

    def __mul__(self, scalar):
        try:
            factor = float(scalar)
        except TypeError:  # (1)
            return NotImplemented  # (2)
        return Vector(n * factor for n in self)

    def __rmul__(self, scalar):
        return self * scalar  # (3)
  1. Se scalar não pode ser convertido para float…​

  2. …​não temos como lidar com ele, então devolvemos NotImplemented, para permitir ao Python tentar __rmul__ no operando scalar.

  3. Neste exemplo, __rmul__ funciona bem apenas executando self * scalar, que delega a operação para o método __mul__.

Com o Exemplo 11, é possível multiplicar um Vector por valores escalares de tipos numéricos comuns e não tão comuns:

>>> v1 = Vector([1.0, 2.0, 3.0])
>>> 14 * v1
Vector([14.0, 28.0, 42.0])
>>> v1 * True
Vector([1.0, 2.0, 3.0])
>>> from fractions import Fraction
>>> v1 * Fraction(1, 3)
Vector([0.3333333333333333, 0.6666666666666666, 1.0])

Agora que podemos multiplicar Vector por valores escalares, vamos ver como implementar o produto de um Vector por outro Vector.

✒️ Nota

Na primeira edição de Python Fluente, usei goose typing no Exemplo 11: verificava o argumento scalar de __mul__ com isinstance(scalar, numbers.Real). Agora eu evito usar as ABCs de numbers, por não serem suportadas pelas anotações de tipo introduzidas na PEP 484. Usar durante a execução tipos que não podem ser também verificados de forma estática me parece uma má ideia.

Outra alternativa seria verificar com o protocolo typing.SupportsFloat, que vimos na Seção 13.6.2. Escolhi usar duck typing naquele exemplo por achar que pythonistas fluentes devem se sentir confortáveis com esse padrão de programação.

Mas __matmul__, no Exemplo 12, que é novo e foi escrito para essa segunda edição, é um bom exemplo de goose typing.

16.6. Usando @ como operador infixo

O símbolo @ é bastante conhecido como o prefixo de decoradores de função, mas desde 2015 ele também pode ser usado como um operador infixo. Por anos, o produto escalar no NumPy foi escrito como numpy.dot(a, b). A notação de invocação de função faz com que fórmulas mais longas sejam difíceis de traduzir da notação matemática para o Python,[212] então a comunidade de computação numérica fez campanha pela PEP 465—A dedicated infix operator for matrix multiplication (Um operador infixo dedicado para multiplicação de matrizes) (EN), que foi implementada no Python 3.5. Hoje é possível escrever a @ b para computar o produto escalar de dois arrays do NumPy.

O operador @ é suportado pelos métodos especiais __matmul__, __rmatmul__ e __imatmul__, cujos nomes derivam de "matrix multiplication" (multiplicação de matrizes). Até o Python 3.10, esses métodos não são usados em lugar algum na biblioteca padrão, mas eles são reconhecidos pelo interpretador desde o Python 3.5, então os desenvolvedores do NumPy—​e o resto de nós—​podemos implementar o operador @ em nossas classes. O analisador sintático do Python também foi modificado para aceitar o novo operador (no Python 3.4, a @ b era um erro de sintaxe).

Os testes simples abaixo mostram como @ deve funcionar com instâncias de Vector:

>>> va = Vector([1, 2, 3])
>>> vz = Vector([5, 6, 7])
>>> va @ vz == 38.0  # 1*5 + 2*6 + 3*7
True
>>> [10, 20, 30] @ vz
380.0
>>> va @ 3
Traceback (most recent call last):
...
TypeError: unsupported operand type(s) for @: 'Vector' and 'int'

O resultado de va @ vz no exemplo acima é o mesmo que obtemos no NumPy fazendo o produto escalar de arrays com os mesmos valores:

>>> import numpy as np
>>> np.array([1, 2, 3]) @ np.array([5, 6, 7])
38

O Exemplo 12 mostra o código dos métodos especiais relevantes na classe Vector.

Exemplo 12. vector_v7.py: operator @ methods
class Vector:
    # many methods omitted in book listing

    def __matmul__(self, other):
        if (isinstance(other, abc.Sized) and  # (1)
            isinstance(other, abc.Iterable)):
            if len(self) == len(other):  # (2)
                return sum(a * b for a, b in zip(self, other))  # (3)
            else:
                raise ValueError('@ requires vectors of equal length.')
        else:
            return NotImplemented

    def __rmatmul__(self, other):
        return self @ other
  1. Ambos os operandos precisam implementar __len__ e __iter__…​

  2. …​e ter o mesmo tamanho, para permitir…​

  3. …​uma linda aplicação de sum, zip e uma expressão geradora.

👉 Dica
O novo recurso de zip() no Python 3.10

Desde o Python 3.10, a função embutida zip aceita um argumento opcional apenas nomeado, strict. Quando strict=True, a função gera um ValueError se os iteráveis tem tamanhos diferentes. O default é False. Esse novo comportamento estrito se alinha à filosofia de falhar rápido do Python. No Exemplo 12, substituí o if interno por um try/except ValueError e acrescentei strict=True à invocação de zip.

O Exemplo 12 é um bom exemplo prático de goose typing. Testar o operando other contra Vector negaria aos usuários a flexibilidade de usar listas ou arrays como operandos de @. Desde que um dos operandos seja um Vector, nossa implementação de @ suporta outros operandos que sejam instâncias de abc.Sized e abc.Iterable. Ambas as ABCs implementam o __subclasshook__, portanto qualquer objeto que forneça __len__ e __iter__ satisfaz nosso teste—não há necessidade de criar subclasses concretas dessas ABCs ou sequer registrar-se com elas, como explicado na Seção 13.5.8. Em especial, nossa classe Vector não é subclasse nem de abc.Sized nem de abc.Iterable, mas passa os testes de isinstance contra aquelas ABCs, pois implementa os métodos necessários.

Vamos revisar os operadores aritméticos suportados pelo Python antes de mergulhar na categoria especial dos Seção 16.8.

16.7. Resumindo os operadores aritméticos

Ao implementar +, *, e @, vimos os padrões de programação mais comuns para operadores infixos. As técnicas descritas são aplicáveis a todos os operadores listados na Tabela 17 (os operadores "no mesmo lugar" serão tratados em Seção 16.9).

Tabela 17. Nomes dos métodos de operadores infixos (os operadores "no mesmo lugar" são usados para atribuição aumentada; operadores de comparação estão na Tabela 18)
Operador Direto Reverso No mesmo lugar Descrição

+

__add__

__radd__

__iadd__

Adição ou concatenação

-

__sub__

__rsub__

__isub__

Subtração

*

__mul__

__rmul__

__imul__

Multiplicação ou repetição

/

__truediv__

__rtruediv__

__itruediv__

Divisão exata (True division)

//

__floordiv__

__rfloordiv__

__ifloordiv__

Divisão inteira (Floor division)

%

__mod__

__rmod__

__imod__

Módulo

divmod()

__divmod__

__rdivmod__

__idivmod__

Devolve uma tupla com o quociente da divisão inteira e o módulo

**, pow()

__pow__

__rpow__

__ipow__

Exponenciação[213]

@

__matmul__

__rmatmul__

__imatmul__

Multiplicação de matrizes

&

__and__

__rand__

__iand__

E binário (bit a bit)

|

__or__

__ror__

__ior__

OU binário (bit a bit)

^

__xor__

__rxor__

__ixor__

XOR binário (bit a bit)

<<

__lshift__

__rlshift__

__ilshift__

Deslocamento de bits para a esquerda

>>

__rshift__

__rrshift__

__irshift__

Deslocamento de bits para a direita

Operadores de comparação cheia usam um conjunto diferente de regras.

16.8. Operadores de comparação cheia

O tratamento dos operadores de comparação cheia ==, !=, >, <, >= e pelo interpretador Python é similar ao que já vimos, com duas importantes diferenças:

  • O mesmo conjunto de métodos é usado para invocações diretas ou reversas do operador. As regras estão resumidas na Tabela 18. Por exemplo, no caso de ==, tanto a chamada direta quanto a reversa invocam __eq__, apenas permutando os argumentos; e uma chamada direta a __gt__ é seguida de uma chamada reversa a __lt__, com os argumentos permutados.

  • Nos casos de == e !=, se o métodos reverso estiver ausente, ou devolver NotImplemented, o Python vai comparar os IDs dos objetos em vez de gerar um TypeError.

Tabela 18. Operadores de comparação cheia: métodos reversos invocados quando a chamada inicial ao método devolve NotImplemented
Grupo Operador infixo Método de invocação direta Método de invocação reversa Alternativa

Igualdade

a == b

a.__eq__(b)

b.__eq__(a)

Devolve id(a) == id(b)

a != b

a.__ne__(b)

b.__ne__(a)

Devolve not (a == b)

Ordenação

a > b

a.__gt__(b)

b.__lt__(a)

Gera um TypeError

a < b

a.__lt__(b)

b.__gt__(a)

Gera um TypeError

a >= b

a.__ge__(b)

b.__le__(a)

Gera um TypeError

a ⇐ b

a.__le__(b)

b.__ge__(a)

Gera um TypeError

Dadas essas regras, vamos revisar e aperfeiçoar o comportamento do método Vector.__eq__, que foi escrito assim no vector_v5.py (Exemplo 16):

class Vector:
    # many lines omitted

    def __eq__(self, other):
        return (len(self) == len(other) and
                all(a == b for a, b in zip(self, other)))

Eaae método produz os resultados do Exemplo 13.

Exemplo 13. Comparando um Vector a um Vector, a um Vector2d, e a uma tuple
>>> va = Vector([1.0, 2.0, 3.0])
>>> vb = Vector(range(1, 4))
>>> va == vb  # (1)
True
>>> vc = Vector([1, 2])
>>> from vector2d_v3 import Vector2d
>>> v2d = Vector2d(1, 2)
>>> vc == v2d  # (2)
True
>>> t3 = (1, 2, 3)
>>> va == t3  # (3)
True
  1. Duas instâncias de Vector com componentes numéricos iguais são iguais.

  2. Um Vector e um Vector2d também são iguais se seus componentes são iguais.

  3. Um Vector também é considerado igual a uma tuple ou qualquer iterável com itens numéricos de valor igual.

O resultado no Exemplo 13 é provavelmente indesejável. Queremos mesmo que um Vector seja considerado igual a uma tuple contendo os mesmos números? Não tenho uma regra fixa sobre isso; depende do contexto da aplicação. O "Zen of Python" diz:

Em face da ambiguidade, rejeite a tentação de adivinhar.

Liberalidade excessiva na avaliação de operandos pode levar a resultados surpreendentes, e programadores odeiam surpresas.

Buscando inspiração no próprio Python, vemos que [1,2] == (1, 2) é False. Então, vamos ser conservadores e executar alguma verificação de tipos. Se o segundo operando for uma instância de Vector (ou uma instância de uma subclasse de Vector), então usaremos a mesma lógica do __eq__ atual. Caso contrário, devolvemos NotImplemented e deixamos o Python cuidar do caso. Veja o Exemplo 14.

Exemplo 14. vector_v8.py: __eq__ aperfeiçoado na classe Vector
    def __eq__(self, other):
        if isinstance(other, Vector):  # (1)
            return (len(self) == len(other) and
                    all(a == b for a, b in zip(self, other)))
        else:
            return NotImplemented  # (2)
  1. Se o operando other é uma instância de Vector (ou de uma subclasse de Vector), executa a comparação como antes.

  2. Caso contrário, devolve NotImplemented.

Rodando os testes do Exemplo 13 com o novo Vector.__eq__ do Exemplo 14, obtemos os resultados que aparecem no Exemplo 15.

Exemplo 15. Mesmas comparações do Exemplo 13: o último resultado mudou
>>> va = Vector([1.0, 2.0, 3.0])
>>> vb = Vector(range(1, 4))
>>> va == vb  # (1)
True
>>> vc = Vector([1, 2])
>>> from vector2d_v3 import Vector2d
>>> v2d = Vector2d(1, 2)
>>> vc == v2d  # (2)
True
>>> t3 = (1, 2, 3)
>>> va == t3  # (3)
False
  1. Mesmo resultado de antes, como esperaado.

  2. Mesmo resultado de antes, mas por que? Explicação a seguir.

  3. Resultado diferente; era o que queríamos. Mas por que isso funciona? Continue lendo…​

Dos três resultados no Exemplo 15, o primeiro não é novidade, mas os dois últimos foram causados por __eq__ devolver NotImplemented no Exemplo 14. Eis o que acontece no exemplo com um Vector e um Vector2d, vc == v2d, passo a passo:

  1. Para avaliar vc == v2d, o Python invoca Vector.eq(vc, v2d).

  2. Vector.__eq__(vc, v2d) verifica que v2d não é um Vector e devolve NotImplemented.

  3. O Python recebe o resultado NotImplemented, então tenta Vector2d.__eq__(v2d, vc).

  4. Vector2d.__eq__(v2d, vc) transforma os dois operandos em tuplas e os compara: o resulltado é True (o código de Vector2d.__eq__ está no Exemplo 11).

Já para a comparação va == t3, entre Vector e tuple no Exemplo 15, os passos são:

  1. Para avaliar va == t3, o Python invoca Vector.__eq__(va, t3).

  2. Vector.__eq__(va, t3) verifica que t3 não é um Vector e devolve NotImplemented.

  3. O Python recebe o resultado NotImplemented, e então tenta tuple.__eq__(t3, va).

  4. tuple.__eq__(t3, va) não tem a menor ideia do que seja um Vector, então devolve NotImplemented.

  5. No caso especial de ==, se a chamada reversa devolve NotImplemented, o Python compara os IDs dos objetos, como último recurso.

Não precisamos implementar __ne__ para !=, pois o comportamento alternativo do __ne__ herdado de object nos serve: quando __eq__ é definido e não devolve NotImplemented, __ne__ devolve o mesmo resultado negado.

Em outras palavras, dados os mesmos objetos que usamos no Exemplo 15, os resultados para != são consistentes:

>>> va != vb
False
>>> vc != v2d
False
>>> va != (1, 2, 3)
True

O __ne__ herdado de object funciona como o código abaixo—exceto pelo original estar escrito em C:[214]

    def __ne__(self, other):
        eq_result = self == other
        if eq_result is NotImplemented:
            return NotImplemented
        else:
            return not eq_result

Vimos o básico da sobrecarga de operadores infixos.Vamos agora voltar nossa atenção para uma classe diferente de operador: os operadores de atribuição aumentada.

16.9. Operadores de atribuição aumentada

Nossa classe Vector já suporta os operadores de atribuição aumentada += e *=. Isso se dá porque a atribuição aumentada trabalha com recipientes imutáveis criando novas instâncias e re-vinculando a variável à esquerda do operador.

O Exemplo 16 os mostra em ação.

Exemplo 16. Usando += e *= com instâncias de Vector
>>> v1 = Vector([1, 2, 3])
>>> v1_alias = v1  # (1)
>>> id(v1)  # (2)
4302860128
>>> v1 += Vector([4, 5, 6])  # (3)
>>> v1  # (4)
Vector([5.0, 7.0, 9.0])
>>> id(v1)  # (5)
4302859904
>>> v1_alias  # (6)
Vector([1.0, 2.0, 3.0])
>>> v1 *= 11  # (7)
>>> v1  # (8)
Vector([55.0, 77.0, 99.0])
>>> id(v1)
4302858336
  1. Cria um alias, para podermos inspecionar o objeto Vector([1, 2, 3]) mais tarde.

  2. Verifica o ID do Vector inicial, vinculado a v1.

  3. Executa a adição aumentada.

  4. O resultado esperado…​

  5. …​mas foi criado um novo Vector.

  6. Inspeciona v1_alias para confirmar que o Vector original não foi alterado.

  7. Executa a multiplicação aumentada.

  8. Novamente, o resultado é o esperado, mas um novo Vector foi criado.

Se uma classe não implementa os operadores "no mesmo lugar" listados na Tabela 17, os operadores de atribuição aumentada funcionam como açúcar sintático: a = b` é avaliado exatamente como `a = a + b`. Esse é o comportamento esperado para tipos imutáveis, e se você fornecer `+__add__, então += funcionará sem qualquer código adicional.

Entretanto, se você implementar um operador "no mesmo lugar" tal como __iadd__, aquele método será chamado para computar o resultado de a += b. Como indica seu nome, se espera que esses operadores modifiquem o operando à esquerda do operador no mesmo lugar[215], e não criem um novo objeto como resultado.

⚠️ Aviso

Os métodos especiais de atualização no mesmo lugar não devem nunca ser implementados para tipos imutáveis como nossa classe Vector. Isso é bastante óbvio, mas vale a pena enfatizar.

Para mostrar o código de um operador de atualização no mesmo lugar, vamos estender a classe BingoCage do Exemplo 9 para implementar __add__ e __iadd__.

Vamos chamar a subclasse de AddableBingoCage. O Exemplo 17 mostra o comportamento esperado para o operador +.

Exemplo 17. O operador + cria uma nova instância de AddableBingoCage
    >>> vowels = 'AEIOU'
    >>> globe = AddableBingoCage(vowels)  # (1)
    >>> globe.inspect()
    ('A', 'E', 'I', 'O', 'U')
    >>> globe.pick() in vowels  # (2)
    True
    >>> len(globe.inspect())  # (3)
    4
    >>> globe2 = AddableBingoCage('XYZ')  # (4)
    >>> globe3 = globe + globe2
    >>> len(globe3.inspect())  # (5)
    7
    >>> void = globe + [10, 20]  # (6)
    Traceback (most recent call last):
      ...
    TypeError: unsupported operand type(s) for +: 'AddableBingoCage' and 'list'
  1. Cria uma instância de globe com cinco itens (cada uma das vowels).

  2. Extrai um dos itens, e verifica que é uma das vowels.

  3. Confirma que globe tem agora quatro itens.

  4. Cria uma segunda instância, com três itens.

  5. Cria uma terceira instância pela soma das duas anteriores. Essa instância tem sete itens.

  6. Tentar adicionar uma AddableBingoCage a uma list falha com um TypeError. A mensagem de erro é produzida pelo interpretador do Python quando nosso método __add__ devolve NotImplemented.

Como uma AddableBingoCage é mutável, o Exemplo 18 mostra como ela funcionará quando implementarmos __iadd__.

Exemplo 18. Uma AddableBingoCage existente pode ser carregada com += (continuando do Exemplo 17)
    >>> globe_orig = globe  # (1)
    >>> len(globe.inspect())  # (2)
    4
    >>> globe += globe2  # (3)
    >>> len(globe.inspect())
    7
    >>> globe += ['M', 'N']  # (4)
    >>> len(globe.inspect())
    9
    >>> globe is globe_orig  # (5)
    True
    >>> globe += 1  # (6)
    Traceback (most recent call last):
      ...
    TypeError: right operand in += must be 'Tombola' or an iterable
  1. Cria um alias para podermos verificar a identidade do objeto mais tarde.

  2. globe tem quatro itens aqui.

  3. Uma instância de AddableBingoCage pode receber itens de outra instância da mesma classe.

  4. O operador à diretia de += também pode ser qualquer iterável.

  5. Durante todo esse exemplo, globe sempre se refere ao mesmo objeto que globe_orig.

  6. Tentar adicionar um não-iterável a uma AddableBingoCage falha com uma mensagem de erro apropriada.

Observe que o operador =` é mais liberal que ` quanto ao segundo operando. Com `, queremos que ambos os operandos sejam do mesmo tipo (nesse caso, `AddableBingoCage`), pois se aceitássemos tipos diferentes, isso poderia causar confusão quanto ao tipo do resultado. Com o `=, a situação é mais clara: o objeto à esquerda do operador é atualizado no mesmo lugar, então não há dúvida quanto ao tipo do resultado.

👉 Dica

Eu validei os comportamentos diversos de ` e `= observando como funciona o tipo embutido list. Ao escrever my_list + x, você só pode concatenar uma list a outra list, mas se você escrever my_list += x, você pode estender a lista da esquerda com itens de qualquer iterável x à direita do operador. É assim que o método list.extend() funciona: ele aceita qualquer argumento iterável.

Agora que esclarecemos o comportamento desejado para AddableBingoCage, podemos examinar sua implementação no Exemplo 19. Lembre-se que BingoCage, do Exemplo 9, é uma subclasse concreta da ABC Tombola do Exemplo 7.

Exemplo 19. bingoaddable.py: AddableBingoCage estende BingoCage para suportar ` e `=
from tombola import Tombola
from bingo import BingoCage


class AddableBingoCage(BingoCage):  # (1)

    def __add__(self, other):
        if isinstance(other, Tombola):  # (2)
            return AddableBingoCage(self.inspect() + other.inspect())
        else:
            return NotImplemented

    def __iadd__(self, other):
        if isinstance(other, Tombola):
            other_iterable = other.inspect()  # (3)
        else:
            try:
                other_iterable = iter(other)  # (4)
            except TypeError:  # (5)
                msg = ('right operand in += must be '
                       "'Tombola' or an iterable")
                raise TypeError(msg)
        self.load(other_iterable)  # (6)
        return self  # (7)
  1. AddableBingoCage estende BingoCage.

  2. Nosso __add__ só vai funcionar se o segundo operando for uma instância de Tombola.

  3. Em __iadd__, obtém os itens de other, se ele for uma instância de Tombola.

  4. Caso contrário, tenta obter um iterador sobre other.[216]

  5. Se aquilo falhar, gera uma exceção explicando o que o usuário deve fazer. Sempre que possível, mensagens de erro devem guiar o usuário explicitamente para a solução.

  6. Se chegamos até aqui, podemos carregar o other_iterable para self.

  7. Muito importante: os métodos especiais de atribuição aumentada de objetos mutáveis devem devolver self. É o que os usuários esperam.

Podemos resumir toda a ideia dos operadores de atualização no mesmo lugar comparando as instruções return que produzem os resultados em __add__ e em __iadd__ no Exemplo 19:

__add__

O resultado é produzido chamando o construtor AddableBingoCage para criar uma nova instância.

__iadd__

O resultado é produzido devolvendo self, após ele ter sido modificado.

Para concluir esse exemplo, uma última observação sobre o Exemplo 19: propositalmente, nenhum método __radd__ foi incluído em AddableBingoCage, porque não há necessidade. O método direto __add__ só vai lidar com operandos à direita do mesmo tipo, então se o Python tentar computar a + b, onde a é uma AddableBingoCage e b não, devolvemos NotImplemented—talvez a classe de b possa fazer isso funcionar. Mas se a expressão for b + a e b não for uma AddableBingoCage, e devolver NotImplemented, então é melhor deixar o Python desistir e gerar um TypeError, pois não temos como tratar b.

👉 Dica

De modo geral, se um método de operador infixo direto (por exemplo __mul__) for projetado para funcionar apenas com operandos do mesmo tipo de self, é inútil implementar o método reverso correspondente (por exemplo, __rmul__) pois, por definição, esse método só será invocado quando estivermos lidando com um operando de um tipo diferente.

Isso conclui nossa exploração de sobrecarga de operadores no Python.

16.10. Resumo do capítulo

Começamos o capítulo revisando algumas restrições impostas pelo Python à sobrecarga de operadores: é proibido redefinir operadores nos próprios tipos embutidos, a sobrecarga está limitada aos operadores existentes, e alguns operadores não podem ser sobrecarregados (is, and, or, not).

Colocamos a mão na massa com os operadores unários, implementando __neg__ e __pos__. A seguir vieram os operadores infixos, começando por `, suportado pelo método `+__add__. Vimos que operadores unários e infixos devem produzir resultados criando novos objetos, sem nunca modificar seus operandos. Para suportar operações com outros tipos, devolvemos o valor especial NotImplemented—não uma exceção—permitindo ao interpretador tentar novamente permutando os operandos e chamando o método especial reverso para aquele operador (por exemplo, __radd__). O algoritmo usado pelo Python para tratar operadores infixos está resumido no fluxograma da Figura 1.

Misturar operandos de mais de um tipo exige detectar os operandos que não podemos tratar. Neste capitulo fizemos isso de duas maneiras: ao modo do duck typing, apenas fomos em frente e tentamos a operação, capturando uma exceção de TypeError se ela acontecesse; mais tarde, em __mul__ e __matmul__, usamos um teste isinstance explícito. Há prós e contras nas duas abordagens: duck typing é mais flexível, mas a verificação explícita de tipo é mais previsível.

De modo geral, bibliotecas deveriam tirar proveito do duck typing--abrindo a porta para objetos independente de seus tipos, desde que eles suportem as operações necessárias. Entretanto, o algoritmo de despacho de operadores do Python pode produzir mensagens de erro enganosas ou resultados inesperados quando combinado com o duck typing. Por essa razão, a disciplina da verificação de tipo com invocações de isinstance contra ABCs é muitas vezes útil quando escrevemos métodos especiais para sobrecarga de operadores. Essa é a técnica batizada de goose typing por Alex Martelli—como vimos na Seção 13.5. A goose typing é um bom compromisso entre a flexibilidade e a segurança, porque os tipos definidos pelo usuário, existentes ou futuros, podem ser declarados como subclasses reais ou virtuais de uma ABC. Além disso, se uma ABC implementa o __subclasshook__, objetos podem então passar por verificações com isinstance contra aquela ABC apenas fornecendo os métodos exigidos—​sem necessidade de ser uma subclasse ou de se registrar com a ABC.

O próximo tópico tratado foram os operadores de comparação cheia. Implementamos == com __eq__ e descobrimos que o Python oferece uma implementação conveniente de != no __ne__ herdado da classe base object. A forma como o Python avalia esses operadores, bem como >, <, >=, e , é um pouco diferente, com uma lógica especial para a escolha do método reverso, e um tratamento alternativo para == e != que nunca gera erros, pois o Python compara os IDs dos objetos como último recurso.

Na última seção, nos concentramos nos operadores de atribuição aumentada. Vimos que o Python os trata, por default, como uma combinação do operador simples seguido de uma atribuição, isto é: a = b` é avaliado exatamente como `a = a + b`. Isso sempre cria um novo objeto, então funciona para tipos mutáveis ou imutáveis. Para objetos mutáveis, podemos implementar métodos especiais de atualização no mesmo lugar, tal como `+__iadd__ para =`, e alterar o valor do operando à esquerda do operador. Para demonstrar isso na prática, deixamos para trás a classe imutável `Vector` e trabalhamos na implementação de uma subclasse de `BingoCage`, suportando `= para adicionar itens ao reservatório de itens para sorteio, de modo similar à forma como o tipo embutido list suporta =` como um atalho para o método `list.extend()`. Enquanto fazíamos isso, discutimos como ` tende a ser mais estrito que =` em relação aos tipos aceitos. Para tipos de sequências, ` normalmente exige que ambos os operandos sejam do mesmo tipo, enquanto += muitas vezes aceita qualquer iterável como o operando à direita do operador.

16.11. Leitura complementar

Guido van Rossum escreveu uma boa apologia da sobrecarga de operadores em "Why operators are useful" (Porque operadores são úteis) (EN). Trey Hunner postou "Tuple ordering and deep comparisons in Python" (Ordenação de tuplas e comparações profundas em Python) (EN), argumentando que os operadores de comparação cheia do Python são mais flexíveis e poderosos do que os programadores vindos de outras linguagens costumam pensar.

A sobrecarga de operadores é uma área da programação em Python onde testes com isinstance são comuns. A melhor prática relacionada a tais testes é a goose typing, tratada na Seção 13.5. Se você pulou essa parte, se assegure de voltar lá e ler aquela seção.

A principal referência para os métodos especiais de operadores é o capítulo "Modelos de Dados" na documentação do Python. Outra leitura relevante é "Implementando as operações aritméticas" no módulo numbers da Biblioteca Padrão do Python.

Um exemplo brilhante de sobrecarga de operadores apareceu no pacote pathlib, adicionado no Python 3.4. Sua classe Path sobrecarrega o operador / para construir caminhos do sistema de arquivos a partir de strings, como mostra o exemplo abaixo, da documentação:

>>> p = Path('/etc')
>>> q = p / 'init.d' / 'reboot'
>>> q
PosixPath('/etc/init.d/reboot')

Outro exemplo não aritmético de sobrecarga de operadores está na biblioteca Scapy, usada para "enviar, farejar, dissecar e forjar pacotes de rede". Na Scapy, o operador / operator cria pacotes empilhando campos de diferentes camadas da rede. Veja "Stacking layers" (_Empilhando camadas) (EN) para mais detalhes.

Se você está prestes a implementar operadores de comparação, estude functools.total_ordering. Esse é um decorador de classes que gera automaticamente os métodos para todos os operadores de comparação cheia em qualquer classe que defina ao menos alguns deles. Veja a documentação do módulo functools (EN).

Se você tiver curiosidade sobre o despacho de métodos de operadores em linguagens com tipagem dinâmica, duas leituras fundamentais são "A Simple Technique for Handling Multiple Polymorphism" (Uma Técnica Simples para Tratar Polimorfismo Múltiplo) (EN), de Dan Ingalls (membro da equipe original do Smalltalk), e "Arithmetic and Double Dispatching in Smalltalk-80" (Aritmética e Despacho Duplo no Smalltalk-80) (EN), de Kurt J. Hebel e Ralph Johnson (Johnson ficou famoso como um dos autores do livro Padrões de Projetos original). Os dois artigos fornecem discussões profundas sobre o poder do polimorfismo em linguagens com tipagem dinâmica, como o Smalltalk, o Python e o Ruby. O Python não tem despacho duplo para tratar operadores, como descrito naqueles artigos. O algoritmo do Python, usando operadores diretos e reversos, é mais fácil de suportar por classes definidas pelo usuário que o despacho duplo, mas exige tratamento especial pelo interpretador. Por outro lado, o despacho duplo clássico é uma técnica geral, que pode ser usada no Python ou em qualquer linguagem orientada a objetos, para além do contexto específico de operadores infixos. E, de fato, Ingalls, Hebel e Johnson usam exemplos muito diferentes para descrever essa técnica.

O artigo "The C Family of Languages: Interview with Dennis Ritchie, Bjarne Stroustrup, and James Gosling"(A Família de Linguagens C: Entrevista com Dennis Ritchie, Bjarne Stroustrup, e James Gosling) (EN), da onde tirei a epígrafe desse capítulo, apareceu na Java Report, 5(7), julho de 2000, e na C++ Report, 12(7), julho/agosto de 2000, juntamente com outros trechos que usei no "Ponto de Vista" deste capítulo (abaixo). Se você se interessa pelo projeto de linguagens de programação, faça um favor a si mesmo e leia aquela entrevista.

Ponto de Vista

Sobrecarga de operadores: prós e contras

James Gosling, citado no início desse capítulo, tomou a decisão consciente de excluir a sobrecarga de operadores quando projetou o Java. Naquela mesma entrevista ("The C Family of Languages: Interview with Dennis Ritchie, Bjarne Stroustrup, and James Gosling" (A Família de Linguagens C: Entrevista com Dennis Ritchie, Bjarne Stroustrup, e James Gosling) (EN)) ele diz:

Provavelmente uns 20 a 30 porcento da população acha que sobrecarga de operadores é uma criação demoníaca; alguém fez algo com sobrecarga de operadores que realmente os tirou do sério, porque eles usaram algo como + para inserção em listas, e isso torna a vida muito, muito confusa. Muito daquele problema vem do fato de existirem apenas uma meia dúzia de operadores que podem ser sobrecarregados de forma razoável, mas existem milhares ou milhões de operadores que as pessoas gostariam de definir—então é preciso escolher, e muitas vezes as escolhas entram em conflito com a sua intuição.

Guido van Rossum escolheu o caminho do meio no suporte à sobrecarga de operadores: ele não deixou a porta aberta para que os usuários criassem novos operadores arbitrários como <⇒ ou :-), evitando uma Torre de Babel de operadores personalizados, e permitindo ao analisador sintático do Python permanecer simples. O Python também não permite a sobrecarga dos operadores de tipos embutidos, outra limitação que promove a legibilidade e o desempenho previsível.

Gosling continua:

E então há uma comunidade de aproximadamente 10 porcento que havia de fato usado a sobrecarga de operadores de forma apropriada, e que realmente gostavam disso, e para quem isso era realmente importante; essas são quase exclusivamente pessoas que fazem trabalho numérico, onde a notação é muito importante para avivar a intuição [das pessoas], porque elas chegam ali com uma intuição sobre o que + significa, e a capacidade de dizer "a + b", onde a e b são números complexos ou matrizes ou alguma outra coisa, realmente faz sentido.

Claro, há benefícios em não permitir a sobrecarga de operadores em uma linguagem. Já ouvi o argumento que C é melhor que C++ para programação de sistemas, porque a sobrecarga de operadores em C++ pode fazer com que operações dispendiosas pareçam triviais. Duas linguagens modernas bem sucedidas, que compilam para executáveis binários, fizeram escolhas opostas: Go não tem sobrecarga de operadores, Rust tem.

Mas operadores sobrecarregados, quando usados de forma sensata, tornam o código mais fácil de ler e escrever. É um ótimo recurso em uma linguagem de alto nível moderna.

Um vislumbre da avaliação preguiçosa

Se você olhar de perto o traceback no Exemplo 9, vai encontrar evidências da avaliação preguiçosa de expressões geradoras. O Exemplo 20 é o mesmo traceback, agora com explicações.

Exemplo 20. Mesmo que o Exemplo 9
>>> v1 + 'ABC'
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "vector_v6.py", line 329, in __add__
    return Vector(a + b for a, b in pairs)  # (1)
  File "vector_v6.py", line 243, in __init__
    self._components = array(self.typecode, components)  # (2)
  File "vector_v6.py", line 329, in <genexpr>
    return Vector(a + b for a, b in pairs)  # (3)
TypeError: unsupported operand type(s) for +: 'float' and 'str'
  1. A chamada a Vector recebe uma expressão geradora como seu argumento components. Nenhum problema nesse estágio.

  2. A genexp components é passada para o construtor de array. Dentro do construtor de array, o Python tenta iterar sobre a genexp, causando a avaliação do primeiro item a + b. É quando ocorre o TypeError.

  3. A exceção se propaga para a chamada ao construtor de Vector, onde é relatada.

Isso mostra como a expressão geradora é avaliada no último instante possível, e não onde é definida no código-fonte.

Se, por outro lado, o construtor de Vector fosse invocado como Vector([a + b for a, b in pairs]), então a exceção ocorreria bem ali, porque a compreensão de lista tentou criar uma list para ser passada como argumento para a chamada a Vector(). O corpo de Vector.__init__ nunca seria alcançado.

O Capítulo 17 vai tratar das expressões geradoras em detalhes, mas não eu queria deixar essa demonstração acidental de sua natureza preguiçosa passar desapercebida.

Parte IV: Controle de fluxo

17. Iteradores, geradores e corrotinas clássicas

Quando vejo padrões em meus programas, considero isso um mau sinal. A forma de um programa deve refletir apenas o problema que ele precisa resolver. Qualquer outra regularidade no código é, pelo menos para mim, um sinal que estou usando abstrações que não são poderosas o suficiente—muitas vezes estou gerando à mão as expansões de alguma macro que preciso escrever.[217]

— Paul Graham
hacker de Lisp e investidor

A iteração é fundamental para o processamento de dados: programas aplicam computações sobre séries de dados, de pixels a nucleotídeos. Se os dados não cabem na memória, precisamos buscar esses itens de forma preguiçosa—um de cada vez e sob demanda. É isso que um iterador faz. Este capítulo mostra como o padrão de projeto Iterator ("Iterador") está embutido na linguagem Python, de modo que nunca será necessário programá-lo manualmente.

Todas as coleções padrão do Python são iteráveis. Um iterável é um objeto que fornece um iterador, que o Python usa para suportar operações como:

  • loops for

  • Compreensões de lista, dict e set

  • Desempacotamento para atribuições

  • Criação de instâncias de coleções

Este capítulo cobre os seguintes tópicos:

  • Como o Python usa a função embutida iter() para lidar com objetos iteráveis

  • Como implementar o padrão Iterator clássico no Python

  • Como o padrão Iterator clássico pode ser substituído por uma função geradora ou por uma expressão geradora

  • Como funciona uma função geradora, em detalhes, com descrições linha a linha

  • Aproveitando o poder das funções geradoras de uso geral da biblioteca padrão

  • Usando expressões yield from para combinar geradoras

  • Porque geradoras e corrotinas clássicas se parecem, mas são usadas de formas muito diferentes e não devem ser misturadas

17.1. Novidades nesse capítulo

A Seção 17.11 aumentou de uma para seis páginas. Ela agora inclui experimentos simples, demonstrando o comportamento de geradoras com yield from, e um exemplo de código para percorrer uma árvore de dados, desenvolvido passo a passo.

Novas seções explicam as dicas de tipo para os tipos Iterable, Iterator e Generator.

A última grande seção do capítulo, Seção 17.13, é agora uma introdução de 9 páginas a um tópico que ocupava um capítulo de 40 páginas na primeira edição. Atualizei e transferi o capítulo Classic Coroutines (Corrotinas Clássicas) para um post no site que acompanha o livro, porque ele era o capítulo mais difícil para os leitores, mas seu tema se tornou menos relevante após a introdução das corrotinas nativas no Python 3.5 (estudaremos as corrotinas nativas no Capítulo 21).

Vamos começar examinando como a função embutida iter() torna as sequências iteráveis.

17.2. Uma sequência de palavras

Vamos começar nossa exploração de iteráveis implementando uma classe Sentence: seu construtor recebe uma string de texto e daí podemos iterar sobre a "sentença" palavra por palavra. A primeira versão vai implementar o protocolo de sequência e será iterável, pois todas as sequências são iteráveis—como sabemos desde o Capítulo 1. Agora veremos exatamente porque isso acontece.

O Exemplo 1 mostra uma classe Sentence que extrai palavras de um texto por índice.

Exemplo 1. sentence.py: uma Sentence como uma sequência de palavras
import re
import reprlib

RE_WORD = re.compile(r'\w+')


class Sentence:

    def __init__(self, text):
        self.text = text
        self.words = RE_WORD.findall(text)  # (1)

    def __getitem__(self, index):
        return self.words[index]  # (2)

    def __len__(self):  # (3)
        return len(self.words)

    def __repr__(self):
        return 'Sentence(%s)' % reprlib.repr(self.text)  # (4)
  1. .findall devolve a lista com todos os trechos não sobrepostos correspondentes à expressão regular, como uma lista de strings.

  2. self.words mantém o resultado de .findall, então basta devolver a palavra em um dado índice.

  3. Para completar o protocolo de sequência, implementamos __len__, apesar dele não ser necessário para criar um iterável.

  4. reprlib.repr é uma função utilitária para gerar representações abreviadas, em forma de strings, de estruturas de dados que podem ser muito grandes.[218]

Por default, reprlib.repr limita a string gerada a 30 caracteres. Veja como Sentence é usada na sessão de console do Exemplo 2.

Exemplo 2. Testando a iteração em uma instância de Sentence
>>> s = Sentence('"The time has come," the Walrus said,')  # (1)
>>> s
Sentence('"The time ha... Walrus said,')  # (2)
>>> for word in s:  # (3)
...     print(word)
The
time
has
come
the
Walrus
said
>>> list(s)  # (4)
['The', 'time', 'has', 'come', 'the', 'Walrus', 'said']
  1. Uma sentença criada a partir de uma string.

  2. Observe a saída de __repr__ gerada por reprlib.repr, usando …​.

  3. Instâncias de Sentence são iteráveis; veremos a razão em seguida.

  4. Sendo iteráveis, objetos Sentence podem ser usados como entrada para criar listas e outros tipos iteráveis.

Nas próximas páginas vamos desenvolver outras classes Sentence que passam nos testes do Exemplo 2. Entretanto, a implementação no Exemplo 1 difere das outras por ser também uma sequência, e então é possível obter palavras usando um índice:

>>> s[0]
'The'
>>> s[5]
'Walrus'
>>> s[-1]
'said'

Programadores Python sabem que sequências são iteráveis. Agora vamos descobrir exatamente o porquê disso.

17.3. Porque sequências são iteráveis: a função iter

Sempre que o Python precisa iterar sobre um objeto x, ele automaticamente invoca iter(x).

A função embutida iter:

  1. Verifica se o objeto implementa o método __iter__, e o invoca para obter um iterador.

  2. Se __iter__ não for implementado, mas __getitem__ sim, então iter() cria um iterador que tenta buscar itens pelo índice, começando de 0 (zero).

  3. Se isso falhar, o Python gera um TypeError, normalmente dizendo 'C' object is not iterable (objeto 'C' não é iterável), onde C é a classe do objeto alvo.

Por isso todas as sequências do Python são iteráveis: por definição, todas elas implementam __getitem__. Na verdade, todas as sequências padrão também implementam __iter__, e as suas próprias sequências também deviam implementar esse método, porque a iteração via __getitem__ existe para manter a compatibilidade retroativa, e pode desaparecer em algum momento—apesar dela não ter sido descontinuada no Python 3.10, e eu duvidar que vá ser removida algum dia.

Como mencionado na Seção 13.4.1, essa é uma forma extrema de duck typing: um objeto é considerado iterável não apenas quando implementa o método especial __iter__, mas também quando implementa __getitem__. Veja isso:

>>> class Spam:
...     def __getitem__(self, i):
...         print('->', i)
...         raise IndexError()
...
>>> spam_can = Spam()
>>> iter(spam_can)
<iterator object at 0x10a878f70>
>>> list(spam_can)
-> 0
[]
>>> from collections import abc
>>> isinstance(spam_can, abc.Iterable)
False

Se uma classe fornece __getitem__, a função embutida iter() aceita uma instância daquela classe como iterável e cria um iterador a partir da instância. A maquinaria de iteração do Python chamará __getitem__ com índices, começando de 0, e entenderá um IndexError como sinal de que não há mais itens.

Observe que, apesar de spam_can ser iterável (seu método __getitem__ poderia fornecer itens), ela não é reconhecida assim por uma chamada a isinstance contra abc.Iterable.

Na abordagem da goose typing, a definição para um iterável é mais simples, mas não tão flexível: um objeto é considerado iterável se implementa o método __iter__. Não é necessário ser subclasse ou se registar, pois abc.Iterable implementa o __subclasshook__, como visto na Seção 13.5.8. Eis uma demonstração:

>>> class GooseSpam:
...     def __iter__(self):
...         pass
...
>>> from collections import abc
>>> issubclass(GooseSpam, abc.Iterable)
True
>>> goose_spam_can = GooseSpam()
>>> isinstance(goose_spam_can, abc.Iterable)
True
👉 Dica

Desde o Python 3.10, a forma mais precisa de verificar se um objeto x é iterável é invocar iter(x) e tratar a exceção TypeError se ele não for. Isso é mais preciso que usar isinstance(x, abc.Iterable), porque iter(x) também leva em consideração o método legado __getitem__, enquanto a ABC Iterable não considera tal método.

Verificar explicitamente se um objeto é iterável pode não valer a pena, se você for iterar sobre o objeto logo após a verificação. Afinal, quando se tenta iterar sobre um não-iterável, a exceção gerada pelo Python é bastante clara: TypeError: 'C' object is not iterable (TypeError: o objeto 'C' não é iterável). Se você puder fazer algo mais além de gerar um TypeError, então faça isso em um bloco try/except ao invés de realizar uma verificação explícita. A verificação explícita pode fazer sentido se você estiver mantendo o objeto para iterar sobre ele mais tarde; nesse caso, capturar o erro mais cedo torna a depuração mais fácil.

A função embutida iter() é usada mais frequentemente pelo Python que no nosso código. Há uma segunda maneira de usá-la, mas não é muito conhecida.

17.3.1. Usando iter com um invocável

Podemos chamar iter() com dois argumentos, para criar um iterador a partir de uma função ou de qualquer objeto invocável. Nessa forma de uso, o primeiro argumento deve ser um invocável que será chamado repetidamente (sem argumentos) para produzir valores, e o segundo argumento é um valor sentinela (EN): um marcador que, quando devolvido por um invocável, faz o iterador gerar um StopIteration ao invés de produzir o valor sentinela.

O exemplo a seguir mostra como usar iter para rolar um dado de seis faces até que o valor 1 seja sorteado:

>>> def d6():
...     return randint(1, 6)
...
>>> d6_iter = iter(d6, 1)
>>> d6_iter
<callable_iterator object at 0x10a245270>
>>> for roll in d6_iter:
...     print(roll)
...
4
3
6
3

Observe que a função iter devolve um callable_iterator. O loop for no exemplo pode rodar por um longo tempo, mas nunca vai devolver 1, pois esse é o valor sentinela. Como é comum com iteradores, o objeto d6_iter se torna inútil após ser exaurido. Para recomeçar, é necessário reconstruir o iterador, invocando novamente iter().

A documentação de iter inclui a seguinte explicação e código de exemplo:

Uma aplicação útil da segunda forma de iter() é para construir um bloco de leitura. Por exemplo, ler blocos de comprimento fixo de um arquivo binário de banco de dados até que o final do arquivo seja atingido:

from functools import partial

with open('mydata.db', 'rb') as f:
    read64 = partial(f.read, 64)
    for block in iter(read64, b''):
        process_block(block)

Para deixar o código mais claro, adicionei a atribuição read64, que não está no exemplo original. A função partial() é necessária porque o invocável passado a iter() não pode requerer argumentos. No exemplo, um objeto bytes vazio é a sentinela, pois é isso que f.read devolve quando não há mais bytes para ler.

A próxima seção detalha a relação entre iteráveis e iteradores.

17.4. Iteráveis versus iteradores

Da explicação na Seção 17.3 podemos extrapolar a seguinte definição:

iterável

Qualquer objeto a partir do qual a função embutida iter consegue obter um iterador. Objetos que implementam um método __iter__ devolvendo um iterador são iteráveis. Sequências são sempre iteráveis, bem como objetos que implementam um método __getitem__ que aceite índices iniciando em 0.

É importante deixar clara a relação entre iteráveis e iteradores: o Python obtém iteradores de iteráveis.

Aqui está um simples loop for iterando sobre uma str. A str 'ABC' é o iterável aqui. Você não vê, mas há um iterador por trás das cortinas:

>>> s = 'ABC'
>>> for char in s:
...     print(char)
...
A
B
C

Se não existisse uma instrução for e fosse preciso emular o mecanismo do for à mão com um loop while, isso é o que teríamos que escrever:

>>> s = 'ABC'
>>> it = iter(s)  # (1)
>>> while True:
...     try:
...         print(next(it))  # (2)
...     except StopIteration:  # (3)
...         del it  # (4)
...         break  # (5)
...
A
B
C
  1. Cria um iterador it a partir de um iterável.

  2. Chama next repetidamente com o iterador, para obter o item seguinte.

  3. O iterador gera StopIteration quando não há mais itens.

  4. Libera a referência a it—o obleto iterador é descartado.

  5. Sai do loop.

StopIteration sinaliza que o iterador foi exaurido. Essa exceção é tratada internamente pela função embutida iter(), que é parte da lógica dos loops for e de outros contextos de iteração, como compreensões de lista, desempacotamento iterável, etc.

A interface padrão do Python para um iterador tem dois métodos:

__next__

Devolve o próximo item em uma série, gerando StopIteration se não há mais nenhum.

__iter__

Devolve self; isso permite que iteradores sejam usado quando um iterável é esperado. Por exemplo, em um loop for loop.

Essa interface está formalizada na ABC collections.abc.Iterator, que declara o método abstrato __next__, e é uma subclasse de Iterable—onde o método abstrato __iter__ é declarado. Veja a Figura 1.

Diagrama UML de Iterable
Figura 1. As ABCs Iterable e Iterator. Métodos em itálico são abstratos. Um Iterable.__iter__ concreto deve devolver uma nova instância de Iterator. Um Iterator concreto deve implementar __next__. O método Iterator.__iter__ apenas devolve a própria instância.

O código-fonte de collections.abc.Iterator aparece no Exemplo 3.

Exemplo 3. Classe abc.Iterator; extraído de Lib/_collections_abc.py
class Iterator(Iterable):

    __slots__ = ()

    @abstractmethod
    def __next__(self):
        'Return the next item from the iterator. When exhausted, raise StopIteration'
        raise StopIteration

    def __iter__(self):
        return self

    @classmethod
    def __subclasshook__(cls, C):  # (1)
        if cls is Iterator:
            return _check_methods(C, '__iter__', '__next__')  # (2)
        return NotImplemented
  1. __subclasshook__ suporta a verificação de tipo estrutural com isinstance e issubclass. Vimos isso na Seção 13.5.8.

  2. _check_methods percorre o parâmetro __mro__ da classe, para verificar se os métodos estão implementados em sua classe base. Ele está definido no mesmo módulo, Lib/_collections_abc.py. Se os métodos estiverem implementados, a classe C será reconhecida como uma subclasse virtual de Iterator. Em outras palavras, issubclass(C, Iterable) devolverá True.

⚠️ Aviso

O método abstrato da ABC Iterator é it.__next__() no Python 3 e it.next() no Python 2. Como sempre, você deve evitar invocar métodos especiais diretamente. Use apenas next(it): essa função embutida faz a coisa certa no Python 2 e no 3—algo útil para quem está migrando bases de código do 2 para o 3.

O código-fonte do módulo Lib/types.py no Python 3.9 tem um comentário dizendo:

# Iteradores no Python não são uma questão de tipo, mas sim de protocolo. Um número
# grande e variável de tipos embutidos implementa *alguma* forma de
# iterador.  Não verifique o tipo! Em vez disso, use `hasattr` para
# verificar [a existência] de ambos os atributos "__iter__" e "__next__".

E de fato, é exatamente o que o método __subclasshook__ da ABC abc.Iterator faz.

👉 Dica

Dado o conselho de Lib/types.py e a lógica implementada em Lib/_collections_abc.py, a melhor forma de verificar se um objeto x é um iterador é invocar isinstance(x, abc.Iterator). Graças ao Iterator.__subclasshook__, esse teste funciona mesmo se a classe de x não for uma subclasse real ou virtual de Iterator.

Voltando à nossa classe Sentence no Exemplo 1, usando o console do Python é possivel ver claramente como o iterador é criado por iter() e consumido por next():

>>> s3 = Sentence('Life of Brian')  # (1)
>>> it = iter(s3)  # (2)
>>> it  # doctest: +ELLIPSIS
<iterator object at 0x...>
>>> next(it)  # (3)
'Life'
>>> next(it)
'of'
>>> next(it)
'Brian'
>>> next(it)  # (4)
Traceback (most recent call last):
  ...
StopIteration
>>> list(it)  # (5)
[]
>>> list(iter(s3))  # (6)
['Life', 'of', 'Brian']
  1. Cria uma sentença s3 com três palavras.

  2. Obtém um iterador a partir de s3.

  3. next(it) devolve a próxima palavra.

  4. Não há mais palavras, então o iterador gera uma exceção StopIteration.

  5. Uma vez exaurido, um itereador irá sempre gerar StopIteration, o que faz parecer que ele está vazio..

  6. Para percorrer a sentença novamente é preciso criar um novo iterador.

Como os únicos métodos exigidos de um iterador são __next__ e __iter__, não há como verificar se há itens restantes, exceto invocando next() e capturando StopIteration. Além disso, não é possível "reiniciar" um iterador. Se for necessário começar de novo, é preciso invocar iter() no iterável que criou o iterador original. Invocar iter() no próprio iterador também não funciona, pois—como já mencionado—a implementação de Iterator.__iter__ apenas devolve self, e isso não reinicia um iterador exaurido.

Essa interface mínima é bastante razoável porque, na realidade, nem todos os itereadores são reiniciáveis. Por exemplo, se um iterador está lendo pacotes da rede, não há como "rebobiná-lo".[219]

A primeira versão de Sentence, no Exemplo 1, era iterável graças ao tratamento especial dispensado pela função embutida às sequências. A seguir vamos implementar variações de Sentence que implementam __iter__ para devolver iteradores.

17.5. Classes Sentence com __iter__

As próximas variantes de Sentence implementam o protocolo iterável padrão, primeiro implementando o padrão de projeto Iterable e depois com funções geradoras.

17.5.1. Sentence versão #2: um iterador clássico

A próxima implementação de Sentence segue a forma do padrão de projeto Iterator clássico, do livro Padrões de Projeto. Observe que isso não é Python idiomático, como as refatorações seguintes deixarão claro. Mas é útil para mostrar a distinção entre uma coleção iterável e um iterador que trabalha com ela.

A classe Sentence no Exemplo 4 é iterável por implementar o método especial __iter__, que cria e devolve um SentenceIterator. É assim que um iterável e um iterador se relacionam.

Exemplo 4. sentence_iter.py: Sentence implementada usando o padrão Iterator
import re
import reprlib

RE_WORD = re.compile(r'\w+')


class Sentence:

    def __init__(self, text):
        self.text = text
        self.words = RE_WORD.findall(text)

    def __repr__(self):
        return f'Sentence({reprlib.repr(self.text)})'

    def __iter__(self):  # (1)
        return SentenceIterator(self.words)  # (2)


class SentenceIterator:

    def __init__(self, words):
        self.words = words  # (3)
        self.index = 0  # (4)

    def __next__(self):
        try:
            word = self.words[self.index]  # (5)
        except IndexError:
            raise StopIteration()  # (6)
        self.index += 1  # (7)
        return word  # (8)

    def __iter__(self):  # (9)
        return self
  1. O método __iter__ é o único acréscimo à implementação anterior de Sentence. Essa versão não inclui um __getitem__, para deixar claro que a classe é iterável por implementar __iter__.

  2. __iter__ atende ao protocolo iterável instanciando e devolvendo um iterador.

  3. SentenceIterator mantém uma referência para a lista de palavras.

  4. self.index determina a próxima palavra a ser recuperada.

  5. Obtém a palavra em self.index.

  6. Se não há palavra em self.index, gera uma StopIteration.

  7. Incrementa self.index.

  8. Devolve a palavra.

  9. Implementa self.__iter__.

O código do Exemplo 4 passa nos testes do Exemplo 2.

Veja que não é de fato necessário implementar __iter__ em SentenceIterator para esse exemplo funcionar, mas é o correto a fazer: supõe-se que iteradores implementem tanto __next__ quanto __iter__, e fazer isso permite ao nosso iterador passar no teste issubclass(SentenceIterator, abc.Iterator). Se tivéssemos tornado SentenceIterator uma subclasse de abc.Iterator, teríamos herdado o método concreto abc.Iterator.__iter__.

É um bocado de trabalho (pelo menos para nós, programadores mimados pelo Python). Observe que a maior parte do código em SentenceIterator serve para gerenciar o estado interno do iterador. Logo veremos como evitar essa burocracia. Mas antes, um pequeno desvio para tratar de um atalho de implementação que pode parecer tentador, mas é apenas errado.

17.5.2. Não torne o iterável também um iterador

Uma causa comum de erros na criação de iteráveis é confundir os dois. Para deixar claro: iteráveis tem um método __iter__ que instancia um novo iterador a cada invocação. Iteradores implementam um método __next__, que devolve itens individuais, e um método __iter__, que devolve self.

Assim, iteradores também são iteráveis, mas iteráveis não são iteradores.

Pode ser tentador implementar __next__ além de __iter__ na classe Sentence, tornando cada instância de Sentence ao mesmo tempo um iterável e um iterador de si mesma. Mas raramente isso é uma boa ideia. Também é um anti-padrão comum, de acordo com Alex Martelli, que possui vasta experiência revisando código no Google.

A seção "Aplicabilidade" do padrão de projeto Iterator no livro Padrões de Projeto diz:

Use o padrão Iterator

  • para acessar o conteúdo de um objeto agregado sem expor sua representação interna.

  • para suportar travessias múltiplas de objetos agregados.

  • para fornecer uma interface uniforme para atravessar diferentes estruturas agregadas (isto é, para suportar iteração polimórfica).

Para "suportar travessias múltiplas", deve ser possível obter múltiplos iteradores independentes de uma mesma instância iterável, e cada iterador deve manter seu próprio estado interno. Assim, uma implementação adequada do padrão exige que cada invocação de iter(my_iterable) crie um novo iterador independente. É por essa razão que precisamos da classe SentenceIterator neste exemplo.

Agora que demonstramos de forma apropriada o padrão Iterator clássico, vamos em frente. O Python incorporou a instrução yield da linguagem CLU, de Barbara Liskov, para não termos que "escrever à mão" o código implementando iteradores.

As próximas seções apresentam versões mais idiomáticas de Sentence.

17.5.3. Sentence versão #3: uma funcão geradora

Uma implementação pythônica da mesma funcionalidade usa uma geradora, evitando todo o trabalho para implementar a classe SentenceIterator. A explicação completa da geradora está logo após o Exemplo 5.

Exemplo 5. sentence_gen.py: Sentence implementada usando uma geradora
import re
import reprlib

RE_WORD = re.compile(r'\w+')


class Sentence:

    def __init__(self, text):
        self.text = text
        self.words = RE_WORD.findall(text)

    def __repr__(self):
        return 'Sentence(%s)' % reprlib.repr(self.text)

    def __iter__(self):
        for word in self.words:  # (1)
            yield word  # (2)
        # (3)

# done! (4)
  1. Itera sobre self.words.

  2. Produz a word atual.

  3. Um return explícito não é necessário; a função pode apenas seguir em frente e retornar automaticamente. De qualquer das formas, uma função geradora não gera StopIteration: ela simplesmente termina quando acaba de produzir valores.[220]

  4. Não há necessidade de uma classe iteradora separada!

Novamente temos aqui uma implementação diferente de Sentence que passa nos testes do Exemplo 2.

No código de Sentence do Exemplo 4, __iter__ chamava o construtor SentenceIterator para criar e devolver um iterador. Agora o iterador do Exemplo 5 é na verdade um objeto gerador, criado automaticamente quando o método __iter__ é invocado, porque aqui __iter__ é uma função geradora.

Segue abaixo uma explicação completa das geradoras.

17.5.4. Como funciona uma geradora

Qualquer função do Python contendo a instrução yield em seu corpo é uma função geradora: uma função que, quando invocada, devolve um objeto gerador. Em outras palavras, um função geradora é uma fábrica de geradores.

👉 Dica

O único elemento sintático distinguindo uma função comum de uma função geradora é o fato dessa última conter a instrução yield em algum lugar de seu corpo. Alguns defenderam que uma nova palavra reservada, algo como gen, deveria ser usada no lugar de def para declarar funções geradoras, mas Guido não concordou. Seus argumentos estão na PEP 255 — Simple Generators (Geradoras Simples).[221]

O Exemplo 6 mostra o comportamento de uma função geradora simples.[222]

Exemplo 6. Uma função geradora que produz três números
>>> def gen_123():
...     yield 1  # (1)
...     yield 2
...     yield 3
...
>>> gen_123  # doctest: +ELLIPSIS
<function gen_123 at 0x...>  # (2)
>>> gen_123()   # doctest: +ELLIPSIS
<generator object gen_123 at 0x...>  # (3)
>>> for i in gen_123():  # (4)
...     print(i)
1
2
3
>>> g = gen_123()  # (5)
>>> next(g)  # (6)
1
>>> next(g)
2
>>> next(g)
3
>>> next(g)  # (7)
Traceback (most recent call last):
  ...
StopIteration
  1. O corpo de uma função geradora muitas vezes contém yield dentro de um loop, mas não necessariamente; aqui eu apenas repeti yield três vezes.

  2. Olhando mais de perto, vemos que gen_123 é um objeto função.

  3. Mas quando invocado, gen_123() devolve um objeto gerador.

  4. Objetos geradores implementam a interface Iterator, então são também iteráveis.

  5. Atribuímos esse novo objeto gerador a g, para podermos experimentar seu funcionamento.

  6. Como g é um iterador, chamar next(g) obtém o próximo item produzido por yield.

  7. Quando a função geradora retorna, o objeto gerador gera uma StopIteration.

Uma função geradora cria um objeto gerador que encapsula o corpo da função. Quando invocamos next() no objeto gerador, a execução avança para o próximo yield no corpo da função, e a chamada a next() resulta no valor produzido quando o corpo da função é suspenso. Por fim, o objeto gerador externo criado pelo Python gera uma StopIteration quando a função retorna, de acordo com o protocolo Iterator.

👉 Dica

Acho útil ser rigoroso ao falar sobre valores obtidos a partir de um gerador. É confuso dizer que um gerador "devolve" valores. Funções devolvem valores. A chamada a uma função geradora devolve um gerador. Um gerador produz (yields) valores. Um gerador não "devolve" valores no sentido comum do termo: a instrução return no corpo de uma função geradora faz com que uma StopIteration seja criada pelo objeto gerador. Se você escrever return x na função geradora, quem a chamou pode recuperar o valor de x na exceção StopIteration, mas normalmente isso é feito automaticamente usando a sintaxe yield from, como veremos na Seção 17.13.2.

O Exemplo 7 torna a iteração entre um loop for e o corpo da função mais explícita.

Exemplo 7. Uma função geradora que exibe mensagens quando roda
>>> def gen_AB():
...     print('start')
...     yield 'A'          # (1)
...     print('continue')
...     yield 'B'          # (2)
...     print('end.')      # (3)
...
>>> for c in gen_AB():     # (4)
...     print('-->', c)    # (5)
...
start     (6)
--> A     (7)
continue  (8)
--> B     (9)
end.      (10)
>>>       (11)
  1. A primeira chamada implícita a next() no loop for em 4 vai exibir 'start' e parar no primeiro yield, produzindo o valor 'A'.

  2. A segunda chamada implícita a next() no loop for vai exibir 'continue' e parar no segundo yield, produzindo o valor 'B'.

  3. A terceira chamada a next() vai exibir 'end.' e continuar até o final do corpo da função, fazendo com que o objeto gerador crie uma StopIteration.

  4. Para iterar, o mecanismo do for faz o equivalente a g = iter(gen_AB()) para obter um objeto gerador, e daí next(g) a cada iteração.

  5. O loop exibe -→ e o valor devolvido por next(g). Esse resultado só aparece após a saída das chamadas print dentro da função geradora.

  6. O texto start vem de print('start') no corpo da geradora.

  7. yield 'A' no corpo da geradora produz o valor 'A' consumido pelo loop for, que é atribuído à variável c e resulta na saída -→ A.

  8. A iteração continua com a segunda chamada a next(g), avançando no corpo da geradora de yield 'A' para yield 'B'. O texto continue é gerado pelo segundo print no corpo da geradora.

  9. yield 'B' produz o valor 'B' consumido pelo loop for, que é atribuído à variável c do loop, que então exibe -→ B.

  10. A iteração continua com uma terceira chamada a next(it), avançando para o final do corpo da função. O texto end. é exibido por causa do terceiro print no corpo da geradora.

  11. Quando a função geradora chega ao final, o objeto gerador cria uma StopIteration. O mecanismo do loop for captura essa exceção, e o loop encerra naturalmente.

Espero agora ter deixado claro como Sentence.__iter__ no Exemplo 5 funciona: __iter__ é uma função geradora que, quando chamada, cria um objeto gerador que implementa a interface Iterator, então a classe SentenceIterator não é mais necessária.

A segunda versão de Sentence é mais concisa que a primeira, mas não é tão preguiçosa quanto poderia ser. Atualmente, a preguiça é considerada uma virtude, pelo menos em linguagens de programação e APIs. Uma implementação preguiçosa adia a produção de valores até o último momento possível. Isso economiza memória e também pode evitar o desperdício de ciclos da CPU.

Vamos criar a seguir classes Sentence preguiçosas.

17.6. Sentenças preguiçosas

As últimas variações de Sentence são preguiçosas, se valendo de um função preguiçosa do módulo re.

17.6.1. Sentence versão #4: uma geradora preguiçosa

A interface Iterator foi projetada para ser preguiçosa: next(my_iterator) produz um item por vez. O oposto de preguiçosa é ávida: avaliação preguiçosa e ávida são termos técnicos da teoria das linguagens de programação[223].

Até aqui, nossas implementações de Sentence não são preguiçosas, pois o __init__ cria avidamemente uma lista com todas as palavras no texto, vinculando-as ao atributo self.words. Isso exige o processamento do texto inteiro, e a lista pode acabar usando tanta memória quanto o próprio texto (provavelmente mais: vai depender de quantos caracteres que não fazem parte de palavras existirem no texto). A maior parte desse trabalho será inútil se o usuário iterar apenas sobre as primeiras palavras. Se você está se perguntado se "Existiria uma forma preguiçosa de fazer isso em Python?", a resposta muitas vezes é "Sim".

A função re.finditer é uma versão preguiçosa de re.findall. Em vez de uma lista, re.finditer devolve uma geradora que produz instâncias de re.MatchObject sob demanda. Se existirem muitos itens, re.finditer economiza muita memória. Com ela, nossa terceira versão de Sentence agora é preguiçosa: ela só lê a próxima palavra do texto quando necessário. O código está no Exemplo 8.

Exemplo 8. sentence_gen2.py: Sentence implementada usando uma função geradora que invoca a função geradora re.finditer
import re
import reprlib

RE_WORD = re.compile(r'\w+')


class Sentence:

    def __init__(self, text):
        self.text = text  # (1)

    def __repr__(self):
        return f'Sentence({reprlib.repr(self.text)})'

    def __iter__(self):
        for match in RE_WORD.finditer(self.text):  # (2)
            yield match.group()  # (3)
  1. Não é necessário manter uma lista words.

  2. finditer cria um iterador sobre os termos encontrados com RE_WORD em self.text, produzindo instâncias de MatchObject.

  3. match.group() extraí o texto da instância de MatchObject.

Geradores são um ótimo atalho, mas o código pode ser ainda mais conciso com uma expressão geradora.

17.6.2. Sentence versão #5: Expressão geradora preguiçosa

Podemos substituir funções geradoras simples como aquela na última classe `Sentence (no Exemplo 8) por uma expressão geradora. Assim como uma compreensão de lista cria listas, uma expressão geradora cria objetos geradores. O Exemplo 9 compara o comportamento nos dois casos.

Exemplo 9. A função geradora gen_AB é usada primeiro por uma compreensão de lista, depois por uma expressão geradora
>>> def gen_AB():  # (1)
...     print('start')
...     yield 'A'
...     print('continue')
...     yield 'B'
...     print('end.')
...
>>> res1 = [x*3 for x in gen_AB()]  # (2)
start
continue
end.
>>> for i in res1:  # (3)
...     print('-->', i)
...
--> AAA
--> BBB
>>> res2 = (x*3 for x in gen_AB())  # (4)
>>> res2
<generator object <genexpr> at 0x10063c240>
>>> for i in res2:  # (5)
...     print('-->', i)
...
start      # (6)
--> AAA
continue
--> BBB
end.
  1. Está é a mesma função gen_AB do Exemplo 7.

  2. A compreensão de lista itera avidamente sobre os itens produzidos pelo objeto gerador devolvido por gen_AB(): 'A' e 'B'. Observe a saída nas linhas seguintes: start, continue, end.

  3. Esse loop for itera sobre a lista res1 criada pela compreensão de lista.

  4. A expressão geradora devolve res2, um objeto gerador. O gerador não é consumido aqui.

  5. Este gerador obtém itens de gen_AB apenas quando o loop for itera sobre res2. Cada iteração do loop for invoca, implicitamente, next(res2), que por sua vez invoca next() sobre o objeto gerador devolvido por gen_AB(), fazendo este último avançar até o próximo yield.

  6. Observe como a saída de gen_AB() se intercala com a saída do print no loop for.

Podemos usar uma expressão geradora para reduzir ainda mais o código na classe Sentence. Veja o Exemplo 10.

Exemplo 10. sentence_genexp.py: Sentence implementada usando uma expressão geradora
import re
import reprlib

RE_WORD = re.compile(r'\w+')


class Sentence:

    def __init__(self, text):
        self.text = text

    def __repr__(self):
        return f'Sentence({reprlib.repr(self.text)})'

    def __iter__(self):
        return (match.group() for match in RE_WORD.finditer(self.text))

A única diferença com o Exemplo 8 é o método __iter__, que aqui não é uma função geradora (ela não contém uma instrução yield) mas usa uma expressão geradora para criar um gerador e devolvê-lo. O resultado final é o mesmo: quem invoca __iter__ recebe um objeto gerador.

Expressões geradoras são "açúcar sintático": elas pode sempre ser substituídas por funções geradoras, mas algumas vezes são mais convenientes. A próxima seção trata do uso de expressões geradoras.

17.7. Quando usar expressões geradoras

Eu usei várias expressões geradoras quando implementamos a classe Vector no Exemplo 16. Cada um destes métodos contém uma expressão geradora: __eq__, __hash__, __abs__, angle, angles, format, __add__, e __mul__. Em todos aqueles métodos, uma compreensão de lista também funcionaria, com um custo adicional de memória para armazenar os valores da lista intermediária.

No Exemplo 10, vimos que uma expressão geradora é um atalho sintático para criar um gerador sem definir e invocar uma função. Por outro lado, funções geradoras são mais flexíveis: podemos programar uma lógica complexa, com múltiplos comandos, e podemos até usá-las como corrotinas, como veremos na Seção 17.13.

Nos casos mais simples, uma expressão geradora é mais fácil de ler de relance, como mostra o exemplo de Vector.

Minha regra básica para escolher qual sintaxe usar é simples: se a expressão geradora exige mais que um par de linhas, prefiro escrever uma função geradora, em nome da legibilidade.

👉 Dica
Dica de sintaxe

Quando uma expressão geradora é passada como único argumento a uma função ou a um construtor, não é necessário escrever um conjunto de parênteses para a chamada da função e outro par cercando a expressão geradora. Um único par é suficiente, como na chamada a Vector no método __mul__ do Exemplo 16, reproduzido abaixo:

def __mul__(self, scalar):
    if isinstance(scalar, numbers.Real):
        return Vector(n * scalar for n in self)
    else:
        return NotImplemented

Entretanto, se existirem mais argumentos para a função após a expressão geradora, é preciso cercar a expressão com parênteses para evitar um SyntaxError.

Os exemplos de Sentence vistos até aqui mostram geradores fazendo o papel do padrão Iterator clássico: obter itens de uma coleção. Mas podemos também usar geradores para produzir valores independente de uma fonte de dados. A próxima seção mostra um exemplo.

Mas antes, um pequena discussão sonre os conceitos sobrepostos de iterador e gerador.

Comparando iteradores e geradores

Na documentação e na base de código oficiais do Python, a terminologia em torno de iteradores e geradores é inconsistente e está em evolução. Adotei as seguintes definições:

iterador

Termo geral para qualquer objeto que implementa um método __next__. Iteradores são projetados para produzir dados a serem consumidos pelo código cliente, isto é, o código que controla o iterador através de um loop for ou outro mecanismo de iteração, ou chamando next(it) explicitamente no iterador—apesar desse uso explícito ser menos comum. Na prática, a maioria dos iteradores que usamos no Python são geradores.

gerador

Um iterador criado pelo compilador Python. Para criar um gerador, não implementamos __next__. Em vez disso, usamos a palavra reservada yield para criar uma função geradora, que é uma fábrica de objetos geradores. Uma expressão geradora é outra maneira de criar um objeto gerador. Objetos geradores fornecem __next__, então são iteradores. Desde o Python 3.5, também temos geradores assíncronos, declarados com async def. Vamos estudá-los no Capítulo 21.

O Glossário do Python introduziu recentemente o termo iterador gerador para se referir a objetos geradores criados por funções geradoras, enquanto o verbete para expressão geradora diz que ela devolve um "iterador".

Mas, de acordo com o interpretador Python, os objetos devolvidos em ambos os casos são objetos geradores:

>>> def g():
...     yield 0
...
>>> g()
<generator object g at 0x10e6fb290>
>>> ge = (c for c in 'XYZ')
>>> ge
<generator object <genexpr> at 0x10e936ce0>
>>> type(g()), type(ge)
(<class 'generator'>, <class 'generator'>)

17.8. Um gerador de progressão aritmética

O padrão Iterator clássico está todo baseado em uma travessia: navegar por alguma estrutura de dados. Mas uma interface padrão baseada em um método para obter o próximo item em uma série também é útil quando os itens são produzidos sob demanda, ao invés de serem obtidos de uma coleção. Por exemplo, a função embutida range gera uma progressão aritmética (PA) de inteiros delimitada. E se precisarmos gerar uma PA com números de qualquer tipo, não apenas inteiros?

O Exemplo 11 mostra alguns testes no console com uma classe ArithmeticProgression, que vermos em breve. A assinatura do construtor no Exemplo 11 é ArithmeticProgression(begin, step[, end]). A assinatura completa da função embutida range é range(start, stop[, step]). Escolhi implementar uma assinatura diferente porque o step é obrigatório, mas end é opcional em uma progressão aritmética. Também mudei os nomes dos argumentos de start/stop para begin/end, para deixar claro que optei por uma assinatura diferente. Para cada teste no Exemplo 11, chamo list() com o resultado para inspecionar o valores gerados.

Exemplo 11. Demonstração de uma classe ArithmeticProgression
    >>> ap = ArithmeticProgression(0, 1, 3)
    >>> list(ap)
    [0, 1, 2]
    >>> ap = ArithmeticProgression(1, .5, 3)
    >>> list(ap)
    [1.0, 1.5, 2.0, 2.5]
    >>> ap = ArithmeticProgression(0, 1/3, 1)
    >>> list(ap)
    [0.0, 0.3333333333333333, 0.6666666666666666]
    >>> from fractions import Fraction
    >>> ap = ArithmeticProgression(0, Fraction(1, 3), 1)
    >>> list(ap)
    [Fraction(0, 1), Fraction(1, 3), Fraction(2, 3)]
    >>> from decimal import Decimal
    >>> ap = ArithmeticProgression(0, Decimal('.1'), .3)
    >>> list(ap)
    [Decimal('0'), Decimal('0.1'), Decimal('0.2')]

Observe que o tipo dos números na progressão aritmética resultante segue o tipo de begin + step, de acordo com as regras de coerção numérica da aritmética do Python. No Exemplo 11, você pode ver listas de números int, float, Fraction, e Decimal. O Exemplo 12 mostra a implementação da classe ArithmeticProgression.

Exemplo 12. A classe ArithmeticProgression
class ArithmeticProgression:

    def __init__(self, begin, step, end=None):       # (1)
        self.begin = begin
        self.step = step
        self.end = end  # None -> "infinite" series

    def __iter__(self):
        result_type = type(self.begin + self.step)   # (2)
        result = result_type(self.begin)             # (3)
        forever = self.end is None                   # (4)
        index = 0
        while forever or result < self.end:          # (5)
            yield result                             # (6)
            index += 1
            result = self.begin + self.step * index  # (7)
  1. __init__ exige dois argumentos: begin e step; end é opcional, se for None, a série será ilimitada.

  2. Obtém o tipo somando self.begin e self.step. Por exemplo, se um for int e o outro float, o result_type será float.

  3. Essa linha cria um result com o mesmo valor numérico de self.begin, mas coagido para o tipo das somas subsequentes.[224]

  4. Para melhorar a legibilidade, o sinalizador forever será True se o atributo self.end for None, resultando em uma série ilimitada.

  5. Esse loop roda forever ou até o resultado ser igual ou maior que self.end. Quando esse loop termina, a função retorna.

  6. O result atual é produzido.

  7. O próximo resultado em potencial é calculado. Ele pode nunca ser produzido, se o loop while terminar.

Na última linha do Exemplo 12, em vez de somar self.step ao result anterior a cada passagem do loop, optei por ignorar o result existente: cada novo result é criado somando self.begin a self.step multiplicado por index. Isso evita o efeito cumulativo de erros após a adição sucessiva de números de ponto flutuante. Alguns experimentos simples tornam clara a diferença:

>>> 100 * 1.1
110.00000000000001
>>> sum(1.1 for _ in range(100))
109.99999999999982
>>> 1000 * 1.1
1100.0
>>> sum(1.1 for _ in range(1000))
1100.0000000000086

A classe ArithmeticProgression do Exemplo 12 funciona como esperado, é outro exemplo do uso de uma função geradora para implementar o método especial __iter__. Entretanto, se o único objetivo de uma classe é criar um gerador pela implementação de __iter__, podemos substituir a classe por uma função geradora. Pois afinal, uma função geradora é uma fábrica de geradores.

O Exemplo 13 mostra uma função geradora chamada aritprog_gen, que realiza a mesma tarefa da ArithmeticProgression, mas com menos código. Se, em vez de chamar ArithmeticProgression, você chamar aritprog_gen, os testes no Exemplo 11 são todos bem sucedidos.[225]

Exemplo 13. a função geradora aritprog_gen
def aritprog_gen(begin, step, end=None):
    result = type(begin + step)(begin)
    forever = end is None
    index = 0
    while forever or result < end:
        yield result
        index += 1
        result = begin + step * index

O Exemplo 13 é elegante, mas lembre-se sempre: há muitos geradores prontos para uso na biblioteca padrão, e a próxima seção vai mostrar uma implementação mais curta, usando o módulo itertools.

17.8.1. Progressão aritmética com itertools

O módulo itertools no Python 3.10 contém 20 funções geradoras, que podem ser combinadas de várias maneiras interessantes.

Por exemplo, a função itertools.count devolve um gerador que produz números. Sem argumentos, ele produz uma série de inteiros começando de 0. Mas você pode fornecer os valores opcionais start e step, para obter um resultado similar ao das nossas funções aritprog_gen:

>>> import itertools
>>> gen = itertools.count(1, .5)
>>> next(gen)
1
>>> next(gen)
1.5
>>> next(gen)
2.0
>>> next(gen)
2.5
⚠️ Aviso

itertools.count nunca para, então se você chamar list(count()), o Python vai tentar criar uma list que preencheria todos os chips de memória já fabricados. Na prática, sua máquina vai ficar muito mal-humorada bem antes da chamada fracassar.

Por outro lado, temos também a função itertools.takewhile: ela devolve um gerador que consome outro gerador e para quando um dado predicado é avaliado como False. Então podemos combinar os dois e escrever o seguinte:

>>> gen = itertools.takewhile(lambda n: n < 3, itertools.count(1, .5))
>>> list(gen)
[1, 1.5, 2.0, 2.5]

Se valendo de takewhile e count, o Exemplo 14 é ainda mais conciso.

Exemplo 14. aritprog_v3.py: funciona como as funções aritprog_gen anteriores
import itertools


def aritprog_gen(begin, step, end=None):
    first = type(begin + step)(begin)
    ap_gen = itertools.count(first, step)
    if end is None:
        return ap_gen
    return itertools.takewhile(lambda n: n < end, ap_gen)

Observe que aritprog_gen no Exemplo 14 não é uma função geradora: não há um yield em seu corpo. Mas ela devolve um gerador, exatamente como faz uma função geradora.

Entretanto, lembre-se que itertools.count soma o step repetidamente, então a série de números de ponto flutuante que ela produz não é tão precisa quanto a do Exemplo 13.

O importante no Exemplo 14 é: ao implementar geradoras, olhe o que já está disponível na biblioteca padrão, caso contrário você tem uma boa chance de reinventar a roda. Por isso a próxima seção trata de várias funções geradoras prontas para usar.

17.9. Funções geradoras na biblioteca padrão

A biblioteca padrão oferece muitas geradoras, desde objetos de arquivo de texto forncendo iteração linha por linha até a incrível função os.walk, que produz nomes de arquivos enquanto cruza uma árvore de diretórios, tornando buscas recursivas no sistema de arquivos tão simples quanto um loop for.

A função geradora os.walk é impressionante, mas nesta seção quero me concentrar em funções genéricas que recebem iteráveis arbitrários como argumento e devolvem geradores que produzem itens selecionados, calculados ou reordenados. Nas tabelas a seguir, resumi duas dúzias delas, algumas embutidas, outras dos módulos itertools e functools. Por conveniência, elas estão agrupadas por sua funcionalidade de alto nível, independente de onde são definidas.

O primeiro grupo contém funções geradoras de filtragem: elas produzem um subconjunto dos itens produzidos pelo iterável de entrada, sem mudar os itens em si. Como takewhile, a maioria das funções listadas na Tabela 19 recebe um predicate, uma função booleana de um argumento que será aplicada a cada item no iterável de entrada, para determinar se aquele item será incluído na saída.

Tabela 19. Funções geradoras de filtragem
Módulo Função Descrição

itertools

compress(it, selector_it)

Consome dois iteráveis em paralelo; produz itens de it sempre que o item correspondente em selector_it é verdadeiro

itertools

dropwhile(predicate, it)

Consome it, pulando itens enquanto predicate resultar verdadeiro, e daí produz todos os elementos restantes (nenhuma verificação adicional é realizada)

(Embutida)

filter(predicate, it)

Aplica predicate para cada item de iterable, produzindo o item se predicate(item) for verdadeiro; se predicate for None, apenas itens verdadeiros serão produzidos

itertools

filterfalse(predicate, it)

Igual a filter, mas negando a lógica de predicate: produz itens sempre que predicate resultar falso

itertools

islice(it, stop) ou islice(it, start, stop, step=1)

Produz itens de uma fatia de it, similar a s[:stop] ou s[start:stop:step], exceto por it poder ser qualquer iterável e a operação ser preguiçosa

itertools

takewhile(predicate, it)

Produz itens enquanto predicate resultar verdadeiro, e daí para (nenhuma verificação adicional é realizada).

A seção de console no Exemplo 15 demonstra o uso de todas as funções na Tabela 19.

Exemplo 15. Exemplos de funções geradoras de filtragem
>>> def vowel(c):
...     return c.lower() in 'aeiou'
...
>>> list(filter(vowel, 'Aardvark'))
['A', 'a', 'a']
>>> import itertools
>>> list(itertools.filterfalse(vowel, 'Aardvark'))
['r', 'd', 'v', 'r', 'k']
>>> list(itertools.dropwhile(vowel, 'Aardvark'))
['r', 'd', 'v', 'a', 'r', 'k']
>>> list(itertools.takewhile(vowel, 'Aardvark'))
['A', 'a']
>>> list(itertools.compress('Aardvark', (1, 0, 1, 1, 0, 1)))
['A', 'r', 'd', 'a']
>>> list(itertools.islice('Aardvark', 4))
['A', 'a', 'r', 'd']
>>> list(itertools.islice('Aardvark', 4, 7))
['v', 'a', 'r']
>>> list(itertools.islice('Aardvark', 1, 7, 2))
['a', 'd', 'a']

O grupo seguinte contém os geradores de mapeamento: eles produzem itens computados a partir de cada item individual no iterável de entrada—​ou iteráveis, nos casos de map e starmap.[226] As geradoras na Tabela 20 produzem um resultado por item dos iteráveis de entrada. Se a entrada vier de mais de um iterável, a saída para assim que o primeiro iterável de entrada for exaurido.

Tabela 20. Funções geradoras de mapeamento
Módulo Função Descrição

itertools

accumulate(it, [func])

Produz somas cumulativas; se func for fornecida, produz o resultado da aplicação de func ao primeiro par de itens, depois ao primeiro resultado e ao próximo item, etc.

(embutida)

enumerate(iterable, start=0)

Produz tuplas de dois itens na forma (index, item), onde index é contado a partir de start, e item é obtido do iterable

(embutida)

map(func, it1, [it2, …, itN])

Aplica func a cada item de it, produzindo o resultado; se forem fornecidos N iteráveis, func deve aceitar N argumentos, e os iteráveis serão consumidos em paralelo

itertools

starmap(func, it)

Aplica func a cada item de it, produzindo o resultado; o iterável de entrada deve produzir itens iteráveis iit, e func é aplicada na forma func(*iit)

O Exemplo 16 demonstra alguns usos de itertools.accumulate.

Exemplo 16. Exemplos das funções geradoras de itertools.accumulate
>>> sample = [5, 4, 2, 8, 7, 6, 3, 0, 9, 1]
>>> import itertools
>>> list(itertools.accumulate(sample))  # (1)
[5, 9, 11, 19, 26, 32, 35, 35, 44, 45]
>>> list(itertools.accumulate(sample, min))  # (2)
[5, 4, 2, 2, 2, 2, 2, 0, 0, 0]
>>> list(itertools.accumulate(sample, max))  # (3)
[5, 5, 5, 8, 8, 8, 8, 8, 9, 9]
>>> import operator
>>> list(itertools.accumulate(sample, operator.mul))  # (4)
[5, 20, 40, 320, 2240, 13440, 40320, 0, 0, 0]
>>> list(itertools.accumulate(range(1, 11), operator.mul))
[1, 2, 6, 24, 120, 720, 5040, 40320, 362880, 3628800]  # (5)
  1. Soma acumulada.

  2. Mínimo corrente.

  3. Máximo corrente.

  4. Produto acumulado.

  5. Fatoriais de 1! a 10!.

As funções restantes da Tabela 20 são demonstradas no Exemplo 17.

Exemplo 17. Exemplos de funções geradoras de mapeamento
>>> list(enumerate('albatroz', 1))  # (1)
[(1, 'a'), (2, 'l'), (3, 'b'), (4, 'a'), (5, 't'), (6, 'r'), (7, 'o'), (8, 'z')]
>>> import operator
>>> list(map(operator.mul, range(11), range(11)))  # (2)
[0, 1, 4, 9, 16, 25, 36, 49, 64, 81, 100]
>>> list(map(operator.mul, range(11), [2, 4, 8]))  # (3)
[0, 4, 16]
>>> list(map(lambda a, b: (a, b), range(11), [2, 4, 8]))  # (4)
[(0, 2), (1, 4), (2, 8)]
>>> import itertools
>>> list(itertools.starmap(operator.mul, enumerate('albatroz', 1)))  # (5)
['a', 'll', 'bbb', 'aaaa', 'ttttt', 'rrrrrr', 'ooooooo', 'zzzzzzzz']
>>> sample = [5, 4, 2, 8, 7, 6, 3, 0, 9, 1]
>>> list(itertools.starmap(lambda a, b: b / a,
...     enumerate(itertools.accumulate(sample), 1)))  # (6)
[5.0, 4.5, 3.6666666666666665, 4.75, 5.2, 5.333333333333333,
5.0, 4.375, 4.888888888888889, 4.5]
  1. Número de letras na palavra, começando por 1.

  2. Os quadrados dos inteiros de 0 a 10.

  3. Multiplicando os números de dois iteráveis em paralelo; os resultados cessam quando o iterável menor termina.

  4. Isso é o que faz a função embutida zip.

  5. Repete cada letra na palavra de acordo com a posição da letra na palavra, começando por 1.

  6. Média corrente.

A seguir temos o grupo de geradores de fusão—todos eles produzem itens a partir de múltiplos iteráveis de entrada. chain e chain.from_iterable consomem os iteráveis de entrada em sequência (um após o outro), enquanto product, zip, e zip_longest consomem os iteráveis de entrada em paralelo. Veja a Tabela 21.

Tabela 21. Funções geradoras que fundem os iteráveis de entrada
Módulo Função Descrição

itertools

chain(it1, …, itN)

Produz todos os itens de it1, a seguir de it2, etc., continuamente.

itertools

chain.from_iterable(it)

Produz todos os itens de cada iterável produzido por it, um após o outro, continuamente; it é um iterável cujos itens também são iteráveis, uma lista de tuplas, por exemplo

itertools

product(it1, …, itN, repeat=1)

Produto cartesiano: produz tuplas de N elementos criadas combinando itens de cada iterável de entrada, como loops for aninhados produziriam; repeat permite que os iteráveis de entrada sejam consumidos mais de uma vez

(embutida)

zip(it1, …, itN, strict=False)

Produz tuplas de N elementos criadas a partir de itens obtidos dos iteráveis em paralelo, terminando silenciosamente quando o menor iterável é exaurido, a menos que strict=True for passado[227]

itertools

zip_longest(it1, …, itN, fillvalue=None)

Produz tuplas de N elementos criadas a partir de itens obtidos dos iteráveis em paralelo, terminando apenas quando o último iterável for exaurido, preenchendo os itens ausentes com o fillvalue

O Exemplo 18 demonstra o uso das funções geradoras itertools.chain e zip, e de suas pares. Lembre-se que o nome da função zip vem do zíper ou fecho-éclair (nenhuma relação com a compreensão de dados). Tanto zip quanto itertools.zip_longest foram apresentadas no O fantástico zip.

Exemplo 18. Exemplos de funções geradoras de fusão
>>> list(itertools.chain('ABC', range(2)))  # (1)
['A', 'B', 'C', 0, 1]
>>> list(itertools.chain(enumerate('ABC')))  # (2)
[(0, 'A'), (1, 'B'), (2, 'C')]
>>> list(itertools.chain.from_iterable(enumerate('ABC')))  # (3)
[0, 'A', 1, 'B', 2, 'C']
>>> list(zip('ABC', range(5), [10, 20, 30, 40]))  # (4)
[('A', 0, 10), ('B', 1, 20), ('C', 2, 30)]
>>> list(itertools.zip_longest('ABC', range(5)))  # (5)
[('A', 0), ('B', 1), ('C', 2), (None, 3), (None, 4)]
>>> list(itertools.zip_longest('ABC', range(5), fillvalue='?'))  # (6)
[('A', 0), ('B', 1), ('C', 2), ('?', 3), ('?', 4)]
  1. chain é normalmente invocada com dois ou mais iteráveis.

  2. chain não faz nada de útil se invocada com um único iterável.

  3. Mas chain.from_iterable pega cada item do iterável e os encadeia em sequência, desde que cada item seja também iterável.

  4. Qualquer número de iteráveis pode ser consumido em paralelo por zip, mas a geradora sempre para assim que o primeiro iterável acaba. No Python ≥ 3.10, se o argumento strict=True for passado e um iterável terminar antes dos outros, um ValueError é gerado.

  5. itertools.zip_longest funciona como zip, exceto por consumir todos os iteráveis de entrada, preenchendo as tuplas de saída com None onde necessário.

  6. O argumento nomeado fillvalue especifica um valor de preenchimento personalizado.

A geradora itertools.product é uma forma preguiçosa para calcular produtos cartesianos, que criamos usando compreensões de lista com mais de uma instrução for na Seção 2.3.3. Expressões geradoras com múltiplas instruções for também podem ser usadas para produzir produtos cartesianos de forma preguiçosa. O Exemplo 19 demonstra itertools.product.

Exemplo 19. Exemplo da função geradora itertools.product
>>> list(itertools.product('ABC', range(2)))  # (1)
[('A', 0), ('A', 1), ('B', 0), ('B', 1), ('C', 0), ('C', 1)]
>>> suits = 'spades hearts diamonds clubs'.split()
>>> list(itertools.product('AK', suits))  # (2)
[('A', 'spades'), ('A', 'hearts'), ('A', 'diamonds'), ('A', 'clubs'),
('K', 'spades'), ('K', 'hearts'), ('K', 'diamonds'), ('K', 'clubs')]
>>> list(itertools.product('ABC'))  # (3)
[('A',), ('B',), ('C',)]
>>> list(itertools.product('ABC', repeat=2))  # (4)
[('A', 'A'), ('A', 'B'), ('A', 'C'), ('B', 'A'), ('B', 'B'),
('B', 'C'), ('C', 'A'), ('C', 'B'), ('C', 'C')]
>>> list(itertools.product(range(2), repeat=3))
[(0, 0, 0), (0, 0, 1), (0, 1, 0), (0, 1, 1), (1, 0, 0),
(1, 0, 1), (1, 1, 0), (1, 1, 1)]
>>> rows = itertools.product('AB', range(2), repeat=2)
>>> for row in rows: print(row)
...
('A', 0, 'A', 0)
('A', 0, 'A', 1)
('A', 0, 'B', 0)
('A', 0, 'B', 1)
('A', 1, 'A', 0)
('A', 1, 'A', 1)
('A', 1, 'B', 0)
('A', 1, 'B', 1)
('B', 0, 'A', 0)
('B', 0, 'A', 1)
('B', 0, 'B', 0)
('B', 0, 'B', 1)
('B', 1, 'A', 0)
('B', 1, 'A', 1)
('B', 1, 'B', 0)
('B', 1, 'B', 1)
  1. O produto cartesiano de uma str com três caracteres e um range com dois inteiros produz seis tuplas (porque 3 * 2 é 6).

  2. O produto de duas cartas altas ('AK') e quatro naipes é uma série de oito tuplas.

  3. Dado um único iterável, product produz uma série de tuplas de um elemento—muito pouco útil.

  4. O argumento nomeado repeat=N diz à função para consumir cada iterável de entrada N vezes.

Algumas funções geradoras expandem a entrada, produzindo mais de um valor por item de entrada. Elas estão listadas na Tabela 22.

Tabela 22. Funções geradoras que expandem cada item de entrada em múltiplos itens de saída
Module Function Description

itertools

combinations(it, out_len)

Produz combinações de out_len itens a partir dos itens produzidos por it

itertools

combinations_with_replacement(it, out_len)

Produz combinações de out_len itens a partir dos itens produzidos por it, incluindo combinações com itens repetidos

itertools

count(start=0, step=1)

Produz números começando em start e adicionando step para obter o número seguinte, indefinidamente

itertools

cycle(it)

Produz itens de it, armazenando uma cópia de cada, e então produz a sequência inteira repetida e indefinidamente

itertools

pairwise(it)

Produz pares sobrepostos sucessivos, obtidos do iterável de entrada[228]

itertools

permutations(it, out_len=None)

Produz permutações de out_len itens a partir dos itens produzidos por it; por default, out_len é len(list(it))

itertools

repeat(item, [times])

Produz um dado item repetidamente e, a menos que um número de times (vezes) seja passado, indefinidamente

As funções count e repeat de itertools devolvem geradores que conjuram itens do nada: nenhum deles recebe um iterável como parâmetro. Vimos itertools.count na Seção 17.8.1. O gerador cycle faz uma cópia do iterável de entrada e produz seus itens repetidamente. O Exemplo 20 ilustra o uso de count, cycle, pairwise e repeat.

Exemplo 20. count, cycle, pairwise, e repeat
>>> ct = itertools.count()  # (1)
>>> next(ct)  # (2)
0
>>> next(ct), next(ct), next(ct)  # (3)
(1, 2, 3)
>>> list(itertools.islice(itertools.count(1, .3), 3))  # (4)
[1, 1.3, 1.6]
>>> cy = itertools.cycle('ABC')  # (5)
>>> next(cy)
'A'
>>> list(itertools.islice(cy, 7))  # (6)
['B', 'C', 'A', 'B', 'C', 'A', 'B']
>>> list(itertools.pairwise(range(7)))  # (7)
[(0, 1), (1, 2), (2, 3), (3, 4), (4, 5), (5, 6)]
>>> rp = itertools.repeat(7)  # (8)
>>> next(rp), next(rp)
(7, 7)
>>> list(itertools.repeat(8, 4))  # (9)
[8, 8, 8, 8]
>>> list(map(operator.mul, range(11), itertools.repeat(5)))  # (10)
[0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50]
  1. Cria ct, uma geradora count.

  2. Obtém o primeiro item de ct.

  3. Não posso criar uma list a partir de ct, pois ct nunca para. Então pego os próximos três itens.

  4. Posso criar uma list de uma geradora count se ela for limitada por islice ou takewhile.

  5. Cria uma geradora cycle a partir de 'ABC', e obtém seu primeiro item, 'A'.

  6. Uma list só pode ser criada se limitada por islice; os próximos sete itens são obtidos aqui.

  7. Para cada item na entrada, pairwise produz uma tupla de dois elementos com aquele item e o próximo—se existir um próximo item. Disponível no Python ≥ 3.10.

  8. Cria uma geradora repeat que vai produzir o número 7 para sempre.

  9. Uma geradora repeat pode ser limitada passando o argumento times: aqui o número 8 será produzido 4 vezes.

  10. Um uso comum de repeat: fornecer um argumento fixo em map; aqui ela fornece o multiplicador 5.

A funções geradoras combinations, combinations_with_replacement e permutations--juntamente com product—são chamadas geradoras combinatórias na página de documentação do itertools. Também há um relação muito próxima entre itertools.product e o restante das funções combinatórias, como mostra o Exemplo 21.

Exemplo 21. Funções geradoras combinatórias produzem múltiplos valores para cada item de entrada
>>> list(itertools.combinations('ABC', 2))  # (1)
[('A', 'B'), ('A', 'C'), ('B', 'C')]
>>> list(itertools.combinations_with_replacement('ABC', 2))  # (2)
[('A', 'A'), ('A', 'B'), ('A', 'C'), ('B', 'B'), ('B', 'C'), ('C', 'C')]
>>> list(itertools.permutations('ABC', 2))  # (3)
[('A', 'B'), ('A', 'C'), ('B', 'A'), ('B', 'C'), ('C', 'A'), ('C', 'B')]
>>> list(itertools.product('ABC', repeat=2))  # (4)
[('A', 'A'), ('A', 'B'), ('A', 'C'), ('B', 'A'), ('B', 'B'), ('B', 'C'),
('C', 'A'), ('C', 'B'), ('C', 'C')]
  1. Todas as combinações com len()==2 a partir dos itens em 'ABC'; a ordem dos itens nas tuplas geradas é irrelevante (elas poderiam ser conjuntos).

  2. Todas as combinação com len()==2 a partir dos itens em 'ABC', incluindo combinações com itens repetidos.

  3. Todas as permutações com len()==2 a partir dos itens em 'ABC'; a ordem dos itens nas tuplas geradas é relevante.

  4. Produto cartesiano de 'ABC' e 'ABC' (esse é o efeito de repeat=2).

O último grupo de funções geradoras que vamos examinar nessa seção foram projetados para produzir todos os itens dos iteráveis de entrada, mas rearranjados de alguma forma. Aqui estão duas funções que devolvem múltiplos geradores: itertools.groupby e itertools.tee. A outra geradora nesse grupo, a função embutida reversed, é a única geradora tratada nesse capítulo que não aceita qualquer iterável como entrada, apenas sequências. Faz sentido: como reversed vai produzir os itens do último para o primeiro, só funciona com uma sequência de tamanho conhecido. Mas ela evita o custo de criar uma cópia invertida da sequência produzindo cada item quando necessário. Coloquei a função itertools.product junto com as geradoras de fusão, na Tabela 21, porque todas aquelas consomem mais de um iterável, enquanto todas as geradoras na Tabela 23 aceitam no máximo um iterável como entrada.

Tabela 23. Funções geradoras de rearranjo
Módulo Função Descrição

itertools

groupby(it, key=None)

Produz tuplas de 2 elementos na forma (key, group), onde key é o critério de agrupamento e group é um gerador que produz os itens no grupo

(embutida)

reversed(seq)

Produz os itens de seq na ordem inversa, do último para o primeiro; seq deve ser uma sequência ou implementar o método especial __reversed__

itertools

tee(it, n=2)

Produz uma tupla de n geradores, cada um produzindo os itens do iterável de entrada de forma independente

O Exemplo 22 demonstra o uso de itertools.groupby e da função embutida reversed. Observe que itertools.groupby assume que o iterável de entrada está ordenado pelo critério de agrupamento, ou que pelo menos os itens estejam agrupados por aquele critério—mesmo que não estejam completamente ordenados. O revisor técnico Miroslav Šedivý sugeriu esse caso de uso: você pode ordenar objetos datetime em ordem cronológica, e então groupby por dia da semana, para obter o grupo com os dados de segunda-feira, seguidos pelos dados de terça, etc., e então da segunda (da semana seguinte) novamente, e assim por diante.

Exemplo 22. itertools.groupby
>>> list(itertools.groupby('LLLLAAGGG'))  # (1)
[('L', <itertools._grouper object at 0x102227cc0>),
('A', <itertools._grouper object at 0x102227b38>),
('G', <itertools._grouper object at 0x102227b70>)]
>>> for char, group in itertools.groupby('LLLLAAAGG'):  # (2)
...     print(char, '->', list(group))
...
L -> ['L', 'L', 'L', 'L']
A -> ['A', 'A',]
G -> ['G', 'G', 'G']
>>> animals = ['duck', 'eagle', 'rat', 'giraffe', 'bear',
...            'bat', 'dolphin', 'shark', 'lion']
>>> animals.sort(key=len)  # (3)
>>> animals
['rat', 'bat', 'duck', 'bear', 'lion', 'eagle', 'shark',
'giraffe', 'dolphin']
>>> for length, group in itertools.groupby(animals, len):  # (4)
...     print(length, '->', list(group))
...
3 -> ['rat', 'bat']
4 -> ['duck', 'bear', 'lion']
5 -> ['eagle', 'shark']
7 -> ['giraffe', 'dolphin']
>>> for length, group in itertools.groupby(reversed(animals), len): # (5)
...     print(length, '->', list(group))
...
7 -> ['dolphin', 'giraffe']
5 -> ['shark', 'eagle']
4 -> ['lion', 'bear', 'duck']
3 -> ['bat', 'rat']
>>>
  1. groupby produz tuplas de (key, group_generator).

  2. Tratar geradoras groupby envolve iteração aninhada: neste caso, o loop for externo e o construtor de list interno.

  3. Ordena animals por tamanho.

  4. Novamente, um loop sobre o par key e group, para exibir key e expandir o group em uma list.

  5. Aqui a geradora reverse itera sobre animals da direita para a esquerda.

A última das funções geradoras nesse grupo é iterator.tee, que apresenta um comportamento singular: ela produz múltiplos geradores a partir de um único iterável de entrada, cada um deles produzindo todos os itens daquele iterável. Esse geradores podem ser consumidos de forma independente, como mostra o Exemplo 23.

Exemplo 23. itertools.tee produz múltiplos geradores, cada um produzindo todos os itens do gerador de entrada
>>> list(itertools.tee('ABC'))
[<itertools._tee object at 0x10222abc8>, <itertools._tee object at 0x10222ac08>]
>>> g1, g2 = itertools.tee('ABC')
>>> next(g1)
'A'
>>> next(g2)
'A'
>>> next(g2)
'B'
>>> list(g1)
['B', 'C']
>>> list(g2)
['C']
>>> list(zip(*itertools.tee('ABC')))
[('A', 'A'), ('B', 'B'), ('C', 'C')]

Observe que vários exemplos nesta seção usam combinações de funções geradoras. Essa é uma excelente característica dessas funções: como recebem como argumentos e devolvem geradores, elas podem ser combinadas de muitas formas diferentes.

Vamos agora revisar outro grupo de funções da biblioteca padrão que lidam com iteráveis.

17.10. Funções de redução de iteráveis

Todas as funções na Tabela 24 recebem um iterável e devolvem um resultado único. Elas são conhecidas como funções de "redução", "dobra" (folding) ou "acumulação". Podemos implementar cada uma das funções embutidas listadas a seguir com functools.reduce, mas elas existem embutidas por resolverem algums casos de uso comuns de forma mais fácil. Já vimos uma explicação mais aprofundada sobre functools.reduce na Seção 12.7.

Nos casos de all e any, há uma importante otimização não suportada por functools.reduce: all e any conseguem criar um curto-circuito—isto é, elas param de consumir o iterador assim que o resultado esteja determinado. Veja o último teste com any no Exemplo 24.

Tabela 24. Funções embutidas que leem iteráveis e devolvem um único valor
Módulo Função Descrição

(embutida)

all(it)

Devolve True se todos os itens em it forem verdadeiros, False em caso contrário; all([]) devolve True

(embutida)

any(it)

Devolve True se qualquer item em it for verdadeiro, False em caso contrário; any([]) devolve False

(embutida)

max(it, [key=,] [default=])

Devolve o valor máximo entre os itens de it;[229] key é uma função de ordenação, como em sorted; default é devolvido se o iterável estiver vazio

(embutida)

min(it, [key=,] [default=])

Devolve o valor mínimo entre os itens de it.[230] key é uma função de ordenação, como em sorted; default é devolvido se o iterável estiver vazio

functools

reduce(func, it, [initial])

Devolve o resultado da aplicação de func consecutivamente ao primeiro par de itens, depois deste último resultado e o terceiro item, e assim por diante; se initial for passado, esse argumento formará o par inicial com o primeiro item

(embutida)

sum(it, start=0)

A soma de todos os itens em it, acrescida do valor opcional start (para uma precisão melhor na adição de números de ponto flutuante, use math.fsum)

O Exemplo 24 exemplifica a operação de all e de any.

Exemplo 24. Resultados de all e any para algumas sequências
>>> all([1, 2, 3])
True
>>> all([1, 0, 3])
False
>>> all([])
True
>>> any([1, 2, 3])
True
>>> any([1, 0, 3])
True
>>> any([0, 0.0])
False
>>> any([])
False
>>> g = (n for n in [0, 0.0, 7, 8])
>>> any(g)  # (1)
True
>>> next(g)  # (2)
8
  1. any iterou sobre g até g produzir 7; neste momento any parou e devolveu True.

  2. É por isso que 8 ainda restava.

Outra função embutida que recebe um iterável e devolve outra coisa é sorted. Diferente de reversed, que é uma função geradora, sorted cria e devolve uma nova list. Afinal, cada um dos itens no iterável de entrada precisa ser lido para que todos possam ser ordenados, e a ordenação acontece em uma list; sorted então apenas devolve aquela list após terminar seu processamento. Menciono sorted aqui porque ela consome um iterável arbitrário.

Claro, sorted e as funções de redução só funcionam com iteráveis que terminam em algum momento. Caso contrário, eles seguirão coletando itens e nunca devolverão um resultado.

✒️ Nota

Se você chegou até aqui, já viu o conteúdo mais importante e útil deste capítulo. As seções restantes tratam de recursos avançados de geradores, que a maioria de nós não vê ou precisa com muita frequência, tal como a instrução yield from e as corrotinas clássicas.

Há também seções sobre dicas de tipo para iteráveis, iteradores e corrotinas clássicas.

A sintaxe yield from fornece uma nova forma de combinar geradores. É nosso próximo assunto.

17.11. Subgeradoras com yield from

A sintaxe da expressão yield from foi introduzida no Python 3.3, para permitir que um gerador delegue tarefas a um subgerador.

Antes da introdução de yield from, usávamos um loop for quando um gerador precisava produzir valores de outro gerador:

>>> def sub_gen():
...     yield 1.1
...     yield 1.2
...
>>> def gen():
...     yield 1
...     for i in sub_gen():
...         yield i
...     yield 2
...
>>> for x in gen():
...     print(x)
...
1
1.1
1.2
2

Podemos obter o mesmo resultado usando yield from, como se vê no Exemplo 25.

Exemplo 25. Experimentando yield from
>>> def sub_gen():
...     yield 1.1
...     yield 1.2
...
>>> def gen():
...     yield 1
...     yield from sub_gen()
...     yield 2
...
>>> for x in gen():
...     print(x)
...
1
1.1
1.2
2

No Exemplo 25, o loop for é o código cliente, gen é o gerador delegante e sub_gen é o subgerador. Observe que yield from suspende gen, e sub_gen toma o controle até se exaurir. Os valores produzidos por sub_gen passam através de gen diretamente para o loop for cliente. Enquanto isso, gen está suspenso e não pode ver os valores que passam por ele. gen continua apenas quando sub_gen termina.

Quando o subgerador contém uma instrução return com um valor, aquele valor pode ser capturado pelo gerador delegante, com o uso de yield from como parte de uma expressão. Veja a demonstração no Exemplo 26.

Exemplo 26. yield from recebe o valor devolvido pelo subgerador
>>> def sub_gen():
...     yield 1.1
...     yield 1.2
...     return 'Done!'
...
>>> def gen():
...     yield 1
...     result = yield from sub_gen()
...     print('<--', result)
...     yield 2
...
>>> for x in gen():
...     print(x)
...
1
1.1
1.2
<-- Done!
2

Agora que já vimos o básico sobre yield from, vamos estudar alguns exemplos simples mas práticos de sua utilização.

17.11.1. Reinventando chain

Vimos na Tabela 21 que itertools fornece uma geradora chain, que produz itens a partir de vários iteráveis, iterando sobre o primeiro, depois sobre o segundo, e assim por diante, até o último. Abaixo está uma implementação caseira de chain, com loops for aninhados, em Python:[231]

>>> def chain(*iterables):
...     for it in iterables:
...         for i in it:
...             yield i
...
>>> s = 'ABC'
>>> r = range(3)
>>> list(chain(s, r))
['A', 'B', 'C', 0, 1, 2]

A geradora chain, no código acima, está delegando para cada iterável it, controlando cada it no loop for interno. Aquele loop interno pode ser substituído por uma expressão yield from, como mostra a seção de console a seguir:

>>> def chain(*iterables):
...     for i in iterables:
...         yield from i
...
>>> list(chain(s, t))
['A', 'B', 'C', 0, 1, 2]

O uso de yield from neste exemplo está correto, e o código é mais legível, mas parece açúcar sintático, com pouco ganho real. Vamos então desenvolver um exemplo mais interessante.

17.11.2. Percorrendo uma árvore

Nessa seção, veremos yield from em um script para percorrer uma estrutura de árvore. Vou desenvolvê-lo bem devagar.

A estrutura de árvore nesse exemplo é a hierarquia das exceções do Python. Mas o padrão pode ser adaptado para exibir uma árvore de diretórios ou qualquer outra estrutura de árvore.

Começando de BaseException no nível zero, a hierarquia de exceções tem cinco níveis de profundidade no Python 3.10. Nosso primeiro pequeno passo será exibir o nível zero.

Dada uma classe raiz, a geradora tree no Exemplo 27 produz o nome dessa classe e para.

Exemplo 27. tree/step0/tree.py: produz o nome da classe raiz e para
def tree(cls):
    yield cls.__name__


def display(cls):
    for cls_name in tree(cls):
        print(cls_name)


if __name__ == '__main__':
    display(BaseException)

A saída do Exemplo 27 tem apenas uma linha:

BaseException

O próximo pequeno passo nos leva ao nível 1. A geradora tree irá produzir o nome da classe raiz e os nomes de cada subclasse direta. Os nomes das subclasses são indentados para explicitar a hierarquia. Esta é a saída que queremos:

$ python3 tree.py
BaseException
    Exception
    GeneratorExit
    SystemExit
    KeyboardInterrupt

O Exemplo 28 produz a saída acima.

Exemplo 28. tree/step1/tree.py: produz o nome da classe raiz e das subclasses diretas
def tree(cls):
    yield cls.__name__, 0                        # (1)
    for sub_cls in cls.__subclasses__():         # (2)
        yield sub_cls.__name__, 1                # (3)


def display(cls):
    for cls_name, level in tree(cls):
        indent = ' ' * 4 * level                 # (4)
        print(f'{indent}{cls_name}')


if __name__ == '__main__':
    display(BaseException)
  1. Para suportar a saída indentada, produz o nome da classe e seu nível na hierarquia.

  2. Usa o método especial __subclasses__ para obter uma lista de subclasses.

  3. Produz o nome da subclasse e o nível (1).

  4. Cria a string de indentação de 4 espaços vezes o level. No nível zero, isso será uma string vazia.

No Exemplo 29, refatorei tree para separar o caso especial da classes raiz de suas subclasses, que agora são processadas na geradora sub_tree. Em yield from, a geradora tree é suspensa, e sub_tree passa a produzir valores.

Exemplo 29. tree/step2/tree.py: tree produz o nome da classe raiz, e entao delega para sub_tree
def tree(cls):
    yield cls.__name__, 0
    yield from sub_tree(cls)              # (1)


def sub_tree(cls):
    for sub_cls in cls.__subclasses__():
        yield sub_cls.__name__, 1         # (2)


def display(cls):
    for cls_name, level in tree(cls):     # (3)
        indent = ' ' * 4 * level
        print(f'{indent}{cls_name}')


if __name__ == '__main__':
    display(BaseException)
  1. Delega para sub_tree, para produzir os nomes das subclasses.

  2. Produz o nome de cada subclasse e o nível (1). Por causa do yield from sub_tree(cls) dentro de tree, esses valores escapam completamente à geradora tree …​

  3. …​ e são recebidos aqui diretamente.

Seguindo com nosso método de pequenos passos, vou escrever o código mais simples que consigo imaginar para chegar ao nível 2. Para percorrer uma árvore primeiro em produndidade (depth-first), após produzir cada nó do nível 1, quero produzir os filhotes daquele nó no nível 2 antes de voltar ao nível 1. Um loop for aninhado cuida disso, como no Exemplo 30.

Exemplo 30. tree/step3/tree.py: sub_tree percorre os níveis 1 e 2, primeiro em profundidade
def tree(cls):
    yield cls.__name__, 0
    yield from sub_tree(cls)


def sub_tree(cls):
    for sub_cls in cls.__subclasses__():
        yield sub_cls.__name__, 1
        for sub_sub_cls in sub_cls.__subclasses__():
            yield sub_sub_cls.__name__, 2


def display(cls):
    for cls_name, level in tree(cls):
        indent = ' ' * 4 * level
        print(f'{indent}{cls_name}')


if __name__ == '__main__':
    display(BaseException)

Este é o resultado da execução de step3/tree.py, do Exemplo 30:

$ python3 tree.py
BaseException
    Exception
        TypeError
        StopAsyncIteration
        StopIteration
        ImportError
        OSError
        EOFError
        RuntimeError
        NameError
        AttributeError
        SyntaxError
        LookupError
        ValueError
        AssertionError
        ArithmeticError
        SystemError
        ReferenceError
        MemoryError
        BufferError
        Warning
    GeneratorExit
    SystemExit
    KeyboardInterrupt

Você pode já ter percebido para onde isso segue, mas vou insistir mais uma vez nos pequenos passos: vamos atingir o nível 3, acrescentando ainda outro loop for aninhado. Não há qualquer alteração no restante do programa, então o Exemplo 31 mostra apenas a geradora sub_tree.

Exemplo 31. A geradora sub_tree de tree/step4/tree.py
def sub_tree(cls):
    for sub_cls in cls.__subclasses__():
        yield sub_cls.__name__, 1
        for sub_sub_cls in sub_cls.__subclasses__():
            yield sub_sub_cls.__name__, 2
            for sub_sub_sub_cls in sub_sub_cls.__subclasses__():
                yield sub_sub_sub_cls.__name__, 3

Há um padrão claro no Exemplo 31. Entramos em um loop for para obter as subclasses do nível N. A cada passagem do loop, produzimos uma subclasse do nível N, e então iniciamos outro loop for para visitar o nível N+1.

Na Seção 17.11.1, vimos como é possível substituir um loop for aninhado controlando uma geradora com yield from sobre a mesma geradora. Podemos aplicar aquela ideia aqui, se fizermos sub_tree aceitar um parâmetro level, usando yield from recursivamente e passando a subclasse atual como nova classe raiz com o número do nível seguinte. Veja o Exemplo 32.

Exemplo 32. tree/step5/tree.py: a sub_tree recursiva vai tão longe quanto a memória permitir
def tree(cls):
    yield cls.__name__, 0
    yield from sub_tree(cls, 1)


def sub_tree(cls, level):
    for sub_cls in cls.__subclasses__():
        yield sub_cls.__name__, level
        yield from sub_tree(sub_cls, level+1)


def display(cls):
    for cls_name, level in tree(cls):
        indent = ' ' * 4 * level
        print(f'{indent}{cls_name}')


if __name__ == '__main__':
    display(BaseException)

O Exemplo 32 pode percorrer árvores de qualquer profundidade, limitado apenas pelo limite de recursão do Python. O limite default permite 1.000 funções pendentes.

Qualquer bom tutorial sobre recursão enfatizará a importância de ter um caso base, para evitar uma recursão infinita. Um caso base é um ramo condicional que retorna sem fazer uma chamada recursiva. O caso base é frequentemente implementado com uma instrução if. No Exemplo 32, sub_tree não tem um if, mas há uma condicional implícita no loop for: Se cls.subclasses() devolver uma lista vazia, o corpo do loop não é executado, e assim a chamada recursiva não ocorre. O caso base ocorre quando a classe cls não tem subclasses. Nesse caso, sub_tree não produz nada, apenas retorna.

O Exemplo 32 funciona como planejado, mas podemos fazê-la mais concisa recordando do padrão que observamos quando alcançamos o nível 3 (no Exemplo 31): produzimos uma subclasse de nível N, e então iniciamos um loop for aninhado para visitar o nível N+1. No Exemplo 32, substituímos o loop aninhado por yield from. Agora podemos fundir tree e sub_tree em uma única geradora. O Exemplo 33 é o último passo deste exemplo.

Exemplo 33. tree/step6/tree.py: chamadas recursivas de tree passam um argumento level incrementado
def tree(cls, level=0):
    yield cls.__name__, level
    for sub_cls in cls.__subclasses__():
        yield from tree(sub_cls, level+1)


def display(cls):
    for cls_name, level in tree(cls):
        indent = ' ' * 4 * level
        print(f'{indent}{cls_name}')


if __name__ == '__main__':
    display(BaseException)

No início da Seção 17.11, vimos como yield from conecta a subgeradora diretamente ao código cliente, escapando da geradora delegante. Aquela conexão se torna realmente importante quando geradoras são usadas como corrotinas, e não apenas produzem mas também consomem valores do código cliente, como veremos na Seção 17.13.

Após esse primeiro encontro com yield from, vamos olhar as dicas de tipo para iteráveis e iteradores.

17.12. Tipos iteráveis genéricos

A bilbioteca padrão do Python contém muitas funções que aceitam argumentos iteráveis. Em seu código, tais funções podem ser anotadas como a função zip_replace, vista no Exemplo 33, usando collections.abc.Iterable (ou typing.Iterable, se você precisa suporta o Python 3.8 ou anterior, como explicado no Suporte a tipos de coleção descontinuados). Veja o Exemplo 34.

Exemplo 34. replacer.py devolve um iterador de tuplas de strings
from collections.abc import Iterable

FromTo = tuple[str, str]  # (1)

def zip_replace(text: str, changes: Iterable[FromTo]) -> str:  # (2)
    for from_, to in changes:
        text = text.replace(from_, to)
    return text
  1. Define um apelido (alias) de tipo; isso não é obrigatório, mas torna a próxima dica de tipo mais legível. Desde o Python 3.10, FromTo deve ter uma dica de tipo de typing.TypeAlias, para esclarecer a razão para essa linha: FromTo: TypeAlias = tuple[str, str].

  2. Anota changes para aceitar um Iterable de tuplas FromTo.

Tipos Iterator não aparecem com a mesma frequência de tipos Iterable, mas eles também são simples de escrever. O Exemplo 35 mostra a conhecida geradora Fibonacci, anotada.

Exemplo 35. fibo_gen.py: fibonacci devolve um gerador de inteiros
from collections.abc import Iterator

def fibonacci() -> Iterator[int]:
    a, b = 0, 1
    while True:
        yield a
        a, b = b, a + b

Observe que o tipo Iterator é usado para geradoras programadas como funções com yield, bem como para iteradores escritos "a mão", como classes que implementam __next__. Há também o tipo collections.abc.Generator (e o decontinuado typing.Generator correspondente) que podemos usar para anotar objetos geradores, mas ele é verboso e redundane para geradoras usadas como iteradores, que não recebem valores via .send().

O Exemplo 36, quando verificado com o Mypy, revela que o tipo Iterator é, na verdade, um caso especial simplificado do tipo Generator.

Exemplo 36. itergentype.py: duas formas de anotar iteradores
from collections.abc import Iterator
from keyword import kwlist
from typing import TYPE_CHECKING

short_kw = (k for k in kwlist if len(k) < 5)  # (1)

if TYPE_CHECKING:
    reveal_type(short_kw)  # (2)

long_kw: Iterator[str] = (k for k in kwlist if len(k) >= 4)  # (3)

if TYPE_CHECKING:  # (4)
    reveal_type(long_kw)
  1. Uma expressão geradora que produz palavras reservadas do Python com menos de 5 caracteres.

  2. O Mypy infere: typing.Generator[builtins.str*, None, None].[232]

  3. Isso também produz strings, mas acrescentei uma dica de tipo explícita.

  4. Tipo revelado: typing.Iterator[builtins.str].

abc.Iterator[str] é consistente-com abc.Generator[str, None, None], assim o Mypy não reporta erros na verificação de tipos no Exemplo 36.

Iterator[T] é um atalho para Generator[T, None, None]. Ambas as anotações significam "uma geradora que produz itens do tipo T, mas não consome ou devolve valores." Geradoras capazes de consumir e devolver valores são corrotinas, nosso próximo tópico.

17.13. Corrotinas clássicas

✒️ Nota

A PEP 342—Coroutines via Enhanced Generators (Corrotinas via geradoras aprimoradas) introduziu .send() e outros recursos que tornaram possível usar geradoras como corrotinas. A PEP 342 usa a palavra "corrotina" (coroutine) no mesmo sentido que estou usando aqui. É lamentável que a documentação oficial do Python e da biblioteca padrão agora usem uma terminologia inconsistente para se referir a geradoras usadas como corrotinas, me obrigando a adotar o qualificador "corrotina clássica", para diferenciar estas últimas com os novos objetos "corrotinas nativas".

Após o lançamento do Python 3.5, a tendência é usar "corrotina" como sinônimo de "corrotina nativa". Mas a PEP 342 não está descontinuada, e as corrotinas clássicas ainda funcionam como originalmente projetadas, apesar de não serem mais suportadas por asyncio.

Entender as corrotinas clássicas no Python é mais confuso porque elas são, na verdade, geradoras usadas de uma forma diferente. Vamos então dar um passo atrás e examinar outro recurso do Python que pode ser usado de duas maneiras.

Vimos na Seção 2.4 que é possível usar instâncias de tuple como registros ou como sequências imutáveis. Quando usadas como um registro, se espera que uma tupla tenha um número específico de itens, e cada item pode ter um tipo diferente. Quando usadas como listas imutáveis, uma tupla pode ter qualquer tamanho, e se espera que todos os itens sejam do mesmo tipo. Por essa razão, há duas formas de anotar tuplas com dicas de tipo:

# Um registro de cidade, como nome, país e população:
city: tuple[str, str, int]

# Uma sequência imutável de nomes de domínios:
domains: tuple[str, ...]

Algo similar ocorre com geradoras. Elas normalmente são usadas como iteradores, mas podem também ser usadas como corrotinas. Na verdade, corrotina é uma função geradora, criada com a palavra-chave yield em seu corpo. E um objeto corrotina é um objeto gerador, fisicamente. Apesar de compartilharem a mesma implementação subjacente em C, os casos de uso de geradoras e corrotinas em Python são tão diferentes que há duas formas de escrever dicas de tipo para elas:

# A variável `readings` pode ser delimitada a um iterador
# ou a um objeto gerador que produz itens `float`:
readings: Iterator[float]

# A variável `sim_taxi` pode ser delimitada a uma corrotina
# representando um táxi em uma simulação de eventos discretos.
# Ela produz eventos, recebe um `float` de data/hora, e devolve
# o número de viagens realizadas durante a simulação:
sim_taxi: Generator[Event, float, int]

Para aumentar a confusão, os autores do módulo typing decidiram nomear aquele tipo Generator, quando ele de fato descreve a API de um objeto gerador projetado para ser usado como uma corrotina, enquanto geradoras são mais frequentemente usadas como iteradores simples.

A documentação do módulo typing (EN) descreve assim os parâmetros de tipo formais de Generator:

Generator[YieldType, SendType, ReturnType]

O SendType só é relevante quando a geradora é usada como uma corrotina. Aquele parâmetro de tipo é o tipo de x na chamada gen.send(x). É um erro invocar .send() em uma geradora escrita para se comportar como um iterador em vez de uma corrotina. Da mesma forma, ReturnType só faz sentido para anotar uma corrotina, pois iteradores não devolvem valores como funções regulares. A única operação razoável em uma geradora usada como um iterador é invocar next(it) direta ou indiretamente, via loops for e outras formas de iteração. O YieldType é o tipo do valor devolvido em uma chamada a next(it).

O tipo Generator tem os mesmo parâmetros de tipo de typing.Coroutine:

Coroutine[YieldType, SendType, ReturnType]

A documentação de typing.Coroutine diz literalmente: "A variância e a ordem das variáveis de tipo correspondem às de Generator." Mas typing.Coroutine (descontinuada) e collections.abc.Coroutine (genérica a partir do Python 3.9) foram projetadas para anotar apenas corrotinas nativas, e não corrotinas clássicas. Se você quiser usar dicas de tipo com corrotinas clássicas, vai sofrer com a confusão advinda de anotá-las como Generator[YieldType, SendType, ReturnType].

David Beazley criou algumas das melhores palestras e algumas das oficinas mais abrangentes sobre corrotinas clássicas. No material de seu curso na PyCon 2009 há um slide chamado "Keeping It Straight" (Cada Coisa em Seu Lugar), onde se lê:

  • Geradoras produzem dados para iteração

  • Corrotinas são consumidoras de dados

  • Para evitar que seu cérebro exploda, não misture os dois conceitos

  • Corrotinas não tem relação com iteração

  • Nota: Há uma forma de fazer yield produzir um valor em uma corrotina, mas isso não está ligado à iteração.[233]

Vamos ver agora como as corrotinas clássicas funcionam.

17.13.1. Exemplo: Corrotina para computar uma média móvel

Quando discutimos clausuras no Capítulo 9, estudamos objetos para computar uma média móvel. O Exemplo 7 mostra uma classe e o Exemplo 13 apresenta uma função de ordem superior devolvendo uma função que mantem as variáveis total e count entre invocações, em uma clausura. O Exemplo 37 mostra como fazer o mesmo com uma corrotina.[234]

Exemplo 37. coroaverager.py: corrotina para computar uma média móvel
from collections.abc import Generator

def averager() -> Generator[float, float, None]:  # (1)
    total = 0.0
    count = 0
    average = 0.0
    while True:  # (2)
        term = yield average  # (3)
        total += term
        count += 1
        average = total/count
  1. Essa função devolve uma geradora que produz valores float, aceita valores float via .send(), e não devolve um valor útil.[235]

  2. Esse loop infinito significa que a corrotina continuará produzindo médias enquanto o código cliente enviar valores.

  3. O comando yield aqui suspende a corrotina, produz um resultado para o cliente e—mais tarde—recebe um valor enviado pelo código de invocação para a corrotina, iniciando outra iteração do loop infinito.

Em uma corrotina, total e count podem ser variáveis locais: atributos de instância ou uma clausura não são necessários para manter o contexto enquanto a corrotina está suspensa, esperando pelo próximo .send(). Por isso as corrotinas são substitutas atraentes para callbacks em programação assíncrona—elas mantêm o estado local entre ativações.

O Exemplo 38 executa doctests mostrando a corrotina averager em operação.

Exemplo 38. coroaverager.py: doctest para a corrotina de média móvel do Exemplo 37
    >>> coro_avg = averager()  # (1)
    >>> next(coro_avg)  # (2)
    0.0
    >>> coro_avg.send(10)  # (3)
    10.0
    >>> coro_avg.send(30)
    20.0
    >>> coro_avg.send(5)
    15.0
  1. Cria o objeto corrotina.

  2. Inicializa a corrotina. Isso produz o valor inicial de average: 0.0.

  3. Agora estamos conversando: cada chamada a .send() produz a média atual.

No Exemplo 38, a chamada next(coro_avg) faz a corrotina avançar até o yield, produzindo o valor inicial de average. Também é possível inicializar a corrotina chamando coro_avg.send(None)—na verdade é isso que a função embutida next() faz. Mas você não pode enviar qualquer valor diferente de None, pois a corrotina só pode aceitar um valor enviado quando está suspensa, em uma linha de yield. Invocar next() ou .send(None) para avançar até o primeiro yield é conhecido como "preparar (priming) a corrotina".

Após cada ativação, a corrotina é suspensa exatamente na palavra-chave yield, e espera que um valor seja enviado. A linha coro_avg.send(10) fornece aquele valor, ativando a corrotina. A expressão yield se resolve para o valor 10, que é atribuído à variável term. O restante do loop atualiza as variáveis total, count, e average. A próxima iteração no loop while produz average, e a corrotina é novamente suspensa na palavra-chave yield.

O leitor atento pode estar ansioso para saber como a execução de uma instância de averager (por exemplo, coro_avg) pode ser encerrada, pois seu corpo é um loop infinito. Em geral, não precisamos encerrar uma geradora, pois ela será coletada como lixo assim que não existirem mais referências válidas para ela. Se for necessário encerrá-la explicitamente, use o método .close(), como mostra o Exemplo 39.

Exemplo 39. coroaverager.py: continuando de Exemplo 38
    >>> coro_avg.send(20)  # (1)
    16.25
    >>> coro_avg.close()  # (2)
    >>> coro_avg.close()  # (3)
    >>> coro_avg.send(5)  # (4)
    Traceback (most recent call last):
      ...
    StopIteration
  1. coro_avg é a instância criada no Exemplo 38.

  2. O método .close() gera uma exceção GeneratorExit na expressão yield suspensa. Se não for tratada na função corrotina, a exceção a encerra. GeneratorExit é capturada pelo objeto gerador que encapsula a corrotina—por isso não a vemos.

  3. Invocar .close() em uma corrotina previamente encerrada não tem efeito.

  4. Tentar usar .send() em uma corrotina encerrada gera uma StopIteration.

Além do método .send(), a PEP 342—Coroutines via Enhanced Generators (Corrotinas via geradoras aprimoradas) também introduziu uma forma de uma corrotina devolver um valor. A próxima seção mostra como fazer isso.

17.13.2. Devolvendo um valor a partir de uma corrotina

Vamos agora estudar outra corrotina para computar uma média. Essa versão não vai produzir resultados parciais. Em vez disso, ela devolve uma tupla com o número de termos e a média. Dividi a listagem em duas partes, no Exemplo 40 e no Exemplo 41.

Exemplo 40. coroaverager2.py: a primeira parte do arquivo
from collections.abc import Generator
from typing import Union, NamedTuple

class Result(NamedTuple):  # (1)
    count: int  # type: ignore  # (2)
    average: float

class Sentinel:  # (3)
    def __repr__(self):
        return f'<Sentinel>'

STOP = Sentinel()  # (4)

SendType = Union[float, Sentinel]  # (5)
  1. A corrotina averager2 no Exemplo 41 vai devolver uma instância de Result.

  2. Result é, na verdade, uma subclasse de tuple, que tem um método .count(), que não preciso aqui. O comentário # type: ignore evita que o Mypy reclame sobre a existência do campo count.[236]

  3. Uma classe para criar um valor sentinela com um __repr__ legível.

  4. O valor sentinela que vou usar para fazer a corrotina parar de coletar dados e devolver uma resultado.

  5. Vou usar esse apelido de tipo para o segundo parâmetro de tipo devolvido pela corrotina Generator, o parâmetro SendType.

A definição de SendType também funciona no Python 3.10 mas, se não for necessário suportar versões mais antigas, é melhor escrever a anotação assim, após importar TypeAlias de typing:

SendType: TypeAlias = float | Sentinel

Usar | em vez de typing.Union é tão conciso e legível que eu provavelmente não criaria aquele apelido de tipo. Em vez disso, escreveria a assinatura de averager2 assim:

def averager2(verbose: bool=False) -> Generator[None, float | Sentinel, Result]:

Vamos agora estudar o código da corrotina em si (no Exemplo 41).

Exemplo 41. coroaverager2.py: uma corrotina que devolve um valor resultante
def averager2(verbose: bool = False) -> Generator[None, SendType, Result]:  # (1)
    total = 0.0
    count = 0
    average = 0.0
    while True:
        term = yield  # (2)
        if verbose:
            print('received:', term)
        if isinstance(term, Sentinel):  # (3)
            break
        total += term  # (4)
        count += 1
        average = total / count
    return Result(count, average)  # (5)
  1. Para essa corrotina, o tipo produzido é None, porque ela não produz dados. Ela recebe dados do tipo SendType e devolve uma tupla Result quando termina o processamento.

  2. Usar yield assim só faz sentido em corrotinas, que são projetadas para consumir dados. Isso produz None, mas recebe um term de .send(term).

  3. Se term é um Sentinel, sai do loop. Graças a essa verificação com isinstance…​

  4. …​Mypy me permite somar term a total sem sinalizar um erro (que eu não poderia somar um float a um objeto que pode ser um float ou um Sentinel).

  5. Essa linha só será alcançada de um Sentinel for enviado para a corrotina.

Vamos ver agora como podemos usar essa corrotina, começando por um exemplo simples, que sequer produz um resultado (no Exemplo 42).

Exemplo 42. coroaverager2.py: doctest mostrando .cancel()
    >>> coro_avg = averager2()
    >>> next(coro_avg)
    >>> coro_avg.send(10)  # (1)
    >>> coro_avg.send(30)
    >>> coro_avg.send(6.5)
    >>> coro_avg.close()  # (2)
  1. Lembre-se que averager2 não produz resultados parciais. Ela produz None, que o console do Python omite.

  2. Invocar .close() nessa corrotina a faz parar, mas não devolve um resultado, pois a exceção GeneratorExit é gerada na linha yield da corrotina, então a instrução return nunca é alcançada.

Vamos então fazê-la funcionar, no Exemplo 43.

Exemplo 43. coroaverager2.py: doctest mostrando StopIteration com um Result
    >>> coro_avg = averager2()
    >>> next(coro_avg)
    >>> coro_avg.send(10)
    >>> coro_avg.send(30)
    >>> coro_avg.send(6.5)
    >>> try:
    ...     coro_avg.send(STOP)  # (1)
    ... except StopIteration as exc:
    ...     result = exc.value  # (2)
    ...
    >>> result  # (3)
    Result(count=3, average=15.5)
  1. Enviar o valor sentinela STOP faz a corrotina sair do loop e devolver um Result. O objeto gerador que encapsula a corrotina gera então uma StopIteration.

  2. A instância de StopIteration tem um atributo value vinculado ao valor do comando return que encerrou a corrotina.

  3. Acredite se quiser!

Essa ideia de "contrabandear" o valor devolvido para fora de uma corrotina dentro de uma exceção StopIteration é um truque bizarro. Entretanto, esse truque é parte da PEP 342—Coroutines via Enhanced Generators (Corrotinas via geradoras aprimoradas) (EN), e está documentada com a exceção StopIteration e na seção "Expressões yield" do capítulo 6 de A Referência da Linguagem Python.

Uma geradora delegante pode obter o valor devolvido por uma corrotina diretamente, usando a sintaxe yield from, como demonstrado no Exemplo 44.

Exemplo 44. coroaverager2.py: doctest mostrando StopIteration com um Result
    >>> def compute():
    ...     res = yield from averager2(True)  # (1)
    ...     print('computed:', res)  # (2)
    ...     return res  # (3)
    ...
    >>> comp = compute()  # (4)
    >>> for v in [None, 10, 20, 30, STOP]:  # (5)
    ...     try:
    ...         comp.send(v)  # (6)
    ...     except StopIteration as exc:  # (7)
    ...         result = exc.value
    received: 10
    received: 20
    received: 30
    received: <Sentinel>
    computed: Result(count=3, average=20.0)
    >>> result  # (8)
    Result(count=3, average=20.0)
  1. res vai coletar o valor devolvido por averager2; o mecanismo de yield from recupera o valor devolvido quando trata a exceção StopIteration, que marca o encerramento da corrotina. Quando True, o parâmetro verbose faz a corrotina exibir o valor recebido, tornando sua operação visível.

  2. Preste atenção na saída desta linha quando a geradora for executada.

  3. Devolve o resultado. Isso também estará encapsulado em StopIteration.

  4. Cria o objeto corrotina delegante.

  5. Esse loop vai controlar a corrotina delegante.

  6. O primeiro valor enviado é None, para preparar a corrotina; o último é a sentinela, para pará-la.

  7. Captura StopIteration para obter o valor devolvido por compute.

  8. Após as linhas exibidas por averager2 e compute, recebemos a instância de Result.

Mesmo com esses exemplos aqui, que não fazem muita coisa, o código é difícil de entender. Controlar a corrotina com chamadas .send() e recuperar os resultados é complicado, exceto com yield from—mas só podemos usar essa sintaxe dentro de uma geradora/corrotina, que no fim precisa ser controlada por algum código não-trivial, como mostra o Exemplo 44.

Os exemplos anteriores mostram que o uso direto de corrotinas é incômodo e confuso. Acrescente o tratamento de exceções e o método de corrotina .throw(), e os exemplos ficam ainda mais complicados. Não vou tratar de .throw() nesse livro porque—como .send()—ele só é útil para controlar corrotinas "manualmente", e não recomendo fazer isso, a menos que você esteja criando um novo framework baseado em corrotinas do zero .

✒️ Nota

Se você estiver interessado em um tratamento mais aprofundado de corrotinas clássicas—incluindo o método .throw()—por favor veja "Classic Coroutines" (Corrotinas Clássicas) (EN) no site que acompanha o livro, fluentpython.com. Aquele texto inclui pseudo-código similar ao Python detalhando como yield from controla geradoras e corrotinas, bem como uma pequena simulação de eventos discretos, demonstrando uma forma de concorrência usando corrotinas sem um framework de programação assíncrona.

Na prática, realizar trabalho produtivo com corrotinas exige o suporte de um framework especializada. É isso que asyncio oferecia para corrotinas clássicas lá atrás, no Python 3.3. Com o advento das corrotinas nativas no Python 3.5, os desenvolvedores principais do Python estão gradualmente eliminando o suporte a corrotinas clássicas no asyncio. Mas os mecanismos subjacentes são muito similares. A sintaxe async def torna a corrotinas nativas mais fáceis de identificar no código, um grande benefício por si só. Internamente, as corrotinas nativas usam await em vez de yield from para delegar a outras corrotinas. O Capítulo 21 é todo sobre esse assunto.

Vamos agora encerrar o capítulo com uma seção alucinante sobre co-variância e contra-variância em dicas de tipo para corrotinas.

17.13.3. Dicas de tipo genéricas para corrotinas clássicas

Anteriomente, na Seção 15.7.4.3, mencionei typing.Generator como um dos poucos tipos da biblioteca padrão com um parâmetro de tipo contra-variante. Agora que estudamos as corrotinas clássicas, estamos prontos para entender esse tipo genérico.

É assim que typing.Generator era declarado no módulo typing.py do Python 3.6:[237]

T_co = TypeVar('T_co', covariant=True)
V_co = TypeVar('V_co', covariant=True)
T_contra = TypeVar('T_contra', contravariant=True)

# muitas linhas omitidas

class Generator(Iterator[T_co], Generic[T_co, T_contra, V_co],
                extra=_G_base):

Essa declaração de tipo genérico significa que uma dica de tipo de Generator requer aqueles três parâmetros de tipo que vimos antes:

my_coro : Generator[YieldType, SendType, ReturnType]

Pelas variáveis de tipo nos parâmetros formais, vemos que YieldType e ReturnType são covariantes, mas SendType é contra-variante. Para entender a razão disso, considere que YieldType e ReturnType são tipos de "saída". Ambos descrevem dados que saem do objeto corrotina—isto é, o objeto gerador quando usado como um objeto corrotina..

Faz sentido que esses parâmetros sejam covariantes, pois qualquer código esperando uma corrotina que produz números de ponto flutuante pode usar uma corrotina que produz inteiros. Por isso Generator é covariante em seu parâmetro YieldType. O mesmo raciocínio se aplica ao parâmetro ReturnType—também covariante.

Usando a notação introduzida na Seção 15.7.4.2, a covariância do primeiro e do terceiro parâmetros pode ser expressa pelos símbolos :> apontando para a mesma direção:

                       float :> int
Generator[float, Any, float] :> Generator[int, Any, int]

YieldType e ReturnType são exemplos da primeira regra apresentada na Seção 15.7.4.4:

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

Por outro lado, SendType é um parâmetro de "entrada": ele é o tipo do argumento value para o método .send(value) do objeto corrotina. Código cliente que precise enviar números de ponto flutuante para uma corrotina não consegue usar uma corrotina que receba int como o SendType, porque float não é um subtipo de int. Em outras palavras, float não é consistente-com int. Mas o cliente pode usar uma corrotina que tenha complex como SendType, pois float é um subtipo de complex, e portanto float é consistente-com complex.

A notação :> torna visível a contra-variância do segundo parâmetro:

                     float :> int
Generator[Any, float, Any] <: Generator[Any, int, Any]

Este é um exemplo da segunda regra geral da variância:

  1. Se um parâmetro de tipo formal define um tipo para dados que entram em um objeto após sua construção inicial, ele pode ser contra-variante.

Essa alegre discussão sobre variância encerra o capítulo mais longo do livro.

17.14. Resumo do capítulo

A iteração está integrada tão profundamente à linguagem que eu gosto de dizer que o Python groks iteradores[238] A integração do padrão Iterator na semântica do Python é um exemplo perfeito de como padrões de projeto não são aplicáveis a todas as linguagens de programação. No Python, um Iterator clássico, implementado "à mão", como no Exemplo 4, não tem qualquer função prática, exceto como exemplo didático.

Neste capítulo, criamos algumas versões de uma classe para iterar sobre palavras individuais em arquivos de texto (que podem ser muito grandes). Vimos como o Python usa a função embutida iter() para criar iteradores a partir de objetos similares a sequências. Criamos um iterador clássico como uma classe com __next__(), e então usamos geradoras, para tornar cada refatoração sucessiva da classe Sentence mais concisa e legível.

Daí criamos uma geradora de progressões aritméticas, e mostramos como usar o módulo itertools para torná-la mais simples. A isso se seguiu uma revisão da maioria das funções geradoras de uso geral na biblioteca padrão.

A seguir estudamos expressões yield from no contexto de geradoras simples, com os exemplos chain e tree.

A última seção de nota foi sobre corrotinas clássicas, um tópico de importância decrescente após a introducão das corrotinas nativas, no Python 3.5. Apesar de difíceis de usar na prática, corrotinas clássicas são os alicerces das corrotinas nativas, e a expressão yield from é uma precursora direta de await.

Dicas de tipo para os tipos Iterable, Iterator, e Generator também foram abordadas—com esse último oferecendo um raro exemplo concreto de um parâmetro de tipo contra-variante.

17.15. Leitura complementar

Uma explicação técnica detalhada sobre geradoras aparece na A Referência da Linguagem Python, em "6.2.9. Expressões yield". A PEP onde as funções geradoras foram definidas é a PEP 255—​Simple Generators (Geradoras Simples).

A documentação do módulo itertools é excelente, especialmente por todos os exemplos incluídos. Apesar das funções daquele módulo serem implementadas em C, a documentação mostra como algumas delas poderiam ser escritas em Python, frequentemente se valendo de outras funções no módulo. Os exemplos de utilização também são ótimos; por exemplo, há um trecho mostrando como usar a função accumulate para amortizar um empréstimo com juros, dada uma lista de pagamentos ao longo do tempo. Há também a seção "Receitas com itertools", com funções adicionais de alto desempenho, usando as funções de itertools como base.

Além da bilbioteca padrão do Python, recomendo o pacote More Itertools, que continua a bela tradição do itertools, oferecendo geradoras poderosas, acompanhadas de muitos exemplos e várias receitas úteis.

"Iterators and Generators" (Iteradores e Geradoras), o capítulo 4 de Python Cookbook, 3ª ed., de David Beazley e Brian K. Jones (O’Reilly), traz 16 receitas sobre o assunto, de muitos ângulos diferentes, concentradas em aplicações práticas. O capítulo contém algumas receitas esclarecedoras com yield from.

Sebastian Rittau—atualmente um dos principais colaboradores do typeshed—explica porque iteradores devem ser iteráveis. Ele observou, em 2006, que "Java: Iterators are not Iterable" (Java:Iteradores não são Iteráveis).

A sintaxe de yield from é explicada, com exemplos, na seção "What’s New in Python 3.3" (Novidades no Python 3.3) da PEP 380—​Syntax for Delegating to a Subgenerator (Sintaxe para Delegar para um Subgerador). Meu artigo "Classic Coroutines" (Corrotinas Clássicas) (EN) no fluentpython.com explica yield from em profundidade, incluindo pseudo-código em Python de sua implementação (em C).

David Beazley é a autoridade final sobre geradoras e corrotinas no Python. O Python Cookbook, 3ª ed., (O’Reilly), que ele escreveu com Brian Jones, traz inúmeras receitas com corrotinas. Os tutoriais de Beazley sobre esse tópico nas PyCon são famosos por sua profundidade e abrangência. O primeiro foi na PyCon US 2008: "Generator Tricks for Systems Programmers" (Truques com Geradoras para Programadores de Sistemas) (EN). A PyCon US 2009 assisitiu ao lendário "A Curious Course on Coroutines and Concurrency" (Um Curioso Curso sobre Corrotinas e Concorrência) (EN) (links de vídeo difíceis de encontrar para todas as três partes: parte 1, parte 2, e parte 3). Seu tutorial na PyCon 2014 em Montreal foi "Generators: The Final Frontier" (Geradoras: A Fronteira Final), onde ele apresenta mais exemplos de concorrência—então é, na verdade, mais relacionado aos tópicos do Capítulo 21. Dave não consegue deixar de explodir cérebros em suas aulas, então, na última parte de "A Fronteira Final", corrotinas substituem o padrão clássico Visitor em um analisador de expressões aritméticas.

Corrotinas permitem organizar o código de novas maneiras e, assim como a recursão e o polimorfismo (despacho dinâmico), demora um certo tempo para se acostumar com suas possibilidades. Um exemplo interessante de um algoritmo clássico reescrito com corrotinas aparece no post "Greedy algorithm with coroutines" (O Algoritmo guloso com corrotinas), de James Powell.

O Effective Python, 1ª ed. (Addison-Wesley), de Brett Slatkin, tem um excelente capítulo curto chamado "Consider Coroutines to Run Many Functions Concurrently" (Considere as Corrotinas para Executar Muitas Funções de Forma Concorrente). Esse capítulo não aparece na segunda edição de Effective Python, mas ainda está disponível online como um capítulo de amostra (EN). Slatkin apresenta o melhor exemplo que já vi do controle de corrotinas com yield from: uma implementaçào do Jogo da Vida, de John Conway, no qual corrotinas gerenciam o estado de cada célula conforme o jogo avança. Refatorei o código do exemplo do Jogo da Vida—separando funções e classes que implementam o jogo dos trechos de teste no código original de Slatkin. Também reescrevi os testes como doctests, então você pode ver o resultados de várias corrotinas e classes sem executar o script. The exemplo refatorado está publicado como um GitHub gist.

Ponto de Vista

A interface Iterador minimalista do Python

Na seção "Implementação" do padrão Iterator,[239], a Guange dos Quatro escreveu:

A interface mínima de Iterator consiste das operações First, Next, IsDone, e CurrentItem.

Entretanto, essa mesma frase tem uma nota de rodapé, onde se lê:

Podemos tornar essa interface ainda menor, fundindo Next, IsDone, e CurrentItem em uma única operação que avança para o próximo objeto e o devolve. Se a travessia estiver encerrada, essa operação daí devolve um valor especial (0, por exemplo), que marca o final da iteração.

Isso é próximo do que temos em Python: um único método __next__, faz o serviço. Mas em vez de uma sentinela, que poderia passar desapercebida por enganoo ou distração, a exceção StopIteration sinaliza o final da iteração. Simples e correto: esse é o jeito do Python.

Geradoras conectáveis

Qualquer um que gerencie grandes conjuntos de dados encontra muitos usos para geradoras. Essa é a história da primeira vez que criei uma solução prática baseada em geradoras.

Muitos anos atrás, eu trabalhava na BIREME, uma biblioteca digital operada pela OPAS/OMS (Organização Pan-Americana da Saúde/Organização Mundial da Saúde) em São Paulo, Brasil. Entre os conjuntos de dados bibliográficos criados pela BIREME estão o LILACS (Literatura Latino-Americana e do Caribe em Ciências da Saúde) and SciELO (Scientific Electronic Library Online), dois bancos de dados abrangentes, indexando a literatura de pesquisa em ciências da saúde produzida na região.

Desde o final dos anos 1980, o sistema de banco de dados usado para gerenciar o LILACS é o CDS/ISIS, um banco de dados não-relacional de documentos, criado pela UNESCO. Uma de minhas tarefas era pesquisar alternativas para uma possível migração do LILACS—​e depois do SciELO, muito maior—​para um banco de dados de documentos moderno e de código aberto, tal como o CouchDB ou o MongoDB. Naquela época escrevi um artigo explicando o modelo de dados semi-estruturado e as diferentes formas de representar dados CDS/ISIS com registros do tipo JSON: "From ISIS to CouchDB: Databases and Data Models for Bibliographic Records" (Do ISIS ao CouchDBL Bancos de Dados e Modelos de Dados para Registros Bibliográficos) (EN).

Como parte daquela pesquisa, escrevi um script Python para ler um arquivo CDS/ISIS e escrever um arquivo JSON adequado para importação pelo CouchDB ou pelo MongoDB. Inicialmente, o arquivo lia arquivos no formato ISO-2709, exportados pelo CDS/ISIS. A leitura e a escrita tinham de ser feitas de forma incremental, pois os conjuntos de dados completos eram muito maiores que a memória principal. Isso era bastante fácil: cada iteração do loop for principal lia um registro do arquivo .iso, o manipulava e escrevia no arquivo de saída .json.

Entretanto, por razões operacionais, foi considerado necessário que o isis2json.py suportasse outro formato de dados do CDS/ISIS: os arquivos binários .mst, usados em produção na BIREME—​para evitar uma exportação dispendiosa para ISO-2709. Agora eu tinha um problema: as bibliotecas usadas para ler arquivos ISO-2709 e .mst tinham APIs muito diferentes. E o loop de escrita JSON já era complicado, pois o script aceitava, na linha de comando, muitas opções para reestruturar cada registro de saída. Ler dados usando duas APIs diferentes no mesmo loop for onde o JSON era produzido seria muito difícil de manejar.

A solução foi isolar a lógica de leitura em um par de funções geradoras: uma para cada formato de entrada suportado. No fim, dividi o script isis2json.py em quatro funções. Você pode ver o código-fonte em Python 2, com suas dependências, no repositório fluentpython/isis2json no GitHub.[240]

Aqui está uma visão geral em alto nível de como o script está estruturado:

main

A função main usa argparse para ler opções de linha de comando que configuram a estrutura dos registros de saída. Baseado na extensão do nome do arquivo de entrada, uma função geradora é selecionada para ler os dados e produzir os registros, um por vez.

iter_iso_records

Essa função geradora lê arquivos .iso (que se presume estarem no formato ISO-2709). Ela aceita dois argumento: o nome do arquivo e isis_json_type, uma das opções relacionadas à estrutura do registro. Cada iteração de seu loop for lê um registro, cria um dict vazio, o preenche com dados dos campos, e produz o dict.

iter_mst_records

Essa outra função geradora lê arquivos .mst.[241] Se você examinar o código-fonte de isis2json.py, vai notar que ela não é tão simples quanto iter_iso_records, mas sua interface e estrutura geral é a mesma: a função recebe como argumentos um nome de arquivo e um isis_json_type, e entra em um loop for, que cria e produz por iteração um dict, representando um único registro.

write_json

Essa função executa a escrita efetiva de registros JSON, um por vez. Ela recebe numerosos argumentos, mas o primeiro—input_gen—é uma referência para uma função geradora: iter_iso_records ou iter_mst_records. O loop for principal itera sobre os dicionários produzidos pela geradora input_gen selecionada, os reestrutura de diferentes formas, determinadas pelas opções de linha de comando, e anexa o registro JSON ao arquivo de saída.

Me aproveitando das funções geradoras, pude dissociar a leitura da escrita. Claro, a maneira mais simples de dissociar as duas operações seria ler todos os registros para a memória e então escrevê-los no disco. Mas essa não era uma opção viável, pelo tamanho dos conjuntos de dados. Usando geradoras, a leitura e a escrita são intercaladas, então o script pode processar arquivos de qualquer tamanho. Além disso, a lógica especial para ler um registro em formatos de entrada diferentes está isolada da lógica de reestruturação de cada registro para escrita.

Agora, se precisarmos que isis2json.py suporte um formato de entrada adicional—digamos, MARCXML, uma DTD[242] usada pela Biblioteca do Congresso norte-americano para representar dados ISO-2709—será fácil acrescentar uma terceira função geradora para implementar a lógica de leitura, sem mudar nada na complexa função write_json.

Não é ciência de foguete, mas é um exemplo real onde as geradoras permitiram um solução eficiente e flexível para processar bancos de dados como um fluxo de registros, mantendo o uso de memória baixo e independente do tamanho do conjunto de dados.

18. Instruções with, match, e blocos else

Gerenciadores de contexto podem vir a ser quase tão importantes quanto a própria sub-rotina. Só arranhamos a superfície das possibilidades. […​] Basic tem uma instrução with, há instruções with em várias linguagens. Mas elas não fazem a mesma coisa, todas fazem algo muito raso, economizam consultas a atributos com o operador ponto (.), elas não configuram e desfazem ambientes. Não pense que é a mesma coisa só porque o nome é igual. A instrução with é muito mais que isso.[243] (EN)

— Raymond Hettinger
um eloquente evangelista de Python

Este capítulo é sobre mecanismos de controle de fluxo não muito comuns em outras linguagens e que, por essa razão, podem ser ignorados ou subutilizados em Python. São eles:

  • A instrução with e o protocolo de gerenciamento de contexto

  • A instrução match/case para pattern matching (casamento de padrões)

  • A cláusula else nas instruções for, while, e try

A instrução with cria um contexto temporário e o destrói com segurança, sob o controle de um objeto gerenciador de contexto. Isso previne erros e reduz código repetitivo, tornando as APIs ao mesmo tempo mais seguras e mais fáceis de usar. Programadores Python estão encontrando muitos usos para blocos with além do fechamento automático de arquivos.

Já estudamos pattern matching em capítulos anteriores, mas aqui veremos como a gramática de uma linguagem de programação pode ser expressa como padrões de sequências. Por isso match/case é uma ferramenta eficiente para criar processadores de linguagem fáceis de entender e de estender. Vamos examinar um interpretador completo para um pequeno (porém funcional) subconjunto da linguagem Scheme. As mesmas ideias poderiam ser aplicadas no desenvolvimento de uma linguagem de templates ou uma DSL (Domain-Specific Language, literalmente Linguagem de Domínio Específico) para codificar regras de negócio em um sistema maior.

A cláusula else não é grande coisa, mas ajuda a transmitir a intenção por trás do código quando usada corretamente junto com for, while e try.

18.1. Novidades nesse capítulo

A Seção 18.3 é nova.

Também atualizei a Seção 18.2.1 para incluir alguns recursos do módulo contextlib adicionados desde o Python 3.6, e os novos gerenciadores de contexto "parentizados", introduzidos no Python 3.10.

Vamos começar com a poderosa instrução with.

18.2. Gerenciadores de contexto e a instrução with

Objetos gerenciadores de contexto existem para controlar uma instrução with, da mesma forma que iteradores existem para controlar uma instrução for.

A instrução with foi projetada para simplificar alguns usos comuns de try/finally, que garantem que alguma operação seja realizada após um bloco de código, mesmo que o bloco termine com um return, uma exceção, ou uma chamada sys.exit(). O código no bloco finally normalmente libera um recurso crítico ou restaura um estado anterior que havia sido temporariamente modificado.

A comunidade Python está encontrando novos usos criativos para gerenciadores de contexto. Alguns exemplos, da biblioteca padrão, são:

A interface gerenciador de contexto consiste dos métodos __enter__ and __exit__. No topo do with, o Python chama o método __enter__ do objeto gerenciador de contexto. Quando o bloco with encerra ou termina por qualquer razão, o Python chama __exit__ no objeto gerenciador de contexto.

O exemplo mais comum é se assegurar que um objeto arquivo seja fechado. O Exemplo 1 é uma demonstração detalhada do uso do with para fechar um arquivo.

Exemplo 1. Demonstração do uso de um objeto arquivo como gerenciador de contexto
>>> with open('mirror.py') as fp:  # (1)
...     src = fp.read(60)  # (2)
...
>>> len(src)
60
>>> fp  # (3)
<_io.TextIOWrapper name='mirror.py' mode='r' encoding='UTF-8'>
>>> fp.closed, fp.encoding  # (4)
(True, 'UTF-8')
>>> fp.read(60)  # (5)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
ValueError: I/O operation on closed file.
  1. fp está vinculado ao arquivo de texto aberto, pois o método __enter__ do arquivo devolve self.

  2. 60 caracteres Unicode de fp.

  3. A variável fp ainda está disponível—blocos with não definem um novo escopo, como fazem as funções.

  4. Podemos ler os atributos do objeto fp.

  5. Mas não podemos ler mais texto de fp pois, no final do bloco with, o método TextIOWrapper.__exit__ foi chamado, e isso fechou o arquivo.

A primeira explicação no Exemplo 1 transmite uma informação sutil porém crucial: o objeto gerenciador de contexto é o resultado da avaliação da expressão após o with, mas o valor vinculado à variável alvo (na cláusula as) é o resultado devolvido pelo método __enter__ do objeto gerenciador de contexto.

E acontece que a função open() devolve uma instância de TextIOWrapper, e o método __enter__ dessa classe devolve self. Mas em uma classe diferente, o método __enter__ também pode devolver algum outro objeto em vez do gerenciador de contexto.

Quando o fluxo de controle sai do bloco with de qualquer forma, o método __exit__ é invocado no objeto gerenciador de contexto, e não no que quer que __enter__ tenha devolvido.

A cláusula as da instrução with é opcional. No caso de open, sempre precisamos obter uma referência para o arquivo, para podermos chamar seus métodos. Mas alguns gerenciadores de contexto devolvem None, pois não tem nenhum objeto útil para entregar ao usuário.

O Exemplo 2 mostra o funcionamento de um gerenciador de contexto perfeitamente frívolo, projetado para ressaltar a diferença entre o gerenciador de contexto e o objeto devolvido por seu método __enter__.

Exemplo 2. Testando a classe gerenciadora de contexto LookingGlass
    >>> from mirror import LookingGlass
    >>> with LookingGlass() as what:  # (1)
    ...      print('Alice, Kitty and Snowdrop')  # (2)
    ...      print(what)
    ...
    pordwonS dna yttiK ,ecilA
    YKCOWREBBAJ
    >>> what  # (3)
    'JABBERWOCKY'
    >>> print('Back to normal.')  # (4)
    Back to normal.
  1. O gerenciador de contexto é uma instância de LookingGlass; o Python chama __enter__ no gerenciador de contexto e o resultado é vinculado a what.

  2. Exibe uma str, depois o valor da variável alvo what. A saída de cada print será invertida.

  3. Agora o bloco with terminou. Podemos ver que o valor devolvido por __enter__, armazenado em what, é a string 'JABBERWOCKY'.

  4. A saída do programa não está mais invertida.

O Exemplo 3 mostra a implementação de LookingGlass.

Exemplo 3. mirror.py: código da classe gerenciadora de contexto LookingGlass
import sys

class LookingGlass:

    def __enter__(self):  # (1)
        self.original_write = sys.stdout.write  # (2)
        sys.stdout.write = self.reverse_write  # (3)
        return 'JABBERWOCKY'  # (4)

    def reverse_write(self, text):  # (5)
        self.original_write(text[::-1])

    def __exit__(self, exc_type, exc_value, traceback):  # (6)
        sys.stdout.write = self.original_write  # (7)
        if exc_type is ZeroDivisionError:  # (8)
            print('Please DO NOT divide by zero!')
            return True  # (9)
        # (10)
  1. O Python invoca __enter__ sem argumentos além de self.

  2. Armazena o método sys.stdout.write original, para podermos restaurá-lo mais tarde.

  3. Faz um monkey-patch em sys.stdout.write, substituindo-o com nosso próprio método.

  4. Devolve a string 'JABBERWOCKY', apenas para termos algo para colocar na variável alvo what.

  5. Nosso substituto de sys.stdout.write inverte o argumento text e chama a implementação original.

  6. Se tudo correu bem, o Python chama __exit__ com None, None, None; se ocorreu uma exceção, os três argumentos recebem dados da exceção, como descrito a seguir, logo após esse exemplo.

  7. Restaura o método original em sys.stdout.write.

  8. Se a exceção não é None e seu tipo é ZeroDivisionError, exibe uma mensagem…​

  9. …​e devolve True, para informar o interpretador que a exceção foi tratada.

  10. Se __exit__ devolve None ou qualquer valor falso, qualquer exceção levantada dentro do bloco with será propagada.

👉 Dica

Quando aplicações reais tomam o controle da saída padrão, elas frequentemente desejam substituir sys.stdout com outro objeto similar a um arquivo por algum tempo, depois voltar ao original. O gerenciador de contexto contextlib.redirect_stdout faz exatamente isso: passe a ele seu objeto similar a um arquivo que substituirá sys.stdout.

O interpretador chama o método __enter__ sem qualquer argumento—além do self implícito. Os três argumentos passados a __exit__ são:

exc_type

A classe da exceção (por exemplo, ZeroDivisionError).

exc_value

A instância da exceção. Algumas vezes, parâmetros passados para o construtor da exceção—tal como a mensagem de erro—podem ser encontrados em exc_value.args.

traceback

Um objeto traceback.[244]

Para uma visão detalhada de como funciona um gerenciador de contexto, vejamos o Exemplo 4, onde LookingGlass é usado fora de um bloco with, de forma que podemos chamar manualmente seus métodos __enter__ e __exit__.

Exemplo 4. Exercitando o LookingGlass sem um bloco with
    >>> from mirror import LookingGlass
    >>> manager = LookingGlass()  # (1)
    >>> manager  # doctest: +ELLIPSIS
    <mirror.LookingGlass object at 0x...>
    >>> monster = manager.__enter__()  # (2)
    >>> monster == 'JABBERWOCKY'  # (3)
    eurT
    >>> monster
    'YKCOWREBBAJ'
    >>> manager  # doctest: +ELLIPSIS
    >... ta tcejbo ssalGgnikooL.rorrim<
    >>> manager.__exit__(None, None, None)  # (4)
    >>> monster
    'JABBERWOCKY'
  1. Instancia e inspeciona a instância de manager.

  2. Chama o método __enter__ do manager e guarda o resultado em monster.

  3. monster é a string 'JABBERWOCKY'. O identificador True aparece invertido, porque toda a saída via stdout passa pelo método write, que modificamos em __enter__.

  4. Chama manager.__exit__ para restaurar o stdout.write original.

👉 Dica
Gerenciadores de contexto entre parênteses

O Python 3.10 adotou um novo parser (analisador sintático), mais poderoso que o antigo parser LL(1). Isso permitiu introduzir novas sintaxes que não eram viáveis anteriormente. Uma melhoria na sintaxe foi permitir gerenciadores de contexto agrupados entre parênteses, assim:

with (
    CtxManager1() as example1,
    CtxManager2() as example2,
    CtxManager3() as example3,
):
    ...

Antes do 3.10, as linhas acima teriam que ser escritas como blocos with aninhados.

A biblioteca padrão inclui o pacote contextlib, com funções, classe e decoradores muito convenientes para desenvolver, combinar e usar gerenciadores de contexto.

18.2.1. Utilitários do contextlib

Antes de desenvolver suas próprias classes gerenciadoras de contexto, dê uma olhada em contextlib—"Utilities for with-statement contexts" ("Utilitários para contextos da instrução with), na documentação do Python. Pode ser que você esteja prestes a escrever algo que já existe, ou talvez exista uma classe ou algum invocável que tornará seu trabalho mais fácil.

Além do gerenciador de contexto redirect_stdout mencionado logo após o Exemplo 3, o redirect_stderr foi acrescentado no Python 3.5—ele faz o mesmo que seu par mais antigo, mas com as saídas direcionadas para stderr.

O pacote contextlib também inclui:

closing

Uma função para criar gerenciadores de contexto a partir de objetos que forneçam um método close() mas não implementam a interface __enter__/__exit__.

suppress

Um gerenciador de contexto para ignorar temporariamente exceções passadas como parâmetros.

nullcontext

Um gerenciador de contexto que não faz nada, para simplificar a lógica condicional em torno de objetos que podem não implementar um gerenciador de contexto adequado. Ele serve como um substituto quando o código condicional antes do bloco with pode ou não fornecer um gerenciador de contexto para a instrução with. Adicionado no Python 3.7.

O módulo contextlib fornece classes e um decorador que são mais largamente aplicáveis que os decoradores mencionados acima:

@contextmanager

Um decorador que permite construir um gerenciador de contexto a partir de um simples função geradora, em vez de criar uma classe e implementar a interface. Veja a Seção 18.2.2.

AbstractContextManager

Uma ABC que formaliza a interface gerenciador de contexto, e torna um pouco mais fácil criar classes gerenciadoras de contexto, através de subclasses—adicionada no Python 3.6.

ContextDecorator

Uma classe base para definir gerenciadores de contexto baseados em classes que podem também ser usadas como decoradores de função, rodando a função inteira dentro de um contexto gerenciado.

ExitStack

Um gerenciador de contexto que permite entrar em um número variável de gerenciadores de contexto. Quando o bloco with termina, ExitStack chama os métodos __exit__ dos gerenciadores de contexto empilhados na ordem LIFO (Last In, First Out, Último a Entrar, Primeiro a Sair). Use essa classe quando você não sabe de antemão em quantos gerenciadores de contexto será necessário entrar no bloco with; por exemplo, ao abrir ao mesmo tempo todos os arquivos de uma lista arbitrária de arquivos.

Com o Python 3.7, contextlib acrescentou AbstractAsyncContextManager, @asynccontextmanager, e AsyncExitStack. Eles são similares aos utilitários equivalentes sem a parte async no nome, mas projetados para uso com a nova instrução async with, tratado no Capítulo 21.

Desses todos, o utilitário mais amplamente usado é o decorador @contextmanager, então ele merece mais atenção. Esse decorador também é interessante por mostrar um uso não relacionado a iteração para a instrução yield.

18.2.2. Usando o @contextmanager

O decorador @contextmanager é uma ferramenta elegante e prática, que une três recursos distintos do Python: um decorador de função, um gerador, e a instrução with.

Usar o @contextmanager reduz o código repetitivo na criação de um gerenciador de contexto: em vez de escrever toda uma classe com métodos __enter__/__exit__, você só precisa implementar um gerador com uma única instrução yield, que deve produzir o que o método __enter__ deveria devolver.

Em um gerador decorado com @contextmanager, o yield divide o corpo da função em duas partes: tudo que vem antes do yield será executado no início do bloco with, quando o interpretador chama __enter__; o código após o yield será executado quando __exit__ é chamado, no final do bloco.

O Exemplo 5 substitui a classe LookingGlass do Exemplo 3 por uma função geradora.

Exemplo 5. mirror_gen.py: um gerenciador de contexto implementado com um gerador
import contextlib
import sys

@contextlib.contextmanager  # (1)
def looking_glass():
    original_write = sys.stdout.write  # (2)

    def reverse_write(text):  # (3)
        original_write(text[::-1])

    sys.stdout.write = reverse_write  # (4)
    yield 'JABBERWOCKY'  # (5)
    sys.stdout.write = original_write  # (6)
  1. Aplica o decorador contextmanager.

  2. Preserva o método sys.stdout.write original.

  3. reverse_write pode chamar original_write mais tarde, pois ele está disponível em sua clausura (closure).

  4. Substitui sys.stdout.write por reverse_write.

  5. Produz o valor que será vinculado à variável alvo na cláusula as da instrução with. O gerador se detem nesse ponto, enquanto o corpo do with é executado.

  6. Quando o fluxo de controle sai do bloco with, a execução continua após o yield; neste ponto o sys.stdout.write original é restaurado.

O Exemplo 6 mostra a função looking_glass em operação.

Exemplo 6. Testando a função gerenciadora de contexto looking_glass
    >>> from mirror_gen import looking_glass
    >>> with looking_glass() as what:  # (1)
    ...      print('Alice, Kitty and Snowdrop')
    ...      print(what)
    ...
    pordwonS dna yttiK ,ecilA
    YKCOWREBBAJ
    >>> what
    'JABBERWOCKY'
    >>> print('back to normal')
    back to normal
  1. A única diferença do Exemplo 2 é o nome do gerenciador de contexto:`looking_glass` em vez de LookingGlass.

O decorador contextlib.contextmanager envolve a função em uma classe que implementa os métodos __enter__ e __exit__.[245]

O método __enter__ daquela classe:

  1. Chama a função geradora para obter um objeto gerador—vamos chamá-lo de gen.

  2. Chama next(gen) para acionar com ele a palavra reservada yield.

  3. Devolve o valor produzido por next(gen), para permitir que o usuário o vincule a uma variável usando o formato with/as.

Quando o bloco with termina, o método __exit__:

  1. Verifica se uma exceção foi passada como exc_type; em caso afirmativo, gen.throw(exception) é invocado, fazendo com que a exceção seja levantada na linha yield, dentro do corpo da função geradora.

  2. Caso contrário, next(gen) é chamado, retomando a execução do corpo da função geradora após o yield.

O Exemplo 5 tem um defeito: Se uma exceção for levantada no corpo do bloco with, o interpretador Python vai capturá-la e levantá-la novamente na expressão yield dentro de looking_glass. Mas não há tratamento de erro ali, então o gerador looking_glass vai terminar sem nunca restaurar o método sys.stdout.write original, deixando o sistema em um estado inconsistente.

O Exemplo 7 acrescenta o tratamento especial da exceção ZeroDivisionError, tornando esse gerenciador de contexto funcionalmente equivalente ao Exemplo 3, baseado em uma classe.

Exemplo 7. mirror_gen_exc.py: gerenciador de contexto baseado em um gerador implementando tratamento de erro—com o mesmo comportamento externo de Exemplo 3
import contextlib
import sys

@contextlib.contextmanager
def looking_glass():
    original_write = sys.stdout.write

    def reverse_write(text):
        original_write(text[::-1])

    sys.stdout.write = reverse_write
    msg = ''  # (1)
    try:
        yield 'JABBERWOCKY'
    except ZeroDivisionError:  # (2)
        msg = 'Please DO NOT divide by zero!'
    finally:
        sys.stdout.write = original_write  # (3)
        if msg:
            print(msg)  # (4)
  1. Cria uma variável para uma possível mensagem de erro; essa é a primeira mudança em relação a Exemplo 5.

  2. Trata ZeroDivisionError, fixando uma mensagem de erro.

  3. Desfaz o monkey-patching de sys.stdout.write.

  4. Mostra a mensagem de erro, se ela foi determinada.

Lembre-se que o método __exit__ diz ao interpretador que ele tratou a exceção ao devolver um valor verdadeiro; nesse caso, o interpretador suprime a exceção.

Por outro lado, se __exit__ não devolver explicitamente um valor, o interpretador recebe o habitual None, e propaga a exceção. Com o @contextmanager, o comportamento default é invertido: o método __exit__ fornecido pelo decorador assume que qualquer exceção enviada para o gerador está tratada e deve ser suprimida.

👉 Dica

Ter um try/finally (ou um bloco with) em torno do yield é o preço inescapável do uso de @contextmanager, porque você nunca sabe o que os usuários do seu gerenciador de contexto vão fazer dentro do bloco with.[246]

Um recurso pouco conhecido do @contextmanager é que os geradores decorados com ele podem ser usados eles mesmos como decoradores.[247] Isso ocorre porque @contextmanager é implementado com a classe contextlib.ContextDecorator.

O Exemplo 8 mostra o gerenciador de contexto looking_glass do Exemplo 5 sendo usado como um decorador.

Exemplo 8. O gerenciador de contexto looking_glass também funciona como um decorador.
    >>> @looking_glass()
    ... def verse():
    ...     print('The time has come')
    ...
    >>> verse()  # (1)
    emoc sah emit ehT
    >>> print('back to normal')  # (2)
    back to normal
  1. looking_glass faz seu trabalho antes e depois do corpo de verse rodar.

  2. Isso confirma que o sys.write original foi restaurado.

Compare o Exemplo 8 com o Exemplo 6, onde looking_glass é usado como um gerenciador de contexto.

Um interessante exemplo real do uso do @contextmanager fora da biblioteca padrão é a reescrita de arquivo no mesmo lugar usando um gerenciador de contexto de Martijn Pieters. O Exemplo 9 mostra como ele é usado.

Exemplo 9. Um gerenciador de contexto para reescrever arquivos no lugar
import csv

with inplace(csvfilename, 'r', newline='') as (infh, outfh):
    reader = csv.reader(infh)
    writer = csv.writer(outfh)

    for row in reader:
        row += ['new', 'columns']
        writer.writerow(row)

A função inplace é um gerenciador de contexto que fornece a você dois identificadores—no exemplo, infh e outfh—para o mesmo arquivo, permitindo que seu código leia e escreva ali ao mesmo tempo. Isso é mais fácil de usar que a função fileinput.input (EN) da biblioteca padrão (que, por sinal, também fornece um gerenciador de contexto).

Se você quiser estudar o código-fonte do inplace de Martijn (listado no post) (EN), encontre a palavra reservada yield: tudo antes dela lida com configurar o contexto, que implica criar um arquivo de backup, então abrir e produzir referências para os identificadores de arquivo de leitura e escrita que serão devolvidos pela chamada a __enter__. O processamento do __exit__ após o yield fecha os identificadores do arquivo e, se algo deu errado, restaura o arquivo do backup.

Isso conclui nossa revisão da instrução with e dos gerenciadores de contexto. Vamos agora olhar o match/case, no contexto de um exemplo completo.

18.3. Pattern matching no lis.py: um estudo de caso

Na Seção 2.6.1, vimos exemplos de sequências de padrões extraídos da funcão evaluate do interpretador lis.py de Peter Norvig, portado para o Python 3.10. Nessa seção quero dar um visão geral do funcionamento do lis.py, e também explorar todas as cláusulas case de evaluate, explicando não apenas os padrões mas também o que o interpretador faz em cada case.

Além de mostrar mais pattern matching, escrevi essa seção por três razões:

  1. O lis.py de Norvig é um lindo exemplo de código Python idiomático.

  2. A simplicidade do Scheme é uma aula magna de design de linguagens.

  3. Aprender como um interpretador funciona me deu um entendimento mais profundo sobre o Python e sobre linguagens de programação em geral—interpretadas ou compiladas.

Antes de olhar o código Python, vamos ver um pouquinho de Scheme, para você poder entender este estudo de caso—pensando em quem nunca viu Scheme e Lisp antes.

18.3.1. A sintaxe do Scheme

No Scheme não há distinção entre expressões e instruções, como temos em Python. Também não existem operadores infixos. Todas as expressões usam a notação prefixa, como (+ x 13) em vez de x + 13. A mesma notação prefixa é usada para chamadas de função—por exemplo, (gcd x 13)—e formas especiais—por exemplo, (define x 13), que em Python escreveríamos como uma declaração de atribuição x = 13.

A notação usada no Scheme e na maioria dos dialetos de Lisp é conhecida como S-expression (Expressão-S).[248]

O Exemplo 10 mostra um exemplo simples em Scheme.

Exemplo 10. Maior divisor comum em Scheme
(define (mod m n)
    (- m (* n (quotient m n))))

(define (gcd m n)
    (if (= n 0)
        m
        (gcd n (mod m n))))

(display (gcd 18 45))

O Exemplo 10 mostra três expressões em Scheme: duas definições de função—mod e gcd—e uma chamada a display, que vai devolver 9, o resultado de (gcd 18 45). O Exemplo 11 é o mesmo código em Python (menor que a explicação em português do algoritmo recursivo de Euclides).

Exemplo 11. Igual ao Exemplo 10, mas escrito em Python
def mod(m, n):
    return m - (m // n * n)

def gcd(m, n):
    if n == 0:
        return m
    else:
        return gcd(n, mod(m, n))

print(gcd(18, 45))

Em Python idiomático, eu usaria o operador % em vez de reinventar mod, e seria mais eficiente usar um loop while em vez de recursão. Mas queria mostrar duas definições de função, e fazer os exemplos o mais similares possível, para ajudar você a ler o código Scheme.

O Scheme não tem instruções iterativas de controle de fluxo como while ou for. A iteração é feita com recursão. Observe que não há atribuições nos exemplos em Python e Scheme. O uso extensivo de recursão e o uso mínimo de atribuição são marcas registradas do estilo funcional de programação.[249]

Agora vamos revisar o código da versão Python 3.10 do lis.py. O código fonte completo, com testes, está no diretório 18-with-match/lispy/py3.10/, do repositório fluentpython/example-code-2e no Github.

18.3.2. Importações e tipos

O Exemplo 12 mostra as primeiras linhas do lis.py. O uso do TypeAlias e do operador de união de tipos | exige o Python 3.10.

Exemplo 12. lis.py: início do arquivo
import math
import operator as op
from collections import ChainMap
from itertools import chain
from typing import Any, TypeAlias, NoReturn

Symbol: TypeAlias = str
Atom: TypeAlias = float | int | Symbol
Expression: TypeAlias = Atom | list

Os tipos definidos são:

Symbol

Só um alias para str. Em lis.py, Symbol é usado para identificadores; não há um tipo de dados string, com operações como fatiamento (slicing), divisão (splitting), etc.[250]

Atom

Um elemento sintático simples, tal como um número ou um Symbol—ao contrário de uma estrutura complexa, composta por vários elementos distintos, como uma lista.

Expression

Os componentes básicos de programas Scheme são expressões feitas de átomos e listas, possivelmente aninhados.

18.3.3. O parser

O parser (analisador sintático) de Norvig tem 36 linhas de código que exibem o poder do Python aplicado ao tratamento da sintaxe recursiva simples das expressões-S—sem strings, comentários, macros e outros recursos que tornam a análise sintática do Scheme padrão mais complicada (Exemplo 13).

Exemplo 13. lis.py: as principais funcões do analisador
def parse(program: str) -> Expression:
    "Read a Scheme expression from a string."
    return read_from_tokens(tokenize(program))

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

def read_from_tokens(tokens: list[str]) -> Expression:
    "Read an expression from a sequence of tokens."
    # mais código do analisador omitido na listagem do livro

A principal função desse grupo é parse, que recebe uma expressão-S em forma de str e devolve um objeto Expression, como definido no Exemplo 12: um Atom ou uma list que pode conter mais átomos e listas aninhadas.

Norvig usa um truque elegante em tokenize: ele acrescenta espaços antes e depois de cada parênteses na entrada, e então a recorta, resultando em uma lista de símbolos sintáticos (tokens) com '(' e ')' como símbolos separados Esse atalho funciona porque não há um tipo string no pequeno Scheme de lis.py, então todo '(' ou ')' é um delimitador de expressão. O código recursivo do analisador está em read_from_tokens, uma função de 14 linhas que você pode ler no repositório fluentpython/example-code-2e. Vou pular isso, pois quero me concentrar em outras partes do interpretador.

Aqui estão alguns doctests estraídos do lispy/py3.10/examples_test.py:

>>> from lis import parse
>>> parse('1.5')
1.5
>>> parse('ni!')
'ni!'
>>> parse('(gcd 18 45)')
['gcd', 18, 45]
>>> parse('''
... (define double
...     (lambda (n)
...         (* n 2)))
... ''')
['define', 'double', ['lambda', ['n'], ['*', 'n', 2]]]

As regras de avaliação para esse subconjunto do Scheme são simples:

  1. Um símbolo sintático que se pareça com um número é tratado como um float ou um int.

  2. Todo o resto que não seja um '(' ou um ')' é considerado um Symbol—uma str, a ser usado como um identificador. Isso inclui texto no código-fonte como +, set!, e make-counter, que são identificadores válidos em Scheme, mas não em Python.

  3. Expressões dentro de '(' e ')' são avaliadas recursivamente como listas contendo átomos ou listas aninhadas que podem conter átomos ou mais listas aninhadas.

Usando a terminologia do interpretador Python, a saída de parse é uma AST (Abstract Syntax Tree—Árvore Sintática Abstrata): uma representação conveniente de um programa Scheme como listas aninhadas formando uma estrutura similar a uma árvore, onde a lista mais externa é o tronco, listas internas são os galhos, e os átomos são as folhas (Figura 1).

Código Scheme, im diagram de árvore e objetos Python
Figura 1. Uma expressão lambda de Scheme, representada como código-fonte (sintaxe concreta de expressões-S), como uma árvore, e como uma sequência de objetos Python (sintaxe abstrata).

18.3.4. O ambiente

A classe Environment estende collections.ChainMap, acrescentando o método change, para atualizar um valor dentro de um dos dicts encadeados que as instâncias de ChainMap mantém em uma lista de mapeamentos: o atributo self.maps. O método change é necessário para suportar a forma (set! …) do Scheme, descrita mais tarde; veja o Exemplo 14.

Exemplo 14. lis.py: a classe Environment
class Environment(ChainMap[Symbol, Any]):
    "A ChainMap that allows changing an item in-place."

    def change(self, key: Symbol, value: Any) -> None:
        "Find where key is defined and change the value there."
        for map in self.maps:
            if key in map:
                map[key] = value  # type: ignore[index]
                return
        raise KeyError(key)

Observe que o método change só atualiza chaves existentes.[251] Tentar mudar uma chave não encontrada causa um KeyError.

Esse doctest mostra como Environment funciona:

>>> from lis import Environment
>>> inner_env = {'a': 2}
>>> outer_env = {'a': 0, 'b': 1}
>>> env = Environment(inner_env, outer_env)
>>> env['a']  # (1)
2
>>> env['a'] = 111  # (2)
>>> env['c'] = 222
>>> env
Environment({'a': 111, 'c': 222}, {'a': 0, 'b': 1})
>>> env.change('b', 333)  # (3)
>>> env
Environment({'a': 111, 'c': 222}, {'a': 0, 'b': 333})
  1. Ao ler os valores, Environment funciona como ChainMap: as chaves são procuradas nos mapeamentos aninhados da esquerda para a direita. Por isso o valor de a no outer_env é encoberto pelo valor em inner_env.

  2. Atribuir com [] sobrescreve ou insere novos itens, mas sempre no primeiro mapeamento, inner_env nesse exemplo.

  3. env.change('b', 333) busca a chave b e atribui a ela um novo valor no mesmo lugar, no outer_env

A seguir temos a função standard_env(), que constrói e devolve um Environment carregado com funções pré-definidas, similar ao módulo __builtins__ do Python, que está sempre disponível (Exemplo 15).

Exemplo 15. lis.py: standard_env() constrói e devolve o ambiente global
def standard_env() -> Environment:
    "An environment with some Scheme standard procedures."
    env = Environment()
    env.update(vars(math))   # sin, cos, sqrt, pi, ...
    env.update({
            '+': op.add,
            '-': op.sub,
            '*': op.mul,
            '/': op.truediv,
            # omitted here: more operator definitions
            'abs': abs,
            'append': lambda *args: list(chain(*args)),
            'apply': lambda proc, args: proc(*args),
            'begin': lambda *x: x[-1],
            'car': lambda x: x[0],
            'cdr': lambda x: x[1:],
            # omitted here: more function definitions
            'number?': lambda x: isinstance(x, (int, float)),
            'procedure?': callable,
            'round': round,
            'symbol?': lambda x: isinstance(x, Symbol),
    })
    return env

Resumindo, o mapeamento env é carregado com:

  • Todas as funções do módulo math do Python

  • Operadores selecionados do módulo op do Python

  • Funções simples porém poderosas construídas com o lambda do Python

  • Estruturas e entidades embutidas do Python, ou renomeadas, como callable para procedure?, ou mapeadas diretamente, como round

18.3.5. O REPL

O REPL (read-eval-print-loop, loop-lê-calcula-imprime ) de Norvig é fácil de entender mas não é amigável ao usuário (veja o Exemplo 16). Se nenhum argumento de linha de comando é passado a lis.py, a função repl() é invocada por main()—definida no final do módulo. No prompt de lis.py>, devemos digitar expressões corretas e completas; se esquecemos de fechar um só parênteses, lis.py se encerra.[252]

Exemplo 16. As funções do REPL
def repl(prompt: str = 'lis.py> ') -> NoReturn:
    "A prompt-read-eval-print loop."
    global_env = Environment({}, standard_env())
    while True:
        ast = parse(input(prompt))
        val = evaluate(ast, global_env)
        if val is not None:
            print(lispstr(val))

def lispstr(exp: object) -> str:
    "Convert a Python object back into a Lisp-readable string."
    if isinstance(exp, list):
        return '(' + ' '.join(map(lispstr, exp)) + ')'
    else:
        return str(exp)

Segue uma breve explicação sobre essas duas funções:

repl(prompt: str = 'lis.py> ') → NoReturn

Chama standard_env() para provisionar as funções embutidas para o ambiente global, então entra em um loop infinito, lendo e avaliando cada linha de entrada, calculando-a no ambiente global, e exibindo o resultado—a menos que seja None. O global_env pode ser modificado por evaluate. Por exemplo, quando o usuário define uma nova variável global ou uma função nomeada, ela é armazenada no primeiro mapeamento do ambiente—o dict vazio na chamada ao construtor de Environment na primeira linha de repl.

lispstr(exp: object) → str

A função inversa de parse: dado um objeto Python representando uma expressão, lispstr devolve o código-fonte para ela. Por exemplo, dado ['', 2, 3]`, o resultado é `'( 2 3)'.

18.3.6. O avaliador de expressões

Agora podemos apreciar a beleza do avaliador de expressões de Norvig—tornado um pouco mais bonito com match/case. A função evaluate no Exemplo 17 recebe uma Expression (construída por parse) e um Environment.

O corpo de evaluate é composto por uma única instrução match com uma expressão exp como sujeito. Os padrões de case expressam a sintaxe e a semântica do Scheme com uma clareza impressionante.

Exemplo 17. evaluate recebe uma expressão e calcula seu valor
KEYWORDS = ['quote', 'if', 'lambda', 'define', 'set!']

def evaluate(exp: Expression, env: Environment) -> Any:
    "Evaluate an expression in an environment."
    match exp:
        case int(x) | float(x):
            return x
        case Symbol(var):
            return env[var]
        case ['quote', x]:
            return x
        case ['if', test, consequence, alternative]:
            if evaluate(test, env):
                return evaluate(consequence, env)
            else:
                return evaluate(alternative, env)
        case ['lambda', [*parms], *body] if body:
            return Procedure(parms, body, env)
        case ['define', Symbol(name), value_exp]:
            env[name] = evaluate(value_exp, env)
        case ['define', [Symbol(name), *parms], *body] if body:
            env[name] = Procedure(parms, body, env)
        case ['set!', Symbol(name), value_exp]:
            env.change(name, evaluate(value_exp, env))
        case [func_exp, *args] if func_exp not in KEYWORDS:
            proc = evaluate(func_exp, env)
            values = [evaluate(arg, env) for arg in args]
            return proc(*values)
        case _:
            raise SyntaxError(lispstr(exp))

Vamos estudar cada cláusula case e o que cada uma faz. Em algumas ocasiões eu acrescentei comentários, mostrando uma expressão-S que casaria com padrão quando transformado em uma lista do Python. Os doctests extraídos de examples_test.py demonstram cada case.

avaliando números
    case int(x) | float(x):
        return x
Padrão:

Instância de int ou float.

Ação:

Devolve o próprio valor.

Exemplo:
>>> from lis import parse, evaluate, standard_env
>>> evaluate(parse('1.5'), {})
1.5
avaliando símbolos
    case Symbol(var):
        return env[var]
Padrão:

Instância de Symbol, isto é, uma str usada como identificador.

Ação:

Consulta var em env e devolve seu valor.

Exemplos:
>>> evaluate(parse('+'), standard_env())
<built-in function add>
>>> evaluate(parse('ni!'), standard_env())
Traceback (most recent call last):
    ...
KeyError: 'ni!'
(quote …)

A forma especial quote trata átomos e listas como dados em vez de expressões a serem avaliadas.

    # (quote (99 bottles of beer))
    case ['quote', x]:
        return x
Padrão:

Lista começando com o símbolo 'quote', seguido de uma expressão x.

Ação:

Devolve x sem avaliá-la.

Exemplos:
>>> evaluate(parse('(quote no-such-name)'), standard_env())
'no-such-name'
>>> evaluate(parse('(quote (99 bottles of beer))'), standard_env())
[99, 'bottles', 'of', 'beer']
>>> evaluate(parse('(quote (/ 10 0))'), standard_env())
['/', 10, 0]

Sem quote, cada expressão no teste geraria um erro:

  • no-such-name seria buscado no ambiente, gerando um KeyError

  • (99 bottles of beer) não pode ser avaliado, pois o número 99 não é um Symbol nomeando uma forma especial, um operador ou uma função

  • (/ 10 0) geraria um ZeroDivisionError

Por que linguagens tem palavras reservadas?

Apesar de ser simples, quote não pode ser implementada como uma função em Scheme. Seu poder especial é impedir que o interpretador avalie (f 10) na expressão (quote (f 10)): o resultado é apenas uma lista com um Symbol e um int. Por outro lado, em uma chamada de função como (abs (f 10)), o interpretador primeiro calcula o resultado de (f 10) antes de invocar abs. Por isso quote é uma palavra reservada: ela precisa ser tratada como uma forma especial.

De modo geral, palavras reservadas são necessárias para:

  • Introduzir regras especiais de avaliação, como quote e lambda—que não avaliam nenhuma de suas sub-expressões

  • Mudar o fluxo de controle, como em if e chamadas de função—que também tem regras especiais de avaliação

  • Para gerenciar o ambiente, como em define e set

Por isso também o Python, e linguagens de programação em geral, precisam de palavras reservadas. Pense em def, if, yield, import, del, e o que elas fazem em Python.

(if …)
    # (if (< x 0) 0 x)
    case ['if', test, consequence, alternative]:
        if evaluate(test, env):
            return evaluate(consequence, env)
        else:
            return evaluate(alternative, env)
Padrão:

Lista começando com 'if' seguida de três expressões: test, consequence, e alternative.

Ação:

Avalia test:

  • Se verdadeira, avalia consequence e devolve seu valor.

  • Caso contrário, avalia alternative e devolve seu valor.

Exemplos:
>>> evaluate(parse('(if (= 3 3) 1 0))'), standard_env())
1
>>> evaluate(parse('(if (= 3 4) 1 0))'), standard_env())
0

Os ramos consequence e alternative devem ser expressões simples. Se mais de uma expressão for necessária em um ramo, você pode combiná-las com (begin exp1 exp2…), fornecida como uma função em lis.py—veja o Exemplo 15.

(lambda …)

A forma lambda do Scheme define funções anônimas. Ela não sofre das limitações da lambda do Python: qualquer função que pode ser escrita em Scheme pode ser escrita usando a sintaxe (lambda …).

    # (lambda (a b) (/ (+ a b) 2))
    case ['lambda' [*parms], *body] if body:
        return Procedure(parms, body, env)
Padrão:

Lista começando com 'lambda', seguida de:

  • Lista de zero ou mais nomes de parâmetros

  • Uma ou mais expressões coletadas em body (a expressão guarda assegura que body não é vazio).

Ação:

Cria e devolve uma nova instância de Procedure com os nomes de parâmetros, a lista de expressões como o corpo da função, e o ambiente atual.

Exemplo:
>>> expr = '(lambda (a b) (* (/ a b) 100))'
>>> f = evaluate(parse(expr), standard_env())
>>> f  # doctest: +ELLIPSIS
<lis.Procedure object at 0x...>
>>> f(15, 20)
75.0

A classe Procedure implementa o conceito de uma closure (clausura): um objeto invocável contendo nomes de parâmetros, um corpo de função, e uma referência ao ambiente no qual a Procedure está sendo instanciada. Vamos estudar o código de Procedure daqui a pouco.

(define …)

A palavra reservada define é usada de duas formas sintáticas diferentes. A mais simples é:

    # (define half (/ 1 2))
    case ['define', Symbol(name), value_exp]:
        env[name] = evaluate(value_exp, env)
Padrão:

Lista começando com 'define', seguido de um Symbol e uma expressão.

Ação:

Avalia a expressão e coloca o valor resultante em env, usando name como chave.

Exemplo:
>>> global_env = standard_env()
>>> evaluate(parse('(define answer (* 7 6))'), global_env)
>>> global_env['answer']
42

O doctest para esse case cria um global_env, para podermos verificar que evaluate coloca answer dentro daquele Environment.

Podemos usar primeira forma de define para criar variáveis ou para vincular nomes a funções anônimas, usando (lambda …) como o value_exp.

A segunda forma de define é um atalho para definir funções nomeadas.

    # (define (average a b) (/ (+ a b) 2))
    case ['define', [Symbol(name), *parms], *body] if body:
        env[name] = Procedure(parms, body, env)
Padrão:

Lista começando com 'define', seguida de:

  • Uma lista começando com um Symbol(name), seguida de zero ou mais itens agrupados em uma lista chamada parms.

  • Uma ou mais expressões agrupadas em body (a expressão guarda garante que body não esteja vazio)

Ação:
  • Cria uma nova instância de Procedure com os nomes dos parâmetros, a lista de expressões como o corpo, e o ambiente atual.

  • Insere a Procedure em env, usando name como chave.

O doctest no Exemplo 18 define e coloca no global_env uma função chamada %, que calcula uma porcentagem.

Exemplo 18. Definindo uma função chamada %, que calcula uma porcentagem
>>> global_env = standard_env()
>>> percent = '(define (% a b) (* (/ a b) 100))'
>>> evaluate(parse(percent), global_env)
>>> global_env['%']  # doctest: +ELLIPSIS
<lis.Procedure object at 0x...>
>>> global_env['%'](170, 200)
85.0

Após chamar evaluate, verificamos que % está vinculada a uma Procedure que recebe dois argumentos numéricos e devolve uma porcentagem.

O padrão para o segundo define não obriga os itens em parms a serem todos instâncias de Symbol. Eu teria que verificar isso antes de criar a Procedure, mas não o fiz—para manter o código aqui tão fácil de acompanhar quanto o de Norvig.

(set! …)

A forma set! muda o valor de uma variável previamente definida.[253]

    # (set! n (+ n 1))
    case ['set!', Symbol(name), value_exp]:
        env.change(name, evaluate(value_exp, env))
Padrão:

Lista começando com 'set!', seguida de um Symbol e de uma expressão.

Ação:

Atualiza o valor de name em env com o resultado da avaliação da expressão.

O método Environment.change atravessa os ambientes encadeados de local para global, e atualiza a primeira ocorrência de name com o novo valor. Se não estivéssemos implementando a palavra reservada 'set!', esse interpretador poderia usar apenas o ChainMap do Python para implementar env, sem precisar da nossa classe Environment.

O nonlocal do Python e o set! do Scheme tratam da mesma questão

O uso da forma set! está relacionado ao uso da palavra reservada nonlocal em Python: declarar nonlocal x permite a x = 10 atualizar uma variável x anteriormente definida fora do escopo local. Sem a declaração nonlocal x, x = 10 vai sempre criar uma variável local em Python, como vimos na Seção 9.7.

De forma similar, (set! x 10) atualiza um x anteriormente definido que pode estar fora do ambiente local da função. Por outro lado, a variável x em (define x 10) é sempre uma variável local, criada ou atualizada no ambiente local.

Ambos, nonlocal e (set! …), são necessários para atualizar o estados do programas mantidos em variáveis dentro de uma clausura (closure). O Exemplo 13 demonstrou o uso de nonlocal para implementar uma função que calcula uma média contínua, mantendo itens count e total em uma clausura. Aqui está a mesma ideia, escrita no subconjunto de Scheme de lis.py:

(define (make-averager)
    (define count 0)
    (define total 0)
    (lambda (new-value)
        (set! count (+ count 1))
        (set! total (+ total new-value))
        (/ total count)
    )
)
(define avg (make-averager))  # (1)
(avg 10)  # (2)
(avg 11)  # (3)
(avg 15)  # (4)
  1. Cria uma nova clausura com a função interna definida por lambda e as variáveis count e total, inicialziadas com 0; vincula a clausura a avg.

  2. Devolve 10.0.

  3. Devolve 10.5.

  4. Devolve 12.0.

O código acima é um dos testes em lispy/py3.10/examples_test.py.

Agora chegamos a uma chamada de função.

Chamada de função
    # (gcd (* 2 105) 84)
    case [func_exp, *args] if func_exp not in KEYWORDS:
        proc = evaluate(func_exp, env)
        values = [evaluate(arg, env) for arg in args]
        return proc(*values)
Padrão:

Lista com um ou mais itens.

A expressão guarda garante que func_exp não é um de ['quote', 'if', 'define', 'lambda', 'set!']—listados logo antes de evaluate no Exemplo 17.

O padrão casa com qualquer lista com uma ou mais expressões, vinculando a primeira expressão a func_exp e o restante a args como uma lista, que pode ser vazia.

Ação:
  • Avaliar func_exp para obter uma proc da função.

  • Avaliar cada item em args para criar uma lista de valores dos argumentos.

  • Chamar proc com os valores como argumentos separados, devolvendo o resultado.

Exemplo:
>>> evaluate(parse('(% (* 12 14) (- 500 100))'), global_env)
42.0

Esse doctest continua do Exemplo 18: ele assume que global_env contém uma função chamada %. Os argumentos passados a % são expressões aritméticas, para enfatizar que eles são avaliados antes da função ser chamada.

A expressão guarda nesse case é necessária porque [func_exp, *args] casa com qualquer sequência sujeito com um ou mais itens. Entretanto, se func_exp é uma palavra reservada e o sujeito não casou com nenhum dos case anteriores, então isso é de fato um erro de sintaxe.

Capturar erros de sintaxe

Se o sujeito exp não casa com nenhum dos case anteriores, o case "pega tudo" gera um SyntaxError:

    case _:
        raise SyntaxError(lispstr(exp))

Aqui está um exemplo de um (lambda …) malformado, identificado como um SyntaxError:

>>> evaluate(parse('(lambda is not like this)'), standard_env())
Traceback (most recent call last):
    ...
SyntaxError: (lambda is not like this)

Se o case para chamada de função não tivesse aquela expressão guarda rejeitando palavras reservadas, a expressão (lambda is not like this) teria sido tratada como uma chamada de função, que geraria um KeyError, pois 'lambda' não é parte do ambiente—da mesma forma que lambda em Python não é uma função embutida.

18.3.7. Procedure: uma classe que implementa uma clausura

A classe Procedure poderia muito bem se chamar Closure, porque é isso que ela representa: uma definição de função junto com um ambiente. A definição de função inclui o nome dos parâmetros e as expressões que compõe o corpo da funcão. O ambiente é usado quando a função é chamada, para fornecer os valores das variáveis livres: variáveis que aparecem no corpo da função mas não são parâmetros, variáveis locais ou variáveis globais. Vimos os conceitos de clausura e de variáveis livres na Seção 9.6.

Aprendemos como usar clausuras em Python, mas agora podemos mergulhar mais fundo e ver como uma clausura é implementada em lis.py:

class Procedure:
    "A user-defined Scheme procedure."

    def __init__(  # (1)
        self, parms: list[Symbol], body: list[Expression], env: Environment
    ):
        self.parms = parms  # (2)
        self.body = body
        self.env = env

    def __call__(self, *args: Expression) -> Any:  # (3)
        local_env = dict(zip(self.parms, args))  # (4)
        env = Environment(local_env, self.env)  # (5)
        for exp in self.body:  # (6)
            result = evaluate(exp, env)
        return result  # (7)
  1. Chamada quando uma função é definida pelas formas lambda ou define.

  2. Salva os nomes dos parâmetros, as expressões no corpo e o ambiente, para uso posterior.

  3. Chamada por proc(*values) na última linha da cláusula case [func_exp, *args].

  4. Cria local_env, mapeando self.parms como nomes de variáveis locais e os args passados como valores.

  5. Cria um novo env combinado, colocando local_env primeiro e então self.env—o ambiente que foi salvo quando a função foi definida.

  6. Itera sobre cada expressão em self.body, avaliando-as no env combinado.

  7. Devolve o resultado da última expressão avaliada.

Há um par de funções simples após evaluate em lis.py: run lê um programa Scheme completo e o executa, e main chama run ou repl, dependendo da linha de comando—parecido com o modo como o Python faz. Não vou descrever essas funções, pois não há nada novo ali. Meus objetivos aqui eram compartilhar com vocês a beleza do pequeno interpretador de Norvig, explicar melhor como as clausuras funcionam, e mostrar como match/case foi uma ótima adição ao Python.

Para fechar essa seção estendida sobre pattern matching, vamos formalizar o conceito de um OR-pattern (padrão-OU).

18.3.8. Using padrões-OU

Uma série de padrões separados por | formam um OR-pattern (EN): ele tem êxito se qualquer dos sub-padrões tiver êxito. O padrão em Seção 18.3.6.1 é um OR-pattern:

    case int(x) | float(x):
        return x

Todos os sub-padrões em um OR-pattern devem usar as mesmas variáveis. Essa restrição é necessária para garantir que as variáveis estejam disponíveis para a expressão de guarda e para o corpo do case, independente de qual sub-padrão tenha sido bem sucedido.

⚠️ Aviso

No contexto de uma cláusula case, o operador | tem um significado especial. Ele não aciona o método especial __or__, que manipula expressões como a | b em outros contextos, onde ele é sobrecarregado para realizar operações como união de conjuntos ou disjunção binária com inteiros (o "ou binário"), dependendo dos operandos.

Um OR-pattern não está limitado a aparecer no nível superior de um padrão. | pode também ser usado em sub-padrões. Por exemplo, se quiséssemos que o lis.py aceitasse a letra grega λ (lambda)[254] além da palavra reservada lambda, poderíamos reescrever o padrão assim:

    # (λ (a b) (/ (+ a b) 2) )
    case ['lambda' | 'λ', [*parms], *body] if body:
        return Procedure(parms, body, env)

Agora podemos passar para o terceiro e último assunto deste capítulo: lugares incomuns onde a cláusula else pode aparecer no Python.

18.4. Faça isso, então aquilo: os blocos else além do if

Isso não é segredo, mas é um recurso pouco conhecido em Python: a cláusula else pode ser usada não apenas com instruções if, mas também com as instruções for, while, e try.

A semântica para for/else, while/else, e try/else é semelhante, mas é muito diferente do if/else. No início, a palavra else na verdade atrapalhou meu entendimento desses recursos, mas no fim acabei me acostumando.

Aqui estão as regras:

for

O bloco else será executado apenas se e quando o loop for rodar até o fim (isto é, não rodará se o for for interrompido com um break).

while

O bloco else será executado apenas se e quando o loop while terminar pela condição se tornar falsa (novamente, não rodará se o while for interrompido por um break)

try

O bloco else será executado apenas se nenhuma exceção for gerada no bloco try. A documentação oficial também afirma: "Exceções na cláusula else não são tratadas pela cláusula except precedente."

Em todos os casos, a cláusula else também será ignorada se uma exceção ou uma instrução return, break ou continue fizer com que o fluxo de controle saia do bloco principal da instrução composta. No caso do try, esta é a diferença importante entre else e finally: o bloco finally será executado sempre, ocorrendo ou não uma exceção, e até mesmo se o fluxo de execução sair do bloco try por uma instrução como return.

✒️ Nota

Não tenho nada contra o funcionamento dessas cláusulas else, mas do ponto de vista do design da linguagem, a palavra else foi uma escolha infeliz; else implica em uma alternativa excludente, como em "Execute esse loop, caso contrário faça aquilo." Mas a semântica do else em loops é o oposto: "Execute esse loop, então faça aquilo." Isso sugere que then ("então") seria uma escolha melhor. Também faria sentido no contexto de um try: "Tente isso, então faça aquilo." Entretanto, acrescentar uma nova palavra reservada é uma ruptura séria em uma linguagem—uma decisão muito difícil. Guido sempre foi econômico com palavras reservadas.

Usar else com essas instruções muitas vezes torna o código mais fácil de ler e evita o transtorno de configurar flags de controle ou acrescentar instruções if extras ao código.

O uso de else em loops em geral segue o padrão desse trecho:

for item in my_list:
    if item.flavor == 'banana':
        break
else:
    raise ValueError('No banana flavor found!')

No caso de blocos try/except, o else pode parecer redundante à primeira vista. Afinal, a after_call() no trecho a seguir só será executado se a dangerous_call() não gerar uma exceção, correto?

try:
    dangerous_call()
    after_call()
except OSError:
    log('OSError...')

Entretanto, isso coloca a after_call() dentro do bloco try sem um bom motivo. Por clareza e correção, o corpo de um bloco try deveria conter apenas instruções que podem gerar as exceções esperadas. Isso é melhor:

try:
    dangerous_call()
except OSError:
    log('OSError...')
else:
    after_call()

Agora fica claro que o bloco try está de guarda contra possíveis erros na dangerous_call(), e não em after_call(). Também fica explícito que after_call() só será executada se nenhuma exceção for gerada no bloco try.

Em Python, try/except é frequentemene usado para controle de fluxo, não apenas para tratamento de erro. Há inclusive um acrônimo/slogan para isso, documentado no glossário oficial do Python:

EAFP

Iniciais da expressão em inglês “easier to ask for forgiveness than permission” que significa “é mais fácil pedir perdão que permissão”. Este estilo de codificação comum em Python assume a existência de chaves ou atributos válidos e captura exceções caso essa premissa se prove falsa. Este estilo limpo e rápido se caracteriza pela presença de várias instruções try e except. A técnica diverge do estilo LBYL, comum em outras linguagens como C, por exemplo.

O glossário então define LBYL:

LBYL

Iniciais da expressão em inglês “look before you leap”, que significa algo como “olhe antes de pisar”[NT: ou "olhe antes de pular"]. Este estilo de codificação testa as pré-condições explicitamente antes de fazer chamadas ou buscas. Este estilo contrasta com a abordagem EAFP e é caracterizada pela presença de muitas instruções if. Em um ambiente multithread, a abordagem LBYL pode arriscar a introdução de uma condição de corrida entre “o olhar” e “o pisar”. Por exemplo, o código if key in mapping: return mapping[key] pode falhar se outra thread remover key do mapping após o teste, mas antes da olhada. Esse problema pode ser resolvido com bloqueios [travas] ou usando a abordagem EAFP.

Dado o estilo EAFP, faz mais sentido conhecer e usar os blocos else corretamente nas instruções try/except.

✒️ Nota

Quando a [inclusão da] instrução match foi discutida, algumas pessoas (eu incluído) acharam que ela também devia ter uma cláusula else. No fim ficou decidido que isso não era necessário, pois case _: tem o mesmo efeito.[255]

Agora vamos resumir o capítulo.

18.5. Resumo do capítulo

Este capítulo começou com gerenciadores de contexto e o significado da instrução with, indo rapidamente além de uso comum (o fechamento automático de arquivos abertos). Implementamos um gerenciador de contexto personalizado: a classe LookingGlass, usando os métodos __enter__/__exit__, e vimos como tratar exceções no método __exit__. Uma ideia fundamental apontada por Raymond Hettinger, na palestra de abertura da Pycon US 2013, é que with não serve apenas para gerenciamento de recursos; ele é uma ferramenta para fatorar código comum de configuração e de finalização, ou qualquer par de operações que precisem ser executadas antes e depois de outro procedimento.[256]

Revisamos funções no módulo contextlib da biblioteca padrão. Uma delas, o decorador @contextmanager, permite implementar um gerenciador de contexto usando apenas um mero gerador com um yield—uma solução menos trabalhosa que criar uma classe com pelo menos dois métodos. Reimplementamos a LookingGlass como uma função geradora looking_glass, e discutimos como fazer tratamento de exceções usando o @contextmanager.

Nós então estudamos o elegante interpretador Scheme de Peter Norvig, o lis.py, escrito em Python idiomático e refatorado para usar match/case em evaluate—a função central de qualquer interpretador. Entender o funcionamenteo de evaluate exigiu revisar um pouco de Scheme, um parser para expressões-S, um REPL simples e a construção de escopos aninhados através de Environment, uma subclasse de collection.ChainMap. No fim, lys.py se tornou um instrumento para explorarmos muito mais que pattern matching. Ele mostra como diferentes partes de um interpretador trabalham juntas, jogando luz sobre recursos fundamentais do próprio Python: porque palavras reservadas são necessárias, como as regras de escopo funcionam, e como clausuras são criadas e usadas.

18.6. Para saber mais

O Capítulo 8, "Instruções Compostas," em A Referência da Linguagem Python diz praticamente tudo que há para dizer sobre cláusulas else em instruções if, for, while e try. Sobre o uso pythônico de try/except, com ou sem else, Raymond Hettinger deu uma resposta brilhante para a pergunta "Is it a good practice to use try-except-else in Python?" (É uma boa prática usar try-except-else em Python?) (EN) no StackOverflow. O Python in a Nutshell, 3rd ed., by Martelli et al., tem um capítulo sobre exceções com uma excelente discussão sobre o estilo EAFP, atribuindo à pioneira da computação Grace Hopper a criação da frase "É mais fácil pedir perdão que pedir permissão."

O capítulo 4 de A Biblioteca Padrão do Python, "Tipos Embutidos", tem uma seção dedicada a "Tipos de Gerenciador de Contexto". Os métodos especiais __enter__/__exit__ também estão documentados em A Referência da Linguagem Python, em "Gerenciadores de Contexto da Instrução with".[257] Os gerenciadores de contexto foram introduzidos na PEP 343—The "with" Statement (EN).

Raymond Hettinger apontou a instrução with como um "recurso maravilhoso da linguagem" em sua palestra de abertura da PyCon US 2013 (EN). Ele também mostrou alguns usos interessantes de gerenciadores de contexto em sua apresentação "Transforming Code into Beautiful, Idiomatic Python" ("Transformando Código em Lindo Python Idiomático") (EN), na mesma conferência.

O post de Jeff Preshing em seu blog, "The Python 'with' Statement by Example" "A Instrução 'with' do Python através de Exemplos"(EN) é interessante pelos exemplos de uso de gerenciadores de contexto com a biblioteca gráfica pycairo.

A classe contextlib.ExitStack foi baseada em uma ideia original de Nikolaus Rath, que escreveu um post curto explicando porque ela é útil: "On the Beauty of Python’s ExitStack" "Sobre a Beleza do ExitStack do Python". No texto, Rath propõe que ExitStack é similar, mas mais flexível que a instrução defer em Go—que acho uma das melhores ideias naquela linguagem.

Beazley and Jones desenvolveram gerenciadores de contexto para propósitos muito diferentes em seu livro, Python Cookbook, (EN) 3rd ed. A "Recipe 8.3. Making Objects Support the Context-Management Protocol" (Receita 8.3. Fazendo Objetos Suportarem o Protocolo Gerenciador de Contexto) implementa uma classe LazyConnection, cujas instâncias são gerenciadores de contexto que abrem e fecham conexões de rede automaticamente, em blocos with. A "Recipe 9.22. Defining Context Managers the Easy Way" (Receita 9.22. O Jeito Fácil de Definir Gerenciadores de Contexto) introduz um gerenciador de contexto para código de cronometragem, e outro para realizar mudanças transacionais em um objeto list: dentro do bloco with é criada um cópia funcional da instância de list, e todas as mudanças são aplicadas àquela cópia funcional. Apenas quando o bloco with termina sem uma exceção a cópia funcional substitui a original. Simples e genial.

Peter Norvig descreve seu pequeno interpretador Scheme nos posts "(How to Write a (Lisp) Interpreter (in Python))" "(_Como Escrever um Interpretador (Lisp) (em Python))_" (EN) e "(An ((Even Better) Lisp) Interpreter (in Python))" "_(Um Interpretador (Lisp (Ainda Melhor)) (em Python))_" (EN). O código-fonte de lis.py e lispy.py está no repositório norvig/pytudes. Meu repositório, fluentpython/lispy, inclui a versão mylis do lis.py, atualizado para o Python 3.10, com um REPL melhor, integraçào com a linha de comando, exemplos, mais testes e referências para aprender mais sobre Scheme. O melhor ambiente e dialeto de Scheme para aprender e experimentar é o Racket.

Ponto de vista

Fatorando o pão

Em sua palestra de abertura na PyCon US 2013, "What Makes Python Awesome" ("O que torna o Python incrível"), Raymond Hettinger diz que quando viu a proposta da instrução with, pensou que era "um pouquinho misteriosa." Inicialmente tive uma reação similar. As PEPs são muitas vezes difíceis de ler, e a PEP 343 é típica nesse sentido.

Mas aí—​nos contou Hettinger—​ele teve uma ideia: as sub-rotinas são a invenção mais importante na história das linguagens de computador. Se você tem sequências de operações, como A;B;C e P;B;Q, você pode fatorar B em uma sub-rotina. É como fatorar o recheio de um sanduíche: usar atum com tipos de diferentes de pão. Mas e se você quiser fatorar o pão, para fazer sanduíches com pão de trigo integral usando recheios diferentes a cada vez? É isso que a instrução with oferece. Ela é o complemento da sub-rotina. Hettinger continuou:

A instrução with é algo muito importante. Encorajo vocês a irem lá e olharem para a ponta desse iceberg, e daí cavarem mais fundo. Provavelmente é possível fazer coisas muito profundas com a instrução with. Seus melhores usos ainda estão por ser descobertos. Espero que, se vocês fizerem bom uso dela, ela será copiada para outras linguagens, e todas as linguagens futuras vão incluí-la. Vocês podem ser parte da descoberta de algo quase tão profundo quanto a invenção da própria sub-rotina.

Hettinger admite que está tentando muito vender a instrução with. Mesmo assim, é um recurso bem útil. Quando ele usou a analogia do sanduíche para explicar como with é o complemento da sub-rotina, muitas possibilidades se abriram na minha mente.

Se você precisa convencer alguém que o Python é maravilhoso, assista a palestra de abertura de Hettinger. A parte sobre gerenciadores de contexto fica entre 23:00 to 26:15. Mas a palestra inteira é excelente.

Recursão eficiente com chamadas de cauda apropriadas

As implementações padrão de Scheme são obrigadas a oferecer chamadas de cauda apropriadas (PTC, sigla em inglês para proper tail calls), para tornar a iteração por recursão uma alternativa prática aos loops while das linguagens imperativas. Alguns autores se referem às PTC como otimização de chamadas de cauda (TCO, sigla em inglês para tail call optimization); para outros, TCO é uma coisa diferente. Para mais detalhes, leia "Chamadas recursivas de cauda na Wikipedia em português e "Tail call" (EN), mais aprofundado, na Wikipedia em inglês, e "Tail call optimization in ECMAScript 6" (EN).

Uma chamada de cauda é quando uma função devolve o resultado de uma chamada de função, que pode ou não ser a ela mesma (a função que está devolvendo o resultado). Os exemplos gcd no Exemplo 10 e no Exemplo 11 fazem chamadas de cauda (recursivas) no lado falso do if.

Por outro lado, essa factorial não faz uma chamada de cauda:

def factorial(n):
    if n < 2:
       return 1
    return n * factorial(n - 1)

A chamada para factorial na última linha não é uma chamada de cauda, pois o valor de return não é somente o resultado de uma chamada recursiva: o resultado é multiplicado por n antes de ser devolvido.

Aqui está uma alternativa que usa uma chamada de cauda, e é portanto recursiva de cauda:

def factorial_tc(n, product=1):
    if n < 1:
        return product
    return factorial_tc(n - 1, product * n)

O Python não tem PTC então não há vantagem em escrever funções recursivas de cauda. Neste caso, a primeira versão é, na minha opinião, mais curta e mais legível. Para usos na vida real, não se esqueça que o Python tem o math.factorial, escrito em C sem recursão. O ponto é que, mesmo em linguagens que implementam PTC, isso não beneficia toda função recursiva, apenas aquelas cuidadosamente escritas para fazerem chamadas de cauda.

Se PTC são suportadas pela linguagem, quando o interpretador vê uma chamada de cauda, ele pula para dentro do corpo da função chamada sem criar um novo stack frame, economizando memória. Há também linguagens compiladas que implementam PTC, por vezes como uma otimização que pode ser ligada e desligada.

Não existe um consenso universal sobre a definição de TCO ou sobre o valor das PTC em linguagens que não foram projetadas como linguagens funcionais desde o início, como Python e Javascript. Em linguagens funcionais, PTC é um recurso esperado, não apenas uma otimização boa de ter à mão. Se a linguagem não tem outro mecanismo de iteração além da recursão, então PTC é necessário para tornar prático o uso da linguagem. O lis.py de Norvig não implementa PTC, mas seu interpretador mais elaborado, o lispy.py, implementa.

Os argumentos contra chamadas de cauda apropriadas em Python e Javascript

O CPython não implementa PTC, e provavelmente nunca o fará. Guido van Rossum escreveu "Final Words on Tail Calls" ("Últimas Palavras sobre Chamadas de Cauda") para explicar o motivo. Resumindo, aqui está uma passagem fundamental de seu post:

Pessoalmente, acho que é um bom recurso para algumas linguagens, mas não acho que se encaixe no Python: a eliminação dos registros do stack para algumas chamadas mas não para outras certamente confundiria muitos usuários, que não foram criados na religião das chamadas de cauda, mas podem ter aprendido sobre a semântica das chamadas restreando algumas chamadas em um depurador.

Em 2015, PTC foram incluídas no padrão ECMAScript 6 para JavaScript. Em outubro de 2021 o interpretador no WebKit as implementa (EN). O WebKit é usado pelo Safari. Os interpretadores JS em todos os outros navegadores populares não tem PTC, assim como o Node.js, que depende da engine V8 que o Google mantém para o Chrome. Transpiladores e polyfills (injetores de código) voltados para o JS, como o TypeScript, o ClojureScript e o Babel, também não suportam PTC, de acordo com essa " Tabela de compatibilidade com ECMAScript 6" (EN).

Já vi várias explicações para a rejeição das PTC por parte dos implementadores, mas a mais comum é a mesma que Guido van Rossum mencionou: PTC tornam a depuração mais difícil para todo mundo, e beneficiam apenas uma minoria que prefere usar recursão para fazer iteração. Para mais detalhes, veja "What happened to proper tail calls in JavaScript?" "O que aconteceu com as chamadas de cauda apropriadas em Javascript?" de Graham Marlow.

Há casos em que a recursão é a melhor solução, mesmo no Python sem PTC. Em um post anterior sobre o assunto, Guido escreveu:

[…​] uma implementação típica de Python permite 1000 recursões, o que é bastante para código não-recursivo e para código que usa recursão para atravessar, por exemplo, um árvore de parsing típica, mas não o bastante para um loop escrito de forma recursiva sobre uma lista grande.

Concordo com Guido e com a maioria dos implementadores de Javascript. A falta de PTC é a maior restrição ao desenvolvimento de programas Python em um estilo funcional—mais que a sintaxe limitada de lambda.

Se você estiver curioso em ver como PTC funciona em um interpretador com menos recursos (e menos código) que o lispy.py de Norvig, veja o mylis_2. O truque é iniciar com o loop infinito em evaluate e o código no case para chamadas de função: essa combinação faz o interpretador pular para dentro do corpo da próxima Procedure sem chamar evaluate recursivamente durante a chamada de cauda. Esses pequenos interpretadores demonstram o poder da abstração: apesar do Python não implementar PTC, é possível e não muito difícil escrever um interpretador, em Python, que implementa PTC. Aprendi a fazer isso lendo o código de Peter Norvig. Obrigado por compartilhar, professor!

A opinião de Norvig sobre evaluate() com pattern matching

Eu compartilhei o código da versão Python 3.10 de lis.py com Peter Norvig. Ele gostou do exemplo usando pattern matching, mas sugeriu uma solução diferente: em vez de usar os guardas que escrevi, ele teria exatamente um case por palavra reservada, e teria testes dentro de cada case, para fornecer mensagens de SyntaxError mais específicas—por exemplo, quando o corpo estiver vazio. Isso também tornaria o guarda em case [func_exp, *args] if func_exp not in KEYWORDS: desnecessário, pois todas as palavras reservadas teriam sido tratadas antes do case para chamadas de função.

Provavelmente seguirei o conselho do professor Norvig quando acrescentar mais funcionalidades ao mylis. Mas a forma como estruturei evaluate no Exemplo 17 tem algumas vantagens didáticas nesse livro: o exemplo é paralelo à implementação com if/elif/… (Exemplo 11), as cláusulas case demonstram mais recursos de pattern matching e o código é mais conciso.

19. Modelos de concorrência em Python

Concorrência é lidar com muitas coisas ao mesmo tempo.
Paralelismo é fazer muitas coisas ao mesmo tempo.
Não são a mesma coisa, mas estão relacionados.
Uma é sobre estrutura, outro é sobre execução.
A concorrência fornece uma maneira de estruturar uma solução para resolver um problema que pode (mas não necessariamente) ser paralelizado.[258]

— Rob Pike
Co-criador da linguagem Go

Este capítulo é sobre como fazer o Python "lidar com muitas coisas ao mesmo tempo." Isso pode envolver programação concorrente ou paralela—e mesmo os acadêmicos rigorosos com terminologia discordam sobre o uso dessas palavras. Vou adotar as definições informais de Rob Pike, na epígrafe desse capítulo, mas saiba que encontrei artigos e livros que dizem ser sobre computação paralela mas são quase que inteiramente sobre concorrência.[259]

O paralelismo é, na perspectiva de Pike, um caso especial de concorrência. Todos sistema paralelo é concorrente, mas nem todo sistema concorrente é paralelo. No início dos anos 2000, usávamos máquinas GNU Linux de um único núcleo, que rodavam 100 processos ao mesmo tempo. Um laptop moderno com quatro núcleos de CPU rotineiramente está executando mais de 200 processos a qualquer momento, sob uso normal, casual. Para executar 200 tarefas em paralelo, você precisaria de 200 núcleos. Assim, na prática, a maior parte da computação é concorrente e não paralela. O SO administra centenas de processos, assegurando que cada um tenha a oportunidade de progredir, mesmo se a CPU em si não possa fazer mais que quatro coisas ao mesmo tempo.

Este capítulo não assume que você tenha qualquer conhecimento prévio de programação concorrente ou paralela. Após uma breve introdução conceitual, vamos estudar exemplos simples, para apresentar e comparar os principais pacotes da biblioteca padrão de Python dedicados a programação concorrente: threading, multiprocessing, e asyncio.

O último terço do capítulo é uma revisão geral de ferramentas, servidores de aplicação e filas de tarefas distribuídas (distributed task queues) de vários desenvolvedores, capazes de melhorar o desempenho e a escalabilidade de aplicações Python. Todos esses são tópicos importantes, mas fogem do escopo de um livro focado nos recursos fundamentais da linguagem Python. Mesmo assim, achei importante mencionar esses temas nessa segunda edição do Python Fluente, porque a aptidão do Python para computação concorrente e paralela não está limitada ao que a biblioteca padrão oferece. Por isso YouTube, DropBox, Instagram, Reddit e outros foram capazes de atingir alta escalabilidade quando começaram, usando Python como sua linguagem primária—apesar das persistentes alegações de que "O Python não escala."

19.1. Novidades nesse capítulo

Este capítulo é novo, escrito para a segunda edição do Python Fluente. Os exemplos com os caracteres giratórios no Seção 19.4 antes estavam no capítulo sobre asyncio. Aqui eles foram revisados, e apresentam uma primeira ilustração das três abordagens do Python à concorrência: threads, processos e corrotinas nativas.

O restante do conteúdo é novo, exceto por alguns parágrafos, que apareciam originalmente nos capítulos sobre concurrent.futures e asyncio.

A Seção 19.7 é diferente do resto do livro: não há código exemplo. O objetivo ali é apresentar brevemente ferramentas importantes, que você pode querer estudar para conseguir concorrência e paralelismo de alto desempenho, para além do que é possível com a biblioteca padrão do Python.

19.2. A visão geral

Há muitos fatores que tornam a programação concorrente difícil, mas quero tocar no mais básico deles: iniciar threads ou processos é fácil, mas como administrá-los?[260]

Quando você chama uma função, o código que origina a chamada fica bloqueado até que função retorne. Então você sabe que a função terminou, e pode facilmente acessar o valor devolvido por ela. Se a função lançar uma exceção, o código de origem pode cercar aquela chamada com um bloco try/except para tratar o erro.

Essas opções não existem quando você inicia threads ou um processo: você não sabe automaticamente quando eles terminaram, e obter os resultados ou os erros requer criar algum canal de comunicação, tal como uma fila de mensagens.

Além disso, criar uma thread ou um processo não é barato, você não quer iniciar uma delas apenas para executar uma única computação e desaparecer. Muitas vezes queremos amortizar o custo de inicialização transformando cada thread ou processo em um "worker" ou "unidade de trabalho", que entra em um loop e espera por dados para processar. Isso complica ainda mais a comunicação e introduz mais perguntas. Como terminar um "worker" quando ele não é mais necessário? E como fazer para encerrá-lo sem interromper uma tarefa inacabada, deixando dados inconsistentes e recursos não liberados—tal como arquivos abertos? A resposta envolve novamente mensagens e filas.

Uma corrotina é fácil de iniciar. Se você inicia uma corrotina usando a palavra-chave await, é fácil obter o valor de retorno e há um local óbvio para interceptar exceções. Mas corrotinas muitas vezes são iniciadas pelo framework assíncrono, e isso pode torná-las tão difíceis de monitorar quanto threads ou processos.

Por fim, as corrotinas e threads do Python não são adequadas para tarefas de uso intensivo da CPU, como veremos.

É por isso tudo que programação concorrente exige aprender novos conceitos e novos modelos de programação. Então vamos primeiro garantir que estamos na mesma página em relação a alguns conceitos centrais.

19.3. Um pouco de jargão

Aqui estão alguns termos que vou usar pelo restante desse capítulo e nos dois seguintes:

Concorrência

A habilidade de lidar com múltiplas tarefas pendentes, fazendo progredir uma por vez ou várias em paralelo (se possível), de forma que cada uma delas avance até terminar com sucesso ou falha. Uma CPU de núcleo único é capaz de concorrência se rodar um "agendador" (scheduler) do sistema operacional, que intercale a execução das tarefas pendentes. Também conhecida como multitarefa (multitasking).

Paralelismo

A habilidade de executar múltiplas operações computacionais ao mesmo tempo. Isso requer uma CPU com múltiplos núcleos, múltiplas CPUs, uma GPU, ou múltiplos computadores em um cluster (agrupamento)).

Unidades de execução

Termo genérico para objetos que executam código de forma concorrente, cada um com um estado e uma pilha de chamada independentes. O Python suporta de forma nativa três tipos de unidade de execução: processos, threads, e corrotinas.

Processo

Uma instância de um programa de computador em execução, usando memória e uma fatia do tempo da CPU. Sistemas operacionais modernos em nossos computadores e celulares rotineiramente mantém centenas de processos de forma concorrente, cada um deles isolado em seu próprio espaço de memória privado. Processos se comunicam via pipes, soquetes ou arquivos mapeados da memória. Todos esses métodos só comportam bytes puros. Objetos Python precisam ser serializados (convertidos em sequências de bytes) para passarem de um processo a outro. Isto é caro, e nem todos os objetos Python podem ser serializados. Um processo pode gerar subprocessos, chamados "processos filhos". Estes também rodam isolados entre si e do processo original. Os processos permitem multitarefa preemptiva: o agendador do sistema operacional exerce preempção—isto é, suspende cada processo em execução periodicamente, para permitir que outro processos sejam executados. Isto significa que um processo paralisado não pode paralisar todo o sistema—em teoria.

Thread

Uma unidade de execução dentro de um único processo. Quando um processo se inicia, ele tem uma única thread: a thread principal. Um processo pode chamar APIs do sistema operacional para criar mais threads para operar de forma concorrente. Threads dentro de um processo compartilham o mesmo espaço de memória, onde são mantidos objetos Python "vivos" (não serializados). Isso facilita o compartilhamento de informações entre threads, mas pode também levar a corrupção de dados, se mais de uma thread atualizar concorrentemente o mesmo objeto. Como os processos, as threads também possibilitam a multitarefa preemptiva sob a supervisão do agendador do SO. Uma thread consome menos recursos que um processo para realizar a mesma tarefa.

Corrotina

Uma função que pode suspender sua própria execução e continuar depois. Em Python, corrotinas clássicas são criadas a partir de funções geradoras, e corrotinas nativas são definidas com async def. A Seção 17.13 introduziu o conceito, e Capítulo 21 trata do uso de corrotinas nativas. As corrotinas do Python normalmente rodam dentro de uma única thread, sob a supervisão de um loop de eventos, também na mesma thread. Frameworks de programação assíncrona como asyncio, Curio, ou Trio fornecem um loop de eventos e bibliotecas de apoio para E/S não-bloqueante baseado em corrotinas. Corrotinas permitem multitarefa cooperativa: cada corrotina deve ceder explicitamente o controle com as palavras-chave yield ou await, para que outra possa continuar de forma concorrente (mas não em paralelo). Isso significa que qualquer código bloqueante em uma corrotina bloqueia a execução do loop de eventos e de todas as outras corrotinas—ao contrário da multitarefa preemptiva suportada por processos e threads. Por outro lado, cada corrotina consome menos recursos para executar o mesmo trabalho de uma thread ou processo.

Fila (queue)

Uma estrutura de dados que nos permite adicionar e retirar itens, normalmente na ordem FIFO: o primeiro que entra é o primeiro que sai.[261] Filas permitem que unidades de execução separadas troquem dados da aplicação e mensagens de controle, tais como códigos de erro e sinais de término. A implementação de uma fila varia de acordo com o modelo de concorrência subjacente: o pacote queue na biblioteca padrão do Python fornece classes de fila para suportar threads, já os pacotes multiprocessing e asyncio implementam suas próprias classes de fila. Os pacotes queue e asyncio também incluem filas não FIFO: LifoQueue e PriorityQueue.

Trava (lock)

Um objeto que as unidades de execução podem usar para sincronizar suas ações e evitar corrupção de dados. Ao atualizar uma estrutura de dados compartilhada, o código em execução deve manter uma trava associada a tal estrutura. Isso sinaliza a outras partes do programa que elas devem aguardar até que a trava seja liberada, antes de acessar a mesma estrutura de dados. O tipo mais simples de trava é conhecida também como mutex (de mutual exclusion, exclusão mútua). A implementação de uma trava depende do modelo de concorrência subjacente.

Contenda (contention)

Disputa por um recurso limitado. Contenda por recursos ocorre quando múltiplas unidades de execução tentam acessar um recurso compartilhado — tal como uma trava ou o armazenamento. Há também contenda pela CPU, quando processos ou threads de computação intensiva precisam aguardar até que o agendador do SO dê a eles uma quota do tempo da CPU.

Agora vamos usar um pouco desse jargão para entender o suporte à concorrência no Python.

19.3.1. Processos, threads, e a infame GIL do Python

Veja como os conceitos que acabamos de tratar se aplicam ao Python, em dez pontos:

  1. Cada instância do interpretador Python é um processo. Você pode iniciar processos Python adicionais usando as bibliotecas multiprocessing ou concurrent.futures. A biblioteca subprocess do Python foi projetada para rodar programas externos, independente das linguagens usadas para escrever tais programas.

  2. O interpretador Python usa uma única thread para rodar o programa do usuário e o coletor de lixo da memória. Você pode iniciar threads Python adicionais usando as bibliotecas threading ou concurrent.futures.

  3. O acesso à contagem de referências a objetos e outros estados internos do interpretador é controlado por uma trava, a Global Interpreter Lock (GIL) ou Trava Global do Interpretador. A qualquer dado momento, apenas uma thread do Python pode reter a trava. Isso significa que apenas uma thread pode executar código Python a cada momento, independente do número de núcleos da CPU.

  4. Para evitar que uma thread do Python segure a GIL indefinidamente, o interpretador de bytecode do Python pausa a thread Python corrente a cada 5ms por default,[262] liberando a GIL. A thread pode então tentar readquirir a GIL, mas se existirem outras threads esperando, o agendador do SO pode escolher uma delas para continuar.

  5. Quando escrevemos código Python, não temos controle sobre a GIL. Mas uma função embutida ou uma extensão escrita em C—ou qualquer linguagem que trabalhe no nível da API Python/C—pode liberar a GIL enquanto estiver rodando alguma tarefa longa.

  6. Toda função na biblioteca padrão do Python que executa uma syscall[263] libera a GIL. Isso inclui todas as funções que executam operações de escrita e leitura em disco, escrita e leitura na rede, e time.sleep(). Muitas funções de uso intensivo da CPU nas bibliotecas NumPy/SciPy, bem como as funções de compressão e descompressão dos módulos zlib and bz2, também liberam a GIL.[264]

  7. Extensões que se integram no nível da API Python/C também podem iniciar outras threads não-Python, que não são afetadas pela GIL. Essas threads fora do controle da GIL normalmente não podem modificar objetos Python, mas podem ler e escrever na memória usada por objetos que suportam o buffer protocol (EN), como bytearray, array.array, e arrays do NumPy.

  8. O efeito da GIL sobre a programação de redes com threads Python é relativamente pequeno, porque as funções de E/S liberam a GIL, e ler e escrever na rede sempre implica em alta latência—comparado a ler e escrever na memória. Consequentemente, cada thread individual já passa muito tempo esperando mesmo, então sua execução pode ser intercalada sem maiores impactos no desempenho geral. Por isso David Beazley diz: "As threads do Python são ótimas em fazer nada."[265]

  9. As contendas pela GIL desaceleram as threads Python de processamento intensivo. Código sequencial de uma única thread é mais simples e mais rápido para esse tipo de tarefa.

  10. Para rodar código Python de uso intensivo da CPU em múltiplos núcleos, você tem que usar múltiplos processos Python.

Aqui está um bom resumo, da documentação do módulo threading:[266]

Detalhe de implementação do CPython: Em CPython, devido à Trava Global do Interpretador, apenas uma thread pode executar código Python de cada vez (mas certas bibliotecas orientadas ao desempenho podem superar essa limitação). Se você quer que sua aplicação faça melhor uso dos recursos computacionais de máquinas com CPUs de múltiplos núcleos, aconselha-se usar multiprocessing ou concurrent.futures.ProcessPoolExecutor.

Entretanto, threads ainda são o modelo adequado se você deseja rodar múltiplas tarefas ligadas a E/S simultaneamente.

O parágrafo anterior começa com "Detalhe de implementação do CPython" porque a GIL não é parte da definição da linguagem Python. As implementações Jython e o IronPython não tem uma GIL. Infelizmente, ambas estão ficando para trás, ainda compatíveis apenas com Python 2.7 e 3.4, respectivamente. O interpretador de alto desempenho PyPy também tem uma GIL em suas versões 2.7, 3.8 e 3.9 (a mais recente em março de 2021).

✒️ Nota

Essa seção não mencionou corrotinas, pois por default elas compartilham a mesma thread Python entre si e com o loop de eventos supervisor fornecido por um framework assíncrono. Assim, a GIL não as afeta. É possível usar múltiplas threads em um programa assíncrono, mas a melhor prática é ter uma thread rodando o loop de eventos e todas as corrotinas, enquanto as threads adicionais executam tarefas específicas. Isso será explicado na Seção 21.8.

Mas chega de conceitos por agora. Vamos ver algum código.

19.4. Um "Olá mundo" concorrente

Durante uma discussão sobre threads e sobre como evitar a GIL, o contribuidor do Python Michele Simionato postou um exemplo que é praticamente um "Olá Mundo" concorrente: o programa mais simples possível mostrando como o Python pode "mascar chiclete e subir a escada ao mesmo tempo".

O programa de Simionato usa multiprocessing, mas eu o adaptei para apresentar também threading e asyncio. Vamos começar com a versão threading, que pode parecer familiar se você já estudou threads em Java ou C.

19.4.1. Caracteres animados com threads

A ideia dos próximos exemplos é simples: iniciar uma função que pausa por 3 segundos enquanto anima caracteres no terminal, para deixar o usuário saber que o programa está "pensando" e não congelado.

O script cria uma animação giratória e mostra em sequência cada caractere da string "\|/-" na mesma posição da tela.[267] Quando a computação lenta termina, a linha com a animação é apagada e o resultado é apresentado: Answer: 42.

Figura 1 mostra a saída de duas versões do exemplo: primeiro com threads, depois com corrotinas. Se você estiver longe do computador, imagine que o \ na última linha está girando.

Captura de tela do console mostrando a saída dos dois exemplos.
Figura 1. Os scripts spinner_thread.py e spinner_async.py produzem um resultado similar: o repr do objeto spinner e o texto "Answer: 42". Na captura de tela, spinner_async.py ainda está rodando, e a mensagem animada "/ thinking!" é apresentada; aquela linha será substituída por "Answer: 42" após 3 segundos.

Vamos revisar o script spinner_thread.py primeiro. O Exemplo 1 lista as duas primeiras funções no script, e o Exemplo 2 mostra o restante.

Exemplo 1. spinner_thread.py: as funções spin e slow
import itertools
import time
from threading import Thread, Event

def spin(msg: str, done: Event) -> None:  # (1)
    for char in itertools.cycle(r'\|/-'):  # (2)
        status = f'\r{char} {msg}'  # (3)
        print(status, end='', flush=True)
        if done.wait(.1):  # (4)
            break  # (5)
    blanks = ' ' * len(status)
    print(f'\r{blanks}\r', end='')  # (6)

def slow() -> int:
    time.sleep(3)  # (7)
    return 42
  1. Essa função vai rodar em uma thread separada. O argumento done é uma instância de threading.Event, um objeto simples para sincronizar threads.

  2. Isso é um loop infinito, porque itertools.cycle produz um caractere por vez, circulando pela string para sempre.

  3. O truque para animação em modo texto: mova o cursor de volta para o início da linha com o caractere de controle ASCII de retorno ('\r').

  4. O método Event.wait(timeout=None) retorna True quando o evento é acionado por outra thread; se o timeout passou, ele retorna False. O tempo de 0,1s estabelece a "velocidade" da animação para 10 FPS. Se você quiser que uma animação mais rápida, use um tempo menor aqui.

  5. Sai do loop infinito.

  6. Sobrescreve a linha de status com espaços para limpá-la e move o cursor de volta para o início.

  7. slow() será chamada pela thread principal. Imagine que isso é uma chamada de API lenta, através da rede. Chamar sleep bloqueia a thread principal, mas a GIL é liberada e a thread da animação pode continuar.

👉 Dica

O primeiro detalhe importante deste exemplo é que time.sleep() bloqueia a thread que a chama, mas libera a GIL, permitindo que outras threads Python rodem.

As funções spin e slow serão executadas de forma concorrente. A thread principal—a única thread quando o programa é iniciado—vai iniciar uma nova thread para rodar spin e então chamará slow. Propositalmente, não há qualquer API para terminar uma thread em Python. É preciso enviar uma mensagem para encerrar uma thread.

A classe threading.Event é o mecanismo de sinalização mais simples do Python para coordenar threads. Uma instância de Event tem uma flag booleana interna que começa como False. Uma chamada a Event.set() muda a flag para True. Enquanto a flag for falsa, se uma thread chamar Event.wait(), ela será bloqueada até que outra thread chame Event.set(), quando então Event.wait() retorna True. Se um tempo de espera (timeout) em segundos é passado para Event.wait(s), essa chamada retorna False quando aquele tempo tiver passado, ou retorna True assim que Event.set() é chamado por outra thread.

A função supervisor, que aparece no Exemplo 2, usa um Event para sinalizar para a função spin que ela deve encerrar.

Exemplo 2. spinner_thread.py: as funções supervisor e main
def supervisor() -> int:  # (1)
    done = Event()  # (2)
    spinner = Thread(target=spin, args=('thinking!', done))  # (3)
    print(f'spinner object: {spinner}')  # (4)
    spinner.start()  # (5)
    result = slow()  # (6)
    done.set()  # (7)
    spinner.join()  # (8)
    return result

def main() -> None:
    result = supervisor()  # (9)
    print(f'Answer: {result}')

if __name__ == '__main__':
    main()
  1. supervisor irá retornar o resultado de slow.

  2. A instância de threading.Event é a chave para coordenar as atividades das threads main e spinner, como explicado abaixo.

  3. Para criar uma nova Thread, forneça uma função como argumento palavra-chave target, e argumentos posicionais para a target como uma tupla passada via args.

  4. Mostra o objeto spinner. A saída é <Thread(Thread-1, initial)>, onde initial é o estado da thread—significando aqui que ela ainda não foi iniciada.

  5. Inicia a thread spinner.

  6. Chama slow, que bloqueia a thread principal. Enquanto isso, a thread secundária está rodando a animação.

  7. Muda a flag de Event para True; isso vai encerrar o loop for dentro da função spin.

  8. Espera até que a thread spinner termine.

  9. Roda a função supervisor. Escrevi main e supervisor como funções separadas para deixar esse exemplo mais parecido com a versão asyncio no Exemplo 4.

Quando a thread main aciona o evento done, a thread spinner acabará notando e encerrando corretamente.

Agora vamos ver um exemplo similar usando o pacote multiprocessing.

19.4.2. Animação com processos

O pacote multiprocessing permite executar tarefas concorrentes em processos Python separados em vez de threads. Quando você cria uma instância de multiprocessing.Process, todo um novo interpretador Python é iniciado como um processo filho, em segundo plano. Como cada processo Python tem sua própria GIL, isto permite que seu programa use todos os núcleos de CPU disponíveis—mas isso depende, em última instância, do agendador do sistema operacional. Veremos os efeitos práticos em Seção 19.6, mas para esse programa simples não faz grande diferença.

O objetivo dessa seção é apresentar o multiprocessing e mostrar como sua API emula a API de threading, tornando fácil converter programas simples de threads para processos, como mostra o spinner_proc.py (Exemplo 3).

Exemplo 3. spinner_proc.py: apenas as partes modificadas são mostradas; todo o resto é idêntico a spinner_thread.py
import itertools
import time
from multiprocessing import Process, Event  # (1)
from multiprocessing import synchronize     # (2)

def spin(msg: str, done: synchronize.Event) -> None:  # (3)

# [snip] the rest of spin and slow functions are unchanged from spinner_thread.py

def supervisor() -> int:
    done = Event()
    spinner = Process(target=spin,               # (4)
                      args=('thinking!', done))
    print(f'spinner object: {spinner}')          # (5)
    spinner.start()
    result = slow()
    done.set()
    spinner.join()
    return result

# [snip] main function is unchanged as well
  1. A API básica de multiprocessing imita a API de threading, mas as dicas de tipo e o Mypy mostram essa diferença: multiprocessing.Event é uma função (e não uma classe como threading.Event) que retorna uma instância de synchronize.Event…​

  2. …​nos obrigando a importar multiprocessing.synchronize…​

  3. …​para escrever essa dica de tipo.

  4. O uso básico da classe Process é similar ao da classe Thread.

  5. O objeto spinner aparece como <Process name='Process-1' parent=14868 initial>`, onde 14868 é o ID do processo da instância de Python que está executando o spinner_proc.py.

As APIs básicas de threading e multiprocessing são similares, mas sua implementação é muito diferente, e multiprocessing tem uma API muito maior, para dar conta da complexidade adicional da programação multiprocessos. Por exemplo, um dos desafios ao converter um programa de threads para processos é a comunicação entre processos, que estão isolados pelo sistema operacional e não podem compartilhar objetos Python. Isso significa que objetos cruzando fronteiras entre processos tem que ser serializados e deserializados, criando custos adicionais. No Exemplo 3, o único dado que cruza a fronteira entre os processos é o estado de Event, que é implementado com um semáforo de baixo nível do SO, no código em C sob o módulo multiprocessing.[268]

👉 Dica

Desde o Python 3.8, há o pacote multiprocessing.shared_memory (memória compartilhada para acesso direto entre processos) na biblioteca padrão, mas ele não suporta instâncias de classes definidas pelo usuário. Além bytes nus, o pacote permite que processos compartilhem uma ShareableList, uma sequência mutável que pode manter um número fixo de itens dos tipos int, float, bool, e None, bem como str e bytes, até o limite de 10 MB por item. Veja a documentação de ShareableList para mais detalhes.

Agora vamos ver como o mesmo comportamento pode ser obtido com corrotinas em vez de threads ou processos.

19.4.3. Animação com corrotinas

✒️ Nota

O Capítulo 21 é inteiramente dedicado à programação assíncrona com corrotinas. Essa seção é apenas um introdução rápida, para contrastar essa abordagem com as threads e os processos. Assim, vamos ignorar muitos detalhes.

Alocar tempo da CPU para a execução de threads e processos é trabalho dos agendadores do SO. As corrotinas, por outro lado, são controladas por um loop de evento no nível da aplicação, que gerencia uma fila de corrotinas pendentes, as executa uma por vez, monitora eventos disparados por operações de E/S iniciadas pelas corrotinas, e passa o controle de volta para a corrotina correspondente quando cada evento acontece. O loop de eventos e as corrotinas da biblioteca e as corrotinas do usuário todas rodam em uma única thread. Assim, o tempo gasto em uma corrotina desacelera loop de eventos—e de todas as outras corrotinas.

A versão com corrotinas do programa de animação é mais fácil de entender se começarmos por uma função main, e depois olharmos a supervisor. É isso que o Exemplo 4 mostra.

Exemplo 4. spinner_async.py: a função main e a corrotina supervisor
def main() -> None:  # (1)
    result = asyncio.run(supervisor())  # (2)
    print(f'Answer: {result}')

async def supervisor() -> int:  # (3)
    spinner = asyncio.create_task(spin('thinking!'))  # (4)
    print(f'spinner object: {spinner}')  # (5)
    result = await slow()  # (6)
    spinner.cancel()  # (7)
    return result

if __name__ == '__main__':
    main()
  1. main é a única função regular definida nesse programa—as outras são corrotinas.

  2. A função`asyncio.run` inicia o loop de eventos para controlar a corrotina que irá em algum momento colocar as outras corrotinas em movimento. A função main ficará bloqueada até que supervisor retorne. O valor de retorno de supervisor será o valor de retorno de asyncio.run.

  3. Corrotinas nativas são definidas com async def.

  4. asyncio.create_task agenda a execução futura de spin, retornando imediatamente uma instância de asyncio.Task.

  5. O repr do objeto spinner se parece com <Task pending name='Task-2' coro=<spin() running at /path/to/spinner_async.py:11>>.

  6. A palavra-chave await chama slow, bloqueando supervisor até que slow retorne. O valor de retorno de slow será atribuído a result.

  7. O método Task.cancel lança uma exceção CancelledError dentro da corrotina, como veremos no Exemplo 5.

O Exemplo 4 demonstra as três principais formas de rodar uma corrotina:

asyncio.run(coro())

É chamado a partir de uma função regular, para controlar o objeto corrotina, que é normalmente o ponto de entrada para todo o código assíncrono no programa, como a supervisor nesse exemplo. Esta chamada bloqueia a função até que coro retorne. O valor de retorno da chamada a run() é o que quer que coro retorne.

asyncio.create_task(coro())

É chamado de uma corrotina para agendar a execução futura de outra corrotina. Essa chamada não suspende a corrotina atual. Ela retorna uma instância de Task, um objeto que contém o objeto corrotina e fornece métodos para controlar e consultar seu estado.

await coro()

É chamado de uma corrotina para transferir o controle para o objeto corrotina retornado por coro(). Isso suspende a corrotina atual até que coro retorne. O valor da expressão await será é o que quer que coro retorne.

✒️ Nota

Lembre-se: invocar uma corrotina como coro() retorna imediatamente um objeto corrotina, mas não executa o corpo da função coro. Acionar o corpo de corrotinas é a função do loop de eventos.

Vamos estudar agora as corrotinas spin e slow no Exemplo 5.

Exemplo 5. spinner_async.py: as corrotinas spin e slow
import asyncio
import itertools

async def spin(msg: str) -> None:  # (1)
    for char in itertools.cycle(r'\|/-'):
        status = f'\r{char} {msg}'
        print(status, flush=True, end='')
        try:
            await asyncio.sleep(.1)  # (2)
        except asyncio.CancelledError:  # (3)
            break
    blanks = ' ' * len(status)
    print(f'\r{blanks}\r', end='')

async def slow() -> int:
    await asyncio.sleep(3)  # (4)
    return 42
  1. Não precisamos do argumento Event, que era usado para sinalizar que slow havia terminado de rodar no spinner_thread.py (Exemplo 1).

  2. Use await asyncio.sleep(.1) em vez de time.sleep(.1), para pausar sem bloquear outras corrotinas. Veja o experimento após o exemplo.

  3. asyncio.CancelledError é lançada quando o método cancel é chamado na Task que controla essa corrotina. É hora de sair do loop.

  4. A corrotina slow também usa await asyncio.sleep em vez de time.sleep.

Experimento: Estragando a animação para sublinhar um ponto

Aqui está um experimento que recomendo para entender como spinner_async.py funciona. Importe o módulo time, daí vá até a corrotina slow e substitua a linha await asyncio.sleep(3) por uma chamada a time.sleep(3), como no Exemplo 6.

Exemplo 6. spinner_async.py: substituindo await asyncio.sleep(3) por time.sleep(3)
async def slow() -> int:
    time.sleep(3)
    return 42

Assistir o comportamento é mais memorável que ler sobre ele. Vai lá, eu espero.

Quando você roda o experimento, você vê isso:

  1. O objeto spinner aparece: <Task pending name='Task-2' coro=<spin() running at …/spinner_async.py:12>>.

  2. A animação nunca aparece. O programa trava por 3 segundos.

  3. Answer: 42 aparece e o programa termina.

Para entender o que está acontecendo, lembre-se que o código Python que está usando asyncio tem apenas um fluxo de execução, a menos que você inicie explicitamente threads ou processos adicionais. Isso significa que apenas uma corrotina é executada a qualquer dado momento. A concorrência é obtida controlando a passagem de uma corrotina a outra. No Exemplo 7, vamos nos concentrar no que ocorre nas corrotinas supervisor e slow durante o experimento proposto.

Exemplo 7. spinner_async_experiment.py: as corrotinas supervisor e slow
async def slow() -> int:
    time.sleep(3)  # (4)
    return 42

async def supervisor() -> int:
    spinner = asyncio.create_task(spin('thinking!'))  # (1)
    print(f'spinner object: {spinner}')  # (2)
    result = await slow()  # (3)
    spinner.cancel()  # (5)
    return result
  1. A tarefa spinner é criada para, no futuro, controlar a execução de spin.

  2. O display mostra que Task está "pending"(em espera).

  3. A expressão await transfere o controle para a corrotina slow.

  4. time.sleep(3) bloqueia tudo por 3 segundos; nada pode acontecer no programa, porque a thread principal está bloqueada—e ela é a única thread. O sistema operacional vai seguir com outras atividades. Após 3 segundos, sleep desbloqueia, e slow retorna.

  5. Logo após slow retornar, a tarefa spinner é cancelada. O fluxo de controle jamais chegou ao corpo da corrotina spin.

O spinner_async_experiment.py ensina uma lição importante, como explicado no box abaixo.

⚠️ Aviso

Nunca use time.sleep(…) em corrotinas asyncio, a menos que você queira pausar o programa inteiro. Se uma corrotina precisa passar algum tempo sem fazer nada, ela deve await asyncio.sleep(DELAY). Isso devolve o controle para o loop de eventos de asyncio, que pode acionar outras corrotinas em espera.

Greenlet e gevent

Ao discutir concorrência com corrotinas, é importante mencionar o pacote greenlet, que já existe há muitos anos e é muito usado.[269] O pacote suporta multitarefa cooperativa através de corrotinas leves—chamadas greenlets—que não exigem qualquer sintaxe especial tal como yield ou await, e assim são mais fáceis de integrar a bases de código sequencial existentes. O SQL Alchemy 1.4 ORM usa greenlets internamente para implementar sua nova API assíncrona compatível com asyncio.

A biblioteca de programação de redes gevent modifica, através de monkey patches, o módulo socket padrão do Python, tornando-o não-bloqueante, substituindo parte do código daquele módulo por greenlets. Na maior parte dos casos, gevent é transparente para o código em seu entorno, tornando mais fácil adaptar aplicações e bibliotecas sequenciais—tal como drivers de bancos de dados—para executar E/S de rede de forma concorrente. Inúmeros projetos open source usam gevent, incluindo o muito usado Gunicorn—mencionado em Seção 19.7.4.

19.4.4. Supervisores lado a lado

O número de linhas de spinner_thread.py e spinner_async.py é quase o mesmo. As funções supervisor são o núcleo desses exemplos. Vamos compará-las mais detalhadamente. O Exemplo 8 mostra apenas a supervisor do Exemplo 2.

Exemplo 8. spinner_thread.py: a função supervisor com threads
def supervisor() -> int:
    done = Event()
    spinner = Thread(target=spin,
                     args=('thinking!', done))
    print('spinner object:', spinner)
    spinner.start()
    result = slow()
    done.set()
    spinner.join()
    return result

Para comparar, o Exemplo 9 mostra a corrotina supervisor do Exemplo 4.

Exemplo 9. spinner_async.py: a corrotina assíncrona supervisor
async def supervisor() -> int:
    spinner = asyncio.create_task(spin('thinking!'))
    print('spinner object:', spinner)
    result = await slow()
    spinner.cancel()
    return result

Aqui está um resumo das diferenças e semelhanças notáveis entre as duas implementações de supervisor:

  • Uma asyncio.Task é aproximadamente equivalente a threading.Thread.

  • Uma Task aciona um objeto corrotina, e uma Thread invoca um callable.

  • Uma corrotina passa o controle explicitamente com a palavra-chave await

  • Você não instancia objetos Task diretamente, eles são obtidos passando uma corrotina para asyncio.create_task(…).

  • Quando asyncio.create_task(…) retorna um objeto Task, ele já esta agendado para rodar, mas uma instância de Thread precisa ser iniciada explicitamente através de uma chamada a seu método start.

  • Na supervisor da versão com threads, slow é uma função comum e é invocada diretamente pela thread principal. Na versão assíncrona da supervisor, slow é uma corrotina guiada por await.

  • Não há API para terminar uma thread externamente; em vez disso, é preciso enviar um sinal—como acionar o done no objeto Event. Para objetos Task, há o método de instância Task.cancel(), que dispara um CancelledError na expressão await na qual o corpo da corrotina está suspensa naquele momento.

  • A corrotina supervisor deve ser iniciada com asyncio.run na função main.

Essa comparação ajuda a entender como a concorrência é orquestrada com asyncio, em contraste com como isso é feito com o módulo Threading, possivelmente mais familiar ao leitor.

Um último ponto relativo a threads versus corrotinas: quem já escreveu qualquer programa não-trivial com threads sabe quão desafiador é estruturar o programa, porque o agendador pode interromper uma thread a qualquer momento. É preciso lembrar de manter travas para proteger seções críticas do programa, para evitar ser interrompido no meio de uma operação de muitas etapas—algo que poderia deixar dados em um estado inválido.

Com corrotinas, seu código está protegido de interrupções arbitrárias. É preciso chamar await explicitamente para deixar o resto do programa rodar. Em vez de manter travas para sincronizar as operações de múltiplas threads, corrotinas são "sincronizadas" por definição: apenas uma delas está rodando em qualquer momento. Para entregar o controle, você usa await para passar o controle de volta ao agendador. Por isso é possível cancelar uma corrotina de forma segura: por definição, uma corrotina só pode ser cancelada quando está suspensa em uma expressão await, então é possível realizar qualquer limpeza necessária capturando a exceção CancelledError.

A chamada time.sleep() bloqueia mas não faz nada. Vamos agora experimentar com uma chamada de uso intensivo da CPU, para entender melhor a GIL, bem como o efeito de funções de processamento intensivo sobre código assíncrono.

19.5. O real impacto da GIL

Na versão com threads(Exemplo 1), você pode substituir a chamada time.sleep(3) na função slow por um requisição de cliente HTTP de sua biblioteca favorita, e a animação continuará girando. Isso acontece porque uma biblioteca de programação para rede bem desenhada liberará a GIL enquanto estiver esperando uma resposta.

Você também pode substituir a expressão asyncio.sleep(3) na corrotina slow para que await espere pela resposta de uma biblioteca bem desenhada de acesso assíncrono à rede, pois tais bibliotecas fornecem corrotinas que devolvem o controle para o loop de eventos enquanto esperam por uma resposta da rede. Enquanto isso, a animação seguirá girando.

Com código de uso intensivo da CPU, a história é outra. Considere a função is_prime no Exemplo 10, que retorna True se o argumento for um número primo, False se não for.

Exemplo 10. primes.py: uma verificação de números primos fácil de entender, do exemplo em ProcessPool​Executor na documentação do Python
def is_prime(n: int) -> bool:
    if n < 2:
        return False
    if n == 2:
        return True
    if n % 2 == 0:
        return False

    root = math.isqrt(n)
    for i in range(3, root + 1, 2):
        if n % i == 0:
            return False
    return True

A chamada is_prime(5_000_111_000_222_021) leva cerca de 3.3s no laptop da empresa que estou usando agora.[270]

19.5.1. Teste Rápido

Dado o que vimos até aqui, pare um instante para pensar sobre a seguinte questão, de três partes. Uma das partes da resposta é um pouco mais complicada (pelo menos para mim foi).

O quê aconteceria à animação se fossem feitas as seguintes modificações, presumindo que n = 5_000_111_000_222_021—aquele mesmo número primo que minha máquina levou 3,3s para checar:

  1. Em spinner_proc.py, substitua time.sleep(3) com uma chamada a is_prime(n)?

  2. Em spinner_thread.py, substitua time.sleep(3) com uma chamada a is_prime(n)?

  3. Em spinner_async.py, substitua await asyncio.sleep(3) com uma chamada a is_prime(n)?

Antes de executar o código ou continuar lendo, recomendo chegar as respostas por você mesmo. Depois, copie e modifique os exemplos spinner_*.py como sugerido.

Agora as respostas, da mais fácil para a mais difícil.

1. Resposta para multiprocessamento

A animação é controlada por um processo filho, então continua girando enquanto o teste de números primos é computado no processo raiz.[271]

2. Resposta para versão com threads

A animação é controlada por uma thread secundária, então continua girando enquanto o teste de número primo é computado na thread principal.

Não acertei essa resposta inicialmente: Esperava que a animação congelasse, porque superestimei o impacto da GIL.

Nesse exemplo em particular, a animação segue girando porque o Python suspende a thread em execução a cada 5ms (por default), tornando a GIL disponível para outras threads pendentes. Assim, a thread principal executando is_prime é interrompida a cada 5ms, permitindo à thread secundária acordar e executar uma vez o loop for, até chamar o método wait do evento done, quando então ela liberará a GIL. A thread principal então pegará a GIL, e o cálculo de is_prime continuará por mais 5 ms.

Isso não tem um impacto visível no tempo de execução deste exemplo específico, porque a função spin rapidamente realiza uma iteração e libera a GIL, enquanto espera pelo evento done, então não há muita disputa pela GIL. A thread principal executando is_prime terá a GIL na maior parte do tempo.

Conseguimos nos safar usando threads para uma tarefa de processamento intensivo nesse experimento simples porque só temos duas threads: uma ocupando a CPU, e a outra acordando apenas 10 vezes por segundo para atualizar a animação.

Mas se você tiver duas ou mais threads disputando por mais tempo da CPU, seu programa será mais lento que um programa sequencial.

3. Resposta para asyncio

Se você chamar is_prime(5_000_111_000_222_021) na corrotina slow do exemplo spinner_async.py, a animação nunca vai aparecer. O efeito seria o mesmo que vimos no Exemplo 6, quando substituímos await asyncio.sleep(3) por time.sleep(3): nenhuma animação. O fluxo de controle vai passar da supervisor para slow, e então para is_prime. Quando is_prime retornar, slow vai retornar também, e supervisor retomará a execução, cancelando a tarefa spinner antes dela ser executada sequer uma vez. O programa parecerá congelado por aproximadamente 3s, e então mostrará a resposta.

Soneca profunda com sleep(0)

Uma maneira de manter a animação funcionando é reescrever is_prime como uma corrotina, e periodicamente chamar asyncio.sleep(0) em uma expressão await, para passar o controle de volta para o loop de eventos, como no Exemplo 11.

Exemplo 11. spinner_async_nap.py: is_prime agora é uma corrotina
async def is_prime(n):
    if n < 2:
        return False
    if n == 2:
        return True
    if n % 2 == 0:
        return False

    root = math.isqrt(n)
    for i in range(3, root + 1, 2):
        if n % i == 0:
            return False
        if i % 100_000 == 1:
            await asyncio.sleep(0)  # (1)
    return True
  1. Vai dormir a cada 50.000 iterações (porque o argumento step em range é 2).

O Issue #284 (EN) no repositório do asyncio tem uma discussão informativa sobre o uso de asyncio.sleep(0).

Entretanto, observe que isso vai tornar is_prime mais lento, e—mais importante—vai também tornar o loop de eventos e o seu programa inteiro mais lentos. Quando eu usei await asyncio.sleep(0) a cada 100.000 iterações, a animação foi suave mas o programa rodou por 4,9s na minha máquina, quase 50% a mais que a função primes.is_prime rodando sozinha com o mesmo argumento (5_000_111_000_222_021).

Usar await asyncio.sleep(0) deve ser considerada uma medida paliativa até o código assíncrono ser refatorado para delegar computações de uso intensivo da CPU para outro processo. Veremos uma forma de fazer isso com o asyncio.loop.run_in_executor, abordado no Capítulo 21. Outra opção seria uma fila de tarefas, que vamos discutir brevemente na Seção 19.7.5.

Até aqui experimentamos com uma única chamada para uma função de uso intensivo de CPU. A próxima seção apresenta a execução concorrente de múltiplas chamadas de uso intensivo da CPU.

19.6. Um pool de processos caseiro

✒️ Nota

Escrevi essa seção para mostrar o uso de múltiplos processos em cenários de uso intensivo de CPU, e o padrão comum de usar filas para distribuir tarefas e coletar resultados. O Capítulo 20 apresenta uma forma mais simples de distribuir tarefas para processos: um ProcessPoolExecutor do pacote concurrent.futures, que internamente usa filas.

Nessa seção vamos escrever programas para verificar se os números dentro de uma amostra de 20 inteiros são primos. Os números variam de 2 até 9.999.999.999.999.999—isto é, 1016 – 1, ou mais de 253. A amostra inclui números primos pequenos e grandes, bem como números compostos com fatores primos grandes e pequenos.

O programa sequential.py fornece a linha base de desempenho. Aqui está o resultado de uma execução de teste:

$ python3 sequential.py
               2  P  0.000001s
 142702110479723  P  0.568328s
 299593572317531  P  0.796773s
3333333333333301  P  2.648625s
3333333333333333     0.000007s
3333335652092209     2.672323s
4444444444444423  P  3.052667s
4444444444444444     0.000001s
4444444488888889     3.061083s
5555553133149889     3.451833s
5555555555555503  P  3.556867s
5555555555555555     0.000007s
6666666666666666     0.000001s
6666666666666719  P  3.781064s
6666667141414921     3.778166s
7777777536340681     4.120069s
7777777777777753  P  4.141530s
7777777777777777     0.000007s
9999999999999917  P  4.678164s
9999999999999999     0.000007s
Total time: 40.31

Os resultados aparecem em três colunas:

  • O número a ser verificado.

  • P se é um número primo, caso contrária, vazia.

  • Tempo decorrido para verificar se aquele número específico é primo.

Neste exemplo, o tempo total é aproximadamente a soma do tempo de cada verificação, mas está computado separadamente, como se vê no Exemplo 12.

Exemplo 12. sequential.py: verificação de números primos em um pequeno conjunto de dados
#!/usr/bin/env python3

"""
sequential.py: baseline for comparing sequential, multiprocessing,
and threading code for CPU-intensive work.
"""

from time import perf_counter
from typing import NamedTuple

from primes import is_prime, NUMBERS

class Result(NamedTuple):  # (1)
    prime: bool
    elapsed: float

def check(n: int) -> Result:  # (2)
    t0 = perf_counter()
    prime = is_prime(n)
    return Result(prime, perf_counter() - t0)

def main() -> None:
    print(f'Checking {len(NUMBERS)} numbers sequentially:')
    t0 = perf_counter()
    for n in NUMBERS:  # (3)
        prime, elapsed = check(n)
        label = 'P' if prime else ' '
        print(f'{n:16}  {label} {elapsed:9.6f}s')

    elapsed = perf_counter() - t0  # (4)
    print(f'Total time: {elapsed:.2f}s')

if __name__ == '__main__':
    main()
  1. A função check (na próxima chamada) retorna uma tupla Result com o valor booleano da chamada a is_prime e o tempo decorrido.

  2. check(n) chama is_prime(n) e calcula o tempo decorrido para retornar um Result.

  3. Para cada número na amostra, chamamos check e apresentamos o resultado.

  4. Calcula e mostra o tempo total decorrido.

19.6.1. Solução baseada em processos

O próximo exemplo, procs.py, mostra o uso de múltiplos processos para distribuir a verificação de números primos por muitos núcleos da CPU. Esses são os tempos obtidos com procs.py:

$ python3 procs.py
Checking 20 numbers with 12 processes:
               2  P  0.000002s
3333333333333333     0.000021s
4444444444444444     0.000002s
5555555555555555     0.000018s
6666666666666666     0.000002s
 142702110479723  P  1.350982s
7777777777777777     0.000009s
 299593572317531  P  1.981411s
9999999999999999     0.000008s
3333333333333301  P  6.328173s
3333335652092209     6.419249s
4444444488888889     7.051267s
4444444444444423  P  7.122004s
5555553133149889     7.412735s
5555555555555503  P  7.603327s
6666666666666719  P  7.934670s
6666667141414921     8.017599s
7777777536340681     8.339623s
7777777777777753  P  8.388859s
9999999999999917  P  8.117313s
20 checks in 9.58s

A última linha dos resultados mostra que procs.py foi 4,2 vezes mais rápido que sequential.py.

19.6.2. Entendendo os tempos decorridos

Observe que o tempo decorrido na primeira coluna é o tempo para verificar aquele número específico. Por exemplo, is_prime(7777777777777753) demorou quase 8,4s para retornar True. Enquanto isso, outros processos estavam verificando outros números em paralelo.

Há 20 números para serem verificados. Escrevi procs.py para iniciar um número de processos de trabalho igual ao número de núcleos na CPU, como determinado por multiprocessing.cpu_count().

O tempo total neste caso é muito menor que a soma dos tempos decorridos para cada verificação individual. Há algum tempo gasto em iniciar processos e na comunicação entre processos, então o resultado final é que a versão multiprocessos é apenas cerca de 4,2 vezes mais rápida que a sequencial. Isso é bom, mas um pouco desapontador, considerando que o código inicia 12 processos, para usar todos os núcleos desse laptop.

✒️ Nota

A função multiprocessing.cpu_count() retorna 12 no MacBook Pro que estou usando para escrever esse capítulo. Ele é na verdade um i7 com uma CPU de 6 núcleos, mas o SO informa 12 CPUs devido ao hyperthreading, uma tecnologia da Intel que executa duas threads por núcleo. Entretanto, hyperthreading funciona melhor quando uma das threads não está trabalhando tão pesado quanto a outra thread no mesmo núcleo—talvez a primeira esteja parada, esperando por dados após uma perda de cache, e a outra está mastigando números. De qualquer forma, não há almoço grátis: este laptop tem o desempenho de uma máquina com 6 CPUs para atividades de processamento intensivo com pouco uso de memória—como essa verificação simples de números primos.

19.6.3. Código para o verificador de números primos com múltiplos núcleos

Quando delegamos processamento para threads e processos, nosso código não chama a função de trabalho diretamente, então não conseguimos simplesmente retornar um resultado. Em vez disso, a função de trabalho é guiada pela biblioteca de threads ou processos, e por fim produz um resultado que precisa ser armazenado em algum lugar. Coordenar threads ou processos de trabalho e coletar resultados são usos comuns de filas em programação concorrente—e também em sistemas distribuídos.

Muito do código novo em procs.py se refere a configurar e usar filas. O início do arquivo está no Exemplo 13.

⚠️ Aviso

SimpleQueue foi acrescentada a multiprocessing no Python 3.9. Se você estiver usando uma versão anterior do Python, pode substituir SimpleQueue por Queue no Exemplo 13.

Exemplo 13. procs.py: verificação de primos com múltiplos processos; importações, tipos, e funções
import sys
from time import perf_counter
from typing import NamedTuple
from multiprocessing import Process, SimpleQueue, cpu_count  # (1)
from multiprocessing import queues  # (2)

from primes import is_prime, NUMBERS

class PrimeResult(NamedTuple):  # (3)
    n: int
    prime: bool
    elapsed: float

JobQueue = queues.SimpleQueue[int]  # (4)
ResultQueue = queues.SimpleQueue[PrimeResult]  # (5)

def check(n: int) -> PrimeResult:  # (6)
    t0 = perf_counter()
    res = is_prime(n)
    return PrimeResult(n, res, perf_counter() - t0)

def worker(jobs: JobQueue, results: ResultQueue) -> None:  # (7)
    while n := jobs.get():  # (8)
        results.put(check(n))  # (9)
    results.put(PrimeResult(0, False, 0.0))  # (10)

def start_jobs(
    procs: int, jobs: JobQueue, results: ResultQueue  # (11)
) -> None:
    for n in NUMBERS:
        jobs.put(n)  # (12)
    for _ in range(procs):
        proc = Process(target=worker, args=(jobs, results))  # (13)
        proc.start()  # (14)
        jobs.put(0)  # (15)
  1. Na tentativa de emular threading, multiprocessing fornece multiprocessing.SimpleQueue, mas esse é um método vinculado a uma instância pré-definida de uma classe de nível mais baixo, BaseContext. Temos que chamar essa SimpleQueue para criar uma fila. Por outro lado, não podemos usá-la em dicas de tipo.

  2. multiprocessing.queues contém a classe SimpleQueue que precisamos para dicas de tipo.

  3. PrimeResult inclui o número verificado. Manter n junto com os outros campos do resultado simplifica a exibição mais tarde.

  4. Isso é um apelido de tipo para uma SimpleQueue que a função main (Exemplo 14) vai usar para enviar os números para os processos que farão a verificação.

  5. Apelido de tipo para uma segunda SimpleQueue que vai coletar os resultados em main. Os valores na fila serão tuplas contendo o número a ser testado e uma tupla Result.

  6. Isso é similar a sequential.py.

  7. worker recebe uma fila com os números a serem verificados, e outra para colocar os resultados.

  8. Nesse código, usei o número 0 como uma pílula venenosa: um sinal para que o processo encerre. Se n não é 0, continue com o loop.[272]

  9. Invoca a verificação de número primo e coloca o PrimeResult na fila.

  10. Devolve um PrimeResult(0, False, 0.0), para informar ao loop principal que esse processo terminou seu trabalho.

  11. procs é o número de processos que executarão a verificação de números primos em paralelo.

  12. Coloca na fila jobs os números a serem verificados.

  13. Cria um processo filho para cada worker. Cada um desses processos executará o loop dentro de sua própria instância da função worker, até encontrar um 0 na fila jobs.

  14. Inicia cada processo filho.

  15. Coloca um 0 na fila de cada processo, para encerrá-los.

Loops, sentinelas e pílulas venenosas

A função worker no Exemplo 13 segue um modelo comum em programação concorrente: percorrer indefinidamente um loop, pegando itens em um fila e processando cada um deles com uma função que realiza o trabalho real. O loop termina quando a fila produz um valor sentinela. Nesse modelo, a sentinela que encerra o processo é muitas vezes chamada de "pílula venenosa.

None é bastante usado como valor sentinela, mas pode não ser adequado se existir a possibilidade dele aparecer entre os dados. Chamar object() é uma maneira comum de obter um valor único para usar como sentinela. Entretanto, isso não funciona entre processos, pois os objetos Python precisam ser serializados para comunicação entre processos. Quando você pickle.dump e pickle.load uma instância de object, a instância recuperada em pickle.load é diferentes da original: elas não serão iguais se comparadas. Uma boa alternativa a None é o objeto embutido Ellipsis (também conhecido como …​), que sobrevive à serialização sem perder sua identidade.[273]

A biblioteca padrão do Python usa muitos valores diferentes (EN) como sentinelas. A PEP 661—Sentinel Values (EN) propõe um tipo sentinela padrão. Em março de 2023, é apenas um rascunho.

Agora vamos estudar a função main de procs.py no Exemplo 14.

Exemplo 14. procs.py: verificação de números primos com múltiplos processos; função main
def main() -> None:
    if len(sys.argv) < 2:  # (1)
        procs = cpu_count()
    else:
        procs = int(sys.argv[1])

    print(f'Checking {len(NUMBERS)} numbers with {procs} processes:')
    t0 = perf_counter()
    jobs: JobQueue = SimpleQueue()  # (2)
    results: ResultQueue = SimpleQueue()
    start_jobs(procs, jobs, results)  # (3)
    checked = report(procs, results)  # (4)
    elapsed = perf_counter() - t0
    print(f'{checked} checks in {elapsed:.2f}s')  # (5)

def report(procs: int, results: ResultQueue) -> int: # (6)
    checked = 0
    procs_done = 0
    while procs_done < procs:  # (7)
        n, prime, elapsed = results.get()  # (8)
        if n == 0:  # (9)
            procs_done += 1
        else:
            checked += 1  # (10)
            label = 'P' if prime else ' '
            print(f'{n:16}  {label} {elapsed:9.6f}s')
    return checked

if __name__ == '__main__':
    main()
  1. Se nenhum argumento é dado na linha de comando, define o número de processos como o número de núcleos na CPU; caso contrário, cria quantos processos forem passados no primeiro argumento.

  2. jobs e results são as filas descritas no Exemplo 13.

  3. Inicia proc processos para consumir jobs e informar results.

  4. Recupera e exibe os resultados; report está definido em 6.

  5. Mostra quantos números foram verificados e o tempo total decorrido.

  6. Os argumentos são o número de procs e a fila para armazenar os resultados.

  7. Percorre o loop até que todos os processos terminem.

  8. Obtém um PrimeResult. Chamar .get() em uma fila deixa o processamento bloqueado até que haja um item na fila. Também é possível fazer isso de forma não-bloqueante ou estabelecer um timeout. Veja os detalhes na documentação de SimpleQueue.get.

  9. Se n é zero, então um processo terminou; incrementa o contador procs_done.

  10. Senão, incrementa o contador checked (para acompanhar os números verificados) e mostra os resultados.

Os resultados não vão retornar na mesma ordem em que as tarefas foram submetidas. Por isso for necessário incluir n em cada tupla PrimeResult. De outra forma eu não teria como saber que resultado corresponde a cada número.

Se o processo principal terminar antes que todos os subprocessos finalizem, podem surgir relatórios de rastreamento (tracebacks) confusos, com referências a exceções de FileNotFoundError causados por uma trava interna em multiprocessing. Depurar código concorrente é sempre difícil, e depurar código baseado no multiprocessing é ainda mais difícil devido a toda a complexidade por trás da fachada emulando threads. Felizmente, o ProcessPoolExecutor que veremos no Capítulo 20 é mais fácil de usar e mais robusto.

✒️ Nota

Agradeço ao leitor Michael Albert, que notou que o código que publiquei durante o pré-lançamento tinha uma "condição de corrida" (race condition) no Exemplo 14. Uma condição de corrida (ou de concorrência) é um bug que pode ou não aparecer, dependendo da ordem das ações realizadas pelas unidades de execução concorrentes. Se "A" acontecer antes de "B", tudo segue normal; mas se "B" acontecer antes, surge um erro. Essa é a corrida.

Se você estiver curiosa, esse diff mostra o bug e sua correção: example-code-2e/commit/2c123057—mas note que depois eu refatorei o exemplo para delegar partes de main para as funções start_jobs e report. Há um arquivo README.md na mesma pasta explicando o problema e a solução.

19.6.4. Experimentando com mais ou menos processos

Você poderia tentar rodar procs.py, passando argumentos que modifiquem o número de processos filho. Por exemplo, este comando…​

$ python3 procs.py 2

…​vai iniciar dois subprocessos, produzindo os resultados quase duas vezes mais rápido que sequential.py—se a sua máquina tiver uma CPU com pelo menos dois núcleos e não estiver ocupada rodando outros programas.

Rodei procs.py 12 vezes, usando de 1 a 20 subprocessos, totalizando 240 execuções. Então calculei a mediana do tempo para todas as execuções com o mesmo número de subprocessos, e desenhei a Figura 2.

Mediana dos tempos de execução para cada número de processos
Figura 2. Mediana dos tempos de execução para cada número de subprocessos de 1 a 20. O maior tempo mediano foi 40,81s, com 1 processo. O tempo mediano mais baixo foi 10,39s, com 6 processos, indicado pela linha pontilhada.

Neste laptop de 6 núcleos, o menor tempo mediano ocorreu com 6 processos:10.39s—marcado pela linha pontilhada na Figura 2. Seria de se esperar que o tempo de execução aumentasse após 6 processos, devido à disputa pela CPU, e ele atingiu um máximo local de 12.51s, com 10 processes. Eu não esperava e não sei explicar porque o desempenho melhorou com 11 processos e permaneceu praticamente igual com 13 a 20 processos, com tempos medianos apenas ligeiramente maiores que o menor tempo mediano com 6 processos.

19.6.5. Não-solução baseada em threads

Também escrevi threads.py, uma versão de procs.py usando threading em vez de multiprocessing. O código é muito similar quando convertemos exemplo simples entre as duas APIs.[274] Devido à GIL e à natureza de processamento intensivo de is_prime, a versão com threads é mais lenta que a versão sequencial do Exemplo 12, e fica mais lenta conforme aumenta o número de threads, por causa da disputa pela CPU e o custo da mudança de contexto. Para passar de uma thread para outra, o SO precisa salvar os registradores da CPU e atualizar o contador de programas e o ponteiro do stack, disparando efeitos colaterais custosos, como invalidar os caches da CPU e talvez até trocar páginas de memória. [275]

Os dois próximos capítulos tratam de mais temas ligados à programação concorrente em Python, usando a biblioteca de alto nível concurrent.futures para gerenciar threads e processos (Capítulo 20) e a biblioteca asyncio para programação assíncrona (Capítulo 21).

As demais seções nesse capítulo procuram responder à questão:

Dadas as limitações discutidas até aqui, como é possível que o Python seja tão bem-sucedido em um mundo de CPUs com múltiplos núcleos?

19.7. Python no mundo multi-núcleo.

Os mais importantes fabricantes e arquiteturas de processadores, da Intel e da AMD até a Sparc e o PowerPC, esgotaram o potencial da maioria das abordagens tradicionais de aumento do desempenho das CPUs. Ao invés de elevar a frequência do clock [dos processadores] e a taxa de transferência das instruções encadeadas a níveis cada vez maiores, eles estão se voltando em massa para o hyper-threading (hiperprocessamento) e para arquiteturas multi-núcleo. Março de 2005. [Disponível online].

O que Sutter chama de "almoço grátis" era a tendência do software ficar mais rápido sem qualquer esforço adicional por parte dos desenvolvedores, porque as CPUs estavam executando código sequencial cada vez mais rápido, ano após ano. Desde 2004 isso não é mais verdade: a frequência dos clocks das CPUs e as otimizações de execução atingiram um platô, e agora qualquer melhoria significativa no desempenho precisa vir do aproveitamento de múltiplos núcleos ou do hyperthreading, avanços que só beneficiam código escrito para execução concorrente.

A história do Python começa no início dos anos 1990, quando as CPUs ainda estavam ficando exponencialmente mais rápidas na execução de código sequencial. Naquele tempo não se falava de CPUs com múltiplos núcleos, exceto para supercomputadores. Assim, a decisão de ter uma GIL era óbvia. A GIL torna o interpretador rodando em um único núcleo mais rápido, e simplifica sua implementação.[276] A GIL também torna mais fácil escrever extensões simples com a API Python/C.

✒️ Nota

Escrevi "extensões simples" porque uma extensão não é obrigada a lidar com a GIL. Uma função escrita em C ou Fortran pode ser centenas de vezes mais rápida que sua equivalente em Python.[277] Assim, a complexidade adicional de liberar a GIL para tirar proveito de CPUs multi-núcleo pode, em muitos casos, não ser necessária. Então podemos agradecer à GIL por muitas das extensões disponíveis em Python—e isso é certamente uma das razões fundamentais da popularidade da linguagem hoje.

Apesar da GIL, o Python está cada vez mais popular entre aplicações que exigem execução concorrente ou paralela, graças a bibliotecas e arquiteturas de software que contornam as limitações do CPython.

Agora vamos discutir como o Python é usado em administração de sistemas, ciência de dados, e desenvolvimento de aplicações para servidores no mundo do processamento distribuído e dos multi-núcleos de 2023.

19.7.1. Administração de sistemas

O Python é largamente utilizado para gerenciar grandes frotas de servidores, roteadores, balanceadores de carga e armazenamento conectado à rede (network-attached storage ou NAS). Ele é também a opção preferencial para redes definidas por software (SND, software-defined networking) e hacking ético. Os maiores provedores de serviços na nuvem suportam Python através de bibliotecas e tutoriais de sua própria autoria ou da autoria de suas grande comunidades de usuários da linguagem.

Nesse campo, scripts Python automatizam tarefas de configuração, emitindo comandos a serem executados pelas máquinas remotas, então raramente há operações limitadas pela CPU. Threads ou corrotinas são bastante adequadas para tais atividades. Em particular, o pacote concurrent.futures, que veremos no Capítulo 20, pode ser usado para realizar as mesmas operações em muitas máquinas remotas ao mesmo tempo, sem grande complexidade.

Além da biblioteca padrão, há muito projetos populares baseados em Python para gerenciar clusters (agrupamentos) de servidores: ferramentas como o Ansible (EN) e o Salt (EN), bem como bibliotecas como a Fabric (EN).

Há também um número crescente de bibliotecas para administração de sistemas que suportam corrotinas e asyncio. Em 2016, a equipe de Engenharia de Produção (EN) do Facebook relatou: "Estamos cada vez mais confiantes no AsyncIO, introduzido no Python 3.4, e vendo ganhos de desempenho imensos conforme migramos as bases de código do Python 2."

19.7.2. Ciência de dados

A ciência de dados—incluindo a inteligência artificial—e a computação científica estão muito bem servidas pelo Python.

Aplicações nesses campos são de processamento intensivo, mas os usuários de Python se beneficiam de um vasto ecossistema de bibliotecas de computação numérica, escritas em C, C++, Fortran, Cython, etc.—muitas das quais capazes de aproveitar os benefícios de máquinas multi-núcleo, GPUs, e/ou computação paralela distribuída em clusters heterogêneos.

Em 2021, o ecossistema de ciência de dados de Python já incluía algumas ferramentas impressionantes:

Project Jupyter

Duas interfaces para navegadores—Jupyter Notebook e JupyterLab—que permitem aos usuários rodar e documentar código analítico, potencialmente sendo executado através da rede em máquinas remotas. Ambas são aplicações híbridas Python/Javascript, suportando kernels de processamento escritos em diferentes linguagens, todos integrados via ZeroMQ—uma biblioteca de comunicação por mensagens assíncrona para aplicações distribuídas. O nome Jupyter, inclusive remete a Julia, Python, e R, as três primeiras linguagens suportadas pelo Notebook. O rico ecossistema construído sobre as ferramentas Jupyter incluí o Bokeh, uma poderosa biblioteca de visualização iterativa que permite aos usuários navegarem e interagirem com grandes conjuntos de dados ou um fluxo de dados continuamente atualizado, graças ao desempenho dos navegadores modernos e seus interpretadores JavaScript.

TensorFlow e PyTorch

Estes são os principais frameworks de aprendizagem profunda (deep learning), de acordo com o relatório de Janeiro de 2021 da O’Reilly’s (EN) medido pela utilização em 2020. Os dois projetos são escritos em C++, e conseguem se beneficiar de múltiplos núcleos, GPUs e clusters. Eles também suportam outras linguagens, mas o Python é seu maior foco e é usado pela maioria de seus usuários. O TensorFlow foi criado e é usado internamente pelo Google; O Pythorch pelo Facebook.

Dask

Uma biblioteca de computação paralela que consegue delegar para processos locais ou um cluster de máquinas, "testado em alguns dos maiores supercomputadores do mundo"—como seu site (EN) afirma. O Dask oferece APIs que emulam muito bem o NumPy, o pandas, e o scikit-learn—hoje as mais populares bibliotecas em ciência de dados e aprendizagem de máquina. O Dask pode ser usado a partir do JupyterLab ou do Jupyter Notebook, e usa o Bokeh não apenas para visualização de dados mas também para um quadro interativo mostrando o fluxo de dados e o processamento entre processos/máquinas quase em tempo real. O Dask é tão impressionante que recomento assistir um vídeo tal como esse, 15-minute demo, onde Matthew Rocklin—um mantenedor do projeto—mostra o Dask mastigando dados em 64 núcleos distribuídos por 8 máquinas EC2 na AWS.

Estes são apenas alguns exemplos para ilustrar como a comunidade de ciência de dados está criando soluções que extraem o melhor do Python e superam as limitações do runtime do CPython.

19.7.3. Desenvolvimento de aplicações server-side para Web/Computação Móvel

O Python é largamente utilizado em aplicações Web e em APIs de apoio a aplicações para computação móvel no servidor. Como o Google, o YouTube, o Dropbox, o Instagram, o Quora, e o Reddit—entre outros—conseguiram desenvolver aplicações de servidor em Python que atendem centenas de milhões de usuários 24X7? Novamente a resposta vai bem além do que o Python fornece "de fábrica". Antes de discutir as ferramentas necessárias para usar o Python larga escala, preciso citar uma advertência da Technology Radar da Thoughtworks:

Inveja de alto desempenho/inveja de escala da web

Vemos muitas equipes se metendo em apuros por escolher ferramentas, frameworks ou arquiteturas complexas, porque eles "talvez precisem de escalabilidade". Empresas como o Twitter e a Netflix precisam aguentar cargas extremas, então precisam dessas arquiteturas, mas elas também tem equipes de desenvolvimento extremamente habilitadas, capazes de lidar com a complexidade. A maioria das situações não exige essas façanhas de engenharia; as equipes devem manter sua inveja da escalabilidade na web sob controle, e preferir soluções simples que ainda assim fazem o que precisa ser feito.[278]

Na escala da web, a chave é uma arquitetura que permita escalabilidade horizontal. Neste cenário, todos os sistemas são sistemas distribuídos, e possivelmente nenhuma linguagem de programação será a única alternativa ideal para todas as partes da solução.

Sistemas distribuídos são um campo da pesquisa acadêmica, mas felizmente alguns profissionais da área escreveram livros acessíveis, baseados em pesquisas sólidas e experiência prática. Um deles é Martin Kleppmann, o autor de Designing Data-Intensive Applications (Projetando Aplicações de Uso Intensivo de Dados) (O’Reilly).

Observe a Figura 3, o primeiro de muitos diagramas de arquitetura no livro de Kleppmann. Aqui há alguns componentes que vi em muitos ambientes Python onde trabalhei ou que conheci pessoalmente:

  • Caches de aplicação:[279] memcached, Redis, Varnish

  • bancos de dados relacionais: PostgreSQL, MySQL

  • Bancos de documentos: Apache CouchDB, MongoDB

  • Full-text indexes (índices de texto integral): Elasticsearch, Apache Solr

  • Enfileiradores de mensagens: RabbitMQ, Redis

Arquitetura para um sistema de dados combinando diversos componentes
Figura 3. Uma arquitetura possível para um sistema, combinando diversos componentes.[280]

Há outros produtos de código aberto extremamente robustos em cada uma dessas categorias. Os grandes fornecedores de serviços na nuvem também oferecem suas próprias alternativas proprietárias

O diagrama de Kleppmann é genérico e independente da linguagem—como seu livro. Para aplicações de servidor em Python, dois componentes específicos são comumente utilizados:

  • Um servidor de aplicação, para distribuir a carga entre várias instâncias da aplicação Python. O servidor de aplicação apareceria perto do topo na Figura 3, processando as requisições dos clientes antes delas chegaram ao código da aplicação.

  • Uma fila de tarefas construída em torno da fila de mensagens no lado direito da Figura 3, oferecendo uma API de alto nível e mais fácil de usar, para distribuir tarefas para processos rodando em outras máquinas.

As duas próximas seções exploram esses componentes, recomendados pelas boas práticas de implementações de aplicações Python de servidor.

19.7.4. Servidores de aplicação WSGI

O WSGI— Web Server Gateway Interface (Interface de Gateway de Servidores Web)—é a API padrão para uma aplicação ou um framework Python receber requisições de um servidor HTTP e enviar para ele as respostas.[281] Servidores de aplicação WSGI gerenciam um ou mais processos rodando a sua aplicação, maximizando o uso das CPUs disponíveis.

A Figura 4 ilustra uma instalação WSGI típica.

👉 Dica

Se quiséssemos fundir os dois diagramas, o conteúdo do retângulo tracejado na Figura 4 substituiria o retângulo sólido "Application code"(código da aplicação) no topo da Figura 3.

Os servidores de aplicação mais conhecidos em projeto web com Python são:

Para usuários do servidor HTTP Apache, mod_wsgi é a melhor opção. Ele é tão antigo com a própria WSGI, mas tem manutenção ativa, e agora pode ser iniciado via linha de comando com o mod_wsgi-express, que o torna mais fácil de configurar e mais apropriado para uso com containers Docker.

Diagrama de bloco mostrando o cliente conectado ao servidor HTTP, conectado ao servidor de aplicação, conectado a quatro processos Python.
Figura 4. Clientes se conectam a um servidor HTTP que entrega arquivos estáticos e roteia outras requisições para o servidor de aplicação, que inicia processo filhos para executar o código da aplicação, utilizando múltiplos núcleos de CPU. A API WSGI é a ponte entre o servidor de aplicação e o código da aplicação Python.

O uWSGI e o Gunicorn são as escolhas mais populares entre os projetos recentes que conheço. Ambos são frequentemente combinados com o servidor HTTP NGINX. uWSGI oferece muita funcionalidade adicional, incluindo um cache de aplicação, uma fila de tarefas, tarefas periódicas estilo cron, e muitas outras. Por outro lado, o uWSGI é muito mais difícil de configurar corretamente que o Gunicorn.[283]

Lançado em 2018, o NGINX Unit é um novo produto dos desenvolvedores do conhecido servidor HTTP e proxy reverso NGINX.

O mod_wsgi e o Gunicorn só suportam apps web Python, enquanto o uWSGI e o NGINX Unit funcionam também com outras linguagens. Para saber mais, consulte a documentação de cada um deles.

O ponto principal: todos esses servidores de aplicação podem, potencialmente, utilizar todos os núcleos de CPU no servidor, criando múltiplos processos Python para executar apps web tradicionais escritas no bom e velho código sequencial em Django, Flask, Pyramid, etc. Isso explica porque tem sido possível ganhar a vida como desenvolvedor Python sem nunca ter estudado os módulos threading, multiprocessing, ou asyncio: o servidor de aplicação lida de forma transparente com a concorrência.

ASGI—Asynchronous Server Gateway Interface

(Interface Assíncrona de Ponto de Entrada de Servidor)

✒️ Nota

A WSGI é uma API síncrona. Ela não suporta corrotinas com async/await—a forma mais eficiente de implementar WebSockets or long pooling de HTTP em Python. A especificação da ASGI é a sucessora da WSGI, projetada para frameworks Python assíncronos para programação web, tais como aiohttp, Sanic, FastAPI, etc., bem como Django e Flask, que estão gradativamente acrescentando funcionalidades assíncronas.

Agora vamos examinar outra forma de evitar a GIL para obter um melhor desempenho em aplicações Python de servidor.

19.7.5. Filas de tarefas distribuídas

Quando o servidor de aplicação entrega uma requisição a um dos processos Python rodando seu código, sua aplicação precisa responder rápido: você quer que o processo esteja disponível para processar a requisição seguinte assim que possível. Entretanto, algumas requisições exigem ações que podem demorar—por exemplo, enviar um email ou gerar um PDF. As filas de tarefas distribuídas foram projetadas para resolver este problema.

A Celery e a RQ são as mais conhecidas filas de tarefas Open Source com uma API para o Python. Provedores de serviços na nuvem também oferecem suas filas de tarefas proprietárias.

Esses produtos encapsulam filas de mensagens e oferecem uma API de alto nível para delegar tarefas a processos executores, possivelmente rodando em máquinas diferentes.

✒️ Nota

No contexto de filas de tarefas, as palavras produtor e consumidor são usado no lugar da terminologia tradicional de cliente/servidor. Por exemplo, para gerar documentos, um processador de views do Django produz requisições de serviço, que são colocadas em uma fila para serem consumidas por um ou mais processos renderizadores de PDFs.

Citando diretamente o FAQ do Celery, eis alguns casos de uso:

  • Executar algo em segundo plano. Por exemplo, para encerrar uma requisição web o mais rápido possível, e então atualizar a página do usuário de forma incremental. Isso dá ao usuário a impressão de um bom desempenho e de "vivacidade", ainda que o trabalho real possa na verdade demorar um pouco mais.

  • Executar algo após a requisição web ter terminado.

  • Se assegurar que algo seja feito, através de uma execução assíncrona e usando tentativas repetidas.

  • Agendar tarefas periódicas.

Além de resolver esses problemas imediatos, as filas de tarefas suportam escalabilidade horizontal. Produtores e consumidores são desacoplados: um produtor não precisa chamar um consumidor, ele coloca uma requisição em uma fila. Consumidores não precisam saber nada sobre os produtores (mas a requisição pode incluir informações sobre o produtor, se uma confirmação for necessária). Pode-se adicionar mais unidades de execução para consumir tarefas a medida que a demanda cresce. Por isso o Celery e o RQ são chamados de filas de tarefas distribuídas.

Lembre-se que nosso simples procs.py (Exemplo 13) usava duas filas: uma para requisições de tarefas, outra para coletar resultados. A arquitetura distribuída do Celery e do RQ usa um esquema similar. Ambos suportam o uso do banco de dados NoSQL Redis para armazenar as filas de mensagens e resultados. O Celery também suporta outras filas de mensagens, como o RabbitMQ ou o Amazon SQS, bem como outros bancos de dados para armazenamento de resultados.

Isso encerra nossa introdução à concorrência em Python. Os dois próximos capítulos continuam nesse tema, se concentrando nos pacotes concurrent.futures e asyncio packages da biblioteca padrão.

19.8. Resumo do capítulo

Após um pouco de teoria, esse capítulo apresentou scripts da animação giratória, implementados em cada um dos três modelos de programação de concorrência nativos do Python:

  • Threads, com o pacote threading

  • Processo, com multiprocessing

  • Corrotinas assíncronas com asyncio

Então exploramos o impacto real da GIL com um experimento: mudar os exemplos de animação para computar se um inteiro grande era primo e observar o comportamento resultante. Isso demonstrou graficamente que funções de uso intensivo da CPU devem ser evitadas em asyncio, pois elas bloqueiam o loop de eventos. A versão com threads do experimento funcionou—apesar da GIL—porque o Python periodicamente interrompe as threads, e o exemplo usou apenas duas threads: uma fazendo um trabalho de computação intensiva, a outra controlando a animação apenas 10 vezes por segundo. A variante com multiprocessing contornou a GIL, iniciando um novo processo só para a animação, enquanto o processo principal calculava se o número era primo.

O exemplo seguinte, computando diversos números primos, destacou a diferença entre multiprocessing e threading, provando que apenas processos permitem ao Python se beneficiar de CPUs com múltiplo núcleos. A GIL do Python torna as threads piores que o código sequencial para processamento pesado.

A GIL domina as discussões sobre computação concorrente e paralela em Python, mas não devemos superestimar seu impacto. Este foi o tema da Seção 19.7. Por exemplo, a GIL não afeta muitos dos casos de uso de Python em administração de sistemas. Por outro lado, as comunidades de ciência de dados e de desenvolvimento para servidores evitaram os problemas com a GIL usando soluções robustas, criadas sob medida para suas necessidades específicas. As últimas duas seções mencionaram os dois elementos comuns que sustentam o uso de Python em aplicações de servidor escaláveis: servidores de aplicação WSGI e filas de tarefas distribuídas.

19.9. Para saber mais

Este capítulo tem uma extensa lista de referências, então a dividi em subseções.

19.9.1. Concorrência com threads e processos

A biblioteca concurrent.futures, tratada no Capítulo 20, usa threads, processos, travas e filas debaixo dos panos, mas você não vai ver as instâncias individuais desses elementos; eles são encapsulados e gerenciados por abstrações de um nível mais alto: ThreadPoolExecutor ou ProcessPoolExecutor. Para aprender mais sobre a prática da programação concorrente com aqueles objetos de baixo nível, "An Intro to Threading in Python" (Uma Introdução [à Programação com] Threads no Python) de Jim Anderson é uma boa primeira leitura. Doug Hellmann tem um capítulo chamado "Concurrency with Processes, Threads, and Coroutines" (Concorrência com Processos, Threads, e Corrotinas) em seus site e livro, The Python 3 Standard Library by Example (Addison-Wesley).

Effective Python, 2nd ed. (Addison-Wesley), de Brett Slatkin, Python Essential Reference, 4th ed. (Addison-Wesley), de David Beazley, e Python in a Nutshell, 3rd ed. (O’Reilly) de Martelli et al são outras referências gerais de Python com uma cobertura significativa de threading e multiprocessing. A vasta documentação oficial de multiprocessing inclui conselhos úteis em sua seção "Programming guidelines" (Diretrizes de programação) (EN).

Jesse Noller e Richard Oudkerk contribuíram para o pacote multiprocessing, introduzido na PEP 371—​Addition of the multiprocessing package to the standard library (EN). A documentação oficial do pacote é um arquivo de 93 KB .rst—são cerca de 63 páginas—tornando-o um dos capítulos mais longos da biblioteca padrão do Python.

Em High Performance Python, 2nd ed., (O’Reilly), os autores Micha Gorelick e Ian Ozsvald incluem um capítulo sobre multiprocessing com um exemplo sobre verificação de números primos usando uma estratégia diferente do nosso exemplo procs.py. Para cada número, eles dividem a faixa de fatores possíveis-de 2 a sqrt(n)—em subfaixas, e fazem cada unidade de execução iterar sobre uma das subfaixas. Sua abordagem de dividir para conquistar é típica de aplicações de computação científica, onde os conjuntos de dados são enormes, e as estações de trabalho (ou clusters) tem mais núcleos de CPU que usuários. Em um sistema servidor, processando requisições de muitos usuários, é mais simples e mais eficiente deixar cada processo realizar uma tarefa computacional do início ao fim—reduzindo a sobrecarga de comunicação e coordenação entre processos. Além de multiprocessing, Gorelick e Ozsvald apresentam muitas outras formas de desenvolver e implantar aplicações de ciência de dados de alto desempenho, aproveitando múltiplos núcleos de CPU, GPUs, clusters, analisadores e compiladores como CYthon e Numba. Seu capítulo final, "Lessons from the Field," (Lições da Vida Real) é uma valiosa coleção de estudos de caso curtos, contribuição de outros praticantes de computação de alto desempenho em Python.

O Advanced Python Development, de Matthew Wilkes (Apress), é um dos raros livros a incluir pequenos exemplos para explicar conceitos, mostrando ao mesmo tempo como desenvolver uma aplicação realista pronta para implantação em produção: um agregador de dados, similar aos sistemas de monitoramento DevOps ou aos coletores de dados para sensores distribuídos IoT. Dois capítulos no Advanced Python Development tratam de programação concorrente com threading e asyncio.

O Parallel Programming with Python (Packt, 2014), de Jan Palach, explica os principais conceitos por trás da concorrência e do paralelismo, abarcando a biblioteca padrão do Python bem como o Celery.

"The Truth About Threads" (A Verdade Sobre as Threads) é o título do capítulo 2 de Using Asyncio in Python, de Caleb Hattingh (O’Reilly).[284] O capítulo trata dos benefícios e das desvantagens das threads—com citações convincentes de várias fontes abalizadas—deixando claro que os desafios fundamentais das threads não tem relação com o Python ou a GIL. Citando literalmente a página 14 de Using Asyncio in Python:

Esses temas se repetem com frequência:

  • Programação com threads torna o código difícil de analisar.

  • Programação com threads é um modelo ineficiente para concorrência em larga escala (milhares de tarefas concorrentes).

Se você quiser aprender do jeito difícil como é complicado raciocinar sobre threads e travas—sem colocar seu emprego em risco—tente resolver os problemas no livro de Allen Downey The Little Book of Semaphores (Green Tea Press). O livro inclui exercícios muito difíceis e até sem solução conhecida, mas mesmo os fáceis são desafiadores.

19.9.2. A GIL

Se você ficou curioso sobre a GIL, lembre-se que não temos qualquer controle sobre ela a partir do código em Python, então a referência canônica é a documentação da C-API: Thread State and the Global Interpreter Lock (EN) (O Estado das Threads e a Trava Global do Interpretador). A resposta no FAQ Python Library and Extension (A Biblioteca e as Extensões do Python): "Can’t we get rid of the Global Interpreter Lock?" (Não podemos remover o Bloqueio Global do interpretador?). Também vale a pena ler os posts de Guido van Rossum e Jesse Noller (contribuidor do pacote multiprocessing), respectivamente: "It isn’t Easy to Remove the GIL" (Não é Fácil Remover a GIL) e "Python Threads and the Global Interpreter Lock" (As Threads do Python e a Trava Global do Interpretador).

CPython Internals, de Anthony Shaw (Real Python) explica a implementação do interpretador CPython 3 no nível da programação em C. O capítulo mais longo do livro é "Parallelism and Concurrency" (Paralelismo e Concorrência): um mergulho profundo no suporte nativo do Python a threads e processos, incluindo o gerenciamento da GIL por extensões usando a API C/Python.

Por fim, David Beazley apresentou uma exploração detalhada em "Understanding the Python GIL" (Entendendo a GIL do Python).[285] No slide 54 da apresentação, Beazley relata um aumento no tempo de processamento de uma benchmark específica com o novo algoritmo da GIL, introduzido no Python 3.2. O problema não tem importância com cargas de trabalho reais, de acordo com um comentário de Antoine Pitrou—​que implementou o novo algoritmo da GIL—​no relatório de bug submetido por Beazley: Python issue #7946.

19.9.3. Concorrência além da biblioteca padrão

O Python Fluente se concentra nos recursos fundamentais da linguagem e nas partes centrais da biblioteca padrão. Full Stack Python é um ótimo complemento para esse livro: é sobre o ecossistema do Python, com seções chamadas "Development Environments (Ambientes de Desenvolvimento)," "Data (Dados)," "Web Development (Desenvolvimento Web)," e "DevOps," entre outros.

Já mencionei dois livros que abordam a concorrência usando a biblioteca padrão do Python e também incluem conteúdo significativo sobre bibliotecas de terceiros e ferramentas:

High Performance Python, 2nd ed. e Parallel Programming with Python. O Distributed Computing with Python de Francesco Pierfederici (Packt) cobre a biblioteca padrão e também provedores de infraestrutura de nuvem e clusters HPC (High-Performance Computing, computação de alto desempenho).

O "Python, Performance, and GPUs" (EN) de Matthew Rocklin é uma atualização do status do uso de aceleradores GPU com Python, publicado em junho de 2019.

"O Instagram hoje representa a maior instalação do mundo do framework web Django, que é escrito inteiramente em Python." Essa é a linha de abertura do post "Web Service Efficiency at Instagram with Python" (EN), escrito por Min Ni—um engenheiro de software no Instagram. O post descreve as métricas e ferramentas usadas pelo Instagram para otimizar a eficiência de sua base de código Python, bem como para detectar e diagnosticar regressões de desempenho a cada uma das "30 a 50 vezes diárias" que o back-end é atualizado.

Architecture Patterns with Python: Enabling Test-Driven Development, Domain-Driven Design, and Event-Driven Microservices, de Harry Percival e Bob Gregory (O’Reilly) apresenta modelos de arquitetura para aplicações de servidor em Python. Os autores disponibilizaram o livro gratuitamente online em cosmicpython.com (EN).

Duas bibliotecas elegantes e fáceis de usar para tarefas de paralelização de processos são a lelo de João S. O. Bueno e a python-parallelize de Nat Pryce. O pacote lelo define um decorador @parallel que você pode aplicar a qualquer função para torná-la magicamente não-bloqueante: quando você chama uma função decorada, sua execução é iniciada em outro processo. O pacote python-parallelize de Nat Pryce fornece um gerador parallelize, que distribui a execução de um loop for por múltiplas CPUs. Ambos os pacotes são baseados na biblioteca multiprocessing.

Eric Snow, um dos desenvolvedores oficiais do Python, mantém um wiki chamado Multicore Python, com observações sobre os esforços dele e de outros para melhorar o suporte do Python a execução em paralelo. Snow é o autor da PEP 554—​Multiple Interpreters in the Stdlib. Se aprovada e implementada, a PEP 554 assenta as bases para melhorias futuras, que podem um dia permitir que o Python use múltiplos núcleos sem as sobrecargas do multiprocessing. Um dos grandes empecilhos é a iteração complexa entre múltiplos subinterpretadores ativos e extensões que assumem a existência de um único interpretador.

Mark Shannon—também um mantenedor do Python—criou uma tabela útil comparando os modelos de concorrência em Python, referida em uma discussão sobre subinterpretadores entre ele, Eric Snow e outros desenvolvedores na lista de discussão python-dev. Na tabela de Shannon, a coluna "Ideal CSP" se refere ao modelo teórico de notação _Communicating Sequential Processes (processos sequenciais comunicantes) (EN), proposto por Tony Hoare em 1978. Go também permite objetos compartilhados, violando uma das restrições essenciais do CSP: as unidades de execução devem se comunicar somente através de mensagens enviadas através de canais.

O Stackless Python (também conhecido como Stackless) é um fork do CPython que implementa microthreads, que são threads leves no nível da aplicação—ao contrário das threads do SO. O jogo online multijogador massivo EVE Online foi desenvolvido com Stackless, e os engenheiros da desenvolvedora de jogos CCP foram mantenedores do Stackless por algum tempo. Alguns recursos do Stackless foram reimplementados no interpretador Pypy e no pacote greenlet, a tecnologia central da biblioteca de programação em rede gevent, que por sua vez é a fundação do servidor de aplicação Gunicorn.

O modelo de atores (actor model) de programação concorrente está no centro das linguagens altamente escaláveis Erlang e Elixir, e é também o modelo do framework Akka para Scala e Java. Se você quiser experimentar o modelo de atores em Python, veja as bibliotecas Thespian e Pykka.

Minhas recomendações restantes fazem pouca ou nenhuma menção ao Python, mas de toda forma são relevantes para leitores interessados no tema do capítulo.

19.9.4. Concorrência e escalabilidade para além do Python

RabbitMQ in Action, de Alvaro Videla and Jason J. W. Williams (Manning), é uma introdução muito bem escrita ao RabbitMQ e ao padrão AMQP (Advanced Message Queuing Protocol, Protocolo Avançado de Enfileiramento de Mensagens), com exemplos em Python, PHP, e Ruby. Independente do resto de seu stack tecnológico, e mesmo se você planeja usar Celery com RabbitMQ debaixo dos panos, recomendo esse livro por sua abordagem dos conceitos, da motivação e dos modelos das filas de mensagem distribuídas, bem como a operação e configuração do RabbitMQ em larga escala.

Aprendi muito lendo Seven Concurrency Models in Seven Weeks, de Paul Butcher (Pragmatic Bookshelf), que traz o eloquente subtítulo When Threads Unravel.[286] O capítulo 1 do livro apresenta os conceitos centrais e os desafios da programação com threads e travas em Java.[287] Os outros seis capítulos do livro são dedicados ao que o autor considera as melhores alternativas para programação concorrente e paralela, e como funcionam com diferentes linguagens, ferramentas e bibliotecas. Os exemplos usam Java, Clojure, Elixir, e C (no capítulo sobre programação paralela com o framework OpenCL). O modelo CSP é exemplificado com código Clojure, apesar da linguagem Go merecer os créditos pela popularização daquela abordagem. Elixir é a linguagem dos exemplos ilustrando o modelo de atores. Um capítulo bonus alternativo (disponível online gratuitamente) sobre atores usa Scala e o framework Akka. A menos que você já saiba Scala, Elixir é uma linguagem mais acessível para aprender e experimentar o modelo de atores e plataforma de sistemas distribuídos Erlang/OTP.

Unmesh Joshi, da Thoughtworks contribuiu com várias páginas documentando os "Modelos de Sistemas Distribuídos" no blog de Martin Fowler. A página de abertura é uma ótima introdução ao assunto, com links para modelos individuais. Joshi está acrescentando modelos gradualmente, mas o que já está publicado espelha anos de experiência adquirida a duras penas em sistema de missão crítica.

O Designing Data-Intensive Applications, de Martin Kleppmann (O’Reilly), é um dos raros livros escritos por um profissional com vasta experiência na área e conhecimento acadêmico avançado. O autor trabalhou com infraestrutura de dados em larga escala no LinkedIn e em duas startups, antes de se tornar um pesquisador de sistemas distribuídos na Universidade de Cambridge. Cada capítulo do livro termina com uma extensa lista de referências, incluindo resultados de pesquisas recentes. O livro também inclui vários diagramas esclarecedores e lindos mapas conceituais.

Tive a sorte de estar na audiência do fantástico workshop de Francesco Cesarini sobre a arquitetura de sistemas distribuídos confiáveis, na OSCON 2016: "Designing and architecting for scalability with Erlang/OTP" (Projetando e estruturando para a escalabilidade com Erlang/OTP) (video na O’Reilly Learning Platform). Apesar do título, aos 9:35 no video, Cesarini explica:

Muito pouco do que vou dizer será específico de Erlang […]. Resta o fato de que o Erlang remove muitas dificuldades acidentais no desenvolvimento de sistemas resilientes que nunca falham, além serem escalonáveis. Então será mais fácil se vocês usarem Erlang ou uma linguagem rodando na máquina virtual Erlang.

Aquele workshop foi baseado nos últimos quatro capítulos do Designing for Scalability with Erlang/OTP de Francesco Cesarini e Steve Vinoski (O’Reilly).

Desenvolver sistemas distribuídos é desafiador e empolgante, mas cuidado com a inveja da escalabilidade na web. O princípio KISS (KISS é a sigla de Keep It Simple, Stupid: "Mantenha Isso Simples, Idiota") continua sendo uma recomendação firme de engenharia.

Veja também o artigo "Scalability! But at what COST?", de Frank McSherry, Michael Isard, e Derek G. Murray. Os autores identificaram sistemas paralelos de processamento de grafos apresentados em simpósios acadêmicos que precisavam de centenas de núcleos para superar "uma implementação competente com uma única thread." Eles também encontraram sistemas que "tem desempenho pior que uma thread em todas as configurações reportadas."

Essas descobertas me lembram uma piada hacker clássica:

Meu script Perl é mais rápido que seu cluster Hadoop.

Ponto de vista

Para gerenciar a complexidade, precisamos de restrições

Aprendi a programar em uma calculadora TI-58. Sua "linguagem" era similar ao assembler. Naquele nível, todas as "variáveis" eram globais, e não havia o conforto dos comandos estruturados de controle de fluxo. Existiam saltos condicionais: instruções que transferiam a execução diretamente para uma localização arbitrária—à frente ou atrás do local atual—dependendo do valor de um registrador ou de uma flag na CPU.

É possível fazer basicamente qualquer coisa em assembler, e esse é o desafio: há muito poucas restrições para evitar que você cometa erros, e para ajudar mantenedores a entender o código quando mudanças são necessárias.

A segunda linguagem que aprendi foi o BASIC desestruturado que vinha nos computadores de 8 bits—nada comparável ao Visual Basic, que surgiu muito mais tarde. Existiam os comandos FOR, GOSUB e RETURN, mas ainda nenhum conceito de variáveis locais. O GOSUB não permitia passagem de parâmetros: era apenas um GOTO mais chique, que inseria um número de linha de retorno em uma pilha, daí o RETURN tinha um local para onde pular de volta. Subrrotinas podiam ler dados globais, e escrever sobre eles também. Era preciso improvisar outras formas de controle de fluxo, com combinações de IF e GOTO—que, lembremos, permita pular para qualquer linha do programa.

Após alguns anos programando com saltos e variáveis globais, lembro da batalha para reestruturar meu cérebro para a "programação estruturada", quando aprendi Pascal. Agora precisava usar comandos de controle de fluxo em torno de blocos de código que tinham um único ponto de entrada. Não podia mais saltar para qualquer instrução que desejasse. Variáveis globais eram inevitáveis em BASIC, mas agora se tornaram tabu. Eu precisava repensar o fluxo de dados e passar argumentos para funções explicitamente.

Meu próximo desafio foi aprender programação orientada a objetos. No fundo, programação orientada a objetos é programação estruturada com mais restrições e polimorfismo. O ocultamento de informações força uma nova perspectiva sobre onde os dados moram. Me lembro de mais de uma vez ficar frustrado por ter que refatorar meu código, para que um método que estava escrevendo pudesse obter informações que estavam encapsuladas em um objeto que aquele método não conseguia acessar.

Linguagens de programação funcionais acrescentam outras restrições, mas a imutabilidade é a mais difícil de engolir, após décadas de programação imperativa e orientada a objetos. Após nos acostumarmos a tais restrições, as vemos como bençãos. Elas fazem com que pensar sobre o código se torne muito mais simples.

A falta de restrições é o maior problema com o modelo de threads—e—travas de programação concorrente. Ao resumir o capítulo 1 de Seven Concurrency Models in Seven Weeks, Paul Butcher escreveu:

A maior fraqueza da abordagem, entretanto, é que programação com threads—e—travas é difícil. Pode ser fácil para um projetista de linguagens acrescentá-las a uma linguagem, mas elas nos dão, a nós pobres programadores, muito pouca ajuda.

Alguns exemplos de comportamento sem restrições naquele modelo:

  • Threads podem compartilhar estruturas de dados mutáveis arbitrárias.

  • O agendador pode interromper uma thread em quase qualquer ponto, incluindo no meio de uma operação simples, como a += 1. Muito poucas operações são atômicas no nível das expressões do código-fonte.

  • Travas são, em geral, recomendações. Esse é um termo técnico, dizendo que você precisa lembrar de obter explicitamente uma trava antes de atualizar uma estrutura de dados compartilhada. Se você esquecer de obter a trava, nada impede seu código de bagunçar os dados enquanto outra thread, que obedientemente detém a trava, está atualizando os mesmos dados.

Em comparação, considere algumas restrições impostas pelo modelo de atores, no qual a unidade de execução é chamada de um actor ("ator"):[288]

  • Um ator pode manter um estado interno, mas não pode compartilhar esse estado com outros atores.

  • Atores só podem se comunicar enviando e recebendo mensagens.

  • Mensagens só contém cópias de dados, e não referências para dados mutáveis.

  • Um ator só processa uma mensagem de cada vez. Não há execução concorrente dentro de um único ator.

Claro, é possível adotar uma forma de programação ao estilo de ator para qualquer linguagem, seguindo essas regras. Você também pode usar idiomas de programação orientada a objetos em C, e mesmo modelos de programação estruturada em assembler. Mas fazer isso requer muita concordância e disciplina da parte de qualquer um que mexa no código.

Gerenciar travas é desnecessário no modelo de atores, como implementado em Erlang e Elixir, onde todos os tipos de dados são imutáveis.

Threads-e-travas não vão desaparecer. Eu só não acho que lidar com esse tipo de entidade básica seja um bom uso de meu tempo quando escrevo aplicações—e não módulos do kernel, drivers de hardware, ou bancos de dados.

Sempre me reservo o direito de mudar de opinião. Mas neste momento, estou convencido que o modelo de atores é o modelo de programação concorrente mais sensato que existe. CSP (Communicating Sequential Processes) também é sensato, mas sua implementação em Go deixa de fora algumas restrições. A ideia em CSP é que corrotinas (ou goroutines em Go) trocam dados e se sincronizam usando filas (chamadas channels, "canais", em Go). Mas Go também permite compartilhamento de memória e travas. Vi um livro sobre Go defende o uso de memória compartilhada e travas em vez de canais—em nome do desempenho. É difícil abandonar velhos hábitos.

20. Executores concorrentes

Quem fala mal de threads são tipicamente programadoras de sistemas, que tem em mente casos de uso que o típico programador de aplicações nunca vai encontrar na vida.[...] Em 99% dos casos de uso que o programador de aplicações vai encontrar, o modelo simples de gerar um monte de threads e coletar os resultados em uma fila é tudo que se precisa saber.

Michele Simionato, profundo pensador do Python. Do post de Michele Simionato, "Threads, processes and concurrency in Python: some thoughts" (_Threads, processos e concorrência em Python: algumas reflexões_), resumido assim: "Removendo exageros sobre a (não-)revolução dos múltiplos núcleos e alguns comentários sensatos (oxalá) sobre threads e outras formas de concorrência."

Este capítulo se concentra nas classes do concurrent.futures.Executor, que encapsulam o modelo de "gerar um monte de threads independentes e coletar os resultados em uma fila" descrito por Michele Simionato. Executores concorrentes tornam o uso desse modelo quase trivial, não apenas com threads mas também com processos—úteis para tarefas de processamento intensivo em CPU.

Também introduzo aqui o conceito de futures—objetos que representam a execução assíncrona de uma operação, similares às promises do Javascript. Essa ideia básica é a fundação de concurrent.futures bem como do pacote asyncio, assunto do Capítulo 21.

20.1. Novidades nesse capítulo

Renomeei este capítulo de "Concorrência com futures" para "Executores concorrentes", porque os executores são o recurso de alto nível mais importante tratado aqui. Futures são objetos de baixo nível, tratados na Seção 20.2.3, mas quase invisíveis no resto do capítulo.

Todos os exemplos de clientes HTTP agora usam a nova biblioteca HTTPX, que oferece APIs síncronas e assíncronas.

A configuração para os experimentos na Seção 20.5 ficou mais simples, graças ao servidor de múltiplas threads adicionado ao pacote http.server no Python 3.7. Antes, a biblioteca padrão oferecia apenas o BaseHttpServer de thread única, que não era adequado para experiências com clientes concorrentes, então na primeira edição desse livro precisei usar um servidor externo.

A Seção 20.3 agora demonstra como um executor simplifica o código que vimos na Seção 19.6.3.

Por fim, movi a maior parte da teoria para o novo Capítulo 19.

20.2. Downloads concorrentes da web

A concorrência é essencial para uma comunicação eficiente via rede: em vez de esperar de braços cruzados por respostas de máquinas remotas, a aplicação deveria fazer alguma outra coisa até a resposta chegar.[289]

Para demonstrar com código, escrevi três programas simples que baixam da web imagens de 20 bandeiras de países. O primeiro, flags.py, roda sequencialmente: ele só requisita a imagem seguinte quando a anterior foi baixada e salva localmente. Os outros dois scripts fazem downloads concorrentes: eles requisitam várias imagens quase ao mesmo tempo, e as salvam conforme chegam. O script flags_threadpool.py usa o pacote concurrent.futures, enquanto flags_asyncio.py usa asyncio.

O Exemplo 1 mostra o resultado da execução dos três scripts, três vezes cada um.

Os scripts baixam imagens de fluentpython.com, que usa uma CDN (Content Delivery Network, Rede de Fornecimento de Conteúdo), então você pode notar os resultados mais lentos nas primeiras passagens. Os resultados no Exemplo 1 foram obtidos após várias execuções, então o cache da CDN estava carregado.

Exemplo 1. Três execuções típicas dos scripts flags.py, flags_threadpool.py, e flags_asyncio.py
$ python3 flags.py
BD BR CD CN DE EG ET FR ID IN IR JP MX NG PH PK RU TR US VN  (1)
20 flags downloaded in 7.26s  (2)
$ python3 flags.py
BD BR CD CN DE EG ET FR ID IN IR JP MX NG PH PK RU TR US VN
20 flags downloaded in 7.20s
$ python3 flags.py
BD BR CD CN DE EG ET FR ID IN IR JP MX NG PH PK RU TR US VN
20 flags downloaded in 7.09s
$ python3 flags_threadpool.py
DE BD CN JP ID EG NG BR RU CD IR MX US PH FR PK VN IN ET TR
20 flags downloaded in 1.37s  (3)
$ python3 flags_threadpool.py
EG BR FR IN BD JP DE RU PK PH CD MX ID US NG TR CN VN ET IR
20 flags downloaded in 1.60s
$ python3 flags_threadpool.py
BD DE EG CN ID RU IN VN ET MX FR CD NG US JP TR PK BR IR PH
20 flags downloaded in 1.22s
$ python3 flags_asyncio.py  (4)
BD BR IN ID TR DE CN US IR PK PH FR RU NG VN ET MX EG JP CD
20 flags downloaded in 1.36s
$ python3 flags_asyncio.py
RU CN BR IN FR BD TR EG VN IR PH CD ET ID NG DE JP PK MX US
20 flags downloaded in 1.27s
$ python3 flags_asyncio.py
RU IN ID DE BR VN PK MX US IR ET EG NG BD FR CN JP PH CD TR  (5)
20 flags downloaded in 1.42s
  1. A saída de cada execução começa com os códigos dos países de cada bandeira a medida que as imagens são baixadas, e termina com uma mensagem mostrando o tempo decorrido.

  2. flags.py precisou em média de 7,18s para baixar 20 imagens.

  3. A média para flags_threadpool.py foi 1,40s.

  4. flags_asyncio.py, obteve um tempo médio de 1,35s.

  5. Observe a ordem do códigos de país: nos scripts concorrentes, as imagens foram baixadas em um ordem diferente a cada vez.

A diferença de desempenho entre os scripts concorrentes não é significativa, mas ambos são mais de cinco vezes mais rápidos que o script sequencial—e isto apenas para a pequena tarefa de baixar 20 arquivos, cada um com uns poucos kilobytes. Se você escalar a tarefa para centenas de downloads, os scripts concorrentes podem superar o código sequencial por um fator de 20 ou mais.

⚠️ Aviso

Ao testar clientes HTTP concorrentes usando servidores web públicos, você pode inadvertidamente lançar um ataque de negação de serviço (DoS, Denial of Service attack), ou se tornar suspeito de estar tentando um ataque. No caso do Exemplo 1 não há problema, pois aqueles scripts estão codificados para realizar apenas 20 requisições. Mais adiante nesse capítulo usaremos o pacote http.server do Python para executar nossos testes localmente.

Vamos agora estudar as implementações de dois dos scripts testados no Exemplo 1: flags.py e flags_threadpool.py. Vou deixar o terceiro, flags_asyncio.py, para o Capítulo 21, mas queria demonstrar os três juntos para fazer duas observações:

  1. Independente dos elementos de concorrência que você use—threads ou corrotinas—haverá um ganho enorme de desempenho sobre código sequencial em operações de E/S de rede, se o script for escrito corretamente.

  2. Para clientes HTTP que podem controlar quantas requisições eles fazem, não há diferenças significativas de desempenho entre threads e corrotinas.[290]

Vamos ver o código.

20.2.1. Um script de download sequencial

O Exemplo 2 contém a implementação de flags.py, o primeiro script que rodamos no Exemplo 1. Não é muito interessante, mas vamos reutilizar a maior parte do código e das configurações para implementar os scripts concorrentes, então ele merece alguma atenção.

✒️ Nota

Por clareza, não há qualquer tipo de tratamento de erro no Exemplo 2. Vamos lidar come exceções mais tarde, mas aqui quero me concentrar na estrutura básica do código, para facilitar a comparação deste script com os scripts que usam concorrência.

Exemplo 2. flags.py: script de download sequencial; algumas funções serão reutilizadas pelos outros scripts
import time
from pathlib import Path
from typing import Callable

import httpx  # (1)

POP20_CC = ('CN IN US ID BR PK NG BD RU JP '
            'MX PH VN ET EG DE IR TR CD FR').split()  # (2)

BASE_URL = 'https://www.fluentpython.com/data/flags'  # (3)
DEST_DIR = Path('downloaded')                         # (4)

def save_flag(img: bytes, filename: str) -> None:     # (5)
    (DEST_DIR / filename).write_bytes(img)

def get_flag(cc: str) -> bytes:  # (6)
    url = f'{BASE_URL}/{cc}/{cc}.gif'.lower()
    resp = httpx.get(url, timeout=6.1,       # (7)
                     follow_redirects=True)  # (8)
    resp.raise_for_status()  # (9)
    return resp.content

def download_many(cc_list: list[str]) -> int:  # (10)
    for cc in sorted(cc_list):                 # (11)
        image = get_flag(cc)
        save_flag(image, f'{cc}.gif')
        print(cc, end=' ', flush=True)         # (12)
    return len(cc_list)

def main(downloader: Callable[[list[str]], int]) -> None:  # (13)
    DEST_DIR.mkdir(exist_ok=True)                          # (14)
    t0 = time.perf_counter()                               # (15)
    count = downloader(POP20_CC)
    elapsed = time.perf_counter() - t0
    print(f'\n{count} downloads in {elapsed:.2f}s')

if __name__ == '__main__':
    main(download_many)     # (16)
  1. Importa a biblioteca httpx. Ela não é parte da biblioteca padrão. Assim, por convenção, a importação aparece após os módulos da biblioteca padrão e uma linha em branco.

  2. Lista do código de país ISO 3166 para os 20 países mais populosos, em ordem decrescente de população.

  3. O diretório com as imagens das bandeiras.[291]

  4. Diretório local onde as imagens são salvas.

  5. Salva os bytes de img para filename no DEST_DIR.

  6. Dado um código de país, constrói a URL e baixa a imagem, retornando o conteúdo binário da resposta.

  7. É uma boa prática adicionar um timeout razoável para operações de rede, para evitar ficar bloqueado sem motivo por vários minutos.

  8. Por default, o HTTPX não segue redirecionamentos.[292]

  9. Não há tratamento de erros nesse script, mas esse método lança uma exceção se o status do HTTP não está na faixa 2XX—algo mutio recomendado para evitar falhas silenciosas.

  10. download_many é a função chave para comparar com as implementações concorrentes.

  11. Percorre a lista de códigos de país em ordem alfabética, para facilitar a confirmação de que a ordem é preservada na saída; retorna o número de códigos de país baixados.

  12. Mostra um código de país por vez na mesma linha, para vermos o progresso a cada download. O argumento end=' ' substitui a costumeira quebra no final de cada linha escrita com um espaço, assim todos os códigos de país aparecem progressivamente na mesma linha. O argumento flush=True é necessário porque, por default, a saída do Python usa um buffer de linha, o que significa que o Python só mostraria os caracteres enviados após uma quebra de linha.

  13. main precisa ser chamada com a função que fará os downloads; dessa forma podemos usar main como uma função de biblioteca com outras implementações de download_many nos exemplos de threadpool e ascyncio.

  14. Cria o DEST_DIR se necessário; não acusa erro se o diretório existir.

  15. Mede e apresenta o tempo decorrido após rodar a função downloader.

  16. Chama main com a função download_many.

👉 Dica

A biblioteca HTTPX é inspirada no pacote pythônico requests, mas foi desenvolvida sobre bases mais modernas. Especialmente, HTTPX tem APIs síncronas e assíncronas, então podemos usá-la em todos os exemplos de clientes HTTP nesse capítulo e no próximo. A biblioteca padrão do Python contém o módulo urllib.request, mas sua API é exclusivamente síncrona, e não é muito amigável.

Não há mesmo nada de novo em flags.py. Ele serve de base para comparação com outros scripts, e o usei como uma biblioteca, para evitar código redundante ao implementar aqueles scripts. Vamos ver agora uma reimplementação usando concurrent.futures.

20.2.2. Download com concurrent.futures

Os principais recursos do pacote concurrent.futures são as classes ThreadPoolExecutor e ProcessPoolExecutor, que implementam uma API para submissão de callables ("chamáveis") para execução em diferentes threads ou processos, respectivamente. As classes gerenciam de forma transparente um grupo de threads ou processos de trabalho, e filas para distribuição de tarefas e coleta de resultados. Mas a interface é de um nível muito alto, e não precisamos saber nada sobre qualquer desses detalhes para um caso de uso simples como nossos downloads de bandeiras.

O Exemplo 3 mostra a forma mais fácil de implementar os downloads de forma concorrente, usando o método ThreadPoolExecutor.map.

Exemplo 3. flags_threadpool.py: script de download com threads, usando futures.ThreadPoolExecutor
from concurrent import futures

from flags import save_flag, get_flag, main  # (1)

def download_one(cc: str):  # (2)
    image = get_flag(cc)
    save_flag(image, f'{cc}.gif')
    print(cc, end=' ', flush=True)
    return cc

def download_many(cc_list: list[str]) -> int:
    with futures.ThreadPoolExecutor() as executor:         # (3)
        res = executor.map(download_one, sorted(cc_list))  # (4)

    return len(list(res))                                  # (5)

if __name__ == '__main__':
    main(download_many)  # (6)
  1. Reutiliza algumas funções do módulo flags (Exemplo 2).

  2. Função para baixar uma única imagem; isso é o que cada thread de trabalho vai executar.

  3. Instancia o ThreadPoolExecutor como um gerenciador de contexto; o método executor​.__exit__ vai chamar executor.shutdown(wait=True), que vai bloquear até que todas as threads terminem de rodar.

  4. O método map é similar ao map embutido, exceto que a função download_one será chamada de forma concorrente por múltiplas threads; ele retorna um gerador que você pode iterar para recuperar o valor retornado por cada chamada da função—nesse caso, cada chamada a download_one vai retornar um código de país.

  5. Retorna o número de resultados obtidos. Se alguma das chamadas das threads levantar uma exceção, aquela exceção será levantada aqui quando a chamada implícita next(), dentro do construtor de list, tentar recuperar o valor de retorno correspondente, no iterador retornado por executor.map.

  6. Chama a função main do módulo flags, passando a versão concorrente de download_many.

Observe que a função download_one do Exemplo 3 é essencialmente o corpo do loop for na função download_many do Exemplo 2. Essa é uma refatoração comum quando se está escrevendo código concorrente: transformar o corpo de um loop for sequencial em uma função a ser chamada de modo concorrente.

👉 Dica

O Exemplo 3 é muito curto porque pude reutilizar a maior parte das funções do script sequencial flags.py. Uma das melhores características do concurrent.futures é tornar simples a execução concorrente de código sequencial legado.

O construtor de ThreadPoolExecutor recebe muitos argumentos além dos mostrados aqui, mas o primeiro e mais importante é max_workers, definindo o número máximo de threads de trabalho a serem executadas. Quando max_workers é None (o default), ThreadPool​Executor decide seu valor usando, desde o Python 3.8, a seguinte expressão:

max_workers = min(32, os.cpu_count() + 4)

A justificativa é apresentada na documentação de ThreadPoolExecutor:

Esse valor default conserva pelo menos 5 threads de trabalho para tarefas de E/S. Ele utiliza no máximo 32 núcleos da CPU para tarefas de processamento, o quê libera a GIL. E ele evita usar recursos muitos grandes implicitamente em máquinas com muitos núcleos.

ThreadPoolExecutor agora também reutiliza threads de trabalho inativas antes iniciar [novas] threads de trabalho de max_workers.

Concluindo: o valor default calculado de max_workers é razoável, e ThreadPoolExecutor evita iniciar novas threads de trabalho desnecessariamente. Entender a lógica por trás de max_workers pode ajudar a decidir quando e como estabelecer o valor em seu código.

A biblioteca se chama concurrency.futures, mas não há qualquer future à vista no Exemplo 3, então você pode estar se perguntando onde estão eles. A próxima seção explica isso.

20.2.3. Onde estão os futures?

Os futures (literalmente "futuros") são componentes centrais de concurrent.futures e de asyncio, mas como usuários dessas bibliotecas, raramente os vemos. O Exemplo 3 depende de futures por trás do palco, mas o código apresentado não lida diretamente com objetos dessa classe. Essa seção apresenta uma visão geral dos futures, com um exemplo mostrando-os em ação.

Desde o Python 3.4, há duas classes chamadas Future na biblioteca padrão: concurrent.futures.Future e asyncio.Future. Elas tem o mesmo propósito: uma instância de qualquer das classes Future representa um processamento adiado, que pode ou não ter sido completado. Isso é algo similar à classe Deferred no Twisted, a classe Future no Tornado, e objetos Promise no Javascript moderno.

Os futures encapsulam operações pendentes de forma que possamos colocá-los em filas, verificar se terminaram, e recuperar resultados (ou exceções) quando eles ficam disponíveis.

Uma coisa importante de saber sobre futures é eu e você, não devemos criá-los: eles são feitos para serem instanciados exclusivamente pelo framework de concorrência, seja ela a concurrent.futures ou a asyncio. O motivo é que um Future representa algo que será executado em algum momento, portanto precisa ser agendado para rodar, e quem agenda tarefas é o framework.

Especificamente, instâncias concurrent.futures.Future são criadas apenas como resultado da submissão de um objeto invocável (callable) para execução a uma subclasse de concurrent.futures.Executor. Por exemplo, o método Executor.submit() recebe um invocável, agenda sua execução e retorna um Future.

O código da aplicação não deve mudar o estado de um future: o framework de concorrência muda o estado de um future quando o processamento que ele representa termina, e não temos como controlar quando isso acontece.

Ambos os tipos de Future tem um método .done() não-bloqueante, que retorna um Boolean informando se o invocável encapsulado por aquele future foi ou não executado. Entretanto, em vez de perguntar repetidamente se um future terminou, o código cliente em geral pede para ser notificado. Por isso as duas classes Future tem um método .add_done_callback(): você passa a ele um invocável e aquele invocável será invocado com o future como único argumento, quando o future tiver terminado. Observe que aquele invocável de callback será invocado na mesma thread ou processo de trabalho que rodou a função encapsulada no future.

Há também um método .result(), que funciona igual nas duas classes quando a execução do future termina: ele retorna o resultado do invocável, ou relança qualquer exceção que possa ter aparecido quando o invocável foi executado. Entretanto, quando o future não terminou, o comportamento do método result é bem diferente entre os dois sabores de Future. Em uma instância de concurrency.futures.Future, invocar f.result() vai bloquear a thread que chamou até o resultado ficar pronto. Um argumento timeout opcional pode ser passado, e se o future não tiver terminado após aquele tempo, o método result gera um TimeoutError. O método asyncio.Future.result não suporta um timeout, e await é a forma preferencial de obter o resultado de futures no asyncio—mas await não funciona com instâncias de concurrency.futures.Future.

Várias funções em ambas as bibliotecas retornam futures; outras os usam em sua implementação de uma forma transparente para o usuário. Um exemplo desse último caso é o Executor.map, que vimos no Exemplo 3: ele retorna um iterador no qual __next__ chama o método result de cada future, então recebemos os resultados dos futures, mas não os futures em si.

Para ver uma experiência prática com os futures, podemos reescrever o Exemplo 3 para usar a função concurrent.futures.as_completed, que recebe um iterável de futures e retorna um iterador que entrega futures quando cada um encerra sua execução.

Usar futures.as_completed exige mudanças apenas na função download_many. A chamada ao executor.map, de alto nível, é substituída por dois loops for: um para criar e agendar os futures, o outro para recuperar seus resultados. Já que estamos aqui, vamos acrescentar algumas chamadas a print para mostrar cada future antes e depois do término de sua execução. O Exemplo 4 mostra o código da nova função download_many. O código de download_many aumentou de 5 para 17 linhas, mas agora podemos inspecionar os misteriosos futures. As outras funções são idênticas as do Exemplo 3.

Exemplo 4. flags_threadpool_futures.py: substitui executor.map por executor.submit e futures.as_completed na função download_many
def download_many(cc_list: list[str]) -> int:
    cc_list = cc_list[:5]  # (1)
    with futures.ThreadPoolExecutor(max_workers=3) as executor:  # (2)
        to_do: list[futures.Future] = []
        for cc in sorted(cc_list):  # (3)
            future = executor.submit(download_one, cc)  # (4)
            to_do.append(future)  # (5)
            print(f'Scheduled for {cc}: {future}')  # (6)

        for count, future in enumerate(futures.as_completed(to_do), 1):  # (7)
            res: str = future.result()  # (8)
            print(f'{future} result: {res!r}')  # (9)

    return count
  1. Para essa demonstração, usa apenas os cinco países mais populosos.

  2. Configura max_workers para 3, para podermos ver os futures pendentes na saída.

  3. Itera pelos códigos de país em ordem alfabética, para deixar claro que os resultados vão aparecer fora de ordem.

  4. executor.submit agenda o invocável a ser executado, e retorna um future representando essa operação pendente.

  5. Armazena cada future, para podermos recuperá-los mais tarde com as_completed.

  6. Mostra uma mensagem com o código do país e seu respectivo future.

  7. as_completed entrega futures conforme eles terminam.

  8. Recupera o resultado desse future.

  9. Mostra o future e seu resultado.

Observe que a chamada a future.result() nunca bloqueará a thread nesse exemplo, pois future está vindo de as_completed. O Exemplo 5 mostra a saída de uma execução do Exemplo 4.

Exemplo 5. Saída de flags_threadpool_futures.py
$ python3 flags_threadpool_futures.py
Scheduled for BR: <Future at 0x100791518 state=running>  (1)
Scheduled for CN: <Future at 0x100791710 state=running>
Scheduled for ID: <Future at 0x100791a90 state=running>
Scheduled for IN: <Future at 0x101807080 state=pending>  (2)
Scheduled for US: <Future at 0x101807128 state=pending>
CN <Future at 0x100791710 state=finished returned str> result: 'CN'  (3)
BR ID <Future at 0x100791518 state=finished returned str> result: 'BR'  (4)
<Future at 0x100791a90 state=finished returned str> result: 'ID'
IN <Future at 0x101807080 state=finished returned str> result: 'IN'
US <Future at 0x101807128 state=finished returned str> result: 'US'

5 downloads in 0.70s
  1. Os futures são agendados em ordem alfabética; o repr() de um future mostra seu estado: os três primeiros estão running, pois há três threads de trabalho.

  2. Os dois últimos futures estão pending; esperando pelas threads de trabalho.

  3. O primeiro CN aqui é a saída de download_one em uma thread de trabalho; o resto da linha é a saída de download_many.

  4. Aqui, duas threads retornam códigos antes que download_many na thread principal possa mostrar o resultado da primeira thread.

👉 Dica

Recomendo experimentar com flags_threadpool_futures.py. Se você o rodar várias vezes, vai ver a ordem dos resultados variar. Aumentar max_workers para 5 vai aumentar a variação na ordem dos resultados. Diminuindo aquele valor para 1 fará o script rodar de forma sequencial, e a ordem dos resultados será sempre a ordem das chamadas a submit.

Vimos duas variantes do script de download usando concurrent.futures: uma no Exemplo 3 com ThreadPoolExecutor.map e uma no Exemplo 4 com futures.as_completed. Se você está curioso sobre o código de flags_asyncio.py, pode espiar o Exemplo 3 no Capítulo 21, onde ele é explicado.

Agora vamos dar uma olhada rápida em um modo simples de desviar da GIL para tarefas de uso intensivo de CPU, usando concurrent.futures.

20.3. Iniciando processos com concurrent.futures

A página de documentação de concurrent.futures tem por subtítulo "Iniciando tarefas em paralelo." O pacote permite computação paralela em máquinas multi-núcleo porque suporta a distribuição de trabalho entre múltiplos processos Python usando a classe ProcessPool​Executor.

Ambas as classes, ProcessPoolExecutor e ThreadPoolExecutor implementam a interface Executor, então é fácil mudar de uma solução baseada em threads para uma baseada em processos usando concurrent.futures.

Não há nenhuma vantagem em usar um ProcessPoolExecutor no exemplo de download de bandeiras ou em qualquer tarefa concentrada em E/S. É fácil comprovar isso; apenas modifique as seguintes linhas no Exemplo 3:

def download_many(cc_list: list[str]) -> int:
    with futures.ThreadPoolExecutor() as executor:

para:

def download_many(cc_list: list[str]) -> int:
    with futures.ProcessPoolExecutor() as executor:

O construtor de ProcessPoolExecutor também tem um parâmetro max_workers, que por default é None. Nesse caso, o executor limita o número de processos de trabalho ao número resultante de uma chamada a os.cpu_count().

Processos usam mais memória e demoram mais para iniciar que threads, então o real valor de of ProcessPoolExecutor é em tarefas de uso intensivo da CPU. Vamos voltar ao exemplo de teste de verificação de números primos deSeção 19.6, e reescrevê-lo com concurrent.futures.

20.3.1. Verificador de primos multinúcleo redux

Na Seção 19.6.3, estudamos procs.py, um script que verificava se alguns números grandes eram primos usando multiprocessing. No Exemplo 6 resolvemos o mesmo problema com o programa proc_pool.py, usando um ProcessPool​Executor. Do primeiro import até a chamada a main() no final, procs.py tem 43 linhas de código não-vazias, e proc_pool.py tem 31—28% mais curto.

Exemplo 6. proc_pool.py: procs.py reescrito com ProcessPoolExecutor
import sys
from concurrent import futures  # (1)
from time import perf_counter
from typing import NamedTuple

from primes import is_prime, NUMBERS

class PrimeResult(NamedTuple):  # (2)
    n: int
    flag: bool
    elapsed: float

def check(n: int) -> PrimeResult:
    t0 = perf_counter()
    res = is_prime(n)
    return PrimeResult(n, res, perf_counter() - t0)

def main() -> None:
    if len(sys.argv) < 2:
        workers = None      # (3)
    else:
        workers = int(sys.argv[1])

    executor = futures.ProcessPoolExecutor(workers)  # (4)
    actual_workers = executor._max_workers  # type: ignore  # (5)

    print(f'Checking {len(NUMBERS)} numbers with {actual_workers} processes:')

    t0 = perf_counter()

    numbers = sorted(NUMBERS, reverse=True)  # (6)
    with executor:  # (7)
        for n, prime, elapsed in executor.map(check, numbers):  # (8)
            label = 'P' if prime else ' '
            print(f'{n:16}  {label} {elapsed:9.6f}s')

    time = perf_counter() - t0
    print(f'Total time: {time:.2f}s')

if __name__ == '__main__':
    main()
  1. Não há necessidade de importar multiprocessing, SimpleQueue etc.; concurrent.futures esconde tudo isso.

  2. A tupla PrimeResult e a função check são as mesmas que vimos em procs.py, mas não precisamos mais das filas nem da função worker.

  3. Em vez de decidirmos por nós mesmos quantos processos de trabalho serão usados se um argumento não for passado na linha de comando, atribuímos None a workers e deixamos o ProcessPoolExecutor decidir.

  4. Aqui criei o ProcessPoolExecutor antes do bloco with em ➐, para poder mostrar o número real de processos na próxima linha.

  5. max_workers é um atributo de instância não-documentado de um ProcessPoolExecutor. Decidi usá-lo para mostrar o número de processos de trabalho criados quando a variável workers é None. O Mypy corretamente reclama quando eu acesso esse atributo, então coloquei o comentário type: ignore para silenciar a reclamação.

  6. Ordena os números a serem verificados em ordem descendente. Isso vai mostrar a diferença no comportamento de proc_pool.py quando comparado a procs.py. Veja a explicação após esse exemplo.

  7. Usa o executor como um gerenciador de contexto.

  8. A chamada a executor.map retorna as instâncias de PrimeResult retornadas por check na mesma ordem dos argumentos numbers.

Se você rodar o Exemplo 6, verá os resultados aparecente em ordem rigorosamente descendente, como mostrado no Exemplo 7. Por outro lado, a ordem da saída de procs.py (mostrado em Seção 19.6.1) é severamente influenciado pela dificuldade em verificar se cada número é ou não primo. Por exemplo, procs.py mostra o resultado para 7777777777777777 próximo ao topo, pois ele tem um divisor pequeno, 7, então is_prime rapidamente determina que ele não é primo.

Já o de 7777777536340681 is 881917092, então is_prime vai demorar muito mais para determinar que esse é um número composto, e ainda mais para descobrir que 7777777777777753 é primo—assim, ambos esses números aparecem próximo do final da saída de procs.py.

Ao rodar proc_pool.py, podemos observar não apenas a ordem descendente dos resultados, mas também que o programa parece emperrar após mostrar o resultado para 9999999999999999.

Exemplo 7. Saída de proc_pool.py
$ ./proc_pool.py
Checking 20 numbers with 12 processes:
9999999999999999     0.000024s  # (1)
9999999999999917  P  9.500677s  # (2)
7777777777777777     0.000022s  # (3)
7777777777777753  P  8.976933s
7777777536340681     8.896149s
6666667141414921     8.537621s
6666666666666719  P  8.548641s
6666666666666666     0.000002s
5555555555555555     0.000017s
5555555555555503  P  8.214086s
5555553133149889     8.067247s
4444444488888889     7.546234s
4444444444444444     0.000002s
4444444444444423  P  7.622370s
3333335652092209     6.724649s
3333333333333333     0.000018s
3333333333333301  P  6.655039s
 299593572317531  P  2.072723s
 142702110479723  P  1.461840s
               2  P  0.000001s
Total time: 9.65s
  1. Essa linha aparece muito rápido.

  2. Essa linha demora mais de 9,5s para aparecer.

  3. Todas as linhas restantes aparecem quase imediatamente.

Aqui está o motivo para aquele comportamento de proc_pool.py:

  • Como mencionado antes, executor.map(check, numbers) retorna o resultado na mesma ordem em que numbers é enviado.

  • Por default, proc_pool.py usa um número de processos de trabalho igual ao número de CPUs—isso é o que ProcessPoolExecutor faz quando max_workers é None. Nesse laptop são então 12 processos.

  • Como estamos submetendo numbers em ordem descendente, o primeiro é 9999999999999999; com 9 como divisor, ele retorna rapidamente.

  • O segundo número é 9999999999999917, o maior número primo na amostra. Ele vai demorar mais que todos os outros para verificar.

  • Enquanto isso, os 11 processos restantes estarão verificando outros números, que são ou primos ou compostos com fatores grandes ou compostos com fatores muito pequenos.

  • Quando o processo de trabalho encarregado de 9999999999999917 finalmente determina que ele é primo, todos os outros processos já completaram suas últimas tarefas, então os resultados aparecem logo depois.

✒️ Nota

Apesar do progresso de proc_pool.py não ser tão visível quanto o de procs.py, o tempo total de execução, para o mesmo número de processo de trabalho e de núcleos de CPU, é praticamente idêntico, como retratado em Figura 2.

Entender como programas concorrentes se comportam não é um processo direto, então aqui está um segundo experimento que pode ajudar a visualizar o funcionamento de Executor.map.

20.4. Experimentando com Executor.map

Vamos investigar Executor.map, agora usando um ThreadPoolExecutor com três threads de trabalho rodando cinco chamáveis que retornam mensagens marcadas com data/hora. O código está no Exemplo 8, o resultado no Exemplo 9.

Exemplo 8. demo_executor_map.py: Uma demonstração simples do método map de ThreadPoolExecutor
from time import sleep, strftime
from concurrent import futures

def display(*args):  # (1)
    print(strftime('[%H:%M:%S]'), end=' ')
    print(*args)

def loiter(n):  # (2)
    msg = '{}loiter({}): doing nothing for {}s...'
    display(msg.format('\t'*n, n, n))
    sleep(n)
    msg = '{}loiter({}): done.'
    display(msg.format('\t'*n, n))
    return n * 10  # (3)

def main():
    display('Script starting.')
    executor = futures.ThreadPoolExecutor(max_workers=3)  # (4)
    results = executor.map(loiter, range(5))  # (5)
    display('results:', results)  # (6)
    display('Waiting for individual results:')
    for i, result in enumerate(results):  # (7)
        display(f'result {i}: {result}')

if __name__ == '__main__':
    main()
  1. Essa função exibe o momento da execução no formato [HH:MM:SS] e os argumentos recebidos.

  2. loiter não faz nada além mostrar uma mensagem quanto inicia, dormir por n segundos, e mostrar uma mensagem quando termina; são usadas tabulações para indentar as mensagens de acordo com o valor de n.

  3. loiter retorna n * 10, então podemos ver como coletar resultados.

  4. Cria um ThreadPoolExecutor com três threads.

  5. Submete cinco tarefas para o executor. Já que há apenas três threads, apenas três daquelas tarefas vão iniciar imediatamente: a chamadas loiter(0), loiter(1), e loiter(2); essa é uma chamada não-bloqueante.

  6. Mostra imediatamente o results da invocação de executor.map: é um gerador, como se vê na saída no Exemplo 9.

  7. A chamada enumerate no loop for vai invocar implicitamente next(results), que por sua vez vai invocar f.result() no future (interno) f, representando a primeira chamada, loiter(0). O método result vai bloquear a thread até que o future termine, portanto cada iteração nesse loop vai esperar até que o próximo resultado esteja disponível.

Encorajo você a rodar o Exemplo 8 e ver o resultado sendo atualizado de forma incremental. Quando for fazer isso, mexa no argumento max_workers do ThreadPool​Executor e com a função range, que produz os argumentos para a chamada a executor.map—ou os substitua por listas com valores escolhidos, para criar intervalos diferentes.

O Exemplo 9 mostra uma execução típica do Exemplo 8.

Exemplo 9. Amostra da execução de demo_executor_map.py, do Exemplo 8
$ python3 demo_executor_map.py
[15:56:50] Script starting.  (1)
[15:56:50] loiter(0): doing nothing for 0s...  (2)
[15:56:50] loiter(0): done.
[15:56:50]      loiter(1): doing nothing for 1s...  (3)
[15:56:50]              loiter(2): doing nothing for 2s...
[15:56:50] results: <generator object result_iterator at 0x106517168>  (4)
[15:56:50]                      loiter(3): doing nothing for 3s...  (5)
[15:56:50] Waiting for individual results:
[15:56:50] result 0: 0  (6)
[15:56:51]      loiter(1): done. (7)
[15:56:51]                              loiter(4): doing nothing for 4s...
[15:56:51] result 1: 10  (8)
[15:56:52]              loiter(2): done.  (9)
[15:56:52] result 2: 20
[15:56:53]                      loiter(3): done.
[15:56:53] result 3: 30
[15:56:55]                              loiter(4): done.  (10)
[15:56:55] result 4: 40
  1. Essa execução começou em 15:56:50.

  2. A primeira thread executa loiter(0), então vai dormir por 0s e retornar antes mesmo da segunda thread ter chance de começar, mas YMMV.[293]

  3. loiter(1) e loiter(2) começam imediatamente (como o pool de threads tem três threads de trabalho, é possível rodar três funções de forma concorrente).

  4. Isso mostra que o results retornado por executor.map é um gerador: nada até aqui é bloqueante, independente do número de tarefas e do valor de max_workers.

  5. Como loiter(0) terminou, a primeira thread de trabalho está disponível para iniciar a quarta thread para loiter(3).

  6. Aqui é ponto a execução pode ser bloqueada, dependendo dos parâmetros passados nas chamadas a loiter: o método __next__ do gerador results precisa esperar até o primeiro future estar completo. Neste caso, ele não vai bloquear porque a chamada a loiter(0) terminou antes desse loop iniciar. Observe que tudo até aqui aconteceu dentro do mesmo segundo: 15:56:50.

  7. loiter(1) termina um segundo depois, em 15:56:51. A thread está livre para iniciar loiter(4).

  8. O resultado de loiter(1) é exibido: 10. Agora o loop for ficará bloqueado, esperando o resultado de loiter(2).

  9. O padrão se repete: loiter(2) terminou, seu resultado é exibido; o mesmo ocorre com loiter(3).

  10. Há um intervalo de 2s até loiter(4) terminar, porque ela começou em 15:56:51 e não fez nada por 4s.

A função Executor.map é fácil de usar, mas muitas vezes é preferível obter os resultados assim que estejam prontos, independente da ordem em que foram submetidos. Para fazer isso, precisamos de uma combinação do método Executor.submit e da função futures.as_completed como vimos no Exemplo 4. Vamos voltar a essa técnica na Seção 20.5.2.

👉 Dica

A combinação de Executor.submit e futures.as_completed é mais flexível que executor.map, pois você pode submit chamáveis e argumentos diferentes. Já executor.map é projetado para rodar o mesmo invocável com argumentos diferentes. Além disso, o conjunto de futures que você passa para futures.as_completed pode vir de mais de um executor—talvez alguns tenham sido criados por uma instância de ThreadPoolExecutor enquanto outros vem de um ProcessPoolExecutor.

Na próxima seção vamos retomar os exemplos de download de bandeiras com novos requerimentos que vão nos obrigar a iterar sobre os resultados de futures.as_completed em vez de usar executor.map.

20.5. Download com exibição do progresso e tratamento de erro

Como mencionado, os scripts em Seção 20.2 não tem tratamento de erros, para torná-los mais fáceis de ler e para comparar a estrutura das três abordagens: sequencial, com threads e assíncrona.

Para testar o tratamento de uma variedade de condições de erro, criei os exemplos flags2:

flags2_common.py

Este módulo contém as funções e configurações comuns, usadas por todos os exemplos flags2, incluindo a função main, que cuida da interpretação da linha de comando, da medição de tempo e de mostrar os resultados. Isso é código de apoio, sem relevância direta para o assunto desse capítulo, então não vou incluir o código-fonte aqui, mas você pode vê-lo no fluentpython/example-code-2e repositório: 20-executors/getflags/flags2_common.py.

flags2_sequential.py

Um cliente HTTP sequencial com tratamento de erro correto e a exibição de uma barra de progresso. Sua função download_one também é usada por flags2_threadpool.py.

flags2_threadpool.py

Cliente HTTP concorrente, baseado em futures.ThreadPoolExecutor, para demonstrar o tratamento de erros e a integração da barra de progresso.

flags2_asyncio.py

Mesma funcionalidade do exemplo anterior, mas implementado com asyncio e httpx. Isso será tratado na Seção 21.7, no Capítulo 21.

⚠️ Aviso
Tenha cuidado ao testar clientes concorrentes

Ao testar clientes HTTP concorrentes em servidores web públicos, você pode gerar muitas requisições por segundo, e é assim que ataques de negação de serviço (DoS, denial-of-service) são feitos. Controle cuidadosamente seus clientes quando for usar servidores públicos. Para testar, configure um servidor HTTP local. Veja o Configurando os servidores de teste para instruções.

A característica mais visível dos exemplos flags2 é sua barra de progresso animada em modo texto, implementada com o pacote tqdm. Publiquei um vídeo de 108s no YouTube mostrando a barra de progresso e comparando a velocidade dos três scripts flags2. No vídeo, começo com o download sequencial, mas interrompo a execução após 32s. O script demoraria mais de 5 minutos para acessar 676 URLs e baixar 194 bandeiras. Então rodo o script usando threads e o que usa asyncio, três vezes cada um, e todas as vezes eles completam a tarefa em 6s ou menos (isto é mais de 60 vezes mais rápido). A Figura 1 mostra duas capturas de tela: durante e após a execução de flags2_threadpool.py.

flags2_threadpool.py running with progress bar
Figura 1. Acima, à esquerda: flags2_threadpool.py rodando com a barra de progresso em tempo real gerada pelo tqdm; Abaixo, à direita: mesma janela do terminal após o script terminar de rodar.

O exemplo de uso mais simples do tqdm aparece em um .gif animado, no README.md do projeto. Se você digitar o código abaixo no console do Python após instalar o pacote tqdm, uma barra de progresso animada aparecerá no lugar onde está o comentário:

>>> import time
>>> from tqdm import tqdm
>>> for i in tqdm(range(1000)):
...     time.sleep(.01)
...
>>> # -> progress bar will appear here <-

Além do efeito elegante, o tqdm também é conceitualmente interessante: ele consome qualquer iterável, e produz um iterador que, enquanto é consumido, mostra a barra de progresso e estima o tempo restante para completar todas as iterações. Para calcular aquela estimativa, o tqdm precisa receber um iterável que tenha um len, ou receber adicionalmente o argumento total= com o número esperado de itens. Integrar o tqdm com nossos exemplos flags2 proporciona um oportunidade de observar mais profundamente o funcionamento real dos scripts concorrentes, pois nos obriga a usar as funções futures.as_completed e asyncio.as_completed, para permitir que o tqdm mostre o progresso conforme cada future é termina sua execução.

A outra característica dos exemplos flags2 é a interface de linha de comando. Todos os três scripts aceitam as mesmas opções, e você pode vê-las rodando qualquer um deles com a opção -h. O Exemplo 10 mostra o texto de ajuda.

Exemplo 10. Tela de ajuda dos scripts da série flags2
$ python3 flags2_threadpool.py -h
usage: flags2_threadpool.py [-h] [-a] [-e] [-l N] [-m CONCURRENT] [-s LABEL]
                            [-v]
                            [CC [CC ...]]

Download flags for country codes. Default: top 20 countries by population.

positional arguments:
  CC                    country code or 1st letter (eg. B for BA...BZ)

optional arguments:
  -h, --help            show this help message and exit
  -a, --all             get all available flags (AD to ZW)
  -e, --every           get flags for every possible code (AA...ZZ)
  -l N, --limit N       limit to N first codes
  -m CONCURRENT, --max_req CONCURRENT
                        maximum concurrent requests (default=30)
  -s LABEL, --server LABEL
                        Server to hit; one of DELAY, ERROR, LOCAL, REMOTE
                        (default=LOCAL)
  -v, --verbose         output detailed progress info

Todos os argumentos são opcionais. Mas o -s/--server é essencial para os testes: ele permite escolher qual servidor HTTP e qual porta serão usados no teste. Passe um desses parâmetros (insensíveis a maiúsculas/minúsculas) para determinar onde o script vai buscar as bandeiras:

LOCAL

Usa http://localhost:8000/flags; esse é o default. Você deve configurar um servidor HTTP local, respondendo na porta 8000. Veja as instruções na nota a seguir.

REMOTE

Usa http://fluentpython.com/data/flags; este é meu site público, hospedado em um servidor compartilhado. Por favor, não o martele com requisições concorrentes excessivas. O domínio fluentpython.com é gerenciado pela CDN (Content Delivery Network, Rede de Fornecimento de Conteúdo) da Cloudflare, então você pode notar que os primeiros downloads são mais lentos, mas ficam mais rápidos conforme o cache da CDN é carregado.

DELAY

Usa http://localhost:8001/flags; um servidor atrasando as respostas HTTP deve responder na porta 8001. Escrevi o slow_server.py para facilitar o experimento. Ele está no diretório 20-futures/getflags/ do repositório de código do Python Fluente. Veja as instruções na nota a seguir.

ERROR

Usa http://localhost:8002/flags; um servidor devolvendo alguns erros HTTP deve responder na porta 8002. Instruções a seguir.

✒️ Nota
Configurando os servidores de teste

Se você não tem um servidor HTTP local para testes, escrevi instruções de configuração usando apenas Python ≥ 3.9 (nenhuma biblioteca externa) em 20-executors/getflags/README.adoc no fluentpython/example-code-2e repositório. Em resumo, o README.adoc descreve como usar:

python3 -m http.server

O servidor LOCAL na porta 8000

python3 slow_server.py

O servidor DELAY na porta 8001, que acrescenta um atraso aleatório de 0,5s a 5s antes de cada resposta

python3 slow_server.py 8002 --error-rate .25

O servidor ERROR na porta 8002, que além do atraso aleatório tem uma chance de 25% de retornar um erro "418 I’m a teapot" como resposta

Por default, cada script flags2*.py irá baixar as bandeiras dos 20 países mais populosos do servidor LOCAL (http://localhost:8000/flags), usando um número default de conexões concorrentes, que varia de script para script. O Exemplo 11 mostra uma execução padrão do script flags2_sequential.py usando as configurações default. Para rodá-lo, você precisa de um servidor local, como explicado em Tenha cuidado ao testar clientes concorrentes.

Exemplo 11. Rodando flags2_sequential.py com todos os defaults: site LOCAL, as 20 bandeiras dos países mais populosos, 1 conexão concorrente
$ python3 flags2_sequential.py
LOCAL site: http://localhost:8000/flags
Searching for 20 flags: from BD to VN
1 concurrent connection will be used.
--------------------
20 flags downloaded.
Elapsed time: 0.10s

Você pode selecionar as bandeiras a serem baixadas de várias formas. O Exemplo 12 mostra como baixar todas as bandeiras com códigos de país começando pelas letras A, B ou C.

Exemplo 12. Roda flags2_threadpool.py para obter do servidor DELAY todas as bandeiras com prefixos de códigos de país A, B ou C
$ python3 flags2_threadpool.py -s DELAY a b c
DELAY site: http://localhost:8001/flags
Searching for 78 flags: from AA to CZ
30 concurrent connections will be used.
--------------------
43 flags downloaded.
35 not found.
Elapsed time: 1.72s

Independente de como os códigos de país são selecionados, o número de bandeiras a serem obtidas pode ser limitado com a opção -l/--limit. O Exemplo 13 demonstra como fazer exatamente 100 requisições, combinando a opção -a para obter todas as bandeiras com -l 100.

Exemplo 13. Roda flags2_asyncio.py para baixar 100 bandeiras (-al 100) do servidor ERROR, usando 100 requisições concorrentes (-m 100)
$ python3 flags2_asyncio.py -s ERROR -al 100 -m 100
ERROR site: http://localhost:8002/flags
Searching for 100 flags: from AD to LK
100 concurrent connections will be used.
--------------------
73 flags downloaded.
27 errors.
Elapsed time: 0.64s

Essa é a interface de usuário dos exemplos flags2. Vamos ver como eles estão implementados.

20.5.1. Tratamento de erros nos exemplos flags2

A estratégia comum em todos os três exemplos para lidar com erros HTTP é que erros 404 (not found) são tratados pela função encarregada de baixar um único arquivo (download_one). Qualquer outra exceção propaga para ser tratada pela função download_many ou pela corrotina supervisor—no exemplo de asyncio.

Vamos novamente começar estudando o código sequencial, que é mais fácil de compreender—e muito reutilizado pelo script com um pool de threads. O Exemplo 14 mostra as funções que efetivamente fazer os downloads nos scripts flags2_sequential.py e flags2_threadpool.py.

Exemplo 14. flags2_sequential.py: funções básicas encarregadas dos downloads; ambas são reutilizadas no flags2_threadpool.py
from collections import Counter
from http import HTTPStatus

import httpx
import tqdm  # type: ignore  # (1)

from flags2_common import main, save_flag, DownloadStatus  # (2)

DEFAULT_CONCUR_REQ = 1
MAX_CONCUR_REQ = 1

def get_flag(base_url: str, cc: str) -> bytes:
    url = f'{base_url}/{cc}/{cc}.gif'.lower()
    resp = httpx.get(url, timeout=3.1, follow_redirects=True)
    resp.raise_for_status()  # (3)
    return resp.content

def download_one(cc: str, base_url: str, verbose: bool = False) -> DownloadStatus:
    try:
        image = get_flag(base_url, cc)
    except httpx.HTTPStatusError as exc:  # (4)
        res = exc.response
        if res.status_code == HTTPStatus.NOT_FOUND:
            status = DownloadStatus.NOT_FOUND  # (5)
            msg = f'not found: {res.url}'
        else:
            raise  # (6)
    else:
        save_flag(image, f'{cc}.gif')
        status = DownloadStatus.OK
        msg = 'OK'

    if verbose:  # (7)
        print(cc, msg)

    return status
  1. Importa a biblioteca de exibição de barra de progresso tqdm, e diz ao Mypy para não checá-la.[294]

  2. Importa algumas funções e um Enum do módulo flags2_common.

  3. Dispara um HTTPStatusError se o código de status do HTTP não está em range(200, 300).

  4. download_one trata o HTTPStatusError, especificamente para tratar o código HTTP 404…​

  5. …​mudando seu status local para DownloadStatus.NOT_FOUND; DownloadStatus é um Enum importado de flags2_common.py.

  6. Qualquer outra exceção de HTTPStatusError é re-emitida e propagada para quem chamou a função.

  7. Se a opção de linha de comando -v/--verbose está vigente, o código do país e a mensagem de status são exibidos; é assim que você verá o progresso no modo verbose.

O Exemplo 15 lista a versão sequencial da função download_many. O código é simples, mas vale a pena estudar para compará-lo com as versões concorrentes que veremos a seguir. Se concentre em como ele informa o progresso, trata erros e conta os downloads.

Exemplo 15. flags2_sequential.py: a implementação sequencial de download_many
def download_many(cc_list: list[str],
                  base_url: str,
                  verbose: bool,
                  _unused_concur_req: int) -> Counter[DownloadStatus]:
    counter: Counter[DownloadStatus] = Counter()  # (1)
    cc_iter = sorted(cc_list)  # (2)
    if not verbose:
        cc_iter = tqdm.tqdm(cc_iter)  # (3)
    for cc in cc_iter:
        try:
            status = download_one(cc, base_url, verbose)  # (4)
        except httpx.HTTPStatusError as exc:  # (5)
            error_msg = 'HTTP error {resp.status_code} - {resp.reason_phrase}'
            error_msg = error_msg.format(resp=exc.response)
        except httpx.RequestError as exc:  # (6)
            error_msg = f'{exc} {type(exc)}'.strip()
        except KeyboardInterrupt:  # (7)
            break
        else:  # (8)
            error_msg = ''

        if error_msg:
            status = DownloadStatus.ERROR  # (9)
        counter[status] += 1           # (10)
        if verbose and error_msg:      # (11)
            print(f'{cc} error: {error_msg}')

    return counter  # (12)
  1. Este Counter vai registrar os diferentes resultados possíveis dos downloads: DownloadStatus.OK, DownloadStatus.NOT_FOUND, ou DownloadStatus.ERROR.

  2. cc_iter mantém a lista de códigos de país recebidos como argumentos, em ordem alfabética.

  3. Se não estamos rodando em modo verbose, cc_iter é passado para o tqdm, que retorna um iterador que produz os itens em cc_iter enquanto também anima a barra de progresso.

  4. Faz chamadas sucessivas a download_one.

  5. As exceções do código de status HTTP ocorridas em get_flag e não tratadas por download_one são tratadas aqui.

  6. Outras exceções referentes à rede são tratadas aqui. Qualquer outra exceção vai interromper o script, porque a função flags2_common.main, que chama download_many, não tem nenhum try/except.

  7. Sai do loop se o usuário pressionar Ctrl-C.

  8. Se nenhuma exceção saiu de download_one, limpa a mensagem de erro.

  9. Se houve um erro, muda o status local de acordo com o erro.

  10. Incrementa o contador para aquele status.

  11. Se no modo verbose, mostra a mensagem de erro para o código de país atual, se houver.

  12. Retorna counter para que main possa mostrar os números no relatório final.

Agora vamos estudar flags2_threadpool.py, o exemplo de pool de threads refatorado.

20.5.2. Usando futures.as_completed

Para integrar a barra de progresso do tqdm e tratar os erros a cada requisição, o script flags2_threadpool.py usa o futures.ThreadPoolExecutor com a função, já vista anteriormente, futures.as_completed. O Exemplo 16 é a listagem completa de flags2_threadpool.py. Apenas a função download_many é implementada; as outras funções são reutilizadas de flags2_common.py e flags2_sequential.py.

Exemplo 16. flags2_threadpool.py: listagem completa
from collections import Counter
from concurrent.futures import ThreadPoolExecutor, as_completed

import httpx
import tqdm  # type: ignore

from flags2_common import main, DownloadStatus
from flags2_sequential import download_one  # (1)

DEFAULT_CONCUR_REQ = 30  # (2)
MAX_CONCUR_REQ = 1000  # (3)


def download_many(cc_list: list[str],
                  base_url: str,
                  verbose: bool,
                  concur_req: int) -> Counter[DownloadStatus]:
    counter: Counter[DownloadStatus] = Counter()
    with ThreadPoolExecutor(max_workers=concur_req) as executor:  # (4)
        to_do_map = {}  # (5)
        for cc in sorted(cc_list):  # (6)
            future = executor.submit(download_one, cc,
                                     base_url, verbose)  # (7)
            to_do_map[future] = cc  # (8)
        done_iter = as_completed(to_do_map)  # (9)
        if not verbose:
            done_iter = tqdm.tqdm(done_iter, total=len(cc_list))  # (10)
        for future in done_iter:  # (11)
            try:
                status = future.result()  # (12)
            except httpx.HTTPStatusError as exc:  # (13)
                error_msg = 'HTTP error {resp.status_code} - {resp.reason_phrase}'
                error_msg = error_msg.format(resp=exc.response)
            except httpx.RequestError as exc:
                error_msg = f'{exc} {type(exc)}'.strip()
            except KeyboardInterrupt:
                break
            else:
                error_msg = ''

            if error_msg:
                status = DownloadStatus.ERROR
            counter[status] += 1
            if verbose and error_msg:
                cc = to_do_map[future]  # (14)
                print(f'{cc} error: {error_msg}')

    return counter


if __name__ == '__main__':
    main(download_many, DEFAULT_CONCUR_REQ, MAX_CONCUR_REQ)
  1. Reutiliza download_one de flags2_sequential (Exemplo 14).

  2. Se a opção de linha de comando -m/--max_req não é passada, este será o número máximo de requisições concorrentes, implementado como o tamanho do poll de threads; o número real pode ser menor se o número de bandeiras a serem baixadas for menor.

  3. MAX_CONCUR_REQ limita o número máximo de requisições concorrentes independente do número de bandeiras a serem baixadas ou da opção de linha de comando -m/--max_req. É uma medida de segurança, para evitar iniciar threads demais, com seu uso significativo de memória.

  4. Cria o executor com max_workers determinado por concur_req, calculado pela função main como o menor de: MAX_CONCUR_REQ, o tamanho de cc_list, ou o valor da opção de linha de comando -m/--max_req. Isso evita criar mais threads que o necessário.

  5. Este dict vai mapear cada instância de Future—representando um download—com o respectivo código de país, para exibição de erros.

  6. Itera sobre a lista de códigos de país em ordem alfabética. A ordem dos resultados vai depender, mais do que de qualquer outra coisa, do tempo das respostas HTTP; mas se o tamanho do pool de threads (dado por concur_req) for muito menor que len(cc_list), você poderá ver os downloads aparecendo em ordem alfabética.

  7. Cada chamada a executor.submit agenda a execução de uma invocável e retorna uma instância de Future. O primeiro argumento é a invocável, o restante são os argumentos que ela receberá.

  8. Armazena o future e o código de país no dict.

  9. futures.as_completed retorna um iterador que produz futures conforme cada tarefa é completada.

  10. Se não estiver no modo verbose, passa o resultado de as_completed com a função tqdm, para mostrar a barra de progresso; como done_iter não tem len, precisamos informar o tqdm qual o número de itens esperado com o argumento total=, para que ele possa estimar o trabalho restante.

  11. Itera sobre os futures conforme eles vão terminando.

  12. Chamar o método result em um future retorna ou o valor retornado pela invocável ou dispara qualquer exceção que tenha sido capturada quando a invocável foi executada. Esse método pode bloquear quem chama, esperando por uma resolução. Mas não nesse exemplo, porque as_completed só retorna futures que terminaram sua execução.

  13. Trata exceções em potencial; o resto dessa função é idêntica à função download_many no Exemplo 15), exceto pela observação a seguir.

  14. Para dar contexto à mensagem de erro, recupera o código de país do to_do_map, usando o future atual como chave. Isso não era necessário na versão sequencial, pois estávamos iterando sobre a lista de códigos de país, então sabíamos qual era o cc atual; aqui estamos iterando sobre futures.

👉 Dica

O Exemplo 16 usa um idioma que é muito útil com futures.as_completed: construir um dict mapeando cada future a outros dados que podem ser úteis quando o future terminar de executar. Aqui o to_do_map mapeia cada future ao código de país atribuído a ele. Isso torna fácil realizar o pós-processamento com os resultados dos futures, apesar deles serem produzidos fora de ordem.

As threads do Python são bastante adequadas a aplicações de uso intensivo de E/S, e o pacote concurrent.futures as torna relativamente simples de implementar em certos casos de uso. Com ProcessPoolExecutor você também pode resolver problemas de uso intensivo de CPU em múltiplos núcleos—se o processamento for "embaraçosamente paralelo". Isso encerra nossa introdução básica a concurrent.futures.

20.6. Resumo do capítulo

Nós começamos o capítulo comparando dois clientes HTTP concorrentes com um sequencial, demonstrando que as soluções concorrentes mostram um ganho significativo de desempenho sobre o script sequencial.

Após estudar o primeiro exemplo, baseado no concurrent.futures, olhamos mais de perto os objetos future, instâncias de concurrent.futures.Future ou de asyncio​.Future, enfatizando as semelhanças entre essas classes (suas diferenças serão examinadas no Capítulo 21). Vimos como criar futures chamando Executor.submit, e como iterar sobre futures que terminaram sua execução com concurrent.futures.as_completed.

Então discutimos o uso de múltiplos processos com a classe concurrent.futures.ProcessPoolExecutor, para evitar a GIL e usar múltiplos núcleos de CPU, simplificando o verificador de números primos multi-núcleo que vimos antes no Capítulo 19.

Na seção seguinte vimos como funciona a concurrent.futures.ThreadPoolExecutor, com um exemplo didático, iniciando tarefas que apenas não faziam nada por alguns segundos, exceto exibir seu status e a hora naquele instante.

Nós então voltamos para os exemplos de download de bandeiras. Melhorar aqueles exemplos com uma barra de progresso e tratamento de erro adequado nos ajudou a explorar melhor a função geradora future.as_completed mostrando um modelo comum: armazenar futures em um dict para anexar a eles informação adicional quando são submetidos, para podermos usar aquela informação quando o future sai do iterador as_completed.

20.7. Para saber mais

O pacote concurrent.futures foi uma contribuição de Brian Quinlan, que o apresentou em uma palestra sensacional intitulada "The Future Is Soon!" (EN), na PyCon Australia 2010. A palestra de Quinlan não tinha slides; ele mostra o que a biblioteca faz digitando código diretamente no console do Python. Como exemplo motivador, a apresentação inclui um pequeno vídeo com o cartunista/programador do XKCD, Randall Munroe, executando um ataque de negação de serviço (DoS) não-intencional contra o Google Maps, para criar um mapa colorido de tempos de locomoção pela cidade. A introdução formal à biblioteca é a PEP 3148 - futures - execute computations asynchronously (`futures` - executar processamento assíncrono) (EN). Na PEP, Quinlan escreveu que a biblioteca concurrent.futures foi "muito influenciada pelo pacote java.util.concurrent do Java."

Para recursos adicionais falando do concurrent.futures, por favor consulte o Capítulo 19. Todas as referências que tratam de threading e multiprocessing do Python na Seção 19.9.1 também tratam do concurrent.futures.

Ponto de vista

Evitando Threads

Concorrência: um dos tópicos mais difíceis na ciência da computação (normalmente é melhor evitá-lo).

David Beazley, educador Python e cientista louco—Slide #9 do tutorial "A Curious Course on Coroutines and Concurrency" ("Um Curioso Curso sobre Corrotinas e Concorrência") (EN), apresentado na PyCon 2009.

Eu concordo com as citações aparentemente contraditórias de David Beazley e Michele Simionato no início desse capítulo.

Assisti um curso de graduação sobre concorrência. Tudo o que vimos foi programação de threads POSIX. O que aprendi: que não quero gerenciar threads e travas pessoalmente, pela mesma razão que não quero gerenciar a alocação e desalocação de memória pessoalmente. Essas tarefas são melhor desempenhadas por programadores de sistemas, que tem o conhecimento, a inclinação e o tempo para fazê-las direito—ou assim esperamos. Sou pago para desenvolver aplicações, não sistemas operacionais. Não preciso desse controle fino de threads, travas, malloc e free—veja "Alocação dinâmica de memória em C".

Por isso acho o pacote concurrent.futures interessante: ele trata threads, processos, e filas como infraestrutura, algo a seu serviço, não algo que você precisa controlar diretamente. Claro, ele foi projetado pensando em tarefas simples, os assim chamado problemas embaraçosamente paralelos—ao contrário de sistemas operacionais ou servidores de banco de dados, como aponta Simionato naquela citação.

Para problemas de concorrência "não embaraçosos", threads e travas também não são a solução. Ao nível do sistema operacional, as threads nunca vão desaparecer. Mas todas as linguagens de programação que achei empolgantes nos últimos muitos anos fornecem abstrações de alto nível para concorrência, como demonstra o excelente livro de Paul Butcher, Seven Concurrency Models in Seven Weeks (Sete Modelos de Concorrência em Sete Semanas) (EN). Go, Elixir, e Clojure estão entre elas. Erlang—a linguagem de implementação do Elixir—é um exemplo claro de uma linguagem projetada desde o início pensando em concorrência. Erlang não me excita por uma razão simples: acho sua sintaxe feia. O Python me acostumou mal.

José Valim, antes um dos contribuidores centrais do Ruby on Rails, projetou o Elixir com uma sintaxe moderna e agradável. Como Lisp e Clojure, o Elixir implementa macros sintáticas. Isso é uma faca de dois gumes. Macros sintáticas permitem criar DSLs poderosas, mas a proliferação de sub-linguagens pode levar a bases de código incompatíveis e à fragmentação da comunidade. O Lisp se afogou em um mar de macros, cada empresa e grupo de desenvolvedores Lisp usando seu próprio dialeto arcano. A padronização em torno do Common Lisp resultou em uma linguagem inchada. Espero que José Valim inspire a comunidade do Elixir a evitar um destino semelhante. Até agora, o cenário parece bom. O invólucro de bancos de dados e gerador de queries Ecto é muito agradável de usar: um grande exemplo do uso de macros para criar uma DSL—sigla de Domain-Specific Language, Linguagem de Domínio Específico—flexível mas amigável, para interagir com bancos de dados relacionais e não-relacionais.

Como o Elixir, o Go é uma linguagem moderna com ideias novas. Mas, em alguns aspectos, é uma linguagem conservadora, comparada ao Elixir. O Go não tem macros, e sua sintaxe é mais simples que a do Python. O Go não suporta herança ou sobrecarga de operadores, e oferece menos oportunidades para metaprogramação que o Python. Essas limitações são consideradas benéficas. Elas levam a comportamentos e desempenho mais previsíveis. Isso é uma grande vantagem em ambientes de missão crítica altamente concorrentes, onde o Go pretende substituir C++, Java e Python.

Enquanto Elixir e Go são competidores diretos no espaço da alta concorrência, seus projetos e filosofias atraem públicos diferentes. Ambos tem boas chances de prosperar. Mas historicamente, as linguagens mais conservadoras tendem a atrair mais programadores.

21. Programação assíncrona

O problema com as abordagens usuais da programação assíncrona é que elas são propostas do tipo "tudo ou nada". Ou você reescreve todo o código, de forma que nada nele bloqueie [o processamento] ou você está só perdendo tempo.

Alvaro Videla e Jason J. W. Williams, RabbitMQ in Action (RabbitMQ em Ação)Videla & Williams, RabbitMQ in Action (RabbitMQ em Ação) (Manning), Capítulo 4, "Solving Problems with Rabbit: coding and patterns (Resolvendo Problemas com Rabbit: programação e modelos)," p. 61.

Este capítulo trata de três grandes tópicos intimamente interligados:

  • Os elementos de linguagem async def, await, async with, e async for do Python;

  • Objetos que suportam tais elementos através de métodos especiais como __await__, __aiter__ etc., tais como corrotinas nativas e variantes assíncronas de gerenciadores de contexto, iteráveis, geradores e compreensões;

  • asyncio e outras bibliotecas assíncronas.

Este capítulo parte das ideias de iteráveis e geradores (Capítulo 17, em particular da Seção 17.13), gerenciadores de contexto (no Capítulo 18), e conceitos gerais de programação concorrente (no Capítulo 19).

Vamos estudar clientes HTTP concorrentes similares aos vistos no Capítulo 20, reescritos com corrotinas nativas e gerenciadores de contexto assíncronos, usando a mesma biblioteca HTTPX de antes, mas agora através de sua API assíncrona. Veremos também como evitar o bloqueio do loop de eventos, delegando operações lentas para um executor de threads ou processos.

Após os exemplos de clientes HTTP, teremos duas aplicações simples de servidor, uma delas usando o framework cada vez mais popular FastAPI. A seguir tratamos de outros artefatos da linguagem viabilizados pelas palavras-chave async/await: funções geradoras assíncronas, compreensões assíncronas, e expressões geradoras assíncronas. Para realçar o fato daqueles recursos da linguagem não estarem limitados ao asyncio, veremos um exemplo reescrito para usar a Curio—o elegante e inovador framework inventado por David Beazley.

Finalizando o capítulo, escrevi uma pequena seção sobre vantagens e armadilhas da programação assíncrona.

Há um longo caminho à nossa frente. Teremos espaço apenas para exemplos básicos, mas eles vão ilustrar as características mais importantes de cada ideia.

👉 Dica

A documentação do asyncio melhorou muito após Yury Selivanov[295] reorganizá-la, dando maior destaque às funções úteis para desenvolvedores de aplicações. A maior parte da API de asyncio consiste em funções e classes voltadas para criadores de pacotes como frameworks web e drivers de bancos de dados, ou seja, são necessários para criar bibliotecas assíncronas, mas não aplicações.

Para mais profundidade sobre asyncio, recomendo Using Asyncio in Python ("Usando Asyncio em Python") de Caleb Hattingh (O’Reilly). Política de transparência: Caleb é um dos revisores técnicos deste livro.

21.1. Novidades nesse capítulo

Quando escrevi a primeira edição de Python Fluente, a biblioteca asyncio era provisória e as palavras-chave async/await não existiam. Assim, todos os exemplos desse capítulo precisaram ser atualizados. Também criei novos exemplos: scripts de sondagem de domínios, um serviço web com FastAPI, e experimentos com o novo modo assíncrono do console do Python.

Novas seções tratam de recursos da linguagem inexistentes naquele momento, como corrotinas nativas, async with, async for, e os objetos que suportam essas instruções.

As ideias na Seção 21.13 refletem lições importantes tiradas da experiência prática, e a considero uma leitura essencial para qualquer um trabalhando com programação assíncrona. Elas podem ajudar você a evitar muitos problemas—seja no Python, seja no Node.js.

Por fim, removi vários parágrafos sobre asyncio.Futures, que agora considero parte das APIs de baixo nível do asyncio.

21.2. Algumas definições.

No início da Seção 17.13, vimos que, desde o Python 3.5, a linguagem oferece três tipos de corrotinas:

Corrotina nativa

Uma função corrotina definida com async def. Você pode delegar de uma corrotina nativa para outra corrotina nativa, usando a palavra-chave await, de forma similar àquela como as corrotinas clássicas usam yield from. O comando async def sempre define uma corrotina nativa, mesmo se a palavra-chave await não seja usada em seu corpo. A palavra-chave await não pode ser usada fora de uma corrotina nativa.[296]

Corrotina clássica

Uma função geradora que consome dados enviados a ela via chamadas a my_coro.send(data), e que lê aqueles dados usando yield em uma expressão. Corrotinas clássicas podem delegar para outras corrotinas clássicas usando yield from. Corrotinas clássicas não podem ser controladas por await, e não são mais suportadas pelo asyncio.

Corrotinas baseadas em geradoras

Uma função geradora decorada com @types.coroutine—introduzido no Python 3.5. Esse decorador torna a geradora compatível com a nova palavra-chave await.

Nesse capítulo vamos nos concentrar nas corrotinas nativas, bem como nas geradoras assíncronas:

Geradora assíncrona

Uma função geradora definida com async def que usa yield em seu corpo. Ela devolve um objeto gerador assíncrono que oferece um __anext__, um método corrotina para obter o próximo item.

⚠️ Aviso
@asyncio.coroutine Não Tem Futuro[297]

O decorador @asyncio.coroutine para corrotinas clássicas e corrotinas baseadas em gerador foi descontinuado no Python 3.8, e está previsto para ser removido no Python 3.11, de acordo com o Issue 43216. Por outro lado, @types.coroutine deve continuar existindo, como se vê aqui: Issue 36921. Esse decorador não é mais suportado pelo asyncio, mas é usado em código interno nos frameworks assíncronos Curio e Trio.

21.3. Um exemplo de asyncio: sondando domínios

Imagine que você esteja prestes a lançar um novo blog sobre Python, e planeje registrar um domínio usando uma palavra-chave do Python e o sufixo .DEV—por exemplo, AWAIT.DEV. O Exemplo 1 é um script usando asyncio que verifica vários domínios de forma concorrente. Essa é saída produzida pelo script:

$ python3 blogdom.py
  with.dev
+ elif.dev
+ def.dev
  from.dev
  else.dev
  or.dev
  if.dev
  del.dev
+ as.dev
  none.dev
  pass.dev
  true.dev
+ in.dev
+ for.dev
+ is.dev
+ and.dev
+ try.dev
+ not.dev

Observe que os domínios aparecem fora de ordem. Se você rodar o script, os verá sendo exibidos um após o outro, a intervalos variados. O sinal de + indica que sua máquina foi capaz de resolver o domínio via DNS. Caso contrário, o domínio não foi resolvido e pode estar disponível.[298]

No blogdom.py, a sondagem de DNS é feita por objetos corrotinas nativas. Como as operações assíncronas são intercaladas, o tempo necessário para verificar 18 domínios é bem menor que se eles fosse verificados sequencialmente. Na verdade, o tempo total é quase o igual ao da resposta mais lenta, em vez da soma dos tempos de todas as respostas do DNS.

O Exemplo 1 mostra o código dp blogdom.py.

Exemplo 1. blogdom.py: procura domínios para um blog sobre Python
#!/usr/bin/env python3
import asyncio
import socket
from keyword import kwlist, softkwlist

MAX_KEYWORD_LEN = 4  # (1)
KEYWORDS = sorted(kwlist + softkwlist)

async def probe(domain: str) -> tuple[str, bool]:  # (2)
    loop = asyncio.get_running_loop()  # (3)
    try:
        await loop.getaddrinfo(domain, None)  # (4)
    except socket.gaierror:
        return (domain, False)
    return (domain, True)


async def main() -> None:  # (5)
    names = (kw for kw in KEYWORDS if len(kw) <= MAX_KEYWORD_LEN)  # (6)
    domains = (f'{name}.dev'.lower() for name in names)  # (7)
    coros = [probe(domain) for domain in domains]  # (8)
    for coro in asyncio.as_completed(coros):  # (9)
        domain, found = await coro  # (10)
        mark = '+' if found else ' '
        print(f'{mark} {domain}')


if __name__ == '__main__':
    asyncio.run(main())  # (11)
  1. Estabelece o comprimento máximo da palavra-chave para domínios, pois quanto menor, melhor.

  2. probe devolve uma tupla com o nome do domínio e um valor booleano; True significa que o domínio foi resolvido. Incluir o nome do domínio aqui facilita a exibição dos resultados.

  3. Obtém uma referência para o loop de eventos do asyncio, para usá-la a seguir.

  4. O método corrotina loop.getaddrinfo(…) devolve uma tupla de parâmetros com cinco partes para conectar ao endereço dado usando um socket. Neste exemplo não precisamos do resultado. Se conseguirmos um resultado, o domínio foi resolvido; caso contrário, não.

  5. main tem que ser uma corrotina, para podemros usar await aqui.

  6. Gerador para produzir palavras-chave com tamanho até MAX_KEYWORD_LEN.

  7. Gerador para produzir nome de domínio com o sufixo .dev.

  8. Cria uma lista de objetos corrotina, invocando a corrotina probe com cada argumento domain.

  9. asyncio.as_completed é um gerador que produz corrotinas que devolvem os resultados das corrotinas passadas a ele. Ele as produz na ordem em que elas terminam seu processamento, não na ordem em que foram submetidas. É similar ao futures.as_completed, que vimos no Capítulo 20, Exemplo 4.

  10. Nesse ponto, sabemos que a corrotina terminou, pois é assim que as_completed funciona. Portanto, a expressão await não vai bloquear, mas precisamos dela para obter o resultado de coro. Se coro gerou uma exceção não tratada, ela será gerada novamente aqui.

  11. asyncio.run inicia o loop de eventos e retorna apenas quando o loop terminar. Esse é um modelo comum para scripts usando asyncio: implementar main como uma corrotina e controlá-la com asyncio.run dentro do bloco if name == 'main':.

👉 Dica

A função asyncio.get_running_loop surgiu no Python 3.7, para uso dentro de corrotinas, como visto em probe. Se não houver um loop em execução, asyncio.get_running_loop gera um RuntimeError. Sua implementação é mais simples e mais rápida que a de asyncio.get_event_loop, que pode iniciar um loop de eventos se necessário. Desde o Python 3.10, asyncio.get_event_loop foi descontinuado, e em algum momento se tornará um alias para asyncio.get_running_loop.

21.3.1. O truque de Guido para ler código assíncrono

Há muitos conceitos novos para entender no asyncio, mas a lógica básica do Exemplo 1 é fácil de compreender se você usar o truque sugerido pelo próprio Guido van Rossum: cerre os olhos e finja que as palavras-chave async e await não estão ali. Fazendo isso, você vai perceber que as corrotinas podem ser lidas como as boas e velhas funções sequenciais.

Por exemplo, imagine que o corpo dessa corrotina…​

async def probe(domain: str) -> tuple[str, bool]:
    loop = asyncio.get_running_loop()
    try:
        await loop.getaddrinfo(domain, None)
    except socket.gaierror:
        return (domain, False)
    return (domain, True)

…​funciona como a função abaixo, exceto que, magicamente, ela nunca bloqueia a execução:

def probe(domain: str) -> tuple[str, bool]:  # no async
    loop = asyncio.get_running_loop()
    try:
        loop.getaddrinfo(domain, None)  # no await
    except socket.gaierror:
        return (domain, False)
    return (domain, True)

Usar a sintaxe await loop.getaddrinfo(…​) evita o bloqueio, porque await suspende o objeto corrotina atual. Por exemplo, durante a execução da corrotina probe('if.dev'), um novo objeto corrotina é criado por getaddrinfo('if.dev', None). Aplicar await sobre ele inicia a consulta de baixo nível addrinfo e devolve o controle para o loop de eventos, não para a corrotina probe(‘if.dev’), que está suspensa. O loop de eventos pode então ativar outros objetos corrotina pendentes, tal como probe('or.dev').

Quando o loop de eventos recebe uma resposta para a consulta getaddrinfo('if.dev', None), aquele objeto corrotina específico prossegue sua execução, e devolve o controle pra o probe('if.dev')—que estava suspenso no await—e pode agora tratar alguma possível exceção e devolver a tupla com o resultado.

Até aqui, vimos asyncio.as_completed e await sendo aplicados apenas a corrotinas. Mas eles podem lidar com qualquer objeto "esperável". Esse conceito será explicado a seguir.

21.4. Novo conceito: awaitable ou esperável

A palavra-chave for funciona com iteráveis. A palavra-chave await funciona com esperáveis (awaitable).

Como um usuário final do asyncio, esses são os esperáveis que você verá diariamente:

  • Um objeto corrotina nativa, que você obtém chamando uma função corrotina nativa

  • Uma asyncio.Task, que você normalmente obtém passando um objeto corrotina para asyncio.create_task()

Entretanto, o código do usuário final nem sempre precisa await por uma Task. Usamos asyncio.create_task(one_coro()) para agendar one_coro para execução concorrente, sem esperar que retorne. Foi o que fizemos com a corrotina spinner em spinner_async.py (no Exemplo 4). Criar a tarefa é o suficiente para agendar a execução da corrotina.

⚠️ Aviso

Mesmo que você não precise cancelar a tarefa ou esperar por ela, é necessário preservar o objeto Task devolvido por create_task, atribuindo ele a uma variável ou coleção que você controla. O loop de eventos usa referências fracas para gerenciar as tarefas, o que significa que elas podem ser descartadas pelo coletor de lixo antes de executarem. Por isso você precisa criar referências fortes para manter cada tarefa na memória. Veja a documentação de asyncio.create_task. Sobre referências fracas, escrevi o artigo "Weak References" fluentpython.com (EN).[299]

Por outro lado, usamos await other_coro() para executar other_coro agora mesmo e esperar que ela termine, porque precisamos do resultado para prosseguir. Em spinner_async.py, a corrotina supervisor usava res = await slow() para executar slow e aguardar seu resultado..

Ao implementar bibliotecas assíncronas ou contribuir para o próprio asyncio, você pode também encontrar esse esperáveis de baixo nível:

  • Um objeto com um método __await__ que devolve um iterador; por exemplo, uma instância de asyncio.Future (asyncio.Task é uma subclasse de asyncio.Future)

  • Objetos escritos em outras linguagens usando a API Python/C, com uma função tp_as_async.am_await, que devolvem um iterador (similar ao método __await__)

As bases de código existentes podem também conter um tipo adicional de esperável: objetos corrotina baseados em geradores, que estão no processo de serem descontinuados.

✒️ Nota

A PEP 492 afirma (EN) que a expressão await "usa a [mesma] implementação de yield from [mas] com um passo adicional de validação de seu argumento" e que “await só aceita um esperável.” A PEP não explica aquela implementação em detalhes, mas se refere à PEP 380, que introduziu yield from. Eu postei uma explicação detalhada no texto "The Meaning of yield from" (EN) da seção "Classic Coroutines" (EN) do fluentpython.com.

Agora vamos estudar a versão asyncio de um script que baixa um conjunto fixo de imagens de bandeiras.

21.5. Downloads com asyncio e HTTPX

O script flags_asyncio.py baixa um conjunto fixo de 20 bandeiras de fluentpython.com. Nós já o mencionamos na Seção 20.2, mas agora vamos examiná-lo em detalhes, aplicando os conceitos que acabamos de ver.

A partir do Python 3.10, o asyncio só suporta TCP e UDP diretamente, e não há pacotes de cliente ou servidor HTTP assíncronos na bilbioteca padrão. Estou usando o HTTPX em todos os exemplos de cliente HTTP.

Vamos explorar o flags_asyncio.py de baixo para cima, isto é, olhando primeiro as função que configuram a ação no Exemplo 2.

⚠️ Aviso

Para deixar o código mais fácil de ler, flags_asyncio.py não tem qualquer tratamento de erro. Nessa introdução a async/await é útil se concentrar inicialmente no "caminho feliz", para entender como funções regulares e corrotinas são dispostas em um programa. Começando na Seção 21.7, os exemplos incluem tratamento de erros e outros recursos.

Os exemplos de flags_.py aqui e no Capítulo 20 compartilham código e dados, então os coloquei juntos no diretório example-code-2e/20-executors/getflags.

Exemplo 2. flags_asyncio.py: funções de inicialização
def download_many(cc_list: list[str]) -> int:    # (1)
    return asyncio.run(supervisor(cc_list))      # (2)

async def supervisor(cc_list: list[str]) -> int:
    async with AsyncClient() as client:          # (3)
        to_do = [download_one(client, cc)
                 for cc in sorted(cc_list)]      # (4)
        res = await asyncio.gather(*to_do)       # (5)

    return len(res)                              # (6)

if __name__ == '__main__':
    main(download_many)
  1. Essa precisa ser uma função comum—não uma corrotina—para poder ser passada para e chamada pela função main do módulo flags.py (Exemplo 2).

  2. Executa o loop de eventos, monitorando o objeto corrotina supervisor(cc_list) até que ele retorne. Isso vai bloquear enquanto o loop de eventos roda. O resultado dessa linha é o que quer que supervisor devolver.

  3. Operação de cliente HTTP assíncronas no httpx são métodos de AsyncClient, que também é um gerenciador de contexto assíncrono: um gerenciador de contexto com métodos assíncronos de configuração e destruição (veremos mais sobre isso na Seção 21.6).

  4. Cria uma lista de objetos corrotina, chamando a corrotina download_one uma vez para cada bandeira a ser obtida.

  5. Espera pela corrotina asyncio.gather, que aceita um ou mais argumentos esperáveis e aguarda até que todos terminem, devolvendo uma lista de resultados para os esperáveis fornecidos, na ordem em que foram enviados.

  6. supervisor devolve o tamanho da lista vinda de asyncio.gather.

Agora vamos revisar a parte superior de flags_asyncio.py (Exemplo 3). Reorganizei as corrotinas para podermos lê-las na ordem em que são iniciadas pelo loop de eventos.

Exemplo 3. flags_asyncio.py: imports and download functions
import asyncio

from httpx import AsyncClient  # (1)

from flags import BASE_URL, save_flag, main  # (2)

async def download_one(client: AsyncClient, cc: str):  # (3)
    image = await get_flag(client, cc)
    save_flag(image, f'{cc}.gif')
    print(cc, end=' ', flush=True)
    return cc

async def get_flag(client: AsyncClient, cc: str) -> bytes:  # (4)
    url = f'{BASE_URL}/{cc}/{cc}.gif'.lower()
    resp = await client.get(url, timeout=6.1,
                                  follow_redirects=True)  # (5)
    return resp.read()  # (6)
  1. httpx precisa ser importado—não faz parte da biblioteca padrão

  2. Reutiliza código de flags.py (Exemplo 2).

  3. download_one tem que ser uma corrotina nativa, para poder await por get_flag—que executa a requisição HTTP. Ela então mostra o código de país bandeira baixada, e salva a imagem.

  4. get_flag precisa receber o AsyncClient para fazer a requisição.

  5. O método get de uma instância de httpx.AsyncClient devolve um objeto ClientResponse, que também é um gerenciador assíncrono de contexto.

  6. Operações de E/S de rede são implementadas como métodos corrotina, então eles são controlados de forma assíncrona pelo loop de eventos do asyncio.

✒️ Nota

Seria melhor, em termos de desempenho, que a chamada a save_flag dentro de get_flag fosse assíncrona, evitando bloquear o loop de eventos. Entretanto, atualmente asyncio não oferece uma API assíncrona de acesso ao sistema de arquivos—como faz o Node.js.

A Seção 21.7.1 vai mostrar como delegar save_flag para uma thread.

O seu código delega para as corrotinas do httpx explicitamente, usando await, ou implicitamente, usando os métodos especiais dos gerenciadores de contexto assíncronos, tais como Async​Client e ClientResponse—como veremos na Seção 21.6.

21.5.1. O segredo das corrotinas nativas: humildes geradores

A diferença fundamental entre os exemplos de corrotinas clássicas vistas nas Seção 17.13 e flags_asyncio.py é que não há chamadas a .send() ou expressões yield visíveis nesse último. O seu código fica entre a biblioteca asyncio e as bibliotecas assíncronas que você estiver usando, como por exemplo a HTTPX. Isso está ilustrado na Figura 1.

Diagrama do canal await
Figura 1. Em um programa assíncrono, uma função do usuário inicia o loop de eventos, agendando uma corrotina inicial com asyncio.run. Cada corrotina do usuário aciona a seguinte com uma expressão await, formando um canal que permite a comunicação entre uma biblioteca como a HTTPX e o loop de eventos.

Debaixo dos panos, o loop de eventos do asyncio faz as chamadas a .send que acionam as nossas corrotinas, e nossas corrotinas await por outras corrotinas, incluindo corrotinas da biblioteca. Como já mencionado, a maior parte da implementação de await vem de yield from, que também usa chamadas a .send para acionar corrotinas.

O canal await acaba por chegar a um esperável de baixo nível, que devolve um gerador que o loop de eventos pode acionar em resposta a eventos tais com cronômetros ou E/S de rede. Os esperáveis e geradores no final desses canais await estão implementados nas profundezas das bibliotecas, não são parte de suas APIs e podem ser extensões Python/C.

Usando funções como asyncio.gather e asyncio.create_task, é possível iniciar múltiplos canais await concorrentes, permitindo a execução concorrente de múltiplas operações de E/S acionadas por um único loop de eventos, em uma única thread.

21.5.2. O problema do tudo ou nada

Observe que, no Exemplo 3, não pude reutilizar a função get_flag de flags.py (Exemplo 2). Tive que reescrevê-la como uma corrotina para usar a API assíncrona do HTTPX. Para obter o melhor desempenho do asyncio, precisamos substituir todas as funções que fazem E/S por uma versão assíncrona, que seja ativada com await ou asyncio.create_task. Dessa forma o controle é devolvido ao loop de eventos enquanto a função aguarda pela operação de entrada ou saída. Se você não puder reescrever a função bloqueante como uma corrotina, deveria executá-la em uma thread ou um processo separados, como veremos na Seção 21.8.

Essa é a razão da escolha da epígrafe desse capítulo, que incluí o seguinte conselho: "[Ou] você reescreve todo o código, de forma que nada nele bloqueie [o processamento] ou você está só perdendo tempo.""

Pela mesma razão, também não pude reutilizar a função download_one de flags_threadpool.py (Exemplo 3). O código no Exemplo 3 aciona get_flag com await, então download_one precisa também ser uma corrotina. Para cada requisição, um objeto corrotina download_one é criado em supervisor, e eles são todos acionados pela corrotina asyncio.gather.

Vamos agora estudar o comando async with, que apareceu em supervisor (Exemplo 2) e get_flag (Exemplo 3).

21.6. Gerenciadores de contexto assíncronos

Na Seção 18.2, vimos como um objeto pode ser usado para executar código antes e depois do corpo de um bloco with, se sua classe oferecer os métodos __enter__ e __exit__.

Agora, considere o Exemplo 4, que usa o driver PostgreSQL asyncpg compatível com o asyncio (documentação do asyncpg sobre transações).

Exemplo 4. Código exemplo da documentação do driver PostgreSQL asyncpg
tr = connection.transaction()
await tr.start()
try:
    await connection.execute("INSERT INTO mytable VALUES (1, 2, 3)")
except:
    await tr.rollback()
    raise
else:
    await tr.commit()

Uma transação de banco de dados se presta naturalmente a protocolo do gerenciador de contexto: a transação precisa ser iniciada, dados são modificados com connection.execute, e então um roolback (reversão) ou um commit (confirmação) precisam acontecer, dependendo do resultado das mudanças.

Em um driver assíncrono como o asyncpg, a configuração e a execução precisam acontecer em corrotinas, para que outras operações possam ocorrer de forma concorrente. Entretando, a implementação do comando with clássico não suporta corrotinas na implementação dos métodos __enter__ ou __exit__.

Por essa razão a PEP 492—Coroutines with async and await syntax (Corrotinas com async e await) (EN) introduziu o comando async with, que funciona com gerenciadores de contexto assíncronos: objetos implementando os métodos __aenter__ e __aexit__ como corrotinas.

Com async with, o Exemplo 4 pode ser escrito como esse outro trecho da documentação do asyncpg:

async with connection.transaction():
    await connection.execute("INSERT INTO mytable VALUES (1, 2, 3)")

Na classe asyncpg.Transaction, o método corrotina __aenter__ executa await self.start(), e a corrotina __aexit__ espera pelos métodos corrotina privados rollback ou commit, dependendo da ocorrência ou não de uma exceção. Usar corrotinas para implementar Transaction como um gerenciador de contexto assíncrono permite ao asyncpg controlar, de forma concorrente, muitas transações simultâneas.

👉 Dica
Caleb Hattingh sobre o asyncpg

Outro detalhe fantástico sobre o asyncpg é que ele também contorna a falta de suporte à alta-concorrência do PostgreSQL (que usa um processo servidor por conexão) implementando um pool de conexões para conexões internas ao próprio Postgres.

Isso significa que você não precisa de ferramentas adicionais (por exemplo o pgbouncer), como explicado na documentação (EN) do asyncpg.[300]

Voltando ao flags_asyncio.py, a classe AsyncClient do httpx é um gerenciador de contexto assíncrono, então pode usar esperáveis em seus métodos corrotina especiais __aenter__ e __aexit__.

✒️ Nota

A Seção 21.10.1.3 mostra como usar a contextlib do Python para criar um gerenciador de contexto assíncrono sem precisar escrever uma classe. Essa explicação aparece mais tarde nesse capítulo por causa de um pré-requsito: a Seção 21.10.1.

Agora vamos melhorar o exemplo asyncio de download de bandeiras com uma barra de progresso, que nos levará a explorar um pouco mais da API do asyncio.

21.7. Melhorando o download de bandeiras asyncio

Vamos recordar a Seção 20.5, na qual o conjunto de exemplos flags2 compartilhava a mesma interface de linha de comando, e todos mostravam uma barra de progresso enquanto os downloads aconteciam. Eles também incluíam tratamento de erros.

👉 Dica

Encorajo você a brincar com os exemplos flags2, para desenvolver uma intuição sobre o funcionamento de clientes HTTP concorrentes. Use a opção -h para ver a tela de ajuda no Exemplo 10. Use as opções de linha de comando -a, -e, e -l para controlar o número de downloads, e a opção -m para estabelecer o número de downloads concorrentes. Execute testes com os servidores LOCAL, REMOTE, DELAY, e ERROR. Descubra o número ótimo de downloads concorrentes para maximizar a taxa de transferência de cada servidor. Varie as opções dos servidores de teste, como descrito no Configurando os servidores de teste.

Por exemplo, o Exemplo 5 mostra uma tentativa de obter 100 bandeiras (-al 100) do servidor ERROR, usando 100 conexões concorrentes (-m 100). Os 48 erros no resultado são ou HTTP 418 ou erros de tempo de espera excedido (time-out)—o [mau]comportamento esperado do slow_server.py.

Exemplo 5. Running flags2_asyncio.py
$ python3 flags2_asyncio.py -s ERROR -al 100 -m 100
ERROR site: http://localhost:8002/flags
Searching for 100 flags: from AD to LK
100 concurrent connections will be used.
100%|█████████████████████████████████████████| 100/100 [00:03<00:00, 30.48it/s]
--------------------
 52 flags downloaded.
 48 errors.
Elapsed time: 3.31s
⚠️ Aviso
Aja de forma responsável ao testar clientes concorrentes

Mesmo que o tempo total de download não seja muito diferente entre os clientes HTTP na versão com threads e na versão asyncio HTTP , o asyncio é capaz de enviar requisições mais rápido, então aumenta a probabilidade do servidor suspeitar de um ataque DoS. Para exercitar esses clientes concorrentes em sua capacidade máxima, por favor use servidores HTTP locais em seus testes, como explicado no Configurando os servidores de teste.

Agora vejamos como o flags2_asyncio.py é implementado.

21.7.1. Usando asyncio.as_completed e uma thread

No Exemplo 3, passamos várias corrotinas para asyncio.gather, que devolve uma lista com os resultados das corrotinas na ordem em que foram submetidas. Isso significa que asyncio.gather só pode retornar quando todos os esperáveis terminarem. Entretanto, para atualizar uma barra de progresso, precisamos receber cada um dos resultados assim que eles estejam prontos.

Felizmente existe um equivalente asyncio da função geradora as_completed que usamos no exemplo de pool de threads com a barra de progresso, (Exemplo 16).

O Exemplo 6 mostra o início do script flags2_asyncio.py, onde as corrotinas get_flag e download_one são definidas. O Exemplo 7 lista o restante do código-fonte, com supervisor e download_many. O script é maior que flags_asyncio.py por causa do tratamento de erros.

Exemplo 6. flags2_asyncio.py: parte superior (inicial) do script; o resto do código está no Exemplo 7
import asyncio
from collections import Counter
from http import HTTPStatus
from pathlib import Path

import httpx
import tqdm  # type: ignore

from flags2_common import main, DownloadStatus, save_flag

# low concurrency default to avoid errors from remote site,
# such as 503 - Service Temporarily Unavailable
DEFAULT_CONCUR_REQ = 5
MAX_CONCUR_REQ = 1000

async def get_flag(client: httpx.AsyncClient,  # (1)
                   base_url: str,
                   cc: str) -> bytes:
    url = f'{base_url}/{cc}/{cc}.gif'.lower()
    resp = await client.get(url, timeout=3.1, follow_redirects=True)   # (2)
    resp.raise_for_status()
    return resp.content

async def download_one(client: httpx.AsyncClient,
                       cc: str,
                       base_url: str,
                       semaphore: asyncio.Semaphore,
                       verbose: bool) -> DownloadStatus:
    try:
        async with semaphore:  # (3)
            image = await get_flag(client, base_url, cc)
    except httpx.HTTPStatusError as exc:  # (4)
        res = exc.response
        if res.status_code == HTTPStatus.NOT_FOUND:
            status = DownloadStatus.NOT_FOUND
            msg = f'not found: {res.url}'
        else:
            raise
    else:
        await asyncio.to_thread(save_flag, image, f'{cc}.gif')  # (5)
        status = DownloadStatus.OK
        msg = 'OK'
    if verbose and msg:
        print(cc, msg)
    return status
  1. get_flag é muito similar à versão sequencial no Exemplo 14. Primeira diferença: ele exige o parâmetro client.

  2. Segunda e terceira diferenças: .get é um método de AsyncClient, e é uma corrotina, então precisamos await por ela.

  3. Usa o semaphore como um gerenciador de contexto assíncrono, assim o programa como um todo não é bloqueado; apenas essa corrotina é suspensa quando o contador do semáforo é zero. Veja mais sobre isso em Semáforos no Python.

  4. A lógica de tratamento de erro é idêntica à de download_one, do Exemplo 14.

  5. Salvar a imagem é uma operação de E/S. Para não bloquear o loop de eventos, roda save_flag em uma thread.

No asyncio, toda a comunicação de rede é feita com corrotinas, mas não E/S de arquivos. Entretanto, E/S de arquivos também é "bloqueante"—no sentido que ler/escrever arquivos é milhares de vezes mais demorado que ler/escrever na RAM. Se você estiver usando armazenamento conectado à rede, isso pode até envolver E/S de rede internamente.

Desde o Python 3.9, a corrotina asyncio.to_thread facilitou delegar operações de arquivo para um pool de threads fornecido pelo asyncio. Se você precisa suportar Python 3.7 ou 3.8, a Seção 21.8 mostra como fazer isso, adicionando algumas linhas ao seu programa. Mas primeiro, vamos terminar nosso estudo do código do cliente HTTP.

21.7.2. Limitando as requisições com um semáforo

Clientes de rede como os que estamos estudando devem ser limitados ("throttled") (isto é, desacelerados) para que não martelem o servidor com um número excessivo de requisições concorrentes.

Um semáforo é uma estrutura primitiva de sincronização, mais flexível que uma trava. Um semáforo pode ser mantido por múltiplas corrotinas, com um número máximo configurável. Isso o torna ideial para limitar o número de corrotinas concorrentes ativas. O Semáforos no Python tem mais informações.

No flags2_threadpool.py (Exemplo 16), a limitação era obtida instanciando o ThreadPoolExecutor com o argumento obrigatório max_workers fixado em concur_req na função download_many. Em flags2_asyncio.py, um asyncio.Semaphore é criado pela função supervisor (mostrada no Exemplo 7) e passado como o argumento semaphore para download_one no Exemplo 6.

Semáforos no Python

O cientista da computação Edsger W. Dijkstra inventou o semáforo no início dos anos 1960. É uma ideia simples, mas tão flexível que a maioria dos outros objetos de sincronização—tais como as travas e as barreiras—podem ser construídas a partir de semáforos. Há três classes Semaphore na biblioteca padrão do Python: uma em threading, outra em multiprocessing, e uma terceira em asyncio. Essas classes são parecidas, mas têm implementações bem diferentes. Aqui vamos descrever a versão de asyncio.

Um asyncio.Semaphore tem um contador interno que é decrementado toda vez que usamos await no método corrotina .acquire(), e incrementado quando chamamos o método .release()—que não é uma corrotina porque nunca bloqueia. O valor inicial do contador é definido quando o Semaphore é instanciado:

    semaphore = asyncio.Semaphore(concur_req)

Invocar await em .acquire() não causa qualquer atraso quando o contador interno é maior que zero. Se o contador for 0, entretanto, .acquire() suspende a a corrotina que chamou await até que alguma outra corrotina chame .release() no mesmo Semaphore, incrementando assim o contador.

Em vez de usar esses métodos diretamente, é mais seguro usar o semaphore como um gerenciador de contexto assíncrono, como fiz na função download_one em Exemplo 6:

        async with semaphore:
            image = await get_flag(client, base_url, cc)

O método corrotina Semaphore.__aenter__ espera por .acquire() (usando await internamente), e seu método corrotina __aexit__ chama .release(). Este async with garante que não mais que concur_req instâncias de corrotinas get_flags estarão ativas a qualquer dado momento.

Cada uma das classes Semaphore na biblioteca padrão tem uma subclasse BoundedSemaphore, que impõe uma restrição adicional: o contador interno não pode nunca ficar maior que o valor inicial, quando ocorrerem mais operações .release() que .acquire().[301]

Agora vamos olhar o resto do script em Exemplo 7.

Exemplo 7. flags2_asyncio.py: continuação de Exemplo 6
async def supervisor(cc_list: list[str],
                     base_url: str,
                     verbose: bool,
                     concur_req: int) -> Counter[DownloadStatus]:  # (1)
    counter: Counter[DownloadStatus] = Counter()
    semaphore = asyncio.Semaphore(concur_req)  # (2)
    async with httpx.AsyncClient() as client:
        to_do = [download_one(client, cc, base_url, semaphore, verbose)
                 for cc in sorted(cc_list)]  # (3)
        to_do_iter = asyncio.as_completed(to_do)  # (4)
        if not verbose:
            to_do_iter = tqdm.tqdm(to_do_iter, total=len(cc_list))  # (5)
        error: httpx.HTTPError | None = None  # (6)
        for coro in to_do_iter:  # (7)
            try:
                status = await coro  # (8)
            except httpx.HTTPStatusError as exc:
                error_msg = 'HTTP error {resp.status_code} - {resp.reason_phrase}'
                error_msg = error_msg.format(resp=exc.response)
                error = exc  # (9)
            except httpx.RequestError as exc:
                error_msg = f'{exc} {type(exc)}'.strip()
                error = exc  # (10)
            except KeyboardInterrupt:
                break

            if error:
                status = DownloadStatus.ERROR  # (11)
                if verbose:
                    url = str(error.request.url)  # (12)
                    cc = Path(url).stem.upper()   # (13)
                    print(f'{cc} error: {error_msg}')
            counter[status] += 1

    return counter

def download_many(cc_list: list[str],
                  base_url: str,
                  verbose: bool,
                  concur_req: int) -> Counter[DownloadStatus]:
    coro = supervisor(cc_list, base_url, verbose, concur_req)
    counts = asyncio.run(coro)  # (14)

    return counts

if __name__ == '__main__':
    main(download_many, DEFAULT_CONCUR_REQ, MAX_CONCUR_REQ)
  1. supervisor recebe os mesmos argumentos que a função download_many, mas ele não pode ser invocado diretamente de main, pois é uma corrotina e não uma função simples como download_many.

  2. Cria um asyncio.Semaphore que não vai permitir mais que concur_req corrotinas ativas entre aquelas usando este semáforo. O valor de concur_req é calculado pela função main de flags2_common.py, baseado nas opções de linha de comando e nas constantes estabelecidas em cada exemplo.

  3. Cria uma lista de objetos corrotina, um para cada chamada à corrotina download_one.

  4. Obtém um iterador que vai devolver objetos corrotina quando eles terminarem sua execução. Não coloquei essa chamada a as_completed diretamente no loop for abaixo porque posso precisar envolvê-la com o iterador tqdm para a barra de progresso, dependendo da opção do usuário para verbosidade.

  5. Envolve o iterador as_completed com a função geradora tqdm, para mostrar o progresso.

  6. Declara e inicializa error com None; essa variável será usada para manter uma exceção além do bloco try/except, se alguma for levantada.

  7. Itera pelos objetos corrotina que terminaram a execução; esse loop é similar ao de download_many em Exemplo 16.

  8. await pela corrotina para obter seu resultado. Isso não bloqueia porque as_completed só produz corrotinas que já terminaram.

  9. Essa atribuição é necessária porque o escopo da variável exc é limitado a essa cláusula except, mas preciso preservar o valor para uso posterior.

  10. Mesmo que acima.

  11. Se houve um erro, muda o status.

  12. Se em modo verboso, extrai a URL da exceção que foi levantada…​

  13. …​e extrai o nome do arquivo para mostrar o código do país em seguida.

  14. download_many instancia o objeto corrotina supervisor e o passa para o loop de eventos com asyncio.run, coletando o contador que supervisor devolve quando o loop de eventos termina.

No Exemplo 7, não podíamos usar o mapeamento de futures para os códigos de país que vimos em Exemplo 16, porque os esperáveis devolvidos por asyncio.as_completed são os mesmos esperáveis que passamos na chamada a as_completed. Internamente, o mecanismo do asyncio pode substituir os esperáveis que fornecemos por outros que irão, no fim, produzir os mesmos resultados.[302]

👉 Dica

Já que não podia usar os esperáveis como chaves para recuperar os códigos de país de um dict em caso de falha, tive que extrair o código de pais da exceção. Para fazer isso, mantive a exceção na variável error, permitindo sua recuperação fora do bloco try/except. O Python não é uma linguagem com escopo de bloco: comandos como loops e try/except não criam um escopo local aos blocos que eles gerenciam. Mas se uma cláusula except vincula uma exceção a uma variável, como as variáveis exc que acabamos de ver—aquele vínculo só existe no bloco dentro daquela cláusula except específica.

Isso encerra nossa discussão da funcionalidade de um exemplo usando asyncio similar ao flags2_threadpool.py que vimos antes.

O próximo exemplo demonstra um modelo simples de execução de uma tarefa assíncrona após outra usando corrotinas. Isso merece nossa atenção porque qualquer um com experiência prévia em Javascript sabe que rodar um função assíncrona após outra foi a razão para o padrão de codificação aninhado conhecido como pyramid of doom (pirâmide da perdição) (EN). A palavra-chave await desfaz a maldição. Por isso await agora é parte do Python e do Javascript.

21.7.3. Fazendo múltiplas requisições para cada download

Suponha que você queira salvar cada bandeira com o nome e o código do país, em vez de apenas o código. Agora você precisa fazer duas requisições HTTP por bandeira: uma para obter a imagem da bandeira propriamente dita, a outra para obter o arquivo metadata.json, no mesmo diretório da imagem—é nesse arquivo que o nome do país está registrado.

Coordenar múltiplas requisições na mesma tarefa é fácil no script com threads: basta fazer uma requisição depois a outra, bloqueando a thread duas vezes, e mantendo os dois dados (código e nome do país) em variáveis locais, prontas para serem usadas quando os arquivos forem salvo. Se você precisasse fazer o mesmo em um script assíncrono com callbacks, você precisaria de funções aninhadas, de forma que o código e o nome do país estivessem disponíveis até o momento em que fosse possível salvar o arquivo, pois cada callback roda em um escopo local diferente. A palavra-chave await fornece um saída para esse problema, permitindo que você acione as requisições assíncronas uma após a outra, compartilhando o escopo local da corrotina que dirige as ações.

👉 Dica

Se você está trabalhando com programação de aplicações assíncronas no Python moderno e recorre a uma grande quantidade de callbacks, provavelmente está aplicando modelos antigos, que não fazem mais sentido no Python atual. Isso é justificável se você estiver escrevendo uma biblioteca que se conecta a código legado ou a código de baixo nível, que não suportem corrotinas. De qualquer forma, o Q&A do StackOverflow, "What is the use case for future.add_done_callback()?" (Qual o caso de uso para future.add_done_callback()?) (EN) explica porque callbacks são necessários em código de baixo nível, mas não são muito úteis hoje em dia em código Python a nível de aplicação.

A terceira variante do script asyncio de download de bandeiras traz algumas mudanças:

get_country

Essa nova corrotina baixa o arquivo metadata.json daquele código de país, e extrai dele o nome do país.

download_one

Essa corrotina agora usa await para delegar para get_flag e para a nova corrotina get_country, usando o resultado dessa última para compor o nome do arquivo a ser salvo.

Vamos começar com o código de get_country (Exemplo 8). Observe que ele muito similar ao get_flag do Exemplo 6.

Exemplo 8. flags3_asyncio.py: corrotina get_country
async def get_country(client: httpx.AsyncClient,
                      base_url: str,
                      cc: str) -> str:    # (1)
    url = f'{base_url}/{cc}/metadata.json'.lower()
    resp = await client.get(url, timeout=3.1, follow_redirects=True)
    resp.raise_for_status()
    metadata = resp.json()  # (2)
    return metadata['country']  # (3)
  1. Essa corrotina devolve uma string com o nome do país—se tudo correr bem.

  2. metadata vai receber um dict Python construído a partir do conteúdo JSON da resposta.

  3. Devolve o nome do país.

Agora vamos ver o download_one modificado do Exemplo 9, que tem apenas algumas linhas diferentes da corrotina de mesmo nome do Exemplo 6.

Exemplo 9. flags3_asyncio.py: corrotina download_one
async def download_one(client: httpx.AsyncClient,
                       cc: str,
                       base_url: str,
                       semaphore: asyncio.Semaphore,
                       verbose: bool) -> DownloadStatus:
    try:
        async with semaphore:  # (1)
            image = await get_flag(client, base_url, cc)
        async with semaphore:  # (2)
            country = await get_country(client, base_url, cc)
    except httpx.HTTPStatusError as exc:
        res = exc.response
        if res.status_code == HTTPStatus.NOT_FOUND:
            status = DownloadStatus.NOT_FOUND
            msg = f'not found: {res.url}'
        else:
            raise
    else:
        filename = country.replace(' ', '_')  # (3)
        await asyncio.to_thread(save_flag, image, f'{filename}.gif')
        status = DownloadStatus.OK
        msg = 'OK'
    if verbose and msg:
        print(cc, msg)
    return status
  1. Segura o semaphore para await por get_flag…​

  2. …​e novamente por get_country.

  3. Usa o nome do país para criar um nome de arquivo. Como usuário da linha de comando, não gosto de ver espaços em nomes de arquivo.

Muito melhor que callbacks aninhados!

Coloquei as chamadas a get_flag e get_country em blocos with separados, controlados pelo semaphore porque é uma boa prática manter semáforos e travas pelo menor tempo possível.

Eu poderia ter agendado ambos os scripts, get_flag e get_country, em paralelo, usando asyncio.gather, mas se get_flag levantar uma exceção não haverá imagem para salvar, então seria inútil rodar get_country. Mas há casos onde faz sentido usar asyncio.gather para acessar várias APIs simultaneamente, em vez de esperar por uma resposta antes de fazer a próxima requisição

Em flags3_asyncio.py, a sintaxe await aparece seis vezes, e async with três vezes. Espero que você esteja pegando o jeito da programação assíncrona em Python. Um desafio é saber quando você precisa usar await e quando você não pode usá-la. A resposta, em princípio, é fácil: você await por corrotinas e outros esperáveis, tais como instâncias de asyncio.Task. Mas algumas APIs são complexas, misturam corrotinas e funções normais de maneiras aparentemente arbitrárias, como a classe StreamWriter que usaremos no Exemplo 14.

O Exemplo 9 encerra o grupo de exemplos flags. Vamos agora discutir o uso de executores de threads ou processos na programação assíncrona.

21.8. Delegando tarefas a executores

Uma vantagem importante do Node.js sobre o Python para programação assíncrona é a biblioteca padrão do Node.js, que inclui APIs assíncronas para toda a E/S—não apenas para E/S de rede. No Python, se você não for cuidadosa, a E/S de arquivos pode degradar seriamente o desempenho de aplicações assíncronas, pois ler e escrever no armazenamento desde a thread principal bloqueia o loop de eventos.

No corrotina download_one de Exemplo 6, usei a seguinte linha para salvar a imagem baixada para o disco:

        await asyncio.to_thread(save_flag, image, f'{cc}.gif')

Como mencionado antes, o asyncio.to_thread foi acrescentado no Python 3.9. Se você precisa suportar 3.7 ou 3.8, substitua aquela linha pelas linhas em Exemplo 10.

Exemplo 10. Linhas para usar no lugar de await asyncio.to_thread
        loop = asyncio.get_running_loop()         # (1)
        loop.run_in_executor(None, save_flag,     # (2)
                             image, f'{cc}.gif')  # (3)
  1. Obtém uma referência para o loop de eventos.

  2. O primeiro argumento é o executor a ser utilizado; passar None seleciona o default, ThreadPoolExecutor, que está sempre disponível no loop de eventos do asyncio.

  3. Você pode passar argumentos posicionais para a função a ser executada, mas se você precisar passar argumentos de palavra-chave, vai precisar recorrer a functool.partial, como descrito na documentação de run_in_executor.

A função mais recente asyncio.to_thread é mais fácil de usar e mais flexível, já que também aceita argumentos de palavra-chave.

A própria implementação de asyncio usa run_in_executor debaixo dos panos em alguns pontos. Por exemplo, a corrotina loop.getaddrinfo(…), que vimos no Exemplo 1 é implementada chamando a função getaddrinfo do módulo socket—uma função bloqueante que pode levar alguns segundos para retornar, pois depende de resolução de DNS.

Um padrão comum em APIs assíncronas é encobrir chamadas bloqueantes que sejam detalhes de implementação nas corrotinas usando run_in_executor internamente. Dessa forma, é possível apresentar uma interface consistente de corrotinas a serem acionadas com await e esconder as threads que precisam ser usadas por razões pragmáticas. O driver assíncrono para o MongoDB Motor tem uma API compatível com async/await que na verdade é uma fachada, encobrindo um núcleo de threads que conversa com o servidor de banco de dados. A. Jesse Jiryu Davis, o principal desenvolvedor do Motor, explica suas razões em “Response to ‘Asynchronous Python and Databases’” (“_Resposta a ‘O Python Assíncrono e os Bancos de Dados’”). Spoiler: Davis descobriu que um pool de threads tem melhor desempenho no caso de uso específico de um driver de banco de dados—apesar do mito que abordagens assíncronas são sempre mais rápidas que threads para E/S de rede.

A principal razão para passar um Executor explícito para loop.run_in_executor é utilizar um ProcessPoolExecutor, se a função a ser executada for de uso intensivo da CPU. Dessa forma ela rodará em um processo Python diferente, evitando a disputa pela GIL. Por seu alto custo de inicialização, seria melhor iniciar o ProcessPoolExecutor no supervisor, e passá-lo para as corrotinas que precisem utilizá-lo.

Caleb Hattingh—O autor de Using Asyncio in Python (O' Reilly)—é um dos revisores técnicos desse livro, e sugeriu que eu acrescentasse o seguinte aviso sobre executores e o asyncio.

⚠️ Aviso
O aviso de Caleb sobre run_in_executors

Usar run_in_executor pode produzir problemas difíceis de depurar, já que o cancelamento não funciona da forma que se esperaria. Corrotinas que usam executores apenas fingem terminar: a thread subjacente (se for um ThreadPoolExecutor) não tem um mecanismo de cancelamento. Por exemplo, uma thread de longa duração criada dentro de uma chamada a run_in_executor pode impedir que seu programa asyncio encerre de forma limpa: asyncio.run vai esperar para retornar até o executor terminar inteiramente, e vai esperar para sempre se os serviços iniciados pelo executor não pararem sozinhos de alguma forma. Minha barba branca me inclina a desejar que aquela função se chamasse run_in_executor_uncancellable.

Agora saímos de scripts cliente para escrever servidores com o asyncio.

21.9. Programando servidores asyncio

O exemplo clássico de um servidor TCP de brinquedo é um servidor eco. Vamos escrever brinquedos um pouco mais interessantes: utilitários de servidor para busca de caracteres Unicode, primeiro usando HTTP com a FastAPI, depois usando TCP puro apenas com asyncio.

Esse servidores permitem que os usuários façam consultas sobre caracteres Unicode baseadas em palavras em seus nomes padrão no módulo unicodedata que discutimos na Seção 4.9. A Figura 2 mostra uma sessão com o web_mojifinder.py, o primeiro servidor que escreveremos.

Captura de tela de conexão do Firefox com o web_mojifinder.py
Figura 2. Janela de navegador mostrando os resultados da busca por "mountain" no serviço web_mojifinder.py.

A lógica de busca no Unicode nesses exemplos é a classe InvertedIndex no módulo charindex.py no repositório de código do Python Fluente. Não há nada concorrente naquele pequeno módulo, então vou dar apenas um explicação breve sobre ele, no box opcional a seguir. Você pode pular para a implementação do servidor HTTP na Seção 21.9.1.

Conhecendo o índice invertido

Um índice invertido normalmente mapeia palavras a documentos onde elas ocorrem. Nos exemplos mojifinder, cada "documento" é o nome de um caractere Unicode. A classe charindex.InvertedIndex indexa cada palavra que aparece no nome de cada caractere no banco de dados Unicode, e cria um índice invertido em um defaultdict. Por exemplo, para indexar o caractere U+0037—DIGIT SEVEN—o construtor de InvertedIndex anexa o caractere '7' aos registros sob as chaves 'DIGIT' e 'SEVEN'. Após indexar os dados do Unicode 13.0.0 incluídos no Python 3.10, 'DIGIT' será mapeado para 868 caracteres que tem essa palavra em seus nomes; e 'SEVEN' para 143, incluindo U+1F556—CLOCK FACE SEVEN OCLOCK e U+2790—DINGBAT NEGATIVE CIRCLED SANS-SERIF DIGIT SEVEN.

Veja a Figura 3 para uma demonstração usando os registro para 'CAT' e 'FACE'.[303]

Captura de tela do console do Python
Figura 3. Explorando o atributo entries e o método search de InvertedIndex no console do Python

O método InvertedIndex.search quebra a consulta em palavras separadas, e devolve a intersecção dos registros para cada palavra. É por isso que buscar por "face" encontra 171 resultados, "cat" encontra 14, mas "cat face" apenas 10.

Essa é a bela ideia por trás dos índices invertidos: uma pedra fundamental da recuperação de informação—a teoria por trás dos mecanismos de busca. Veja o artigo "Listas Invertidas" na Wikipedia para saber mais.

21.9.1. Um serviço web com FastAPI

Escrevi o próximo exemplo—web_mojifinder.py—usando a FastAPI: uma dos frameworks ASGI de desenvolvimento Web do Python, mencionada na ASGI—Asynchronous Server Gateway Interface. A Figura 2 é uma captura de tela da interface de usuário. É uma aplicação muito simples, de uma página só (SPA, Single Page Application): após o download inicial do HTML, a interface é atualizada via Javascript no cliente, em comunicação com o servidor.

A FastAPI foi projetada para implementar o lado servidor de SPAs and apps móveis, que consistem principalmente de pontos de acesso de APIs web, devolvendo respostas JSON em vez de HTML renderizado no servidor. A FastAPI se vale de decoradores, dicas de tipo e introspecção de código para eliminar muito do código repetitivo das APIs web, e também publica automaticamente uma documentação no padrão OpenAPI—a.k.a. Swagger—para a API que criamos. A Figura 4 mostra a página /docs para o web_mojifinder.py, gerada automaticamente.

Captura de tela do Firefox mostrando o schema OpenAPI para o ponto de acesso `/search`
Figura 4. Schema OpenAPI gerado automaticamente para o ponto de acesso /search.

O Exemplo 11 é o código do web_mojifinder.py, mas aquele é apenas o código do lado servidor. Quando você acessa a URL raiz /, o servidor envia o arquivo form.html, que contém 81 linhas de código, incluindo 54 linhas de Javascript para comunicação com o servidor e preenchimento de uma tabela com os resultados. Se você estiver interessado em ler Javascript puro sem uso de frameworks, vá olhar o 21-async/mojifinder/static/form.html no repositório de código do Python Fluente.

Para rodar o web_mojifinder.py, você precisa instalar dois pacotes e suas dependências: FastAPI e uvicorn.[304]

Este é o comando para executar o Exemplo 11 com uvicorn em modo de desenvolvimento:

$ uvicorn web_mojifinder:app --reload

os parâmetros são:

web_mojifinder:app

O nome do pacote, dois pontos, e o nome da aplicação ASGI definida nele—app é o nome usado por convenção.

--reload

Faz o uvicorn monitorar mudanças no código-fonte da aplicação, e recarregá-la automaticamente. Útil apenas durante o desenvolvimento.

Vamos agora olhar o código-fonte do web_mojifinder.py.

Exemplo 11. web_mojifinder.py: código-fonte completo
from pathlib import Path
from unicodedata import name

from fastapi import FastAPI
from fastapi.responses import HTMLResponse
from pydantic import BaseModel

from charindex import InvertedIndex

STATIC_PATH = Path(__file__).parent.absolute() / 'static'  # (1)

app = FastAPI(  # (2)
    title='Mojifinder Web',
    description='Search for Unicode characters by name.',
)

class CharName(BaseModel):  # (3)
    char: str
    name: str

def init(app):  # (4)
    app.state.index = InvertedIndex()
    app.state.form = (STATIC_PATH / 'form.html').read_text()

init(app)  # (5)

@app.get('/search', response_model=list[CharName])  # (6)
async def search(q: str):  # (7)
    chars = sorted(app.state.index.search(q))
    return ({'char': c, 'name': name(c)} for c in chars)  # (8)

@app.get('/', response_class=HTMLResponse, include_in_schema=False)
def form():  # (9)
    return app.state.form

# no main funcion  # (10)
  1. Não relacionado ao tema desse capítulo, mas digno de nota: o uso elegante do operador / sobrecarregado por pathlib.[305]

  2. Essa linha define a app ASGI. Ela poderia ser tão simples como app = FastAPI(). Os parâmetros mostrados são metadata para a documentação auto-gerada.

  3. Um schema pydantic para uma resposta JSON, com campos char e name.[306]

  4. Cria o index e carrega o formulário HTML estático, anexando ambos ao app.state para uso posterior.

  5. Roda init quando esse módulo é carregado pelo servidor ASGI.

  6. Rota para o ponto de acesso /search; response_model usa aquele modelo CharName do pydantic para descrever o formato da resposta.

  7. A FastAPI assume que qualquer parâmetro que apareça na assinatura da função ou da corrotina e que não esteja no caminho da rota será passado na string de consulta HTTP, isto é, /search?q=cat. Como q não tem default, a FastAPI devolverá um status 422 (Unprocessable Entity, Entidade Não-Processável) se q não estiver presente na string da consulta.

  8. Devolver um iterável de dicts compatível com o schema response_model permite ao FastAPI criar uma resposta JSON de acordo com o response_model no decorador @app.get,

  9. Funções regulares (isto é, não-assíncronas) também podem ser usadas para produzir respostas.

  10. Este módulo não tem uma função principal. É carregado e acionado pelo servidor ASGI—neste exemplo, o uvicorn.

O Exemplo 11 não tem qualquer chamada direta ao asyncio. O FastAPI é construído sobre o tollkit ASGI Starlette, que por sua vez usa o asyncio.

Observe também que o corpo de search não usa await, async with, ou async for, e assim poderia ser uma função normal. Defini search como um corrotina apenas para mostrar que o FastAPI sabe como lidar com elas. Em uma aplicação real, a maioria dos pontos de acesso serão consultas a bancos de dados ou acessos a outros servidores remotos, então é uma vantagem crítica do FastAPI—e de frameworks ASGI em geral— suportarem corrotinas que podem se valer de bibliotecas assíncronas para E/S de rede.

👉 Dica

As funções init e form, escritas para carregar e entregar o formulário em HTML estático é uma improvisação que escrevi para manter esse exemplo curto e fácil de executar. A melhor prática recomendada é ter um proxy/balanceador de carga na frente do ASGI, para gerenciar todos os recursos estáticos, e também usar uma CDN (Content Delivery Network, Rede de Entrega de Conteúdo) quando possível. Um proxy/balanceador de carga desse tipo é o Traefik (EN), que se auto-descreve como um "roteador de ponta (edge router)", que "recebe requisições em nome de seu sistema e descobre quais componentes são responsáveis por lidar com elas." O _FastAPI tem scripts de geração de projeto que preparam seu código para fazer isso.

Os entusiastas pela tipagem podem ter notado que não há dicas de tipo para os resultados devolvidos por search e form. Em vez disto, o FastAPI aceita o argumento nomeado response_model= nos decoradores de rota. A página "Modelo de Resposta" (EN) na documentação do FastAPI explica:

O modelo de resposta é declarado neste parâmetro em vez de como uma anotação de tipo de resultado devolvido por uma função, porque a função de rota pode não devolver aquele modelo de resposta mas sim um dict, um objeto banco de dados ou algum outro modelo, e então usar o response_model para realizar a limitação de campo e a serialização.

Por exemplo, em search, eu devolvi um gerador de itens dict e não uma lista de objetos CharName, mas isso é o suficiente para o FastAPI e o pydantic validarem meus dados e construírem a resposta JSON apropriada, compatível com response_model=list[CharName].

Agora vamos nos concentrar no script tcp_mojifinder.py, que responde às consultas, na Figura 5.

21.9.2. Um servidor TCP asyncio

O programa tcp_mojifinder.py usa TCP puro para se comunicar com um cliente como o Telnet ou o Netcat, então pude escrevê-lo usando asyncio sem dependências externas—e sem reinventar o HTTP. O Figura 5 mostra a interface de texto do usuário.

Captura de tela de conexão via telnet com tcp_mojifinder.py
Figura 5. Sessão de telnet com o servidor tcp_mojifinder.py: consultando "fire."

Este programa é duas vezes mais longo que o web_mojifinder.py, então dividi sua apresentação em três partes: Exemplo 12, Exemplo 14, e Exemplo 15. O início de tcp_mojifinder.py—incluindo os comandos import—está no Exemplo 14,mas vou começar descrevendo a corrotina supervisor e a função main que controla o programa.

Exemplo 12. tcp_mojifinder.py: um servidor TCP simples; continua em Exemplo 14
async def supervisor(index: InvertedIndex, host: str, port: int) -> None:
    server = await asyncio.start_server(    # (1)
        functools.partial(finder, index),   # (2)
        host, port)                         # (3)

    socket_list = cast(tuple[TransportSocket, ...], server.sockets)  # (4)
    addr = socket_list[0].getsockname()
    print(f'Serving on {addr}. Hit CTRL-C to stop.')  # (5)
    await server.serve_forever()  # (6)

def main(host: str = '127.0.0.1', port_arg: str = '2323'):
    port = int(port_arg)
    print('Building index.')
    index = InvertedIndex()                         # (7)
    try:
        asyncio.run(supervisor(index, host, port))  # (8)
    except KeyboardInterrupt:                       # (9)
        print('\nServer shut down.')

if __name__ == '__main__':
    main(*sys.argv[1:])
  1. Este await rapidamente recebe um instância de asyncio.Server, um servidor TCP baseado em sockets. Por default, start_server cria e inicia o servidor, então ele está pronto para receber conexões.

  2. O primeiro argumento para start_server é client_connected_cb, um callback para ser executado quando a conexão com um novo cliente se inicia. O callback pode ser uma função ou uma corrotina, mas precisa aceitar exatamente dois argumentos: um asyncio.StreamReader e um asyncio.StreamWriter. Entretanto, minha corrotina finder também precisa receber um index, então usei functools.partial para vincular aquele parâmetro e obter um invocável que receber o leitor (asyncio.StreamReader) e o escritor (asyncio.StreamWriter). Adaptar funções do usuário a APIs de callback é o caso de uso mais comum de functools.partial.

  3. host e port são o segundo e o terceiro argumentos de start_server. Veja a assinatura completa na documentação do asyncio.

  4. Este cast é necessário porque o typeshed tem uma dica de tipo desatualizada para a propriedade sockets da classe Server—isso em maio de 2021. Veja Issue #5535 no typeshed.[307]

  5. Mostra o endereço e a porta do primeiro socket do servidor.

  6. Apesar de start_server já ter iniciado o servidor como uma tarefa concorrente, preciso usar o await no método server_forever, para que meu supervisor seja suspenso aqui. Sem essa linha, o supervisor retornaria imediatamente, encerrando o loop iniciado com asyncio.run(supervisor(…)), e fechando o programa. A documentação de Server.serve_forever diz: "Este método pode ser chamado se o servidor já estiver aceitando conexões."

  7. Constrói o índice invertido.[308]

  8. Inicia o loop de eventos rodando supervisor.

  9. Captura KeyboardInterrupt para evitar o traceback dispersivo quando encerro o servidor com Ctrl-C, no terminal onde ele está rodando.

Pode ser mais fácil entender como o controle flui em tcp_mojifinder.py estudando a saída que ele gera no console do servidor, listada em Exemplo 13.

Exemplo 13. tcp_mojifinder.py: isso é o lado servidor da sessão mostrada na Figura 5
$ python3 tcp_mojifinder.py
Building index.  # (1)
Serving on ('127.0.0.1', 2323). Hit Ctrl-C to stop.  # (2)
 From ('127.0.0.1', 58192): 'cat face'   # (3)
   To ('127.0.0.1', 58192): 10 results.
 From ('127.0.0.1', 58192): 'fire'       # (4)
   To ('127.0.0.1', 58192): 11 results.
 From ('127.0.0.1', 58192): '\x00'       # (5)
Close ('127.0.0.1', 58192).              # (6)
^C  # (7)
Server shut down.  # (8)
$
  1. Saída de main. Antes da próxima linha surgir, vi um intervalo de 0,6s na minha máquina, enquanto o índice era construído.

  2. Saída de supervisor.

  3. Primeira iteração de um loop while em finder. A pilha TCP/IP atribuiu a porta 58192 a meu cliente Telnet. Se você conectar diversos clientes ao servidor, verá suas várias portas aparecerem na saída.

  4. Segunda iteração do loop while em finder.

  5. Eu apertei Ctrl-C no terminal cliente; o loop while em finder termina.

  6. A corrotina finder mostra essa mensagem e então encerra. Enquanto isso o servidor continua rodando, pronto para receber outro cliente.

  7. Aperto Ctrl-C no terminal do servidor; server.serve_forever é cancelado, encerrando supervisor e o loop de eventos.

  8. Saída de main.

Após main construir o índice e iniciar o loop de eventos, supervisor rapidamente mostra a mensagem Serving on…, e é suspenso na linha await server.serve_forever(). Nesse ponto o controle flui para dentro do loop de eventos e lá permanece, voltando ocasionalmente para a corrotina finder, que devolve o controle de volta para o loop de eventos sempre que precisa esperar que a rede envie ou receba dados.

Enquanto o loop de eventos estiver ativo, uma nova instância da corrotina finder será iniciada para cada cliente que se conecte ao servidor. Dessa forma, múltiplos clientes podem ser atendidos de forma concorrente por esse servidor simples. Isso segue até que ocorra um KeyboardInterrupt no servidor ou que seu processo seja eliminado pelo SO.

Agora vamos ver o início de tcp_mojifinder.py, com a corrotina finder.

Exemplo 14. tcp_mojifinder.py: continuação de Exemplo 12
import asyncio
import functools
import sys
from asyncio.trsock import TransportSocket
from typing import cast

from charindex import InvertedIndex, format_results  # (1)

CRLF = b'\r\n'
PROMPT = b'?> '

async def finder(index: InvertedIndex,          # (2)
                 reader: asyncio.StreamReader,
                 writer: asyncio.StreamWriter) -> None:
    client = writer.get_extra_info('peername')  # (3)
    while True:  # (4)
        writer.write(PROMPT)  # can't await!  # (5)
        await writer.drain()  # must await!  # (6)
        data = await reader.readline()  # (7)
        if not data:  # (8)
            break
        try:
            query = data.decode().strip()  # (9)
        except UnicodeDecodeError:  # (10)
            query = '\x00'
        print(f' From {client}: {query!r}')  # (11)
        if query:
            if ord(query[:1]) < 32:  # (12)
                break
            results = await search(query, index, writer)  # (13)
            print(f'   To {client}: {results} results.')  # (14)

    writer.close()  # (15)
    await writer.wait_closed()  # (16)
    print(f'Close {client}.')  # (17)
  1. format_results é útil para mostrar os resultado de InvertedIndex.search em uma interface de usuário baseada em texto, como a linha de comando ou uma sessão Telnet.

  2. Para passar finder para asyncio.start_server, a envolvi com functools.partial, porque o servidor espera uma corrotina ou função que receba apenas os argumentos reader e writer.

  3. Obtém o endereço do cliente remoto ao qual o socket está conectado.

  4. Esse loop controla um diálogo que persiste até um caractere de controle ser recebido do cliente.

  5. O método StreamWriter.write não é uma corrotina, é apenas um função normal; essa linha envia o prompt ?>.

  6. O StreamWriter.drain esvazia o buffer de writer; ela é uma corrotina, então precisa ser acionada com await.

  7. StreamWriter.readline é um corrotina que devolve bytes.

  8. Se nenhum byte foi recebido, o cliente fechou a conexão, então sai do loop.

  9. Decodifica os bytes para str, usando a codificação UTF-8 como default.

  10. Pode ocorrer um UnicodeDecodeError quando o usuário digita Ctrl-C e o cliente Telnet envia caracteres de controle; se isso acontecer, substitui a consulta pelo caractere null, para simplificar.

  11. Registra a consulta no console do servidor.

  12. Sai do loop se um caractere de controle ou null foi recebido.

  13. search realiza a busca efetiva; o código será apresentado a seguir.

  14. Registra a resposta no console do servidor.

  15. Fecha o StreamWriter.

  16. Espera até StreamWriter fechar. Isso é recomendado na documentação do método .close().

  17. Registra o final dessa sessão do cliente no console do servidor.

O último pedaço desse exemplo é a corrotina search, listada no Exemplo 15.

  1. search tem que ser uma corrotina, pois escreve em um StreamWriter e precisa usar seu método corrotina .drain().

  2. Consulta o índice invertido.

  3. Essa expressão geradora vai produzir strings de bytes codificadas em UTF-8 com o ponto de código Unicode, o caractere efetivo, seu nome e uma sequência CRLF (Return+Line Feed), isto é, b’U+0039\t9\tDIGIT NINE\r\n'.

  4. Envia a lines. Surpreendentemente, writer.writelines não é uma corrotina.

  5. Mas writer.drain() é uma corrotina. Não esqueça do await!

  6. Cria depois envia uma linha de status.

Observe que toda a E/S de rede em tcp_mojifinder.py é feita em bytes; precisamos decodificar os bytes recebidos da rede, e codificar strings antes de enviá-las. No Python 3, a codificação default é UTF-8, e foi o que usei implicitamente em todas as chamadas a encode e decode nesse exemplo.

⚠️ Aviso

Veja que alguns dos métodos de E/S são corrotinas, e precisam ser acionados com await, enquanto outros são funções simples, Por exemplo, StreamWriter.write é uma função normal, porque escreve em um buffer. Por outro lado, StreamWriter.drain—que esvazia o buffer e executa o E/S de rede—é uma corrotina, assim como StreamReader.readline—mas não StreamWriter.writelines! Enquanto estava escrevendo a primeira edição desse livro, a documentação da API asyncio API docs foi melhorada pela indicação clara das corrotinas como tal.

O código de tcp_mojifinder.py se vale da API Streams de alto nível do asyncio, que fornece um servidor pronto para ser usado, de forma que basta implemetar uma função de processamento, que pode ser um callback simples ou uma corrotina. Há também uma API de Transportes e Protocolos (EN) de baixo nível, inspirada pelas abstrações transporte e protocolo do framework Twisted. Veja a documentação do asyncio para maiores informações, incluindo os servidores echo e clientes TCP e UDP implementados com aquela API de nível mais baixo.

Nosso próximo tópico é async for e os objetos que a fazem funcionar.

21.10. Iteração assíncrona e iteráveis assíncronos

Na Seção 21.6 vimos como async with funciona com objetos que implementam os métodos __aenter__ and __aexit__, devolvendo esperáveis—normalmente na forma de objetos corrotina.

Se forma similar, async for funciona com iteráveis assíncronos: objetos que implementam __aiter__. Entretanto, __aiter__ precisa ser um método regular—não um método corrotina—e precisa devolver um iterador assíncrono.

Um iterador assíncrono fornece um método corrotina __anext__ que devolve um esperável—muitas vezes um objeto corrotina. Também se espera que eles implementem __aiter__, que normalmente devolve self. Isso espelha a importante distinção entre iteráveis e iteradores que discutimos na Seção 17.5.2.

A documentação (EN) do driver assíncrono de PostgreSQL aiopg traz um exemplo que ilustra o uso de async for para iterar sobre as linhas de cursor de banco de dados.

async def go():
    pool = await aiopg.create_pool(dsn)
    async with pool.acquire() as conn:
        async with conn.cursor() as cur:
            await cur.execute("SELECT 1")
            ret = []
            async for row in cur:
                ret.append(row)
            assert ret == [(1,)]

Nesse exemplo, a consulta vai devolver uma única linha, mas em um cenário realista é possível receber milhares de linhas na resposta a um SELECT. Para respostas grandes, o cursor não será carregado com todas as linhas de uma vez só. Assim é importante que async for row in cur: não bloqueie o loop de eventos enquanto o cursor pode estar esperando por linhas adicionais. Ao implementar o cursor como um iterador assíncrono, aiopg pode devolver o controle para o loop de eventos a cada chamada a __anext__, e continuar mais tarde, quando mais linhas cheguem do PostgreSQL.

21.10.1. Funções geradoras assíncronas

Você pode implementar um iterador assíncrono escrevendo uma classe com __anext__ e __aiter__, mas há um jeito mais simples: escreve uma função declarada com async def que use yield em seu corpo. Isso é paralelo à forma como funções geradoras simplificam o modelo clássico do Iterador.

Vamos estudar um exemplo simples usando async for e implementando um gerador assíncrono. No Exemplo 1 vimos blogdom.py, um script que sondava nomes de domínio. Suponha agora que encontramos outros usos para a corrotina probe definida ali, e decidimos colocá-la em um novo módulo—domainlib.py—junto com um novo gerador assíncrono multi_probe, que recebe uma lista de nomes de domínio e produz resultados conforme eles são sondados.

Vamos ver a implementação de domainlib.py logo, mas primeiro examinaremos como ele é usado com o novo console assíncrono do Python.

Experimentando com o console assíncrono do Python

Desde o Python 3.8, é possível rodar o interpretador com a opção de linha de comando -m asyncio, para obter um "async REPL": um console de Python que importa asyncio, fornece um loop de eventos ativo, e aceita await, async for, e async with no prompt principal—que em qualquer outro contexto são erros de sintaxe quando usados fora de corrotinas nativas.[309]

Para experimentar com o domainlib.py, vá ao diretório 21-async/domains/asyncio/ na sua cópia local do repositório de código do Python Fluente. Aí rode::

$ python -m asyncio

Você verá o console iniciar, de forma similar a isso:

asyncio REPL 3.9.1 (v3.9.1:1e5d33e9b9, Dec  7 2020, 12:10:52)
[Clang 6.0 (clang-600.0.57)] on darwin
Use "await" directly instead of "asyncio.run()".
Type "help", "copyright", "credits" or "license" for more information.
>>> import asyncio
>>>

Veja como o cabeçalho diz que você pode usar await em vez de asyncio.run()—para acionar corrotinas e outros esperáveis. E mais: eu não digitei import asyncio. O módulo asyncio é automaticamente importado e aquela linha torna esse fato claro para o usuário.

Vamos agora importar domainlib.py e brincar com suas duas corrotinas: probe and multi_probe (Exemplo 16).

Exemplo 16. Experimentando com domainlib.py após executar python3 -m asyncio
>>> await asyncio.sleep(3, 'Rise and shine!')  # (1)
'Rise and shine!'
>>> from domainlib import *
>>> await probe('python.org')  # (2)
Result(domain='python.org', found=True)  # (3)
>>> names = 'python.org rust-lang.org golang.org no-lang.invalid'.split()  # (4)
>>> async for result in multi_probe(names):  # (5)
...      print(*result, sep='\t')
...
golang.org      True    # (6)
no-lang.invalid False
python.org      True
rust-lang.org   True
>>>
  1. Tente um simples await para ver o console assíncrono em ação. Dica: asyncio.sleep() pode receber um segundo argumento opcional que é devolvido quando você usa await com ele.

  2. Acione a corrotina probe.

  3. A versão domainlib de probe devolve uma tupla nomeada Result.

  4. Faça um lista de domínios. O domínio de nível superior .invalid é reservado para testes. Consultas ao DNS por tais domínios sempre recebem uma resposta NXDOMAIN dos servidores DNS, que quer dizer "aquele domínio não existe."[310]

  5. Itera com async for sobre o gerador assíncrono multi_probe para mostrar os resultados.

  6. Note que os resultados não estão na ordem em que os domínios foram enviados a multiprobe. Eles aparecem quando cada resposta do DNS chega.

O Exemplo 16 mostra que multi_probe é um gerador assíncrono, pois ele é compatível com async for. Vamos executar mais alguns experimentos, continuando com o Exemplo 17.

Exemplo 17. mais experimentos, continuando de Exemplo 16
>>> probe('python.org')  # (1)
<coroutine object probe at 0x10e313740>
>>> multi_probe(names)  # (2)
<async_generator object multi_probe at 0x10e246b80>
>>> for r in multi_probe(names):  # (3)
...    print(r)
...
Traceback (most recent call last):
   ...
TypeError: 'async_generator' object is not iterable
  1. Chamar uma corrotina nativa devolve um objeto corrotina.

  2. Chamar um gerador assíncrono devolve um objeto async_generator.

  3. Não podemos usar um loop for regular com geradores assíncronos, porque eles implementam __aiter__ em vez de __iter__.

Geradores assíncronos são acionados por async for, que pode ser um comando bloqueante (como visto em Exemplo 16), e também podem aparecer em compreensões assíncronas, que veremos mais tarde.

Implementando um gerador assíncrono

Vamos agora estudar o código do domainlib.py, com o gerador assíncrono multi_probe (Exemplo 18).

Exemplo 18. domainlib.py: funções para sondar domínios
import asyncio
import socket
from collections.abc import Iterable, AsyncIterator
from typing import NamedTuple, Optional


class Result(NamedTuple):  # (1)
    domain: str
    found: bool


OptionalLoop = Optional[asyncio.AbstractEventLoop]  # (2)


async def probe(domain: str, loop: OptionalLoop = None) -> Result:  # (3)
    if loop is None:
        loop = asyncio.get_running_loop()
    try:
        await loop.getaddrinfo(domain, None)
    except socket.gaierror:
        return Result(domain, False)
    return Result(domain, True)


async def multi_probe(domains: Iterable[str]) -> AsyncIterator[Result]:  # (4)
    loop = asyncio.get_running_loop()
    coros = [probe(domain, loop) for domain in domains]  # (5)
    for coro in asyncio.as_completed(coros):  # (6)
        result = await coro  # (7)
        yield result  # (8)
  1. A NamedTuple torna o resultado de probe mais fácil de ler e depurar.

  2. Este apelido de tipo serve para evitar que a linha seguinte fique grande demais em uma listagem impressa em um livro.

  3. probe agora recebe um argumento opcional loop, para evitar chamadas repetidas a get_running_loop quando essa corrotina é acionada por multi_probe.

  4. Uma função geradora assíncrona produz um objeto gerador assíncrono, que pode ser anotado como AsyncIterator[SomeType].

  5. Constrói uma lista de objetos corrotina probe, cada um com um domain diferente.

  6. Isso não é async for porque asyncio.as_completed é um gerador clássico.

  7. Espera pelo objeto corrotina para obter o resultado.

  8. Produz result. Esta linha faz com que multi_probe seja um gerador assíncrono.

✒️ Nota

O loop for no Exemplo 18 poderia ser mais conciso:

    for coro in asyncio.as_completed(coros):
        yield await coro

O Python interpreta isso como yield (await coro), então funciona.

Achei que poderia ser confuso usar esse atalho no primeiro exemplo de gerador assíncrono no livro, então dividi em duas linhas.

Dado domainlib.py, podemos demonstrar o uso do gerador assíncrono multi_probe em domaincheck.py: um script que recebe um sufixo de domínio e busca por domínios criados a partir de palavras-chave curtas do Python.

Aqui está uma amostra da saída de domaincheck.py:

$ ./domaincheck.py net
FOUND           NOT FOUND
=====           =========
in.net
del.net
true.net
for.net
is.net
                none.net
try.net
                from.net
and.net
or.net
else.net
with.net
if.net
as.net
                elif.net
                pass.net
                not.net
                def.net

Graças à domainlib, o código de domaincheck.py é bastante direto, como se vê no Exemplo 19.

Exemplo 19. domaincheck.py: utilitário para sondar domínios usando domainlib
#!/usr/bin/env python3
import asyncio
import sys
from keyword import kwlist

from domainlib import multi_probe


async def main(tld: str) -> None:
    tld = tld.strip('.')
    names = (kw for kw in kwlist if len(kw) <= 4)  # (1)
    domains = (f'{name}.{tld}'.lower() for name in names)  # (2)
    print('FOUND\t\tNOT FOUND')  # (3)
    print('=====\t\t=========')
    async for domain, found in multi_probe(domains):  # (4)
        indent = '' if found else '\t\t'  # (5)
        print(f'{indent}{domain}')


if __name__ == '__main__':
    if len(sys.argv) == 2:
        asyncio.run(main(sys.argv[1]))  # (6)
    else:
        print('Please provide a TLD.', f'Example: {sys.argv[0]} COM.BR')
  1. Gera palavras-chave de tamanho até 4.

  2. Gera nomes de domínio com o sufixo recebido como TLD (Top Level Domain, "Domínio de Topo").

  3. Formata um cabeçalho para a saída tabular.

  4. Itera de forma assíncrona sobre multi_probe(domains).

  5. Define indent como zero ou dois tabs, para colocar o resultado na coluna correta.

  6. Roda a corrotina main com o argumento de linha de comando passado.

Geradores tem um uso adicional, não relacionado à iteração: ele podem ser usados como gerenciadores de contexto. Isso também se aplica aos geradores assíncronos.

Geradores assíncronos como gerenciadores de contexto

Escrever nossos próprios gerenciadores de contexto assíncronos não é uma tarefa de programação frequente, mas se você precisar escrever um, considere usar o decorador @asynccontextmanager (EN), incluído no módulo contextlib no Python 3.7. Ele é muito similar ao decorador @contextmanager que estudamos na Seção 18.2.2.

Um exemplo interessante da combinação de @asynccontextmanager com loop.run_in_executor aparece no livro de Caleb Hattingh, Using Asyncio in Python. O Exemplo 20 é o código de Caleb—com uma única mudança e o acréscimo das explicações.

Exemplo 20. Exemplo usando @asynccontextmanager e loop.run_in_executor
from contextlib import asynccontextmanager

@asynccontextmanager
async def web_page(url):  # (1)
    loop = asyncio.get_running_loop()   # (2)
    data = await loop.run_in_executor(  # (3)
        None, download_webpage, url)
    yield data                          # (4)
    await loop.run_in_executor(None, update_stats, url)  # (5)

async with web_page('google.com') as data:  # (6)
    process(data)
  1. A função decorada tem que ser um gerador assíncrono.

  2. Uma pequena atualização no código de Caleb: usar o get_running_loop, mais leve, no lugar de get_event_loop.

  3. Suponha que download_webpage é uma função bloqueante que usa a biblioteca requests; vamos rodá-la em uma thread separada, para evitar o bloqueio do loop de eventos.

  4. Todas as linhas antes dessa expressão yield vão se tornar o método corrotina __aenter__ do gerenciador de contexto assíncrono criado pelo decorador. O valor de data será vinculado à variável data após a cláusula as no comando async with abaixo.

  5. As linhas após o yield se tornarão o método corrotina __aexit__. Aqui outra chamada bloqueante é delegada para um executor de threads.

  6. Usa web_page com async with.

Isso é muito similar ao decorador sequencial @contextmanager. Por favor, consulte a Seção 18.2.2 para maiores detalhes, inclusive o tratamento de erro na linha do yield. Para outro exemplo usando @asynccontextmanager, veja a documentação do contextlib.

Por fim, vamos terminar nossa jornada pelas funções geradoras assíncronas comparado-as com as corrotinas nativas.

Geradores assíncronos versus corrotinas nativas

Aqui estão algumas semelhanças e diferenças fundamentais entre uma corrotina nativa e uma função geradora assíncrona:

  • Ambas são declaradas com async def.

  • Um gerador assíncrono sempre tem uma expressão yield em seu corpo—é isso que o torna um gerador. Uma corrotina nativa nunca contém um yield.

  • Uma corrotina nativa pode return (devolver) algum valor diferente de None. Um gerador assíncrono só pode usar comandos return vazios.

  • Corrotinas nativas são esperáveis: elas podem ser acionadas por expressões await ou passadas para alguma das muitas funções do asyncio que aceitam argumentos esperáveis, tal como create_task. Geradores assíncronos não são esperáveis. Eles são iteráveis assíncronos, acionados por async for ou por compreensões assíncronas.

É hora então de falar sobre as compreensões assíncronas.

21.10.2. Compreensões assíncronas e expressões geradoras assíncronas

A PEP 530—Asynchronous Comprehensions (EN) introduziu o uso de async for e await na sintaxe de compreensões e expressões geradoras, a partir do Python 3.6.

A única sintaxe definida na PEP 530 que pode aparecer fora do corpo de uma async def é uma expressão geradora assíncrona.

Definindo e usando uma expressão geradora assíncrona

Dado o gerador assíncrono multi_probe do Exemplo 18, poderíamos escrever outro gerador assíncrono que devolvesse apenas os nomes de domínios encontrados. Aqui está uma forma de fazer isso—novamente usando o console assíncrono iniciado com -m asyncio:

>>> from domainlib import multi_probe
>>> names = 'python.org rust-lang.org golang.org no-lang.invalid'.split()
>>> gen_found = (name async for name, found in multi_probe(names) if found)  # (1)
>>> gen_found
<async_generator object <genexpr> at 0x10a8f9700>  # (2)
>>> async for name in gen_found:  # (3)
...     print(name)
...
golang.org
python.org
rust-lang.org
  1. O uso de async for torna isso uma expressão geradora assíncrona. Ela pode ser definida em qualquer lugar de um módulo Python.

  2. A expressão geradora assíncrona cria um objeto async_generator—exatamente o mesmo tipo de objeto devolvido por uma função geradora assíncrona como multi_probe.

  3. O objeto gerador assíncrono é acionado pelo comando async for, que por sua vez só pode aparecer dentro do corpo de uma async def ou no console assíncrono mágico que eu usei nesse exemplo.

Resumindo: uma expressão geradora assíncrona pode ser definida em qualquer ponto do seu programa, mas só pode ser consumida dentro de uma corrotina nativa ou de uma função geradora assíncrona.

As demais construções sintáticas introduzidos pela PEP 530 só podem ser definidos e usados dentro de corrotinas nativas ou de funções geradoras assíncronas.

Compreeensões assíncronas

Yury Selivanov—autor da PEP 530—justifica a necessidade de compreensões assíncronas com três trechos curtos de código, reproduzidos a seguir.

Podemos todos concordar que deveria ser possível reescrever esse código:

result = []
async for i in aiter():
    if i % 2:
        result.append(i)

assim:

result = [i async for i in aiter() if i % 2]

Além disso, dada uma corrotina nativa fun, deveria ser possível escrever isso:

result = [await fun() for fun in funcs]
👉 Dica

Usar await em uma compreensão de lista é similar a usar asyncio.gather. Mas gather dá a você um maior controle sobre o tratamento de exceções, graças ao seu argumento opcional return_exceptions. Caleb Hattingh recomenda sempre definir return_exceptions=True (o default é False). Veja a documentação de asyncio.gather para mais informações.

Voltemos ao console assíncrono mágico:

>>> names = 'python.org rust-lang.org golang.org no-lang.invalid'.split()
>>> names = sorted(names)
>>> coros = [probe(name) for name in names]
>>> await asyncio.gather(*coros)
[Result(domain='golang.org', found=True),
Result(domain='no-lang.invalid', found=False),
Result(domain='python.org', found=True),
Result(domain='rust-lang.org', found=True)]
>>> [await probe(name) for name in names]
[Result(domain='golang.org', found=True),
Result(domain='no-lang.invalid', found=False),
Result(domain='python.org', found=True),
Result(domain='rust-lang.org', found=True)]
>>>

Observe que eu ordenei a lista de nomes, para mostrar que os resultados chegam na ordem em que foram enviados, nos dois casos.

A PEP 530 permite o uso de async for e await em compreensões de lista, bem como em compreensões de dict e de set. Por exemplo, aqui está uma compreensão de dict para armazenar os resultados de multi_probe no console assíncrono:

>>> {name: found async for name, found in multi_probe(names)}
{'golang.org': True, 'python.org': True, 'no-lang.invalid': False,
'rust-lang.org': True}

Podemos usar a palavra-chave await na expressão antes de cláusulas for ou de async for, e também na expressão após a cláusula if. Aqui está uma compreensão de set no console assíncrono, coletando apenas os domínios encontrados.

>>> {name for name in names if (await probe(name)).found}
{'rust-lang.org', 'python.org', 'golang.org'}

Precisei colocar parênteses extras ao redor da expressão await devido à precedência mais alta do operador . (ponto) de __getattr__.

Repetindo, todas essas compreensões só podem aparecer no corpo de uma async def ou no console assíncrono encantado.

Agora vamos discutir um recurso muito importante dos comandos async, das expressões async, e dos objetos que eles criam. Esses artefatos são muitas vezes usados com o asyncio mas, na verdade, eles são independentes da biblioteca.

21.11. Programação assíncrona além do asyncio: Curio

Os elementos da linguagem async/await do Python não estão presos a nenhum loop de eventos ou biblioteca específicos.[311] Graças à API extensível fornecida por métodos especiais, qualquer um suficientemente motivado pode escrever seu ambiente de runtime e suo framework assíncronos para acionar corrotinas nativas, geradores assíncronos, etc.

Foi isso que David Beazley fez em seu projeto Curio. Ele estava interessado em repensar como esses recursos da linguagem poderiam ser usados em um framework desenvolvido do zero. Lembre-se que o asyncio foi lançado no Python 3.4, e usava yield from em vez de await, então sua API não conseguia aproveitar gerenciadores de contexto assíncronos, iteradores assíncronos e tudo o mais que as palavras-chave async/await tornaram possível. O resultado é que o Curio tem uma API mais elegante e uma implementação mais simples quando comparado ao asyncio.

O Exemplo 21 mostra o script blogdom.py (Exemplo 1) reescrito para usar o Curio.

Exemplo 21. blogdom.py: Exemplo 1, agora usando o Curio
#!/usr/bin/env python3
from curio import run, TaskGroup
import curio.socket as socket
from keyword import kwlist

MAX_KEYWORD_LEN = 4


async def probe(domain: str) -> tuple[str, bool]:  # (1)
    try:
        await socket.getaddrinfo(domain, None)  # (2)
    except socket.gaierror:
        return (domain, False)
    return (domain, True)

async def main() -> None:
    names = (kw for kw in kwlist if len(kw) <= MAX_KEYWORD_LEN)
    domains = (f'{name}.dev'.lower() for name in names)
    async with TaskGroup() as group:  # (3)
        for domain in domains:
            await group.spawn(probe, domain)  # (4)
        async for task in group:  # (5)
            domain, found = task.result
            mark = '+' if found else ' '
            print(f'{mark} {domain}')

if __name__ == '__main__':
    run(main())  # (6)
  1. probe não precisa obter o loop de eventos, porque…​

  2. …​getaddrinfo é uma função nível superior de curio.socket, não um método de um objeto loop—como ele é no asyncio.

  3. Um TaskGroup é um conceito central no Curio, para monitorar e controlar várias corrotinas, e para garantir que elas todas sejam executadas e terminadas corretamente.

  4. TaskGroup.spawn é como você inicia uma corrotina, gerenciada por uma instância específica de TaskGroup. A corrotina é envolvida em uma Task.

  5. Iterar com async for sobre um TaskGroup produz instâncias de Task a medida que cada uma termina. Isso corresponde à linha em Exemplo 1 que usa for … as_completed(…):.

  6. O Curio foi pioneiro no uso dessa maneira sensata de iniciar um programa assíncrono em Python.

Para expandir esse último ponto: se você olhar nos exemplo de código de asyncio na primeira edição do Python Fluente, verá linhas como as seguintes, repetidas várias vezes:

    loop = asyncio.get_event_loop()
    loop.run_until_complete(main())
    loop.close()

Um TaskGroup do Curio é um gerenciador de contexto assíncrono que substitui várias APIs e padrões de codificação ad hoc do asyncio. Acabamos de ver como iterar sobre um TaskGroup torna a função asyncio.as_completed(…) desnecessária.

Outro exemplo: em vez da função especial gather, este trecho da documentação de "Task Groups" (EN) coleta os resultados de todas as tarefas no grupo:

async with TaskGroup(wait=all) as g:
    await g.spawn(coro1)
    await g.spawn(coro2)
    await g.spawn(coro3)
print('Results:', g.results)

Grupos de tarefas (task groups) suportam concorrência estruturada: uma forma de programação concorrente que restringe todas a atividade de um grupo de tarefas assíncronas a um único ponto de entrada e saída. Isso é análogo à programaçào estruturada, que eliminou o comando GOTO e introduziu os comandos de bloco para limitar os pontos de entrada e saída de loops e sub-rotinas. Quando usado como um gerenciador de contexto assíncrono, um TaskGroup garante que na saída do bloco, todas as tarefas criadas dentro dele estão ou finalizadas ou canceladas e qualquer exceção foi levantada.

✒️ Nota

A concorrência estruturada vai provavelmente ser adotada pelo asyncio em versões futuras do Python. Uma indicação forte disso aparece na PEP 654–Exception Groups and except* (EN), que foi aprovada para o Python 3.11 (EN). A seção "Motivation"(EN) menciona as "creches" (nurseries) do _Trio, o nome que els dão para grupos de tarefas: "Implementar uma API de geração de tarefas melhor no asyncio, inspirada pelas creches do Trio, foi a principal motivação dessa PEP."

Outro importante recurso do Curio é um suporte melhor para programar com corrotinas e threads na mesma base de código—uma necessidade de qualquer programa assíncrono não-trivial. Iniciar uma thread com await spawn_thread(func, …) devolve um objeto AsyncThread com uma interface de Task. As threads podem chamar corrotinas, graças à função especial AWAIT(coro)—escrita inteiramente com maiúsculas porque await agora é uma palavra-chave.

O Curio também oferece uma UniversalQueue que pode ser usada para coordenar o trabalho entre threads, corrotinas Curio e corrotinas asyncio. Isso mesmo, o Curio tem recursos que permitem que ele rode em uma thread junto com asyncio em outra thread, no mesmo processo, se comunicando através da UniversalQueue e de UniversalEvent. A API para essas classes "universais" é a mesma dentro e fora de corrotinas, mas em uma corrotina é preciso preceder as chamadas com await.

Em outubro de 2021, quando estou escrevendo esse capítulo, a HTTPX é a primeira biblioteca HTTP cliente compatível com o Curio, mas não sei de nenhuma biblioteca assíncrona de banco de dados que o suporte nesse momento. No repositório do Curio há um conjunto impressionante de exemplos de programação para rede, incluindo um que utiliza WebSocket, e outro implementando o algoritmo concorrente RFC 8305—Happy Eyeballs, para conexão com pontos de acesso IPv6 com rápido recuo para IPv4 se necessário.

O design do Curio tem tido grande influência. o framework Trio, iniciada por Nathaniel J. Smith, foi muito inspirada pelo Curio. O Curio pode também ter alertado os contribuidores do Python a melhorarem a usabilidade da API asyncio. Por exemplo, em suas primeiras versões, os usuários do asyncio muitas vezes eram obrigados a obter e ficar passando um objeto loop, porque algumas funções essenciais eram ou métodos de loop ou exigiam um argumento loop. Em versões mais recentes do Python, acesso direto ao loop não é mais tão necessário e, de fato, várias funções que aceitavam um loop opcional estão agora descontinuando aquele argumento.

Anotações de tipo para tipos assíncronos é o nosso próximo tópico.

21.12. Dicas de tipo para objetos assíncronos

O tipo devolvido por uma corrotina nativa é o tipo do objeto que você obtém quando usa await naquela corrotina, que é o tipo do objeto que aparece nos comandos return no corpo da função corrotina nativa.[312]

Nesse capítulo mostro muitos exemplos de corrotinas nativas anotadas, incluindo a probe do Exemplo 21:

async def probe(domain: str) -> tuple[str, bool]:
    try:
        await socket.getaddrinfo(domain, None)
    except socket.gaierror:
        return (domain, False)
    return (domain, True)

Se você precisar anotar um parâmetro que recebe um objeto corrotina, então o tipo genérico é:

class typing.Coroutine(Awaitable[V_co], Generic[T_co, T_contra, V_co]):
    ...

Aquele tipo e os tipos seguintes foram introduzidos no Python 3.5 e 3.6 para anotar objetos assíncronos:

class typing.AsyncContextManager(Generic[T_co]):
    ...
class typing.AsyncIterable(Generic[T_co]):
    ...
class typing.AsyncIterator(AsyncIterable[T_co]):
    ...
class typing.AsyncGenerator(AsyncIterator[T_co], Generic[T_co, T_contra]):
    ...
class typing.Awaitable(Generic[T_co]):
    ...

Com o Python ≥ 3.9, use os equivalentes deles em collections.abc.

Quero destacar três aspectos desses tipos genéricos.

Primeiro: eles são todos covariantes do primeiro parâmetro de tipo, que é o tipo dos itens produzidos a partir desses objetos. Lembre-se da regra #1 da Seção 15.7.4.4:

Se um parâmetro de tipo formal define um tipo para um dado que sai do objeto, ele pode ser covariante.

Segundo: AsyncGenerator e Coroutine são contra-variantes do segundo ao último parâmetros. Aquele é o tipo do argumento do método de baixo nível .send(), que o loop de eventos chama para acionar geradores assíncronos e corrotinas. Dessa forma, é um tipo de "entrada". Assim, pode ser contra-variante, pelo Regra de Variância #2

Se um parâmetro de tipo formal define um tipo para um dado que entra no objeto após sua construção inicial, ele pode ser contra-variante.

Terceiro: AsyncGenerator não tem tipo de devolução, ao contrário de typing.Generator, que vimos na Seção 17.13.3. Devolver um valor levantando StopIteration(value) era uma das gambiarras que permitia a geradores operarem como corrotinas e suportar yield from, como vimos na Seção 17.13. Não há tal sobreposição entre os objetos assíncronos: objetos AsyncGenerator não devolvem valores e são completamente separados de objetos corrotina, que são anotados com typing.Coroutine.

Por fim, vamos discutir rapidamente as vantagens e desafios da programaçào assíncrona.

21.13. Como a programação assíncrona funciona e como não funciona

As seções finais deste capítulo discutem as ideias de alto nível em torno da programação assíncrona, independente da linguagem ou da biblioteca usadas.

Vamos começar explicando a razão número 1 pela qual a programação assíncrona é atrativa, seguido por um mito popular e como lidar com ele.

21.13.1. Correndo em círculos em torno de chamadas bloqueantes

Ryan Dahl, o inventor do Node.js, introduz a filosofia por trás de seu projeto dizendo "Estamos fazendo E/S de forma totalmente errada."[313] (EN). Ele define uma função bloqueante como uma função que faz E/S de arquivo ou rede, e argumenta que elas não podem ser tratadas da mesma forma que tratamos funções não-bloqueantes. Para explicar a razão disso, ele apresenta os números na segunda coluna da Tabela 25.

Tabela 25. Latência de computadores modernos para ler dados em diferentes dispositivos. A terceira coluna mostra os tempos proporcionais em uma escala fácil de entender para nós, humanos vagarosos.
Dispositivo Ciclos de CPU Escala proporcional "humana"

L1 cache

3

3 segundos

L2 cache

14

14 segundos

RAM

250

250 segundos

disk

41.000.000

1,3 anos

network

240.000.000

7,6 anos

Para a Tabela 25 fazer sentido, tenha em mente que as CPUs modernas, com relógios funcionando em frequências na casa dos GHz, rodam bilhões de ciclos por segundo. Vamos dizer que uma CPU rode exatamente 1 bilhão de ciclos por segundo. Aquela CPU pode realizar mais de 333 milhões de leituras do cache L1 em 1 segundo, ou 4 (quatro!) leituras da rede no mesmo tempo. A terceira coluna da Tabela 25 coloca os números em perspectiva, multiplicando a segunda coluna por um fator constante. Então, em um universo alternativo, se uma leitura do cache L1 demorasse 3 segundos, uma leitura da rede demoraria 7,6 anos!

A Tabela 25 explica porque uma abordagem disciplinada da programação assíncrona pode levar a servidores de alto desempenho. O desafio é conseguir essa disciplina. O primeiro passo é reconhecer que um sistema limitado apenas por E/S é uma fantasia.

21.13.2. O mito dos sistemas limitados por E/S

Um meme exaustivamente repetido é que programação assíncrona é boa para "sistemas limitados por E/S"—I/O bound systems, ou seja, sistemas onde o gargalo é E/S, e não processamento de dados na CPU. Aprendi da forma mais difícil que não existem "sistemas limitados por E/S." Você pode ter funções limitadas por E/S. Talvez a maioria das funções no seu sistema sejam limitadas por E/S; isto é, elas passam mais tempo esperando por E/S do que realizando operações na memória. Enquanto esperam, cedem o controle para o loop de eventos, que pode então acionar outras tarefas pendentes. Mas, inevitavelmente, qualquer sistema não-trivial terá partes limitadas pela CPU. Mesmo sistemas triviais revelam isso, sob stress. No Ponto de vista, conto a história de dois programas assíncronos sofrendo com funções limitadas pela CPU freando loop de eventos, com severos impactos no desempenho do sistema como um todo.

Dado que qualquer sistema não-trivial terá funções limitadas pela CPU, lidar com elas é a chave do sucesso na programação assíncrona.

21.13.3. Evitando as armadilhas do uso da CPU

Se você está usando Python em larga escala, deve ter testes automatizados projetados especificamente para detectar regressões de desempenho assim que elas acontecem. Isso é de importância crítica com código assíncrono, mas é relevante também para código Python baseado em threads—por causa da GIL. Se você esperar até a lentidão começar a incomodar a equipe de desenvolvimento, será tarde demais. O conserto provavelmente vai exigir algumas mudanças drásticas.

Aqui estão algumas opções para quando você identifica gargalos de uso da CPU:

  • Delegar a tarefa para um pool de processos Python.

  • Delegar a tarefa para um fila de tarefas externa.

  • Reescrever o código relevante em Cython, C, Rust ou alguma outra linguagem que compile para código de máquina e faça interface com a API Python/C, de preferência liberando a GIL.

  • Decidir que você pode tolerar a perda de desempenho e deixar como está—mas registre essa decisão, para torná-la mais fácil de reverter no futuro.

A fila de tarefas externa deveria ser escolhida e integrada o mais rápido possível, no início do projeto, para que ninguém na equipe hesite em usá-la quando necessário.

A opção deixar como está entra na categoria de dívida tecnológica.

Programação concorrente é um tópico fascinante, e eu gostaria de escrever muito mais sobre isso. Mas não é o foco principal deste livro, e este já é um dos capítulos mais longos, então vamos encerrar por aqui.

21.14. Resumo do capítulo

O problema com as abordagens usuais da programação assíncrona é que elas são propostas do tipo "tudo ou nada". Ou você reescreve todo o código, de forma que nada nele bloqueie [o processamento] ou você está só perdendo tempo.

— Alvaro Videla e Jason J. W. Williams
RabbitMQ in Action

Escolhi essa epígrafe para esse capítulo por duas razões. Em um nível mais alto, ela nos lembra de evitar o bloqueio do loop de eventos, delegando tarefas lentas para uma unidade de processamento diferente, desde uma simples thread indo até uma fila de tarefas distribuída. Em um nível mais baixo, ela também é um aviso: no momento em que você escreve seu primeiro async def, seu programa vai inevitavelmente ver surgir mais e mais async def, await, async with, e async for. E o uso de bibliotecas não-assíncronas subitamente se tornará um desafio.

Após os exemplos simples com o spinner no Capítulo 19, aqui nosso maior foco foi a programação assíncrona com corrotinas nativas, começando com o exemplo de sondagem de DNS blogdom.py, seguido pelo conceito de esperáveis. Lendo o código-fonte de flags_asyncio.py, descobrimos o primeiro exemplo de um gerenciador de contexto assíncrono.

As variantes mais avançadas do programa de download de bandeiras introduziram duas funções poderosas: o gerador asyncio.as_completed generator e a corrotina loop.run_in_executor. Nós também vimos o conceito e a aplicação de um semáforo, para limitar o número de downloads concorrentes—como esperado de clientes HTTP bem comportados.

A programaçãp assíncrona para servidores foi apresentada com os exemplos mojifinder: um serviço web usando a FastAPI e o tcp_mojifinder.py—este último utilizando apenas o asyncio e o protocolo TCP..

A seguir, iteração assíncrona e iteráveis assíncronos foram o principal tópico, com seções sobre async for, o console assíncrono do Python, geradores assíncronos expressões geradoras assíncronas e compreensões assíncronas.

O último exemplo do capítulo foi o blogdom.py reescrito com o framework Curio, demonstrando como os recursos de programação assíncrona do Python não estão presos ao pacote asyncio. O Curio também demonstra o conceito de concorrência estruturada, que pode vir a ter um grande impacto em toda a indústria de tecnologia, trazendo mais clareza para o código concorrente.

Por fim, as seções sob o título Seção 21.13 discutiram o principal atrativo da programação assíncrona, a falácia dos "sistemas limitados por E/S" e como lidar com as inevitáveis partes de uso intensivo de CPU em seu programa.

21.15. Para saber mais

A palestra de abertura da PyOhio 2016, de David Beazley, "Fear and Awaiting in Async" (EN) é uma fantástica introdução, com "código ao vivo", ao potencial dos recursos da linguagem tornados possíveis pela contribuição de Yury Selivanov ao Python 3.5, as palavras-chave async/await. Em certo momento, Beazley reclama que await não pode ser usada em compreensões de lista, mas isso foi resolvido por Selivanov na PEP 530—Asynchronous Comprehensions (EN), implementada mais tarde naquele mesmo ano, no Python 3.6.

Fora isso, todo o resto da palestra de Beazley é atemporal, pois ele demonstra como os objetos assíncronos vistos nesse capítulo funcionam, sem ajuda de qualquer framework—com uma simples função run usando .send(None) para acionar corrotinas. Apenas no final Beazley mostra o Curio, que ele havia começado a programar naquele ano, como um experimento, para ver o quão longe era possível levar a programação assíncrona sem se basear em callbacks ou futures, usando apenas corrotinas. Como se viu, dá para ir muito longe—como demonstra a evolução do Curio e a criação posterior do Trio por Nathaniel J. Smith. A documentação do Curio’s contém links para outras palestras de Beazley sobre o assunto.

Além criar o Trio, Nathaniel J. Smith escreveu dois post de blog muito profundos, que gostaria de recomendar: "Some thoughts on asynchronous API design in a post-async/await world" (Algumas reflexões sobre o design de APIs assíncronas em um mundo pós-async/await) (EN), comparando os designs do Curio e do asyncio, e "Notes on structured concurrency, or: Go statement considered harmful" (Notas sobre concorrência estruturada, ou: o comando Go considerado nocivo) (EN), sobre concorrência estruturada. Smith também deu uma longa e informativa resposta à questão: "What is the core difference between asyncio and trio?" (Qual é a principal diferença entre o asyncio e o trio?) (EN) no StackOverflow.

Para aprender mais sobre o pacote asyncio, já mencionei os melhores recursos escritos que conheço no início do capítulo: a documentação oficial, após a fantástica reorganização (EN) iniciada por Yury Selivanov em 2018, e o livro de Caleb Hattingh, Using Asyncio in Python (O’Reilly).

Na documentação oficial, não deixe de ler "Desenvolvendo com asyncio", que documenta o modo de depuração do asyncio e também discute erros e armadilhas comuns, e como evitá-los.

Para uma introdução muito acessível, de 30 minutos, à programação assíncrona em geral e também ao asyncio, assista a palestra "Asynchronous Python for the Complete Beginner" (Python Assíncrono para o Iniciante Completo) (EN), de Miguel Grinberg, apresentada na PyCon 2017. Outra ótima introdução é "Demystifying Python’s Async and Await Keywords" (Desmistificando as Palavras-Chave Async e Await do Python) (EN), apresentada por Michael Kennedy, onde entre outras coisas aprendi sobre a bilblioteca unsync, que oferece um decorador para delegar a execução de corrotinas, funções dedicadas a E/S e funções de uso intensivo de CPU para asyncio, threading, ou multiprocessing, conforme a necessidade.

Na EuroPython 2019, Lynn Root—uma líder global da PyLadies—apresentou a excelente "Advanced asyncio: Solving Real-world Production Problems" (Asyncio Avançado: Resolvendo Problemas de Produção do Mundo Real) (EN), baseada na sua experiência usando Python como um engenheira do Spotify.

Em 2020, Łukasz Langa gravou um grande série de vídeos sobre o asyncio, começando com "Learn Python’s AsyncIO #1—​The Async Ecosystem" (Aprenda o AsyncIO do Python—O Ecossistema Async) (EN). Langa também fez um vídeo muito bacana, "AsyncIO + Music" (EN), para a PyCon 2020, que não apenas mostra o asyncio aplicado a um domínio orientado a eventos muito concreto, como também explica essa aplicação do início ao fim.

Outra área dominada por programaçao orientada a eventos são os sistemas embarcados. Por isso Damien George adicionou o suporte a async/await em seu interpretador MicroPython (EN) para microcontroladores Na PyCon Australia 2018, Matt Trentini demonstrou a biblioteca uasyncio (EN), um subconjunto do asyncio que é parte da biblioteca padrão do MicroPython.

Para uma visão de mais alto nível sobre a programação assíncrona em Python, leia o post de blog "Python async frameworks—Beyond developer tribalism" (Frameworks assíncronos do Python—Para além do tribalismo dos desenvolvedores) (EN), de Tom Christie.

Por fim, recomendo "What Color Is Your Function?" (Qual a Cor da Sua Função?) de Bob Nystrom, discutindo os modelos de execução incompatíveis de funções normais versus funções assíncronas—também conhecidas como corrotinas—em Javascript, Python, C# e outras linguagens. Alerta de spoiler: A conclusão de Nystrom é que a linguagem que acertou nessa área foi Go, onde todas as funções tem a mesma cor. Eu gosto disso no Go. Mas também acho que Nathaniel J. Smith tem razão quando escreveu "Go statement considered harmful" (Comando Go considerado nocivo) (EN). Nada é perfeito, e programação concorrente é sempre difícil.

Ponto de vista

Como uma função lerda quase estragou as benchmarks do uvloop

Em 2016, Yury Selivanov lançou o uvloop, "um substituto rápido e direto para o loop de eventos embutido do asyncio event loop." Os números de referência (benchmarks) apresentados no post de blog de Selivanov anunciando a biblioteca, em 2016, eram muito impressionantes. Ele escreveu: "ela é pelo menos 2x mais rápida que o nodejs e gevent, bem como qualquer outro framework assíncrona do Python. O desempenho do asyncio com o uvloop é próximo àquele de programas em Go."

Entretanto, o post revela que a uvloop é capaz de competir com o desempenho do Go sob duas condições:

  1. Que o Go seja configurado para usar uma única thread. Isso faz o runtime do Go se comportar de forma similar ao asyncio: a concorrência é alcançada através de múltiplas corrotinas acionadas por um loop de eventos, todos na mesma thread.[314]

  2. Que o código Python 3.5 use a biblioteca httptools além do próprio uvloop.

Selivanov explica que escreveu httptools após testar o desempenho da uvloop com a aiohttp—uma das primeiras bibliotecas HTTP completas construídas sobre o asyncio:

Entretanto, o gargalo de desempenho no aiohttp estava em seu parser de HTTP, que era tão lento que pouco importava a velocidade da biblioteca de E/S subjacente. Para tornar as coisas mais interessantes, criamos uma biblioteca para Python usar a http-parser (a biblioteca em C do parser do Node.js, originalmente desenvolvida para o NGINX). A biblioteca é chamada httptools, e está disponível no Github e no PyPI.

Agora reflita sobre isso: os testes de desempenho HTTP de Selivanov consistiam de um simples servidor eco escrito em diferentes linguagens e usando diferentes bibliotecas, testados pela ferramenta de benchmarking wrk. A maioria dos desenvolvedores consideraria um simples servidor eco um "sistema limitado por E/S", certo? Mas no fim, a análise de cabeçalhos HTTP é vinculada à CPU, e tinha uma implementação Python lenta na aiohttp quando Selivanov realizou os testes, em 2016. Sempre que uma função escrita em Python estava processando os cabeçalhos, o loop de eventos era bloqueado. O impacto foi tão significativo que Selivanov se deu ao trabalho extra de escrever o httptools. Sem a otimização do código de uso intensivo de CPU, os ganhos de desempenho de um loop de eventos mais rápido eram perdidos.

Morte por mil cortes

Em vez de um simples servidor eco, imagine um sistema Python complexo e em evolução, com milhares de linhas de código assíncrono, e conectado a muitas bibliotexas externas. Anos atrás me pediram para ajudar a diagnosticar problemas de desempenho em um sistema assim. Ele era escrito em Python 2.7, com o framework Twisted—uma biblioteca sólida e, de muitas maneiras, uma precursora do próprio asyncio.

Python era usado para construir uma fachada para a interface Web, integrando funcionalidades fornecidas por biliotecas pré-existentes e ferramentas de linha de comando escritas em outras linguagens—mas não projetadas para execução concorrente.

O projeto era ambicioso: já estava em desenvolvimento há mais de um ano, mas ainda não estava em produção.[315] Com o tempo, os desenvolvedores notaram que o desempenho do sistema como um todo estava diminuindo, e estavam tendo muita dificuldade em localizar os gargalos.

O que estava acontecendo: a cada nova funcionalidade, mais código intensivo em CPU desacelerava o loop de eventos do Twisted. O papel do Python como um linguagem de "cola" significava que havia muita interpretação de dados, serialização, desserialização, e muitas conversões entre formatos. Não havia um gargalo único: o problema estava espalhado por incontáveis pequenas funções criadas ao longo de meses de desenvolvimento. O conserto exigiria repensar a arquitetura do sistema, reescrever muito código, provavelmente usar um fila de tarefas, e talvez usar microsserviços ou bibliotecas personalizadas, escritas em linguagens mais adequadas a processamento concorrente intensivo em CPU. Os apoiadores internos não estavam preparados para fazer aquele investimento adicional, e o projeto foi cancelado semanas depois deste diagnóstico.

Quando contei essa história para Glyph Lefkowitz—fundador do projeto Twisted—ele me disse que uma de suas prioridades, no início de qualquer projeto envolvendo programação assíncrona, é decidir que ferramentas serão usadas para executar de tarefas intensivas em CPU sem atrapalhar o loop de eventos. Essa conversa com Glyph foi a inspiração para a Seção 21.13.3.

Parte V: Metaprogramação

22. Atributos dinâmicos e propriedades

A importância crucial das propriedades é que sua existência torna perfeitamente seguro, e de fato aconselhável, expor atributos públicos de dados como parte da interface pública de sua classe.[316]

— Martelli, Ravenscroft & Holden
Why properties are important (Porque propriedades são importantes)

No Python, atributos de dados e métodos são conhecidos conjuntamente como atributos . Um método é um atributo invocável. Atributos dinâmicos apresentam a mesma interface que os atributos de dados—isto é, obj.attr—mas são computados sob demanda. Isso atende ao Princípio de Acesso Uniforme de Bertrand Meyer:

Todos os serviços oferecidos por um módulo deveriam estar disponíveis através de uma notação uniforme, que não revele se eles são implementados por armazenamento ou por computação.[317]

— Bertrand Meyer
Object-Oriented Software Construction (Construção de Software Orientada a Objetos)

Há muitas formas de implementar atributos dinâmicos em Python. Este capítulo trata das mais simples delas: o decorador @property e o método especial __getattr__.

Uma classe definida pelo usuário que implemente __getattr__ pode implementar uma variação dos atributos dinâmicos que chamo de atributos virtuais: atributos que não são declarados explicitamente em lugar algum no código-fonte da classe, e que não estão presentes no __dict__ das instâncias, mas que podem ser obtidos de algum outro lugar ou calculados em tempo real sempre que um usuário tenta ler um atributo inexistente tal como obj.no_such_attr.

Programar atributos dinâmicos e virtuais é o tipo de metaprogramação que autores de frameworks fazem. Entretanto, como as técnicas básicas no Python são simples, podemos usá-las nas tarefas cotidianas de processamento de dados. É por aí que iniciaremos esse capítulo.

22.1. Novidades nesse capítulo

A maioria das atualizações deste capítulo foram motivadas pela discussão relativa a @functools.cached_property (introduzido no Python 3.8), bem como pelo uso combinado de @property e @functools.cache (novo no 3.9). Isso afetou o código das classes Record e Event, que aparecem na Seção 22.3. Também acrescentei uma refatoração para aproveitar a otimização da PEP 412—Key-Sharing Dictionary (Dicionário de Compartilhamento de Chaves).

Para enfatizar as características mais relevantes, e ao mesmo tempo manter os exemplos legíveis, removi algum código não-essencial—fundindo a antiga classe DbRecord com Record, substituindo shelve.Shelve por um dict e suprimindo a lógica para baixar o conjunto de dados da OSCON—que os exemplos agora leem de um arquivo local, disponível no repositório de código do Python Fluente.

22.2. Processamento de dados com atributos dinâmicos

Nos próximos exemplos, vamos nos valer dos atributos dinâmicos para trabalhar com um conjunto de dados JSON publicado pela O’Reilly, para a conferência OSCON 2014. O Exemplo 1 mostra quatro registros daquele conjunto de dados.[318]

Exemplo 1. Amostra de registros do osconfeed.json; o conteúdo de alguns campos foi abreviado
{ "Schedule": 
  { "conferences": [{"serial": 115 }],
    "events": [
      { "serial": 34505,
        "name": "Why Schools Don´t Use Open Source to Teach Programming",
        "event_type": "40-minute conference session",
        "time_start": "2014-07-23 11:30:00",
        "time_stop": "2014-07-23 12:10:00",
        "venue_serial": 1462,
        "description": "Aside from the fact that high school programming...",
        "website_url": "http://oscon.com/oscon2014/public/schedule/detail/34505", 
        "speakers": [157509],
        "categories": ["Education"] }
    ],
    "speakers": [
      { "serial": 157509,
        "name": "Robert Lefkowitz",
        "photo": null,
        "url": "http://sharewave.com/",
        "position": "CTO",
        "affiliation": "Sharewave",
        "twitter": "sharewaveteam",
        "bio": "Robert ´r0ml´ Lefkowitz is the CTO at Sharewave, a startup..." }
    ],
    "venues": [
      { "serial": 1462,
        "name": "F151",
        "category": "Conference Venues" }
    ]
  }
}

O Exemplo 1 mostra 4 dos 895 registros no arquivo JSON. O conjunto dados total é um único objeto JSON, com a chave "Schedule" (Agenda), e seu valor é outro mapeamento com quatro chaves: "conferences" (conferências), "events" (eventos), "speakers" (palestrantes), e "venues" (locais). Cada uma dessas quatro últimas chaves aponta para uma lista de registros. No conjunto de dados completo, as listas de "events", "speakers" e "venues"`contêm dezenas ou centenas de registros, ao passo que `"conferences" contém apenas aquele único registro exibido no Exemplo 1. Cada registro inclui um campo "serial", que é um identificador único do registro dentro da lista.

Usei o console do Python para explorar o conjuntos de dados, como mostra o Exemplo 2.

Exemplo 2. Exploração interativa do osconfeed.json
>>> import json
>>> with open('data/osconfeed.json') as fp:
...     feed = json.load(fp)  # (1)
>>> sorted(feed['Schedule'].keys())  # (2)
['conferences', 'events', 'speakers', 'venues']
>>> for key, value in sorted(feed['Schedule'].items()):
...     print(f'{len(value):3} {key}')  # (3)
...
  1 conferences
484 events
357 speakers
 53 venues
>>> feed['Schedule']['speakers'][-1]['name']  # (4)
'Carina C. Zona'
>>> feed['Schedule']['speakers'][-1]['serial']  # (5)
141590
>>> feed['Schedule']['events'][40]['name']
'There *Will* Be Bugs'
>>> feed['Schedule']['events'][40]['speakers']  # (6)
[3471, 5199]
  1. feed é um dict contendo dicts e listas aninhados, com valores string e inteiros.

  2. Lista as quatro coleções de registros dentro de "Schedule".

  3. Exibe a contagem de registros para cada coleção.

  4. Navega pelos dicts e listas aninhados para obter o nome da última palestrante (speaker).

  5. Obtém o número de série para aquela mesma palestrante.

  6. Cada evento tem uma lista 'speakers', com o número de série de zero ou mais palestrantes.

22.2.1. Explorando dados JSON e similares com atributos dinâmicos

O Exemplo 2 é bastanre simples, mas a sintaxe feed['Schedule']['events'][40]['name'] é desajeitada. Em JavaScript, é possível obter o mesmo valor escrevendo feed.Schedule.events[40].name. É fácil de implementar uma classe parecida com um dict para fazer o mesmo em Python—​há inúmeras implementações na web.[319] Escrevi FrozenJSON, que é mais simples que a maioria das receitas, pois suporta apenas leitura: ela serve apenas para explorar os dados. FrozenJSON é também recursivo, lidando automaticamente com mapeamentos e listas aninhados.

O Exemplo 3 é uma demonstração da FrozenJSON, e o código-fonte aparece no Exemplo 4.

Exemplo 3. FrozenJSON, do Exemplo 4, permite ler atributos como name, e invocar métodos como .keys() e .items()
    >>> import json
    >>> raw_feed = json.load(open('data/osconfeed.json'))
    >>> feed = FrozenJSON(raw_feed)  # (1)
    >>> len(feed.Schedule.speakers)  # (2)
    357
    >>> feed.keys()
    dict_keys(['Schedule'])
    >>> sorted(feed.Schedule.keys())  # (3)
    ['conferences', 'events', 'speakers', 'venues']
    >>> for key, value in sorted(feed.Schedule.items()): # (4)
    ...     print(f'{len(value):3} {key}')
    ...
      1 conferences
    484 events
    357 speakers
     53 venues
    >>> feed.Schedule.speakers[-1].name  # (5)
    'Carina C. Zona'
    >>> talk = feed.Schedule.events[40]
    >>> type(talk)  # (6)
    <class 'explore0.FrozenJSON'>
    >>> talk.name
    'There *Will* Be Bugs'
    >>> talk.speakers  # (7)
    [3471, 5199]
    >>> talk.flavor  # (8)
    Traceback (most recent call last):
      ...
    KeyError: 'flavor'
  1. Cria uma instância de FrozenJSON a partir de raw_feed, feito de dicts e listas aninhados.

  2. FrozenJSON permite navegar dicts aninhados usando a notação de atributos; aqui exibimos o tamanho da lista de palestrantes.

  3. Métodos dos dicts subjacentes também podem ser acessados; por exemplo,.keys(), para recuperar os nomes das coleções de registros.

  4. Usando items(), podemos buscar os nomes das coleções de registros e seus conteúdos, para exibir o len() de cada um deles.

  5. Uma list, tal como feed.Schedule.speakers, permanece uma lista, mas os itens dentro dela, se forem mapeamentos, são convertidos em um FrozenJSON.

  6. O item 40 na lista events era um objeto JSON; agora ele é uma instância de FrozenJSON.

  7. Registros de eventos tem uma lista de speakers com os números de séries de palestrantes.

  8. Tentar ler um atributo inexistente gera uma exceção KeyError, em vez da AttributeError usual.

A pedra angular da classe FrozenJSON é o metodo __getattr__, que já usamos no exemplo Vector da Seção 12.6, para recuperar componentes de Vector por letra: v.x, v.y, v.z, etc. É essencial lembrar que o método especial __getattr__ só é invocado pelo interpretador quando o processo habitual falha em recuperar um atributo (isto é, quando o atributo nomeado não é encontrado na instância, nem na classe ou em suas superclasses).

A última linha do Exemplo 3 expõe um pequeno problema em meu código: tentar ler um atributo ausente deveria produzir uma exceção AttributeError, e não a KeyError gerada. Quando implementei o tratamento de erro para fazer isso, o método __getattr__ se tornou duas vezes mais longo, distraindo o leitor da lógica mais importante que eu queria apresentar. Dado que os usuários saberiam que uma FrozenJSON é criada a partir de mapeamentos e listas, acho que KeyError não é tão confuso assim.

Exemplo 4. explore0.py: transforma um conjunto de dados JSON em um FrozenJSON contendo objetos FrozenJSON aninhados, listas e tipos simples
from collections import abc


class FrozenJSON:
    """A read-only façade for navigating a JSON-like object
       using attribute notation
    """

    def __init__(self, mapping):
        self.__data = dict(mapping)  # (1)

    def __getattr__(self, name):  # (2)
        try:
            return getattr(self.__data, name)  # (3)
        except AttributeError:
            return FrozenJSON.build(self.__data[name])  # (4)

    def __dir__(self):  # (5)
        return self.__data.keys()

    @classmethod
    def build(cls, obj):  # (6)
        if isinstance(obj, abc.Mapping):  # (7)
            return cls(obj)
        elif isinstance(obj, abc.MutableSequence):  # (8)
            return [cls.build(item) for item in obj]
        else:  # (9)
            return obj
  1. Cria um dict a partir do argumento mapping. Isso garante que teremos um mapeamento ou algo que poderá ser convertido para isso. O prefixo de duplo sublinhado em __data o torna um atributo privado.

  2. __getattr__ é invocado apenas quando não existe um atributo com aquele name.

  3. Se name corresponde a um atributo da instância de dict __data, devolve aquele atributo. É assim que chamadas como feed.keys() são tratadas: o método keys é um atributo do dict __data.

  4. Caso contrário, obtém o item com a chave name de self.__data, e devolve o resultado da chamada FrozenJSON.build() com aquele argumento.[320]

  5. Implementar __dir__ suporta a função embutida dir(), que por sua vez suporta o preenchimento automático (auto-complete) no console padrão do Python, bem como no IPython, no Jupyter Notebook, etc. Esse código simples vai permitir preenchimento automático recursivo baseado nas chaves em self.__data, porque __getattr__ cria instâncias de FrozenJSON em tempo real—um recurso útil para a exploração interativa dos dados.

  6. Este é um construtor alternativo, um uso comum para o decorador

  7. Se obj é um mapeamento, cria um FrozenJSON com ele. Esse é um exmeplo de goose typing—veja a Seção 13.5 caso precise de uma revisão desse tópico.

  8. Se for uma MutableSequence, tem que ser uma lista[321], então criamos uma list, passando recursivamente cada item em obj para .build().

  9. Se não for um dict ou uma list, devolve o item com está.

Uma instância de FrozenJSON contém um atributo de instância privado __data, armazenado sob o nome _FrozenJSON__data, como explicado na Seção 11.10. Tentativas de recuperar atributos por outros nomes vão disparar __getattr__. Esse método irá primeiro olhar se o dict self.__data contém um atributo (não uma chave!) com aquele nome; isso permite que instâncias de FrozenJSON tratem métodos de dict tal como items, delegando para self.__data.items(). Se self.__data não contiver uma atributo como o name dado, __getattr__ usa name como chave para recuperar um item de self.__data, e passa aquele item para FrozenJSON.build. Isso permite navegar por estruturas aninhadas nos dados JSON, já que cada mapeamento aninhado é convertido para outra instância de FrozenJSON pelo método de classe build.

Observe que FrozenJSON não transforma ou armazena o conjunto de dados original. Conforme navegamos pelos dados, __getattr__ cria continuamente instâncias de FrozenJSON. Isso é aceitável para um conjunto de dados deste tamanho, e para um script que só será usado para explorar ou converter os dados.

Qualquer script que gera ou emula nomes de atributos dinâmicos a partir de fontes arbitrárias precisa lidar com uma questão: as chaves nos dados originais podem não ser nomes adequados de atributos. A próxima seção fala disso.

22.2.2. O problema do nome de atributo inválido

O código de FrozenJSON não aceita com nomes de atributos que sejam palavras reservadas do Python. Por exemplo, se você criar um objeto como esse

>>> student = FrozenJSON({'name': 'Jim Bo', 'class': 1982})

não será possível ler student.class, porque class é uma palavra reservada no Python:

>>> student.class
  File "<stdin>", line 1
    student.class
         ^
SyntaxError: invalid syntax

Claro, sempre é possível fazer assim:

>>> getattr(student, 'class')
1982

Mas a ideia de FrozenJSON é oferecer acesso conveniente aos dados, então uma solução melhor é verificar se uma chave no mapamento passado para FrozenJSON.__init__ é uma palavra reservada e, em caso positivo, anexar um _ a ela, de forma que o atributo possa ser acessado assim:

>>> student.class_
1982

Isso pode ser feito substituindo o __init__ de uma linha do Exemplo 4 pela versão no Exemplo 5.

Exemplo 5. explore1.py: anexa um _ a nomes de atributo que sejam palavraas reservadas do Python
    def __init__(self, mapping):
        self.__data = {}
        for key, value in mapping.items():
            if keyword.iskeyword(key):  # (1)
                key += '_'
            self.__data[key] = value
  1. A função keyword.iskeyword(…) é exatamente o que precisamos; para usá-la, o módulo keyword precisa ser importado; isso não aparece nesse trecho.

Um problema similar pode surgir se uma chave em um registro JSON não for um identificador válido em Python:

>>> x = FrozenJSON({'2be':'or not'})
>>> x.2be
  File "<stdin>", line 1
    x.2be
      ^
SyntaxError: invalid syntax

Essas chaves problemáticas são fáceis de detectar no Python 3, porque a classe str oferece o método s.isidentifier(), que informa se s é um identificador Python válido, de acordo com a gramática da linguagem. Mas transformar uma chave que não seja um identificador válido em um nome de atributo válido não é trivial. Uma solução seria implementar __getitem__ para permitir acesso a atributos usando uma notação como x['2be']. Em nome da simplicidade, não vou me preocupar com esse problema.

Após essa pequena conversa sobre os nomes de atributos dinâmicos, vamos examinar outra característica essencial de FrozenJSON: a lógica do método de classe build. Frozen.JSON.build é usado por __getattr__ para devolver um tipo diferente de objeto, dependendo do valor do atributo que está sendo acessado: estruturas aninhadas são convertidas para instâncias de FrozenJSON ou listas de instâncias de FrozenJSON.

Em vez de usar um método de classe, a mesma lógica poderia ser implementada com o método especial __new__, como veremos a seguir.

22.2.3. Criação flexível de objetos com __new__

Muitas vezes nos referimos ao __init__ como o método construtor, mas isso é porque adotamos o jargão de outras linguagens. No Python, __init__ recebe self como primeiro argumentos, portanto o objeto já existe quando __init__ é invocado pelo interpretador. Além disso, __init__ não pode devolver nada. Então, na verdade, esse método é um inicializador, não um construtor.

Quando uma classe é chamada para criar uma instância, o método especial chamado pelo Python naquela classe para construir a instância é __new__. É um método de classe, mas recebe tratamento especial, então o decorador @classmethod não é aplicado a ele. O Python recebe a instância devolvida por __new__, e daí a passa como o primeiro argumento (self) para __init__. Raramente precisamos escrever um __new__, pois a implementação herdada de object é suficiente na vasta maioria dos casos.

Se necessário, o método __new__ pode também devolver uma instância de uma classe diferente. Quando isso acontece, o interpretador não invoca __init__. Em outras palavras, a lógica do Python para criar um objeto é similar a esse pseudo-código:

# pseudocode for object construction
def make(the_class, some_arg):
    new_object = the_class.__new__(some_arg)
    if isinstance(new_object, the_class):
        the_class.__init__(new_object, some_arg)
    return new_object

# the following statements are roughly equivalent
x = Foo('bar')
x = make(Foo, 'bar')

O Exemplo 6 mostra uma variante de FrozenJSON onde a lógica da antiga classe build foi transferida para o método __new__.

Exemplo 6. explore2.py: usando __new__ em vez de build para criar novos objetos, que podem ou não ser instâncias de FrozenJSON
from collections import abc
import keyword

class FrozenJSON:
    """A read-only façade for navigating a JSON-like object
       using attribute notation
    """

    def __new__(cls, arg):  # (1)
        if isinstance(arg, abc.Mapping):
            return super().__new__(cls)  # (2)
        elif isinstance(arg, abc.MutableSequence):  # (3)
            return [cls(item) for item in arg]
        else:
            return arg

    def __init__(self, mapping):
        self.__data = {}
        for key, value in mapping.items():
            if keyword.iskeyword(key):
                key += '_'
            self.__data[key] = value

    def __getattr__(self, name):
        try:
            return getattr(self.__data, name)
        except AttributeError:
            return FrozenJSON(self.__data[name])  # (4)

    def __dir__(self):
        return self.__data.keys()
  1. Como se trata de um método de classe, o primeiro argumento recebido por __new__ é a própria classe, e os argumentos restantes são os mesmos recebido por __init__, exceto por self.

  2. O comportamento default é delegar para o __new__ de uma superclasse. Nesse caso, estamos invocando o __new__ da classe base object, passando FrozenJSON como único argumento.

  3. As linhas restantes de __new__ são exatamente as do antigo método build.

  4. Era daqui que FrozenJSON.build era chamado antes; agora chamamos apenas a classe FrozenJSON, e o Python trata essa chamada invocando FrozenJSON.__new__.

O método __new__ recebe uma classe como primeiro argumento porque, normalmente, o objeto criado será uma instância daquela classe. Então, em FrozenJSON.__new__, quando a expressão super().__new__(cls) efetivamente chama object.__new__(FrozenJSON), a instância criada pela classe object é, na verdade, uma instância de FrozenJSON. O atributo __class__ da nova instância vai manter uma referência para FrozenJSON, apesar da construção concreta ser realizada por object.__new__, implementado em C, nas entranhas do interpretador.

O conjunto de dados da OSCON está estruturado de uma forma pouco amigável à exploração interativa. Por exemplo, o evento no índice 40, chamado 'There Will Be Bugs' (Haverá Bugs) tem dois palestrantes, 3471 e 5199. Encontrar os nomes dos palestrantes é confuso, pois esses são números de série e a lista Schedule.speakers não está indexada por eles. Para obter cada palestrante, precisamos iterar sobre a lista até encontrar um registro com o número de série correspondente. Nossa próxima tarefa é reestruturar os dados para preparar a recuperação automática de registros relacionados.

22.3. Propriedades computadas

Vimos inicialmente o decorador @property no Capítulo 11, na Seção 11.7. No Exemplo 7, usei duas propriedades no Vector2d apenas para tornar os atributos x e y apenas para leitura. Aqui vamos ver propriedades que calculam valores, levando a uma discussão sobre como armazenar tais valores.

Os registros na lista 'events' dos dados JSON da OSCON contêm números de série inteiros apontando para registros nas listas 'speakers' e 'venues'. Por exemplo, esse é o registro de uma palestra (com a descrição parcial terminando em reticências):

{ "serial": 33950,
  "name": "There *Will* Be Bugs",
  "event_type": "40-minute conference session",
  "time_start": "2014-07-23 14:30:00",
  "time_stop": "2014-07-23 15:10:00",
  "venue_serial": 1449,
  "description": "If you're pushing the envelope of programming...",
  "website_url": "http://oscon.com/oscon2014/public/schedule/detail/33950",
  "speakers": [3471, 5199],
  "categories": ["Python"] }

Vamos implementar uma classe Event com propriedades venue e speakers, para devolver automaticamente os dados relacionados—​em outras palavras, "derreferenciar" o número de série. Dada uma instância de Event, o Exemplo 7 mostra o comportamento desejado.

Exemplo 7. Ler venue e speakers devolve objetos Record
    >>> event  # (1)
    <Event 'There *Will* Be Bugs'>
    >>> event.venue  # (2)
    <Record serial=1449>
    >>> event.venue.name  # (3)
    'Portland 251'
    >>> for spkr in event.speakers:  # (4)
    ...     print(f'{spkr.serial}: {spkr.name}')
    ...
    3471: Anna Martelli Ravenscroft
    5199: Alex Martelli
  1. Dada uma instância de Event,…​

  2. …​ler event.venue devolve um objeto Record em vez de um número de série.

  3. Agora é fácil obter o nome do venue.

  4. A propriedade event.speakers devolve uma lista de instâncias de Record.

Como sempre, vamos criar o código passo a passo, começando com a classe Record e uma função para ler dados JSON e devolver um dict com instâncias de Record.

22.3.1. Passo 1: criação de atributos baseados em dados

O Exemplo 8 mostra o doctest para orientar esse primeiro passo.

Exemplo 8. Testando schedule_v1.py (do Exemplo 9)
    >>> records = load(JSON_PATH)  # (1)
    >>> speaker = records['speaker.3471']  # (2)
    >>> speaker  # (3)
    <Record serial=3471>
    >>> speaker.name, speaker.twitter  # (4)
    ('Anna Martelli Ravenscroft', 'annaraven')
  1. load um dict com os dados JSON.

  2. As chaves em records são strings criadas a partir do tipo de registro e do número de série.

  3. speaker é uma instância da classe Record, definida no Exemplo 9.

  4. Campos do JSON original podem ser acessados como atributos de instância de Record.

O código de schedule_v1.py está no Exemplo 9.

Exemplo 9. schedule_v1.py: reorganizando os dados de agendamento da OSCON
import json

JSON_PATH = 'data/osconfeed.json'

class Record:
    def __init__(self, **kwargs):
        self.__dict__.update(kwargs)  # (1)

    def __repr__(self):
        return f'<{self.__class__.__name__} serial={self.serial!r}>'  # (2)

def load(path=JSON_PATH):
    records = {}  # (3)
    with open(path) as fp:
        raw_data = json.load(fp)  # (4)
    for collection, raw_records in raw_data['Schedule'].items():  # (5)
        record_type = collection[:-1]  # (6)
        for raw_record in raw_records:
            key = f'{record_type}.{raw_record["serial"]}' # (7)
            records[key] = Record(**raw_record)  # (8)
    return records
  1. Isso é um atalho comum para construir uma instância com atributos criados a partir de argumentos nomeados (a explicação detalhada está abaixo) .

  2. Usa o campo serial para criar a representação personalizada de Record exibida no Exemplo 8.

  3. load vai por fim devolver um dict de instâncias de Record.

  4. Analisa o JSON, devolvendo objetos Python nativos: listas, dicts, strings, números, etc.

  5. Itera sobre as quatro listas principais, chamadas 'conferences', 'events', 'speakers', e 'venues'.

  6. record_type é o nome da lista sem o último caractere, então speakers se torna speaker. No Python ≥ 3.9, podemos fazer isso de forma mais explícita com collection.removesuffix('s')—veja a PEP 616—String methods to remove prefixes and suffixes (Métodos de string para remover prefixos e sufixos_).

  7. Cria a key no formato 'speaker.3471'.

  8. Cria uma instância de Record e a armazena em records com a chave key.

O método Record.__init__ ilustra um antigo truque do Python. Lembre-se que o __dict__ de um objeto é onde são mantidos seus atributos—​a menos que __slots__ seja declarado na classe, como vimos na Seção 11.11. Daí, atualizar o __dict__ de uma instância é uma maneira fácil de criar um punhado de atributos naquela instância.[322]

✒️ Nota

Dependendo da aplicação, a classe Record pode ter que lidar com chaves que não sejam nomes de atributo válidos, como vimos na Seção 22.2.2. Tratar essa questão nos distrairia da ideia principal desse exemplo, e não é um problema no conjunto de dados que estamos usando.

A definição de Record no Exemplo 9 é tão simples que você pode estar se perguntando porque não a usei antes, em vez do mais complicado FrozenJSON. São duas razões. Primeiro, FrozenJSON funciona convertendo recursivamente os mapeamentos aninhados e listas; Record não precisa fazer isso, pois nosso conjunto de dados convertido não contém mapeamentos aninhados ou listas. Os registros contêm apenas strings, inteiros, listas de strings e listas de inteiros. A segunda razão: FrozenJSON oferece acesso aos atributos no dict embutido __data—que usamos para invocar métodos como .keys()—e agora também não precisamos mais dessa funcionalidade.

✒️ Nota

A biblioteca padrão do Python oferece classes similares a Record, onde cada instância tem um conjunto arbitrário de atributos criados a partir de argumentos nomeados passados a __init__: types.SimpleNamespace, argparse.Namespace (EN), and multiprocessing.managers.Namespace (EN). Escrevi a classe Record, mais simples, para destacar a ideia essencial: __init__ atualizando o __dict__ da instância.

Após reorganizar o conjunto de dados de agendamento, podemos aprimorar a classe Record para obter automaticamente registros de venue e speaker referenciados em um registro event. Vamos utilizar propriedades para fazer exatamente isso nos próximos exemplos.

22.3.2. Passo 2: Propriedades para recuperar um registro relacionado

O objetivo da próxima versão é: dado um registro event, ler sua propriedade venue vai devolver um Record. Isso é similar ao que o ORM (Object Relational Mapping, Mapeamento Relacional de Objetos) do Django faz quando acessamos um campo ForeignKey: em vez da chave, recebemos o modelo de objeto relacionado.

Vamos começar pela propriedade venue. Veja a interação parcial no Exemplo 10.

Exemplo 10. Extratos dos doctests de schedule_v2.py
    >>> event = Record.fetch('event.33950')  # (1)
    >>> event  # (2)
    <Event 'There *Will* Be Bugs'>
    >>> event.venue  # (3)
    <Record serial=1449>
    >>> event.venue.name  # (4)
    'Portland 251'
    >>> event.venue_serial  # (5)
    1449
  1. O método estático Record.fetch obtém um Record ou um Event do conjunto de dados.

  2. Observe que event é uma instância da classe Event.

  3. Acessar event.venue devolve uma instância de Record.

  4. Agora é fácil encontrar o nome de um event.venue.

  5. A instância de Event também tem um atributo venue_serial, vindo dos dados JSON.

Event é uma subclasse de Record, acrescentando um venue para obter os registros relacionados, e um método __repr__ especializado.

O código dessa seção está no módulo schedule_v2.py, no repositório de código do Python Fluente. O exemplo tem aproximadamente 50 linhas, então vou apresentá-lo em partes, começando pela classe Record aperfeiçoada.

Exemplo 11. schedule_v2.py: a classe Record com um novo método fetch
import inspect  # (1)
import json

JSON_PATH = 'data/osconfeed.json'

class Record:

    __index = None  # (2)

    def __init__(self, **kwargs):
        self.__dict__.update(kwargs)

    def __repr__(self):
        return f'<{self.__class__.__name__} serial={self.serial!r}>'

    @staticmethod  # (3)
    def fetch(key):
        if Record.__index is None:  # (4)
            Record.__index = load()
        return Record.__index[key]  # (5)
  1. inspect será usado em load, lista do no Exemplo 13.

  2. No final, o atributo de classe privado __index manterá a referência ao dict devolvido por load.

  3. fetch é um staticmethod, para deixar explícito que seu efeito não é influenciado pela classe ou pela instância de onde ele é invocado.

  4. Preenche o Record.__index, se necessário.

  5. E o utiliza para obter um registro com uma dada key.

👉 Dica

Esse é um exemplo onde o uso de staticmethod faz sentido. O método fetch sempre age sobre o atributo de classe Record.__index, mesmo quando invocado desde uma subclasse, como Event.fetch()—que exploraremos a seguir. Seria equivocado programá-lo como um método de classe, pois o primeiro argumento, cls, nunca é usado.

Agora podemos usar a propriedade na classe Event, listada no Exemplo 12.

Exemplo 12. schedule_v2.py: a classe Event
class Event(Record):  # (1)

    def __repr__(self):
        try:
            return f'<{self.__class__.__name__} {self.name!r}>'  # (2)
        except AttributeError:
            return super().__repr__()

    @property
    def venue(self):
        key = f'venue.{self.venue_serial}'
        return self.__class__.fetch(key)  # (3)
  1. Event estende Record.

  2. Se a instância tem um atributo name, esse atributo será usado para produzir uma representação personalizada. Caso contrário, delega para o __repr__ de Record.

  3. A propriedade venue cria uma key a partir do atributo venue_serial, e a passa para o método de classe fetch, herdado de Record (a razão para usar self.__class__ logo ficará clara).

A segunda linha do método venue no Exemplo 12 devolve self​.__class__.fetch(key). Por que não podemos simplesmente invocar self.fetch(key)? A forma simples funciona com esse conjunto específico de dados da OSCON porque não há registro de evento com uma chave 'fetch'. Mas, se um registro de evento possuísse uma chave chamada 'fetch', então dentro daquela instância específica de Event, a referência self.fetch apontaria para o valor daquele campo, em vez do método de classe fetch que Event herda de Record. Esse é um bug sutil, e poderia facilmente escapar aos testes, pois depende do conjunto de dados.

⚠️ Aviso

Ao criar nomes de atributos de instância a partir de dados, sempre existe o risco de bugs causados pelo ocultamento de atributos de classe—tais como métodos—ou pela perda de dados por sobrescrita acidental de atributos de instância existentes. Esses problemas talvez expliquem, mais que qualquer outra coisa, porque os dicts do Python não são como objetos Javascript.

Se a classe Record se comportasse mais como um mapeamento, implementando um __getitem__ dinâmico em vez de um __getattr__ dinâmico, não haveria risco de bugs por ocultamento ou sobrescrita. Um mapeamento personalizado seria provavelmente a forma pythônica de implementar Record. Mas se eu tivesse seguido por aquele caminho, não estaríamos estudando os truques e as armadilhas da programação dinâmica de atributos.

A parte final deste exemplo é a função load revisada, no Exemplo 13.

Exemplo 13. schedule_v2.py: a função load
def load(path=JSON_PATH):
    records = {}
    with open(path) as fp:
        raw_data = json.load(fp)
    for collection, raw_records in raw_data['Schedule'].items():
        record_type = collection[:-1]  # (1)
        cls_name = record_type.capitalize()  # (2)
        cls = globals().get(cls_name, Record)  # (3)
        if inspect.isclass(cls) and issubclass(cls, Record):  # (4)
            factory = cls  # (5)
        else:
            factory = Record  # (6)
        for raw_record in raw_records:  # (7)
            key = f'{record_type}.{raw_record["serial"]}'
            records[key] = factory(**raw_record)  # (8)
    return records
  1. Até aqui, nenhuma mudança em relação ao load em schedule_v1.py (do Exemplo 9).

  2. Muda a primeira letra de record_type para maiúscula, para obter um possível nome de classe; por exemplo, 'event' se torna 'Event'.

  3. Obtém um objeto com aquele nome do escopo global do módulo; se aquele objeto não existir, obtém a classe Record.

  4. Se o objeto recém-obtido é uma classe, e é uma subclasse de Record…​

  5. …​vincula o nome factory a ele. Isso significa que factory pode ser qualquer subclasse de Record, dependendo do record_type.

  6. Caso contrário, vincula o nome factory a Record.

  7. O loop for, que cria a key e armazena os registros, é o mesmo de antes, exceto que…​

  8. …​o objeto armazenado em records é construído por factory, e pode ser Record ou uma subclasse, como Event, selecionada de acordo com o record_type.

Observe que o único record_type que tem uma classe personalizada é Event, mas se classes chamadas Speaker ou Venue existirem, load vai automaticamente usar aquelas classes ao criar e armazenar registros, em vez da classe default Record.

Vamos agora aplicar a mesma ideia à nova propriedade speakers, na classe Events.

22.3.3. Passo 3: Uma propriedade sobrepondo um atributo existente

O nome da propriedade venue no Exemplo 12 não corresponde a um nome de campo nos registros da coleção "events". Seus dados vem de um nome de campo venue_serial. Por outro lado, cada registro na coleção events tem um campo speakers, contendo uma lista de números de série. Queremos expor essa informação na forma de uma propriedade speakers em instâncias de Event, que devolve um lista de instâncias de Record. Essa colisão de nomes exige uma atenção especial, como revela o Exemplo 14.

Exemplo 14. schedule_v3.py: a propriedade speakers
    @property
    def speakers(self):
        spkr_serials = self.__dict__['speakers']  # (1)
        fetch = self.__class__.fetch
        return [fetch(f'speaker.{key}')
                for key in spkr_serials]  # (2)
  1. Os dados que precisamos estão em um atributo speakers, mas precisamos obtê-los diretamente do __dict__ da instância, para evitar uma chamada recursiva à propriedade speakers.

  2. Devolve uma lista de todos os registros com chaves correspondendo aos números em spkr_serials.

Dentro do método speakers, tentar ler self.speakers irá invocar a própria propriedade, gerando rapidamente um RecursionError. Entretanto, se lemos os mesmos dados via self.__dict__['speakers'], o algoritmo normal do Python para busca e recuperação de atributos é ignorado, a propriedade não é chamada e a recursão é evitada. Por essa razão, ler ou escrever dados diretamente no __dict__ de um objeto é um truque comum em metaprogramação no Python.

⚠️ Aviso

O interpretador avalia obj.my_attr olhando primeiro a classe de obj. Se a classe possuir uma propriedade de nome my_attr, aquela propriedade oculta um atributo de instância com o mesmo nome. Isso será demonstrado por exemplos na Seção 22.5.1, e o Capítulo 23 vai revelar que uma propriedade é implementada como um descritor—uma abstração mais geral e poderosa.

Quando programava a compreensão de lista no Exemplo 14, meu cérebro réptil de programador pensou: "Isso talvez seja custoso". Na verdade não é, porque os eventos no conjuntos de dados da OSCON contêm poucos palestrantes, então programar algo mais complexo seria uma otimização prematura. Entretanto, criar um cache de uma propriedade é uma necessidade comum—e há ressalvas. Vamos ver então, nos próximos exemplos, como fazer isso.

22.3.4. Passo 4: Um cache de propriedades sob medida

Fazer caching de propriedades é uma necessidade comum, pois há a expectativa de que uma expressão como event.venue deveria ser pouco dispendiosa.[323] Alguma forma de caching poderia se tornar necessário caso o método Record.fetch, subjacente às propriedades de Event, precise consultar um banco de dados ou uma API web.

Na primeira edição de Python Fluente, programei a lógica personalizada de caching para o método speakers, como mostra o Exemplo 15.

Exemplo 15. A lógica de caching personalizada usando hasattr desabilita a otimização de compartilhamento de chaves
    @property
    def speakers(self):
        if not hasattr(self, '__speaker_objs'):  # (1)
            spkr_serials = self.__dict__['speakers']
            fetch = self.__class__.fetch
            self.__speaker_objs = [fetch(f'speaker.{key}')
                    for key in spkr_serials]
        return self.__speaker_objs  # (2)
  1. Se a instância não tem um atributo chamado __speaker_objs, obtém os objetos speaker e os armazena ali..

  2. Devolve self.__speaker_objs.

O caching caseiro no Exemplo 15 é bastante direto, mas criar atributos após a inicialização da instância frustra a otimização da PEP 412—Key-Sharing Dictionary (Dicionário de Compartilhamento de Chaves), como explicado na Seção 3.9. Dependendo do tamanho da massa de dados, a diferença de uso de memória pode ser importante.

Uma solução manual similar, que funciona bem com a otimização de compartilhamento de chaves, exige escrever um __init__ para a classe Event, para criar o necessário __speaker_objs inicializado para None, e então usá-lo no método speakers. Veja o Exemplo 16.

Exemplo 16. Armazenamento definido em __init__ para manter a otimização de compartilhamento de chaves
class Event(Record):

    def __init__(self, **kwargs):
        self.__speaker_objs = None
        super().__init__(**kwargs)

# 15 lines omitted...
    @property
    def speakers(self):
        if self.__speaker_objs is None:
            spkr_serials = self.__dict__['speakers']
            fetch = self.__class__.fetch
            self.__speaker_objs = [fetch(f'speaker.{key}')
                    for key in spkr_serials]
        return self.__speaker_objs

O Exemplo 15 e o Exemplo 16 ilustram técnicas simples de caching bastante comuns em bases de código Python legadas. Entretanto, em programas com múltiplas threads, caches manuais como aqueles introduzem condições de concorrência (ou de corrida) que podem levar à corrupção de dados. Se duas threads estão lendo uma propriedade que não foi armazenada no cache anteriormente, a primeira thread precisará computar os dados para o atributo de cache (_speaker_objs nos exemplos) e a segunda thread corre o risco de ler um valor incompleto do _cache.

Felizmente, o Python 3.8 introduziu o decorador @functools.cached_property, que é seguro para uso com threads. Infelizmente, ele vem com algumas ressalvas, discutidas a seguir.

22.3.5. Passo 5: Caching de propriedades com functools

O módulo functools oferece três decoradores para caching. Vimos @cache e @lru_cache na Seção 9.9.1 (no Capítulo 9). O Python 3.8 introduziu @cached_property.

O decorador functools.cached_property faz cache do resultado de um método em uma variável de instância com o mesmo nome.

Por exemplo, no Exemplo 17, o valor computado pelo método venue é armazenado em um atributo venue, em self. Após isso, quando código cliente tenta ler venue, o recém-criado atributo de instância venue é usado, em vez do método.

Exemplo 17. Uso simples de uma @cached_property
    @cached_property
    def venue(self):
        key = f'venue.{self.venue_serial}'
        return self.__class__.fetch(key)

Na Seção 22.3.3, vimos que uma propriedade oculta um atributo de instância de mesmo nome. Se isso é verdade, como @cached_property pode funcionar? Se a propriedade se sobrepõe ao atributo de instância, o atributo venue será ignorado e o método venue será sempre chamado, computando a key e rodando fetch todas as vezes!

A resposta é um tanto triste: cached_property é um nome enganador. O decorador @cached_property não cria uma propriedade completa, ele cria um descritor não dominante. Um descritor é um objeto que gerencia o acesso a um atributo em outra classe. Vamos mergulhar nos descritores no Capítulo 23. O decorador property é uma API de alto nível para criar um descritor dominante. O Capítulo 23 inclui um explicação completa sobre descritores dominantes e não dominantes.

Por hora, vamos deixar de lado a implementação subjacente e nos concentrar nas diferenças entre cached_property e property do ponto de vista de um usuário. Raymond Hettinger os explica muito bem na Documentação do Python:

A mecânica de cached_property() é um tanto diferente da de property(). Uma propriedade regular bloqueia a escrita em atributos, a menos que um setter seja definido. Uma cached_property, por outro lado, permite a escrita.

O decorador cached_property só funciona para consultas e apenas quando um atributo de mesmo nome não existe. Quando funciona, cached_property escreve no atributo de mesmo nome. Leituras e escritas subsequentes do/no atributo tem precedência sobre o método decorado com cached_property e ele funciona como um atributo normal.

O valor em cache pode ser excluído apagando-se o atributo. Isso permite que o método cached_property rode novamente.[324]

Voltando à nossa classe Event: o comportamento específico de @cached_property o torna inadequado para decorar speakers, porque aquele método depende de um atributo existente também chamado speakers, contendo os números de série dos palestrantes do evento.

⚠️ Aviso

@cached_property tem algumas importantes limitações:

  • Ele não pode ser usado como um substituto direto de @property se o método decorado já depender de um atributo de instância de mesmo nome.

  • Ele não pode ser usado em uma classe que defina __slots__.

  • Ele impede a otimização de compartilhamento de chaves do __dict__ da instância, pois cria um atributo de instância após o __init__.

Apesar dessas limitações, @cached_property supre uma necessidade comum de uma maneira simples, e é seguro para usar com threads. Seu código Python é um exemplo do uso de uma trava recursiva (reentrant lock).

A documentação de @cached_property recomenda uma solução altenativa que podemos usar com speakers: Empilhar decoradores @property e @cache, como exibido no Exemplo 18.

Exemplo 18. Stacking @property sobre @cache
    @property  # (1)
    @cache  # (2)
    def speakers(self):
        spkr_serials = self.__dict__['speakers']
        fetch = self.__class__.fetch
        return [fetch(f'speaker.{key}')
                for key in spkr_serials]
  1. A ordem é importante: @property vai acima…​

  2. …​de @cache.

Lembre-se do significado dessa sintaxe, comentada em Decoradore empilhados. A três primeiras linhas do Exemplo 18 são similares a :

speakers = property(cache(speakers))

O @cache é aplicado a speakers, devolvendo uma nova função. Essa função é então decorada por @property, que a substitui por uma propriedade recém-criada.

Isso encerra nossa discussão de propriedades somente para leitura e decoradores de caching, explorando o conjunto de dados da OSCON.

Na próxima seção iniciamos uma nova série de exemplos, criando propriedades de leitura e escrita.

22.4. Usando uma propriedade para validação de atributos

Além de computar valores de atributos, as propriedades também são usadas para impor regras de negócio, transformando um atributo público em um atributo protegido por um getter e um setter, sem afetar o código cliente. Vamos explorar um exemplo estendido.

22.4.1. LineItem Versão #1: Um classe para um item em um pedido

Imagine uma aplicação para uma loja que vende comida orgânica a granel, onde os fregueses podem encomendar nozes, frutas secas e cereais por peso. Nesse sistema, cada pedido mantém uma sequência de produtos, e cada produto pode ser representado por uma instância de uma classe, como no Exemplo 19.

Exemplo 19. bulkfood_v1.py: a classe LineItem mais simples
class LineItem:

    def __init__(self, description, weight, price):
        self.description = description
        self.weight = weight
        self.price = price

    def subtotal(self):
        return self.weight * self.price

Esse código é simples e agradável. Talvez simples demais. Exemplo 20 mostra um problema.

Exemplo 20. Um peso negativo resulta em um subtotal negativo
    >>> raisins = LineItem('Golden raisins', 10, 6.95)
    >>> raisins.subtotal()
    69.5
    >>> raisins.weight = -20  # garbage in...
    >>> raisins.subtotal()    # garbage out...
    -139.0

Apesar desse ser um exemplo inventado, não é tão fantasioso quanto se poderia imaginar. Aqui está uma história do início da Amazon.com:

Descobrimos que os clientes podiam encomendar uma quantidade negativa de livros! E nós creditaríamos seus cartões de crédito com o preço e, suponho, esperaríamos que eles nos enviassem os livros.[325]

— Jeff Bezos
fundador e CEO da Amazon.com

Como consertar isso? Poderíamos mudar a interface de LineItem para usar um getter e um setter para o atributo weight. Esse seria o caminho do Java, e não está errado. Por outro lado, é natural poder determinar o weight (peso) de um item apenas atribuindo um valor a ele; e talvez o sistema esteja em produção, com outras partes já acessando item.weight diretamente. Nesse caso, o caminho do Python seria substituir o atributo de dados por uma propriedade.

22.4.2. LineItem versão #2: Uma propriedade de validação

Implementar uma propriedade nos permitirá usar um getter e um setter, mas a interface de LineItem não mudará (isto é, definir o weight de um LineItem ainda será escrito no formato raisins.weight = 12).

O Exemplo 21 lista o código para uma propriedade de leitura e escrita de weight.

Exemplo 21. bulkfood_v2.py: um LineItem com uma propriedade weight
class LineItem:

    def __init__(self, description, weight, price):
        self.description = description
        self.weight = weight  # (1)
        self.price = price

    def subtotal(self):
        return self.weight * self.price

    @property  # (2)
    def weight(self):  # (3)
        return self.__weight  # (4)

    @weight.setter  # (5)
    def weight(self, value):
        if value > 0:
            self.__weight = value  # (6)
        else:
            raise ValueError('value must be > 0')  # (7)
  1. Aqui o setter da propriedade já está em uso, assegurando que nenhuma instância com peso negativo possa ser criada.

  2. @property decora o método getter.

  3. Todos os métodos que implementam a propriedade compartilham o mesmo nome, do atributo público: weight.

  4. O valor efetivo é armazenado em um atributo privado __weight.

  5. O getter decorado tem um atributo .setter, que também é um decorador; isso conecta o getter e o setter.

  6. Se o valor for maior que zero, definimos o __weight privado.

  7. Caso contrário, uma ValueError é gerada.

Observe como agora não é possível criar uma LineItem com peso inválido:

>>> walnuts = LineItem('walnuts', 0, 10.00)
Traceback (most recent call last):
    ...
ValueError: value must be > 0

Agora protegemos weight impedindo que usuários forneçam valores negativos. Apesar de compradores normalmente não poderem definir o preço de um produto, um erro administrativo ou um bug poderiam criar um LineItem com um price negativo. Para evitar isso, poderíamos também transformar price em uma propriedade, mas isso levaria a alguma repetição no nosso código.

Lembre-se da citação de Paul Graham no Capítulo 17: "Quando vejo padrões em meus programas, considero isso um mau sinal." A cura para a repetição é a abstração. Há duas maneiras de abstrair definições de propriedades: usar uma fábrica de propriedades ou uma classe descritora. A abordagem via classe descritora é mais flexível, e dedicaremos o Capítulo 23 a uma discussão completa desse recurso. Na verdade, propriedades são, elas mesmas, implementadas como classes descritoras. Mas aqui vamos seguir com nossa exploração das propriedades, implementando uma fábrica de propriedades em forma de função.

Mas antes de podermos implementar uma fábrica de propriedades, precisamos entender melhor as propriedades em si.

22.5. Considerando as propriedades de forma adequada

Apesar de ser frequentemente usada como um decorador, property é na verdade uma classe embutida. No Python, funções e classes são muitas vezes intercambiáveis, pois ambas são invocáveis e não há um operador new para instanciação de objeto, então invocar um construtor não é diferente de invocar uma função fábrica. E ambas podem ser usadas como decoradores, desde que elas devolvam um novo invocável, que seja um substituto adequado do invocável decorado.

Essa é a assinatura completa do construtor de property:

property(fget=None, fset=None, fdel=None, doc=None)

Todos os argumentos são opcionais, e se uma função não for fornecida para algum deles, a operação correspondente não será permitida pelo objeto propriedade resultante.

O tipo property foi introduzido no Python 2.2, mas a sintaxe @ do decorador só surgiu no Python 2.4. Então, por alguns anos, propriedades eram definidas passando as funções de acesso nos dois primeiros argumentos.

A sintaxe "clássica" para definir propriedades sem decoradores é ilustrada pelo Exemplo 22.

Exemplo 22. bulkfood_v2b.py: igual ao Exemplo 21, mas sem usar decoradores
class LineItem:

    def __init__(self, description, weight, price):
        self.description = description
        self.weight = weight
        self.price = price

    def subtotal(self):
        return self.weight * self.price

    def get_weight(self):  # (1)
        return self.__weight

    def set_weight(self, value):  # (2)
        if value > 0:
            self.__weight = value
        else:
            raise ValueError('value must be > 0')

    weight = property(get_weight, set_weight)  # (3)
  1. Um getter simples.

  2. Um setter simples.

  3. Cria a property e a vincula a um atributo de classe simples.

Em algumas situações, a forma clássica é melhor que a sintaxe do decorador; o código da fábrica de propriedade, que discutiremos em breve, é um exemplo. Por outro lado, no corpo de uma classe com muitos métodos, os decoradores tornam explícito quais são os getters e os setters, sem depender da convenção do uso dos prefixos get e set em seus nomes.

A presença de uma propriedade em uma classe afeta como os atributos nas instâncias daquela classe podem ser encontrados, de uma forma que à primeira vista pode ser surpreendente. A próxima seção explica isso.

22.5.1. Propriedades sobrepõe atributos de instância

Propriedades são sempre atributos de classe, mas elas na verdade gerenciam o acesso a atributos nas instâncias da classe.

Na Seção 11.12, vimos que quando uma instância e sua classe tem um atributo de dados com o mesmo nome, o atributo de instância sobrepõe, ou oculta, o atributo da classe—ao menos quando lidos através daquela instância. O Exemplo 23 ilustra esse ponto.

Exemplo 23. Atributo de instância oculta o atributo de classe data
>>> class Class:  # (1)
...     data = 'the class data attr'
...     @property
...     def prop(self):
...         return 'the prop value'
...
>>> obj = Class()
>>> vars(obj)  # (2)
{}
>>> obj.data  # (3)
'the class data attr'
>>> obj.data = 'bar' # (4)
>>> vars(obj)  # (5)
{'data': 'bar'}
>>> obj.data  # (6)
'bar'
>>> Class.data  # (7)
'the class data attr'
  1. Define Class com dois atributos de classe: o atributo data e a propriedade prop.

  2. vars devolve o __dict__ de obj, mostrando que ele não tem atributos de instância.

  3. Ler de obj.data obtém o valor de Class.data.

  4. Escrever em obj.data cria um atributo de instância.

  5. Inspeciona a instância, para ver o atributo de instância.

  6. Ler agora de obj.data obtém o valor do atributo da instância. Quanto lido a partir da instância obj, o data da instância oculta o data da classe.

  7. O atributo Class.data está intacto.

Agora vamos tentar sobrepor o atributo prop na instância obj. Continuando a sessão de console anterior, temos o Exemplo 24.

Exemplo 24. Um atributo de instância não oculta uma propriedade da classe (continuando do Exemplo 23)
>>> Class.prop  # (1)
<property object at 0x1072b7408>
>>> obj.prop  # (2)
'the prop value'
>>> obj.prop = 'foo'  # (3)
Traceback (most recent call last):
  ...
AttributeError: can't set attribute
>>> obj.__dict__['prop'] = 'foo'  # (4)
>>> vars(obj)  # (5)
{'data': 'bar', 'prop': 'foo'}
>>> obj.prop  # (6)
'the prop value'
>>> Class.prop = 'baz'  # (7)
>>> obj.prop  # (8)
'foo'
  1. Ler prop diretamente de Class obtém o próprio objeto propriedade, sem executar seu método getter.

  2. Ler obj.prop executa o getter da propriedade.

  3. Tentar definir um atributo prop na instância falha.

  4. Inserir 'prop' diretamente em obj.__dict__ funciona.

  5. Podemos ver que agora obj tem dois atributos de instância: data e prop.

  6. Entretanto, ler obj.prop ainda executa o getter da propriedade. A propriedade não é ocultada pelo atributo de instância.

  7. Sobrescrever Class.prop destrói o objeto propriedade.

  8. Agora obj.prop obtém o atributo de instância. Class.prop não é mais uma propriedade, então ela não mais sobrepõe obj.prop.

Como uma demonstração final, vamos adicionar uma propriedade a Class, e vê-la sobrepor um atributo de instância. O Exemplo 25 retoma a sessão onde Exemplo 24 parou.

Exemplo 25. Uma nova propriedade de classe oculta o atributo de instância existente (continuando do Exemplo 24)
>>> obj.data  # (1)
'bar'
>>> Class.data  # (2)
'the class data attr'
>>> Class.data = property(lambda self: 'the "data" prop value')  # (3)
>>> obj.data  # (4)
'the "data" prop value'
>>> del Class.data  # (5)
>>> obj.data  # (6)
'bar'
  1. obj.data obtém o atributo de instância data.

  2. Class.data obtém o atributo de classe data.

  3. Sobrescreve Class.data com uma nova propriedade.

  4. obj.data está agora ocultado pela propriedade Class.data.

  5. Apaga a propriedade .

  6. obj.data agora lê novamente o atributo de instância data.

O ponto principal desta seção é que uma expressão como obj.data não começa a busca por data em obj. A busca na verdade começa em obj.__class__, e o Python só olha para a instância obj se não houver uma propriedade chamada data na classe. Isso se aplica a descritores dominantes em geral, dos quais as propriedades são apenas um exemplo. Mas um tratamento mais profundo de descritores vai ter que aguardar pelo Capítulo 23.

Voltemos às propriedades. Toda unidade de código do Python—módulos, funções, classes, métodos—pode conter uma docstring. O próximo tópico mostra como anexar documentação às propriedades.

22.5.2. Documentação de propriedades

Quando ferramentas como a função help() do console ou IDEs precisam mostrar a documentação de uma propriedade, elas extraem a informação do atributo __doc__ da propriedade.

Se usada com a sintaxe clássica de invocação, property pode receber a string de documentação no argumento doc:

    weight = property(get_weight, set_weight, doc='weight in kilograms')

A docstring do método getter—aquele que recebe o decorador @property—é usado como documentação da propriedade toda. O Figura 1 mostra telas de ajuda geradas a partir do código no Exemplo 26.

Screenshots of the Python console
Figura 1. Capturas de tela do console do Python para os comandos help(Foo.bar) e help(Foo). O código-fonte está no Exemplo 26.
Exemplo 26. Documentação para uma propriedade
class Foo:

    @property
    def bar(self):
        """The bar attribute"""
        return self.__dict__['bar']

    @bar.setter
    def bar(self, value):
        self.__dict__['bar'] = value

Agora que cobrimos o essencial sobre as propriedades, vamos voltar para a questão de proteger os atributos weight e price de LineItem, para que eles só aceitem valores maiores que zero—mas sem implementar manualmente dois pares de getters/setters praticamente idênticos.

22.6. Criando uma fábrica de propriedades

Vamos programar uma fábrica para criar propriedades quantity (quantidade)--assim chamadas porque os atributos gerenciados representam quantidades que não podem ser negativas ou zero na aplicação. O Exemplo 27 mostra a aparência cristalina da classe LineItem usando duas instâncias de propriedades quantity: uma para gerenciar o atributo weight, a outra para o price.

Exemplo 27. bulkfood_v2prop.py: a fábrica de propriedades quantity em ação
class LineItem:
    weight = quantity('weight')  # (1)
    price = quantity('price')  # (2)

    def __init__(self, description, weight, price):
        self.description = description
        self.weight = weight  # (3)
        self.price = price

    def subtotal(self):
        return self.weight * self.price  # (4)
  1. Usa a fábrica para definir a primeira propriedade personalizada, weight, como um atributo de classe.

  2. Essa segunda chamada cria outra propriedade personalizada, price.

  3. Aqui a propriedade já está ativa, assegurando que um peso negativo ou 0 seja rejeitado.

  4. As propriedades também são usadas aqui, para recuperar os valores armazenados na instância.

Recorde que propriedades são atributos de classe. Ao criar cada propriedade quantity, precisamos passar o nome do atributo de LineItem que será gerenciado por aquela propriedade específica. Ter que digitar a palavra weight duas vezes na linha abaixo é lamentável:

    weight = quantity('weight')

Mas evitar tal repetição é complicado, pois a propriedade não tem como saber qual nome de atributo será vinculado a ela. Lembre-se: o lado direito de uma atribuição é avaliado primeiro, então quando quantity() é invocada, o atributo de classe weight sequer existe.

✒️ Nota

Aperfeiçoar a propriedade quantity para que o usuário não precise redigitar o nome do atributo é uma problema não-trivial de metaprogramação. Um problema que resolveremos no Capítulo 23.

O Exemplo 28 apresenta a implementação da fábrica de propriedades quantity.[326]

Exemplo 28. bulkfood_v2prop.py: a fábrica de propriedades quantity
def quantity(storage_name):  # (1)

    def qty_getter(instance):  # (2)
        return instance.__dict__[storage_name]  # (3)

    def qty_setter(instance, value):  # (4)
        if value > 0:
            instance.__dict__[storage_name] = value  # (5)
        else:
            raise ValueError('value must be > 0')

    return property(qty_getter, qty_setter)  # (6)
  1. O argumento storage_name, onde os dados de cada propriedade são armazenados; para weight, o nome do armazenamento será 'weight'.

  2. O primeiro argumento do qty_getter poderia se chamar self, mas soaria estranho, pois isso não é o corpo de uma classe; instance se refere à instância de LineItem onde o atributo será armazenado.

  3. qty_getter se refere a storage_name, então ele será preservado na clausura desta função; o valor é obtido diretamente de instance.__dict__, para contornar a propriedade e evitar uma recursão infinita.

  4. qty_setter é definido, e também recebe instance como primeiro argumento.

  5. O value é armazenado diretamente no instance.__dict__, novamente contornando a propriedade.

  6. Cria e devolve um objeto propriedade personalizado.

As partes do Exemplo 28 que merecem um estudo mais cuidadoso giram em torno da variável storage_name.

Quando programamos um propriedade da maneira tradicional, o nome do atributo onde um valor será armazenado está definido explicitamente nos métodos getter e setter. Mas aqui as funções qty_getter e qty_setter são genéricas, e dependem da variável storage_name para saber onde ler/escrever o atributo gerenciado no __dict__ da instância. Cada vez que a fábrica quantity é chamada para criar uma propriedade, storage_name precisa ser definida com um valor único.

As funções qty_getter e qty_setter serão encapsuladas pelo objeto property, criado na última linha da função fábrica. Mais tarde, quando forem chamadas para cumprir seus papéis, essas funções lerão a storage_name de suas clausuras para determinar de onde ler ou onde escrever os valores dos atributos gerenciados.

No Exemplo 29, criei e inspecionei uma instância de LineItem, expondo os atributos armazenados.

Exemplo 29. bulkfood_v2prop.py: explorando propriedades e atributos de armazenamento
    >>> nutmeg = LineItem('Moluccan nutmeg', 8, 13.95)
    >>> nutmeg.weight, nutmeg.price  # (1)
    (8, 13.95)
    >>> nutmeg.__dict__  # (2)
    {'description': 'Moluccan nutmeg', 'weight': 8, 'price': 13.95}
  1. Lendo o weight e o price através das propriedades que ocultam os atributos de instância de mesmo nome.

  2. Usando vars para inspecionar a instância nutmeg: aqui vemos os reais atributos de instância usados para armazenar os valores.

Observe como as propriedades criadas por nossa fábrica se valem do comportamento descrito na Seção 22.5.1: a propriedade weight se sobrepõe ao atributo de instância weight, de forma que qualquer referência a self.weight ou nutmeg.weight é tratada pelas funções da propriedade, e a única maneira de contornar a lógica da propriedade é acessando diretamente o `__dict__`da instância.

O código no Exemplo 28 pode ser um pouco complicado, mas é conciso: seu tamanho é idêntico ao do par getter/setter decorado que define apenas a propriedade weight no Exemplo 21. A definição de LineItem no Exemplo 27 parece muito melhor sem o ruído de getters e setters.

Em um sistema real, o mesmo tipo de validação pode aparecer em muitos campos espalhados por várias classes, e a fábrica quantity estaria em um módulo utilitário, para ser usada continuamente. Por fim, aquela fábrica simples poderia ser refatorada em um classe descritora mais extensível, com subclasses especializadas realizando diferentes validações. Faremos isso no Capítulo 23.

Vamos agora encerrar a discussão das propriedades com a questão da exclusão de atributos.

22.7. Tratando a exclusão de atributos

Podemos usar a instrução del para excluir não apenas variáveis, mas também atributos:

>>> class Demo:
...    pass
...
>>> d = Demo()
>>> d.color = 'green'
>>> d.color
'green'
>>> del d.color
>>> d.color
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
AttributeError: 'Demo' object has no attribute 'color'

Na prática, a exclusão de atributos não é algo que se faça todo dia no Python, e a necessidade de lidar com isso no caso de uma propriedade é ainda mais rara. Mas tal operação é suportada, e consigo pensar em um exemplo bobo para demonstrá-la.

Em uma definição de propriedade, o decorador @my_property.deleter encapsula o método responsável por excluir o atributo gerenciado pela propriedade. Como prometido, o tolo Exemplo 30 foi inspirado pela cena com o Cavaleiro Negro, do filme Monty Python e o Cálice Sagrado.[327]

Exemplo 30. blackknight.py
class BlackKnight:

    def __init__(self):
        self.phrases = [
            ('an arm', "'Tis but a scratch."),
            ('another arm', "It's just a flesh wound."),
            ('a leg', "I'm invincible!"),
            ('another leg', "All right, we'll call it a draw.")
        ]

    @property
    def member(self):
        print('next member is:')
        return self.phrases[0][0]

    @member.deleter
    def member(self):
        member, text = self.phrases.pop(0)
        print(f'BLACK KNIGHT (loses {member}) -- {text}')

Os doctests em blackknight.py estão no Exemplo 31.

Exemplo 31. blackknight.py: doctests para Exemplo 30 (o Cavaleiro Negro nunca reconhece a derrota)
    >>> knight = BlackKnight()
    >>> knight.member
    next member is:
    'an arm'
    >>> del knight.member
    BLACK KNIGHT (loses an arm) -- 'Tis but a scratch.
    >>> del knight.member
    BLACK KNIGHT (loses another arm) -- It's just a flesh wound.
    >>> del knight.member
    BLACK KNIGHT (loses a leg) -- I'm invincible!
    >>> del knight.member
    BLACK KNIGHT (loses another leg) -- All right, we'll call it a draw.

Usando a sintaxe clássica de invocação em vez de decoradores, o argumento fdel configura a função de exclusão. Por exemplo, a propriedade member seria escrita assim no corpo da classe BlackKnight:

    member = property(member_getter, fdel=member_deleter)

Se você não estiver usando uma propriedade, a exclusão de atributos pode ser tratada implementando o método especial de nível mais baixo __delattr__, apresentado na Seção 22.8.3. Programar um classe tola com __delattr__ fica como exercício para a leitora que queira procrastinar.

Propriedades são recursos poderosos, mas algumas vezes alternativas mais simples ou de nível mais baixo são preferíveis. Na seção final deste capítulo, vamos revisar algumas das APIs essenciais oferecidas pelo Python para programação de atributos dinâmicos.

22.8. Atributos e funções essenciais para tratamento de atributos

Por todo este capítulo, e mesmo antes no livro, usamos algumas das funções embutidas e alguns dos métodos especiais oferecidos pelo Python para lidar com atributos dinâmicos. Esta seção os reúne em um único lugar para uma visão geral, pois sua documentação está espalhada na documentação oficial.

22.8.1. Atributos especiais que afetam o tratamento de atributos

O comportamento de muitas das funções e dos métodos especiais elencados nas próximas seções dependem de três atributos especiais:

__class__

Uma referência à classe do objeto (isto é, obj.__class__ é o mesmo que type(obj)). O Python procura por métodos especiais tal como __getattr__ apenas na classe do objeto, e não nas instâncias em si.

__dict__

Um mapeamento que armazena os atributos passíveis de escrita de um objeto ou de uma classe. Um objeto que tenha um __dict__ pode ter novos atributos arbitrários definidos a qualquer tempo. Se uma classe tem um atributo __slots__, então suas instâncias não podem ter um __dict__. Veja __slots__ (abaixo).

__slots__

Um atributo que pode ser definido em uma classe para economizar memória. __slots__ é uma tuple de strings, nomeando os atributos permitidos[328]. Se o nome '__dict__' não estiver em __slots__, as instâncias daquela classe então não terão um __dict__ próprio, e apenas os atributos listados em __slots__ serão permitidos naquelas instâncias. Revise a Seção 11.11 para recordar esse tópico.

22.8.2. Funções embutidas para tratamento de atributos

Essas cinco funções embutidas executam leitura, escrita e introspecção de atributos de objetos:

dir([object])

Lista a maioria dis atributos de um objeto. A documentação oficial diz que o objetivo de dir é o uso interativo, então ele não fornece uma lista completa de atributos, mas um conjunto de nomes "interessantes". dir pode inspecionar objetos implementados com ou sem um __dict__. O próprio atributo __dict__ não é exibido por dir, mas as chaves de __dict__ são listadas. Vários atributos especiais de classes, tais como __mro__, __bases__ e __name__, também não são exibidos por dir. Você pode personalziar a saída de dir implementando o método especial __dir__, como vimos no Exemplo 4. Se o argumento opcional object não for passado, dir lista os nomes no escopo corrente.

getattr(object, name[, default])

Devolve o atributo do object identificado pela string name. O principal caso de uso é obter atributos (ou métodos) cujos nomes não sabemos de antemão. Essa função pode recuperar um atributo da classe do objeto ou de uma superclasse. Se tal atributo não existir, getattr gera uma AttributeError ou devolve o valor default, se ele for passado. Um ótimo exemplo de uso de gettatr aparece no método Cmd.onecmd, no pacote cmd da biblioteca padrão, onde ela é usada para obter e executar um comando definido pelo usuário.

hasattr(object, name)

Devolve True se o atributo nomeado existir em object, ou puder ser obtido de alguma forma através dele (por herança, por exemplo). A documentação explica: "Isto é implementado chamando getattr(object, name) e vendo se [isso] levanta um AttributeError ou não."

setattr(object, name, value)

Atribui o value ao atributo de object nomeado, se o object permitir essa operação. Isso pode criar um novo atributo ou sobrescrever um atributo existente.

vars([object])

Devolve o __dict__ de object; vars não funciona com instâncias de classes que definem __slots__ e não têm um __dict__ (compare com dir, que aceita essas instâncias). Sem argumentos, vars() faz o mesmo que locals(): devolve um dict representando o escopo local.

22.8.3. Métodos especiais para tratamento de atributos

Quando implementados em uma classe definida pelo usuário, os métodos especiais listados abaixo controlam a recuperação, a atualização, a exclusão e a listagem de atributos.

Acessos a atributos, usando tanto a notação de ponto ou as funções embutidas getattr, hasattr e setattr disparam os métodos especiais adequados, listados aqui. A leitura e escrita direta de atributos no __dict__ da instância não dispara esses métodos especiais—​e essa é a forma habitual de evitá-los se isso for necessário.

A seção "3.3.11. Pesquisa de método especial" do capítulo "Modelo de dados" adverte:

Para classes personalizadas, as invocações implícitas de métodos especiais só têm garantia de funcionar corretamente se definidas em um tipo de objeto, não no dicionário de instância do objeto.

Em outras palavras, assuma que os métodos especiais serão acessados na própria classe, mesmo quando o alvo da ação é uma instância. Por essa razão, métodos especiais não são ocultados por atributos de instância de mesmo nome.

Nos exemplos a seguir, assuma que há uma classe chamada Class, que obj é uma instância de Class, e que attr é um atributo de obj.

Para cada um destes métodos especiais, não importa se o acesso ao atributo é feito usando a notação de ponto ou uma das funções embutidas listadas acima, em Seção 22.8.2. Por exemplo, tanto obj.attr quanto getattr(obj, 'attr', 42) disparam Class.__getattribute__(obj, 'attr').

__delattr__(self, name)

É sempre invocado quando ocorre uma tentativa de excluir um atributo usando a instrução del; por exemplo, del obj.attr dispara Class.__delattr__(obj, 'attr'). Se attr for uma propriedade, seu método de exclusão nunca será invocado se a classe implementar __delattr__.

__dir__(self)

Chamado quando dir é invocado sobre um objeto, para fornecer uma lista de atributos; por exemplo, dir(obj) dispara Class.__dir__(obj). Também usado pelo recurso de auto-completar em todos os consoles modernos do Python.

__getattr__(self, name)

Chamado apenas quando uma tentativa de obter o atributo nomeado falha, após obj, Class e suas superclasses serem pesquisadas. As expressões obj.no_such_attr, getattr(obj, 'no_such_attr') e hasattr(obj, 'no_such_attr') podem disparar Class.__getattr__(obj, 'no_such_attr'), mas apenas se um atributo com aquele nome não for encontrado em obj ou em Class e suas superclasses.

__getattribute__(self, name)

Sempre chamado quando há uma tentativa de obter o atributo nomeado diretamente a partir de código Python (o interpretador pode ignorar isso em alguns casos, por exemplo para obter o método __repr__). A notação de ponto e as funções embutidas getattr e hasattr disparam esse método. __getattr__ só é invocado após __getattribute__, e apenas quando __getattribute__ gera uma AttributeError. Para acessar atributos da instância obj sem entrar em uma recursão infinita, implementações de __getattribute__ devem usar super().__getattribute__(obj, name).

__setattr__(self, name, value)

Sempre chamado quando há uma tentativa de atribuir um valor ao atributo nomeado. A notação de ponto e a função embutida setattr disparam esse método; por exemplo, tanto obj.attr = 42 quanto setattr(obj, 'attr', 42) disparam Class.__setattr__(obj, 'attr', 42).

⚠️ Aviso

Na prática, como são chamados incondicionalmene e afetam praticamente todos os acessos a atributos, os métodos especiais __getattribute__ e __setattr__ são mais difíceis de usar corretamente que __getattr__, que só lida com nome de atributos não-existentes. Usar propriedades ou descritores tende a causar menos erros que definir esses métodos especiais.

Isso conclui nosso mergulho nas propriedades, nos métodos especiais e nas outras técnicas de programação de atributos dinâmicos.

22.9. Resumo do capítulo

Começamos nossa discussão dos atributos dinâmicos mostrando exemplos práticos de classes simples, que tornavam mais fácil processar um conjunto de dados JSON. O primeiro exemplo foi a classe FrozenJSON, que converte listas e dicts aninhados em instâncias aninhadas de FrozenJSON, e em listas de instâncias da mesma classe. O código de FrozenJSON demonstrou o uso do método especial __getattr__ para converter estruturas de dados em tempo real, sempre que seus atributos eram lidos. A última versão de FrozenJSON mostrou o uso do método construtor __new__ para transformar uma classe em uma fábrica flexível de objetos, não restrita a instâncias de si mesma.

Convertemos então o conjunto de dados JSON em um dict que armazena instâncias da classe Record. A primeira versão de Record tinha apenas algumas linhas e introduziu o dialeto do "punhado" ("bunch"): usar self.__dict__.update(**kwargs) para criar atributos arbitrários a partir de argumentos nomeados passados para __init__. A segunda passagem acrescentou a classe Event, implementando a recuperação automática de registros relacionados através de propriedades. Valores calculados de propriedades algumas vezes exigem caching, e falamos de algumas formas de fazer isso. Após descobrir que @functools.cached_property não é sempre aplicável, aprendemos sobre uma alternativa: a combinação de @property acima de @functools.cache, nessa ordem.

A discussão sobre propriedades continuou com a classe LineItem, onde uma propriedade foi criada para proteger um atributo weight de receber valores negativos ou zero, que não fazem sentido em termos do negócio. Após um aprofundamento da sintaxe e da semântica das propriedades, criamos uma fábrica de propriedades para aplicar a mesma validação a weight e a price, sem precisar escrever múltiplos getters e setters. A fábrica de propriedades se apoiou em conceitos sutis—tais como clausuras e a sobreposição de atributos de instância por propriedades—​para fornecer um solução genérica elegante, usando para isso o mesmo número de linhas que usamos antes para escrever manualmente a definição de uma única propriedade.

Por fim, demos uma rápida passada pelo tratamento da exclusão de atributos com propriedades, seguida por um resumo dos principais atributos especiais, funções embutidas e métodos especiais que suportam a metaprogramação de atributos no núcleo da linguagem Python.

22.10. Leitura Complementar

A documentação oficial para as funções embutidas de tratamento de atributos e introspecção é o Capítulo 2, "Funções embutidas" da Biblioteca Padrão do Python. Os métodos especiais relacionados e o atributo especial __slots__ estão documentados em A Referência da Linguagem Python, em "3.3.2. Personalizando o acesso aos atributos". A semântica de como métodos especiais são invocados ignorando as instâncias está explicada em "3.3.11. Pesquisa de método especial". No capítulo 4 da Biblioteca Padrão do Python, "Tipos embutidos", "Atributos especiais" trata dos atributos __class__ e __dict__.

O Python Cookbook (EN), 3ª ed., de David Beazley e Brian K. Jones (O’Reilly), tem várias receitas relacionadas aos tópicos deste capítulo, mas eu destacaria três mais marcantes: A "Recipe 8.8. Extending a Property in a Subclass" (Receita 8.8. Estendendo uma Propriedade em uma Subclasse) trata da espinhosa questão de sobrepor métodos dentro de uma propriedade herdada de uma superclasse; a "Recipe 8.15. Delegating Attribute Access" (Receita 8.15. Delegando o Acesso a Atributos) implementa uma classe proxy, demonstrando a maioria dos métodos especiais da Seção 22.8.3 deste livro; e a fantástica "Recipe 9.21. Avoiding Repetitive Property Methods" (Receita 9.21. Evitando Métodos de Propriedade Repetitivos), que foi a base da função fábrica de propriedades apresentada no Exemplo 28.

O Python in a Nutshell, 3ª ed., de Alex Martelli, Anna Ravenscroft e Steve Holden (O’Reilly), é rigoroso e objetivo. Eles dedicam apenas três páginas a propriedades, mas isso se dá porque o livro segue um estilo de apresentação axiomático: as 15 ou 16 páginas precedentes fornecem uma descrição minuciosa da semântica das classes do Python, a partir do zero, incluindo descritores, que são como as propriedades são efetivamente implementadas debaixo dos panos. Assim, quando Martelli et al. chegam à propriedades, eles concentram várias ideias profundas naquelas três páginas—incluindo o trecho que selecionei para abrir este capítulo.

Bertrand Meyer—citado na definição do Princípio do Acesso Uniforme no início do capítulo—foi um pioneiro da metodologia Programação por Contrato (Design by Contract), projetou a linguagem Eiffel e escreveu o excelente Object-Oriented Software Construction, 2ª ed. (Pearson). Os primeiros seis capítulos fornecem uma das melhores introduções conceituais à análise e design orientados a objetos que tenho notícia. O capítulo 11 apresenta a Programação por Contrato, e o capítulo 35 traz as avaliações de Meyer de algumas das mais influentes linguagens orientadas a objetos: Simula, Smalltalk, CLOS (the Common Lisp Object System), Objective-C, C++, e Java, com comentários curtos sobre algumas outras. Apenas na última página do livro o autor revela que a "notação" extremamente legível usada como pseudo-código no livro é Eiffel.

Ponto de Vista

O Princípio de Acesso Uniforme de Meyer é esteticamente atraente. Como um programador usando uma API, eu não deveria ter de me preocupar se product.price simplesmente lê um atributo de dados ou executa uma computação. Como um consumidor e um cidadão, eu me importo: no comércio online atual, o valor de product.price muitas vezes depende de quem está perguntando, então ele certamente não é um mero atributo de dados. Na verdade, é uma prática comum apresentar um preço mais baixo se a consulta vem de fora da loja—por exemplo, de um mecanismo de comparação de preços. Isso efetivamente pune os fregueses fiéis, que gostam de navegar dentro de uma loja específica. Mas estou divagando.

A digressão anterior toca um ponto relevante para programação: apesar do Princípio de Acesso Uniforme fazer todo sentido em um mundo ideal, na realidade os usuários de uma API podem precisar saber se ler product.price é potencialmente dispendioso ou demorado demais. Isso é um problema com abstrações de programaçõa em geral: elas tornam difícil raciocinar sobre o custo da avaliação de uma expressão durante a execução. Por outro lado, abstrações permitem aos usuários fazerem mais com menos código. É uma negociação. Como de hábito em questões de engenharia de software, o wiki original (EN) de Ward Cunningham contém argumentos perspicazes sobre os méritos do Princípio de Acesso Uniforme (EN).

Em linguagens de programação orientadas a objetos, a aplicação ou violação do Princípio de Acesso Uniforme muitas vezes gira em torno da sintaxe de leitura de atributos de dados públicos versus a invocação de métodos getter/setter.

Smalltalk e Ruby resolvem essa questão de uma forma simples e elegante: elas não suportam nenhuma forma de atributos de dados públicos. Todo atributo de instância nessas linguagens é privado, então qualquer acesso a eles deve passar por métodos. Mas sua sintaxe torna isso indolor: em Ruby, product.price invoca o getter de price; em Smalltalk, ele é simplesmente product price.

Na outra ponta do espectro, a linguagem Java permite ao programador escolher entre quatro modificadores de nível de acesso—incluindo o default sem nome que o Tutorial do Java (EN) chama de "package-private".

A prática geral, entretanto, não concorda com a sintaxe definida pelos projetistas do Java. Todos no campo do Java concordam que atributos devem ser private, e é necessário escrever isso explicitamente todas as vezes, porque não é o default. Quando todos os atributos são privados, todo acesso a eles de fora da classe precisa passar por métodos de acesso. Os IDEs de Java incluem atalhos para gerar métodos de acesso automaticamente. Infelizmente, o IDE não ajuda quando você precisa ler aquele código seis meses depois. É problema seu navegar por um oceano de métodos de acesso que não fazem nada, para encontrar aqueles que adicionam valor, implementando alguma lógica do negócio.

Alex Martelli fala pela maioria da comunidade Python quando chama métodos de acesso de "idiomas patéticos", e então apresenta esse exemplos que parecem muito diferentes, mas fazem a mesma coisa: [329]

someInstance.widgetCounter += 1
# rather than...
someInstance.setWidgetCounter(someInstance.getWidgetCounter() + 1)

Algumas vezes, ao projetar uma API, me pergunto se todo método que não recebe qualquer argumento (além de self), devolve um valor (diferente de None) e é uma função pura (isto é, não tem efeitos colaterais) não deveria ser substituído por uma propriedade somente de leitura. Nesse capítulo, o método LineItem.subtotal (no Exemplo 27) seria um bom candidato a se tornar uma propriedade somente para leitura. Claro, isso exclui métodos projetados para modificar o objeto, tal como my_list.clear(). Seria uma péssima ideia transformar isso em uma propriedade, de tal forma que o mero acesso a my_list.clear apagaria o conteúdo da lista!

Na biblioteca GPIO Pingo (mencionada na Seção 3.5.2), da qual sou co-autor, grande parte da API do usuário está baseada em propriedades. Por exemplo, para ler o valor atual de uma porta analógica, o usuário escreve pin.value, e definir o modo de uma porta digital é escrito pin.mode = OUT. Por trás da cortina, ler o valor de uma porta analógica ou definir o modo de uma porta digital pode implicar em bastante código, dependendo do driver específico da placa. Decidimos usar propriedades no Pingo porque queríamos que a API fosse confortável de usar até mesmo em ambientes interativos como um Jupyter Notebook, e achamos que pin.mode = OUT é mais fácil para os olhos e para os dedos que pin.set_mode(OUT).

Apesar de achar a solução do Smalltalk e do Ruby mais limpa, acho que a abordagem do Python faz mais sentido que a do Java. Podemos começar simples, programando elementos de dados como atributos públicos, pois sabemos que eles sempre podem ser encapsulados por propriedades (ou descritores, dos quais falaremos no próximo capítulo).

+__new__+ é melhor que new

Outro exemplo do Princípio de Acesso Uniforme (ou uma variante dele) é o fato de chamadas de função e instanciação de objetos usarem a mesma sintaxe no Python: my_obj = foo(), onde foo pode ser uma classe ou qualquer outro invocável.

Outras linguagens, influenciadas pela sintaxe do C++, tem um operador new que faz a instanciação parecer diferente de uma invocação. Na maior parte do tempo, o usuário de uma API não se importa se foo é uma função ou uma classe. Por anos tive a impressão que property era uma função. Para o uso normal, não faz diferença.

Há muitas boas razões para substituir construtores por fábricas. [330] Um motivo popular é limitar o número de instâncias, devolvendo objetos construídps anterioremente (como no padrão de projeto Singleton). Um uso relacionado é fazer caching de uma construção de objeto dispendiosa. Além disso, às vezes é conveniente devolver objetos de tipos diferentes, dependendo dos argumentos passados.

Programar um construtor é simples; fornecer uma fábrica aumenta a flexibilidade às custas de mais código. Em linguagens com um operador new, o projetista de uma API precisa decidir a priori se vai se ater a um construtor simples ou investir em uma fábrica. Se a escolha inicial estiver errada, a correção pode ser cara—tudo porque new é um operador.

Algumas vezes pode ser conveniente pegar o caminho inverso, e substituir uma função simples por uma classe.

Em Python, classes e funções são muitas vezes intercambiáveis. Não apenas pela ausência de um operador new, mas também porque existe o método especial __new__, que pode transformar uma classe em uma fábrica que produz tipos diferentes de objetos (como vimos na Seção 22.2.3) ou devolver instâncias pré-fabricadas em vez de criar uma nova todas as vezes.

Essa dualidade função-classe seria mais fácil de aproveitar se a PEP 8 — Style Guide for Python Code (Guia de Estilo para Código Python) não recomendasse CamelCase para nomes de classe. Por outro lado, dezenas de classes na biblioteca padrão tem nomes apenas em minúsculas (por exemplo, property, str, defaultdict, etc.). Daí que talvez o uso de nomes de classe apenas com minúsculas seja um recurso, e não um bug. Mas independente de como olhemos, essa inconsistência no uso de maiúsculas e minúsculas nos nomes de classes na biblioteca padrão do Python nos coloca um problema de usabilidade.

Apesar da invocação de uma função não ser diferente da invocação de uma classe, é bom saber qual é qual, por causa de outra coisa que podemos fazer com uma classe: criar uma subclasse. Então eu, pessoalmente, uso CamelCase em todas as classes que escrevo, e gostaria que todas as classea na biblioteca padrão do Python seguissem a mesma convenção. Estou olhando para vocês, collections.OrderedDict e collections.defaultdict.

23. Descritores de Atributos

Aprender sobre descritores não apenas dá acesso a um conjunto maior de ferramentas, cria também uma maior compreensão sobre o funcionamento do Python e uma apreciação pela elegância de seu design.[331]

— Raymond Hettinger
guru do Python e um de seus desenvolvedores principais

Descritores são uma forma de reutilizar a mesma lógica de acesso em múltiplos atributos. Por exemplo, tipos de campos em ORMs ("Object Relational Mapping" - Mapeamento Objeto-Relacional), tais como o ORM do Django e o SQLAlchemy, são descritores, gerenciando o fluxo de dados dos campos em um registro de banco de dados para atributos de objetos do Python, e vice-versa.

Um descritor é uma classe que implementa um protocolo dinâmico, composto pelos métodos __get__, __set__, e __delete__. A classe property implementa o protocolo descritor completo. Como habitual em protocolos dinâmicos, implementações parciais são aceitáveis. E, na verdade, a maioria dos descritores que vemos em código real implementam apenas __get__ e __set__, e muitos implementam apenas um destes métodos.

Descritores são um recurso característico do Python, presentes não apenas no nível das aplicações mas também na infraestrutura da linguagem. Funções definidas pelo usuário são descritores. Veremos como o protocolo descritor permite que métodos operem como métodos vinculados ou desvinculados, dependendo de como são invocados.

Entender os descritores é crucial para dominar o Python. Esse capítulo é sobre isso.

Nas próximas páginas vamos refatorar o exemplo da loja de comida orgânica a granel, visto na Seção 22.4, substituindo propriedades por descritores. Isso tornará mais fácil reutilizar a lógica de validação de atributos em diferentes classes.

Vamos estudar os conceitos de descritores dominantes e não dominantes, e entender que as funções do Python são descritores. Para finalizar, veremos algumas dicas para a implementação de descritores.

23.1. Novidades nesse capítulo

O exemplo do descritor Quantity, na Seção 23.2.2, foi dramaticamente simplificado, graças ao método especial __set_name__, adicionado ao protocolo descritor no Python 3.6. Nessa mesma seção, removi o exemplo da fábrica de propriedades, pois ele se tornou irrelevante: o ponto ali era mostrar uma solução alternativa para o problema de Quantity, mas com __set_name__ a solução com o descritor se tornou muito mais simples.

A classe AutoStorage, que aparecia na Seção 23.2.3, também foi removida, pois o mesmo __set_name__ a tornou obsoleta.

23.2. Exemplo de descritor: validação de atributos

Como vimos na Seção 22.6, uma fábrica de propriedades é uma maneira de evitar código repetitivo de getters e setters, aplicando padrões de programação funcional. Um fábrica de propriedades é uma função de ordem superior que cria um conjunto de funções de acesso parametrizadas e constrói uma instância de propriedade personalizada, com clausuras para manter configurações como storage_name. A forma orientada a objetos de resolver o mesmo problema é uma classe descritora.

Vamos seguir com a série de exemplos LineItem de onde paramos, na Seção 22.6, refatorando a fábrica de propriedades quantity em uma classe descritora Quantity. Isso vai torná-la mais fácil de usar.

23.2.1. LineItem versão #3: Um descritor simples

Como dito na introdução, uma classe que implemente um método __get__, um __set__ ou um __delete__ é um descritor. Podemos usar um descritor declarando instâncias dele como atributos de classe em outra classe.

Vamos criar um descritor Quantity, e a classe LineItem vai usar duas instâncias de Quantity: uma para gerenciar o atributo weight, a outra para price. Um diagrama ajuda: dê uma olhada na Figura 1.

Diagrama de classes UML para `Quantity` e `LineItem`
Figura 1. Diagrama de classe UML para LineItem usando uma classe descritora chamada Quantity. Atributos sublinhados no UML são atributos de classe. Observe que weight e price são instâncias de Quantity na classe LineItem, mas instâncias de LineItem também têm seus próprios atributos weight e price, onde esses valores são armazenados.

Note que a palavra weight aparece duas vezes na Figura 1, pois na verdade há dois atributos diferentes chamados weight: um é um atributo de classe de LineItem, o outro é um atributo de instância que existirá em cada objeto LineItem. O mesmo se aplica a price.

Termos para entender descritores

Implementar e usar descritores envolve vários componentes, então é útil ser preciso ao nomeá-los. Vou utilizar termos e definições abaixo nas descrições dos exemplos desse capítulo. Será mais fácil entendê-los após ver o código, mas quis colocar todas as definições no início, para você poder voltar a elas quando necessário.

Classe descritora

Uma classe que implementa o protocolo descritor. Por exemplo, Quantity na Figura 1.

Classe gerenciada

A classe onde as instâncias do descritor são declaradas, como atributos de classe. Na Figura 1, LineItem é a classe gerenciada.

Instância do descritor

Cada instância de uma classe descritora, declarada como um atributo de classe da classe gerenciada. Na Figura 1, cada instância do descritor está representada pela seta de composição com um nome sublinhado (na UML, o sublinhado indica um atributo de classe). Os diamantes pretos tocam a classe LineItem, que contém as instâncias do descritor.

Instância gerenciada

Uma instância da classe gerenciada. Nesse exemplo, instâncias de LineItem são as instâncias gerenciadas (elas não aparecem no diagrama de classe).

Atributo de armazenamento

Um atributo da instância gerenciada que mantém o valor de um atributo gerenciado para aquela instância específica. Na Figura 1, os atributos de instância weight e price de LineItem são atributos de armazenamento. Eles são diferentes das instâncias do descritor, que são sempre atributos de classe.

Atributos gerenciados

Um atributo público na classe gerenciada que é controlado por uma instância do descritor, com os valores mantidos em atributos de armazenamento. Em outras palavras, uma instância do descritor e um atributo de armazenamento fornecem a infraestrutura para um atributo gerenciado.

É importante entender que instâncias de Quantity são atributos de classe de LineItem. Este ponto fundamental é realçado pelas "engenhocas" (mills) e bugigangas (gizmos) na Figura 2.

Diagrama de classe UML+MGN para `Quantity` e `LineItem`
Figura 2. Diagrama de classe UML anotado com MGN (Mills & Gizmos Notation - Notação de Engenhocas e Bugigangas): classes são engenhocas que produzem bugigangas—as instâncias. A engenhoca Quantity produz duas bugigangas de cabeça redonda, que são anexadas à engenhoca LineItem: weight e price. A engenhoca LineItem produz bugigangas retangulares que tem seus próprios atributos weight e price, onde aqueles valores são armazenados.
Introduzindo a notação Engenhocas & Bugigangas (Mills & Gizmos)

Após explicar descritores várias vezes, percebi que a UML não é muito boa para mostrar as relações entre classes e instâncias, tal como a relação entre uma classe gerenciada e as instâncias do descritor.[332] Daí inventei minha própria "linguagem", a Notação Engenhocas e Bugigangas (MGN), que uso para anotar diagramas UML.

A MGN é projetada para tornar bastante clara a diferença entre classes e instâncias. Veja a Figura 3. Na MGN, uma classe aparece como uma "engenhoca", uma máquina complexa que produz bugigangas. Classes/engenhocas são sempre máquina com alavancas e mostradores. As bugigangas são as instâncias, e elas têm uma aparência bem mais simples. Quando este livro é gerado em cores, as bugigangas tem a mesma cor da engenhoca que as produziu.

Esboço MGN para `LineItem` e `Quantity`
Figura 3. Esboço MGN mostrando a classe LineItem produzindo três instâncias, e Quantity produzindo duas. Uma instância de Quantity está recuperando um valor armazenado em uma instância de LineItem.

Para este exemplo, desenhei instâncias de LineItem como linhas em uma fatura tabular, com três células representando os três atributos (description, weight e price). Como as instâncias de Quantity são descritores, eles tem uma lente de aumento para __get__ (obter) os valores, e uma garra para __set__ (definir) os valores. Quando chegarmos às metaclasses, você me agradecerá por esses desenhos.

Mas chega de rabiscos por enquanto. Aqui está o código: o Exemplo 1 mostra a classe descritora Quantity, e o Exemplo 2 lista a nova classe LineItem usando duas instâncias de Quantity.

Exemplo 1. bulkfood_v3.py: o descritor Quantity não aceita valores negativos
class Quantity:  # (1)

    def __init__(self, storage_name):
        self.storage_name = storage_name  # (2)

    def __set__(self, instance, value):  # (3)
        if value > 0:
            instance.__dict__[self.storage_name] = value  # (4)
        else:
            msg = f'{self.storage_name} must be > 0'
            raise ValueError(msg)

    def __get__(self, instance, owner):  # (5)
        return instance.__dict__[self.storage_name]
  1. O descritor é um recurso baseado em protocolo: não é necessário criar uma subclasse para implementá-lo.

  2. Cada instância de Quantity terá um atributo storage_name: é o nome do atributo de armazenamento que vai manter o valar nas instâncias gerenciadas.

  3. O __set__ é chamado quando ocorre uma tentativa de atribuir um valor a um atributo gerenciado. Aqui, self é a instância do descritor (isto é, LineItem.weight ou LineItem.price), instance é a instância gerenciada (uma instância de LineItem) e value é o valor que está sendo atribuído.

  4. Precisamos armazenar o valor do atributo diretamente no __dict__; chamar set​attr​(instance, self.storage_name) dispararia novamente o método __set__, levando a uma recursão infinita.

  5. Precisamos implementar __get__, pois o nome do atributo gerenciado pode não ser igual ao storage_name. O argumento owner será explicado a seguir.

Implementar __get__ é necessário porque um usuário poderia escrever algo assim:

class House:
    rooms = Quantity('number_of_rooms')

Na classe House, o atributo gerenciado é rooms, mas o atributo de armazenamento é number_of_rooms. Dada uma instância de House chamada chaos_manor, acessar e modificar chaos_manor.rooms passa pela instância do descritor Quantity ligada a rooms, mas acessar e modificar chaos_manor.number_of_rooms escapa ao descritor.

Observe que __get__ recebe três argumentos: self, instance e owner. O argumento owner é uma referência à classe gerenciada (por exemplo, LineItem), e é útil se você quiser que o descritor suporte o acesso a um atributo de classe—talvez para emular o comportamento default do Python, de procurar um atributo de classe quando o nome não é encontrado na instância.

Se um atributo gerenciado, tal como weight, é acessado através da classe como Line​Item.weight, o método __get__ do descritor recebe None como valor do argumento instance.

Para suportar introspecção e outras técnicas de metaprogramação pelo usuário, é uma boa prática fazer __get__ devolver a instância do descritor quando o atributo gerenciado é acessado através da classe. Para fazer isso, escreveríamos __get__ assim:

    def __get__(self, instance, owner):
        if instance is None:
            return self
        else:
            return instance.__dict__[self.storage_name]

O Exemplo 2 demonstra o uso de Quantity em LineItem.

Exemplo 2. bulkfood_v3.py: descritores Quantity gerenciam atributos em LineItem
class LineItem:
    weight = Quantity('weight')  # (1)
    price = Quantity('price')  # (2)

    def __init__(self, description, weight, price):  # (3)
        self.description = description
        self.weight = weight
        self.price = price

    def subtotal(self):
        return self.weight * self.price
  1. A primeira instância do descritor vai gerenciar o atributo weight.

  2. A segunda instância do descritor vai gerenciar o atributo price.

  3. O restante do corpo da classe é tão simples e limpo como o código orginal em bulkfood_v1.py (no Exemplo 19).

O código no Exemplo 2 funciona como esperado, evitando a venda de trufas por $0:[333]

>>> truffle = LineItem('White truffle', 100, 0)
Traceback (most recent call last):
    ...
ValueError: value must be > 0
⚠️ Aviso

Ao programar os métodos __get__ e __set__ de um descritor, tenha em mente o significado dos argumentos self e instance: self é a instância do descritor, instance é a instância gerenciada. Descritores que gerenciam atributos de instância devem armazenar os valores nas instâncias gerenciadas. É por isso que o Python fornece o argumento instance aos métodos do descritor.

Pode ser tentador, mas é um erro, armazenar o valor de cada atributo gerenciado na própria instância do descritor. Em outras palavras, em vez de escrever o método __set__ assim:

    instance.__dict__[self.storage_name] = value

escrever a alternativa tentadora mas ruim, assim:

    self.__dict__[self.storage_name] = value

Para entender porque isso está errado, pense no significado dos dois primeiros argumentos passados a __set__: self e instance. Aqui, self é a instância do descritor, que na verdade é um atributo de classe da classe gerenciada. Você pode ter milhares de instâncias de LineItem na memória em um dado momento, mas terá apenas duas instâncias dos descritores: os atributos de classe LineItem.weight e LineItem.price. Então, qualquer coisa armazenada nas próprias instâncias do descritor é na verdade parte de um atributo de classe de LineItem, e portanto é compartilhada por todas as instâncias de LineItem.

Um inconveniente do Exemplo 2 é a necessidade de repetir os nomes dos atributos quando os descritores são instanciados no corpo da classe gerenciada. Seria bom se a classe LineItem pudesse ser declarada assim:

class LineItem:
    weight = Quantity()
    price = Quantity()

    # o restante dos métodos permanece igual

Da forma como está escrito, o Exemplo 2 exige nomear explicitamente cada Quantity, algo não apenas inconveniente, mas também perigoso. Se um programador, ao copiar e colar código, se esquecer de editar os dois nomes, e terminar com uma linha como price = Quantity('weight'), o programa vai se comportar de forma muito errática, sobrescrevendo o valor de weight sempre que price for definido.

O problema é que—como vimos no Capítulo 6—o lado direito de uma atribuição é executado antes da variável existir. A expressão Quantity() é avaliada para criar uma instância do descritor, e não há como o código na classe Quantity adivinhar o nome da variável à qual o descritor será vinculado (por exemplo, weight ou price).

Felizmente, o protocolo descritor agora suporta o muito bem batizado método __set_name__. Veremos a seguir como usá-lo.

✒️ Nota

Nomear automaticamente o atributo de armazenamendo de um descritor contumava ser uma tarefa espinhosa. Na primeira edição do Python Fluente, dediquei várias páginas e muitas linhas de código neste capítulo e no seguinte para apresentar diferentes soluções, incluindo o uso de um decorador de classe e depois metaclasses (no Capítulo 24). Tudo isso ficou muito mais simples no Python 3.6.

23.2.2. LineItem versão #4: Nomeando atributos de armazenamento automaticamente

Para evitar a redigitação do nome do atributo em instâncias do descritor, vamos implementar __set_name__, para definir o storage_name de cada instância de Quantity. O método especial __set_name__ foi acrescentado ao protocolo descritor no Python 3.6.

O interpretador invoca __set_name__ em cada descritor encontrado no corpo de uma class—se o descritor implementar esse método.[334]

No Exemplo 3, a classe descritora Quantity não precisa de um __init__. Em vez disso, __set_item__ armazena o nome do atributo de armazenamento.

Exemplo 3. bulkfood_v4.py: __set_name__ define o nome para cada instância do descritor Quantity
class Quantity:

    def __set_name__(self, owner, name):  # (1)
        self.storage_name = name          # (2)

    def __set__(self, instance, value):   # (3)
        if value > 0:
            instance.__dict__[self.storage_name] = value
        else:
            msg = f'{self.storage_name} must be > 0'
            raise ValueError(msg)

    # no __get__ needed  # (4)

class LineItem:
    weight = Quantity()  # (5)
    price = Quantity()

    def __init__(self, description, weight, price):
        self.description = description
        self.weight = weight
        self.price = price

    def subtotal(self):
        return self.weight * self.price
  1. self é a instância do descritor (não a instância gerenciada), owner é a classe gerenciada e name é o nome do atributo de owner ao qual essa instância do descritor foi atrbuída no corpo da classe de owner.

  2. Isso é o que o __init__ fazia no Exemplo 1.

  3. O método __set__ aqui é exatamente igual ao do Exemplo 1.

  4. Não é necessário implementar __get__, porque o nome do atributo de armazenamento é igual ao nome do atributo gerenciado. A expressão product.price obtém o atributo price diretamente da instância de LineItem.

  5. Não é necessário passar o nome do atributo gerenciado para o construtor de Quantity. Esse era o objetivo dessa versão.

Olhando para o Exemplo 3, pode parecer muito código apenas para gerenciar um par de atributos, mas é importante perceber que a lógica do descritor foi agora abstraida em uma unidade de código diferente: a classe Quantity. Nós normalmente sequer definimos um descritor no mesmo módulo em que ele é usado, mas em um módulo utilitário separado, projetado para ser usado por toda a aplicação—ou mesmo por muitas aplicações, se estivermos desenvolvendo uma bliblioteca ou um framework.

Tendo isso em mente, o Exemplo 4 representa melhor o uso típico de um descritor.

Exemplo 4. bulkfood_v4c.py: uma definição mais limpa de LineItem; a classe descritora Quantity agora reside no módulo importado model_v4c
import model_v4c as model  # (1)


class LineItem:
    weight = model.Quantity()  # (2)
    price = model.Quantity()

    def __init__(self, description, weight, price):
        self.description = description
        self.weight = weight
        self.price = price

    def subtotal(self):
        return self.weight * self.price
  1. Importa o módulo model_v4c, onde Quantity é implementada.

  2. Coloca model.Quantity em uso.

Usuários do Django vão perceber que o Exemplo 4 se parece muito com uma definição de modelo. Isso não é uma coincidência: os campos de modelos Django são descritores.

Já que descritores são implementado como classes, podemos aproveitar a herança para reutilizar parte do código que já temos em novos descritores. É o que faremos na próxima seção.

23.2.3. LineItem versão #5: um novo tipo descritor

A loja imaginária de comida orgânica encontra um obstáculo: de alguma forma, uma instância de um produto foi criada com uma descrição vazia, e o pedido não pode ser processado. Para prevenir isso, criaremos um novo descritor: NonBlank. Ao projetar NonBlank, percebemos que ele será muito parecido com o descritor Quantity, exceto pela lógica de validação.

Isso leva a uma refatoração, resultando em Validated, uma classe abstrata que sobrepõe um método __set__, invocando o método validate, que precisa ser implementado por subclasses.

Vamos então reescrever Quantity e implementar NonBlank, herdando de Validated e programando apenas os métodos validate.

A relação entre Validated, Quantity e NonBlank é uma aplicação do método modelo ("template method"), como descrito no clássico Design Patterns:

Um método modelo define um algoritimo em termos de operações abstratas que subclasses sobrepõe para fornecer o comportamento concreto.[335]

No Exemplo 5, Validated.__set__ é um método modelo e self.validate é a operação abstrata.

Exemplo 5. model_v5.py: the Validated ABC
import abc

class Validated(abc.ABC):

    def __set_name__(self, owner, name):
        self.storage_name = name

    def __set__(self, instance, value):
        value = self.validate(self.storage_name, value)  # (1)
        instance.__dict__[self.storage_name] = value  # (2)

    @abc.abstractmethod
    def validate(self, name, value):  # (3)
        """return validated value or raise ValueError"""
  1. __set__ delega a validação para o método validate…​

  2. …​e então usa o value devolvido para atualizar o valor armazenado.

  3. validate é um método abstrato; este é o método modelo.

Alex Martelli prefere chamar este padrão de projeto Auto-Delegação ("Self-Delegation"), e concordo que é um nome mais descritivo: a primeira linha de __set__ auto-delega para validate.[336]

As subclasses concretas de Validated neste exemplo são Quantity e NonBlank, apresentadas no Exemplo 6.

Exemplo 6. model_v5.py: Quantity e NonBlank, subclasses concretas de Validated
class Quantity(Validated):
    """a number greater than zero"""

    def validate(self, name, value):  # (1)
        if value <= 0:
            raise ValueError(f'{name} must be > 0')
        return value


class NonBlank(Validated):
    """a string with at least one non-space character"""

    def validate(self, name, value):
        value = value.strip()
        if not value:  # (2)
            raise ValueError(f'{name} cannot be blank')
        return value  # (3)
  1. Implementação do método modelo exigida pelo método abstrado Validated.validate.

  2. Se não sobrar nada após a remoção os espaços em branco antes e depois do valor, este é rejeitado.

  3. Exigir que os métodos validate concretos devolvam o valor validado dá a eles a oportunidade de limpar, converter ou normalizar os dados recebidos. Neste caso, value é devolvido sem espaços iniciais ou finais.

Usuários de model_v5.py não precisam saber todos esses detalhes. O que importa é poder usar Quantity e NonBlank para automatizar a validação de atributos de instância. Veja a última classe LineItem no Exemplo 7.

Exemplo 7. bulkfood_v5.py: LineItem usando os descritores Quantity e NonBlank
import model_v5 as model  # (1)

class LineItem:
    description = model.NonBlank()  # (2)
    weight = model.Quantity()
    price = model.Quantity()

    def __init__(self, description, weight, price):
        self.description = description
        self.weight = weight
        self.price = price

    def subtotal(self):
        return self.weight * self.price
  1. Importa o módulo model_v5, dando a ele um nome amigável.

  2. Usa model.NonBlank. O restante do código não foi modificado.

Os exemplos de LineItem que vimos neste capítulo demonstram um uso típico de descritores, para gerenciar atributos de dados. Descritores como Quantity são chamado descritores dominantes, pois seu método __set__ sobrepõe (isto é, intercepta e anula) a definição de um atributo de instância com o mesmo nome na instância gerenciada. Entretanto, há também descritores não dominantes. Vamos explorar essa diferença detalhadamente na próxima seção.

23.3. Descritores dominantes versus descritores não dominantes

Recordando, há uma importante assimetria na forma como o Python lida com atributos. Ler um atributo através de uma instância normalmente devolve o atributo definido na instância. Mas se tal atributo não existir na instância, um atributo de classe será obtido. Por outro lado, uma atribuição a um atributo em uma instância normalmente cria o atributo na instância, sem afetar a classe de forma alguma.

Essa assimetria também afeta descritores, criando efetivamente duas grandes categorias de descritores, dependendo do método __set__ estar ou não implementado. Se __set__ estiver presente, a classe é um descritor dominante; caso contrário, ela é um descritor não dominante. Esses termos farão sentido quando examinarmos os comportamentos de descritores, nos próximos exemplos.

Observar as categorias diferentes de descritores exige algumas classes, então vamos usar o código no Exemplo 8 como nossa bancada de testes para as próximas seções.

👉 Dica

Todos os métodos __get__ e __set__ no Exemplo 8 chamam print_args. Assim, suas invocações são apresentadas de uma forma legível. Entender print_args e suas funções auxiliares, cls_name e display, não é importante, então não se deixe distrair por elas.

Exemplo 8. descriptorkinds.py: classes simples para estudar os comportamentos dominantes de descritores
### auxiliary functions for display only ###

def cls_name(obj_or_cls):
    cls = type(obj_or_cls)
    if cls is type:
        cls = obj_or_cls
    return cls.__name__.split('.')[-1]

def display(obj):
    cls = type(obj)
    if cls is type:
        return f'<class {obj.__name__}>'
    elif cls in [type(None), int]:
        return repr(obj)
    else:
        return f'<{cls_name(obj)} object>'

def print_args(name, *args):
    pseudo_args = ', '.join(display(x) for x in args)
    print(f'-> {cls_name(args[0])}.__{name}__({pseudo_args})')


### essential classes for this example ###

class Overriding:  # (1)
    """a.k.a. data descriptor or enforced descriptor"""

    def __get__(self, instance, owner):
        print_args('get', self, instance, owner)  # (2)

    def __set__(self, instance, value):
        print_args('set', self, instance, value)


class OverridingNoGet:  # (3)
    """an overriding descriptor without ``__get__``"""

    def __set__(self, instance, value):
        print_args('set', self, instance, value)


class NonOverriding:  # (4)
    """a.k.a. non-data or shadowable descriptor"""

    def __get__(self, instance, owner):
        print_args('get', self, instance, owner)


class Managed:  # (5)
    over = Overriding()
    over_no_get = OverridingNoGet()
    non_over = NonOverriding()

    def spam(self):  # (6)
        print(f'-> Managed.spam({display(self)})')
  1. Uma classe descritora dominante com __get__ e __set__.

  2. A função print_args é chamada por todos os métodos do descritor neste exemplo.

  3. Um descritor dominante sem um método __get__.

  4. Nenhum método __set__ aqui, estão este é um descritor não dominante.

  5. A classe gerenciada, usando uma instância de cada uma das classes descritoras.

  6. O método spam está aqui para efeito de comparação, pois métodos também são descritores.

Nas próximas seções, examinaremos o comportamento de leitura e escrita de atributos na classe Managed e em uma de suas instâncias, passando por cada um dos diferentes descritores definidos.

23.3.1. Descritores dominantes

Um descritor que implementa o método __set__ é um descritor dominante pois, apesar de ser um atributo de classe, um descritor que implementa __set__ irá sobrepor tentativas de atribuição a atributos de instância. É assim que o Exemplo 3 foi implementado. Propriedades também são descritores dominantes: se você não fornecer uma função setter, o __set__ default da classe property vai gerar um AttributeError, para sinalizar que o atributo é somente para leitura.

⚠️ Aviso

Contribuidores e autores da comunidade Python usam termos diferentes ao discutir esses conceitos. Adotei "descritor dominante" (overriding descriptor), do livro Python in a Nutshell. A documentação oficial do Python usa "descritor de dados" (data descriptor) mas "descritor dominante" destaca o comportamento especial. Descritores dominantes também são chamados "descritores forçados" (enforced descriptors). Sinônimos para descritores não dominantes incluem "descritores sem dados" (nondata descriptors, na documentação oficial em português) ou "descritores ocultáveis" (shadowable descriptors).

Dado o código no Exemplo 8, alguns experimentos com um descritor dominante podem ser vistos no Exemplo 9.

Exemplo 9. O comportamento de um descritor dominante
>>> obj = Managed()  # (1)
>>> obj.over  # (2)
-> Overriding.__get__(<Overriding object>, <Managed object>, <class Managed>)
>>> Managed.over  # (3)
-> Overriding.__get__(<Overriding object>, None, <class Managed>)
>>> obj.over = 7  # (4)
-> Overriding.__set__(<Overriding object>, <Managed object>, 7)
>>> obj.over  # (5)
-> Overriding.__get__(<Overriding object>, <Managed object>, <class Managed>)
>>> obj.__dict__['over'] = 8  # (6)
>>> vars(obj)  # (7)
{'over': 8}
>>> obj.over  # (8)
-> Overriding.__get__(<Overriding object>, <Managed object>, <class Managed>)
  1. Cria o objeto Managed, para testes.

  2. obj.over aciona o método __get__ do descritor, passando a instância gerenciada obj como segundo argumento.

  3. Managed.over aciona o método __get__ do descritor, passando None como segundo argumento (instance).

  4. Atribuir a obj.over aciona o método __set__ do descritor, passando o valor 7 como último argumento.

  5. Ler obj.over ainda invoca o método __get__ do descritor.

  6. Contorna o descritor, definindo um valor diretamente no obj.__dict__.

  7. Verifica se aquele valor está no obj.__dict__, sob a chave over.

  8. Entretanto, mesmo com um atributo de instância chamado over, o descritor Managed.over continua interceptando tentativas de ler obj.over.

23.3.2. Descritor dominante sem __get__

Propriedades e outros descritores dominantes, tal como os campos de modelo do Django, implementam tanto __set__ quanto __get__. Mas também é possível implementar apenas __set__, como vimos no Exemplo 2. Neste caso, apenas a escrita é controlada pelo descritor. Ler o descritor através de uma instância irá devolver o próprio objeto descritor, pois não há um __get__ para tratar daquele acesso. Se um atributo de instância de mesmo nome for criado com um novo valor, através de acesso direto ao __dict__ da instância, o método __set__ continuará interceptando tentativas posteriores de definir aquele atributo, mas a leitura do atributo vai simplesmente devolver o novo valor na instância, em vez de devolver o objeto descritor. Em outras palavras, o atributo de instância vai ocultar o descritor, mas apenas para leitura. Veja o Exemplo 10.

Exemplo 10. Descritor dominante sem __get__
>>> obj.over_no_get  # (1)
<__main__.OverridingNoGet object at 0x665bcc>
>>> Managed.over_no_get  # (2)
<__main__.OverridingNoGet object at 0x665bcc>
>>> obj.over_no_get = 7  # (3)
-> OverridingNoGet.__set__(<OverridingNoGet object>, <Managed object>, 7)
>>> obj.over_no_get  # (4)
<__main__.OverridingNoGet object at 0x665bcc>
>>> obj.__dict__['over_no_get'] = 9  # (5)
>>> obj.over_no_get  # (6)
9
>>> obj.over_no_get = 7  # (7)
-> OverridingNoGet.__set__(<OverridingNoGet object>, <Managed object>, 7)
>>> obj.over_no_get  # (8)
9
  1. Este descritor dominante não tem um método __get__, então ler obj.over_no_get obtém a instância do descritor a partir da classe.

  2. A mesma coisa acontece se obtivermos a instância do descritor diretamente da classe gerenciada.

  3. Tentar definir um valor para obj.over_no_get invoca o método __set__ do descritor.

  4. Como nosso __set__ não faz modificações, ler obj.over_no_get novamente obtém a instância do descritor na classe gerenciada.

  5. Percorrendo o __dict__ da instância para definir um atributo de instância chamado over_no_get.

  6. Agora aquele atributo de instância over_no_get oculta o descritor, mas apenas para leitura.

  7. Tentar atribuir um valor a obj.over_no_get continua passando pelo set do descritor.

  8. Mas, para leitura, aquele descritor é ocultado enquanto existir um atributo de instância de mesmo nome.

23.3.3. Descritor não dominante

Um descritor que não implementa __set__ é um descritor não dominante. Definir um atributo de instância com o mesmo nome vai ocultar o descritor, tornando-o incapaz de tratar aquele atributo naquela instância específica. Métodos e a @functools.cached_property são implementados como descritores não dominantes. O Exemplo 11 mostra a operação de um descritor não dominante.

Exemplo 11. Comportamento de um descritor não dominante
>>> obj = Managed()
>>> obj.non_over  # (1)
-> NonOverriding.__get__(<NonOverriding object>, <Managed object>, <class Managed>)
>>> obj.non_over = 7  # (2)
>>> obj.non_over  # (3)
7
>>> Managed.non_over  # (4)
-> NonOverriding.__get__(<NonOverriding object>, None, <class Managed>)
>>> del obj.non_over  # (5)
>>> obj.non_over  # (6)
-> NonOverriding.__get__(<NonOverriding object>, <Managed object>, <class Managed>)
  1. obj.non_over aciona o método __get__ do descritor, passando obj como segundo argumento.

  2. Managed.non_over é um descritor não dominante, então não há um __set__ para interferir com essa atribuição.

  3. O obj agora tem um atributo de instância chamado non_over, que oculta o atributo do descritor de mesmo nome na classe Managed.

  4. O descritor Managed.non_over ainda está lá, e intercepta esse acesso através da classe.

  5. Se o atributo de instância non_over for excluído…​

  6. …​então ler obj.non_over encontra o método __get__ do descritor; mas observe que o segundo argumento é a instância gerenciada.

Nos exemplos anteriores, vimos várias atribuições a um atributo de instância com nome igual ao do descritor, com resultados diferentes dependendo da presença ou não de um método __set__ no descritor.

A definição de atributos na classe não pode ser controlada por descritores ligados à mesma classe. Em especial, isso significa que os próprios atributos do descritor podem ser danificados por atribuições à classe, como explicado na próxima seção.

23.3.4. Sobrescrevendo um descritor em uma classe

Independente do descritor ser ou não dominante, ele pode ser sobrescrito por uma atribuição à classe. Isso é uma técnica de monkey-patching mas, no Exemplo 12, os descritores são substituídos por números inteiros, algo que certamente quebraria a lógica de qualquer classe que dependesse dos descritores para seu funcionamento correto.

Exemplo 12. Qualquer descritor pode ser sobrescrito na própria classe
>>> obj = Managed()  # (1)
>>> Managed.over = 1  # (2)
>>> Managed.over_no_get = 2
>>> Managed.non_over = 3
>>> obj.over, obj.over_no_get, obj.non_over  # (3)
(1, 2, 3)
  1. Cria uma nova instância para testes posteriores.

  2. Sobrescreve os atributos dos descritores na classe.

  3. Os descritores realmente desapareceram.

O Exemplo 12 expõe outra assimetria entre a leitura e a escrita de atributos: apesar da leitura de um atributo de classe poder ser controlada por um __get__ de um descritor ligado à classe gerenciada, a escrita em um atributo de classe não pode ser tratado por um __set__ de um descritor ligado à mesma classe.

👉 Dica

Para controlar a escrita a atributos em uma classe, é preciso associar descritores à classe da classe—em outras palavras, à metaclasse. Por default, a metaclasse de classes definidas pelo usuário é type, e não podemos acrescentar atributos a type. Mas, no Capítulo 24, vamos criar nossas próprias metaclasses.

Vamos ver agora como descritores são usados para implementar métodos no Python.

23.4. Métodos são descritores

Uma função dentro de uma classe se torna um método vinculado quando invocada em uma instância, porque todas as funções definidas pelo usuário possuem um método __get__, e portanto operam como descritores quando associados a uma classe. O Exemplo 13 demonstra a leitura do método spam, da classe Managed, apresentada no Exemplo 8.

Exemplo 13. Um método é um descritor não dominante
>>> obj = Managed()
>>> obj.spam  # (1)
<bound method Managed.spam of <descriptorkinds.Managed object at 0x74c80c>>
>>> Managed.spam  # (2)
<function Managed.spam at 0x734734>
>>> obj.spam = 7  # (3)
>>> obj.spam
7
  1. Ler de obj.spam obtém um objeto método vinculado.

  2. Mas ler de Managed.spam obtém uma função.

  3. Atribuir um valor a obj.spam oculta o atributo de classe, tornando o método spam inacessível a partir da instância obj.

Funções não implementam __set__, portanto são descritores não dominantes, como mostra a última linha do Exemplo 13.

A outra lição fundamental do Exemplo 13 é que obj.spam e Managed.spam devolvem objetos diferentes. Como de hábito com descritores, o __get__ de uma função devolve uma referência para a própria função quando o acesso ocorre através da classe gerenciada. Mas quando o acesso vem através da instância, o __get__ da função devolve um objeto método vinculado: um invocável que envolve a função e vincula a instância gerenciada (no exemplo, obj) ao primeiro argumento da função (isto é, self), como faz a função functools.partial (que vimos na Seção 7.8.2). Para um entendimento mais profundo desse mecanismo, dê uma olhada no Exemplo 14.

Exemplo 14. method_is_descriptor.py: uma classe Text, derivada de UserString
import collections


class Text(collections.UserString):

    def __repr__(self):
        return 'Text({!r})'.format(self.data)

    def reverse(self):
        return self[::-1]

Vamos então investigar o método Text.reverse. Veja o Exemplo 15.

Exemplo 15. Experimentos com um método
    >>> word = Text('forward')
    >>> word  # (1)
    Text('forward')
    >>> word.reverse()  # (2)
    Text('drawrof')
    >>> Text.reverse(Text('backward'))  # (3)
    Text('drawkcab')
    >>> type(Text.reverse), type(word.reverse)  # (4)
    (<class 'function'>, <class 'method'>)
    >>> list(map(Text.reverse, ['repaid', (10, 20, 30), Text('stressed')]))  # (5)
    ['diaper', (30, 20, 10), Text('desserts')]
    >>> Text.reverse.__get__(word)  # (6)
    <bound method Text.reverse of Text('forward')>
    >>> Text.reverse.__get__(None, Text)  # (7)
    <function Text.reverse at 0x101244e18>
    >>> word.reverse  # (8)
    <bound method Text.reverse of Text('forward')>
    >>> word.reverse.__self__  # (9)
    Text('forward')
    >>> word.reverse.__func__ is Text.reverse  # (10)
    True
  1. O repr de uma instância de Text se parece com uma chamada ao construtor de Text que criaria uma instância idêntica.

  2. O método reverse devolve o texto escrito de trás para frente.

  3. Um método invocado na classe funciona como uma função.

  4. Observe os tipos diferentes: uma function e um method.

  5. Text.reverse opera como uma função, mesmo ao trabalhar com objetos que não são instâncias de Text.

  6. Toda função é um descritor não dominante. Invocar seu __get__ com uma instância obtém um método vinculado a aquela instância.

  7. Invocar o __get__ da função com None como argumento instance obtém a própria função.

  8. A expressão word.reverse na verdade invoca Text.reverse.__get__(word), devolvendo o método vinculado.

  9. O objeto método vinculado tem um atributo __self__, contendo uma referência à instância na qual o método foi invocado.

  10. O atributo __func__ do método vinculado é uma referência à função original, ligada à classe gerenciada.

O objeto método vinculado contém um método __call__, que trata a invocação em si. Este método chama a função original, referenciada em __func__, passando o atributo __self__ do método como primeiro argumento. É assim que funciona a vinculação implícita do argumento self convencional.

O modo como funções são transformadas em métodos vinculados é um exemplo perfeito de como descritores são usados como infraestrutura da linguagem.

Após este mergulho profundo no funcionamento de descritores e métodos, vamos repassar alguns conselhos práticos sobre seu uso.

23.5. Dicas para o uso de descritores

A lista a seguir trata de algumas consequências práticas das características dos descritores descritas acima:

Use property para manter as coisas simples

A classe embutida property cria descritores dominantes, implementando __set__ e __get__, mesmo se um método setter não for definido.[337] O __set__ default de uma propriedade gera um AttributeError: can’t set attribute (AttributeError: não é permitido definir o atributo), então uma propriedade é a forma mais fácil de criar um atributo somente para leitura, evitando o problema descrito a seguir.

Descritores somente para leitura exigem um __set__

Se você usar uma classe descritora para implementar um atributo somente para leitura, precisa lembrar de programar tanto __get__ quanto __set__. Caso contrário, definir um atributo com o mesmo nome em uma instância vai ocultar o descritor. O método __set__ de um atributo somente para leitura deve apenas gerar um AttributeError com uma mensagem adequada.[338]

Descritores de validação podem funcionar apenas com __set__

Em um descritor projetado apenas para validação, o método __set__ deve verificar o argumento value recebido e, se ele for válido, atualizar o __dict__ da instância diretamente, usando o nome da instância do descritor como chave. Dessa forma, ler o atributo de mesmo nome a partir da instância será tão rápido quanto possível, pois não vai precisar de um __get__. Veja o código no Exemplo 3.

Caching pode ser feito de forma eficiente apenas com __get__

Se você escrever apenas o método __get__, cria um descritor não dominante. Eles são úteis para executar alguma computação custosa e então armazenar o resultado, definindo um atributo com o mesmo nome na instância[339]. O atributo de mesmo nome na instância vai ocultar o descritor, daí acessos subsequentes a aquele atributo vão buscá-lo diretamente no __dict__ da instância, sem acionar mais o __get__ do descritor. O decorador @functools.cached_property na verdade produz um descritor não dominante.

Métodos não especiais pode ser ocultados por atributos de instância

Como funções e métodos implementam apenas __get__, eles são descritores não dominantes. Uma atribuição simples, como my_obj.the_method = 7, significa que acessos posteriores a the_method através daquela instância irão obter o número 7—sem afetar a classe ou outras instâncias. Essa questão, entretanto, não interfere com os métodos especiais. O interpretador só procura métodos especiais na própria classe. Em outras palavras, repr(x) é executado como x.__class__.__repr__(x), então um atributo __repr__, definido em x, não tem qualquer efeito em repr(x). Pela mesma razão, a existência de um atributo chamado __getattr__ em uma instância não vai subverter o algoritmo normal de acesso a atributos.

O fato de métodos não especiais poderem ser sobrepostos tão facilmente pode soar frágil e propenso a erros. Mas eu, pessoalmente, em mais de 20 anos programando em Python, nunca tive problemas com isso. Por outro lado, se você estiver criando muitos atributos dinâmicos, onde os nomes dos atributos vêm de dados que você não controla (como fizemos na parte inicial desse capítulo), então você precisa estar atenta para isso, e talvez implementar alguma filtragem ou reescrita (escaping) dos nomes dos atributos dinâmicos, para preservar sua sanidade.

✒️ Nota

A classe FrozenJSON no Exemplo 5 está a salvo de atributos de instância ocultando métodos, pois seus únicos métodos são métodos especiais e o método de classe build. Métodos de classe são seguros desde que sejam sempre acessados através da classe, como fiz com FrozenJSON.build no Exemplo 5—mais tarde substituído por __new__ no Exemplo 6. As classes Record e Event, apresentadas na Seção 22.3, também estão a salvo: elas implementam apenas métodos especiais, métodos estáticos e propriedades. Propriedades são descritores dominantes, então não são ocultados por atributos de instância.

Para encerrar esse capítulo, vamos falar de dois recursos que vimos com as propriedades, mas não no contexto dos descritores: documentação e o tratamento de tentativas de excluir um atributo gerenciado.

23.6. Docstrings de descritores e a sobreposição de exclusão

A docstring de uma classe descritora é usada para documentar todas as instâncias do descritor na classe gerenciada. O Figura 4 mostra as telas de ajuda para a classe LineItem com os descritores Quantity e NonBlank, do Exemplo 6 e do Exemplo 7.

Isso é um tanto insatisfatório. No caso de LineItem, seria bom acrescentar, por exemplo, a informação de que weight deve ser expresso em quilogramas. Isso seria trivial com propriedades, pois cada propriedade controla um atributo gerenciado específico. Mas com descritores, a mesma classe descritora Quantity é usada para weight e price.[340]

O segundo detalhe que discutimos com propriedades, mas não com descritores, é o tratamento de tentativas de apagar um atributo gerenciado. Isso pode ser feito pela implementação de um método __delete__ juntamente com (ou em vez de) os habituais __get__ e/ou __set__ na classe descritora. Omiti deliberadamente falar de __delete__, porque acredito que seu uso no mundo real é raro. Se você precisar disso, por favor consulte a seção "Implementando descritores" na documentação do Modelo de dados do Python. Escrever um classe descritora boba com __delete__ fica como exercício para a leitora ociosa.

Capturas de tela do console do Python com a ajuda de descritores.
Figura 4. Capturas de tela do console do Python após os comandos help(LineItem.weight) e help(LineItem).

23.7. Resumo do capítulo

O primeiro exemplo deste capítulo foi uma continuação dos exemplos LineItem do Capítulo 22. No Exemplo 2, substituímos propriedades por descritores. Vimos que um descritor é uma classe que fornece instâncias, que são instaladas como atributos na classe gerenciada. Discutir esse mecanismo exigiu uma terminologia especial, apresentando termos tais como instância gerenciada e atributo de armazenamento.

Na Seção 23.2.2, removemos a exigência de descritores Quantity serem declarados com um storage_name explícito, um requisito redundante e propenso a erros. A solução foi implementar o método especial __set_name__ em Quantity, para armazenar o nome da propriedade gerenciada como self.storage_name.

A Seção 23.2.3 mostrou como criar uma subclasse de uma classe descritora abstrata, para compartilhar código ao programar descritores especializados com alguma funcionalidade em comum.

Examinamos então os comportamentos diferentes de descritores, fornecendo ou omitindo o método __set__, criando uma distinção fundamental entre descritores dominantes e não dominantes, também conhecidos como descritores de dados e sem dados. Por meio de testes detalhados, revelamos quando os descritores estão no controle, e quando são ocultados, contornados ou sobrescritos.

Em seguida, estudamos uma categoria específica de descritores não dominantes: métodos. Experimentos no console revelaram como uma função associada ao uma classe se torna um método ao ser acessada através de uma instância, se valendo do protocolo descritor.

Para concluir o capítulo, a Seção 23.5 trouxe dicas práticas, e a Seção 23.6 forneceu um rápido olhar sobre como documentar descritores.

✒️ Nota

Como observado na Seção 23.1, vários exemplos deste capítulo se tornaram muito mais simples graças ao método especial __set_name__ do protocolo descritor, adicionado no Python 3.6. Isso é evolução da linguagem!

23.8. Leitura complementar

Além da referência obrigatória ao capítulo "Modelo de dados", o "HowTo - Guia de descritores", de Raymond Hettinger, é um recurso valioso—​e parte da coleção de HOWTOS na documentação oficial do Python.

Como sempre, em se tratando de assuntos relativos ao modelo de objetos do Python, o Python in a Nutshell, 3ª ed. (O’Reilly), de Martelli, Ravenscroft, e Holden é competente e objetivo. Martelli também tem uma apresentação chamada "Python’s Object Model" (O Modelo de Objetos do Python), tratando com profundidade de propriedades e descritores (veja os slides (EN) e o video (EN)).

⚠️ Aviso

Cuidado, qualquer tratamento de descritores escrito ou gravado antes da PEP 487 ser adotada, em 2016, corre o risco de conter exemplos desnecessariamente complicados hoje, pois __set_name__ não era suportado nas versões do Python anteriores a 3.6.

Para mais exemplos práticos, o Python Cookbook, 3ª ed., de David Beazley e Brian K. Jones (O’Reilly), traz muitas receitas ilustrando descritores, dentre as quais quero destacar "6.12. Reading Nested and Variable-Sized Binary Structures" (Lendo Estruturas Binárias Aninhadas e de Tamanho Variável), "8.10. Using Lazily Computed Properties" (Usando Propriedades Computadas de Forma Preguiçosa), "8.13. Implementing a Data Model or Type System" (Implementando um Modelo de Dados ou um Sistema de Tipos) e "9.9. Defining Decorators As Classes" (Definindo Decoradores como Classes). Essa última receita trata das questões profundas envolvidas na interação entre decoradores de função, descritores e métodos, e de como um decorador de função implementado como uma classe, com __call__, também precisa implementar __get__ se quiser funcionar com métodos de decoração e também com funções.

Ponto de vista

O projeto de self

A exigência de declarar self explicitamente como o primeiro argumento em métodos foi uma decisão de projeto controversa no Python. Após 23 anos usando a linguagem, já estou acostumado com isso. Acho que essa decisão é um exemplo de "pior é melhor" (worse is better): a filosofia de projeto descrita pelo cientista da computação Richard P. Gabriel em "The Rise of Worse is Better" (A Ascenção do Pior é Melhor) (EN). A primeira prioridade dessa filosofia é "simplicidade", que Gabriel apresenta assim:

O projeto deve ser simples, tanto na implementação quanto na interface. É mais importante que a implementação seja simples, do que a interface. A simplicidade é a consideração mais importante em um projeto.

O self explícito do Python incorpora essa filosofia de projeto. A implementação é simples—até mesmo elegante—à custas da interface do usuário: uma assinatura de método como def zfill(self, width): não corresponde, visualmente, à invocação label.zfill(8).

O Modula-3 introduziu essa convenção com o mesmo identificador, self. Mas há uma diferença crucial: no Modula-3, interfaces são declaradas separadamente de sua implementação, e na declaração da interface o argumento self é omitido. Então, da perspectiva do usuário, um método aparece em uma declaração de interface com os mesmos parâmetros explícitos usados para invocá-lo.

Ao longo do tempo, as mensagens de erro do Python relacionadas a argumentos de métodos se tornaram mais claras. Em um método definido pelo usuário com um argumento além de self, se o usuário invocasse obj.meth(), o Python 2.7 gerava:

TypeError: meth() takes exactly 2 arguments (1 given)
("TypeError: meth() recebe exatamente 2 argumentos (1 passado)"")

No Python 3, a confusa contagem de argumentos não é mencionada, e o argumento ausente é nomeado:

TypeError: meth() missing 1 required positional argument: 'x'
("TypeError: 1 argumento posicional obrigatório faltando em meth(): 'x'")

Além do uso de self como um argumento explícito, a exigência de qualificar cada acesso a atributos de instância com self também é criticada. Veja, por exemplo, o famoso post "Python Warts" (As verrugas do Python) de A. M. Kuchling (em archived (EN)); o próprio Kuchling não se incomoda muito com o qualificador self, mas ele o menciona—provavelmente ecoando opiniões do grupo comp.lang.python. Pessoalmente não me importo em digitar o qualificador self: é bom para distinguir variáveis locais de atributos. Minha questão é com o uso de self em comandos def.

Quem estiver triste com o self explícito do Python pode se sentir bem melhor após considerar a semântica desconcertante (EN) do this implícito em JavaScript. Guido tinha algumas boas razões para fazer self funcionar como funciona, e ele escreveu sobre elas em "Adding Support for User-Defined Classes" (Adicionando Suporte a Classes Definidas pelo Usuário), um post em seu blog, The History of Python ("A História do Python").

24. Metaprogramação de classes

Todo mundo sabe que depurar um programa é duas vezes mais difícil que escrever o mesmo programa. Mas daí, se você der tudo de si ao escrever o programa, como vai conseguir depurá-lo?[341]

— Brian W. Kernighan and P. J. Plauger
The Elements of Programming Style

Metaprogramação de classes é a arte de criar ou personalizar classes durante a execução do programa. Em Python, classes são objetos de primeira classe, então uma função pode ser usada para criar uma nova classe a qualquer momento, sem usar a palavra-chave class.

Decoradores de classes também são funções, mas são projetados para inspecionar, modificar ou mesmo substituir a classe decorada por outra classe. Por fim, metaclasses são a ferramenta mais avançada para metaprogramação de classes: elas permitem a criação de categorias de classes inteiramente novas, com características especiais, tais como as classes base abstratas, que já vimos anteriormente.

Metaclasses são poderosas, mas difíceis de justificar na prática, e ainda mais difíceis de entender direito. Decoradores de classe resolvem muitos dos mesmos problemas, e são mais fáceis de compreender. Mais ainda, o Python 3.6 implementou a PEP 487—Simpler customization of class creation (PEP 487—Uma personalização mais simples da criação de classes), fornecendo métodos especiais para tarefas que antes exigiam metaclasses ou decoradores de classe.[342]

Este capítulo apresenta as técnicas de metaprogramação de classes em ordem ascendente de complexidade.

⚠️ Aviso

Esse é um tópico empolgante, e é fácil se deixar levar pelo entusiasmo. Então preciso deixar aqui esse conselho.

Em nome da legibilidade e facilidade de manutenção, você provavelmente deveria evitar as técnicas descritas neste capítulo em aplicações.

Por outro lado, caso você queira escrever o próximo framework formidável do Python, essas são suas ferramentas de trabalho.

24.1. Novidades nesse capítulo

Todo o código do capítulo "Metaprogramação de Classes" da primeira edição do Python Fluente ainda funciona corretamente. Entretanto, alguns dos exemplos antigos não representam mais as soluções mais simples, tendo em vista os novos recursos surgidos desde o Python 3.6.

Substituí aqueles exemplos por outros, enfatizando os novos recursos de metaprogramação ou acrescentando novos requisitos para justificar o uso de técnicas mais avançadas. Alguns destes novos exemplos se valem de dicas de tipo para fornecer fábricas de classes similares ao decorador @dataclass e a typing.NamedTuple.

A Seção 24.10 é nova, trazendo algumas considerações de alto nível sobre a aplicabilidade das metaclasses.

👉 Dica

Algumas das melhores refatorações envolvem a remoção de código tornado redundante por formas novas e e mais simples de resolver o mesmo problema. Isso se aplica tanto a código em produção quando a livros.

Vamos começar revisando os atributos e métodos definidos no Modelo de Dados do Python para todas as classes.

24.2. Classes como objetos

Como acontece com a maioria das entidades programáticas do Python, classes também são objetos. Toda classe tem alguns atributos definidos no Modelo de Dados do Python, documentados na seção "4.13. Atributos Especiais" do capítulo "Tipos Embutidos" da Biblioteca Padrão do Python. Três destes atributos já apareceram várias vezes no livro: __class__, __name__, and __mro__. Outros atributos de classe padrão são:

cls.__bases__

A tupla de classes base da classe.

cls.__qualname__

O nome qualificado de uma classe ou função, que é um caminho pontuado, desde o escopo global do módulo até a definição da classe. Isso é relevante quando a classe é definida dentro de outra classe. Por exemplo, em um modelo de classe Django, tal como Ox (EN), há uma classe interna chamada Meta. O __qualname__ de Meta é Ox.Meta, mas seu __name__ é apenas Meta. A especificação para este atributo está na PEP 3155—​Qualified name for classes and functions (PEP 3155—Nome qualificado para classes e funções) (EN).

cls.__subclasses__()

Este método devolve uma lista das subclasses imediatas da classe. A implementação usa referências fracas, para evitar referências circulares entre a superclasse e suas subclasses—que mantêm uma referência forte para a superclasse em seus atributos __bases__. O método lista as subclasses na memória naquele momento. Subclasses em módulos ainda não importados não aparecerão no resultado.

cls.mro()

O interpretador invoca este método quando está criando uma classe, para obter a tupla de superclasses armazenada no atributo __mro__ da classe. Uma metaclasse pode sobrepor este método, para personalziar a ordem de resolução de métodos da classe em construção.

👉 Dica

Nenhum dos atributos mencionados nesta seção aparecem na lista devolvida pela função dir(…).

Agora, se classe é um objeto, o que é a classe de uma classe?

24.3. type: a fábrica de classes embutida

Nós normalmente pensamos em type como uma função que devolve a classe de um objeto, porque é isso que type(my_object) faz: devolve my_object.class.

Entretanto, type é uma classe que cria uma nova classe quando invocada com três argumentos.

Considere essa classe simples:

class MyClass(MySuperClass, MyMixin):
    x = 42

    def x2(self):
        return self.x * 2

Usando o construtor type, podemos criar MyClass durante a execução, com o seguinte código:

MyClass = type('MyClass',
               (MySuperClass, MyMixin),
               {'x': 42, 'x2': lambda self: self.x * 2},
          )

Aquela chamada a type é funcionalmente equivalente ao bloco sob a instrução class MyClass… anterior.

Quando o Python lê uma instrução class, invoca type para construir um objeto classe com os parâmetros abaixo:

name

O identificador que aparece após a palavra-chave class, por exemplo, MyClass.

bases

A tupla de superclasses passadas entre parênteses após o identificador da classe, ou (object,), caso nenhuma superclasse seja mencionada na instrução class.

dict

Um mapeamento entre nomes de atributo e valores. Invocáveis se tornam métodos, como vimos na Seção 23.4. Outros valores se tornam atributos de classe.

✒️ Nota

O construtor type aceita argumentos nomeados opcionais, que são ignorados por type, mas que são passados como recebidos para __init_subclass__, que deve consumi-los. Vamos estudar esse método especial na Seção 24.5, mas não vou tratar do uso de argumentos nomeados. Para saber mais sobre isso, por favor leia a PEP 487—Simpler customization of class creation (PEP 487—Uma personalização mais simples da criação de classes) (EN).

A classe type é uma metaclasse: uma classe que cria classes. Em outras palavras, instâncias da classe type são classes. A biblioteca padrão contém algumas outras metaclasses, mas type é a default:

>>> type(7)
<class 'int'>
>>> type(int)
<class 'type'>
>>> type(OSError)
<class 'type'>
>>> class Whatever:
...     pass
...
>>> type(Whatever)
<class 'type'>

Vamos criar metaclasses personalizadas na Seção 24.8.

Agora, vamos usar a classe embutida type para criar uma função que constrói classes.

24.4. Uma função fábrica de classes

A biblioteca padrão contém uma função fábrica de classes que já apareceu várias vezes aqui: collections.namedtuple. No Capítulo 5 também vimos typing.NamedTuple e @dataclass. Todas essas fábricas de classe usam técnicas que veremos neste capítulo.

Vamos começar com uma fábrica muito simples, para classes de objetos mutáveis—a substituta mais simples possível de @dataclass.

Suponha que eu esteja escrevendo uma aplicação para uma pet shop, e queira armazenar dados sobre cães como registros simples. Mas não quero escrever código padronizado como esse:

class Dog:
    def __init__(self, name, weight, owner):
        self.name = name
        self.weight = weight
        self.owner = owner

Chato…​ cada nome de campo aparece três vezes, e essa repetição sequer nos garante um bom repr:

>>> rex = Dog('Rex', 30, 'Bob')
>>> rex
<__main__.Dog object at 0x2865bac>

Inspirados por collections.namedtuple, vamos criar uma record_factory, que cria classes simples como Dog em tempo real. O Exemplo 1 mostra como ela deve funcionar.

Exemplo 1. Testando record_factory, uma fábrica de classes simples
    >>> Dog = record_factory('Dog', 'name weight owner')  # (1)
    >>> rex = Dog('Rex', 30, 'Bob')
    >>> rex  # (2)
    Dog(name='Rex', weight=30, owner='Bob')
    >>> name, weight, _ = rex  # (3)
    >>> name, weight
    ('Rex', 30)
    >>> "{2}'s dog weighs {1}kg".format(*rex)  # (4)
    "Bob's dog weighs 30kg"
    >>> rex.weight = 32  # (5)
    >>> rex
    Dog(name='Rex', weight=32, owner='Bob')
    >>> Dog.__mro__  # (6)
    (<class 'factories.Dog'>, <class 'object'>)
  1. A fábrica pode ser chamada como namedtuple: nome da classe, seguido dos nomes dos atributos separados por espaços, em um única string.

  2. Um repr agradável.

  3. Instâncias são iteráveis, então elas podem ser convenientemente desempacotadas em uma atribuição…​

  4. …​ou quando são passadas para funções como format.

  5. Uma instância do registro é mutável.

  6. A classe recém-criada herda de object—não tem qualquer relação com nossa fábrica.

O código para record_factory está no Exemplo 2.[343]

Exemplo 2. record_factory.py: uma classe fábrica simples
from typing import Union, Any
from collections.abc import Iterable, Iterator

FieldNames = Union[str, Iterable[str]]  # (1)

def record_factory(cls_name: str, field_names: FieldNames) -> type[tuple]:  # (2)

    slots = parse_identifiers(field_names)  # (3)

    def __init__(self, *args, **kwargs) -> None:  # (4)
        attrs = dict(zip(self.__slots__, args))
        attrs.update(kwargs)
        for name, value in attrs.items():
            setattr(self, name, value)

    def __iter__(self) -> Iterator[Any]:  # (5)
        for name in self.__slots__:
            yield getattr(self, name)

    def __repr__(self):  # (6)
        values = ', '.join(f'{name}={value!r}'
            for name, value in zip(self.__slots__, self))
        cls_name = self.__class__.__name__
        return f'{cls_name}({values})'

    cls_attrs = dict(  # (7)
        __slots__=slots,
        __init__=__init__,
        __iter__=__iter__,
        __repr__=__repr__,
    )

    return type(cls_name, (object,), cls_attrs)  # (8)


def parse_identifiers(names: FieldNames) -> tuple[str, ...]:
    if isinstance(names, str):
        names = names.replace(',', ' ').split()  # (9)
    if not all(s.isidentifier() for s in names):
        raise ValueError('names must all be valid identifiers')
    return tuple(names)
  1. O usuário pode fornecer os nomes dos campos como uma string única ou como um iterável de strings.

  2. Aceita argumentos como os dois primeiros de collections.namedtuple; devolve type—isto é, uma classe que se comporta como uma tuple.

  3. Cria uma tupla de nomes de atributos; esse será o atributo __slots__ da nova classe.

  4. Essa função se tornará o método __init__ na nova classe. Ela aceita argumentos posicionais e/ou nomeados.[344]

  5. Produz os valores dos campos na ordem dada por __slots__.

  6. Produz um repr agradável, iterando sobre __slots__ e self.

  7. Monta um dicionário de atributos de classe.

  8. Cria e devolve a nova classe, invocando o construtor de type.

  9. Converte names separados por espaços ou vírgulas em uma lista de str.

O Exemplo 2 é a primeira vez que vemos type em uma dica de tipo. Se a anotação fosse apenas → type, significaria que record_factory devolve uma classe—e isso estaria correto. Mas a anotação → type[tuple] é mais precisa: indica que a classe devolvida será uma subclasse de tuple.

A última linha de record_factory no Exemplo 2 cria uma classe cujo nome é o valor de cls_name, com object como sua única classe base imediata, e um espaço de nomes carregado com __slots__, __init__, __iter__, e __repr__, sendo os útimos três métodos de instância.

Poderíamos ter dado qualquer outro nome ao atributo de classe __slots__, mas então teríamos que implementar __setattr__ para validar os nomes dos atributos em uma atribuição, porque em nossas classes similares a registros queremos que o conjunto de atributos seja sempre o mesmo e na mesma ordem. Entretanto, lembre-se que a principal característica de __slots__ é economizar memória quando estamos lidando com milhões de instâncias, e que usar __slots__ traz algumas desvantagens, discutidas na Seção 11.11.

⚠️ Aviso

Instâncias de classes criadas por record_factory não são serializáveis—​isto é, elas não podem ser exportadas com a função dump do módulo pickle. Resolver este problema está além do escopo desse exemplo, que tem por objetivo mostrar a classe type funcionando em um caso de uso simples. Para uma solução completa, estude o código-fonte de collections.namedtuple; procure pela palavra "pickling".

Vamos ver agora como emular fábricas de classes mais modernas, como typing.NamedTuple, que recebe uma classe definida pelo usuário, escrita com o comando class, e a melhora automaticamente, acrescentando funcionalidade.

24.5. Apresentando __init_subclass__

Tanto __init_subclass__ quanto __set_name__ foram propostos na PEP 487—Simpler customization of class creation (PEP 487—Uma personalização mais simples da criação de classes). Falamos pela primeira vez do método especial para descritores __set_name__ na Seção 23.2.2. Agora vamos estudar __init_subclass__.

No Capítulo 5, vimos como typing.NamedTuple e @dataclass permitem a programadores usarem a instrução class para especificar atributos para uma nova classe, que então é aperfeiçoada pela fábrica de classes com a adição automática de métodos essenciais, tais como __init__, __repr__, __eq__, etc.

Ambas as fábricas de classes leem as dicas de tipo na instrução class do usuário para aperfeiçoar a classe. Essas dicas de tipo também permitem que verificadores de tipo estáticos validem código que define ou lê aqueles atributos. Entretanto, NamedTuple e @dataclass não se valem das dicas de tipo para validação de atributos durante a execução. A classe Checked, no próximo exemplo, faz isso.

✒️ Nota

Não é possível suportar toda dica de tipo estática concebível para verificação de tipo durante a execução, e possivelmente essa é a razão para typing.NamedTuple e @dataclass sequer tentarem. Entretanto, algums tipos que são também classes concretas podem ser usados com Checked. Isso inclui tipos simples, usados com frequência para o conteúdo de campos, tais como str, int, float, e bool, bem como listas destes tipos.

O Exemplo 3 mostra como usar Checked para criar uma classe Movie.

Exemplo 3. initsub/checkedlib.py: doctest para a criação de uma subclasse Movie de Checked
    >>> class Movie(Checked):  # (1)
    ...     title: str  # (2)
    ...     year: int
    ...     box_office: float
    ...
    >>> movie = Movie(title='The Godfather', year=1972, box_office=137)  # (3)
    >>> movie.title
    'The Godfather'
    >>> movie  # (4)
    Movie(title='The Godfather', year=1972, box_office=137.0)
  1. Movie herda de Checked—que definiremos mais tarde, no Exemplo 5.

  2. Cada atributo é anotado com um construtor. Aqui usei tipos embutidos.

  3. Instâncias de Movie devem ser criadas usando argumentos nomeados.

  4. Em troca, temos um __repr__ agradável.

Os construtores usados como dicas de tipo podem ser qualquer invocável que receba zero ou um argumento, e devolva um valor adequado ao tipo do campo pretendido ou rejeite o argumento, gerando um TypeError ou um ValueError.

Usar tipos embutidos para as anotações no Exemplo 3 significa que os valores devem aceitáveis pelo construtor do tipo. Para int, isso significa qualquer x tal que int(x) devolva um int. Para str, qualquer coisa serve durante a execução, pois str(x) funciona com qualquer x no Python.[345]

Quando chamado sem argumentos, o construtor deve devolver um valor default de seu tipo.[346]

Esse é o comportamento padrão de construtores embutidos no Python:

>>> int(), float(), bool(), str(), list(), dict(), set()
(0, 0.0, False, '', [], {}, set())

Em uma subclasse de Checked como Movie, parâmetros ausentes criam instâncias com os valores default devolvidos pelos construtores dos campos. Por exemplo:

    >>> Movie(title='Life of Brian')
    Movie(title='Life of Brian', year=0, box_office=0.0)

Os construtores são usados para validação durante a instanciação, e quando um atributo é definido diretamente em uma instância:

    >>> blockbuster = Movie(title='Avatar', year=2009, box_office='billions')
    Traceback (most recent call last):
      ...
    TypeError: 'billions' is not compatible with box_office:float
    >>> movie.year = 'MCMLXXII'
    Traceback (most recent call last):
      ...
    TypeError: 'MCMLXXII' is not compatible with year:int
⚠️ Aviso
Subclasses de Checked e a verificação estática de tipos

Em um arquivo de código fonte .py contendo uma instância movie da classe Movie, como definida no Exemplo 3, o Mypy marca essa atribuição como um erro de tipo:

movie.year = 'MCMLXXII'

Entretanto, o Mypy não consegue detectar erros de tipo nessa chamada ao construtor:

blockbuster = Movie(title='Avatar', year='MMIX')

Isso porque Movie herda Checked.__init__, e a assinatura daquele método deve aceitar qualquer argumento nomeado, para suportar classes arbitrárias definidas pelo usuário.

Por outro lado, se você declarar um campo de uma subclasse de Checked com a dica de tipo list[float], o Mypy pode sinalizar atribuições de listas com tipos incompatíveis, mas Checked vai ignorar o parâmetro de tipo e tratá-lo como igual a list.

Vamos ver agora a implementação de checkedlib.py. A primeira classe é o descritor Field, como mostra o Exemplo 4.

Exemplo 4. initsub/checkedlib.py: a classe descritora Field
from collections.abc import Callable  # (1)
from typing import Any, NoReturn, get_type_hints


class Field:
    def __init__(self, name: str, constructor: Callable) -> None:  # (2)
        if not callable(constructor) or constructor is type(None):  # (3)
            raise TypeError(f'{name!r} type hint must be callable')
        self.name = name
        self.constructor = constructor

    def __set__(self, instance: Any, value: Any) -> None:
        if value is ...:  # (4)
            value = self.constructor()
        else:
            try:
                value = self.constructor(value)  # (5)
            except (TypeError, ValueError) as e:  # (6)
                type_name = self.constructor.__name__
                msg = f'{value!r} is not compatible with {self.name}:{type_name}'
                raise TypeError(msg) from e
        instance.__dict__[self.name] = value  # (7)
  1. Lembre-se, desde o Python 3.9, o tipo Callable para anotações é a ABC em collections.abc, e não o descontinuado typing.Callable.

  2. Essa é a dica de tipo Callable mínima; o parâmetro de tipo e o tipo devolvido para constructor são ambos implicitamente Any.

  3. Para verificação durante a execução, usamos o embutido callable.[347] O teste contra type(None) é necessário porque o Python entende None em um tipo como NoneType, a classe de None (e portanto invocável), mas esse é um construtor inútil, que apenas devolve None.

  4. Se Checked.__init__ definir value como …​ (o objeto embutido Ellipsis), invocamos o construtor sem argumentos.

  5. Caso contrário, invocamos o constructor com o value dado.

  6. Se constructor gerar qualquer dessas exceções, geramos um TypeError com uma mensagem útil, incluindo os nomes do campo e do construtor; por exemplo, 'MMIX' não é compatível com year:int.

  7. Se nenhuma exceção for gerada, o value é armazenado no instance.__dict__.

Em __set__, precisamos capturar TypeError e ValueError, pois os construtores embutidos podem gerar qualquer dos dois, dependendo do argumento. Por exemplo, float(None) gera um TypeError, mas float('A') gera um ValueError. Por outro lado, float('8') não causa qualquer erro, e devolve 8.0. E assim eu aqui declaro que, nesse exemplo simples, este um recurso, não um bug.

👉 Dica

Na Seção 23.2.2, vimos o conveniente método especial __set_name__ para descritores. Não precisamos disso na classe Field, porque os descritores não são instanciados no código-fonte cliente; o usuário declara tipos que são construtores, como visto na classe Movie (no Exemplo 3). Em vez disso, as instâncias do descritor Field são criadas durante a execução, pelo método Checked.__init_subclass__, que veremos no Exemplo 5.

Vamos agora nos concentrar na classe Checked, que dividi em duas listagens. O Exemplo 5 mostra a parte inicial da classe, incluindo os métodos mais importantes para esse exemplo. O restante dos métodos está no Exemplo 6.

Exemplo 5. initsub/checkedlib.py: os métodos mais importante da classe Checked
class Checked:
    @classmethod
    def _fields(cls) -> dict[str, type]:  # (1)
        return get_type_hints(cls)

    def __init_subclass__(subclass) -> None:  # (2)
        super().__init_subclass__()           # (3)
        for name, constructor in subclass._fields().items():   # (4)
            setattr(subclass, name, Field(name, constructor))  # (5)

    def __init__(self, **kwargs: Any) -> None:
        for name in self._fields():             # (6)
            value = kwargs.pop(name, ...)       # (7)
            setattr(self, name, value)          # (8)
        if kwargs:                              # (9)
            self.__flag_unknown_attrs(*kwargs)  # (10)
  1. Escrevi este método de classe para ocultar a chamada a typing.get_type_hints do resto da classe. Se precisasse suportar apenas versões do Python ≥ 3.10, invocaria inspect.get_annotations em vez disso. Reveja a Seção 15.5.1 para uma discussão dos problemas com essas funções.

  2. __init_subclass__ é chamado quando uma subclasse da classe atual é definida. Ele recebe aquela nova subclasse como seu primeiro argumento—e por isso nomeei o argumento subclass em vez do habitual cls. Para mais informações sobre isso, veja __init_subclass__ não é um método de classe típico.

  3. super().__init_subclass__() não é estritamente necessário, mas deve ser invocado para ajudar outras classes que implementem .__init_subclass__() na mesma árvore de herança. Veja a Seção 14.4.

  4. Itera sobre name e constructor em cada campo…​

  5. …​criando um atributo em subclass com aquele name vinculado a um descritor Field, parametrizado com name e constructor.

  6. Para cada name nos campos da classe…​

  7. …​obtém o value correspondente de kwargs e o remove de kwargs. Usar …​ (o objeto Ellipsis) como default nos permite distinguir entre argumentos com valor None de argumentos ausentes.[348]

  8. Essa chamada a setattr aciona Checked.__setattr__, apresentado no Exemplo 6.

  9. Se houver itens remanescentes em kwargs, seus nomes não correspondem a qualquer dos campos declarados, e __init__ vai falhar.

  10. Esse erro é informado por __flag_unknown_attrs, listado no Exemplo 6. Ele recebe um argumento *names com os nomes de atributos desconhecidos. Usei um único asterisco em *kwargs, para passar suas chaves como uma sequência de argumentos.

__init_subclass__ não é um método de classe típico

O decorador @classmethod nunca é usado com __init_subclass__, mas isso não quer dizer muita coisa, pois o método especial __new__ se comporta como um método de classe mesmo sem @classmethod. O primeiro argumento que o Python passa para __init_subclass__ é uma classe. Entretanto, essa nunca é a classe onde __init_subclass__ é implementado, mas sim uma subclasse recém-definida daquela classe. Isso é diferente de __new__ e de qualquer outro método de classe que eu conheço. Assim, acho que __init_subclass__ não é um método de classe no sentido usual, e é errado nomear seu primeiro argumento cls. A documentação de __init_suclass__ chama o argumento de cls, mas explica: "…​chamado sempre que se cria uma subclasse da classe que o contém. cls é então a nova subclasse…​"[349].

Vamos examinar os métodos restantes da classe Checked, continuando do Exemplo 5. Observe que prefixei os nomes dos métodos _fields e _asdict com _, pela mesma razão pela qual isso é feito na API de collections.namedtuple: reduzir a possibilidade de colisões de nomes com nomes de campos definidos pelo usuário.

Exemplo 6. initsub/checkedlib.py: métodos restantes da classe Checked
    def __setattr__(self, name: str, value: Any) -> None:  # (1)
        if name in self._fields():              # (2)
            cls = self.__class__
            descriptor = getattr(cls, name)
            descriptor.__set__(self, value)     # (3)
        else:                                   # (4)
            self.__flag_unknown_attrs(name)

    def __flag_unknown_attrs(self, *names: str) -> NoReturn:  # (5)
        plural = 's' if len(names) > 1 else ''
        extra = ', '.join(f'{name!r}' for name in names)
        cls_name = repr(self.__class__.__name__)
        raise AttributeError(f'{cls_name} object has no attribute{plural} {extra}')

    def _asdict(self) -> dict[str, Any]:  # (6)
        return {
            name: getattr(self, name)
            for name, attr in self.__class__.__dict__.items()
            if isinstance(attr, Field)
        }

    def __repr__(self) -> str:  # (7)
        kwargs = ', '.join(
            f'{key}={value!r}' for key, value in self._asdict().items()
        )
        return f'{self.__class__.__name__}({kwargs})'
  1. Intercepta qualquer tentativa de definir um atributo de instância. Isso é necessário para evitar a definição de um atributo desconhecido.

  2. Se o name do atributo é conhecido, busca o descriptor correspondente.

  3. Normalmente não é preciso invocar o __set__ do descritor explicitamente. Nesse caso isso foi necessário porque __setattr__ intercepta todas as tentativas de definir um atributo em uma instância, mesmo na presença de um descritor dominante, tal como Field.[350]

  4. Caso contrário, o atributo name é desconhecido, e uma exceção será gerada por __flag_unknown_attrs.

  5. Cria uma mensagem de erro útil, listando todos os argumentos inesperados, e gera um AttributeError. Este é um raro exemplo do tipo especial NoReturn, tratado na Seção 8.5.12.

  6. Cria um dict a partir dos atributos de um objeto Movie. Eu chamaria este método de _as_dict, mas segui a convenção iniciada com o método _asdict em collections.namedtuple.

  7. Implementar um __repr__ agradável é a principal razão para ter _asdict neste exemplo.

O exemplo Checked mostra como tratar descritores dominantes ao implementar __setattr__ para bloquear a definição arbitrária de atributos após a instanciação. É possível debater se vale a pena implementar __setattr__ neste exemplo. Sem ele, definir movie.director = 'Greta Gerwig' funcionaria, mas o atributo director não seria verificado de forma alguma, não apareceria no __repr__ nem seria incluído no dict devolvido por _asdict—ambos definidos no Exemplo 6.

Em record_factory.py (no Exemplo 2), solucionei essa questão usando o atributo de classe __slots__. Entretanto, essa solução mais simples não é viável aqui, como explicado a seguir.

24.5.1. Por que __init_subclass__ não pode configurar __slots__?

O atributo __slots__ só é efetivo se for um dos elementos do espaço de nomes da classe passado para type.__new__. Acrescentar __slots__ a uma classe existente não tem qualquer efeito. O Python invoca __init_subclass__ apenas após a classe ser criada—neste ponto, é tarde demais para configurar __slots__. Um decorador de classes também não pode configurar __slots__, pois ele é aplicado ainda mais tarde que __init_subclass__. Vamos explorar essas questões de sincronia na Seção 24.7.

Para configurar __slots__ durante a execução, nosso próprio código precisa criar o espaço de nomes da classe a ser passado como último argumento de type.__new__. Para fazer isso, podemos escrever uma função fábrica de classes, como record_factory.py, ou optar pelo caminho bombástico, e implementar uma metaclasse. Veremos como configurar __slots__ dinamicamente na Seção 24.8.

Antes da PEP 487 (EN) simplificar a personalização da criação de classes com __init_subclass__, no Python 3.7, uma funcionalidade similar só poderia ser implementada usando um decorador de classe. É o tópico de nossa próxima seção.

24.6. Melhorando classes com um decorador de classes

Um decorador de classes é um invocável que se comporta de forma similar a um decorador de funções: recebe uma classe decorada como argumento, e deve devolver um classe para substituir a classe decorada. Decoradores de classe frequentemente devolvem a própria classe decorada, após injetar nela mais métodos pela definição de atributos. Provavelmente, a razão mais comum para escolher um decorador de classes, em vez do mais simples __init_subclass__, é evitar interferência com outros recursos da classe, tais como herança e metaclasses.[351]

Nessa seção vamos estudar checkeddeco.py, que oferece a mesma funcionalidade de checkedlib.py, mas usando um decorador de classe. Como sempre, começamos examinando um exemplo de uso, extraído dos doctests em checkeddeco.py (no Exemplo 7).

Exemplo 7. checkeddeco.py: criando uma classe Movie decorada com @checked
    >>> @checked
    ... class Movie:
    ...     title: str
    ...     year: int
    ...     box_office: float
    ...
    >>> movie = Movie(title='The Godfather', year=1972, box_office=137)
    >>> movie.title
    'The Godfather'
    >>> movie
    Movie(title='The Godfather', year=1972, box_office=137.0)

A única diferença entre o Exemplo 7 e o Exemplo 3 é a forma como a classe Movie é declarada: ela é decorada com @checked em vez de ser uma subclasse de Checked. Fora isso, o comportamento externo é o mesmo, incluindo a validação de tipo e a atribuição de valores default, apresentados após o Exemplo 3, na Seção 24.5.

Vamos olhar agora para a implementação de checkeddeco.py. As importações e a classe Field são as mesmas de checkedlib.py, listadas no Exemplo 4. Em checkeddeco.py não há qualquer outra classe, apenas funções.

A lógica antes implementada em __init_subclass__ agora é parte da função checked—o decorador de classes listado no Exemplo 8.

Exemplo 8. checkeddeco.py: o decorador de classes
def checked(cls: type) -> type:  # (1)
    for name, constructor in _fields(cls).items():    # (2)
        setattr(cls, name, Field(name, constructor))  # (3)

    cls._fields = classmethod(_fields)  # type: ignore  # (4)

    instance_methods = (  # (5)
        __init__,
        __repr__,
        __setattr__,
        _asdict,
        __flag_unknown_attrs,
    )
    for method in instance_methods:  # (6)
        setattr(cls, method.__name__, method)

    return cls  # (7)
  1. Lembre-se que classes são instâncias de type. Essas dicas de tipo sugerem fortemente que este é um decorador de classes: ele recebe uma classe e devolve uma classe.

  2. _fields agora é uma função de alto nível definida mais tarde no módulo (no Exemplo 9).

  3. Substituir cada atributo devolvido por _fields por uma instância do descritor Field é o que __init_subclass__ fazia no Exemplo 5. Aqui há mais trabalho a ser feito…​

  4. Cria um método de classe a partir de _fields, e o adiciona à classe decorada. O comentário type: ignore é necessário, porque o Mypy reclama que type não tem um atributo _fields.

  5. Funções ao nível do módulo, que se tornarão métodos de instância da classe decorada.

  6. Adiciona cada um dos instance_methods a cls.

  7. Devolve a cls decorada, cumprindo o contrato básico de um decorador de classes.

Todas as funções no primeiro nível de checkeddeco.py estão prefixadas com um sublinhado, exceto o decorador checked. Essa convenção para a nomenclatura faz sentido por duas razões:

  • checked é parte da interface pública do módulo checkeddeco.py, as outras funções não.

  • As funções no Exemplo 9 serão injetadas na classe decorada, e o _ inicial reduz as chances de um conflito de nomes com atributos e métodos definidos pelo usuário na classe decorada.

O restante de checkeddeco.py está listado no Exemplo 9. Aquelas funções no nível do módulo contém o mesmo código dos métodos correspondentes na classe Checked de checkedlib.py. Elas foram explicadas no Exemplo 5 e no Exemplo 6.

Observe que a função _fields exerce dois papéis em checkeddeco.py. Ela é usada como uma função regular na primeira linha do decorador checked e será também injetada como um método de classe na classe decorada.

Exemplo 9. checkeddeco.py: os métodos que serão injetados na classe decorada
def _fields(cls: type) -> dict[str, type]:
    return get_type_hints(cls)

def __init__(self: Any, **kwargs: Any) -> None:
    for name in self._fields():
        value = kwargs.pop(name, ...)
        setattr(self, name, value)
    if kwargs:
        self.__flag_unknown_attrs(*kwargs)

def __setattr__(self: Any, name: str, value: Any) -> None:
    if name in self._fields():
        cls = self.__class__
        descriptor = getattr(cls, name)
        descriptor.__set__(self, value)
    else:
        self.__flag_unknown_attrs(name)

def __flag_unknown_attrs(self: Any, *names: str) -> NoReturn:
    plural = 's' if len(names) > 1 else ''
    extra = ', '.join(f'{name!r}' for name in names)
    cls_name = repr(self.__class__.__name__)
    raise AttributeError(f'{cls_name} has no attribute{plural} {extra}')

def _asdict(self: Any) -> dict[str, Any]:
    return {
        name: getattr(self, name)
        for name, attr in self.__class__.__dict__.items()
        if isinstance(attr, Field)
    }

def __repr__(self: Any) -> str:
    kwargs = ', '.join(
        f'{key}={value!r}' for key, value in self._asdict().items()
    )
    return f'{self.__class__.__name__}({kwargs})'

O módulo checkeddeco.py implementa um decorador de classes simples mas usável. O @dataclass do Python faz muito mais. Ele suporta várias opções de configuração, acrescenta mais métodos à classe decorada, trata ou avisa sobre conflitos com métodos definidos pelo usuário na classe decorada, e até percorre o __mro__ para coletar atributos definidos pelo usuário declarados em superclasses da classe decorada. O código-fonte do pacote dataclasses no Python 3.9 tem mais de 1200 linhas.

Para fazer metaprogramação de classes, precisamos saber quando o interpretador Python avalia cada bloco de código durante a criação de uma classe. É disso que falaremos a seguir.

24.7. O que acontece quando: importação versus execução

Programadores Python falam de "importação" (import time) versus "execução" (runtime), mas estes termos não tem definições precisas e há uma zona cinzenta entre eles.

Na importação, o interpretador:

  1. Analisa o código-fonte de módulo .py em uma passagem, de cima até embaixo. É aqui que um SyntaxError pode ocorrer.

  2. Compila o bytecode a ser executado.

  3. Executa o código no nível superior do módulo compilado.

Se existir um arquivo .pyc atualizado no __pycache__ local, a análise e a compilação são omitidas, pois o bytecode está pronto para ser executado.

Apesar da análise e a compilação serem definitivamente atividades de "importação", outras coisas podem acontecer durante o processo, pois quase todos os comandos ou instruções no Python são executáveis, no sentido de poderem potencialmente rodar código do usuário e modificar o estado do programa do usuário.

Em especial, a instrução import não é meramente uma declaração[352], pois na verdade ela executa todo o código no nível superior de um módulo, quando este é importado para o processo pela primeira vez. Importações posteriores do mesmo módulo usarão um cache, e então o único efeito será a vinculação dos objetos importados a nomes no módulo cliente. Aquele código no primeiro nível pode fazer qualquer coisa, incluindo ações típicas da "execução", tais como escrever em um arquivo de log ou conectar-se a um banco de dados.[353] Por isso a fronteira entre a "importação" e a "execução" é difusa: import pode acionar todo tipo de comportamento de "execução", porque a instrução import e a função embutida __import__() podem ser usadas dentro de qualquer função regular.

Tudo isso é bastante abstrato e sútil, então vamos realizar alguns experimentos para ver o que acontece, e quando.

24.7.1. Experimentos com a fase de avaliação (evaluation time)

Considere um script evaldemo.py, que usa um decorador de classes, um descritor e uma fábrica de classes baseada em __init_subclass__, todos definidos em um módulo builderlib.py. Os módulos usados tem várias chamadas a print, para revelar o que acontece por baixo dos panos. Fora isso, eles não fazem nada de útil. O objetivo destes experimentos é observar a ordem na qual essas chamadas a print acontecem.

⚠️ Aviso

Aplicar um decorador de classes e uma fábrica de classes com __init_subclass__ juntos, em uma única classe, é provavelmente um sinal de excesso de engenharia ou de desespero. Essa combinação incomum é útil nesses experimentos, para mostrar a ordem temporal das mudanças que um decorador de classes e __init_subclass__ podem aplicar a uma classe.

Vamos começar examinando builderlib.py, dividido em duas partes: o Exemplo 10 e o Exemplo 11.

Exemplo 10. builderlib.py: primeira parte do módulo
print('@ builderlib module start')

class Builder:  # (1)
    print('@ Builder body')

    def __init_subclass__(cls):  # (2)
        print(f'@ Builder.__init_subclass__({cls!r})')

        def inner_0(self):  # (3)
            print(f'@ SuperA.__init_subclass__:inner_0({self!r})')

        cls.method_a = inner_0

    def __init__(self):
        super().__init__()
        print(f'@ Builder.__init__({self!r})')


def deco(cls):  # (4)
    print(f'@ deco({cls!r})')

    def inner_1(self):  # (5)
        print(f'@ deco:inner_1({self!r})')

    cls.method_b = inner_1
    return cls  # (6)
  1. Essa é uma fábrica de classes para implementar…​

  2. …​um método __init_subclass__.

  3. Define uma função para ser adicionada à subclasse na atribuição abaixo.

  4. Um decorador de classes.

  5. Função a ser adicionada à classe decorada.

  6. Devolve a classe recebida como argumento.

Continuando builderlib.py no Exemplo 11…​

Exemplo 11. builderlib.py: a parte final do módulo
class Descriptor:  # (1)
    print('@ Descriptor body')

    def __init__(self):  # (2)
        print(f'@ Descriptor.__init__({self!r})')

    def __set_name__(self, owner, name):  # (3)
        args = (self, owner, name)
        print(f'@ Descriptor.__set_name__{args!r}')

    def __set__(self, instance, value):  # (4)
        args = (self, instance, value)
        print(f'@ Descriptor.__set__{args!r}')

    def __repr__(self):
        return '<Descriptor instance>'


print('@ builderlib module end')
  1. Uma classe descritora para demonstrar quando…​

  2. …​uma instância do descritor é criada, e quando…​

  3. …​__set_name__ será invocado durante a criação da classe owner.

  4. Como os outros métodos, este __set__ não faz nada, exceto exibir seus argumentos.

Se importarmos builderlib.py no console do Python, veremos o seguinte:

>>> import builderlib
@ builderlib module start
@ Builder body
@ Descriptor body
@ builderlib module end

Observe que as linhas exibidas por builderlib.py tem um @ como prefixo.

Vamos agora voltar a atenção para evaldemo.py, que vai acionar método especiais em builderlib.py (no Exemplo 12).

Exemplo 12. evaldemo.py: script para experimentar com builderlib.py
#!/usr/bin/env python3

from builderlib import Builder, deco, Descriptor

print('# evaldemo module start')

@deco  # (1)
class Klass(Builder):  # (2)
    print('# Klass body')

    attr = Descriptor()  # (3)

    def __init__(self):
        super().__init__()
        print(f'# Klass.__init__({self!r})')

    def __repr__(self):
        return '<Klass instance>'


def main():  # (4)
    obj = Klass()
    obj.method_a()
    obj.method_b()
    obj.attr = 999

if __name__ == '__main__':
    main()

print('# evaldemo module end')
  1. Aplica um decorador.

  2. Cria uma subclasse de Builder para acionar seu __init_subclass__.

  3. Instancia o descritor.

  4. Isso só será chamado se o módulo for executado como o programa pincipal.

As chamadas a print em evaldemo.py tem um # como prefixo. Se você abrir o console novamente e importar evaldemo.py, a saída aparece no Exemplo 13.

Exemplo 13. Experimentos de console com evaldemo.py
>>> import evaldemo
@ builderlib module start  (1)
@ Builder body
@ Descriptor body
@ builderlib module end
# evaldemo module start
# Klass body  (2)
@ Descriptor.__init__(<Descriptor instance>)  (3)
@ Descriptor.__set_name__(<Descriptor instance>,
      <class 'evaldemo.Klass'>, 'attr')                (4)
@ Builder.__init_subclass__(<class 'evaldemo.Klass'>)  (5)
@ deco(<class 'evaldemo.Klass'>)  (6)
# evaldemo module end
  1. As primeiras quatro linhas são o resultado de from builderlib import…. Elas não vão aparecer se você não fechar o console após o experimento anterior, pois builderlib.py já estará carregado.

  2. Isso sinaliza que o Python começou a ler o corpo de Klass. Neste momento o objeto classe ainda não existe.

  3. A instância do descritor é criada e vinculada a attr, no espaço de nomes que o Python passará para o construtor default do objeto classe: type.__new__.

  4. Neste ponto, a função embutida do Python type.__new__ já criou o objeto Klass e invoca __set_name__ em cada instância das classes do descritor que oferecem aquele método, passando Klass como argumento owner.

  5. type.__new__ então chama __init_subclass__ na superclasse de Klass, passando Klass como único argumento.

  6. Quando type.__new__ devolve o objeto classe, o Python aplica o decorador. Neste exemplo, a classe devolvida por deco está vinculada a Klass no espaço de nomes do módulo

A implementação de type.__new__ está escrita em C. O comportamento que acabei de descrever está documentado na seção "Criando o objeto classe", no capítulo "Modelo de Dados" da referência do Python.

Observe que a função main() de evaldemo.py (no Exemplo 12) não foi executada durante a sessão no console (no Exemplo 13), portanto nenhuma instância de Klass foi criada. Todas as ações que vimos foram acionadas por operações de "importação": importar builderlib e definir Klass.

Se você executar evaldemo.py como um script, vai ver a mesma saída do Exemplo 13, com linhas extras logo antes do final. As linhas adicionais são o resultado da execução de main() (veja o Exemplo 14).

Exemplo 14. Executando evaldemo.py como um programa
$ ./evaldemo.py
[... 9 linhas omitidas ...]
@ deco(<class '__main__.Klass'>)  (1)
@ Builder.__init__(<Klass instance>)  (2)
# Klass.__init__(<Klass instance>)
@ SuperA.__init_subclass__:inner_0(<Klass instance>)  (3)
@ deco:inner_1(<Klass instance>)  (4)
@ Descriptor.__set__(<Descriptor instance>, <Klass instance>, 999)  (5)
# evaldemo module end
  1. As 10 primeiras linhas—incluindo essa—são as mesma que aparecem no Exemplo 13.

  2. Acionado por super().__init__() em Klass.__init__.

  3. Acionado por obj.method_a() em main; o method_a foi injetado por SuperA.__init_subclass__.

  4. Acionado por obj.method_b() em main; method_b foi injetado por deco.

  5. Acionado por obj.attr = 999 em main.

Uma classe base com __init_subclass__ ou um decorador de classes são ferramentas poderosas, mas elas estão limitadas a trabalhar sobre uma classe já criada por type.__new__ por baixo dos panos. Nas raras ocasiões em que for preciso ajustar os argumentos passados a type.__new__, uma metaclasse é necessária. Esse é o destino final desse capítulo—e desse livro.

24.8. Introdução às metaclasses

[Metaclasses] são uma mágica tão profunda que 99% dos usuários jamais deveria se preocupar com elas. Quem se pergunta se precisa delas, não precisa (quem realmente precisa de metaclasses sabe disso com certeza, e não precisa que lhe expliquem a razão).[354]

— Tim Peters
inventor do algoritmo timsort e um produtivo colaborador do Python

Uma metaclasse é uma fábrica de classes. Diferente de record_factory, do Exemplo 2, uma metaclasse é escrita como uma classe. Em outras palavras, uma metaclasse é uma classe cujas instâncias são classes. A Figura 1 usa a Notação Engenhocas e Bugigangas para representar uma metaclasse: uma engenhoca que produz outra engenhoca.

Diagrama MGN com metaclasse e classe.
Figura 1. Uma metaclasse é uma classe que cria classes.

Pense no modelo de objetos do Python: classes são objetos, portanto cada classe deve ser uma instância de alguma outra classe. Por default, as classes do Python são instâncias de type. Em outras palavras, type é a metaclasse da maioria das classes, sejam elas embutidas ou definidas pelo usuário:

>>> str.__class__
<class 'type'>
>>> from bulkfood_v5 import LineItem
>>> LineItem.__class__
<class 'type'>
>>> type.__class__
<class 'type'>

Para evitar regressões infinitas, a classe de type é type, como mostra a última linha.

Observe que não estou dizendo que str ou LineItem são subclasses de type. Estou dizendo que str e LineItem são instâncias de type. Elas são todas subclasses de object. A Figura 2 pode ajudar você a contemplar essa estranha realidade.

Diagrama de classes UML com as relações de `object` e `type`.
Figura 2. Os dois diagramas são verdadeiros. O da esquerda enfatiza que str, type, e LineItem são subclasses de object. O da direita deixa claro que str, object, e LineItem são instâncias de type, pois todas são classes.
✒️ Nota

As classes object e type tem uma relação singular: object é uma instância de type, e type é uma subclasse de object. Essa relação é "mágica": ela não pode ser expressa em Python, porque cada uma das classes teria que existir antes da outra poder ser definida. O fato de type ser uma instância de si mesma também é mágico.

O próximo trecho mostra que a classe de collections.Iterable é abc.ABCMeta. Observe que Iterable é uma classe abstrata, mas ABCMeta é uma classe concreta—​afinal, Iterable é uma instância de ABCMeta:

>>> from collections.abc import Iterable
>>> Iterable.__class__
<class 'abc.ABCMeta'>
>>> import abc
>>> from abc import ABCMeta
>>> ABCMeta.__class__
<class 'type'>

Por fim, a classe de ABCMeta também é type. Toda classe é uma instância de type, direta ou indiretamente, mas apenas metaclasses são também subclasses de type. Essa é a mais importante relação para entender as metaclasses: uma metaclasse, tal como ABCMeta, herda de type o poder de criar classes. A Figura 3 ilustra essa relação fundamental.

Diagramas de classe UML com as relações de `Iterable` e `ABCMeta`.
Figura 3. Iterable é uma subclasse de object e uma instância de ABCMeta. Tanto object quanto ABCMeta são instâncias de type, mas a relação crucial aqui é que ABCMeta também é uma subclasse de type, porque ABCMeta é uma metaclasse. Neste diagrama, Iterable é a única classe abstrata.

A lição importante aqui é que metaclasses são subclasses de type, e é isso que permite a elas funcionarem como fábricas de classes. Uma metaclasse pode personalizar suas instâncias implementando métodos especiais, como demosntram as próximas seções.

24.8.1. Como uma metaclasse personaliza uma classe

Para usar uma metaclasse, é crucial entender como __new__ funciona em qualquer classe. Isso foi discutido na Seção 22.2.3.

A mesma mecânica se repete no nível "meta", quando uma metaclasse está prestes a criar uma nova instância, que é uma classe. Considere a declaração abaixo:

class Klass(SuperKlass, metaclass=MetaKlass):
    x = 42
    def __init__(self, y):
        self.y = y

Para processar essa instrução class, o Python invoca MetaKlass.__new__ com os seguintes argumentos:

meta_cls

A própria metaclasse(MetaKlass), porque __new__ funciona como um método de classe.

cls_name

A string Klass.

bases

A tupla com um único elemento (SuperKlass,) (ou com mais elementos, em caso de herança múltipla).

cls_dict

Um mapeamento como esse:

{x: 42, `+__init__+`: <function __init__ at 0x1009c4040>}

Ao implementar MetaKlass.__new__, podemos inspecionar e modificar aqueles argumentos antes de passá-los para super().__new__, que por fim invocará type.__new__ para criar o novo objeto classe.

Após super().__new__ retornar, podemos também aplicar processamento adicional à classe recém-criada, antes de devolvê-la para o Python. O Python então invoca SuperKlass.__init_subclass__, passando a classe que criamos, e então aplicando um decorador de classe, se algum estiver presente. Finalmente, o Python vincula o objeto classe a seu nome no espaço de nomes circundante—normalmente o espaço de nomes global do módulo, se a instrução class foi uma instrução no primeiro nível.

O processamento mais comum realizado no __new__ de uma metaclasse é adicionar ou substituir itens no cls_dict—o mapeamento que representa o espaço de nomes da classe em construção. Por exemplo, antes de chamar super().__new__, podemos injetar métodos na classe em construção adicionando funções a cls_dict. Entretanto, observe que adicionar métodos pode também ser feito após a classe ser criada, e é por essa razão que podemos fazer isso usando __init_subclass__ ou um decorador de classe.

Um atributo que precisa ser adicionado a cls_dict antes de se executar type.__new__ é __slots__, como discutido na Seção 24.5.1. O método __new__ de uma metaclasse é o lugar ideal para configurar __slots__. A próxima seção mostra como fazer isso.

24.8.2. Um belo exemplo de metaclasse

A metaclasse MetaBunch, apresentada aqui, é uma variação do último exemplo no Capítulo 4 do Python in a Nutshell, 3ª ed., de Alex Martelli, Anna Ravenscroft, e Steve Holden, escrito para rodar sob Python 2.7 e 3.5.[355] Assumindo o uso do Python 3.6 ou mais recente, pude simplificar ainda mais o código.

Mas primeiro vamos ver o que a classe base Bunch oferece:

    >>> class Point(Bunch):
    ...     x = 0.0
    ...     y = 0.0
    ...     color = 'gray'
    ...
    >>> Point(x=1.2, y=3, color='green')
    Point(x=1.2, y=3, color='green')
    >>> p = Point()
    >>> p.x, p.y, p.color
    (0.0, 0.0, 'gray')
    >>> p
    Point()

Lembre-se que Checked atribui nomes aos descritores Field em subclasses, baseada em dicas de tipo de variáveis de classe, que não se tornam atributos na classe, já que não tem valores.

Subclasses de Bunch, por outro lado, usam atributos de classe reais com valores, que então se tornam os valores default dos atributos de instância. O __repr__ gerado omite os argumentos para atributos iguais aos defaults.

MetaBunch—a metaclasse de Bunch—gera __slots__ para a nova classe a partir de atributos de classe declarados na classe do usuário. Isso bloqueia a instanciação e posterior atribuição a atributos não declarados:

    >>> Point(x=1, y=2, z=3)
    Traceback (most recent call last):
      ...
    AttributeError: No slots left for: 'z'
    >>> p = Point(x=21)
    >>> p.y = 42
    >>> p
    Point(x=21, y=42)
    >>> p.flavor = 'banana'
    Traceback (most recent call last):
      ...
    AttributeError: 'Point' object has no attribute 'flavor'

Vamos agora mergulhar no elegante código de MetaBunch, no Exemplo 15.

Exemplo 15. metabunch/from3.6/bunch.py: a metaclasse MetaBunch e a classe Bunch
class MetaBunch(type):  # (1)
    def __new__(meta_cls, cls_name, bases, cls_dict):  # (2)

        defaults = {}  # (3)

        def __init__(self, **kwargs):  # (4)
            for name, default in defaults.items():  # (5)
                setattr(self, name, kwargs.pop(name, default))
            if kwargs:  # (6)
                extra = ', '.join(kwargs)
                raise AttributeError(f'No slots left for: {extra!r}')

        def __repr__(self):  # (7)
            rep = ', '.join(f'{name}={value!r}'
                            for name, default in defaults.items()
                            if (value := getattr(self, name)) != default)
            return f'{cls_name}({rep})'

        new_dict = dict(__slots__=[], __init__=__init__, __repr__=__repr__)  # (8)

        for name, value in cls_dict.items():  # (9)
            if name.startswith('__') and name.endswith('__'):  # (10)
                if name in new_dict:
                    raise AttributeError(f"Can't set {name!r} in {cls_name!r}")
                new_dict[name] = value
            else:  # (11)
                new_dict['__slots__'].append(name)
                defaults[name] = value
        return super().__new__(meta_cls, cls_name, bases, new_dict)  # (12)


class Bunch(metaclass=MetaBunch):  # (13)
    pass
  1. Para criar uma nova metaclasse, herdamos de type.

  2. __new__ funciona como um método de classe, mas a classe é uma metaclasse, então gosto de nomear o primeiro argumento meta_cls (mcs é uma alternativa comum). Os três argumentos restantes são os mesmos da assinatura de três argumentos de type(), quando chamada diretamente para criar uma classe.

  3. defaults vai manter um mapeamento de nomes de atributos e seus valores default.

  4. Isso irá ser injetado na nova classe.

  5. defaults e define o atributo de instância correspondente, com o valor extraído de kwargs, ou um valor default.

  6. Se ainda houver itens em kwargs, isso significa que não há posição restante onde possamos colocá-los. Acreditamos em falhar rápido como melhor prática, então não queremos ignorar silenciosamente os itens em excesso. Uma solução rápida e eficiente é extrair um item de kwargs e tentar defini-lo na instância, gerando propositalmente um AttributeError.

  7. __repr__ devolve uma string que se parece com uma chamada ao construtor—por exemplo, Point(x=3), omitindo os argumentos nomeados com valores default.

  8. Inicializa o espaço de nomes para a nova classe.

  9. Itera sobre o espaço de nomes da classe do usuário.

  10. Se um name dunder (com sublinhados como prefixo e sufixo) é encontrado, copia o item para o espaço de nomes da nova classe, a menos que ele já esteja lá. Isso evita que usuários sobrescrevam __init__, __repr__ e outros atributos definidos pelo Python, tais como __qualname__ e __module__.

  11. Se name não for um dunder, acrescenta name a __slots__ e armazena seu value em defaults.

  12. Cria e devolve a nova classe.

  13. Fornece uma classe base, assim os usuários não precisam ver MetaBunch.

MetaBunch funciona por ser capaz de configurar __slots__ antes de invocar super().__new__ para criar a classe final. Como sempre em metaprogramação, o fundamental é entender a sequência de ações. Vamos fazer outro experimento sobre a fase de avaliação, agora com uma metaclasse.

24.8.3. Experimento com a fase de avaliação de metaclasses

Essa é uma variação do Seção 24.7.1, acrescentando uma metaclasse à mistura. O módulo builderlib.py é o mesmo de antes, mas o script principal é agora evaldemo_meta.py, listado no Exemplo 16.

Exemplo 16. evaldemo_meta.py: experimentando com uma metaclasse
#!/usr/bin/env python3

from builderlib import Builder, deco, Descriptor
from metalib import MetaKlass  # (1)

print('# evaldemo_meta module start')

@deco
class Klass(Builder, metaclass=MetaKlass):  # (2)
    print('# Klass body')

    attr = Descriptor()

    def __init__(self):
        super().__init__()
        print(f'# Klass.__init__({self!r})')

    def __repr__(self):
        return '<Klass instance>'


def main():
    obj = Klass()
    obj.method_a()
    obj.method_b()
    obj.method_c()  # (3)
    obj.attr = 999


if __name__ == '__main__':
    main()

print('# evaldemo_meta module end')
  1. Importa MetaKlass de metalib.py, que veremos no Exemplo 18.

  2. Declara Klass como uma subclasse de Builder e uma instância de MetaKlass.

  3. Este método é injetado por MetaKlass.__new__, como veremos adiante.

⚠️ Aviso

Em nome da ciência, o Exemplo 16 desafia qualquer racionalidade e aplica três técnicas diferentes de metaprogramação juntas a Klass: um decorador, uma classe base usando __init_subclass__, e uma metaclasse personalizada. Se você fizer isso com código em produção, por favor não me culpe. Repito, o objetivo é observar a ordem na qual as três técnicas interferem no processo de criação de uma classe.

Como no experimento anterior com a fase de avaliação, este exemplo não faz nada, apenas exibe mensagens revelando o fluxo de execução. O Exemplo 17 mostra a primeira parte do código de metalib.py—o restante está no Exemplo 18.

Exemplo 17. metalib.py: a classe NosyDict
print('% metalib module start')

import collections

class NosyDict(collections.UserDict):
    def __setitem__(self, key, value):
        args = (self, key, value)
        print(f'% NosyDict.__setitem__{args!r}')
        super().__setitem__(key, value)

    def __repr__(self):
        return '<NosyDict instance>'

Escrevi a classe NosyDict para sobrepor __setitem__ e exibir cada key e cada value conforme eles são definidos. A metaclasse vai usar uma instância de NosyDict para manter o espaço de nomes da classe em construção, revelando um pouco mais sobre o funcionamento interno do Python.

A principal atração de metalib.py é a metaclasse no Exemplo 18. Ela implementa o método especial __prepare__, um método de classe que o Python só invoca em metaclasses. O método __prepare__ oferece a primeira oportunidade para influenciar o processo de criação de uma nova classe.

👉 Dica

Ao programar uma metaclasse, acho útil adotar a seguinte convenção de nomenclatura para argumentos de métodos especiais:

  • Usar cls em vez de self para métodos de instância, pois a instância é uma classe.

  • Usar meta_cls em vez de cls para métodos de classe, pois a classe é uma metaclasse. Lembre-se que __new__ se comporta como um método de classe mesmo sem o decorador @classmethod.

Exemplo 18. metalib.py: a MetaKlass
class MetaKlass(type):
    print('% MetaKlass body')

    @classmethod  # (1)
    def __prepare__(meta_cls, cls_name, bases):  # (2)
        args = (meta_cls, cls_name, bases)
        print(f'% MetaKlass.__prepare__{args!r}')
        return NosyDict()  # (3)

    def __new__(meta_cls, cls_name, bases, cls_dict):  # (4)
        args = (meta_cls, cls_name, bases, cls_dict)
        print(f'% MetaKlass.__new__{args!r}')
        def inner_2(self):
            print(f'% MetaKlass.__new__:inner_2({self!r})')

        cls = super().__new__(meta_cls, cls_name, bases, cls_dict.data)  # (5)

        cls.method_c = inner_2  # (6)

        return cls  # (7)

    def __repr__(cls):  # (8)
        cls_name = cls.__name__
        return f"<class {cls_name!r} built by MetaKlass>"

print('% metalib module end')
  1. __prepare__ deve ser declarado como um método de classe. Ele não é um método de instância, pois a classe em construção ainda não existe quando o Python invoca __prepare__.

  2. O Python invoca __prepare__ em uma metaclasse para obter um mapeamento, onde vai manter o espaço de nomes da classe em construção.

  3. Devolve uma instância de NosyDict para ser usado como o espaço de nomes.

  4. cls_dict é uma instância de NosyDict devolvida por __prepare__.

  5. type.__new__ exige um dict real como último argumento, então passamos a ele o atributo data de NosyDict, herdado de UserDict.

  6. Injeta um método na classe recém-criada.

  7. Como sempre, __new__ precisa devolver o objeto que acaba de ser criado—neste caso, a nova classe.

  8. Definir __repr__ em uma metaclasse permite personalizar o repr() de objetos classe.

O principal caso de uso para __prepare__ antes do Python 3.6 era oferecer um OrderedDict para manter os atributos de uma classe em construção, para que o __new__ da metaclasse pudesse processar aqueles atributos na ordem em que aparecem no código-fonte da definição de classe do usuário. Agora que dict preserva a ordem de inserção, __prepare__ raramente é necessário. Veremos um uso criativo para ele no Seção 24.11.

Importar metalib.py no console do Python não é muito empolgante. Observe o uso de % para prefixar as linhas geradas por esse módulo:

>>> import metalib
% metalib module start
% MetaKlass body
% metalib module end

Muitas coisas acontecem quando importamos evaldemo_meta.py, como visto no Exemplo 19.

Exemplo 19. Experimento com evaldemo_meta.py no console
>>> import evaldemo_meta
@ builderlib module start
@ Builder body
@ Descriptor body
@ builderlib module end
% metalib module start
% MetaKlass body
% metalib module end
# evaldemo_meta module start  (1)
% MetaKlass.__prepare__(<class 'metalib.MetaKlass'>, 'Klass',  (2)
                        (<class 'builderlib.Builder'>,))
% NosyDict.__setitem__(<NosyDict instance>, '__module__', 'evaldemo_meta')  (3)
% NosyDict.__setitem__(<NosyDict instance>, '__qualname__', 'Klass')
# Klass body
@ Descriptor.__init__(<Descriptor instance>)  (4)
% NosyDict.__setitem__(<NosyDict instance>, 'attr', <Descriptor instance>)  (5)
% NosyDict.__setitem__(<NosyDict instance>, '__init__',
                       <function Klass.__init__ at …>)  (6)
% NosyDict.__setitem__(<NosyDict instance>, '__repr__',
                       <function Klass.__repr__ at …>)
% NosyDict.__setitem__(<NosyDict instance>, '__classcell__', <cell at …: empty>)
% MetaKlass.__new__(<class 'metalib.MetaKlass'>, 'Klass',
                    (<class 'builderlib.Builder'>,), <NosyDict instance>)  (7)
@ Descriptor.__set_name__(<Descriptor instance>,
                          <class 'Klass' built by MetaKlass>, 'attr')  (8)
@ Builder.__init_subclass__(<class 'Klass' built by MetaKlass>)
@ deco(<class 'Klass' built by MetaKlass>)
# evaldemo_meta module end
  1. As linhas antes disso são resultado da importação de builderlib.py e metalib.py.

  2. O Python invoca __prepare__ para iniciar o processamento de uma instrução class.

  3. Antes de analisar o corpo da classe, o Python acrescenta __module__ e __qualname__ ao espaço de nomes de uma classe em construção.

  4. A instância do descritor é criada…​

  5. …​e vinculada a attr no espaço de nomes da classe.

  6. Os métodos __init__ e __repr__ são definidos e adicionados ao espaço de nomes.

  7. Após terminar o processamento do corpo da classe, o Python chama MetaKlass.__new__.

  8. __set_name__, __init_subclass__ e o decorador são invocados nessa ordem, após o método __new__ da metaclasse devolver a classe recém-criada.

Se executarmos evaldemo_meta.py como um script, main() é chamado, e algumas outras coisas acontecem (veja o Exemplo 20).

Exemplo 20. Rodando evaldemo_meta.py como um programa
$ ./evaldemo_meta.py
[... 20 linhas omitidas ...]
@ deco(<class 'Klass' built by MetaKlass>)  (1)
@ Builder.__init__(<Klass instance>)
# Klass.__init__(<Klass instance>)
@ SuperA.__init_subclass__:inner_0(<Klass instance>)
@ deco:inner_1(<Klass instance>)
% MetaKlass.__new__:inner_2(<Klass instance>)  (2)
@ Descriptor.__set__(<Descriptor instance>, <Klass instance>, 999)
# evaldemo_meta module end
  1. As primeiras 21 linhas—incluindo esta—são as mesmas que aparecem no Exemplo 19.

  2. Acionado por obj.method_c() em main; method_c foi injetado por MetaKlass.__new__.

Vamos agora voltar à ideia da classe Checked, com descritores Field implementando validação de tipo durante a execução, e ver como aquilo pode ser feito com uma metaclasse.

24.9. Uma solução para Checked usando uma metaclasse

Não quero encorajar a otimização prematura nem excessos de engenharia, então aqui temos um cenário de faz de conta para justificar a reescrever checkedlib.py com __slots__, exigindo a aplicação de uma metaclasse. Sinta-se a vontade para pular a historinha.

Uma contação de história

Nosso checkedlib.py usando __init_subclass__ é um sucesso na empresa, e em qualquer dado momento nossos servidores de produção guardam milhões de instâncias de subclasses de Checked em suas memórias.

Analisando o perfil de uma prova de conceito, descobrimos que usar __slots__ pode reduzi os custos de hospedagem, por duas razões:

  • Menos uso de memória, já que as instâncias de Checked não precisarão manter seus próprios __dict__

  • Melhor desempenho, pela remoção de __setattr__, que foi criado só para bloquear atributos inesperados, mas é acionado na instanciação e para todas as definições de atributos antes de Field.__set__ ser chamado para realizar seu trabalho

O módulo metaclass/checkedlib.py, que estudaremos a seguir, é um substituto instantâneo para initsub/checkedlib.py. Os doctests embutidos nos dois módulos são idênticos, bem como os arquivos checkedlib_test.py para o pytest.

A complexidade de checkedlib.py é ocultada do usuário. Aqui está o código-fonte de um script que usa o pacote:

from checkedlib import Checked

class Movie(Checked):
    title: str
    year: int
    box_office: float

if __name__ == '__main__':
    movie = Movie(title='The Godfather', year=1972, box_office=137)
    print(movie)
    print(movie.title)

Essa definição concisa da classe Movie se vale de três instâncias do descritor de validação Field, uma configuração de __slots__, cinco métodos herdados de Checked e uma metaclasse para juntar tudo isso. A única parte visível de checkedlib é a classe base Checked.

Observe a Figura 4. A Notação Engenhocas e Bugigangas complementa o diagrama de classes UML, tornando mais visível a relação entre classes e instâncias.

Por exemplo, uma classe Movie usando a nova checkedlib.py é uma instância de CheckedMeta e uma subclasse de Checked. Adicionalmente, os atributos de classe title, year e box_office de Movie são três instâncias diferentes de Field. Cada instância de Movie tem seus próprios atributos _title, _year e _box_office, para armazenar os valores dos campos correspondentes.

Vamos agora estudar o código, começando pela classe Field, exibida no Exemplo 21.

A classe descritora Field agora está um pouco diferente. Nos exemplos anteriores, cada instância do descritor Field armazenava seu valor na instância gerenciada, usando um atributo de mesmo nome. Por exemplo, na classe Movie, o descritor title armazenava o valor do campo em um atributo title na instância gerenciada. Isso tornava desnecessário que Field implementasse um método __get__.

Entretanto, quando uma classe como Movie usa __slots__, ela não pode ter atributos de classe e atributos de instância com o mesmo nome. Cada instância do descritor é um atributo de classe, e agora precisamos de atributos de armazenamento separados em cada instância. O código usa o nome do descritor prefixado por um único _. Portanto, instâncias de Field têm atributos name e storage_name distintos, e implementamos Field.__get__.

Diagrama de classes UML+MGN para `CheckedMeta`, `Movie` etc.
Figura 4. Diagrama de classes UML com MGN: a meta-engenhoca CheckedMeta cria a engenhoca Movie. A engenhoca Field cria os descritores title, year, e box_office, que são atributos de classe de Movie. Os dados de cada instância para os campos são armazenados nos atributos de instância _title, _year e _box_office de Movie. Observe a fronteira do pacote checkedlib. O desenvolvedor de Movie não precisa entender todo o maquinário dentro de checkedlib.py.

O Exemplo 21 mostra o código-fonte de Field, com os textos explicativos descrevendo apenas as mudanças nessa versão.

Exemplo 21. metaclass/checkedlib.py: o descritor Field com storage_name e __get__
class Field:
    def __init__(self, name: str, constructor: Callable) -> None:
        if not callable(constructor) or constructor is type(None):
            raise TypeError(f'{name!r} type hint must be callable')
        self.name = name
        self.storage_name = '_' + name  # (1)
        self.constructor = constructor

    def __get__(self, instance, owner=None):
        if instance is None:  # (2)
            return self
        return getattr(instance, self.storage_name)  # (3)

    def __set__(self, instance: Any, value: Any) -> None:
        if value is ...:
            value = self.constructor()
        else:
            try:
                value = self.constructor(value)
            except (TypeError, ValueError) as e:
                type_name = self.constructor.__name__
                msg = f'{value!r} is not compatible with {self.name}:{type_name}'
                raise TypeError(msg) from e
        setattr(instance, self.storage_name, value)  # (4)
  1. Determina storage_name a partir do argumento name.

  2. Se __get__ recebe None como argumento instance, o descritor está sendo lido desde a própria classe gerenciada, não de uma instância gerenciada. Neste caso devolvemos o descritor.

  3. Caso contrário, devolve o valor armazenado no atributo chamado storage_name.

  4. __set__ agora usa setattr para definir ou atualizar o atributo gerenciado.

O Exemplo 22 mostra o código para a metaclasse que controla este exemplo.

Exemplo 22. metaclass/checkedlib.py: tha metaclasse CheckedMeta
class CheckedMeta(type):

    def __new__(meta_cls, cls_name, bases, cls_dict):  # (1)
        if '__slots__' not in cls_dict:  # (2)
            slots = []
            type_hints = cls_dict.get('__annotations__', {})  # (3)
            for name, constructor in type_hints.items():   # (4)
                field = Field(name, constructor)  # (5)
                cls_dict[name] = field  # (6)
                slots.append(field.storage_name)  # (7)

            cls_dict['__slots__'] = slots  # (8)

        return super().__new__(
                meta_cls, cls_name, bases, cls_dict)  # (9)
  1. __new__ é o único método implementado em CheckedMeta.

  2. Só melhora a classe se seu cls_dict não incluir __slots__. Se __slots__ já está presente, assume que essa é a classe base Checked e não uma subclasse definida pelo usuário, e cria a classe sem modificações.

  3. Nos exemplos anteriores usamos typing.get_type_hints para obter as dicas de tipo, mas aquilo exige um classe existente como primeiro argumento. Neste ponto, a classe que estamos configurando ainda não existe, então precisamos recuperar __annotations__ diretamente do cls_dict—o espaço de nomes da classe em construção, que o Python passa como último argumento para o __new__ da metaclasse.

  4. Itera sobre type_hints para…​

  5. …​criar um Field para cada atributo anotado…​

  6. …​sobrescreve o item correspondente em cls_dict com a instância de Field…​

  7. …​e acrescenta o storage_name do campo à lista que usaremos para…​

  8. …​preencher o __slots__ no cls_dict—o espaço de nomes da classe em construção.

  9. Por fim, invocamos super().__new__.

A última parte de metaclass/checkedlib.py é a classe base Checked, a partir da qual os usuários dessa biblioteca criarão subclasses para melhorar suas classes, como Movie.

O código desta versão de Checked é o mesmo da Checked em initsub/checkedlib.py (listada no Exemplo 5 e no Exemplo 6), com três modificações:

  1. O acréscimo de um __slots__ vazio, para sinalizar a CheckedMeta.__new__ que esta classe não precisa de processamento especial.

  2. A remoção de __init_subclass__, cujo trabalho agora é feito por CheckedMeta.__new__.

  3. A remoção de __setattr__, que se tornou redundante: o acréscimo de __slots__ à classe definida pelo usuário impede a definição de atributos não declarados.

O Exemplo 23 é a listagem completa da versão final de Checked.

Exemplo 23. metaclass/checkedlib.py: a classe base Checked
class Checked(metaclass=CheckedMeta):
    __slots__ = ()  # skip CheckedMeta.__new__ processing

    @classmethod
    def _fields(cls) -> dict[str, type]:
        return get_type_hints(cls)

    def __init__(self, **kwargs: Any) -> None:
        for name in self._fields():
            value = kwargs.pop(name, ...)
            setattr(self, name, value)
        if kwargs:
            self.__flag_unknown_attrs(*kwargs)

    def __flag_unknown_attrs(self, *names: str) -> NoReturn:
        plural = 's' if len(names) > 1 else ''
        extra = ', '.join(f'{name!r}' for name in names)
        cls_name = repr(self.__class__.__name__)
        raise AttributeError(f'{cls_name} object has no attribute{plural} {extra}')

    def _asdict(self) -> dict[str, Any]:
        return {
            name: getattr(self, name)
            for name, attr in self.__class__.__dict__.items()
            if isinstance(attr, Field)
        }

    def __repr__(self) -> str:
        kwargs = ', '.join(
            f'{key}={value!r}' for key, value in self._asdict().items()
        )
        return f'{self.__class__.__name__}({kwargs})'

Isso conclui nossa terceira versão de uma fábrica de classes com descritores validados.

A próxima seção trata de algumas questões gerais relacionadas a metaclasses.

24.10. Metaclasses no mundo real

Metaclasses são poderosas mas complexas. Antes de se decidir a implementar uma metaclasse, considere os pontos a seguir.

24.10.1. Recursos modernos simplificam ou substituem as metaclasses

Ao longo do tempo, vários casos de uso comum de metaclasses se tornaram redundantes devido a novos recursos da linguagem:

Decoradores de classes

Mais simples de entender que metaclasses, e com menor probabilidade de causar conflitos com classes base e metaclasses.

__set_name__

Elimina a necessidade de uma metaclasse com lógica personalizada para definir automaticamente o nome de um descritor.[356]

__init_subclass__

Fornece uma forma de personalizar a criação de classes que é transparente para o usuário final e ainda mais simples que um decorador—mas pode introduzir conflitos em uma hierarquia de classes complexa.

O dict embutido preservando a ordem de inserção de chaves

Eliminou a principal razão para usar __prepare__: fornecer um OrderedDict para armazenar o espaço de nomes de uma classe em construção. O Python só invoca __prepare__ em metaclasses e então, se fosse necessário processar o espaço de nomes da classe na ordem em que eles aparecem o código-fonte, antes do Python 3.6 era preciso usar uma metaclasse.

Em 2021, todas as versões sob manutenção ativa do CPython suportam todos os recursos listados acima.

Sigo defendendo esses recursos porque vejo muita complexidade desnecessária em nossa profissão, e as metaclasses são uma porta de entrada para a complexidade.

24.10.2. Metaclasses são um recurso estável da linguagem

As metaclasses foram introduzidas no Python em 2002, junto com as assim chamadas "classes com novo estilo", descritores e propriedades. together with so-called "new-style classes," descriptors, and properties.

É impressionante que o exemplo do MetaBunch, postado pela primeira vez por Alex Martelli em julho de 2002, ainda funcione no Python 3.9—a única modificação sendo a forma de especificar a metaclasse a ser usada, algo que no Python 3 é feito com a sintaxe class Bunch(metaclass=MetaBunch):.

Nenhum dos acréscimos que mencionei na Seção 24.10.1 quebrou código existente que usava metaclasses. Mas código legado com metaclasses frequentemente pode ser simplificado através do uso daqueles recursos, especialmente se for possível ignorar versões do Python anteriores à 3.6—versões que não são mais mantidas.

24.10.3. Uma classe só pode ter uma metaclasse

Se sua declaração de classe envolver duas ou mais metaclasses, você verá essa intrigante mensagem de erro:

TypeError: metaclass conflict: the metaclass of a derived class
must be a (non-strict) subclass of the metaclasses of all its bases
(_TypeError: conflito de metaclasses: a metaclasse de uma classe derivada deve ser uma subclasse (não-estrita) das metaclasses de todas as suas bases)

Isso pode acontecer mesmo sem herança múltipla. Por exemplo, a declaração abaixo pode gerar aquele TypeError:

class Record(abc.ABC, metaclass=PersistentMeta):
    pass

Vimos que abc.ABC é uma instância da metaclasse abc.ABCMeta. Se aquela metaclasse Persistent não for uma subclasse de abc.ABCMeta, você tem um conflito de metaclasses.

Há duas maneiras de lidar com esse erro:

  • Encontre outra forma de fazer o que precisa ser feito, evitando o uso de pelo menos uma das metaclasse envolvidas.

  • Escreva a sua própria metaclasse PersistentABCMeta como uma subclasse tanto de abc.ABCMeta quanto de PersistentMeta, usando herança múltipla, e faça dela a única metaclasse de Record.[357]

👉 Dica

Posso aceitar a solução de uma metaclasse com duas metaclasses base, implementada para atender um prazo. Na minha experiência, a programação de metaclasses sempre leva mais tempo que o esperado, tornando essa abordagem arriscada ante um prazo inflexível. Se você fizer isso e cumprir o prazo previsto, seu código pode conter bugs sutis. E mesmo na ausência de bugs conhecidos, essa abordagem deveria ser considerada uma dívida técnica, pelo simples fato de ser difícil de entender e manter.

24.10.4. Metaclasses devem ser detalhes de implementação

Além de type, existem apenas outras seis metaclasses em toda a bilbioteca padrão do Python 3.9. As metaclasses mais conhecidas provavelmnete são abc.ABCMeta, typing.NamedTupleMeta e enum.EnumMeta. Nenhuma delas foi projetada com a intenção de aparecer explicitamente no código do usuário. Podemos considerá-las detalhes de implementação.

Apesar de ser possível fazer metaprogramação bem maluca com metaclasses, é melhor se ater ao princípio do menor espanto, de forma que a maioria dos usuários possa de fato considerar metaclasses detalhes de implementação.[358]

Nos últimos anos, algumas metaclasses na biblioteca padrão do Python foram substituídas por outros mecanismos, sem afetar a API pública de seus pacotes. A forma mais simples de resguardar essas APIs para o futuro é oferecer uma classe regular, da qual usuários podem então criar subclasses para acessar a funcionalidade fornecida pela metaclasse. como fizemos em nossos exemplos.

Para encerrar nossa conversa sobre metaprogramação de classes, vou compartilhar com vocês o pequeno exemplo de metaclasse mais sofisticado que encontrei durante minha pesquisa para esse capítulo.

24.11. Um hack de metaclasse com __prepare__

Quando atualizei esse capítulo para a segunda edição, precisava encontrar exemplos simples mas reveladores, para substituir o código de LineItem no exemplo da loja de comida a granel, que não precisava mais de metaclasses desde o Python 3.6.

A ideia de metaclasse mais interessante e mais simples me foi dada por João S. O. Bueno—mais conhecido como JS na comunidade Python brasileira. Uma aplicação de sua ideia é criar uma classe que gera constantes numéricas automaticamente:

    >>> class Flavor(AutoConst):
    ...     banana
    ...     coconut
    ...     vanilla
    ...
    >>> Flavor.vanilla
    2
    >>> Flavor.banana, Flavor.coconut
    (0, 1)

Sim, esse código funciona como exibido! Aquilo acima é um doctest em autoconst_demo.py.

Aqui está a classe base fácil de usar AutoConst , e a metaclasse por trás dela, implementadas em autoconst.py:

class AutoConstMeta(type):
    def __prepare__(name, bases, **kwargs):
        return WilyDict()

class AutoConst(metaclass=AutoConstMeta):
    pass

É só isso.

Claramente, o truque está em WilyDict.

Quando o Python processa o espaço de nomes da classe do usuário e lê banana, ele procura aquele nome no mapeamento fornecido por __prepare__: uma instância de WilyDict. WilyDict implementa __missing__, tratado na Seção 3.5.2. A instância de WilyDict inicialmente não contém uma chave 'banana', então o método __missing__ é acionado. Ele cria um item em tempo real, com a chave 'banana' e o valor 0, e devolve esse valor. O Python se contenta com isso, e daí tenta recuperar 'coconut'. WilyDict imediatamente adiciona aquele item com o valor 1, e o devolve. O mesmo acontece com 'vanilla', que é então mapeado para 2.

Já vimos __prepare__ e __missing__ antes. A verdadeira inovação é a forma como JS as juntou.

Aqui está o código-fonte de WilyDict, também de autoconst.py:

class WilyDict(dict):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.__next_value = 0

    def __missing__(self, key):
        if key.startswith('__') and key.endswith('__'):
            raise KeyError(key)
        self[key] = value = self.__next_value
        self.__next_value += 1
        return value

Enquanto experimentava, descobri que o Python procurava __name__ no espaço de nomes da classe em construção, fazendo com que WilyDict acrescentasse um item __name__ e incrementasse __next_value. Eu então inseri uma instrução if em __missing__, para gerar um KeyError para chaves que se parecem com atributos dunder.

O pacote autoconst.py tanto exige quanto ilustra o mecanismo de criação dinâmica de classes do Python.

Me diverti muito adicionando mais funcionalidades a AutoConstMeta e AutoConst, mas em vez de compartilhar meus experimentos, vou deixar vocês se divertirem, brincando com o hack genial de JS.

Aqui estão algumas ideias:

  • Torne possivel obter o nome da constante a partir do valor. Por exemplo, Flavor[2] devolveria 'vanilla'. Você pode fazer isso implementando __getitem__ em AutoConstMeta. Desde o Python 3.9, épossível implementar __class_getitem__ na própria AutoConst.

  • Suporte a iteração sobre a classe, implementando __iter__ na metaclasse. Eu faria __iter__ produzir as constantes na forma de pares (name, value).

  • Implemente uma nova variante de Enum. Isso seria um empreeendimento complexo, pois o pacote enum está cheio de armadilhas, incluindo a metaclasse EnumMeta, com centenas de linhas de código e um método __prepare__ nem um pouco trivial.

Divirta-se!

✒️ Nota

O método especial __class_getitem__ foi introduzido no Python 3.9 para suportar tipos genéricos, como parte da PEP 585—Type Hinting Generics In Standard Collections (Dicas de Tipos Genéricas em Coleções Padrão) (EN). Graças a __class_getitem__, os desenvolvedores principais do Python não precisaram escrever uma nova metaclasse para que os tipos embutidos implementassem __getitem__, de modo que fosse possível escrever dicas de tipo genéricas, tal como list[int]. Esse é um recurso restrito, mas representativo, de um caso de uso mais amplo para metaclasses: implementar operadores e outros métodos especiais para funcionarem a nível de classes, tal como fazer a própria classe iterável, como as subclasses de Enum.

24.12. Para encerrar

Metaclasses, bem como decoradores de classes e __init_subclass__, são úteis para:

  • Registro de subclasses

  • Validação estrutural de subclasses

  • Aplicar decoradores a muitos métodos ao mesmo tempo

  • Serialização de objetos

  • Mapeamento objeto-relacional

  • Persistência baseada em objetos

  • Implementar métodos especiais a nível de classe

  • Implementar recursos de classes encontrados em outras linguagens, tal como traits (traços) (EN) e programação orientada a aspecto

Em alguns casos, a metaprogramação de classes também pode ajudar em questões de desempenho, executando tarefas no momento da importação que de outra forma seriam executadas repetidamente durante a execução.

Para finalizar, vamos nos lembrar do conselho final de Alex Martelli em seu ensaio Pássaros aquáticos e as ABCs:

E não defina ABCs personalizadas (ou metaclasses) em código de produção. Se você sentir uma forte necessidade de fazer isso, aposto que é um caso da síndrome de "todos os problemas se parecem com um prego" em alguém que acabou de ganhar um novo martelo brilhante - você ( e os futuros mantenedores de seu código) serão muito mais felizes se limitando a código simples e direto, e evitando tais profundezas.

Acredito que o conselho de Martelli se aplica não apenas a ABCs e metaclasses, mas também a hierarquias de classe, sobrecarga de operadores, decoradores de funções, descritores, decoradores de classes e fábricas de classes usando __init_subclass__.

Em princípio, essas poderosas ferramentas existem para suportar o desenvolvimento de bibliotecas e frameworks. Naturalmente, as aplicações devem usar tais ferramentas, na forma oferecida pela biblioteca padrão do Python ou por pacotes externos. Mas implementá-las em código de aplicações é frequentemente resultado de uma abstração prematura.

Bons frameworks são extraídos, não inventados.[359]

— David Heinemeier Hansson
criador do Ruby on Rails

24.13. Resumo do capítulo

Este capítulo começou com uma revisão dos atributos encontrados em objetos classe, tais como __qualname__ e o método __subclasses__(). A seguir, vimos como a classe embutida type pode ser usada para criar classes durante a execução.

O método especial __init_subclass__ foi introduzido, com a primeira versão de uma classe base Checked, projetada para substituir dicas de tipo de atributos em subclasses definidas pelo usuário por instâncias de Field, que usam construtores para impor o tipo daqueles atributos durante a execução.

A mesma ideia foi implementada com um decorador de classes @checked, que acrescenta recursos a classes definidas pelo usuário, de forma similar ao que pode ser feito com __init_subclass__. Vimos que nem __init_subclass__ nem um decorador de classes podem configurar __slots__ dinamicamente, pois operam apenas após a criação da classe.

Os conceitos de "[momento/tempo de] importação" e "[momento/tempo de] execução" foram esclarecidos com experimentos mostrando a ordem na qual o código Python é executado quando módulos, descritores, decoradores de classe e __init_subclass__ estão envolvidos.

Nossa exploração de metaclasses começou com um explicação geral de type como uma metaclasse, e sobre como metaclasses definidas pelo usuário podem implementar __new__, para personalziar as classes que criam. Vimos então nossa primeira metaclasse personalizada, o clássico exemplo MetaBunch, usando __slots__. A seguir, outro experimento com o tempo de avaliação demonstrou como os métodos __prepare__ e __new__ de uma metaclasse são invocados mais cedo que __init_subclass__ e decoradores de classe, oferecendo oportunidades para uma personalização de classes mais profunda.

A terceira versão de uma fábrica de classes Checked, com descritores Field e uma configuração personalizada de __slots__ foi apresentada, seguida de considerações gerais sobre o uso de metaclasses na prática.

Por fim, vimos o hack AutoConst, inventado por João S. O. Bueno, baseado na brilhante ideia de uma metaclasse com __prepare__ devolvendo um mapeamento que implementa __missing__. Em menos de 20 linhas de código, autoconst.py demonstra o poder da combinação de técnicas de metaprogramação no Python.

Nunca encontrei outra linguagem como o Python, fácil para iniciantes, prática para profissionais e empolgante para hackers. Obrigado, Guido van Rossum e todos que a fazem ser assim.

24.14. Leitura complementar

Caleb Hattingh—um dos revisores técnicos desse livro—escreveu o pacote autoslot, fornecendo uma metaclasse para a criação automática do atributo __slots__ em uma classe definida pelo usuário, através da inspeção do bytecode de __init__ e da identificação de todas as atribuições a atributos de self. Além de útil, esse pacote é um excelente exemplo para estudo: são apenas 74 linhas de código em autoslot.py, incluindo 20 linhas de comentários que explicam as partes mais difíceis.

As referências essenciais deste capítulo na documentação do Python são "3.3.3. Personalizando a criação de classe" no capítulo "Modelos de Dados" da Referência da Linguagem Python, que cobre __init_subclass__ e metaclasses. A documentação da classe type na página "Funções Embutidas", e "4.13. Atributos especiais" do capítulo "Tipos embutidos" na Biblioteca Padrão do Python também são leituras fundamentais.

Na Biblioteca Padrão do Python, a documentação do módulo types trata de duas funções introduzidas no Python 3.3, que simplificam a metaprogramação de classes: types.new_class and types.prepare_class.

Decoradores de classes foram formalizados na PEP 3129—Class Decorators (Decoradores de Classes) (EN), escrita por Collin Winter, com a implemetação de referência desenvolvida por Jack Diederich. A palestra "Class Decorators: Radically Simple" (Decoradores de Classes: Radicalmente Simples. Aqui o video (EN)), na PyCon 2009, também de Jack Diederich, é uma rápida introdução a esse recurso. Além de @dataclass, um exemplo interessante—e muito mais simples—de decorador de classes na bilbioteca padrão do Python é functools.total_ordering (EN), que gera métodos especiais para comparação de objetos.

Para metaclasses, a principal referência na documentação do Python é a PEP 3115—Metaclasses in Python 3000 (Metaclasses no Python 3000), onde o método especial __prepare__ foi introduzido.

O Python in a Nutshell, 3ª ed., de Alex Martelli, Anna Ravenscroft, e Steve Holden, é uma referência, mas foi escrito antes da PEP 487—Simpler customization of class creation (PEP 487—Uma personalização mais simples da criação de classes) ser publicada. O principal exemplo de metaclasse no livro—MetaBunch—ainda é válido, pois não pode ser escrito com mecanismos mais simples. O Effective Python, 2ª ed. (Addison-Wesley), de Brett Slatkin, traz vários exemplos atualizados de técnicas de criação de classes, incluindo metaclasses.

Para aprender sobre as origens da metaprogramação de classes no Python, recomento o artigo de Guido van Rossum de 2003, "Unifying types and classes in Python 2.2" (Unificando tipos e classes no Python 2.2) (EN). O texto se aplica também ao Python moderno, pois cobre o quê era então chamado de "novo estilo" de semântica de classes—a semântica default no Python 3—incluindo descritores e metaclasses. Uma das referências citadas por Guido é Putting Metaclasses to Work: a New Dimension in Object-Oriented Programming, de Ira R. Forman e Scott H. Danforth (Addison-Wesley), livro para o qual ele deu cinco estrelas na Amazon.com, acrescentando o seguinte comentário:

Este livro contribuiu para o projeto das metaclasses no Python 2.2

Pena que esteja fora de catálogo; sempre me refiro a ele como o melhor tutorial que conheço para o difícil tópico da herança múltipla cooperativa, suportada pelo Python através da função super().[360]

Se você gosta de metaprogramação, talvez gostaria que o Python suportasse o recurso definitivo de metaprogramação: macros sintáticas, como as oferecidas pela família de linguagens Lisp e—mais recentemente—pelo Elixir e pelo Rust. Macros sintáticas são mais poderosas e menos sujeitas a erros que as macros primitivas de substituição de código da linguagem C. Elas são funções especiais que reescrevem código-fonte para código padronizado, usando uma sintaxe personalizada, antes da etapa de compilação, permitindo a desenvolvedores introduzir novas estruturas na linguagem sem modificar o compilador. Como a sobrecarga de operadores, macros sintáticas podem ser mal usadas. Mas, desde que a comunidade entenda e gerencie as desvantagens, elas suportam abstrações poderosas e amigáveis, como as DSLs (Domain-Specific Languages—Linguagens de Domínio Específico). Em setembro de 2020, Marc Shannon, um dos desenvolvedores principais do Python, publicou a PEP 638—Syntactic Macros (Macros Sintáticas) (EN), defendendo exatamente isso. Um ano após sua publicação inicial (quando escrevo essas linhas), a PEP 638 ainda era um rascunho e não havia discussões contínuas sobre ela. Claramente não é uma prioridade muito alta entre os desenvolvedores principais do Python. Eu gostaria de ver a PEP 638 sendo melhor discutida e, por fim, aprovada. Macros sintáticas permitiriam à comunidade Python experimentar com novos recursos controversos, tal como o "operador morsa" (operador walrus) (PEP 572 (EN)), correspondência/casamento de padrões (PEP 634 (EN)) e regras alternativas para avaliação de dicas de tipo (PEPs 563 (EN) e 649 (EN)), antes que se fizessem modificações permanentes no núcleo da linguagem. Nesse meio tempo, podemos sentir o gosto das macros sintáticas com o pacote MacroPy.

Ponto de vista

Vou iniciar o último ponto de vista no livro com uma longa citação de Brian Harvey e Matthew Wright, dois professores de ciência da computação da Universidade da California (Berkeley e Santa Barbara). Em seu livro, Simply Scheme: Introducing Computer Science ("Simplesmente Scheme: Introduzindo a Ciência da Computação") (MIT Press), Harvey e Wright escreveram:

Há duas escolas de pensamento sobre o ensino de ciência da computação. Podemos representar as duas visões de uma forma caricatual, assim:

  1. A visão conservadora: Programas de computador se tornaram muito grandes e complexos para serem apreendidos pela mente humana. Portanto, a tarefa da educação na ciência da computação é ensinar os estudantes como se disciplinarem, de tal forma que 500 programadores medíocres possam se juntar e produzir um programa que atende suas especificações.

  2. A visão radical: Programas de computador se tornaram muito grandes e complexos para serem apreendidos pela mente humana. Portanto, a tarefa da educação na ciência da computação é ensinar os estudantes como expandir suas mentes até que os programas caibam ali, aprendendo a pensar com um vocabulário de ideias maiores, mais poderosas e mais flexíveis que aquelas óbvias. Cada unidade de pensamento programático deve gerar uma grande recompensa para as capacidades do programa.[361]

— Brian Harvey and Matthew Wright
no prefácio de Simply Scheme

As descrições exageradas de Harvey e Wright versam sobre o ensino de ciência da computação, mas também se aplicam ao projeto de linguagens de programação. Nesse ponto você já deve ter adivinhado que eu concordo com a visão "radical", e acredito que o Python foi projetado nesse espírito.

A ideia de propriedade é um grande passo adiante, comparado com a abordagem "métodos de acesso desde o início", praticamente exigida em Java e suportada pela geração de getters/setters através de atalhos do teclado por IDEs Java. A principal vantagem das propriedades é nos permitir começar a criar nossos programas simplesmente expondo atributos publicamente—no espírito do KISS—sabendo que um atributo público pode se tornar uma propriedade a qualquer momento sem quebrar código existente. Mas a ideia de descritor vai muito além disso, fornecendo um framework para abstrair lógica repetitiva para acessar atributos. Esse framework é tão eficiente que mecanismos essenciais do Python o utilizam por baixo dos panos.

Outra ideia poderosa são as funções como objetos de primeira classe, pavimentando o caminho para funções de ordem superior. E acontece que a combinação de descritores e funções de ordem superior permite a unificação de funções e métodos. O __get__ de uma função produz um objeto método em tempo real, vinculando a instância ao argumento self. Isso é elegante.[362]

Por fim, temos a ideia de classes como objetos de primeira classe. É uma façanha marcante do projeto, que uma linguagem acessível para um iniciante forneça abstrações poderosas, tais como fábricas de classe, decoradores de classe, e metaclasses completas e definidas pelo usuário. Melhor ainda, os recursos avançados estão integrados de forma a não afetar a adequação do Python para programação casual (eles na verdade ajudam nisso, por trás da cortina). A conveniência e o sucesso de frameworks como o Django e o SQLAlchemy devem muito às metaclasses. Ao longo dos anos, a metaprogramação de classes em Python está se tornando cada vez mais simples, pelo menos para os casos de uso comuns. Os melhores recursos da linguagem são aqueles que beneficiam a todos, mesmo que alguns usuários do Python não os conheçam. Mas esses usuários sempre podem aprender, e criar a próxima grande biblioteca.

Espero notícias sobre suas contribuições ao ecossistema e à comunidade do Python!

Posfácio

O Python é uma linguagem para programação consensual entre adultos.

— Alan Runyan
co-fundador do Plone

A definição sagaz do Alan expressa uma das melhores qualidades do Python: ele sai da frente e deixa você fazer o que for preciso. Isso também significa que a linguagem não dá ferramentas para restringir o que outros podem fazer com seu código e com os objetos que ele cria.

Aos 30, o Python segue ganhando popularidade. Mas, claro, não é perfeito. Entre minhas maiores irritações está o uso inconsistente do CamelCase [363], do snake_case [364], e joinedwords [365] na biblioteca padrão. Mas a definição da linguagem e a biblioteca padrão são apenas uma parte de um ecossistema. A comunidade de usuários e colaboradores é a melhor parte do ecossistema Python.

Aqui está um exemplo da força da comunidade: quando estava escrevendo sobre asyncio na primeira edição, ficava frustrado porque a API tem muitas funções, dezenas das quais são corrotinas, e era preciso invocar corrotinas com yield from—agora com await—mas você não pode fazer isso com funções regulares. Isso estava documentado nas páginas do asyncio, mas por vezes era necessário ler alguns parágrafos para descobrir se uma função específica era uma corrotina. Então mandei uma mensagem para a python-tulip, intitulada "Proposal: make coroutines stand out in the asyncio docs" (Proposta: destacar as corrotinas na documentação do asyncio). Victor Stinner, um dos desenvolvedores principais do asyncio; Andrew Svetlov, principal autor do aiohttp; Ben Darnell, desenvolvedor principal do Tornado; e Glyph Lefkowitz, inventor do Twisted, se juntaram à conversa. Darnell sugeriu uma solução, Alexander Shorin explicou como implementá-la no Sphinx, e Stinner acrescentou a configuração e as marcações necessárias. Em menos de 12 horas após eu ter relatado o problema, toda a documentação online do asyncio estava atualizada com as tags coroutine que vemos hoje.

Isso não se passou em um clube exclusivo. Qualquer pessoa pode se juntar à lista python-tulip, e eu havia escrito ali apenas algumas vezes antes de enviar aquela proposta. Essa história mostra uma comunidade realmente aberta a novas ideias e a novos membros. Guido van Rossum costumava frequentar a python-tulip, e muitas vezes respondia perguntas básicas.

Outro exemplo de abertura: a Python Software Foundation (PSF) tem trabalhado para aumentar a diversidade na comunidade Python. Alguns resultados encorajadores já apareceram. A diretoria para 2013–2014 da PSF viu as primeiras diretoras eleitas: Jessica McKellar e Lynn Root. Em 2015, Diana Clarke presidiu a PyCon North America em Montreal, onde cerca de um terço dos palestrantes foram mulheres. A PyLadies se tornou um movimento verdadeiramente global, e me orgulha que tenhamos tantas seções da PyLadies no Brasil.

Se você é um pythonista mas ainda não se envolveu com a comunidade, encorajo você a fazê-lo. Procure a PyLadies ou um Grupo de Usuários Python na sua vizinhança. Se nenhum existir, crie um. O Python está em todo lugar, então você não ficará sozinho. Viaje para eventos, se puder. Junte-se também a eventos online. Durante a pandemia de Covid-19, aprendi muito nos "encontros no hall" das conferências online. Venha a uma conferência da PythonBrasil—​temos a presença de palestrantes estrangeiros há anos. Encontrar outros pythonistas traz benefícios reais além do compartilhamento de conhecimento. Como empregos reais e amizades reais.

Sei que não conseguiria escrever esse livro sem a ajuda dos muitos amigos que fiz na communidade Python ao longo desses anos.

Meu pai, Jairo Ramalho, costumava dizer que "Só erra quem trabalha", um ótimo conselho para não se deixar paralisar pelo medo de cometer erros. Eu certamente cometi minha cota de erros durante a escrita desse livro. Os revisores, editores e leitores das versões iniciais pegaram muitos deles. Horas após o pré-lançamento da primeira edição, um leitor já estava enviando relatórios de erros ortográficos na página de erratas do livro. Outros leitores contribuiram com mais relatórios, e amigos me contataram diretamente com sugestões e correções. Os revisores da O’Reilly irão achar outros erros durante o processo de produção, que começará assim que eu conseguir parar de escrever. Assumo toda a responsabilidade e me desculpo por qualquer erro ou trechos com escrita truncada que restarem.

Estou muito feliz em concluir essa segunda edição, com erros e tudo, e estou muito agredecido a todos que me ajudaram pelo caminho.

Espero encontrar você logo em algum evento. Por favor, venha dizer olá se nos cruzarmos por aí!

Leitura complementar

Vou encerrar com referências sobre o que significa ser "pythônico”—a principal questão que tentei apresentar neste livro.

Brandon Rhodes é um fantático professor de Python, e sua palestra "A Python Æsthetic: Beauty and Why I Python" (Uma Estética Python: o Belo e Porque Eu 'Pythono') (EN) é linda, começando pelo uso do caractere Unicode U+00C6 (LATIN CAPITAL LETTER AE) no título (em inglês). Outro professor maravilhoso, Raymond Hettinger, falou sobre a beleza no Python na PyCon US 2013: "Transforming Code into Beautiful, Idiomatic Python" (Transformando Código em Belo Python Idiomático) (EN).

A thread "Evolution of Style Guides" (A Evolução dos Guias de Estilo) (EN), iniciada por Ian Lee no Python-ideas, vale a leitura. Lee é o mantenedor do pacote pep8, que verifica código Python quanto à aderência ao PEP 8. Para verificar o código deste livro usei o flake8, que inclui o pep8, pyflakes, e o McCabe complexity plug-in (plug-in de complexidade McCabe ou complexidade ciclomática), de Ned Batchelder.

Além do PEP 8, outros influentes guias de estilo são o Google Python Style Guide ("Guia de Estilo Python do Google") e o Pocoo Styleguide ("Guia de Estilo Pocoo"), do mesmo grupo que nos deu o Flake, o Sphinx, a Jinja 2 e outras ótimas bibliotecas Python.

The Hitchhiker’s Guide to Python! ("O Guia do Mochileiro Python") é um esforço coletivo sobre a escrita de código pythônico. Seu contribuidor mais prolífico é Kenneth Reitz, um herói da comunidade devido a seu maravilhoso e pythônico pacote requests. David Goodger apresentou um tutorial na PyCon US 2008 intitulado "Code Like a Pythonista: Idiomatic Python" (Programe como um Pythonista: Python Idiomático). Se impressas, as notas do tutorial tem 30 páginas. Goodger criou tanto reStructuredText quanto docutils—as bases do Sphinx, o excelente sistema de documentação do Python (que, por sinal, também é o sistema oficial de documentação do MongoDB e de muitos outros projetos).

Martijn Faassen enfrenta a questão diretamente em "What is Pythonic?" (O que é pythônico?) Na python-list há uma thread com o mesmo título. O post de Martijn é de 2005, e a thread de 2003, mas o ideal pythônico não mudou muito—e aliás, nem a própria linguagem mudou tanto. Uma ótima thread com "pythônico" no título é "Pythonic way to sum n-th list element?" (A forma pythônica de somar os "n" elementos de uma lista), que citei extensivamente no Ponto de vista.

A PEP 3099 — Things that will Not Change in Python 3000 (PEP 3099 — Coisas que não vão mudar no Python 3000) explica porque muitas coisas são como são, mesmo após uma revisão profunda como foi o Python 3. Por muito tempo, o Python 3 foi apelidado Python 3000, mas acabou chegando alguns séculos adiantado—​para o desespero de alguns. A PEP 3099 foi escrita por Georg Brandl, compilando muitas opiniões expressas pelo BDFL Guido van Rossum. A página "Python Essays" (Ensaios sobre Python) lista vários textos do próprio Guido.


1. Mensagem para o grupo da Usenet comp.lang.python em 23 de dezembro de 2002: "Acrimony in c.l.p" (EN).
2. Read-Eval-Print Loop, o nome acadêmico de um console interativo que funciona como um laço lendo código, avaliando, e exibindo resultados.
3. NT: Nesta edição em português também usamos itálico em alguns termos mantidos em inglês ou traduções de termos cuja versão em português não é familiar
4. Uma struct do C é um tipo de registro com campos nomeados.
5. Agradeço à leitora Tina Lapine por apontar essa informação.
6. Operadores invertidos são explicados no Capítulo 16.
7. Também usado para sobrescrever uma sub-sequência. Veja a Seção 2.7.4.
8. Agradeço ao revisor técnico Leonardo Rochael por esse exemplo.
9. NT: A tradução em português da documentação do Python adotou o termo "correspondência de padrões" no lugar de pattern matching. Escolhemos manter o termo em inglês, pois é usado nas comunidades brasileiras de linguagens que implementam pattern matching há muitos anos, como por exemplo Scala, Elixir e Haskell. Naturalmente mantivemos os títulos originais nos links externos.
10. Na minha opinião, uma sucessão if/elif/elif/…​/else funciona muito bem como no lugar de switch/case. E ela não sofre dos problemas de fallthrough (cascateamento) (EN) e de dangling else (o else errante) (EN), que alguns projetistas de linguagens copiaram irracionalmente do C—décadas após sabermos que tais problemas são a causa de inúmeros bugs.
11. A última é chamada eval no código original; a renomeei para evitar que fosse confundida com a função embutida eval do Python.
12. Na Seção 2.10.2 vamos mostrar que views da memória construídas de forma especial podem ter mais de uma dimensão.
13. Não, eu não escrevi ao contrário: o nome da classe ellipsis realmente se escreve só com minúsculas, e a instância é um objeto embutido chamado Ellipsis, da mesma forma que bool é em minúsculas mas suas instâncias são True e False.
14. str é uma exceção a essa descrição. Como criar strings com += em loops é tão comum em bases de código reais, o CPython foi otimizado para esse caso de uso. Instâncias de str são alocadas na memória com espaço extra, então a concatenação não exige a cópia da string inteira a cada operação.
15. Agradeço a Leonardo Rochael e Cesar Kawakami por compartilharem esse enigma na Conferência PythonBrasil de 2013.
16. Alguns leitores sugeriram que a operação no exemplo pode ser realizada com t[2].extend([50,60]), sem erros. Eu sei disso, mas a intenção aqui é mostrar o comportamento estranho do operador += nesse caso.
17. Receptor (receiver) é o alvo de uma chamada a um método, o objeto vinculado a self no corpo do método.
18. O principal algoritmo de ordenação do Python se chama Timsort, em homenagem a seu criador, Tim Peters. Para curiosidades sobre o Timsort, veja o Ponto de Vista.
19. As palavras nesse exemplo estão ordenadas em ordem alfabética porque são 100% constituídas de caracteres ASCII em letras minúsculas. Veja o aviso após o exemplo.
20. Sigla em inglês para "First in, first out" (Primeiro a entrar, primeiro a sair—o comportamento padrão de filas.
21. Operadores invertidos são explicados no Capítulo 16.
22. Operadores invertidos são explicados no Capítulo 16.
23. a_list.pop(p) permite remover da posição p, mas deque não suporta essa opção.
24. NT: Conceito da lei de copyright norte-americana que permite, em determinadas circunstâncias, o uso sem custo de partes da propriedade intelectual de outros. Em geral traduzido como "uso razoável" ou "uso aceitável". Essa doutrina não faz parte da lei brasileira.
25. Uma subclasse virtual é qualquer classe registrada com uma chamada ao método .register() de uma ABC, como explicado na Seção 13.5.6. Um tipo implementado através da API Python/C também é aceitável, se um bit de marcação específico estiver acionado. Veja Py_TPFLAGS_MAPPING (EN).
26. O verbete para "hashable" no Glossário do Python usa o termo "valor de hash" em vez de código de hash. Prefiro código de hash porque "valor" é um conceito frequentemente usado no contexto de mapeamentos, onde itens são compostos de chavas e valores. Então pode ser confuso se referir ao código de hash como um valor. Nesse livro usarei apenas código de hash.
27. Veja a PEP 456—Secure and interchangeable hash algorithm (Algoritmo de hash seguro e intercambiável_) (EN) para saber mais sobre as implicações de segurança e as soluções adotadas.
28. default_factory não é um método, mas um atributo chamável definido pelo usuário quando um defaultdict é instanciado.
29. OrderedDict.popitem(last=False) remove o primeiro item inserido (FIFO). O argumento nomeado last não é suportado por dict ou defaultdict, pelo menos até o Python 3.10b3.
30. Operadores invertidos são tratados no Capítulo 16.
31. O script original aparece no slide 41 da apresentação de Martelli, "Re-learning Python" (Reaprendendo Python) (EN). O script é, na verdade, uma demonstração de dict.setdefault, como visto no nosso Exemplo 5.
32. Isso é um exemplo do uso de um método como uma função de primeria classe, o assunto do Capítulo 7.
33. NT: "Missing" significa ausente, perdido ou desaparecido
34. Uma biblioteca dessas é a Pingo.io, que não está mais em desenvolvimento ativo.
35. NT: Least Recently Used, Menos Recentemente Usado, esquema de cache que descarta o item armazenado que esteja há mais tempo sem requisições
36. NT: "to shelve" é "colocar na prateleira", "pickle" também significa "conserva" e "pickles" é literalmente picles. O trocadilho dos desenvolvedores do Python é sobre colocar pickles em shelves.
37. O problema exato de se criar subclasses de dict e de outros tipos embutidos é tratado na Seção 14.3.
38. É assim que as tuplas são armazenadas.
39. A menos que classe tenha umm atributo __slots__, como explicado na Seção 11.11.
40. Isso pode ser interessante, mas não é super importante. Essa diferença de velocidade vai ocorrer apenas quando um conjunto literal for avaliado, e isso acontece no máximo uma vez por processo Python—quando um módulo é compilado pela primeira vez. Se você estiver curiosa, importe a função dis do módulo dis, e a use para desmontar os bytecodes para um set literal—por exemplo, dis('{1}')—e uma chamada a setdis('set([1])')
41. NT: Na teoria dos conjuntos, A é um subconjunto próprio de B se A é subconjunto de B e A é diferente de B.
42. NT: Explicando o trocadilho intraduzível: "colon", em inglês, designa "a parte central do intestino grosso"; "semicolon", por outro lado, é "ponto e vírgula". A frase diz, literalmente, "Açúcar sintático causa câncer no ponto e vírgula", que faz sentido em inglês pela proximidade ortográfica das duas palavras.
43. Slide 12 da palestra "Character Encoding and Unicode in Python" (Codificação de Caracteres e Unicode no Python) na PyCon 2014 (slides (EN), vídeo (EN)).
44. O Python 2.6 e o 2.7 também tinham um bytes, mas ele era só um apelido (alias) para o tipo str.
45. Trívia: O caractere ASCII "aspas simples", que por default o Python usa como delimitador de strings, na verdade se chama APOSTROPHE no padrão Unicode. As aspas simples reais são assimétricas: a da esquerda é U+2018 e a da direita U+2019.
46. Ele não funcionava do Python 3.0 ao 3.4, causando muitas dores de cabeça nos desenvolvedores que lidam com dados binários. O retorno está documentado na PEP 461—​Adding % formatting to bytes and bytearray (Acrescentando formatação com % a bytes e bytearray). (EN)
47. A primeira vez que vi o termo "Unicode sandwich" (sanduíche de Unicode) foi na excelente apresentação de Ned Batchelder, "Pragmatic Unicode" (Unicode pragmático) (EN) na US PyCon 2012.
48. Fonte: "Windows Command-Line: Unicode and UTF-8 Output Text Buffer" (A Linha de Comando do Windows: O Buffer de Saída de Texto para Unicode e UTF-8).
49. Curiosamente, o símbolo de micro é considerado um "caractere de compatibilidade", mas o símbolo de ohm não. O resultado disso é que a NFC não toca no símbolo de micro, mas muda o símbolo de ohm para ômega maiúsculo, ao passo que a NFKC e a NFKD mudam tanto o ohm quanto o micro para caracteres gregos.
50. NT: algo como "dobra" ou "mudança" de caixa.
51. Sinais diacríticos afetam a ordenação apenas nos raros casos em que eles são a única diferennça entre duas palavras—nesse caso, a palavra com o sinal diacrítico é colocada após a palavra sem o sinal na ordenação.
52. De novo, eu não consegui encontrar uma solução, mas encontrei outras pessoas relatando o mesmo problema. Alex Martelli, um dos revisores técnicos, não teve problemas para usar setlocale e locale.strxfrm em seu Macintosh com o macOS 10.9. Em resumo: cada caso é um caso.
53. Aquilo é uma imagem—não uma listagem de código—porque, no momento em que esse capítulo foi escrito, os emojis não tem um bom suporte no sistema de publicação digital da O’Reilly.
54. Embora não tenha se saído melhor que o re para identificar dígitos nessa amostra em particular.
55. As metaclasses são um dos assuntos tratados no #class_metaprog.
56. Decoradores de classe são discutidos no Capítulo 24, na seção "Metaprogramação de classes", junto com as metaclasses. Ambos são formas de personalizar o comportamento de uma classe além do que seria possível com herança.
57. Se você conhece Ruby, sabe que injetar métodos é uma técnica bastante conhecida, apesar de controversa, entre rubystas. Em Python isso não é tão comum, pois não funciona com nenhum dos tipos embutidos—str, list, etc. Considero essa limitação do Python uma benção.
58. No contexto das dicas de tipo, None não é o singleton NoneType, mas um apelido para o próprio NoneType. Se pararmos para pensar, isso é estranho, mas agrada nossa intuição e torna as anotações de valores devolvidos por uma função mais fáceis de ler, no caso comum de funções que devolvem None.
59. O conceito de undefined, um dos erros mais idiotas no design do Javascript, não existe no Python. Obrigado, Guido!
60. NT: Um getter é um método que devolve o valor um atributo do objeto. Para propriedades mutáveis, o getter vem geralmente acompanhado por um setter, que modifica a mesma propriedade. Os nomes derivam dos verbos em inglês get (obter, receber) e set (definir, estabelecer).
61. Definir um atributo após o __init__ prejudica a otimização de uso de memória com o compartilhamento das chaves do __dict__, mencionada na Seção 3.9.
62. O @dataclass emula a imutabilidade criando um __setattr__ e um __delattr__ que geram um dataclass.FrozenInstanceError—uma subclasse de AttributeError—quando o usuário tenta definir ou apagar o valor de um campo.
63. dataclass._MISSING_TYPE é um valor sentinela, indicando que a opção não foi fornecida. Ele existe para que se possa definir None como um valor default efetivo, um caso de uso comum.
64. A opção hash=None significa que o campo será usado em __hash__ apenas se compare=True.
65. Fonte: O artigo Dublin Core na Wikipedia.
66. NT: Code smell em geral não é traduzido na bibliografia em português—uma tradução quase literal seria "fedor no código". Uma tradução mais gentil pode ser "cheiro no código", adotado aqui. Mais gentil e menos enviesada: um "cheiro no código" nem sempre é indicação de um problema.
67. Eu tenho a felicidade de ter Martin Fowler como colega de trabalho na Thoughtworks, estão precisei de apenas 20 minutos para obter sua permissão.
68. Trato desse conteúdo aqui por ser o primeiro capítulo sobre classes definidas pelo usuário, e acho que pattern matching com classes é um assunto muito importante para esperar até a Parte II: Funções como objetos do livro. Minha filosofia: é mais importante saber como usar classes que como defini-las.
69. Lynn Andrea Stein é uma aclamada educadora de ciências da computação. Ela atualmente leciona na Olin College of Engineering (EN).
70. Ao contrário de sequências simples de tipo único, como str, byte e array.array, que não contêm referências e sim seu conteúdo — caracteres, bytes e números — armazenado em um espaço contíguo de memória.
71. Ver Principle of least astonishment (EN).
72. Isso está claramente documentado. Digite help(tuple) no console do Python e leia: "Se o argumento é uma tupla, o valor de retorno é o mesmo objeto." Pensei que sabia tudo sobre tuplas antes de escrever esse livro.
73. Essa mentirinha inofensiva, do método copy não copiar nada, é justificável pela compatibilidade da interface: torna frozenset mais compatível com set. De qualquer forma, não faz nenhuma diferença para o usuário final se dois objetos imutáveis idênticos são o mesmo ou são cópias.
74. Um péssimo uso dessas informações seria perguntar sobre elas quando entrevistando candidatos a emprego ou criando perguntas para exames de "certificação". Há inúmeros fatos mais importantes e úteis para testar conhecimentos de Python.
75. Na verdade, o tipo de um objeto pode ser modificado, bastando para isso atribuir uma classe diferente ao atributo __class__ do objeto. Mas isso é uma perversão, e eu me arrependo de ter escrito essa nota de rodapé.
76. "Origins of Python’s 'Functional' Features" (As origens dos recursos 'funcionais' do Python—EN), do blog The History of Python (A História do Python) do próprio Guido.
77. "Benevolent Dictator For Life." - Ditador Benevolente Vitalício. Veja Guido van van Rossum em "Origin of BDFL" (A Origem do BDFL) (EN).
78. Invocar uma classe normalmente cria uma instância daquela mesma classe, mas outros comportamentos são possíveis, sobrepondo o __new__. Veremos um exemplo disso na Seção 22.2.3.
79. Por que criar uma BingoCage quando já temos random.choice? A função choice pode devolver o mesmo item múltiplas vezes, pois o item escolhido não é removido da coleção usada. Invocações de BingoCage nunca devolvem um resultado duplicado—desde que a instância tenha sido preenchida com valores únicos.
80. O código fonte de functools.py revela que functools.partial é implementada em C e é usada por default. Se ela não estiver disponível, uma implementação em Python puro de partial está disponível desde o Python 3.4.
81. Há também o problema da perda de indentação quando colamos trechos de código em fóruns na Web, mas isso é outro assunto.
82. NT: Sim, "diferir" também significa "adiar" em português!
83. 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.
84. 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.).
85. 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 Seção 8.5.10 — e com mais detalhes em Capítulo 13.
86. 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.
87. Professora do MIT, designer de linguagens de programação e homenageada com o Turing Award em 2008. Wikipedia: Barbara Liskov.
88. 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.
89. 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.
90. 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.
91. 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.
92. Na verdade, dict é uma subclasse virtual de abc.MutableMapping. O conceito de subclasse virtual será explicado em Capítulo 13. 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.
93. A implementação aqui é mais simples que aquela do módulo statistics na biblioteca padrão do Python
94. Eu contribui com essa solução para typeshed, e em 26 de maio de 2020 mode aparecia anotado assim em statistics.pyi.
95. 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.
96. 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 Seção 17.12.
97. 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.
98. REPL significa Read-Eval-Print-Loop (Ler-Calcular-Imprimir-Recomeçar), o comportamento básico de interpretadores iterativos.
99. "Benevolent Dictator For Life." - Ditador Benevolente Vitalício. Veja Guido van van Rossum em "Origin of BDFL".
100. 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.
101. Fonte: "A Conversation with Alan Kay".
102. GoF se refere ao livro Design Patterns (traduzido no Brasil como "Padrões de Projeto"), de 1985. Seus (quatro) autores ficaram conhecidos como a "Gang of Four" (Gangue dos Quatro).
103. NT: Adotamos a tradução "clausura" para "closure". Alguns autores usam "fechamento". O termo em inglês é pronunciado "clôujure", e o nome da linguagem Clojure brinca com esse fato. Gosto da palavra clausura por uma analogia cultural. Em conventos, a clausura é um espaço fechado onde algumas freiras vivem isoladas. Suas memórias são seu único vínculo com o exterior, mas elas refletem o mundo do passado. Em programação, uma clausura é um espaço isolado onde a função tem acesso a variáveis que existiam quando a própria função foi criada, variáveis de um escopo que não existe mais, preservadas apenas na memória clausura.
104. Se você substituir "função"por "classe" na sentença anterior, o resultado é uma descrição resumida do papel de um decorador de classe. Decoradores de classe são tratadas no Capítulo 24.
105. Agradeço ao revisor técnico Leonardo Rochael por sugerir esse resumo.
106. O Python não tem um escopo global de programa, apenas escopos globais de módulos.
107. Esclarecendo, isso não é um erro de ortografia: memoization é um termo da ciência da computação vagamente relacionado a "memorização", mas não idêntico.
108. Infelizmente, o Mypy 0.770 reclama quando vê múltiplas funções com o mesmo nome.
109. Apesar do alerta em Seção 8.5.7.1, as ABCs de numbers não foram descontinuadas, e você as encontra em código de Python 3.
110. Talvez algum dia seja possível expressar isso com um único @htmlize.register sem parâmetros, e uma dica de tipo usando Union. Mas quando tentei, o Python gerou um TypeError com uma mensagem dizendo que Union não é uma classe. Então, apesar da sintaxe da PEP 484 ser suportada, a semântica ainda não chegou lá.
111. NumPy, por exemplo, implementa vários tipos de números inteiros e de ponto flutuante (EN) em formatos voltados para a arquitetura da máquina.
112. O revisor técnico Miroslav Šedivý observou: "Isso também quer dizer que analisadores de código-fonte (linters) vão reclamar de variáveis não utilizadas, pois eles tendem a ignorar o uso de locals()." Sim, esse é mais um exemplo de como ferramentas estáticas de verificação desencorajam o uso dos recursos dinâmicos do Python que primeiro me atraíram (e a incontáveis outros programadores) na linguagem. Para fazer o linter feliz, eu poderia escrever cada variável local duas vezes na chamada: fmt.format(elapsed=​elapsed, name=name, args=args, result=result). Prefiro não fazer isso. Se você usa ferramentas estáticas de verificação, é importante saber quando ignorá-las.
113. Como queria manter o código o mais simples possível, não segui o excelente conselho de Slatkin em todos os exemplos.
114. De um slide na palestra "Root Cause Analysis of Some Faults in Design Patterns," (Análise das Causas Básicas de Alguns Defeitos em Padrões de Projetos), apresentada por Ralph Johnson no IME/CCSL da Universidade de São Paulo, em 15 de novembro de 2014.
115. Visitor, Citado da página 4 da edição em inglês de Padrões de Projeto.
116. Precisei reimplementar Order com @dataclass devido a um bug no Mypy. Você pode ignorar esse detalhe, pois essa classe funciona também com NamedTuple, exatamente como no Exemplo 1. Quando Order é uma NamedTuple, o Mypy 0.910 encerra com erro ao verificar a dica de tipo para promotion. Tentei acrescentar # type ignore àquela linha específica, mas o erro persistia. Entretanto, se Order for criada com @dataclass, o Mypy trata corretamente a mesma dica de tipo. O Issue #9397 não havia sido resolvido em 19 de julho de 2021, quando essa nota foi escrita. Espero que o problema tenha sido solucionado quando você estiver lendo isso. NT: Aparentemente foi resolvido. O Issue #9397 gerou o Issue #12629, fechado com indicação de solucionado em agosto de 2022, o último comentário indicando que a opção de linha de comando --enable-recursive-aliases do Mypy evita os erros relatados).
117. veja a página 323 da edição em inglês de Padrões de Projetos.
118. Ibid., p. 196.
119. Tanto o flake8 quanto o VS Code reclamam que esses nomes são importados mas não são usados. Por definição, ferramentas de análise estática não conseguem entender a natureza dinâmica do Python. Se seguirmos todos os conselhos dessas ferramentas, logo estaremos escrevendo programas austeros e prolixos similares aos do Java, mas com a sintaxe do Python.
120. "Root Cause Analysis of Some Faults in Design Patterns" (Análise das Causas Básicas de Alguns Defeitos em Padrões de Projetos), palestra apresentada por Johnson no IME/CCSL da Universidade de São Paulo, em 15 de novembro de 2014.
121. NT: Literalmente "Tartarugas até embaixo" ou algo como "Tartarugas até onde a vista alcança" ou "Uma torre infinita de tartarugas". Curiosamente, um livro com esse nome foi publicado no Brasil com o título "Mil vezes adeus", na tradição brasileira (especialmente para filmes) de traduzir nomes de obras de forma preguiçosa ou aleatória.
122. Do post no blog de Faassen intitulado What is Pythonic? (O que é Pythônico?)
123. Usei eval para clonar o objeto aqui apenas para mostrar uma característica de repr; para clonar uma instância, a função copy.copy é mais segura e rápida.
124. Essa linha também poderia ser escrita assim: yield self.x; yield.self.y. Terei muito mais a dizer sobre o método especial __iter__, sobre expressões geradoras e sobre a palavra reservada yield no Capítulo 17.
125. Tivemos uma pequena introdução a memoryview e explicamos seu método .cast na Seção 2.10.2.
126. Leonardo Rochael, um dos revisores técnicos deste livro, discorda de minha opinião desabonadora sobre o staticmethod, e recomenda como contra-argumento o post de blog "The Definitive Guide on How to Use Static, Class or Abstract Methods in Python" (O Guia Definitivo sobre Como Usar Métodos Estáticos, de Classe ou Abstratos em Python) (EN), de Julien Danjou. O post de Danjou é muito bom; recomendo sua leitura. Mas ele não foi suficiente para mudar meu ponto de vista sobre staticmethod. Você terá que decidir por si mesmo.
127. Os prós e contras dos atributos privados são assunto da Seção 11.10, mais adiante.
128. Do "Paste Style Guide" (Guia de Estilo do Paste).
129. Em módulos, um único _ no início de um nome de nível superior tem sim um efeito: se você escrever from mymod import *, os nomes com um prefixo _ não são importados de mymod. Entretanto, ainda é possível escrever from mymod import _privatefunc. Isso é explicado no Tutorial do Python, seção 6.1., "Mais sobre módulos".
130. Um exemplo é a documentação do módulo gettext.
131. Se você acha este estado de coisas deprimente e desejaria que o Python fosse mais parecido com o Java nesse aspecto, nem leia minha discussão sobre a força relativa do modificador private do Java no Ponto de Vista.
132. Veja "Simplest Thing that Could Possibly Work: A Conversation with Ward Cunningham, Part V" (A Coisa Mais Simples que Poderia Funcionar: Uma Conversa com Ward Cunningham, Parte V).
133. A função iter() é tratada no Capítulo 17, juntamente com o método __iter__.
134. A pesquisa de atributos é mais complicada que isso; veremos todos detalhes macabros desse processo no Parte V: Metaprogramação. Por ora, essa explicação simplificada nos serve.
135. Apesar de __match_args__ existir para suportar pattern matching desde Python 3.10, definir este atributo em versões anteriores da linguagem é inofensivo. Na primeira edição chamei este atributo de shortcut_names. Com o novo nome, ele cumpre dois papéis: suportar padrões posicionais em instruções case e manter os nomes dos atributos dinâmicos suportados por uma lógica especial em __getattr__ e __setattr__.
136. sum, any, e all cobrem a maioria dos casos de uso comuns de reduce. Veja a discussão na Seção 7.3.1.
137. Vamos considerar seriamente o caso de Vector([1, 2]) == (1, 2) na Seção 16.2.
138. O website Wolfram Mathworld tem um artigo sobre hypersphere (hiperesfera) (EN); na Wikipedia, "hypersphere" redireciona para a página “n-sphere” (EN)footnote:[NT: A Wikipedia tem uma página em português, "N-esfera". Entretanto, enquanto a versão em inglês traz uma extensa explicação matemática, dividida em 12 seções e inúmeras subseções, a versão em português se resume a um parágrafo curto. Preferimos então manter o link para a versão mais completa.
139. Adaptei o código apresentado aqui: em 2003, reduce era uma função embutida, mas no Python 3 precisamos importá-la; também substitui os nomes x e y por my_list e sub (para sub-lista).
140. NT: Aqui Martelli está se referindo à linguagem APL
141. NT:E aqui à linguagem FP
142. O artigo "Monkey patch" (EN) na Wikipedia tem um exemplo engraçado em Python.
143. Por isso a necessidade de testes automatizados.
144. Consultada em 3 de março de 2023.
145. Você também pode, claro, definir suas próprias ABCs - mas eu não recomendaria esse caminho a ninguém, exceto aos mais avançados pythonistas, da mesma forma que eu os desencorajaria de definir suas próprias metaclasses personalizadas…​ e mesmo para os ditos "mais avançados pythonistas", aqueles de nós que exibem o domínio de todos os recantos por mais obscuros da linguagem, essas não são ferramentas de uso frequente. Este tipo de "metaprogramação profunda", se alguma vez for apropriada, o será no contexto dos autores de frameworks abrangentes, projetadas para serem estendidas de forma independente por inúmeras equipes de desenvolvimento diferentes…​ menos que 1% dos "mais avançados pythonistas" precisará disso alguma vez na vida! - A.M
146. Herança múltipla foi considerada nociva e excluída do Java, exceto para interfaces: Interfaces Java podem estender múltiplas interfaces, e classes Java podem implementar múltiplas interfaces.
147. Talvez o cliente precise auditar o randomizador ou a agência queira fornecer um randomizador "viciado". Nunca se sabe…​
148. Antes das ABCs existirem, métodos abstratos levantariam um NotImplementedError para sinalizar que as subclasses eram responsáveis por suas implementações. No Smalltalk-80, o corpo dos métodos abstratos invocaria subclassResponsibility, um método herdado de object que gerava um erro com a mensagem "Minha subclasse deveria ter sobreposto uma de minhas mensagens."
149. A árvore completa está na seção "5.4. Exception hierarchy" da documentação da _Biblioteca Padrão do Python.
150. O verbete @abc.abstractmethod na documentação do módulo abc.
151. Seção 6.5.2 em Capítulo 6 foi dedicado à questão de apelidamento que acabamos de evitar aqui.
152. O truque usado com load() não funciona com loaded(), pois o tipo list não implementa __bool__, o método que eu teria de vincular a loaded. O bool() nativo não precisa de __bool__ para funcionar, porque pode também usar __len__. Veja "4.1. Teste do Valor Verdade" no capítulo "Tipos Embutidos" da documentação do Python.
153. Há toda uma explicação sobre o atributo de classe __mro__ na Seção 14.4. Por agora, essas informações básicas são o suficiente.
154. O conceito de consistência de tipo é explicado na Seção 8.5.1.1.
155. Certo, double()` não é muito útil, exceto como um exemplo. Mas a biblioteca padrão do Python tem muitas funções que não poderiam ser anotadas de modo apropriado antes dos protocolos estáticos serem adicionados, no Python 3.8. Eu ajudei a corrigir alguns bugs no typeshed acrescentando dicas de tipo com o uso de protocolos. Por exemplo, o pull request (nome do processo de pedido de envio de modificações a um repositório de código) que consertou "Should Mypy warn about potential invalid arguments to max? (Deveria o Mypy avisar sobre argumentos potencialmente inválidos passados a max?)" aproveitava um protocolo _SupportsLessThan, que usei para melhorar as anotações de max, min, sorted, e list.sort.
156. O atributo __slots__ é irrelevante para nossa discussão aqui - é uma otimização sobre a qual falamos na Seção 11.11.
157. Agradeço a Ivan Levkivskyi, co-autor da PEP 544 (sobre Protocolos), por apontar que checagem de tipo não é apenas uma questão de verificar se o tipo de x é T: é sobre determinar que o tipo de x é consistente-com T, o que pode ser caro. Não é de se espantar que o Mypy leve alguns segundos para fazer uma verificação de tipo, mesmo em scripts Python curtos.
158. Leia a decisão (EN) do Python Steering Council no python-dev.
159. NT: "papel" aqui é usado no sentido de incorporação de um personagem
160. Qualquer método pode ser chamado, então essa recomendação não diz muito. Talvez "forneça um ou dois métodos"? De qualquer forma, é uma recomendação, não uma regra absoluta.
161. Para detalhes e justificativa, veja por favor a seção sobre @runtime_checkable (EN) na PEP 544—Protocols: Structural subtyping (static duck typing).
162. Novamente, leia por favor "Merging and extending protocols" (EN) na PEP 544 para os detalhes e justificativas.
163. ver Issue #41974—Remove complex.__float__, complex.__floordiv__, etc.
164. Eu não testei todas as outras variantes de float e integer que o NumPy oferece.
165. Os tipos numéricos do NumPy são todos registrados com as ABCs apropriadas de numbers, que o Mypy ignora.
166. Isso é uma mentira bem intencionada da parte do typeshed: a partir do Python 3.9, o tipo embutido complex na verdade não tem mais um método __complex__.
167. Agradeço ao revisor técnico Jürgen Gmach por ter recomentado o post "Interfaces and Protocols".
168. Alan Kay, "The Early History of Smalltalk" (Os Primórdios do Smalltalk), na SIGPLAN Not. 28, 3 (março de 1993), 69–95. Também disponível online (EN). Agradeço a meu amigo Christiano Anderson, por compartilhar essa referência quando eu estava escrevendo este capítulo.
169. Modifiquei apenas a docstring do exemplo, porque a original está errada. Ela diz: "Armazena itens na ordem das chaves adicionadas por último" ("Store items in the order the keys were last added"), mas não é isso o que faz a classe claramente batizada LastUpdatedOrderedDict.
170. Adotamos o termo "receptor" como tradução para receiver, que é o objeto o vinculado um método m no momento da chamada o.m().
171. Também é possível passar apenas o primeiro argumento, mas isso não é útil e pode logo ser descontinuado, com as bênçãos de Guido van Rossum, o próprio criador de super(). Veja a discussão em "Is it time to deprecate unbound super methods?" (É hora de descontinuar métodos "super" não vinculados?).
172. É interessante observar que o C++ diferencia métodos virtuais e não-virtuais. Métodos virtuais tem vinculação tardia, enquanto os métodos não-virtuais são vinculados na compilação. Apesar de todos os métodos que podemos escrever em Python serem de vinculação tardia, como um método virtual, objetos embutidos escritos em C parecem ter métodos não-virtuais por default, pelo menos no CPython.
173. Se você tiver curiosidade, o experimento está no arquivo 14-inheritance/strkeydict_dictsub.py do repositório fluentpython/example-code-2e.
174. Aliás, nesse mesmo tópico, o PyPy se comporta de forma mais "correta" que o CPython, às custas de introduzir uma pequena incompatibilidade. Veja os detalhes em "Differences between PyPy and CPython" (Diferenças entre o PyPy e o CPython) (EN).
175. Classes também têm um método .mro(), mas este é um recurso avançado de programaçõa de metaclasses, mencionado no Seção 24.2. Durante o uso normal de uma classe, apenas o conteúdo do atributo __mro__ importa.
176. Erich Gamma, Richard Helm, Ralph Johnson, e John Vlissides, Padrões de Projetos: Soluções Reutilizáveis de Software Orientados a Objetos (Bookman).
177. Como já mencionado, o Java 8 permite que interfaces também forneçam implementações de métodos. Esse novo recurso é chamado "Default Methods" (Métodos Default) (EN) no Tutorial oficial do Java.
178. Os programadores Django sabem que o método de classe as_view é a parte mais visível da interface View, mas isso não é relevante para nós aqui.
179. Se você gosta de padrões de projetos, note que o mecanismo de despacho do Django é uma variação dinâmica do padrão Template Method (Método Template. Ele é dinâmico porque a classe View não obriga subclasses a implementarem todos os métodos de tratamento, mas dispatch verifica, durante a execução, se um método de tratamento concreto está disponível para cada requisição específica.
180. NT: Literalmente "nos trilhos", mas obviamente uma referência à popular framework web baseada na linguagem Ruby, a Ruby on Rails
181. Esse princípio aparece na página 20 da introdução, na edição em inglês do livro.
182. Grady Booch et al., "Object-Oriented Analysis and Design with Applications" (Análise e Projeto Orientados a Objetos, com Aplicações), 3ª ed. (Addison-Wesley), p. 109.
183. NT: a doctring diz "Renderiza alguma lista de objetos, definida por self.model ou self.queryset. self.queryset na verdade pode ser qualquer iterável de itens, não apenas um queryset."
184. A PEP 591 também introduz uma anotação Final para variáveis e atributos que não devem ser reatribuídos ou sobrepostos.
185. NT: O nome da seção é uma referência ao filme "The Good, the Bad and the Ugly", um clássico do spaghetti western de 1966, lançado no Brasil com o título "Três Homens em Conflito".
186. Alan Kay, "The Early History of Smalltalk" (Os Promórdios do Smalltalk), na SIGPLAN Not. 28, 3 (março de 1993), 69–95. Também disponível online (EN). Agradeço a meu amigo Cristiano Anderson, que compartilhou essa referência quando eu estava escrevendo esse capítulo)
187. Meu amigo e revisor técnico Leonardo Rochael explica isso melhor do que eu poderia: "A existência continuada junto com o persistente adiamento da chegada do Perl 6 estava drenando a força de vontade da evolução do próprio Perl. Agora o Perl continua a ser desenvolvido como uma linguagem separada (está na versão 5.34), sem a ameaça de ser descontinuada pela linguagem antes conhecida como Perl 6."
188. De um vídeo no YouTube da A Language Creators' Conversation: Guido van Rossum, James Gosling, Larry Wall & Anders Hejlsberg ("Uma Conversa entre Criadores de Linguagens: Guido van Rossum, James Gosling, Larry Wall & Anders Hejlsberg), transmitido em 2 de abril de 2019. A citação (editada por brevidade) começa em 1:32:05. A transcrição completa está disponível em https://github.com/fluentpython/language-creators (EN).
189. NT: Texto original em inglês: "Return the sum of a 'start' value (default: 0) plus an iterable of numbers When the iterable is empty, return the start value. This function is intended specifically for use with numeric values and may reject non-numeric types"
190. Agradeço a Jelle Zijlstra—um mantenedor do typeshed—que me ensinou várias coisas, incluindo como reduzir minhas nove sobreposições originais para seis.
191. Em maio de 2020, o pytype ainda permite isso. Mas seu FAQ (EN) diz que tal operação será proibida no futuro. Veja a pergunta "Why didn’t pytype catch that I changed the type of an annotated variable?" (Por que o pytype não avisou quando eu mudei o tipo de uma variável anotada?) no FAQ (EN) do pytype.
192. Prefiro usar o pacote lxml (EN) para gerar e interpretar XML: ele é fácil de começar a usar, completo e rápido. Infelizmente, nem o lxml nem o ElementTree do próprio Python cabem na RAM limitada de meu microcontrolador hipotético.
193. A documentação do Mypy discute isso na seção "Types of empty collections" (Tipos de coleções vazias) (EN) da página "Common issues and solutions" (Problemas comuns e suas soluções) (EN).
194. Brett Cannon, Guido van Rossum e outros vem discutindo como escrever dicas de tipo para json.loads() desde 2016, em Mypy issue #182: Define a JSON type (Definir um tipo JSON) (EN).
195. O uso de enumerate no exemplo serve para confundir intencionalmente o verificador de tipo. Uma implementação mais simples, produzindo strings diretamente, sem passar pelo índice de enumerate, seria corretamente analisada pelo Mypy, e o cast() não seria necessário.
196. Relatei o problema em issue #5535 no typeshed, "Dica de tipo errada para o atributo sockets em asyncio.base_events.Server sockets attribute.", e ele foi rapidamente resolvido por Sebastian Rittau. Mas decidi manter o exemplo, pois ele ilustra um caso de uso comum para cast, e o cast que escrevi é inofensivo.
197. Para ser franco, originalmente eu anexei um comentário # type: ignore às linhas com `server.sockets[0]` porque, após pesquisar um pouco, encontrei linhas similares na documentação do asyncio e em um caso de teste (EN), e aí comecei a suspeitar que o problema não estava em meu código.
198. Mensagem de 18 de maio de 2020 para a lista de email typing-sig.
199. A sintaxe `# type: ignore[code]` permite especificar qual erro do Mypy está sendo silenciado, mas os códigos nem sempre são fáceis de interpretar. Veja a página "Error codes" na documentação do Mypy.
200. Não vou entrar nos detalhes da implementação de clip, mas se você tiver curiosidade, pode ler o módulo completo em clip_annot.py.
201. Mensagem "PEP 563 in light of PEP 649" (PEP 563 à luz da PEP 649), publicado em 16 de abril de 2021.
202. Os termos são do livro clássico de Joshua Bloch, Java Efetivo, 3rd ed. (Alta Books). As definições e exemplos são meus.
203. A primeira vez que vi a analogia da cantina para variância foi no prefácio de Erik Meijer para o livro The Dart Programming Language ("A Linguagem de Programação Dart"), de Gilad Bracha (Addison-Wesley).
204. Muito melhor que banir livros!
205. O leitor de notas de rodapé se lembrará que dei o crédito a Erik Meijer pela analogia da cantina para explicar variância.
206. Esse livro foi escrito para o Dart 1. Há mudanças significativas no Dart 2, inclusive no sistema de tipos. Mesmo assim, Bracha é um pesquisador importante na área de design de linguagens de programação, e achei o livro valioso por sya perspectiva sobre o design do Dart.
207. Veja o último parágrafo da seção "Covariance and Contravariance" (Covariância e Contravariância) (EN) na PEP 484.
208. Fonte: "The C Family of Languages: Interview with Dennis Ritchie, Bjarne Stroustrup, and James Gosling" (A Família de Linguagens C: Entrevista com Dennis Ritchie, Bjarne Stroustrup, e James Gosling) (EN).
209. O restante das ABCs na biblioteca padrão do Python ainda são valiosas para o goose typing e a tipagem estática. O problema com as ABCs numbers é explicado na Seção 13.6.8.
210. Veja Lógica Binária - NOT para uma explicação da negação binária.
211. A documentação do Python usa os dois termos. O capítulo "Modelo de Dados" usa "refletido", mas em "9.1.2.2. Implementando operações aritméticas", a documentação do módulo menciona métodos de "adiante" (forward) e "reverso" (reverse), uma terminologia que considero melhor, pois "adiante" e "reverso" são claramente sentidos opostos, mas o oposto de "refletido" não é tão evidente.
212. Veja o Ponto de Vista para uma discussão desse problema.
213. pow pode receber um terceiro argumento opcional, modulo: pow(a, b, modulo), também suportado pelos métodos especiais quando invocados diretamente (por exemplo, a.__pow__(b, modulo)).
214. A lógica para object.__eq__ e object.__ne__ está na função object_richcompare em Objects/typeobject.c, no código-fonte do CPython.
215. NT: O "i" nos nomes desses operadores se refere a "in-place"
216. A função embutida iter será tratada no próximo capítulo. Eu poderia ter usado tuple(other) aqui, e isso funcionaria, ao custo de criar uma nova tuple quando tudo que o método .load(…) precisa é iterar sobre seu argumento.
217. De "Revenge of the Nerds" (A Revanche dos Nerds), um post de blog.
218. Já usamos reprlib na Seção 12.3.
219. Agradeço ao revisor técnico Leonardo Rochael por esse ótimo exemplo.
220. Ao revisar esse código, Alex Martelli sugeriu que o corpo deste método poderia ser simplesmente return iter(self.words). Ele está certo: o resultado da invocação de self.words.iter() também seria um iterador, como deve ser. Entretanto, usei um loop for com yield aqui para introduzir a sintaxe de uma função geradora, que exige a instrução yield, como veremos na próxima seção. Durante a revisão da segunda edição deste livro, Leonardo Rochael sugeriu ainda outro atalho para o corpo de __iter__: yield from self.words. Também vamos falar de yield from mais adiante neste mesmo capítulo.
221. Eu algumas vezes acrescento um prefixo ou sufixo gen ao nomear funções geradoras, mas essa não é uma prática comum. E claro que não é possível fazer isso ao implementar um iterável: o método especial obrigatório deve se chamar __iter__.
222. Agradeço a David Kwast por sugerir esse exemplo.
223. NT: Os termos em inglês são lazy (preguiçosa) e eager (ávida). Em português essas traduções aparecem, mas a literatura usa também avaliação estrita e avaliação não estrita. Optamos pelos termos "preguiçosa" e "ávida", que parecem mais claros.
224. No Python 2, havia uma função embutida coerce(), mas ela não existe mais no Python 3. Foi considerada desnecessária, pois as regras de coerção numérica estão implícitas nos métodos dos operadores aritméticos. Então, a melhor forma que pude imaginar para forçar o valor inicial para o mesmo tipo do restante da série foi realizar a adição e usar seu tipo para converter o resultado. Perguntei sobre isso na Python-list e recebi uma excelente resposta de Steven D’Aprano (EN).
225. O diretório 17-it-generator/ no repositório de código do Python Fluente inclui doctests e um script, aritprog_runner.py, que roda os testes contra todas as variações dos scripts aritprog*.py.
226. O termo "mapeamento" aqui não está relacionado a dicionários, mas com a função embutida map.
227. O argumento apenas nomeado strict é novo, surgiu no Python 3.10. Quando strict=True, um ValueError é gerado se qualquer iterável tiver um tamanho diferente. O default é False, para manter a compatibilidade retroativa.
228. itertools.pairwise foi introduzido no Python 3.10.
229. Pode também ser invocado na forma max(arg1, arg2, …, [key=?]), devolvendo então o valor máximo entre os argumentos passados.
230. Pode também ser invocado na forma min(arg1, arg2, …, [key=?]), devolvendo então o valor mínimo entre os argumentos passados.
231. chain e a maioria das funções de itertools são escritas em C.
232. Na versão 0.910, a versão mais recente disponível quando escrevi este capítulo), o Mypy ainda utiliza os tipos descontinuados de typing.
233. Slide 33, "Keeping It Straight" (Cada Coisa em seu Lugar) em "A Curious Course on Coroutines and Concurrency" (Um Curioso Curso sobre Corrotinas e Concorrência).
234. Este exemplo foi inspirado por um trecho enviado por Jacob Holm à lista Python-ideas, em uma mensagem intitulada "Yield-From: Finalization guarantees" (Yield-From: Garantias de finalização) (EN). Algumas variantes aparecem mais tarde na mesma thread, e Holm dá mais explicações sobre suas ideia em message 003912 (EN).
235. Na verdade, ela nunca retorna, a menos que uma exceção interrompa o loop. O Mypy 0.910 aceita tanto None quanto typing​.NoReturn como parâmetro de tipo devolvido pela geradora—mas ele também aceita str naquela posição, então aparentemente o Mypy não consegue, neste momento, analisar completamente o código da corrotina.
236. Considerei renomear o campo, mas count é o melhor nome para a variável local na corrotina, e é o nome que usei para essa variável em exemplos similares ao longo do livro, então faz sentido usar o mesmo nome no campo de Result. Não hesitei em usar # type: ignore para evitar as limitações e os aborrecimentos de verificadores de tipo estáticos, quando se submeteer à ferramenta tornaria o código pior ou desnecessariamente complicado.
237. Desde o Python 3.7, typing.Generator e outros tipos que correspondem a ABCs em`collections.abc` foram refatorados e encapsulads em torno da ABC correspondente, então seus parâmetros genéricos não são visíveis no código-fonte de typing.py . Por isso estou fazendo referência ao código-fonte do Python 3.6 aqui.
238. De acordo com o Jargon file (EN), to grok não é meramente aprender algo, mas absorver de uma forma que "aquilo se torna parte de você, parte de sua identidade".
239. Gamma et. al., Design Patterns: Elements of Reusable Object-Oriented Software, p. 261.
240. O código está em Python 2 porque uma de suas dependências opcionais é uma biblioteca Java chamada Bruma, que podemos importar quando executamos o script com o Jython—que ainda não suporta o Python 3.
241. A biblioteca usada para ler o complexo arquivo binário .mst é na verdade escrita em Java, então essa funcionalidade só está disponível quando isis2json.py é executado com o interpretador Jython, versão 2.5 ou superior. Para maiores detalhes, veja o arquivo README.rst (EN)no repositório. As dependências são importadas dentro das funções geradoras que precisam delas, então o script pode rodar mesmo se apenas uma das bibliotecas externas esteja disponível.
242. NT: sigla de Document Type Definition, Definição de Tipo de Documento
243. Palestra de abertura da PyCon US 2013: "What Makes Python Awesome" ("O que torna o Python incrível"); a parte sobre with começa em 23:00 e termina em 26:15.
244. Os três argumentos recebidos por self são exatamente o que você obtém se chama sys.exc_info() no bloco finally de uma instrução try/finally. Isso faz sentido, considerando que a instrução with tem por objetivo substituir a maioria dos usos de try/finally, e chamar sys.exc_info() é muitas vezes necessário para determinar que ação de limpeza é necessária.
245. A classe real se chama _GeneratorContextManager. Se você quiser saber exatamente como ela funciona, leia seu código fonte na Lib/contextlib.py do Python 3.10.
246. Essa dica é uma citação literal de um comentário de Leonardo Rochael, um do revisores técnicos desse livro. Muito bem dito, Leo!
247. "Pouco conhecido" porque pelo menos eu e os outros revisores técnicos não sabíamos disso até Caleb Hattingh nos contar. Obrigado, Caleb!
248. As pessoas reclamam sobre o excesso de parênteses no Lisp, mas uma indentação bem pensada e um bom editor praticamente resolvem essa questão. O maior problema de legibilidade é o uso da mesma notação (f …​) para chamadas de função e formas especiais como (define …​), (if …​) e (quote …​), que de forma alguma se comportam como chamadas de função
249. Para tornar a iteração por recursão prática e eficiente, o Scheme e outras linguagens funcionais implementam chamadas de cauda apropriadas (ou otimizadas). Para ler mais sobre isso, veja o Ponto de vista.
250. Mas o segundo interpretador de Norvig, lispy.py, suporta strings como um tipo de dado, e também traz recursos avançados como macros sintáticas, continuações, e chamadas de cauda otimizadas. Entretanto, o lispy.py é quase três vezes maior que o lis.py—é muito mais difícil de entender.
251. O comentário # type: ignore[index] está ali por causa do issue #6042 no typeshed, que segue sem resolução quando esse capítulo está sendo revisado. ChainMap é anotado como MutableMapping, mas a dica de tipo no atributo maps diz que ele é uma lista de Mapping, indiretamente tornando todo o ChainMap imutável até onde o Mypy entende.
252. Enquanto estudava o lis.py e o lispy.py de Norvig, comecei uma versão chamada mylis, que acrescenta alguns recursos, incluindo um REPL que aceita expressões-S parciais e espera a continuação, como o REPL do Python sabe que não terminamos e apresenta um prompt secundário (…​) até entrarmos uma expressão ou instrução completa, que possa ser analisada e avaliada. O mylis também trata alguns erros de forma graciosa, mas ele ainda é fácil de quebrar. Não é nem de longe tão robusto quanto o REPL do Python.
253. A atribuição é um dos primeiros recursos ensinados em muitos tutoriais de programacão, mas set! só aparece na página 220 do mais conhecido livro de Scheme, Structure and Interpretation of Computer Programs (A Estrutura e a Interpretação de Programas de Computador), 2nd ed., de Abelson et al. (MIT Press), também conhecido como SICP ou "Wizard Book" (Livro do Mago). Programas em estilo funcional podem nos levar muito longe sem as mudanças de estado típicas da programação imperativa e da programação orientada a objetos.
254. O nome Unicode oficial para λ (U+03BB) é GREEK SMALL LETTER LAMDA. Isso não é um erro ortográfico: o caractere é chamado "lamda" sem o "b" no banco de dados do Unicode. De acordo com o artigo "Lambda" (EN) da Wikipedia em inglês, o Unicode Consortium adotou essa forma em função de "preferências expressas pela Autoridade Nacional Grega."
255. Acompanhando a discussão na lista python-dev, achei que uma razão para a rejeição do else foi a falta de consenso sobre como indentá-lo dentro do match: o else deveria ser indentedo no mesmo nível do match ou no mesmo nível do case?
256. Veja slide 21 em "Python is Awesome" ("O Python é Incrível") (EN).
257. NT:No momento em que essa tradução é feita, o título dessa seção na documentação diz "Com gerenciadores de contexto de instruções", uma frase que sequer faz sentido. Foi aberto um issue sobre isso.
258. Slide 8 of the talk Concurrency Is Not Parallelism.
259. Estudei e trabalhei com o Prof. Imre Simon, que gostava de dizer que há dois grandes pecados na ciência: usar palavras diferentes para significar a mesma coisa e usar uma palavra para significar coisas diferentes. Imre Simon (1942-2009) foi um pioneiro da ciência da computação no Brasil, com contribuições seminais para a Teoria dos Autômatos. Ele fundou o campo da Matemática Tropical e foi também um defensor do software livre, da cultura livre, e da Wikipédia.
260. Essa seção foi sugerida por meu amigo Bruce Eckel—autor de livros sobre Kotlin, Scala, Java, e C++.
261. NT: "FIFO" é a sigla em inglês para "first in, first out".
262. Chame sys.getswitchinterval() para obter o intervalo; ele pode ser modificado com sys.setswitchinterval(s).
263. Uma syscall é uma chamada a partir do código do usuário para uma função do núcleo (kernel) do sistema operacional. E/S, temporizadores e travas são alguns dos serviços do núcleo do SO disponíveis através de syscalls. Para aprender mais sobre esse tópico, leia o artigo "Chamada de sistema" na Wikipedia.
264. Os módulos zlib e bz2 são mencionados nominalmente em uma mensagem de Antoine Pitrou na python-dev (EN). Pitrou contribuiu para a lógica da divisão de tempo da GIL no Python 3.2.
265. Fonte: slide 106 do tutorial de Beazley, "Generators: The Final Frontier" (EN).
266. Fonte: início do capítulo "threading — Paralelismo baseado em Thread" (EN).
267. O Unicode tem muitos caracteres úteis para animações simples, como por exemplo os padrões Braille. Usei os caracteres ASCII "\|/-" para simplificar os exemplos do livro.
268. O semáforo é um bloco fundamental que pode ser usado para implementar outros mecanismos de sincronização. O Python fornece diferentes classes de semáforos para uso com threads, processos e corrotinas. Veremos o asyncio.Semaphore na Seção 21.7.1 (no Capítulo 21).
269. Agradeço aos revisores técnicos Caleb Hattingh e Jürgen Gmach, que não me deixaram esquecer de greenlet e gevent.
270. É um MacBook Pro 15” de 2018, com uma CPU Intel Core i7 2.2 GHz de 6 núcleos.
271. Isso é verdade hoje porque você provavelmente está usando um SO moderno, com multitarefa preemptiva. O Windows antes da era NT e o MacOS antes da era OSX não eram "preemptivos", então qualquer processo podia tomar 100% da CPU e paralisar o sistema inteiro. Não estamos inteiramente livres desse tipo de problema hoje, mas confie na minha barba branca: esse tipo de coisa assombrava todos os usuários nos anos 1990, e a única cura era um reset de hardware.
272. Nesse exemplo, 0 é uma sentinela conveniente. None também é comumente usado para essa finalidade, mas usar 0 simplifica a dica de tipo para PrimeResult e a implementação de worker.
273. Sobreviver à serialização sem perder nossa identidade é um ótimo objetivo de vida.
274. See 19-concurrency/primes/threads.py no repositório de código do Fluent Python.
275. Para saber mais, consulte "Troca de contexto" na Wikipedia.
276. Provavelmente foram essas mesmas razões que levaram o criador do Ruby, Yukihiro Matsumoto, a também usar uma GIL no seu interpretador.
277. Na faculdade, como exercício, tive que implementar o algorítimo de compressão LZW em C. Mas antes escrevi o código em Python, para verificar meu entendimento da especificação. A versão C foi cerca de 900 vezes mais rápida.
278. Fonte: Thoughtworks Technology Advisory Board, Technology Radar—November 2015 (EN).
279. Compare os caches de aplicação—usados diretamente pelo código de sua aplicação—com caches HTTP, que estariam no limite superior da Figura 3, servindo recursos estáticos como imagens e arquivos CSS ou JS. Redes de Fornecimento de Conteúdo (CDNs de Content Delivery Networks) oferecem outro tipo de cache HTTP, instalados em datacenters próximos aos usuários finais de sua aplicação.
280. Diagrama adaptado da Figure 1-1, Designing Data-Intensive Applications de Martin Kleppmann (O’Reilly).
281. Alguns palestrantes soletram a sigla WSGI, enquanto outros a pronunciam como uma palavra rimando com "whisky."
282. uWSGI é escrito com um "u" minúsculo, mas pronunciado como a letra grega "µ," então o nome completo soa como "micro-whisky", mas com um "g" no lugar do "k."
283. Os engenheiros da Bloomberg Peter Sperl and Ben Green escreveram "Configuring uWSGI for Production Deployment" (Configurando o uWSGI para Implantação em Produção) (EN), explicando como muitas das configurações default do uWSGI não são adequadas para cenários comuns de implantação. Sperl apresentou um resumo de suas recomendações na EuroPython 2019. Muito recomendado para usuários de uWSGI.
284. Caleb é um dos revisores técnicos dessa edição de Python Fluente.
285. Agradeço a Lucas Brunialti por me enviar um link para essa palestra.
286. NT: Trocadilho intraduzível com thread no sentido de "fio" ou "linha", algo como "Quando as linhas desfiam."
287. As APIs Python threading e concurrent.futures foram fortemente influenciadas pela biblioteca padrão do Java.
288. A comunidade Erlang usa o termo "processo" para se referir a atores. Em Erlang, cada processo é uma função em seu próprio loop, que então são muito leves, tornando viável ter milhões deles ativos ao mesmo tempo em uma única máquina—nenhuma relação com os pesados processo do SO, dos quais falamos em outros pontos desse capitulo. Então temos aqui exemplos dos dois pecados descritos pelo Prof. Simon: usar palavras diferentes para se referir à mesma coisa, e usar uma palavra para se referir a coisas diferentes.
289. Especialmente se o seu provedor de serviços na nuvem aluga máquinas por tempo de uso, independente de quão ocupada esteja a CPU.
290. Para servidores que podem ser acessados por muitos clientes, há uma diferença: as corrotinas escalam melhor, pois usam menos memória que as threads, e também reduzem o custo das mudanças de contexto, que mencionei na Seção 19.6.5.
291. As imagens são originalmente do CIA World Factbook, uma publicação de domínio público do governo norte-americano. Copiei as imagens para o meu site, para evitar o risco de lançar um ataque de DoS contra cia.gov.
292. Definir follow_redirects=True não é necessário nesse exemplo, mas eu queria destacar essa importante diferença entre HTTPX e requests. Além disso, definir follow_redirects=True nesse exemplo me dá a flexibilidade de armazenar os arquivos de imagem em outro lugar no futuro. Acho sensata a configuração default do HTTPX, follow_redirects​=False, pois redirecionamentos inesperados podem mascarar requisições desnecessárias e complicar o diagnóstico de erro.
293. Acrônimo de your mileage may vary, algo como sua quilometragem pode variar, querendo dizer "seu caso pode ser diferente". Com threads, você nunca sabe a sequência exata de eventos que deveriam acontecer quase ao mesmo tempo; é possível que, em outra máquina, se veja loiter(1) começar antes de loiter(0) terminar, especialmente porque sleep sempre libera a GIL, então o Python pode mudar para outra thread mesmo se você dormir por 0s.
294. Em setembro de 2021 não havia dicas de tipo na versão (então) atual do tqdm. Tudo bem. O mundo não vai acabar por causa disso. Obrigado, Guido, pela tipagem opcional!
295. Selivanov implementou async/await no Python, e escreveu as PEPs relacionadas: 492, 525, e 530.
296. Há uma exceção a essa regra: se você iniciar o Python com a opção -m asyncio, pode então usar await diretamente no prompt >>> para controlar uma corrotina nativa. Isso é explicado na Seção 21.10.1.1.
297. Desculpem, não consegui resistir.
298. true.dev está disponível por US$ 360,00 ao ano no momento em que escrevi isso. Também notei que for.dev está registrado, mas seu DNS não está configurado.
299. Agradeço ao leitor Samuel Woodward por ter reportado esse erro para a O’Reilly em fevereiro de 2023
300. Essa dica é uma citação literal de um comentário do revisor técnico Caleb Hattingh. Obrigado, Caleb!
301. Agradeço a Guto Maia, que notou que conceito de um semáforo não era explicado, quando leu o primeiro rascunho deste capítulo.
302. Um discussão detalhada sobre esse tópico pode era encontrada em uma thread de discussão que iniciei no grupo python-tulip, intitulada "Which other futures may come out of asyncio.as_completed?" (Que outros futures podem sair de asyncio.as_completed? ). Guido responde e fornece detalhes sobre a implementação de as_completed, bem como sobre a relação próxima entre futures e corrotinas no asyncio.
303. O ponto de interrogação encaixotado na captura de tela não é um defeito do livro ou do ebook que você está lendo. É o caractere U+101EC—PHAISTOS DISC SIGN CAT, que não existe na fonte do terminal que usei. O Disco de Festo é um artefato antigo inscrito com pictogramas, descoberto na ilha de Creta.
304. Você pode usar outro servidor ASGI no lugar do uvicorn, tais como o hypercorn ou o Daphne. Veja na documentação oficial do ASGI a página sobre implementações (EN) para maiores informações.
305. Agradeço o revisor técnico Miroslav Šedivý por apontar bons lugares para usar pathlib nos exemplo de código.
306. Como mencionado no Capítulo 8, o pydantic aplica dicas de tipo durante a execução, para validação de dados.
307. O issue #5535 está fechado desde outubro de 2021, mas o Mypy não lançou uma nova versão desde então, daí o erro permanece.
308. O revisor técnico Leonardo Rochael apontou que a construção do índice poderia ser delegada a outra thread, usando loop.run_with_executor() na corrotina supervisor. Dessa forma o servidor estaria pronto para receber requisições imediatamente, enquanto o índice é construído. Isso é verdade, mas como consultar o índice é a única coisa que esse servidor faz, isso não seria uma grande vantagem nesse exemplo.
309. Isso é ótimo para experimentação, como o console do Node.js. Agradeço a Yury Selivanov por mais essa excelente contribuição para o Python assíncrono.
310. Veja RFC 6761—Special-Use Domain Names.
311. Em contraste com o Javascript, onde async/await são atrelados ao loop de eventos que é inseparável do ambiente de runtime, isto é, um navegador, o Node.js ou o Deno.
312. Isso difere das anotações de corrotinas clássicas, discutidas na Seção 17.13.3.
313. Vídeo: "Introduction to Node.js" (Introdução ao Node.js), em 4:55.
314. Usar uma única thread era o default até o lançamento do Go 1.5. Anos antes, o Go já tinha ganho uma merecida reputação por permitir a criação de sistemas em rede de alta concorrência. Mais uma evidência que a concorrência não exige múltiplas threads ou múltiplos núcleos de CPU.
315. Independente de escolhas técnicas, esse foi talvez o maior erro daquela projeto: as partes interessadas não forçaram uma abordagem MVP—entregar o "Mínimo Produto Viável" o mais rápido possível e acrescentar novos recursos em um ritmo estável.
316. Alex Martelli, Anna Ravenscroft & Steve Holden, Python in a Nutshell, Third Edition (EN) (O’Reilly), p. 123.
317. Bertrand Meyer, Object-Oriented Software Construction, 2nd ed. (Pearson), p. 57. (EN)
318. A OSCON—O’Reilly Open Source Conference (Conferência O’Reilly de Código Aberto)—foi uma vítima da pandemia de COVID-19. O arquivo JSON original de 744 KB, que usei para esses exemplos, não está mais disponível online hoje (10 de janeiro de 2021). Você pode obter uma cópia do osconfeed.json no repositório de exemplos do livro.
319. Dois exemplos são AttrDict e addict.
320. A expressão self.__data[name] é onde a exceção KeyError pode acontecer. Idealmente, ela deveria ser tratada, e uma AttributeError deveria ser gerada em seu lugar, pois é isso que se espera de __getattr__. O leitor mais diligente está convidado a programar o tratamento de erro, como um exercício.
321. A fonte dos dados é JSON e os únicos tipos de coleção em dados JSON são dict e list.
322. Bunch ou "punhado" é o nome da classe usada por Alex Martelli para compartilhar essa dica em uma receita de 2001 intitulada "The simple but handy ‘collector of a bunch of named stuff’ class" (Uma classe simples mas prática 'coletora de um punhado de coisas nomeadas').
323. Isso é, na verdade, uma desvantagem do Princípio de Acesso Uniforme de Meyer, mencionada no início deste capítulo. Quem tiver interesse nessa discussão pode ler o Ponto de Vista opcional.
324. Fonte: documentação de @functools.cached_property. Sei que autor dessa explicação é Raymond Hettinger porque ele a escreveu em resposta a um problema que eu mesmo reportei: bpo42781—functools.cached_property docs should explain that it is non-overriding (a documentação de functools.cached_property deveria explicar que ele é não-dominante) (EN). Hettinger é um grande colaborador da documentação oficial do Python e da biblioteca padrão. Ele também escreveu o excelente Descriptor HowTo Guide (Guia de Utilização de Descritores) (EN), um recurso fundamental para o Capítulo 23.
325. Citação direta de Jeff Bezos no artigo do Wall Street Journal "Birth of a Salesman" (O Nascimento de um Vendedor) (EN) (15 de outubro de 2011). Pelo menos até 2023, é necessario ser assinante para ler o artigo.
326. Esse código foi adaptado da "Recipe 9.21. Avoiding Repetitive Property Methods" (Receita 9.21. Evitando Métodos Repetitivos de Propriedades) do Python Cookbook (EN), 3ª ed., de David Beazley e Brian K. Jones (O’Reilly).
327. Aquela cena sangrenta está disponível no Youtube (EN) quando reviso essa seção, em outubro de 2021.
328. Alex Martelli assinala que, apesar de __slots__ poder ser definido como uma list, é melhor ser explícito e sempre usar uma tuple, pois modificar a lista em __slots__ após o processamento do corpo da classe não tem qualquer efeito. Assim, seria equivocado usar uma sequência mutável ali.
329. Alex Martelli, Python in a Nutshell, 2ª ed. (O’Reilly), p. 101.
330. As razões que menciono foram apresentadas no artigo intitulado "Java’s new Considered Harmful" (new do Java considerado nocivo), de Jonathan Amsterdam, publicado na Dr. Dobbs Journal, e no "Consider static factory methods instead of constructors" (Considere substituir construtores por métodos estáticos de fábrica), que é o Item 1 do premiado livro Effective Java, 3ª ed., de Joshua Bloch (Addison-Wesley).
331. Raymond Hettinger, "HowTo - Guia de descritores".
332. Classes e instâncias são representadas por retângulos em diagramas de classe UML. Há diferenças visuais, mas instâncias raramente aparecem em diagramas de classe, entao desenvolvedores podem não reconhecê-las como tal.
333. O quilo de trufas brancas custa milhares de reais. Impedir a venda de trufas por $0,01 fica como exercício para a leitora com espírito de aventura. Conheço um caso real, de uma pessoa que comprou uma enciclopédia de estatísticas de 1.800 dólares por 18 dólares, devido a um erro em uma loja online(neste caso não foi na Amazon.com).
334. Mais precisamente, __set_name__ é invocado por type.__new__—o construtor de objetos que representam classes. A classe embutida type é na verdade uma metaclasse, a classe default de classes definidas pelo usuário. Isso é um pouco difícil de entender de início, mas fique tranquila: o Capítulo 24 é dedicado à configuração dinâmica de classes, incluindo o conceito de metaclasses.
335. Gamma et al., Design Patterns: Elements of Reusable Object-Oriented Software, p. 326. (Padrões de Projetos: Soluções Reutilizáveis de Software Orientados a Objetos)
336. Slide #50 da palestra "Python Design Patterns" (Padrões de Projeto do Python) (EN), de Alex Martelli. Altamente recomendada.
337. Um método __delete__ também é fornecido pelo decorador property, mesmo se você não definir um método deleter (de exclusão).
338. O Python não é consistente nessas mensagens. Tentar modificar o atributo c.real de um número complex resulta em um AttributeError: readonly attribute (AttributeError: atributo somente para leitura), mas uma tentativa de mudar c.conjugate (um método de complex) gera um AttributeError: 'complex' object attribute 'conjugate' is read-only (AttributeError: o atributo 'conjugate' do objeto 'complex' é somente para leitura). Até "read-only" está escrito de maneira diferente (na mensagem original em inglês).
339. Entretanto, lembre-se que criar atributos de instância após o método __init__ frustra a otimização de memória através de compartilhamento de chaves, como discutido na Seção 3.9.
340. Personalizar o texto de ajuda para cada instância do descritor é supreendentemente difícil. Uma solução exige criar dinamicamente uma classe invólucro (wrapper) para cada instância do descritor.
341. Citação extraída do capítulo 2, Expression ("Expressão"), página 10, de _The Elements of Programming Style, Second Edition (NT: "Elementos de Estilo de Programação"; não encontramos edição traduzida deste livro.)
342. Isso não quer dizer que a PEP 487 quebrou código que usava aqueles recursos, mas apenas que parte do código que utilizava decoradores de classe ou metaclasses antes do Python 3.6 pode agora ser refatorado para usar classes comuns, resultando em um código mais simples e possivelmente mais eficiente.
343. Agradeço a meu amigo J. S. O. Bueno por ter contribuído com esse exemplo.
344. Não acrescentei dicas de tipo aos argumentos porque os tipos reais são Any. Escrevi a dica to tipo devolvido porque em caso contrário, o Mypy não verificaria o código dentro do método.
345. Isso é verdade para qualquer objeto, exceto quando sua classe sobrepõe os métodos __str__ ou __repr__, herdados de object, por uma implementação que não funcione.
346. Essa solução evita usar None como default. Evitar valores nulos é uma boa ideia. Em geral, eles são difíceis de evitar, mas em alguns casos isso é fácil. Tanto no Python quanto no SQL, prefiro representar dados ausentes em um campo de texto como um string vazia em vez de None ou NULL. Aprender Go reforçou essa ideia: em Go, variáveis e campos struct de tipos primitivos são inicializados por default com um "valor zero" (zero value). Se você estiver curiosa, veja a página "Zero values" ("Valores zero") (EN) no Tour of Go ("Tour do Go") online
347. Na minha opinião, callable deveria se tornar adequado para dicas de tipo. Em 6 de maio de 2021, quando essa nota foi escrita, essa ainda era uma questão aberta (EN).
348. Como mencionado em Loops, sentinelas e pílulas venenosas, o objeto Ellipsis é um valor sentinela conveniente e seguro. Ele existe no Python há muito tempo, mas recentemente mais usos tem sido encontrados para ele, como vemos nas dicas de tipo e no NumPy.
349. NT: Em 17 de setembro de 2023, a primeira frase está traduzida de forma confusa na documentação em português. Optamos por traduzir aqui diretamente da documentação em inglês.
350. O sutil conceito de descritor dominante foi explicado na Seção 23.3.1.
351. Essa justificativa aparece no resumo da PEP 557–Data Classes (Classes de Dados) (EN), para explicar porque ela foi implementada como um decorador de classes.
352. Compare com a instrução import em Java, que é apenas uma declaração para informar o compilador que determinados pacotes são necessários.
353. Não estou dizendo que é uma boa ideia abrir uma conexão com um banco de dados só porque o módulo foi importado, apenas apontando que isso pode ser feito.
354. Mensagem a comp.lang.python, assunto: "Acrimony in c.l.p." (animosidade no c.l.p.). Essa é outra parte da mesma mensagem de 23 de dezembro de 2002, citada na [preface_sec]. O TimBot estava inspirado naquele dia.
355. Os autores gentilmente me deram permissão para usar seu exemplo. MetaBunch apareceu pela primeira vez em uma mensagem enviada por Martelli para o grupo comp.lang.python, em 7 de julho de 2002, com o assunto "a nice metaclass example (was Re: structs in python)" (um belo exmeplo de metaclasse (era Re: structs no python)), na sequência de uma discussão sobre estruturas de dados similares a registros no Python. O código original de Martelli, para Python 2.2, ainda roda após uma única modificação: para usar uma metaclasse no Python 3, é necessário usar o argumento nomeado metaclass na declaração da classe (por exemplo, Bunch(metaclass=MetaBunch)), em vez da convenção antiga, que era adicionar um atributo __metaclass__ no corpo da classe.
356. Na primeira edição de Python Fluente, as versões mais avançadas da classe LineItem usavam uma metaclasse apenas para definir o nome do armazenamento dos atributos. Veja o código nas metaclasses do exemplo da comida a granel, no repositório de código da primeira edição.
357. Se você sentiu vertigem ao ponderar sobre as implicações de herança múltipla com metaclasses, bom para você. Eu também passaria longe dessa solução.
358. Eu ganhei a vida por alguns anos escrevendo código para Django, antes de resolver estudar como os campos dos modelos Django eram implementados. Só então aprendi sobre descritores e metaclasses.
359. Essa frase é muito citada. Encontrei uma citação direta antiga em um post de 2005 no blog de DHH.
360. Comprei um exemplar usado, e achei uma leitura muito desafiadora.
361. Brian Harvey e Matthew Wright, Simply Scheme (MIT Press, 1999), p. xvii. O texto completo está disponível em Berkeley.edu (EN).
362. Machine Beauty: Elegance and the Heart of Technology ("Beleza de Máquina: A Elegância e o Coração da Tecnologia"), de David Gelernter (Basic Books), começa com uma discussão intrigante sobre elegância e estética em obras de engenharia, de pontes a software. Os capítulos posteriores não são tão bons, mas o início vale o preço.
363. NT: termos com maiúsculas no início de cada palavra de uma expressão composta.
364. NT: termos com hífens separando palavras de uma expressão composta
365. NT: composição direta de palavras