Pythonで型検査しようぜ
やりたいこと
- Pythonのコードを書く際に型注釈と型検査を用いることで,実行前にバグなりそうなエラーを潰す
- 他の人が読むとき処理を追いやすいコードを書く
- 型の重要性を知ってもらう
背景
今はPythonでgRPCのapi書いたり, 機械学習のコードをぶん回したりしてるのでPython漬けの生活です.
「退屈なことはPythonにやらせよう」
なんて言われるぐらいには書きやすいPythonですが,しかし,書きやすさと読みやすさ, 安全性が比例するとは限りません.
OSSやプロダクトにおけるコードというのは書くことより読まれることの方が圧倒的に多いです. それなら多少の利便性は犠牲にしてもシンプルな文法や安全な規則で読みやすさや実行の安全性を担保するべきです.
その結果としてGoやRust, 少し異なりますがTypescriptなどが現在流行っているとも思っています.
例として次のようなPythonのコードを見てみます.
name = "hoge" print(name) name = 1 print(name)
こういうコードは割とよくあります.完全にnameが意味することが変わっています. 上記は1個目の代入文と2個目の代入文が近いのですぐに意味が書き換わっていることがわかりますが,プロダクトのコードだったら二つの代入文の間にたくさんの処理が挟まったり,関数内の副作用で書き換わる可能性もあります.
ではもう一つ例をみてみます
class A: def name(self): print("A") class B: def name(self): print("B") a = A() a.name() a = B() a.name()
このコードが動くということがどれだけ恐怖なのかはGoのような静的型付け言語を書く人ならよく理解できるのではないでしょうか.
これはpythonやrubyのような動的型付け言語でよくある問題です. こういった型の束縛を無視したコードは絶対に潰すべきです.
今回は可読性と実行時の安全性を担保するためにPythonにおける静的型検査と型注釈の導入について述べようと思います.
型とは
まずプログラミング言語における型とは一体なんでしょうか.
そもそもプログラムが扱う値というものはコンピュータから見ればただのメモリやハード上のバイト値です.
型はそのメモリ上に連続するバイトの塊の解釈の仕方を変えることで人間に扱いやすくするものという認識です.
0x01 => int: 1, char: 'a'
このような感じですね. 型というものがあるおかげで私たちはプログラムでより複雑かつ抽象的なロジックを記述できるようになりました.
型安全
型は現代の高級プログラミング言語における式評価において重要な意味を持ちます.
一つ例をあげてみます
#include <stdio.h> #include <string.h> int main(void) { int i = 1; char c = 'a'; // int + char = ? printf("result = %d\n", i + c); // segmentation fault strlen(i); }
このcのプログラムをコンパイルして実行してみます.
type_checker git:(feature/mypy) ✗ gcc type.c type.c:11:12: warning: incompatible integer to pointer conversion passing 'int' to parameter of type 'const char *' [-Wint-conversion] strlen(i); ^ /usr/include/string.h:82:28: note: passing argument to parameter '__s' here size_t strlen(const char *__s); ^ 1 warning generated. type_checker git:(feature/mypy) ✗ ./a.out result = 98 zsh: segmentation fault ./a.out
コンパイル自体はwarningが出ていましたが, 成功しています. しかし, 実行は途中でセグフォッています
まず, int + charの足し算が成立してしまうこと自体結構アレですね.
セグフォの原因はstrlenに渡したiがint型なのでstrlenではcharのポインタだと思っていてそのポインタ(おそらく 0x01)にアクセスしようとしたら当然権限外なので落ちたわけですね.
c言語はこのように暗黙の型変換や型における意味論の未定義動作から型安全な言語はありません. 型とはプログラミング言語の構文規則上は大丈夫でも, 式の意味を正しく評価するために存在しているわけです.
なんでもありにすると開発者も大変ですからね.
Pythonにおける型安全
ではPythonにおける型はどうなのでしょうか
Python は実行時に型の代入, 評価を行う動的型付け言語です.
最初に挙げたコードが動いてしまうことから不安はありますが, 次のようなコードはしっかり落ちます
Python 3.7.1 (default, Dec 14 2018, 13:28:58) [Clang 4.0.1 (tags/RELEASE_401/final)] :: Anaconda, Inc. on darwin Type "help", "copyright", "credits" or "license" for more information. >>> 1 + "hoge" Traceback (most recent call last): File "<stdin>", line 1, in <module> TypeError: unsupported operand type(s) for +: 'int' and 'str'
なのでpythonも変数の型束縛がないことを除けば, ほぼ型安全な言語だと言えます.
中間まとめ
ここで一旦今までのことをまとめてみます
- 型はプログラムが構文的に正しくても式の意味が正しいかをチェックするための重要な要素(式における型の評価規則を定義したものを意味論という)
- C言語のような言語は型安全ではない.
- pythonは変数の束縛がないので若干怪しい部分もあるがほぼ型安全.しかし, インタプリタ言語なので実行時までそのエラーはわからない
- ただし, 変数に型を指定することがないので可読性は低い
というわけで最初にも言ったように, 静的型検査とPython3.5から導入された 型注釈(Type annotation) を導入することによって以下のメリットを提供します
- 可読性の担保: 新しい開発者の参入障壁を下げることにも繋がります
- プログラマの意識改善(Pythonだからという理由で書き捨てのコードになっていい理由はない).
- 実行前に型エラーを検出することでデバッグの手間を減らす
実際に導入する
型注釈
Python3.5から次のような記法がサポートされるようになりました
def add(x: int, y: int) -> int: return x + y
引数の値と返り値の型を記述できます. これだけでもかなりコードは読みやすくなります.
しかし, 実行時に特に制限はかかっていないので上記のコードを次のように変更しても実行はできます
def add(x: int, y: int) -> int: # return x + y return "hoge" if __name__ == '__main__': add(1, 1)
そこで mypy という型チェックツールを導入します
インストールは pip install mypy
でOKです
先ほどの間違ったコードに対して検査をかけます
type_checker git:(feature/mypy) ✗ mypy example_type.py example_type.py:3: error: Incompatible return value type (got "str", expected "int")
ちゃんと定義した返り値の型と違うと怒ってくれています
また, 次のような定義済み変数の型を書き換えてしまう現象もしっかり検出できます
class User: def __init__(self, name: str) -> None: self.name: str = name def change_name_type(self, id: int) -> None: self.name: int = id if __name__ == '__main__': u: User = User("katsuya") u.change_name_type(1) print(type(u.name))
type_checker git: main.py:6: error: Incompatible types in assignment (expression has type "int", variable has type "str")
これで概ねやりたことは可能になりましたので今実装中のコードに入れたりしてみました. いい感じにやばそうなコードを検出してくれます.
小ネタとして関数ポインタのように引数を扱いたい場合はこんな感じで定義します
from typing import Callable def load_network(scale: int) -> Callable[[tf.Tensor, bool, bool], tll.InputLayer]: if scale == 2: return network2 elif scale == 4: return network4 else: return network4
ただ, mypyは型注釈したコードのみを検出して評価するので型注釈のないコードで型エラーがあっても素通りしてしまいます. しっかりと開発者が型注釈を用いること前提です.
また, 型の不一致などがあっても, 実行自体はできてしまうため, デプロイ前には必ずmypyによる検査を挟んでエラーがあれば実行しないというようなスクリプトを組む必要があるでしょう.
所感
可読性と実行前に安全性を担保するためにmypyと型注釈の導入を試しましたがかなり良い感じだと思います.
あとは開発者やレビュアーが型注釈に対しての心構えを持つこととmypyを用いたデプロイフローを用意すればより堅牢なPythonアプリケーションを実装することができると思います.