Skip to content

Latest commit

 

History

History
1368 lines (1041 loc) · 77 KB

cap18.adoc

File metadata and controls

1368 lines (1041 loc) · 77 KB

Instruções with, match, e blocos else

Gerenciadores de contexto podem vir a ser quase tão importantes quanto a própria sub-rotina. Só arranhamos a superfície das possibilidades. […​] Basic tem uma instrução with, há instruções with em várias linguagens. Mas elas não fazem a mesma coisa, todas fazem algo muito raso, economizam consultas a atributos com o operador ponto (.), elas não configuram e desfazem ambientes. Não pense que é a mesma coisa só porque o nome é igual. A instrução with é muito mais que isso.[1] (EN)

— Raymond Hettinger
um eloquente evangelista de Python

Este capítulo é sobre mecanismos de controle de fluxo não muito comuns em outras linguagens e que, por essa razão, podem ser ignorados ou subutilizados em Python. São eles:

  • A instrução with e o protocolo de gerenciamento de contexto

  • A instrução match/case para pattern matching (casamento de padrões)

  • A cláusula else nas instruções for, while, e try

A instrução with cria um contexto temporário e o destrói com segurança, sob o controle de um objeto gerenciador de contexto. Isso previne erros e reduz código repetitivo, tornando as APIs ao mesmo tempo mais seguras e mais fáceis de usar. Programadores Python estão encontrando muitos usos para blocos with além do fechamento automático de arquivos.

Já estudamos pattern matching em capítulos anteriores, mas aqui veremos como a gramática de uma linguagem de programação pode ser expressa como padrões de sequências. Por isso match/case é uma ferramenta eficiente para criar processadores de linguagem fáceis de entender e de estender. Vamos examinar um interpretador completo para um pequeno (porém funcional) subconjunto da linguagem Scheme. As mesmas ideias poderiam ser aplicadas no desenvolvimento de uma linguagem de templates ou uma DSL (Domain-Specific Language, literalmente Linguagem de Domínio Específico) para codificar regras de negócio em um sistema maior.

A cláusula else não é grande coisa, mas ajuda a transmitir a intenção por trás do código quando usada corretamente junto com for, while e try.

Novidades nesse capítulo

Também atualizei a Utilitários do contextlib 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.

Gerenciadores de contexto e a instrução with

Objetos gerenciadores de contexto existem para controlar uma instrução with, da mesma forma que iteradores existem para controlar uma instrução for.

A instrução with foi projetada para simplificar alguns usos comuns de try/finally, que garantem que alguma operação seja realizada após um bloco de código, mesmo que o bloco termine com um return, uma exceção, ou uma chamada sys.exit(). O código no bloco finally normalmente libera um recurso crítico ou restaura um estado anterior que havia sido temporariamente modificado.

A comunidade Python está encontrando novos usos criativos para gerenciadores de contexto. Alguns exemplos, da biblioteca padrão, são:

A interface gerenciador de contexto consiste dos métodos __enter__ and __exit__. No topo do with, o Python chama o método __enter__ do objeto gerenciador de contexto. Quando o bloco with encerra ou termina por qualquer razão, o Python chama __exit__ no objeto gerenciador de contexto.

O exemplo mais comum é se assegurar que um objeto arquivo seja fechado. O Exemplo 1 é uma demonstração detalhada do uso do with para fechar um arquivo.

Exemplo 1. Demonstração do uso de um objeto arquivo como gerenciador de contexto
>>> with open('mirror.py') as fp:  # (1)
...     src = fp.read(60)  # (2)
...
>>> len(src)
60
>>> fp  # (3)
<_io.TextIOWrapper name='mirror.py' mode='r' encoding='UTF-8'>
>>> fp.closed, fp.encoding  # (4)
(True, 'UTF-8')
>>> fp.read(60)  # (5)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
ValueError: I/O operation on closed file.
  1. fp está vinculado ao arquivo de texto aberto, pois o método __enter__ do arquivo devolve self.

  2. 60 caracteres Unicode de fp.

  3. A variável fp ainda está disponível—blocos with não definem um novo escopo, como fazem as funções.

  4. Podemos ler os atributos do objeto fp.

  5. Mas não podemos ler mais texto de fp pois, no final do bloco with, o método TextIOWrapper.__exit__ foi chamado, e isso fechou o arquivo.

A primeira explicação no Exemplo 1 transmite uma informação sutil porém crucial: o objeto gerenciador de contexto é o resultado da avaliação da expressão após o with, mas o valor vinculado à variável alvo (na cláusula as) é o resultado devolvido pelo método __enter__ do objeto gerenciador de contexto.

E acontece que a função open() devolve uma instância de TextIOWrapper, e o método __enter__ dessa classe devolve self. Mas em uma classe diferente, o método __enter__ também pode devolver algum outro objeto em vez do gerenciador de contexto.

Quando o fluxo de controle sai do bloco with de qualquer forma, o método __exit__ é invocado no objeto gerenciador de contexto, e não no que quer que __enter__ tenha devolvido.

A cláusula as da instrução with é opcional. No caso de open, sempre precisamos obter uma referência para o arquivo, para podermos chamar seus métodos. Mas alguns gerenciadores de contexto devolvem None, pois não tem nenhum objeto útil para entregar ao usuário.

O Exemplo 2 mostra o funcionamento de um gerenciador de contexto perfeitamente frívolo, projetado para ressaltar a diferença entre o gerenciador de contexto e o objeto devolvido por seu método __enter__.

Exemplo 2. Testando a classe gerenciadora de contexto LookingGlass
link:code/18-with-match/mirror.py[role=include]
  1. O gerenciador de contexto é uma instância de LookingGlass; o Python chama __enter__ no gerenciador de contexto e o resultado é vinculado a what.

  2. Exibe uma str, depois o valor da variável alvo what. A saída de cada print será invertida.

  3. Agora o bloco with terminou. Podemos ver que o valor devolvido por __enter__, armazenado em what, é a string 'JABBERWOCKY'.

  4. A saída do programa não está mais invertida.

O Exemplo 3 mostra a implementação de LookingGlass.

Exemplo 3. mirror.py: código da classe gerenciadora de contexto LookingGlass
link:code/18-with-match/mirror.py[role=include]
  1. O Python invoca __enter__ sem argumentos além de self.

  2. Armazena o método sys.stdout.write original, para podermos restaurá-lo mais tarde.

  3. Faz um monkey-patch em sys.stdout.write, substituindo-o com nosso próprio método.

  4. Devolve a string 'JABBERWOCKY', apenas para termos algo para colocar na variável alvo what.

  5. Nosso substituto de sys.stdout.write inverte o argumento text e chama a implementação original.

  6. Se tudo correu bem, o Python chama __exit__ com None, None, None; se ocorreu uma exceção, os três argumentos recebem dados da exceção, como descrito a seguir, logo após esse exemplo.

  7. Restaura o método original em sys.stdout.write.

  8. Se a exceção não é None e seu tipo é ZeroDivisionError, exibe uma mensagem…​

  9. …​e devolve True, para informar o interpretador que a exceção foi tratada.

  10. Se __exit__ devolve None ou qualquer valor falso, qualquer exceção levantada dentro do bloco with será propagada.

Tip

