Metaclasses em Ação

Beginner

This tutorial is from open-source community. Access the source code

Introdução

Neste laboratório, você aprenderá sobre metaclasses, um dos recursos mais poderosos e avançados do Python. Metaclasses permitem que você personalize a criação de classes, dando a você controle sobre como as classes são definidas e instanciadas. Você explorará metaclasses através de exemplos práticos.

Os objetivos deste laboratório são entender o que são metaclasses e como funcionam, implementar uma metaclasse para resolver problemas reais de programação e explorar as aplicações práticas de metaclasses em Python. Os arquivos modificados neste laboratório são structure.py e validate.py.

Entendendo o Problema

Antes de começarmos a explorar metaclasses, é importante entender o problema que pretendemos resolver. Em programação, frequentemente precisamos criar estruturas com tipos específicos para seus atributos. Em nosso trabalho anterior, desenvolvemos um sistema para estruturas com verificação de tipos. Este sistema nos permite definir classes onde cada atributo tem um tipo específico, e os valores atribuídos a esses atributos são validados de acordo com esse tipo.

Aqui está um exemplo de como usamos este sistema para criar uma classe Stock:

from validate import String, PositiveInteger, PositiveFloat
from structure import Structure

class Stock(Structure):
    name = String()
    shares = PositiveInteger()
    price = PositiveFloat()

    @property
    def cost(self):
        return self.shares * self.price

    def sell(self, nshares: PositiveInteger):
        self.shares -= nshares

Neste código, primeiro importamos os tipos de validadores (String, PositiveInteger, PositiveFloat) do módulo validate e a classe Structure do módulo structure. Em seguida, definimos a classe Stock, que herda de Structure. Dentro da classe Stock, definimos atributos com tipos de validadores específicos. Por exemplo, o atributo name deve ser uma string, shares deve ser um inteiro positivo e price deve ser um float positivo.

No entanto, há um problema com essa abordagem. Precisamos importar todos os tipos de validadores no topo do nosso arquivo. À medida que adicionamos mais e mais tipos de validadores em um cenário do mundo real, essas importações podem se tornar muito longas e difíceis de gerenciar. Isso pode nos levar a usar from validate import *, o que geralmente é considerado uma má prática porque pode causar conflitos de nomes e tornar o código menos legível.

Para entender nosso ponto de partida, vamos dar uma olhada na classe Structure. Você precisa abrir o arquivo structure.py no editor e examinar seu conteúdo. Isso ajudará você a ver como o tratamento da estrutura básica é implementado antes de adicionarmos a funcionalidade de metaclasse.

code structure.py

Ao abrir o arquivo, você verá uma implementação básica da classe Structure. Esta classe é responsável por lidar com a inicialização de atributos, mas ainda não possui nenhuma funcionalidade de metaclasse.

Em seguida, vamos examinar as classes de validadores. Essas classes são definidas no arquivo validate.py. Elas já possuem funcionalidade de descritor, o que significa que podem controlar como os atributos são acessados e definidos. Mas precisaremos aprimorá-las para resolver o problema de importação que discutimos anteriormente.

code validate.py

Ao olhar para essas classes de validadores, você terá uma melhor compreensão de como o processo de validação funciona e quais alterações precisamos fazer para melhorar nosso código.

Coletando Tipos de Validadores

Em Python, validadores são classes que nos ajudam a garantir que os dados atendam a certos critérios. Nossa primeira tarefa neste experimento é modificar a classe base Validator para que ela possa coletar todas as suas subclasses. Por que precisamos fazer isso? Bem, ao coletar todas as subclasses de validadores, podemos criar um namespace que contém todos os tipos de validadores. Mais tarde, injetaremos este namespace na classe Structure, o que facilitará o gerenciamento e o uso de diferentes validadores.

Agora, vamos começar a trabalhar no código. Abra o arquivo validate.py. Você pode usar o seguinte comando no terminal para abri-lo:

code validate.py

Depois que o arquivo estiver aberto, precisamos adicionar um dicionário em nível de classe e um método __init_subclass__() à classe Validator. O dicionário em nível de classe será usado para armazenar todas as subclasses de validadores, e o método __init_subclass__() é um método especial em Python que é chamado toda vez que uma subclasse da classe atual é definida.

Adicione o seguinte código à classe Validator, logo após a definição da classe:

## Adicione isso à classe Validator em validate.py
validators = {}  ## Dicionário para coletar todas as subclasses de validadores

@classmethod
def __init_subclass__(cls):
    """Registra cada subclasse de validador no dicionário de validadores"""
    Validator.validators[cls.__name__] = cls

Depois de adicionar o código, sua classe Validator modificada agora deve se parecer com isto:

class Validator:
    validators = {}  ## Dicionário para coletar todas as subclasses de validadores

    @classmethod
    def __init_subclass__(cls):
        """Registra cada subclasse de validador no dicionário de validadores"""
        Validator.validators[cls.__name__] = cls

    def __set_name__(self, owner, name):
        self.name = name

    def __get__(self, instance, owner):
        if instance is None:
            return self
        return instance.__dict__[self.name]

    def __set__(self, instance, value):
        self.validate(value)
        instance.__dict__[self.name] = value

    def validate(self, value):
        pass

