segunda-feira, 11 de julho de 2022

Iteradores avançados em Python

❝ Grandes pulgas têm pequenas pulgas nas costas para mordê-las, E as pulgas pequenas têm pulgas menores, e assim ad infinitum. ❞
— Augusto De Morgan

Mergulho

Assim como expressões regulares colocam esteróides em strings, o módulo itertools coloca esteróides nos iteradores. Mas primeiro, quero mostrar um quebra-cabeça clássico.

HAWAII + IDAHO + IOWA + OHIO == STATES
510199 + 98153 + 9301 + 3593 == 621246

H = 5
A = 1
W = 0
I = 9
D = 8
O = 3
S = 6
T = 2
E = 4

Quebra-cabeças como esse são chamados de criptaritmos ou alfaméticos. As letras formam palavras reais, mas se você substituir cada letra por um dígito de 0 a 9, ela também “soletra” uma equação aritmética. O truque é descobrir qual letra mapeia para cada dígito. Todas as ocorrências de cada letra devem ser mapeadas para o mesmo dígito, nenhum dígito pode ser repetido e nenhuma “palavra” pode começar com o dígito 0.

Neste capítulo, vamos mergulhar em um incrível programa Python originalmente escrito por Raymond Hettinger. Este programa resolve quebra-cabeças alfaméticos em apenas 14 linhas de código.

O quebra-cabeça alfamético mais conhecido é SEND + MORE = MONEY.

import re
import itertools

def solve(puzzle):
    words = re.findall('[A-Z]+', puzzle.upper())
    unique_characters = set(''.join(words))
    assert len(unique_characters) <= 10, 'Too many letters'
    first_letters = {word[0] for word in words}
    n = len(first_letters)
    sorted_characters = ''.join(first_letters) + \
        ''.join(unique_characters - first_letters)
    characters = tuple(ord(c) for c in sorted_characters)
    digits = tuple(ord(c) for c in '0123456789')
    zero = digits[0]
    for guess in itertools.permutations(digits, len(characters)):
        if zero not in guess[:n]:
            equation = puzzle.translate(dict(zip(characters, guess)))
            if eval(equation):
                return equation

if __name__ == '__main__':
    import sys
    for puzzle in sys.argv[1:]:
        print(puzzle)
        solution = solve(puzzle)
        if solution:
            print(solution)

Você pode executar o programa a partir da linha de comando. No Linux, ficaria assim. (Isso pode levar algum tempo, dependendo da velocidade do seu computador, e não há barra de progresso. Apenas seja paciente!)

you@localhost:~/diveintopython3/examples$ python3 alphametics.py "HAWAII + IDAHO + IOWA + OHIO == STATES"
HAWAII + IDAHO + IOWA + OHIO = STATES
510199 + 98153 + 9301 + 3593 == 621246
you@localhost:~/diveintopython3/examples$ python3 alphametics.py "I + LOVE + YOU == DORA"
I + LOVE + YOU == DORA
1 + 2784 + 975 == 3760
you@localhost:~/diveintopython3/examples$ python3 alphametics.py "SEND + MORE == MONEY"
SEND + MORE == MONEY
9567 + 1085 == 10652

Encontrando todas as ocorrências de um padrão

A primeira coisa que esse solucionador alfamético faz é encontrar todas as letras (A–Z) no quebra-cabeça.

>>> import re
>>> re.findall('[0-9]+', '16 2-by-4s in rows of 8')  ①
['16', '2', '4', '8']
>>> re.findall('[A-Z]+', 'SEND + MORE == MONEY')     ②
['SEND', 'MORE', 'MONEY']
  1. O módulo re é a implementação de expressões regulares do Python. Ele tem uma função bacana chamada findall() que pega um padrão de expressão regular e uma string e encontra todas as ocorrências do padrão dentro da string. Nesse caso, o padrão corresponde a sequências de números. A função findall() retorna uma lista de todas as substrings que corresponderam ao padrão.
  2. Aqui o padrão de expressão regular corresponde a sequências de letras. Novamente, o valor de retorno é uma lista e cada item na lista é uma string que corresponde ao padrão de expressão regular.

Aqui está outro exemplo que vai esticar um pouco o seu cérebro.

>>> re.findall(' s.*? s', "The sixth sick sheikh's sixth sheep's sick.")
[' sixth s', " sheikh's s", " sheep's s"]

Surpreso? A expressão regular procura um espaço, um s e, em seguida, a série mais curta possível de qualquer caractere (.*?), um espaço e outro s. Bem, olhando para essa string de entrada, vejo cinco correspondências:

  1. The sixth sick sheikh's sixth sheep's sick.
  2. The sixth sick sheikh's sixth sheep's sick.
  3. The sixth sick sheikh's sixth sheep's sick.
  4. The sixth sick sheikh's sixth sheep's sick.
  5. The sixth sick sheikh's sixth sheep's sick.

Este é o trava-línguas mais difícil do idioma inglês.

Mas a função re.findall() retornou apenas três correspondências. Especificamente, ele retornou o primeiro, o terceiro e o quinto. Por que isso? Porque não retorna correspondências sobrepostas. A primeira correspondência se sobrepõe à segunda, de modo que a primeira é retornada e a segunda é ignorada. Em seguida, o terceiro se sobrepõe a quarta, de modo que a terceira é retornada e a quarta é ignorada. Finalmente, a quinta é retornada. Três corresponências, não cinco.

Isso não tem nada a ver com o solucionador alfamético; Eu só pensei que isso era interessante.

Encontrando os itens únicos em uma lista do Python

Os conjuntos do python tornam trivial encontrar os itens exclusivos em uma lista do python.


>>> a_list = ['The', 'sixth', 'sick', "sheik's", 'sixth', "sheep's", 'sick']
>>> set(a_list)                      ①
{'sixth', 'The', "sheep's", 'sick', "sheik's"}
>>> a_string = 'EAST IS EAST'
>>> set(a_string)                    ②
{'A', ' ', 'E', 'I', 'S', 'T'}
>>> words = ['SEND', 'MORE', 'MONEY']
>>> ''.join(words)                   ③
'SENDMOREMONEY'
>>> set(''.join(words))              ④
{'E', 'D', 'M', 'O', 'N', 'S', 'R', 'Y'}
  1. Dada uma lista de várias strings, a função set() retornará um conjunto de strings únicas da lista. Isso faz sentido se você pensar nisso como um loop for. Pegue o primeiro item da lista, coloque-o no conjunto. Segundo. Terceiro. Quarto. Quinto – espere, isso já está no conjunto, então só é listado uma vez, porque os conjuntos do Python não permitem duplicatas. Sexto. Sétimo – novamente, uma duplicata, então só é listada uma vez. O resultado final? Todos os itens exclusivos da lista original, sem duplicatas. A lista original nem precisa ser classificada primeiro.
  2. A mesma técnica funciona com strings, pois uma string é apenas uma sequência de caracteres.
  3. Dada uma lista de strings, ''.join(a_list) concatena todas as strings em uma.
  4. Assim, dada uma lista de strings, esta linha de código retorna todos os caracteres únicos em todas as strings, sem duplicatas.

O solucionador alfamético usa essa técnica para construir um conjunto de todos os caracteres únicos do quebra-cabeça.

unique_characters = set(''.join(words))

Essa lista é usada posteriormente para atribuir dígitos a caracteres à medida que o solucionador percorre as soluções possíveis.

Fazendo afirmações

Como muitas linguagens de programação, Python tem uma declaração assert. Aqui está como funciona.

>>> assert 1 + 1 == 2                                     ①
>>> assert 1 + 1 == 3                                     ②
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
AssertionError
>>> assert 2 + 2 == 5, "Only for very large values of 2"  ③
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
AssertionError: Only for very large values of 2
  1. A declaração assert é seguida por qualquer expressão Python válida. Nesse caso, a expressão 1 + 1 == 2 é avaliada como True, portanto, a instrução assert não faz nada.
  2. No entanto, se a expressão Python for avaliada como False, a instrução assert gerará um AssertionError.
  3. Você também pode incluir uma mensagem legível que é impressa se o AssertionError for gerado.

Portanto, esta linha de código:

assert len(unique_characters) <= 10, 'Too many letters'

…é equivalente a isso:

if len(unique_characters) > 10:
   raise AssertionError('Too many letters')

O solucionador alfamético usa essa declaração assert exata para sair mais cedo se o quebra-cabeça contiver mais de dez letras únicas. Como a cada letra é atribuído um dígito único e existem apenas dez dígitos, um quebra-cabeça com mais de dez letras únicas não pode ter uma solução.

Expressões do gerador

Uma expressão geradora é como uma função geradora sem a função.

>>> unique_characters = {'E', 'D', 'M', 'O', 'N', 'S', 'R', 'Y'}
>>> gen = (ord(c) for c in unique_characters)  ①
>>> gen                                        ②
<generator object <genexpr> at 0x00BADC10>
>>> next(gen)                                  ③
69
>>> next(gen)
68
>>> tuple(ord(c) for c in unique_characters)   ④
(69, 68, 77, 79, 78, 83, 82, 89)
    1. Uma expressão geradora é como uma função anônima que produz valores. A expressão em si parece uma compreensão de lista, mas está entre parênteses em vez de colchetes.
    2. A expressão geradora retorna… um iterador.
    3. Chamar next(gen) retorna o próximo valor do iterador.
    4. Se desejar, você pode percorrer todos os valores possíveis e retornar uma tupla, lista ou conjunto, passando a expressão geradora para tuple(), list() ou set(). Nesses casos, você não precisa de um conjunto extra de parênteses — apenas passe a expressão “nua” ord(c) para c em unique_characters para a função tuple(), e o Python descobre que é uma expressão geradora.

Observação

Usar uma expressão geradora em vez de uma compreensão de lista pode economizar CPU e RAM. Se você está construindo uma lista apenas para jogá-la fora (por exemplo, passando para tuple() ou set()), use uma expressão geradora!

Aqui está outra maneira de fazer a mesma coisa, usando uma função de gerador:

def ord_map(a_string):
    for c in a_string:
        yield ord(c)

gen = ord_map(unique_characters)

A expressão do gerador é mais compacta, mas funcionalmente equivalente.

Calculando Permutações… A Maneira Preguiçosa!

Em primeiro lugar, o que diabos são permutações? Permutações são um conceito matemático. (Na verdade, existem várias definições, dependendo do tipo de matemática que você está fazendo. Aqui estou falando de combinatória, mas se isso não significa nada para você, não se preocupe. Como sempre, a Wikipedia é sua amigo.)

