ディスクリプタについて学ぶ

Beginner

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

はじめに

この実験では、Python のディスクリプタ(descriptor)について学びます。ディスクリプタは、オブジェクトの属性アクセスをカスタマイズする強力な仕組みです。ディスクリプタを使用すると、属性のアクセス、設定、削除の方法を定義でき、オブジェクトの動作を制御し、検証ロジックを実装することが可能になります。

この実験の目的は、ディスクリプタプロトコルを理解し、カスタムディスクリプタを作成および使用し、ディスクリプタを用いたデータ検証を実装し、ディスクリプタの実装を最適化することです。実験中に、descrip.pystock.pyvalidate.py などのいくつかのファイルを作成します。

これは Guided Lab です。学習と実践を支援するためのステップバイステップの指示を提供します。各ステップを完了し、実践的な経験を積むために、指示に注意深く従ってください。過去のデータによると、この 初級 レベルの実験の完了率は 91%です。学習者から 100% の好評価を得ています。

ディスクリプタプロトコルの理解

このステップでは、簡単な Stock クラスを作成することで、Python のディスクリプタがどのように動作するかを学びます。Python のディスクリプタは、属性のアクセス、設定、削除方法をカスタマイズできる強力な機能です。ディスクリプタプロトコルは、__get__()__set__()__delete__() という 3 つの特殊メソッドで構成されています。これらのメソッドは、それぞれ属性がアクセスされたとき、値が割り当てられたとき、削除されたときのディスクリプタの動作を定義します。

まず、プロジェクトディレクトリに stock.py という新しいファイルを作成する必要があります。このファイルには Stock クラスを記述します。以下は 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})'

この Stock クラスでは、property デコレータを使用して、namesharesprice 属性のゲッターとセッターメソッドを定義しています。これらのゲッターとセッターメソッドはディスクリプタとして機能し、これらの属性のアクセスと設定方法を制御します。たとえば、セッターメソッドは入力値を検証し、正しい型であり、許容範囲内であることを確認します。

stock.py ファイルが準備できたら、Python シェルを開いて Stock クラスを試し、ディスクリプタが実際にどのように動作するかを確認しましょう。これを行うには、ターミナルを開き、以下のコマンドを実行します。

cd ~/project
python3 -i stock.py

python3 コマンドの -i オプションは、stock.py ファイルを実行した後に対話型シェルを起動するよう Python に指示します。これにより、先ほど定義した Stock クラスと直接対話できます。

Python シェルで、株式オブジェクトを作成し、その属性にアクセスしてみましょう。以下のように操作できます。

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

s オブジェクトの nameshares 属性にアクセスするとき、Python は実際にはディスクリプタの __get__ メソッドを内部で使用しています。クラス内の property デコレータはディスクリプタを使用して実装されており、属性のアクセスと割り当てを制御します。

ディスクリプタオブジェクトを確認するために、クラス辞書を詳しく見てみましょう。クラス辞書には、クラスで定義されたすべての属性とメソッドが含まれています。以下のコードを使用して、クラス辞書のキーを表示できます。

Stock.__dict__.keys()

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

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

namesharesprice のキーは、property デコレータによって作成されたディスクリプタオブジェクトを表しています。

次に、ディスクリプタのメソッドを手動で呼び出すことで、ディスクリプタがどのように動作するかを調べてみましょう。shares ディスクリプタを例に説明します。以下のように操作できます。

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

s.shares のように属性にアクセスするとき、Python はディスクリプタの __get__ メソッドを呼び出して値を取得します。s.shares = 75 のように値を割り当てるとき、Python はディスクリプタの __set__ メソッドを呼び出します。ディスクリプタはデータを検証し、入力値が無効な場合はエラーを発生させます。

Stock クラスとディスクリプタの実験が終了したら、以下のコマンドを実行して Python シェルを終了できます。

exit()

カスタムディスクリプタの作成

このステップでは、独自のディスクリプタクラスを作成します。まず、ディスクリプタとは何かを理解しましょう。ディスクリプタは、__get____set____delete__ メソッドから構成されるディスクリプタプロトコルを実装した Python オブジェクトです。これらのメソッドにより、ディスクリプタは属性のアクセス、設定、削除方法を管理できます。独自のディスクリプタクラスを作成することで、このプロトコルがどのように動作するかをより深く理解できます。