Quando aplicações reais tomam o controle da saída padrão, elas frequentemente desejam substituir sys.stdout com outro objeto similar a um arquivo por algum tempo, depois voltar ao original. O gerenciador de contexto contextlib.redirect_stdout faz exatamente isso: passe a ele seu objeto similar a um arquivo que substituirá sys.stdout.

O interpretador chama o método __enter__ sem qualquer argumento—além do self implícito. Os três argumentos passados a __exit__ são:

exc_type

A classe da exceção (por exemplo, ZeroDivisionError).

exc_value

A instância da exceção. Algumas vezes, parâmetros passados para o construtor da exceção—tal como a mensagem de erro—podem ser encontrados em exc_value.args.

traceback

Um objeto traceback.[2]

Para uma visão detalhada de como funciona um gerenciador de contexto, vejamos o Exemplo 4, onde LookingGlass é usado fora de um bloco with, de forma que podemos chamar manualmente seus métodos __enter__ e __exit__.

Exemplo 4. Exercitando o LookingGlass sem um bloco with
link:code/18-with-match/mirror.py[role=include]
  1. Instancia e inspeciona a instância de manager.

  2. Chama o método __enter__ do manager e guarda o resultado em monster.

  3. monster é a string 'JABBERWOCKY'. O identificador True aparece invertido, porque toda a saída via stdout passa pelo método write, que modificamos em __enter__.

  4. Chama manager.__exit__ para restaurar o stdout.write original.

Tip
Gerenciadores de contexto entre parênteses

O Python 3.10 adotou um novo parser (analisador sintático), mais poderoso que o antigo parser LL(1). Isso permitiu introduzir novas sintaxes que não eram viáveis anteriormente. Uma melhoria na sintaxe foi permitir gerenciadores de contexto agrupados entre parênteses, assim:

with (
    CtxManager1() as example1,
    CtxManager2() as example2,
    CtxManager3() as example3,
):
    ...

Antes do 3.10, as linhas acima teriam que ser escritas como blocos with aninhados.

A biblioteca padrão inclui o pacote contextlib, com funções, classe e decoradores muito convenientes para desenvolver, combinar e usar gerenciadores de contexto.

Utilitários do contextlib

