Personalizando o Acesso a Atributos

Beginner

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

Introdução

Neste laboratório, você aprenderá sobre um aspecto fundamental da programação orientada a objetos do Python: o acesso a atributos. O Python permite que os desenvolvedores personalizem como os atributos são acessados, definidos e gerenciados em classes por meio de métodos especiais. Isso oferece maneiras poderosas de controlar o comportamento dos objetos.

Além disso, você aprenderá como personalizar o acesso a atributos em classes Python, entender a diferença entre delegação e herança e praticar a implementação de gerenciamento de atributos personalizados em objetos Python.

Compreendendo __setattr__ para Controle de Atributos

No Python, existem métodos especiais que permitem personalizar como os atributos de um objeto são acessados e modificados. Um desses métodos importantes é __setattr__(). Este método entra em ação toda vez que você tenta atribuir um valor a um atributo de um objeto. Ele oferece a capacidade de ter controle preciso sobre o processo de atribuição de atributos.

O que é __setattr__?

O método __setattr__(self, name, value) atua como um interceptador para todas as atribuições de atributos. Quando você escreve uma simples instrução de atribuição como obj.attr = value, o Python não apenas atribui o valor diretamente. Em vez disso, ele internamente chama obj.__setattr__("attr", value). Esse mecanismo fornece a você o poder de decidir o que deve acontecer durante a atribuição do atributo.

Agora, vamos ver um exemplo prático de como podemos usar __setattr__ para restringir quais atributos podem ser definidos em uma classe.

Passo 1: Crie um novo arquivo

Primeiro, abra um novo arquivo no WebIDE. Você pode fazer isso clicando no menu "File" e selecionando "New File". Nomeie este arquivo restricted_stock.py e salve-o no diretório /home/labex/project. Este arquivo conterá a definição da classe onde usaremos __setattr__ para controlar a atribuição de atributos.

Passo 2: Adicione código a restricted_stock.py

Adicione o seguinte código ao arquivo restricted_stock.py. Este código define uma classe RestrictedStock.

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

    def __setattr__(self, name, value):
        ## Only allow specific attributes
        if name not in {'name', 'shares', 'price'}:
            raise AttributeError(f'Cannot set attribute {name}')

        ## If attribute is allowed, set it using the parent method
        super().__setattr__(name, value)

No método __init__, inicializamos o objeto com os atributos name, shares e price. O método __setattr__ verifica se o nome do atributo que está sendo atribuído está no conjunto de atributos permitidos (name, shares, price). Se não estiver, ele levanta um AttributeError. Se o atributo for permitido, ele usa o método __setattr__ da classe pai para realmente definir o atributo.

Passo 3: Crie um arquivo de teste

Crie um novo arquivo chamado test_restricted.py e adicione o seguinte código a ele. Este código testará a funcionalidade da classe RestrictedStock.

from restricted_stock import RestrictedStock

## Create a new stock
stock = RestrictedStock('GOOG', 100, 490.1)

## Test accessing existing attributes
print(f"Name: {stock.name}")
print(f"Shares: {stock.shares}")
print(f"Price: {stock.price}")

## Test modifying an existing attribute
print("\nChanging shares to 75...")
stock.shares = 75
print(f"New shares value: {stock.shares}")

## Test setting an invalid attribute
try:
    print("\nTrying to set an invalid attribute 'share'...")
    stock.share = 50
except AttributeError as e:
    print(f"Error: {e}")

Neste código, primeiro importamos a classe RestrictedStock. Em seguida, criamos uma instância da classe. Testamos o acesso a atributos existentes, a modificação de um atributo existente e, finalmente, tentamos definir um atributo inválido para ver se o método __setattr__ funciona como esperado.

Passo 4: Execute o arquivo de teste

Abra um terminal no WebIDE e execute os seguintes comandos para executar o arquivo test_restricted.py:

cd /home/labex/project
python3 test_restricted.py

Após executar esses comandos, você deverá ver uma saída semelhante a esta:

