Personalización del acceso a atributos

Beginner

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

Introducción

En este laboratorio, aprenderás sobre un aspecto fundamental de la programación orientada a objetos en Python: el acceso a atributos. Python permite a los desarrolladores personalizar cómo se accede, se establece y se gestiona los atributos en las clases a través de métodos especiales. Esto ofrece formas poderosas de controlar el comportamiento de los objetos.

Además, aprenderás cómo personalizar el acceso a atributos en las clases de Python, entenderás la diferencia entre delegación e herencia, y practicarás la implementación de la gestión personalizada de atributos en objetos de Python.

Comprendiendo __setattr__ para el control de atributos

En Python, hay métodos especiales que te permiten personalizar cómo se accede y se modifican los atributos de un objeto. Uno de estos métodos importantes es __setattr__(). Este método entra en juego cada vez que intentas asignar un valor a un atributo de un objeto. Te da la capacidad de tener un control detallado sobre el proceso de asignación de atributos.

¿Qué es __setattr__?

El método __setattr__(self, name, value) actúa como un interceptor para todas las asignaciones de atributos. Cuando escribes una simple declaración de asignación como obj.attr = value, Python no asigna directamente el valor. En lugar de eso, llama internamente a obj.__setattr__("attr", value). Este mecanismo te brinda el poder de decidir qué debe suceder durante la asignación de atributos.

Veamos ahora un ejemplo práctico de cómo podemos usar __setattr__ para restringir qué atributos se pueden establecer en una clase.

Paso 1: Crear un nuevo archivo

Primero, abre un nuevo archivo en el WebIDE. Puedes hacer esto haciendo clic en el menú "File" y luego seleccionando "New File". Nombrar este archivo restricted_stock.py y guardarlo en el directorio /home/labex/project. Este archivo contendrá la definición de la clase donde usaremos __setattr__ para controlar la asignación de atributos.

Paso 2: Agregar código a restricted_stock.py

Agrega el siguiente código al archivo restricted_stock.py. Este código define una clase 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)

En el método __init__, inicializamos el objeto con los atributos name, shares y price. El método __setattr__ comprueba si el nombre del atributo que se está asignando está en el conjunto de atributos permitidos (name, shares, price). Si no lo está, levanta un AttributeError. Si el atributo está permitido, utiliza el método __setattr__ de la clase padre para establecer realmente el atributo.

Paso 3: Crear un archivo de prueba

Crea un nuevo archivo llamado test_restricted.py y agrega el siguiente código a él. Este código probará la funcionalidad de la clase 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}")

En este código, primero importamos la clase RestrictedStock. Luego creamos una instancia de la clase. Probamos el acceso a atributos existentes, la modificación de un atributo existente y, finalmente, intentamos establecer un atributo no válido para ver si el método __setattr__ funciona como se espera.

Paso 4: Ejecutar el archivo de prueba

Abre una terminal en el WebIDE y ejecuta los siguientes comandos para ejecutar el archivo test_restricted.py:

cd /home/labex/project
python3 test_restricted.py

Después de ejecutar estos comandos, deberías ver una salida similar 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

Cómo funciona

El método __setattr__ en nuestra clase RestrictedStock funciona en los siguientes pasos:

  1. Primero comprueba si el nombre del atributo está en el conjunto permitido (name, shares, price).
  2. Si el nombre del atributo no está en el conjunto permitido, levanta un AttributeError. Esto evita la asignación de atributos no deseados.
  3. Si el atributo está permitido, utiliza super().__setattr__() para establecer realmente el atributo. Esto asegura que el proceso normal de asignación de atributos se lleve a cabo para los atributos permitidos.

Este método es más flexible que usar __slots__, que vimos en ejemplos anteriores. Si bien __slots__ puede optimizar el uso de memoria y restringir atributos, tiene limitaciones cuando se trabaja con herencia y puede entrar en conflicto con otras características de Python. Nuestro enfoque con __setattr__ nos da un control similar sobre la asignación de atributos sin algunas de esas limitaciones.

Creación de objetos de solo lectura con proxies

En este paso, vamos a explorar las clases proxy, un patrón muy útil en Python. Las clases proxy te permiten tomar un objeto existente y cambiar su comportamiento sin alterar su código original. Esto es como poner una envoltura especial alrededor de un objeto para agregar nuevas características o restricciones.

¿Qué es un proxy?

