Python

ディスクリプタを制する者は Python を制す

注) タイトルはただの煽りです。

はじめに

最近ようやく Fluent Python に一通り目を通すことができた Python 初心者ですが、終盤のとある一文に心を奪われました。

Python の奥義を極めるには、ディスクリプタを理解しなければなりません。
20 章 属性ディスクリプタ - Fluent Python P. 657

なるほど。Python には、そんな秘密兵器があったのですね。これは学ばなければなりません。

注) この序文もただの煽りです。

属性とプロパティ

まずはじめに、ディスクリプタのことは忘れ、属性とプロパティについて話をしましょう。Python では、いわゆるメンバ変数(インスタンス変数)やメンバ関数(インスタンスメソッド)の事をまとめて属性(attribute)と呼びます。

class MyClass:
    def m(self):
        """
        >>> c = MyClass()
        >>> c.m()
        method
        >>> c.m = 1
        >>> print(c.m)
        1
        """
        print('method')

この属性をメソッドでラップして動的な挙動を持たせる事も可能です。その様な事をしたい場合、 __getattr____setattr__ といった特殊メソッドを利用する事ができます。

※ 余談ですが、Python で良く目にする特殊メソッド(マジックメソッド)の事を、二つのアンダースコア(double underscore)で囲まれた見た目から、ダンダー(dunder)と呼ぶ、事がある様ですね。経験豊富な Pythonista であればそれ(ダンダー)で通じるのだとか。

class MyGetAttr:
    def __getattr__(self, name):
        """
        >>> c = MyGetAttr()
        >>> c.hoge
        MyGetAttr.hoge
        >>> c.hoge = 1
        >>> print(c.hoge)
        1
        """
        print(f'{__class__.__name__}.{name}')

class MySetAttr:
    def __setattr__(self, name, value):
        """
        >>> c = MySetAttr()
        >>> c.hoge
        Traceback (most recent call last):
        ...
        AttributeError: 'MySetAttr' object has no attribute 'hoge'
        >>> c.hoge = 1
        MySetAttr.hoge = 1
        >>> c.hoge
        Traceback (most recent call last):
        ...
        AttributeError: 'MySetAttr' object has no attribute 'hoge'
        """
        print(f'{__class__.__name__}.{name} = {value}')

さて、属性は Python の(クラス)メンバ全般を表す呼称ですが、プロパティとはいったい何者でしょうか。属性を言い換えたものでしょうか。

一部のプログラミング言語において、プロパティとはクラスに定義できる、特殊なメンバの一種である。
プロパティへのアクセスは文法上はフィールドと同様に行えるものの、実際にはアクセサの呼び出しに変換される。
(略)

プロパティ - Wikipedia

obj.name() ではなく obj.name の様にしてデータ属性を参照するかの様に書く事ができつつ、実際はアクセサとして働くメソッドが呼び出される、という感じの事ですね。まあ、みなさん良くお使いになるあれですよ、あれ。

プロパティデコレータ

Python は、プロパティを実装するための property デコレータを組み込み機能として提供しています。

class MyProp:
    """
    >>> c = MyProp()
    >>> print(c.hoge)
    Traceback (most recent call last):
    ...
    AttributeError: 'MyProp' object has no attribute '_MyProp__hoge'
    >>> c.hoge = 'hog'
    Traceback (most recent call last):
    ...
    ValueError: value must be `hoge`.
    >>> c.hoge = 'hoge'
    >>> print(c.hoge)
    hoge
    """
    @property
    def hoge(self):
        return self.__hoge

    @hoge.setter
    def hoge(self, value):
        if value != 'hoge':
            raise ValueError('value must be `hoge`.')
        self.__hoge = value

まずは、プロパティ名で名付けたメソッドを @property をデコレータでラップして getter を実装します。setter も必要であれば、更にプロパティ名で名付けたメソッドをプロパティの setter デコレータでラップする事になります(@property ではなく @{プロパティ名}.setter です)。同じ名前のメソッドを宣言するという点がよく分かりませんね。

ちなみに、ドキュメントを読めばすぐに気づきますが property は関数ではなくクラスです。

class property(fget=None, fset=None, fdel=None, doc=None)

先ほどの例は以下の様に書きかえる事もできます。プロパティごとに getter, setter 関数を用意し、それを property クラスに与えることで、プロパティオブジェクトを生成している、という事だった様です。

