Definir un objeto invocable adecuado

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 objetos invocables (callable objects) en Python. Un objeto invocable se puede llamar como una función utilizando la sintaxis object(). Si bien las funciones de Python son inherentemente invocables, puedes crear objetos invocables personalizados implementando el método __call__.

También aprenderás a implementar un objeto invocable utilizando el método __call__ y a utilizar anotaciones de función con objetos invocables para la validación de parámetros. El archivo validate.py se modificará durante este laboratorio.


Skills Graph

%%%%{init: {'theme':'neutral'}}%%%% flowchart RL python(("Python")) -.-> python/BasicConceptsGroup(["Basic Concepts"]) python(("Python")) -.-> python/FunctionsGroup(["Functions"]) python(("Python")) -.-> python/ObjectOrientedProgrammingGroup(["Object-Oriented Programming"]) python/BasicConceptsGroup -.-> python/variables_data_types("Variables and Data Types") python/BasicConceptsGroup -.-> python/type_conversion("Type Conversion") python/FunctionsGroup -.-> python/function_definition("Function Definition") python/ObjectOrientedProgrammingGroup -.-> python/classes_objects("Classes and Objects") python/ObjectOrientedProgrammingGroup -.-> python/constructor("Constructor") subgraph Lab Skills python/variables_data_types -.-> lab-132513{{"Definir un objeto invocable adecuado"}} python/type_conversion -.-> lab-132513{{"Definir un objeto invocable adecuado"}} python/function_definition -.-> lab-132513{{"Definir un objeto invocable adecuado"}} python/classes_objects -.-> lab-132513{{"Definir un objeto invocable adecuado"}} python/constructor -.-> lab-132513{{"Definir un objeto invocable adecuado"}} end

Comprender las clases de validación

En este laboratorio, vamos a construir sobre un conjunto de clases de validación para crear un objeto invocable (callable object). Antes de comenzar a construir, es importante entender las clases de validación proporcionadas en el archivo validate.py. Estas clases nos ayudarán a realizar comprobaciones de tipo, que es una parte crucial para garantizar que nuestro código funcione como se espera.

Comencemos abriendo el archivo validate.py en el WebIDE. Este archivo contiene el código de las clases de validación que utilizaremos. Para abrirlo, ejecuta el siguiente comando en la terminal:

code /home/labex/project/validate.py

Una vez que hayas abierto el archivo, verás que contiene varias clases. Aquí está un breve resumen de lo que hace cada clase:

  1. Validator: Esta es una clase base. Tiene un método check, pero actualmente este método no hace nada. Sirve como punto de partida para las otras clases de validación.
  2. Typed: Esta es una subclase de Validator. Su función principal es comprobar si un valor es de un tipo específico.
  3. Integer, Float y String: Estas son validadores de tipo específicos que heredan de Typed. Están diseñados para comprobar si un valor es un entero, un número de punto flotante o una cadena de texto, respectivamente.

Ahora, veamos cómo funcionan estas clases de validación en la práctica. Crearemos un nuevo archivo llamado test.py para probarlas. Para crear y abrir este archivo, ejecuta el siguiente comando:

code /home/labex/project/test.py

Una vez que el archivo test.py esté abierto, agrega el siguiente código a él. Este código probará los validadores Integer y String:

from validate import Integer, String, Float

## Test Integer validator
print("Testing Integer validator:")
try:
    Integer.check(42)
    print("✓ Integer check passed for 42")
except TypeError as e:
    print(f"✗ Error: {e}")

try:
    Integer.check("Hello")
    print("✗ Integer check incorrectly passed for 'Hello'")
except TypeError as e:
    print(f"✓ Correctly raised error: {e}")

## Test String validator
print("\nTesting String validator:")
try:
    String.check("Hello")
    print("✓ String check passed for 'Hello'")
except TypeError as e:
    print(f"✗ Error: {e}")

En este código, primero importamos los validadores Integer, String y Float del archivo validate.py. Luego, probamos el validador Integer intentando comprobar un valor entero (42) y un valor de cadena ("Hello"). Si la comprobación pasa para el entero, imprimimos un mensaje de éxito. Si pasa incorrectamente para la cadena, imprimimos un mensaje de error. Si la comprobación levanta correctamente un TypeError para la cadena, imprimimos un mensaje de éxito. Hacemos una prueba similar para el validador String.

