🚀 ニフティ’s Notion

【オブジェクト指向2024 #12】クイズ

概要

あなたは経験豊かなトレーナーです。新人が簡単なゲームのコマンドバトルっぽいものをつくろうとしています。新人が要所要所で色々とアドバイスを求めてくるので、新人の意図を汲んで適切だと思われる設計方法を教えてあげてください。

ざっくり仕様は次のとおりです。

  • プレイヤー1人と敵集団のターン制バトルです。
  • プレイヤーは2つの行動から一つを選んで実行できます。
    • 攻撃: 敵集団の一人を攻撃する
    • 回復: 自分自身を回復する
  • 敵は毎ターンプレイヤーに攻撃します。
  • 全滅させた側の勝利です。
実行イメージ
自 ゆうしゃ: HP=30
敵 スライム: HP=10
敵 ゴブリン: HP=20
----------
ゆうしゃのターン
0: 攻撃
1: 回復
どうする?> 0
----------
0: スライム HP=10
1: ゴブリン HP=20
誰に?> 0
----------
ゆうしゃの攻撃!
スライムに10のダメージ
自 ゆうしゃ: HP=30
敵 スライム: HP=0
敵 ゴブリン: HP=20
----------
ゴブリンの攻撃!
ゆうしゃに5のダメージ
自 ゆうしゃ: HP=25
敵 スライム: HP=0
敵 ゴブリン: HP=20
----------
ゆうしゃのターン
0: 攻撃
1: 回復
どうする?>

クラス

Aさん: プレイヤーを実装するんですけど、どっちのコードのほうがよさそうですかね?
🧑‍🎓
あなた: 今後の色々な発展を見据えて、良さそうなコードは..

A. クラスにまとめると良いよ

class Player:
    def __init__(self, name: str, hp: int, attack: int):
        self.name = name
        self.hp = hp
        self.attack = attack

Player("プレイヤー", 30, 10)

B. ベタ書きしておくといいよ

player_name = "プレイヤー"
player_hp = 30
player_attack = 10

解答例

自分なら A をおすすめします。

ゲームの主人公は、バランス調整や面白さを追加するためにこれからいろいろなステータスが追加される可能性がありそうです。例えば防御力をもたせたり回避率をもたせたりすることで、主人公の能力タイプに合わせた戦略性が生まれることもあるでしょう。そうなったときベタ書きされていると不利です。ここはクラスにまとめて高凝集にしましょう。

ただし、 (余裕がある人向け)

B という選択が絶対だめなわけでもありません。例えば、ローグのように主人公が一人と決まっているとします。ゲーム性から主人公が世界のすべてでなのであれば、すべてのステータスをグローバル変数で持つことで実装をシンプルにできるケースがあるかもしれません。かもです。かも。

image block
元祖 ROGUE 引用元:

カプセル化

Aさん: 試しにダメージ計算したら HP が負になってバグったんですが…
🧑‍🎓
あなた: よりフィールドアクセスを安全にするには…

A. 間違えないように気をつけると良いよ (コード変更なし)

B. セッターで異常な状態にならないように守るよ

class Player:
    def __init__(self, name: str, hp: int, attack: int):
        self.__name = name
        self.__hp = hp
        self.__attack = attack

    @property
    def name(self):
        return self.__name

    @property
    def hp(self):
        return self.__hp

    @hp.setter
    def hp(self, value: int):
        # 負の数にならないようにする
        self.__hp = max(0, value)

    @property
    def attack(self):
        return self.__attack

解答例

自分なら B をおすすめします。

基本的に自分ほど信じられないものはないので、「気をつける」という言葉が出てきた瞬間、気をつけなくていい方法を考えにいくといいと思います。これは日常の業務でもそうです。〇〇しないように気をつける、ではなく、〇〇できないようなしくみにするということです。カプセル化は一つの方法ですし、他にも型システムだったり、いろいろな仕組みがあります。

ただし、 (余裕がある人向け)

A がいつでもだめなわけでもありません。ご覧の通り、B はコード量が多くなりますし、変数を読み出したように見えて実は裏で関数が実行されているというのも曲者です。普通の変数であれば起きないような「不可解な」現象を起こしてしまうこともあります。例えば以下のような計算は普通の変数であれば順番を入れ替えても (なんなら丸ごと消し去っても) 同じですが、セッターが絡むと違うかもしれません。

