こんにちは。いろいろ開発していると、システムに用いる定数を管理したいことがあります。ファイルに保存したりデータベースで管理したりすることも多いかと思いますが、今回は標準モジュール enum を使って Python ファイル内で Python から呼びやすい形で管理する方法を考えます。
まず enum については公式の説明が詳しいので、初見の方はぜひ読んでみてください↓(正直公式ドキュメントを読むだけでいろいろな使い方ができるので、この記事の存在意義は微妙かもしれません(小声))
docs.python.org
定数管理のベースとなるのは、公式ドキュメントでも紹介されている Planet の使い方です。
class Planet(Enum): MERCURY = (3.303e+23, 2.4397e6) VENUS = (4.869e+24, 6.0518e6) EARTH = (5.976e+24, 6.37814e6) MARS = (6.421e+23, 3.3972e6) JUPITER = (1.9e+27, 7.1492e7) SATURN = (5.688e+26, 6.0268e7) URANUS = (8.686e+25, 2.5559e7) NEPTUNE = (1.024e+26, 2.4746e7) def __init__(self, mass, radius): self.mass = mass # in kilograms self.radius = radius # in meters @property def surface_gravity(self): # universal gravitational constant (m3 kg-1 s-2) G = 6.67300E-11 return G * self.mass / (self.radius * self.radius) if __name__ == "__main__": print(Planet.EARTH.value) # (5.976e+24, 6378140.0) print(Planet.EARTH.surface_gravity) # 9.802652743337129
このクラスが持つ特徴はこんな感じです:
- "Planet" という名前のもとに集めることで集まりに意味を持たせられる
- 各惑星の特徴付ける定数を値に持たせている
- 各惑星ごとに値を得るためのメソッドを用意している
さらに、enum の値 (右辺の値) は後から変更することが禁止されています:
Planet.EARTH = (1.3, 2.5) # SyntaxError Planet.EARTH.value = (1.3, 2.5) # SyntaxError
あとから enum の要素を増やすこともできません。強制的に一箇所でまとめて定義することになります。
これらの特徴からも、enum は定数管理にぴったりですね。
Planet を真似て、架空の友人たちの誕生日を管理するクラスを作ってみます。
from datetime import date from enum import Enum class Birthday(Enum): JOHN = date(1995, 10, 12) MIKE = date(1992, 5, 18) ALICE = date(1985, 2, 2) @classmethod def from_name(cls, name: str) -> date: return getattr(cls, name.upper()).value if __name__ == "__main__": print(Birthday.JOHN.value) # 1995-10-12 print(Birthday.from_name("Alice")) # 1985-02-02
ここではクラスメソッド "from_name" を実装して、必ずしも大文字でなくても呼び出せるようにしてみました。
より多くの定数を同時に管理することも可能です。例として性別(これも enum 管理!)も要素に含めるようにしてみましょう。クラス名もやや一般的に「Friends」にしてみます。
from datetime import date from enum import Enum class Sex(Enum): M = "Male" F = "Female" class Friends(Enum): JOHN = (Sex.M, date(1995, 10, 12)) MIKE = (Sex.M, date(1992, 5, 18)) ALICE = (Sex.F, date(1985, 2, 2)) def __init__(self, sex: Sex, birthday: date): self.sex = sex self.birthday = birthday @classmethod def sex_of(cls, name: str) -> Sex: return getattr(cls, name.upper()).sex # 別の書き方↓ # return cls[name.upper()].sex @classmethod def birthday_of(cls, name: str) -> date: return getattr(cls, name.upper()).birthday # 別の書き方↓ # return cls[name.upper()].birthday if __name__ == "__main__": print(Friends.JOHN.sex) # Sex.M print(Friends.MIKE.birthday) # 1992-05-18 print(Friends.sex_of("Alice")) # Sex.F print(Friends.birthday_of("John")) # 1995-10-12
例えば上のクラスを定義したファイル const.py をどこかにおいておいて、別ファイルで以下のような使い方もできます。
from datetime import date from const import Friends if __name__ == "__main__": today = date(2021, 10, 12) for friend in Friends: name = friend.name # "JOHN" birthday = friend.birthday if (today.month, today.day) == (birthday.month, birthday.day): print(f"Happy Birthday, {name.capitalize()}!") # Happy Birthday, John!
John の誕生日が知りたいときは以下のように書けます:
from const import Friends if __name__ == "__main__": print(Friends.JOHN.birthday) # 1995-10-12 print(Friends["JOHN"].birthday) # 1995-10-12 print(Friends.birthday_of("John")) # 1995-10-12
ところで・・・John のあだ名で呼び出したい場合もあるかもしれませんが、今のままではエラーになります*1:
from datetime import date from const import Friends if __name__ == "__main__": print(Friends.birthday_of("Johnny")) # AttributeError: JOHNNY
コードの中で定義していないので当然の結果といえば当然の結果です。
この書き方をできるようにするために、alias=別名を設定できるようにしてみましょう。具体的には、enum を定義するときの値に alias を含めておき、birthday_of や sex_of を呼び出すときにその中から検索するようにします:
from datetime import date from enum import Enum class Sex(Enum): M = "Male" F = "Female" class Friends(Enum): JOHN = ({"JOHNNY", "WALKER"}, Sex.M, date(1995, 10, 12)) MIKE = ({"MICKY"}, Sex.M, date(1992, 5, 18)) ALICE = ({"PRINCESS", "WHITE"}, Sex.F, date(1985, 2, 2)) def __init__(self, aliases, sex: Sex, birthday: date): self.aliases = aliases self.sex = sex self.birthday = birthday @classmethod def sex_of(cls, name: str) -> Sex: try: return getattr(cls, name.upper()).sex except AttributeError as e: # 名前がなければ alias を検索 for friend in cls: if name.upper() in friend.aliases: return friend.sex raise e # 見つからなければそのまま raise @classmethod def birthday_of(cls, name: str) -> date: try: return getattr(cls, name.upper()).birthday except AttributeError as e: # 名前がなければ alias を検索 for friend in cls: if name.upper() in friend.aliases: return friend.birthday raise e # 見つからなければそのまま raise if __name__ == "__main__": print(Friends.birthday_of("Johnny")) # 1995-10-12 print(Friends.birthday_of("Walker")) # 1995-10-12 print(Friends.birthday_of("Princess")) # 1985-02-02
処理が似ている部分が出てきたので、関数でまとめてしまいましょう。また「名前を探してなかったら別名から探す」というのが二度手間なので、全部 alias に入れてしまう実装に直してみます。
from datetime import date from enum import Enum class Sex(Enum): M = "Male" F = "Female" class Friends(Enum): JOHN = ({"JOHN", "JOHNNY", "WALKER"}, Sex.M, date(1995, 10, 12)) MIKE = ({"MIKE", "MICKY"}, Sex.M, date(1992, 5, 18)) ALICE = ({"ALICE", "PRINCESS", "WHITE"}, Sex.F, date(1985, 2, 2)) def __init__(self, aliases, sex: Sex, birthday: date): self.aliases = aliases self.sex = sex self.birthday = birthday @classmethod def alias_to_member(cls, name: str): for friend in cls: if name.upper() in friend.aliases: return friend raise KeyError(name.upper()) @classmethod def sex_of(cls, name: str) -> Sex: return cls.alias_to_member(name).sex @classmethod def birthday_of(cls, name: str) -> date: return cls.alias_to_member(name).birthday if __name__ == "__main__": print(Friends.birthday_of("Johnny")) # 1995-10-12 print(Friends.birthday_of("Walker")) # 1995-10-12 print(Friends.birthday_of("Princess")) # 1985-02-02 print(Friends.birthday_of("Henry")) # KeyError: 'HENRY'
かなり利便性が出てきました。この書き方は汎用性がありそうなので、もうすこし一般化したクラス "AliasEnum" を作っておいて、使い回せるようにしておきます。
from enum import Enum from typing import Iterable # *** 一般的なクラス *** class FrozenValueDict(dict): """値の上書き禁止辞書""" def __setitem__(self, key, value): if key in self.keys(): raise ValueError(f"key '{key}' already exists") super().__setitem__(key, value) class AliasEnum(Enum): def __new__(cls, aliases: Iterable[str], value): obj = object.__new__(cls) obj._value_ = value if "_alias2member_map_" not in cls.__dict__.keys(): cls._alias2member_map_ = FrozenValueDict() for alias in aliases: try: cls._alias2member_map_[alias] = obj except ValueError: raise ValueError(f"duplicated alias '{alias}'") return obj @classmethod def alias_to_member(cls, alias: str): filtered_alias = cls._alias_filter(alias) if filtered_alias in cls._alias2member_map_: return cls._alias2member_map_[filtered_alias] else: raise KeyError(filtered_alias) @classmethod def alias_to_value(cls, alias: str): filtered_alias = cls._alias_filter(alias) if filtered_alias in cls._alias2member_map_: return cls._alias2member_map_[filtered_alias].value else: return cls._default_value(alias) @classmethod def _alias_filter(cls, alias: str): """overwrite it in sub-class if necessary""" return alias @classmethod def _default_value(cls, alias: str): """overwrite it in sub-class if necessary""" filtered_alias = cls._alias_filter(alias) raise KeyError(filtered_alias)
このクラスの特徴はこんな感じです↓
- 内部に _alias2member_map_ を持っておき、alias に該当するものが来たら Enum オブジェクトを返すように定義しておく(__new__ 内に記述)
- _alias2member_map_ 作成の際に alias が重複したら raise ValueError する(__new__ 内に記述, FrozenValueDict を利用)
- value は単一の値に固定。その後の扱いは子クラスでよしなに定義する(複数値を取りたい場合は NamedTuple を使うとか)
- Enum オブジェクトの .value のところは value として与えた部分のみを持つようにする
- value を取得する際、alias が無いときの既定値を設定する方法を提供する(子クラスで _default_value をオーバーライド)
- alias の登録や使い方のルールを自分で決められるようにする。例えば大文字のものを alias として登録し、検索する差異には大文字に変換してから探しに行くようにするためには、_default_value メソッドを子クラスで x -> x.upper() という関数でオーバーライドする。
このクラスを例えば mylib.py に入れておき、import した上で継承して先ほどの Friends クラスを定義してみると、↓のようになります。
from enum import Enum from typing import NamedTuple from mylib import AliasEnum # *** 具体的なクラス *** class Sex(Enum): M = "Male" F = "Female" class FriendValue(NamedTuple): sex: Sex birthday: date class Friends(AliasEnum): JOHN = ({"JOHN", "JOHNNY", "WALKER"}, FriendValue(Sex.M, date(1995, 10, 12))) MIKE = ({"MIKE", "MICKY"}, # "JOHN" を alias に足すと ValueError FriendValue(Sex.M, date(1992, 5, 18))) ALICE = ({"ALICE", "PRINCESS", "WHITE"}, FriendValue(Sex.F, date(1985, 2, 2))) def __init__(self, _, value): self.sex = value.sex self.birthday = value.birthday @classmethod def _alias_filter(cls, alias: str): return alias.upper() @classmethod def sex_of(cls, name: str): return cls.alias_to_value(name).sex @classmethod def birthday_of(cls, name: str): return cls.alias_to_value(name).birthday
_alias_filter を設定することで、alias は必ず大文字という制約を設定しています。
このクラスを const.py に入れておけば、以下のような使い方ができます。
from datetime import date from const import Friends if __name__ == "__main__": print(Friends.alias_to_member("John").value) # FriendValue(sex=<Sex.M: 'Male'>, birthday=datetime.date(1995, 10, 12)) print(Friends.birthday_of("John")) # 1995-10-12 print(Friends.birthday_of("Micky")) # 1992-05-18 print(Friends.birthday_of("Princess")) # 1985-02-02 today = date(2021, 10, 12) for friend in Friends: birthday = friend.birthday if (birthday.month, birthday.day) == (today.month, today.day): print(f"Happy Birthday, {friend.name.capitalize()}!") # John
AliasEnum は結構汎用性が高そうなので、いろいろ使ってみてください。
追記(2021/10/11):
AliasEnum の機能をいろいろ整備して doc-string も適当に付けてみたものを github で公開しました↓
github.com
*1:birthday_of を「別の書き方」でリターンしている場合は KeyError が出ます。