頑張らないために頑張る

ゆるく頑張ります

Pythonにおける型アノテーションとmypyによる型チェック

Posted at — Jun 14, 2023

動的型付け言語のおさらい

ご存じの通り、Pythonは動的型付け言語ですので、関数や変数などのオブジェクトに対して型の宣言を強制することはありません。動的型付け言語では、値自体に型情報が含まれており、変数には固定された型情報が存在しません。

hoge = 123
fuga = 'ham'

ここでは変数hogeに数値が、fugaには文字列が格納されています。

hoge = 123
hoge = 'ham'

先述の通り、Pythonのような動的型付け言語では、変数には型情報が付与されません。そのため、上記のようなコードもエラーになりません。

一方、静的型付け言語であるC言語やJavaでは、変数hogeの宣言時に型情報を指定します。

#include <stdio.h>
#include <stdlib.h>

int hoge(int num) {
    return num * 2;
}

int main() {
    char str[BUFSIZ];
    
    sprintf(str, "%d", hoge(3));
    printf("%s", str);
    
    return 0;
}

たとえばC言語だと、上記のように関数や変数の宣言時に型を指定します。

そのため、先述のPythonコードの例において、もし変数hogeに数値型であるという型情報が付与されている場合、文字列を代入しようとする行でエラーが発生するわけです。

型が想定と違ったら

def piyo(foo):
  return foo * 2

上記の例だと、関数piyo()は引数を取りそれを2倍した結果を返しています。その実装から推測すると、引数fooはおそらく数値が入ることを前提としているように見えます。

ところが、関数piyo()の引数に文字列を入れても動作します。つまり、本来想定しているであろう型以外の型を代入することが可能です。たとえば真偽値とかリストとか辞書とか。これって大丈夫?

もちろん、大丈夫ではありません。しっかり実行時にエラーを吐きます。

ただし、エラーを吐かないケースもあります。

>>> def piyo(foo):
...   return foo*2
...
>>> piyo(13)
26
>>> piyo('hoge')
'hogehoge'
>>> piyo(['a', 'bc', 3])
['a', 'bc', 3, 'a', 'bc', 3]
>>> piyo({'ham': 10, 'eggs': 20, 'spam': 30})
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "<stdin>", line 2, in piyo
TypeError: unsupported operand type(s) for *: 'dict' and 'int'

上記の例ですと、文字列やリストはとりあえず結果が返ってきます。ただ、辞書などはエラーになります(これは後述)。

上記で「引数が数値であることを前提にしているように見える」とあいまいに書いたのは、これが原因です。つまり、必ずしも数値のみを前提としているわけではない可能性があるためです。Pythonでは、文字列*2というコードを実行すると「文字列文字列」という結果が返されます。そのため、こちらの挙動を目的としていた場合、引数が文字列なのは正規の使用方法です。いずれにしても、ここでは関数の目的がコメントなどで明示されていないため、文字列を繰り返す用途よりも数値を2倍する用途が一般的だと仮定しました。

このようなどっちつかずの状況を避けるには、関数を実装する際にその関数の目的を明記することが重要です。そうしないと、上記のようにメンテナンスの際に問題が生じる可能性があるわけですね。コメントはこまめにね!

動的型付け言語特有の問題

piyo({'hoge': 'foo', 'fuga': 2})

問題となるのは、本来想定していた型とは異なる型が代入される場合などです。上記の例では、関数piyo()は引数として数値を想定しているのに、辞書型のオブジェクトが渡されています。このコードは正しく動作するでしょうか。まぁ、しそうにないですよね。

>>> piyo({'hoge': 'foo', 'fuga': 2})
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "<stdin>", line 2, in foo
TypeError: unsupported operand type(s) for *: 'dict' and 'int'

予想通り、実行時エラーが発生します。

静的型付け言語では、このようなデータ型が異なるという問題はコンパイル時に検出され、コンパイルエラーとして報告されます。しかし、Pythonは動的型付け言語であるため、このような問題は実際にコードを実行するまでエラーが発生しません。実行してはじめてエラーに気付き、コードを修正することになります。このようなバグを開発中に気付くことができたなら、対処はできるでしょう。ところがです。「デバッグしても気づかなくて、正式リリースしたあとにバグが顕在化した」なんてことになったら大変なことになります。

この問題を回避するためには、意図しない型が入力されないように制御する必要があります。ただし、Pythonのような動的型付け言語では、「コンパイルエラーによる型の不整合の排除」はできません。そのため、コンパイル時に対処することができない場合は、実行時のエラーを避けるためにコードで適切な制御を行う必要があります。

型アノテーション

これらの問題を解決するため、Python3.5から型アノテーションがサポートされることになりました。

>>> hoge: int = 123
>>> hoge
123
>>> type(hoge)
<class 'int'>

型アノテーションは、変数や引数などを宣言する際に<名前>: <型情報>という形式で型情報を付与することを意味します。

従来通り、pydocなどのコメントに型情報を記述することも可能ですが、型アノテーションを使用すると明確に「このオブジェクトには指定したXXX型の値が入ることを期待している」と表現できます。さらに、IDEによる入力支援も期待できます。

なお、先ほどの例ではtype()の結果がintになっていますが、これは型アノテーションの効果ではありません。実際に入力された値123を参照しており、変数hogeは現時点では数値型であると解釈されているだけです。

