リストを受け取ってループで処理する関数を実装するとき、引数のタイプヒントに list ではなく最小の要求として Iterable を書くことを好む人がいる。コードの実装が引数に対して必要としている最小要件(必要十分条件)を表すためだ。
def func(arg: Iterable[int]) -> None: for a in arg: do_work(a)
しかし、その関数でログかトレースにその引数の中身を追加したくなった場合にどうしたらいいだろうか?
OpenTelemetryのAttributeValue型はSequenceには対応しているがIterableには対応していない。
また、Iterableを一度巡回してしまうと再び巡回できる保証はないので、 arg の中身を複数回使うことができない。
引数のタイプヒントをlistかSequenceに修正しようと思っても、他のコードも「最小要件原則」で書かれていると大量の呼び出し元のコードのtype hintも次々に修正しないといけなくなる。もしこの関数のユーザーがチーム外で、後方互換性を保つ必要があるのであれば、そもそもこの修正はできない。
そこで諦めてタイプヒントを修正せずに対応すると次のようになる。
def func(arg: Iterable[int]) -> None: # arg : list[int] = list(arg) # Mypyは再定義をエラーにする。 arg = list(arg) with tracer.start_span("func") as span: span.set_attribute("arg", arg) for a in arg: do_work(a)
ここで3つのコストが発生した。
- 一度修正を試みてから、影響範囲が広いからという理由で修正を断念するまでの作業コスト
- 引数を毎回
list(arg)する実行コスト - argの型が途中で
Iterableからlistに変わることによる認知負荷。(list型に別の名前をつけても2つの変数の認知負荷になるだけである。)
もし、この関数が最初からlistを受け取る用途しか想定していないなら型ヒントには list を使うべきだったし、listかtupleのどちらかを受け取ることを想定していたならlist|tupleかSequenceを使うべきだった。このように、タイプヒントには「(今の)実装が求める最小要件」ではなく「想定している引数の型の範囲」を表すべきである。
しかし、想定する引数の型を最初から完全に決めるのは難しい。試しにこの関数のtype hintがlistだったのにtupleを渡したくなった場合を考えてみよう。上の例と逆に「具体的すぎた」ケースだ。
- タイプヒントを
list | tupleかSequenceに修正する場合、既存の呼び出し元はlist型の値を渡しているので芋づる式に大量の呼び出し元の修正は必要ない。 - タイプヒントを変えずに呼び出し元で
list(arg)に変換する場合も、変換コストがかかるのはその1箇所だけで済む。
このように、「実装の最小要件」を使うポリシーよりも「必要になるまで具体型を使う」ポリシーの方が対応コストが低くなることが多い。どこまでの抽象度の型を受け取るべきか判断を後回しにしたい場合は、とりあえず具体型を使うことにしよう。変更の必要が生じた時は、その関数のあるべき仕様をより正しく理解してタイプヒントを書けるはずだ。
戻り値についても考えてみよう。戻り値を list から Sequence に変更するのは破壊的変更になるので、特にライブラリの公開APIのように利用側コードが別チームで開発されている場合は簡単に変更できない。ただしこれはタイプヒントだけの問題ではない。タイプヒントのないコードでも、 list を返していた関数が tuple 型を返すようになったら破壊的変更になる。だから破壊的変更を恐れてなるべく抽象度の高い型を選ぶ必要性は薄い。そもそも戻り値の型をlistから別のシーケンス型に変えるケースなんてどれくらいあるだろうか?稀に内部処理を変更して処理結果が tuple になることがあったとして、後方互換性を保つ必要があるならlistに変換して返せばいいだけだ。なので、引数よりは少し気を遣うとはいえ、最初から list と書いてしまって良い場合が多い。
結論:
- タイプヒントには「実装の最小要件」ではなく「想定範囲」を表す型を書く。
- 迷ったらとりあえず具体型を書いて、必要になってから抽象型に変える。