プロジェクトディレクトリに descrip.py という新しいファイルを作成します。このファイルにはカスタムディスクリプタクラスを記述します。以下がコードです。

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

Descriptor クラスでは、__init__ メソッドがディスクリプタを名前で初期化します。__get__ メソッドは属性がアクセスされたときに呼び出され、__set__ メソッドは属性が設定されたときに呼び出され、__delete__ メソッドは属性が削除されたときに呼び出されます。

次に、カスタムディスクリプタを試すためのテストファイルを作成しましょう。これにより、さまざまなシナリオでディスクリプタがどのように動作するかを確認できます。以下のコードを持つ test_descrip.py というファイルを作成します。

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

test_descrip.py ファイルでは、descrip.py から Descriptor クラスをインポートします。次に、3 つの属性 abc を持つ Foo クラスを作成し、それぞれがディスクリプタによって管理されます。Foo のインスタンスを作成し、属性のアクセス、設定、削除などの操作を行い、ディスクリプタメソッドがどのように呼び出されるかを確認します。

では、このテストファイルを実行して、ディスクリプタの動作を確認しましょう。ターミナルを開き、プロジェクトディレクトリに移動し、以下のコマンドを使用してテストファイルを実行します。

cd ~/project
python3 test_descrip.py

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

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__

ご覧のように、ディスクリプタによって管理される属性をアクセス、設定、または削除するたびに、対応するマジックメソッド (__get____set__、または __delete__) が呼び出されます。

また、ディスクリプタを対話的に調べてみましょう。これにより、ディスクリプタをリアルタイムでテストし、すぐに結果を確認できます。ターミナルを開き、プロジェクトディレクトリに移動し、descrip.py ファイルを使用して対話型 Python セッションを開始します。

cd ~/project
python3 -i descrip.py

次に、対話型 Python セッションで以下のコマンドを入力して、ディスクリプタプロトコルがどのように動作するかを確認します。

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

ここでの重要なポイントは、ディスクリプタが属性アクセスをインターセプトしてカスタマイズする方法を提供することです。これにより、データ検証、計算属性、その他の高度な動作の実装に強力な手段となります。ディスクリプタを使用することで、クラス属性のアクセス、設定、削除方法をより細かく制御できます。

ディスクリプタを使用したバリデータの実装

このステップでは、ディスクリプタを使用してバリデーションシステムを作成します。まず、ディスクリプタとは何か、なぜそれを使用するのかを理解しましょう。ディスクリプタは、__get____set__、または __delete__ メソッドを含むディスクリプタプロトコルを実装した Python オブジェクトです。これらを使用すると、オブジェクトの属性のアクセス、設定、または削除方法をカスタマイズできます。今回のケースでは、ディスクリプタを使用してデータの整合性を保証するバリデーションシステムを作成します。つまり、オブジェクトに格納されるデータは常に特定の条件(特定の型である、正の値であるなど)を満たすようになります。

では、バリデーションシステムの作成を始めましょう。プロジェクトディレクトリに validate.py という新しいファイルを作成します。このファイルには、バリデータを実装するクラスが含まれます。

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

validate.py ファイルでは、まず Validator という基底クラスを定義しています。このクラスには、検証対象の属性を識別するために使用される name パラメータを受け取る __init__ メソッドがあります。check メソッドはクラスメソッドで、渡された値をそのまま返します。__set__ メソッドは、オブジェクトの属性が設定されたときに呼び出されるディスクリプタメソッドです。このメソッドは check メソッドを呼び出して値を検証し、検証された値をオブジェクトの辞書に格納します。

次に、Validator の 3 つのサブクラス StringPositiveIntegerPositiveFloat を定義しています。これらのサブクラスはそれぞれ、特定のバリデーションチェックを実行するために check メソッドをオーバーライドしています。String クラスは値が文字列であるかをチェックし、PositiveInteger クラスは値が正の整数であるかをチェックし、PositiveFloat クラスは値が正の数(整数または浮動小数点数)であるかをチェックします。

バリデータが定義できたので、これらのバリデータを使用するように Stock クラスを修正しましょう。stock_with_validators.py という新しいファイルを作成し、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})'

