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 クラスです。これを Article
と Page
の 2 つのクラスで利用しています。
TitleField
の __set__
にバリデーション処理があるので、 Article
や Page
の title
アトリビュートに文字列以外のオブジェクトや指定された長さよりも長い文字列をセットすることはできません。
このような処理は __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 マッパライブラリのコードを見られるとよいかと思います。
参考
- Understanding __get__ and __set__ and Python descriptors - Stack Overflow
- Descriptor HowTo Guide — Python documentation
object.__getattribute__
| 3. Data model — Python documentation- Implementing Descriptors | 3. Data model — Python documentation
- Objects, values and types | 3. Data model — Python documentation