適切な呼び出し可能オブジェクトを定義する

PythonPythonBeginner
オンラインで実践に進む

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

💡 このチュートリアルは英語版からAIによって翻訳されています。原文を確認するには、 ここをクリックしてください

はじめに

この実験では、Python の呼び出し可能オブジェクトについて学びます。呼び出し可能オブジェクトは、object() 構文を使用して関数のように呼び出すことができます。Python の関数は本質的に呼び出し可能ですが、__call__ メソッドを実装することでカスタムの呼び出し可能オブジェクトを作成することができます。

また、__call__ メソッドを使用して呼び出し可能オブジェクトを実装し、呼び出し可能オブジェクトに関数アノテーションを使用してパラメーターの検証を行う方法を学びます。この実験中に validate.py ファイルを変更します。

バリデータクラスの理解

この実験では、バリデータクラスのセットを基にして呼び出し可能オブジェクトを作成します。作成を開始する前に、validate.py ファイルに用意されているバリデータクラスを理解することが重要です。これらのクラスは、型チェックを行うのに役立ちます。型チェックは、コードが期待通りに動作することを保証する上で重要な要素です。

まず、WebIDE で validate.py ファイルを開きましょう。このファイルには、使用するバリデータクラスのコードが含まれています。ファイルを開くには、ターミナルで以下のコマンドを実行します。

code /home/labex/project/validate.py

ファイルを開くと、いくつかのクラスが含まれていることがわかります。各クラスの機能の概要を以下に示します。

  1. Validator:これは基底クラスです。check メソッドを持っていますが、現在は何もしません。他のバリデータクラスの起点として機能します。
  2. Typed:これは Validator のサブクラスです。主な役割は、値が特定の型であるかどうかをチェックすることです。
  3. IntegerFloatString:これらは Typed を継承した特定の型のバリデータです。それぞれ、値が整数、浮動小数点数、または文字列であるかどうかをチェックするように設計されています。

では、これらのバリデータクラスが実際にどのように動作するか見てみましょう。これらをテストするために、test.py という新しいファイルを作成します。このファイルを作成して開くには、以下のコマンドを実行します。

code /home/labex/project/test.py

test.py ファイルが開いたら、以下のコードを追加します。このコードは、IntegerString のバリデータをテストします。

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

このコードでは、まず validate.py ファイルから IntegerStringFloat のバリデータをインポートします。次に、整数値 (42) と文字列値 ("Hello") をチェックすることで Integer バリデータをテストします。整数のチェックが通過した場合は成功メッセージを出力し、文字列のチェックが誤って通過した場合はエラーメッセージを出力します。文字列に対して正しく TypeError が発生した場合は成功メッセージを出力します。String バリデータについても同様のテストを行います。

コードを追加した後、以下のコマンドを使用してテストファイルを実行します。

python3 /home/labex/project/test.py

以下のような出力が表示されるはずです。

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

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

このように、これらのバリデータクラスを使用すると、簡単に型チェックを行うことができます。たとえば、Integer.check(x) を呼び出すと、x が整数でない場合は TypeError が発生します。

では、実際のシナリオを考えてみましょう。特定の型の引数を必要とする関数があるとします。以下はそのような関数の例です。

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

この関数は動作しますが、問題があります。型チェックを行うたびに手動でバリデータのチェックを追加する必要があります。これは、特に大きな関数やプロジェクトでは時間がかかり、エラーが発生しやすくなります。

次のステップでは、呼び出し可能オブジェクトを作成することでこの問題を解決します。このオブジェクトは、関数アノテーションに基づいてこれらの型チェックを自動的に適用することができます。これにより、毎回手動でチェックを追加する必要がなくなります。

基本的な呼び出し可能オブジェクトの作成

Python では、呼び出し可能オブジェクトとは、関数のように使用できるオブジェクトです。関数を呼び出すときのように、オブジェクトの後に括弧を付けて「呼び出す」ことができるものと考えることができます。Python のクラスを呼び出し可能オブジェクトのように振る舞わせるには、__call__ という特殊メソッドを実装する必要があります。このメソッドは、オブジェクトに括弧を付けて使用すると自動的に呼び出され、関数を呼び出すときと同じように動作します。

まず、validate.py ファイルを変更しましょう。このファイルに ValidatedFunction という新しいクラスを追加します。このクラスが私たちの呼び出し可能オブジェクトになります。コードエディタでファイルを開くには、ターミナルで以下のコマンドを実行します。

code /home/labex/project/validate.py

ファイルが開いたら、末尾までスクロールして以下のコードを追加します。

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

このコードが何をするか解説しましょう。ValidatedFunction クラスには __init__ メソッドがあり、これはコンストラクタです。このクラスのインスタンスを作成するときに、関数を渡します。この関数は、インスタンスの属性として self.func という名前で保存されます。

