Aprende sobre descriptores

PythonPythonBeginner
Practicar Ahora

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

💡 Este tutorial está traducido por IA desde la versión en inglés. Para ver la versión original, puedes hacer clic aquí

Introducción

En este laboratorio, aprenderás sobre descriptores en Python, un mecanismo poderoso para personalizar el acceso a atributos en objetos. Los descriptores te permiten definir cómo se accede, se establece y se elimina un atributo, lo que te da control sobre el comportamiento de los objetos y te permite implementar lógica de validación.

Los objetivos de este laboratorio incluyen comprender el protocolo de descriptores, crear y utilizar descriptores personalizados, implementar validación de datos con descriptores y optimizar las implementaciones de descriptores. Crearás varios archivos durante el laboratorio, incluyendo descrip.py, stock.py y validate.py.


Skills Graph

%%%%{init: {'theme':'neutral'}}%%%% flowchart RL python(("Python")) -.-> python/ErrorandExceptionHandlingGroup(["Error and Exception Handling"]) python(("Python")) -.-> python/ControlFlowGroup(["Control Flow"]) python(("Python")) -.-> python/FunctionsGroup(["Functions"]) python(("Python")) -.-> python/ObjectOrientedProgrammingGroup(["Object-Oriented Programming"]) python/ControlFlowGroup -.-> python/conditional_statements("Conditional Statements") python/FunctionsGroup -.-> python/function_definition("Function Definition") python/ObjectOrientedProgrammingGroup -.-> python/classes_objects("Classes and Objects") python/ObjectOrientedProgrammingGroup -.-> python/encapsulation("Encapsulation") python/ErrorandExceptionHandlingGroup -.-> python/raising_exceptions("Raising Exceptions") subgraph Lab Skills python/conditional_statements -.-> lab-132501{{"Aprende sobre descriptores"}} python/function_definition -.-> lab-132501{{"Aprende sobre descriptores"}} python/classes_objects -.-> lab-132501{{"Aprende sobre descriptores"}} python/encapsulation -.-> lab-132501{{"Aprende sobre descriptores"}} python/raising_exceptions -.-> lab-132501{{"Aprende sobre descriptores"}} end

Comprender el protocolo de descriptores

En este paso, vamos a aprender cómo funcionan los descriptores en Python creando una simple clase Stock. Los descriptores en Python son una característica poderosa que te permite personalizar cómo se accede, se establece y se elimina un atributo. El protocolo de descriptores consiste en tres métodos especiales: __get__(), __set__() y __delete__(). Estos métodos definen cómo se comporta el descriptor cuando se accede a un atributo, se le asigna un valor o se elimina, respectivamente.

Primero, necesitamos crear un nuevo archivo llamado stock.py en el directorio del proyecto. Este archivo contendrá nuestra clase Stock. Aquí está el código que debes poner en el archivo 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})'

En esta clase Stock, estamos usando el decorador property para definir métodos getter y setter para los atributos name, shares y price. Estos métodos getter y setter actúan como descriptores, lo que significa que controlan cómo se accede y se establecen estos atributos. Por ejemplo, los métodos setter validan los valores de entrada para asegurarse de que son del tipo correcto y dentro de un rango aceptable.

Ahora que tenemos nuestro archivo stock.py listo, abramos una shell de Python para experimentar con la clase Stock y ver cómo funcionan los descriptores en acción. Para hacer esto, abre tu terminal y ejecuta los siguientes comandos:

cd ~/project
python3 -i stock.py

La opción -i en el comando python3 le dice a Python que inicie una shell interactiva después de ejecutar el archivo stock.py. De esta manera, podemos interactuar directamente con la clase Stock que acabamos de definir.

En la shell de Python, creemos un objeto de tipo stock y probemos a acceder a sus atributos. Aquí está cómo puedes hacerlo:

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

Cuando accedes a los atributos name y shares del objeto s, Python está usando en realidad el método __get__ del descriptor detrás de escena. Los decoradores property en nuestra clase se implementan utilizando descriptores, lo que significa que manejan el acceso y la asignación de atributos de manera controlada.

Echemos un vistazo más de cerca al diccionario de la clase para ver los objetos descriptor. El diccionario de la clase contiene todos los atributos y métodos definidos en la clase. Puedes ver las claves del diccionario de la clase utilizando el siguiente código:

Stock.__dict__.keys()

Deberías ver una salida similar a esta:

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

Las claves name, shares y price representan los objetos descriptor creados por los decoradores property.

Ahora, examinemos cómo funcionan los descriptores llamando manualmente a sus métodos. Usaremos el descriptor shares como ejemplo. Aquí está cómo puedes hacerlo:

## 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}")

