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

0 comentários:

Postar um comentário