A ideia é que você pegue uma lista de coisas (podem ser números, podem ser letras, podem ser ursos dançantes) e encontrar todas as maneiras possíveis de dividi-las em listas menores. Todas as listas menores têm o mesmo tamanho, que pode ser tão pequeno quanto 1 e tão grande quanto o número total de itens. Ah, e nada pode ser repetido. Os matemáticos dizem coisas como “vamos encontrar as permutações de 3 itens diferentes tomados 2 de cada vez”, o que significa que você tem uma sequência de 3 itens e deseja encontrar todos os pares ordenados possíveis.

>>> import itertools                              ①
>>> perms = itertools.permutations([1, 2, 3], 2)  ②
>>> next(perms)                                   ③
(1, 2)
>>> next(perms)
(1, 3)
>>> next(perms)
(2, 1)                                            ④
>>> next(perms)
(2, 3)
>>> next(perms)
(3, 1)
>>> next(perms)
(3, 2)
>>> next(perms)                                   ⑤
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
StopIteration
  1. O módulo itertools tem todos os tipos de coisas divertidas nele, incluindo uma função permutations() que faz todo o trabalho duro de encontrar permutações.
  2. A função permutations() recebe uma sequência (aqui uma lista de três inteiros) e um número, que é o número de itens que você deseja em cada grupo menor. A função retorna um iterador, que você pode usar em um loop for ou em qualquer lugar antigo que itere. Aqui vou percorrer o iterador manualmente para mostrar todos os valores.
  3. A primeira permutação de [1, 2, 3] pegando 2 de cada vez é (1, 2).
  4. Observe que as permutações são ordenadas: (2, 1) é diferente de (1, 2).
  5. É isso! Essas são todas as permutações de [1, 2, 3] pegando 2 de cada vez. Pares como (1, 1) e (2, 2) nunca aparecem, porque contêm repetições, então não são permutações válidas. Quando não há mais permutações, o iterador gera uma exceção StopIteration.

A função permutations() não precisa receber uma lista. Pode levar qualquer sequência – até mesmo uma string.

>>> import itertools
>>> perms = itertools.permutations('ABC', 3)  ①
>>> next(perms)
('A', 'B', 'C')                               ②
>>> next(perms)
('A', 'C', 'B')
>>> next(perms)
('B', 'A', 'C')
>>> next(perms)
('B', 'C', 'A')
>>> next(perms)
('C', 'A', 'B')
>>> next(perms)
('C', 'B', 'A')
>>> next(perms)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
StopIteration
>>> list(itertools.permutations('ABC', 3))    ③
[('A', 'B', 'C'), ('A', 'C', 'B'),
 ('B', 'A', 'C'), ('B', 'C', 'A'),
 ('C', 'A', 'B'), ('C', 'B', 'A')]

O módulo itertools tem todos os tipos de coisas divertidas.

  1. Uma string é apenas uma sequência de caracteres. Para fins de encontrar permutações, a string 'ABC' é equivalente à lista ['A', 'B', 'C'].
  2. A primeira permutação dos 3 itens ['A', 'B', 'C'], pegando 3 de cada vez, é ('A', 'B', 'C'). Existem cinco outras permutações – os mesmos três caracteres em todas as ordens concebíveis.
  3. Como a função permutations() sempre retorna um iterador, uma maneira fácil de depurar permutações é passar esse iterador para a função list() integrada para ver todas as permutações imediatamente.

Outras coisas divertidas no módulo itertools

>>> import itertools
>>> list(itertools.product('ABC', '123'))   ①
[('A', '1'), ('A', '2'), ('A', '3'), 
 ('B', '1'), ('B', '2'), ('B', '3'), 
 ('C', '1'), ('C', '2'), ('C', '3')]
>>> list(itertools.combinations('ABC', 2))  ②
[('A', 'B'), ('A', 'C'), ('B', 'C')]
  1. A função itertools.product() retorna um iterador contendo o produto cartesiano de duas sequências.
  2. A função itertools.combinations() retorna um iterador contendo todas as combinações possíveis da sequência dada do comprimento dado. É como a função itertools.permutations(), exceto que as combinações não incluem itens duplicados de outros itens em uma ordem diferente. Portanto, itertools.permutations('ABC', 2) retornará ('A', 'B') e ('B', 'A') (entre outros), mas itertools.combinations('ABC', 2) não retornará ('B', 'A') porque é uma duplicata de ('A', 'B') em uma ordem diferente.
>>> names = list(open('examples/favorite-people.txt', encoding='utf-8'))  ①
>>> names
['Dora\n', 'Ethan\n', 'Wesley\n', 'John\n', 'Anne\n',
'Mike\n', 'Chris\n', 'Sarah\n', 'Alex\n', 'Lizzie\n']
>>> names = [name.rstrip() for name in names]                             ②
>>> names
['Dora', 'Ethan', 'Wesley', 'John', 'Anne',
'Mike', 'Chris', 'Sarah', 'Alex', 'Lizzie']
>>> names = sorted(names)                                                 ③
>>> names
['Alex', 'Anne', 'Chris', 'Dora', 'Ethan',
'John', 'Lizzie', 'Mike', 'Sarah', 'Wesley']
>>> names = sorted(names, key=len)                                        ④
>>> names
['Alex', 'Anne', 'Dora', 'John', 'Mike',
'Chris', 'Ethan', 'Sarah', 'Lizzie', 'Wesley']
  1. Este idioma retorna uma lista das linhas em um arquivo de texto.
  2. Infelizmente (para este exemplo), o idioma list(open(filename)) também inclui os retornos de carro no final de cada linha. Essa compreensão de lista usa o método de string rstrip() para remover os espaços em branco à direita de cada linha. (Strings também têm um método lstrip() para remover o espaço em branco principal e um método strip() que remove ambos.)
  3. A função sorted() pega uma lista e a retorna ordenada. Por padrão, ele classifica em ordem alfabética.
  4. Mas a função sorted() também pode receber uma função como parâmetro de chave e classifica por essa chave. Nesse caso, a função de classificação é len(), então ela classifica por len(cada item). Nomes mais curtos vêm primeiro, depois mais longos e depois mais longos.

O que isso tem a ver com o módulo itertools? fico feliz que tenha perguntado.

…continuando do shell interativo anterior…

>>> import itertools
>>> groups = itertools.groupby(names, len)  ①
>>> groups
>itertools.groupby object at 0x00BB20C0>
>>> list(groups)
[(4, >itertools._grouper object at 0x00BA8BF0>),
 (5, >itertools._grouper object at 0x00BB4050>),
 (6, >itertools._grouper object at 0x00BB4030>)]
>>> groups = itertools.groupby(names, len)   ②
>>> for name_length, name_iter in groups:    ③
...     print('Names with {0:d} letters:'.format(name_length))
...     for name in name_iter:
...         print(name)
... 
Names with 4 letters:
Alex
Anne
Dora
John
Mike
Names with 5 letters:
Chris
Ethan
Sarah
Names with 6 letters:
Lizzie
Wesley
  1. A função itertools.groupby() recebe uma sequência e uma função de chave e retorna um iterador que gera pares. Cada par contém o resultado de key_function (cada item) e outro iterador contendo todos os itens que compartilharam esse resultado de chave.
  2. Chamar a função list() “esgotou” o iterador, ou seja, você já gerou todos os itens no iterador para fazer a lista. Não há botão “reset” em um iterador; você não pode simplesmente recomeçar depois de esgotar. Se você quiser fazer um loop novamente (digamos, no próximo loop for), você precisa chamar itertools.groupby() novamente para criar um novo iterador.
  3. Neste exemplo, dada uma lista de nomes já ordenados por comprimento, itertools.groupby(names, len) colocará todos os nomes de 4 letras em um iterador, todos os nomes de 5 letras em outro iterador e assim por diante. A função groupby() é completamente genérica; ele pode agrupar strings pela primeira letra, números por seu número de fatores ou qualquer outra função chave que você possa imaginar.

A função itertools.groupby() só funciona se a sequência de entrada já estiver ordenada pela função de agrupamento. No exemplo acima, você agrupou uma lista de nomes pela função len(). Isso só funcionou porque a lista de entrada já estava classificada por comprimento.

Você está observando de perto?

>>> list(range(0, 3))
[0, 1, 2]
>>> list(range(10, 13))
[10, 11, 12]
>>> list(itertools.chain(range(0, 3), range(10, 13)))        ①
[0, 1, 2, 10, 11, 12]
>>> list(zip(range(0, 3), range(10, 13)))                    ②
[(0, 10), (1, 11), (2, 12)]
>>> list(zip(range(0, 3), range(10, 14)))                    ③
[(0, 10), (1, 11), (2, 12)]
>>> list(itertools.zip_longest(range(0, 3), range(10, 14)))  ④
[(0, 10), (1, 11), (2, 12), (None, 13)]
  1. A função itertools.chain() recebe dois iteradores e retorna um iterador que contém todos os itens do primeiro iterador, seguidos por todos os itens do segundo iterador. (Na verdade, pode levar qualquer número de iteradores e encadeia todos eles na ordem em que foram passados para a função.)
  2. A função zip() faz algo prosaico que acaba sendo extremamente útil: pega qualquer número de sequências e retorna um iterador que retorna tuplas dos primeiros itens de cada sequência, depois os segundos itens de cada, depois o terceiro e assim por diante.
  3. A função zip() para no final da sequência mais curta. range(10, 14) tem 4 itens (10, 11, 12 e 13), mas range(0, 3) tem apenas 3, então a função zip() retorna um iterador de 3 itens.
  4. Por outro lado, a função itertools.zip_longest() para no final da sequência mais longa, inserindo valores None para itens após o final das sequências mais curtas.

OK, tudo isso foi muito interessante, mas como isso se relaciona com o solucionador alfamético? Veja como:

>>> characters = ('S', 'M', 'E', 'D', 'O', 'N', 'R', 'Y')
>>> guess = ('1', '2', '0', '3', '4', '5', '6', '7')
>>> tuple(zip(characters, guess))  ①
(('S', '1'), ('M', '2'), ('E', '0'), ('D', '3'),
 ('O', '4'), ('N', '5'), ('R', '6'), ('Y', '7'))
