18
20

More than 3 years have passed since last update.

Pythonで定数を宣言したい(n+1番煎じ)

Last updated at Posted at 2021-03-18

いきさつのいきさつ

先日、この記事を書いたが、バグがある上に殴り書き過ぎて、自分がこの記事見たときに「は?」となる気しかしなかったので、書き直しなおしました。

いきさつ

C言語やJava言語、Javascript言語など、さまざまな言語で定数(再代入不可変数)が言語仕様として用意されているが、Python言語にはない。
ネットで調べてみると、Python Receipeの方法がよく出てくる。

Python Receipeの方法

まず、定数を置いておく専用のモジュールを定義する。

const.py
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()

そして、インポートしたモジュールに対して定数を代入する。

recipe_sample.py
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モジュールに定義することになってしまう。
    • 本来内部ネットワーク向けの設定を参照すべきところを、誤って外部ネットワーク向けの設定を参照してしまい、「内部に送信するつもりが外部にデータ送っちゃった☆」とか目も当てられない。

つまり何が言いたいかというと

定数を扱うにも、アクセス制限とか、追加で勝手に定数を追加されないような、そんなモジュールが必要だよねってこと。

もうちょっと具体的な要求に落とし込んでみる

では、定数を扱うモジュールに必要な要求・あったらいい要求は何だろうかと考えたとき、下記のような要求が思いついた。

  1. 再代入不可の変数を持つ
  2. アクセス制御ができる
  3. あとから定数を追加することができない

これらに関して、下記の方針で作ってみる。

  1. 再代入不可の変数を持つ
    • __setattr__関数をオーバーライドして代入できないようにする
  2. アクセス制御ができる
    • クラス変数に定数を格納し、クラスの継承関係でアクセス制御を実現する
  3. あとから定数を追加することができない
    • 定数のクラス宣言時のみ定数の宣言が可能とし、それ以外は代入禁止にする

追加の問題

ここまでで、定数をクラス変数に格納する方法を検討しているが、ここで一つ問題がある。
それはインスタンス変数によるクラス変数の隠匿である。
例えば、同じ変数名で、インスタンス変数とクラス変数が両方存在した場合、通常インスタンス変数のほうが優先的に参照されてしまう。
しかし、これでは定数クラスに求められる「再代入不可」という制約に怪しさが出てくる。また、もし再代入不可であったとしても、インスタンス変数とクラス変数が両方存在するというのは定数の観点から好ましくない。
そのように考えたとき、「あ、じゃあインスタンスを作れないようなクラスにすればいいんじゃないか」と思いついた。

メタクラスの利用

クラス変数に定数を格納するとして、インスタンスを作られたくない。
と考えたとき、真っ先に思いつくのは「__init__関数を呼び出されたらエラーにすればいいんじゃないか」ということである。
しかし、これではまだ不十分だった。
なぜなら、継承時に__init__関数がオーバーライドすることで、結果インスタンスの生成ができてしまうからだ。
ではどうするのかと考えたときに、メタクラスの利用をふと思いついた。
メタクラスとは簡単に言うと「クラスのクラス」のようなもので、メタクラスのインスタンス=クラス と思ってもらえればよいと思う。

また、クラスの継承関係でアクセス制限をしたいと考えたときに、親クラスの定数を子クラスが再定義してしまうことが考えられる。
これに関しても、クラスの定義に失敗するべきである。
このような動作を実装するにも、メタクラスは非常に便利である。
メタクラスってクラス定義が正しく行われているかの確認できるんですね(最近知った)。

実際に作ってみた

では、実際に出来上がったコードをご覧ください。

constant.py
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

具体的な使い方はこんな感じ。

constant_sample.py
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_attris_settabe_attr関数をオーバーライドすればよい。
例として、「定数は"_CONST_"から始まるものだけ」かつ「代入可能なクラス変数は"_VAR_"から始まるものだけ」という定数クラスを宣言してみる。

ExpConstant.py
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を返せば、完全に再代入禁止にできる)

もうちょっとだけこだわったこと

メタクラスの継承によって、任意のルールに基づく定数クラスを作れるようになったが、実はさらに問題が発生している。
それは、「ルールの異なる定数クラス同士の継承ができてしまう」ということである。
具体的な例を考えてみるとわかりやすいので、下図を見てみてほしい。
複数メタクラスの継承例.png
上記図において、クラス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が定義できたとは考えられない。定義しようとした段階で、失敗すべきである。この問題の回避のため、今回のコードでは、異なるメタクラスを持つ定数クラスを複数同時に継承できないようにしている。

終わりに

結局内容として読み返しても「は?」としかならなさそうではあるが、それでも最初の記事よりましになったと考えることにする。

もし、「こういう考え方もしないとダメでは?」とか「こうしたい場合はどうすればいい?」などありましたら、コメントしていただけるとむせび泣きます。
以上。

参考にさせていただいた記事

18
20
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
18
20