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

segunda-feira, 10 de maio de 2021

Expressões regulares em python

❝Algumas pessoas, quando confrontadas com um problema, pensam “Eu sei, usarei expressões regulares”. Agora eles tem dois problemas.❞
- Jamie Zawinski

Mergulho

Tirar um pequeno trecho de um grande bloco de texto é um desafio. Em Python, strings têm métodos para pesquisa e substituição: index(), find(), split(), count(), replace(). Mas esses métodos são limitados aos casos mais simples. Por exemplo, o método index() procura uma única substring codificada e a pesquisa sempre diferencia maiúsculas de minúsculas. Para fazer pesquisas que não diferenciam maiúsculas de minúsculas de uma string s, você deve chamar s.lower() ou s.upper() e certificar-se de que suas strings de pesquisa são as maiúsculas e minúsculas apropriadas. Os métodos replace() e split() têm as mesmas limitações.

Se seu objetivo pode ser alcançado com métodos de string, você deve usá-los. Eles são rápidos, simples e fáceis de ler, e há muito a ser dito sobre um código rápido, simples e legível. Mas se você estiver usando várias funções de string diferentes com instruções if para lidar com casos especiais, ou se estiver encadeando chamadas para split() e join() para fatiar e dividir suas strings, pode ser necessário passar para as expressões regulares.

As expressões regulares são uma forma poderosa e (principalmente) padronizada de pesquisar, substituir e analisar texto com padrões complexos de caracteres. Embora a sintaxe da expressão regular seja restrita e diferente do código normal, o resultado pode acabar sendo mais legível do que uma solução enrolada à mão que usa uma longa cadeia de funções de string. Existem até maneiras de incorporar comentários em expressões regulares, para que você possa incluir documentação refinada neles.

Observação

Se você usou expressões regulares em outras linguagens (como Perl, JavaScript ou PHP), a sintaxe do Python será muito familiar. Leia o resumo do módulo re para obter uma visão geral das funções disponíveis e seus argumentos.

Estudo de caso: Nº de endereços de rua

Esta série de exemplos foi inspirada por um problema da vida real que tive em meu trabalho diário há vários anos, quando precisei limpar e padronizar endereços de rua exportados de um sistema legado antes de importá-los para um sistema mais novo. (Veja, eu não apenas invento essas coisas; elas são realmente úteis). Este exemplo mostra como abordei o problema.

>>> s = '100 NORTH MAIN ROAD'
>>> s.replace('ROAD', 'RD.')                ①
'100 NORTH MAIN RD.'
>>> s = '100 NORTH BROAD ROAD'
>>> s.replace('ROAD', 'RD.')                ②
'100 NORTH BRD. RD.'
>>> s[:-4] + s[-4:].replace('ROAD', 'RD.')  ③
'100 NORTH BROAD RD.'
>>> import re                               ④
>>> re.sub('ROAD$', 'RD.', s)               ⑤
'100 NORTH BROAD RD.'
  1. Meu objetivo é padronizar um endereço de rua para que 'ROAD' seja sempre abreviado como 'RD.'. À primeira vista, pensei que era simples o suficiente para que pudesse usar apenas o método string replace(). Afinal, todos os dados já estavam em maiúsculas, portanto, as incompatibilidades de maiúsculas e minúsculas não seriam um problema. E a string de pesquisa, 'ROAD', era uma constante. E neste exemplo aparentemente simples, s.replace() realmente funciona.
  2. A vida, infelizmente, está cheia de contra-exemplos, e eu rapidamente descobri este. O problema aqui é que 'ROAD' aparece duas vezes no endereço, uma vez como parte do nome da rua 'BROAD' e outra como sua própria palavra. O método replace() vê essas duas ocorrências e substitui cegamente as duas; enquanto isso, vejo meus endereços sendo destruídos.
  3. Para resolver o problema de endereços com mais de uma substring 'ROAD', você poderia recorrer a algo assim: apenas pesquise e substitua 'ROAD' nos últimos quatro caracteres do endereço (s[-4:]), e deixe a string sozinha (s[:-4]). Mas você pode ver que isso já está ficando complicado. Por exemplo, o padrão depende do comprimento da string que você está substituindo. (Se você estivesse substituindo 'STREET' com 'ST.', você precisaria usar s[:-6] e s[-6:].replace(...)). Você gostaria de voltar em seis meses e depurar isso? Eu sei que não.
  4. É hora de passar para as expressões regulares. Em Python, todas as funcionalidades relacionadas às expressões regulares estão contidas no módulo re.
  5. Dê uma olhada no primeiro parâmetro: 'ROAD$'. Esta é uma expressão regular simples que corresponde 'ROAD' apenas quando ocorre no final de uma string. O $ significa “fim da cadeia.” (Há um caractere correspondente, o circunflexo ^, que significa “início da string”). Usando a função re.sub(), você pesquisa a string s para a expressão regular 'ROAD$' e a substitui por 'RD.'. Corresponde ao ROAD no final da string s, mas não corresponde ao ROAD que é parte da palavra BROAD, porque está no meio de s.

^ corresponde ao início de uma string. $ corresponde ao final de uma string.

Continuando com minha história de limpeza de endereços, logo descobri que o exemplo anterior, correspondendo 'ROAD' no final do endereço, não era bom o suficiente, porque nem todos os endereços incluíam uma designação de rua. Alguns endereços simplesmente terminavam com o nome da rua. Eu fugia com isso na maioria das vezes, mas se o nome da rua fosse 'BROAD', a expressão regular corresponderia 'ROAD' ao final da string como parte da palavra 'BROAD', o que não é o que eu queria.