self.hp -= amount
self.hp += amount
# 最初に self.hp < amount のとき、setter が 0 に丸めるせいで上の順番を入れ替えると違う結果になる

継承

Aさん: 敵を実装したいです。とりあえずスライムとゴブリンを例として追加したいですが、これから先増やすかもしれません。
🧑‍🎓
あなた: そうだね。今後追加する可能性も考えるなら…

A. 完全に別々のクラスを作ると良いよ

class EnemySlime:
    def __init__(self, hp: int, attack: int):
        self.name = "スライム"
        self.hp = hp
        self.attack = attack


class EnemyGoblin:
    def __init__(self, hp: int, attack: int):
        self.name = "ゴブリン"
        self.hp = hp
        self.attack = attack

B. 戦闘で行動をするエンティティを継承で共通化するといいよ

class Entity:
    def __init__(
        self,
        is_enemy: bool,
        name: str,
        hp: int,
        attack: int
    ):
        self.__is_enemy = is_enemy
        self.__name = name
        self.__hp = hp
        self.__attack = attack

    # ...カプセル化のためのセッターとゲッターが並ぶ...


class EnemySlime(Entity):
    def __init__(self):
        super().__init__(True, "スライム", 10, 1)


class EnemyGoblin(Entity):
    def __init__(self):
        super().__init__(True, "ゴブリン", 20, 5)

解答例

自分なら B をおすすめしますね。今後エンティティに共通の処理が増えたときも、B であれば親クラスを修正すればすべて完了します。なんなら Player も継承で整理できますね。このようにしてしまえば、for ループなどで処理を共通化することもできるようになります。

class Player(Entity):
    def __init__(self, name: str, hp: int, attack: int):
        super().__init__(False, name, hp, attack)
ただし、 (余裕がある人向け)

もうそろそろ何が言いたいかおわかりですね? A が絶対だめなわけでもないです。

まずコード量が A のほうがかなり少ないので、小規模なコードベースでは理解しやすくなる可能性があります。また共通化されていないので、変更に対する自由度が非常に高くなります。例えば、スライムは柔らかいので HP という概念をなくし、その代わりに 2 ターンで自然に蒸発して消滅してしまうという発想をしたとしましょう。そうすると HP の代わりに残り生存ターン数のような数値を持つことになりますが、これは Entity に縛られていると難しいです。

インターフェース

Aさん: 次は戦闘のコマンドを追加したいです。一旦は攻撃と回復だけでいいんですが、これも後からふやすかもしれません。
🧑‍🎓
あなた: ふむ。後々、技を追加したくなったときにもこまらない設計は…

A. コマンド用のインターフェースを定義して、コマンドごとに別々のクラスを実装するといいよ

class Command(ABC):
    def __init__(self, name: str):
        self.__name = name

    @property
    def name(self):
        return self.__name

    @abstractmethod
    def possible_targets(
        self,
        entities: list[Entity],
        operator: Entity
    ):
        "このコマンドの対象となりうるエンティティを列挙する"
        ...

    @abstractmethod
    def execute(
        self,
        operator: Entity,
        target: Entity
    ):
        "operatorがこのコマンドをtargetに対して実行する"
        ...


class AttackCommand(Command):
    def __init__(self):
        super().__init__("攻撃")

    @override
    def possible_targets(
        self,
        entities: list[Entity],
        operator: Entity
    ):
        # 自分と違うサイドのエンティティを攻撃可能
        return [
            e for e in entities
            if e.is_enemy != operator.is_enemy
        ]

    @override
    def execute(self, operator: Entity, target: Entity):
        print(f"{operator.name}の攻撃!")
        print(f"{target.name}{operator.attack}のダメージ")
        target.hp -= operator.attack


class HealCommand(Command):
    def __init__(self):
        super().__init__("回復")

    @override
    def possible_targets(
        self,
        entities: list[Entity],
        operator: Entity
    ):
        # 自分自身のみ回復可能
        return [operator]

    @override
    def execute(self, operator: Entity, target: Entity):
        print(f"{operator.name}の回復!")
        print(f"{target.name}のHPが10回復した")
        target.hp += 10

