Aprenda sobre Descriptors

Beginner

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

Introdução

Neste laboratório, você aprenderá sobre descriptors em Python, um mecanismo poderoso para personalizar o acesso a atributos em objetos. Descriptors permitem que você defina como os atributos são acessados, definidos e excluídos, dando a você controle sobre o comportamento do objeto e permitindo a implementação de lógica de validação.

Os objetivos deste laboratório incluem a compreensão do protocolo de descriptor, a criação e o uso de descriptors personalizados, a implementação de validação de dados com descriptors e a otimização de implementações de descriptor. Você criará vários arquivos durante o laboratório, incluindo descrip.py, stock.py e validate.py.

Este é um Lab Guiado, que fornece instruções passo a passo para ajudá-lo a aprender e praticar. Siga as instruções cuidadosamente para completar cada etapa e ganhar experiência prática. Dados históricos mostram que este é um laboratório de nível iniciante com uma taxa de conclusão de 91%. Recebeu uma taxa de avaliações positivas de 100% dos estudantes.

Compreendendo o Protocolo de Descriptor

Nesta etapa, vamos aprender como os descriptors funcionam em Python, criando uma classe Stock simples. Descriptors em Python são um recurso poderoso que permite personalizar como os atributos são acessados, definidos e excluídos. O protocolo de descriptor consiste em três métodos especiais: __get__(), __set__() e __delete__(). Esses métodos definem como o descriptor se comporta quando um atributo é acessado, recebe um valor ou é excluído, respectivamente.

Primeiro, precisamos criar um novo arquivo chamado stock.py no diretório do projeto. Este arquivo conterá nossa classe Stock. Aqui está o código que você deve colocar no arquivo stock.py:

## stock.py

class Stock:
    __slots__ = ['_name', '_shares', '_price']

    def __init__(self, name, shares, price):
        self.name = name
        self.shares = shares
        self.price = price

    @property
    def name(self):
        return self._name

    @name.setter
    def name(self, value):
        if not isinstance(value, str):
            raise TypeError('Expected a string')
        self._name = value

    @property
    def shares(self):
        return self._shares

    @shares.setter
    def shares(self, value):
        if not isinstance(value, int):
            raise TypeError('Expected an integer')
        if value < 0:
            raise ValueError('Expected a positive value')
        self._shares = value

    @property
    def price(self):
        return self._price

    @price.setter
    def price(self, value):
        if not isinstance(value, (int, float)):
            raise TypeError('Expected a number')
        if value < 0:
            raise ValueError('Expected a positive value')
        self._price = value

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

    def sell(self, amount):
        self.shares -= amount

    def __repr__(self):
        return f'Stock({self.name!r}, {self.shares!r}, {self.price!r})'

Nesta classe Stock, estamos usando o decorador @property para definir métodos getter e setter para os atributos name, shares e price. Esses métodos getter e setter atuam como descriptors, o que significa que controlam como esses atributos são acessados e definidos. Por exemplo, os métodos setter validam os valores de entrada para garantir que sejam do tipo correto e dentro de uma faixa aceitável.

Agora que temos nosso arquivo stock.py pronto, vamos abrir um shell Python para experimentar a classe Stock e ver como os descriptors funcionam na prática. Para fazer isso, abra seu terminal e execute os seguintes comandos:

cd ~/project
python3 -i stock.py

A opção -i no comando python3 diz ao Python para iniciar um shell interativo após executar o arquivo stock.py. Dessa forma, podemos interagir diretamente com a classe Stock que acabamos de definir.

No shell Python, vamos criar um objeto de ação e tentar acessar seus atributos. Veja como você pode fazer isso:

s = Stock('GOOG', 100, 490.10)
s.name      ## Should return 'GOOG'
s.shares    ## Should return 100

Quando você acessa os atributos name e shares do objeto s, o Python está realmente usando o método __get__ do descriptor nos bastidores. Os decoradores @property em nossa classe são implementados usando descriptors, o que significa que eles lidam com o acesso e a atribuição de atributos de maneira controlada.