>>> s = '100 BROAD'
>>> re.sub('ROAD$', 'RD.', s)
'100 BRD.'
>>> re.sub('\\bROAD$', 'RD.', s)   ①
'100 BROAD'
>>> re.sub(r'\bROAD$', 'RD.', s)   ②
'100 BROAD'
>>> s = '100 BROAD ROAD APT. 3'
>>> re.sub(r'\bROAD$', 'RD.', s)   ③
'100 BROAD ROAD APT. 3'
>>> re.sub(r'\bROAD\b', 'RD.', s)  ④
'100 BROAD RD. APT 3'
  1. O que eu realmente queria era encontrar 'ROAD' quando estava no final da string e era sua própria palavra (e não uma parte de alguma palavra maior). Para expressar isso em uma expressão regular, você usa \b, que significa "um limite de palavra deve ocorrer bem aqui." Em Python, isso é complicado pelo fato de que o próprio caractere '\' em uma string deve ser ignorado. Isso às vezes é chamado de praga da barra invertida e é um dos motivos pelos quais as expressões regulares são mais fáceis em Perl do que em Python. Por outro lado, o Perl mistura expressões regulares com outra sintaxe, portanto, se você tiver um bug, pode ser difícil dizer se é um bug na sintaxe ou na sua expressão regular.
  2. Para contornar a praga da barra invertida, você pode usar o que é chamado de string bruta, prefixando a string com a letra r. Isso diz ao Python que nada nesta string deve ser ignorado; '\t' é um caractere de tabulação, mas na verdade r'\t' é o caractere de barra invertida \ seguido pela letra t. Eu recomendo sempre usar strings brutas ao lidar com expressões regulares; caso contrário, as coisas ficam muito confusas muito rapidamente (e as expressões regulares já são confusas o suficiente).
  3. *suspiro* Infelizmente, logo encontrei mais casos que contradiziam minha lógica. Nesse caso, o endereço da rua continha a palavra 'ROAD' como uma palavra inteira sozinha, mas não era no final, porque o endereço tinha um número de apartamento após a designação da rua. Como 'ROAD' não está bem no final da string, não corresponde, então toda a chamada para re.sub() acaba não substituindo nada, e você recebe a string original de volta, que não é o que você quer.
  4. Para resolver este problema, removi o caractere $ e adicionei outro \b. Agora, a expressão regular diz “corresponde 'ROAD' quando é uma palavra inteira sozinha em qualquer lugar da string”, seja no final, no início ou em algum lugar no meio.

Estudo de caso: números romanos

Você provavelmente já viu algarismos romanos, mesmo que não os tenha reconhecido. Você pode tê-los visto em direitos autorais de filmes antigos e programas de televisão (“Copyright MCMXLVI” em vez de “Copyright 1946”), ou nas paredes de dedicação de bibliotecas ou universidades (“estabelecido MDCCCLXXXVIII” em vez de “estabelecido 1888”). Você também pode tê-los visto em esboços e referências bibliográficas. É um sistema de representação de números que realmente data do antigo Império Romano (daí o nome).

Em algarismos romanos, existem sete caracteres que são repetidos e combinados de várias maneiras para representar números.

  • I = 1
  • V = 5
  • X = 10
  • L = 50
  • C = 100
  • D = 500
  • M = 1000

A seguir estão algumas regras gerais para a construção de algarismos romanos:

  • Às vezes, os caracteres são aditivos. I é 1, II é 2 e III é 3. VI é 6 (literalmente, “5 e 1”), VII é 7 e VIII é 8.
  • Os caracteres dezenas (I, X, C, e M) podem ser repetidos até três vezes. Em 4, você precisa subtrair do próximo caractere cincos mais alto. Você não pode representar 4 como IIII; em vez disso, é representado como IV (“1 menor que 5”). 40 é escrito como XL (“10 menos que 50”), 41 como XLI, 42 como XLII, 43 como XLIII e então 44 como XLIV (“10 menos que 50, então 1 menos que 5”).
  • Às vezes, os caracteres são... o oposto de aditivos. Ao colocar certos caracteres antes de outros, você subtrai do valor final. Por exemplo, em 9, você precisa subtrair do próximo caractere de dezenas mais alto: 8 é VIII, mas 9 é IX (“1 menor que 10”), não VIIII (já que o caractere I não pode ser repetido quatro vezes). 90 é XC, 900 é CM.
  • Os cinco caracteres não podem ser repetidos. 10 é sempre representado como X, nunca como VV. 100 é sempre C, nunca LL.
  • Os algarismos romanos são lidos da esquerda para a direita, portanto, a ordem dos caracteres é muito importante. DC é 600; CD é um número completamente diferente ( 400, “100 menor que 500”). CI é 101; IC não é nem mesmo um algarismo romano válido (porque você não pode subtrair 1 diretamente de 100; você precisaria escrevê-lo como XCIX “10menor que 100, então 1 menor que 10”).

Verificando Milhares

O que seria necessário para validar que uma string arbitrária é um numeral romano válido? Vamos pegar um dígito de cada vez. Como os algarismos romanos são sempre escritos da ordem superior para a inferior, vamos começar com a mais alta: a casa dos milhares. Para números 1000 e superiores, os milhares são representados por uma série de caracteres M.

