FJCT Tech blog

富士通クラウドテクノロジーズ公式エンジニアブログです

富士通クラウドテクノロジーズ

FJCT/Tech blog

【CI戦術編 その5】Pythonで明示的に型を書く理由

クラウドインフラ本部ネットワークサービス部の田上(id:rtagami)です。 オーディオインタフェースも液タブもまだ買っていませんが、 なぜか3Dプリンターを先に買ってしまいました。 早速、(既に買ってあったものの良い設置方法がなく困っていた)Wi-Fi 6のアクセスポイントを設置するためのブラケットの制作などに役立っています。

今回はPythonに型ヒントが導入された経緯と現状を軽くまとめた上で、 書き手の意図が伝わるコードを書くことやコードの変更に問題がないことを確かめることに、 型ヒントがどう役立つかをご紹介します。

なお、前回の記事は 【CI戦術編 その4】Blackを利用したコーディングスタイルの統一 でした。

Pythonにおける型ヒント

Pythonは動的型付け言語であり、オブジェクトの型は実行時に決定されます。 しかし、動的に型付けられたコードは開発者が全容を理解するのが難しく、 コードベースの規模が大きくなってくるとタイプミスや実行時に決定される型に関する問題を見つけることが困難になります。

この問題に対応するため、 Pythonに静的型付け言語のメリットをもたらす「型ヒント」がPythonの言語仕様に導入されました。 Pythonにおける型ヒントの詳細は PEP 484公式ドキュメント に記載されており、これらが今回の記事の一次情報となります。

静的型付け言語と違ってPythonの型ヒントはあくまでヒントであり、 型ヒントを書くことは強制されていませんし基本的に書いた型ヒントは実行時には尊重されません。 しかし、型ヒントをIDEや静的解析ツール(型チェッカー)が処理することによって、 タイプミスや型に関する問題を事前に検知できます。

Python自体は型ヒントの処理系を提供していませんが、 mypy, pyre, pyright, pytype といった複数のプロジェクトがPEP 484に従った型チェッカーの実装を提供しています。

なぜ型ヒントを書くのか

書き手の意図が伝わるコードを書きたい

あるコードを書くのは一度きりの行為ですが、 一度書いたコードは何度も読まれることになります。 そのコードを読むのは書いた人ではない可能性がありますし、 書いた人も数カ月後には何を考えながら書いたのかを忘れていることでしょう。

コードを書くときに、 将来の読み手にコードの意図が明確かつ簡潔に伝わることを意識して書くようにすることで、 将来の読み手の認知負荷を下げてコードの書き換えがうまくいく確率を上げることができます。

コード内のコメントや外部のドキュメントを書くことによって書き手の意図を伝えることも可能ですし、 テストコードも書き手の意図を将来の読み手に伝える手段の1つです。 しかし、いずれにせよ価値を生み出すプロダクションコードは書かなければならないので、 書き手の意図もあわせてプロダクションコードで伝えられればそれが最善です。

mypyのドキュメントから簡単な 関数の例 を引用します。

アリスが以下のような greeting 関数を書きました。

def greeting(name):
    return 'Hello ' + name

ボブが以下のように greeting 関数を呼び出してみました。

greeting(123)
greeting(b"Alice")

ボブが書いたコードは以下の通り実行できません。

Traceback (most recent call last):
  File "/home/rtagami/work/blog/test.py", line 5, in <module>
    greeting(123)
  File "/home/rtagami/work/blog/test.py", line 2, in greeting
    return "Hello " + name
           ~~~~~~~~~^~~~~~
TypeError: can only concatenate str (not "int") to str

スタックトレースからわかるように、 str に連結できるのは str だけだからです。 アリスは name 仮引数が str しか取らないことを意図していましたが、 ボブにはその意図が伝わっていなかったようです。

アリスは以下のように greeting 関数を型ヒント付きで書き直しました。

def greeting(name: str) -> str:
    return "Hello " + name

これで greeting 関数は name 仮引数に str を取り、 str を返す関数であるという意図がボブにも伝わるようになりました。

この例はかなり恣意的なものになってしまいましたが、 関数の本文が長い場合や内容が複雑な場合、 ライブラリなどの遠く離れたモジュールに定義された関数などは、 型ヒントの助けがあると正しく使いこなせる可能性が高くなります。

型ヒントはコメントやドキュメントと違って人間だけでなく機械も容易に理解できるので、 書き手の意図に従って書いているかどうかを機械に確認してもらうこともできます。

書き直された greeting 関数をボブが以下のように呼び出してみました。

greeting(3)
greeting(b"Alice")
greeting("World!")

これをPEP 484に従った型チェッカーの実装であるmypyに通してみます。

$ mypy test.py
test.py:5: error: Argument 1 to "greeting" has incompatible type "int"; expected "str"  [arg-type]
test.py:6: error: Argument 1 to "greeting" has incompatible type "bytes"; expected "str"  [arg-type]