Vamos dar uma olhada mais de perto no dicionário da classe para ver os objetos descriptor. O dicionário da classe contém todos os atributos e métodos definidos na classe. Você pode visualizar as chaves do dicionário da classe usando o seguinte código:

Stock.__dict__.keys()

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

dict_keys(['__module__', '__slots__', '__init__', 'name', '_name', 'shares', '_shares', 'price', '_price', 'cost', 'sell', '__repr__', '__doc__'])

As chaves name, shares e price representam os objetos descriptor criados pelos decoradores @property.

Agora, vamos examinar como os descriptors funcionam chamando manualmente seus métodos. Usaremos o descriptor shares como exemplo. Veja como você pode fazer isso:

## Get the descriptor object for 'shares'
q = Stock.__dict__['shares']

## Use the __get__ method to retrieve the value
q.__get__(s, Stock)  ## Should return 100

## Use the __set__ method to set a new value
q.__set__(s, 75)
s.shares  ## Should now be 75

## Try to set an invalid value
try:
    q.__set__(s, '75')  ## Should raise TypeError
except TypeError as e:
    print(f"Error: {e}")

Quando você acessa um atributo como s.shares, o Python chama o método __get__ do descriptor para recuperar o valor. Quando você atribui um valor como s.shares = 75, o Python chama o método __set__ do descriptor. O descriptor pode então validar os dados e gerar erros se o valor de entrada não for válido.

Depois de terminar de experimentar a classe Stock e os descriptors, você pode sair do shell Python executando o seguinte comando:

exit()

Criando Descriptors Personalizados

Nesta etapa, vamos criar nossa própria classe descriptor. Mas, primeiro, vamos entender o que é um descriptor. Um descriptor é um objeto Python que implementa o protocolo de descriptor, que consiste nos métodos __get__, __set__ e __delete__. Esses métodos permitem que o descriptor gerencie como um atributo é acessado, definido e excluído. Ao criar nossa própria classe descriptor, podemos entender melhor como esse protocolo funciona.

Crie um novo arquivo chamado descrip.py no diretório do projeto. Este arquivo conterá nossa classe descriptor personalizada. Aqui está o código:

## descrip.py

class Descriptor:
    def __init__(self, name):
        self.name = name

    def __get__(self, instance, cls):
        print(f'{self.name}:__get__')
        ## In a real descriptor, you would return a value here

    def __set__(self, instance, value):
        print(f'{self.name}:__set__ {value}')
        ## In a real descriptor, you would store the value here

    def __delete__(self, instance):
        print(f'{self.name}:__delete__')
        ## In a real descriptor, you would delete the value here

Na classe Descriptor, o método __init__ inicializa o descriptor com um nome. O método __get__ é chamado quando o atributo é acessado, o método __set__ é chamado quando o atributo é definido e o método __delete__ é chamado quando o atributo é excluído.

Agora, vamos criar um arquivo de teste para experimentar nosso descriptor personalizado. Isso nos ajudará a ver como o descriptor se comporta em diferentes cenários. Crie um arquivo chamado test_descrip.py com o seguinte código:

## test_descrip.py

from descrip import Descriptor

class Foo:
    a = Descriptor('a')
    b = Descriptor('b')
    c = Descriptor('c')

## Create an instance and try accessing the attributes
if __name__ == '__main__':
    f = Foo()
    print("Accessing attribute f.a:")
    f.a

    print("\nAccessing attribute f.b:")
    f.b

    print("\nSetting attribute f.a = 23:")
    f.a = 23

    print("\nDeleting attribute f.a:")
    del f.a

No arquivo test_descrip.py, importamos a classe Descriptor de descrip.py. Em seguida, criamos uma classe Foo com três atributos a, b e c, cada um gerenciado por um descriptor. Criamos uma instância de Foo e realizamos operações como acessar, definir e excluir atributos para ver como os métodos do descriptor são chamados.