>>> import re
>>> pattern = '^M?M?M?$'        ①
>>> re.search(pattern, 'M')     ②
<_sre.SRE_Match object at 0106FB58>
>>> re.search(pattern, 'MM')    ③
<_sre.SRE_Match object at 0106C290>
>>> re.search(pattern, 'MMM')   ④
<_sre.SRE_Match object at 0106AA38>
>>> re.search(pattern, 'MMMM')  ⑤
>>> re.search(pattern, '')      ⑥
<_sre.SRE_Match object at 0106F4A8>
  1. Esse padrão tem três partes. ^ corresponde ao que segue apenas no início da string. Se isso não fosse especificado, o padrão corresponderia independentemente de onde os caracteres M estivessem, o que não é o que você deseja. Você quer ter certeza de que os caracteres M, se estiverem lá, estão no início da string. M? opcionalmente, corresponde a um único caractere M. Como isso é repetido três vezes, você está combinando de zero a três caracteres M em uma linha. E $ corresponde ao final da string. Quando combinado com o caractere ^ no início, isso significa que o padrão deve corresponder a toda a string, sem outros caracteres antes ou depois dos caracteresb M.
  2. A essência do módulo re é a função search(), que usa uma expressão regular (padrão) e uma string ('M') para tentar fazer a correspondência com a expressão regular. Se uma correspondência for encontrada, search() retorna um objeto que possui vários métodos para descrever a correspondência; se nenhuma correspondência for encontrada, search() retorna None o valor nulo do Python. No momento, tudo o que você se importa é se o padrão corresponde, o que você pode dizer apenas olhando para o valor de retorno de search(). 'M' corresponde a esta expressão regular, porque a primeira correspondências opcionais de M foi encontrada e o segundo e terceiro caracteres opcionais M são ignorados.
  3. 'MM' corresponde porque o primeiro e o segundo caracteres opcionais M correspondem e o terceiro M é ignorado.
  4. 'MMM' corresponde porque todos os três caracteres M correspondem.
  5. 'MMMM' não corresponde. Todos os três caracteres M combinam, mas então a expressão regular insiste no final da string (por causa do caractere $), e a string ainda não termina (por causa do quarto M). Então search() retorna None.
  6. Curiosamente, uma string vazia também corresponde a essa expressão regular, uma vez que todos os caracteres M são opcionais.

Verificando Centenas

? torna um padrão opcional.

A casa das centenas é mais difícil do que a dos milhares, porque existem várias maneiras mutuamente exclusivas de expressá-la, dependendo de seu valor.

  • 100 = C
  • 200 = CC
  • 300 = CCC
  • 400 = CD
  • 500 = D
  • 600 = DC
  • 700 = DCC
  • 800 = DCCC
  • 900 = CM

Portanto, existem quatro padrões possíveis:

  • CM
  • CD
  • De zero a três caracteres C (zero se a casa das centenas for 0)
  • D, seguido de zero a três caracteres C

Os dois últimos padrões podem ser combinados:

  • um opcional D, seguido de zero a três caracteres C

Este exemplo mostra como validar a casa das centenas de um algarismo romano.

>>> import re
>>> pattern = '^M?M?M?(CM|CD|D?C?C?C?)$'  ①
>>> re.search(pattern, 'MCM')             ②
<_sre.SRE_Match object at 01070390>
>>> re.search(pattern, 'MD')              ③
<_sre.SRE_Match object at 01073A50>
>>> re.search(pattern, 'MMMCCC')          ④
<_sre.SRE_Match object at 010748A8>
>>> re.search(pattern, 'MCMC')            ⑤
>>> re.search(pattern, '')                ⑥
<_sre.SRE_Match object at 01071D98>
  1. Este padrão começa igual ao anterior, verificando o início da string (^) e depois a casa dos milhares (M?M?M?). Em seguida, ele tem a nova parte, entre parêntesis, que define um conjunto de três padrões mutuamente exclusivos, separados por barras verticais: CM, CD, e D?C?C?C? (o que é um opcional D seguido por zero a três opcionais caracteres C). O analisador de expressão regular verifica cada um desses padrões em ordem (da esquerda para a direita), pega o primeiro que corresponde e ignora o resto.
  2. 'MCM' corresponde porque o primeiro M corresponde, o segundo e o terceiro caracteres M são ignorados e as CMcorrespondências (portanto, os padrões CDe D?C?C?C?nunca são considerados). MCMé a representação numérica romana de 1900.
  3. 'MD' corresponde porque o primeiro M corresponde, o segundo e o terceiro caracteres M são ignorados e o padrão D?C?C?C? corresponde a D (cada um dos três caracteres C são opcionais e são ignorados). MD é a representação em algarismo romano de 1500.
  4. 'MMMCCC' corresponde porque todos os três caracteres M correspondem e o padrão D?C?C?C? corresponde CCC (o D é opcional e é ignorado). MMMCCC é a representação numérica romana de 3300.
  5. 'MCMC' não corresponde. O primeiro Mcorresponde, o segundo e o terceiro caracteres M são ignorados e CM corresponde, mas o $ não corresponde porque você ainda não está no final da string (ainda possui um caractere C sem correspondência). O C que não corresponder como parte do padrão D?C?C?C?, porque o mutuamente exclusivos padrão CM já tem correspondente.
  6. Curiosamente, uma string vazia ainda corresponde a esse padrão, porque todos os caracteres M são opcionais e ignorados, e a string vazia corresponde ao padrão D?C?C?C? em que todos os caracteres são opcionais e ignorados.