Después de agregar el código, ejecuta el archivo de prueba utilizando el siguiente comando:

python3 /home/labex/project/test.py

Deberías ver una salida similar a esta:

Testing Integer validator:
✓ Integer check passed for 42
✓ Correctly raised error: Expected <class 'int'>

Testing String validator:
✓ String check passed for 'Hello'

Como puedes ver, estas clases de validación nos permiten realizar comprobaciones de tipo fácilmente. Por ejemplo, cuando llamas a Integer.check(x), levantará un TypeError si x no es un entero.

Ahora, pensemos en un escenario práctico. Supongamos que tenemos una función que requiere que sus argumentos sean de tipos específicos. Aquí hay un ejemplo de tal función:

def add(x, y):
    Integer.check(x)  ## Make sure x is an integer
    Integer.check(y)  ## Make sure y is an integer
    return x + y

Esta función funciona, pero hay un problema. Tenemos que agregar manualmente las comprobaciones de validación cada vez que queremos usar la comprobación de tipo. Esto puede ser tedioso y propenso a errores, especialmente para funciones o proyectos más grandes.

En los siguientes pasos, resolveremos este problema creando un objeto invocable. Este objeto será capaz de aplicar automáticamente estas comprobaciones de tipo basadas en las anotaciones de la función. De esta manera, no tendremos que agregar las comprobaciones manualmente cada vez.

Crear un objeto invocable básico

En Python, un objeto invocable (callable object) es un objeto que se puede usar como una función. Puedes pensar en él como algo que puedes "llamar" poniendo paréntesis después, similar a cómo se llama a una función normal. Para hacer que una clase en Python actúe como un objeto invocable, necesitamos implementar un método especial llamado __call__. Este método se invoca automáticamente cuando se usa el objeto con paréntesis, al igual que cuando se llama a una función.

Comencemos modificando el archivo validate.py. Vamos a agregar una nueva clase llamada ValidatedFunction a este archivo, y esta clase será nuestro objeto invocable. Para abrir el archivo en el editor de código, ejecuta el siguiente comando en la terminal:

code /home/labex/project/validate.py

Una vez que el archivo esté abierto, desplázate hasta el final y agrega el siguiente código:

class ValidatedFunction:
    def __init__(self, func):
        self.func = func

    def __call__(self, *args, **kwargs):
        print('Calling', self.func)
        result = self.func(*args, **kwargs)
        return result

Analicemos lo que hace este código. La clase ValidatedFunction tiene un método __init__, que es el constructor. Cuando se crea una instancia de esta clase, se le pasa una función. Esta función se almacena luego como un atributo de la instancia, llamado self.func.

El método __call__ es la parte clave que hace que esta clase sea invocable. Cuando se llama a una instancia de la clase ValidatedFunction, se ejecuta este método __call__. Esto es lo que hace paso a paso:

  1. Imprime un mensaje que indica qué función se está llamando. Esto es útil para depurar y entender lo que está sucediendo.
  2. Llama a la función que se almacenó en self.func con los argumentos que se pasaron cuando se llamó a la instancia. Los *args y **kwargs permiten pasar cualquier número de argumentos posicionales y de palabra clave.
  3. Devuelve el resultado de la llamada a la función.

Ahora, probemos esta clase ValidatedFunction. Crearemos un nuevo archivo llamado test_callable.py para escribir nuestro código de prueba. Para abrir este nuevo archivo en el editor de código, ejecuta el siguiente comando:

code /home/labex/project/test_callable.py

Agrega el siguiente código al archivo test_callable.py:

from validate import ValidatedFunction

def add(x, y):
    return x + y

## Wrap the add function with ValidatedFunction
validated_add = ValidatedFunction(add)

## Call the wrapped function
result = validated_add(2, 3)
print(f"Result: {result}")

## Try another call
result = validated_add(10, 20)
print(f"Result: {result}")

En este código, primero importamos la clase ValidatedFunction del archivo validate.py. Luego definimos una función simple llamada add que toma dos números y devuelve su suma.

