Definir um Objeto Chamável Adequado

Beginner

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

Introdução

Neste laboratório, você aprenderá sobre objetos chamáveis (callable objects) em Python. Um objeto chamável pode ser invocado como uma função usando a sintaxe object(). Embora as funções Python sejam inerentemente chamáveis, você pode criar objetos chamáveis personalizados implementando o método __call__.

Você também aprenderá a implementar um objeto chamável usando o método __call__ e a usar anotações de função com objetos chamáveis para validação de parâmetros. O arquivo validate.py será modificado durante este laboratório.

Compreendendo as Classes Validadoras

Neste laboratório, vamos construir um conjunto de classes validadoras para criar um objeto chamável. Antes de começarmos a construir, é importante entender as classes validadoras fornecidas no arquivo validate.py. Essas classes nos ajudarão a realizar a verificação de tipo (type checking), que é uma parte crucial para garantir que nosso código funcione como esperado.

Vamos começar abrindo o arquivo validate.py no WebIDE. Este arquivo contém o código para as classes validadoras que usaremos. Para abri-lo, execute o seguinte comando no terminal:

code /home/labex/project/validate.py

Depois de abrir o arquivo, você verá que ele contém várias classes. Aqui está uma breve visão geral do que cada classe faz:

  1. Validator: Esta é uma classe base. Ela tem um método check, mas atualmente, este método não faz nada. Ele serve como um ponto de partida para as outras classes validadoras.
  2. Typed: Esta é uma subclasse de Validator. Sua principal função é verificar se um valor é de um tipo específico.
  3. Integer, Float e String: Estes são validadores de tipo específicos que herdam de Typed. Eles são projetados para verificar se um valor é um inteiro, um float ou uma string, respectivamente.

Agora, vamos ver como essas classes validadoras funcionam na prática. Criaremos um novo arquivo chamado test.py para testá-las. Para criar e abrir este arquivo, execute o seguinte comando:

code /home/labex/project/test.py

Depois que o arquivo test.py estiver aberto, adicione o seguinte código a ele. Este código testará os validadores Integer e 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}")

Neste código, primeiro importamos os validadores Integer, String e Float do arquivo validate.py. Em seguida, testamos o validador Integer tentando verificar um valor inteiro (42) e um valor string ("Hello"). Se a verificação passar para o inteiro, imprimimos uma mensagem de sucesso. Se passar incorretamente para a string, imprimimos uma mensagem de erro. Se a verificação levantar corretamente um TypeError para a string, imprimimos uma mensagem de sucesso. Fazemos um teste semelhante para o validador String.

Depois de adicionar o código, execute o arquivo de teste usando o seguinte comando:

python3 /home/labex/project/test.py

Você deve ver uma saída semelhante 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 você pode ver, essas classes validadoras nos permitem realizar a verificação de tipo facilmente. Por exemplo, quando você chama Integer.check(x), ele levantará um TypeError se x não for um inteiro.

Agora, vamos pensar em um cenário prático. Suponha que temos uma função que exige que seus argumentos sejam de tipos específicos. Aqui está um exemplo de tal função:

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 função funciona, mas há um problema. Temos que adicionar manualmente as verificações do validador toda vez que quisermos usar a verificação de tipo. Isso pode ser demorado e propenso a erros, especialmente para funções ou projetos maiores.

Nos próximos passos, resolveremos esse problema criando um objeto chamável. Este objeto será capaz de aplicar automaticamente essas verificações de tipo com base nas anotações de função. Dessa forma, não teremos que adicionar as verificações manualmente toda vez.

Criando um Objeto Chamável Básico

Em Python, um objeto chamável (callable object) é um objeto que pode ser usado como uma função. Você pode pensar nisso como algo que você pode "chamar" colocando parênteses depois dele, semelhante a como você chama uma função regular. Para fazer com que uma classe em Python aja como um objeto chamável, precisamos implementar um método especial chamado __call__. Este método é invocado automaticamente quando você usa o objeto com parênteses, assim como quando você chama uma função.

Vamos começar modificando o arquivo validate.py. Vamos adicionar uma nova classe chamada ValidatedFunction a este arquivo, e esta classe será nosso objeto chamável. Para abrir o arquivo no editor de código, execute o seguinte comando no terminal:

code /home/labex/project/validate.py

Depois que o arquivo estiver aberto, role até o final dele e adicione o seguinte 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

Vamos detalhar o que este código faz. A classe ValidatedFunction tem um método __init__, que é o construtor. Quando você cria uma instância desta classe, você passa uma função para ela. Esta função é então armazenada como um atributo da instância, chamado self.func.

