2018/09/10

Python のアトリビュート取り扱いの仕組み

Python で オブジェクトのアトリビュートへのアクセスがあったときに内部で起こっていること について説明してみます。

# オブジェクトのアトリビュートへのアクセスがあると・・・?
obj.attr1

私は他の言語においてこのあたりの仕組みをよく理解してないため厳密な比較はできませんが、 Python のこの仕組みはとてもユニークでおもしろいと思います。

早速説明していきます。馴染みの無い方にとっては少し複雑なので、ざっくりとした説明から始めて徐々により詳細で厳密な説明へと進んでいきます。

目次


  • レベル 1: 基本 1
  • レベル 2: 基本 2
  • レベル 3: 基本 3
  • レベル 4: 発展 1
  • レベル 5: 発展 2
  • レベル 6: 発展 3


レベル 1: 基本 1


アトリビュートへのアクセスがあると、オブジェクト固有のデータ保持領域で要素が探索される。要素が存在すればその値が返され、存在しなければ AttributeError があがる。

これは標準的なオブジェクト指向の概念に慣れている方にとっては直感的な挙動ですね。

「オブジェクト固有のデータ保持領域」というのは、具体的には各オブジェクトに備わった __dict__ アトリビュートのことを指しています。 __dict__ はデフォルトでは空の dict です。

コードで確認してみましょう。

class A:
    pass

a1 = A()
# アトリビュートがセットされなければ、 __dict__ の初期状態は空の dict
print(a1.__dict__)
# => {}

# アトリビュートへのアクセスがあると __dict__ 内の該当する要素が返される
a1.__dict__['attr1'] = 10
print(a1.attr1)
# => 10

# __dict__ 内に該当する要素がなければ AttributeError があがる
a2 = A()
print(a2.attr1)
# => AttributeError

a1 については、あらかじめ __dict__attr1 というキーで要素を格納したあとに a1.attr1 にアクセスしています。 a1.attr1 にアクセスすると a1.__dict__['attr1'] の値が返されることが確認できています。

一方、 a2 では前準備などせずすぐに a2.attr1 にアクセスしています。結果、例外 AttributeError があがります。

まずはこれが基本です。

レベル 2: 基本 2


レベル 1 の説明を少し更新します。

アトリビュートへのアクセスがあると、オブジェクト固有のデータ保持領域で要素が探索される。要素が存在すればその値が返される。 存在しなければ、オブジェクトのクラスが __getattr__ メソッドを持っているかどうかがチェックされる。持っていればそれが呼び出され、その戻り値が返される。 持っていなければ AttributeError があがる。

強調部分がレベル 1 との違いです。

レベル 1 では「オブジェクト固有のデータ保持領域に該当する要素が存在しなければ AttributeError があがる」と説明しましたが、実はその間にはプログラマが自由に処理をはさめるようになっていて、そのための仕組みが __getattr__ メソッドです。

コードで確認してみましょう。

class B:
    def __getattr__(self, name):
        return name

# __dict__ 内に該当する要素がなくて、クラスが __getattr__ を定義していればその戻り値が返される
b1 = B()
print(b1.attr1)
# => 'attr1'

a1 のところで見たとおり、コンストラクタで何もせず単純にオブジェクトを生成すると、そのオブジェクトの __dict__ は空の dict となります。 b1__dict__ は空なので、キー attr1 に対応する要素は存在しません。結果、 __getattr__ の呼び出しが発生し、その戻り値が返されます。

__getattr__ の引数 name には オブジェクト.アトリビュートアトリビュート に相当する文字列が渡されます。つまり、 b1.attr1 が実行された場合の name には文字列 'attr1' が格納されています。 B__getattr__ は戻り値として name をそのまま返しているので、結果として b1.attr1 にアクセスすると文字列 'attr1' が返ってきます。

ここでは説明のために __getattr__name を返す単純は実装にしていますが、実践的なコードではここにさまざまな工夫を加えます。例えば次のようにすると、データ保持領域の要素を int に変換して返させることができます。

