Pythonでは以下のようにしてクラスを定義できます。
class Foobar:
def __init__(self, xyzzy):
self.xyzzy = xyzzy
上の例では__init__メソッド——語弊を恐れずに言えばコンストラクタ——で引数に渡された値をインスタンスの属性に設定していますが、この属性はインスタンス作成後にオーナーが自由に変更できます。
foobar = Foobar(0)
print(f"{foobar.xyzzy=}") # foobar.xyzzy=0
foobar.xyzzy = 42
print(f"{foobar.xyzzy=}") # foobar.xyzzy=42
これは基本的には直感的で便利ですが、場合によっては一度生成された後はその中身を変更させたくない場合もあると思います。この記事ではそういったイミュータブルなクラスを作成する方法についてまとめます。
よくある話ですが、クラスの属性自身がリストのようなミュータブルな値である場合は全体もミュータブルになる点には注意してください。C++やRustみたいに可変参照と不変参照を厳密に扱える言語ならよいのですが。
特殊メソッドをオーバーロードしてカプセル化
Pythonでは属性への代入や属性の削除を行うと特殊メソッドの呼び出しが行われます。前者は__setattr__、後者は__delattr__です。クラスの定義でこれらのメソッドをオーバーロードし、呼び出すと例外を上げるようにすることで擬似的なイミュータブルを達成できます。
class Foobar:
def __init__(self, xyzzy):
object.__setattr__(self, "xyzzy", xyzzy)
def __setattr__(self, name, value, /):
raise AttributeError(f"cannot assign to field {name!r}")
def __delattr__(self, name, /):
raise AttributeError(f"cannot delete field {name!r}")
ポイントは、コンストラクタ内で属性を設定する際に通常の代入ではなく__setattr__を直接呼び出している、それも親クラスのものを使用していることです1。これはインスタンスが出来たときには既に__setattr__がオーバーロードされているため、通常の代入では自分の定義内でも属性を設定できなくなってしまうからです。
これによってfoobar.xyzzy = 42のような式は実行時に失敗するようになりましたが、タイプチェッカを導入している環境では実行前にこのようなミスに気づいておきたいものです。これは、ややボイラープレート的にはなりますが、マングリングとプロパティの合わせ技によって実現することが可能です。
class Foobar:
@property
def xyzzy(self):
return self.__xyzzy
def __init__(self, xyzzy):
object.__setattr__(self, "_Foobar__xyzzy", xyzzy)
def __setattr__(self, name, value, /):
raise AttributeError(f"cannot assign to field {name!r}")
def __delattr__(self, name, /):
raise AttributeError(f"cannot delete field {name!r}")
属性名はあくまでも__xyzzyであり、xyzzyは読み取り専用プロパティなので、タイプチェッカはfoobar.xyzzy = 42が正しくないコードであると気づいてくれます。そして、foobar.__xyzzy = 42はマングリング前の名称を参照しているため、これもタイプチェッカが拒否してくれます。
注意点として、コンストラクタ内の属性設定ではマングリング後の名前を指定しなければなりません。Pythonのマングリングの仕様についてはこの記事では詳しくは説明しませんので、各自別のドキュメントを参照してください。
凍結されたデータクラス
ここまで示した方法は柔軟ですが、通常イミュータブルなクラスが欲しい場合は複数の属性を単に格納しているだけの、単純な構造体的なものがほとんどだと思います。Pythonではそういった単純な構造体を作成するためにデータクラスという機構がありますが、frozen=Trueを指定することで同様に擬似イミュータブル化することができます。
from dataclasses import dataclass
@dataclass(frozen=True)
class Foobar:
xyzzy: int
データクラスは、宣言した順に属性を渡すコンストラクタを自動で生成します。また、__eq__なども自動で実装してくれるので、データクラスで済むクラスであればこちらを使うのがよいでしょう。タイプチェッカもデータクラスが凍結されていることを認識し、foobar.xyzzy = 42のようなコードは拒否してくれます。
データクラスはコンストラクタを自動生成してくれますが、必要ならオーバーライドも可能です。しかし、自動生成されるコンストラクタは関数から抜ける直前に特殊メソッド__post_init__を呼び出すので、簡単なバリデーションなどの用途であればこちらの方をオーバーライドする方が簡単です。
from dataclasses import dataclass
@dataclass(frozen=True)
class Foobar:
xyzzy: int
def __post_init__(self):
if self.xyzzy < 0:
raise ValueError("xyzzy must be nonnegative")
余談ですが、ここまでに述べた2方法を使用する場合、__slots__およびslots=Trueについても調べておくと幸せになれるかもしれません。
名前付きタプル
ここまで示してきた方法は、外からobject.__setattr__を使用することで対策を回避することが可能です。そこまでする偏執的なユーザーのことまでケアする必要は基本的にはないとは思いますが、真にイミュータブルなクラスを作る方法もあります。
まず、名前付きタプルを使用する方法です。これはその名の通りタプルの各要素に名前をつけて扱いやすくするためのものですが、これを構造体的に扱うことが可能です。タプルはもともとイミュータブルであるため、真にイミュータブルなクラスを自作する最も自然な方法と言えるでしょう。
from typing import NamedTuple
class Foobar(NamedTuple):
xyzzy: int
このように、単純な使い方であればほとんどデータクラスと同様に書けます。__post_init__機構が存在しないことには注意しましょう。
タプルを継承
名前付きタプルではデータクラスと違いコンストラクタをオーバーライドすることはできません。コンストラクタを自由に実装したいなら、自分でタプルを継承したクラスを作成するのがよいです。
class Foobar(tuple):
@property
def xyzzy(self):
return self[0]
def __new__(cls, xyzzy):
self = tuple.__new__(cls, (xyzzy,))
return self
def __getnewargs__(self):
return (self.xyzzy,)
ポイントは__init__ではなく__new__を使用することです。Pythonにおける「コンストラクタ呼び出し」の実体はメタクラスの__call__であり、これはまず__new__によってインスタンスを生成し、その後__init__で初期化を行っています。タプルは真にイミュータブルであるため、インスタンス作成後はその中身を変更できません。なので、__new__ですべて済ませる必要があるのです。
# 擬似コード
class type: # メタクラス …… `int` などの一般的なクラスは `type` の「インスタンス」である
def __call__(self, *args, **kwargs):
instance = self.__new__(self, *args, **kwargs)
if isinstance(instance, self):
instance.__init__(*args, **kwargs)
return instance
__init__に比べると__new__の定義は慣れが必要になります。というのは、__new__呼び出し段階ではインスタンスが生成されていないので、引数でselfを受け取ることはできません。@staticmethodこそついていませんが、__new__は静的メソッドとなっています2。インスタンスを生成するために親クラスの__new__を呼び出し、第一引数に自分のクラスを渡すことで__new__を乗っ取り自分のクラスのインスタンスを生成させます。そして、関数の最後で生成したインスタンスを返します。
もう一つ、あまり目にすることのない特殊メソッド__getnewargs__がオーバーライドされていることにも注目します。このメソッドをオーバーライドしなくても大抵のケースでは問題は起こりません。しかし、もしあなたがこのクラスのインスタンスをピクル化する可能性があるならこのオーバーライドは必須です。
クラスがこのメソッドを定義している場合、ピクル化する際にはこのメソッドの呼び出し結果を保存し、復元する際にコンストラクタに与えます。つまり、
args = foobar.__getnewargs__()
copied_foobar = Foobar(*args)
として復元できるように__getnewargs__を定義しておかなければなりません3。
タプルはもともとこのメソッドを持っており、それはタプル用に調整されています。つまり、オーバーライドしなければ、このメソッドは((self.xyzzy,),)を返します。これはtuple(*args)として与えるなら正しいですが、いまはコンストラクタをオーバーライドしていますから、これでは正しく復元できません。そのため、コンストラクタの引数の形式に合わせて__getnewargs__をオーバーライドしなおさなければならないわけです。
名前付きタプルやタプル継承方式の弱点は、結局のところFoobarがタプルになってしまうことです。
タプルであるということはタプルのメソッドがそのままになってしまっています。for e in foobar: ...やfoobar[0]やfoobar + (1, 2, 3)などが有効な式になるため、ちょっと不自然です。
不自然なだけならいいのですが、これらと同様の構文を自作クラスに実装したいときに、気を配らないとリスコフ置換原則違反でタイプチェッカに怒られまくるため、タプルを使用してまで真のイミュータブルが欲しいか否かはよく検討した方がよいでしょう。
-
人によっては
objectを明示的に指定する代わりにsuper()を使用することもあります。今回の__setattr__のような決め打ち用途においてはMROとか気にしなくていいので自分はこっちを好んでいますが。 ↩ -
こういう「書いていないけど暗黙に」で例外を作るのキショいからやめてほしい、という信仰が私の中にある。 ↩
-
コンストラクタがキーワード専用引数を持っている場合では
*argsだけでは対応できません。そのため、__getnewargs_ex__という特殊メソッドも用意されています。これはargs, kwargs = foobar.__getnewargs_ex__()としてFoobar(*args, **kwargs)として復元できるように定義されるべきです。ただし、__getnewargs_ex__を定義すると__getnewargs__の方は呼ばれなくなります。ピクルバージョンの後方互換性のため、__getnewargs__で済むならそちらを定義すべきです。 ↩