Antes de desenvolver suas próprias classes gerenciadoras de contexto, dê uma olhada em contextlib—"Utilities for with-statement contexts" ("Utilitários para contextos da instrução with), na documentação do Python. Pode ser que você esteja prestes a escrever algo que já existe, ou talvez exista uma classe ou algum invocável que tornará seu trabalho mais fácil.

Além do gerenciador de contexto redirect_stdout mencionado logo após o Exemplo 3, o redirect_stderr foi acrescentado no Python 3.5—ele faz o mesmo que seu par mais antigo, mas com as saídas direcionadas para stderr.

O pacote contextlib também inclui:

closing

Uma função para criar gerenciadores de contexto a partir de objetos que forneçam um método close() mas não implementam a interface __enter__/__exit__.

suppress

Um gerenciador de contexto para ignorar temporariamente exceções passadas como parâmetros.

nullcontext

Um gerenciador de contexto que não faz nada, para simplificar a lógica condicional em torno de objetos que podem não implementar um gerenciador de contexto adequado. Ele serve como um substituto quando o código condicional antes do bloco with pode ou não fornecer um gerenciador de contexto para a instrução with. Adicionado no Python 3.7.

O módulo contextlib fornece classes e um decorador que são mais largamente aplicáveis que os decoradores mencionados acima:

@contextmanager

Um decorador que permite construir um gerenciador de contexto a partir de um simples função geradora, em vez de criar uma classe e implementar a interface. Veja a Usando o @contextmanager.

AbstractContextManager

Uma ABC que formaliza a interface gerenciador de contexto, e torna um pouco mais fácil criar classes gerenciadoras de contexto, através de subclasses—adicionada no Python 3.6.

ContextDecorator

Uma classe base para definir gerenciadores de contexto baseados em classes que podem também ser usadas como decoradores de função, rodando a função inteira dentro de um contexto gerenciado.

ExitStack

Um gerenciador de contexto que permite entrar em um número variável de gerenciadores de contexto. Quando o bloco with termina, ExitStack chama os métodos __exit__ dos gerenciadores de contexto empilhados na ordem LIFO (Last In, First Out, Último a Entrar, Primeiro a Sair). Use essa classe quando você não sabe de antemão em quantos gerenciadores de contexto será necessário entrar no bloco with; por exemplo, ao abrir ao mesmo tempo todos os arquivos de uma lista arbitrária de arquivos.

Com o Python 3.7, contextlib acrescentou AbstractAsyncContextManager, @asynccontextmanager, e AsyncExitStack. Eles são similares aos utilitários equivalentes sem a parte async no nome, mas projetados para uso com a nova instrução async with, tratado no [async_ch].

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.

Usando o @contextmanager

O decorador @contextmanager é uma ferramenta elegante e prática, que une três recursos distintos do Python: um decorador de função, um gerador, e a instrução with.

Usar o @contextmanager reduz o código repetitivo na criação de um gerenciador de contexto: em vez de escrever toda uma classe com métodos __enter__/__exit__, você só precisa implementar um gerador com uma única instrução yield, que deve produzir o que o método __enter__ deveria devolver.

Em um gerador decorado com @contextmanager, o yield divide o corpo da função em duas partes: tudo que vem antes do yield será executado no início do bloco with, quando o interpretador chama __enter__; o código após o yield será executado quando __exit__ é chamado, no final do bloco.

O Exemplo 5 substitui a classe LookingGlass do Exemplo 3 por uma função geradora.

Exemplo 5. mirror_gen.py: um gerenciador de contexto implementado com um gerador
link:code/18-with-match/mirror_gen.py[role=include]
  1. Aplica o decorador contextmanager.

  2. Preserva o método sys.stdout.write original.

  3. reverse_write pode chamar original_write mais tarde, pois ele está disponível em sua clausura (closure).

  4. Substitui sys.stdout.write por reverse_write.

  5. Produz o valor que será vinculado à variável alvo na cláusula as da instrução with. O gerador se detem nesse ponto, enquanto o corpo do with é executado.

  6. Quando o fluxo de controle sai do bloco with, a execução continua após o yield; neste ponto o sys.stdout.write original é restaurado.

O Exemplo 6 mostra a função looking_glass em operação.

Exemplo 6. Testando a função gerenciadora de contexto looking_glass
link:code/18-with-match/mirror_gen.py[role=include]
  1. A única diferença do Exemplo 2 é o nome do gerenciador de contexto:`looking_glass` em vez de LookingGlass.

O decorador contextlib.contextmanager envolve a função em uma classe que implementa os métodos __enter__ e __exit__.[3]

O método __enter__ daquela classe:

  1. Chama a função geradora para obter um objeto gerador—vamos chamá-lo de gen.

  2. Chama next(gen) para acionar com ele a palavra reservada yield.

  3. Devolve o valor produzido por next(gen), para permitir que o usuário o vincule a uma variável usando o formato with/as.

Quando o bloco with termina, o método __exit__:

  1. Verifica se uma exceção foi passada como exc_type; em caso afirmativo, gen.throw(exception) é invocado, fazendo com que a exceção seja levantada na linha yield, dentro do corpo da função geradora.

  2. Caso contrário, next(gen) é chamado, retomando a execução do corpo da função geradora após o yield.

O Exemplo 5 tem um defeito: Se uma exceção for levantada no corpo do bloco with, o interpretador Python vai capturá-la e levantá-la novamente na expressão yield dentro de looking_glass. Mas não há tratamento de erro ali, então o gerador looking_glass vai terminar sem nunca restaurar o método sys.stdout.write original, deixando o sistema em um estado inconsistente.

O Exemplo 7 acrescenta o tratamento especial da exceção ZeroDivisionError, tornando esse gerenciador de contexto funcionalmente equivalente ao Exemplo 3, baseado em uma classe.

Exemplo 7. mirror_gen_exc.py: gerenciador de contexto baseado em um gerador implementando tratamento de erro—com o mesmo comportamento externo de Exemplo 3
link:code/18-with-match/mirror_gen_exc.py[role=include]
  1. Cria uma variável para uma possível mensagem de erro; essa é a primeira mudança em relação a Exemplo 5.

  2. Trata ZeroDivisionError, fixando uma mensagem de erro.

  3. Desfaz o monkey-patching de sys.stdout.write.

  4. Mostra a mensagem de erro, se ela foi determinada.

Lembre-se que o método __exit__ diz ao interpretador que ele tratou a exceção ao devolver um valor verdadeiro; nesse caso, o interpretador suprime a exceção.

Por outro lado, se __exit__ não devolver explicitamente um valor, o interpretador recebe o habitual None, e propaga a exceção. Com o @contextmanager, o comportamento default é invertido: o método __exit__ fornecido pelo decorador assume que qualquer exceção enviada para o gerador está tratada e deve ser suprimida.

Tip

Ter um try/finally (ou um bloco with) em torno do yield é o preço inescapável do uso de @contextmanager, porque você nunca sabe o que os usuários do seu gerenciador de contexto vão fazer dentro do bloco with.[4]

Um recurso pouco conhecido do @contextmanager é que os geradores decorados com ele podem ser usados eles mesmos como decoradores.[5] Isso ocorre porque @contextmanager é implementado com a classe contextlib.ContextDecorator.

O Exemplo 8 mostra o gerenciador de contexto looking_glass do Exemplo 5 sendo usado como um decorador.

Exemplo 8. O gerenciador de contexto looking_glass também funciona como um decorador.
link:code/18-with-match/mirror_gen.py[role=include]
  1. looking_glass faz seu trabalho antes e depois do corpo de verse rodar.

  2. Isso confirma que o sys.write original foi restaurado.

Compare o Exemplo 8 com o Exemplo 6, onde looking_glass é usado como um gerenciador de contexto.

Um interessante exemplo real do uso do @contextmanager fora da biblioteca padrão é a reescrita de arquivo no mesmo lugar usando um gerenciador de contexto de Martijn Pieters. O Exemplo 9 mostra como ele é usado.

Exemplo 9. Um gerenciador de contexto para reescrever arquivos no lugar
import csv

with inplace(csvfilename, 'r', newline='') as (infh, outfh):
    reader = csv.reader(infh)
    writer = csv.writer(outfh)

    for row in reader:
        row += ['new', 'columns']
        writer.writerow(row)

A função inplace é um gerenciador de contexto que fornece a você dois identificadores—no exemplo, infh e outfh—para o mesmo arquivo, permitindo que seu código leia e escreva ali ao mesmo tempo. Isso é mais fácil de usar que a função fileinput.input (EN) da biblioteca padrão (que, por sinal, também fornece um gerenciador de contexto).

Se você quiser estudar o código-fonte do inplace de Martijn (listado no post) (EN), encontre a palavra reservada yield: tudo antes dela lida com configurar o contexto, que implica criar um arquivo de backup, então abrir e produzir referências para os identificadores de arquivo de leitura e escrita que serão devolvidos pela chamada a __enter__. O processamento do __exit__ após o yield fecha os identificadores do arquivo e, se algo deu errado, restaura o arquivo do backup.

Isso conclui nossa revisão da instrução with e dos gerenciadores de contexto. Vamos agora olhar o match/case, no contexto de um exemplo completo.

Pattern matching no lis.py: um estudo de caso

Na [pattern_matching_seq_interp_sec], vimos exemplos de sequências de padrões extraídos da funcão evaluate do interpretador lis.py de Peter Norvig, portado para o Python 3.10. Nessa seção quero dar um visão geral do funcionamento do lis.py, e também explorar todas as cláusulas case de evaluate, explicando não apenas os padrões mas também o que o interpretador faz em cada case.

Além de mostrar mais pattern matching, escrevi essa seção por três razões:

  1. O lis.py de Norvig é um lindo exemplo de código Python idiomático.

  2. A simplicidade do Scheme é uma aula magna de design de linguagens.

  3. Aprender como um interpretador funciona me deu um entendimento mais profundo sobre o Python e sobre linguagens de programação em geral—interpretadas ou compiladas.

Antes de olhar o código Python, vamos ver um pouquinho de Scheme, para você poder entender este estudo de caso—pensando em quem nunca viu Scheme e Lisp antes.

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).[6]

O Exemplo 10 mostra um exemplo simples em Scheme.

Exemplo 10. Maior divisor comum em Scheme
(define (mod m n)
    (- m (* n (quotient m n))))

(define (gcd m n)
    (if (= n 0)
        m
        (gcd n (mod m n))))

(display (gcd 18 45))

O Exemplo 10 mostra três expressões em Scheme: duas definições de função—mod e gcd—e uma chamada a display, que vai devolver 9, o resultado de (gcd 18 45). O Exemplo 11 é o mesmo código em Python (menor que a explicação em português do algoritmo recursivo de Euclides).

