LoginSignup
8
3

More than 1 year has passed since last update.

【Python】型ヒントと抽象基底クラス ~○○クエストを添えて~

Last updated at Posted at 2021-12-02

前書き

この記事はJSL(日本システム技研) Advent Calendar 2021の記事です
結構殴り書きしたため、誤字等ございましたら:pray:

環境

  • python: 3.9.1
  • windows10

本編の前に

本編の前に、今回使用する型ヒント君と抽象基底クラス君について触りだけ紹介したいとおもいます

型ヒント君?

Pythonの関数や変数に型のヒントを付けれるよっ!って機能です
記述方法が複数あるため、その時々のベストプラクティスを調べることをお勧めします

b: int = 1
c: str = 'str'

def add(x: int, y: int) -> int:
    return x + y

list等に関しても適用できます

str_list: list[str] = ['py', 'thon']
multi_tuple: tuple[int, str, int] = (10, 'python', 10)

複数の型を指定したい場合、Unionを使用すると実現できます
Unionはtypingモジュールよりインポートできます

from typing import Union
str_int_list: list[Union[str, int]] = ['str', 1, 2]

非常に便利に見える型ヒント君ですが...
実はヒントとあるように強制はしないため、型以外の値も代入等可能なのです

a: int = 'str'

def add(x: int, y: int) -> int:
    return x + y

add('py', 'thon') # 'python'

ここら辺をガッチリ固めたい!のでしたらPythonやめましょう...

嘘です(ほんとです)。サードパッケージ等ありますので検討してみてください
(IDE上で確認するだけなら、pyright入れやすいですし使ってみたいですね!)

もっと詳しく型ヒントについて知りたい場合は公式をチェックしてみてください

抽象基底クラス君?

class実装時に、これは絶対に実装しろよっ!って強制ができます
その名の通り抽象クラスを定義できます
公式を見たところ、ヘルパークラスABCを使用すれば抽象クラスが作成できるようです

from abc import ABC

class AbstractClass(ABC)

サブクラスに実装させたいメソッド等には、abstractmethod凸レータを付けてあげます
抽象クラスの特徴として、継承しないとインスタンス化できない特徴があります

from abc import ABC, abstractmethod


class AbstractClass(ABC):
    @abstractmethod
    def abs_method(self):
        pass

    @property
    @abstractmethod
    def abs_property(self):
        pass

abstract_class = AbstractClass()
# TypeError: Can't instantiate abstract class AbstractClass with abstract methods abs_method, abs_property

上記を継承した場合、abstractmethod凸レータが付いているものを全て実装しないとエラーになります

class TestClass(AbstractClass):
    def abs_method(self):
        return 'abs_method'

test_class = TestClass()
# TypeError: Can't instantiate abstract class TestClass with abstract method abs_property

class TestClass2(AbstractClass):
    def abs_method(self):
        return 'abs_method'

    @property
    def abs_property(self):
        return 'abs_property'

test_class2 = TestClass2()
# OK

ただ、他言語の抽象クラスとは違い多重継承も行えます

from abc import ABC


class A(ABC):
    def __init__(self):
        self.name = 'A'

    def myname(self):
        return self.name

    def a_method(self):
        return 'a_method'

class B(ABC):
    def __init__(self):
        self.name = 'B'

    def myname(self):
        return self.name

    def b_method(self):
        return 'b_method'

class C(A, B):
    pass

class D(B, A):
    pass

c = C()
d = D()
c.myname()   # A
d.myname()   # B
c.a_method() # a_method
d.a_method() # a_method
c.b_method() # b_method
d.b_method() # b_method

結果をみてわかる通り、Pythonでは多重継承された際、左側のクラスから探索されます

ほとんどのシンプルな多重継承において、親クラスから継承される属性の検索は、深さ優先で、左から右に、そして継承の階層の中で同じクラスが複数出てくる(訳注: ダイアモンド継承と呼ばれます)場合に2度探索をしない、と考えることができます。なので、 DerivedClassName にある属性が存在しない場合、まず Base1 から検索され、そして(再帰的に) Base1 の基底クラスから検索され、それでも見つからなかった場合は Base2 から検索される、といった具合になります。

他言語のインターフェースと抽象クラスを足して2で割った感じがpythonの抽象基底クラスかなって印象です

本編

Pythonを使用していると、あまり型を意識しながらプログラミングを書く機会は少ないかと思います
(えっ意識してないのって...もしかして私だけ!?)
本編は少し(少し(ほんの少しだけ))を意識したPythonを見ていきたいと思います

○○クエストで理解する型ヒントと抽象基底クラス

話は全く変わってしまうのですが、何か理解したい物事があった際に
私は基本的にゲームに落とし込んで考えています
(ゲームだとわかりやすいじゃないですか...頭がいい方々はわかりにくいかもしれませんが...)
そのため、今回は上記2つを組み合わせると何が嬉しいのかをドラクエに落とし込んで考えてみたいと思います

○○クエスト、一番好きなキャラは誰でしょうか?
私は5で序盤から終盤までお世話になったスライムナイトが好きですね
前線で戦えるかつ、ベホマによる回復も行えてしまう...そして何より仲間になりやすい
もう一度5をプレイする場合でも絶対に外せないですね

そんなことはさておき、スライムナイトが武器をもった状態でフィールドにインスタンス化されるとします
武器ははがねの剣にしましょうか

スライムナイトが現れた

class SteelBroadSword:
    def __init__(self):
        self.plus_attack_power = 33
        self.plus_magic_power = 0

    def attack_power(self):
        return self.plus_attack_power


class SlimeKnight:
    def __init__(self, name, strength, hp, mp):
        self.name = name
        self.strength = strength
        self.hp = hp
        self.mp = mp
        self.steel_broad_sword = SteelBroadSword()

    def attack(self):
        return self.strength + self.steel_broad_sword.attack_power()

