概要
これまで会社のプロジェクトやOSSでも型アノテーションや型ヒントは使っていました。
しかし、typing.NewType
は使ったことがなく使いどころもいまいちわからなかったのですが、最近になって日本語版が刊行された『ロバストPython』に型制約の付け方のひとつとして紹介されていました。
これについてチーム内で議論をしたり、サンプルコードを書いたりした結果、「これは型についてルーズなコードをリファクタリングするときに役に立つもので、新しくコードを書く時には使うべきではない」という考えになりました。
そこに至る理由を本記事で述べます。
想定するビジネスシーン
下記画像のお菓子を作って販売することが業務ドメインであるビジネスの生産管理システムを作成します。
ビジネス上の必要性から、現物がひとつひとつが製造されるごとに生産管理ソフトウェアではインスタンスがひとつひとつ生成されます。
生産管理ソフトウェアの設計思想
このお菓子を製造する際には、「生地」「中身」「焼き加減」を指定して「焼く」業務プロセスがあり、生産管理ソフトウェアでもそれをプログラム的に表現する実装を行います。
このお菓子は「今川焼き」、「大判焼き」、「回転焼き」など、地域や材料によってさまざまな呼ばれ方をします。
「今川焼き」、「大判焼き」や「回転焼き」は「どれがどれの派生である」「どれがどれの別名である」ということはなく、ビジネス要件上はっきりとした違いがあるものとして、生産管理ソフトウェアでも実装としてそれを表現しなくてはなりません。
そこで、このお菓子を抽象概念化した名前である「ベイクドモチョチョ」1に由来する名前の基底クラスを定義し、それから派生した具象クラスとして「今川焼き」「大判焼き」などを実装します。
生産管理ソフトウェアの実装
「今川焼き」を任意の数生産(して管理する)スクリプトを想定します。
最後に生産した「今川焼き」のリストを標準出力に表示します。
型ヒント導入前のコード
# 焼き加減の定数
RARE = 1
MEDIUM = 2
WELL = 3
class Batter:
... # 生地の実装は省略
class Filling:
... # 中身の実装は省略
class Mochocho:
def __init__(self, batter, filling):
self.batter = batter
self.filling = filling
def bake(self, doneness):
self.doneness = doneness
return self
def __repr__(self):
return f"{type(self).__name__}(doneness={self.doneness})"
class ImagawaYaki(Mochocho):
... # 今川焼き特有の処理は将来的に実装予定
class OhBanYaki(Mochocho):
... # 大判焼き特有の処理は将来的に実装予定
def main():
unbaked_imagawas = []
baked_imagawas = []
unbaked_imagawas.append(ImagawaYaki(Batter(), Filling()))
baked_imagawas.append(unbaked_imagawas.pop(0).bake(RARE))
print(baked_imagawas)
# [ImagawaYaki(doneness=1)]
if __name__ == "__main__":
main()
要件の変更
ここで、3通りの焼き加減の今川焼きを1つずつ作るようにスクリプトを改修して実行します。
def main():
unbaked_imagawas = []
baked_imagawas = []
unbaked_imagawas.append(ImagawaYaki(Batter(), Filling()))
unbaked_imagawas.append(ImagawaYaki(Batter(), Filling()))
unbaked_imagawas.append(ImagawaYaki(Batter(), Filling()))
baked_imagawas.append(unbaked_imagawas.pop(0).bake(RARE))
baked_imagawas.append(unbaked_imagawas.pop(0))
baked_imagawas.append(unbaked_imagawas.pop(0).bake(WELL))
print(baked_imagawas)
# Traceback (most recent call last):
# ...
# File "...\sample\mochocho.py", line ..., in __repr__
# return f"{type(self).__name__}(doneness={self.doneness})"
# ^^^^^^^^^^^^^
# AttributeError: 'ImagawaYaki' object has no attribute 'doneness'
どこかに「焼き加減doneness
が設定されていない」=「bake
メソッドが呼ばれたことがない」インスタンスがbaked_imagawas
に含まれているようです。
まさに、『良いコード/悪いコードで学ぶ設計入門 ―保守しやすい 成長し続けるコードの書き方』で指摘される生焼けオブジェクト2があるわけです。
NewType
を使わない型ヒント導入後のコード
「型ヒントをつければ型チェッカーがバグを見つけてくれるのではないか」ということで、型ヒントをつけてみました。
import enum
from typing import Self
# bakeに渡す引数に型をつける & printしたときマジックナンバーがそのまま表示されてわかりにくいのでenumを導入
class Doneness(enum.Enum):
RARE = enum.auto()
MEDIUM = enum.auto()
WELL = enum.auto()
class Batter:
... # 生地の実装は省略
class Filling:
... # 中身の実装は省略
class Mochocho:
def __init__(self, batter: Batter, filling: Filling) -> None:
self.batter = batter
self.filling = filling
def bake(self, doneness: Doneness) -> Self:
self.doneness = doneness
return self
def __repr__(self) -> str:
return f"{type(self).__name__}(doneness={self.doneness})"
class ImagawaYaki(Mochocho):
... # 今川焼き特有の処理は将来的に実装予定
class OhBanYaki(Mochocho):
... # 大判焼き特有の処理は将来的に実装予定
def main():
unbaked_imagawas: list[Mochocho] = []
baked_imagawas: list[Mochocho] = []
unbaked_imagawas.append(ImagawaYaki(Batter(), Filling()))
unbaked_imagawas.append(ImagawaYaki(Batter(), Filling()))
unbaked_imagawas.append(ImagawaYaki(Batter(), Filling()))
baked_imagawas.append(unbaked_imagawas.pop(0).bake(Doneness.RARE))
baked_imagawas.append(unbaked_imagawas.pop(0))
baked_imagawas.append(unbaked_imagawas.pop(0).bake(Doneness.WELL))
print(baked_imagawas)
if __name__ == "__main__":
main()
しかしこれでも型チェッカーによるエラーは出現しません。
それもそのはず、bake
メソッドが呼び出された前後で型としてはMochocho
(の派生型ImagawaYaki
)のままなので、型チェッカーはその違いを見つけることができないのです。
NewType
を導入したコード
型チェッカーで「焼く前」と「焼いた後」を区別するため、bake
メソッドがインターフェイスは保持しつつ別の型を返すような実装にします。
import enum
from typing import NewType
class Doneness(enum.Enum):
RARE = enum.auto()
MEDIUM = enum.auto()
WELL = enum.auto()
class Batter:
... # 生地の実装は省略
class Filling:
... # 中身の実装は省略
class Mochocho:
def __init__(self, batter: Batter, filling: Filling) -> None:
self.batter = batter
self.filling = filling
def bake(self, doneness: Doneness) -> "BakedMochocho":
self.doneness = doneness
return self
def __repr__(self) -> str:
return f"{type(self).__name__}(doneness={self.doneness})"
BakedMochocho = NewType("BakedMochocho", Mochocho)
class ImagawaYaki(Mochocho):
... # 今川焼き特有の処理は将来的に実装予定
class OhBanYaki(Mochocho):
... # 大判焼き特有の処理は将来的に実装予定
def main():
unbaked_imagawas: list[Mochocho] = []
baked_imagawas: list[BakedMochocho] = []
unbaked_imagawas.append(ImagawaYaki(Batter(), Filling()))
unbaked_imagawas.append(ImagawaYaki(Batter(), Filling()))
unbaked_imagawas.append(ImagawaYaki(Batter(), Filling()))
baked_imagawas.append(unbaked_imagawas.pop(0).bake(Doneness.RARE))
baked_imagawas.append(unbaked_imagawas.pop(0))
baked_imagawas.append(unbaked_imagawas.pop(0).bake(Doneness.WELL))
print(baked_imagawas)
if __name__ == "__main__":
main()
こうすると、VSCode上ではPylanceがmain
関数内で「bake
していない」インスタンスをリストに追加している部分についてエラーを出してくれます。
エラーが発生しているコードを改修してbake
したインスタンスをリストに追加するようにして、バグフィックスができました。
def main():
unbaked_imagawas: list[Mochocho] = []
baked_imagawas: list[BakedMochocho] = []
unbaked_imagawas.append(ImagawaYaki(Batter(), Filling()))
unbaked_imagawas.append(ImagawaYaki(Batter(), Filling()))
unbaked_imagawas.append(ImagawaYaki(Batter(), Filling()))
baked_imagawas.append(unbaked_imagawas.pop(0).bake(Doneness.RARE))
baked_imagawas.append(unbaked_imagawas.pop(0).bake(Doneness.MEDIUM))
baked_imagawas.append(unbaked_imagawas.pop(0).bake(Doneness.WELL))
print(baked_imagawas)
# [ImagawaYaki(doneness=Doneness.RARE), ImagawaYaki(doneness=Doneness.MEDIUM), ImagawaYaki(doneness=Doneness.WELL)]
このコードの問題点
しかしこのコードは保守性が十分に良いとは言えません。
どうしてこうなるのか、NewType
のドキュメントを見るとわかります。
静的型検査器は新しい型を元々の型のサブクラスのように扱います。
つまり、派生クラス(として扱われている型)をアノテーションしているところで基底クラスのインスタンスを返しているので、型チェッカーでエラーが起きています。
NewType
で返される型は元のクラスと同じインターフェースを持つので、ランタイム上ではエラーになりませんが、ぱっと見でmain
関数のエラーとbake
メソッドのエラーのどちらが「ランタイムでクリティカルな」エラーになるのかがわからない状態です。
リストの内容を具象クラスの実態に関わらずBakedMochocho
としてしか型情報を静的解析できない
ランタイムでリストの内容が「今川焼き」具象クラスのインスタンスでも、baked_imagawas
のアノテーションはlist[BakedMochocho]
です。
そのためリストから取り出したオブジェクトはBakedMochocho
としてしか型情報を静的解析できず、「今川焼き特有の処理」メソッドを参照するコードが将来的に追加されたら型チェッカーがエラーとなります。
そもそも生焼けオブジェクトを生成している
型をアノテーションしただけでは、ランタイムで生産物インスタンスが「未完成の生産物」「完成済みの生産物」となる状態が許容されていること自体は変わりません。
「生地」「中身」「焼き加減」を指定して「焼く」と「生産物」が完成されるような設計がよりよいはずです。
どうするべきか
もしこれらのコードを書き換えても他の機能に影響がないなら、モダナイズしたコードに書き直すべきです。
-
doneness
が未設定のインスタンスが生まれないように、コンストラクタ引数にdoneness
を加え「完全コンストラクタ」にする - 「生地」「中身」のまとまりは「未完成の生産物」ではなく「材料」であるという概念を導入する
- 「材料」を「焼き加減」を指定して「調理」すると「完成品」を返す振る舞いをするファクトリを導入する
リファクタリング後のコード
import enum
from typing import Generic, TypeVar
class Doneness(enum.Enum):
RARE = enum.auto()
MEDIUM = enum.auto()
WELL = enum.auto()
class Batter:
... # 生地の実装は省略
class Filling:
... # 中身の実装は省略
class Mochocho:
def __init__(self, batter: Batter, filling: Filling, doneness: Doneness) -> None:
self.batter = batter
self.filling = filling
self.doneness = doneness
def __repr__(self) -> str:
return f"{type(self).__name__}(doneness={self.doneness})"
_TM = TypeVar("_TM", bound=Mochocho)
class MochochoBaker(Generic[_TM]):
product_type: type[_TM]
def cook(self, batter: Batter, filling: Filling, doneness: Doneness) -> _TM:
return self.product_type(batter, filling, doneness)
class ImagawaYaki(Mochocho):
... # 今川焼き特有の処理は将来的に実装予定
class ImagawaYakiCooker(MochochoBaker[ImagawaYaki]):
product_type = ImagawaYaki
class OhBanYaki(Mochocho):
... # 大判焼き特有の処理は将来的に実装予定
def main():
baked_imagawas: list[ImagawaYaki] = []
cooker = ImagawaYakiCooker()
baked_imagawas.append(cooker.cook(Batter(), Filling(), Doneness.RARE))
baked_imagawas.append(cooker.cook(Batter(), Filling(), Doneness.MEDIUM))
baked_imagawas.append(cooker.cook(Batter(), Filling(), Doneness.WELL))
print(baked_imagawas)
# [ImagawaYaki(doneness=Doneness.RARE), ImagawaYaki(doneness=Doneness.MEDIUM), ImagawaYaki(doneness=Doneness.WELL)]
if __name__ == "__main__":
main()
リファクタリングができない現実
現実には、Mochocho
の実装をすぐに変えられない下記のような事情が存在します。
- 「
doneness
が設定されていないインスタンス」に依存する処理が書かれている - 「
bake
以外でdoneness
を設定したインスタンスを返すメソッド」が派生クラスに書かれている - 単純にプロダクションコード内で出現箇所が多く、書き換えた際のコード変更量が膨大になる
そんな時、NewType
を使うことで変更量を小さく抑えた上で「bake
が呼ばれていないインスタンスを不適切に使っているところをあぶりだす」ことができます。
しかし、前述の通り多くの問題点が残ります。
そのため、レガシーコードを生かし続けなくてはいけないとき「型を補完することで認知負荷を下げることができる」場合にあくまで応急処置としてのみ使う=新しく書くコードでは使わない方が良いと考えています。
そして、NewType
を使ったコードをモダナイズするためのissueを作成し、技術的負債を返済する計画を立てるべきです。