Exemplo 11. Igual ao Exemplo 10, mas escrito em Python
def mod(m, n):
    return m - (m // n * n)

def gcd(m, n):
    if n == 0:
        return m
    else:
        return gcd(n, mod(m, n))

print(gcd(18, 45))

Em Python idiomático, eu usaria o operador % em vez de reinventar mod, e seria mais eficiente usar um loop while em vez de recursão. Mas queria mostrar duas definições de função, e fazer os exemplos o mais similares possível, para ajudar você a ler o código Scheme.

O Scheme não tem instruções iterativas de controle de fluxo como while ou for. A iteração é feita com recursão. Observe que não há atribuições nos exemplos em Python e Scheme. O uso extensivo de recursão e o uso mínimo de atribuição são marcas registradas do estilo funcional de programação.[7]

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.

Importações e tipos

O Exemplo 12 mostra as primeiras linhas do lis.py. O uso do TypeAlias e do operador de união de tipos | exige o Python 3.10.

Exemplo 12. lis.py: início do arquivo
link:code/18-with-match/lispy/py3.10/lis.py[role=include]

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.[8]

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.

O parser

O parser (analisador sintático) de Norvig tem 36 linhas de código que exibem o poder do Python aplicado ao tratamento da sintaxe recursiva simples das expressões-S—sem strings, comentários, macros e outros recursos que tornam a análise sintática do Scheme padrão mais complicada (Exemplo 13).

Exemplo 13. lis.py: as principais funcões do analisador
link:code/18-with-match/lispy/py3.10/lis.py[role=include]
    # 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:

link:code/18-with-match/lispy/py3.10/examples_test.py[role=include]

As regras de avaliação para esse subconjunto do Scheme são simples:

  1. Um símbolo sintático que se pareça com um número é tratado como um float ou um int.

  2. Todo o resto que não seja um '(' ou um ')' é considerado um Symbol—uma str, a ser usado como um identificador. Isso inclui texto no código-fonte como +, set!, e make-counter, que são identificadores válidos em Scheme, mas não em Python.

  3. Expressões dentro de '(' e ')' são avaliadas recursivamente como listas contendo átomos ou listas aninhadas que podem conter átomos ou mais listas aninhadas.

Usando a terminologia do interpretador Python, a saída de parse é uma AST (Abstract Syntax Tree—Árvore Sintática Abstrata): uma representação conveniente de um programa Scheme como listas aninhadas formando uma estrutura similar a uma árvore, onde a lista mais externa é o tronco, listas internas são os galhos, e os átomos são as folhas (Figura 1).

Código Scheme, im diagram de árvore e objetos Python
Figura 1. Uma expressão lambda de Scheme, representada como código-fonte (sintaxe concreta de expressões-S), como uma árvore, e como uma sequência de objetos Python (sintaxe abstrata).

O ambiente

A classe Environment estende collections.ChainMap, acrescentando o método change, para atualizar um valor dentro de um dos dicts encadeados que as instâncias de ChainMap mantém em uma lista de mapeamentos: o atributo self.maps. O método change é necessário para suportar a forma (set! …) do Scheme, descrita mais tarde; veja o Exemplo 14.

Exemplo 14. lis.py: a classe Environment
link:code/18-with-match/lispy/py3.10/lis.py[role=include]

Observe que o método change só atualiza chaves existentes.[9] Tentar mudar uma chave não encontrada causa um KeyError.

Esse doctest mostra como Environment funciona:

link:code/18-with-match/lispy/py3.10/examples_test.py[role=include]
  1. Ao ler os valores, Environment funciona como ChainMap: as chaves são procuradas nos mapeamentos aninhados da esquerda para a direita. Por isso o valor de a no outer_env é encoberto pelo valor em inner_env.

  2. Atribuir com [] sobrescreve ou insere novos itens, mas sempre no primeiro mapeamento, inner_env nesse exemplo.

  3. env.change('b', 333) busca a chave b e atribui a ela um novo valor no mesmo lugar, no outer_env

A seguir temos a função standard_env(), que constrói e devolve um Environment carregado com funções pré-definidas, similar ao módulo __builtins__ do Python, que está sempre disponível (Exemplo 15).

Exemplo 15. lis.py: standard_env() constrói e devolve o ambiente global
link:code/18-with-match/lispy/py3.10/lis.py[role=include]
            # omitted here: more operator definitions
link:code/18-with-match/lispy/py3.10/lis.py[role=include]
            # omitted here: more function definitions
link:code/18-with-match/lispy/py3.10/lis.py[role=include]

Resumindo, o mapeamento env é carregado com:

  • Todas as funções do módulo math do Python

  • Operadores selecionados do módulo op do Python

  • Funções simples porém poderosas construídas com o lambda do Python

  • Estruturas e entidades embutidas do Python, ou renomeadas, como callable para procedure?, ou mapeadas diretamente, como round

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.[10]

Exemplo 16. As funções do REPL
link:code/18-with-match/lispy/py3.10/lis.py[role=include]

Segue uma breve explicação sobre essas duas funções:

repl(prompt: str = 'lis.py> ') → NoReturn

Chama standard_env() para provisionar as funções embutidas para o ambiente global, então entra em um loop infinito, lendo e avaliando cada linha de entrada, calculando-a no ambiente global, e exibindo o resultado—a menos que seja None. O global_env pode ser modificado por evaluate. Por exemplo, quando o usuário define uma nova variável global ou uma função nomeada, ela é armazenada no primeiro mapeamento do ambiente—o dict vazio na chamada ao construtor de Environment na primeira linha de repl.

lispstr(exp: object) → str

A função inversa de parse: dado um objeto Python representando uma expressão, lispstr devolve o código-fonte para ela. Por exemplo, dado ['', 2, 3]`, o resultado é `'( 2 3)'.

O avaliador de expressões

Agora podemos apreciar a beleza do avaliador de expressões de Norvig—tornado um pouco mais bonito com match/case. A função evaluate no Exemplo 17 recebe uma Expression (construída por parse) e um Environment.

O corpo de evaluate é composto por uma única instrução match com uma expressão exp como sujeito. Os padrões de case expressam a sintaxe e a semântica do Scheme com uma clareza impressionante.

Exemplo 17. evaluate recebe uma expressão e calcula seu valor
link:code/18-with-match/lispy/py3.10/lis.py[role=include]

Vamos estudar cada cláusula case e o que cada uma faz. Em algumas ocasiões eu acrescentei comentários, mostrando uma expressão-S que casaria com padrão quando transformado em uma lista do Python. Os doctests extraídos de examples_test.py demonstram cada case.

avaliando números
    case int(x) | float(x):
        return x
Padrão:

Instância de int ou float.

Ação:

Devolve o próprio valor.

Exemplo:
link:code/18-with-match/lispy/py3.10/examples_test.py[role=include]
avaliando símbolos
    case Symbol(var):
        return env[var]
Padrão:

Instância de Symbol, isto é, uma str usada como identificador.

Ação:

Consulta var em env e devolve seu valor.

Exemplos:
link:code/18-with-match/lispy/py3.10/examples_test.py[role=include]
(quote …)

A forma especial quote trata átomos e listas como dados em vez de expressões a serem avaliadas.

    # (quote (99 bottles of beer))
    case ['quote', x]:
        return x
Padrão:

Lista começando com o símbolo 'quote', seguido de uma expressão x.

Ação:

Devolve x sem avaliá-la.

Exemplos:
link:code/18-with-match/lispy/py3.10/examples_test.py[role=include]

Sem quote, cada expressão no teste geraria um erro:

  • no-such-name seria buscado no ambiente, gerando um KeyError

  • (99 bottles of beer) não pode ser avaliado, pois o número 99 não é um Symbol nomeando uma forma especial, um operador ou uma função

  • (/ 10 0) geraria um ZeroDivisionError

Por que linguagens tem palavras reservadas?

Apesar de ser simples, quote não pode ser implementada como uma função em Scheme. Seu poder especial é impedir que o interpretador avalie (f 10) na expressão (quote (f 10)): o resultado é apenas uma lista com um Symbol e um int. Por outro lado, em uma chamada de função como (abs (f 10)), o interpretador primeiro calcula o resultado de (f 10) antes de invocar abs. Por isso quote é uma palavra reservada: ela precisa ser tratada como uma forma especial.

De modo geral, palavras reservadas são necessárias para:

  • Introduzir regras especiais de avaliação, como quote e lambda—que não avaliam nenhuma de suas sub-expressões

  • Mudar o fluxo de controle, como em if e chamadas de função—que também tem regras especiais de avaliação

  • Para gerenciar o ambiente, como em define e set

Por isso também o Python, e linguagens de programação em geral, precisam de palavras reservadas. Pense em def, if, yield, import, del, e o que elas fazem em Python.

(if …)
    # (if (< x 0) 0 x)
    case ['if', test, consequence, alternative]:
        if evaluate(test, env):
            return evaluate(consequence, env)
        else:
            return evaluate(alternative, env)
Padrão:

Lista começando com 'if' seguida de três expressões: test, consequence, e alternative.

Ação:

Avalia test:

  • Se verdadeira, avalia consequence e devolve seu valor.

  • Caso contrário, avalia alternative e devolve seu valor.

Exemplos:
link:code/18-with-match/lispy/py3.10/examples_test.py[role=include]

Os ramos consequence e alternative devem ser expressões simples. Se mais de uma expressão for necessária em um ramo, você pode combiná-las com (begin exp1 exp2…), fornecida como uma função em lis.py—veja o Exemplo 15.

(lambda …)

A forma lambda do Scheme define funções anônimas. Ela não sofre das limitações da lambda do Python: qualquer função que pode ser escrita em Scheme pode ser escrita usando a sintaxe (lambda …).

    # (lambda (a b) (/ (+ a b) 2))
    case ['lambda' [*parms], *body] if body:
        return Procedure(parms, body, env)
Padrão:

Lista começando com 'lambda', seguida de:

  • Lista de zero ou mais nomes de parâmetros

  • Uma ou mais expressões coletadas em body (a expressão guarda assegura que body não é vazio).

Ação:

Cria e devolve uma nova instância de Procedure com os nomes de parâmetros, a lista de expressões como o corpo da função, e o ambiente atual.

Exemplo:
link:code/18-with-match/lispy/py3.10/examples_test.py[role=include]

A classe Procedure implementa o conceito de uma closure (clausura): um objeto invocável contendo nomes de parâmetros, um corpo de função, e uma referência ao ambiente no qual a Procedure está sendo instanciada. Vamos estudar o código de Procedure daqui a pouco.

(define …)

A palavra reservada define é usada de duas formas sintáticas diferentes. A mais simples é:

    # (define half (/ 1 2))
    case ['define', Symbol(name), value_exp]:
        env[name] = evaluate(value_exp, env)
Padrão:

Lista começando com 'define', seguido de um Symbol e uma expressão.

Ação:

Avalia a expressão e coloca o valor resultante em env, usando name como chave.

Exemplo:
link:code/18-with-match/lispy/py3.10/examples_test.py[role=include]

O doctest para esse case cria um global_env, para podermos verificar que evaluate coloca answer dentro daquele Environment.

Podemos usar primeira forma de define para criar variáveis ou para vincular nomes a funções anônimas, usando (lambda …) como o value_exp.

A segunda forma de define é um atalho para definir funções nomeadas.

    # (define (average a b) (/ (+ a b) 2))
    case ['define', [Symbol(name), *parms], *body] if body:
        env[name] = Procedure(parms, body, env)
Padrão:

Lista começando com 'define', seguida de:

  • Uma lista começando com um Symbol(name), seguida de zero ou mais itens agrupados em uma lista chamada parms.

  • Uma ou mais expressões agrupadas em body (a expressão guarda garante que body não esteja vazio)

Ação:
  • Cria uma nova instância de Procedure com os nomes dos parâmetros, a lista de expressões como o corpo, e o ambiente atual.

  • Insere a Procedure em env, usando name como chave.

O doctest no Exemplo 18 define e coloca no global_env uma função chamada %, que calcula uma porcentagem.

Exemplo 18. Definindo uma função chamada %, que calcula uma porcentagem
link:code/18-with-match/lispy/py3.10/examples_test.py[role=include]

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.[11]

    # (set! n (+ n 1))
    case ['set!', Symbol(name), value_exp]:
        env.change(name, evaluate(value_exp, env))
Padrão:

Lista começando com 'set!', seguida de um Symbol e de uma expressão.

Ação:

Atualiza o valor de name em env com o resultado da avaliação da expressão.

O método Environment.change atravessa os ambientes encadeados de local para global, e atualiza a primeira ocorrência de name com o novo valor. Se não estivéssemos implementando a palavra reservada 'set!', esse interpretador poderia usar apenas o ChainMap do Python para implementar env, sem precisar da nossa classe Environment.

O nonlocal do Python e o set! do Scheme tratam da mesma questão

O uso da forma set! está relacionado ao uso da palavra reservada nonlocal em Python: declarar nonlocal x permite a x = 10 atualizar uma variável x anteriormente definida fora do escopo local. Sem a declaração nonlocal x, x = 10 vai sempre criar uma variável local em Python, como vimos na [nonlocal_sec].

De forma similar, (set! x 10) atualiza um x anteriormente definido que pode estar fora do ambiente local da função. Por outro lado, a variável x em (define x 10) é sempre uma variável local, criada ou atualizada no ambiente local.

Ambos, nonlocal e (set! …), são necessários para atualizar o estados do programas mantidos em variáveis dentro de uma clausura (closure). O [ex_average_fixed] demonstrou o uso de nonlocal para implementar uma função que calcula uma média contínua, mantendo itens count e total em uma clausura. Aqui está a mesma ideia, escrita no subconjunto de Scheme de lis.py:

(define (make-averager)
    (define count 0)
    (define total 0)
    (lambda (new-value)
        (set! count (+ count 1))
        (set! total (+ total new-value))
        (/ total count)
    )
)
(define avg (make-averager))  # (1)
(avg 10)  # (2)
(avg 11)  # (3)
(avg 15)  # (4)
  1. Cria uma nova clausura com a função interna definida por lambda e as variáveis count e total, inicialziadas com 0; vincula a clausura a avg.

  2. Devolve 10.0.

  3. Devolve 10.5.

  4. Devolve 12.0.

O código acima é um dos testes em lispy/py3.10/examples_test.py.

Agora chegamos a uma chamada de função.

Chamada de função
    # (gcd (* 2 105) 84)
    case [func_exp, *args] if func_exp not in KEYWORDS:
        proc = evaluate(func_exp, env)
        values = [evaluate(arg, env) for arg in args]
        return proc(*values)
Padrão:

Lista com um ou mais itens.

A expressão guarda garante que func_exp não é um de ['quote', 'if', 'define', 'lambda', 'set!']—listados logo antes de evaluate no Exemplo 17.

O padrão casa com qualquer lista com uma ou mais expressões, vinculando a primeira expressão a func_exp e o restante a args como uma lista, que pode ser vazia.

Ação:
  • Avaliar func_exp para obter uma proc da função.

  • Avaliar cada item em args para criar uma lista de valores dos argumentos.

  • Chamar proc com os valores como argumentos separados, devolvendo o resultado.

Exemplo:
link:code/18-with-match/lispy/py3.10/examples_test.py[role=include]

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:

link:code/18-with-match/lispy/py3.10/examples_test.py[role=include]

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.

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 [closures_sec].

Aprendemos como usar clausuras em Python, mas agora podemos mergulhar mais fundo e ver como uma clausura é implementada em lis.py:

link:code/18-with-match/lispy/py3.10/lis.py[role=include]
  1. Chamada quando uma função é definida pelas formas lambda ou define.

  2. Salva os nomes dos parâmetros, as expressões no corpo e o ambiente, para uso posterior.

  3. Chamada por proc(*values) na última linha da cláusula case [func_exp, *args].

  4. Cria local_env, mapeando self.parms como nomes de variáveis locais e os args passados como valores.

  5. Cria um novo env combinado, colocando local_env primeiro e então self.env—o ambiente que foi salvo quando a função foi definida.

  6. Itera sobre cada expressão em self.body, avaliando-as no env combinado.

  7. Devolve o resultado da última expressão avaliada.

Há um par de funções simples após evaluate em lis.py: run lê um programa Scheme completo e o executa, e main chama run ou repl, dependendo da linha de comando—parecido com o modo como o Python faz. Não vou descrever essas funções, pois não há nada novo ali. Meus objetivos aqui eram compartilhar com vocês a beleza do pequeno interpretador de Norvig, explicar melhor como as clausuras funcionam, e mostrar como match/case foi uma ótima adição ao Python.

Para fechar essa seção estendida sobre pattern matching, vamos formalizar o conceito de um OR-pattern (padrão-OU).

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 avaliando números é 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.

Warning

No contexto de uma cláusula case, o operador | tem um significado especial. Ele não aciona o método especial __or__, que manipula expressões como a | b em outros contextos, onde ele é sobrecarregado para realizar operações como união de conjuntos ou disjunção binária com inteiros (o "ou binário"), dependendo dos operandos.

Um OR-pattern não está limitado a aparecer no nível superior de um padrão. | pode também ser usado em sub-padrões. Por exemplo, se quiséssemos que o lis.py aceitasse a letra grega λ (lambda)[12] 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.

Faça isso, então aquilo: os blocos else além do if

Isso não é segredo, mas é um recurso pouco conhecido em Python: a cláusula else pode ser usada não apenas com instruções if, mas também com as instruções for, while, e try.

A semântica para for/else, while/else, e try/else é semelhante, mas é muito diferente do if/else. No início, a palavra else na verdade atrapalhou meu entendimento desses recursos, mas no fim acabei me acostumando.

Aqui estão as regras:

for

O bloco else será executado apenas se e quando o loop for rodar até o fim (isto é, não rodará se o for for interrompido com um break).

while

O bloco else será executado apenas se e quando o loop while terminar pela condição se tornar falsa (novamente, não rodará se o while for interrompido por um break)

try

O bloco else será executado apenas se nenhuma exceção for gerada no bloco try. A documentação oficial também afirma: "Exceções na cláusula else não são tratadas pela cláusula except precedente."

Em todos os casos, a cláusula else também será ignorada se uma exceção ou uma instrução return, break ou continue fizer com que o fluxo de controle saia do bloco principal da instrução composta. No caso do try, esta é a diferença importante entre else e finally: o bloco finally será executado sempre, ocorrendo ou não uma exceção, e até mesmo se o fluxo de execução sair do bloco try por uma instrução como return.

Note

Não tenho nada contra o funcionamento dessas cláusulas else, mas do ponto de vista do design da linguagem, a palavra else foi uma escolha infeliz; else implica em uma alternativa excludente, como em "Execute esse loop, caso contrário faça aquilo." Mas a semântica do else em loops é o oposto: "Execute esse loop, então faça aquilo." Isso sugere que then ("então") seria uma escolha melhor. Também faria sentido no contexto de um try: "Tente isso, então faça aquilo." Entretanto, acrescentar uma nova palavra reservada é uma ruptura séria em uma linguagem—uma decisão muito difícil. Guido sempre foi econômico com palavras reservadas.

Usar else com essas instruções muitas vezes torna o código mais fácil de ler e evita o transtorno de configurar flags de controle ou acrescentar instruções if extras ao código.

O uso de else em loops em geral segue o padrão desse trecho:

for item in my_list:
    if item.flavor == 'banana':
        break
else:
    raise ValueError('No banana flavor found!')

No caso de blocos try/except, o else pode parecer redundante à primeira vista. Afinal, a after_call() no trecho a seguir só será executado se a dangerous_call() não gerar uma exceção, correto?

try:
    dangerous_call()
    after_call()
except OSError:
    log('OSError...')

Entretanto, isso coloca a after_call() dentro do bloco try sem um bom motivo. Por clareza e correção, o corpo de um bloco try deveria conter apenas instruções que podem gerar as exceções esperadas. Isso é melhor:

try:
    dangerous_call()
except OSError:
    log('OSError...')
else:
    after_call()

Agora fica claro que o bloco try está de guarda contra possíveis erros na dangerous_call(), e não em after_call(). Também fica explícito que after_call() só será executada se nenhuma exceção for gerada no bloco try.

Em Python, try/except é frequentemene usado para controle de fluxo, não apenas para tratamento de erro. Há inclusive um acrônimo/slogan para isso, documentado no glossário oficial do Python:

EAFP

Iniciais da expressão em inglês “easier to ask for forgiveness than permission” que significa “é mais fácil pedir perdão que permissão”. Este estilo de codificação comum em Python assume a existência de chaves ou atributos válidos e captura exceções caso essa premissa se prove falsa. Este estilo limpo e rápido se caracteriza pela presença de várias instruções try e except. A técnica diverge do estilo LBYL, comum em outras linguagens como C, por exemplo.

O glossário então define LBYL:

LBYL

Iniciais da expressão em inglês “look before you leap”, que significa algo como “olhe antes de pisar”[NT: ou "olhe antes de pular"]. Este estilo de codificação testa as pré-condições explicitamente antes de fazer chamadas ou buscas. Este estilo contrasta com a abordagem EAFP e é caracterizada pela presença de muitas instruções if. Em um ambiente multithread, a abordagem LBYL pode arriscar a introdução de uma condição de corrida entre “o olhar” e “o pisar”. Por exemplo, o código if key in mapping: return mapping[key] pode falhar se outra thread remover key do mapping após o teste, mas antes da olhada. Esse problema pode ser resolvido com bloqueios [travas] ou usando a abordagem EAFP.

Dado o estilo EAFP, faz mais sentido conhecer e usar os blocos else corretamente nas instruções try/except.

Note

Quando a [inclusão da] instrução match foi discutida, algumas pessoas (eu incluído) acharam que ela também devia ter uma cláusula else. No fim ficou decidido que isso não era necessário, pois case _: tem o mesmo efeito.[13]

Agora vamos resumir o capítulo.

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.[14]

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.

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".[15] Os gerenciadores de contexto foram introduzidos na PEP 343—The "with" Statement (EN).

Raymond Hettinger apontou a instrução with como um "recurso maravilhoso da linguagem" em sua palestra de abertura da PyCon US 2013 (EN). Ele também mostrou alguns usos interessantes de gerenciadores de contexto em sua apresentação "Transforming Code into Beautiful, Idiomatic Python" ("Transformando Código em Lindo Python Idiomático") (EN), na mesma conferência.

O post de Jeff Preshing em seu blog, "The Python 'with' Statement by Example" "A Instrução 'with' do Python através de Exemplos"(EN) é interessante pelos exemplos de uso de gerenciadores de contexto com a biblioteca gráfica pycairo.

A classe contextlib.ExitStack foi baseada em uma ideia original de Nikolaus Rath, que escreveu um post curto explicando porque ela é útil: "On the Beauty of Python’s ExitStack" "Sobre a Beleza do ExitStack do Python". No texto, Rath propõe que ExitStack é similar, mas mais flexível que a instrução defer em Go—que acho uma das melhores ideias naquela linguagem.

Beazley and Jones desenvolveram gerenciadores de contexto para propósitos muito diferentes em seu livro, Python Cookbook, (EN) 3rd ed. A "Recipe 8.3. Making Objects Support the Context-Management Protocol" (Receita 8.3. Fazendo Objetos Suportarem o Protocolo Gerenciador de Contexto) implementa uma classe LazyConnection, cujas instâncias são gerenciadores de contexto que abrem e fecham conexões de rede automaticamente, em blocos with. A "Recipe 9.22. Defining Context Managers the Easy Way" (Receita 9.22. O Jeito Fácil de Definir Gerenciadores de Contexto) introduz um gerenciador de contexto para código de cronometragem, e outro para realizar mudanças transacionais em um objeto list: dentro do bloco with é criada um cópia funcional da instância de list, e todas as mudanças são aplicadas àquela cópia funcional. Apenas quando o bloco with termina sem uma exceção a cópia funcional substitui a original. Simples e genial.

Peter Norvig descreve seu pequeno interpretador Scheme nos posts "(How to Write a (Lisp) Interpreter (in Python))" "(_Como Escrever um Interpretador (Lisp) (em Python))_" (EN) e "(An ((Even Better) Lisp) Interpreter (in Python))" "_(Um Interpretador (Lisp (Ainda Melhor)) (em Python))_" (EN). O código-fonte de lis.py e lispy.py está no repositório norvig/pytudes. Meu repositório, fluentpython/lispy, inclui a versão mylis do lis.py, atualizado para o Python 3.10, com um REPL melhor, integraçào com a linha de comando, exemplos, mais testes e referências para aprender mais sobre Scheme. O melhor ambiente e dialeto de Scheme para aprender e experimentar é o Racket.

Ponto de vista

Fatorando o pão

Em sua palestra de abertura na PyCon US 2013, "What Makes Python Awesome" ("O que torna o Python incrível"), Raymond Hettinger diz que quando viu a proposta da instrução with, pensou que era "um pouquinho misteriosa." Inicialmente tive uma reação similar. As PEPs são muitas vezes difíceis de ler, e a PEP 343 é típica nesse sentido.

Mas aí—​nos contou Hettinger—​ele teve uma ideia: as sub-rotinas são a invenção mais importante na história das linguagens de computador. Se você tem sequências de operações, como A;B;C e P;B;Q, você pode fatorar B em uma sub-rotina. É como fatorar o recheio de um sanduíche: usar atum com tipos de diferentes de pão. Mas e se você quiser fatorar o pão, para fazer sanduíches com pão de trigo integral usando recheios diferentes a cada vez? É isso que a instrução with oferece. Ela é o complemento da sub-rotina. Hettinger continuou:

A instrução with é algo muito importante. Encorajo vocês a irem lá e olharem para a ponta desse iceberg, e daí cavarem mais fundo. Provavelmente é possível fazer coisas muito profundas com a instrução with. Seus melhores usos ainda estão por ser descobertos. Espero que, se vocês fizerem bom uso dela, ela será copiada para outras linguagens, e todas as linguagens futuras vão incluí-la. Vocês podem ser parte da descoberta de algo quase tão profundo quanto a invenção da própria sub-rotina.

Hettinger admite que está tentando muito vender a instrução with. Mesmo assim, é um recurso bem útil. Quando ele usou a analogia do sanduíche para explicar como with é o complemento da sub-rotina, muitas possibilidades se abriram na minha mente.

Se você precisa convencer alguém que o Python é maravilhoso, assista a palestra de abertura de Hettinger. A parte sobre gerenciadores de contexto fica entre 23:00 to 26:15. Mas a palestra inteira é excelente.

Recursão eficiente com chamadas de cauda apropriadas

As implementações padrão de Scheme são obrigadas a oferecer chamadas de cauda apropriadas (PTC, sigla em inglês para proper tail calls), para tornar a iteração por recursão uma alternativa prática aos loops while das linguagens imperativas. Alguns autores se referem às PTC como otimização de chamadas de cauda (TCO, sigla em inglês para tail call optimization); para outros, TCO é uma coisa diferente. Para mais detalhes, leia "Chamadas recursivas de cauda na Wikipedia em português e "Tail call" (EN), mais aprofundado, na Wikipedia em inglês, e "Tail call optimization in ECMAScript 6" (EN).

Uma chamada de cauda é quando uma função devolve o resultado de uma chamada de função, que pode ou não ser a ela mesma (a função que está devolvendo o resultado). Os exemplos gcd no Exemplo 10 e no Exemplo 11 fazem chamadas de cauda (recursivas) no lado falso do if.

Por outro lado, essa factorial não faz uma chamada de cauda:

def factorial(n):
    if n < 2:
       return 1
    return n * factorial(n - 1)

A chamada para factorial na última linha não é uma chamada de cauda, pois o valor de return não é somente o resultado de uma chamada recursiva: o resultado é multiplicado por n antes de ser devolvido.

Aqui está uma alternativa que usa uma chamada de cauda, e é portanto recursiva de cauda:

def factorial_tc(n, product=1):
    if n < 1:
        return product
    return factorial_tc(n - 1, product * n)

O Python não tem PTC então não há vantagem em escrever funções recursivas de cauda. Neste caso, a primeira versão é, na minha opinião, mais curta e mais legível. Para usos na vida real, não se esqueça que o Python tem o math.factorial, escrito em C sem recursão. O ponto é que, mesmo em linguagens que implementam PTC, isso não beneficia toda função recursiva, apenas aquelas cuidadosamente escritas para fazerem chamadas de cauda.

Se PTC são suportadas pela linguagem, quando o interpretador vê uma chamada de cauda, ele pula para dentro do corpo da função chamada sem criar um novo stack frame, economizando memória. Há também linguagens compiladas que implementam PTC, por vezes como uma otimização que pode ser ligada e desligada.

Não existe um consenso universal sobre a definição de TCO ou sobre o valor das PTC em linguagens que não foram projetadas como linguagens funcionais desde o início, como Python e Javascript. Em linguagens funcionais, PTC é um recurso esperado, não apenas uma otimização boa de ter à mão. Se a linguagem não tem outro mecanismo de iteração além da recursão, então PTC é necessário para tornar prático o uso da linguagem. O lis.py de Norvig não implementa PTC, mas seu interpretador mais elaborado, o lispy.py, implementa.

Os argumentos contra chamadas de cauda apropriadas em Python e Javascript

O CPython não implementa PTC, e provavelmente nunca o fará. Guido van Rossum escreveu "Final Words on Tail Calls" ("Últimas Palavras sobre Chamadas de Cauda") para explicar o motivo. Resumindo, aqui está uma passagem fundamental de seu post:

Pessoalmente, acho que é um bom recurso para algumas linguagens, mas não acho que se encaixe no Python: a eliminação dos registros do stack para algumas chamadas mas não para outras certamente confundiria muitos usuários, que não foram criados na religião das chamadas de cauda, mas podem ter aprendido sobre a semântica das chamadas restreando algumas chamadas em um depurador.

Em 2015, PTC foram incluídas no padrão ECMAScript 6 para JavaScript. Em outubro de 2021 o interpretador no WebKit as implementa (EN). O WebKit é usado pelo Safari. Os interpretadores JS em todos os outros navegadores populares não tem PTC, assim como o Node.js, que depende da engine V8 que o Google mantém para o Chrome. Transpiladores e polyfills (injetores de código) voltados para o JS, como o TypeScript, o ClojureScript e o Babel, também não suportam PTC, de acordo com essa " Tabela de compatibilidade com ECMAScript 6" (EN).

Já vi várias explicações para a rejeição das PTC por parte dos implementadores, mas a mais comum é a mesma que Guido van Rossum mencionou: PTC tornam a depuração mais difícil para todo mundo, e beneficiam apenas uma minoria que prefere usar recursão para fazer iteração. Para mais detalhes, veja "What happened to proper tail calls in JavaScript?" "O que aconteceu com as chamadas de cauda apropriadas em Javascript?" de Graham Marlow.

Há casos em que a recursão é a melhor solução, mesmo no Python sem PTC. Em um post anterior sobre o assunto, Guido escreveu:

[…​] uma implementação típica de Python permite 1000 recursões, o que é bastante para código não-recursivo e para código que usa recursão para atravessar, por exemplo, um árvore de parsing típica, mas não o bastante para um loop escrito de forma recursiva sobre uma lista grande.

Concordo com Guido e com a maioria dos implementadores de Javascript. A falta de PTC é a maior restrição ao desenvolvimento de programas Python em um estilo funcional—mais que a sintaxe limitada de lambda.

Se você estiver curioso em ver como PTC funciona em um interpretador com menos recursos (e menos código) que o lispy.py de Norvig, veja o mylis_2. O truque é iniciar com o loop infinito em evaluate e o código no case para chamadas de função: essa combinação faz o interpretador pular para dentro do corpo da próxima Procedure sem chamar evaluate recursivamente durante a chamada de cauda. Esses pequenos interpretadores demonstram o poder da abstração: apesar do Python não implementar PTC, é possível e não muito difícil escrever um interpretador, em Python, que implementa PTC. Aprendi a fazer isso lendo o código de Peter Norvig. Obrigado por compartilhar, professor!

A opinião de Norvig sobre evaluate() com pattern matching

Eu compartilhei o código da versão Python 3.10 de lis.py com Peter Norvig. Ele gostou do exemplo usando pattern matching, mas sugeriu uma solução diferente: em vez de usar os guardas que escrevi, ele teria exatamente um case por palavra reservada, e teria testes dentro de cada case, para fornecer mensagens de SyntaxError mais específicas—por exemplo, quando o corpo estiver vazio. Isso também tornaria o guarda em case [func_exp, *args] if func_exp not in KEYWORDS: desnecessário, pois todas as palavras reservadas teriam sido tratadas antes do case para chamadas de função.

Provavelmente seguirei o conselho do professor Norvig quando acrescentar mais funcionalidades ao mylis. Mas a forma como estruturei evaluate no Exemplo 17 tem algumas vantagens didáticas nesse livro: o exemplo é paralelo à implementação com if/elif/… ([ex_norvigs_eval]), as cláusulas case demonstram mais recursos de pattern matching e o código é mais conciso.


1. Palestra de abertura da PyCon US 2013: "What Makes Python Awesome" ("O que torna o Python incrível"); a parte sobre with começa em 23:00 e termina em 26:15.
2. Os três argumentos recebidos por self são exatamente o que você obtém se chama sys.exc_info() no bloco finally de uma instrução try/finally. Isso faz sentido, considerando que a instrução with tem por objetivo substituir a maioria dos usos de try/finally, e chamar sys.exc_info() é muitas vezes necessário para determinar que ação de limpeza é necessária.
3. A classe real se chama _GeneratorContextManager. Se você quiser saber exatamente como ela funciona, leia seu código fonte na Lib/contextlib.py do Python 3.10.
4. Essa dica é uma citação literal de um comentário de Leonardo Rochael, um do revisores técnicos desse livro. Muito bem dito, Leo!
5. "Pouco conhecido" porque pelo menos eu e os outros revisores técnicos não sabíamos disso até Caleb Hattingh nos contar. Obrigado, Caleb!
6. As pessoas reclamam sobre o excesso de parênteses no Lisp, mas uma indentação bem pensada e um bom editor praticamente resolvem essa questão. O maior problema de legibilidade é o uso da mesma notação (f …​) para chamadas de função e formas especiais como (define …​), (if …​) e (quote …​), que de forma alguma se comportam como chamadas de função
7. Para tornar a iteração por recursão prática e eficiente, o Scheme e outras linguagens funcionais implementam chamadas de cauda apropriadas (ou otimizadas). Para ler mais sobre isso, veja o Ponto de vista.
8. Mas o segundo interpretador de Norvig, lispy.py, suporta strings como um tipo de dado, e também traz recursos avançados como macros sintáticas, continuações, e chamadas de cauda otimizadas. Entretanto, o lispy.py é quase três vezes maior que o lis.py—é muito mais difícil de entender.
9. O comentário # type: ignore[index] está ali por causa do issue #6042 no typeshed, que segue sem resolução quando esse capítulo está sendo revisado. ChainMap é anotado como MutableMapping, mas a dica de tipo no atributo maps diz que ele é uma lista de Mapping, indiretamente tornando todo o ChainMap imutável até onde o Mypy entende.
10. Enquanto estudava o lis.py e o lispy.py de Norvig, comecei uma versão chamada mylis, que acrescenta alguns recursos, incluindo um REPL que aceita expressões-S parciais e espera a continuação, como o REPL do Python sabe que não terminamos e apresenta um prompt secundário (…​) até entrarmos uma expressão ou instrução completa, que possa ser analisada e avaliada. O mylis também trata alguns erros de forma graciosa, mas ele ainda é fácil de quebrar. Não é nem de longe tão robusto quanto o REPL do Python.
11. A atribuição é um dos primeiros recursos ensinados em muitos tutoriais de programacão, mas set! só aparece na página 220 do mais conhecido livro de Scheme, Structure and Interpretation of Computer Programs (A Estrutura e a Interpretação de Programas de Computador), 2nd ed., de Abelson et al. (MIT Press), também conhecido como SICP ou "Wizard Book" (Livro do Mago). Programas em estilo funcional podem nos levar muito longe sem as mudanças de estado típicas da programação imperativa e da programação orientada a objetos.
12. O nome Unicode oficial para λ (U+03BB) é GREEK SMALL LETTER LAMDA. Isso não é um erro ortográfico: o caractere é chamado "lamda" sem o "b" no banco de dados do Unicode. De acordo com o artigo "Lambda" (EN) da Wikipedia em inglês, o Unicode Consortium adotou essa forma em função de "preferências expressas pela Autoridade Nacional Grega."
13. Acompanhando a discussão na lista python-dev, achei que uma razão para a rejeição do else foi a falta de consenso sobre como indentá-lo dentro do match: o else deveria ser indentedo no mesmo nível do match ou no mesmo nível do case?
15. NT:No momento em que essa tradução é feita, o título dessa seção na documentação diz "Com gerenciadores de contexto de instruções", uma frase que sequer faz sentido. Foi aberto um issue sobre isso.