見出し画像

Python 3: Deep Dive (Part 4 - OOP): 関数はディスクリプタ (セクション8-5/15)

  • Pythonのディスクリプタを使用することで、`ValidType`のような再利用可能な型検証システムを作成でき、コードの重複を大幅に削減できます。

  • 多角形や点などの複雑なデータ構造において、ディスクリプタを活用することで、データの整合性を自動的に検証し、堅牢なクラス設計を実現できます。

  • Pythonでは関数自体がディスクリプタとして機能しており、これによってインスタンスメソッドが自動的にインスタンスにバインドされる仕組みが実現されています。

Pythonのディスクリプタプロトコルの学習を通じて、ディスクリプタがどのようにデータを検証し、複雑なストレージソリューションを管理し、プロパティやメソッドの基礎となるかを学んできました。この最終回(レッスン105-108)では、実践的なアプリケーション例(型付き属性や点を持つ多角形など)を見て、そして最後にPythonにおいて関数自体がディスクリプタであるという興味深い観察で締めくくります。


1. アプリケーション例1 – 属性型の検証

再利用可能なディスクリプタ:`ValidType`

ディスクリプタの最大の利点の一つは、繰り返しコードを減らせることです。属性が常に特定の型(例:`int`、`float`、`list`)である必要がある場合、特殊なディスクリプタを定義できます—さらに良いのは、指定した任意の型を検証できる単一の汎用ディスクリプタです:

class ValidType:
    def __init__(self, type_):
        self._type = type_

    def __set_name__(self, owner_class, prop_name):
        self.prop_name = prop_name

    def __set__(self, instance, value):
        if not isinstance(value, self._type):
            raise ValueError(f'{self.prop_name} must be of type {self._type.__name__}')
        instance.__dict__[self.prop_name] = value

    def __get__(self, instance, owner_class):
        if instance is None:
            return self
        return instance.__dict__.get(self.prop_name, None)

このディスクリプタは、各代入が与えられた`_type`と一致することを保証します。そして、複数のクラスに付加できます:

import numbers

class Person:
    age = ValidType(int)
    height = ValidType(numbers.Real)  # floatまたはintを許可
    tags = ValidType(list)
    favorite_foods = ValidType(tuple)
    name = ValidType(str)

p = Person()
try:
    p.age = 10.5
except ValueError as ex:
    print(ex)
    # "age must be of type int"

try:
    p.height = 'abc'
except ValueError as ex:
    print(ex)
    # "height must be of type Real"

# しかしp.height = 10は問題ありません。10もRealだからです。

一つのディスクリプタで、そうでなければ多くの繰り返しプロパティコードとなっていたものを置き換えました。


2. アプリケーション例2 – 多角形と2D点

ディスクリプタは、属性に深い検証が必要な場合—例えば属性が特殊なオブジェクトのリストであることを保証したい場合—に輝きを放ちます。以下を想定してみましょう:

  1. `Point2D`クラス:`x`と`y`は非負整数である必要があります(最大値で制限)。

  2. `Polygon`クラス:`vertices`は`Point2D`のシーケンス(リストまたはタプル)である必要があり、最小または最大長を持ちます。

ステップ1:制限付き整数ディスクリプタ

class Int:
    def __init__(self, min_value=None, max_value=None):
        self.min_value = min_value
        self.max_value = max_value

    def __set_name__(self, owner_class, name):
        self.name = name

    def __set__(self, instance, value):
        if not isinstance(value, int):
            raise ValueError(f'{self.name} must be an int.')
        if self.min_value is not None and value < self.min_value:
            raise ValueError(f'{self.name} must be at least {self.min_value}')
        if self.max_value is not None and value > self.max_value:
            raise ValueError(f'{self.name} cannot exceed {self.max_value}')
        instance.__dict__[self.name] = value

    def __get__(self, instance, owner_class):
        if instance is None:
            return self
        return instance.__dict__.get(self.name, None)

このディスクリプタは、割り当てられた整数が`[min_value, max_value]`の範囲内にあることを保証します。

ステップ2:`Point2D`クラス

class Point2D:
    x = Int(min_value=0, max_value=800)
    y = Int(min_value=0, max_value=400)

    def __init__(self, x, y):
        self.x = x
        self.y = y

    def __repr__(self):
        return f'Point2D(x={self.x}, y={self.y})'

    def __str__(self):
        return f'({self.x}, {self.y})'

    def __eq__(self, other):
        return isinstance(other, Point2D) and self.x == other.x and self.y == other.y

    def __hash__(self):
        return hash((self.x, self.y))
  • `x`や`y`が`[0, 800]`や`[0, 400]`の範囲外の場合、`ValueError`が発生します。

  • `eq`をオーバーライドすることで、座標を比較して等価性をチェックできます。また、一貫性を保つために`hash`も定義します。

ステップ3:点のシーケンスディスクリプタ

`vertices`はオプションで最小/最大長を持つ`Point2D`のシーケンス(`list`、`tuple`など)である必要があります。何かがシーケンスであることを確認するために`collections.abc.Sequence`抽象基底クラスを使用します:

import collections