>>> dict(zip(characters, guess))   ②
{'E': '0', 'D': '3', 'M': '2', 'O': '4',
 'N': '5', 'S': '1', 'R': '6', 'Y': '7'}
  1. Dada uma lista de letras e uma lista de dígitos (cada um representado aqui como strings de 1 caractere), a função zip criará um par de letras e dígitos, em ordem.
  2. Por que isso é legal? Porque essa estrutura de dados é exatamente a estrutura certa para passar para a função dict() para criar um dicionário que usa letras como chaves e seus dígitos associados como valores. (Esta não é a única maneira de fazer isso, é claro. Você pode usar uma compreensão de dicionário para criar o dicionário diretamente.) Embora a representação impressa do dicionário liste os pares em uma ordem diferente (os dicionários não têm “ordem” por se), você pode ver que cada letra está associada ao dígito, com base na ordenação dos caracteres originais e nas sequências de adivinhação.

O solucionador alfamético usa essa técnica para criar um dicionário que mapeia letras no quebra-cabeça para dígitos na solução, para cada solução possível.

characters = tuple(ord(c) for c in sorted_characters)
digits = tuple(ord(c) for c in '0123456789')
...
for guess in itertools.permutations(digits, len(characters)):
    ...
    equation = puzzle.translate(dict(zip(characters, guess)))

Mas o que é esse método translate()? Ah, agora você está chegando à parte realmente divertida.

Um novo tipo de manipulação de strings

As strings do Python têm muitos métodos. Você aprendeu sobre alguns desses métodos no capítulo sobre strings do python: lower(), count() e format(). Agora quero apresentar a você uma técnica de manipulação de strings poderosa, mas pouco conhecida: o método translate().

>>> translation_table = {ord('A'): ord('O')}  ①
>>> translation_table                         ②
{65: 79}
>>> 'MARK'.translate(translation_table)       ③
'MORK'
  1. A tradução de strings começa com uma tabela de tradução, que é apenas um dicionário que mapeia um caractere para outro. Na verdade, "caractere" está incorreto - a tabela de tradução realmente mapeia um byte para outro.
  2. Lembre-se, os bytes no Python 3 são inteiros. A função ord() retorna o valor ASCII de um caractere, que, no caso de A–Z, é sempre um byte de 65 a 90.
  3. O método translate() em uma string pega uma tabela de tradução e executa a string nela. Ou seja, ele substitui todas as ocorrências das chaves da tabela de conversão pelos valores correspondentes. Neste caso, “traduzindo” MARK para MORK.

O que isso tem a ver com a resolução de quebra-cabeças alfaméticos? Como se vê, tudo.

>>> characters = tuple(ord(c) for c in 'SMEDONRY')       ①
>>> characters
(83, 77, 69, 68, 79, 78, 82, 89)
>>> guess = tuple(ord(c) for c in '91570682')            ②
>>> guess
(57, 49, 53, 55, 48, 54, 56, 50)
>>> translation_table = dict(zip(characters, guess))     ③
>>> translation_table
{68: 55, 69: 53, 77: 49, 78: 54, 79: 48, 82: 56, 83: 57, 89: 50}
>>> 'SEND + MORE == MONEY'.translate(translation_table)  ④
'9567 + 1085 == 10652'

Agora você está chegando à parte realmente divertida.

  1. Usando uma expressão geradora, calculamos rapidamente os valores de byte para cada caractere em uma string. caracteres é um exemplo do valor de sorted_characters na função alphametics.solve().
  2. Usando outra expressão geradora, calculamos rapidamente os valores de byte para cada dígito nesta string. O resultado, palpite, é da forma retornada pela função itertools.permutations() na função alphametics.solve().
  3. Esta tabela de tradução é gerada compactando caracteres e adivinhando juntos e construindo um dicionário a partir da sequência de pares resultante. Isso é exatamente o que a função alphametics.solve() faz dentro do loop for.
  4. Finalmente, passamos esta tabela de tradução para o método translate() da string original do quebra-cabeça. Isso converte cada letra na string para o dígito correspondente (com base nas letras em caracteres e nos dígitos em palpite). O resultado é uma expressão Python válida, como uma string.

Isso é bem impressionante. Mas o que você pode fazer com uma string que é uma expressão Python válida?

Avaliando strings arbitrárias como expressões Python

Esta é a peça final do quebra-cabeça (ou melhor, a peça final do solucionador de quebra-cabeças). Depois de toda essa manipulação sofisticada de strings, ficamos com uma string como '9567 + 1085 == 10652'. Mas isso é uma string, e para que serve uma string? Digite eval(), a ferramenta de avaliação universal do Python.

>>> eval('1 + 1 == 2')
True
>>> eval('1 + 1 == 3')
False
>>> eval('9567 + 1085 == 10652')
True

Mas espere, tem mais! A função eval() não se limita a expressões booleanas. Ele pode lidar com qualquer expressão Python e retornar qualquer tipo de dados.

>>> eval('"A" + "B"')
'AB'
>>> eval('"MARK".translate({65: 79})')
'MORK'
>>> eval('"AAAAA".count("A")')
5
>>> eval('["*"] * 5')
['*', '*', '*', '*', '*']

Mas espere, isso não é tudo!

>>> x = 5
>>> eval("x * 5")         ①
25
>>> eval("pow(x, 2)")     ②
25
>>> import math
>>> eval("math.sqrt(x)")  ③
2.2360679774997898
  1. A expressão que eval() usa pode referenciar variáveis globais definidas fora de eval(). Se chamado dentro de uma função, também pode referenciar variáveis locais.
  2. E funções.
  3. E módulos.

Ei, espere um minuto...

>>> import subprocess
>>> eval("subprocess.getoutput('ls ~')")                  ①
'Desktop         Library         Pictures \
 Documents       Movies          Public   \
 Music           Sites'
>>> eval("subprocess.getoutput('rm /some/random/file')")  ②
  1. O módulo subprocess permite que você execute comandos shell arbitrários e obtenha o resultado como uma string Python.
  2. Comandos shell arbitrários podem ter consequências permanentes.

É ainda pior do que isso, porque há uma função global __import__() que recebe um nome de módulo como uma string, importa o módulo e retorna uma referência a ele. Combinado com o poder de eval(), você pode construir uma única expressão que eliminará todos os seus arquivos:

>>> eval("__import__('subprocess').getoutput('rm /some/random/file')") ①
  1. Agora imagine a saída de 'rm -rf ~'. Na verdade, não haveria saída, mas você também não teria nenhum arquivo.

eval() é do mal

Bem, a parte ruim é avaliar expressões arbitrárias de fontes não confiáveis. Você só deve usar eval() na entrada confiável. Claro, o truque é descobrir o que é “confiável”. Mas aqui está algo que eu sei com certeza: você NÃO deve pegar este solucionador de alfamética e colocá-lo na internet como um pequeno serviço web divertido. Não cometa o erro de pensar: “Nossa, a função faz muita manipulação de strings antes de obter uma string para avaliar; Não consigo imaginar como alguém poderia explorar isso.” Alguém VAI descobrir como esconder código executável desagradável por toda essa manipulação de strings (coisas mais estranhas aconteceram), e então você pode dar adeus ao seu servidor.

Mas certamente há alguma maneira de avaliar expressões com segurança? Para colocar eval() em uma sandbox onde não pode acessar ou prejudicar o mundo exterior? Bem, sim e não.

>>> x = 5
>>> eval("x * 5", {}, {})               ①
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "<string>", line 1, in <module>
NameError: name 'x' is not defined
>>> eval("x * 5", {"x": x}, {})         ②
25
>>> import math
>>> eval("math.sqrt(x)", {"x": x}, {})  ③
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "<string>", line 1, in <module>
NameError: name 'math' is not defined
  1. O segundo e o terceiro parâmetros passados para a função eval() atuam como namespaces globais e locais para avaliar a expressão. Nesse caso, ambos estão vazios, o que significa que quando a string "x * 5" é avaliada, não há referência a x no namespace global ou local, então eval() lança uma exceção.
  2. Você pode incluir seletivamente valores específicos no namespace global listando-os individualmente. Então essas — e apenas essas — variáveis estarão disponíveis durante a avaliação.
  3. Mesmo que você tenha importado o módulo math, você não o incluiu no namespace passado para a função eval(), então a avaliação falhou.

Puxa, isso foi fácil. Deixe-me fazer um web service alfamético agora!

>>> eval("pow(5, 2)", {}, {})                   ①
25
>>> eval("__import__('math').sqrt(5)", {}, {})  ②
2.2360679774997898
  1. Mesmo que você tenha passado dicionários vazios para os namespaces globais e locais, todas as funções internas do Python ainda estão disponíveis durante a avaliação. Então pow(5, 2) funciona, porque 5 e 2 são literais, e pow() é uma função embutida.
  2. Infelizmente (e se você não vê por que é lamentável, continue lendo), a função __import__() também é uma função embutida, então também funciona.

Sim, isso significa que você ainda pode fazer coisas desagradáveis, mesmo se você definir explicitamente os namespaces globais e locais para dicionários vazios ao chamar eval():

>>> eval("__import__('subprocess').getoutput('rm /some/random/file')", {}, {})

Ops. Estou feliz por não ter feito esse serviço da web alphametics. Existe alguma maneira de usar eval() com segurança? Bem, sim e não.

>>> eval("__import__('math').sqrt(5)",
...     {"__builtins__":None}, {})          ①
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "<string>", line 1, in <module>
NameError: name '__import__' is not defined
>>> eval("__import__('subprocess').getoutput('rm -rf /')",
...     {"__builtins__":None}, {})          ②
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "<string>", line 1, in <module>
NameError: name '__import__' is not defined
  1. Para avaliar expressões não confiáveis com segurança, você precisa definir um dicionário de namespace global que mapeie "__builtins__" para None, o valor nulo do Python. Internamente, as funções “incorporadas” estão contidas em um pseudo-módulo chamado "__builtins__". Este pseudomódulo (ou seja, o conjunto de funções internas) é disponibilizado para expressões avaliadas, a menos que você o substitua explicitamente.
  2. Certifique-se de ter substituído __builtins__. Não __built-in__, __built-ins__, ou alguma outra variação que funcionará muito bem, mas o exporá a riscos catastróficos.

Então eval() está seguro agora? Bem, sim e não.

>>> eval("2 ** 2147483647",
...     {"__builtins__":None}, {})          ①
  1. Mesmo sem acesso a __builtins__, você ainda pode iniciar um ataque de negação de serviço. Por exemplo, tentar elevar 2 à potência 2147483647 aumentará a utilização da CPU do seu servidor para 100% por algum tempo. (Se você estiver tentando isso no shell interativo, pressione Ctrl-C algumas vezes para sair dele.) Tecnicamente, essa expressão retornará um valor eventualmente, mas enquanto isso seu servidor não fará nada.