def main():
    slime_knight = SlimeKnight('ピエール', 44, 40, 6)
    print(f'{slime_knight.name}の攻撃力: {slime_knight.attack()}')

上記のピエール君の、attackの攻撃力をテストしてみたいと思います

test
def test_slime_knight():
    slime_knight = SlimeKnight('ピエール', 44, 40, 6)
    assert slime_knight.attack() == 77

# test_slime_knight.py .                                                                                                                                              # [100%]
# ================= 1 passed in 0.02s =================

いい感じですね

次に上記に型ヒントを追加してみましょう。

class SteelBroadSword:
    def __init__(self):
        self.plus_attack_power: int = 33
        self.plus_magic_power: int = 0

    def attack_power(self) -> int:
        return self.plus_attack_power


class SlimeKnight:
    def __init__(self, name: str, strength: int, hp: int, mp: int):
        self.name = name
        self.strength = strength
        self.hp = hp
        self.mp = mp
        self.steel_broad_sword: SteelBroadSword = SteelBroadSword()

    def attack(self) -> int:
        return self.strength + self.steel_broad_sword.attack_power()

def main():
    slime_knight = SlimeKnight('ピエール', 44, 40, 6)
    print(f'{slime_knight.name}の攻撃力: {slime_knight.attack()}')

ふぅ...一安心...ではないんです...
上記コードの場合、ひょんなイベント開催で無事爆発します

○○クエスト1000周年記念!

○○クエスト1000年記念イベントが開催されました
「イベント期間中低確率でメタルキングの剣を所持したスライムナイトと遭遇出来ます!」

困りました...今はSlimeKnightクラスがSteelBroadSwordに依存してしまっているため、メタルキングの剣を持たせることができません...

実はこれは、「依存性の注入」を行うことで回避できます。

class SlimeKnight:
    def __init__(self, name, strength, hp, mp, sword):
        self.name = name
        self.strength = strength
        self.hp = hp
        self.mp = mp
        self.sword = sword

    def attack(self):
        return self.strength + self.sword.attack_power()

これで、メタルキングの剣のクラスを作成し、依存性を注入してあげればよさそうです

class MetalKingSword:
    def __init__(self):
        self.plus_attack_power = 130
        self.plus_magic_power = 100

    def attack_power(self):
        return self.plus_attack_power * self.plus_magic_power

def main():
    slime_knight_a = SlimeKnight('ピエール_A', 44, 40, 6, SteelBroadsword())
    slime_knight_b = SlimeKnight('ピエール_B', 44, 40, 6, MetalKingSword())
    print(f'{slime_knight_a.name}の攻撃力: {slime_knight_a.attack()}')
    # ピエール_Aの攻撃力: 77
    print(f'{slime_knight_b.name}の攻撃力: {slime_knight_b.attack()}')
    # ピエール_Bの攻撃力: 13044

うん、よさそうですね。なんだかんだいつも上記のように書いていると思います
では型を追加してみましょう

from typing import Union

# 略

class SlimeKnight:
    def __init__(
            self,
            name: str,
            strength: int,
            hp: int,
            mp: int,
            sword: Union[SteelBroadsword, MetalKingSword]
        ):
        self.name = name
        self.strength = strength
        self.hp = hp
        self.mp = mp
        self.sword = sword

    def attack(self) -> int:
        if isinstance(self.sword, SteelBroadSword):
            return self.strength + self.sword.attack_power()
        return self.strength + self.sword.attack_power()

Unionを使用すれば、型を記述することはできました...ですが上記だと...武器が次々に実装されてしまったらUnion部分とif分岐部分が武器の個数に比例して増えてしまいます
(もうこんなイベント...来ないでくれ...)

ここで使えるのが抽象クラス君です
抽象クラス君を用いて上記を書き換えてみましょう

from abc import ABC, abstractmethod

class AbstractSword(ABC):
    @abstractmethod
    def attack_power(self) -> int:
        pass


class SteelBroadSword(AbstractSword):
    # 上記同様のため省略


class MetalKingSword(AbstractSword):
    # 上記同様のため省略


class SlimeKnight:
    def __init__(
            self,
            name: str,
            strength: int,
            hp: int,
            mp: int,
            sword: AbstractSword
        ):
        self.name = name
        self.strength = strength
        self.hp = hp
        self.mp = mp
        self.sword = sword

    def attack(self) -> int:
        return self.strength + self.sword.attack_power()

うーいい感じですね、これでどんなに武器が増えたとしてもSlimeKnightのクラスの処理が変更されることはなくなりそうです!
また、抽象クラスを用いることで、以下メリットがあります

  • Swordクラスはインスタンス化が行えないため、バグを防止できる
  • attack_powerメソッドを強制できるため、具象クラスにメソッドがあるかチェックする必要がない

ちなみに、上記抽象クラス(正確にはインターフェース)に依存することを「依存性逆転の原則」といいます

余談なんですが、依存性逆転の原則の説明文哲学感あってめっちゃ好きです

上位モジュールはいかなるものも下位モジュールから持ち込んではならない。双方とも抽象(例としてインターフェース)に依存するべきである。

終わりに

Pythonでコードを書くとしても、型を意図的に明記することは個人的にはお勧めします
(我が書いたUnコードを少しでも後続の方が理解できるように...)
ただ、他の言語のようにガッチガチに型で縛ってしまうと、それはそれでPythonの良さが活かせなくなってしまうことにつながってしまう気がするので、ここら辺の塩梅は難しい気がします...

間違っている部分や不正確な部分などあれば、ぜひご指摘いただけると、とっても嬉しいです!!

参考リンク

8
3
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
8
3