class B2:
    def __getattr__(self, name):
        # 実在するアトリビュートの後ろに _as_int をつけた名前に対応する
        if name.endswith('_as_int'):
            stripped = name[:-len('_as_int')]
            if stripped in self.__dict__:
                return int(self.__dict__[stripped])
        raise AttributeError()

b2 = B2()
b2.pi = 3.14
b2.radius = 5.25

# __getattr__ の戻り値が返される
print(b2.pi_as_int)
# => 3
print(b2.radius_as_int)
# => 5

レベル 3: 基本 3


アトリビュートへのアクセスがあると、オブジェクト固有のデータ保持領域で要素が探索される。要素が存在すればその値が返される。存在しなければ、 オブジェクトのクラスのデータ保持領域で要素が探索される。存在しなければ、継承をたどってすべての親のデータ保持領域で要素が探索される。それでも存在しなければ、 オブジェクトのクラスが __getattr__ メソッドを持っているかどうかがチェックされる。持っていればそれが呼び出され、その戻り値が返される。持っていなければ AttributeError があがる。

強調部分がレベル 2 との違いです。

レベル 2 では「オブジェクトのデータ保持領域で対応する要素が見つからなかった場合は __getattr__ メソッドを持っているかどうかがチェックされ・・・」と説明しましたが、実は、「オブジェクトのデータ保持領域に要素が見つからなかった」と「 __getattr__ メソッドを持っているかどうかがチェックされ」の間に、クラスのデータ保持領域での要素の探索が発生します。

コードで確認してみましょう。

class C:
    attr1 = 10

    def __getattr__(self, name):
        return name

# クラスアトリビュートは __dict__ に格納される
print(C.__dict__['attr1'])
# => 10

c1 = C()

# クラス C の __dict__['attr1'] が返される
print(c1.attr1)
# => 10

# __getattr__ の戻り値が返される
print(c1.attr2)
# => 'attr2'

オブジェクト c1 において c1.attr1 にアクセスすると、クラス C で定義されたアトリビュート attr1 の値が返されました。これは内部的には C のデータ保持領域である C.__dict__ に格納された値です。クラスのデータ保持領域に該当する要素が見つかった場合は、メソッド __getattr__ の呼び出しは発生しません。

このサンプルでは示されていませんが、クラスのデータ保持領域の探索はクラスの継承関係を辿って行われます。つまり、 C.__dict__['attr1'] が存在しなかった場合はその親クラスの __dict__['attr1'] が探索され、そこに無ければまた親の・・・と続いていきます。最終的な親である object.__dict__ まで探索しても要素が見つからなかった場合は、レベル 2 での説明のとおり __getattr__ へと処理が移っていきます。上の c1.attr2 でのアクセスではまさにこの流れを辿った末に値が返された結果 'attr2' という文字列が返ってきています。

内部的には __dict__ が介在していますが、表面的には単純に「オブジェクトに該当するアトリビュートがなければ、クラスの同名のアトリビュートにフォールバックする」という挙動になるので、このあたりはふだん利用するときには難しく考えなくても直感的に利用できるでしょう。

と、ここまでは他の言語でもわりとよく見られるパターンなので、何らかのプログラミング言語に馴染みのある方であればすんなり受け入れられるところではないかと思います。続いて、 Python の特徴である descriptor (ディスクリプタ)も含めた説明へと進みます。

レベル 4: 発展 1


アトリビュートへのアクセスがあると、オブジェクト固有のデータ保持領域で要素が探索される。要素が存在すればその値が返される。存在しなければ、オブジェクトのクラスのデータ保持領域で要素が探索される。 該当する要素が存在した場合、それが __get__ メソッドを備えていれば __get__ メソッドが呼ばれ、その戻り値が返される。 __get__ メソッドを備えていなければそのオブジェクトそのものが返される。 同様の探索が、継承をたどってすべての親のデータ保持領域で行われる。それでも存在しなければ、オブジェクトのクラスが __getattr__ メソッドを持っているかどうかがチェックされる。持っていればそれが呼び出され、その戻り値が返される。持っていなければ AttributeError があがる。