Name: GOOG
Shares: 100
Price: 490.1

Changing shares to 75...
New shares value: 75

Trying to set an invalid attribute 'share'...
Error: Cannot set attribute share

Como Funciona

O método __setattr__ em nossa classe RestrictedStock funciona nas seguintes etapas:

  1. Primeiro, ele verifica se o nome do atributo está no conjunto permitido (name, shares, price).
  2. Se o nome do atributo não estiver no conjunto permitido, ele levanta um AttributeError. Isso impede a atribuição de atributos indesejados.
  3. Se o atributo for permitido, ele usa super().__setattr__() para realmente definir o atributo. Isso garante que o processo normal de atribuição de atributos ocorra para os atributos permitidos.

Este método é mais flexível do que usar __slots__, que vimos em exemplos anteriores. Embora __slots__ possa otimizar o uso de memória e restringir atributos, ele tem limitações ao trabalhar com herança e pode entrar em conflito com outros recursos do Python. Nossa abordagem __setattr__ nos dá controle semelhante sobre a atribuição de atributos sem algumas dessas limitações.

Criando Objetos Somente Leitura com Proxies

Nesta etapa, vamos explorar classes proxy, um padrão muito útil em Python. As classes proxy permitem que você pegue um objeto existente e altere seu comportamento sem alterar seu código original. Isso é como colocar um wrapper especial em torno de um objeto para adicionar novos recursos ou restrições.

O que é um Proxy?

Um proxy é um objeto que fica entre você e outro objeto. Ele tem o mesmo conjunto de funções e propriedades que o objeto original, mas pode fazer coisas extras. Por exemplo, ele pode controlar quem pode acessar o objeto, manter um registro de ações (logging) ou adicionar outros recursos úteis.

Vamos criar um proxy somente leitura. Esse tipo de proxy impedirá que você altere os atributos de um objeto.

Passo 1: Crie a Classe Proxy Somente Leitura

Primeiro, precisamos criar um arquivo Python que defina nosso proxy somente leitura.

  1. Navegue até o diretório /home/labex/project.
  2. Crie um novo arquivo chamado readonly_proxy.py neste diretório.
  3. Abra o arquivo readonly_proxy.py e adicione o seguinte código:
class ReadonlyProxy:
    def __init__(self, obj):
        ## Store the wrapped object directly in __dict__ to avoid triggering __setattr__
        self.__dict__['_obj'] = obj

    def __getattr__(self, name):
        ## Forward attribute access to the wrapped object
        return getattr(self._obj, name)

    def __setattr__(self, name, value):
        ## Block all attribute assignments
        raise AttributeError("Cannot modify a read-only object")

Neste código, a classe ReadonlyProxy é definida. O método __init__ armazena o objeto que queremos encapsular. Usamos self.__dict__ para armazená-lo diretamente para evitar chamar o método __setattr__. O método __getattr__ é usado quando tentamos acessar um atributo do proxy. Ele simplesmente passa a solicitação para o objeto encapsulado. O método __setattr__ é chamado quando tentamos alterar um atributo. Ele levanta um erro para evitar quaisquer alterações.

Passo 2: Crie um Arquivo de Teste

Agora, criaremos um arquivo de teste para ver como nosso proxy somente leitura funciona.

  1. Crie um novo arquivo chamado test_readonly.py no mesmo diretório /home/labex/project.
  2. Adicione o seguinte código ao arquivo test_readonly.py:
from stock import Stock
from readonly_proxy import ReadonlyProxy

## Create a normal Stock object
stock = Stock('AAPL', 100, 150.75)
print("Original stock object:")
print(f"Name: {stock.name}")
print(f"Shares: {stock.shares}")
print(f"Price: {stock.price}")
print(f"Cost: {stock.cost}")

## Modify the original stock object
stock.shares = 200
print(f"\nAfter modification - Shares: {stock.shares}")
print(f"After modification - Cost: {stock.cost}")