B. シンプルにゲームループ内部にベタ書きすると良いよ

global player_hp
print("0: 攻撃")
print("1: 回復")

command = ask_int("どうする?> ", 0, 1)
print("----------")

if command == 0:
    print("0: スライム")
    print("1: ゴブリン")
    target = ask_int("誰に?> ", 0, 1)

    print(f"{player_name}の攻撃!")
    if target == 0:
        print(f"{slime.name}{player_attack}のダメージ")
        slime.hp -= player_attack
    else:
        print(f"{goblin.name}{player_attack}のダメージ")
        goblin.hp -= player_attack
else:
    print(f"{player_name}の回復!")
    print(f"{player_name}のHPが10回復した")
    player_hp += 10

解答例

自分なら A がおすすめですね。

新しいコマンドを追加するとなってももう一つクラスを増やすだけですし、例えば今後一時的にコマンドが無効になるなどの機能拡張があっても簡単に対応できそうです。一方、B ではコマンドがベタ書きなので個数が増減するようなケースは丸ごと if で分けるしかなく、そのような機能改善は厳しいですよね。

ただし、 (余裕がある人向け)

B がだめなわけでもないです。必ず攻撃と回復の二択しかないと決まっているのであれば抽象化の必要はありません。特に、たとえば「はい」「いいえ」の 2 択のように、多分日本語が変わらない限りは変更しなくていいだろうなという選択までいちいち抽象化するのはオーバーエンジニアリングといっていいでしょう。

そして完成へ

他にもいろいろなパーツを付け加え、無事コマンドバトルが実行できるようになりました。

コード全文: https://wandbox.org/permlink/Uv5kxY494bEkuytW

  • wandbox は入力を受け付けられないので実行例は落ちていますが、気になる人は手元で実行してみてね
# ...ありとあらゆるクラスが定義された後...

players: list[Entity] = [
    Player("ゆうしゃ", 30, 10),
]
enemies: list[Entity] = [
    EnemySlime(),
    EnemyGoblin(),
]
battle = Battle(players, enemies)
battle.start()
Aさん: 先輩ありがとうございました!おかげさまで無事に動くものができました。
Aさん: (でもこのコード 206 行もあってちょっと読むのに気合がいるなあ…。本当にこんな面倒なことする必要、あったのかな?)

後日、A さんは一人でゲームバランスの調整をしています。

Aさん: ちょっと敵が弱すぎる気がするな。ドラゴンみたいな敵を追加したほうがいいんだろうか。
Aさん: どうやって追加しよう…
解答例

EnemyDragon クラスを追加して、それを Battle クラスに渡してあげれば OK です。

--- good.py     2024-05-20 19:28:57
+++ good_add_dragon.py  2024-05-20 19:28:41
@@ -162,6 +162,11 @@
         super().__init__(True, "ゴブリン", 20, 5)


+class EnemyDragon(Entity):
+    def __init__(self):
+        super().__init__(True, "ドラゴン", 100, 10)
+
+
 # コマンド
 class AttackCommand(Command):
     def __init__(self):
@@ -201,6 +206,7 @@
 enemies: list[Entity] = [
     EnemySlime(),
     EnemyGoblin(),
+    EnemyDragon(),
 ]
 battle = Battle(players, enemies)
 battle.start()
自 ゆうしゃ: HP=30
敵 スライム: HP=10
敵 ゴブリン: HP=20
敵 ドラゴン: HP=100
----------
ゆうしゃのターン
0: 攻撃
1: 回復
どうする?> 0
----------
0: スライム HP=10
1: ゴブリン HP=20
2: ドラゴン HP=100
誰に?> 2
----------
ゆうしゃの攻撃!
ドラゴンに10のダメージ
自 ゆうしゃ: HP=30
敵 スライム: HP=10
敵 ゴブリン: HP=20
敵 ドラゴン: HP=90
----------
スライムの攻撃!
ゆうしゃに1のダメージ
ゴブリンの攻撃!
ゆうしゃに5のダメージ
ドラゴンの攻撃!
ゆうしゃに10のダメージ
自 ゆうしゃ: HP=14
敵 スライム: HP=10
敵 ゴブリン: HP=20
敵 ドラゴン: HP=90
----------

