抽象化について ~ドラクエを添えて~

published
2024-08-14

初めに

抽象化について、社内でゲームに例えて説明した内容を備忘録を兼ねてまとめます。
(説明したときと少し違いますが...)

ちなみに、以前(3年ほど前...)もドラクエでこの手の記事を書いたことがありますが、今回はもう少し抽象化までの流れも含めて、わかりやすく書ければなぁと思ってはいます。(思ってはいます!!!)

以前の記事はこちら

環境

  • python 3.11.4
  • mypy 1.11.1

本編

今回は、ドラクエで宿屋に泊まって全回復をするをお題に抽象化について考えていきたいと思います。
登場するのは、其々の役職キャラ(Hero, Knight, Priest, Brave)と宿屋(Inn)のみです。
また、其々の役職ごとに最大のhpとmpが決まっていることとします。

全回復の仕様としては、Brave以外は現状のhp,mpを最大のhp,mpに更新するとします。Braveは上記に加え全回復時に自分の力を+1することとします。
Innは、宿屋に泊まった役職キャラの全回復をする責務があるとします。

また、抽象化の恩恵がよりわかりやすいように型ヒントも記述して、mypyでチェックしていこうと思います。

mypy is 何?はこちらを参照ください。

1. 一人旅

まずは主人公の一人旅で宿屋に泊まるケースを想定して、コードを書いていきます。

MAX_HP = 100 MAX_MP = 100 class Hero: def __init__(self, hp: int, mp: int) -> None: self.hp = hp self.mp = mp class Inn: def stay(self, object: Hero) -> None: object.hp = MAX_HP object.mp = MAX_MP def main() -> None: hero = Hero(50, 50) inn = Inn() inn.stay(hero) assert hero.hp == 100 print("successful!") if __name__ == "__main__": main()

上記を実行し正しく動作するかみてみましょう

$ python main.py successful!

良いですね。
上記の型チェックもmypyで行ってみます。より厳密にチェックを行いたいので、今回は--strictの引数を付与します。

--strictについてと、他の引数についてはこちらを参照ください。

$ mypy main.py --strict Success: no issues found in 1 source file

良いですね。型チェックもばっちりっぽいです。

さて、上記実装で一人旅の仕様は満たせます。ただし上記は本当に仕様を満たしただけのコードとなっています。
では上記のどこに問題があり、どのように修正するのかなのですが、それは次の素敵な仲間との出会いで判明します!

2. 仲間

冒険も順調に進み、旅の仲間ができました。
素敵な仲間の役職は、僧侶(Priest)騎士(Knight)です。この二人も宿に泊まるときに全回復をします。
ただし、役職ごとに全回復時の最大hp,mpは異なるので、その仕様を加味して既存コードに修正を加えます。

from typing import List HERO_MAX_HP = 100 HERO_MAX_MP = 100 KNIGHT_MAX_HP = 150 KNIGHT_MAX_MP = 150 PRIEST_MAX_HP = 80 PRIEST_MAX_MP = 80 class Hero: def __init__(self, hp: int, mp: int, strength: int) -> None: self.hp = hp self.mp = mp self.strength = strength class Knight: def __init__(self, hp: int, mp: int, strength: int) -> None: self.hp = hp self.mp = mp self.strength = strength class Priest: def __init__(self, hp: int, mp: int, strength: int) -> None: self.hp = hp self.mp = mp self.strength = strength class Inn: def stay(self, objects: List[Hero | Knight | Priest]) -> None: for object in objects: if isinstance(object, Hero): object.hp = HERO_MAX_HP object.mp = HERO_MAX_MP if isinstance(object, Knight): object.hp = KNIGHT_MAX_HP object.mp = KNIGHT_MAX_MP if isinstance(object, Priest): object.hp = PRIEST_MAX_HP object.mp = PRIEST_MAX_MP def main() -> None: hero = Hero(50, 50, 50) knight = Knight(120, 120, 80) priest = Priest(120, 120, 80) inn = Inn() inn.stay([hero, knight, priest]) assert hero.hp == 100 assert knight.hp == 150 assert priest.hp == 80 print("successful!") if __name__ == "__main__": main()

では、上記を実行して正しく動くか確認します。

$ python main.py successful!

正しく動いているようです。
ただし、上記では今回のように役職が増えた際に、以下を修正しなけらばなりません

  • stayの引数の型定義
  • stayの分岐処理
  • 役職クラスの追加

役職を追加するたびに、上記のstay関数(今回の関心外)に修正が入るのはあまりよろしくはないですね...
これは、stay関数が抽象ではなく、具象に依存している為です。

ここで、抽象化の登場です。抽象化をすることにより、このstay関数を抽象に依存させることができます。

以下のように変更することで、stay関数を抽象に依存させることができます。

from typing import List from abc import ABC, abstractmethod class PlayableCharacter(ABC): def __init__(self, hp: int, mp: int, strength: int) -> None: self.hp = hp self.mp = mp self.strength = strength @property @abstractmethod def MAX_HP(self) -> int: pass @property @abstractmethod def MAX_MP(self) -> int: pass class Hero(PlayableCharacter): MAX_HP = 100 MAX_MP = 100 class Knight(PlayableCharacter): MAX_HP = 150 MAX_MP = 150 class Priest(PlayableCharacter): MAX_HP = 80 MAX_MP = 80 class Inn: def stay(self, objects: List[PlayableCharacter]) -> None: for object in objects: object.hp = object.MAX_HP object.mp = object.MAX_MP def main() -> None: hero = Hero(50, 50, 50) knight = Knight(120, 120, 80) priest = Priest(120, 120, 80) inn = Inn() inn.stay([hero, knight, priest, brave]) assert hero.hp == 100 assert knight.hp == 150 assert priest.hp == 80 print("successful!") if __name__ == "__main__": main()

