いきさつのいきさつ
先日、この記事を書いたが、バグがある上に殴り書き過ぎて、自分がこの記事見たときに「は?」となる気しかしなかったので、書き直しなおしました。
いきさつ
C言語やJava言語、Javascript言語など、さまざまな言語で定数(再代入不可変数)が言語仕様として用意されているが、Python言語にはない。
ネットで調べてみると、Python Receipeの方法がよく出てくる。
Python Receipeの方法
まず、定数を置いておく専用のモジュールを定義する。
class _consttype:
class _ConstTypeError(TypeError):
pass
def __repr__(self):
return "Constant type definitions."
def __setattr__(self, name, value):
v = self.__dict__.get(name, value)
if type(v) is not type(value):
raise self._ConstTypeError(f"Can't rebind {type(v)} to {type(value)}")
self.__dict__[name] = value
def __del__(self):
self.__dict__.clear()
import sys
sys.modules[__name__] = _consttype()
そして、インポートしたモジュールに対して定数を代入する。
import const
const.FOO = "FOO"
print(const.FOO) # ==> FOO
# 再代入はできない
const.FOO = "BAR" # ==> _ConstTypeError
上記方法は、確かに再代入ができない。
Python Receipeの問題点
一見Python Receipeの方法で十分のように思える。
しかし、この方法には「呼び出しのタイミングに依存する」という点や「新しく変なデータ入れられたくない」、「constモジュールがグローバルにただ一つ存在して、アクセス制御ができない」といった問題が出てくる。
- 呼び出しのタイミングに依存する
- 上記の例では
FOO
という名前で定数を新規に代入しているが、もし別のコードからすでにFOO
という名前で定数を宣言していた場合、このコードはエラーを出す。 - 当然、定数を定義するコードを決めておけばいいが、正直、そんなん人間が気にしなきゃいけないというのはエレガントじゃない。
- 上記の例では
- 新しく変なデータ入れられたくない
- すでに
FOO
という定数が存在する状態で、const.FOOO = "FOOO"
とすると、格納できでしまう。しかし、これではもともと定義していたFOO
と名前が似通ってしまっていて、コード補完の時にイライラする。 - それ以上に、「これ便利じゃん」と、本来定数にすべきでない変数まで、constモジュールに追加で登録する人が出てくる危険性がある。おお恐ろしや。
- すでに
- constモジュールがグローバルにただ一つ存在し、アクセス制御ができない
- 通常、定数はグローバル変数として扱っても問題なさそうに思える。しかし、「外部ネットワーク向けの設定」と「内部ネットワーク向けの設定」があったときに、この例ではこれらをまとめてconstモジュールに定義することになってしまう。
- 本来内部ネットワーク向けの設定を参照すべきところを、誤って外部ネットワーク向けの設定を参照してしまい、「内部に送信するつもりが外部にデータ送っちゃった☆」とか目も当てられない。
つまり何が言いたいかというと
定数を扱うにも、アクセス制限とか、追加で勝手に定数を追加されないような、そんなモジュールが必要だよねってこと。
もうちょっと具体的な要求に落とし込んでみる
では、定数を扱うモジュールに必要な要求・あったらいい要求は何だろうかと考えたとき、下記のような要求が思いついた。
- 再代入不可の変数を持つ
- アクセス制御ができる
- あとから定数を追加することができない
これらに関して、下記の方針で作ってみる。
- 再代入不可の変数を持つ
-
__setattr__
関数をオーバーライドして代入できないようにする
-
- アクセス制御ができる
- クラス変数に定数を格納し、クラスの継承関係でアクセス制御を実現する
- あとから定数を追加することができない
- 定数のクラス宣言時のみ定数の宣言が可能とし、それ以外は代入禁止にする
追加の問題
ここまでで、定数をクラス変数に格納する方法を検討しているが、ここで一つ問題がある。
それはインスタンス変数によるクラス変数の隠匿である。
例えば、同じ変数名で、インスタンス変数とクラス変数が両方存在した場合、通常インスタンス変数のほうが優先的に参照されてしまう。
しかし、これでは定数クラスに求められる「再代入不可」という制約に怪しさが出てくる。また、もし再代入不可であったとしても、インスタンス変数とクラス変数が両方存在するというのは定数の観点から好ましくない。
そのように考えたとき、「あ、じゃあインスタンスを作れないようなクラスにすればいいんじゃないか」と思いついた。
メタクラスの利用
クラス変数に定数を格納するとして、インスタンスを作られたくない。
と考えたとき、真っ先に思いつくのは「__init__
関数を呼び出されたらエラーにすればいいんじゃないか」ということである。
しかし、これではまだ不十分だった。
なぜなら、継承時に__init__
関数がオーバーライドすることで、結果インスタンスの生成ができてしまうからだ。
ではどうするのかと考えたときに、メタクラスの利用をふと思いついた。
メタクラスとは簡単に言うと「クラスのクラス」のようなもので、メタクラスのインスタンス=クラス と思ってもらえればよいと思う。
また、クラスの継承関係でアクセス制限をしたいと考えたときに、親クラスの定数を子クラスが再定義してしまうことが考えられる。
これに関しても、クラスの定義に失敗するべきである。
このような動作を実装するにも、メタクラスは非常に便利である。
メタクラスってクラス定義が正しく行われているかの確認できるんですね(最近知った)。
実際に作ってみた
では、実際に出来上がったコードをご覧ください。
class ConstantError(Exception):
"""Constantクラスの例外"""
pass
class ConstantMeta(type):
"""Constantクラスのメタクラス"""
def __new__(mcls, classname, bases, dict):
# 異なるConstantMetaを継承していないか検証する
sub_clses = [cls for cls in bases if isinstance(cls, ConstantMeta)]
for sub_cls in sub_clses[1:]:
if sub_clses[0] != sub_cls:
raise ConstantError(f"Can't inhelitance of [{sub_clses[0].__name__}] and [{sub_cls.__name__}] together")
# 親クラス同士で定数の衝突が起こっていないか確認
super_consts = set()
for base in bases:
base_consts = ConstantMeta.__get_constant_attr(mcls, base.__dict__)
collisions = (super_consts & base_consts)
if collisions:
collis_str = ", ".join(collisions)
raise ConstantError(f"Collision the constant [{collis_str}]")
super_consts |= base_consts
# 定義するクラスで定数の再定義をしていないか確認
new_consts = ConstantMeta.__get_constant_attr(mcls, dict)
rebinds = (super_consts & new_consts)
if rebinds:
rebinds_str = ", ".join(rebinds)
raise ConstantError(f"Can't rebind constant [{rebinds_str}]")
# __init__関数置き換えてインスタンス生成を禁止する
def _meta__init__(self, *args, **kwargs):
# インスタンスの生成をしようとした際、ConstantErrorを送出する。
raise ConstantError("Can't make instance of Constant class")
# __init__関数を置き換えてインスタンス生成を禁止する。
dict["__init__"] = _meta__init__
return type.__new__(mcls, classname, bases, dict)
@staticmethod
def __get_constant_attr(mcls, dict):
"""定数として扱うアトリビュートの集合を取得する"""
# 特殊なアトリビュートを除くアトリビュートを取得する
attrs = set(
atr for atr in dict if not ConstantMeta.__is_special_func(atr)
)
# アトリビュートがすべて定数または例外的にクラス変数に格納可能な
# 変数であることを確認する
cnst_atr = set(atr for atr in attrs if mcls.is_constant_attr(atr))
var_atr = set(atr for atr in attrs if mcls.is_settable_attr(atr))
wrong_atr = attrs - (cnst_atr | var_atr)
if wrong_atr:
wrong_atr_str = ", ".join(wrong_atr)
raise ConstantError(f"Attribute [{wrong_atr_str}] were not constant or not settable.")
return cnst_atr
@staticmethod
def __is_special_func(name):
"""特殊アトリビュートかどうかを判定する"""
return name.startswith("__") and name.endswith("__")
@classmethod
def is_constant_attr(mcls, name):
"""定数として扱うアトリビュートか判定する"""
return (not name.startswith("__"))
@classmethod
def is_settable_attr(mcls, name):
"""例外的にクラス変数に格納することを許可するアトリビュートか判定する"""
return (not mcls.is_constant_attr(name))
def __setattr__(cls, name, value):
mcls = type(cls)
if mcls.is_constant_attr(name) or (not mcls.is_settable_attr(name)):
raise ConstantError(f"Can't set attribute to Constant [{name}]")
else:
super().__setattr__(name, value)
class Constant(metaclass=ConstantMeta):
"""定数クラス"""
pass
具体的な使い方はこんな感じ。
class MyConstant(Constant):
"""サンプルの定数クラス
Constantクラスを継承してつくる。
Attributes
----------
FOO : int
methods
-------
twice_of_FOO
"""
# クラス変数として定数を定義する
FOO: int = 1
# クラスメソッドも定義できる(再定義不可)
@classmethod
def twice_of_FOO(cls):
"""FOOの二倍を返す関数
Returns
-------
int : 2*MyConstant.FOO
"""
return 2*cls.FOO
# 定義した定数クラスのサブクラスも作れる
# (サブクラスから親クラスの定数も参照できる)
class MySubConstant(MyConstant):
"""サンプルの定数クラス
Attributes
----------
FOO : int (by MyConstant)
BAR : int
"""
BAR: int = 2
# 参照は通常のクラス変数と同じように参照する
print(f"MyConstant.FOO = {MyConstant.FOO}")
print(f"MyConstant.twice_of_FOO() = {MyConstant.twice_of_FOO()}")
print(f"MySubConstant.FOO = {MySubConstant.FOO}")
print(f"MySubConstant.BAR = {MySubConstant.BAR}")
# 代入はできない
MyConstant.FOO = 2 # ConstantError
# あとから追加も不可
MyConstant.BAZ = 3 # ConstantError
# ただし例外的に"__"(アンダースコア2個)から始まるものは代入・変更OK
# 用法用量を守って正しく使ってね
MyConstant.__BAZ = 3
print(f"MyConstant.__BAZ = {MyConstant.__BAZ}")
# インスタンスの生成ができない
constant_instance = MyConstant() # ConstantError
カスタマイズ
今回、メタクラスを使って、定数を管理するクラス用のメタクラスを作成したが、実は少しだけカスタマイズできるように作っている。
例えば、今回の例では、"__"(アンダースコア2個)から始まるクラス変数は、定数ではないという作りになっており、MyConstant.__BAZ
に変数が格納できるようになっている。
しかし、真に「再代入不可にすべき!」という場合や、「アンダースコア2個じゃなくて、"_CONST_"から始まるものだけ定数にしたい」ということもあるだろう(コーデイング規約による制限もあるだろうし)。
そこで、実は定数として判定する・クラス変数として代入できる変数を簡単にカスタマイズできるようにした。
方法は簡単で、ConstantMeta
クラスを継承したメタクラスを新しく作って、is_constant_attr
やis_settabe_attr
関数をオーバーライドすればよい。
例として、「定数は"_CONST_"から始まるものだけ」かつ「代入可能なクラス変数は"_VAR_"から始まるものだけ」という定数クラスを宣言してみる。
class ExpConstantMeta(ConstantMeta):
@classmethod
def is_constant_attr(mcls, name):
# _CONST_から始まるアトリビュートは定数として扱う
return name.startswith("_CONST_")
@classmethod
def is_settable_attr(mcls, name):
# _VAR_から始まるアトリビュートはクラス変数に代入可として扱う
return name.startswith("_VAR_")
class ExpConstant(metaclass=ExpConstantMeta):
pass
class MyExpConstant(ExpConstant):
_CONST_FOO = "foo" # 定数になる
_CONST_BAR = "BAR" # 定数になる
_VAR_BAZ = "baz" # 例外的に再代入可能なクラス変数になる
# __VAR_BAZ = "BAZ" # ==> ConstantError
こうすることで、任意のルールに基づく定数クラスを作ることができる。
カスタマイズによっては、完全に再代入禁止とすることもできるのである。(is_settable_attrが常にFalseを返せば、完全に再代入禁止にできる)
もうちょっとだけこだわったこと
メタクラスの継承によって、任意のルールに基づく定数クラスを作れるようになったが、実はさらに問題が発生している。
それは、「ルールの異なる定数クラス同士の継承ができてしまう」ということである。
具体的な例を考えてみるとわかりやすいので、下図を見てみてほしい。
上記図において、クラスC_Constant
では__C_FOO
が定数、__F_FOO
は定数ではない。同様に、クラスF_Constant
の__F_BAR
は定数、__C_BAR
は定数でない。
さて、ここでC_Constant
クラスとF_Constant
クラスを継承したようなFC_Constant
クラスを定義しようとしたときに、アトリビュート__C_FOO
は定数だろうか?同様に、__F_BAR
は定数だろうか?
この場合、普通に考えれば、片方のクラスで定数なのであれば、もう片方のクラスで定数でなくても、両方を継承したFC_Constant
では定数として扱う方が自然に思える。
しかし、FC_Constant
クラスから見て、新たなアトリビュート__F_BAZ
は定数であっても、C_Constant
クラスから見ると、__F_BAZ
は定数でない。よって、FC_Constant
クラスで定数として扱われるはずの__F_BAZ
が、C_Constant
クラスから書き換え可能なクラス変数として定義できてしまうのである。
これでは、定数クラスFC_Constant
が定義できたとは考えられない。定義しようとした段階で、失敗すべきである。この問題の回避のため、今回のコードでは、異なるメタクラスを持つ定数クラスを複数同時に継承できないようにしている。
終わりに
結局内容として読み返しても「は?」としかならなさそうではあるが、それでも最初の記事よりましになったと考えることにする。
もし、「こういう考え方もしないとダメでは?」とか「こうしたい場合はどうすればいい?」などありましたら、コメントしていただけるとむせび泣きます。
以上。