stock_with_validators.py ファイルでは、Stock クラスを定義し、バリデータをクラス属性として使用しています。これは、Stock オブジェクトの属性が設定されるたびに、対応するバリデータの __set__ メソッドが呼び出されて値が検証されることを意味します。__init__ メソッドは Stock オブジェクトの属性を初期化し、costsell__repr__ メソッドは追加の機能を提供します。

では、バリデータベースの Stock クラスをテストしましょう。ターミナルを開き、プロジェクトディレクトリに移動し、対話モードで stock_with_validators.py ファイルを実行します。

cd ~/project
python3 -i stock_with_validators.py

Python インタープリタが起動したら、バリデーションシステムをテストするためのいくつかのコマンドを試してみましょう。

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

テストコードでは、まず有効な値で Stock オブジェクトを作成し、その属性を印刷して正しく設定されていることを確認します。次に、shares 属性を有効な値に変更し、再度印刷して変更が反映されていることを確認します。最後に、shares 属性を無効な値(文字列と負の数)に設定し、バリデータによって発生する例外をキャッチします。

コードがかなりクリーンになったことに注目してください。Stock クラスはもはやすべてのプロパティメソッドを実装する必要がなくなりました。バリデータがすべての型チェックと制約を処理します。

ディスクリプタを使用することで、任意のクラス属性に適用できる再利用可能なバリデーションシステムを作成することができました。これは、アプリケーション全体でデータの整合性を維持するための強力なパターンです。

ディスクリプタ実装の改善

このステップでは、ディスクリプタの実装を強化します。場合によっては、名前を冗長に指定していることに気づいたかもしれません。これはコードを少し混乱させ、保守が難しくなる可能性があります。この問題を解決するために、Python 3.6 で導入された便利な機能である __set_name__ メソッドを使用します。

__set_name__ メソッドは、クラスが定義されると自動的に呼び出されます。その主な役割は、ディスクリプタの名前を自動的に設定することで、毎回手動で設定する必要がなくなります。これにより、コードがクリーンで効率的になります。

では、validate.py ファイルを更新して __set_name__ メソッドを追加しましょう。更新後のコードは次のようになります。

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

上記のコードでは、Validator クラスの __set_name__ メソッドが name 属性が None かどうかをチェックします。もし None であれば、name をクラス定義で使用される実際の属性名に設定します。このようにして、ディスクリプタクラスのインスタンスを作成するときに名前を明示的に指定する必要がなくなります。

validate.py ファイルを更新したので、Stock クラスの改良版を作成できます。この新しいバージョンでは、名前を冗長に指定する必要がありません。改良版の Stock クラスのコードは次の通りです。

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

この Stock クラスでは、StringPositiveIntegerPositiveFloat ディスクリプタクラスのインスタンスを名前を指定せずに作成しています。Validator クラスの __set_name__ メソッドが名前を自動的に設定します。

改良版の Stock クラスをテストしましょう。まず、ターミナルを開き、プロジェクトディレクトリに移動します。次に、対話モードで improved_stock.py ファイルを実行します。以下はそのためのコマンドです。

cd ~/project
python3 -i improved_stock.py

対話型 Python セッションに入ったら、次のコマンドを試して 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()

これらのコマンドは、Stock クラスのインスタンスを作成し、その属性を印刷し、属性の値を変更し、そして無効な値を設定して適切なエラーが発生するかどうかを確認します。

__set_name__ メソッドは、クラスが定義されるときにディスクリプタの名前を自動的に設定します。これにより、属性名を 2 回指定する必要がなくなり、コードがクリーンで冗長性が減ります。

この改善は、Python のディスクリプタプロトコルがどのように進化し、クリーンで保守可能なコードを書きやすくしているかを示しています。

まとめ

この実験では、Python のディスクリプタについて学びました。ディスクリプタは、クラス内の属性アクセスをカスタマイズできる強力な機能です。__get____set____delete__ メソッドを含むディスクリプタプロトコルを探索しました。また、属性アクセスをインターセプトする基本的なディスクリプタクラスを作成し、ディスクリプタを使用してデータの整合性を保証するバリデーションシステムを実装しました。

さらに、__set_name__ メソッドを使用してディスクリプタを強化し、冗長性を減らしました。ディスクリプタは Django や SQLAlchemy などの Python ライブラリやフレームワークで広く使用されています。ディスクリプタを理解することで、Python の深い理解が得られ、よりエレガントで保守可能なコードを書くことができます。