__call__ メソッドが、このクラスを呼び出し可能にする重要な部分です。ValidatedFunction クラスのインスタンスを呼び出すと、この __call__ メソッドが実行されます。以下は、その動作をステップごとに説明したものです。

  1. どの関数が呼び出されているかを示すメッセージを出力します。これはデバッグや動作の理解に役立ちます。
  2. インスタンスを呼び出したときに渡された引数で、self.func に保存されている関数を呼び出します。*args**kwargs を使用することで、任意の数の位置引数とキーワード引数を渡すことができます。
  3. 関数呼び出しの結果を返します。

では、この ValidatedFunction クラスをテストしましょう。テストコードを書くために、test_callable.py という新しいファイルを作成します。コードエディタでこの新しいファイルを開くには、以下のコマンドを実行します。

code /home/labex/project/test_callable.py

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

このコードでは、まず validate.py ファイルから ValidatedFunction クラスをインポートします。次に、2 つの数値を受け取り、その合計を返す add という簡単な関数を定義します。

ValidatedFunction クラスのインスタンスを作成し、add 関数を渡します。これにより、add 関数が ValidatedFunction インスタンスの中に「ラップ」されます。

その後、ラップされた関数を 2 回呼び出します。1 回目は引数 23 で、2 回目は 1020 です。ラップされた関数を呼び出すたびに、ValidatedFunction クラスの __call__ メソッドが呼び出され、それが元の add 関数を呼び出します。

テストコードを実行するには、ターミナルで以下のコマンドを実行します。

python3 /home/labex/project/test_callable.py

以下のような出力が表示されるはずです。

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

この出力は、私たちの呼び出し可能オブジェクトが期待通りに動作していることを示しています。validated_add(2, 3) を呼び出すと、実際には ValidatedFunction クラスの __call__ メソッドが呼び出され、それが元の add 関数を呼び出します。

現時点では、ValidatedFunction クラスはメッセージを出力し、呼び出しを元の関数に渡すだけです。次のステップでは、このクラスを改良して、関数のアノテーションに基づいた型検証を行うようにします。

✨ 解答を確認して練習

関数アノテーションを用いた型検証の実装

Python では、関数のパラメータに型アノテーションを追加することができます。これらのアノテーションは、パラメータと関数の戻り値の期待されるデータ型を示す方法として機能します。デフォルトでは実行時に型を強制しませんが、検証目的で使用することができます。

例を見てみましょう。

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

このコードでは、x: inty: int は、パラメータ xy が整数であるべきことを示しています。末尾の -> int は、関数 add が整数を返すことを示しています。これらの型アノテーションは、関数の __annotations__ 属性に格納されます。この属性は、パラメータ名をそのアノテーションされた型にマッピングする辞書です。

では、これらの型アノテーションを検証に使用するように、ValidatedFunction クラスを拡張しましょう。これを行うには、Python の inspect モジュールを使用する必要があります。このモジュールは、モジュール、クラス、メソッド、関数などのライブオブジェクトに関する情報を取得するための便利な関数を提供します。今回の場合、関数の引数を対応するパラメータ名とマッチングさせるために使用します。

まず、validate.py ファイルの ValidatedFunction クラスを変更する必要があります。以下のコマンドを使用してこのファイルを開くことができます。

code /home/labex/project/validate.py

既存の ValidatedFunction クラスを以下の改良版に置き換えます。

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)

この改良版は以下のようなことを行います。

  1. inspect.signature() を使用して、関数のパラメータに関する情報(名前、デフォルト値、アノテーションされた型など)を取得します。
  2. シグネチャの bind() メソッドを使用して、提供された引数を対応するパラメータ名とマッチングさせます。これにより、各引数を関数内の正しいパラメータに関連付けることができます。
  3. 各引数をその型アノテーション(存在する場合)と照合します。アノテーションが見つかった場合、アノテーションからバリデータクラスを取得し、check() メソッドを使用して検証を適用します。
  4. 最後に、検証された引数で元の関数を呼び出します。

では、型アノテーションにバリデータクラスを使用するいくつかの関数で、この拡張された ValidatedFunction クラスをテストしましょう。以下のコマンドを使用して test_validation.py ファイルを開きます。

code /home/labex/project/test_validation.py

ファイルに以下のコードを追加します。

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

このコードでは、型アノテーション name: Stringtimes: Integer を持つ greet 関数を定義しています。これは、name パラメータは String クラスを使用して検証され、times パラメータは Integer クラスを使用して検証されることを意味します。その後、greet 関数を ValidatedFunction クラスでラップして、型検証を有効にします。

3 つのテストケースを実行します。有効な呼び出し、name の型が間違っている無効な呼び出し、times の型が間違っている無効な呼び出しです。各呼び出しは、検証中に発生する可能性のある TypeError 例外をキャッチするために try-except ブロックでラップされています。

テストファイルを実行するには、以下のコマンドを使用します。

python3 /home/labex/project/test_validation.py

以下のような出力が表示されるはずです。

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