class MyProp:
    """
    >>> c = MyProp()
    >>> c.hoge = 'hoge'
    >>> print(c.hoge)
    hoge
    """
    __hoge = None

    def get_hoge(self):
        return self.__hoge

    def set_hoge(self, value):
        if value != 'hoge':
            raise ValueError('value must be `hoge`.')
        self.__hoge = value

    hoge = property(get_hoge, set_hoge)

プロパティファクトリ

プロパティを実装するために property デコレータが便利だということは分かりました。ところで、似たような性質のプロパティを複数実装する必要がある場合、getter, setter を何度も書く必要があるのでしょうか。それは、なんとなく、スマートではないですよね。

class Size:
    """
    >>> s = Size()
    >>> s.width = 300
    >>> s.height = 400
    >>> print(s.width, s.height)
    300 400
    """
    @property
    def width(self):
        return self.__w

    @width.setter
    def width(self, value):
        if value < 0:
            raise ValueError('value must be > 0')
        self.__w = value

    @property
    def height(self):
        return self.__h

    @height.setter
    def height(self, value):
        if value < 0:
            raise ValueError('value must be > 0')
        self.__h = value

この様な共通処理を抽象化するひとつの方法が、プロパティファクトリという考え方です。ファクトリ関数内部で setter, getter を定義し、それらを property クラスに与えて作ったプロパティオブジェクトを返すというもの。getter, setter の第一引数 instance は、プロパティを追加したいクラスのインスタスを意味します(クラス側のメソッドに与える self と同等)。

def scale(name):
    def getter(instance):
        return instance.__dict__[name]

    def setter(instance, value):
        if value < 0:
            raise ValueError('value must be > 0')
        instance.__dict__[name] = value

    return property(getter, setter)

class Size:
    """
    >>> s = Size()
    >>> s.width = 300
    >>> s.height = 400
    >>> print(s.width, s.height)
    300 400
    """
    width = scale('width')
    height = scale('height')

property デコレータとして利用するよりも分かりやすい様な気がしてきます。一方で、プロパティである事が分かっていないと、通常の属性として値を上書き(交換)できる(できてしまう)様にも見えてしまい、どちらが良いのか実感としてはよく分かっていません。

※ データ属性を直接読み書きするならプロパティの話題はなんだったんだ、という事なのですが。

def scale(name):
    def getter(instance):
        return instance.__dict__[name]

    def setter(instance, value):
        if value < 0:
            raise ValueError('value must be > 0')
        instance.__dict__[name] = value

    return property(getter, setter)

def dummy(name):
    def getter(instance):
        raise

    return property(getter)

class Size:
    """
    >>> s = Size()
    >>> s.width = 300
    >>> s.height = 400
    >>> print(s.width, s.height)
    300 400
    >>> s.zoom = 1
    >>> print(s.zoom)
    1
    >>> s.width = dummy('width')
    Traceback (most recent call last):
    ...
    TypeError: '<' not supported between instances of 'property' and 'int'
    """
    width = scale('width')
    height = scale('height')
    zoom = 100

ところで、当然のように『(プロパティ)属性への代入 obj.attr = によって(setter を持つ)プロパティをプロパティ以外のものに置き換えることはできない』という話をしましたが、これはどういう理屈でしょうか?これを説明するためには、Python の『ディスクリプタ』について説明する必要があります。

ディスクリプタ

ということで、ようやく本題のディスクリプタについて話を進めることができました。以下、Python 公式ドキュメントからの引用ですが、やはり、何か凄いやつの様ですね。

Learning about descriptors not only provides access to a larger toolset, it creates a deeper understanding of how Python works and an appreciation for the elegance of its design.
Descriptor HowTo Guide — Python 3.6.3 documentation

ところで、Python の文脈に限らず一般的に『ディスクリプタ』とはどういう意味でしょうか?

文脈によって様々という事ですが、抽象化すれば識別子(identifier)の様な意味合いになる様です。例えば、ファイルディスクリプタであれば、ファイルの抽象化されたインデックスであったり。

今回学ぶ Python の属性ディスクリプタであれば、クラスが持つ属性(プロパティ)の値自体ではなくその値へアクセスするための何かである、と考えると良いでしょうか。