Uau! Vê com que rapidez as expressões regulares podem ficar desagradáveis? E você cobriu apenas os milhares e centenas de casas de algarismos romanos. Mas se você seguiu tudo isso, as casas das dezenas e unidades são fáceis, porque são exatamente o mesmo padrão. Mas vamos examinar outra maneira de expressar o padrão.

Usando a sintaxe {n,m}

{1,4} corresponde entre 1 e 4 ocorrências de um padrão.

Na seção anterior, você estava lidando com um padrão em que o mesmo caractere poderia ser repetido até três vezes. Existe outra maneira de expressar isso em expressões regulares, que algumas pessoas consideram mais legíveis. Primeiro, olhe para o método que já usamos no exemplo anterior.

>>> import re
>>> pattern = '^M?M?M?$'
>>> re.search(pattern, 'M')     ①
<_sre.SRE_Match object at 0x008EE090>
>>> re.search(pattern, 'MM')    ②
<_sre.SRE_Match object at 0x008EEB48>
>>> re.search(pattern, 'MMM')   ③
<_sre.SRE_Match object at 0x008EE090>
>>> re.search(pattern, 'MMMM')  ④
>>>
  1. Isso corresponde ao início da string e, em seguida, ao primeiro opcional M, mas não ao segundo e ao terceiro M (mas está tudo bem porque eles são opcionais) e, em seguida, ao final da string.
  2. Isso corresponde ao início da string e, em seguida, ao primeiro e ao segundo opcionais M, mas não ao terceiro M (mas está tudo bem porque é opcional) e, em seguida, ao final da string.
  3. Isso corresponde ao início da string, aos três opcionais M e ao final da string.
  4. Isso corresponde ao início da string e, em seguida, a todos os três opcionais M, mas não corresponde ao final da string (porque ainda há um M sem correspondência), portanto, o padrão não corresponde e retorna None.
>>> pattern = '^M{0,3}$'        ①
>>> re.search(pattern, 'M')     ②
<_sre.SRE_Match object at 0x008EEB48>
>>> re.search(pattern, 'MM')    ③
<_sre.SRE_Match object at 0x008EE090>
>>> re.search(pattern, 'MMM')   ④
<_sre.SRE_Match object at 0x008EEDA8>
>>> re.search(pattern, 'MMMM')  ⑤
>>>
  1. Este padrão diz: "Combine o início da string, em qualquer lugar de zero a três caracteres M e, a seguir, no final da string." O 0 e o 3 podem ser quaisquer números; se quiser combinar pelo menos um, mas não mais do que três caracteres M, você pode dizer M{1,3}.
  2. Isso corresponde ao início da string, depois a um M entre os três possíveis e, a seguir, ao final da string.
  3. Isso corresponde ao início da string, depois a dois M dos três possíveis e, a seguir, ao final da string.
  4. Isso corresponde ao início da string, depois a três M dos três possíveis e, a seguir, ao final da string.
  5. Isso corresponde ao início da string, depois a três M dos três possíveis, mas não corresponde ao final da string. A expressão regular permite até três caracteres M antes do final da string, mas você tem quatro, portanto, o padrão não corresponde e retorna None.

Verificando Dezenas e Uns

Agora, vamos expandir a expressão regular do numeral romano para cobrir a casa das dezenas e unidades. Este exemplo mostra a verificação de dezenas.

>>> pattern = '^M?M?M?(CM|CD|D?C?C?C?)(XC|XL|L?X?X?X?)$'
>>> re.search(pattern, 'MCMXL')     ①
<_sre.SRE_Match object at 0x008EEB48>
>>> re.search(pattern, 'MCML')      ②
<_sre.SRE_Match object at 0x008EEB48>
>>> re.search(pattern, 'MCMLX')     ③
<_sre.SRE_Match object at 0x008EEB48>
>>> re.search(pattern, 'MCMLXXX')   ④
<_sre.SRE_Match object at 0x008EEB48>
>>> re.search(pattern, 'MCMLXXXX')  ⑤
>>> 
  1. Isso coincide com o início da string, então o primeiro opcional M e, em seguida CM, em seguida XL, em seguida, no final da string. Lembre-se de que a sintaxe (A|B|C) significa “corresponder exatamente a um de A, B ou C”. Você corresponde XL, então você ignorar as escolhas XC e L?X?X?X?, e depois passar para o fim da string. MCMXL é a representação numérica romana de 1940.
  2. Isso corresponde ao início da string, depois ao primeiro opcional M, então CM, então L?X?X?X?. Do L?X?X?X?, ele corresponde ao L e ignora todos os três caracteres X opcionais. Em seguida, você se move para o final da string. MCML é a representação numérica romana de 1950.
  3. Isso corresponde ao início da string, depois ao primeiro opcional e M, a seguir CM, ao opcional L e ao primeiro opcional X, ignora o segundo e o terceiro opcionais X, a seguir, o final da string. MCMLX é a representação numérica romana de 1960.
  4. Isso corresponde ao início da string, depois ao primeiro opcional M, CM depois ao opcional L e aos três caracteres opcionais X e, por fim, ao final da string. MCMLXXX é a representação numérica romana de 1980.
  5. Isso coincide com o início da string, depois com o primeiro M opcional, depois com CM, depois com o L opcional e todos os três caracteres X opcionais e, a seguir, falha em coincidir com o final da string porque ainda há mais um X não contabilizado. Portanto, todo o padrão falha em corresponder e retorna Nenhum. MCMLXXXX não é um numeral romano válido.