No final, é possível avaliar com segurança expressões Python não confiáveis, para alguma definição de “seguro” que acaba não sendo muito útil na vida real. Tudo bem se você estiver apenas brincando, e tudo bem se você apenas passar uma entrada confiável. Mas qualquer outra coisa é apenas pedir problemas.

Juntando tudo

Para recapitular: este programa resolve quebra-cabeças alfaméticos por força bruta, ou seja, através de uma busca exaustiva de todas as soluções possíveis. Para isso, é preciso …

  1. Encontra todas as letras do quebra-cabeça com a função re.findall()
  2. Encontre todas as letras únicas no quebra-cabeça com conjuntos e a função set()
  3. Verifica se há mais de 10 letras únicas (o que significa que o quebra-cabeça é definitivamente insolúvel) com uma declaração assert
  4. Converte as letras em seus equivalentes ASCII com um objeto gerador
  5. Calcula todas as soluções possíveis com a função itertools.permutations()
  6. Converte cada solução possível em uma expressão Python com o método de string translate()
  7. Testa cada solução possível avaliando a expressão Python com a função eval()
  8. Retorna a primeira solução avaliada como True

…em apenas 14 linhas de código.

Leitura adicional

Muito obrigado a Raymond Hettinger por concordar em relicenciar seu código para que eu pudesse portá-lo para o Python 3 e usá-lo como base para este capítulo.

Esse artigo é uma tradução de um capítulo do livro "Dive Into Python 3" escrito por Mark Pilgrim. Você pode ler o livro desde o início em português clicando aqui.

Traduzido por Acervo Lima. O original pode ser acessado aqui.

Licença

quarta-feira, 12 de maio de 2021

Classes e Iteradores em python

❝Oriente é Oriente e Ocidente é Ocidente, e nunca os dois se encontrarão.❞
- Rudyard Kipling

Mergulho