強調部分がレベル 3 との違いです。ここで新たに __get__ メソッドというものが出てきました。

レベル 2 で述べたとおり、オブジェクトのアトリビュートへのアクセスが起こったときに、オブジェクトそのもののデータ保持領域 __dict__ に該当する要素がなければ、クラスのデータ保持領域 __dict__ での探索が行われます。その際、ふつうはその要素(=オブジェクト)そのものが返されるのですが、それが __get__ メソッドを持っている場合にかぎり、 __get__ メソッドが実行され、その戻り値が返されます。

ことばでの説明だけだと意味が分かりづらいですね。コードで確認してみましょう。

class Descriptor1:
    def __init__(self, name):
        self._name = name

    def __get__(self, instance, owner):
        print(self, instance, owner)
        return '{}.__get__ for {}'.format(self.__class__.__name__, self._name)

class D:
    attr1 = Descriptor1(name='attr1')
    attr2 = 10

d1 = D()

# Descriptor1 の __get__ メソッドの戻り値が返される
d1.attr1
# => 'Descriptor1.__get__ for attr1'

# __get__ を持たないアトリビュートの場合は値がそのまま返される
d1.attr2
# => 10

d1.attr1 にアクセスすると 'Descriptor1.__get__ for attr1' という文字列が返ってきます。これは Descriptor1 で定義されているメソッド __get__ の戻り値です。

通常、オブジェクトのアトリビュートへのアクセスでクラスのアトリビュートの参照が発生するとその値がそのまま返されますが、そのアトリビュートの値が __get__ メソッドを持っている場合にかぎり __get__ メソッドが実行されその戻り値が返されます。

これが Python のいわゆる descriptor です。 Python の descriptor とは「そのインスタンスが他のクラスのアトリビュートとして利用されたときに特殊な挙動をするクラス」です。

descriptor プロトコルを構成するメソッドは __get__ の他に __set____delete__ があります。

ちなみに、上の Descriptor1__init__ メソッドは、 descriptor オブジェクト自身がアトリビュート名 を知れるように次の形で利用するためのものです。

attr1 = Descriptor1(name='attr1')

Python 3.6 で __set_name__ という特殊メソッドが追加され、 Python 3.6 以降では descriptor オブジェクト自身がアトリビュート名をかんたんに知れるようになりました。

class Descriptor1:
    def __set_name__(self, owner, name):
        self._name = name

    def __get__(self, instance, owner):
        print(self, instance, owner)
        return '{}.__get__ for {}'.format(self.__class__.__name__, self._name)

class D:
    attr1 = Descriptor1()

d1 = D()

d1.attr1
# => 'Descriptor1.__get__ for attr1'

attr1 = Descriptor1() が実行されると __set_name__ が呼び出され引数 name にアトリビュート名が渡されるので、 descriptor 側でアトリビュート名を利用することができます。

descriptor のロジックはこれだけではありません。

レベル 5: 発展 2


アトリビュートへのアクセスがあると、 オブジェクトのクラスのデータ保持領域で要素が探索される。該当する要素が存在し、かつ、該当する要素が __get__ メソッドと __set__ メソッドを備えていれば __get__ メソッドが呼ばれその戻り値が返される。 そうでない場合は、オブジェクト固有のデータ保持領域で要素が探索される。要素が存在すればその値が返される。存在しなければ、オブジェクトのクラスのデータ保持領域で要素が探索される。該当する要素が存在した場合、それが __get__ メソッドを備えていれば __get__ メソッドが呼ばれ、その戻り値が返される。 __get__ メソッドを備えていなければそのオブジェクトそのものが返される。同様の探索が、継承をたどってすべての親のデータ保持領域で行われる。それでも存在しなければ、オブジェクトのクラスが __getattr__ メソッドを持っているかどうかがチェックされる。持っていればそれが呼び出され、その戻り値が返される。持っていなければ AttributeError があがる。

強調部分がレベル 4 との違いです。