Aさん: HPが半分になる代わり攻撃力が2倍になるオーバーアタックコマンドとかも面白いんじゃない?
Aさん: どうやって追加しよう…
解答例
--- good_add_dragon.py  2024-05-20 19:33:43
+++ good_add_overattack.py      2024-05-20 19:33:31
@@ -145,10 +145,10 @@
 # プレイヤー
 class Player(Entity):
     def __init__(self, name: str, hp: int, attack: int):
         super().__init__(False, name, hp, attack)

     def commands(self):
-        return [AttackCommand(), HealCommand()]
+        return [AttackCommand(), OverAttackCommand(), HealCommand()]


 # 敵
@@ -184,6 +184,24 @@
         target.hp -= operator.attack


+class OverAttackCommand(Command):
+    def __init__(self):
+        super().__init__("オーバーアタック")
+
+    @override
+    def possible_targets(self, entities: list[Entity], operator: Entity):
+        # 自分と違うサイドのエンティティを攻撃可能
+        return [e for e in entities if e.is_enemy != operator.is_enemy]
+
+    @override
+    def execute(self, operator: Entity, target: Entity):
+        print(f"{operator.name}のオーバーアタック!")
+        print(f"{operator.name}のHPが半分になった")
+        print(f"{target.name}に{operator.attack * 2}のダメージ")
+        operator.hp //= 2
+        target.hp -= operator.attack * 2
+
+
 class HealCommand(Command):
     def __init__(self):
         super().__init__("回復")
自 ゆうしゃ: HP=30
敵 スライム: HP=10
敵 ゴブリン: HP=20
敵 ドラゴン: HP=100
----------
ゆうしゃのターン
0: 攻撃
1: オーバーアタック
2: 回復
どうする?> 1
----------
0: スライム HP=10
1: ゴブリン HP=20
2: ドラゴン HP=100
誰に?> 1
----------
ゆうしゃのオーバーアタック!
ゆうしゃのHPが半分になった
ゴブリンに20のダメージ
自 ゆうしゃ: HP=15
敵 スライム: HP=10
敵 ゴブリン: HP=0
敵 ドラゴン: HP=100
----------

Aさん: なにこれ!? 変更箇所が全然ない! 変更部分も固まってるしレビューもしてもらいやすい! これが設計の力か…

短いコードのほうがいいんじゃないの?

解答例を選んでいるとき、毎回長い側が選ばれたところに疑問を持った人はいるだろうか。

一般に「短いコードのほうがいい」とざっくり言われることがあるが、これは語弊のある言い方でもある。

全体のコードの長さ自体はさほど重要ではなく、重要なのは、一つ一つの処理単位が小さく簡潔にまとまっていること。同じものに対する処理が散逸せず、一箇所にまとめられていること。

そもそも単純なコード長は、抽象化を行うとまず基本的には増える。もとのコードがよっぽど何百行もコピペされていない限り、増える。親クラス分だけクラス定義が増えたり、インターフェースの定義が増えたりするので仕方ない。ただ、全体が長かろうと、一つ一つの処理が小さくまとまっていれば、処理単位で一つずつ理解することができる。きれいに抽象化されているコードは、一つの処理を完全に理解したらその処理の詳細を忘れられる。依存関係がめちゃくちゃになっていて、コードの正しさを理解するのにいろいろな場所のいろいろな記述を詳細に読み込まないといけないコードこそ避けたほうがよい。

短くてもつらいコードの具体例

実は、先程の二択クイズの「おすすめじゃないほう」を選び続けたコードがここにある。

一見しただけではまだ辛さがわからないかもしれない。むしろ、読むだけならこちらのほうがよく思えるかもしれない。実際、素朴なコードは部分部分の処理を単純に追うだけなら、読みやすい。

image block
DDDと呼ばれる設計手法で現れる概念と依存関係の図。今見ても何もわからないと思う。

では、ここにドラゴンを追加することを考えてみよう。どこを変更すればいいだろうか?

解答例
--- bad.py      2024-05-20 19:17:33
+++ bad_add_dragon.py   2024-05-20 19:40:18
@@ -17,19 +17,28 @@
         self.attack = attack


