見出し画像

Python 3: Deep Dive (Part 4 - OOP): 非ハッシュ可能オブジェクト (セクション8-3/15)

  • Pythonディスクリプタは属性管理のための強力な機能だが、非ハッシュ可能オブジェクトの処理やメモリリークの防止が課題となり、`WeakKeyDictionary`や`id()`を使用した解決策が必要になります。

  • メモリリークを防ぐために、弱い参照(weak reference)とコールバックを組み合わせることで、非ハッシュ可能なオブジェクトでも安全にデータを保存・管理することができます。

  • Python 3.6で導入された__set_name__メソッドにより、ディスクリプタのプロパティ名の自動設定が可能になり、より簡潔で保守性の高いコードを書けるようになりました。

Pythonディスクリプタは、バリデーションや読み書き操作にカスタム動作が必要な場合に、インスタンス属性を管理する柔軟な方法を提供します。しかし、非ハッシュ可能なクラスのデータを保存する必要がある場合や、強い参照を保存することによって発生する可能性のあるメモリリークに対処する場合、ディスクリプタはより困難になります。そして、Python 3.6で導入された新しい__set_name__メソッドについてはどうでしょうか?

以下では、これらの問題についてより詳しく見ていきます:非ハッシュ可能オブジェクト(つまり、__hash__を定義せずに__eq__をオーバーライドするクラス)の処理方法、弱い参照のコールバックを使用してメモリリークを回避する方法、そして__set_name__が特定のディスクリプタパターンをどのように簡素化できるかについてです。


ハッシュ可能性のジレンマと最終的なストレージアプローチ

インスタンスごとのデータを保存するディスクリプタの一般的なアプローチは、ディスクリプタ自体に辞書を配置することです:

class IntegerValue:
    def __init__(self):
        self.values = {}

    def __set__(self, instance, value):
        self.values[instance] = int(value)

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

しかし、これを行うとすぐに、各インスタンスへの強い参照が作成されます。インスタンス変数を削除しても(例:`del p`)、ディスクリプタが`self.values`内に`p`を保持しているため、メモリは解放されません。メモリリークが発生します。

ハッシュ可能なオブジェクトの場合、より良い解決策は内部で弱い参照を使用する`WeakKeyDictionary`に保存することです:

import weakref

class IntegerValue:
    def __init__(self):
        self.values = weakref.WeakKeyDictionary()

    def __set__(self, instance, value):
        self.values[instance] = int(value)

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

これにより、オブジェクトへの最後の強い参照がなくなると自動的にエントリが削除され、リークを防ぐことができます。

しかし、オブジェクトがハッシュ可能でない場合は?

`WeakKeyDictionary`は、ハッシュ不可能なクラス(つまり、__eq__を定義するが__hash__を定義しない、または本質的にハッシュ不可能な性質に依存するクラス)では機能しません。その場合、`instance`を辞書のキーとして直接保存することは不可能です。一つの回避策:代わりに**`id(instance)`**を保存します。ただし、`id(instance)`には:

  1. それ自体は強い参照を保持しない(つまりメモリリークはない)

  2. しかしIDだけを保存すると、インスタンスがガベージコレクトされた場合にそのエントリを自動的に削除する機能を失う

  3. さらに悪いことに、PythonはオブジェクトのIDを再利用するので、辞書のエントリが古くなったり、後で同じメモリアドレスを占める新しいオブジェクトと競合したりする可能性がある

弱い参照 + コールバックによるクリーンアップ

キーとして`id(instance)`を使用する際のクラッタ問題を解決するために、辞書の値に弱い参照をインスタンスに保持し、エントリを削除するためのコールバックを登録することができます。例えば:

import weakref

class IntegerValue:
    def __init__(self):
        self.values = {}  # 通常の辞書、キー = id(instance)

    def __set__(self, instance, value):
        # (weak_ref, actual_value)タプルを保存
        # 終了時にこのエントリを削除するコールバックを登録
        self.values[id(instance)] = (
            weakref.ref(instance, self._remove_object),
            int(value)
        )

    def __get__(self, instance, owner_class):
        if instance is None:
            return self
        value_tuple = self.values.get(id(instance))
        if value_tuple:
            return value_tuple[1]  # 実際に保存された値
        return None

    def _remove_object(self, weak_ref):
        # どの弱参照が死んだかは分かるが、その`id`は分からないので、値で検索する:
        dead_keys = [
            key for key, (ref, _) in self.values.items()
            if ref is weak_ref
        ]
        for key in dead_keys:
            del self.values[key]

なぜこれが機能するのか?

  • 辞書のキーは`id(instance)`なので、インスタンスがハッシュ可能かどうかは関係ありません。

  • 辞書の値には`weakref.ref(instance, callback)`が含まれます。インスタンスへの強い参照がゼロになると、ガベージコレクタがオブジェクトをクリーンアップし、コールバック`_remove_object`がトリガーされます。

  • `_remove_object`は`self.values`内の一致するエントリ(一致する弱参照に基づく)を見つけて削除します。