Agora, vamos executar este arquivo de teste para ver os descriptors em ação. Abra seu terminal, navegue até o diretório do projeto e execute o arquivo de teste usando os seguintes comandos:

cd ~/project
python3 test_descrip.py

Você deve ver uma saída como esta:

Accessing attribute f.a:
a:__get__

Accessing attribute f.b:
b:__get__

Setting attribute f.a = 23:
a:__set__ 23

Deleting attribute f.a:
a:__delete__

Como você pode ver, toda vez que você acessa, define ou exclui um atributo que é gerenciado por um descriptor, o método mágico correspondente (__get__, __set__ ou __delete__) é chamado.

Vamos também examinar nosso descriptor interativamente. Isso nos permitirá testar o descriptor em tempo real e ver os resultados imediatamente. Abra seu terminal, navegue até o diretório do projeto e inicie uma sessão Python interativa com o arquivo descrip.py:

cd ~/project
python3 -i descrip.py

Agora, digite estes comandos na sessão Python interativa para ver como o protocolo de descriptor funciona:

class Foo:
    a = Descriptor('a')
    b = Descriptor('b')
    c = Descriptor('c')

f = Foo()
f.a         ## Should call __get__
f.b         ## Should call __get__
f.a = 23    ## Should call __set__
del f.a     ## Should call __delete__
exit()

A principal ideia aqui é que os descriptors fornecem uma maneira de interceptar e personalizar o acesso a atributos. Isso os torna poderosos para implementar validação de dados, atributos computados e outros comportamentos avançados. Ao usar descriptors, você pode ter mais controle sobre como os atributos da sua classe são acessados, definidos e excluídos.

Implementando Validadores Usando Descriptors

Nesta etapa, vamos criar um sistema de validação usando descriptors. Mas, primeiro, vamos entender o que são descriptors e por que estamos usando-os. Descriptors são objetos Python que implementam o protocolo de descriptor, que inclui os métodos __get__, __set__ ou __delete__. Eles permitem que você personalize como um atributo é acessado, definido ou excluído em um objeto. Em nosso caso, usaremos descriptors para criar um sistema de validação que garante a integridade dos dados. Isso significa que os dados armazenados em nossos objetos sempre atenderão a certos critérios, como ser de um tipo específico ou ter um valor positivo.

Agora, vamos começar a criar nosso sistema de validação. Criaremos um novo arquivo chamado validate.py no diretório do projeto. Este arquivo conterá as classes que implementam nossos validadores.

## validate.py

class Validator:
    def __init__(self, name):
        self.name = name

    @classmethod
    def check(cls, value):
        return value

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


class String(Validator):
    expected_type = str

    @classmethod
    def check(cls, value):
        if not isinstance(value, cls.expected_type):
            raise TypeError(f'Expected {cls.expected_type}')
        return value


class PositiveInteger(Validator):
    expected_type = int

    @classmethod
    def check(cls, value):
        if not isinstance(value, cls.expected_type):
            raise TypeError(f'Expected {cls.expected_type}')
        if value < 0:
            raise ValueError('Expected a positive value')
        return value


class PositiveFloat(Validator):
    expected_type = float

    @classmethod
    def check(cls, value):
        if not isinstance(value, (int, float)):
            raise TypeError('Expected a number')
        if value < 0:
            raise ValueError('Expected a positive value')
        return float(value)

No arquivo validate.py, primeiro definimos uma classe base chamada Validator. Esta classe tem um método __init__ que recebe um parâmetro name, que será usado para identificar o atributo que está sendo validado. O método check é um método de classe que simplesmente retorna o valor passado para ele. O método __set__ é um método descriptor que é chamado quando um atributo é definido em um objeto. Ele chama o método check para validar o valor e, em seguida, armazena o valor validado no dicionário do objeto.