レベル 4 までは「アトリビュートのアクセスがあるとオブジェクト固有のデータ保持領域で要素が探索される」と言っていましたが、実は、アトリビュートのアクセスがあったときに最初に行われることは、オブジェクトではなくクラスのデータ保持領域 __dict__ での探索です。そこに該当する要素があり、なおかつその要素が __get____set__ の 2 つのメソッドを備えていれば、その __get__ メソッドが呼ばれて戻り値が返されます。クラスのデータ保持領域に該当する要素がなかったり、あっても __get__ メソッド・ __set__ メソッドを備えていない場合は、通常どおりオブジェクトのデータ保持領域 __dict__ での探索が行われます。以降の処理はレベル 4 での説明のとおりです。

コードで確認してみましょう。

class Descriptor2:
    def __set_name__(self, owner, name):
        self._name = name

    def __get__(self, instance, owner):
        return '{}.__get__ for {}'.format(self.__class__.__name__, self._name)

    def __set__(self, instance, value):
        pass

class E:
    attr1 = Descriptor2()

e1 = E()
e1.__dict__['attr1'] = 10

# オブジェクトの __dict__ よりもクラスの __dict__ が優先される
print(e1.attr1)
# => 'Descriptor2.__get__ for attr1'

e1.attr1 にアクセスすると 'Descriptor2.__get__ for attr1' という文字列が返されました。これは Descriptor2__get__ の戻り値です。

ポイントは、 e1.__dict__ には attr1 というキーの要素があらかじめセットされているにもかかわらず Descriptor2__get__ が優先して呼び出されている点です。通常はオブジェクトそのものの __dict__ が先に探索されますが、クラスのデータ保持領域にある同名の要素が __get____set__ の 2 つのメソッドを備えている場合のみ、それが優先的に利用されます。

一見とてもトリッキーな動きですが、 Python がこの仕組みを用意してくれているおかげで、プログラマは「クラス定義時にそのオブジェクトの特定のアトリビュートを特別扱いする指示ができる汎用的な方法」を作ることができます。

この仕組みを利用したかんたんな例をあげてみます。

class TitleField:
    def __init__(self, length):
        self._len = length

    def __set_name__(self, owner, name):
        self._name = name

    def __get__(self, instance, owner):
        return instance.__dict__[self._name]

    def __set__(self, instance, value):
        if not isinstance(value, str):
            raise ValueError('アトリビュート {} には文字列のみがセットできます。'.format(self.self._name))
        if len(value) > self._len:
            raise ValueError('アトリビュート {} の最大長さは {} です。'.format(self.self._name, self._len))
        instance.__dict__[self._name] = value

    def __delete__(self, instance):
        del instance.__dict__[self._name]


class Article:
    title = TitleField(length=32)


a1 = Article()
a1.title = 10
# => ValueError: アトリビュート title には文字列のみがセットできます。


class Page:
    title = TitleField(length=20)


p1 = Page()
p1.title = 'Guardians of the Galaxy 2'
# => ValueError: アトリビュート title の最大長さは 20 です。

p1.title = 'Jurassic World'
print(p1.title)
# => 'Jurassic World'

クラス TitleField__get____set__ の 2 つのメソッドを持った descriptor クラスです。これを ArticlePage の 2 つのクラスで利用しています。

TitleField__set__ にバリデーション処理があるので、 ArticlePagetitle アトリビュートに文字列以外のオブジェクトや指定された長さよりも長い文字列をセットすることはできません。

このような処理は __setattr__ メソッドや property を使っても実装することができますが、 TitleField という独立したクラスに定義することによって、複数のクラス・複数のアトリビュートで使い回せるというメリットが生まれます。

尚、ここであげた TitleField の各メソッドの定義では descriptor として不十分なところがあります。 TitleField はあくまでも descriptor の少し実用的なイメージを示すためのサンプルなので、実際に descriptor クラスを書こうというときにはぜひ公式ドキュメントや詳しい書籍を参照してください。