class Point2DSequence:
    def __init__(self, min_length=None, max_length=None):
        self.min_length = min_length
        self.max_length = max_length

    def __set_name__(self, cls, name):
        self.name = name

    def __set__(self, instance, value):
        # 何らかのシーケンス型(list、tupleなど)である必要がある
        if not isinstance(value, collections.abc.Sequence):
            raise ValueError(f'{self.name} must be a sequence type.')
        # 長さチェック
        if self.min_length is not None and len(value) < self.min_length:
            raise ValueError(f'{self.name} must contain at least {self.min_length} elements')
        if self.max_length is not None and len(value) > self.max_length:
            raise ValueError(f'{self.name} cannot contain more than {self.max_length} elements')
        # 各アイテムはPoint2Dでなければならない
        for index, item in enumerate(value):
            if not isinstance(item, Point2D):
                raise ValueError(f'Item at index {index} is not a Point2D instance.')
        # 後で変更できるように*リスト*として保存
        instance.__dict__[self.name] = list(value)

    def __get__(self, instance, cls):
        if instance is None:
            return self
        # まだ存在しない場合は空リストを保存
        if self.name not in instance.__dict__:
            instance.__dict__[self.name] = []
        return instance.__dict__[self.name]

ステップ4:`Polygon`クラス

class Polygon:
    vertices = Point2DSequence(min_length=3)

    def __init__(self, *vertices):
        self.vertices = vertices  # ディスクリプタチェックをトリガー

    def append(self, pt):
        # 追加のロジック:max_lengthを超えていないことを確認
        if not isinstance(pt, Point2D):
            raise ValueError('Can only append Point2D instances.')
        max_length = type(self).vertices.max_length
        if max_length is not None and len(self.vertices) >= max_length:
            raise ValueError(f'Vertices length is at max ({max_length})')
        self.vertices.append(pt)

    def __len__(self):
        return len(self.vertices)

    def __getitem__(self, idx):
        return self.vertices[idx]

    def __iadd__(self, pt):
        # 'p += pt'はappendを呼び出す
        self.append(pt)
        return self

    def __contains__(self, pt):
        return pt in self.vertices
  • ディスクリプタによって最小3頂点が強制されます。例えば`Polygon(Point2D(0,0), Point2D(1,0))`は失敗します。

  • オプションで`max_length`を定義できます。

  • `append`メソッドは容量に達しているかチェックします。

  • `len`、`getitem`、`iadd`、`contains`を定義し、`Polygon`を`Point2D`オブジェクトの可変シーケンスのように振る舞わせます。

継承による特殊な多角形

正確に3つの頂点を持つ三角形が欲しいですか?サブクラスでディスクリプタをオーバーライドするだけです:

class Triangle(Polygon):
    vertices = Point2DSequence(min_length=3, max_length=3)

これで`Triangle`は`Polygon`のすべてのロジック(`append`を含む)を再利用しますが、`vertices`プロパティはmin_length=3、max_length=3に固定されます。


3. 関数とディスクリプタ:バウンドメソッドの「魔法」

最後に、興味深い観察:Pythonにおける関数は、それ自体が非データディスクリプタです。これにより、インスタンスメソッドが自動的にインスタンスにバインドされます。

class Person:
    def __init__(self, name):
        self.name = name

    def say_hello(self):
        return f'{self.name} says hello'

次のようにすると:

p = Person('Alex')
p.say_hello()  # "Alex says hello"

Pythonは関数`say_hello`の`get`を呼び出し、`p`をインスタンスとして渡します。結果はバウンドメソッドオブジェクトです。確認すると:

p.say_hello
# <bound method Person.say_hello of <__main__.Person object at 0x...>>
  • クラスからアクセス? `Person.say_hello`は生の関数を返します。

  • インスタンスからアクセス? バウンドメソッドを返します。

その動作を模倣する

同様のことを行うディスクリプタを手作りできます:

import types

def hello_function(self):
    return f'{self.name} says hello!'

class MyFunc:
    def __init__(self, func):
        self._func = func

    def __get__(self, instance, owner):
        if instance is None:
            # クラスからアクセスされた
            return self._func
        else:
            # インスタンスからアクセスされた → バウンドメソッドを返す
            return types.MethodType(self._func, instance)

class Person:
    def __init__(self, name):
        self.name = name

    say_hello = MyFunc(hello_function)
  • `Person.say_hello`(クラスレベル)は生の`hello_function`を返します。

  • `p.say_hello`(インスタンスレベル)は`p`に対するバウンドメソッドを生成します。

これは実際のコードで行うことではないかもしれませんが、メソッドが実行時にバウンドメソッドオブジェクトを返す非データディスクリプタによって形成されることを美しく示しています。


最後の考察

これらの最終レッスンで、ディスクリプタが以下のことができることを見てきました:

  1. 単一のディスクリプタ(例:`ValidType`)で型の検証を強制し、コードの繰り返しを防ぎます。

  2. `Polygon`の制限付き`Point2D`オブジェクトのリストのような複雑な属性を作成し、形状定義が有効であることを保証します。

  3. 関数自体がディスクリプタであることを示し、Pythonがどのようにメソッドを自動的にインスタンスにバインドするかを説明します。

この時点で、ディスクリプタが「プロパティ、メソッド、スロット、そして関数さえも支える基礎メカニズム」である理由をしっかりと理解できているはずです。これらはPythonの高度だが強力なツールで、属性の管理と検証の方法を大規模に変革できます—クラスをよりロバストで、再利用可能で、Pythonicにします。

ディスクリプタへの深い探求に付き合っていただき、ありがとうございました!


「超温和なパイソン」へ

いいなと思ったら応援しよう!