## Create a read-only proxy around the stock
readonly_stock = ReadonlyProxy(stock)
print("\nRead-only proxy object:")
print(f"Name: {readonly_stock.name}")
print(f"Shares: {readonly_stock.shares}")
print(f"Price: {readonly_stock.price}")
print(f"Cost: {readonly_stock.cost}")

## Try to modify the read-only proxy
try:
    print("\nAttempting to modify the read-only proxy...")
    readonly_stock.shares = 300
except AttributeError as e:
    print(f"Error: {e}")

## Show that the original object is unchanged
print(f"\nOriginal stock shares are still: {stock.shares}")

Neste código de teste, primeiro criamos um objeto Stock normal e imprimimos suas informações. Em seguida, modificamos um de seus atributos e imprimimos as informações atualizadas. Em seguida, criamos um proxy somente leitura para o objeto Stock e imprimimos suas informações. Finalmente, tentamos modificar o proxy somente leitura e esperamos obter um erro.

Passo 3: Execute o Script de Teste

Depois de criar a classe proxy e o arquivo de teste, precisamos executar o script de teste para ver os resultados.

  1. Abra um terminal e navegue até o diretório /home/labex/project usando o seguinte comando:
cd /home/labex/project
  1. Execute o script de teste usando o seguinte comando:
python3 test_readonly.py

Você deve ver uma saída semelhante a:

Original stock object:
Name: AAPL
Shares: 100
Price: 150.75
Cost: 15075.0

After modification - Shares: 200
After modification - Cost: 30150.0

Read-only proxy object:
Name: AAPL
Shares: 200
Price: 150.75
Cost: 30150.0

Attempting to modify the read-only proxy...
Error: Cannot modify a read-only object

Original stock shares are still: 200

Como o Proxy Funciona

A classe ReadonlyProxy usa dois métodos especiais para alcançar sua funcionalidade somente leitura:

  1. __getattr__(self, name): Este método é chamado quando o Python não consegue encontrar um atributo da maneira normal. Em nossa classe ReadonlyProxy, usamos a função getattr() para passar a solicitação de acesso ao atributo para o objeto encapsulado. Portanto, quando você tenta acessar um atributo do proxy, ele realmente obterá o atributo do objeto encapsulado.

  2. __setattr__(self, name, value): Este método é chamado quando você tenta atribuir um valor a um atributo. Em nossa implementação, levantamos um AttributeError para impedir que quaisquer alterações sejam feitas nos atributos do proxy.

  3. No método __init__, modificamos diretamente self.__dict__ para armazenar o objeto encapsulado. Isso é importante porque, se usássemos a maneira normal de atribuir o objeto, ele chamaria o método __setattr__, que levantaria um erro.

Este padrão de proxy nos permite adicionar uma camada somente leitura em torno de qualquer objeto existente sem alterar sua classe original. O objeto proxy age como o objeto encapsulado, mas não permitirá que você faça nenhuma modificação.

Delegação como Alternativa à Herança

Na programação orientada a objetos, reutilizar e estender código é uma tarefa comum. Existem duas maneiras principais de conseguir isso: herança e delegação.

Herança é um mecanismo onde uma subclasse herda métodos e atributos de uma classe pai. A subclasse pode optar por substituir alguns desses métodos herdados para fornecer sua própria implementação.

Delegação, por outro lado, envolve um objeto contendo outro objeto e encaminhando chamadas de métodos específicas para ele.

Nesta etapa, exploraremos a delegação como uma alternativa à herança. Implementaremos uma classe que delega parte de seu comportamento a outro objeto.

Configurando um Exemplo de Delegação

Primeiro, precisamos configurar a classe base com a qual nossa classe delegadora irá interagir.

  1. Crie um novo arquivo chamado base_class.py no diretório /home/labex/project. Este arquivo definirá uma classe chamada Spam com três métodos: method_a, method_b e method_c. Cada método imprime uma mensagem e retorna um resultado. Aqui está o código para colocar em base_class.py:
