はじめに
property
を使った書き方を丸暗記だけしてよくわからないままclassを使っている人が多いですね。元をたどればclassをなんとなく使っているからです。ということで、中で何が起きているのかを徹底的に解説します!
2021/12/31 編集
- インスタンスからクラス変数にアクセスするとき、コピーではなく参照を受け取っています。その点間違っていたので修正しました。
目次
そもそもclassとは
property
を理解するには、まずclassが何なのかを知る必要があります。
ほとんどの解説記事では、classは次のような構成で紹介されます。
class A:
def __init__(self, value=0):
"""インスタンスを作成"""
self.value = value
def show(self):
"""値を表示"""
print(self.value)
これによりshow
関数は謎の「メソッド」というものとして定義され、classの定義はメソッドの集合体として認識されがちです。しかしこれはいったん忘れてください。
名前空間としてのclass
classの定義もあくまで1行(または1ブロック)ずつ実行しているにすぎません。次の例を見てください。
class A:
a = 1
def print_value(x):
print("value =", x)
print_value(a)
classの中であること以外、地の文と変わりません。これを実行すると、
value = 1
とちゃんとprintされます。違いは定義した変数a
や関数print_value
にアクセスするときにA
を介する必要があることです。
A.a # [Out]: 1
A.print_value # [Out]: <function __main__.A.print_value(x)>
このおかげで、classの外でa = 10
のように同じ名前の変数を新しく定義しても混同されません。「ただのa
」と「クラスA
のa
」で区別できるので「名前空間を分ける」というふうに言われるわけです。
インスタンスの作成
上で定義したA
は関数呼び出し可能です。
callable(A) # [Out]: True
A
を関数呼び出ししたときの返り値は「A
のインスタンス」と呼ばれます。type
で型を確認するとA
になっているので、型を定義したと解釈することもできます。
ins = A()
ins # [Out]: <__main__.A at 0x...>
type(ins) # [Out]: __main__.A
A
のインスタンスは、ピリオド.
を介してA
の中で定義した変数・関数すべてにアクセス可能です。しかし、このとき、アクセス先の変数によって挙動が異なります。もう一度A
の定義に戻りましょう。
class A:
a = 1
def print_value(x):
print("value =", x)
インスタンスからアクセスできるものはa
とprint_value
です。前者は
ins.a # [Out]: 1
となり、とても直観的ですが、後者は
ins.print_value(0) # TypeError: print_value() takes 1 positional argument but 2 were given
ins.print_value() # [Out]: value = <__main__.A object at 0x0000021CC6533DC0>
からわかるように、引数が1つ減っている代わりに、1つめの引数x
にins
自身が渡されていることが分かります。このことはclassの基本として紹介されていますが、考えてみればPythonにおいては整数も文字列も関数もすべてobject
型に属します。
isinstance(1, object) # [Out]: True
isinstance(A.print_value, object) # [Out]: True
なぜこのような違いが出てくるのでしょうか。
__get__
メソッドについて
classA
内の変数に、A
とは全く別物である、A
のインスタンスins
からアクセスすることは未定義です。これがPythonでどう定義されているのかがポイントです。
A
のインスタンスins
はどのようにA
から変数をもらっているのか。結論から言うと
- デフォルトでは変数の参照をもらう
- 変数に
__get__
メソッドがある場合は__get__
メソッドを呼び出す
の2パターンです。
1. デフォルトでは変数の参照をもらう
int
は__get__
メソッドを持ちません。
hasattr(0, "__get__") # [Out]: False
したがって、ins.a
とA.a
は同じオブジェクトを表します。
ins.a is A.a # [Out]: True
ただし、ins.a
に新しい値を代入しても、ins
側で別のオブジェクトに更新されるだけで、A.a
に影響はありません。
ins = A()
ins.a = 10
ins.a # [Out]: 10
A.a # [Out]: 1
ただし、参照渡しであるので、たとえばlist
の中身は共有されます。
hasattr([], "__get__") # [Out]: False
class B:
x = [0]
ins_b = B()
ins_b.x # [Out]: [0]
ins_b.x[0] = -1
ins_b.x # [Out]: [-1]
B.x # [Out]: [-1] <-- 上書きされる!
2. 変数に__get__
メソッドがある場合は__get__
メソッドを呼び出す
まずは__get__
が何なのかを知る必要があります。最も大事な、関数を例に説明します。
(1) 関数の__get__
メソッド
Pythonでの関数はfunction
型です。関数は、どこで定義されようとfunction
のインスタンスであり、__get__
メソッドを持ちます。
def f(x): ...
type(f) # [Out]: function
hasattr(f, "__get__") # [Out]: True
type(A.print_value) # [Out]: function
hasattr(A.print_value, "__get__") # [Out]: True
Python内部での関数の__get__
メソッドはPython実装ではないですが、真似すれば次のようになっています。
class function:
def __get__(self, obj, objtype=None):
def method(*args, **kwargs):
return self(obj, *args, **kwargs) # selfが関数自身であることに注意
return method
( これはいろいろな意味で完全ではありません。あくまで説明のためです)
少しわかりづらいですが、自身の1つめの引数のみobj
にした新しい関数method
を作成して返しています。
function
クラスを無理やりtype
で取得してこれを実行してみましょう。足し算する関数とfunction
クラスを用意します。
def add(x, y):
return x + y
function = type(add)
整数1
のadd
メソッド1.add
のようなものを用意します。
add_method = function.__get__(add, 1)
add_method
はadd
の1つめの引数に1
が入った状態ですので、もう片方を指定すれば足し算が計算されます。
add_method(2) # [Out]: 3
(2) class内での__get__
の挙動
以上を念頭に考えます。さきほどから使っているclassA
とそのインスタンスを使います。
class A:
a = 1
def print_value(x):
print("value =", x)
ins = A()
関数print_value
がclassA
で定義され、それがA
のインスタンスins
からins.print_value
とアクセスされたとき、次のような作業が行われます。
-
method = function.__get__(A.print_value, ins)
によりメソッド関数method
が作成される。 -
ins.print_value
の返り値がそのままmethod
になる。
つまり、print_value
をそのままコピーする代わりに、print_value
には__get__
が定義されているので、__get__
の返り値(すなわち1つめの引数が指定された関数)をコピーの代わりにしているということです。
(3) __get__
を使ってみる
よりシンプルに、"Hello!"
を常にコピーとして渡してくる無能な型を作ってみましょう。
class Munou:
def __get__(self, obj, objtype=None):
return "Hello!"
この無能オブジェクトをclass内で定義すれば、アクセスしたときに常に"Hello!"
が得られます。
class B:
m = Munou()
ins = B()
ins.m # [Out]: 'Hello!'
値へのアクセスが完全に制御できました。
__set__
メソッドについて
__get__
は値を受け取るときに呼び出されました。値を渡す(代入する)ときはどうでしょうか。これも同じような構成になっています。
- デフォルトではそのまま値を上書きする。
- 上書きしようとしている変数に
__set__
メソッドがある場合は__set__
メソッドを呼び出す
int
には__set__
メソッドがないので、普通に上書きされます。
class B:
i = 0
ins = B()
ins.i # [Out]: 0
ins.i = 1
ins.i # [Out]: 1 <-- 上書きされている
先ほどのMunou
も__set__
を定義していないので、新しい値を与えると上書きされます。
class B:
m = Munou()
ins = B()
ins.m = 0
ins.m # [Out]: 0 <-- 上書きされている
そこで、上書きしないように、エラーを返すように書き換えます。
class Munou:
def __get__(self, obj, objtype=None):
return "Hello!"
def __set__(self, obj, value):
raise AttributeError
この無能オブジェクトを使うと、上書きins.m = 0
の際にMunou.__set__(B.m, ins, 0)
が呼び出されます。この例では、無能なうえに上書きが阻止されます。
class B:
m = Munou()
ins = B()
ins.m = 0 # [Out]: AttributeError
ins.m # [Out]: 'Hello!'
property
とは
以上のように__get__
や__set__
は値の取得・上書きを制御するのに使えることが分かりました。これをうまく使えば、たとえば
- 格納された値を使いやすい形で返してくれるように
__get__
を定義する。 - 値の更新の際、型や値をチェックしてから更新するように
__set__
を定義する。 - 値の更新を禁止するために
__set__
でエラーを吐くようにする。
のようなことができます。
しかし問題は、毎回新しいクラスを定義するのは面倒だということです。メインのクラスであるB
とは別に、__get__
と__set__
を有したMunou
クラスを定義しましたが、そもそもB
にしか使わないのにわざわざMunou
を別で用意するといった大掛かりな作業は避けたいです。
ここで出てくるのが、標準実装のproperty
クラスです。
property
クラスの実装
property
クラスはPythonで真似れば次のような実装になっています。
class property:
def __init__(self, fget=None, fset=None):
self.fget = fget # ゲッター関数(なくてもよい)
self.fset = fset # セッター関数(なくてもよい)
def getter(self, fget):
"""ゲッター関数を更新した新たなpropertyを返す"""
return property(fget=fget, fset=self.fset)
def __get__(self, obj, objtype=None):
if self.fget is None:
raise AttributeError("unreadable attribute") # ゲッター関数がなければエラー
return self.fget(obj) # ゲッター関数を呼び出す
def setter(self, fset):
"""セッター関数を更新した新たなpropertyを返す"""
return property(fget=self.fget, fset=fset)
def __set__(self, obj, value):
if self.fset is None:
raise AttributeError("can't set attribute") # セッター関数がなければエラー
return self.fset(obj, value) # セッター関数を呼び出す
( これもいろいろな意味で完全ではありません。あくまで説明のためです)
つまり、property
のインスタンスprop
を何かのクラスに定義し、適宜prop
にゲッター関数やセッター関数を与えているとすると、インスタンスins
からprop
にアクセスするとき、
-
ins.prop
では、ゲッター関数があればそれを呼び出し、なければ書き込み専用ということでエラーを返す。 -
ins.prop = 0
では、セッター関数があればそれを呼び出し、なければ読み取り専用ということでエラーを返す。
というルールに従うということです。したがって「__get__
や__set__
を持ったクラスを定義する」代わりに「ゲッター関数とセッター関数を定義する」ことでほぼ同じ仕組みが実現できることになります。
property
の利用
それでは、自己紹介と、セットしようとした値の説明をするだけのproperty
を作ります。ゲッター関数とセッター関数を定義します。
def getter_func(self):
print(f"I am {self}")
def setter_func(self, value):
print(f"This is {value}")
これらをもちいてproperty
インスタンスを作成します。
prop = property(getter_func, setter_func)
prop # [Out]: <property at 0x...>
ゲッター関数とセッター関数を別々でセットするのであれば次のコードで同じprop
が得られます。
prop0 = property(getter_func)
prop = prop0.setter(setter_func)
prop # [Out]: <property at 0x...>
このproperty
のインスタンスprop
をclassに渡します。
class C:
prop = prop
すると値のアクセス/代入をするたびに対応する関数が呼び出されます。
c = C()
c.prop # [Out]: I am <__main__.C object at 0x...>
c.prop = 0 # [Out]: This is 0
よく見るproperty
の使い方
さて、ようやくいつもの使い方が出てきます。ポイントは
- ゲッター関数・セッター関数はどうせクラス外部から使わないので、クラス内で定義する。
-
property
のメソッド、__init__
とsetter
は単一の関数を引数にとれるので、できるだけデコレータを使う。
の2点です。
デコレータのとは
関数またはクラスを引数にとり、引数1つで動く関数は、デコレータ表記ができます。たとえばlist
に関数を追加するとき、
def func():
return 0
function_list.append(func)
と
@function_list.append
def func():
return 0
はほぼ等価です。後者ではfunc
にはfunction_list.append(func)
の返り値が格納されるので、func
がNone
になっているという違いだけです。
デコレータを使ったproperty
の定義
以上から、property
の__init__
, getter
, setter
関数はすべてデコレータ表記できます。したがって
def getter_func(self):
print(f"I am {self}")
def setter_func(self, value):
print(f"This is {value}")
prop0 = property(getter_func)
prop = prop0.setter(setter_func)
と
@property
def getter_func(self):
print(f"I am {self}")
@getter_func.setter
def setter_func(self, value):
print(f"This is {value}")
はほぼ等価です。そして、getter_func
はゲッター関数だけ定義された中間産物で不要なので、すべて同じ変数名prop
で問題ないです。
@property
def prop(self):
print(f"I am {self}")
@prop.setter
def prop(self, value):
print(f"This is {value}")
このように、はじめの方法で出てきた不要な変数名getter_func
, setter_func
, prop0
を書かずにprop
を定義できたので、デコレータを使った方法が良いとされています。これをクラス内でやればOKです。
class C:
@property
def prop(self):
print(f"I am {self}")
@prop.setter
def prop(self, value):
print(f"This is {value}")
まとめ
以上のようにproperty
というのは、Pythonでのクラスとインスタンスのアクセス関係を利用して、読み取り専用・書き込み専用のような変数を可読性高く簡単に定義するためのものでした。この仕組みが分かっていれば無用なトラブルシューティングが避けられますね。
また、__get__
/__set__
はPythonで魔法のような(直観的で使いやすいが一見Pythonの範疇を超えているかのように動作する)クラスを作るときに非常に有用です。ぜひ使えるようにしたいメソッドです。