+class EnemyDragon:
+    def __init__(self, hp: int, attack: int):
+        self.name = "ドラゴン"
+        self.hp = hp
+        self.attack = attack
+
+
 slime = EnemySlime(10, 1)
 goblin = EnemyGoblin(20, 5)
+dragon = EnemyDragon(100, 10)


 def print_status():
     print(f"自 {player_name}: HP={player_hp}")
     print(f"敵 {slime.name}: HP={slime.hp}")
     print(f"敵 {goblin.name}: HP={goblin.hp}")
+    print(f"敵 {dragon.name}: HP={dragon.hp}")
     print("----------")


 def is_settled():
-    if slime.hp <= 0 and goblin.hp <= 0:
+    if slime.hp <= 0 and goblin.hp <= 0 and dragon.hp <= 0:
         return "player"
     if player_hp <= 0:
         return "enemies"
@@ -47,15 +56,19 @@
     if command == 0:
         print("0: スライム")
         print("1: ゴブリン")
-        target = ask_int("誰に?> ", 0, 1)
+        print("2: ドラゴン")
+        target = ask_int("誰に?> ", 0, 2)

         print(f"{player_name}の攻撃!")
         if target == 0:
             print(f"{slime.name}に{player_attack}のダメージ")
             slime.hp -= player_attack
-        else:
+        elif target == 1:
             print(f"{goblin.name}に{player_attack}のダメージ")
             goblin.hp -= player_attack
+        elif target == 2:
+            print(f"{dragon.name}に{player_attack}のダメージ")
+            goblin.hp -= player_attack
     else:
         print(f"{player_name}の回復!")
         print(f"{player_name}のHPが10回復した")
@@ -75,7 +88,12 @@
         print(f"{player_name}に{goblin.attack}のダメージ")
         player_hp -= goblin.attack

+    if dragon.hp > 0:
+        print(f"{dragon.name}の攻撃!")
+        print(f"{player_name}に{dragon.attack}のダメージ")
+        player_hp -= dragon.attack

+
 def ask_int(prompt: str, min_value: int, max_value: int) -> int:
     while True:
         try:

すごい差分が増えてしまった。

このように、コードが短くて読みやすいことは、必ずしも変更が容易なことには結びつかない。個別の1行1行は読みやすいかもしれないが、全体としてどのように依存し合っているのかを読み取るのは難しいことが多い。

ちなみに

上のドラゴン追加差分がバグってたの、気づいたかな?

答え

プレイヤーがドラゴンに攻撃するとゴブリンのHPが減る

設計の難しさ

とはいえ、全体のコードが一瞬で把握できるくらい小さいコードであれば、必要以上な抽象化は単純に読みにくく認知不可を上げるだけに終わってしまうこともある。これは設計手法に正解はないという話で、 トレードオフがあるだけだ という言葉にも通ずる。どこまで抽象化を進めるのかというところも「時と場合による」。極端な話、今後一生変更がないと確信できる部分に関して抽象化する必要はない。

結局、設計するためにはまずは依頼者からの 要件 を確認することが非常に重要になる。今回のAさんの例を良く思い返すと、このような話だった。

… とりあえずスライムとゴブリンを … これから先増やすかもしれません。
… 一旦は攻撃と回復だけ … 後からふやすかもしれません。

この言葉からは、 敵やコマンドは抽象化を噛ませて追加削除を簡単にしておいたほうがいいだろう という判断ができる。一方、例えば行動不能の判定は HP が 0 であることだとベタ書きしている。

    def player_turn(self):
        # コマンドを表示
        decision = []
        for player in self.players:
            if player.hp == 0:
                continue
            # ...

もし今回、状態異常の概念があれば、麻痺による行動不能などを考える必要があっただろう。その仕様が追加された場合、今のままでは対応できないし、そういう仕様になりうるのであれば、行動不能判定も抽象化が必要になったと思う。ただし、その場合はまたインターフェースが追加されたりコードが膨れ上がったりしてしまうことは間違いない。

まとめ

  • オブジェクト指向の考え方やSOLID原則を適用すると、より変更しやすいプログラムを書くことができる。
  • 設計により抽象化を行うとコード量が増え、認知不可があがる側面もあるので、どこを抽象化するべきかは見極める必要がある。
  • 唯一絶対の答えはない。