2つの関数呼び出し greeting(3)greeting(b'Alice') にはエラーが検知されています。 型ヒントを書いたことによって、実際に関数を呼び出すことなしに関数を正しく使えていないことがわかりました。

変更に問題がないことを確かめたい

ソフトウェアは一度書いたら終わり、ということはなく、常に変更され続けるものです。

ソフトウェアは常に機能が追加・削除されますし、バグが発見されたら修正する必要もあります。 使用しているフレームワークやライブラリは定常的に更新しないと陳腐化してしまいますし、 ぜい弱性が発見されたという理由で急いで更新する必要がある場合もあります。

テストを書けば変更に問題がないことを保証できますが、 テストを書いて維持することには相応のコストがかかりますし、 ありとあらゆる状況を想定したテストを書くことは現実的には不可能です。

テストを書かずとも変更に問題がないことを保証できるのであれば、 テストを書かないことを戦略的なチョイスにできます。

例えば以下のようなコードがあったとします。

from typing import TypeAlias

A: TypeAlias = str | int


def func(a: A) -> str:
    if isinstance(a, str):
        return "str"
    else:
        return "int"

このコードは現時点では想定した通りに動きます。

1年後に要件の変更があり、 A の型は strint だけでなく float も含めることになりました。

from typing import TypeAlias

A: TypeAlias = str | int | float


def func(a: A) -> str:
    if isinstance(a, str):
        return "str"
    else:
        return "int"

型システムの視点では何の問題もないコードなので、mypyに怒られることはありません。

$ mypy test.py
Success: no issues found in 1 source file

もしテストを書いているのであれば、 テストを見直す際にテストケースが足りていない (funcafloat な値を与えて "float" という文字列が返ってくるテストがない) ことに気づきます。

しかし、テストを書かずに済ませられるものであれば、書かずに済ませたいところです。

ここで、元のコードにExhaustiveness Checkを導入してみましょう。

from typing import TypeAlias, assert_never

A: TypeAlias = str | int


def func(a: A) -> str:
    match a:
        case str():
            return "str"
        case int():
            return "int"
        case _:
            assert_never(a)

mypyはタイプナローイングを行います。 つまり1つ目の case に入らなかった時点で a の型が str でないことを理解しており、 同じく2つ目の case にも入らなかった時点で a の型が int でないことを理解しています。 Astr | int と定義されているので、 a は型として strint しかありえず、 よって3つ目の case に入る方法は存在しない事が型システムによって確認されています。

では要件を変更して Afloat も与えられるようにしましょう。

from typing import TypeAlias, assert_never

A: TypeAlias = str | int | float


def func(a: A) -> str:
    match a:
        case str():
            return "str"
        case int():
            return "int"
        case _:
            assert_never(a)

この状態でmypyに通すと以下のように怒られます。

$ mypy test.py
test.py:13: error: Argument 1 to "assert_never" has incompatible type "float"; expected "NoReturn"  [arg-type]
Found 1 error in 1 file (checked 1 source file)

afloat である可能性を考慮していないコードになっていることにmypyが気づいてエラーを出してくれました。 タイプナローイングによって型が絞り込めておらず、本来到達できないはずの3つ目の case に到達してしまったためです。

このように、適切に型ヒントを使いこなすことによって、 テストを書かずとも変更に問題がないことを確認できるようになります。

わたしたちの型ヒントの導入状況

冒頭でも書いたとおり、Pythonには複数の型チェッカーの実装が存在します。

型チェッカーの実装によって検出精度や実行速度にある程度の違いがありますが、 わたしたちはこれらの中から本記事の例示にも使用したmypyを採用しています。

以前ご紹介した各種フォーマッタと同じようにCIの中でmypyを実行するようにしており、 プロダクションコード全体が --strict オプション付きのmypyで検査されています。

--strict オプションを付けている場合、 完全な型ヒントを書いていない関数(およびメソッド)は定義できなくなり呼び出すこともできなくなります。 これにより、プロダクションコード内のすべての関数に型ヒントが書かれており、 また指定した型に不整合がないことを確認できていることになります。

まとめ

今回の記事では、 型ヒントをうまく使いこなすことによって書き手の意図が伝わるコードを書けるようになり、 変更に問題がないことを確かめられることをご紹介しました。

冒頭でも書いた通り型ヒントは実行時には尊重されませんので、 型ヒントを書かなくてもプロダクションコードは書けます。 そして、型ヒントを導入・維持するコストはゼロではありません。

しかし、コード内のコメントや外部のドキュメントあるいはテストコードとくらべると型ヒントは導入・維持するコストが低く、 型ヒントのみで十分書き手の意図を伝えられ変更に問題がないことを確認できる場合も多くあります。

適切に型ヒントを使うことでドキュメントやテストコードを戦略的に削減し、 価値を生み出すプロダクションコードを書くことにより時間を割けるようになりますので、 まだ型ヒントを導入されていない方はぜひこの機会に導入をご検討ください。

次回は型ヒントを書かなくてもPythonのコードの内容を静的解析できるPylintをご紹介する予定です。 お楽しみに。

連載バックナンバー