Em seguida, definimos três subclasses de Validator: String, PositiveInteger e PositiveFloat. Cada uma dessas subclasses substitui o método check para realizar verificações de validação específicas. A classe String verifica se o valor é uma string, a classe PositiveInteger verifica se o valor é um inteiro positivo e a classe PositiveFloat verifica se o valor é um número positivo (seja um inteiro ou um float).

Agora que definimos nossos validadores, vamos modificar nossa classe Stock para usar esses validadores. Criaremos um novo arquivo chamado stock_with_validators.py e importaremos os validadores do arquivo validate.py.

## stock_with_validators.py

from validate import String, PositiveInteger, PositiveFloat

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

    def __init__(self, name, shares, price):
        self.name = name
        self.shares = shares
        self.price = price

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

    def sell(self, amount):
        self.shares -= amount

    def __repr__(self):
        return f'Stock({self.name!r}, {self.shares!r}, {self.price!r})'

No arquivo stock_with_validators.py, definimos a classe Stock e usamos os validadores como atributos de classe. Isso significa que sempre que um atributo é definido em um objeto Stock, o método __set__ do validador correspondente será chamado para validar o valor. O método __init__ inicializa os atributos do objeto Stock, e os métodos cost, sell e __repr__ fornecem funcionalidade adicional.

Agora, vamos testar nossa classe Stock baseada em validadores. Abriremos um terminal, navegaremos até o diretório do projeto e executaremos o arquivo stock_with_validators.py no modo interativo.

cd ~/project
python3 -i stock_with_validators.py

Depois que o interpretador Python estiver em execução, podemos tentar alguns comandos para testar o sistema de validação.

## Create a stock with valid values
s = Stock('GOOG', 100, 490.10)
print(s.name)    ## Should return 'GOOG'
print(s.shares)  ## Should return 100
print(s.price)   ## Should return 490.1

## Try changing to valid values
s.shares = 75
print(s.shares)  ## Should return 75

## Try setting invalid values
try:
    s.shares = '75'  ## Should raise TypeError
except TypeError as e:
    print(f"Error setting shares to string: {e}")

try:
    s.shares = -50  ## Should raise ValueError
except ValueError as e:
    print(f"Error setting negative shares: {e}")

exit()

No código de teste, primeiro criamos um objeto Stock com valores válidos e imprimimos seus atributos para verificar se eles estão definidos corretamente. Em seguida, tentamos alterar o atributo shares para um valor válido e imprimimos novamente para confirmar a alteração. Finalmente, tentamos definir o atributo shares para um valor inválido (uma string e um número negativo) e capturamos as exceções que são geradas pelos validadores.

Observe como nosso código agora está muito mais limpo. A classe Stock não precisa mais implementar todos aqueles métodos de propriedade - os validadores lidam com toda a verificação de tipo e restrições.

Os descriptors nos permitiram criar um sistema de validação reutilizável que pode ser aplicado a qualquer atributo de classe. Este é um padrão poderoso para manter a integridade dos dados em sua aplicação.

Melhorando a Implementação de Descriptors

Nesta etapa, vamos aprimorar nossa implementação de descriptor. Você pode ter notado que, em alguns casos, especificamos nomes de forma redundante. Isso pode tornar nosso código um pouco confuso e mais difícil de manter. Para resolver esse problema, usaremos o método __set_name__, um recurso útil introduzido no Python 3.6.

O método __set_name__ é chamado automaticamente quando a classe é definida. Sua principal função é definir o nome do descriptor para nós, para que não precisemos fazê-lo manualmente toda vez. Isso tornará nosso código mais limpo e eficiente.

Agora, vamos atualizar seu arquivo validate.py para incluir o método __set_name__. Veja como o código atualizado ficará:

## validate.py

class Validator:
    def __init__(self, name=None):
        self.name = name

    def __set_name__(self, cls, name):
        ## This gets called when the class is defined
        ## It automatically sets the name of the descriptor
        if self.name is None:
            self.name = name

    @classmethod
    def check(cls, value):
        return value

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


class String(Validator):
    expected_type = str

    @classmethod
    def check(cls, value):
        if not isinstance(value, cls.expected_type):
            raise TypeError(f'Expected {cls.expected_type}')
        return value


