16
9

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

【Python】`typing.NewType`をこれから書くコードでは使わないほうがいいと考える理由

Last updated at Posted at 2023-05-10

概要

これまで会社のプロジェクトやOSSでも型アノテーションや型ヒントは使っていました。

しかし、typing.NewTypeは使ったことがなく使いどころもいまいちわからなかったのですが、最近になって日本語版が刊行された『ロバストPython』に型制約の付け方のひとつとして紹介されていました。

これについてチーム内で議論をしたり、サンプルコードを書いたりした結果、「これは型についてルーズなコードをリファクタリングするときに役に立つもので、新しくコードを書く時には使うべきではない」という考えになりました。

そこに至る理由を本記事で述べます。

想定するビジネスシーン

下記画像のお菓子を作って販売することが業務ドメインであるビジネスの生産管理システムを作成します。

Star History Chart

ビジネス上の必要性から、現物がひとつひとつが製造されるごとに生産管理ソフトウェアではインスタンスがひとつひとつ生成されます。

生産管理ソフトウェアの設計思想

このお菓子を製造する際には、「生地」「中身」「焼き加減」を指定して「焼く」業務プロセスがあり、生産管理ソフトウェアでもそれをプログラム的に表現する実装を行います。

このお菓子は「今川焼き」、「大判焼き」、「回転焼き」など、地域や材料によってさまざまな呼ばれ方をします。

「今川焼き」、「大判焼き」や「回転焼き」は「どれがどれの派生である」「どれがどれの別名である」ということはなく、ビジネス要件上はっきりとした違いがあるものとして、生産管理ソフトウェアでも実装としてそれを表現しなくてはなりません。

そこで、このお菓子を抽象概念化した名前である「ベイクドモチョチョ」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していない」インスタンスをリストに追加している部分についてエラーを出してくれます。

image.png

エラーが発生しているコードを改修して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)]

このコードの問題点

しかしこのコードは保守性が十分に良いとは言えません。

Mochocho.bakeメソッドでselfを返す行で型チェッカーがエラーを検出しています
image.png

どうしてこうなるのか、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を作成し、技術的負債を返済する計画を立てるべきです。

  1. このお菓子のもうひとつの抽象概念名として議論されている「アンコリーノ」は「中にあんこを入れられる実装をされる」が前提となった名前で、この名前で開発・保守を進めると将来的にカスタードクリームを入れられる実装にできなくなる可能性があります。「このお菓子をそれたらしめるのは中身ではない」という観点で依存性逆転の原則に反した名前なので採用しませんでした。

  2. 今川焼きの状態としての生焼けではないです。

16
9
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
16
9

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?