Agora, toda vez que um novo tipo de validador é definido, como String ou PositiveInteger, o Python chamará automaticamente o método __init_subclass__(). Este método, então, adicionará a nova subclasse de validador ao dicionário validators, usando o nome da classe como chave.

Vamos testar se nosso código funciona. Criaremos um script Python simples para verificar o conteúdo do dicionário validators. Você pode executar o seguinte comando no terminal:

python3 -c "from validate import Validator; print(Validator.validators)"

Se tudo funcionar corretamente, você deverá ver uma saída semelhante a esta, mostrando todos os tipos de validadores e suas classes correspondentes:

{'Typed': <class 'validate.Typed'>, 'Positive': <class 'validate.Positive'>, 'NonEmpty': <class 'validate.NonEmpty'>, 'String': <class 'validate.String'>, 'Integer': <class 'validate.Integer'>, 'Float': <class 'validate.Float'>, 'PositiveInteger': <class 'validate.PositiveInteger'>, 'PositiveFloat': <class 'validate.PositiveFloat'>, 'NonEmptyString': <class 'validate.NonEmptyString'>}

Agora que temos um dicionário contendo todos os nossos tipos de validadores, podemos usá-lo na próxima etapa para criar nossa metaclasse.

Criando a Metaclasse StructureMeta

Agora, vamos falar sobre o que faremos a seguir. Encontramos uma maneira de coletar todos os tipos de validadores. Nossa próxima etapa é criar uma metaclasse. Mas o que exatamente é uma metaclasse? Em Python, uma metaclasse é um tipo especial de classe. Suas instâncias são as próprias classes. Isso significa que uma metaclasse pode controlar como uma classe é criada. Ela pode gerenciar o namespace onde os atributos da classe são definidos.

Em nossa situação, queremos criar uma metaclasse que tornará os tipos de validadores disponíveis quando definirmos uma subclasse Structure. Não queremos ter que importar esses tipos de validadores explicitamente toda vez.

Vamos começar abrindo o arquivo structure.py novamente. Você pode usar o seguinte comando para abri-lo:

code structure.py

Depois que o arquivo estiver aberto, precisamos adicionar algum código no topo, antes da definição da classe Structure. Este código definirá nossa metaclasse.

from validate import Validator
from collections import ChainMap

class StructureMeta(type):
    @classmethod
    def __prepare__(meta, clsname, bases):
        """Prepara o namespace para a classe que está sendo definida"""
        return ChainMap({}, Validator.validators)

    @staticmethod
    def __new__(meta, name, bases, methods):
        """Cria a nova classe usando apenas o namespace local"""
        methods = methods.maps[0]  ## Extrai o namespace local
        return super().__new__(meta, name, bases, methods)

Agora que definimos a metaclasse, precisamos modificar a classe Structure para usá-la. Dessa forma, qualquer classe que herde de Structure se beneficiará da funcionalidade da metaclasse.

class Structure(metaclass=StructureMeta):
    _fields = []

    def __init__(self, *args, **kwargs):
        if len(args) > len(self._fields):
            raise TypeError(f'Esperava {len(self._fields)} argumentos')

        ## Define todos os argumentos posicionais
        for name, val in zip(self._fields, args):
            setattr(self, name, val)

        ## Define os argumentos de palavra-chave restantes
        for name, val in kwargs.items():
            if name not in self._fields:
                raise TypeError(f'Argumento inválido: {name}')
            setattr(self, name, val)

    def __repr__(self):
        values = [getattr(self, name) for name in self._fields]
        args_str = ','.join(repr(val) for val in values)
        return f'{type(self).__name__}({args_str})'

Vamos detalhar o que este código faz:

  1. O método __prepare__() é um método especial em Python. Ele é chamado antes que a classe seja criada. Sua função é preparar o namespace onde os atributos da classe serão definidos. Usamos ChainMap aqui. ChainMap é uma ferramenta útil que cria um dicionário em camadas. Em nosso caso, ele inclui nossos tipos de validadores, tornando-os acessíveis no namespace da classe.

  2. O método __new__() é responsável por criar a nova classe. Extraímos apenas o namespace local, que é o primeiro dicionário no ChainMap. Descartamos o dicionário de validadores porque já tornamos os tipos de validadores disponíveis no namespace.

Com esta configuração, qualquer classe que herde de Structure terá acesso a todos os tipos de validadores sem a necessidade de importá-los explicitamente.

Agora, vamos testar nossa implementação. Criaremos uma classe Stock usando nossa classe base Structure aprimorada.

cat > stock.py << EOF
from structure import Structure

class Stock(Structure):
    name = String()
    shares = PositiveInteger()
    price = PositiveFloat()

    @property
    def cost(self):
        return self.shares * self.price

    def sell(self, nshares: PositiveInteger):
        self.shares -= nshares
