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]
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 destr
ebytes
--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
easync 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 bookquestions@oreilly.com.
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 permissions@oreilly.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 bookquestions@oreilly.com, 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.
No Facebook: http://facebook.com/oreilly.
No Twitter: https://twitter.com/oreillymedia.
No YouTube: http://www.youtube.com/oreillymedia.
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:
-
inglês,
-
português brasileiro,
-
chinês simplificado (China),
-
chinês tradicional (Taiwan),
-
japonês,
-
coreano,
-
russo,
-
francês,
-
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
ouasync 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 |
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 |
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__
.
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
Nesse casos, usei a diretiva |
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 |
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 |
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__
.
"""
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 |
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étodostr.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 "What is the difference between |
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
Isso é mais difícil de ler, mas evita a jornada através de |
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.
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 suportarfor
, desempacotamento, e outras formas de iteração -
Sized
para suportar a função embutidalen
-
Container
para suportar o operadorin
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 comolist
estr
-
Mapping
, implementado pordict
,collections.defaultdict
, etc. -
Set
, a interface dos tipos embutidosset
efrozenset
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 |
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).
Categoria | Nomes dos métodos |
---|---|
Representação de string/bytes |
|
Conversão para número |
|
Emulação de coleções |
|
Iteração |
|
Execução de chamável ou corrotina |
|
Gerenciamento de contexto |
|
Criação e destruição de instâncias |
|
Gerenciamento de atributos |
|
Descritores de atributos |
|
Classes base abstratas |
|
Metaprogramação de classes |
|
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.
Categoria do operador | Símbolos | Nomes de métodos |
---|---|---|
Unário numérico |
|
|
Comparação rica |
|
|
Aritmético |
|
|
Aritmética reversa |
operadores aritméticos com operandos invertidos) |
|
Atribuição aritmética aumentada |
|
|
Bit a bit |
|
|
Bit a bit reversa |
(operadores bit a bit com os operandos invertidos) |
|
Atribuição bit a bit aumentada |
|
|
✒️ 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 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 |
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.
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
etuple
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
, ecollections.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
, earray.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.
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
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 |
Outra forma de agrupar as sequências é por mutabilidade:
- Sequências mutáveis
-
Por exemplo,
list
,bytearray
,array.array
ecollections.deque
. - Sequências imutáveis
-
Por exemplo,
tuple
,str
, ebytes
.
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
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
>>> symbols = '$¢£¥€¤'
>>> codes = []
>>> for symbol in symbols:
... codes.append(ord(symbol))
...
>>> codes
[36, 162, 163, 165, 8364, 164]
>>> 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 |
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.
>>> 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.
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.
>>> 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')]
-
Isso gera uma lista de tuplas ordenadas por cor, depois por tamanho.
-
Observe que a lista resultante é ordenada como se os loops
for
estivessem aninhados na mesma ordem que eles aparecem na listcomp. -
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.
>>> 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])
-
Se a expressão geradora é o único argumento em uma chamada de função, não há necessidade de duplicar os parênteses circundantes.
-
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 dearray
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
.
>>> 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
-
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.
>>> 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
-
Latitude e longitude do Aeroporto Internacional de Los Angeles.
-
Dados sobre Tóquio: nome, ano, população (em milhares), crescimento populacional (%) e área (km²).
-
Uma lista de tuplas no formato (código_de_país, número_do_passaporte).
-
Iterando sobre a lista,
passport
é vinculado a cada tupla. -
O operador de formatação
%
entende as tuplas e trata cada item como um campo separado. -
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 |
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 umalist
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.
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 mesmat
. Não há necessidade de cópia. Por outro lado, dada uma listal
, o construtorlist(l)
precisa criar uma nova cópia del
. -
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 delist
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.
list |
tuple |
||
---|---|---|---|
|
● |
● |
s + s2—concatenação |
|
● |
s += s2—concatenação no mesmo lugar |
|
|
● |
Acrescenta um elemento após o último |
|
|
● |
Apaga todos os itens |
|
|
● |
● |
|
|
● |
Cópia rasa da lista |
|
|
● |
● |
Conta as ocorrências de um elemento |
|
● |
Remove o item na posição |
|
|
● |
Acrescenta itens do iterável |
|
|
● |
● |
s[p]—obtém o item na posição |
|
● |
Suporte a serialização otimizada com |
|
|
● |
● |
Encontra a posição da primeira ocorrência de |
|
● |
Insere elemento |
|
|
● |
● |
Obtém o iterador |
|
● |
● |
len(s)—número de itens |
|
● |
● |
s * n—concatenação repetida |
|
● |
s *= n—concatenação repetida no mesmo lugar |
|
|
● |
● |
n * s—concatenação repetida inversa[6] |
|
● |
Remove e devolve o último item ou o item na posição opcional |
|
|
● |
Remove a primeira ocorrência do elemento |
|
|
● |
Reverte, no lugar, a ordem dos itens |
|
|
● |
Obtém iterador para examinar itens, do último para o primeiro |
|
|
● |
s[p] = e—coloca |
|
|
● |
Ordena os itens no lugar, com os argumentos nomeados opcionais |
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.
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()
-
Cada tupla contém um registro com quatro campos, o último deles um par de coordenadas.
-
Ao atribuir o último campo a uma tupla aninhada, desempacotamos as coordenadas.
-
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:
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)
-
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çãocase
. -
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áveisfrequency
etimes
, nessa ordem. -
Isso casa com qualquer sujeito com dois itens, se o primeiro for
'NECK'
. -
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óximocase
. -
Outro padrão de sequência começando com
'LED'
, agora com cinco itens—incluindo a constante'LED'
. -
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
.
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}')
-
O sujeito desse
match
érecord
—isto é, cada uma das tuplas emmetro_areas
. -
Uma instrução
case
tem duas partes: um padrão e uma guarda opcional, com a palavra-chaveif
.
Em geral, um padrão de sequência casa com o sujeito se estas três condições forem verdadeiras:
-
O sujeito é uma sequência, e
-
O sujeito e o padrão tem o mesmo número de itens, e
-
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
|
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 |
---|---|
|
|
|
|
|
|
|
|
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 |
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 |
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.
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.
match/case
—requer Python ≥ 3.10def 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))
-
Casa se o sujeito for uma sequência de dois itens começando com
'quote'
. -
Casa se o sujeito for uma sequência de quatro itens começando com
'if'
. -
Casa se o sujeito for uma sequência com três ou mais itens começando com
'lambda'
. A guarda assegura quebody
não esteja vazio. -
Casa se o sujeito for uma sequência de três itens começando com
'define'
, seguido de uma instância deSymbol
. -
é uma boa prática ter um
case
para capturar todo o resto. Neste exemplo, seexp
não casar com nenhum dos padrões, a expressão está mal-formada, então gera umSyntaxError
.
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.
Sintaxe do Scheme | Padrão de sequência |
---|---|
|
|
|
|
|
|
|
|
|
|
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 |
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)
quantomy_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 escrevamy_list[:x]
emy_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.
>>> 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]
-
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 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.
>>> board = [['_'] * 3 for i in range(3)] (1)
>>> board
[['_', '_', '_'], ['_', '_', '_'], ['_', '_', '_']]
>>> board[1][2] = 'X' (2)
>>> board
[['_', '_', '_'], ['_', '_', 'X'], ['_', '_', '_']]
-
Cria uma lista de três listas, cada uma com três itens. Inspeciona a estrutura criada.
-
Coloca um "X" na linha 1, coluna 2, e verifica o resultado.
Um atalho tentador mas errado seria fazer algo como o Exemplo 15.
>>> weird_board = [['_'] * 3] * 3 (1)
>>> weird_board
[['_', '_', '_'], ['_', '_', '_'], ['_', '_', '_']]
>>> weird_board[1][2] = 'O' (2)
>>> weird_board
[['_', '_', 'O'], ['_', '_', 'O'], ['_', '_', 'O']]
-
A lista externa é feita de três referências para a mesma lista interna. Enquanto ela não é modificada, tudo parece correr bem.
-
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)
-
A mesma
row
é anexada três vezes aoboard
.
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', '_', '_']]
-
Cada iteração cria uma nova
row
e a acrescenta aoboard
. -
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)
-
O ID da lista inicial.
-
Após a multiplicação, a lista é o mesmo objeto, com novos itens anexados.
-
O ID da tupla inicial.
-
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]
>>> t = (1, 2, [30, 40])
>>> t[2] += [50, 60]
O que acontece a seguir? Escolha a melhor alternativa:
-
t
se torna(1, 2, [30, 40, 50, 60])
. -
É gerado um
TypeError
com a mensagem'tuple' object does not support item assignment
(o objeto tupla não suporta atribuição de itens). -
Nenhuma das alternativas acima..
-
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]
>>> 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.
Se olharmos o bytecode gerado pelo Python para a expressão s[a] += b
(Exemplo 18), fica claro como isso acontece.
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
-
Coloca o valor de
s[a]
noTOS
(Top Of Stack, topo da pilha de execução_). -
Executa
TOS += b
. Isso é bem sucedido seTOS
se refere a um objeto mutável (no Exemplo 17 é uma lista). -
Atribui
s[a] = TOS
. Isso falha ses
é imutável (a tuplat
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 |
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, ekey=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 |
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)
-
Isso produz uma lista de strings ordenadas alfabeticamente.[19]
-
Inspecionando a lista original, vemos que ela não mudou.
-
Isso é a ordenação "alfabética" anterior, invertida.
-
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.
-
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."
-
Até aqui, a ordenação da lista
fruits
original não mudou. -
Isso ordena a lista no mesmo lugar, devolvendo
None
(que o console omite). -
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, |
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.
>>> 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
-
Importa o tipo
array
. -
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. -
Inspeciona o último número no array.
-
Salva o array em um arquivo binário.
-
Cria um array vazio de números de ponto flutuante de dupla precisão
-
Lê 10 milhões de números do arquivo binário.
-
Inspeciona o último número no array.
-
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
.
list | array | ||
---|---|---|---|
|
● |
● |
|
|
● |
● |
|
|
● |
● |
Acrescenta um elemento após o último |
|
● |
Permuta os bytes de todos os itens do array para conversão de endianness (ordem de interpretação bytes) |
|
|
● |
Apaga todos os itens |
|
|
● |
● |
|
|
● |
Cópia rasa da lista |
|
|
● |
Suporte a |
|
|
● |
● |
Conta as ocorrências de um elemento |
|
● |
Suporte otimizado a |
|
|
● |
● |
Remove item na posição |
|
● |
● |
Acrescenta itens a partir do iterável |
|
● |
Acrescenta itens de uma sequência de bytes, interpretada como valores em código de máquina empacotados |
|
|
● |
Acrescenta |
|
|
● |
Acrescenta itens de lista; se um deles causar um |
|
|
● |
● |
|
|
● |
● |
Encontra a posição da primeira ocorrência de |
|
● |
● |
Insere elemento |
|
● |
Tamanho em bytes de cada item do array |
|
|
● |
● |
Obtém iterador |
|
● |
● |
|
|
● |
● |
|
|
● |
● |
|
|
● |
● |
n * s—concatenação repetida invertida[21] |
|
● |
● |
Remove e devolve o item na posição |
|
● |
● |
Remove a primeira ocorrência do elemento |
|
● |
● |
Reverte a ordem dos itens no mesmo lugar |
|
● |
Obtém iterador para percorrer itens do último até o primeiro |
|
|
● |
● |
s[p] = e—coloca |
|
● |
Ordena itens no mesmo lugar, com os argumentos de palavra-chave opcionais |
|
|
● |
Devolve itens como pacotes de valores em código de máquina em um objeto |
|
|
● |
Grava itens como pacotes de valores em código de máquina no arquivo binário |
|
|
● |
Devolve os itens como objetos numéricos em uma |
|
|
● |
String de um caractere identificando o tipo em C dos itens |
👉 Dica
|
Até o Python 3.10, o tipo
Para manter a ordem de um array ordenado ao acrescentar novos itens, use a função |
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.
>>> 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])
-
Cria um array de 6 bytes (código de tipo
'B'
). -
Cria uma
memoryview
a partir daquele array, e a exporta como uma lista. -
Cria uma nova
memoryview
a partir da anterior, mas com2
linhas e3
colunas. -
Ainda outra
memoryview
, agora com3
linhas e2
colunas. -
Sobrescreve o byte em
m2
, na linha1
, coluna1
com22
. -
Sobrescreve o byte em
m3
, na linha1
, coluna1
com33
. -
Mostra o array original, provando que a memória era compartilhada entre
octets
,m1
,m2
, em3
.
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.
>>> 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)
-
Cria uma
memoryview
a partir de um array de 5 inteiros com sinal de 16 bits (código de tipo'h'
). -
memv
vê os mesmos 5 itens no array. -
Cria
memv_oct
, transformando os elementos dememv
em bytes (código de tipo'B'
). -
Exporta os elementos de
memv_oct
como uma lista de 10 bytes, para inspeção. -
Atribui o valor
4
ao byte com offset5
. -
Observe a mudança em
numbers
: um4
no byte mais significativo de um inteiro de 2 bytes sem sinal é1024
.
✒️ Nota
|
Você pode ver um exemplo de inspeção de uma |
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.
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]])
-
Importa a NumPy, que precisa ser instalada previamente (ela não faz parte da biblioteca padrão do Python). Por convenção,
numpy
é importada comonp
. -
Cria e inspeciona uma
numpy.ndarray
com inteiros de0
a11
. -
Inspeciona as dimensões do array: essa é um array com uma dimensão e 12 elementos.
-
Muda o formato do array, acrescentando uma dimensão e depois inspecionando o resultado.
-
Obtém a linha no índice
2
-
Obtém elemento na posição
2, 1
. -
Obtém a coluna no índice
1
-
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])
-
Carrega 10 milhões de números de ponto flutuante de um arquivo de texto.
-
Usa a notação de fatiamento de sequência para inspecionar os três últimos números.
-
Multiplica cada elemento no array
floats
por.5
e inspeciona novamente os três últimos elementos. -
Importa o cronômetro de medida de tempo em alta resolução (disponível desde o Python 3.3).
-
Divide cada elemento por
3
; o tempo decorrido para dividir os 10 milhões de números de ponto flutuante é menos de 40 milissegundos. -
Salva o array em um arquivo binário .npy.
-
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.
-
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
.
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)
-
O argumento opcional
maxlen
determina o número máximo de itens permitidos nessa instância dedeque
; isso estabelece o valor de um atributo de instânciamaxlen
, somente de leitura. -
Rotacionar com
n > 0
retira itens da direita e os recoloca pela esquerda; quandon < 0
, os itens são retirados pela esquerda e anexados pela direita. -
Acrescentar itens a um
deque
cheio (len(d) == d.maxlen
) elimina itens da ponta oposta. Observe, na linha seguinte, que o0
foi descartado. -
Acrescentar três itens à direita derruba
-1
,1
, e2
da extremidade esquerda. -
Observe que
extendleft(iter)
acrescenta cada item sucessivo do argumentoiter
do lado esquerdo dodeque
, 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.
list | deque | ||
---|---|---|---|
|
● |
s + s2—concatenação |
|
|
● |
● |
s += s2—concatenação no mesmo lugar |
|
● |
● |
Acrescenta um elemento à direita (após o último) |
|
● |
Acrescenta um elemento à esquerda (antes do primeiro) |
|
|
● |
● |
Apaga todos os itens |
|
● |
|
|
|
● |
Cópia rasa da lista |
|
|
● |
Suporte a |
|
|
● |
● |
Conta ocorrências de um elemento |
|
● |
● |
Remove item na posição |
|
● |
● |
Acrescenta item do iterável |
|
● |
Acrescenta item do iterável |
|
|
● |
● |
s[p]—obtém item ou fatia na posição |
|
● |
Encontra a primeira ocorrência de |
|
|
● |
Insere elemento |
|
|
● |
● |
Obtém iterador |
|
● |
● |
len(s)—número de itens |
|
● |
s * n—concatenação repetida |
|
|
● |
s *= n—concatenação repetida no mesmo lugar |
|
|
● |
n * s—concatenação repetida invertida[22] |
|
|
● |
● |
Remove e devolve último item[23] |
|
● |
Remove e devolve primeiro item |
|
|
● |
● |
Remove primeira ocorrência do elemento |
|
● |
● |
Inverte a ordem do itens no mesmo lugar |
|
● |
● |
Obtém iterador para percorrer itens, do último para o primeiro |
|
● |
Move |
|
|
● |
● |
s[p] = e—coloca |
|
● |
Ordena os itens no mesmo lugar, com os argumentos de palavra-chave opcionais |
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
, ePriorityQueue
. Essas classes podem ser usadas para comunicação segura entre threads. Todas, excetoSimpleQueue
, podem ser delimitadas passando um argumentomaxsize
maior que 0 ao construtor. Entretanto, elas não descartam um item para abrir espaço, como fazdeque
. 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, eQueue
, delimitada, muito similares àquelas no pacotequeue
, mas projetadas para comunicação entre processos. Uma fila especializada,multiprocessing.JoinableQueue
, é disponibilizada para gerenciamento de tarefas. asyncio
-
Fornece
Queue
,LifoQueue
,PriorityQueue
, eJoinableQueue
com APIs inspiradas pelas classes nos módulosqueue
emultiprocessing
, 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 comoheappush
eheappop
, 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).
O post de Eli Bendersky em seu blog, "Less copies in Python with the buffer protocol and memoryviews" (Menos cópias em Python, com o protocolo de buffer e mamoryviews) inclui um pequeno tutorial sobre memoryview
.
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.
3. Dicionários e conjuntos
O Python é feito basicamente de dicionários cobertos por muitas camadas de açúcar sintático
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
efrozenset
-
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 pelosdicts
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
eOrderedDict
—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
, edict.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:
|
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.
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'}
-
Um iterável de pares chave-valor como
dial_codes
pode ser passado diretamente para o construtor dedict
, mas… -
…aqui permutamos os pares:
country
é a chave, ecode
é o valor. -
Ordenando
country_dial
por nome, revertendo novamente os pares, colocando os valores em maiúsculas e filtrando os itens comcode < 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
.
get_creators()
extrai o nome dos criadores em registros de mídiadef 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}')
-
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 novalist
. -
Casa com qualquer mapeamento na forma
'type': 'book', 'api' :1
, e uma chave'author'
mapeada para qualquer objeto. Devolve aquele objeto dentro de umalist
. -
Qualquer outro mapeamento na forma
'type': 'book'
é inválido e gera umValueError
. -
Casa qualquer mapeamento na forma
'type': 'movie'
e uma chave'director'
mapeada para um único objeto. Devolve o objeto dentro de umalist
. -
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 umcase
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 |
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 |
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 abstratosPara 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
.
dict | defaultdict | OrderedDict | ||
---|---|---|---|---|
|
● |
● |
● |
Remove todos os itens. |
|
● |
● |
● |
|
|
● |
● |
● |
Cópia rasa. |
|
● |
Suporte a |
||
|
● |
Chamável invocado por |
||
|
● |
● |
● |
|
|
● |
● |
● |
Novo mapeamento com chaves no iterável |
|
● |
● |
● |
Obtém item com chave |
|
● |
● |
● |
d[k]—obtém item com chave |
|
● |
● |
● |
Obtém uma view dos itens—pares |
|
● |
● |
● |
Obtém iterador das chaves. |
|
● |
● |
● |
Obtém view das chaves. |
|
● |
● |
● |
|
|
● |
Chamado quando |
||
|
● |
Move |
||
|
● |
● |
● |
Suporte a |
|
● |
● |
● |
Suporte a |
|
● |
● |
● |
Remove e devolve valor em |
|
● |
● |
● |
Remove e devolve, na forma |
|
● |
● |
● |
Suporte a |
|
● |
● |
● |
Suporte a |
|
● |
● |
● |
Se |
|
● |
● |
● |
|
|
● |
● |
● |
Atualiza |
|
● |
● |
● |
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.
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]
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])
-
Obtém a lista de ocorrências de
word
, ou[]
se a palavra não for encontrada. -
Acrescenta uma nova localização a
occurrences
. -
Coloca a
occurrences
modificada no dictindex
; isso exige uma segunda busca emindex
. -
Não estou chamando
str.upper
no argumentokey=
desorted
, apenas passando uma referência àquele método, para que a funçãosorted
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.
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])
-
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:
-
Chama list() para criar uma nova lista.
-
Insere a lista em
dd
usando'new-key'
como chave. -
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
.
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])
-
Cria um
defaultdict
com o construtor delist
comodefault_factory
. -
Se
word
não está inicialmente noindex
, odefault_factory
é chamado para produzir o valor ausente, que neste caso é umalist
vazia, que então é atribuída aindex[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 |
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.
StrKeyDict0
a converte para str
quando ela não é encontradaTests 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 |
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)
-
StrKeyDict0
herda dedict
. -
Verifica se
key
já é umastr
. Se é, e está ausente, gera umKeyError
. -
Cria uma
str
dekey
e a procura. -
O método
get
delega para__getitem__
usando a notaçãoself[key]
; isso dá oportunidade para nosso__missing__
agir. -
Se um
KeyError
foi gerado,__missing__
já falhou, então devolvemos odefault
. -
Procura pela chave não-modificada (a instância pode conter chaves não-
str
), depois por umastr
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 k
—str
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
|
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 emd[k]
, que usará o__getitem__
herdado dedict
. - subclasse de
collections.UserDict
-
Da mesma forma, uma subclasse de
UserDict
que implemente apenas__missing__
e nenhum outro método. O métodoget
herdado deUserDict
chama__getitem__
. Isso significa que__missing__
pode ser chamado para tratar de consultas comd[k]
e comd.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 comd[k]
,d.get(k)
, ek 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()
deOrderedDict
tem uma assinatura diferente, que aceita um argumento opcional especificando qual item será devolvido. -
OrderedDict
tem um métodomove_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 deabc.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, comosync
eclose
. -
Uma instância de
Shelf
é um gerenciador de contexto, então é possível usar um blocowith
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 |
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).
StrKeyDict
sempre converte chaves que não sejam strings para str
na inserção, atualização e buscaimport 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)
-
StrKeyDict
estendeUserDict
. -
__missing__
é exatamente igual ao do Exemplo 8. -
__contains__
é mais simples: podemos assumir que todas as chaves armazenadas sãostr
, e podemos operar sobreself.data
em vez de invocarself.keys()
, como fizemos emStrKeyDict0
. -
__setitem__
converte qualquerkey
para umastr
. Esse método é mais fácil de sobrepor quando podemos delegar para o atributoself.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 usaself[chave] = valor
para adicionar itens, ele termina por invocar nossa implementação de__setitem__
. Mapping.get
-
No
StrKeyDict0
(Exemplo 8), precisamos codificar nosso próprioget
para devolver os mesmos resultados de__getitem__
, mas no Exemplo 9 herdamosMapping.get
, que é implementado exatamente comoStrKeyDict0.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 |
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.
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'
>>>
-
Os items em
d
podem ser vistos através ded_proxy
. -
Não é possível fazer modificações através de
d_proxy
. -
d_proxy
é dinâmica: qualquer mudança emd
é 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.
.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
-
O
repr
de um objeto view mostra seu conteúdo. -
Podemos consultar a
len
de uma view. -
Views são iteráveis, então é fácil criar listas a partir delas.
-
Views implementam
__reversed__
, devolvendo um iterador personalizado. -
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 |
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
|
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 palheiro
—haystack) e um conjunto menor de endereços (as agulhas
—needles), 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).
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.
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.
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 |
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.
>>> from unicodedata import name (1)
>>> {chr(i) for i in range(32, 256) if 'SIGN' in name(chr(i),'')} (2)
{'§', '=', '¢', '#', '¤', '<', '¥', 'µ', '×', '$', '¶', '£', '©',
'°', '+', '÷', '±', '>', '¬', '®', '%'}
-
Importa a função
name
deunicodedata
para obter os nomes dos caracteres. -
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, |
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).Math symbol | Python operator | Method | Description |
---|---|---|---|
S ∩ Z |
|
|
Intersecção de |
|
|
Operador |
|
|
Intersecção de |
||
|
|
|
|
|
|
||
S ∪ Z |
|
|
União de |
|
|
|
|
|
União de |
||
|
|
|
|
|
|
||
S \ Z |
|
|
Complemento relativo ou diferença entre |
|
|
Operador |
|
|
Diferença entre |
||
|
|
|
|
|
|
||
S ∆ Z |
|
|
Diferença simétrica (o complemento da intersecção |
|
|
Operador |
|
|
Complemento de |
||
|
|
|
|
|
|
A Tabela 9 lista predicados de conjuntos: operadores e métodos que devolvem True
ou False
.
Math symbol | Python operator | Method | Description |
---|---|---|---|
S ∩ Z = ∅ |
|
|
|
e ∈ S |
|
|
Elemento |
S ⊆ Z |
|
|
|
|
|
||
S ⊂ Z |
|
|
|
S ⊇ Z |
|
|
|
|
|
||
S ⊃ 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.
set | frozenset | ||
---|---|---|---|
|
● |
Adiciona elemento |
|
|
● |
Remove todos os elementos de |
|
|
● |
● |
Cópia rasa de |
|
● |
Remove elemento |
|
|
● |
● |
Obtém iterador de |
|
● |
● |
|
|
● |
Remove e devolve um elemento de |
|
|
● |
Remove elemento |
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
.
frozenset | dict_keys | dict_items | Description | |
---|---|---|---|---|
|
● |
● |
● |
|
|
● |
● |
● |
operador |
|
● |
● |
● |
|
|
● |
Cópia rasa de |
||
|
● |
Diferença entre |
||
|
● |
Intersecção de |
||
|
● |
● |
● |
|
|
● |
|
||
|
● |
|
||
|
● |
● |
● |
obtém iterador para |
|
● |
● |
● |
|
|
● |
● |
● |
|
|
● |
● |
● |
Operador |
|
● |
● |
Obtém iterador para |
|
|
● |
● |
● |
Operador |
|
● |
● |
● |
|
|
● |
Complemento de |
||
|
● |
União de |
||
|
● |
● |
● |
|
|
● |
● |
● |
Operador |
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 Por outro lado, uma view devolvida por |
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).
4. Texto em Unicode versus Bytes
Humanos usam texto. Computadores falam em bytes.[43]
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
, ememoryview
-
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
ebytes
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 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.
>>> 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é'
-
A
str
'café'
tem quatro caracteres Unicode. -
Codifica
str
parabytes
usando a codificação UTF-8. -
bytes
literais são prefixados com umb
. -
bytes
b
tem cinco bytes (o ponto de código para "é" é codificado com dois bytes em UTF-8). -
Decodifica
bytes
parastr
usando a codificação UTF-8.
👉 Dica
|
Um jeito fácil de sempre lembrar a distinção entre |
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.
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')
-
bytes
pode ser criado a partir de umastr
, dada uma codificação. -
Cada item é um inteiro em
range(256)
. -
Fatias de
bytes
também sãobytes
—mesmo fatias de um único byte. -
Não há uma sintaxe literal para
bytearray
: elas aparecem comobytearray()
com um literalbytes
como argumento. -
Uma fatia de
bytearray
também é umabytearray
.
⚠️ Aviso
|
O fato de |
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 nomeadoencoding
-
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 |
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.
>>> 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)
-
O typecode
'h'
cria umarray
de short integers (inteiros de 16 bits). -
octets
mantém uma cópia dos bytes que compõemnumbers
. -
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.
>>> 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.
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 dolatin1
aparecem nos bytes docp1252
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 |
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.
>>> 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ão Paulo'
-
As codificações UTF lidam com qualquer
str
-
iso8859_1
também funciona com a string'São Paulo'
. -
cp437
não consegue codificar o'ã'
("a" com til). O método default de tratamento de erro, —'strict'
—gera umUnicodeEncodeError
. -
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. -
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. -
'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 |
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
.
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'
-
A palavra "Montréal" codificada em
latin1
;'\xe9'
é o byte para "é". -
Decodificar com Windows 1252 funciona, pois esse codec é um superconjunto de
latin1
. -
ISO-8859-7 foi projetado para a língua grega, então o byte
'\xe9'
é interpretado de forma incorreta, e nenhum erro é gerado. -
KOI8-R é foi projetado para o russo. Agora
'\xe9'
significa a letra "И" do alfabeto cirílico. -
O codec
'utf_8'
detecta queoctets
não é UTF-8 válido, e gera umUnicodeDecodeError
. -
Usando
'replace'
para tratamento de erro, o\xe9
é substituído por "�" (ponto de código #U+FFFD), o caractere oficial do Unicode chamadoREPLACEMENT 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.
# 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 |
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.
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: |
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.
Observe a sessão de console no Exemplo 8. Você consegue ver o erro?
>>> 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 |
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.
>>> 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'
-
Por default,
open
usa o modo texto e devolve um objetoTextIOWrapper
com uma codificação específica. -
O método
write
de umTextIOWrapper
devolve o número de caracteres Unicode escritos. -
os.stat
diz que o arquivo tem 5 bytes; o UTF-8 codifica'é'
com 2 bytes, 0xc3 e 0xa9. -
Abrir um arquivo de texto sem uma codificação explícita devolve um
TextIOWrapper
com a codificação configurada para um default do locale. -
Um objeto
TextIOWrapper
tem um atributo de codificação que pode ser inspecionado: neste caso,cp1252
. -
Na codificação
cp1252
do Windows, o byte 0xc3 é um "Ã" (A maiúsculo com til), e 0xa9 é o símbolo de copyright. -
Abrindo o mesmo arquivo com a codificação correta.
-
O resultado esperado: os mesmo quatro caracteres Unicode para
'café'
. -
A flag
'rb'
abre um arquivo para leitura em modo binário. -
O objeto devolvido é um
BufferedReader
, e não umTextIOWrapper
. -
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.
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.
> 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'
-
chcp
mostra a página de código ativa para o console:437
. -
Executando default_encodings.py, com a saída direcionada para o console.
-
locale.getpreferredencoding()
é a configuração mais importante. -
Arquivos de texto usam`locale.getpreferredencoding()` por default.
-
A saída está direcionada para o console, então
sys.stdout.isatty()
éTrue
. -
Agora,
sys.stdout.encoding
não é a mesma que a página de código informada porchcp
!
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 |
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.
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.
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.
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 porlocale.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 ambientePYTHONIOENCODING
—agora essa variável é ignorada, a menos quePYTHONLEGACYWINDOWSSTDIO
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 porlocale.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 parastr
. 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 quandoopen()
recebe um argumentostr
para um nome de arquivo; se o nome do arquivo é passado como um argumentobytes
, 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 |
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.
"""
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.
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)
-
Decompõe todos os caracteres em caracteres base e marcações combinadas.
-
Filtra e retira todas as marcações combinadas.
-
Recompõe todos os caracteres.
Exemplo 15 mostra alguns usos para shave_marks
.
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)
-
Apenas as letras "è", "ç", e "í" foram substituídas.
-
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.
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)
-
Decompõe todos os caracteres em caracteres base e marcações combinadas.
-
Pula as marcações combinadas quando o caractere base é latino.
-
Caso contrário, mantém o caractere original.
-
Detecta um novo caractere base e determina se ele é latino.
-
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.
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)
-
Cria uma tabela de mapeamento para substituição de caractere para caractere.
-
Cria uma tabela de mapeamento para substituição de string para caractere.
-
Funde as tabelas de mapeamento.
-
dewinize
não afeta texto emASCII
oulatin1
, apenas os acréscimos da Microsoft aolatin1
nocp1252
. -
Aplica
dewinize
e remove as marcações de sinais diacríticos. -
Substitui o Eszett por "ss" (não estamos usando case folding aqui, pois queremos preservar maiúsculas e minúsculas).
-
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.
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)
-
dewinize
substitui as aspas curvas, os bullets, e o ™ (símbolo de marca registrada). -
asciize
aplicadewinize
, 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 |
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ê.
locale.strxfrm
como chave de ornenamentoimport 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 delocale.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. Massorted(fruits, key=locale.strxfrm)
produz o mesmo resultado incorreto desorted(fruits)
. Também tentei os localesfr_FR
,es_ES
, ede_DE
no macOS, maslocale.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.
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 |
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]
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.
⚠️ 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
#!/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:])
-
Configura os defaults para a faixa de pontos de código da busca.
-
find
aceitaquery_words
e somente argumentos nomeados (opcionais) para limitar a faixa da busca, facilitando os testes. -
Converte
query_words
em um conjunto de strings capitalizadas. -
Obtém o caractere Unicode para
code
. -
Obtém o nome do caractere, ou
None
se o ponto de código não estiver atribuído a um caractere. -
Se há um nome, separa esse nome em uma lista de palavras, então verifica se o conjunto
query
é um subconjunto daquela lista. -
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
.
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')
-
Ponto de código no formato
U+0000
. -
O caractere, centralizado em uma
str
de tamanho 6. -
Mostra
re_dig
se o caractere casa com a regexr'\d'
. -
Mostra
isdig
sechar.isdigit()
éTrue
. -
Mostra
isnum
sechar.isnumeric()
éTrue
. -
Valor numérico formatado com tamanho 5 e duas casa decimais.
-
O nome Unicode do caractere.
Executar o Exemplo 22 gera a Figura 7, se a fonte do seu terminal incluir todos aqueles símbolos.
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
.
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)
-
As duas primeiras expressões regulares são do tipo
str
. -
As duas últimas são do tipo
bytes
. -
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). -
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).
-
Uma string
bytes
é necessária para a busca com as expressões regularesbytes
. -
O padrão
str
r'\d+'
casa com os dígitos ASCII e tamil. -
O padrão
bytes
rb'\d+'
casa apenas com os bytes ASCII para dígitos. -
O padrão
str
r'\w+'
casa com letras, superescritos e dígitos tamil e ASCII. -
O padrão
bytes
rb'\w+'
casa apenas com bytes ASCII para letras e dígitos.
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.
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']
-
O segundo nome de arquivo é "digits-of-π.txt" (com a letra grega pi).
-
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.
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 Entretanto, |
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.
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
-
O
__repr__
herdado deobject
não é muito útil. -
O
==
não faz sentido; o método__eq__
herdado deobject
compara os IDs dos objetos. -
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 |
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
-
Um
__repr__
útil. -
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:
Além de ser mais legível, essa forma permite fornecer o mapeamento de campos e tipos como |
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.
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
|
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.
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.
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 |
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 |
O Exemplo 4 mostra como poderíamos definir uma tupla nomeada para manter informações sobre uma cidade.
>>> 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'
-
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.
-
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) -
É 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()
.
>>> 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]}'
-
._fields
é uma tupla com os nomes dos campos da classe. -
._make()
cria umaCity
a partir de um iterável;City(*delhi_data)
faria o mesmo. -
._asdict()
devolve umdict
criado a partir da instância de tupla nomeada. -
._asdict()
é útil para serializar os dados no formato JSON, por exemplo.
⚠️ Aviso
|
Até o Python 3.7, o método |
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
.
>>> 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.
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.
from typing import NamedTuple
class Coordinate(NamedTuple):
lat: float # (1)
lon: float
reference: str = 'WGS84' # (2)
-
Todo campo de instância precisa ter uma anotação de tipo.
-
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 |
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.
>>> import typing
>>> class Coordinate(typing.NamedTuple):
... lat: float
... lon: float
...
>>> trash = Coordinate('Ni!', None)
>>> print(trash)
Coordinate(lat='Ni!', lon=None) # (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
ouFrenchDeck
. -
Um tipo de coleção parametrizada, como
list[int]
,tuple[str, float]
, etc. -
typing.Optional
, por exemploOptional[str]
—para declarar um campo que pode ser umastr
ouNone
.
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
.
class DemoPlainClass:
a: int # (1)
b: float = 1.1 # (2)
c = 'spam' # (3)
-
a
se torna um registro em__annotations__
, mas é então descartada: nenhum atributo chamadoa
é criado na classe. -
b
é salvo como uma anotação, e também se torna um atributo de classe com o valor1.1
. -
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.
typing.NamedTuple
import typing
class DemoNTClass(typing.NamedTuple):
a: int # (1)
b: float = 1.1 # (2)
c = 'spam' # (3)
-
a
se torna uma anotação e também um atributo de instância. -
b
é outra anotação, mas também se torna um atributo de instância com o valor default1.1
. -
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 AttributeError
, 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.
@dataclass
from dataclasses import dataclass
@dataclass
class DemoDataClass:
a: int # (1)
b: float = 1.1 # (2)
c = 'spam' # (3)
-
a
se torna uma anotação, e também um atributo de instância controlado por um descritor. -
b
é outra anotação, e também se torna um atributo de instância com um descritor e um valor default de1.1
. -
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 DemoDataClass
:
>>> 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.
Option | Meaning | Default | Notes |
---|---|---|---|
|
Gera o |
|
Ignorado se o
|
|
Gera o |
|
Ignorado se o
|
|
Gera o |
|
Ignorado se o
|
|
Gera |
|
Se |
|
Gera o |
|
Semântica complexa e várias restrições—veja a: documentação de dataclass. |
|
Cria instâncias "imutáveis" |
|
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__
comunsafe_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.
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
.
ClubMember
funcionafrom 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 |
Se você estudar a documentação do módulo dataclasses
, verá um campo list
definido com uma sintaxe nova, como no Exemplo 15.
ClubMember
é mais precisafrom dataclasses import dataclass, field
@dataclass
class ClubMember:
name: str
guests: list[str] = field(default_factory=list) # (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 |
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.
Option | Meaning | Default |
---|---|---|
|
Valor default para o campo |
|
|
função com 0 parâmetros usada para produzir um valor default |
|
|
Incluir o campo nos parâmetros de |
|
|
Incluir o campo em |
|
|
Usar o campo nos métodos de comparação |
|
|
Incluir o campo no cálculo de |
None[64] |
|
Mapeamento com dados definidos pelo usuário; ignorado por |
|
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.
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 |
O Exemplo 17 mostra a implementação.
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)
-
HackerClubMember
estendeClubMember
. -
all_handles
é um atributo de classe. -
handle
é um campo de instância do tipostr
, com uma string vazia como valor default; isso o torna opcional. -
Obtém a classe da instância.
-
Se
self.handle
é a string vazia, a define como a primeira parte dename
. -
Se
self.handle
está emcls.all_handles
, gera umValueError
. -
Insere o novo
handle
emcls.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 umset
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".
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]
O padrão define 15 campos opcionais; a classe Resource
, no Exemplo 19, usa 8 deles.
Resource
, uma classe baseada nos termos do Dublin Corefrom 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)
-
Esse
Enum
vai fornecer valores de um tipo seguro para o campoResource.type
. -
identifier
é o único campo obrigatório. -
title
é o primeiro campo com um default. Isso obriga todos os campos abaixo dele a fornecerem defaults. -
O valor de
date
pode ser uma instância dedatetime.date
ouNone
. -
O default do campo
type
éResourceType.BOOK
.
O Exemplo 20 mostra um doctest, para demonstrar como um registro Resource
aparece no código.
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.
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)
-
Dá início à lista
res
, para criar a string de saída com o nome da classe e o parênteses abrindo. -
Para cada campo
f
na classe… -
…obtém o atributo nomeado da instância.
-
Anexa uma linha indentada com o nome do campo e
repr(value)
—é isso que o!r
faz. -
Acrescenta um parênteses fechando.
-
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]
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.
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.
City
e algumas instânciasimport 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
|
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.
PEP 557 "Justificativa"
Em RealPython.com, Geir Arne Hjelle escreveu um "Ultimate guide to data classes in Python 3.7" (O guia definitivo das classes de dados no Python 3.7) (EN) muito completo.
Na PyCon US 2018, Raymond Hettinger apresentou "Dataclasses: The code generator to end all code generators" (video) (Dataclasses: O gerador de código para acabar com todos os geradores de código) (EN).
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.
O site Refactoring Guru (Guru da Refatoração) (EN) também tem uma descrição do data class code smell (classe de dados como cheiro no código) (EN).
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.‘”
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.
>>> a = [1, 2, 3] (1)
>>> b = a (2)
>>> a.append(4) (3)
>>> b (4)
[1, 2, 3, 4]
-
Cria uma lista [1, 2, 3] e a vincula à variável
a
. -
Vincula a variável
b
ao mesmo valor referenciado pora
. -
Modifica a lista referenciada por
a
, anexando um novo item. -
É possível ver o efeito através da variável
b
. Se você pensar emb
como uma caixa que guardava uma cópia de[1, 2, 3]
da caixaa
, este comportamento não faz sentido.
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.
>>> 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']
-
A saída
Gizmo id: …
é um efeito colateral da criação de uma instância deGizmo
. -
Multiplicar uma instância de
Gizmo
levanta uma exceção. -
Aqui está a prova que um segundo
Gizmo
foi de fato instanciado antes que a multiplicação fosse tentada. -
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.
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}
-
lewis
é um apelido paracharles
. -
O operador
is
e a funçãoid
confirmam essa afirmação. -
Adicionar um item a
lewis
é o mesmo que adicionar um item acharles
.
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.
charles
e lewis
estão vinculados ao mesmo objeto; alex
está vinculado a um objeto diferente de valor igual.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
-
alex
é uma referência a um objeto que é uma réplica do objeto vinculado acharles
. -
Os objetos são iguais quando comparados devido à implementação de
__eq__
na classedict
. -
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çãoid()
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 |
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 é |
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.
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
-
t1
é imutável, mast1[-1]
é mutável. -
Cria a tupla
t2
, cujos itens são iguais àqueles det1
. -
Apesar de serem objetos distintos, quando comparados
t1
et2
são iguais, como esperado. -
Obtém o ID da lista na posição
t1[-1]
. -
Modifica diretamente a lista
t1[-1]
. -
O ID de
t1[-1]
não mudou, apenas seu valor. -
t1
et2
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
-
list(l1)
cria uma cópia del1
. -
As cópias são iguais…
-
…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. |
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)
-
l2
é uma cópia rasa del1
. Este estado está representado em Figura 3. -
Concatenar
100
al1
não tem qualquer efeito sobrel2
. -
Aqui removemos
55
da lista internal1[1]
. Isso afetal2
, poisl2[1]
está associado à mesma lista eml1[1]
. -
Para um objeto mutável como a lista referida por
l2[1]
, o operador+=
altera a lista diretamente. Essa mudança é visível eml1[1]
, que é um apelido paral2[1]
. -
+=
em uma tupla cria uma nova tupla e reassocia a variávell2[2]
a ela. Isso é equivalente a fazerl2[2] = l2[2] + (10, 11)
. Agora as tuplas na última posição del1
el2
não são mais o mesmo objeto. Veja Figura 4.
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)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)]
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.
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.
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)
-
Usando
copy
edeepcopy
, criamos três instâncias distintas deBus
. -
Após
bus1
deixar'Bill'
, ele também desaparece debus2
. -
A inspeção do atributo dos
passengers
mostra quebus1
ebus2
compartilham o mesmo objeto lista, poisbus2
é uma cópia rasa debus1
. -
bus3
é uma cópia profunda debus1
, então seu atributopassengers
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.
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.
>>> 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))
-
O número
x
não se altera. -
A lista
a
é alterada. -
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.
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)
-
Quando o argumento
passengers
não é passado, esse parâmetro é vinculado ao objeto lista default, que inicialmente está vazio. -
Essa atribuição torna
self.passengers
um apelido depassengers
, que por sua vez é um apelido para a lista default, quando um argumentopassengers
não é passado para a função. -
Quando os métodos
.remove()
e.append()
são usados comself.passengers
, estamos, na verdade, mudando a lista default, que é um atributo do objeto-função.
Exemplo 13 mostra o comportamento misterioso de HauntedBus
.
>>> 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']
-
bus1
começa com uma lista de dois passageiros. -
Até aqui, tudo bem: nenhuma surpresa em
bus1
. -
bus2
começa vazio, então a lista vazia default é vinculada aself.passengers
. -
bus3
também começa vazio, e novamente a lista default é atribuída. -
A lista default não está mais vazia!
-
Agora
Dave
, pego pelobus3
, aparece nobus2
. -
O problema:
bus2.passengers
ebus3.passengers
se referem à mesma lista. -
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.
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']
-
basketball_team
contém o nome de cinco estudantes. -
Um
TwilightBus
é carregado com o time. -
O
bus
deixa uma estudante, depois outra. -
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.
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)
-
Aqui nós cuidadosamente criamos uma lista vazia quando
passengers
éNone
. -
Entretanto, esta atribuição transforma
self.passengers
em um apelido parapassengers
, que por sua vez é um apelido para o argumento efetivamente passado para__init__
(i.e.basketball_team
em Exemplo 14). -
Quando os métodos
.remove()
e.append()
são usados comself.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)
-
Cria uma cópia da lista
passengers
, ou converte o argumento paralist
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.
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)
-
Cria o objeto
[1, 2]
e vinculaa
a ele. -
Vincula
b
ao mesmo objeto[1, 2]
. -
Apaga a referência
a
. -
[1, 2]
não é afetado, poisb
ainda aponta para ele. -
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 |
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.
>>> 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
-
s1
es2
são apelidos do mesmo conjunto,{1, 2, 3}
. -
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.
-
Registra o callback
bye
no objeto referenciado pors1
. -
O atributo
.alive
éTrue
antes do objetofinalize
ser chamado. -
Como vimos,
del
não apaga o objeto, apenas a referências1
a ele. -
Reassociar a última referência,
s2
, torna{1, 2, 3}
inacessível. Ele é destruído, o callbackbye
é invocado, eender.alive
se tornaFalse
.
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 |
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.
>>> t1 = (1, 2, 3)
>>> t2 = tuple(t1)
>>> t2 is t1 (1)
True
>>> t3 = t1[:]
>>> t3 is t1 (2)
True
-
t1
et2
estão vinculadas ao mesmo objeto -
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]
>>> t1 = (1, 2, 3)
>>> t3 = (1, 2, 3) # (1)
>>> t3 is t1 # (2)
False
>>> s1 = 'ABC'
>>> s2 = 'ABC' # (3)
>>> s2 is s1 # (4)
True
-
Criando uma nova tupla do zero.
-
t1
et3
são iguais, mas não são o mesmo objeto. -
Criando uma segunda
str
do zero. -
Surpresa:
a
eb
se referem à mesmastr
!
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 |
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."
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]
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
.
__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'>
-
Isso é uma sessão do console, então estamos criando uma função "durante a execução".
-
__doc__
é um dos muitos atributos de objetos função. -
factorial
é um instância da classefunction
.
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.
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.
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.
>>> 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.
>>> 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.
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]
>>>
-
Cria uma lista de fatoriais de 0! a 5!.
-
Mesma operação, com uma compreensão de lista.
-
Lista de fatoriais de números ímpares até 5!, usando
map
efilter
. -
A compreensão de lista realiza a mesma tarefa, substituindo
map
efilter
, e tornandolambda
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).
reduce
e sum
>>> from functools import reduce (1)
>>> from operator import add (2)
>>> reduce(add, range(100)) (3)
4950
>>> sum(range(100)) (4)
4950
>>>
-
A partir do Python 3.0,
reduce
deixou de ser uma função embutida. -
Importa
add
para evitar a criação de uma função apenas para somar dois números. -
Soma os inteiros até 99.
-
Mesma operação, com
sum
—não é preciso importar nem chamarreduce
eadd
.
✒️ Nota
|
A ideia comum de |
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([])
devolveTrue
. any(iterable)
-
Devolve
True
se qualquer elemento doiterable
for verdadeiro;any([])
devolveFalse
.
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
.
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 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õeslambda
. - Funções embutidas
-
Uma funções implementadas em C (no CPython), como
len
outime.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 operadornew
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
, contendoyield
em seu corpo. Quando chamados, devolvem um gerador assíncrono para ser usado comasync 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 >>> 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]
BingoCage
faz apenas uma coisa: escolhe itens de uma lista embaralhadaimport 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()
-
__init__
aceita qualquer iterável; criar uma cópia local evita efeitos colaterais inesperados sobre qualquerlist
passada como argumento. -
shuffle
sempre vai funcionar, poisself._items
é umalist
. -
O método principal.
-
Se
self._items
está vazia, gera uma exceção com uma mensagem apropriada. -
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.
tag
gera elementos HTML; um argumento somente nomeado class_
é usado para passar atributos "class"; o _
é necessário porque class
é uma palavra reservada no Pythondef 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.
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" />'
-
Um argumento posicional único produz uma
tag
vazia com aquele nome. -
Quaisquer argumentos após o primeiro serão capturados por
*content
na forma de umatuple
. -
Argumentos nomeados que não são mencionados explicitamente na assinatura de
tag
são capturados por**attrs
como umdict
. -
O parâmetro
class_
só pode ser passado como um argumento nomeado. -
O primeiro argumento posicional também pode ser passado como argumento nomeado.
-
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'
nodict
de argumentos, porque ele é uma string, e não colide com a palavra reservadaclass
.
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 |
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
.
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.
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.
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
.
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)
-
Usa
namedtuple
para definirLatLon
. -
Também define
Metropolis
. -
Cria a lista
metro_areas
com instâncias deMetropolis
; observe o desempacotamento da tupla aninhada para extrair(lat, lon)
e usá-los para criar oLatLon
do atributocoord
deMetropolis
. -
Obtém a latitude de dentro de
metro_areas[0]
. -
Define um
attrgetter
para obtername
e o atributo aninhadocoord.lat
. -
Usa
attrgetter
novamente para ordenar uma lista de cidades pela latitude. -
Usa o attrgetter definido em 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.
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.
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]
-
Cria uma nova função
triple
a partir demul
, vinculando o primeiro argumento posicional a3
. -
Testa a função.
-
Usa
triple
commap
;mul
não funcionaria commap
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.
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.
>>> 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'}
-
Importa
tag
do Exemplo 9 e mostra seu ID. -
Cria a função
picture
a partir detag
, fixando o primeiro argumento posicional em'img'
e o argumento nomeadoclass_
em'pic-frame'
. -
picture
funciona como esperado. -
partial()
devolve um objetofunctools.partial
.[80] -
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.
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 tipoAny
é 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 argumentos: 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.
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.
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 [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.
showcount
de hints_2/messages.py com um argumento opcionaldef 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?
O relatório de erros do Mypy não é muito útil:
A dica de tipo para o argumento 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.
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 queplural
pode ser umastr
ouNone
. -
É 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
|
|
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.
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 deBird
, você pode atribuir uma instância deDuck
a um parâmetro anotado comobirdie: Bird
. Mas no corpo da função, o verificador considera a chamadabirdie.quack()
ilegal, poisbirdie
é nominalmente umBird
, e aquela classe não fornece o método.quack()
. Não interessa que o argumento real, durante a execução, é umDuck
, 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]
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()
-
Duck
é uma subclasse deBird
. -
alert
não tem dicas de tipo, então o verificador a ignora. -
alert_duck
aceita um argumento do tipoDuck
. -
alert_bird
aceita um argumento do tipoBird
.
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.
from birds import *
daffy = Duck()
alert(daffy) # (1)
alert_duck(daffy) # (2)
alert_bird(daffy) # (3)
-
Chamada válida, pois
alert
não tem dicas de tipo. -
Chamada válida, pois
alert_duck
recebe um argumento do tipoDuck
edaffy
é umDuck
. -
Chamada válida, pois
alert_bird
recebe um argumento do tipoBird
, edaffy
também é umBird
— a superclasse deDuck
.
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.
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.
>>> 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'
-
O Mypy não tinha como detectar esse erro, pois não há dicas de tipo em
alert
. -
O Mypy avisou do problema:
Argument 1 to "alert_duck" has incompatible type "Bird"; expected "Duck"
(Argumento 1 paraalert_duck
é do tipo incompatível "Bird"; argumento esperado era "Duck") -
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
etyping.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:
-
Dados
T1
e um subtipoT2
, entãoT2
é consistente-comT1
(substituição de Liskov). -
Todo tipo é consistente-com
Any
: você pode passar objetos de qualquer tipo em um argumento declarado como de tipo `Any. -
Any
é consistente-com todos os tipos: você sempre pode passar um objeto de tipoAny
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 |
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 |
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
O operador |
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 |
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.
tokenize
com dicas de tipo para Python ≥ 3.9def 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.
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.
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)
-
Esse comentário evita que o Mypy avise que o pacote
geolib
não tem nenhuma dica de tipo. -
O parâmetro
lat_lon
, anotado como umatuple
com dois camposfloat
.
👉 Dica
|
Com Python < 3.9, importe e use |
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
.
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+2026
—HORIZONTAL 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, ...]]
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.
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
-
tokenize
é uma função geradora. Capítulo 17 é sobre geradores. -
A variável local
index
está anotada. Sem a dica, o Mypy diz:Need type annotation for 'index' (hint: "index: dict[<type>, <type>] = …")
. -
Eu usei o operador morsa (walrus operator)
:=
na condição doif
. Ele atribui o resultado da chamada aunicodedata.name()
aname
, e a expressão inteira é calculada a partir daquele resultado. Quando o resultado é''
, isso é falso, e oindex
não é atualizado.[91]
✒️ Nota
|
Ao usar |
8.5.7. Classes bases abstratas
Seja conservador no que envia, mas liberal no que aceita.
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 comoSequence
ouIterable
.
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:
-
Usar um dos tipo concretos,
int
,float
, oucomplex
— como recomendado pela PEP 488. -
Declarar um tipo union como
Union[float, Decimal, Fraction]
. -
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 |
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.
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
-
FromTo
é um apelido de tipo: eu atribuituple[str, str]
aFromTo
, para tornar a assinatura dezip_replace
mais legível. -
changes
tem que ser umIterable[FromTo]
; é o mesmo que escreverIterable[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
|
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.
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-comSequence[int]
- então o tipo parametrizado éint
, então o tipo de retorno élist[int]
. -
Se chamada com uma
str
— que é consistente-comSequence[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 |
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.
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]
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 |
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 |
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.
top
function com um parâmetro de tipo T
indefinidodef 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
.
Protocol
, SupportsLessThan
from typing import Protocol, Any
class SupportsLessThan(Protocol): # (1)
def __lt__(self, other: Any) -> bool: ... # (2)
-
Um protocolo é uma subclasse de
typing.Protocol
. -
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.
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
.
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)
-
A constante
typing.TYPE_CHECKING
é sempreFalse
durante a execução do programa, mas os verificadores de tipo fingem que ela éTrue
quando estão fazendo a verificação. -
Declaração de tipo explícita para a variável
series
, para tornar mais fácil a leitura da saída do Mypy.[96] -
Esse
if
evita que as três linhas seguintes sejam executadas durante o teste. -
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çãoreveal_type()
, mostrando o tipo inferido do argumento. -
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 |
…/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)
-
Em
test_top_tuples
,reveal_type(series)
mostra que ele é umIterator[tuple[int, str]]
— que eu declarei explicitamente. -
reveal_type(result)
confirma que o tipo produzido pela chamada atop
é o que eu queria: dado o tipo deseries
, oresult
élist[tuple[int, str]]
. -
Em
test_top_objects_error
,reveal_type(series)
mostra que ele é umalist[object*]
. Mypy põe um*
após qualquer tipo que tenha sido inferido: eu não anotei o tipo deseries
nesse teste. -
Mypy marca o erro que esse teste produz intencionalmente: o tipo dos elementos do
Iterable
series
não pode serobject
(ele tem que ser do tipoSupportsLessThan
).
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 |
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.
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)
-
update
recebe duas funções callable como argumentos. -
probe
precisa ser uma callable que não recebe nenhuma argumento e retorna umfloat
-
display
recebe um argumentofloat
e retornaNone
. -
probe_ok
é consistente-comCallable[[], float]
porque retornar umint
não quebra código que espera umfloat
. -
display_wrong
não é consistente-comCallable[[float], None]
porque não há garantia que uma função esperando umint
consiga lidar com umfloat
; por exemplo, a funçãohex
do Python aceita umint
mas rejeita umfloat
. -
O Mypy marca essa linha porque
display_wrong
é incompatível com a dica de tipo no parâmetrodisplay
emupdate
. -
display_ok
é consistente_comCallable[[float], None]
porque uma função que aceita umcomplex
também consegue lidar com um argumentofloat
. -
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
|
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]
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.
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.
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.
>>> 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>
-
deco
devolve seu objeto funçãoinner
. -
target
é decorada pordeco
. -
Invocar a
target
decorada causa, na verdade, a execução deinner
. -
A inspeção revela que
target
é agora uma referência ainner
.
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.
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)
-
registry
vai manter referências para funções decoradas por@register
. -
register
recebe uma função como argumento. -
Exibe a função que está sendo decorada, para fins de demonstração.
-
Insere
func
emregistry
. -
Devolve
func
: precisamos devolver uma função; aqui devolvemos a mesma função recebida como argumento. -
f1
ef2
são decoradas por@register
. -
f3
não é decorada. -
main
mostraregistry
, depois chamaf1()
,f2()
, ef3()
. -
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.
>>> 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.
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.
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.
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
.
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.
>>> 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.
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.
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.
>>> 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?
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.
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 globalx
do módulo.[106] -
Se há uma declaração
nonlocal x
,x
vem de e atribuída à variável localx
na função circundante mais próxima de ondex
for definida. -
Se
x
é um parâmetro ou tem um valor atribuído a si no corpo da função, entãox
é 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.
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)
-
Define a função interna
clocked
para aceitar qualquer número de argumentos posicionais. -
Essa linha só funciona porque a clausura para
clocked
engloba a variável livrefunc
. -
Devolve a função interna para substituir a função decorada.
O Exemplo 15 demonstra o uso do decorador clock
.
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:
-
Registra o tempo inicial
t0
. -
Chama a função
factorial
original, salvando o resultado. -
Computa o tempo decorrido.
-
Formata e exibe os dados coletados.
-
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.
clock
melhoraimport 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 |
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.
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.
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))
-
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.
-
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
é o mesmo que isso:
Em outras palavras, o decorador |
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 |
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ê passarmaxsize=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)
ef(1.0)
. Setyped=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
eDecimal
-
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.
htmlize()
gera HTML adaptado para diferentes tipos de objetos>>> htmlize({1, 2, 3}) # (1)
'<pre>{1, 2, 3}</pre>'
>>> htmlize(abs)
'<pre><built-in function abs></pre>'
>>> htmlize('Heimlich & Co.\n- a game') # (2)
'<p>Heimlich & 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>'
-
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. -
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'
. -
Um
int
é exibido nos formatos decimal e hexadecimal, dentro de um bloco<pre></pre>
. -
Cada item na lista é formatado de acordo com seu tipo, e a sequência inteira é apresentada como uma lista HTML.
-
Apesar de ser um subtipo de
int
,bool
recebe um tratamento especial. -
Mostra
Fraction
como uma fração. -
Mostra
float
eDecimal
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
|
|
@singledispatch
cria uma @htmlize.register
personalizada, para empacotar várias funções em uma função genéricafrom 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>'
-
@singledispatch
marca a função base, que trata o tipoobject
. -
Cada função especializada é decorada com
@«base».register
. -
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] -
Registra uma nova função para cada tipo que precisa de tratamento especial, com uma dica de tipo correspondente no primeiro parâmetro.
-
As ABCs em
numbers
são úteis para uso em conjunto comsingledispatch
.[109] -
bool
é um subtipo-denumbers.Integral
, mas a lógica desingledispatch
busca a implementação com o tipo correspondente mais específico, independente da ordem na qual eles aparecem no código. -
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. -
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 |
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 |
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.
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.
register
precisa ser invocado como uma funçãoregistry = 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()')
-
registry
é agora umset
, tornando mais rápido acrescentar ou remover funções. -
register
recebe um argumento nomeado opcional. -
A função interna
decorate
é o verdadeiro decorador; observe como ela aceita uma função como argumento. -
Registra
func
apenas se o argumentoactive
(obtido da clausura) forTrue
. -
Se
not active
efunc in registry
, remove a função. -
Como
decorate
é um decorador, ele deve devolver uma função. -
register
é nossa fábrica de decoradores, então devolvedecorate
. -
A fábrica
@register
precisa ser invocada como uma função, com os parâmetros desejados. -
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
.
>>> 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>}
-
Quando o módulo é importado,
f2
é inserida noregistry
. -
A expressão
register()
devolvedecorate
, que então é aplicado af3
. -
A linha anterior adicionou
f3
aoregistry
. -
Essa chamada remove
f2
doregistry
. -
Confirma que apenas
f3
permanece noregistry
.
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
parametrizadoimport 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)
-
clock
é a nossa fábrica de decoradores parametrizados. -
decorate
é o verdadeiro decorador. -
clocked
envolve a função decorada. -
_result
é o resultado efetivo da função decorada. -
_args
mantém os verdadeiros argumentos declocked
, enquantoargs
é astr
usada para exibição. -
result
é astr
que representa_result
, para exibição. -
Usar
**locals()
aqui permite que qualquer variável local declocked
seja referenciada emfmt
.[112] -
clocked
vai substituir a função decorada, então ela deve devolver o mesmo que aquela função devolve. -
decorate
devolveclocked
. -
clock
devolvedecorate
. -
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.
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
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 |
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?
clock
, implementado como uma classeimport 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
-
Ao invés de uma função externa
clock
, a classeclock
é nossa fábrica de decoradores parametrizados. A nomeei com umc
minúsculo, para deixar claro que essa implementação é uma substituta direta para aquela no Exemplo 24. -
O argumento passado em
clock(my_format)
é atribuído ao parâmetrofmt
aqui. O construtor da classe devolve uma instância declock
, commy_format
armazenado emself.fmt
. -
__call__
torna a instância declock
invocável. Quando chamada, a instância substitui a função decorada comclocked
. -
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.
10. Padrões de projetos com funções de primeira classe
Conformidade a padrões não é uma medida de virtude.[114]
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.
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
, eLargeOrderPromo
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.
Order
com estratégias de desconto intercambiáveisfrom 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.
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>
-
Dois clientes:
joe
tem 0 pontos de fidelidade,ann
tem 1.100. -
Um carrinho de compras com três itens.
-
A promoção
FidelityPromo
não dá qualquer desconto parajoe
. -
ann
recebe um desconto de 5% porque tem pelo menos 1.000 pontos. -
O
banana_cart
contém 30 unidade do produto"banana"
e 10 maçãs. -
Graças à
BulkItemPromo
,joe
recebe um desconto de $1,50 no preço das bananas. -
O
long_cart
tem 10 itens diferentes, cada um custando $1,00. -
joe
recebe um desconto de 7% no pedido total, por causa daLargerOrderPromo
.
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]
Order
com as estratégias de descontos implementadas como funçõesfrom 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)
-
Essa dica de tipo diz:
promotion
pode serNone
, ou pode ser um invocável que recebe umaOrder
como argumento e devolve umDecimal
. -
Para calcular o desconto, chama o invocável
self.promotion
, passandoself
como um argumento. Veja a razão disso logo abaixo. -
Nenhuma classe abstrata.
-
Cada estratégia é uma função.
👉 Dica
|
Por que self.promotion(self)?
Na classe A Seção 23.4 vai explicar o mecanismo que vincula automaticamente métodos a instâncias. Mas isso não se aplica a |
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.
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>
-
Mesmos dispositivos de teste do Exemplo 1.
-
Para aplicar uma estratégia de desconto a uma
Order
, basta passar a função de promoção como argumento. -
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.
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>
-
best_promo
selecionou alarger_order_promo
para o clientejoe
. -
Aqui
joe
recebeu o desconto debulk_item_promo
, por comprar muitas bananas. -
Encerrando a compra com um carrinho simples,
best_promo
deu à cliente fielann
o desconto dafidelity_promo
.
A implementação de best_promo
é muito simples. Veja o Exemplo 6.
best_promo
encontra o desconto máximo iterando sobre uma lista de funçõespromos = [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)
-
promos
: lista de estratégias implementadas como funções. -
best_promo
recebe uma instância deOrder
como argumento, como as outras funções*_promo
. -
Usando uma expressão geradora, aplicamos cada uma das funções de
promos
aorder
, 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.
promos
é construída a partir da introspecção do espaço de nomes global do módulofrom 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)
-
Importa as funções de promoções, para que fiquem disponíveis no espaço de nomes global.[119]
-
Itera sobre cada item no
dict
devolvido porglobals()
. -
Seleciona apenas aqueles valores onde o nome termina com o sufixo
_promo
e… -
…filtra e remove a própria
best_promo
, para evitar uma recursão infinita quandobest_promo
for invocada. -
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.
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.
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)
-
A lista
promos
é global no módulo, e começa vazia. -
promotion
é um decorador de registro: ele devolve a funçãopromo
inalterada, após inserí-la na listapromos
. -
Nenhuma mudança é necessária em
best_promo
, pois ela se baseia na listapromos
. -
Qualquer função decorada com
@promotion
será adicionada apromos
.
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.
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.
MacroCommand
tem uma lista interna de comandosclass 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()
-
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 deMacroCommand
. -
Quando uma instância de
MacroCommand
é invocada, cada comando emself.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.
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]
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étodostr.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 |
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
.
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)
-
Os componentes de um
Vector2d
podem ser acessados diretamente como atributos (não é preciso invocar métodos getter). -
Um
Vector2d
pode ser desempacotado para uma tupla de variáveis. -
O
repr
de umVector2d
emula o código-fonte usado para construir a instância. -
Usar
eval
aqui mostra que orepr
de umVector2d
é uma representação fiel da chamada a seu construtor.[123] -
Vector2d
suporta a comparação com==
; isso é útil para testes. -
print
chamastr
, que no caso deVector2d
exibe um par ordenado. -
bytes
usa o método__bytes__
para produzir uma representação binária. -
abs
usa o método__abs__
para devolver a magnitude doVector2d
. -
bool
usa o método__bool__
para devolverFalse
se oVector2d
tiver magnitude zero, caso contrário esse método devolveTrue
.
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.
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)
-
typecode
é um atributo de classe, usado na conversão de instâncias deVector2d
de/parabytes
. -
Converter
x
ey
parafloat
em__init__
captura erros mais rápido, algo útil quandoVector2d
é chamado com argumentos inadequados. -
__iter__
torna umVector2d
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] -
O
__repr__
cria uma string interpolando os componentes com{!r}
, para obter seusrepr
; comoVector2d
é iterável,*self
alimentaformat
com os componentesx
ey
. -
Dado um iterável
Vector2d
, é fácil criar umatuple
para exibição como um par ordenado. -
Para gerar
bytes
, convertemos o typecode parabytes
e concatenamos… -
…
bytes
convertidos a partir de umarray
criada iterando sobre a instância. -
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. -
A magnitude é o comprimento da hipotenusa do triângulo retângulo de catetos formados pelos componentes
x
ey
. -
__bool__
usaabs(self)
para computar a magnitude, então a converte parabool
; assim,0.0
se tornaFalse
, qualquer valor diferente de zero éTrue
.
⚠️ Aviso
|
O método |
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).
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)
-
O decorador
classmethod
modifica um método para que ele possa ser chamado diretamente em uma classe. -
Nenhum argumento
self
; em vez disso, a própria classe é passada como primeiro argumento—por convenção chamadocls
. -
Lê o
typecode
do primeiro byte. -
Cria uma
memoryview
a partir da sequência bináriaoctets
, e usa otypecode
para convertê-la.[125] -
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
.
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',)
-
klassmeth
apenas devolve todos os argumentos posicionais. -
statmeth
faz o mesmo. -
Não importa como ele seja invocado,
Demo.klassmeth
recebe sempre a classeDemo
como primeiro argumento. -
Demo.statmeth
se comporta exatamente como uma boa e velha função.
✒️ Nota
|
O decorador |
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 ofmt
emfmt.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'
-
A formatação especificada é
'0.4f'
. -
A formatação especificada é
'0.2f'
. Orate
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. -
Novamente, a especificação é
'0.2f'
. A expressão1 / 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, |
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.
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)
-
Usa a função embutida
format
para aplicar ofmt_spec
a cada componente do vetor, criando um iterável de strings formatadas. -
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 |
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.
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)
-
O formato termina com
'p'
: usa coordenadas polares. -
Remove o sufixo
'p'
defmt_spec
. -
Cria uma
tuple
de coordenadas polares:(magnitude, angle)
. -
Configura o formato externo com chaves de ângulo.
-
Caso contrário, usa os componentes
x, y
deself
para coordenadas retângulares. -
Configura o formato externo com parênteses.
-
Gera um iterável cujos componentes são strings formatadas.
-
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.
Vector2d
imutável são exibidas aqui; a listagem completa está no Exemplo 11class 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
-
Usa exatamente dois sublinhados como prefixo (com zero ou um sublinhado como sufixo), para tornar um atributo privado.[127]
-
O decorador
@property
marca o método getter de uma propriedade. -
O método getter é nomeado de acordo com o nome da propriedade pública que ele expõe:
x
. -
Apenas devolve
self.__x
. -
Repete a mesma fórmula para a propriedade
y
. -
Todos os métodos que apenas leem os componentes
x
ey
podem permanecer como estavam, lendo as propriedades públicas através deself.x
eself.y
em vez de usar os atributos privados. Então essa listagem omite o restante do código da classe.
✒️ Nota
|
|
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.
# 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 |
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.
Vector2d
—requer Python 3.10def 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.
Vector2d
—requer Python 3.10def 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.
"""
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.
_
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]
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.
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'
-
__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 umatuple
ou em umalist
. Prefiro usar umatuple
, para deixar claro que não faz sentido modificá-la. -
Cria uma instância de
Pixel
, pois os efeitos de__slots__
são vistos nas instâncias. -
Primeiro efeito: instâncias de
Pixel
não têm um__dict__
. -
Define normalmente os atributos
p.x
ep.y
. -
Segundo efeito: tentar definir um atributo não listado em
__slots__
gera umAttributeError
.
Até aqui, tudo bem. Agora vamos criar uma subclasse de Pixel
, no Exemplo 14, para ver o lado contraintuitivo de __slots__
.
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'}
-
OpenPixel
não declara qualquer atributo próprio. -
Surpresa: instâncias de
OpenPixel
têm um__dict__
. -
Se você definir o atributo
x
(nomeado no__slots__
da classe basePixel
)… -
…ele não será armazenado no
__dict__
da instância… -
…mas sim no array oculto de referências na instância.
-
Se você definir um atributo não nomeado no
__slots__
… -
…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.
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'
-
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. -
Instâncias de
ColorPixel
não tem um__dict__
. -
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
.
__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
-
__match_args__
lista os nomes dos atributos públicos, para pattern matching posicional. -
__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.
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 |
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 |
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'
-
A representação default em
bytes
tem 17 bytes de comprimento. -
Define
typecode
como'f'
na instânciav1
. -
Agora
bytes
tem 9 bytes de comprimento. -
Vector2d.typecode
não foi modificado; apenas a instânciav1
usa otypecode
'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.
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
-
Cria
ShortVector2d
como uma subclasse deVector2d
apenas para sobrepor o atributo de classetypecode
. -
Cria
sv
, uma instância deShortVector2d
, para demonstração. -
Verifica o
repr
desv
. -
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.
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.
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)
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.
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
.
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 |
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).
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)
-
O atributo de instância "protegido"
self._components
vai manter umarray
com os componentes doVector
. -
Para permitir iteração, devolvemos um itereador sobre self._components.[133]
-
Usa
reprlib.repr()
para obter um representação de tamanho limitado deself._components
(por exemplo,array('d', [0.0, 1.0, 2.0, 3.0, 4.0, …])
). -
Remove o prefixo
array('d',
e o)
final, antes de inserir a string em uma chamada ao construtor deVector
. -
Cria um objeto
bytes
diretamente deself._components
. -
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))
. -
A única mudança necessária no
frombytes
anterior é na última linha: passamos amemoryview
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 |
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.
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 |
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.
__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))
-
Para essa demonstração, o método
__getitem__
simplesmente devolve o que for passado a ele. -
Um único índice, nada de novo.
-
A notação
1:4
se tornaslice(1, 4, None)
. -
slice(1, 4, 2)
significa comece em 1, pare em 4, ande de 2 em 2. -
Surpresa: a presença de vírgulas dentro do
[]
significa que__getitem__
recebe uma tupla. -
A tupla pode inclusive conter vários objetos
slice
.
Vamos agora olhar mais de perto a própria classe slice
, no Exemplo 5.
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']
-
slice
é um tipo embutido (que já vimos antes na Seção 2.7.2). -
Inspecionando uma
slice
descobrimos os atributos de dadosstart
,stop
, estep
, e um métodoindices
.
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 índicesstart
(início) estop
(fim), e a extensão dostride
(passo) da fatia estendida descrita porS
. Í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)
-
'ABCDE'[:10:2]
é o mesmo que'ABCDE'[0:5: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).
__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)
-
Se o argumento
key
é umaslice
… -
…obtém a classe da instância (isto é,
Vector
) e… -
…invoca a classe para criar outra instância de
Vector
a partir de uma fatia do array_components
. -
Se podemos obter um
index
dekey
… -
…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 |
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 .
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
-
Um índice inteiro recupera apenas o valor de um componente, um
float
. -
Uma fatia como índice cria um novo
Vector
. -
Um fatia de
len == 1
também cria umVector
. -
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.
__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)
-
Define
__match_args__
para permitir pattern matching posicional sobre os atributos dinâmicos suportados por__getattr__
.[135] -
Obtém a classe de
Vector
, para uso posterior. -
Tenta obter a posição de
name
em__match_args__
. -
.index(name)
gera umValueError
quandoname
não é encontrado; definepos
como-1
. (Eu preferiria usar algo comostr.find
aqui, mastuple
não implementa esse método.) -
Se
pos
está dentro da faixa de componentes disponíveis, devolve aquele componente. -
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.
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)
-
Acessa o elemento
v[0]
comov.x
. -
Atribui um novo valor a
v.x
. Isso deveria gera uma exceção. -
Ler
v.x
obtém o novo valor,10
. -
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.
__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)
-
Tratamento especial para nomes de atributos com uma única letra.
-
Se
name
está em__match_args__
, configura mensagens de erro específicas. -
Se
name
é uma letra minúscula, configura a mensagem de erro sobre todos os nomes de uma única letra. -
Caso contrário, configura uma mensagem de erro vazia.
-
Se existir uma mensagem de erro não-vazia, gera um
AttributeError
. -
Caso default: chama
__setattr__
na superclasse para obter o comportamento padrão.
👉 Dica
|
A função |
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 |
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
.
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 lst
—fn(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
.
>>> 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
-
xor agregado com um loop
for
e uma variável de acumulação. -
functools.reduce
usando uma função anônima. -
functools.reduce
substituindo alambda
personalizada poroperator.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.
__hash__
adicionados à classe Vector
de vector_v3.pyfrom 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...
-
Importa
functools
para usarreduce
. -
Importa
operator
para usarxor
. -
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. -
Cria uma expressão geradora para computar sob demanda o hash de cada componente.
-
Alimenta
reduce
comhashes
e a funçãoxor
, para computar o código hash agregado; o terceiro argumento,0
, é o inicializador (veja o próximo aviso).
⚠️ Aviso
|
Ao usar |
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).
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 |
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.
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)
-
Se as
len
dos objetos são diferentes, eles não são iguais. -
zip
produz um gerador de tuplas criadas a partir dos itens em cada argumento iterável. Veja a caixa O fantástico zip, sezip
for novidade para você. Em , a comparação comlen
é necessária porquezip
para de produzir valores sem qualquer aviso quando uma das fontes de entrada se exaure. -
Sai assim que dois componentes sejam diferentes, devolvendo
False
. -
Caso contrário, os objetos são iguais.
👉 Dica
|
O nome da função |
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
.
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.
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 ( |
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
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)
-
Importa
itertools
para usar a funçãochain
em__format__
. -
Computa uma das coordendas angulares, usando fórmulas adaptadas do artigo n-sphere (EN: ver Nota 6) na Wikipedia.
-
Cria uma expressão geradora para computar sob demanda todas as coordenadas angulares.
-
Produz uma genexp usando
itertools.chain
, para iterar de forma contínua sobre a magnitude e as coordenadas angulares. -
Configura uma coordenada esférica para exibição, com os delimitadores de ângulo (
<
e>
). -
Configura uma coordenda cartesiana para exibição, com parênteses.
-
Cria uma expressão geradoras para formatar sob demanda cada item de coordenada.
-
Insere componentes formatados, separados por vírgulas, dentro de delimitadores ou parênteses.
✒️ Nota
|
Estamos fazendo uso intensivo de expressões geradoras em |
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.
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.
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 aCollection
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.
__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.
Veja essa parte do Manual de referência da API Python/C, "Seção Protocolo de Sequência":
int PySequence_Check(PyObject *o)
-
Retorna
1
se o objeto oferecer o protocolo de sequência, caso contrário retorna0
. Observe que ela retorna1
para classes Python com um método__getitem__
, a menos que sejam subclasses dedict
[…]
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.
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 |
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.
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.
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.
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')]
-
Cria uma função que recebe
deck
,position
, ecard
como argumentos. -
Atribui aquela função a um atributo chamado
__setitem__
na classeFrenchDeck
. -
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.
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')
-
Supõe que é uma string (MFPP - mais fácil pedir perdão que permissão).
-
Converte vírgulas em espaços e divide o resultado em uma lista de nomes.
-
Desculpe,
field_names
não grasna como umastr
: não tem.replace
, ou retorna algo que não conseguimos passar para.split
-
Se um
AttributeError
aconteceu, entãofield_names
não é umastr
. Supomos que já é um iterável de nomes. -
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 umalist
, e também impede que meu código troque os nomes por engano. -
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 porisinstance()
eissubclass()
; veja a documentação do móduloabc
.[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! |
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
eissubclass
.
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
|
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
.
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)
-
__setitem__
é tudo que precisamos para possibilitar o embaralhamento… -
…mas uma subclasse de
MutableSequence
é forçada a implementar__delitem__
, um método abstrato daquela ABC. -
Também precisamos implementar
insert
, o terceiro método abstrato deMutableSequence
.
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.
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, 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 |
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]
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 operadorin
com__contains__
, eSized
oferecelen()
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
, eSized
. 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; paraMutableMapping
eMutableSet
, 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 emItemsView
,KeysView
, eValuesView
, respectivamente. Os dois primeiros também implementam a rica interface deSet
, 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.
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.
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
.
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)
-
Para definir uma ABC, crie uma subclasse de
abc.ABC
. -
Um método abstrato é marcado com o decorador
@abstractmethod
, e muitas vezes seu corpo é vazio, exceto por uma docstring.[148] -
A docstring instrui os implementadores a levantarem
LookupError
se não existirem itens para escolher. -
Uma ABC pode incluir métodos concretos.
-
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).
-
Não sabemos como as subclasses concretas vão armazenar os itens, mas podemos escrever o resultado de
inspect
esvaziando aTombola
com chamadas sucessivas a.pick()
… -
…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 |
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.
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.
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
-
Declara
Fake
como subclasse deTombola
. -
A classe é criada, nenhum erro até agora.
-
Um
TypeError
é sinalizado quando tentamos instanciarFake
. A mensagem é bastante clara:Fake
é considerada abstrata porque deixou de implementarload
, um dos métodos abstratos declarados na ABCTombola
.
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
Em outras palavras, nenhum outro decorador pode aparecer entre |
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
.
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()
-
Essa classe
BingoCage
estendeTombola
explicitamente. -
Finja que vamos usar isso para um jogo online.
random.SystemRandom
implementa a APIrandom
sobre a funçãoos.urandom(…)
, que fornece bytes aleatórios "adequados para uso em criptografia", segundo a documentação do móduloos
. -
Delega o carregamento inicial para o método
.load()
-
Em vez da função
random.shuffle()
normal, usamos o método.shuffle()
de nossa instância deSystemRandom
. -
pick
é implementado como em Exemplo 8. -
__call__
também é de Exemplo 8. Ele não é necessário para satisfazer a interface deTombola
, 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..
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)
-
O construtor aceita qualquer iterável: o argumento é usado para construir uma lista.
-
a função
random.randrange(…)
levanta umValueError
se a faixa de valores estiver vazia, então capturamos esse erro e trocamos porLookupError
, para ser compatível comTombola
. -
Caso contrário, o item selecionado aleatoriamente é retirado de
self._balls
. -
Sobrepõe
loaded
para evitar a chamada ainspect
(comoTombola.loaded
faz no Exemplo 7). Podemos fazer isso mais rápido rápida trabalhando diretamente comself._balls
— não há necessidade de criar toda uma novatuple
. -
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.
TomboList
, subclasse real de list
e subclassse virtual de Tombola
.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)
-
TomboList
é registrada como subclasse virtual deTombola
. -
TomboList
estendelist
. -
TomboList
herda seu comportamento booleano delist
, e isso retornaTrue
se a lista não estiver vazia. -
Nosso
pick
chamaself.pop
, herdado delist
, passando um índice aleatório para um item. -
TomboList.load
é o mesmo quelist.extend
. -
loaded
delega parabool
.[152] -
É 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.
Sized
no código-fonte de Lib/_collections_abc.pyclass 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)
-
Se há um atributo chamado
__len__
no__dict__
de qualquer classe listada emC.__mro__
(isto é,C
e suas superclasses)… -
…retorna
True
, sinalizando queC
é uma subclasse virtual deSized
. -
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 |
É 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.
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
-
Vamos usar esse
T
na assinatura de__mul__
. -
__mul__
é a essência do protocoloRepeatable
. O parâmetroself
normalmente não é anotado - presume-se que seu tipo seja a classe. Aqui usamosT
para assegurar que o tipo do resultado é o mesmo tipo deself
. Além disso observe querepeat_count
está limitado nesse protocolo aint
. -
A variável de tipo
RT
é vinculada pelo protocoloRepeatable
: o verificador de tipo vai exigir que o tipo efetivo implementeRepeatable
. -
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 quex
.
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
.
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.
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)
-
complex64
é um dos cinco tipos de números complexos fornecidos pelo NumPy. -
Nenhum dos tipos complexos do NumPy é subclasse do
complex
embutido. -
Mas os tipos complexos de NumPy implementam
__complex__
, então cumprem o protocoloSupportsComplex
. -
Portanto, você pode criar objetos
complex
a partir deles. -
Infelizmente, o tipo
complex
embutido não implementa__complex__
, apesar decomplex(c)
funcionar sem problemas sec
for umcomplex
.
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 |
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 Mas o problema geral persiste:
Verificações com |
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
.
complex
def __complex__(self):
return complex(self.x, self.y)
@classmethod
def fromcomplex(cls, datum):
return cls(datum.real, datum.imag) # (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.
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)
-
A anotação de retorno
float
é necessária, senão o Mypy infereAny
, e não verifica o corpo do método. -
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. -
Aqui
SupportsComplex
garante quedatum
é conversível. -
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.
RandomPicker
from typing import Protocol, runtime_checkable, Any
@runtime_checkable
class RandomPicker(Protocol):
def pick(self) -> Any: ...
✒️ Nota
|
O método |
RandomPicker
em usoimport 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)
-
Não é necessário importar um protocolo estático para definir uma classe que o implementa, Aqui eu importei
RandomPicker
apenas para usá-lo emtest_isinstance
mais tarde. -
SimplePicker
implementaRandomPicker
— mas não é uma subclasse dele. Isso é o duck typing estático em ação. -
Any
é o tipo de retorno default, então essa anotação não é estritamente necessária, mas deixa mais claro que estamos implementando o protocoloRandomPicker
, como definido em Exemplo 18. -
Não esqueça de acrescentar dicas
→ None
aos seus testes, se você quiser que o Mypy olhe para eles. -
Acrescentei uma dica de tipo para a variável
popper
, para mostrar que o Mypy entende que oSimplePicker
é consistente-com. -
Esse teste prova que uma instância de
SimplePicker
também é uma instância deRandomPicker
. Isso funciona por causa do decorador@runtime_checkable
aplicado aRandomPicker
, e porque oSimplePicker
tem um métodopick
, como exigido. -
Esse teste invoca o método
pick
deSimplePicker
, verifica que ele retorna um dos itens dados aSimplePicker
, e então realiza testes estáticos e de execução sobre o item obtido. -
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.
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)
-
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]
-
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] -
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 deRandomPicker
.
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, |
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 |
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.
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]
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:
-
Usar
super().__setitem__
, invocando aquele método na superclasse e permitindo que ele insira ou atualize o par chave/valor. -
Invocar
self.move_to_end
, para garantir que akey
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:
|
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 chamadasuper()
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 dedict
nunca será invocado pelo métodoget()
do tipo embutido.
O Exemplo 21 ilustra o problema.
__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]}
-
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. -
O método
__init__
, herdado dedict
, claramente ignora que__setitem__
foi sobreposto: o valor de'one'
não foi duplicado. -
O operador
[]
chama nosso__setitem__
e funciona como esperado:'two'
está mapeado para o valor duplicado[2, 2]
. -
O método
update
dedict
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).
__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'}
-
AnswerDict.__getitem__
sempre devolve42
, independente da chave. -
ad
é umAnswerDict
carregado com o par chave-valor('a', 'foo')
. -
ad['a']
devolve42
, como esperado. -
d
é uma instância direta dedict
, que atualizamos comad
. -
O método
dict.update
ignora nossoAnswerDict.__getitem__
.
⚠️ Aviso
|
Criar subclasses diretamente de tipos embutidos como |
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.
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.
leaf1.ping()
. Direita: Sequência de ativação para a chamada leaf1.pong()
.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()
-
Root
forneceping
,pong
, e__repr__
(para facilitar a leitura da saída). -
Os métodos
ping
epong
na classeA
chamamsuper()
. -
Apenas o método
ping
na classeB
chamasuper()
. -
A classe
Leaf
implementa apenasping
, e chamasuper()
.
Vejamos agora o efeito da invocação dos métodos ping
e pong
em uma instância de Leaf
(Exemplo 25).
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
-
leaf1
é uma instância deLeaf
. -
Chamar
leaf1.ping()
ativa os métodosping
emLeaf
,A
,B
, eRoot
, porque os métodosping
nas três primeiras classes chamamsuper().ping()
. -
Chamar
leaf1.pong()
ativapong
emA
através da herança, que por sua vez chamasuper.pong()
, ativandoB.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 |
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.
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()
-
A classe
A
vem de diamond.py (no Exemplo 24). -
A classe
U
não tem relação comA
ou`Root` do módulodiamond
. -
O que
super().ping()
faz? Resposta: depende. Continue lendo. -
LeafUA
é subclasse deU
eA
, 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.
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
.
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.
UpperCaseMixin
suporta mapeamentos indiferentes a maiúsculas/minúsculasimport 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))
-
Essa função auxiliar recebe uma
key
de qualquer tipo e tenta devolverkey.upper()
; se isso falha, devolve akey
inalterada. -
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.
UpperCaseMixin
class UpperDict(UpperCaseMixin, collections.UserDict): # (1)
pass
class UpperCounter(UpperCaseMixin, collections.Counter): # (2)
"""Specialized 'Counter' that uppercases string keys""" # (3)
-
UpperDict
não precisa de qualquer implementação própria, masUpperCaseMixin
deve ser a primeira classe base, caso contrário os métodos chamados seriam os deUserDict
. -
UpperCaseMixin
também funciona comCounter
. -
Em vez de
pass
, é melhor fornecer uma docstring para satisfazer a necessidade sintática de um corpo não-vazio na declaraçãoclass
.
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 aThreadingMixIn
. Isso é útil para lidar com navegadores web que abrem sockets prematuramente, situação na qual oHTTPServer
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.
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()
-
process_request_thread
não chamasuper()
porque é um método novo, não uma sobreposição. Sua implementação chama três métodos de instância queHTTPServer
oferece ou herda. -
Isso sobrepõe o método
process_request
, queHTTPServer
herda desocketserver.BaseServer
, iniciando uma thread e delegando o trabalho efetivo para aprocess_request_thread
que roda naquela thread. O método não chamasuper()
. -
server_close
chamasuper().server_close()
para parar de receber requisições, e então espera que as threads iniciadas porprocess_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
.
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
.
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
).
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:
|
Observe como essas classes se relacionam com outras:
-
Toplevel
é a única classe gráfica que não herda deWidget
, 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 deWm
, 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 deBaseWidget
e dePack
,Place
, eGrid
. 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 deWidget
, mas indiretamente deMisc
, que fornece dezenas de métodos para todos os componentes. -
Entry
é subclasse deWidget
eXView
, que suporta rolagem horizontal. -
Text
é subclasse deWidget
,XView
eYView
(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]
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 |
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.
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.
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]
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 paradicts
usados como registros -
Coerção de tipo
-
Acesso a dicas de tipo durante a execução
-
Tipos genéricos
-
Declarando uma classe genérica
-
Variância: tipos invariantes, covariantes e contravariantes
-
Protocolos estáticos genéricos
-
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.
sum
com assinaturaas sobrepostasimport 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)
-
Precisamos deste segundo
TypeVar
na segunda assinatura. -
Essa assinatura é para o caso simples:
sum(my_iterable)
. O tipo do resultado pode serT
—o tipo dos elementos quemy_iterable
produz—ou pode serint
, se o iterável for vazio, pois o valor default do parâmetrostart
é0
. -
Quando
start
é dado, ele pode ser de qualquer tipoS
, então o tipo do resultado éUnion[T, S]
. É por isso que precisamos deS
. SeT
fosse reutilizado aqui, então o tipo destart
teria que ser do mesmo tipo dos elementos deIterable[T]
. -
A assinatura da implementação efetiva da função não tem dicas de tipo.
São muitas linhas para anotar uma função de uma única linha.
Sim, eu sei, provavelmente isso é excessivo.
Mas pelo menos a função do exemplo não é foo
.
Se você quiser aprender sobre @overload
lendo código, o typeshed tem centenas de exemplos.
Quando escrevo esse capítulo, o arquivo stub do typeshed para as funções embutidas do Python tem 186 sobreposições—mais que qualquer outro na biblioteca padrão.
👉 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.
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…
-
O usuário não forneceu um argumento para
default=
, então ele éMISSING
, emax
gera umValueError
. -
O usuário forneceu um valor para
default=
, incluindoNone
, e entãomax
devolve o valor dedefault
.
Para consertar o issue #4051, escrevi o código no Exemplo 3.[190]
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 tipoT
-
Invocável que recebe um argumento do tipo
T
e devolve um valor do tipoLT
, que implementaSupportsLessThan
-
Um valor default de qualquer tipo
DT
O tipo devolvido por max
deve ser uma Union
do tipo T
e do tipo do argumento default
:
max([1, 2, -3], key=abs, default=None) # returns -3
max([], key=abs, default=None) # returns None
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 |
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 umastr
, ele não pode ser umint
ou umaList[str]
.
A PEP 589—TypedDict: Type Hints for Dictionaries with a Fixed Set of Keys (TypedDict: Dicas de Tipo para Dicionários com um Conjunto Fixo de Chaves) enfrenta esse problema. O Exemplo 4 mostra um TypedDict
simples.
BookDict
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).
BookDict
, mas não exatamente como planejado>>> from books import BookDict
>>> pp = BookDict(title='Programming Pearls', # (1)
... authors='Jon Bentley', # (2)
... isbn='0201657880',
... pagecount=256)
>>> pp # (3)
{'title': 'Programming Pearls', 'authors': 'Jon Bentley', 'isbn': '0201657880',
'pagecount': 256}
>>> type(pp)
<class 'dict'>
>>> pp.title # (4)
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
AttributeError: 'dict' object has no attribute 'title'
>>> pp['title']
'Programming Pearls'
>>> BookDict.__annotations__ # (5)
{'isbn': <class 'str'>, 'title': <class 'str'>, 'authors': typing.List[str],
'pagecount': <class 'int'>}
-
É possível invocar
BookDict
como um construtor dedict
, com argumentos nomeados, ou passando um argumentodict
—incluindo um literaldict
. -
Oops…Esqueci que
authors
deve ser uma lista. Mas tipagem gradual significa que não há checagem de tipo durante a execução. -
O resultado da chamada a
BookDict
é umdict
simples… -
…assim não é possível ler os campos usando a notação
objeto.campo
. -
As dicas de tipo estão em
BookDict.__annotations__
, e não empp
.
Sem um verificador de tipo, TypedDict
é tão útil quanto comentários em um programa:
pode ajudar a documentar o código, mas só isso.
As fábricas de classes do 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.
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()
-
Lembre-se de adicionar o tipo devolvido, assim o Mypy não ignora a função.
-
Este é um
BookDict
válido: todas as chaves estão presentes, com valores do tipo correto. -
O Mypy vai inferir o tipo de
authors
a partir da anotação na chave'authors'
emBookDict
. -
typing.TYPE_CHECKING
só éTrue
quando os tipos no programa estão sendo verificados. Durante a execução ele é sempre falso. -
O
if
anterior evita quereveal_type(authors)
seja chamado durante a execução.reveal_type
não é uma função do Python disponível durante a execução, mas sim um instrumento de depuração fornecido pelo Mypy. Por isso não há umimport
para ela. Veja sua saída no Exemplo 7. -
As últimas três linhas da função
demo
são ilegais. Elas vão causar mensagens de erro no Exemplo 7.
…/typeddict/ $ mypy demo_books.py
demo_books.py:13: note: Revealed type is 'built-ins.list[built-ins.str]' (1)
demo_books.py:14: error: Incompatible types in assignment
(expression has type "str", variable has type "List[str]") (2)
demo_books.py:15: error: TypedDict "BookDict" has no key 'weight' (3)
demo_books.py:16: error: Key 'title' of TypedDict "BookDict" cannot be deleted (4)
Found 3 errors in 1 file (checked 1 source file)
-
Essa observação é o resultado de
reveal_type(authors)
. -
O tipo da variável
authors
foi inferido a partir do tipo da expressão que a inicializou,book['authors']
. Você não pode atribuir umastr
para uma variável do tipoList[str]
. Verificadores de tipo em geral não permitem que o tipo de uma variável mude.[191] -
Não é permitido atribuir a uma chave que não é parte da definição de
BookDict
. -
Não se pode apagar uma chave que é parte da definição de
BookDict
.
Vejamos agora BookDict
sendo usado em assinaturas de função, para checar o tipo em chamadas de função.
Imagine que você precisa gerar XML a partir de registros de livros como esse:
<BOOK>
<ISBN>0134757599</ISBN>
<TITLE>Refactoring, 2e</TITLE>
<AUTHOR>Martin Fowler</AUTHOR>
<AUTHOR>Kent Beck</AUTHOR>
<PAGECOUNT>478</PAGECOUNT>
</BOOK>
Se você estivesse escrevendo o código em MicroPython, para ser integrado a um pequeno microcontrolador, poderia escrever uma função parecida com a que aparece no Exemplo 8.[192]
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>'
-
O principal objetivo do exemplo: usar
BookDict
em uma assinatura de função. -
Se a coleção começa vazia, o Mypy não tem inferir o tipo dos elementos. Por isso a anotação de tipo é necessária aqui.[193]
-
O Mypy entende testes com
isinstance
, e tratavalue
como umalist
neste bloco. -
Quando usei
key == 'authors'
como condição doif
que guarda esse bloco, o Mypy encontrou um erro nessa linha:"object" has no attribute "__iter__"
("object" não tem um atributo "__iter__" ), porque inferiu o tipo devalue
devolvido porbook.items()
comoobject
, que não suporta o método__iter__
exigido pela expressão geradora. O teste comisinstance
funciona porque garante quevalue
é umalist
nesse bloco.
O Exemplo 9 mostra uma função que interpreta uma str
JSON e devolve um BookDict
.
from_json
def from_json(data: str) -> BookDict:
whatever = json.loads(data) # (1)
return whatever # (2)
-
O tipo devolvido por
json.loads()
éAny
.[194] -
Posso devolver
whatever
—de tipoAny
—porqueAny
é consistente-com todos os tipos, incluindo o tipo declarado do valor devolvido,BookDict
.
O segundo ponto do Exemplo 9 é muito importante de ter em mente:
O Mypy não vai apontar qualquer problema nesse código, mas durante a execução o valor em whatever
pode não se adequar à estrutura de BookDict
—na verdade, pode nem mesmo ser um dict
!
Se você rodar o Mypy com --disallow-any-expr
, ele vai reclamar sobre as duas linhas no corpo de from_json
:
…/typeddict/ $ mypy books_any.py --disallow-any-expr
books_any.py:30: error: Expression has type "Any"
books_any.py:31: error: Expression has type "Any"
Found 2 errors in 1 file (checked 1 source file)
As linhas 30 e 31 mencionadas no trecho acima são o corpo da função from_json
.
Podemos silenciar o erro de tipo acrescentando uma dica de tipo à inicialização da variável whatever
, como no Exemplo 10.
from_json
com uma anotação de variáveldef from_json(data: str) -> BookDict:
whatever: BookDict = json.loads(data) # (1)
return whatever # (2)
-
--disallow-any-expr
não gera erros quando uma expressão de tipoAny
é imediatamente atribuída a uma variável com uma dica de tipo. -
Agora
whatever
é do tipoBookDict
, o tipo declarado do valor devolvido.
⚠️ 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 |
A verificação de tipo estática é incapaz de prevenir erros cm código inerentemente dinâmico, como json.loads()
, que cria objetos Python de tipos diferentes durante a execução. O Exemplo 11, o Exemplo 12 e o Exemplo 13 demonstram isso.
from_json
devolve um BookDict
inválido, e to_xml
o aceitafrom 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()
-
Essa linha não produz um
BookDict
válido—veja o conteúdo deNOT_BOOK_JSON
. -
Vamos deixar o Mypy revelar alguns tipos.
-
Isso não deve causar problemas:
print
consegue lidar comobject
e com qualquer outro tipo. -
BookDict
não tem uma chave'flavor'
, mas o fonte JSON tem…o que vai acontecer?? -
Lembre-se da assinatura:
def to_xml(book: BookDict) → str:
. -
Como será a saída XML?
Agora verificamos demo_not_book.py com o Mypy (no Exemplo 12).
…/typeddict/ $ mypy demo_not_book.py
demo_not_book.py:12: note: Revealed type is
'TypedDict('books.BookDict', {'isbn': built-ins.str,
'title': built-ins.str,
'authors': built-ins.list[built-ins.str],
'pagecount': built-ins.int})' (1)
demo_not_book.py:13: note: Revealed type is 'built-ins.list[built-ins.str]' (2)
demo_not_book.py:16: error: TypedDict "BookDict" has no key 'flavor' (3)
Found 1 error in 1 file (checked 1 source file)
-
O tipo revelado é o tipo nominal, não o conteúdo de
not_book
durante a execução. -
De novo, este é o tipo nominal de
not_book['authors']
, como definido emBookDict
. Não o tipo durante a execução. -
Esse erro é para a linha
print(not_book['flavor'])
: essa chave não existe no tipo nominal.
Agora vamos executar demo_not_book.py, mostrando o resultado no Exemplo 13.
demo_not_book.py
…/typeddict/ $ python3 demo_not_book.py
{'title': 'Andromeda Strain', 'flavor': 'pistachio', 'authors': True} (1)
pistachio (2)
<BOOK> (3)
<TITLE>Andromeda Strain</TITLE>
<FLAVOR>pistachio</FLAVOR>
<AUTHORS>True</AUTHORS>
</BOOK>
-
Isso não é um
BookDict
de verdade. -
O valor de
not_book['flavor']
. -
to_xml
recebe um argumentoBookDict
, mas não há qualquer verificação durante a execução: entra lixo, sai lixo.
O Exemplo 13 mostra que demo_not_book.py devolve bobagens, mas não há qualquer erro durante a execução.
Usar um TypedDict
ao tratar dados em formato JSON não resultou em uma tipagem segura.
Olhando o código de to_xml
no Exemplo 8 através das lentes do duck typing, o argumento book
deve fornecer um método .items()
que devolve um iterável de tuplas na forma (chave, valor)
, onde:
-
chave
deve ter um método.upper()
-
valor
pode ser qualquer coisa
A conclusão desta demonstração: quando estamos lidando com dados de estrutura dinâmica, tal como JSON ou XML, TypedDict
não é, de forma alguma, um substituto para a validaçào de dados durante a execução. Para isso, use o pydantic (EN).
TypedDict
tem mais recursos, incluindo suporte a chaves opcionais, uma forma limitada de herança e uma sintaxe de declaração alternativa. Para saber mais sobre ele, revise a
PEP 589—TypedDict: Type Hints for Dictionaries with a Fixed Set of Keys (TypedDict: Dicas de Tipo para Dicionários com um Conjunto Fixo de Chaves) (EN).
Vamos agora voltar nossas atenções para uma função que é melhor evitar, mas que algumas vezes é inevitável: typing.cast
.
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 |
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á queAny
é consistente-com todos os tipos, seu abuso pode produzir efeitos em cascata através da inferência de tipo, minando a capacidade do verificador de tipo para detectar erros em outras partes do código.
Claro, nem todos os contratempos de tipagem podem ser resolvidos com cast
.
Algumas vezes precisamos de # type: ignore
, do Any
ocasional, ou mesmo deixar uma função sem dicas de tipo.
A seguir, vamos falar sobre o uso de anotações durante a execução.
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]
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 nomesglobals
elocals
. […]
⚠️ Aviso
|
Desde o Python 3.10, a nova função |
A PEP 563—Postponed Evaluation of Annotations (_Avaliação Adiada de Anotações) (EN) foi aprovada para tornar desnecessário escrever anotações como strings, e para reduzir o custo das dicas de tipo durante a execução. A ideia principal está descrita nessas duas sentenças do "Abstract" (EN):
Esta PEP propõe modificar as anotações de funções e de variáveis, de forma que elas não mais sejam avaliadas no momento da definição da função. Em vez disso, elas são preservadas em __annotations__ na forma de strings..
A partir do Python 3.7, é assim que anotações são tratadas em qualquer módulo que comece com a seguinte instrução import
:
from __future__ import annotations
Para demonstrar seu efeito, coloquei a mesma função clip
do Exemplo 14 em um módulo clip_annot_post.py com aquela linha de importação __future__
no início.
No console, esse é o resultado de importar aquele módulo e ler as anotações de clip
:
>>> from clip_annot_post import clip
>>> clip.__annotations__
{'text': 'str', 'max_len': 'int', 'return': 'str'}
Como se vê, todas as dicas de tipo são agora strings simples, apesar de não terem sido escritas como strings na definição de clip
(no Exemplo 14).
A função typing.get_type_hints
consegue resolver muitas dicas de tipo, incluindo essas de clip
:
>>> from clip_annot_post import clip
>>> from typing import get_type_hints
>>> get_type_hints(clip)
{'text': <class 'str'>, 'max_len': <class 'int'>, 'return': <class 'str'>}
A chamada a get_type_hints
nos dá os tipos resis—mesmo em alguns casos onde a dica de tipo original foi escrita como uma string.
Essa é a maneira recomendada de ler dicas de tipo durante a execução.
O comportamento prescrito na PEP 563 estava previsto para se tornar o default no Python
3.10, tornando a importação com __future__
desnecessária.
Entretanto, os mantenedores da FastAPI e do pydantic soaram o alarme, essa mudança quebraria seu código, que se baseia em dicas de tipo durante a execução e não podem usar get_type_hints
de forma confiável.
Na discussão que se seguiu na lista de email python-dev, Łukasz Langa—autor da PEP 563—descreveu algumas limitações daquela função:
[…] a verdade é que
typing.get_type_hints()
tem limites que tornam seu uso geral custoso durante a execução e, mais importante, insuficiente para resolver todos os tipos. O exemplo mais comum se refere a contextos não-globais nos quais tipos são gerados (isto é, classes aninhadas, classes dentro de funções, etc.). Mas um dos principais exemplos de referências adiantadas, classes com métodos aceitando ou devolvendo objetos de seu próprio tipo, também não é tratado de forma apropriada portyping.get_type_hints()
se um gerador de classes for usado. Há alguns truques que podemos usar para ligar os pontos mas, de uma forma geral, isso não é bom.[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, useinspect.get_annotations
(desde o Python 3.10) outyping.get_type_hints
(desde o Python 3.5). -
Escreva uma função personalizada própria, como um invólucro para
inspect.get_annotations
outyping.get_type_hints
, e faça o restante de sua base de código chamar aquela função, de forma que mudanças futuras fiquem restritas a um único local.
Para demonstrar esse segundo ponto, aqui estão as primeiras linhas da classe Checked
, definida no
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.
from generic_lotto import LottoBlower
machine = LottoBlower[int](range(1, 11)) # (1)
first = machine.pick() # (2)
remain = machine.inspect() # (3)
-
Para instanciar uma classe genérica, passamos a ela um parâmetro de tipo concreto, como
int
aqui. -
O Mypy irá inferir corretamente que
first
é umint
… -
… e que
remain
é umatuple
de inteiros.
Além disso, o Mypy aponta violações do tipo parametrizado com mensagens úteis, como ilustrado no Exemplo 16.
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]
-
Na instanciação de
LottoBlower[int]
, o Mypy marca ofloat
. -
Na chamada
.load('ABC')
, o Mypy explica porque umastr
não serve:str.__iter__
devolve umIterator[str]
, masLottoBlower[int]
exige umIterator[int]
.
O Exemplo 17 é a implementação.
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)
-
Declarações de classes genéricas muitas vezes usam herança múltipla, porque precisamos de uma subclasse de
Generic
para declarar os parâmetros de tipo formais—nesse caso,T
. -
O argumento
items
em__init__
é do tipoIterable[T]
, que se tornaIterable[int]
quando uma instância é declarada comoLottoBlower[int]
. -
O método
load
é igualmente restrito. -
O tipo do valor devolvido
T
agora se tornaint
em umLottoBlower[int]
. -
Nenhuma variável de tipo aqui.
-
Por fim,
T
define o tipo dos itens natuple
devolvida.
👉 Dica
|
A seção "User-defined generic types" (Tipos genéricos definidos pelo usuário) (EN), na documentação do módulo |
Agora que vimos como implementar um classe genérica, vamos definir a terminologia para falar sobre tipos genéricos.
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
eVT
no último exemplo:abc.Mapping[KT, VT]
- Tipo parametrizado
-
Um tipo declarado com os parâmetros de tipo reais.
Exemplos:LottoBlower[int]
,abc.Mapping[str, float]
- Parâmetro de tipo real
-
Os tipos reais passados como parâmetros quando um tipo parametrizado é declarado.
Exemplo: oint
emLottoBlower[int]
O próximo tópico é sobre como tornar os tipos genéricos mais flexíveis, introduzindo os conceitos de covariância, contravariância e invariância.
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.
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."""
-
Beverage
,Juice
, eOrangeJuice
formam uma hierarquia de tipos. -
Uma declaração
TypeVar
simples. -
BeverageDispenser
é parametrizada pelo tipo de bebida. -
install
é uma função global do módulo. Sua dica de tipo faz valer a regra de que apenas máquinas de suco são aceitáveis.
Dadas as definições no Exemplo 18, o seguinte código é legal:
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
.
install
functionT_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."""
-
Define
covariant=True
ao declarar a variável de tipo;_co
é o sufixo convencional para parâmetros de tipo covariantes no typeshed. -
Usa
T_co
para parametrizar a classe especialGeneric
. -
As dicas de tipo para
install
são as mesmas do Exemplo 18.
O código abaixo funciona porque tanto a máquina de Juice
quanto a de OrangeJuice
são válidas em uma BeverageDispenser
covariante:
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:
|
Para modelar a regra descrevendo uma lata de lixo aceitável na cantina, precisamos introduzir o conceito de "contravariância" através de um exemplo, apresentado no Exemplo 20.
install
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."""
-
Uma hierarquia de tipos para resíduos:
Refuse
é o tipo mais geral,Compostable
o mais específico. -
T_contra
é o nome convencional para uma variável de tipo contravariante. -
TrashCan
é contravariante ao tipo de resíduo. -
A função
deploy
exige uma lata de lixo compatível comTrashCan[Biodegradable]
.
Dadas essas definições, os seguintes tipos de lata de lixo são aceitáveis:
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 aB
. B <: A
-
B
é um subtipo-de ou igual aA
.
Dado A :> B
, um tipo genérico C
é covariante quando C[A] :> C[B]
.
Observe que a direção da seta no símbolo :>
é a mesma nos dois casos em que A
está à esquerda de B
.
Tipos genéricos covariantes seguem a relação de subtipo do tipo real dos parâmetros.
Contêineres imutáveis podem ser covariantes.
Por exemplo, é assim que a classe typing.FrozenSet
está
documentada como covariante com uma variável de tipo usando o nome convencional T_co
:
class FrozenSet(frozenset, AbstractSet[T_co]):
Aplicando a notação :>
a tipos parametrizados, temos:
float :> int
frozenset[float] :> frozenset[int]
Iteradores são outro exemplo de genéricos covariantes:
eles não são coleções apenas para leitura como um frozenset
,
mas apenas produzem saídas.
Qualquer código que espere um abc.Iterator[float]
que produz números de ponto flutuante pode usar com segurança um abc.Iterator[int]
que produz inteiros.
Tipos Callable
são covariantes no tipo devolvido por uma razão similar.
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.
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')
-
Definir
__abs__
tornaVector2d
consistente-comSupportsAbs
. -
Parametrizar
SupportsAbs
comfloat
assegura… -
…que o Mypy aceite
abs(v)
como primeiro argumento paramath.isclose
. -
Graças a
@runtime_checkable
na definição deSupportsAbs
, essa é uma asserção válida durante a execução. -
Todo o restante do código passa pelas verificações do Mypy e pelas asserções durante a execução.
-
O tipo
int
também é consistente-comSupportsAbs
. De acordo com o typeshed,int.__abs__
devolve umint
, o que é consistente-com o parametro de tipofloat
declarado na dica de tipois_unit
para o argumentov
.
De forma similar, podemos escrever uma versão genérica do protocolo RandomPicker
, apresentado na 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
.
RandomPicker
genéricofrom 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)
-
Declara
T_co
comocovariante
. -
Isso torna
RandomPicker
genérico, com um parâmetro de tipo formal covariante. -
Usa
T_co
como tipo do valor devolvido.
O protocolo genérico RandomPicker
pode ser covariante porque seu único parâmetro formal é usado em um tipo de saída.
Com isso, podemos dizer que temos um capítulo.
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.
A documentação oficial do Python mal consegue acompanhar tudo aquilo, então a documentação do Mypy (EN) é uma referência essencial. Robust Python (EN), de Patrick Viafore (O’Reilly), é o primeiro livro com um tratamento abrangente do sistema de tipagem estática do Python que conheço, publicado em agosto de 2021. Você pode estar lendo o segundo livro sobre o assunto nesse exato instante.
O sutil tópico da variância tem sua própria seção na PEP 484 (EN), e também é abordado na página "Generics" (Genéricos) (EN) do Mypy, bem como em sua inestimável página "Common Issues" (Problemas Comuns).
A PEP 362—Function Signature Object (O Objeto Assinatura de Função)
vale a pena ler se você pretende usar o módulo inspect
, que complementa a função typing.get_type_hints
.
Se você estiver interessado na história do Python, pode gostar de saber que Guido van Rossum publicou "Adding Optional Static Typing to Python" (Acrescentando Tipagem Estática Opcional ao Python) em 23 de dezembro de 2004.
"Python 3 Types in the Wild: A Tale of Two Type Systems" (Os Tipos do Python 3 na Natureza: Um Conto de Dois Sistemas de Tipo) (EN) é um artigo científico de Ingkarat Rak-amnouykit e outros, do Rensselaer Polytechnic Institute e do IBM TJ Watson Research Center. O artigo avalia o uso de dicas de tipo em projetos de código aberto no GitHub, mostrando que a maioria dos projetos não as usam , e também que a maioria dos projetos que incluem dicas de tipo aparentemente não usam um verificador de tipos. Achei particularmente interessante a discussão das semânticas diferentes do Mypy e do pytype do Google, onde os autores concluem que eles são "essencialmente dois sistemas de tipos diferentes."
Dois artigos fundamentais sobre tipagem gradual são "Pluggable Type Systems" (Sistemas de Tipo Conectáveis) (EN), de Gilad Bracha, e "Static Typing Where Possible, Dynamic Typing When Needed: The End of the Cold War Between Programming Languages" (Tipagem Estática Quando Possível, Tipagem Dinâmica Quando Necessário: O Fim da Guerra Fria Entre Linguagens de Programação) (EN), de Eric Meijer e Peter Drayton.[205]
Aprendi muito lendo as partes relevantes de alguns livros sobre outras linguagens que implementam algumas das mesmas ideias:
-
Atomic Kotlin (EN), de Bruce Eckel e Svetlana Isakova (Mindview)
-
Effective Java, 3rd ed., (EN), de Joshua Bloch (Addison-Wesley)
-
Programming with Types: TypeScript Examples (EN), de Vlad Riscutia (Manning)
-
Programming TypeScript (EN), de Boris Cherny (O’Reilly)
-
The Dart Programming Language (EN) de Gilad Bracha (Addison-Wesley).[206]
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.
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]
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
enot
(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)
. Sex
é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.
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)
-
Para computar
-v
, cria um novoVector
com a negação de cada componente deself
. -
Para computar
+v
, cria um novoVector
com cada componente deself
.
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 .
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
|
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.
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)
-
pairs
é um gerador que produz tuplas(a, b)
, ondea
vem deself
eb
deother
. Seself
eother
tiverem tamanhos diferentes,fillvalue
fornece os valores ausentes para o iterável mais curto. -
Um novo
Vector
é criado a partir de uma expressão geradora, produzindo uma soma para cada(a, b)
depairs
.
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 ( |
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.
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.
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):
-
Se
a
implementa__add__
, invocaa.__add__(b)
e devolve o resultado, a menos que sejaNotImplemented
. -
Se
a
não implementa__add__
, ou a chamada devolveNotImplemented
, verifica seb
implementa__radd__
, e então invocab.__radd__(a)
e devolve o resultado, a menos que sejaNotImplemented
. -
Se
b
não implementa__radd__
, ou a chamada devolveNotImplemented
, gera umTypeError
com a mensagem 'unsupported operand types' (tipos de operandos não suportados).
👉 Dica
|
O método |
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 |
A implementação viável mais simples de __radd__
aparece no Exemplo 7.
__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
-
Nenhuma mudança no
__add__
do Exemplo 4; ele é listado aqui porque é usado por__radd__
. -
__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.
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.
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.
+
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 |
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 |
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.
*
adicionadosclass 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)
-
Se
scalar
não pode ser convertido parafloat
… -
…não temos como lidar com ele, então devolvemos
NotImplemented
, para permitir ao Python tentar__rmul__
no operandoscalar
. -
Neste exemplo,
__rmul__
funciona bem apenas executandoself * 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 Outra alternativa seria verificar com o protocolo Mas |
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
.
@
methodsclass 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
-
Ambos os operandos precisam implementar
__len__
e__iter__
… -
…e ter o mesmo tamanho, para permitir…
-
…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 |
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).
Operador | Direto | Reverso | No mesmo lugar | Descrição |
---|---|---|---|---|
|
|
|
|
Adição ou concatenação |
|
|
|
|
Subtração |
|
|
|
|
Multiplicação ou repetição |
|
|
|
|
Divisão exata (True division) |
|
|
|
|
Divisão inteira (Floor division) |
|
|
|
|
Módulo |
|
|
|
|
Devolve uma tupla com o quociente da divisão inteira e o módulo |
|
|
|
|
Exponenciação[213] |
|
|
|
|
Multiplicação de matrizes |
|
|
|
|
E binário (bit a bit) |
| |
|
|
|
OU binário (bit a bit) |
|
|
|
|
XOR binário (bit a bit) |
|
|
|
|
Deslocamento de bits para a esquerda |
|
|
|
|
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 devolverNotImplemented
, o Python vai comparar os IDs dos objetos em vez de gerar umTypeError
.
Grupo | Operador infixo | Método de invocação direta | Método de invocação reversa | Alternativa |
---|---|---|---|---|
Igualdade |
|
|
|
Devolve |
|
|
|
Devolve |
|
Ordenação |
|
|
|
Gera um |
|
|
|
Gera um |
|
|
|
|
Gera um |
|
|
|
|
Gera um |
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.
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
-
Duas instâncias de
Vector
com componentes numéricos iguais são iguais. -
Um
Vector
e umVector2d
também são iguais se seus componentes são iguais. -
Um
Vector
também é considerado igual a umatuple
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.
__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)
-
Se o operando
other
é uma instância deVector
(ou de uma subclasse deVector
), executa a comparação como antes. -
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.
>>> 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
-
Mesmo resultado de antes, como esperaado.
-
Mesmo resultado de antes, mas por que? Explicação a seguir.
-
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:
-
Para avaliar
vc == v2d
, o Python invocaVector.eq(vc, v2d)
. -
Vector.__eq__(vc, v2d)
verifica quev2d
não é umVector
e devolveNotImplemented
. -
O Python recebe o resultado
NotImplemented
, então tentaVector2d.__eq__(v2d, vc)
. -
Vector2d.__eq__(v2d, vc)
transforma os dois operandos em tuplas e os compara: o resulltado éTrue
(o código deVector2d.__eq__
está no Exemplo 11).
Já para a comparação va == t3
, entre Vector
e tuple
no Exemplo 15, os passos são:
-
Para avaliar
va == t3
, o Python invocaVector.__eq__(va, t3)
. -
Vector.__eq__(va, t3)
verifica quet3
não é umVector
e devolveNotImplemented
. -
O Python recebe o resultado
NotImplemented
, e então tentatuple.__eq__(t3, va)
. -
tuple.__eq__(t3, va)
não tem a menor ideia do que seja umVector
, então devolveNotImplemented
. -
No caso especial de
==
, se a chamada reversa devolveNotImplemented
, 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.
+=
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
-
Cria um alias, para podermos inspecionar o objeto
Vector([1, 2, 3])
mais tarde. -
Verifica o ID do
Vector
inicial, vinculado av1
. -
Executa a adição aumentada.
-
O resultado esperado…
-
…mas foi criado um novo
Vector
. -
Inspeciona
v1_alias
para confirmar que oVector
original não foi alterado. -
Executa a multiplicação aumentada.
-
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 |
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 +
.
+
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'
-
Cria uma instância de
globe
com cinco itens (cada uma dasvowels
). -
Extrai um dos itens, e verifica que é uma das
vowels
. -
Confirma que
globe
tem agora quatro itens. -
Cria uma segunda instância, com três itens.
-
Cria uma terceira instância pela soma das duas anteriores. Essa instância tem sete itens.
-
Tentar adicionar uma
AddableBingoCage
a umalist
falha com umTypeError
. A mensagem de erro é produzida pelo interpretador do Python quando nosso método__add__
devolveNotImplemented
.
Como uma AddableBingoCage
é mutável, o Exemplo 18 mostra como ela funcionará quando implementarmos __iadd__
.
>>> 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
-
Cria um alias para podermos verificar a identidade do objeto mais tarde.
-
globe
tem quatro itens aqui. -
Uma instância de
AddableBingoCage
pode receber itens de outra instância da mesma classe. -
O operador à diretia de
+=
também pode ser qualquer iterável. -
Durante todo esse exemplo,
globe
sempre se refere ao mesmo objeto queglobe_orig
. -
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 |
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.
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)
-
AddableBingoCage
estendeBingoCage
. -
Nosso
__add__
só vai funcionar se o segundo operando for uma instância deTombola
. -
Em
__iadd__
, obtém os itens deother
, se ele for uma instância deTombola
. -
Caso contrário, tenta obter um iterador sobre
other
.[216] -
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.
-
Se chegamos até aqui, podemos carregar o
other_iterable
paraself
. -
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 |
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.
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]
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.
Sentence
como uma sequência de palavrasimport 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)
-
.findall
devolve a lista com todos os trechos não sobrepostos correspondentes à expressão regular, como uma lista de strings. -
self.words
mantém o resultado de.findall
, então basta devolver a palavra em um dado índice. -
Para completar o protocolo de sequência, implementamos
__len__
, apesar dele não ser necessário para criar um iterável. -
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.
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']
-
Uma sentença criada a partir de uma string.
-
Observe a saída de
__repr__
gerada porreprlib.repr
, usando…
. -
Instâncias de
Sentence
são iteráveis; veremos a razão em seguida. -
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
:
-
Verifica se o objeto implementa o método
__iter__
, e o invoca para obter um iterador. -
Se
__iter__
não for implementado, mas__getitem__
sim, entãoiter()
cria um iterador que tenta buscar itens pelo índice, começando de 0 (zero). -
Se isso falhar, o Python gera um
TypeError
, normalmente dizendo'C' object is not iterable
(objeto 'C' não é iterável), ondeC
é 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 |
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
-
Cria um iterador
it
a partir de um iterável. -
Chama
next
repetidamente com o iterador, para obter o item seguinte. -
O iterador gera
StopIteration
quando não há mais itens. -
Libera a referência a
it
—o obleto iterador é descartado. -
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 loopfor
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.
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.
abc.Iterator
; extraído de Lib/_collections_abc.pyclass 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
-
__subclasshook__
suporta a verificação de tipo estrutural comisinstance
eissubclass
. Vimos isso na Seção 13.5.8. -
_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 classeC
será reconhecida como uma subclasse virtual deIterator
. Em outras palavras,issubclass(C, Iterable)
devolveráTrue
.
⚠️ Aviso
|
O método abstrato da ABC |
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 |
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']
-
Cria uma sentença
s3
com três palavras. -
Obtém um iterador a partir de
s3
. -
next(it)
devolve a próxima palavra. -
Não há mais palavras, então o iterador gera uma exceção
StopIteration
. -
Uma vez exaurido, um itereador irá sempre gerar
StopIteration
, o que faz parecer que ele está vazio.. -
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.
Sentence
implementada usando o padrão Iteratorimport 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
-
O método
__iter__
é o único acréscimo à implementação anterior deSentence
. Essa versão não inclui um__getitem__
, para deixar claro que a classe é iterável por implementar__iter__
. -
__iter__
atende ao protocolo iterável instanciando e devolvendo um iterador. -
SentenceIterator
mantém uma referência para a lista de palavras. -
self.index
determina a próxima palavra a ser recuperada. -
Obtém a palavra em
self.index
. -
Se não há palavra em
self.index
, gera umaStopIteration
. -
Incrementa
self.index
. -
Devolve a palavra.
-
Implementa
self.__iter__
.
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.
Sentence
implementada usando uma geradoraimport 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)
-
Itera sobre
self.words
. -
Produz a
word
atual. -
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 geraStopIteration
: ela simplesmente termina quando acaba de produzir valores.[220] -
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 |
>>> 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
-
O corpo de uma função geradora muitas vezes contém
yield
dentro de um loop, mas não necessariamente; aqui eu apenas repetiyield
três vezes. -
Olhando mais de perto, vemos que
gen_123
é um objeto função. -
Mas quando invocado,
gen_123()
devolve um objeto gerador. -
Objetos geradores implementam a interface
Iterator
, então são também iteráveis. -
Atribuímos esse novo objeto gerador a
g
, para podermos experimentar seu funcionamento. -
Como
g
é um iterador, chamarnext(g)
obtém o próximo item produzido poryield
. -
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 |
O Exemplo 7 torna a iteração entre um loop for
e o corpo da função mais explícita.
>>> 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)
-
A primeira chamada implícita a
next()
no loopfor
em vai exibir'start'
e parar no primeiroyield
, produzindo o valor'A'
. -
A segunda chamada implícita a
next()
no loopfor
vai exibir'continue'
e parar no segundoyield
, produzindo o valor'B'
. -
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 umaStopIteration
. -
Para iterar, o mecanismo do
for
faz o equivalente ag = iter(gen_AB())
para obter um objeto gerador, e daínext(g)
a cada iteração. -
O loop exibe
-→
e o valor devolvido pornext(g)
. Esse resultado só aparece após a saída das chamadasprint
dentro da função geradora. -
O texto
start
vem deprint('start')
no corpo da geradora. -
yield 'A'
no corpo da geradora produz o valor 'A' consumido pelo loopfor
, que é atribuído à variávelc
e resulta na saída-→ A
. -
A iteração continua com a segunda chamada a
next(g)
, avançando no corpo da geradora deyield 'A'
parayield 'B'
. O textocontinue
é gerado pelo segundoprint
no corpo da geradora. -
yield 'B'
produz o valor 'B' consumido pelo loopfor
, que é atribuído à variávelc
do loop, que então exibe-→ B
. -
A iteração continua com uma terceira chamada a
next(it)
, avançando para o final do corpo da função. O textoend.
é exibido por causa do terceiroprint
no corpo da geradora. -
Quando a função geradora chega ao final, o objeto gerador cria uma
StopIteration
. O mecanismo do loopfor
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.
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)
-
Não é necessário manter uma lista
words
. -
finditer
cria um iterador sobre os termos encontrados comRE_WORD
emself.text
, produzindo instâncias deMatchObject
. -
match.group()
extraí o texto da instância deMatchObject
.
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.
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.
-
Está é a mesma função
gen_AB
do Exemplo 7. -
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.
-
Esse loop
for
itera sobre a listares1
criada pela compreensão de lista. -
A expressão geradora devolve
res2
, um objeto gerador. O gerador não é consumido aqui. -
Este gerador obtém itens de
gen_AB
apenas quando o loopfor
itera sobreres2
. Cada iteração do loopfor
invoca, implicitamente,next(res2)
, que por sua vez invocanext()
sobre o objeto gerador devolvido porgen_AB()
, fazendo este último avançar até o próximoyield
. -
Observe como a saída de
gen_AB()
se intercala com a saída doprint
no loopfor
.
Podemos usar uma expressão geradora para reduzir ainda mais o código na classe Sentence
. Veja o Exemplo 10.
Sentence
implementada usando uma expressão geradoraimport 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
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 |
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.
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.
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
.
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)
-
__init__
exige dois argumentos:begin
estep
;end
é opcional, se forNone
, a série será ilimitada. -
Obtém o tipo somando
self.begin
eself.step
. Por exemplo, se um forint
e o outrofloat
, oresult_type
seráfloat
. -
Essa linha cria um
result
com o mesmo valor numérico deself.begin
, mas coagido para o tipo das somas subsequentes.[224] -
Para melhorar a legibilidade, o sinalizador
forever
seráTrue
se o atributoself.end
forNone
, resultando em uma série ilimitada. -
Esse loop roda
forever
ou até o resultado ser igual ou maior queself.end
. Quando esse loop termina, a função retorna. -
O
result
atual é produzido. -
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]
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
|
|
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.
aritprog_gen
anterioresimport 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.
Módulo | Função | Descrição |
---|---|---|
|
|
Consome dois iteráveis em paralelo; produz itens de |
|
|
Consome |
(Embutida) |
|
Aplica |
|
|
Igual a |
|
|
Produz itens de uma fatia de |
|
|
Produz itens enquanto |
A seção de console no Exemplo 15 demonstra o uso de todas as funções na Tabela 19.
>>> 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.
Módulo | Função | Descrição |
---|---|---|
|
|
Produz somas cumulativas; se |
(embutida) |
|
Produz tuplas de dois itens na forma |
(embutida) |
|
Aplica |
|
|
Aplica |
O Exemplo 16 demonstra alguns usos de itertools.accumulate
.
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)
-
Soma acumulada.
-
Mínimo corrente.
-
Máximo corrente.
-
Produto acumulado.
-
Fatoriais de
1!
a10!
.
As funções restantes da Tabela 20 são demonstradas no Exemplo 17.
>>> 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]
-
Número de letras na palavra, começando por
1
. -
Os quadrados dos inteiros de
0
a10
. -
Multiplicando os números de dois iteráveis em paralelo; os resultados cessam quando o iterável menor termina.
-
Isso é o que faz a função embutida
zip
. -
Repete cada letra na palavra de acordo com a posição da letra na palavra, começando por
1
. -
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.
Módulo | Função | Descrição |
---|---|---|
|
|
Produz todos os itens de |
|
|
Produz todos os itens de cada iterável produzido por |
|
|
Produto cartesiano: produz tuplas de N elementos criadas combinando itens de cada iterável de entrada, como loops |
(embutida) |
|
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 |
|
|
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 |
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.
>>> 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)]
-
chain
é normalmente invocada com dois ou mais iteráveis. -
chain
não faz nada de útil se invocada com um único iterável. -
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. -
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 argumentostrict=True
for passado e um iterável terminar antes dos outros, umValueError
é gerado. -
itertools.zip_longest
funciona comozip
, exceto por consumir todos os iteráveis de entrada, preenchendo as tuplas de saída comNone
onde necessário. -
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
.
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)
-
O produto cartesiano de uma
str
com três caracteres e umrange
com dois inteiros produz seis tuplas (porque3 * 2
é6
). -
O produto de duas cartas altas (
'AK'
) e quatro naipes é uma série de oito tuplas. -
Dado um único iterável,
product
produz uma série de tuplas de um elemento—muito pouco útil. -
O argumento nomeado
repeat=N
diz à função para consumir cada iterável de entradaN
vezes.
Algumas funções geradoras expandem a entrada, produzindo mais de um valor por item de entrada. Elas estão listadas na Tabela 22.
Module | Function | Description |
---|---|---|
|
|
Produz combinações de |
|
|
Produz combinações de |
|
|
Produz números começando em |
|
|
Produz itens de |
|
|
Produz pares sobrepostos sucessivos, obtidos do iterável de entrada[228] |
|
|
Produz permutações de |
|
|
Produz um dado item repetidamente e, a menos que um número de |
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
.
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]
-
Cria
ct
, uma geradoracount
. -
Obtém o primeiro item de
ct
. -
Não posso criar uma
list
a partir dect
, poisct
nunca para. Então pego os próximos três itens. -
Posso criar uma
list
de uma geradoracount
se ela for limitada porislice
outakewhile
. -
Cria uma geradora
cycle
a partir de'ABC'
, e obtém seu primeiro item,'A'
. -
Uma
list
só pode ser criada se limitada porislice
; os próximos sete itens são obtidos aqui. -
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. -
Cria uma geradora
repeat
que vai produzir o número7
para sempre. -
Uma geradora
repeat
pode ser limitada passando o argumentotimes
: aqui o número8
será produzido4
vezes. -
Um uso comum de
repeat
: fornecer um argumento fixo emmap
; aqui ela fornece o multiplicador5
.
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.
>>> 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')]
-
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). -
Todas as combinação com
len()==2
a partir dos itens em'ABC'
, incluindo combinações com itens repetidos. -
Todas as permutações com
len()==2
a partir dos itens em'ABC'
; a ordem dos itens nas tuplas geradas é relevante. -
Produto cartesiano de
'ABC'
e'ABC'
(esse é o efeito derepeat=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.
Módulo | Função | Descrição |
---|---|---|
|
|
Produz tuplas de 2 elementos na forma |
(embutida) |
|
Produz os itens de |
|
|
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.
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']
>>>
-
groupby
produz tuplas de(key, group_generator)
. -
Tratar geradoras
groupby
envolve iteração aninhada: neste caso, o loopfor
externo e o construtor delist
interno. -
Ordena
animals
por tamanho. -
Novamente, um loop sobre o par
key
egroup
, para exibirkey
e expandir ogroup
em umalist
. -
Aqui a geradora
reverse
itera sobreanimals
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.
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.
Módulo | Função | Descrição |
---|---|---|
(embutida) |
|
Devolve |
(embutida) |
|
Devolve |
(embutida) |
|
Devolve o valor máximo entre os itens de |
(embutida) |
|
Devolve o valor mínimo entre os itens de |
|
|
Devolve o resultado da aplicação de |
(embutida) |
|
A soma de todos os itens em |
O Exemplo 24 exemplifica a operação de all
e de any
.
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
-
any
iterou sobreg
atég
produzir7
; neste momentoany
parou e devolveuTrue
. -
É 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 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.
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.
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.
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.
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)
-
Para suportar a saída indentada, produz o nome da classe e seu nível na hierarquia.
-
Usa o método especial
__subclasses__
para obter uma lista de subclasses. -
Produz o nome da subclasse e o nível (
1
). -
Cria a string de indentação de
4
espaços vezes olevel
. 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.
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)
-
Delega para
sub_tree
, para produzir os nomes das subclasses. -
Produz o nome de cada subclasse e o nível (
1
). Por causa doyield from sub_tree(cls)
dentro detree
, esses valores escapam completamente à geradoratree
… -
… 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.
sub_tree
percorre os níveis 1 e 2, primeiro em profundidadedef 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
.
sub_tree
de tree/step4/tree.pydef 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.
sub_tree
recursiva vai tão longe quanto a memória permitirdef 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.
tree
passam um argumento level
incrementadodef 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.
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
-
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 detyping.TypeAlias
, para esclarecer a razão para essa linha:FromTo: TypeAlias = tuple[str, str]
. -
Anota
changes
para aceitar umIterable
de tuplasFromTo
.
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.
fibonacci
devolve um gerador de inteirosfrom 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
.
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)
-
Uma expressão geradora que produz palavras reservadas do Python com menos de
5
caracteres. -
O Mypy infere:
typing.Generator[builtins.str*, None, None]
.[232] -
Isso também produz strings, mas acrescentei uma dica de tipo explícita.
-
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 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 |
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]
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
-
Essa função devolve uma geradora que produz valores
float
, aceita valoresfloat
via.send()
, e não devolve um valor útil.[235] -
Esse loop infinito significa que a corrotina continuará produzindo médias enquanto o código cliente enviar valores.
-
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.
>>> 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
-
Cria o objeto corrotina.
-
Inicializa a corrotina. Isso produz o valor inicial de
average
: 0.0. -
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.
>>> 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
-
coro_avg
é a instância criada no Exemplo 38. -
O método
.close()
gera uma exceçãoGeneratorExit
na expressãoyield
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. -
Invocar
.close()
em uma corrotina previamente encerrada não tem efeito. -
Tentar usar
.send()
em uma corrotina encerrada gera umaStopIteration
.
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.
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)
-
A corrotina
averager2
no Exemplo 41 vai devolver uma instância deResult
. -
Result
é, na verdade, uma subclasse detuple
, 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 campocount
.[236] -
Uma classe para criar um valor sentinela com um
__repr__
legível. -
O valor sentinela que vou usar para fazer a corrotina parar de coletar dados e devolver uma resultado.
-
Vou usar esse apelido de tipo para o segundo parâmetro de tipo devolvido pela corrotina
Generator
, o parâmetroSendType
.
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).
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)
-
Para essa corrotina, o tipo produzido é
None
, porque ela não produz dados. Ela recebe dados do tipoSendType
e devolve uma tuplaResult
quando termina o processamento. -
Usar
yield
assim só faz sentido em corrotinas, que são projetadas para consumir dados. Isso produzNone
, mas recebe umterm
de.send(term)
. -
Se
term
é umSentinel
, sai do loop. Graças a essa verificação comisinstance
… -
…Mypy me permite somar
term
atotal
sem sinalizar um erro (que eu não poderia somar umfloat
a um objeto que pode ser umfloat
ou umSentinel
). -
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).
.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)
-
Lembre-se que
averager2
não produz resultados parciais. Ela produzNone
, que o console do Python omite. -
Invocar
.close()
nessa corrotina a faz parar, mas não devolve um resultado, pois a exceçãoGeneratorExit
é gerada na linhayield
da corrotina, então a instruçãoreturn
nunca é alcançada.
Vamos então fazê-la funcionar, no Exemplo 43.
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)
-
Enviar o valor sentinela
STOP
faz a corrotina sair do loop e devolver umResult
. O objeto gerador que encapsula a corrotina gera então umaStopIteration
. -
A instância de
StopIteration
tem um atributovalue
vinculado ao valor do comandoreturn
que encerrou a corrotina. -
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.
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)
-
res
vai coletar o valor devolvido poraverager2
; o mecanismo deyield from
recupera o valor devolvido quando trata a exceçãoStopIteration
, que marca o encerramento da corrotina. QuandoTrue
, o parâmetroverbose
faz a corrotina exibir o valor recebido, tornando sua operação visível. -
Preste atenção na saída desta linha quando a geradora for executada.
-
Devolve o resultado. Isso também estará encapsulado em
StopIteration
. -
Cria o objeto corrotina delegante.
-
Esse loop vai controlar a corrotina delegante.
-
O primeiro valor enviado é
None
, para preparar a corrotina; o último é a sentinela, para pará-la. -
Captura
StopIteration
para obter o valor devolvido porcompute
. -
Após as linhas exibidas por
averager2
ecompute
, recebemos a instância deResult
.
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 |
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.
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:
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:
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.
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çõeswith
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çãowith
é muito mais que isso.[243] (EN)
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çõesfor
,while
, etry
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:
-
Gerenciar transações no módulo
sqlite3
— veja "Usando a conexão como gerenciador de contexto". -
Manipular travas, condições e semáforos de forma segura—como descrito na documentação do módulo
threading
(EN). -
Configurar ambientes personalizados para operações aritméticas com objetos
Decimal
—veja a documentação dedecimal.localcontext
(EN). -
Remendar (patch) objetos para testes—veja a função
unittest.mock.patch
(EN).
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.
>>> 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.
-
fp
está vinculado ao arquivo de texto aberto, pois o método__enter__
do arquivo devolveself
. -
Lê
60
caracteres Unicode defp
. -
A variável
fp
ainda está disponível—blocoswith
não definem um novo escopo, como fazem as funções. -
Podemos ler os atributos do objeto
fp
. -
Mas não podemos ler mais texto de
fp
pois, no final do blocowith
, o métodoTextIOWrapper.__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__
.
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.
-
O gerenciador de contexto é uma instância de
LookingGlass
; o Python chama__enter__
no gerenciador de contexto e o resultado é vinculado awhat
. -
Exibe uma
str
, depois o valor da variável alvowhat
. A saída de cadaprint
será invertida. -
Agora o bloco
with
terminou. Podemos ver que o valor devolvido por__enter__
, armazenado emwhat
, é a string'JABBERWOCKY'
. -
A saída do programa não está mais invertida.
O Exemplo 3 mostra a implementação de LookingGlass
.
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)
-
O Python invoca
__enter__
sem argumentos além deself
. -
Armazena o método
sys.stdout.write
original, para podermos restaurá-lo mais tarde. -
Faz um monkey-patch em
sys.stdout.write
, substituindo-o com nosso próprio método. -
Devolve a string
'JABBERWOCKY'
, apenas para termos algo para colocar na variável alvowhat
. -
Nosso substituto de
sys.stdout.write
inverte o argumentotext
e chama a implementação original. -
Se tudo correu bem, o Python chama
__exit__
comNone, 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. -
Restaura o método original em
sys.stdout.write
. -
Se a exceção não é
None
e seu tipo éZeroDivisionError
, exibe uma mensagem… -
…e devolve
True
, para informar o interpretador que a exceção foi tratada. -
Se
__exit__
devolveNone
ou qualquer valor falso, qualquer exceção levantada dentro do blocowith
será propagada.
👉 Dica
|
Quando aplicações reais tomam o controle da saída padrão, elas frequentemente desejam substituir |
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__
.
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'
-
Instancia e inspeciona a instância de
manager
. -
Chama o método
__enter__
do manager e guarda o resultado emmonster
. -
monster
é a string'JABBERWOCKY'
. O identificadorTrue
aparece invertido, porque toda a saída viastdout
passa pelo métodowrite
, que modificamos em__enter__
. -
Chama
manager.__exit__
para restaurar ostdout.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:
Antes do 3.10, as linhas acima teriam que ser escritas como blocos |
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çãowith
. 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 blocowith
; 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.
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)
-
Aplica o decorador
contextmanager
. -
Preserva o método
sys.stdout.write
original. -
reverse_write
pode chamaroriginal_write
mais tarde, pois ele está disponível em sua clausura (closure). -
Substitui
sys.stdout.write
porreverse_write
. -
Produz o valor que será vinculado à variável alvo na cláusula
as
da instruçãowith
. O gerador se detem nesse ponto, enquanto o corpo dowith
é executado. -
Quando o fluxo de controle sai do bloco
with
, a execução continua após oyield
; neste ponto osys.stdout.write
original é restaurado.
O Exemplo 6 mostra a função looking_glass
em operação.
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
-
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:
-
Chama a função geradora para obter um objeto gerador—vamos chamá-lo de
gen
. -
Chama
next(gen)
para acionar com ele a palavra reservadayield
. -
Devolve o valor produzido por
next(gen)
, para permitir que o usuário o vincule a uma variável usando o formatowith/as
.
Quando o bloco with
termina, o método __exit__
:
-
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 linhayield
, dentro do corpo da função geradora. -
Caso contrário,
next(gen)
é chamado, retomando a execução do corpo da função geradora após oyield
.
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.
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)
-
Cria uma variável para uma possível mensagem de erro; essa é a primeira mudança em relação a Exemplo 5.
-
Trata
ZeroDivisionError
, fixando uma mensagem de erro. -
Desfaz o monkey-patching de
sys.stdout.write
. -
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 |
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.
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
-
looking_glass
faz seu trabalho antes e depois do corpo deverse
rodar. -
Isso confirma que o
sys.write
original foi restaurado.
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.
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:
-
O lis.py de Norvig é um lindo exemplo de código Python idiomático.
-
A simplicidade do Scheme é uma aula magna de design de linguagens.
-
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.
(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).
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.
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).
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:
-
Um símbolo sintático que se pareça com um número é tratado como um
float
ou umint
. -
Todo o resto que não seja um
'('
ou um')'
é considerado umSymbol
—umastr
, a ser usado como um identificador. Isso inclui texto no código-fonte como+
,set!
, emake-counter
, que são identificadores válidos em Scheme, mas não em Python. -
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).
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.
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})
-
Ao ler os valores,
Environment
funciona comoChainMap
: as chaves são procuradas nos mapeamentos aninhados da esquerda para a direita. Por isso o valor dea
noouter_env
é encoberto pelo valor eminner_env
. -
Atribuir com
[]
sobrescreve ou insere novos itens, mas sempre no primeiro mapeamento,inner_env
nesse exemplo. -
env.change('b', 333)
busca a chaveb
e atribui a ela um novo valor no mesmo lugar, noouter_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).
standard_env()
constrói e devolve o ambiente globaldef 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
paraprocedure?
, ou mapeadas diretamente, comoround
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]
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 sejaNone
. Oglobal_env
pode ser modificado porevaluate
. Por exemplo, quando o usuário define uma nova variável global ou uma função nomeada, ela é armazenada no primeiro mapeamento do ambiente—odict
vazio na chamada ao construtor deEnvironment
na primeira linha derepl
. 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.
evaluate
recebe uma expressão e calcula seu valorKEYWORDS = ['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
oufloat
. - 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 é, umastr
usada como identificador. - Ação:
-
Consulta
var
emenv
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ãox
. - 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 umKeyError
-
(99 bottles of beer)
não pode ser avaliado, pois o número 99 não é umSymbol
nomeando uma forma especial, um operador ou uma função -
(/ 10 0)
geraria umZeroDivisionError
(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
, ealternative
. - 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 quebody
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 umSymbol
e uma expressão. - Ação:
-
Avalia a expressão e coloca o valor resultante em
env
, usandoname
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 chamadaparms
. -
Uma ou mais expressões agrupadas em
body
(a expressão guarda garante quebody
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
emenv
, usandoname
como chave.
-
O doctest no Exemplo 18 define e coloca no global_env
uma função chamada %
, que calcula uma porcentagem.
%
, 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 umSymbol
e de uma expressão. - Ação:
-
Atualiza o valor de
name
emenv
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
.
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 deevaluate
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 aargs
como uma lista, que pode ser vazia. - Ação:
-
-
Avaliar
func_exp
para obter umaproc
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)
-
Chamada quando uma função é definida pelas formas
lambda
oudefine
. -
Salva os nomes dos parâmetros, as expressões no corpo e o ambiente, para uso posterior.
-
Chamada por
proc(*values)
na última linha da cláusulacase [func_exp, *args]
. -
Cria
local_env
, mapeandoself.parms
como nomes de variáveis locais e osargs
passados como valores. -
Cria um novo
env
combinado, colocandolocal_env
primeiro e entãoself.env
—o ambiente que foi salvo quando a função foi definida. -
Itera sobre cada expressão em
self.body
, avaliando-as noenv
combinado. -
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 |
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 loopfor
rodar até o fim (isto é, não rodará se ofor
for interrompido com umbreak
). while
-
O bloco
else
será executado apenas se e quando o loopwhile
terminar pela condição se tornar falsa (novamente, não rodará se owhile
for interrompido por umbreak
) try
-
O bloco
else
será executado apenas se nenhuma exceção for gerada no blocotry
. A documentação oficial também afirma: "Exceções na cláusulaelse
não são tratadas pela cláusulaexcept
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 |
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
eexcept
. 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ódigoif key in mapping: return mapping[key]
pode falhar se outra thread removerkey
domapping
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 |
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.
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]
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-chaveyield
ouawait
, 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 pacotesmultiprocessing
easyncio
implementam suas próprias classes de fila. Os pacotesqueue
easyncio
também incluem filas não FIFO:LifoQueue
ePriorityQueue
. - 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:
-
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.
-
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.
-
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.
-
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.
-
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.
-
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óduloszlib
andbz2
, também liberam a GIL.[264] -
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. -
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]
-
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.
-
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
ouconcurrent.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.
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.
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
-
Essa função vai rodar em uma thread separada. O argumento
done
é uma instância dethreading.Event
, um objeto simples para sincronizar threads. -
Isso é um loop infinito, porque
itertools.cycle
produz um caractere por vez, circulando pela string para sempre. -
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'
). -
O método
Event.wait(timeout=None)
retornaTrue
quando o evento é acionado por outra thread; se otimeout
passou, ele retornaFalse
. 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. -
Sai do loop infinito.
-
Sobrescreve a linha de status com espaços para limpá-la e move o cursor de volta para o início.
-
slow()
será chamada pela thread principal. Imagine que isso é uma chamada de API lenta, através da rede. Chamarsleep
bloqueia a thread principal, mas a GIL é liberada e a thread da animação pode continuar.
👉 Dica
|
O primeiro detalhe importante deste exemplo é que |
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.
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()
-
supervisor
irá retornar o resultado deslow
. -
A instância de
threading.Event
é a chave para coordenar as atividades das threadsmain
espinner
, como explicado abaixo. -
Para criar uma nova
Thread
, forneça uma função como argumento palavra-chavetarget
, e argumentos posicionais para atarget
como uma tupla passada viaargs
. -
Mostra o objeto
spinner
. A saída é<Thread(Thread-1, initial)>
, ondeinitial
é o estado da thread—significando aqui que ela ainda não foi iniciada. -
Inicia a thread
spinner
. -
Chama
slow
, que bloqueia a thread principal. Enquanto isso, a thread secundária está rodando a animação. -
Muda a flag de
Event
paraTrue
; isso vai encerrar o loopfor
dentro da funçãospin
. -
Espera até que a thread
spinner
termine. -
Roda a função
supervisor
. Escrevimain
esupervisor
como funções separadas para deixar esse exemplo mais parecido com a versãoasyncio
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).
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
-
A API básica de
multiprocessing
imita a API dethreading
, mas as dicas de tipo e o Mypy mostram essa diferença:multiprocessing.Event
é uma função (e não uma classe comothreading.Event
) que retorna uma instância desynchronize.Event
… -
…nos obrigando a importar
multiprocessing.synchronize
… -
…para escrever essa dica de tipo.
-
O uso básico da classe
Process
é similar ao da classeThread
. -
O objeto
spinner
aparece como <Process name='Process-1' parent=14868 initial>`, onde14868
é 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 |
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.
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()
-
main
é a única função regular definida nesse programa—as outras são corrotinas. -
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é quesupervisor
retorne. O valor de retorno desupervisor
será o valor de retorno deasyncio.run
. -
Corrotinas nativas são definidas com
async def
. -
asyncio.create_task
agenda a execução futura despin
, retornando imediatamente uma instância deasyncio.Task
. -
O
repr
do objetospinner
se parece com<Task pending name='Task-2' coro=<spin() running at /path/to/spinner_async.py:11>>
. -
A palavra-chave
await
chamaslow
, bloqueandosupervisor
até queslow
retorne. O valor de retorno deslow
será atribuído aresult
. -
O método
Task.cancel
lança uma exceçãoCancelledError
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é quecoro
retorne. O valor de retorno da chamada arun()
é o que quer quecoro
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é quecoro
retorne. O valor da expressãoawait
será é o que quer quecoro
retorne.
✒️ Nota
|
Lembre-se: invocar uma corrotina como |
Vamos estudar agora as corrotinas spin
e slow
no Exemplo 5.
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
-
Não precisamos do argumento
Event
, que era usado para sinalizar queslow
havia terminado de rodar no spinner_thread.py (Exemplo 1). -
Use
await asyncio.sleep(.1)
em vez detime.sleep(.1)
, para pausar sem bloquear outras corrotinas. Veja o experimento após o exemplo. -
asyncio.CancelledError
é lançada quando o métodocancel
é chamado naTask
que controla essa corrotina. É hora de sair do loop. -
A corrotina
slow
também usaawait asyncio.sleep
em vez detime.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.
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:
-
O objeto
spinner
aparece:<Task pending name='Task-2' coro=<spin() running at …/spinner_async.py:12>>
. -
A animação nunca aparece. O programa trava por 3 segundos.
-
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.
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
-
A tarefa
spinner
é criada para, no futuro, controlar a execução despin
. -
O display mostra que
Task
está "pending"(em espera). -
A expressão
await
transfere o controle para a corrotinaslow
. -
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, eslow
retorna. -
Logo após
slow
retornar, a tarefaspinner
é cancelada. O fluxo de controle jamais chegou ao corpo da corrotinaspin
.
O spinner_async_experiment.py ensina uma lição importante, como explicado no box abaixo.
⚠️ Aviso
|
Nunca use |
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.
supervisor
com threadsdef 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
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 athreading.Thread
. -
Uma
Task
aciona um objeto corrotina, e umaThread
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 paraasyncio.create_task(…)
. -
Quando
asyncio.create_task(…)
retorna um objetoTask
, ele já esta agendado para rodar, mas uma instância deThread
precisa ser iniciada explicitamente através de uma chamada a seu métodostart
. -
Na
supervisor
da versão com threads,slow
é uma função comum e é invocada diretamente pela thread principal. Na versão assíncrona dasupervisor
,slow
é uma corrotina guiada porawait
. -
Não há API para terminar uma thread externamente; em vez disso, é preciso enviar um sinal—como acionar o
done
no objetoEvent
. Para objetosTask
, há o método de instânciaTask.cancel()
, que dispara umCancelledError
na expressãoawait
na qual o corpo da corrotina está suspensa naquele momento. -
A corrotina
supervisor
deve ser iniciada comasyncio.run
na funçãomain
.
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.
ProcessPoolExecutor
na documentação do Pythondef 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:
Em spinner_proc.py, substitua
time.sleep(3)
com uma chamada ais_prime(n)
?Em spinner_thread.py, substitua
time.sleep(3)
com uma chamada ais_prime(n)
?Em spinner_async.py, substitua
await asyncio.sleep(3)
com uma chamada ais_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.
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 |
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.
#!/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()
-
A função
check
(na próxima chamada) retorna uma tuplaResult
com o valor booleano da chamada ais_prime
e o tempo decorrido. -
check(n)
chamais_prime(n)
e calcula o tempo decorrido para retornar umResult
. -
Para cada número na amostra, chamamos
check
e apresentamos o resultado. -
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 |
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
|
|
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)
-
Na tentativa de emular
threading
,multiprocessing
fornecemultiprocessing.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 essaSimpleQueue
para criar uma fila. Por outro lado, não podemos usá-la em dicas de tipo. -
multiprocessing.queues
contém a classeSimpleQueue
que precisamos para dicas de tipo. -
PrimeResult
inclui o número verificado. Mantern
junto com os outros campos do resultado simplifica a exibição mais tarde. -
Isso é um apelido de tipo para uma
SimpleQueue
que a funçãomain
(Exemplo 14) vai usar para enviar os números para os processos que farão a verificação. -
Apelido de tipo para uma segunda
SimpleQueue
que vai coletar os resultados emmain
. Os valores na fila serão tuplas contendo o número a ser testado e uma tuplaResult
. -
Isso é similar a sequential.py.
-
worker
recebe uma fila com os números a serem verificados, e outra para colocar os resultados. -
Nesse código, usei o número
0
como uma pílula venenosa: um sinal para que o processo encerre. Sen
não é0
, continue com o loop.[272] -
Invoca a verificação de número primo e coloca o
PrimeResult
na fila. -
Devolve um
PrimeResult(0, False, 0.0)
, para informar ao loop principal que esse processo terminou seu trabalho. -
procs
é o número de processos que executarão a verificação de números primos em paralelo. -
Coloca na fila
jobs
os números a serem verificados. -
Cria um processo filho para cada
worker
. Cada um desses processos executará o loop dentro de sua própria instância da funçãoworker
, até encontrar um0
na filajobs
. -
Inicia cada processo filho.
-
Coloca um
0
na fila de cada processo, para encerrá-los.
Agora vamos estudar a função main
de procs.py no Exemplo 14.
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()
-
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.
-
jobs
eresults
são as filas descritas no Exemplo 13. -
Inicia
proc
processos para consumirjobs
e informarresults
. -
Recupera e exibe os resultados;
report
está definido em . -
Mostra quantos números foram verificados e o tempo total decorrido.
-
Os argumentos são o número de
procs
e a fila para armazenar os resultados. -
Percorre o loop até que todos os processos terminem.
-
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 deSimpleQueue.get
. -
Se
n
é zero, então um processo terminou; incrementa o contadorprocs_done
. -
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 |
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.
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.
Considere a seguinte passagem, do artigo muito citado "The Free Lunch Is Over: A Fundamental Turn Toward Concurrency in Software" (O Almoço Grátis Acabou: Uma Virada Fundamental do Software em Direção à Concorrência) (EN) de Herb Sutter:
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
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
|
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.
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.
(Interface Assíncrona de Ponto de Entrada de Servidor)
✒️ Nota
|
A WSGI é uma API síncrona. Ela não suporta corrotinas com |
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.
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.
$ 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
-
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.
-
flags.py precisou em média de 7,18s para baixar 20 imagens.
-
A média para flags_threadpool.py foi 1,40s.
-
Já flags_asyncio.py, obteve um tempo médio de 1,35s.
-
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 |
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:
-
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.
-
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. |
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)
-
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. -
Lista do código de país ISO 3166 para os 20 países mais populosos, em ordem decrescente de população.
-
O diretório com as imagens das bandeiras.[291]
-
Diretório local onde as imagens são salvas.
-
Salva os bytes de
img
parafilename
noDEST_DIR
. -
Dado um código de país, constrói a URL e baixa a imagem, retornando o conteúdo binário da resposta.
-
É uma boa prática adicionar um timeout razoável para operações de rede, para evitar ficar bloqueado sem motivo por vários minutos.
-
Por default, o HTTPX não segue redirecionamentos.[292]
-
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.
-
download_many
é a função chave para comparar com as implementações concorrentes. -
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.
-
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 argumentoflush=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. -
main
precisa ser chamada com a função que fará os downloads; dessa forma podemos usarmain
como uma função de biblioteca com outras implementações dedownload_many
nos exemplos dethreadpool
eascyncio
. -
Cria o
DEST_DIR
se necessário; não acusa erro se o diretório existir. -
Mede e apresenta o tempo decorrido após rodar a função
downloader
. -
Chama
main
com a funçãodownload_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 |
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
.
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)
-
Reutiliza algumas funções do módulo
flags
(Exemplo 2). -
Função para baixar uma única imagem; isso é o que cada thread de trabalho vai executar.
-
Instancia o
ThreadPoolExecutor
como um gerenciador de contexto; o métodoexecutor.__exit__
vai chamarexecutor.shutdown(wait=True)
, que vai bloquear até que todas as threads terminem de rodar. -
O método
map
é similar aomap
embutido, exceto que a funçãodownload_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 adownload_one
vai retornar um código de país. -
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 delist
, tentar recuperar o valor de retorno correspondente, no iterador retornado porexecutor.map
. -
Chama a função
main
do móduloflags
, passando a versão concorrente dedownload_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 |
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),
ThreadPoolExecutor
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 demax_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.
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
-
Para essa demonstração, usa apenas os cinco países mais populosos.
-
Configura
max_workers
para3
, para podermos ver os futures pendentes na saída. -
Itera pelos códigos de país em ordem alfabética, para deixar claro que os resultados vão aparecer fora de ordem.
-
executor.submit
agenda o invocável a ser executado, e retorna umfuture
representando essa operação pendente. -
Armazena cada
future
, para podermos recuperá-los mais tarde comas_completed
. -
Mostra uma mensagem com o código do país e seu respectivo
future
. -
as_completed
entrega futures conforme eles terminam. -
Recupera o resultado desse
future
. -
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.
$ 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
-
Os futures são agendados em ordem alfabética; o
repr()
de umfuture
mostra seu estado: os três primeiros estãorunning
, pois há três threads de trabalho. -
Os dois últimos
futures
estãopending
; esperando pelas threads de trabalho. -
O primeiro
CN
aqui é a saída dedownload_one
em uma thread de trabalho; o resto da linha é a saída dedownload_many
. -
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 |
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
ProcessPoolExecutor
.
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 ProcessPoolExecutor
. 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.
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()
-
Não há necessidade de importar
multiprocessing
,SimpleQueue
etc.;concurrent.futures
esconde tudo isso. -
A tupla
PrimeResult
e a funçãocheck
são as mesmas que vimos em procs.py, mas não precisamos mais das filas nem da funçãoworker
. -
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
aworkers
e deixamos oProcessPoolExecutor
decidir. -
Aqui criei o
ProcessPoolExecutor
antes do blocowith
em ➐, para poder mostrar o número real de processos na próxima linha. -
max_workers
é um atributo de instância não-documentado de umProcessPoolExecutor
. Decidi usá-lo para mostrar o número de processos de trabalho criados quando a variávelworkers
éNone
. O Mypy corretamente reclama quando eu acesso esse atributo, então coloquei o comentáriotype: ignore
para silenciar a reclamação. -
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.
-
Usa o
executor
como um gerenciador de contexto. -
A chamada a
executor.map
retorna as instâncias dePrimeResult
retornadas porcheck
na mesma ordem dos argumentosnumbers
.
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.
$ ./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
-
Essa linha aparece muito rápido.
-
Essa linha demora mais de 9,5s para aparecer.
-
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 quenumbers
é 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 quandomax_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.
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()
-
Essa função exibe o momento da execução no formato
[HH:MM:SS]
e os argumentos recebidos. -
loiter
não faz nada além mostrar uma mensagem quanto inicia, dormir porn
segundos, e mostrar uma mensagem quando termina; são usadas tabulações para indentar as mensagens de acordo com o valor den
. -
loiter
retornan * 10
, então podemos ver como coletar resultados. -
Cria um
ThreadPoolExecutor
com três threads. -
Submete cinco tarefas para o
executor
. Já que há apenas três threads, apenas três daquelas tarefas vão iniciar imediatamente: a chamadasloiter(0)
,loiter(1)
, eloiter(2)
; essa é uma chamada não-bloqueante. -
Mostra imediatamente o
results
da invocação deexecutor.map
: é um gerador, como se vê na saída no Exemplo 9. -
A chamada
enumerate
no loopfor
vai invocar implicitamentenext(results)
, que por sua vez vai invocarf.result()
no future (interno)f
, representando a primeira chamada,loiter(0)
. O métodoresult
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 ThreadPoolExecutor
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.
$ 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
-
Essa execução começou em 15:56:50.
-
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] -
loiter(1)
eloiter(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). -
Isso mostra que o
results
retornado porexecutor.map
é um gerador: nada até aqui é bloqueante, independente do número de tarefas e do valor demax_workers
. -
Como
loiter(0)
terminou, a primeira thread de trabalho está disponível para iniciar a quarta thread paraloiter(3)
. -
Aqui é ponto a execução pode ser bloqueada, dependendo dos parâmetros passados nas chamadas a
loiter
: o método__next__
do geradorresults
precisa esperar até o primeiro future estar completo. Neste caso, ele não vai bloquear porque a chamada aloiter(0)
terminou antes desse loop iniciar. Observe que tudo até aqui aconteceu dentro do mesmo segundo: 15:56:50. -
loiter(1)
termina um segundo depois, em 15:56:51. A thread está livre para iniciarloiter(4)
. -
O resultado de
loiter(1)
é exibido:10
. Agora o loopfor
ficará bloqueado, esperando o resultado deloiter(2)
. -
O padrão se repete:
loiter(2)
terminou, seu resultado é exibido; o mesmo ocorre comloiter(3)
. -
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 |
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çãomain
, 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 porflags2_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
ehttpx
. 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.
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.
$ 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:
|
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.
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.
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
.
-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.
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
-
Importa a biblioteca de exibição de barra de progresso
tqdm
, e diz ao Mypy para não checá-la.[294] -
Importa algumas funções e um
Enum
do móduloflags2_common
. -
Dispara um
HTTPStatusError
se o código de status do HTTP não está emrange(200, 300)
. -
download_one
trata oHTTPStatusError
, especificamente para tratar o código HTTP 404… -
…mudando seu
status
local paraDownloadStatus.NOT_FOUND
;DownloadStatus
é umEnum
importado de flags2_common.py. -
Qualquer outra exceção de
HTTPStatusError
é re-emitida e propagada para quem chamou a função. -
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 modoverbose
.
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.
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)
-
Este
Counter
vai registrar os diferentes resultados possíveis dos downloads:DownloadStatus.OK
,DownloadStatus.NOT_FOUND
, ouDownloadStatus.ERROR
. -
cc_iter
mantém a lista de códigos de país recebidos como argumentos, em ordem alfabética. -
Se não estamos rodando em modo
verbose
,cc_iter
é passado para otqdm
, que retorna um iterador que produz os itens emcc_iter
enquanto também anima a barra de progresso. -
Faz chamadas sucessivas a
download_one
. -
As exceções do código de status HTTP ocorridas em
get_flag
e não tratadas pordownload_one
são tratadas aqui. -
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 chamadownload_many
, não tem nenhumtry/except
. -
Sai do loop se o usuário pressionar Ctrl-C.
-
Se nenhuma exceção saiu de
download_one
, limpa a mensagem de erro. -
Se houve um erro, muda o
status
local de acordo com o erro. -
Incrementa o contador para aquele
status
. -
Se no modo
verbose
, mostra a mensagem de erro para o código de país atual, se houver. -
Retorna
counter
para quemain
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.
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)
-
Reutiliza
download_one
deflags2_sequential
(Exemplo 14). -
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. -
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. -
Cria o
executor
commax_workers
determinado porconcur_req
, calculado pela funçãomain
como o menor de:MAX_CONCUR_REQ
, o tamanho decc_list
, ou o valor da opção de linha de comando-m/--max_req
. Isso evita criar mais threads que o necessário. -
Este
dict
vai mapear cada instância deFuture
—representando um download—com o respectivo código de país, para exibição de erros. -
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 quelen(cc_list)
, você poderá ver os downloads aparecendo em ordem alfabética. -
Cada chamada a
executor.submit
agenda a execução de uma invocável e retorna uma instância deFuture
. O primeiro argumento é a invocável, o restante são os argumentos que ela receberá. -
Armazena o
future
e o código de país nodict
. -
futures.as_completed
retorna um iterador que produz futures conforme cada tarefa é completada. -
Se não estiver no modo
verbose
, passa o resultado deas_completed
com a funçãotqdm
, para mostrar a barra de progresso; comodone_iter
não temlen
, precisamos informar otqdm
qual o número de itens esperado com o argumentototal=
, para que ele possa estimar o trabalho restante. -
Itera sobre os futures conforme eles vão terminando.
-
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, porqueas_completed
só retorna futures que terminaram sua execução. -
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. -
Para dar contexto à mensagem de erro, recupera o código de país do
to_do_map
, usando ofuture
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 occ
atual; aqui estamos iterando sobre futures.
👉 Dica
|
O Exemplo 16 usa um idioma que é muito útil com |
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
.
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
, easync 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-chaveawait
, de forma similar àquela como as corrotinas clássicas usamyield from
. O comandoasync def
sempre define uma corrotina nativa, mesmo se a palavra-chaveawait
não seja usada em seu corpo. A palavra-chaveawait
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 usandoyield
em uma expressão. Corrotinas clássicas podem delegar para outras corrotinas clássicas usandoyield from
. Corrotinas clássicas não podem ser controladas porawait
, 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-chaveawait
.
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 usayield
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 |
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.
#!/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)
-
Estabelece o comprimento máximo da palavra-chave para domínios, pois quanto menor, melhor.
-
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. -
Obtém uma referência para o loop de eventos do
asyncio
, para usá-la a seguir. -
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. -
main
tem que ser uma corrotina, para podemros usarawait
aqui. -
Gerador para produzir palavras-chave com tamanho até
MAX_KEYWORD_LEN
. -
Gerador para produzir nome de domínio com o sufixo
.dev
. -
Cria uma lista de objetos corrotina, invocando a corrotina
probe
com cada argumentodomain
. -
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 aofutures.as_completed
, que vimos no Capítulo 20, Exemplo 4. -
Nesse ponto, sabemos que a corrotina terminou, pois é assim que
as_completed
funciona. Portanto, a expressãoawait
não vai bloquear, mas precisamos dela para obter o resultado decoro
. Secoro
gerou uma exceção não tratada, ela será gerada novamente aqui. -
asyncio.run
inicia o loop de eventos e retorna apenas quando o loop terminar. Esse é um modelo comum para scripts usandoasyncio
: implementarmain
como uma corrotina e controlá-la comasyncio.run
dentro do blocoif name == 'main':
.
👉 Dica
|
A função |
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 paraasyncio.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 |
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 deasyncio.Future
(asyncio.Task
é uma subclasse deasyncio.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 |
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 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. |
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)
-
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). -
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 quesupervisor
devolver. -
Operação de cliente HTTP assíncronas no
httpx
são métodos deAsyncClient
, 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). -
Cria uma lista de objetos corrotina, chamando a corrotina
download_one
uma vez para cada bandeira a ser obtida. -
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. -
supervisor
devolve o tamanho da lista vinda deasyncio.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.
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)
-
httpx
precisa ser importado—não faz parte da biblioteca padrão -
Reutiliza código de flags.py (Exemplo 2).
-
download_one
tem que ser uma corrotina nativa, para poderawait
porget_flag
—que executa a requisição HTTP. Ela então mostra o código de país bandeira baixada, e salva a imagem. -
get_flag
precisa receber oAsyncClient
para fazer a requisição. -
O método
get
de uma instância dehttpx.AsyncClient
devolve um objetoClientResponse
, que também é um gerenciador assíncrono de contexto. -
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 A Seção 21.7.1 vai mostrar como delegar |
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 AsyncClient
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.
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
.
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).
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 |
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 |
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.
$ 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.
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
-
get_flag
é muito similar à versão sequencial no Exemplo 14. Primeira diferença: ele exige o parâmetroclient
. -
Segunda e terceira diferenças:
.get
é um método deAsyncClient
, e é uma corrotina, então precisamosawait
por ela. -
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. -
A lógica de tratamento de erro é idêntica à de
download_one
, do Exemplo 14. -
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.
Agora vamos olhar o resto do script em Exemplo 7.
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)
-
supervisor
recebe os mesmos argumentos que a funçãodownload_many
, mas ele não pode ser invocado diretamente demain
, pois é uma corrotina e não uma função simples comodownload_many
. -
Cria um
asyncio.Semaphore
que não vai permitir mais queconcur_req
corrotinas ativas entre aquelas usando este semáforo. O valor deconcur_req
é calculado pela funçãomain
de flags2_common.py, baseado nas opções de linha de comando e nas constantes estabelecidas em cada exemplo. -
Cria uma lista de objetos corrotina, um para cada chamada à corrotina
download_one
. -
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 loopfor
abaixo porque posso precisar envolvê-la com o iteradortqdm
para a barra de progresso, dependendo da opção do usuário para verbosidade. -
Envolve o iterador
as_completed
com a função geradoratqdm
, para mostrar o progresso. -
Declara e inicializa
error
comNone
; essa variável será usada para manter uma exceção além do blocotry/except
, se alguma for levantada. -
Itera pelos objetos corrotina que terminaram a execução; esse loop é similar ao de
download_many
em Exemplo 16. -
await
pela corrotina para obter seu resultado. Isso não bloqueia porqueas_completed
só produz corrotinas que já terminaram. -
Essa atribuição é necessária porque o escopo da variável
exc
é limitado a essa cláusulaexcept
, mas preciso preservar o valor para uso posterior. -
Mesmo que acima.
-
Se houve um erro, muda o
status
. -
Se em modo verboso, extrai a URL da exceção que foi levantada…
-
…e extrai o nome do arquivo para mostrar o código do país em seguida.
-
download_many
instancia o objeto corrotinasupervisor
e o passa para o loop de eventos comasyncio.run
, coletando o contador quesupervisor
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 |
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 paraget_flag
e para a nova corrotinaget_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.
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)
-
Essa corrotina devolve uma string com o nome do país—se tudo correr bem.
-
metadata
vai receber umdict
Python construído a partir do conteúdo JSON da resposta. -
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.
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
-
Segura o
semaphore
paraawait
porget_flag
… -
…e novamente por
get_country
. -
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.
await asyncio.to_thread
loop = asyncio.get_running_loop() # (1)
loop.run_in_executor(None, save_flag, # (2)
image, f'{cc}.gif') # (3)
-
Obtém uma referência para o loop de eventos.
-
O primeiro argumento é o executor a ser utilizado; passar
None
seleciona o default,ThreadPoolExecutor
, que está sempre disponível no loop de eventos doasyncio
. -
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 derun_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 |
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.
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.
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.
/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.
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)
-
Não relacionado ao tema desse capítulo, mas digno de nota: o uso elegante do operador
/
sobrecarregado porpathlib
.[305] -
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. -
Um schema pydantic para uma resposta JSON, com campos
char
ename
.[306] -
Cria o
index
e carrega o formulário HTML estático, anexando ambos aoapp.state
para uso posterior. -
Roda
init
quando esse módulo é carregado pelo servidor ASGI. -
Rota para o ponto de acesso
/search
;response_model
usa aquele modeloCharName
do pydantic para descrever o formato da resposta. -
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
. Comoq
não tem default, a FastAPI devolverá um status 422 (Unprocessable Entity, Entidade Não-Processável) seq
não estiver presente na string da consulta. -
Devolver um iterável de
dicts
compatível com o schemaresponse_model
permite ao FastAPI criar uma resposta JSON de acordo com oresponse_model
no decorador@app.get
, -
Funções regulares (isto é, não-assíncronas) também podem ser usadas para produzir respostas.
-
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 |
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 oresponse_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.
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.
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:])
-
Este
await
rapidamente recebe um instância deasyncio.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. -
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: umasyncio.StreamReader
e umasyncio.StreamWriter
. Entretanto, minha corrotinafinder
também precisa receber umindex
, então useifunctools.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 defunctools.partial
. -
host
eport
são o segundo e o terceiro argumentos destart_server
. Veja a assinatura completa na documentação doasyncio
. -
Este
cast
é necessário porque o typeshed tem uma dica de tipo desatualizada para a propriedadesockets
da classeServer
—isso em maio de 2021. Veja Issue #5535 no typeshed.[307] -
Mostra o endereço e a porta do primeiro socket do servidor.
-
Apesar de
start_server
já ter iniciado o servidor como uma tarefa concorrente, preciso usar oawait
no métodoserver_forever
, para que meusupervisor
seja suspenso aqui. Sem essa linha, osupervisor
retornaria imediatamente, encerrando o loop iniciado comasyncio.run(supervisor(…))
, e fechando o programa. A documentação deServer.serve_forever
diz: "Este método pode ser chamado se o servidor já estiver aceitando conexões." -
Constrói o índice invertido.[308]
-
Inicia o loop de eventos rodando
supervisor
. -
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.
$ 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)
$
-
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. -
Saída de
supervisor
. -
Primeira iteração de um loop
while
emfinder
. 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. -
Segunda iteração do loop
while
emfinder
. -
Eu apertei Ctrl-C no terminal cliente; o loop
while
emfinder
termina. -
A corrotina
finder
mostra essa mensagem e então encerra. Enquanto isso o servidor continua rodando, pronto para receber outro cliente. -
Aperto Ctrl-C no terminal do servidor;
server.serve_forever
é cancelado, encerrandosupervisor
e o loop de eventos. -
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
.
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)
-
format_results
é útil para mostrar os resultado deInvertedIndex.search
em uma interface de usuário baseada em texto, como a linha de comando ou uma sessão Telnet. -
Para passar
finder
paraasyncio.start_server
, a envolvi comfunctools.partial
, porque o servidor espera uma corrotina ou função que receba apenas os argumentosreader
ewriter
. -
Obtém o endereço do cliente remoto ao qual o socket está conectado.
-
Esse loop controla um diálogo que persiste até um caractere de controle ser recebido do cliente.
-
O método
StreamWriter.write
não é uma corrotina, é apenas um função normal; essa linha envia o prompt?>
. -
O
StreamWriter.drain
esvazia o buffer dewriter
; ela é uma corrotina, então precisa ser acionada comawait
. -
StreamWriter.readline
é um corrotina que devolvebytes
. -
Se nenhum byte foi recebido, o cliente fechou a conexão, então sai do loop.
-
Decodifica os
bytes
parastr
, usando a codificação UTF-8 como default. -
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. -
Registra a consulta no console do servidor.
-
Sai do loop se um caractere de controle ou null foi recebido.
-
search
realiza a busca efetiva; o código será apresentado a seguir. -
Registra a resposta no console do servidor.
-
Fecha o
StreamWriter
. -
Espera até
StreamWriter
fechar. Isso é recomendado na documentação do método.close()
. -
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.
search
async def search(query: str, # (1)
index: InvertedIndex,
writer: asyncio.StreamWriter) -> int:
chars = index.search(query) # (2)
lines = (line.encode() + CRLF for line # (3)
in format_results(chars))
writer.writelines(lines) # (4)
await writer.drain() # (5)
status_line = f'{"─" * 66} {len(chars)} found' # (6)
writer.write(status_line.encode() + CRLF)
await writer.drain()
return len(chars)
-
search
tem que ser uma corrotina, pois escreve em umStreamWriter
e precisa usar seu método corrotina.drain()
. -
Consulta o índice invertido.
-
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'
. -
Envia a
lines
. Surpreendentemente,writer.writelines
não é uma corrotina. -
Mas
writer.drain()
é uma corrotina. Não esqueça doawait
! -
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 |
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).
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
>>>
-
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ê usaawait
com ele. -
Acione a corrotina
probe
. -
A versão
domainlib
deprobe
devolve uma tupla nomeadaResult
. -
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] -
Itera com
async for
sobre o gerador assíncronomulti_probe
para mostrar os resultados. -
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.
>>> 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
-
Chamar uma corrotina nativa devolve um objeto corrotina.
-
Chamar um gerador assíncrono devolve um objeto
async_generator
. -
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).
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)
-
A
NamedTuple
torna o resultado deprobe
mais fácil de ler e depurar. -
Este apelido de tipo serve para evitar que a linha seguinte fique grande demais em uma listagem impressa em um livro.
-
probe
agora recebe um argumento opcionalloop
, para evitar chamadas repetidas aget_running_loop
quando essa corrotina é acionada pormulti_probe
. -
Uma função geradora assíncrona produz um objeto gerador assíncrono, que pode ser anotado como
AsyncIterator[SomeType]
. -
Constrói uma lista de objetos corrotina
probe
, cada um com umdomain
diferente. -
Isso não é
async for
porqueasyncio.as_completed
é um gerador clássico. -
Espera pelo objeto corrotina para obter o resultado.
-
Produz
result
. Esta linha faz com quemulti_probe
seja um gerador assíncrono.
✒️ Nota
|
O loop
O Python interpreta isso como 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.
#!/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')
-
Gera palavras-chave de tamanho até
4
. -
Gera nomes de domínio com o sufixo recebido como TLD (Top Level Domain, "Domínio de Topo").
-
Formata um cabeçalho para a saída tabular.
-
Itera de forma assíncrona sobre
multi_probe(domains)
. -
Define
indent
como zero ou dois tabs, para colocar o resultado na coluna correta. -
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.
@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)
-
A função decorada tem que ser um gerador assíncrono.
-
Uma pequena atualização no código de Caleb: usar o
get_running_loop
, mais leve, no lugar deget_event_loop
. -
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. -
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 dedata
será vinculado à variáveldata
após a cláusulaas
no comandoasync with
abaixo. -
As linhas após o
yield
se tornarão o método corrotina__aexit__
. Aqui outra chamada bloqueante é delegada para um executor de threads. -
Usa
web_page
comasync 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 umyield
. -
Uma corrotina nativa pode
return
(devolver) algum valor diferente deNone
. Um gerador assíncrono só pode usar comandosreturn
vazios. -
Corrotinas nativas são esperáveis: elas podem ser acionadas por expressões
await
ou passadas para alguma das muitas funções doasyncio
que aceitam argumentos esperáveis, tal comocreate_task
. Geradores assíncronos não são esperáveis. Eles são iteráveis assíncronos, acionados porasync 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
-
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. -
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 comomulti_probe
. -
O objeto gerador assíncrono é acionado pelo comando
async for
, que por sua vez só pode aparecer dentro do corpo de umaasync 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 |
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.
#!/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)
-
probe
não precisa obter o loop de eventos, porque… -
…
getaddrinfo
é uma função nível superior decurio.socket
, não um método de um objetoloop
—como ele é noasyncio
. -
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. -
TaskGroup.spawn
é como você inicia uma corrotina, gerenciada por uma instância específica deTaskGroup
. A corrotina é envolvida em umaTask
. -
Iterar com
async for
sobre umTaskGroup
produz instâncias deTask
a medida que cada uma termina. Isso corresponde à linha em Exemplo 1 que usafor … as_completed(…):
. -
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 |
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.
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.
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.
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]
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]
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]
{ "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.
>>> 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]
-
feed
é umdict
contendo dicts e listas aninhados, com valores string e inteiros. -
Lista as quatro coleções de registros dentro de
"Schedule"
. -
Exibe a contagem de registros para cada coleção.
-
Navega pelos dicts e listas aninhados para obter o nome da última palestrante (
speaker
). -
Obtém o número de série para aquela mesma palestrante.
-
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.
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'
-
Cria uma instância de
FrozenJSON
a partir deraw_feed
, feito de dicts e listas aninhados. -
FrozenJSON
permite navegar dicts aninhados usando a notação de atributos; aqui exibimos o tamanho da lista de palestrantes. -
Métodos dos dicts subjacentes também podem ser acessados; por exemplo,
.keys()
, para recuperar os nomes das coleções de registros. -
Usando
items()
, podemos buscar os nomes das coleções de registros e seus conteúdos, para exibir olen()
de cada um deles. -
Uma
list
, tal comofeed.Schedule.speakers
, permanece uma lista, mas os itens dentro dela, se forem mapeamentos, são convertidos em umFrozenJSON
. -
O item 40 na lista
events
era um objeto JSON; agora ele é uma instância deFrozenJSON
. -
Registros de eventos tem uma lista de
speakers
com os números de séries de palestrantes. -
Tentar ler um atributo inexistente gera uma exceção
KeyError
, em vez daAttributeError
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.
FrozenJSON
contendo objetos FrozenJSON
aninhados, listas e tipos simplesfrom 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
-
Cria um
dict
a partir do argumentomapping
. 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. -
__getattr__
é invocado apenas quando não existe um atributo com aquelename
. -
Se
name
corresponde a um atributo da instância dedict
__data
, devolve aquele atributo. É assim que chamadas comofeed.keys()
são tratadas: o métodokeys
é um atributo dodict
__data
. -
Caso contrário, obtém o item com a chave
name
deself.__data
, e devolve o resultado da chamadaFrozenJSON.build()
com aquele argumento.[320] -
Implementar
__dir__
suporta a função embutidadir()
, 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 emself.__data
, porque__getattr__
cria instâncias deFrozenJSON
em tempo real—um recurso útil para a exploração interativa dos dados. -
Este é um construtor alternativo, um uso comum para o decorador
-
Se
obj
é um mapeamento, cria umFrozenJSON
com ele. Esse é um exmeplo de goose typing—veja a Seção 13.5 caso precise de uma revisão desse tópico. -
Se for uma
MutableSequence
, tem que ser uma lista[321], então criamos umalist
, passando recursivamente cada item emobj
para.build()
. -
Se não for um
dict
ou umalist
, 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
_
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
-
A função
keyword.iskeyword(…)
é exatamente o que precisamos; para usá-la, o módulokeyword
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__
.
__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()
-
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 porself
. -
O comportamento default é delegar para o
__new__
de uma superclasse. Nesse caso, estamos invocando o__new__
da classe baseobject
, passandoFrozenJSON
como único argumento. -
As linhas restantes de
__new__
são exatamente as do antigo métodobuild
. -
Era daqui que
FrozenJSON.build
era chamado antes; agora chamamos apenas a classeFrozenJSON
, e o Python trata essa chamada invocandoFrozenJSON.__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.
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
-
Dada uma instância de
Event
,… -
…ler
event.venue
devolve um objetoRecord
em vez de um número de série. -
Agora é fácil obter o nome do
venue
. -
A propriedade
event.speakers
devolve uma lista de instâncias deRecord
.
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.
>>> records = load(JSON_PATH) # (1)
>>> speaker = records['speaker.3471'] # (2)
>>> speaker # (3)
<Record serial=3471>
>>> speaker.name, speaker.twitter # (4)
('Anna Martelli Ravenscroft', 'annaraven')
-
load
umdict
com os dados JSON. -
As chaves em
records
são strings criadas a partir do tipo de registro e do número de série. -
speaker
é uma instância da classeRecord
, definida no Exemplo 9. -
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.
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
-
Isso é um atalho comum para construir uma instância com atributos criados a partir de argumentos nomeados (a explicação detalhada está abaixo) .
-
Usa o campo
serial
para criar a representação personalizada deRecord
exibida no Exemplo 8. -
load
vai por fim devolver umdict
de instâncias deRecord
. -
Analisa o JSON, devolvendo objetos Python nativos: listas, dicts, strings, números, etc.
-
Itera sobre as quatro listas principais, chamadas
'conferences'
,'events'
,'speakers'
, e'venues'
. -
record_type
é o nome da lista sem o último caractere, entãospeakers
se tornaspeaker
. No Python ≥ 3.9, podemos fazer isso de forma mais explícita comcollection.removesuffix('s')
—veja a PEP 616—String methods to remove prefixes and suffixes (Métodos de string para remover prefixos e sufixos_). -
Cria a
key
no formato'speaker.3471'
. -
Cria uma instância de
Record
e a armazena emrecords
com a chavekey
.
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 |
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 |
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.
>>> 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
-
O método estático
Record.fetch
obtém umRecord
ou umEvent
do conjunto de dados. -
Observe que
event
é uma instância da classeEvent
. -
Acessar
event.venue
devolve uma instância deRecord
. -
Agora é fácil encontrar o nome de um
event.venue
. -
A instância de
Event
também tem um atributovenue_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.
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)
-
inspect
será usado emload
, lista do no Exemplo 13. -
No final, o atributo de classe privado
__index
manterá a referência aodict
devolvido porload
. -
fetch
é umstaticmethod
, para deixar explícito que seu efeito não é influenciado pela classe ou pela instância de onde ele é invocado. -
Preenche o
Record.__index
, se necessário. -
E o utiliza para obter um registro com uma dada
key
.
👉 Dica
|
Esse é um exemplo onde o uso de |
Agora podemos usar a propriedade na classe Event
, listada no Exemplo 12.
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)
-
Event
estendeRecord
. -
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__
deRecord
. -
A propriedade
venue
cria umakey
a partir do atributovenue_serial
, e a passa para o método de classefetch
, herdado deRecord
(a razão para usarself.__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.
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
-
Até aqui, nenhuma mudança em relação ao
load
em schedule_v1.py (do Exemplo 9). -
Muda a primeira letra de
record_type
para maiúscula, para obter um possível nome de classe; por exemplo,'event'
se torna'Event'
. -
Obtém um objeto com aquele nome do escopo global do módulo; se aquele objeto não existir, obtém a classe
Record
. -
Se o objeto recém-obtido é uma classe, e é uma subclasse de
Record
… -
…vincula o nome
factory
a ele. Isso significa quefactory
pode ser qualquer subclasse deRecord
, dependendo dorecord_type
. -
Caso contrário, vincula o nome
factory
aRecord
. -
O loop
for
, que cria akey
e armazena os registros, é o mesmo de antes, exceto que… -
…o objeto armazenado em
records
é construído porfactory
, e pode serRecord
ou uma subclasse, comoEvent
, selecionada de acordo com orecord_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.
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)
-
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 à propriedadespeakers
. -
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 |
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.
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)
-
Se a instância não tem um atributo chamado
__speaker_objs
, obtém os objetosspeaker
e os armazena ali.. -
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.
__init__
para manter a otimização de compartilhamento de chavesclass 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.
@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 deproperty()
. Uma propriedade regular bloqueia a escrita em atributos, a menos que um setter seja definido. Umacached_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 comcached_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
|
|
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.
@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]
-
A ordem é importante:
@property
vai acima… -
…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.
LineItem
mais simplesclass 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.
>>> 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]
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
.
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)
-
Aqui o setter da propriedade já está em uso, assegurando que nenhuma instância com peso negativo possa ser criada.
-
@property
decora o método getter. -
Todos os métodos que implementam a propriedade compartilham o mesmo nome, do atributo público:
weight
. -
O valor efetivo é armazenado em um atributo privado
__weight
. -
O getter decorado tem um atributo
.setter
, que também é um decorador; isso conecta o getter e o setter. -
Se o valor for maior que zero, definimos o
__weight
privado. -
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.
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)
-
Um getter simples.
-
Um setter simples.
-
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.
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'
-
Define
Class
com dois atributos de classe: o atributodata
e a propriedadeprop
. -
vars
devolve o__dict__
deobj
, mostrando que ele não tem atributos de instância. -
Ler de
obj.data
obtém o valor deClass.data
. -
Escrever em
obj.data
cria um atributo de instância. -
Inspeciona a instância, para ver o atributo de instância.
-
Ler agora de
obj.data
obtém o valor do atributo da instância. Quanto lido a partir da instânciaobj
, odata
da instância oculta odata
da classe. -
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.
>>> 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'
-
Ler
prop
diretamente deClass
obtém o próprio objeto propriedade, sem executar seu método getter. -
Ler
obj.prop
executa o getter da propriedade. -
Tentar definir um atributo
prop
na instância falha. -
Inserir
'prop'
diretamente emobj.__dict__
funciona. -
Podemos ver que agora
obj
tem dois atributos de instância:data
eprop
. -
Entretanto, ler
obj.prop
ainda executa o getter da propriedade. A propriedade não é ocultada pelo atributo de instância. -
Sobrescrever
Class.prop
destrói o objeto propriedade. -
Agora
obj.prop
obtém o atributo de instância.Class.prop
não é mais uma propriedade, então ela não mais sobrepõeobj.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.
>>> 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'
-
obj.data
obtém o atributo de instânciadata
. -
Class.data
obtém o atributo de classedata
. -
Sobrescreve
Class.data
com uma nova propriedade. -
obj.data
está agora ocultado pela propriedadeClass.data
. -
Apaga a propriedade .
-
obj.data
agora lê novamente o atributo de instânciadata
.
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.
help(Foo.bar)
e help(Foo)
. O código-fonte está no Exemplo 26.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
.
quantity
em açãoclass 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)
-
Usa a fábrica para definir a primeira propriedade personalizada,
weight
, como um atributo de classe. -
Essa segunda chamada cria outra propriedade personalizada,
price
. -
Aqui a propriedade já está ativa, assegurando que um peso negativo ou
0
seja rejeitado. -
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 |
O Exemplo 28 apresenta a implementação da fábrica de propriedades quantity
.[326]
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)
-
O argumento
storage_name
, onde os dados de cada propriedade são armazenados; paraweight
, o nome do armazenamento será'weight'
. -
O primeiro argumento do
qty_getter
poderia se chamarself
, mas soaria estranho, pois isso não é o corpo de uma classe;instance
se refere à instância deLineItem
onde o atributo será armazenado. -
qty_getter
se refere astorage_name
, então ele será preservado na clausura desta função; o valor é obtido diretamente deinstance.__dict__
, para contornar a propriedade e evitar uma recursão infinita. -
qty_setter
é definido, e também recebeinstance
como primeiro argumento. -
O
value
é armazenado diretamente noinstance.__dict__
, novamente contornando a propriedade. -
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.
>>> 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}
-
Lendo o
weight
e oprice
através das propriedades que ocultam os atributos de instância de mesmo nome. -
Usando
vars
para inspecionar a instâncianutmeg
: 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]
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.
>>> 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 quetype(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__
é umatuple
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 pordir
, 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 pordir
. Você pode personalziar a saída dedir
implementando o método especial__dir__
, como vimos no Exemplo 4. Se o argumento opcionalobject
não for passado,dir
lista os nomes no escopo corrente. getattr(object, name[, default])
-
Devolve o atributo do
object
identificado pela stringname
. 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 umaAttributeError
ou devolve o valordefault
, se ele for passado. Um ótimo exemplo de uso degettatr
aparece no métodoCmd.onecmd
, no pacotecmd
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 emobject
, 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 deobject
nomeado, se oobject
permitir essa operação. Isso pode criar um novo atributo ou sobrescrever um atributo existente. vars([object])
-
Devolve o
__dict__
deobject
;vars
não funciona com instâncias de classes que definem__slots__
e não têm um__dict__
(compare comdir
, que aceita essas instâncias). Sem argumentos,vars()
faz o mesmo quelocals()
: devolve umdict
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
disparaClass.__delattr__(obj, 'attr')
. Seattr
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)
disparaClass.__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õesobj.no_such_attr
,getattr(obj, 'no_such_attr')
ehasattr(obj, 'no_such_attr')
podem dispararClass.__getattr__(obj, 'no_such_attr')
, mas apenas se um atributo com aquele nome não for encontrado emobj
ou emClass
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 embutidasgetattr
ehasattr
disparam esse método.__getattr__
só é invocado após__getattribute__
, e apenas quando__getattribute__
gera umaAttributeError
. Para acessar atributos da instânciaobj
sem entrar em uma recursão infinita, implementações de__getattribute__
devem usarsuper().__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, tantoobj.attr = 42
quantosetattr(obj, 'attr', 42)
disparamClass.__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 |
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.
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]
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.
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
eprice
deLineItem
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.
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.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
.
Quantity
não aceita valores negativosclass 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]
-
O descritor é um recurso baseado em protocolo: não é necessário criar uma subclasse para implementá-lo.
-
Cada instância de
Quantity
terá um atributostorage_name
: é o nome do atributo de armazenamento que vai manter o valar nas instâncias gerenciadas. -
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
ouLineItem.price
),instance
é a instância gerenciada (uma instância deLineItem
) evalue
é o valor que está sendo atribuído. -
Precisamos armazenar o valor do atributo diretamente no
__dict__
; chamarsetattr(instance, self.storage_name)
dispararia novamente o método__set__
, levando a uma recursão infinita. -
Precisamos implementar
__get__
, pois o nome do atributo gerenciado pode não ser igual aostorage_name
. O argumentoowner
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 LineItem.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
.
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
-
A primeira instância do descritor vai gerenciar o atributo
weight
. -
A segunda instância do descritor vai gerenciar o atributo
price
. -
O restante do corpo da classe é tão simples e limpo como o código orginal em bulkfood_v1.py (no Exemplo 19).
>>> truffle = LineItem('White truffle', 100, 0)
Traceback (most recent call last):
...
ValueError: value must be > 0
⚠️ Aviso
|
Ao programar os métodos |
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.
__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
-
self
é a instância do descritor (não a instância gerenciada),owner
é a classe gerenciada ename
é o nome do atributo deowner
ao qual essa instância do descritor foi atrbuída no corpo da classe deowner
. -
Isso é o que o
__init__
fazia no Exemplo 1. -
O método
__set__
aqui é exatamente igual ao do Exemplo 1. -
Não é necessário implementar
__get__
, porque o nome do atributo de armazenamento é igual ao nome do atributo gerenciado. A expressãoproduct.price
obtém o atributoprice
diretamente da instância deLineItem
. -
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.
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
-
Importa o módulo
model_v4c
, ondeQuantity
é implementada. -
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.
Validated
ABCimport 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"""
-
__set__
delega a validação para o métodovalidate
… -
…e então usa o
value
devolvido para atualizar o valor armazenado. -
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.
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)
-
Implementação do método modelo exigida pelo método abstrado
Validated.validate
. -
Se não sobrar nada após a remoção os espaços em branco antes e depois do valor, este é rejeitado.
-
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.
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
-
Importa o módulo
model_v5
, dando a ele um nome amigável. -
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 |
### 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)})')
-
Uma classe descritora dominante com
__get__
e__set__
. -
A função
print_args
é chamada por todos os métodos do descritor neste exemplo. -
Um descritor dominante sem um método
__get__
. -
Nenhum método
__set__
aqui, estão este é um descritor não dominante. -
A classe gerenciada, usando uma instância de cada uma das classes descritoras.
-
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.
>>> 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>)
-
Cria o objeto
Managed
, para testes. -
obj.over
aciona o método__get__
do descritor, passando a instância gerenciadaobj
como segundo argumento. -
Managed.over
aciona o método__get__
do descritor, passandoNone
como segundo argumento (instance
). -
Atribuir a
obj.over
aciona o método__set__
do descritor, passando o valor7
como último argumento. -
Ler
obj.over
ainda invoca o método__get__
do descritor. -
Contorna o descritor, definindo um valor diretamente no
obj.__dict__
. -
Verifica se aquele valor está no
obj.__dict__
, sob a chaveover
. -
Entretanto, mesmo com um atributo de instância chamado
over
, o descritorManaged.over
continua interceptando tentativas de lerobj.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.
__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
-
Este descritor dominante não tem um método
__get__
, então lerobj.over_no_get
obtém a instância do descritor a partir da classe. -
A mesma coisa acontece se obtivermos a instância do descritor diretamente da classe gerenciada.
-
Tentar definir um valor para
obj.over_no_get
invoca o método__set__
do descritor. -
Como nosso
__set__
não faz modificações, lerobj.over_no_get
novamente obtém a instância do descritor na classe gerenciada. -
Percorrendo o
__dict__
da instância para definir um atributo de instância chamadoover_no_get
. -
Agora aquele atributo de instância
over_no_get
oculta o descritor, mas apenas para leitura. -
Tentar atribuir um valor a
obj.over_no_get
continua passando pelo set do descritor. -
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.
>>> 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>)
-
obj.non_over
aciona o método__get__
do descritor, passandoobj
como segundo argumento. -
Managed.non_over
é um descritor não dominante, então não há um__set__
para interferir com essa atribuição. -
O
obj
agora tem um atributo de instância chamadonon_over
, que oculta o atributo do descritor de mesmo nome na classeManaged
. -
O descritor
Managed.non_over
ainda está lá, e intercepta esse acesso através da classe. -
Se o atributo de instância
non_over
for excluído… -
…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.
>>> 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)
-
Cria uma nova instância para testes posteriores.
-
Sobrescreve os atributos dos descritores na classe.
-
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 é |
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.
>>> 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
-
Ler de
obj.spam
obtém um objeto método vinculado. -
Mas ler de
Managed.spam
obtém uma função. -
Atribuir um valor a
obj.spam
oculta o atributo de classe, tornando o métodospam
inacessível a partir da instânciaobj
.
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.
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.
>>> 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
-
O
repr
de uma instância deText
se parece com uma chamada ao construtor deText
que criaria uma instância idêntica. -
O método
reverse
devolve o texto escrito de trás para frente. -
Um método invocado na classe funciona como uma função.
-
Observe os tipos diferentes: uma
function
e ummethod
. -
Text.reverse
opera como uma função, mesmo ao trabalhar com objetos que não são instâncias deText
. -
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. -
Invocar o
__get__
da função comNone
como argumentoinstance
obtém a própria função. -
A expressão
word.reverse
na verdade invocaText.reverse.__get__(word)
, devolvendo o método vinculado. -
O objeto método vinculado tem um atributo
__self__
, contendo uma referência à instância na qual o método foi invocado. -
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 umAttributeError: 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 umAttributeError
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 argumentovalue
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, comomy_obj.the_method = 7
, significa que acessos posteriores athe_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 comox.__class__.__repr__(x)
, então um atributo__repr__
, definido emx
, não tem qualquer efeito emrepr(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 |
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.
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 |
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 |
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.
A PEP 487—Simpler customization of class creation (PEP 487—Uma personalização mais simples da criação de classes) (EN)
introduziu o método especial __set_name__
e inclui um exemplo de um
validating descriptor (descritor de validação) (EN).
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]
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 chamadaMeta
. O__qualname__
deMeta
éOx.Meta
, mas seu__name__
é apenasMeta
. 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 |
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çãoclass
. 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 |
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.
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'>)
-
A fábrica pode ser chamada como
namedtuple
: nome da classe, seguido dos nomes dos atributos separados por espaços, em um única string. -
Um
repr
agradável. -
Instâncias são iteráveis, então elas podem ser convenientemente desempacotadas em uma atribuição…
-
…ou quando são passadas para funções como
format
. -
Uma instância do registro é mutável.
-
A classe recém-criada herda de
object
—não tem qualquer relação com nossa fábrica.
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)
-
O usuário pode fornecer os nomes dos campos como uma string única ou como um iterável de strings.
-
Aceita argumentos como os dois primeiros de
collections.namedtuple
; devolvetype
—isto é, uma classe que se comporta como umatuple
. -
Cria uma tupla de nomes de atributos; esse será o atributo
__slots__
da nova classe. -
Essa função se tornará o método
__init__
na nova classe. Ela aceita argumentos posicionais e/ou nomeados.[344] -
Produz os valores dos campos na ordem dada por
__slots__
. -
Produz um
repr
agradável, iterando sobre__slots__
eself
. -
Monta um dicionário de atributos de classe.
-
Cria e devolve a nova classe, invocando o construtor de
type
. -
Converte
names
separados por espaços ou vírgulas em uma lista destr
.
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 |
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 |
O Exemplo 3 mostra como usar Checked
para criar uma classe Movie
.
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)
-
Movie
herda deChecked
—que definiremos mais tarde, no Exemplo 5. -
Cada atributo é anotado com um construtor. Aqui usei tipos embutidos.
-
Instâncias de
Movie
devem ser criadas usando argumentos nomeados. -
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 tiposEm um arquivo de código fonte .py contendo uma instância
Entretanto, o Mypy não consegue detectar erros de tipo nessa chamada ao construtor:
Isso porque Por outro lado, se você declarar um campo de uma subclasse de |
Vamos ver agora a implementação de checkedlib.py.
A primeira classe é o descritor Field
, como mostra o Exemplo 4.
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)
-
Lembre-se, desde o Python 3.9, o tipo
Callable
para anotações é a ABC emcollections.abc
, e não o descontinuadotyping.Callable
. -
Essa é a dica de tipo
Callable
mínima; o parâmetro de tipo e o tipo devolvido paraconstructor
são ambos implicitamenteAny
. -
Para verificação durante a execução, usamos o embutido
callable
.[347] O teste contratype(None)
é necessário porque o Python entendeNone
em um tipo comoNoneType
, a classe deNone
(e portanto invocável), mas esse é um construtor inútil, que apenas devolveNone
. -
Se
Checked.__init__
definirvalue
como…
(o objeto embutidoEllipsis
), invocamos o construtor sem argumentos. -
Caso contrário, invocamos o
constructor
com ovalue
dado. -
Se
constructor
gerar qualquer dessas exceções, geramos umTypeError
com uma mensagem útil, incluindo os nomes do campo e do construtor; por exemplo,'MMIX' não é compatível com year:int
. -
Se nenhuma exceção for gerada, o
value
é armazenado noinstance.__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 |
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.
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)
-
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, invocariainspect.get_annotations
em vez disso. Reveja a Seção 15.5.1 para uma discussão dos problemas com essas funções. -
__init_subclass__
é chamado quando uma subclasse da classe atual é definida. Ele recebe aquela nova subclasse como seu primeiro argumento—e por isso nomeei o argumentosubclass
em vez do habitualcls
. Para mais informações sobre isso, veja __init_subclass__ não é um método de classe típico. -
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. -
Itera sobre
name
econstructor
em cada campo… -
…criando um atributo em
subclass
com aquelename
vinculado a um descritorField
, parametrizado comname
econstructor
. -
Para cada
name
nos campos da classe… -
…obtém o
value
correspondente dekwargs
e o remove dekwargs
. Usar…
(o objetoEllipsis
) como default nos permite distinguir entre argumentos com valorNone
de argumentos ausentes.[348] -
Essa chamada a
setattr
acionaChecked.__setattr__
, apresentado no Exemplo 6. -
Se houver itens remanescentes em
kwargs
, seus nomes não correspondem a qualquer dos campos declarados, e__init__
vai falhar. -
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.
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.
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})'
-
Intercepta qualquer tentativa de definir um atributo de instância. Isso é necessário para evitar a definição de um atributo desconhecido.
-
Se o
name
do atributo é conhecido, busca odescriptor
correspondente. -
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 comoField
.[350] -
Caso contrário, o atributo
name
é desconhecido, e uma exceção será gerada por__flag_unknown_attrs
. -
Cria uma mensagem de erro útil, listando todos os argumentos inesperados, e gera um
AttributeError
. Este é um raro exemplo do tipo especialNoReturn
, tratado na Seção 8.5.12. -
Cria um
dict
a partir dos atributos de um objetoMovie
. Eu chamaria este método de_as_dict
, mas segui a convenção iniciada com o método_asdict
emcollections.namedtuple
. -
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).
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.
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)
-
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. -
_fields
agora é uma função de alto nível definida mais tarde no módulo (no Exemplo 9). -
Substituir cada atributo devolvido por
_fields
por uma instância do descritorField
é o que__init_subclass__
fazia no Exemplo 5. Aqui há mais trabalho a ser feito… -
Cria um método de classe a partir de
_fields
, e o adiciona à classe decorada. O comentáriotype: ignore
é necessário, porque o Mypy reclama quetype
não tem um atributo_fields
. -
Funções ao nível do módulo, que se tornarão métodos de instância da classe decorada.
-
Adiciona cada um dos
instance_methods
acls
. -
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.
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:
-
Analisa o código-fonte de módulo .py em uma passagem, de cima até embaixo. É aqui que um
SyntaxError
pode ocorrer. -
Compila o bytecode a ser executado.
-
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 |
Vamos começar examinando builderlib.py, dividido em duas partes: o Exemplo 10 e o Exemplo 11.
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)
-
Essa é uma fábrica de classes para implementar…
-
…um método
__init_subclass__
. -
Define uma função para ser adicionada à subclasse na atribuição abaixo.
-
Um decorador de classes.
-
Função a ser adicionada à classe decorada.
-
Devolve a classe recebida como argumento.
Continuando builderlib.py no Exemplo 11…
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')
-
Uma classe descritora para demonstrar quando…
-
…uma instância do descritor é criada, e quando…
-
…
__set_name__
será invocado durante a criação da classeowner
. -
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).
#!/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')
-
Aplica um decorador.
-
Cria uma subclasse de
Builder
para acionar seu__init_subclass__
. -
Instancia o descritor.
-
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.
>>> 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
-
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. -
Isso sinaliza que o Python começou a ler o corpo de
Klass
. Neste momento o objeto classe ainda não existe. -
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__
. -
Neste ponto, a função embutida do Python
type.__new__
já criou o objetoKlass
e invoca__set_name__
em cada instância das classes do descritor que oferecem aquele método, passandoKlass
como argumentoowner
. -
type.__new__
então chama__init_subclass__
na superclasse deKlass
, passandoKlass
como único argumento. -
Quando
type.__new__
devolve o objeto classe, o Python aplica o decorador. Neste exemplo, a classe devolvida pordeco
está vinculada aKlass
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).
$ ./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
-
As 10 primeiras linhas—incluindo essa—são as mesma que aparecem no Exemplo 13.
-
Acionado por
super().__init__()
emKlass.__init__
. -
Acionado por
obj.method_a()
emmain
; omethod_a
foi injetado porSuperA.__init_subclass__
. -
Acionado por
obj.method_b()
emmain
;method_b
foi injetado pordeco
. -
Acionado por
obj.attr = 999
emmain
.
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]
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.
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.
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 |
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.
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.
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
-
Para criar uma nova metaclasse, herdamos de
type
. -
__new__
funciona como um método de classe, mas a classe é uma metaclasse, então gosto de nomear o primeiro argumentometa_cls
(mcs
é uma alternativa comum). Os três argumentos restantes são os mesmos da assinatura de três argumentos detype()
, quando chamada diretamente para criar uma classe. -
defaults
vai manter um mapeamento de nomes de atributos e seus valores default. -
Isso irá ser injetado na nova classe.
-
Lê
defaults
e define o atributo de instância correspondente, com o valor extraído dekwargs
, ou um valor default. -
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 dekwargs
e tentar defini-lo na instância, gerando propositalmente umAttributeError
. -
__repr__
devolve uma string que se parece com uma chamada ao construtor—por exemplo,Point(x=3)
, omitindo os argumentos nomeados com valores default. -
Inicializa o espaço de nomes para a nova classe.
-
Itera sobre o espaço de nomes da classe do usuário.
-
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__
. -
Se
name
não for um dunder, acrescentaname
a__slots__
e armazena seuvalue
emdefaults
. -
Cria e devolve a nova classe.
-
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.
#!/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')
-
Importa
MetaKlass
de metalib.py, que veremos no Exemplo 18. -
Declara
Klass
como uma subclasse deBuilder
e uma instância deMetaKlass
. -
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 |
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.
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:
|
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')
-
__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__
. -
O Python invoca
__prepare__
em uma metaclasse para obter um mapeamento, onde vai manter o espaço de nomes da classe em construção. -
Devolve uma instância de
NosyDict
para ser usado como o espaço de nomes. -
cls_dict
é uma instância deNosyDict
devolvida por__prepare__
. -
type.__new__
exige umdict
real como último argumento, então passamos a ele o atributodata
deNosyDict
, herdado deUserDict
. -
Injeta um método na classe recém-criada.
-
Como sempre,
__new__
precisa devolver o objeto que acaba de ser criado—neste caso, a nova classe. -
Definir
__repr__
em uma metaclasse permite personalizar orepr()
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.
>>> 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
-
As linhas antes disso são resultado da importação de builderlib.py e metalib.py.
-
O Python invoca
__prepare__
para iniciar o processamento de uma instruçãoclass
. -
Antes de analisar o corpo da classe, o Python acrescenta
__module__
e__qualname__
ao espaço de nomes de uma classe em construção. -
A instância do descritor é criada…
-
…e vinculada a
attr
no espaço de nomes da classe. -
Os métodos
__init__
e__repr__
são definidos e adicionados ao espaço de nomes. -
Após terminar o processamento do corpo da classe, o Python chama
MetaKlass.__new__
. -
__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).
$ ./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
-
As primeiras 21 linhas—incluindo esta—são as mesmas que aparecem no Exemplo 19.
-
Acionado por
obj.method_c()
emmain
;method_c
foi injetado porMetaKlass.__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.
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__
.
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.
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)
-
Determina
storage_name
a partir do argumentoname
. -
Se
__get__
recebeNone
como argumentoinstance
, o descritor está sendo lido desde a própria classe gerenciada, não de uma instância gerenciada. Neste caso devolvemos o descritor. -
Caso contrário, devolve o valor armazenado no atributo chamado
storage_name
. -
__set__
agora usasetattr
para definir ou atualizar o atributo gerenciado.
O Exemplo 22 mostra o código para a metaclasse que controla este exemplo.
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)
-
__new__
é o único método implementado emCheckedMeta
. -
Só melhora a classe se seu
cls_dict
não incluir__slots__
. Se__slots__
já está presente, assume que essa é a classe baseChecked
e não uma subclasse definida pelo usuário, e cria a classe sem modificações. -
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 docls_dict
—o espaço de nomes da classe em construção, que o Python passa como último argumento para o__new__
da metaclasse. -
Itera sobre
type_hints
para… -
…criar um
Field
para cada atributo anotado… -
…sobrescreve o item correspondente em
cls_dict
com a instância deField
… -
…e acrescenta o
storage_name
do campo à lista que usaremos para… -
…preencher o
__slots__
nocls_dict
—o espaço de nomes da classe em construção. -
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:
-
O acréscimo de um
__slots__
vazio, para sinalizar aCheckedMeta.__new__
que esta classe não precisa de processamento especial. -
A remoção de
__init_subclass__
, cujo trabalho agora é feito porCheckedMeta.__new__
. -
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
.
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 umOrderedDict
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 deabc.ABCMeta
quanto dePersistentMeta
, usando herança múltipla, e faça dela a única metaclasse deRecord
.[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__
emAutoConstMeta
. Desde o Python 3.9, épossível implementar__class_getitem__
na própriaAutoConst
. -
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 pacoteenum
está cheio de armadilhas, incluindo a metaclasseEnumMeta
, com centenas de linhas de código e um método__prepare__
nem um pouco trivial.
Divirta-se!
✒️ Nota
|
O método especial |
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]
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.
Posfácio
O Python é uma linguagem para programação consensual entre adultos.
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.