クラスを書けるようになったころ、こんな場面に出会いませんか。最初はただの変数だった属性に、あとから条件を足したくなる場面です。
たとえば商品の価格を考えてみてください。書きはじめの時点では、self.price = priceと置いておけば十分でした。
ところが運用が始まると、負の数を入れられては困ることに気づきます。在庫の金額がマイナスになるような事故は、あとから直すほど厄介です。
ここでset_price()のようなメソッドを新しく作ると、すでにあちこちへ書かれたitem.price = 500をすべて書き換えることになります。呼び出し側が10箇所あれば、10箇所です。
この板挟みをほどいてくれるのが、@propertyという仕組みです。見た目は属性のまま、中身はメソッドという形が作れます。
この記事では、@propertyの考え方から書き方、そして初心者の方がつまずきやすい落とし穴までを順番に見ていきます。追加インストールは要りません。
属性をそのまま公開すると、あとで困る¶
まず、何が困るのかをはっきりさせておきます。ここが腑に落ちると、@propertyの出番が自然に見えてきます。
Pythonのクラスは、属性への代入を止めてくれません。item.price = -100と書けば、その値がそのまま入ります。
JavaやC#のような言語では、最初からゲッターとセッターを用意しておく文化があります。あとで検査を足せるようにするための、いわば保険です。
ただ、その作法をPythonでそのまま真似ると、コードがくどくなります。item.get_price()やitem.set_price(500)が並ぶ書き方は、Pythonらしさから遠く離れてしまいます。
Pythonの答えは、もっと素直です。必要になるまでは普通の属性で書いておき、必要になった時点で呼び出し側を変えずに中身だけ差し替える、という考え方をとります。
そもそも属性を外から隠すという話が曖昧な方は、オブジェクト指向の土台から眺めておくと理解が早くなります。【関連記事】継承・カプセル化・ポリモーフィズム。オブジェクト指向の3大要素をPythonで理解
@propertyは、メソッドを属性の顔で使わせる仕組み¶
言葉を重ねるより、動かしたほうが早いはずです。まずは計算した結果を属性のように見せる、最小限の例を見てください。
class Item:
def __init__(self, price):
self._price = price
@property
def price_with_tax(self):
return int(self._price * 1.1)
item = Item(1000)
print(item.price_with_tax) # 1100
注目してほしいのは、最後の行です。price_with_taxはメソッドとして定義されているのに、呼び出しの丸かっこが要りません。
@propertyを付けた関数は、その名前で読み出したときに自動で実行されます。つまり、外から見れば属性そのものです。
Pythonの公式ドキュメントでは、propertyはfget・fset・fdel・docの4つを受け取るものとして説明されています。@propertyは、このうちfget、つまり読み出し役だけを登録する書き方にあたります。
そして@で始まるこの記法は、Pythonのデコレータそのものです。仕組みの側から知っておきたい方は、こちらもあわせてどうぞ。【関連記事】Pythonのデコレータ(@)って何?関数の前後で処理を追加する不思議な記法の正体
セッターを足して、代入のときにも検査する¶
読み出しができたら、次は代入です。ここで冒頭の悩みが解決します。
@propertyを付けた関数と同じ名前で、@名前.setterを付けた関数を用意します。すると代入のたびに、その関数が呼ばれるようになります。
実際に、負の数をはじく例を書いてみます。
class Item:
def __init__(self, price):
self.price = price # ここもセッターを通る
@property
def price(self):
return self._price
@price.setter
def price(self, value):
if value < 0:
raise ValueError("価格に負の数は入れられません")
self._price = value
item = Item(1000)
item.price = 2000
print(item.price) # 2000
item.price = -1 # ValueError: 価格に負の数は入れられません
手元のPython 3.11で動かすと、最後の行できちんとValueErrorが上がりました。item.price = -1という、ごく普通の代入に見える一行がです。
ここが@propertyのいちばんおいしいところです。呼び出し側のコードは1文字も変えないまま、検査だけをあとから差し込めます。
__init__の中でself._priceではなくself.priceと書いている点にも触れておきます。こうしておくと、初期化のときの値も同じ検査を通ります。
読み取り専用のプロパティも作れる¶
セッターを書かなければ、その属性は読み取り専用になります。先ほどのprice_with_taxが、まさにそれです。
代入しようとすると、どうなるのでしょうか。手元で試したところ、AttributeError: property 'price_with_tax' of 'Item' object has no setterというメッセージが出ました。
セッターがないから代入できない、と素直に教えてくれます。計算で決まる値や、あとから変えられては困るIDのような属性で使いやすい形です。
セッターの中でうっかり無限ループさせる¶
ここで、初心者の方がほぼ全員一度は踏む落とし穴を紹介します。セッターの中で、同じ名前へ代入してしまうミスです。
class Bad:
@property
def x(self):
return self._x
@x.setter
def x(self, value):
self.x = value # 誤り。またセッターが呼ばれる
self.x = valueと書くと、そのセッターがもう一度呼ばれます。そしてまた同じ行にたどり着き、永遠に終わりません。
実際に動かすとRecursionErrorで止まります。手元で確認したときも、例外の名前はRecursionErrorでした。
正しくは、アンダースコア付きのself._x = valueへ入れます。プロパティの名前と、実際に値をしまう変数の名前は、必ず別にしてください。
なぜアンダースコア付きの変数に値を入れるのか¶
いま出てきた_priceや_xについて、もう少しだけ説明させてください。この習慣には、はっきりした理由があります。
プロパティの名前と値をしまう変数の名前が同じだと、さきほどの無限ループが起きます。だから名前をずらす必要があり、その目印として先頭のアンダースコアが使われます。
先頭のアンダースコアは、Pythonでは外から触らないでほしいという合図です。文法として禁止されるわけではなく、書き手どうしの約束ごとにすぎません。
この慣習の細かい意味合いは、記事1本ぶんの奥行きがあります。【関連記事】Pythonのアンダーバー(アンダースコア)とはなんなのか?
ちなみに、item.__dict__を覗くと_priceだけが入っていて、priceは入っていません。プロパティはクラス側に住んでいて、インスタンスの中身とは別の場所にあるからです。
重い計算はcached_propertyで1回だけにする¶
@propertyには、ひとつ弱点があります。読み出すたびに、中の処理が毎回走ることです。
税込価格の計算くらいなら気になりません。ですが、ファイルを読む処理や集計のような重い処理だと、読み出すたびに待たされます。
そこで使えるのが、標準ライブラリfunctoolsのcached_propertyです。最初の1回だけ計算し、その結果をインスタンスにしまって使い回してくれます。
import functools
class Report:
def __init__(self, rows):
self.rows = rows
@functools.cached_property
def total(self):
print("集計を実行しました")
return sum(self.rows)
r = Report([1, 2, 3])
print(r.total) # 集計を実行しました → 6
print(r.total) # 6 だけが出る
print(r.__dict__) # {'rows': [1, 2, 3], 'total': 6}
手元で動かすと、集計を実行しましたという行は1回しか出ませんでした。2回目からは、しまってある値をそのまま返しているためです。
最後の行が種明かしになっています。計算結果が__dict__の中にtotalという名前で置かれていて、以降はそちらが読まれます。
ただし気をつけたい点もあります。値が古くなっても自動では作り直されないので、あとからself.rowsを書き換えてもtotalは昔のままです。
もうひとつ、複数のスレッドから使う場合の注意もあります。Python 3.12でcached_propertyから内部のロックが取り除かれたため、同時に読まれると計算が2回走ることがあります。
2つの違いを、表で並べておきます。
| 項目 | @property | functools.cached_property |
|---|---|---|
| 計算のタイミング | 読み出すたび毎回 | 最初の1回だけ |
| 値の置き場所 | 置かない(毎回作る) | インスタンスの__dict__ |
| セッターを足せるか | 足せる | 足せない(代入で上書きになる) |
| 元の値を変えたとき | 追従する | 古い値のまま残る |
| 向いている処理 | 軽い計算や検査 | 重い集計やファイル読み込み |
@propertyを使わないほうがいい場面¶
便利な道具ほど、使いどころを間違えると裏目に出ます。ここは正直に線を引いておきます。
いちばんの落とし穴は、重い処理を@propertyの中に隠すことです。読み手は属性だと思って気軽に触るので、ループの中で何度も呼ばれて詰まります。
時間のかかる処理や、外部への通信を伴う処理は、素直にメソッドにしてください。丸かっこが付いていれば、読み手は何かが起きると身構えられます。
もうひとつは、ただ値を出し入れするだけのプロパティです。検査も計算もしないなら、普通の属性で足ります。
書き方の選び分けを、表にまとめました。
| やりたいこと | おすすめの書き方 |
|---|---|
| ただ値を持たせたいだけ | 普通の属性のままにする |
| 代入のときに検査したい | @propertyとセッター |
| 他の属性から計算して見せたい | @property(読み取り専用) |
| 重い集計を1回だけ行いたい | functools.cached_property |
| 時間がかかる処理や通信を行う | 普通のメソッドにする |
値を持たせるだけのクラスなら、そもそもdataclassのほうが短く書けます。【関連記事】Pythonのdataclassとは?クラスの定義がぐっと短くなる書き方を初心者向けに解説
なお@propertyは、Pythonが属性へのアクセスを横取りする仕組みの一種です。この裏側をもっと知りたい方は、特殊メソッドの話へ進むと視界が開けます。【関連記事】Pythonの特殊メソッドとは?どんな種類があるのか?どうやって使うのか?
実務で気をつけていること¶
私は10年ほどエンジニアとして開発に関わってきましたが、@propertyはありがたさと危うさが表裏一体の機能だと感じています。書くのが楽しくなって、つい使いすぎてしまう類の道具です。
いちばん痛かったのは、あるモデルクラスのプロパティの中でデータベースへ問い合わせていたコードです。一覧画面で1000件を表示したとき、裏で1000回の問い合わせが走っていました。
画面が重いという報告を受けて調べても、テンプレート側には怪しい記述がありません。属性を読んでいるだけに見える一行が原因だと気づくまで、まる1日かかりました。
それ以来、プロパティの中には、その場ですぐ終わる処理しか置かないと決めています。1ミリ秒で終わらない処理なら、メソッドにするかcached_propertyにするかを考えます。
もうひとつ、最初から全部の属性をプロパティで包むのもやめました。検査が必要になった属性だけを、あとから包めば十分だからです。
@propertyの本当の価値は、あとから変えられるという一点にあります。先回りして守りを固める必要は、思っているほどありません。
まとめ¶
@propertyは、メソッドを属性の顔で使わせる仕組みです。呼び出し側のコードを変えないまま、あとから検査や計算を差し込めます。
書き方の骨格は単純で、読み出しに@property、代入に@名前.setterを添えるだけです。値は必ずアンダースコア付きの別名にしまい、セッターの中で同じ名前へ代入しないよう気をつけてください。
重い計算を何度も走らせたくないときは、functools.cached_propertyが助けになります。ただし元の値を変えても追従しないという性質は、頭の隅に置いておきましょう。
そして、時間のかかる処理はプロパティに隠さない。この線引きさえ守れば、危ない使い方の大半は避けられます。
まずはお手元のクラスで、代入のときに検査したい属性がないか探すところから始めてみてはいかがでしょうか。ここまでお読みいただきありがとうございました。