(A|B) corresponde ao padrão A ou ao padrão B, mas não aos dois.

A expressão para a casa de uns segue o mesmo padrão. Vou poupar você dos detalhes e mostrar o resultado final.

>>> pattern = '^M?M?M?(CM|CD|D?C?C?C?)(XC|XL|L?X?X?X?)(IX|IV|V?I?I?I?)$'

Então, como é usar essa sintaxe {n,m} alternativa? Este exemplo mostra a nova sintaxe.

>>> pattern = '^M{0,3}(CM|CD|D?C{0,3})(XC|XL|L?X{0,3})(IX|IV|V?I{0,3})$'
>>> re.search(pattern, 'MDLV')              ①
<_sre.SRE_Match object at 0x008EEB48>
>>> re.search(pattern, 'MMDCLXVI')          ②
<_sre.SRE_Match object at 0x008EEB48>
>>> re.search(pattern, 'MMMDCCCLXXXVIII')   ③
<_sre.SRE_Match object at 0x008EEB48>
>>> re.search(pattern, 'I')                 ④
<_sre.SRE_Match object at 0x008EEB48>
  1. Isso corresponde ao início da string, depois a um dos três caracteres M possíveis e a D?C{0,3}. Disto, ele corresponde ao D opcional e zero dos três caracteres C possíveis. Continuando, ele corresponde a L?X{0,3} combinando o L opcional e zero dos três caracteres X possíveis. Em seguida, ele corresponde a V?I{0,3} combinando o V opcional e zero dos três caracteres I possíveis e, finalmente, o final da string. MDLV é a representação numeral romana de 1555.
  2. Isso corresponde ao início da string, depois a dois dos três caracteres M possíveis, a D?C{0,3} com um D e um dos três caracteres C possíveis; então L?X{0,3} com um L e um dos três caracteres X possíveis; então V?I{0,3} com um V e um dos três caracteres I possíveis; então o fim da string. MMDCLXVI é a representação numérica romana de 2666.
  3. Isso corresponde ao início da string, então três de três caracteres M, então D?C{0,3} com um D e três de três caracteres C; então L?X{0,3} com um L e três dos três caracteres de X; então V? I {0,3} com um V e três dos três caracteres I; então o fim da string. MMMDCCCLXXXVIII é a representação em algarismo romano de 3888 e é o algarismo romano mais longo que você pode escrever sem sintaxe estendida.
  4. Observe de perto. (Sinto-me um mágico. “Observem atentamente, crianças, vou tirar um coelho da cartola”). Isso corresponde ao início da string, depois a zero em três M, depois corresponde a D?C{0,3} ignorando o D opcional e combinando zero de três C, então casa L?X{0,3} ignorando o L opcional e combinando zero de três X, então casa V?I{0,3} pulando o V opcional e combinando um de três I. Em seguida, o fim da string. Uau.

Se você acompanhou tudo isso e entendeu na primeira tentativa, está se saindo melhor do que eu. Agora imagine tentar entender as expressões regulares de outra pessoa, no meio de uma função crítica de um grande programa. Ou até imagine voltar às suas próprias expressões regulares alguns meses depois. Eu fiz isso, e não é uma visão bonita.

Agora vamos explorar uma sintaxe alternativa que pode ajudar a manter suas expressões sustentáveis.

Expressões regulares detalhadas

Até agora, você lidou apenas com o que chamarei de expressões regulares “compactas”. Como você viu, eles são difíceis de ler e, mesmo que você descubra o que um deles faz, não há garantia de que será capaz de entendê-los seis meses depois. O que você realmente precisa é de documentação embutida.

Python permite que você faça isso com algo chamado expressões regulares detalhadas. Uma expressão regular detalhada é diferente de uma expressão regular compacta de duas maneiras:

  • O espaço em branco é ignorado. Espaços, tabulações e retornos de carro não são correspondidos como espaços, tabulações e retornos de carro. Eles não são compatíveis de forma alguma. (Se quiser corresponder a um espaço em uma expressão regular detalhada, você precisará escapar dele colocando uma barra invertida na frente dele).
  • Os comentários são ignorados. Um comentário em uma expressão regular detalhada é como um comentário no código Python: ele começa com um caractere # e vai até o final da linha. Neste caso, é um comentário dentro de uma string de várias linhas em vez de dentro do seu código-fonte, mas funciona da mesma maneira.

Isso ficará mais claro com um exemplo. Vamos revisitar a expressão regular compacta com a qual você está trabalhando e torná-la uma expressão regular detalhada. Este exemplo mostra como.

>>> pattern = '''
    ^                   # beginning of string
    M{0,3}              # thousands - 0 to 3 Ms
    (CM|CD|D?C{0,3})    # hundreds - 900 (CM), 400 (CD), 0-300 (0 to 3 Cs),
                        #            or 500-800 (D, followed by 0 to 3 Cs)
    (XC|XL|L?X{0,3})    # tens - 90 (XC), 40 (XL), 0-30 (0 to 3 Xs),
                        #        or 50-80 (L, followed by 0 to 3 Xs)
    (IX|IV|V?I{0,3})    # ones - 9 (IX), 4 (IV), 0-3 (0 to 3 Is),
                        #        or 5-8 (V, followed by 0 to 3 Is)
    $                   # end of string
    '''