class PositiveInteger(Validator):
    expected_type = int

    @classmethod
    def check(cls, value):
        if not isinstance(value, cls.expected_type):
            raise TypeError(f'Expected {cls.expected_type}')
        if value < 0:
            raise ValueError('Expected a positive value')
        return value


class PositiveFloat(Validator):
    expected_type = float

    @classmethod
    def check(cls, value):
        if not isinstance(value, (int, float)):
            raise TypeError('Expected a number')
        if value < 0:
            raise ValueError('Expected a positive value')
        return float(value)

No código acima, o método __set_name__ na classe Validator verifica se o atributo name é None. Se for, ele define o name para o nome real do atributo usado na definição da classe. Dessa forma, não precisamos especificar o nome explicitamente ao criar instâncias das classes descriptor.

Agora que atualizamos o arquivo validate.py, podemos criar uma versão aprimorada de nossa classe Stock. Esta nova versão não exigirá que especifiquemos os nomes de forma redundante. Aqui está o código para a classe Stock aprimorada:

## improved_stock.py

from validate import String, PositiveInteger, PositiveFloat

class Stock:
    name = String()  ## No need to specify 'name' anymore
    shares = PositiveInteger()
    price = PositiveFloat()

    def __init__(self, name, shares, price):
        self.name = name
        self.shares = shares
        self.price = price

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

    def sell(self, amount):
        self.shares -= amount

    def __repr__(self):
        return f'Stock({self.name!r}, {self.shares!r}, {self.price!r})'

Nesta classe Stock, simplesmente criamos instâncias das classes descriptor String, PositiveInteger e PositiveFloat sem especificar os nomes. O método __set_name__ na classe Validator cuidará de definir os nomes automaticamente.

Vamos testar nossa classe Stock aprimorada. Primeiro, abra seu terminal e navegue até o diretório do projeto. Em seguida, execute o arquivo improved_stock.py no modo interativo. Aqui estão os comandos para fazer isso:

cd ~/project
python3 -i improved_stock.py

Depois de entrar na sessão Python interativa, você pode tentar os seguintes comandos para testar a funcionalidade da classe Stock:

s = Stock('GOOG', 100, 490.10)
print(s.name)    ## Should return 'GOOG'
print(s.shares)  ## Should return 100
print(s.price)   ## Should return 490.1

## Try changing values
s.shares = 75
print(s.shares)  ## Should return 75

## Try invalid values
try:
    s.shares = '75'  ## Should raise TypeError
except TypeError as e:
    print(f"Error: {e}")

try:
    s.price = -10.5  ## Should raise ValueError
except ValueError as e:
    print(f"Error: {e}")

exit()

Esses comandos criam uma instância da classe Stock, imprimem seus atributos, alteram o valor de um atributo e, em seguida, tentam definir valores inválidos para ver se os erros apropriados são gerados.

O método __set_name__ define automaticamente o nome do descriptor quando a classe é definida. Isso torna seu código mais limpo e menos redundante, pois você não precisa mais especificar o nome do atributo duas vezes.

Essa melhoria demonstra como o protocolo de descriptor do Python continua a evoluir, tornando mais fácil escrever um código limpo e de fácil manutenção.

Resumo

Neste laboratório, você aprendeu sobre descriptors Python, um recurso poderoso que permite a personalização do acesso a atributos em classes. Você explorou o protocolo de descriptor, incluindo os métodos __get__, __set__ e __delete__. Você também criou uma classe descriptor básica para interceptar o acesso a atributos e usou descriptors para implementar um sistema de validação para integridade de dados.

Além disso, você aprimorou seus descriptors com o método __set_name__ para reduzir a redundância. Descriptors são amplamente utilizados em bibliotecas e frameworks Python, como Django e SQLAlchemy. Compreendê-los fornece insights mais profundos sobre Python e ajuda você a escrever um código mais elegante e de fácil manutenção.