Os iteradores são o “molho secreto” do Python 3. Eles estão por toda parte, por trás de tudo, sempre fora de vista. As compreensões são apenas uma forma simples de iteradores. Os geradores são apenas uma forma simples de iteradores. Uma função que produz valores (yield é uma maneira compacta e agradável de construir um iterador sem construir um iterador.

Lembra do gerador Fibonacci? Aqui está como um iterador criado do zero:

class Fib:
    '''iterator that yields numbers in the Fibonacci sequence'''

    def __init__(self, max):
        self.max = max

    def __iter__(self):
        self.a = 0
        self.b = 1
        return self

    def __next__(self):
        fib = self.a
        if fib > self.max:
            raise StopIteration
        self.a, self.b = self.b, self.a + self.b
        return fib

Vamos pegar uma linha de cada vez.

class Fib:

class? O que é uma class?

Definindo Classes

Python é totalmente orientado a objetos: você pode definir suas próprias classes, herdar de suas próprias classes ou classes internas e instanciar as classes que você definiu.

Definir uma classe em Python é simples. Tal como acontece com as funções, não existe uma definição de interface separada. Apenas defina a classe e comece a codificar. Uma classe Python começa com a palavra reservada class, seguida pelo nome da classe. Tecnicamente, isso é tudo o que é necessário, já que uma classe não precisa herdar de nenhuma outra classe.

class PapayaWhip:  ①
    pass           ②
  1. O nome desta classe é PapayaWhip e não herda de nenhuma outra classe. Os nomes das classes geralmente são escritos em maiúscula, EachWordLikeThis mas isso é apenas uma convenção, não um requisito.
  2. Você provavelmente adivinhou isso, mas tudo em uma classe é indentado, assim como o código em uma função, instrução if, loop for ou qualquer outro bloco de código. A primeira linha não identada está fora da classe.

Esta classe PapayaWhip não define nenhum método ou atributo, mas sintaticamente, deve haver algo na definição, portanto, a instrução pass. Esta é uma palavra reservada do Python que significa apenas “siga em frente, nada para ver aqui”. É uma instrução que não faz nada e é um bom marcador de posição quando você está removendo funções ou classes.

Observação

A instrução pass em Python é como um conjunto vazio de chaves ({}) em Java ou C.

Muitas classes são herdadas de outras classes, mas esta não é. Muitas classes definem métodos, mas esta não. Não há nada que uma classe Python absolutamente deva ter, exceto um nome. Em particular, os programadores C++ podem achar estranho que as classes Python não tenham construtores e destruidores explícitos. Embora não seja obrigatório, as classes Python podem ter algo semelhante a um construtor: o método __init__().

O Método __init__()

Este exemplo mostra a inicialização da classe Fib usando o método __init__.

class Fib:
    '''iterator that yields numbers in the Fibonacci sequence'''  ①

    def __init__(self, max):                                      ②
  1. As classes também podem (e devem) ter docstrings, assim como módulos e funções.
  2. O método __init__() é chamado imediatamente após a criação de uma instância da classe. Seria tentador - mas tecnicamente incorreto - chamá-lo de “construtor” da classe. É tentador porque se parece com um construtor C++ (por convenção, o método __init__() é o primeiro método definido para a classe), age como um (é a primeira parte do código executado em uma instância recém-criada da classe) e até mesmo soa como um. Incorreto, porque o objeto já foi construído no momento em que o método __init__() é chamado e você já tem uma referência válida para a nova instância da classe.

O primeiro argumento de cada método de classe, incluindo o método __init__(), é sempre uma referência à instância atual da classe. Por convenção, esse argumento é denominado self. Este argumento preenche a função da palavra reservada this em C++ ou Java, mas self não é uma palavra reservada em Python, apenas uma convenção de nomenclatura. No entanto, por favor, não chame de nada além de self; esta é uma convenção muito forte.

Em todos os métodos de classe, self se refere à instância cujo método foi chamado. Mas, no caso específico do método __init__(), a instância cujo método foi chamado também é o objeto recém-criado. Embora seja necessário especificar self explicitamente ao definir o método, você não o especifica ao chamar o método; Python irá adicioná-lo para você automaticamente.

Instanciando uma classe

Instanciar classes em Python é simples. Para instanciar uma classe, basta chamar a classe como se fosse uma função, passando os argumentos que o método __init__() requer. O valor de retorno será o objeto recém-criado.

>>> import fibonacci2
>>> fib = fibonacci2.Fib(100)  ①
>>> fib                        ②
<fibonacci2.Fib object at 0x00DB8810>
>>> fib.__class__              ③
<class 'fibonacci2.Fib'>
>>> fib.__doc__                ④
'iterator that yields numbers in the Fibonacci sequence'
  1. Você está criando uma instância da classe Fib (definida no módulo fibonacci2) e atribuindo a instância recém-criada à variável fib. Você está passando um parâmetro, 100 que vai acabar como o argumento max no método __init__() de Fib.
  2. fib agora é uma instância da classe Fib.
  3. Cada instância de classe tem um atributo embutido __class__, que é a classe do objeto. Os programadores Java podem estar familiarizados com a classe Class, que contém métodos como getName() e getSuperclass() para obter informações de metadados sobre um objeto. Em Python, esse tipo de metadado está disponível por meio de atributos, mas a ideia é a mesma.
  4. Você pode acessar a instância da docstring da mesma forma que com uma função ou um módulo. Todas as instâncias de uma classe compartilham a mesma docstring.

Observação

Em Python, simplesmente chame uma classe como se fosse uma função para criar uma nova instância da classe. Não há newoperador explícito como em C ++ ou Java.

Variáveis de instância

Para a próxima linha:

class Fib:
    def __init__(self, max):
        self.max = max        ①
  1. O que é self.max? É uma variável de instância. É completamente separado de max, que foi passado para o método __init__() como um argumento. self.max é “global” para a instância. Isso significa que você pode acessá-lo de outros métodos.
class Fib:
    def __init__(self, max):
        self.max = max        ①
    .
    .
    .
    def __next__(self):
        fib = self.a
        if fib > self.max:    ②
  1. self.max é definido no __init__()método ...
  2. … E referenciado no método __next__().

Variáveis de instância são específicas para uma instância de uma classe. Por exemplo, se você criar duas instâncias Fib com valores máximos diferentes, cada uma se lembrará de seus próprios valores.

>>> import fibonacci2
>>> fib1 = fibonacci2.Fib(100)
>>> fib2 = fibonacci2.Fib(200)
>>> fib1.max
100
>>> fib2.max
200

Um Iterador Fibonacci

Agora você está pronto para aprender como construir um iterador. Um iterador é apenas uma classe que define um método __iter__().

Todos os três destes métodos de classe, __init__, __iter__, e __next__, começam e terminam com um par de sublinhado( _) caracteres. Por que isso? Não há nada de mágico nisso, mas geralmente indica que esses são "métodos especiais". A única coisa “especial” sobre métodos especiais é que eles não são chamados diretamente; O Python os chama quando você usa alguma outra sintaxe na classe ou uma instância da classe. Mais sobre métodos especiais.

class Fib:                                        ①
    def __init__(self, max):                      ②
        self.max = max

    def __iter__(self):                           ③
        self.a = 0
        self.b = 1
        return self

    def __next__(self):                           ④
        fib = self.a
        if fib > self.max:
            raise StopIteration                   ⑤
        self.a, self.b = self.b, self.a + self.b
        return fib                                ⑥
  1. Para construir um iterador do zero, Fib precisa ser uma classe, não uma função.
  2. “Chamar” Fib(max) é realmente criar uma instância dessa classe e chamar seu método __init__() passando o valor max. O método __init__() salva o valor máximo como uma variável de instância para que outros métodos possam se referir a ele posteriormente.
  3. O método __iter__() é chamado sempre que alguém faz uma chamada iter(fib). (Como você verá em um minuto, um loop for o chamará automaticamente, mas você também pode chamá-lo manualmente). Depois de realizar a inicialização do início da iteração (neste caso, redefinir self.a e self.b, nossos dois contadores), o método __iter__() pode retornar qualquer objeto que implemente um método __next__(). Nesse caso (e na maioria dos casos), __iter__() simplesmente retorna self, já que essa classe implementa seu próprio método __next__().
  4. O método __next__() é chamado sempre que alguém chama next() um iterador de uma instância de uma classe. Isso fará mais sentido em um minuto.
  5. Quando o método __next__() gera uma exceção StopIteration, isso sinaliza ao chamador que a iteração se esgotou. Ao contrário da maioria das exceções, isso não é um erro; é uma condição normal que significa apenas que o iterador não tem mais valores para gerar. Se o chamador for um loop for, ele notará essa exceção StopIteration e sairá normalmente do loop. (Em outras palavras, ele engolirá a exceção). Esse pouco de mágica é na verdade a chave para usar iteradores em loops for.
  6. Para cuspir o próximo valor, o método __next __() de um iterador simplesmente retorna o valor. Não use yield aqui; isso é um pouco de açúcar sintático que só se aplica quando você está usando geradores. Aqui você está criando seu próprio iterador do zero; use em seu lugar return.

Completamente confuso ainda? Excelente. Vamos ver como chamar este iterador:

>>> from fibonacci2 import Fib
>>> for n in Fib(1000):
...     print(n, end=' ')
0 1 1 2 3 5 8 13 21 34 55 89 144 233 377 610 987

Ora, é exatamente o mesmo! Byte por byte idêntico ao que você chamou de Fibonacci-as-a-generator (módulo uma letra maiúscula). Mas como?

Há um pouco de magia envolvida em loops for. Aqui está o que acontece:

  • O loop for chama Fib(1000), conforme mostrado. Isso retorna uma instância da classe Fib. Chame isso de fib_inst.
  • Secretamente, e muito habilmente, o loop for chama iter(fib_inst), que retorna um objeto iterador. Chame isso de fib_iter. Nesse caso, fib_iter == fib_inst, porque o método __iter__() retorna self, mas o loop for não sabe (ou se importa) com isso.
  • Para “percorrer” o iterador, o loop for chama next(fib_iter), que chama o método __next__() no objeto fib_iter, que faz os cálculos do próximo número de Fibonacci e retorna um valor. O loop for pega esse valor e o atribui a n, depois executa o corpo do loop for para esse valor de n.
  • Como o loop for sabe quando parar? Estou feliz que você perguntou! Quando next(fib_iter) gera uma exceção StopIteration, o loop for engolirá a exceção e sairá normalmente. (Qualquer outra exceção passará e será gerada como de costume). E onde você viu uma exceção StopIteration? No método __next__(), é claro!

Um iterador de regra plural

iter(f) chama f.__ iter__
next(f) chama f.__ next__

Agora é a hora do final. Vamos reescrever o gerador de regras plurais como um iterador.

class LazyRules:
    rules_filename = 'plural6-rules.txt'

    def __init__(self):
        self.pattern_file = open(self.rules_filename, encoding='utf-8')
        self.cache = []

    def __iter__(self):
        self.cache_index = 0
        return self

    def __next__(self):
        self.cache_index += 1
        if len(self.cache) >= self.cache_index:
            return self.cache[self.cache_index - 1]

        if self.pattern_file.closed:
            raise StopIteration

        line = self.pattern_file.readline()
        if not line:
            self.pattern_file.close()
            raise StopIteration

        pattern, search, replace = line.split(None, 2)
        funcs = build_match_and_apply_functions(
            pattern, search, replace)
        self.cache.append(funcs)
        return funcs

rules = LazyRules()

Portanto, esta é uma classe que implementa __iter__() e __next__(), portanto, pode ser usada como um iterador. Em seguida, você instancia a classe e a atribui a rules. Isso acontece apenas uma vez, na importação.

Vamos dar uma mordida na classe de cada vez.

class LazyRules:
    rules_filename = 'plural6-rules.txt'

    def __init__(self):
        self.pattern_file = open(self.rules_filename, encoding='utf-8')  ①
        self.cache = []                                                  ②
  1. Quando instanciamos a LazyRulesclasse, abra o arquivo de padrão, mas não leia nada dele. (Isso vem depois.)
  2. Após abrir o arquivo de padrões, inicialize o cache. Você usará esse cache posteriormente (no método __next__()) ao ler as linhas do arquivo de padrão.

Antes de continuar, vamos dar uma olhada mais de perto em rules_filename. Não está definido dentro do método __iter__(). Na verdade, não é definido em nenhum método. É definido no nível da classe. É uma variável de classe e, embora você possa acessá-la como uma variável de instância (self.rules_filename ), ela é compartilhada por todas as instâncias da classe LazyRules.

>>> import plural6
>>> r1 = plural6.LazyRules()
>>> r2 = plural6.LazyRules()
>>> r1.rules_filename                               ①
'plural6-rules.txt'
>>> r2.rules_filename
'plural6-rules.txt'
>>> r2.rules_filename = 'r2-override.txt'           ②
>>> r2.rules_filename
'r2-override.txt'
>>> r1.rules_filename
'plural6-rules.txt'
>>> r2.__class__.rules_filename                     ③
'plural6-rules.txt'
>>> r2.__class__.rules_filename = 'papayawhip.txt'  ④
>>> r1.rules_filename
'papayawhip.txt'
>>> r2.rules_filename                               ⑤
'r2-overridetxt'
  1. Cada instância da classe herda o atributo rules_filename com o valor definido pela classe.
  2. Alterar o valor do atributo em uma instância não afeta outras instâncias...
  3. …Nem muda o atributo de classe. Você pode acessar o atributo de classe (em oposição ao atributo de uma instância individual) usando o atributo especial __class__ para acessar a própria classe.
  4. Se você alterar o atributo de classe, todas as instâncias que ainda herdam esse valor (como r1 aqui) serão afetadas.
  5. As instâncias que substituíram esse atributo (como r2 aqui) não serão afetadas.

E agora de volta ao nosso show.

def __iter__(self):       ①
    self.cache_index = 0
    return self           ②
  1. O método __iter__() será chamado sempre que alguém - digamos, um loop for - chamar iter(rules).
  2. A única coisa que todo método __iter__() deve fazer é retornar um iterador. Nesse caso, ele retorna self, o que sinaliza que essa classe define um método __next__() que se encarregará de retornar valores ao longo da iteração.
def __next__(self):                                 ①
    .
    .
    .
    pattern, search, replace = line.split(None, 2)
    funcs = build_match_and_apply_functions(        ②
        pattern, search, replace)
    self.cache.append(funcs)                        ③
    return funcs
  1. O método __next__() é chamado sempre que alguém - digamos, um loop for - chama next(rules). Este método só fará sentido se começarmos pelo final e trabalharmos de trás para frente. Então vamos fazer isso.
  2. A última parte desta função deve parecer familiar, pelo menos. A função build_match_and_apply_functions() não mudou; é o mesmo de sempre.
  3. A única diferença é que, antes de retornar as funções match e apply (que são armazenadas na tupla funcs), vamos salvá-las em self.cache.

Movendo-se para trás...

def __next__(self):
    .
    .
    .
    line = self.pattern_file.readline()  ①
    if not line:                         ②
        self.pattern_file.close()
        raise StopIteration              ③
    .
    .
    .
  1. Um pouco de truque avançado de arquivo aqui. O método readline() (nota: singular, não plural readlines()) lê exatamente uma linha de um arquivo aberto. Especificamente, a próxima linha. (Objetos de arquivo também são iteradores! São iteradores até o fim... )
  2. Se houver uma linha para readline() ler, line não será uma string vazia. Mesmo se o arquivo contiver uma linha em branco, line terminará como uma string de um caractere '\n' (um retorno de carro). Se line for realmente uma string vazia, isso significa que não há mais linhas para ler do arquivo.
  3. Quando chegarmos ao final do arquivo, devemos fechá-lo e gerar a exceção mágica StopIteration. Lembre-se, chegamos a este ponto porque precisávamos de uma função de correspondência e aplicação para a próxima regra. A próxima regra vem da próxima linha do arquivo... mas não há próxima linha! Portanto, não temos valor a retornar. A iteração acabou. ( ♫ A festa acabou… ♫)

Movendo-se para trás todo o caminho até o início do método __next__()...

def __next__(self):
    self.cache_index += 1
    if len(self.cache) >= self.cache_index:
        return self.cache[self.cache_index - 1]     ①

    if self.pattern_file.closed:
        raise StopIteration                         ②
    .
    .
    .
  1. self.cache será uma lista das funções de que precisamos para combinar e aplicar regras individuais. (Pelo menos isso deve soar familiar!). self.cache_index mantém registro de qual item em cache devemos retornar em seguida. Se ainda não esgotamos o cache (ou seja, se o comprimento de self.cache é maior que self.cache_index), ocorreu um acerto no cache! Viva! Podemos retornar a correspondência e aplicar funções do cache em vez de construí-las do zero.
  2. Por outro lado, se não obtivermos um acerto do cache, e o objeto de arquivo tiver sido fechado (o que poderia acontecer, mais adiante no método, como você viu no trecho de código anterior), então não há mais nada que possamos Faz. Se o arquivo estiver fechado, significa que o esgotamos - já lemos todas as linhas do arquivo de padrão e já construímos e armazenamos em cache as funções de correspondência e aplicação para cada padrão. O arquivo está esgotado; o cache está esgotado; Estou exausto. Espere, o que? Aguente firme, estamos quase terminando.

Juntando tudo, eis o que acontece quando:

  • Quando o módulo é importado, ele cria uma única instância da classe LazyRules, chamada rules, que abre o arquivo padrão, mas não lê a partir dele.
  • Quando perguntado sobre a primeira correspondência e a função de aplicação, ele verifica seu cache, mas descobre que o cache está vazio. Portanto, ele lê uma única linha do arquivo de padrão, constrói a correspondência e aplica funções a partir desses padrões e os armazena em cache.
  • Digamos, para fins de argumentação, que a primeira regra corresponda. Nesse caso, nenhuma função adicional de correspondência e aplicação é construída e nenhuma linha adicional é lida do arquivo de padrão.
  • Além disso, para fins de argumentação, suponha que o chamador chame a função plural() novamente para pluralizar uma palavra diferente. O loop for na função plural() será chamado iter(rules), o que redefinirá o índice do cache, mas não redefinirá o objeto de arquivo aberto.
  • Na primeira vez, o loop for pedirá um valor de rules, que invocará seu método __next__(). Desta vez, no entanto, o cache é inicializado com um único par de funções de correspondência e aplicação, correspondendo aos padrões na primeira linha do arquivo de padrão. Como foram construídos e armazenados em cache durante a pluralização da palavra anterior, eles são recuperados do cache. O índice do cache é incrementado e o arquivo aberto nunca é tocado.
  • Digamos, para fins de argumentação, que a primeira regra não corresponda desta vez. Assim, o loop for volta a ocorrer e pede outro valor de rules. Isso invoca o método __next__() uma segunda vez. Desta vez, o cache está esgotado - ele continha apenas um item e estamos pedindo um segundo - então o método __next__() continua. Ele lê outra linha do arquivo aberto, compila e aplica funções a partir dos padrões e os armazena em cache.
  • Este processo de leitura-construção-e-cache continuará enquanto as regras que estão sendo lidas do arquivo padrão não corresponderem à palavra que estamos tentando pluralizar. Se encontrarmos uma regra correspondente antes do final do arquivo, simplesmente a usamos e paramos, com o arquivo ainda aberto. O ponteiro do arquivo ficará onde quer que paramos de ler, esperando pelo próximo comando readline(). Nesse ínterim, o cache agora tem mais itens nele, e se começarmos tudo de novo tentando pluralizar uma nova palavra, cada um desses itens no cache será tentado antes de ler a próxima linha do arquivo de padrão.

Alcançamos o nirvana da pluralização.

  1. Custo mínimo de inicialização. A única coisa que acontece em import é instanciar uma única classe e abrir um arquivo (mas não lê-lo).
  2. Performance máxima. O exemplo anterior iria ler o arquivo e construir funções dinamicamente toda vez que você quisesse pluralizar uma palavra. Essa versão armazenará funções em cache assim que forem construídas e, no pior dos casos, só lerá o arquivo de padrão uma vez, não importa quantas palavras você pluralize.
  3. Separação de código e dados. Todos os padrões são armazenados em um arquivo separado. Código é código e dados são dados, e nunca os dois se encontrarão.

Observação

Isso é realmente o nirvana? Bem, sim e não. Aqui está algo a considerar com o exemplo LazyRules: o arquivo de padrão é aberto (durante __init__()) e permanece aberto até que a regra final seja alcançada. O Python acabará fechando o arquivo quando ele sair ou depois que a última instanciação da classe LazyRules for destruída, mas ainda assim, isso pode levar muito tempo. Se esta classe fizer parte de um processo Python de longa execução, o interpretador Python pode nunca sair e o objeto LazyRules nunca pode ser destruído.

Existem maneiras de contornar isso. Em vez de abrir o arquivo durante __init__() e deixá-lo aberto enquanto lê as regras, uma linha por vez, você pode abrir o arquivo, ler todas as regras e fechá-lo imediatamente. Ou você pode abrir o arquivo, ler uma regra, salvar a posição do arquivo com o método tell(), fechar o arquivo e depois abri-lo novamente e usar o método seek() para continuar lendo de onde parou. Ou você não poderia se preocupar com isso e apenas deixar o arquivo aberto, como este código de exemplo faz. Programação é design, e design é tudo sobre trade-offs e restrições. Deixar um arquivo aberto por muito tempo pode ser um problema; tornar seu código mais complicado pode ser um problema. Qual deles é o maior problema depende de sua equipe de desenvolvimento, seu aplicativo e seu ambiente de execução.

Leitura adicional

Esse artigo é uma tradução de um capítulo do livro "Dive Into Python 3" escrito por Mark Pilgrim. Você pode ler o livro desde o início em português clicando aqui.

Traduzido por Acervo Lima. O original pode ser acessado aqui.

Licença

terça-feira, 11 de maio de 2021

Closures e geradores em python

❝Minha ortografia é instável. É boa ortografia, mas oscila e as letras ficam nos lugares errados.❞
- Ursinho Pooh

Mergulho

Tendo crescido como filho de um bibliotecário e formado em inglês, sempre fui fascinado por línguas. Não são linguagens de programação. Bem, sim, linguagens de programação, mas também linguagens naturais. Faça o inglês. O inglês é uma língua esquizofrênica que empresta palavras do alemão, francês, espanhol e latim (para citar alguns). Na verdade, “pede emprestado” é a palavra errada; “Pilhagens” é mais parecido. Ou talvez “assimila” - como os Borg. Sim eu gosto disso.

We are the Borg. Your linguistic and etymological distinctiveness will be added to our own. Resistance is futile.

Neste capítulo, você aprenderá sobre substantivos no plural. Além disso, funções que retornam outras funções, expressões regulares avançadas e geradores. Mas primeiro, vamos falar sobre como fazer substantivos no plural. (Se você ainda não leu o capítulo sobre expressões regulares, agora seria uma boa hora. Este capítulo pressupõe que você entende os fundamentos das expressões regulares e rapidamente desce para usos mais avançados).

Se você cresceu em um país onde se fala inglês ou aprendeu inglês em uma escola formal, provavelmente você está familiarizado com as regras básicas:

  • Se uma palavra terminar em S, X ou Z, adicione ES. Bass torna-se Basses, fax torna-se faxes, e waltz se torna waltzes.
  • Se uma palavra terminar em um H barulhento, adicione ES; se terminar em H silencioso, basta adicionar S. O que é um H barulhento? Um que se combina com outras letras para fazer um som que você pode ouvir. Assim, o coach se torna um coaches e a rash se transforma em rashes, porque você pode ouvir os sons de CH e SH quando os diz. Mas cheetah torna-se cheetahs, porque o H é silenciosa.
  • Se uma palavra terminar em Y que soe como I, altere Y para IES; se o Y é combinado com uma vogal a soar como algo mais, basta adicionar S. Então vacancy se torna vacancies, mas day se torna days.
  • Se tudo mais falhar, basta adicionar S e esperar o melhor.

(Eu sei, há uma série de exceções. Man se torna men e woman se torna women, mas human se torna humans. Mouse torna-se mice e louse torna-se lice, mas house torna-se houses. Knife torna-se knives e wife torna-se wives, mas lowlife se torna lowlifes. E nem me fale em palavras que estão no seu próprio plural, como sheep, deer e haiku).

Outras línguas, é claro, são completamente diferentes.

Vamos projetar uma biblioteca Python que pluraliza automaticamente os substantivos em inglês. Começaremos apenas com essas quatro regras, mas lembre-se de que você inevitavelmente precisará adicionar mais.

Eu sei, vamos usar expressões regulares!

Então você está olhando para palavras, o que, pelo menos em inglês, significa que você está olhando para cadeias de caracteres. Você tem regras que dizem que você precisa encontrar diferentes combinações de caracteres e, em seguida, fazer coisas diferentes com eles. Isso parece um trabalho para expressões regulares!

import re

def plural(noun):          
    if re.search('[sxz]$', noun):             ①
        return re.sub('$', 'es', noun)        ②
    elif re.search('[^aeioudgkprt]h$', noun):
        return re.sub('$', 'es', noun)       
    elif re.search('[^aeiou]y$', noun):      
        return re.sub('y$', 'ies', noun)     
    else:
        return noun + 's'
  1. Esta é uma expressão regular, mas usa uma sintaxe que você não viu nas Expressões regulares. Os colchetes significam "corresponder exatamente a um desses caracteres". Então [sxz] significa “s, ou x, ou z”, mas apenas um deles. O $ deve ser familiar; ele corresponde ao final da string. Combinadas, isso testa expressão regulares, se substantivo termina com s, x ou z.
  2. Esta função re.sub() executa substituições de string baseadas em expressões regulares.

Vejamos as substituições de expressões regulares com mais detalhes.

>>> import re
>>> re.search('[abc]', 'Mark')    ①
<_sre.SRE_Match object at 0x001C1FA8>
>>> re.sub('[abc]', 'o', 'Mark')  ②
'Mork'
>>> re.sub('[abc]', 'o', 'rock')  ③
'rook'
>>> re.sub('[abc]', 'o', 'caps')  ④
'oops'
  1. Será que a string Mark conter a, b ou c? Sim, contém a.
  2. OK, agora encontre a, b ou c e substitua-o por o. Mark torna-se Mork.
  3. A mesma função transforma rock em rook.
  4. Você pode pensar que isso iria transformar caps em oaps, mas isso não acontece. re.sub substitui todas as correspondências, não apenas a primeira. Portanto, essa expressão regular transforma caps em oops, porque tanto o c quanto o a são transformados em o.

E agora, de volta à função plural()...

def plural(noun):          
    if re.search('[sxz]$', noun):            
        return re.sub('$', 'es', noun)         ①
    elif re.search('[^aeioudgkprt]h$', noun):  ②
        return re.sub('$', 'es', noun)
    elif re.search('[^aeiou]y$', noun):        ③
        return re.sub('y$', 'ies', noun)     
    else:
        return noun + 's'
  1. Aqui, você está substituindo o final da string (combinada por $) pela string es. Em outras palavras, adicionando es à string. Você poderia realizar a mesma coisa com a concatenação de strings, por exemplo noun + 'es', mas optei por usar expressões regulares para cada regra, por motivos que ficarão claros posteriormente neste capítulo.
  2. Olhe bem, esta é outra nova variação. O ^ como o primeiro caractere dentro dos colchetes significa algo especial: negação. [^abc] significa “qualquer caractere único, exceto a , b ou c”. Então, [^aeioudgkprt] significa qualquer caractere, exceto a, e, i, o, u, d, g, k, p, r, ou t. Então, esse caractere precisa ser seguido por h, seguido pelo final da string. Você está procurando palavras que terminam em H, onde o H pode ser ouvido.
  3. Mesmo padrão aqui: coincidem com as palavras que terminam em Y, onde o caractere antes do Y é qualquer caracter diferente de a, e, i, o, ou u. Você está procurando palavras que terminam em Y que soam como I.

Vejamos as expressões regulares de negação com mais detalhes.

>>> import re
>>> re.search('[^aeiou]y$', 'vacancy')  ①
<_sre.SRE_Match object at 0x001C1FA8>
>>> re.search('[^aeiou]y$', 'boy')      ②
>>> 
>>> re.search('[^aeiou]y$', 'day')
>>> 
>>> re.search('[^aeiou]y$', 'pita')     ③
  1. vacancy corresponde a esta expressão regular, porque termina no cy, e c não é a, e, i, o, ou u.
  2. boy não corresponde, porque termina em oy, e você disse especificamente que o caractere anterior ao y não poderia ser o. day não corresponde, porque termina em ay.
  3. pita não corresponde, porque não termina em y.
>>> re.sub('y$', 'ies', 'vacancy')               ①
'vacancies'
>>> re.sub('y$', 'ies', 'agency')
'agencies'
>>> re.sub('([^aeiou])y$', r'\1ies', 'vacancy')  ②
'vacancies'
  1. Essa expressão regular transforma vacancy em vacancies e agency em agencies, que é o que você queria. Observe que também transformaria boy em boies, mas isso nunca acontecerá na função porque você chamou re.search primeiro para descobrir se deveria fazer essa substituição (re.sub).
  2. De passagem, quero salientar que é possível combinar essas duas expressões regulares (uma para descobrir se a regra se aplica e outra para aplicá-la de fato) em uma única expressão regular. É assim que ficaria. A maior parte deve parecer familiar: você está usando um grupo lembrado, que aprendeu no Estudo de caso: análise de números de telefone. O grupo é usado para lembrar o caractere antes da letra y. Então, na string de substituição, você usa uma nova sintaxe \1, que significa “ei, aquele primeiro grupo que você lembrou? coloque bem aqui”. Neste caso, você se lembra de c antes do y; quando você faz a substituição, você substitui c no lugar de c, e ies no lugar de y. (Se você tiver mais de um grupo lembrado, você pode usar \2 e \3 e assim por diante).

As substituições de expressões regulares são extremamente poderosas e a sintaxe \1 as torna ainda mais poderosas. Mas combinar a operação inteira em uma expressão regular também é muito mais difícil de ler e não mapeia diretamente para a maneira como você descreveu pela primeira vez as regras de pluralização. Você originalmente estabeleceu regras como “se a palavra terminar em S, X ou Z, adicione ES”. Se você olhar para esta função, terá duas linhas de código que dizem “se a palavra terminar em S, X ou Z, adicione ES”. Não fica muito mais direto do que isso.

Uma lista de funções

Agora você vai adicionar um nível de abstração. Você começou definindo uma lista de regras: se isso, faça aquilo, caso contrário, vá para a próxima regra. Vamos complicar temporariamente parte do programa para que você possa simplificar outra parte.

import re

def match_sxz(noun):
    return re.search('[sxz]$', noun)

def apply_sxz(noun):
    return re.sub('$', 'es', noun)

def match_h(noun):
    return re.search('[^aeioudgkprt]h$', noun)

def apply_h(noun):
    return re.sub('$', 'es', noun)

def match_y(noun):                             ①
    return re.search('[^aeiou]y$', noun)
        
def apply_y(noun):                             ②
    return re.sub('y$', 'ies', noun)

def match_default(noun):
    return True

def apply_default(noun):
    return noun + 's'

rules = ((match_sxz, apply_sxz),               ③
         (match_h, apply_h),
         (match_y, apply_y),
         (match_default, apply_default)
         )

def plural(noun):           
    for matches_rule, apply_rule in rules:       ④
        if matches_rule(noun):
            return apply_rule(noun)
  1. Agora, cada regra de correspondência é sua própria função, que retorna os resultados da chamada da função re.search().
  2. Cada regra de aplicação também é sua própria função, que chama a função re.sub() para aplicar a regra de pluralização apropriada.
  3. Em vez de ter uma função (plural()) com várias regras, você tem a estrutura de dados, rules, que é uma sequência de pares de funções.
  4. Como as regras foram divididas em uma estrutura de dados separada, a nova função plural() pode ser reduzida a algumas linhas de código. Usando um loop for, você pode retirar a correspondência e aplicar duas regras por vez (uma correspondência, uma aplicação) da estrutura de rules. Na primeira iteração do loop for, match_rule será obtido match_sxz e apply_rule será obtido apply_sxz. Na segunda iteração (presumindo que você tenha chegado tão longe), match_rule será atribuído match_h, e apply_rule será atribuído apply_h. A função tem a garantia de retornar algo eventualmente, porque a regra de correspondência final (match_default) simplesmente retorna True, o que significa que a regra de aplicação correspondente (apply_default) sempre será aplicado.

A variável “rules” é uma sequência de pares de funções.

O motivo pelo qual essa técnica funciona é que tudo em Python é um objeto, incluindo funções. A estrutura de dados das rules contém funções - não nomes de funções, mas objetos de função reais. Quando eles são atribuídos no loop for, match_rule e apply_rule são funções reais que você pode chamar. Na primeira iteração do loop for, isso equivale a chamar matches_sxz(noun) e, se retornar uma correspondência, chamar apply_sxz(noun).

Se esse nível adicional de abstração for confuso, tente desenrolar a função para ver a equivalência. Todo o loop for é equivalente ao seguinte:

def plural(noun):
    if match_sxz(noun):
        return apply_sxz(noun)
    if match_h(noun):
        return apply_h(noun)
    if match_y(noun):
        return apply_y(noun)
    if match_default(noun):
        return apply_default(noun)

A vantagem aqui é que a função plural() agora foi simplificada. Ele pega uma sequência de regras, definida em outro lugar, e itera por meio delas de maneira genérica.

  1. Obtenha uma regra de correspondência.
  2. Combina? Em seguida, chame a regra de aplicação e retorne o resultado.
  3. Sem correspondência? Vá para a etapa 1.

As regras podem ser definidas em qualquer lugar, de qualquer maneira. A função plural() não importa.

Agora, adicionar esse nível de abstração. Valeu a pena? Bem, ainda não. Vamos considerar o que seria necessário para adicionar uma nova regra à função. No primeiro exemplo, seria necessário adicionar uma instrução if à função plural(). Neste segundo exemplo, seria necessário adicionar duas funções match_foo() e apply_foo(), em seguida, atualizar a sequência de rules para especificar onde, na ordem, as novas funções de correspondência e aplicação devem ser chamadas em relação às outras regras.

Mas este é realmente apenas um trampolim para a próxima seção. Vamos continuar…

Uma lista de padrões

Definir funções nomeadas separadas para cada correspondência e aplicar regra não é realmente necessário. Você nunca liga para eles diretamente; você os adiciona à sequência de rules e os chama por lá. Além disso, cada função segue um de dois padrões. Todas as funções de correspondência são chamadas re.search()e todas as funções de aplicação são chamadas re.sub(). Vamos fatorar os padrões para que seja mais fácil definir novas regras.

import re

def build_match_and_apply_functions(pattern, search, replace):
    def matches_rule(word):                                     ①
        return re.search(pattern, word)
    def apply_rule(word):                                       ②
        return re.sub(search, replace, word)
    return (matches_rule, apply_rule)                           ③
  1. build_match_and_apply_functions() é uma função que cria outras funções dinamicamente. Ela pega pattern, search e substitui, então define uma função matches_rule() que chama re.search() com o pattern que foi passado para a função build_match_and_apply_functions() e a word que foi passada para a função matches_rule() que você está construindo. Uau.
  2. Construir a função apply_rule funciona da mesma maneira. A função apply_rule é uma função que recebe um parâmetro e chama re.sub() com os parâmetros de search e replace que foram passados ​​para a função build_match_and_apply_functions() e a word que foi passada para a função apply_rule() que você está construindo. Essa técnica de usar os valores de parâmetros externos dentro de uma função dinâmica é chamada de closures. Você está essencialmente definindo constantes dentro de apply_rule que você está construindo: leva um parâmetro (word), mas então atua sobre ele mais dois outros valores (search e replace) que foram configurados quando você definiu a função de aplicação.
  3. Finalmente, a função build_match_and_apply_functions() retorna uma tupla de dois valores: as duas funções que você acabou de criar. As constantes que você definiu nessas funções (pattern dentro da função matches_rule() e search e replace dentro da função apply_rule()) permanecem com essas funções, mesmo depois de retornar de build_match_and_apply_functions(). Isso é incrivelmente legal.

Se isso é incrivelmente confuso (e deveria ser, isso é coisa esquisita), pode ficar mais claro quando você vir como usá-lo.

patterns = \                                                        ①
  (
    ('[sxz]$',           '$',  'es'),
    ('[^aeioudgkprt]h$', '$',  'es'),
    ('(qu|[^aeiou])y$',  'y$', 'ies'),
    ('$',                '$',  's')                                 ②
  )
rules = [build_match_and_apply_functions(pattern, search, replace)  ③
         for (pattern, search, replace) in patterns]
  1. Nossas “regras” de pluralização são agora definidas como uma tupla de tuplas de strings (não funções). A primeira string em cada grupo é o padrão de expressão regular que você usaria re.search() para ver se essa regra corresponde. A segunda e a terceira strings em cada grupo são as expressões de pesquisa e substituição que você usaria re.sub() para realmente aplicar a regra para transformar um substantivo em seu plural.
  2. Há uma pequena mudança aqui, na regra de fallback. No exemplo anterior, a função match_default() simplesmente retornou True, o que significa que se nenhuma das regras mais específicas correspondesse, o código simplesmente adicionaria um s ao final da palavra dada. Este exemplo faz algo funcionalmente equivalente. A expressão regular final pergunta se a palavra tem um final ($ corresponde ao final de uma string). É claro que toda string tem um fim, até mesmo uma string vazia, portanto, essa expressão sempre corresponde. Assim, ele tem o mesmo propósito que a função match_default() que sempre retornou True: ele garante que, se nenhuma regra mais específica corresponder, o código adiciona um s ao final da palavra dada.
  3. Esta linha é mágica. Ele pega a sequência de strings em patterns e os transforma em uma sequência de funções. Como? Ao “mapear” as strings para a função build_match_and_apply_functions(). Ou seja, ele pega cada trinca de strings e chama a função build_match_and_apply_functions() com essas três strings como argumentos. A função build_match_and_apply_functions() retorna uma tupla de duas funções. Isso significa que as rules acabam sendo funcionalmente equivalentes ao exemplo anterior: uma lista de tuplas, onde cada tupla é um par de funções. A primeira função é a função de correspondência que chama re.search() e a segunda função é a função de aplicação que chama re.sub().

Completando esta versão do script está o principal ponto de entrada, a função plural().

def plural(noun):
    for matches_rule, apply_rule in rules:  ①
        if matches_rule(noun):
            return apply_rule(noun)
  1. Como a lista de rules é a mesma do exemplo anterior (realmente é), não deve ser surpresa que a função plural() não tenha mudado em nada. É completamente genérico; ele pega uma lista de funções de regra e as chama em ordem. Não importa como as regras são definidas. No exemplo anterior, eles foram definidos como funções nomeadas separadas. Agora eles são construídos dinamicamente mapeando a saída da função build_match_and_apply_functions() em uma lista de strings brutas. Não importa; a função plural() ainda funciona da mesma maneira.

Um arquivo de padrões

Você fatorou todo o código duplicado e adicionou abstrações suficientes para que as regras de pluralização sejam definidas em uma lista de strings. A próxima etapa lógica é pegar essas strings e colocá-las em um arquivo separado, onde podem ser mantidas separadamente do código que as usa.

Primeiro, vamos criar um arquivo de texto que contém as regras que você deseja. Nenhuma estrutura de dados extravagante, apenas strings delimitadas por espaços em branco em três colunas. Vamos chamá-lo plural4-rules.txt.

[sxz]$               $    es
[^aeioudgkprt]h$     $    es
[^aeiou]y$          y$    ies
$                    $    s

Agora vamos ver como você pode usar este arquivo de regras.

import re

def build_match_and_apply_functions(pattern, search, replace):  ①
    def matches_rule(word):
        return re.search(pattern, word)
    def apply_rule(word):
        return re.sub(search, replace, word)
    return (matches_rule, apply_rule)

rules = []
with open('plural4-rules.txt', encoding='utf-8') as pattern_file:  ②
    for line in pattern_file:                                      ③
        pattern, search, replace = line.split(None, 2)             ④
        rules.append(build_match_and_apply_functions(              ⑤
                pattern, search, replace))
  1. A função build_match_and_apply_functions() não mudou. Você ainda está usando closures para construir duas funções dinamicamente que usam variáveis definidas na função externa.
  2. A função global open() abre um arquivo e retorna um objeto de arquivo. Nesse caso, o arquivo que estamos abrindo contém as strings de padrão para substantivos pluralizantes. A instrução with cria o que é chamado de contexto: quando o bloco with termina, o Python fecha automaticamente o arquivo, mesmo se uma exceção for levantada dentro do bloco with. Você aprenderá mais sobre blocos with e objetos de arquivo no capítulo Arquivos.
  3. for line in <fileobject> lê os dados do arquivo aberto, uma linha por vez, e atribui o texto à variável line. Você aprenderá mais sobre como ler arquivos no capítulo Arquivos.
  4. Cada linha do arquivo realmente tem três valores, mas eles são separados por espaços em branco (tabulações ou espaços, não faz diferença). Para dividir, use o método split() das strings. O primeiro argumento do método split() é None, que significa “dividir em qualquer espaço em branco (tabulações ou espaços, não faz diferença)”. O segundo argumento é 2, que significa "dividir em espaços em branco 2 vezes (dividir uma vez retorna dois valores, dividir duas vezes retorna três valores e assim por diante) e, em seguida, deixe o resto da linha sozinho." Uma linha como [sxz]$ $ esserá dividida na lista ['[sxz]$', '$', 'es'], o que significa que o pattern será obtido '[sxz]$', search obterá '$' e a replace obterá 'es'. Isso é muito poder em uma pequena linha de código.
  5. Finalmente, você passa pattern, search e replace para a função build_match_and_apply_functions(), que retorna uma tupla de funções. Você anexa essa tupla à lista de rules e as rules acabam armazenando a lista de funções de correspondência e aplicação que a função plural() espera.

A melhoria aqui é que você separou completamente as regras de pluralização em um arquivo externo, para que ele possa ser mantido separadamente do código que o usa. Código é código, dados são dados e a vida é boa.

Geradores

Não seria ótimo ter uma função plural() genérica que analisa o arquivo de regras? Obtenha regras, verifique se há uma correspondência, aplique a transformação apropriada e seguir para a próxima regra. Isso é tudo que a função plural() precisa fazer, e isso é tudo que a função plural() deve fazer.

def rules(rules_filename):
    with open(rules_filename, encoding='utf-8') as pattern_file:
        for line in pattern_file:
            pattern, search, replace = line.split(None, 2)
            yield build_match_and_apply_functions(pattern, search, replace)

def plural(noun, rules_filename='plural5-rules.txt'):
    for matches_rule, apply_rule in rules(rules_filename):
        if matches_rule(noun):
            return apply_rule(noun)
    raise ValueError('no matching rule for {0}'.format(noun))

Como diabos isso funciona? Vejamos primeiro um exemplo interativo.

>>> def make_counter(x):
...     print('entering make_counter')
...     while True:
...         yield x                    ①
...         print('incrementing x')
...         x = x + 1
... 
>>> counter = make_counter(2)          ②
>>> counter                            ③
<generator object at 0x001C9C10>
>>> next(counter)                      ④
entering make_counter
2
>>> next(counter)                      ⑤
incrementing x
3
>>> next(counter)                      ⑥
incrementing x
4
  1. A presença da palavra-chave yield em make_counter significa que esta não é uma função normal. É um tipo especial de função que gera valores um de cada vez. Você pode pensar nisso como uma função recuperável. Chamá-lo retornará um gerador que pode ser usado para gerar valores sucessivos de x.
  2. Para criar uma instância do gerador make_counter, basta chamá-la como qualquer outra função. Observe que isso não executa realmente o código da função. Você pode dizer isso porque a primeira linha da função make_counter() chama print(), mas nada foi impresso ainda.
  3. A função make_counter() retorna um objeto gerador.
  4. A função next() pega um objeto gerador e retorna seu próximo valor. A primeira vez que você chama next() com o gerador de counter, ele executa o código make_counter() até a primeira instrução yield e retorna o valor gerado. Nesse caso, será 2, porque você criou originalmente o gerador chamando make_counter(2).
  5. Chamar repetidamente next() com o mesmo objeto gerador continua exatamente de onde parou e continua até chegar à próxima instrução yield. Todas as variáveis, estado local, etc. são salvos yield e restaurados em next(). A próxima linha de código esperando para ser executada chama print(), que imprime incrementing x. Depois disso, a declaração x = x + 1. Em seguida, ele percorre o loop while novamente e a primeira coisa que atinge é a instrução yield x, que salva o estado de tudo e retorna o valor atual de x (agora 3).
  6. Na segunda vez que você chama next(counter), você faz todas as mesmas coisas novamente, mas desta vez x é agora 4.

Uma vez que make_counter configura um loop infinito, você poderia teoricamente fazer isso para sempre, e ele simplesmente continuaria incrementando x e emitindo valores. Mas, em vez disso, vamos dar uma olhada em usos mais produtivos de geradores.

Um gerador de Fibonacci

“Yield” pausa uma função. “Next()” continua de onde parou.

def fib(max):
    a, b = 0, 1          ①
    while a < max:
        yield a          ②
        a, b = b, a + b  ③
  1. A sequência de Fibonacci é uma sequência de números onde cada número é a soma dos dois números anteriores. Ele começa com 0 e 1 aumenta lentamente no início, depois cada vez mais rapidamente. Para iniciar a sequência, você precisa de duas variáveis: a começa em 0 e b começa em 1.
  2. a é o número atual na sequência, portanto, forneça-o.
  3. b é o próximo número na sequência, então atribua-o a a, mas também calcule o próximo valor (a + b) e atribua-o a b para uso posterior. Observe que isso acontece em paralelo; se a for 3 e b for 5, a, b = b, a + b definirá a como 5 (o valor anterior de b) e b como 8 (a soma dos valores anteriores de a e b).

Portanto, você tem uma função que produz números de Fibonacci sucessivos. Claro, você poderia fazer isso com recursão, mas dessa forma é mais fácil de ler. Além disso, funciona bem com loops for.

>>> from fibonacci import fib
>>> for n in fib(1000):      ①
...     print(n, end=' ')    ②
0 1 1 2 3 5 8 13 21 34 55 89 144 233 377 610 987
>>> list(fib(1000))          ③
[0, 1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89, 144, 233, 377, 610, 987]
  1. Você pode usar um gerador como fib() em um loop for diretamente. O loop for irá chamar automaticamente a função next() para obter valores do gerador fib() e atribuí-los à variável n de índice do loop for.
  2. Cada vez que percorre o loop for, n obtém um novo valor da instrução yield em fib() e tudo o que você precisa fazer é imprimi-lo. Uma vez que os números de fib() acabam (a torna-se maior que max, o que neste caso é 1000), o loop for termina normalmente.
  3. Este é útil: passe um gerador para a função list() e ele iterará por todo o gerador (assim como o loop for no exemplo anterior) e retornará uma lista de todos os valores.

Um gerador de regra plural

Vamos voltar para o script plural5.py e ver como essa versão da função plural() funciona.

def rules(rules_filename):
    with open(rules_filename, encoding='utf-8') as pattern_file:
        for line in pattern_file:
            pattern, search, replace = line.split(None, 2)                   ①
            yield build_match_and_apply_functions(pattern, search, replace)  ②

def plural(noun, rules_filename='plural5-rules.txt'):
    for matches_rule, apply_rule in rules(rules_filename):                   ③
        if matches_rule(noun):
            return apply_rule(noun)
    raise ValueError('no matching rule for {0}'.format(noun))
  1. Nenhuma mágica aqui. Lembre-se de que as linhas do arquivo de regras têm três valores separados por espaços em branco, então você usa line.split(None, 2) para obter as três “colunas” e atribuí-las a três variáveis locais.
  2. E então você cede. O que você cede? Duas funções, construídas dinamicamente com seu velho amigo build_match_and_apply_functions(), que são idênticas aos exemplos anteriores. Em outras palavras, rules() é um gerador que gera correspondência e aplica funções sob demanda.
  3. Como rules() é um gerador, você pode usá-lo diretamente em um loop for. Na primeira vez no loop for, você chamará a função rules(), que abrirá o arquivo de padrões, lerá a primeira linha, construirá dinamicamente uma função de correspondência e uma função de aplicação a partir dos padrões nessa linha e produzirá as funções construídas dinamicamente. Na segunda vez no loop for, você continuará exatamente de onde parou rules() (que foi no meio do loop for line in pattern_file). A primeira coisa que ele fará é ler a próxima linha do arquivo (que ainda está aberta), construir dinamicamente outra correspondência e aplicar a função com base nos padrões dessa linha no arquivo e produzir as duas funções.

O que você ganhou no estágio 4? Hora de inicialização. No estágio 4, quando você importou o módulo plural4, ele leu todo o arquivo de padrões e construiu uma lista de todas as regras possíveis, antes mesmo que você pudesse pensar em chamar a função plural(). Com os geradores, você pode fazer tudo preguiçosamente: você lê a primeira regra, cria funções e as experimenta, e se isso funcionar, você nunca lê o resto do arquivo ou cria quaisquer outras funções.

O que você perdeu? Desempenho! Cada vez que você chama a função plural(), o gerador rules() começa do início - o que significa reabrir o arquivo de padrões e ler desde o início, uma linha de cada vez.

E se você pudesse ter o melhor dos dois mundos: custo mínimo de inicialização (não execute nenhum código import) e desempenho máximo (não crie as mesmas funções repetidamente). Ah, e você ainda deseja manter as regras em um arquivo separado (porque código é código e dados são dados), contanto que nunca precise ler a mesma linha duas vezes.

Para fazer isso, você precisará construir seu próprio iterador. Mas antes de fazer isso, você precisa aprender sobre as classes Python.

Leitura adicional

Esse artigo é uma tradução de um capítulo do livro "Dive Into Python 3" escrito por Mark Pilgrim. Você pode ler o livro desde o início em português clicando aqui.

Traduzido por Acervo Lima. O original pode ser acessado aqui.

Licença