EOF

Se nossa metaclasse estiver funcionando corretamente, devemos ser capazes de definir a classe Stock sem importar os tipos de validadores. Isso ocorre porque a metaclasse já os tornou disponíveis no namespace.

Testando Nossa Implementação

Agora que implementamos nossa metaclasse e modificamos a classe Structure, é hora de testar nossa implementação. Testar é crucial porque nos ajuda a garantir que tudo está funcionando corretamente. Ao executar testes, podemos detectar quaisquer problemas potenciais no início e garantir que nosso código se comporte conforme o esperado.

Primeiro, vamos executar os testes unitários para ver se nossa classe Stock funciona como esperado. Testes unitários são testes pequenos e isolados que verificam partes individuais do nosso código. Neste caso, queremos ter certeza de que a classe Stock funciona corretamente. Para executar os testes unitários, usaremos o seguinte comando no terminal:

python3 teststock.py

Se tudo estiver funcionando corretamente, todos os testes devem passar sem erros. Quando os testes são executados com sucesso, a saída deve ser semelhante a esta:

........
----------------------------------------------------------------------
Ran 6 tests in 0.001s

OK

Os pontos representam cada teste que passou, e o OK final indica que todos os testes foram bem-sucedidos.

Agora, vamos testar nossa classe Stock com alguns dados reais e a funcionalidade de formatação de tabela. Isso nos dará um cenário mais real para ver como nossa classe Stock interage com os dados e como a formatação da tabela funciona. Usaremos o seguinte comando no terminal:

python3 -c "
from stock import Stock
from reader import read_csv_as_instances
from tableformat import create_formatter, print_table

## Lê os dados do portfólio em instâncias Stock
portfolio = read_csv_as_instances('portfolio.csv', Stock)
print('Portfolio:')
print(portfolio)

## Formata e imprime os dados do portfólio
print('\nTabela formatada:')
formatter = create_formatter('text')
print_table(portfolio, ['name', 'shares', 'price'], formatter)
"

Neste código, primeiro importamos as classes e funções necessárias. Em seguida, lemos dados de um arquivo CSV em instâncias Stock. Depois disso, imprimimos os dados do portfólio e, em seguida, formatamos em uma tabela e imprimimos a tabela formatada.

Você deve ver uma saída semelhante a esta:

Portfolio:
[Stock('AA',100,32.2), Stock('IBM',50,91.1), Stock('CAT',150,83.44), Stock('MSFT',200,51.23), Stock('GE',95,40.37), Stock('MSFT',50,65.1), Stock('IBM',100,70.44)]

Formatted table:
      name     shares      price
---------- ---------- ----------
        AA        100       32.2
       IBM         50       91.1
       CAT        150      83.44
      MSFT        200      51.23
        GE         95      40.37
      MSFT         50       65.1
       IBM        100      70.44

Reserve um momento para apreciar o que realizamos:

  1. Criamos um mecanismo para coletar automaticamente todos os tipos de validadores. Isso significa que não precisamos acompanhar manualmente todos os validadores, o que economiza tempo e reduz a chance de erros.
  2. Implementamos uma metaclasse que injeta esses tipos no namespace das subclasses Structure. Isso permite que as subclasses usem esses validadores sem ter que importá-los explicitamente.
  3. Eliminamos a necessidade de importações explícitas de tipos de validadores. Isso torna nosso código mais limpo e mais fácil de ler.
  4. Tudo isso acontece nos bastidores, tornando o código para definir novas estruturas limpo e simples.

O arquivo stock.py final é notavelmente limpo em comparação com o que seria sem nossa metaclasse:

from structure import Structure

class Stock(Structure):
    name = String()
    shares = PositiveInteger()
    price = PositiveFloat()

    @property
    def cost(self):
        return self.shares * self.price

    def sell(self, nshares: PositiveInteger):
        self.shares -= nshares

Sem a necessidade de importar os tipos de validadores diretamente, o código é mais conciso e fácil de manter. Este é um ótimo exemplo de como as metaclasses podem melhorar a qualidade do nosso código.

Resumo

Neste laboratório, você aprendeu a aproveitar o poder das metaclasses em Python. Primeiro, você entendeu o desafio de gerenciar as importações para os tipos de validadores. Em seguida, você modificou a classe Validator para reunir automaticamente suas subclasses e criou uma metaclasse StructureMeta para injetar os tipos de validadores nos namespaces das classes. Finalmente, você testou a implementação com uma classe Stock, eliminando a necessidade de importações explícitas.

As metaclasses, um recurso avançado do Python, permitem a personalização do processo de criação de classes. Embora devam ser usadas com moderação, elas oferecem soluções elegantes para problemas específicos, como demonstrado neste laboratório. Ao usar uma metaclasse, você simplificou o código para definir estruturas com atributos validados, removeu a necessidade de importações explícitas de tipos de validadores e criou uma API mais sustentável e elegante. Este padrão de injeção de namespace baseado em metaclasse pode ser aplicado a outros cenários para uma API de usuário simplificada.