PythonのProtocolで行うStructural Subtyping

published
2024-09-09

初めに

pythonは動的型付けの言語で、型チェックがない言語となっています。そのため実行時エラーが他言語に比べると起こりやすい言語なのですが、型ヒントを用いた開発をすることで上記のケースを減らし、保守性を高めることができます。(mypyで静的型チェックをするとなおよし)

いつも型ヒントで抽象的なクラスを宣言するとき、抽象基底クラス(ABC)を用いていたのですが、python3.8にて追加されたProtocolを用いるとよりpythonらしく記述できると感じました。今回はそんなProtocolを用いたStructural Subtypingの紹介をしていきます。

Protocolに関して詳しくはドキュメント、またPEP544を参照ください。

Structural Subtyping(構造的型付け)とは

Structural Subtyping(構造的型付け)とは、データの「構造」に基づいて型の判断をする型システムのことです。
簡単に言うと、オブジェクトが持つメソッドやプロパティの構造が同じであれば、異なる型であっても互換性があるとみなされる型付けの方法です。

似たような用語で「静的ダックタイピング」というものがあるのですが、自分はほぼ同じものとして扱っています
(厳密には違うようなのですが、Pythonの公式ドキュメントでも、「structural subtyping (static duck-typing)」と説明されているので同じでよい感)

ちなみに、Structural Subtypingと違い、名前に基づいて型を判断するのをNominal Typingと言います。pythonの抽象基底クラス(ABC)は、Structural Subtypingではなくnominal typingとなります。

詳しい違いはこちらを参照ください!

Protocolを用いてのStructural Subtyping

では、Protocolを用いてStructural Subtypingの具体例を書いていきます。

題材としては前回記事で書いた最後のコード部分をProtocolにしてみます。

from typing import List, Protocol class PlayableCharacter(Protocol): def full_healing(self) -> None: pass class Hero: _MAX_HP = 100 _MAX_MP = 100 def __init__(self, hp: int, mp: int, strength: int) -> None: self._hp = hp self._mp = mp self._strength = strength def full_healing(self) -> None: self._hp = self._MAX_HP self._mp = self._MAX_MP class DarkSlime: _MAX_HP = 150 _MAX_DARK_AURA = 150 def __init__(self, hp: int, dark_aura: int) -> None: self._hp = hp self._dark_aura = dark_aura def full_healing(self) -> None: self._hp = self._MAX_HP self._dark_aura = self._MAX_DARK_AURA class Inn: def stay(self, characters: List[PlayableCharacter]) -> None: for character in characters: character.full_healing() def main() -> None: hero = Hero(50, 50, 50) dark_slime = DarkSlime(120, 120) inn = Inn() inn.stay([hero, dark_slime]) assert hero._hp == 100 assert dark_slime._hp == 150 print("successful!") if __name__ == "__main__": main()

前回から内容を少し変えて、DarkSlimeを追加しました。これはMPの代わりにDarkAuraを回復するようになっていますが、stayとしてはfull_healingのメソッドを持つかの構造を見ている為、実行も正常に完了しますし、mypyによるエラーも起こりません。

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

勿論、Protocolを継承したClassは抽象基底クラス同様にインスタンス化することもできません。(これがとてもうれしい)

from typing import Protocol class PlayableCharacter(Protocol): def full_healing(self) -> None: pass playable = PlayableCharacter() # TypeError: Protocols cannot be instantiated

まとめ

pythonは動的型付け言語でダックタイピングを自然と用いる為、nominal typingよりもStructural Subtypingとの相性のほうが良いと感じました。
ただ、より堅牢なシステム構成が必要な際にはnominal typingのほうが良いかなと思います。
(要は一長一短ですね)

おまけ

前回記事でEchoのNewHTTPErrorのカスタムレスポンス方法について触れたのですが、errorインターフェースの実装には触れておらず、丁度良いのでここで、errorインターフェースと判定される振る舞いを実装してみます。

前回記事はこちら

package main import ( "net/http" "github.com/labstack/echo/v4" "strings" ) type CustomError []string func (e CustomError) Error() string { return strings.Join(e, ", ") } func main() { e := echo.New() e.GET("/", func(c echo.Context) error { return echo.NewHTTPError(http.StatusUnauthorized, CustomError{"エラーなのだ", "エラーなのだ2"}) }) e.Logger.Fatal(e.Start(":1323")) }
$ curl http://localhost:1323 {"message":"エラーなのだ, エラーなのだ2"}

CustomErrorはerrorインターフェースとしての振る舞いが定義されている為、DefaultHTTPErrorHandlerのswitch文でerrorインターフェースとして判定され上記のような結果となっています。

ただ、errorインターフェースでErrorメソッドの戻り値がstringと定義されている為、CustomErrorの戻り値もstringにしてあげないといけない関係上、完全に前回の結果と同一ではないですが...(ジェネリクス等にしてほしかった感)
もし、配列とかにしたいならjson.Marshaler君のcase判定されれば良いのかなぁ感。

そりでは