属性ディスクリプタ

Python におけるディスクリプタとは、 __get__, __set__, __delete__ メソッドを(部分的にでも)実装したクラスの事です。多くのディスクリプタでは __get____set__ のみを実装している様です。

例えば、 property で実現していた様な事を、自作のディスクリプタクラスを利用して実現する事ができます。

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

    def __get__(self, obj, objtype=None):
        print('get')
        return obj.__dict__[self.name]

    def __set__(self, obj, value):
        print('set')
        obj.__dict__[self.name] = value

class Obj:
    """
    >>> obj = Obj()
    >>> obj.prop = 123
    set
    >>> print(obj.prop)
    get
    123
    >>> print(obj.__dict__['prop'])
    123
    >>> obj.prop = 456
    set
    """
    prop = Prop('prop')
  • Obj クラスに参照と代入操作をラップした prop 属性を追加する
  • prop 属性は Prop ディスクリプタクラスによってラップされる
  • 属性値は Obj クラスのインスタンスが保持している
  • Obj のインスタンスが持つ属性値に対する操作は Prop ディスクリプタを経由して行われる

さて、代入によってインスタンスの属性自体が置き換わらないのはなぜか、という話をしたことを覚えているでしょうか。以下は公式ドキュメントからの引用です。

属性アクセスのデフォルトの振る舞いは、オブジェクトの辞書の属性の取得、設定、削除です。例えば a.x は、まず a.__dict__['x']、それから type(a).__dict__['x']、さらに type(a) のメタクラスを除く基底クラスへと続くというように探索が連鎖します。見つかった値が、デスクリプタメソッドのいずれかを定義しているオブジェクトなら、Python はそのデフォルトの振る舞いをオーバーライドし、代わりにデスクリプタメソッドを呼び出します。これがどの連鎖順位で行われるかは、どのデスクリプタメソッドが定義されているかに依ります。
デスクリプタ HowTo ガイド — Python 3.6.3 ドキュメント

つまり、属性としてディスクリプタを保持している場合、その属性の読み書き削除の振る舞いをそのディスクリプタによってオーバーライド(ハイジャック)する事ができるという事。Python の標準的な振る舞いにディスクリプタが寄与しているのですね。

例えば、静的メソッドを宣言するために @staticmethod デコレータを使う事があると思います。他にもクラスメソッドを宣言するための @classmethod もあります。これらも @property 同様にディスクリプタを活用しています。公式ドキュメントのサンプルを参考に、組み込みの staticmethod および classmethod を pure Python でエミュレートしたコードを以下に示します。

class StaticMethod:
    def __init__(self, f):
        self.func = f

    def __get__(self, obj, objtype=None):
        print('get static method')
        return self.func

class ClassMethod:
    def __init__(self, f):
        self.func = f

    def __get__(self, obj, objtype=None):
        if objtype is None:
            objtype = type(obj)

        def f(*args):
            return self.func(objtype, *args)

        print('get class method')
        return f

class MyClass:
    """
    >>> MyClass.static(1)
    get static method
    1
    >>> MyClass().static(1)
    get static method
    1
    >>> MyClass.klass(1)
    get class method
    MyClass 1
    >>> MyClass().klass(1)
    get class method
    MyClass 1
    """
    def static(x):
        print(x)

    static = StaticMethod(static)

    def klass(cls, x):
        print(f"{cls.__name__} {x}")

    klass = ClassMethod(klass)

ディスクリプタを利用する事で、属性の読み書き(削除)の振る舞いをハックできる、という事がよく分かりますね。

まとめ

中途半端な終わり方になりますが、Python の属性、プロパティ、ディスクリプタに関するお話はここまでとなります。

  • Python ではデータもメソッドも全てまとめて属性と呼ぶ
    • クラスの属性とインスタンスの属性の関係性については別途詳しく学ぶと良いと思います
    • キーワードはシャドーイング
  • 属性の読み書き削除の振る舞いをラップ(カプセ化、オーバーライド)するためにプロパティが役立つ
  • プロパティを実現するためには Python のディスクリプタを利用する
    • 例えば property, classmethod, staticmethod などの組み込み機能

属性アクセス時の振る舞いをハックしたいとき、ディスクリプタを活用するとスマートに書ける。ことがある。のかもしれません。