class Spam:
    def method_a(self):
        print('Spam.method_a executed')
        return "Result from Spam.method_a"

    def method_b(self):
        print('Spam.method_b executed')
        return "Result from Spam.method_b"

    def method_c(self):
        print('Spam.method_c executed')
        return "Result from Spam.method_c"

Em seguida, criaremos a classe delegadora.

  1. Crie um novo arquivo chamado delegator.py. Neste arquivo, definiremos uma classe chamada DelegatingSpam que delega parte de seu comportamento a uma instância da classe Spam.
from base_class import Spam

class DelegatingSpam:
    def __init__(self):
        ## Create an instance of Spam that we'll delegate to
        self._spam = Spam()

    def method_a(self):
        ## Override method_a but also call the original
        print('DelegatingSpam.method_a executed')
        result = self._spam.method_a()
        return f"Modified {result}"

    def method_c(self):
        ## Completely override method_c
        print('DelegatingSpam.method_c executed')
        return "Result from DelegatingSpam.method_c"

    def __getattr__(self, name):
        ## For any other attribute/method, delegate to self._spam
        print(f"Delegating {name} to the wrapped Spam object")
        return getattr(self._spam, name)

No método __init__, criamos uma instância da classe Spam. O método method_a substitui o método original, mas também chama o method_a da classe Spam. O método method_c substitui completamente o método original. O método __getattr__ é um método especial em Python que é chamado quando um atributo ou método que não existe na classe DelegatingSpam é acessado. Ele então delega a chamada para a instância Spam.

Agora, vamos criar um arquivo de teste para verificar nossa implementação.

  1. Crie um arquivo de teste chamado test_delegation.py. Este arquivo criará uma instância da classe DelegatingSpam e chamará seus métodos.
from delegator import DelegatingSpam

## Create a delegating object
spam = DelegatingSpam()

print("Calling method_a (overridden with delegation):")
result_a = spam.method_a()
print(f"Result: {result_a}\n")

print("Calling method_b (not defined in DelegatingSpam, delegated):")
result_b = spam.method_b()
print(f"Result: {result_b}\n")

print("Calling method_c (completely overridden):")
result_c = spam.method_c()
print(f"Result: {result_c}\n")

## Try accessing a non-existent method
try:
    print("Calling non-existent method_d:")
    spam.method_d()
except AttributeError as e:
    print(f"Error: {e}")

Finalmente, executaremos o script de teste.

  1. Execute o script de teste usando os seguintes comandos no terminal:
cd /home/labex/project
python3 test_delegation.py

Você deve ver uma saída semelhante à seguinte:

Calling method_a (overridden with delegation):
DelegatingSpam.method_a executed
Spam.method_a executed
Result: Modified Result from Spam.method_a

Calling method_b (not defined in DelegatingSpam, delegated):
Delegating method_b to the wrapped Spam object
Spam.method_b executed
Result: Result from Spam.method_b

Calling method_c (completely overridden):
DelegatingSpam.method_c executed
Result: Result from DelegatingSpam.method_c

Calling non-existent method_d:
Delegating method_d to the wrapped Spam object
Error: 'Spam' object has no attribute 'method_d'

Delegação vs. Herança

Agora, vamos comparar a delegação com a herança tradicional.

  1. Crie um arquivo chamado inheritance_example.py. Neste arquivo, definiremos uma classe chamada InheritingSpam que herda da classe Spam.
from base_class import Spam

class InheritingSpam(Spam):
    def method_a(self):
        ## Override method_a but also call the parent method
        print('InheritingSpam.method_a executed')
        result = super().method_a()
        return f"Modified {result}"

    def method_c(self):
        ## Completely override method_c
        print('InheritingSpam.method_c executed')
        return "Result from InheritingSpam.method_c"

A classe InheritingSpam substitui os métodos method_a e method_c. No método method_a, usamos super() para chamar o method_a da classe pai.

Em seguida, criaremos um arquivo de teste para o exemplo de herança.

  1. Crie um arquivo de teste chamado test_inheritance.py. Este arquivo criará uma instância da classe InheritingSpam e chamará seus métodos.