Un proxy es un objeto que se interpone entre tú y otro objeto. Tiene el mismo conjunto de funciones y propiedades que el objeto original, pero puede hacer cosas adicionales. Por ejemplo, puede controlar quién puede acceder al objeto, mantener un registro de las acciones (registro de eventos o "logging"), o agregar otras características útiles.

Vamos a crear un proxy de solo lectura. Este tipo de proxy evitará que cambies los atributos de un objeto.

Paso 1: Crear la clase proxy de solo lectura

Primero, necesitamos crear un archivo de Python que defina nuestro proxy de solo lectura.

  1. Navega al directorio /home/labex/project.
  2. Crea un nuevo archivo llamado readonly_proxy.py en este directorio.
  3. Abre el archivo readonly_proxy.py y agrega el siguiente 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")

En este código, se define la clase ReadonlyProxy. El método __init__ almacena el objeto que queremos envolver. Usamos self.__dict__ para almacenarlo directamente y evitar llamar al método __setattr__. El método __getattr__ se utiliza cuando intentamos acceder a un atributo del proxy. Simplemente pasa la solicitud al objeto envuelto. El método __setattr__ se llama cuando intentamos cambiar un atributo. Levanta un error para evitar cualquier cambio.

Paso 2: Crear un archivo de prueba

Ahora, crearemos un archivo de prueba para ver cómo funciona nuestro proxy de solo lectura.

  1. Crea un nuevo archivo llamado test_readonly.py en el mismo directorio /home/labex/project.
  2. Agrega el siguiente código al archivo 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}")

En este código de prueba, primero creamos un objeto Stock normal e imprimimos su información. Luego modificamos uno de sus atributos e imprimimos la información actualizada. A continuación, creamos un proxy de solo lectura para el objeto Stock e imprimimos su información. Finalmente, intentamos modificar el proxy de solo lectura y esperamos obtener un error.

Paso 3: Ejecutar el script de prueba

Después de crear la clase proxy y el archivo de prueba, necesitamos ejecutar el script de prueba para ver los resultados.

  1. Abre una terminal y navega al directorio /home/labex/project usando el siguiente comando:
cd /home/labex/project
  1. Ejecuta el script de prueba usando el siguiente comando:
python3 test_readonly.py

Deberías ver una salida similar 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

Cómo funciona el proxy

La clase ReadonlyProxy utiliza dos métodos especiales para lograr su funcionalidad de solo lectura:

  1. __getattr__(self, name): Este método se llama cuando Python no puede encontrar un atributo de la manera normal. En nuestra clase ReadonlyProxy, usamos la función getattr() para pasar la solicitud de acceso al atributo al objeto envuelto. Entonces, cuando intentas acceder a un atributo del proxy, en realidad obtendrás el atributo del objeto envuelto.

  2. __setattr__(self, name, value): Este método se llama cuando intentas asignar un valor a un atributo. En nuestra implementación, levantamos un AttributeError para evitar que se realicen cambios en los atributos del proxy.

  3. En el método __init__, modificamos directamente self.__dict__ para almacenar el objeto envuelto. Esto es importante porque si usáramos la forma normal de asignar el objeto, se llamaría al método __setattr__, que levantaría un error.

Este patrón de proxy nos permite agregar una capa de solo lectura alrededor de cualquier objeto existente sin cambiar su clase original. El objeto proxy se comporta como el objeto envuelto, pero no te permitirá realizar modificaciones.

Delegación como alternativa a la herencia

En la programación orientada a objetos, reutilizar y extender código es una tarea común. Hay dos formas principales de lograr esto: la herencia y la delegación.

La herencia es un mecanismo en el que una subclase hereda métodos y atributos de una clase padre. La subclase puede elegir anular algunos de estos métodos heredados para proporcionar su propia implementación.

La delegación, por otro lado, implica que un objeto contenga otro objeto y reenvíe llamadas a métodos específicos a él.

En este paso, exploraremos la delegación como alternativa a la herencia. Implementaremos una clase que delegue parte de su comportamiento a otro objeto.

Configuración de un ejemplo de delegación

Primero, necesitamos configurar la clase base con la que interactuará nuestra clase delegadora.

  1. Crea un nuevo archivo llamado base_class.py en el directorio /home/labex/project. Este archivo definirá una clase llamada Spam con tres métodos: method_a, method_b y method_c. Cada método imprime un mensaje y devuelve un resultado. Aquí está el código que debes poner en 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"