したがって、以下が得られます:

  1. ディスクリプタからインスタンスへの強い参照はない

  2. インスタンスがなくなると古いエントリは残らない

  3. `id(instance)`を使用しているため、ハッシュの要件は不要

  4. インスタンスが不要になるとすぐにメモリが解放される

__slots__と__weakref__に関する注意

弱い参照を使用するには、クラスがそれをサポートしている必要があります。クラスが__slots__を持っていても、そのスロットタプルに__weakref__が含まれていない場合、そのインスタンスへの弱い参照を作成することはできません

class Person:
    __slots__ = ('name',)  # ここに__weakref__がない

p = Person()
import weakref
try:
    w = weakref.ref(p)
except TypeError as ex:
    print(ex)  # cannot create weak reference to ...

弱い参照を許可するには、__slots__に__weakref__を追加します:

class Person:
    __slots__ = ('__weakref__', 'name')

これにより、参照の保存や`WeakKeyDictionary`の使用、または独自の(weak_ref, value)アプローチの使用が有効になります。


__set_name__メソッド:自動プロパティ名

最後に注目すべき点は、Python 3.6で導入された**set_name**です。ディスクリプタの一つの煩わしさは、エラーメッセージやインスタンス辞書内の属性ルックアップのためにプロパティ名(`'x'`や`'first_name'`など)が必要になることです。Python 3.6以前では、次のようにしていました:

class IntegerValue:
    def __init__(self, prop_name):
        self.prop_name = prop_name
    ...

そしてクラスでは:

class Point:
    x = IntegerValue('x')
    y = IntegerValue('y')

set_nameは重複を排除します。Pythonは、クラス本体で作成されたディスクリプタオブジェクトに対して__set_name__(self, owner, name)`を自動的に呼び出します。例えば:

class ValidString:
    def __init__(self, min_length=1):
        self.min_length = min_length

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

    def __set__(self, instance, value):
        if not isinstance(value, str):
            raise ValueError(f"{self.property_name}は文字列でなければなりません。")
        if len(value) < self.min_length:
            raise ValueError(f"{self.property_name}は少なくとも{self.min_length}文字必要です。")
        instance.__dict__[self.property_name] = value

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

そして:

class Person:
    first_name = ValidString(min_length=1)
    last_name = ValidString(min_length=2)

Pythonが`Person`を作成するとき、`ValidString(...)`を2回インスタンス化し(`first_name`用に1回、`last_name`用に1回)、以下を呼び出します:

__set_name__(first_name用のvalid_string_instance, Person, "first_name")
__set_name__(last_name用のvalid_string_instance, Person, "last_name")

これで各ディスクリプタは自身が関連付けられているプロパティ名を知っているので、`'first_name'`や`'last_name'`を明示的に渡す必要がありません。また、エラーメッセージも改善されます:

last_nameは少なくとも2文字必要です。

`instance.__dict__に値を保存するとディスクリプタは隠れるのか?

`instance.dict[property_name]`にデータを保存することで、ディスクリプタを「オーバーライド」してしまうのではないかと心配するかもしれません。しかし、データディスクリプタ(__set__を定義するもの)は、ルックアップ時に常にインスタンス辞書内の同名のキーよりも優先されます。したがって、`p.first_name`を呼び出すと、依然としてディスクリプタの__get__メソッドがトリガーされます。ディスクリプタが非データ(__get__のみ)の場合、同じ名前のインスタンス辞書キーがそれを隠します。


すべてをまとめる

  • メモリの安全性:オブジェクトがハッシュ可能で(かつ弱い参照を許可する場合)、`WeakKeyDictionary`はメモリリークを避けながらデータを保存する最も簡単な方法です。

  • 非ハッシュ可能なクラス:辞書のキーとして`id(instance)`を使用し、辞書の値として`(weak_ref, value)`を保存し、古いエントリを自動的に削除するためのコールバックを登録します。

  • slots:それらのオブジェクトへの弱い参照を保存したい場合は、__weakref__を含めることを忘れないでください。

  • set_name:ディスクリプタで属性名を自動的に取得するのに便利です。バリデーションやダイナミックな命名に最適です。

これらの機能により、検証済みフィールド、型付き属性、またはカスタムロジック用の高度に再利用可能なディスクリプタを作成し、スロット付きクラスから非ハッシュ可能オブジェクトまでのケースを処理できます。`WeakKeyDictionary`や独自の辞書+コールバックメソッドのいずれに依存するにしても、ディスクリプタ駆動の属性をクリーンに保ち、メモリリークがなく、エラーメッセージでユーザーフレンドリーにすることができます—これらすべてはPythonの堅牢なディスクリプタプロトコルのおかげです。


「超本当にドラゴン」へ

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