クラスの属性に直接アクセスすると楽ですが、後から仕様変更やバリデーションが必要になった時に困ることがあります。
@propertyは、見た目は属性アクセスのまま、中身は関数として制御できる仕組みです。
初心者でも段階的に理解できるよう、基礎から実践、ベストプラクティスまで丁寧に解説します。
@propertyとは
ゲッターとセッターをPythonで簡潔に
Pythonでは@property
デコレータを使うと、属性の取得や更新の裏で関数を実行できるようになります。
従来のget_x()
やset_x()
のようなメソッド名を使わず、見た目は単なる属性アクセスのまま安全な読み書きを実装できます。
プロパティの利点とカプセル化
カプセル化とは、オブジェクトの内部表現を隠し、外部からの不正な操作を防ぐ設計です。
プロパティを使うと次の利点があります。
メソッドに変更しても外部APIは属性のままなので、既存コードの互換性が高いこと、値の検査や変換、ログ記録などを透過的に差し込めること、後片付けや遅延計算を簡単に仕込めることです。
直接アクセスとの違い
属性へ直接アクセスする場合は制御が効きません。
一方、プロパティなら、取得時や設定時に関数が呼ばれるため、バリデーションや変換、エラーメッセージによる誘導が可能です。
比較を以下にまとめます。
アプローチ | 記法の見た目 | バリデーション | 互換性維持 | 実装の手間 |
---|---|---|---|---|
直接アクセス | obj.x | できない | 低い | 低い |
明示的メソッド(get/set) | obj.get_x() / obj.set_x(v) | できる | 低い(呼び方が変わる) | 中 |
@property | obj.x / obj.x = v | できる | 高い(見た目は属性のまま) | 中 |
プロパティは便利ですが、重い処理を隠すとパフォーマンス問題に気づきにくくなります。
Pythonの@propertyの基本
読み取り専用プロパティの定義
ゲッターだけを定義すると読み取り専用になります。
内部保存用の属性は_name
のようなバッキングフィールドを使います。
class Person:
def __init__(self, first_name: str, last_name: str) -> None:
# バッキングフィールドに実データを保持
self._first_name = first_name
self._last_name = last_name
@property
def full_name(self) -> str:
"""名と姓から表示用の氏名を返します(読み取り専用)。"""
# 取得時に計算して返す(計算プロパティ)
return f"{self._first_name} {self._last_name}"
p = Person("Taro", "Yamada")
print(p.full_name) # 読み取りは可能
# p.full_name = "X" # 書き込みはエラー(AttributeError)
Taro Yamada
setterの追加と値の更新
@プロパティ名.setter
でセッターを追加します。
値の検査や変換を行い、不正な値にはValueError
やTypeError
を投げると、使い方を誤った呼び出し側へ明確に伝えられます。
class Temperature:
def __init__(self, celsius: float) -> None:
self._celsius = float(celsius) # バッキングフィールド
@property
def celsius(self) -> float:
"""摂氏温度を返します。"""
return self._celsius
@celsius.setter
def celsius(self, value: float) -> None:
"""摂氏温度を設定します。絶対零度未満は不可です。"""
# バリデーションを実施
if not isinstance(value, (int, float)):
raise TypeError("celsiusは数値で指定してください")
if value < -273.15:
raise ValueError("celsiusは-273.15以上である必要があります")
self._celsius = float(value)
t = Temperature(0)
print(t.celsius)
t.celsius = 100
print(t.celsius)
0.0
100.0
deleterの追加と後片付け
@プロパティ名.deleter
でデリータを定義すると、del obj.prop
時の挙動を制御できます。
キャッシュの破棄や外部リソースの解放を入れることができます。
class CacheHolder:
def __init__(self) -> None:
self._data = [1, 2, 3]
self._sum_cache = None # 計算結果のキャッシュ
@property
def total(self) -> int:
"""配列の合計値(キャッシュあり)。"""
if self._sum_cache is None:
# 高コスト計算を想定
self._sum_cache = sum(self._data)
return self._sum_cache
@total.deleter
def total(self) -> None:
"""キャッシュを破棄します。"""
self._sum_cache = None # 後片付け
c = CacheHolder()
print(c.total) # 初回は計算
print(c.total) # 2回目はキャッシュ
del c.total # キャッシュ破棄
print(c.total) # 再計算
6
6
6
docstringとhelpの書き方
デコレータ形式では、ゲッター関数のdocstringがプロパティのdocstringになります。
help()
や__doc__
で確認できます。
class Meter:
def __init__(self, value: float) -> None:
self._value = float(value)
@property
def value(self) -> float:
"""メートル(m)単位の長さ。0以上の数値。"""
return self._value
print(Meter.value.__doc__) # プロパティのdocstringにアクセス
help(Meter) # クラスのヘルプにプロパティ説明が含まれる
メートル(m)単位の長さ。0以上の数値。
Help on class Meter in module __main__:
class Meter(builtins.object)
| Methods defined here:
|
| value(self)
| メートル(m)単位の長さ。0以上の数値。
実用パターンとサンプル
バリデーションで不正値を防ぐ
ユーザー入力をそのまま属性に入れると危険です。
プロパティで早期に検証し、具体的なメッセージを返すと、使う人が迷いません。
class User:
def __init__(self, name: str, age: int) -> None:
self._name = name
self._age = None
self.age = age # セッター経由で検証
@property
def age(self) -> int:
"""年齢は0以上の整数。"""
return self._age
@age.setter
def age(self, value: int) -> None:
if not isinstance(value, int):
raise TypeError("ageはintで指定してください。例: user.age = 20")
if value < 0:
raise ValueError("ageは0以上である必要があります。例: user.age = 0")
self._age = value
u = User("A", 18)
print(u.age)
# u.age = -1 # ValueError
18
計算プロパティで派生データを提供
保存しなくても、元データから計算できる値はプロパティに向いています。
表示用の文字列や合計値などが典型です。
class Rectangle:
def __init__(self, width: float, height: float) -> None:
self._width = float(width)
self._height = float(height)
@property
def area(self) -> float:
"""面積(読み取り専用の計算プロパティ)。"""
return self._width * self._height
r = Rectangle(3, 4)
print(r.area) # 3×4=12
12.0
公開属性を後からプロパティ化して互換維持
最初は単純な公開属性でも、後で検証が必要になることがあります。
同じ名前でプロパティ化すれば、既存コードの記法はそのまま、裏側だけ強化できます。
# 初期版(公開属性)
class ProductV1:
def __init__(self, price: float) -> None:
self.price = price # 直接代入
# 改訂版(プロパティ化して検証追加)
class ProductV2:
def __init__(self, price: float) -> None:
self._price = 0.0
self.price = price # 既存コードを壊さない
@property
def price(self) -> float:
"""価格。0以上の数値。"""
return self._price
@price.setter
def price(self, value: float) -> None:
if not isinstance(value, (int, float)):
raise TypeError("priceは数値で指定してください")
if value < 0:
raise ValueError("priceは0以上である必要があります")
self._price = float(value)
p = ProductV2(1200)
print(p.price)
p.price = 1500
print(p.price)
1200.0
1500.0
エラーメッセージで使い方を案内
プロパティは使い方を間違えやすい箇所です。
例つきの親切なメッセージにすると、学習コストを下げられます。
class Percentage:
def __init__(self, value: float) -> None:
self._value = 0.0
self.value = value
@property
def value(self) -> float:
"""0.0〜1.0の割合。例: 25%は0.25"""
return self._value
@value.setter
def value(self, v: float) -> None:
if not isinstance(v, (int, float)):
raise TypeError("valueは数値で指定してください。例: pct.value = 0.25")
if not (0.0 <= v <= 1.0):
raise ValueError("valueは0.0〜1.0の範囲で指定してください。例: pct.value = 0.75")
self._value = float(v)
pct = Percentage(0.3)
print(pct.value)
0.3
ベストプラクティスと落とし穴
バッキングフィールドで無限再帰を回避
セッターの中でself.prop = ...
と書くと、自分自身のセッターを再帰呼び出ししてしまいます。
必ず_prop
のような別名のバッキングフィールドに代入します。
class Bad:
@property
def x(self) -> int:
return self._x
@x.setter
def x(self, v: int) -> None:
# self.x = v # これは無限再帰になるのでNG
self._x = v # OK: バッキングフィールドに代入
名前付けと外部APIを安定させる
公開した属性名は外部の利用者のコードに直結します。
将来の変更を見越して外部APIは属性名のままキープし、内部はプロパティ化して進化させると、破壊的変更を避けられます。
バッキングフィールドは先頭にアンダースコアを付け、内部的であることを示しましょう。
パフォーマンスと副作用に注意
プロパティは見た目が軽いので、重いI/Oやネットワーク呼び出しを隠すのは避けるべきです。
計算コストが高い場合は明示的なcompute_*
メソッドにする、あるいは結果をキャッシュしたい時はfunctools.cached_property
(Python 3.8+)の利用を検討します。
ただしキャッシュの無効化方法も設計しておく必要があります。
from functools import cached_property
class Dataset:
def __init__(self, path: str) -> None:
self._path = path
@cached_property
def data(self) -> list[str]:
"""初回のみ読み込み、以後はキャッシュしたリストを返す。"""
# 実際にはファイル読み込みなどを行う想定
return ["row1", "row2"]
d = Dataset("data.csv")
print(d.data) # 初回は読み込み
print(d.data) # 2回目以降はキャッシュ
['row1', 'row2']
['row1', 'row2']
型ヒントでgetterとsetterの型をそろえる
型ヒントは可読性とツール支援に有効です。
ゲッターの返り値型とセッターの引数型は一致させます。
Pythonではセッター側のシグネチャに引数型を注釈できます。
class Account:
def __init__(self, balance: float) -> None:
self._balance = float(balance)
@property
def balance(self) -> float:
"""口座残高。負の値は不可。"""
return self._balance
@balance.setter
def balance(self, value: float) -> None: # value: float で型を明示
if value < 0:
raise ValueError("balanceは0以上である必要があります")
self._balance = float(value)
継承とオーバーライドの指針
プロパティはディスクリプタとして振る舞うため、サブクラスで上書きできます。
全体を置き換える方法と、.getter/.setter
で一部だけ差し替える方法があります。
class Base:
def __init__(self) -> None:
self._x = 0
@property
def x(self) -> int:
"""基底のx。"""
return self._x
@x.setter
def x(self, v: int) -> None:
self._x = int(v)
class Sub(Base):
# 取得だけロギングを追加し、設定は親のまま使う例
@Base.x.getter
def x(self) -> int: # type: ignore[override]
val = super().x # 親のゲッターを使う
print(f"debug: x -> {val}")
return val
s = Sub()
s.x = 10
print(s.x) # 取得時にログが出る
debug: x -> 10
10
セッターを削除したい場合は、サブクラスで読み取り専用の新たなプロパティを定義して置き換えます。
インターフェースの後方互換性に配慮しましょう。
まとめ
@propertyは、属性アクセスの書き心地とメソッドの柔軟性を両立する強力な仕組みです。
読み取り専用からセッター、デリータ、docstringまで一貫した記法で扱え、後からの仕様強化や互換性維持にも役立ちます。
実装ではバッキングフィールドで無限再帰を避けること、明確なバリデーションと親切なエラーメッセージ、過度に重い処理を隠さないといった基本を守ると安心です。
型ヒントや継承の指針も押さえ、プロパティを適材適所で活用してください。