A continuación, crearemos la clase delegadora.

  1. Crea un nuevo archivo llamado delegator.py. En este archivo, definiremos una clase llamada DelegatingSpam que delegue parte de su comportamiento a una instancia de la clase 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)

En el método __init__, creamos una instancia de la clase Spam. El método method_a anula el método original pero también llama al method_a de la clase Spam. El método method_c anula completamente el método original. El método __getattr__ es un método especial en Python que se llama cuando se accede a un atributo o método que no existe en la clase DelegatingSpam. Luego delega la llamada a la instancia de Spam.

Ahora, creemos un archivo de prueba para verificar nuestra implementación.

  1. Crea un archivo de prueba llamado test_delegation.py. Este archivo creará una instancia de la clase DelegatingSpam y llamará a sus 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, ejecutaremos el script de prueba.

  1. Ejecuta el script de prueba usando los siguientes comandos en la terminal:
cd /home/labex/project
python3 test_delegation.py

Deberías ver una salida similar a la siguiente:

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'

Delegación vs. Herencia

Ahora, comparemos la delegación con la herencia tradicional.

  1. Crea un archivo llamado inheritance_example.py. En este archivo, definiremos una clase llamada InheritingSpam que herede de la clase 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"

La clase InheritingSpam anula los métodos method_a y method_c. En el método method_a, usamos super() para llamar al method_a de la clase padre.

A continuación, crearemos un archivo de prueba para el ejemplo de herencia.

  1. Crea un archivo de prueba llamado test_inheritance.py. Este archivo creará una instancia de la clase InheritingSpam y llamará a sus 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, ejecutaremos la prueba de herencia.

  1. Ejecuta la prueba de herencia usando los siguientes comandos en la terminal:
cd /home/labex/project
python3 test_inheritance.py

Deberías ver una salida similar a la siguiente:

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'

Diferencias clave y consideraciones

Veamos las similitudes y diferencias entre la delegación y la herencia.

  1. Anulación de métodos: Tanto la delegación como la herencia te permiten anular métodos, pero la sintaxis es diferente.

    • En la delegación, defines tu propio método y decides si llamar al método del objeto envuelto.
    • En la herencia, defines tu propio método y usas super() para llamar al método de la clase padre.
  2. Acceso a métodos:

    • En la delegación, los métodos no definidos se reenvían a través del método __getattr__.
    • En la herencia, los métodos no definidos se heredan automáticamente.
  3. Relaciones de tipo:

    • Con la delegación, isinstance(delegating_spam, Spam) devuelve False porque el objeto DelegatingSpam no es una instancia de la clase Spam.
    • Con la herencia, isinstance(inheriting_spam, Spam) devuelve True porque la clase InheritingSpam hereda de la clase Spam.
  4. Limitaciones: La delegación a través de __getattr__ no funciona con métodos especiales como __getitem__, __len__, etc. Estos métodos deben definirse explícitamente en la clase delegadora.

La delegación es especialmente útil en las siguientes situaciones:

  • Quieres personalizar el comportamiento de un objeto sin afectar su jerarquía.
  • Quieres combinar comportamientos de múltiples objetos que no comparten un padre común.
  • Necesitas más flexibilidad de la que proporciona la herencia.

La herencia se prefiere generalmente cuando:

  • La relación "es-un" es clara (por ejemplo, un automóvil es un vehículo).
  • Necesitas mantener la compatibilidad de tipos en tu código.
  • Los métodos especiales deben heredarse.

Resumen

En este laboratorio, has aprendido sobre poderosos mecanismos de Python para personalizar el acceso y el comportamiento de los atributos. Has explorado cómo usar __setattr__ para controlar qué atributos se pueden establecer en un objeto, lo que permite un acceso controlado a las propiedades del objeto. Además, has implementado un proxy de solo lectura para envolver objetos existentes, evitando modificaciones mientras se conserva su funcionalidad.

También has profundizado en la diferencia entre la delegación y la herencia para la reutilización y personalización de código. Al usar __getattr__, has aprendido a reenviar llamadas a métodos a un objeto envuelto. Estas técnicas ofrecen formas flexibles de controlar el comportamiento de los objetos más allá de la herencia estándar, útiles para interfaces controladas, implementación de restricciones de acceso, adición de comportamientos transversales y composición de comportamiento a partir de múltiples fuentes. Comprender estos patrones te ayuda a escribir código Python más mantenible y flexible.