この出力は、ValidatedFunction 呼び出し可能オブジェクトが、関数アノテーションに基づいて型検証を実施していることを示しています。間違った型の引数を渡すと、バリデータクラスがエラーを検出し、TypeError を発生させます。これにより、関数が正しいデータ型で呼び出されることを保証でき、バグを防ぎ、コードをより堅牢にすることができます。

✨ 解答を確認して練習

チャレンジ:呼び出し可能オブジェクトをメソッドとして使用する

Python では、呼び出し可能オブジェクトをクラス内のメソッドとして使用する場合、独自の課題に直面することがあります。呼び出し可能オブジェクトとは、関数のように「呼び出す」ことができるもので、関数自体や __call__ メソッドを持つオブジェクトなどが該当します。クラスメソッドとして使用すると、Python がインスタンス (self) を最初の引数として渡す仕組みのため、必ずしも期待通りに動作しないことがあります。

この問題を Stock クラスを作成することで探ってみましょう。このクラスは、名前、株式数、価格などの属性を持つ株式を表します。また、取り扱うデータが正しいことを確認するためにバリデータを使用します。

まず、Stock クラスを記述するために stock.py ファイルを開きます。以下のコマンドを使用してエディタでファイルを開くことができます。

code /home/labex/project/stock.py

次に、stock.py ファイルに以下のコードを追加します。このコードは、株式の属性を初期化する __init__ メソッド、総コストを計算する cost プロパティ、株式数を減らす sell メソッドを持つ Stock クラスを定義します。また、sell メソッドの入力を検証するために ValidatedFunction を使用しようとします。

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)

Stock クラスを定義した後、期待通りに動作するかどうかをテストする必要があります。test_stock.py という名前のテストファイルを作成し、以下のコマンドを使用して開きます。

code /home/labex/project/test_stock.py

test_stock.py ファイルに以下のコードを追加します。このコードは、Stock クラスのインスタンスを作成し、初期の株式数とコストを表示し、いくつかの株式を売却しようとし、その後更新された株式数とコストを表示します。

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

次に、以下のコマンドを使用してテストファイルを実行します。

python3 /home/labex/project/test_stock.py

おそらく、以下のようなエラーが発生するでしょう。

Error: missing a required argument: 'nshares'

このエラーが発生するのは、Python が s.sell(10) のようなメソッドを呼び出すとき、実際には Stock.sell(s, 10) を呼び出すためです。self パラメータはクラスのインスタンスを表し、最初の引数として自動的に渡されます。しかし、ValidatedFunction はこの self パラメータを正しく処理できません。なぜなら、メソッドとして使用されていることを認識していないからです。

問題の理解

クラス内でメソッドを定義し、それを ValidatedFunction で置き換えると、実質的に元のメソッドをラップすることになります。問題は、ラップされたメソッドが self パラメータを正しく自動的に処理しないことです。インスタンスが最初の引数として渡されることを考慮していない形で引数を期待しています。

問題の解決

この問題を解決するには、メソッドの処理方法を変更する必要があります。メソッド呼び出しを適切に処理できる ValidatedMethod という新しいクラスを作成します。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)

次に、Stock クラスを変更して ValidatedFunction の代わりに ValidatedMethod を使用するようにします。再度 stock.py ファイルを開きます。

code /home/labex/project/stock.py

Stock クラスを以下のように更新します。

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

ValidatedMethod クラスはディスクリプタです。ディスクリプタは、属性のアクセス方法を変更できる Python の特殊なオブジェクトです。__get__ メソッドは、属性がメソッドとしてアクセスされたときに呼び出されます。このメソッドは、インスタンスを最初の引数として正しく渡す呼び出し可能オブジェクトを返します。

以下のコマンドを使用して再度テストファイルを実行します。

python3 /home/labex/project/test_stock.py

今度は、以下のような出力が表示されるはずです。

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

このチャレンジは、呼び出し可能オブジェクトの重要な側面を示しています。クラス内のメソッドとして使用する場合、特別な処理が必要です。__get__ メソッドを使用してディスクリプタプロトコルを実装することで、スタンドアロン関数としてもメソッドとしても正しく動作する呼び出し可能オブジェクトを作成することができます。

まとめ

この実験では、Python で適切な呼び出し可能オブジェクトを作成する方法を学びました。まず、型チェック用の基本的なバリデータクラスを探索し、__call__ メソッドを使用して呼び出し可能オブジェクトを作成しました。次に、このオブジェクトを拡張して、関数アノテーションに基づく検証を行い、呼び出し可能オブジェクトをクラスメソッドとして使用する際のチャレンジに取り組みました。

カバーされた主要な概念には、呼び出し可能オブジェクトと __call__ メソッド、型ヒント用の関数アノテーション、関数シグネチャを調べるための inspect モジュールの使用、およびクラスメソッド用の __get__ メソッドを持つディスクリプタプロトコルが含まれます。これらの技術を使用すると、呼び出し前と呼び出し後の処理を行う強力な関数ラッパーを作成できます。これは、デコレータやその他の高度な Python 機能の基本的なパターンです。