>>> re.search(pattern, 'M', re.VERBOSE)                 ①
<_sre.SRE_Match object at 0x008EEB48>
>>> re.search(pattern, 'MCMLXXXIX', re.VERBOSE)         ②
<_sre.SRE_Match object at 0x008EEB48>
>>> re.search(pattern, 'MMMDCCCLXXXVIII', re.VERBOSE)   ③
<_sre.SRE_Match object at 0x008EEB48>
>>> re.search(pattern, 'M')                             ④
  1. A coisa mais importante a se lembrar ao usar expressões regulares verbosas é que você precisa passar um argumento extra ao trabalhar com elas: re.VERBOSE é uma constante definida no módulo re que sinaliza que o padrão deve ser tratado como uma expressão regular detalhada. Como você pode ver, esse padrão tem um pouco de espaço em branco (todos eles são ignorados) e vários comentários (todos eles são ignorados). Depois de ignorar o espaço em branco e os comentários, esta é exatamente a mesma expressão regular que você viu na seção anterior, mas é muito mais legível.
  2. Isso corresponde ao início da string, depois a um dos três possíveis M, então CM, L e a três dos três possíveis X, então IX, ao final da string.
  3. Isso corresponde ao início da string, então três de três possíveis M, então D e três de um possível três C, então L e três de um possível três X, então V e três de um possível três I, então o final de a string.
  4. Isso não corresponde. Por quê? Como não tem o sinalizador re.VERBOSE, a função re.search está tratando o padrão como uma expressão regular compacta, com espaços em branco significativos e marcas de hash literais. Python não consegue detectar automaticamente se uma expressão regular é prolixa ou não. Python assume que toda expressão regular é compacta, a menos que você declare explicitamente que ela é detalhada.

Estudo de caso: análise de números de telefone

\d corresponde a qualquer dígito numérico (0–9). \D corresponde a qualquer coisa, exceto dígitos.

Até agora, você se concentrou em combinar padrões inteiros. Ou o padrão corresponde ou não. Mas as expressões regulares são muito mais poderosas do que isso. Quando uma expressão regular faz jogo, você pode escolher partes específicas do mesmo. Você pode descobrir o que combinou onde.

Este exemplo veio de outro problema do mundo real que encontrei, novamente em um trabalho do dia anterior. O problema: analisar um número de telefone americano. O cliente queria poder inserir o número de forma livre (em um único campo), mas depois queria armazenar o código de área, tronco, número e, opcionalmente, um ramal separadamente no banco de dados da empresa. Eu vasculhei a web e encontrei muitos exemplos de expressões regulares que pretendiam fazer isso, mas nenhuma delas era permissiva o suficiente.

Aqui estão os números de telefone de que eu precisava para aceitar:

  • 800-555-1212
  • 800 555 1212
  • 800.555.1212
  • (800) 555-1212
  • 1-800-555-1212
  • 800-555-1212-1234
  • 800-555-1212x1234
  • 800-555-1212 ext. 1234
  • work 1-(800) 555.1212 #1234

Bastante variedade! Em cada um desses casos, preciso saber se o código de área era 800, o tronco era 555 e o restante do número de telefone era 1212. Para quem tem uma extensão, preciso saber se a extensão era 1234.

Vamos trabalhar no desenvolvimento de uma solução para análise de número de telefone. Este exemplo mostra a primeira etapa.

>>> phonePattern = re.compile(r'^(\d{3})-(\d{3})-(\d{4})$')  ①
>>> phonePattern.search('800-555-1212').groups()             ②
('800', '555', '1212')
>>> phonePattern.search('800-555-1212-1234')                 ③
>>> phonePattern.search('800-555-1212-1234').groups()        ④
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
AttributeError: 'NoneType' object has no attribute 'groups'
  1. Sempre leia as expressões regulares da esquerda para a direita. Este corresponde ao início da string, e então (\d{3}). O que é \d{3}? Bem, \d significa “qualquer dígito numérico” (0 a 9). O {3} no meio “corresponde exatamente a três dígitos numéricos”; é uma variação da sintaxe {n,m} que você viu antes. Colocar tudo entre parênteses significa “combine exatamente três dígitos numéricos, e então lembre-os como um grupo que eu posso pedir mais tarde ”. Em seguida, combine um hífen literal. Em seguida, combine outro grupo de exatamente três dígitos. Em seguida, outro hífen literal. Em seguida, outro grupo de exatamente quatro dígitos. Em seguida, combine o final da string.
  2. Para obter acesso aos grupos que o analisador de expressão regular lembrou ao longo do caminho, use o método groups() no objeto que o método search() retorna. Ele retornará uma tupla de quantos grupos foram definidos na expressão regular. Nesse caso, você definiu três grupos, um com três dígitos, um com três dígitos e um com quatro dígitos.
  3. Essa expressão regular não é a resposta final, porque não trata um número de telefone com um ramal no final. Para isso, você precisará expandir a expressão regular.
  4. E é por isso que você nunca deve “encadear” os métodos search() e groups() no código de produção. Se o método search() não retornar nenhuma correspondência, ele retornará None, não um objeto de correspondência de expressão regular. Chamar None.groups() levanta uma exceção perfeitamente óbvia: None não tem um método groups(). (Claro, é um pouco menos óbvio quando você obtém essa exceção nas profundezas do seu código. Sim, falo por experiência própria aqui).