良いですね。見た目もすっきりしたかと思います。

上記のように、具象ではなく抽象に依存させることを、「依存性逆転の原則」といいます。

また、上記のように、既存コードに修正を加えずに、機能拡張を行えるようにすることをオープン・クローズドの原則といいます。

では最後に勇者クラスを追加したいと思います。

3. 勇者

旅も終盤、伝説の勇者なるものがパーティに加わってくれました。
勇者も他の役職同様、全回復をすることでhp,mpを最大まで回復します。

ただし、勇者は仕様上全回復を行った際に力が+1される特性を持っています。しかし現状のコードはそれに対応できる実装ではありません。
これは宿屋が責務外の事柄に関与してしまっていることが原因です。

そもそも宿屋の責務は宿屋に泊まった方の全回復を行うことです。つまり、其々の役職キャラを全回復する場所は提供しますが、具体的な全回復に関しては関与しません。
しかし、現状のコードはhpやmpの値を直接操作することで、役職キャラの内部状態(具体的には回復の仕組み)に関与する形になっています。本来、内部状態の管理はPlayableCharacterまたはそのサブクラスが担当すべき責務です。

そのため、今回ではPlayableCharacterクラスに全回復を行う振る舞いを追加し、勇者ではその振る舞いをoverrideするのが適切です。
宿屋はその全回復を行う振る舞いを呼び出すだけにします。

上記のように、其々の関心ごとで適切に分離することを関心の分離(責務の分離の方が好き)と言います。

また、役職側のクラスが適切なカプセル化が行われていないことも上記を引き起こす原因となります。今回ではhpやmpの値を直接操作できてしまうことが上記を引き起こす原因です。そのためこのインスタンス変数・クラス変数をprivate変数として宣言します。
(pythonでは厳密なprivate変数はなく、宣言だけになりますが...)

また、よくカプセル化とともにgetter/setterがありますが、使用する箇所がassertくらいなので、今回は省略します。(個人的にgetter/setterを用意するのは好みではないのもありますが...)

もし、pythonでのgetter, setterについて気になる方はドキュメントを参照ください。

カプセル化について詳しくはこちらを参照ください。

長くなりましたが、最後に既存コードに修正を加えて終わります。

from typing import List from abc import ABC, abstractmethod class PlayableCharacter(ABC): def __init__(self, hp: int, mp: int, strength: int) -> None: self._hp = hp self._mp = mp self._strength = strength @property @abstractmethod def _MAX_HP(self) -> int: pass @property @abstractmethod def _MAX_MP(self) -> int: pass def full_healing(self) -> None: self._hp = self._MAX_HP self._mp = self._MAX_MP class Hero(PlayableCharacter): _MAX_HP = 100 _MAX_MP = 100 class Knight(PlayableCharacter): _MAX_HP = 150 _MAX_MP = 150 class Priest(PlayableCharacter): _MAX_HP = 80 _MAX_MP = 80 class Brave(PlayableCharacter): _MAX_HP = 150 _MAX_MP = 150 def full_healing(self) -> None: self._strength += 1 super().full_healing() class Inn: def stay(self, objects: List[PlayableCharacter]) -> None: for object in objects: object.full_healing() def main() -> None: hero = Hero(50, 50, 50) knight = Knight(120, 120, 80) priest = Priest(120, 120, 80) brave = Brave(120, 120, 80) inn = Inn() inn.stay([hero, knight, priest, brave]) assert hero._hp == 100 assert knight._hp == 150 assert priest._hp == 80 assert brave._hp == 150 assert brave._strength == 81 print("successful!") if __name__ == "__main__": main()

まとめ

このような抽象化を進めることで、さらに多くの役職や新しい機能が追加されても、コード全体の修正はできる限り抑えることができます。
また、抽象化だけでなく、それを応用したアーキテクチャデザインパターンを駆使することで、より保守性の高いコードを実現可能です。(アーキテクチャ詳しくなりたいマン)

不正確、また間違っている箇所等ございましたらXにてご連絡いただけると助かります!
(コメント機能実装シヨカナ)

ぼやき

ここまでつらつらと抽象化について説明し、抽象化はいいぞぉ〜感を出していますが、個人的には具象化されたままで良いケースが多々あると思っています。
特に、DRY原則やOAOO原則と言った 重複したコードを排除する目的で行った誤った抽象化をしてしまうのであれば、具象クラスがそのまま書いてあった方がメンテ等はしやすいと思います。
またもし正しい抽象化を行なったとしても、その抽象化した部分が修正されないのであれば、結果具象化されたままでよかったことになります。(抽象化するって結構コストかかりますし...)

まとめると無理に抽象化しなくていいよって個人的には思っています。

ちなみに、上記は以下記事で紹介されている早すぎる抽象化に似通った部分があるので、併せて読んでいただけると良いかもです。