>>> hoge: int = 123
>>> hoge
123
>>> type(hoge)
<class 'int'>
>>> hoge = 'foo'
>>> type(hoge)
<class 'str'>

つまり、型アノテーションは単に「この型を想定している」という注釈であり、型チェックが実行されるわけではありません。

実際に上記のようにint型と宣言された変数hogeに対して文字列を代入しても、処理は継続されますし、type()関数の結果は普通にstr型となります。つまり、書かれた型アノテーションは無視されてしまうのです。したがって、型アノテーションはあくまでも注釈であり、それ以上の意味を持ちません。

>>> def hoge(foo: int, bar: str) -> str:
...   return bar * foo
...
>>> hoge(3, 'ham')
'hamhamham'
>>> hoge(2,3)
6
>>> hoge(2, [1, 'eggs'])
[1, 'eggs', 1, 'eggs']
>>> hoge(2, {'aaa': 'bbb', 'ccc': 4})
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "<stdin>", line 2, in hoge
TypeError: unsupported operand type(s) for *: 'dict' and 'int'

関数においても、型アノテーションを記述できます。先ほどの例では、def function_name(arg: <type>) -> <return type>:の形式で型アノテーションを行います。関数のアノテーションも、前述の通りあくまで注釈です。そのため、引数に「数値と文字列が必要」というようにアノテーションを記述しても、実際には型チェックが行われないため、hoge(2, 3)のような「アノテーションに反している」入力値を受け取ってしまう可能性がある点に留意する必要があります。

mypyで型チェックしてみる

「型アノテーション書いても、注意喚起だけでチェックしてくれないんじゃ意味ないじゃん」と言われると、まぁその通りなわけです。IDEの仕組みに頼るのもありですが、ここは「それ用のツール」を導入します。

それはmypyです。これを導入することで、型アノテーションをもとにコードの型チェックを行うことが可能です。

mypyはPythonの静的型チェッカーです。型チェッカーは、コード内の変数や関数を正しく使用しているかどうかを確認するのに役立ちます。mypyを使用すると、Pythonプログラムに型ヒント(PEP 484)を追加し、型が正しく使用されていない場合に警告が表示されます²。

mypyをインストールするには、pipを使用します。

pip install mypy

mypyがインストールされたら、mypyを実行します。

mypy hoge.py

蒸気のコマンドを実行すると、hoge.pyファイルを型チェックします。なんらかのエラーが見つかったら、そのエラーを出力します。

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

たとえば、上記のようなコードを含むファイルがあるとします。

この関数は、動的型付けされているとmypyによって判断されます。型情報が書いてないわけですからね。デフォルトでは、mypyは動的型付けされた関数の型チェックを行いません。

$ cat hoge.py
def greeting(name):
    return 'Hello ' + name

greeting(1)

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

実際にmypyで先述のコードをチェックしてみた結果です。「Success」って言われてますね。

これは、いくつかの例外を除けば、通常の注釈のないPythonコードにおいては型チェックが実行されずエラーが報告されないことを意味しています。ただ、引数に想定している型以外のデータが入力されると困るからチェックしたいのに、このままだと型チェックされないっていうのはちょっと待てと言いたくなります。

じゃあ、mypyでエラーを検出するにはどうするかって言うと、型アノテーションを追記するわけです。

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

たとえば、上記のように型アノテーションを追記することで、greeting()関数が文字列を受け取り文字列を返すことをmypyに伝えることができます。これで、mypyは提供された型アノテーションを使用して、greeting()関数の不正な使用やgreeting()関数内の変数の不正な使用を検出できるようになります。

$ cat hoge.py 
def greeting(name: str) -> str:
    return 'Hello ' + name

greeting(1)

$ mypy hoge.py 
hoge.py:4: error: Argument 1 to "greeting" has incompatible type "int"; expected "str"  [arg-type]
Found 1 error in 1 file (checked 1 source file)

追記された型アノテーションの情報に準拠しつつ、今度こそmypyが型チェックを実行してくれます。めでたく(?)、エラーが表示されています。

mypyを使わない型チェック

mypyを導入しなくても一部のIDEでは、型アノテーションの内容に基づいて解析を行い、型が不一致の場合などには警告を表示する機能も存在するようです。ただし、これはIDEごとに対応が異なるため、Python自体の機能ではない点は注意が必要です。

まとめ

実は今回の話、「ロバストPython」という本で型ヒントについて学んだアウトプットです。最近買ったばっかりでまだ読み終わってないのですが、Pythonを使って保守性高く開発しようとするなら必読書ではないでしょうか。わりと冒頭部分の正論連打で心が折れそうですが_(:3」∠)_

Pythonはシンプルで構文も比較的読みやすい動的型付け言語であるため、ガーッとプログラムを書いてとにかく動作させることを最重要視するのももちろんひとつの観点です。が、ロバストにする機能もしっかり備えています。せっかくそんな便利機能があるのですから、使わない手はないわけです。クリーンで保守しやすいコードが書けるようになりたいものです。

参照

  1. typing — 型ヒントのサポート
  2. PEP 484 – Type Hints
  3. ロバストPython
  4. 『ロバストPython』を監訳しました
  5. Pythonの関数アノテーションと型ヒント、typingモジュール
  6. mypy
  7. 最強のPython型チェッカーmypy
  8. Type hints cheat sheet
comments powered by Disqus