>>> phonePattern = re.compile(r'^(\d{3})-(\d{3})-(\d{4})-(\d+)$')  ①
>>> phonePattern.search('800-555-1212-1234').groups()              ②
('800', '555', '1212', '1234')
>>> phonePattern.search('800 555 1212 1234')                       ③
>>> 
>>> phonePattern.search('800-555-1212')                            ④
>>>
  1. Esta expressão regular é quase idêntica à anterior. Assim como antes, você corresponde ao início da string, a um grupo lembrado de três dígitos, a um hífen, a um grupo de três dígitos lembrado, a um hífen e a um grupo de quatro dígitos lembrado. A novidade é que você combina outro hífen e um grupo lembrado de um ou mais dígitos e, em seguida, o final da string.
  2. O método groups() agora retorna uma tupla de quatro elementos, uma vez que a expressão regular agora define quatro grupos a serem lembrados.
  3. Infelizmente, essa expressão regular também não é a resposta final, porque presume que as diferentes partes do número de telefone estão separadas por hífens. E se eles estiverem separados por espaços, vírgulas ou pontos? Você precisa de uma solução mais geral para combinar vários tipos diferentes de separadores.
  4. Ops! Essa expressão regular não apenas não faz tudo o que você deseja, como também é um retrocesso, porque agora você não pode analisar números de telefone sem um ramal. Não era isso que você queria; se a extensão estiver lá, você quer saber o que é, mas se não estiver, você ainda quer saber quais são as diferentes partes do número principal.

O próximo exemplo mostra a expressão regular para lidar com separadores entre as diferentes partes do número de telefone.

>>> phonePattern = re.compile(r'^(\d{3})\D+(\d{3})\D+(\d{4})\D+(\d+)$')  ①
>>> phonePattern.search('800 555 1212 1234').groups()  ②
('800', '555', '1212', '1234')
>>> phonePattern.search('800-555-1212-1234').groups()  ③
('800', '555', '1212', '1234')
>>> phonePattern.search('80055512121234')              ④
>>> 
>>> phonePattern.search('800-555-1212')                ⑤
>>> 
  1. Segure seu chapéu. Você está combinando o início da string, um grupo de três dígitos, então \D+. Que raio é aquilo? Bem, \D corresponde a qualquer caractere, exceto um dígito numérico e + significa “1 ou mais”. Portanto, \D+ corresponde a um ou mais caracteres que não são dígitos. Isso é o que você está usando em vez de um hífen literal, para tentar combinar diferentes separadores.
  2. Usando \D+ em vez de - significa, agora você pode combinar números de telefone onde as partes são separadas por espaços em vez de hifens.
  3. É claro que os números de telefone separados por hífens também funcionam.
  4. Infelizmente, essa ainda não é a resposta final, porque pressupõe que haja um separador. E se o número de telefone for inserido sem nenhum espaço ou hífen?
  5. Ups! Isso ainda não resolveu o problema de exigir extensões. Agora você tem dois problemas, mas pode resolvê-los com a mesma técnica.

O próximo exemplo mostra a expressão regular para lidar com números de telefone sem separadores.

>>> phonePattern = re.compile(r'^(\d{3})\D*(\d{3})\D*(\d{4})\D*(\d*)$')  ①
>>> phonePattern.search('80055512121234').groups()      ②
('800', '555', '1212', '1234')
>>> phonePattern.search('800.555.1212 x1234').groups()  ③
('800', '555', '1212', '1234')
>>> phonePattern.search('800-555-1212').groups()        ④
('800', '555', '1212', '')
>>> phonePattern.search('(800)5551212 x1234')           ⑤
  1. A única alteração que você fez desde a última etapa foi alterar todos os + para *. Em vez de \D+ entre as partes do número de telefone, agora você combina em \D*. Lembra que + significa “1 ou mais”? Bem, *significa “zero ou mais”. Portanto, agora você deve ser capaz de analisar números de telefone mesmo quando não há nenhum caractere separador.
  2. Eis que realmente funciona. Por quê? Você combinou o início da string, depois um grupo lembrado de três dígitos (800), depois zero caracteres não numéricos, um grupo lembrado de três dígitos (555), depois zero caracteres não numéricos e um grupo lembrado de quatro dígitos (1212), em seguida, zero caracteres não numéricos, um grupo lembrado de um número arbitrário de dígitos (1234) e, em seguida, o final da string.
  3. Outras variações também funcionam agora: pontos em vez de hífens e um espaço e um x antes da extensão.
  4. Finalmente, você resolveu o outro problema antigo: as extensões são opcionais novamente. Se nenhuma extensão for encontrada, o método groups() ainda retorna uma tupla de quatro elementos, mas o quarto elemento é apenas uma string vazia.
  5. Odeio ser o portador de más notícias, mas você ainda não acabou. Qual é o problema aqui? Há um caractere extra antes do código de área, mas a expressão regular assume que o código de área é a primeira coisa no início da string. Não tem problema, você pode usar a mesma técnica de “zero ou mais caracteres não numéricos” para pular os caracteres iniciais antes do código de área.

O próximo exemplo mostra como lidar com os caracteres principais em números de telefone.