O método __call__ é a parte chave que torna esta classe chamável. Quando você chama uma instância da classe ValidatedFunction, este método __call__ é executado. Veja o que ele faz passo a passo:

  1. Ele imprime uma mensagem que informa qual função está sendo chamada. Isso é útil para depuração e compreensão do que está acontecendo.
  2. Ele chama a função que foi armazenada em self.func com os argumentos que você passou quando chamou a instância. *args e **kwargs permitem que você passe qualquer número de argumentos posicionais e de palavras-chave.
  3. Ele retorna o resultado da chamada da função.

Agora, vamos testar esta classe ValidatedFunction. Criaremos um novo arquivo chamado test_callable.py para escrever nosso código de teste. Para abrir este novo arquivo no editor de código, execute o seguinte comando:

code /home/labex/project/test_callable.py

Adicione o seguinte código ao arquivo 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}")

Neste código, primeiro importamos a classe ValidatedFunction do arquivo validate.py. Em seguida, definimos uma função simples chamada add que recebe dois números e retorna sua soma.

Criamos uma instância da classe ValidatedFunction, passando a função add para ela. Isso "envolve" a função add dentro da instância ValidatedFunction.

Em seguida, chamamos a função envolvida duas vezes, uma vez com os argumentos 2 e 3, e depois com 10 e 20. Cada vez que chamamos a função envolvida, o método __call__ da classe ValidatedFunction é invocado, que por sua vez chama a função add original.

Para executar o código de teste, execute o seguinte comando no terminal:

python3 /home/labex/project/test_callable.py

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

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

Esta saída mostra que nosso objeto chamável está funcionando como esperado. Quando chamamos validated_add(2, 3), na verdade estamos chamando o método __call__ da classe ValidatedFunction, que então chama a função add original.

No momento, nossa classe ValidatedFunction apenas imprime uma mensagem e passa a chamada para a função original. Na próxima etapa, melhoraremos esta classe para realizar a validação de tipo com base nas anotações da função.

Implementando a Validação de Tipo com Anotações de Função

Em Python, você tem a capacidade de adicionar anotações de tipo aos parâmetros de função. Essas anotações servem como uma maneira de indicar os tipos de dados esperados dos parâmetros e o valor de retorno de uma função. Elas não impõem os tipos em tempo de execução por padrão, mas podem ser usadas para fins de validação.

Vamos dar uma olhada em um exemplo:

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

Neste código, x: int e y: int nos dizem que os parâmetros x e y devem ser inteiros. O -> int no final indica que a função add retorna um inteiro. Essas anotações de tipo são armazenadas no atributo __annotations__ da função, que é um dicionário que mapeia nomes de parâmetros para seus tipos anotados.

Agora, vamos aprimorar nossa classe ValidatedFunction para usar essas anotações de tipo para validação. Para fazer isso, precisaremos usar o módulo inspect do Python. Este módulo fornece funções úteis para obter informações sobre objetos ativos, como módulos, classes, métodos, funções, etc. Em nosso caso, usaremos para corresponder os argumentos da função com seus nomes de parâmetros correspondentes.

Primeiro, precisamos modificar a classe ValidatedFunction no arquivo validate.py. Você pode abrir este arquivo usando o seguinte comando:

code /home/labex/project/validate.py

Substitua a classe ValidatedFunction existente pela seguinte versão aprimorada:

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)

Aqui está o que esta versão aprimorada faz:

  1. Ele usa inspect.signature() para obter informações sobre os parâmetros da função, como seus nomes, valores padrão e tipos anotados.
  2. O método bind() da assinatura é usado para corresponder os argumentos fornecidos aos seus nomes de parâmetros correspondentes. Isso nos ajuda a associar cada argumento ao seu parâmetro correto na função.
  3. Ele verifica cada argumento em relação à sua anotação de tipo (se existir). Se uma anotação for encontrada, ele recupera a classe validadora da anotação e aplica a validação usando o método check().
  4. Finalmente, ele chama a função original com os argumentos validados.

Agora, vamos testar esta classe ValidatedFunction aprimorada com algumas funções que usam nossas classes validadoras em suas anotações de tipo. Abra o arquivo test_validation.py usando o seguinte comando:

code /home/labex/project/test_validation.py

Adicione o seguinte código ao arquivo:

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

Neste código, definimos uma função greet com anotações de tipo name: String e times: Integer. Isso significa que o parâmetro name deve ser validado usando a classe String e o parâmetro times deve ser validado usando a classe Integer. Em seguida, envolvemos a função greet com nossa classe ValidatedFunction para habilitar a validação de tipo.

Realizamos três casos de teste: uma chamada válida, uma chamada inválida com o tipo errado para name e uma chamada inválida com o tipo errado para times. Cada chamada é envolvida em um bloco try-except para capturar quaisquer exceções TypeError que possam ser levantadas durante a validação.

Para executar o arquivo de teste, use o seguinte comando:

python3 /home/labex/project/test_validation.py

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

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