Creamos una instancia de la clase ValidatedFunction, pasándole la función add. Esto "envuelve" la función add dentro de la instancia de ValidatedFunction.

Luego llamamos a la función envuelta dos veces, una vez con los argumentos 2 y 3, y luego con 10 y 20. Cada vez que llamamos a la función envuelta, se invoca el método __call__ de la clase ValidatedFunction, que a su vez llama a la función add original.

Para ejecutar el código de prueba, ejecuta el siguiente comando en la terminal:

python3 /home/labex/project/test_callable.py

Deberías ver una salida similar a esta:

Calling <function add at 0x7f2d1c3a9940>
Result: 5
Calling <function add at 0x7f2d1c3a9940>
Result: 30

Esta salida muestra que nuestro objeto invocable está funcionando como se espera. Cuando llamamos a validated_add(2, 3), en realidad estamos llamando al método __call__ de la clase ValidatedFunction, que luego llama a la función add original.

Por ahora, nuestra clase ValidatedFunction solo imprime un mensaje y pasa la llamada a la función original. En el siguiente paso, mejoraremos esta clase para realizar validación de tipo basada en las anotaciones de la función.

✨ Revisar Solución y Practicar

Implementar validación de tipos con anotaciones de funciones

En Python, tienes la capacidad de agregar anotaciones de tipo a los parámetros de una función. Estas anotaciones sirven como una forma de indicar los tipos de datos esperados de los parámetros y el valor de retorno de una función. Por defecto, no imponen los tipos en tiempo de ejecución, pero se pueden utilizar con fines de validación.

Echemos un vistazo a un ejemplo:

def add(x: int, y: int) -> int:
    return x + y

En este código, x: int y y: int nos indican que los parámetros x e y deben ser enteros. El -> int al final indica que la función add devuelve un entero. Estas anotaciones de tipo se almacenan en el atributo __annotations__ de la función, que es un diccionario que mapea los nombres de los parámetros a sus tipos anotados.

Ahora, vamos a mejorar nuestra clase ValidatedFunction para utilizar estas anotaciones de tipo para la validación. Para hacer esto, necesitaremos utilizar el módulo inspect de Python. Este módulo proporciona funciones útiles para obtener información sobre objetos en tiempo de ejecución, como módulos, clases, métodos, funciones, etc. En nuestro caso, lo utilizaremos para hacer coincidir los argumentos de la función con sus nombres de parámetros correspondientes.

Primero, necesitamos modificar la clase ValidatedFunction en el archivo validate.py. Puedes abrir este archivo utilizando el siguiente comando:

code /home/labex/project/validate.py

Reemplaza la clase ValidatedFunction existente con la siguiente versión mejorada:

import inspect

class ValidatedFunction:
    def __init__(self, func):
        self.func = func
        self.signature = inspect.signature(func)

    def __call__(self, *args, **kwargs):
        ## Bind the arguments to the function parameters
        bound = self.signature.bind(*args, **kwargs)

        ## Validate each argument against its annotation
        for name, val in bound.arguments.items():
            if name in self.func.__annotations__:
                ## Get the validator class from the annotation
                validator = self.func.__annotations__[name]
                ## Apply the validation
                validator.check(val)

        ## Call the function with the validated arguments
        return self.func(*args, **kwargs)

Esto es lo que hace esta versión mejorada:

  1. Utiliza inspect.signature() para obtener información sobre los parámetros de la función, como sus nombres, valores predeterminados y tipos anotados.
  2. El método bind() de la firma se utiliza para hacer coincidir los argumentos proporcionados con sus nombres de parámetros correspondientes. Esto nos ayuda a asociar cada argumento con su parámetro correcto en la función.
  3. Comprueba cada argumento contra su anotación de tipo (si existe). Si se encuentra una anotación, recupera la clase de validación de la anotación y aplica la validación utilizando el método check().
  4. Finalmente, llama a la función original con los argumentos validados.

Ahora, probemos esta clase ValidatedFunction mejorada con algunas funciones que utilizan nuestras clases de validación en sus anotaciones de tipo. Abre el archivo test_validation.py utilizando el siguiente comando:

code /home/labex/project/test_validation.py

Agrega el siguiente código al archivo:

from validate import ValidatedFunction, Integer, String

def greet(name: String, times: Integer):
    return name * times

## Wrap the greet function with ValidatedFunction
validated_greet = ValidatedFunction(greet)

## Valid call
try:
    result = validated_greet("Hello ", 3)
    print(f"Valid call result: {result}")
except TypeError as e:
    print(f"Unexpected error: {e}")

## Invalid call - wrong type for 'name'
try:
    result = validated_greet(123, 3)
    print(f"Invalid call unexpectedly succeeded: {result}")
except TypeError as e:
    print(f"Expected error for name: {e}")

## Invalid call - wrong type for 'times'
try:
    result = validated_greet("Hello ", "3")
    print(f"Invalid call unexpectedly succeeded: {result}")
except TypeError as e:
    print(f"Expected error for times: {e}")

En este código, definimos una función greet con anotaciones de tipo name: String y times: Integer. Esto significa que el parámetro name debe ser validado utilizando la clase String, y el parámetro times debe ser validado utilizando la clase Integer. Luego envuelvemos la función greet con nuestra clase ValidatedFunction para habilitar la validación de tipos.

Realizamos tres casos de prueba: una llamada válida, una llamada inválida con el tipo incorrecto para name y una llamada inválida con el tipo incorrecto para times. Cada llamada está envuelta en un bloque try-except para capturar cualquier excepción TypeError que se pueda generar durante la validación.

Para ejecutar el archivo de prueba, utiliza el siguiente comando:

python3 /home/labex/project/test_validation.py

Deberías ver una salida similar a la siguiente:

Valid call result: Hello Hello Hello
Expected error for name: Expected <class 'str'>
Expected error for times: Expected <class 'int'>

Esta salida demuestra que nuestro objeto invocable ValidatedFunction ahora está aplicando la validación de tipos basada en las anotaciones de la función. Cuando pasamos argumentos del tipo incorrecto, las clases de validación detectan el error y generan una excepción TypeError. De esta manera, podemos asegurarnos de que las funciones se llamen con los tipos de datos correctos, lo que ayuda a prevenir errores y hace que nuestro código sea más robusto.

✨ Revisar Solución y Practicar

Desafío: Usar un objeto invocable como método

En Python, cuando se utiliza un objeto invocable (callable object) como un método dentro de una clase, hay un desafío único que se debe abordar. Un objeto invocable es algo que se puede "llamar" como una función, como una función en sí o un objeto con un método __call__. Cuando se utiliza como un método de clase, no siempre funciona como se espera debido a cómo Python pasa la instancia (self) como el primer argumento.

Exploremos este problema creando una clase Stock. Esta clase representará una acción con atributos como el nombre, el número de acciones y el precio. También usaremos un validador para asegurarnos de que los datos con los que trabajamos son correctos.

Primero, abre el archivo stock.py para comenzar a escribir nuestra clase Stock. Puedes usar el siguiente comando para abrir el archivo en un editor:

code /home/labex/project/stock.py

Ahora, agrega el siguiente código al archivo stock.py. Este código define la clase Stock con un método __init__ para inicializar los atributos de la acción, una propiedad cost para calcular el costo total y un método sell para reducir el número de acciones. También intentaremos usar ValidatedFunction para validar la entrada del método sell.

from validate import ValidatedFunction, Integer

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

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

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

    ## Try to use ValidatedFunction
    sell = ValidatedFunction(sell)

Después de definir la clase Stock, necesitamos probarla para ver si funciona como se espera. Crea un archivo de prueba llamado test_stock.py y ábrelo usando el siguiente comando:

code /home/labex/project/test_stock.py

Agrega el siguiente código al archivo test_stock.py. Este código crea una instancia de la clase Stock, imprime el número inicial de acciones y el costo, intenta vender algunas acciones y luego imprime el número actualizado de acciones y el costo.

from stock import Stock

try:
    ## Create a stock
    s = Stock('GOOG', 100, 490.1)

    ## Get the initial cost
    print(f"Initial shares: {s.shares}")
    print(f"Initial cost: ${s.cost}")

    ## Try to sell some shares
    s.sell(10)

    ## Check the updated cost
    print(f"After selling, shares: {s.shares}")
    print(f"After selling, cost: ${s.cost}")

except Exception as e:
    print(f"Error: {e}")

Ahora, ejecuta el archivo de prueba usando el siguiente comando:

python3 /home/labex/project/test_stock.py

Probablemente encontrarás un error similar a:

Error: missing a required argument: 'nshares'

Este error ocurre porque cuando Python llama a un método como s.sell(10), en realidad llama a Stock.sell(s, 10) detrás de escena. El parámetro self representa la instancia de la clase y se pasa automáticamente como el primer argumento. Sin embargo, nuestra ValidatedFunction no maneja correctamente este parámetro self porque no sabe que se está usando como un método.

Comprendiendo el problema

Cuando se define un método dentro de una clase y luego se reemplaza con una ValidatedFunction, en esencia se está envolviendo el método original. El problema es que el método envuelto no maneja automáticamente el parámetro self correctamente. Espera los argumentos de una manera que no tiene en cuenta que la instancia se pasa como el primer argumento.

Solucionando el problema

Para solucionar este problema, necesitamos modificar la forma en que manejamos los métodos. Crearemos una nueva clase llamada ValidatedMethod que pueda manejar correctamente las llamadas a métodos. Agrega el siguiente código al final del archivo validate.py:

import inspect

class ValidatedMethod:
    def __init__(self, func):
        self.func = func
        self.signature = inspect.signature(func)

    def __get__(self, instance, owner):
        ## This method is called when the attribute is accessed as a method
        if instance is None:
            return self

        ## Return a callable that binds 'self' to the instance
        def method_wrapper(*args, **kwargs):
            return self(instance, *args, **kwargs)

        return method_wrapper

    def __call__(self, instance, *args, **kwargs):
        ## Bind the arguments to the function parameters
        bound = self.signature.bind(instance, *args, **kwargs)

        ## Validate each argument against its annotation
        for name, val in bound.arguments.items():
            if name in self.func.__annotations__:
                ## Get the validator class from the annotation
                validator = self.func.__annotations__[name]
                ## Apply the validation
                validator.check(val)

        ## Call the function with the validated arguments
        return self.func(instance, *args, **kwargs)

Ahora, necesitamos modificar la clase Stock para usar ValidatedMethod en lugar de ValidatedFunction. Abre el archivo stock.py nuevamente:

code /home/labex/project/stock.py

Actualiza la clase Stock de la siguiente manera:

from validate import ValidatedMethod, Integer

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

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

    @ValidatedMethod
    def sell(self, nshares: Integer):
        self.shares -= nshares

La clase ValidatedMethod es un descriptor (descriptor), que es un tipo especial de objeto en Python que puede cambiar cómo se accede a los atributos. El método __get__ se llama cuando se accede al atributo como un método. Devuelve un objeto invocable que pasa correctamente la instancia como el primer argumento.

Ejecuta el archivo de prueba nuevamente usando el siguiente comando:

python3 /home/labex/project/test_stock.py

Ahora deberías ver una salida similar a:

Initial shares: 100
Initial cost: $49010.0
After selling, shares: 90
After selling, cost: $44109.0

Este desafío te ha mostrado un aspecto importante de los objetos invocables. Cuando se usan como métodos en una clase, requieren un manejo especial. Al implementar el protocolo de descriptor con el método __get__, podemos crear objetos invocables que funcionen correctamente tanto como funciones independientes como como métodos.

Resumen

En este laboratorio (lab), has aprendido cómo crear objetos invocables adecuados en Python. Primero, exploraste clases de validación básicas para la comprobación de tipos y creaste un objeto invocable utilizando el método __call__. Luego, mejoraste este objeto para realizar validaciones basadas en anotaciones de funciones y abordaste el desafío de usar objetos invocables como métodos de clase.

Los conceptos clave cubiertos incluyen objetos invocables y el método __call__, anotaciones de funciones para indicar tipos (type hinting), el uso del módulo inspect para examinar las firmas de las funciones y el protocolo de descriptor con el método __get__ para métodos de clase. Estas técnicas te permiten crear envoltorios de funciones poderosos para el procesamiento previo y posterior a la llamada, lo cual es un patrón fundamental para decoradores y otras características avanzadas de Python.