注) タイトルはただの煽りです。
はじめに
最近ようやく 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 の(クラス)メンバ全般を表す呼称ですが、プロパティとはいったい何者でしょうか。属性を言い換えたものでしょうか。
一部のプログラミング言語において、プロパティとはクラスに定義できる、特殊なメンバの一種である。
プロパティへのアクセスは文法上はフィールドと同様に行えるものの、実際にはアクセサの呼び出しに変換される。
(略)
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
などの組み込み機能
- 例えば
属性アクセス時の振る舞いをハックしたいとき、ディスクリプタを活用するとスマートに書ける。ことがある。のかもしれません。