Esta saída demonstra que nosso objeto chamável ValidatedFunction agora está aplicando a validação de tipo com base nas anotações da função. Quando passamos argumentos do tipo errado, as classes validadoras detectam o erro e levantam um TypeError. Dessa forma, podemos garantir que as funções sejam chamadas com os tipos de dados corretos, o que ajuda a evitar bugs e torna nosso código mais robusto.

Desafio: Usando um Objeto Chamável como um Método

Em Python, quando você usa um objeto chamável como um método dentro de uma classe, há um desafio único que você precisa enfrentar. Um objeto chamável é algo que você pode "chamar" como uma função, como uma função em si ou um objeto com um método __call__. Quando usado como um método de classe, nem sempre funciona como esperado devido à forma como o Python passa a instância (self) como o primeiro argumento.

Vamos explorar esse problema criando uma classe Stock. Esta classe representará uma ação com atributos como nome, número de ações e preço. Também usaremos um validador para garantir que os dados com os quais estamos trabalhando estejam corretos.

Primeiro, abra o arquivo stock.py para começar a escrever nossa classe Stock. Você pode usar o seguinte comando para abrir o arquivo em um editor:

code /home/labex/project/stock.py

Agora, adicione o seguinte código ao arquivo stock.py. Este código define a classe Stock com um método __init__ para inicializar os atributos da ação, uma propriedade cost para calcular o custo total e um método sell para reduzir o número de ações. Também tentaremos usar o ValidatedFunction para validar a entrada para o 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)

Depois de definir a classe Stock, precisamos testá-la para ver se ela funciona como esperado. Crie um arquivo de teste chamado test_stock.py e abra-o usando o seguinte comando:

code /home/labex/project/test_stock.py

Adicione o seguinte código ao arquivo test_stock.py. Este código cria uma instância da classe Stock, imprime o número inicial de ações e o custo, tenta vender algumas ações e, em seguida, imprime o número atualizado de ações e o custo.

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

Agora, execute o arquivo de teste usando o seguinte comando:

python3 /home/labex/project/test_stock.py

Você provavelmente encontrará um erro semelhante a:

Error: missing a required argument: 'nshares'

Este erro ocorre porque, quando o Python chama um método como s.sell(10), ele realmente chama Stock.sell(s, 10) nos bastidores. O parâmetro self representa a instância da classe e é automaticamente passado como o primeiro argumento. No entanto, nosso ValidatedFunction não lida com este parâmetro self corretamente porque não sabe que está sendo usado como um método.

Entendendo o Problema

Quando você define um método dentro de uma classe e, em seguida, o substitui por um ValidatedFunction, você está essencialmente envolvendo o método original. O problema é que o método envolvido não lida automaticamente com o parâmetro self corretamente. Ele espera os argumentos de uma forma que não leva em consideração a instância sendo passada como o primeiro argumento.

Corrigindo o Problema

Para corrigir esse problema, precisamos modificar a maneira como lidamos com os métodos. Criaremos uma nova classe chamada ValidatedMethod que pode lidar com chamadas de método corretamente. Adicione o seguinte código ao final do arquivo 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)

Agora, precisamos modificar a classe Stock para usar ValidatedMethod em vez de ValidatedFunction. Abra o arquivo stock.py novamente:

code /home/labex/project/stock.py

Atualize a classe Stock da seguinte forma:

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

A classe ValidatedMethod é um descritor (descriptor), que é um tipo especial de objeto em Python que pode alterar como os atributos são acessados. O método __get__ é chamado quando o atributo é acessado como um método. Ele retorna um chamável que passa corretamente a instância como o primeiro argumento.

Execute o arquivo de teste novamente usando o seguinte comando:

python3 /home/labex/project/test_stock.py

Agora você deve ver uma saída semelhante a:

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

Este desafio mostrou a você um aspecto importante dos objetos chamáveis. Ao usá-los como métodos em uma classe, eles exigem um tratamento especial. Ao implementar o protocolo de descritor com o método __get__, podemos criar objetos chamáveis que funcionam corretamente tanto como funções independentes quanto como métodos.

Resumo

Neste laboratório, você aprendeu como criar objetos chamáveis (callable objects) adequados em Python. Primeiro, você explorou classes validadoras básicas para verificação de tipos (type-checking) e criou um objeto chamável usando o método __call__. Em seguida, você aprimorou este objeto para realizar a validação com base nas anotações de função e abordou o desafio de usar objetos chamáveis como métodos de classe.

Os principais conceitos abordados incluem objetos chamáveis e o método __call__, anotações de função para dicas de tipo (type hinting), usando o módulo inspect para examinar assinaturas de função (function signatures) e o protocolo de descritor (descriptor protocol) com o método __get__ para métodos de classe. Essas técnicas permitem que você crie wrappers de função poderosos para processamento pré e pós-chamada (pre- e post-call processing), que é um padrão fundamental para decoradores (decorators) e outros recursos avançados do Python.