Cuando accedes a un atributo como s.shares, Python llama al método __get__ del descriptor para recuperar el valor. Cuando asignas un valor como s.shares = 75, Python llama al método __set__ del descriptor. El descriptor puede entonces validar los datos y lanzar errores si el valor de entrada no es válido.

Una vez que hayas terminado de experimentar con la clase Stock y los descriptores, puedes salir de la shell de Python ejecutando el siguiente comando:

exit()

Creación de descriptores personalizados

En este paso, vamos a crear nuestra propia clase de descriptor. Pero primero, entendamos qué es un descriptor. Un descriptor es un objeto de Python que implementa el protocolo de descriptores, que consiste en los métodos __get__, __set__ y __delete__. Estos métodos permiten al descriptor gestionar cómo se accede, se establece y se elimina un atributo. Al crear nuestra propia clase de descriptor, podemos entender mejor cómo funciona este protocolo.

Crea un nuevo archivo llamado descrip.py en el directorio del proyecto. Este archivo contendrá nuestra clase de descriptor personalizado. Aquí está el 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

En la clase Descriptor, el método __init__ inicializa el descriptor con un nombre. El método __get__ se llama cuando se accede al atributo, el método __set__ se llama cuando se establece el atributo y el método __delete__ se llama cuando se elimina el atributo.

Ahora, creemos un archivo de prueba para experimentar con nuestro descriptor personalizado. Esto nos ayudará a ver cómo se comporta el descriptor en diferentes escenarios. Crea un archivo llamado test_descrip.py con el siguiente 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

En el archivo test_descrip.py, importamos la clase Descriptor de descrip.py. Luego creamos una clase Foo con tres atributos a, b y c, cada uno gestionado por un descriptor. Creamos una instancia de Foo y realizamos operaciones como acceder, establecer y eliminar atributos para ver cómo se llaman los métodos del descriptor.

Ahora ejecutemos este archivo de prueba para ver los descriptores en acción. Abre tu terminal, navega hasta el directorio del proyecto y ejecuta el archivo de prueba utilizando los siguientes comandos:

cd ~/project
python3 test_descrip.py

Deberías ver una salida 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 puedes ver, cada vez que accedes, estableces o eliminas un atributo que está gestionado por un descriptor, se llama al método mágico correspondiente (__get__, __set__ o __delete__).

Examinemos también nuestro descriptor de forma interactiva. Esto nos permitirá probar el descriptor en tiempo real y ver los resultados inmediatamente. Abre tu terminal, navega hasta el directorio del proyecto e inicia una sesión interactiva de Python con el archivo descrip.py:

cd ~/project
python3 -i descrip.py

Ahora escribe estos comandos en la sesión interactiva de Python para ver cómo funciona el protocolo de descriptores:

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()

La idea clave aquí es que los descriptores proporcionan una forma de interceptar y personalizar el acceso a los atributos. Esto los hace poderosos para implementar validación de datos, atributos calculados y otros comportamientos avanzados. Al usar descriptores, puedes tener más control sobre cómo se accede, se establece y se elimina los atributos de tu clase.

✨ Revisar Solución y Practicar

Implementación de validadores utilizando descriptores

En este paso, vamos a crear un sistema de validación utilizando descriptores. Pero primero, entendamos qué son los descriptores y por qué los estamos utilizando. Los descriptores son objetos de Python que implementan el protocolo de descriptores, que incluye los métodos __get__, __set__ o __delete__. Permiten personalizar cómo se accede, se establece o se elimina un atributo en un objeto. En nuestro caso, usaremos descriptores para crear un sistema de validación que asegure la integridad de los datos. Esto significa que los datos almacenados en nuestros objetos siempre cumplirán ciertos criterios, como ser de un tipo específico o tener un valor positivo.

Ahora, comencemos a crear nuestro sistema de validación. Crearemos un nuevo archivo llamado validate.py en el directorio del proyecto. Este archivo contendrá las clases que implementan nuestros 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)

En el archivo validate.py, primero definimos una clase base llamada Validator. Esta clase tiene un método __init__ que toma un parámetro name, que se utilizará para identificar el atributo que se está validando. El método check es un método de clase que simplemente devuelve el valor pasado a él. El método __set__ es un método de descriptor que se llama cuando se establece un atributo en un objeto. Llama al método check para validar el valor y luego almacena el valor validado en el diccionario del objeto.

Luego definimos tres subclases de Validator: String, PositiveInteger y PositiveFloat. Cada una de estas subclases anula el método check para realizar comprobaciones de validación específicas. La clase String comprueba si el valor es una cadena, la clase PositiveInteger comprueba si el valor es un entero positivo y la clase PositiveFloat comprueba si el valor es un número positivo (ya sea un entero o un flotante).

Ahora que tenemos definidos nuestros validadores, modifiquemos nuestra clase Stock para utilizar estos validadores. Crearemos un nuevo archivo llamado stock_with_validators.py e importaremos los validadores del archivo 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})'

En el archivo stock_with_validators.py, definimos la clase Stock y utilizamos los validadores como atributos de clase. Esto significa que cada vez que se establece un atributo en un objeto Stock, se llamará al método __set__ del validador correspondiente para validar el valor. El método __init__ inicializa los atributos del objeto Stock, y los métodos cost, sell y __repr__ proporcionan funcionalidad adicional.

Ahora, probemos nuestra clase Stock basada en validadores. Abramos una terminal, naveguemos hasta el directorio del proyecto y ejecutemos el archivo stock_with_validators.py en modo interactivo.

cd ~/project
python3 -i stock_with_validators.py

Una vez que el intérprete de Python esté en ejecución, podemos probar algunos comandos para probar el sistema de validación.

## 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()

En el código de prueba, primero creamos un objeto Stock con valores válidos e imprimimos sus atributos para verificar que se hayan establecido correctamente. Luego intentamos cambiar el atributo shares a un valor válido e imprimimos de nuevo para confirmar el cambio. Finalmente, intentamos establecer el atributo shares a un valor no válido (una cadena y un número negativo) y capturamos las excepciones que lanzan los validadores.

Observa lo mucho más limpio que es nuestro código ahora. La clase Stock ya no necesita implementar todos esos métodos de propiedad; los validadores se encargan de todas las comprobaciones de tipo y restricciones.

Los descriptores nos han permitido crear un sistema de validación reutilizable que se puede aplicar a cualquier atributo de clase. Este es un patrón poderoso para mantener la integridad de los datos en toda tu aplicación.

✨ Revisar Solución y Practicar

Mejora de la implementación de descriptores

En este paso, vamos a mejorar nuestra implementación de descriptores. Es posible que hayas notado que en algunos casos, hemos estado especificando nombres de forma redundante. Esto puede hacer que nuestro código sea un poco desordenado y más difícil de mantener. Para resolver este problema, usaremos el método __set_name__, una función útil introducida en Python 3.6.

El método __set_name__ se llama automáticamente cuando se define la clase. Su función principal es establecer el nombre del descriptor por nosotros, por lo que no tenemos que hacerlo manualmente cada vez. Esto hará que nuestro código sea más limpio y eficiente.

Ahora, actualicemos el archivo validate.py para incluir el método __set_name__. Así es como se verá el código actualizado:

## 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)

En el código anterior, el método __set_name__ en la clase Validator comprueba si el atributo name es None. Si lo es, establece el name al nombre del atributo real utilizado en la definición de la clase. De esta manera, no tenemos que especificar el nombre explícitamente cuando creamos instancias de las clases de descriptores.

Ahora que hemos actualizado el archivo validate.py, podemos crear una versión mejorada de nuestra clase Stock. Esta nueva versión no requerirá que especifiquemos los nombres de forma redundante. Aquí está el código para la clase Stock mejorada:

## 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})'

En esta clase Stock, simplemente creamos instancias de las clases de descriptores String, PositiveInteger y PositiveFloat sin especificar los nombres. El método __set_name__ en la clase Validator se encargará de establecer los nombres automáticamente.

Probemos nuestra clase Stock mejorada. Primero, abre tu terminal y navega hasta el directorio del proyecto. Luego, ejecuta el archivo improved_stock.py en modo interactivo. Aquí están los comandos para hacerlo:

cd ~/project
python3 -i improved_stock.py

Una vez que estés en la sesión interactiva de Python, puedes probar los siguientes comandos para probar la funcionalidad de la clase 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()

Estos comandos crean una instancia de la clase Stock, imprimen sus atributos, cambian el valor de un atributo y luego intentan establecer valores no válidos para ver si se generan los errores adecuados.

El método __set_name__ establece automáticamente el nombre del descriptor cuando se define la clase. Esto hace que tu código sea más limpio y menos redundante, ya que ya no necesitas especificar el nombre del atributo dos veces.

Esta mejora demuestra cómo el protocolo de descriptores de Python sigue evolucionando, lo que facilita escribir código limpio y mantenible.

✨ Revisar Solución y Practicar

Resumen

En este laboratorio, has aprendido sobre los descriptores de Python, una función poderosa que permite personalizar el acceso a los atributos en las clases. Has explorado el protocolo de descriptores, incluyendo los métodos __get__, __set__ y __delete__. También has creado una clase de descriptor básica para interceptar el acceso a los atributos y has utilizado descriptores para implementar un sistema de validación para la integridad de los datos.

Además, has mejorado tus descriptores con el método __set_name__ para reducir la redundancia. Los descriptores se utilizan ampliamente en bibliotecas y marcos de trabajo de Python, como Django y SQLAlchemy. Comprenderlos brinda una visión más profunda de Python y te ayuda a escribir código más elegante y mantenible.