>>> phonePattern = re.compile(r'^\D*(\d{3})\D*(\d{3})\D*(\d{4})\D*(\d*)$')  ①
>>> phonePattern.search('(800)5551212 ext. 1234').groups()                  ②
('800', '555', '1212', '1234')
>>> phonePattern.search('800-555-1212').groups()                            ③
('800', '555', '1212', '')
>>> phonePattern.search('work 1-(800) 555.1212 #1234')                      ④
  1. Este é o mesmo que no exemplo anterior, exceto que agora você está combinando \D* zero ou mais caracteres não numéricos antes do primeiro grupo lembrado (o código de área). Observe que você não está se lembrando desses caracteres não numéricos (eles não estão entre parênteses). Se você encontrá-los, simplesmente os pule e comece a lembrar o código de área sempre que chegar a ele.
  2. Você pode analisar o número de telefone com êxito, mesmo com o primeiro parêntese esquerdo antes do código de área. (O parêntese direito após o código de área já foi tratado; é tratado como um separador não numérico e correspondido pelo \D* após o primeiro grupo lembrado).
  3. Apenas uma verificação de sanidade para ter certeza de que você não quebrou nada que funcionava antes. Como os caracteres iniciais são totalmente opcionais, isso corresponde ao início da string, depois a zero caracteres não numéricos, a um grupo lembrado de três dígitos (800), a um caractere não numérico (o hífen) e a um grupo de três lembrado dígitos (555), um caractere não numérico (o hífen), um grupo lembrado de quatro dígitos (1212), zero caracteres não numéricos, um grupo lembrado de zero dígitos e o final da string.
  4. É aqui que as expressões regulares me fazem querer arrancar os olhos com um objeto rombudo. Por que este número de telefone não corresponde? Porque existe um 1 antes do código de área, mas você presumiu que todos os caracteres iniciais antes do código de área eram caracteres não numéricos (\D*). Aargh.

Vamos voltar por um segundo. Até agora, todas as expressões regulares corresponderam desde o início da string. Mas agora você vê que pode haver uma quantidade indeterminada de coisas no início da string que você deseja ignorar. Em vez de tentar combinar tudo apenas para pular, vamos adotar uma abordagem diferente: não combine explicitamente o início da string. Essa abordagem é mostrada no próximo exemplo.

>>> phonePattern = re.compile(r'(\d{3})\D*(\d{3})\D*(\d{4})\D*(\d*)$')  ①
>>> phonePattern.search('work 1-(800) 555.1212 #1234').groups()         ②
('800', '555', '1212', '1234')
>>> phonePattern.search('800-555-1212').groups()                        ③
('800', '555', '1212', '')
>>> phonePattern.search('80055512121234').groups()                      ④
('800', '555', '1212', '1234')
  1. Observe a falta de ^ nesta expressão regular. Você não está mais correspondendo ao início da string. Não há nada que diga que você precisa combinar toda a entrada com sua expressão regular. O mecanismo de expressão regular fará o trabalho árduo de descobrir onde a string de entrada começa a corresponder e partir daí.
  2. Agora você pode analisar com êxito um número de telefone que inclui caracteres e um dígito à esquerda, além de qualquer número de qualquer tipo de separador em torno de cada parte do número de telefone.
  3. Verificação de sanidade. Isso ainda funciona.
  4. Isso ainda funciona também.

Vê com que rapidez uma expressão regular pode ficar fora de controle? Dê uma olhada rápida em qualquer uma das iterações anteriores. Você pode dizer a diferença entre um e o outro?

Enquanto você ainda entende a resposta final (e é a resposta final; se você descobriu um caso que não resolve, não quero saber sobre isso), vamos escrevê-la como uma expressão regular detalhada, antes você esquece por que fez as escolhas que fez.

>>> phonePattern = re.compile(r'''
        # don't match beginning of string, number can start anywhere
(\d{3})     # area code is 3 digits (e.g. '800')
\D*         # optional separator is any number of non-digits
(\d{3})     # trunk is 3 digits (e.g. '555')
\D*         # optional separator
(\d{4})     # rest of number is 4 digits (e.g. '1212')
\D*         # optional separator
(\d*)       # extension is optional and can be any number of digits
$           # end of string
''', re.VERBOSE)
>>> phonePattern.search('work 1-(800) 555.1212 #1234').groups()  ①
('800', '555', '1212', '1234')
>>> phonePattern.search('800-555-1212')                          ②
('800', '555', '1212', '')
  1. Além de estar espalhada por várias linhas, esta é exatamente a mesma expressão regular da última etapa, portanto, não é surpresa que analise as mesmas entradas.
  2. Verificação final de sanidade. Sim, ainda funciona. Você Terminou.

Resumo

Esta é apenas a ponta do iceberg do que as expressões regulares podem fazer. Em outras palavras, mesmo que você esteja completamente dominado por eles agora, acredite em mim, você não viu nada ainda.

Agora você deve estar familiarizado com as seguintes técnicas:

  • ^ corresponde ao início de uma string.
  • $ corresponde ao final de uma string.
  • \b corresponde a um limite de palavra.
  • \d corresponde a qualquer dígito numérico.
  • \D corresponde a qualquer caractere não numérico.
  • x? corresponde a um caractere x opcional (em outras palavras, corresponde a x zero ou uma vez).
  • x* corresponde a x zero ou mais vezes.
  • x+ corresponde x uma ou mais vezes.
  • x{n,m} corresponde a um caractere x pelo menos n vezes, mas não mais do que m vezes.
  • (a|b|c) corresponde exatamente a um de a, b ou c.
  • (x) em geral, é um grupo lembrado. Você pode obter o valor da correspondência usando o método groups() do objeto retornado por re.search.

As expressões regulares são extremamente poderosas, mas não são a solução correta para todos os problemas. Você deve aprender o suficiente sobre eles para saber quando são apropriados, quando resolverão seus problemas e quando causarão mais problemas do que soluções.

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