書籍や記事でご存知の方にはおなじみですが、この __set__ メソッドを持つ desriptor を data descriptor (データ・ディスクリプタ) 、持たない descriptor を non-data decriptor (ノンデータ・ディスクリプタ) と呼びます。私は「 non-data 」を日本語で書く場合は「ノンデータ」とカタカナで書くのが好みですが、 non-data descriptor は「非データ・ディスクリプタ」と訳されているのをよく目にします。

この data decriptor ・ non-data descriptor という概念を使って見ると、レベル 4 でのディスクリプタの呼び出しタイミングは non-data descriptor の挙動の説明で、レベル 5 の「オブジェクトの __dict__ の前にクラスの __dict__ が参照される」は data descriptor の挙動の説明でした。

ここまででお腹いっぱいになりそうですが、もうひとレベルあります。次のレベルが最後です。

レベル 6: 発展 3


アトリビュートへのアクセスがあると、 真っ先にメソッド __getattribute__ が呼ばれる。オブジェクトのクラスとその先祖クラスで __getattribute__ を定義しているものがなければ、基底クラス object__getattribute__ が呼ばれる。その中で以下の処理が行われる。

まずは、オブジェクトのクラスのデータ保持領域で要素が探索される。該当する要素が存在し、かつ、該当する要素が __get__ メソッドと __set__ メソッドを備えていれば __get__ メソッドが呼ばれ、その戻り値が返される。そうでない場合は、オブジェクト固有のデータ保持領域で要素が探索される。要素が存在すればその値が返される。存在しなければ、オブジェクトのクラスのデータ保持領域で要素が探索される。該当する要素が存在した場合、それが __get__ メソッドを備えていれば __get__ メソッドが呼ばれその戻り値が返される。 __get__ メソッドを備えていなければそのオブジェクトそのものが返される。同様の探索が、継承をたどってすべての親のデータ保持領域で行われる。それでも存在しなければ、オブジェクトのクラスが __getattr__ メソッドを持っているかどうかがチェックされる。持っていればそれが呼び出され、その戻り値が返される。持っていなければ AttributeError があがる。

強調部分がレベル 5 との違いです。

レベル 5 の説明は、実は object.__getattribute__ が呼ばれた後の処理の流れを説明したものです。プログラマがクラスで __getattribute__ を定義すると、この流れをカスタマイズすることができます。

ただ、 __getattribute__ を定義しないといけないようなケースというのは非常に稀だと思います。 __getattribute__ を上書きできる仕組みは用意されてはいるものの、独自の __getattribute__ はおそらくメリットよりも多くのデメリットをもたらすので、よほどのことでないかぎり __getattribute__ の上書きが必要なケースは無いでしょう。

ここまで来ると、 Python のアトリビュートアクセスの仕組みをある程度正確に把握したと言ってよいのではないでしょうか。

ここでは obj.attr1 という形でアトリビュートが「参照」されたときの処理の流れだけを説明しましたが、 obj.attr1 = ... という「代入」のときの流れや del obj.attr1 という「削除」のときの流れも同様にあります。上の説明の中に __set__ メソッド・ __delete__ メソッドへの言及が少しありましたが、これらが「代入」や「削除」のときの流れをコントロールするためのものになります。

というわけで、 Python のオブジェクトにおいてアトリビュートへのアクセスがあったときに起こる処理の流れ についてでした。

Python でコードを書くときにこのあたりのところをどこまで押さえておくべきか、についてですが、私は必ずしもすべて頭に入れておく必要は無いと思います。ひとまず基本的な使い方をするだけであればレベル 1 ・ 2 あたりを押さえておけば十分で、アトリビュート周りについて発展的な使い方をしたいときにレベル 3 ・ 4 を、 descriptor を活用したパッケージにコントリビュートしたり自身で descriptor を使ったパッケージを書いたりしたい場合に必要に応じて 5 ・ 6 までを押さえる、というのがよいかと思います。

興味がある方のご参考になれば幸いです :)

descriptor を深掘りしたくて実践的な例を見てみたい方は、 peewee 等の ORM マッパライブラリのコードを見られるとよいかと思います。

参考


0 件のコメント: