Pythonにおける型アノテーション(typing)はバージョン3.5で導入されて以降バージョンアップごとに段階的に強化されてきた。今や型アノテーションがないコードはレガシーと言わんばかりになったが、その効力は強制力を持つものではなく、しばしば悩みの種にもなりうる。

型アノテーションの基本

型アノテーションはこのように書く。関数の引数や返値、変数宣言などでも利用できる。

def add(a: int, b: int) -> int:
    return a + b

しかし、始めに書いたようにこの型アノーテーションには強制力がなく、あらゆる値の組み合わせで add() 関数を呼び出すことができる。ただし、オブジェクト ab の間に + 演算1が定義されていることが期待されており、そうでなければ実行時に計算エラーになる。

コレクション型のアノテーション

コレクション型については collections.abc モジュールに分離されている。これらはどちらかといえば型アノテーションよりもコレクション型のインタフェースを提供するモジュールであり、「どういう型を返すか」というよりも「どういう処理ができるか」を表現することに主眼が置かられている。

typingとcollections.abcで同じ名前のアノテーションがあるときには、collections.abcのほうが推奨されるようだ。ただアノテーションを付け始めると必然的に両方のモジュールが必要になる状況にあり、あまりイケていない。

型アノテーションの簡素化

Python 3.9 からは標準のコレクション型が使えるようになり、3.10からは複合型のアノテーションが簡素化された。| 演算子を使うことで、typingモジュールなしで定義できるようになった。

# Python 3.9
List[str]  ->  list[str]
 
# Python 3.10
Mapping[str, None]  ->  str | None

ジェネリック型の表現

TypeVarを使うとジェネリック型を表現できる。Anyと異なるのは同じジェネリック型名を持つならば、同じ型を持つことが期待されることだ。最近の静的型付け言語ではジェネリック型は一般的になっており、これによって複数の型に対して同じ定義の関数をいくつも書かなくていいようになっている。

Pythonは動的型付けなので最低限コードが実行できればよく、ジェネリック型の恩恵は少ないが、型が明示されていないと死んでしまう型狂信者のためにこうしたジェネリック型が用意されている。

T = TypeVar('T')
 
def double(value: T) -> T:
    return value * 2

また、特定の型であることやインタフェースであるという制約を課すこともできる。

# Tはintかfloatでなければならない
T = TypeVar('T', int, float)
 
# Uはintのサブクラスでなければならない
U = TypeVar('U', bound=int)
 
def double(value: T) -> T:
    return value * 2

種々の問題

アノテーションは型チェッカのためのもの

現状として型アノテーションは単なるヒントであって、実行時に特定の型であることを求めるようにはできない。不正な値が与えられても動いてしまうし、それによって偶然うまく動いてしまうこともある。例えば値の読み書きをするような単純なロジックではなんら値が疑われることなく不正な値が混入しうる。

そうなると実行時に正しい型が与えられているかどうかを確認するためのデコレータ関数やassert文を書きたくなるのだが、こうした行為は本末転倒である。あえて強い動的型付け言語を使った柔軟なプログラミング体験をよしとしているのにも関わらず、静的型付けを実行時に行うという効率の悪いことをしているからだ。

もしPythonのプロジェクトがある程度大きくなって、静的型付けでないと正確さが担保できないという状況になってきたら、別の言語に移植することを検討してもいいのかもしれない。

型を突き止めるには工夫が必要

ライブラリの型を突き止めるのは少々骨の折れる作業となる。Flaskの flask.render_template() の返値やOpenCVのテンソル型の定義がパッと出てくるだろうか?これはライブラリのソースコードを辿ってみることでしかわからないし、辿ってアノテーションをしたところで余計な import が増えるという悩みが生まれる。

せっかく型を突き止めたとしてもエディタの補完機能にあまり寄与しないこともあり、型アノテーションのやりすぎは本質ではない作業を生み出しかねない。場合によっては返却される型がひとつではないこともあり、さらなる混沌を招く。

アノテーションが必要とされる場合もある

injectorのようなDI2ライブラリでは型アノテーションの定義にしたがって依存性を注入するし、SQLAlchemyといったORM3ではテーブル定義と型を一致させるために正確さが求められる。とはいえ、そういった前提に基づいた設計になっているので、大きな問題にはならないが、型アノテーションをする習慣のないコードベースの中で急にアノテーションが出てくると違和感は発生しうる。

どのインタフェースを持つのかわからない

collections.abc の最大の悩みはこれだ。あるコレクション型を期待するときに、Collection で受ければいいのか、Iterable で受ければいいのか、Sequence で受ければいいのか、わかりにくい。型チェッカがない環境であれば検証もされないので、誤った指定をしていることもある。

とはいえ、Pythonではあまりイテレータやジェネレータを入力にすることはないので、抽象コレクション型で受けるよりも、list[str] のような実体となる型で受けてしまってもいいのかもしれない。実際にそこまで広い型で受ける必要もないし、動的型付けに利点を感じていたのにインタフェースに縛られることで余計な計算をすることにも繋がりかねない。

Footnotes

  1. __add__() メソッドを定義することに等しい。

  2. Dependency Injection: 依存性の注入。

  3. Object-Relational Mapping: オブジェクト関係マッピング。広義にはデータベースの型と言語の型を相互に変換して効率的にデータベースに問合せをするためのライブラリのことを指す。