from inheritance_example import InheritingSpam

## Create an inheriting object
spam = InheritingSpam()

print("Calling method_a (overridden with super call):")
result_a = spam.method_a()
print(f"Result: {result_a}\n")

print("Calling method_b (inherited from parent):")
result_b = spam.method_b()
print(f"Result: {result_b}\n")

print("Calling method_c (completely overridden):")
result_c = spam.method_c()
print(f"Result: {result_c}\n")

## Try accessing a non-existent method
try:
    print("Calling non-existent method_d:")
    spam.method_d()
except AttributeError as e:
    print(f"Error: {e}")

Finalmente, executaremos o teste de herança.

  1. Execute o teste de herança usando os seguintes comandos no terminal:
cd /home/labex/project
python3 test_inheritance.py

Você deve ver uma saída semelhante à seguinte:

Calling method_a (overridden with super call):
InheritingSpam.method_a executed
Spam.method_a executed
Result: Modified Result from Spam.method_a

Calling method_b (inherited from parent):
Spam.method_b executed
Result: Result from Spam.method_b

Calling method_c (completely overridden):
InheritingSpam.method_c executed
Result: Result from InheritingSpam.method_c

Calling non-existent method_d:
Error: 'InheritingSpam' object has no attribute 'method_d'

Principais Diferenças e Considerações

Vamos analisar as semelhanças e diferenças entre delegação e herança.

  1. Substituição de Método: Tanto a delegação quanto a herança permitem que você substitua métodos, mas a sintaxe é diferente.

    • Na delegação, você define seu próprio método e decide se deve chamar o método do objeto encapsulado.
    • Na herança, você define seu próprio método e usa super() para chamar o método do pai.
  2. Acesso ao Método:

    • Na delegação, métodos não definidos são encaminhados por meio do método __getattr__.
    • Na herança, métodos não definidos são herdados automaticamente.
  3. Relações de Tipo:

    • Com delegação, isinstance(delegating_spam, Spam) retorna False porque o objeto DelegatingSpam não é uma instância da classe Spam.
    • Com herança, isinstance(inheriting_spam, Spam) retorna True porque a classe InheritingSpam herda da classe Spam.
  4. Limitações: A delegação por meio de __getattr__ não funciona com métodos especiais como __getitem__, __len__, etc. Esses métodos precisariam ser explicitamente definidos na classe delegadora.

A delegação é particularmente útil nas seguintes situações:

  • Você deseja personalizar o comportamento de um objeto sem afetar sua hierarquia.
  • Você deseja combinar comportamentos de vários objetos que não compartilham um pai comum.
  • Você precisa de mais flexibilidade do que a herança oferece.

A herança é geralmente preferida quando:

  • A relação "é-um" (is-a) é clara (por exemplo, um Carro é um Veículo).
  • Você precisa manter a compatibilidade de tipo em todo o seu código.
  • Métodos especiais precisam ser herdados.

Resumo

Neste laboratório, você aprendeu sobre mecanismos Python poderosos para personalizar o acesso e o comportamento de atributos. Você explorou como usar __setattr__ para controlar quais atributos podem ser definidos em um objeto, permitindo o acesso controlado às propriedades do objeto. Além disso, você implementou um proxy somente leitura para encapsular objetos existentes, impedindo modificações enquanto preserva sua funcionalidade.

Você também se aprofundou na diferença entre delegação e herança para reutilização e personalização de código. Ao usar __getattr__, você aprendeu a encaminhar chamadas de métodos para um objeto encapsulado. Essas técnicas oferecem maneiras flexíveis de controlar o comportamento do objeto além da herança padrão, útil para criar interfaces controladas, implementar restrições de acesso, adicionar comportamentos transversais (cross-cutting behaviors) e compor comportamento de múltiplas fontes. Compreender esses padrões ajuda você a escrever um código Python mais sustentável e flexível.