Pythonで快適に開発する話【アドカレ2021 16日目】

プログラミング アドベントカレンダー2021

こんにちは。 cordx56です。 アドカレ大遅刻してしまいすみません。 今回はPythonで快適に開発する話を書きたいと思います。 ほぼほぼ私のおすすめPython機能&ツールみたいな感じの記事になります。 対象読者はPython初心者になります。 私もPython初心者なので、誤った点などもあるかもしれません。 優しくご指摘いただけますと幸いです。 よろしくお願いします。

言語機能

型注釈

Pythonは動的型付き言語であり、一般的に、実行前に変数や関数の返り値などの型を知ることができません。 これはつまり、静的型付き言語で一般的に行われている型検査を行うことができず、実際に実行するまで型エラーの発見ができないということになります。 小さいプログラムであればそれでも良いかもしれませんが、大きなプログラムではプログラムのすべてを実行してみて検査する、というのは現実的ではありません。 また、実行前に型がわかると、エディタの補完機能で型を利用することもできるようになります。 そういった背景から、Pythonでも実行前に型を知りたいという需要がありました。

そこで、Python 3.5より、PEP 484の定める型注釈が利用できるようになりました。 これにより、typingモジュールが追加され、型注釈が導入されました。

では、型注釈と型検査について学ぶため、以下のプログラムをgreeting.pyという名前で保存してみましょう。

def greeting():
    return "Hello, world!"

greeting() + 1

このプログラムを実行してみると、4行目のstr型とint型の加算実行時に型エラーになることがわかると思います。

$ python greeting.py
TypeError: can only concatenate str (not "int") to str

このような型エラーは実行前に知りたいところです。

上記のプログラムには型注釈がないので、greeting関数の返す値の型がわかりません。 上記のプログラムを以下のように変更して、保存してみてください。

def greeting() -> str:
    return "Hello, world!"

greeting() + 1

このプログラムは、greeting関数の返り値にstrという型注釈を付与したものになります。

ではこのファイルを対象に、一般的に広く用いられている型検査器であるmypyを利用して、型検査をしてみましょう。

mypyのインストールは

$ pip install mypy

で可能です。

mypyの実行ファイルがインストールされたディレクトリにPATHを通すのを忘れないようにしてください。 (PATHの通し方がわからなければ、調べてください。)

インストールが終わったら、実際に先ほどのソースコードに対して型検査をかけてみましょう。

$ mypy greeting.py
greeting.py:4: error: Unsupported operand types for + ("str" and "int")
Found 1 error in 1 file (checked 1 source file)

こんな感じで、エラーになったはずです。

このように、型注釈があるプログラムに対しては、mypyを用いて簡単に型検査を行うことができます。 ただし、mypyでは、型注釈のないプログラムに対しては、型推論が行われず、型検査が行われない場合もあります。 (例えば変数への定数の代入などは型推論が行われますが、関数の返り値は型推論されません。) つまり、より確実に型検査を行うためには、型注釈を書く必要があるのです。

例えば、次のようなプログラムを考えます。

def div(a, b):
    return a // b

print(div("10", 2))

このプログラムは当然実行時に型エラーとなります。 div関数に文字列"10"と整数2を渡していますが、str型とint型に対して // の演算はサポートされていないからです。

しかし、このプログラムをmypyで検査すると、次のようになります。

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

このように、mypyは型注釈のない関数の仮引数に対し、実引数と関数内の処理を見て型エラーが発生しないかまでは検査してくれません。

次のようなプログラムも同様にmypyの検査ではエラーなしとなります。

def div(a: int, b: int):
    return a // b

print(div(10, 2) + "1")

これも実行時に型エラーとなります。 int型とstr型の加算はサポートされていないからです。

mypyは型推論をして関数の返り値の型を求めることはしません。 よって、mypyでは関数の返り値に型注釈がない場合は型検査でエラーを報告することができません。

上記のようなプログラムに対し、事前の型検査でエラーを検出したい場合には、次のように型注釈を書いてあげる必要があります。

def div(a: int, b: int) -> int:
    return a // b

print(div("10", 2))     # Type Error
print(div(10, 2) + "1") # Type Error

型検査を書いたことで、mypyでの検査が通らなくなります。

$ mypy div.py
div.py:4: error: Argument 1 to "div" has incompatible type "str"; expected "int"
div.py:5: error: Unsupported operand types for + ("int" and "str")
Found 2 errors in 1 file (checked 1 source file)

ちゃんと型エラーが検出されるようになりましたね。

このように、型注釈を書くと事前の型検査でエラーを見つけやすくなったり、エディタで型の情報を利用した入力補完などができるようになります。

型注釈、書きたいと思ってもらえたでしょうか?

遅延評価

遅延評価と言えば一般にはHaskellなどが有名でしょう。

遅延評価とは、一般的に、必要になるまで値の評価を行わない評価戦略のことを言います。 ここでの遅延評価とは、Haskellなどの遅延評価とは異なるそうですが、公式ドキュメントなどでも遅延評価と呼ばれているため、この用語を利用します。

例えば、map関数を利用した次のようなプログラムについて考えてみましょう。

def map_test(x):
    print(x)
    return x

l = [1, 2, 3]
map_obj = map(map_test, l)

print(map_obj)
for v in map_obj:
    print("test")

このプログラムの出力は次のようになります。

<map object at 0x7fdd25493a90>
1
test
2
test
3
test

もしかすると、次のような出力を想像した方もいるかもしれません。

1
2
3
[1, 2, 3]
test
test
test

これは誤りです。

map関数はmapオブジェクトを返します。 mapオブジェクトはイテレータなので、for文が実行される度に map_test にリストの要素が一つずつ渡されて評価されます。 つまり、for文で値を取り出そうとするまで、map関数はmap_test関数を実行しないのです。 そのため、6行目でmap関数が実行されたときには2行目の print(x) は実行されず、for文で値を取り出そうとしたときに初めて print(x) が実行されます。

では、何故遅延評価という仕組みを利用しているのでしょうか。

遅延評価を行う理由に、不要なものを計算しないことで実行速度を上げる、ということが挙げられます。

例えば、次のようなプログラムを考えてみましょう。

def square(x):
    print(x)
    return x * x

l = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
m = map(square, l)

# mapの結果が10未満の値だけ取り出したい
print(list(filter(lambda x: x < 10, m)))

この出力は次のようになります。

1
2
3
4
5
6
7
8
9
10
[1, 4, 9]

出力からわかる通り、1から10までのすべての値に対して、 square 関数が実行されています。 今回扱っているリストは昇順でソート済みのため、5以降は計算する必要がないのに、無駄に計算されてしまっています。

では、次のようなプログラムはどうでしょうか。

def square(x):
    print(x)
    return x * x

l = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
m = map(square, l)

# mapの結果が10未満の値だけ取り出したい
result = []
while (value := next(m)) < 10:
    result.append(value)
print(result)

イテレータはnext関数を使うことで、次の値を取り出すことができます。

while文の条件式でmapオブジェクトの次の値を取り出し、もし10未満だった場合は結果のリストに追加、そうでなかった場合はループを終了して出力する、というプログラムになります。

実行してみましょう。

1
2
3
4
[1, 4, 9]

お、5以降が出力されていないですね!

出力から、mapオブジェクトから値を取り出す際に、10以上の値が出てきたらループを抜けているので、5以降を評価していないことが観察できます。

対象のリストがソート済みという条件こそありますが、mapの計算量を半分以下に減らすことができました。

非同期処理

少し難しい話になるので、ブロッキング / ノンブロッキングの説明は省略して(どこかの授業で習いますかね?)、ここではコードを動かしていきましょう。

次のようなプログラムを考えてみます。

import time

def task(t: float):
    time.sleep(t)

def main():
    for _ in range(5):
        task(1)

main()

ここでは、処理に一秒かかるtask関数があったとしましょう。 これを通常通り実行した場合、私の手元では実行に5.38秒かかりました。 1秒かかる処理をfor文で直列に5回呼び出しているので、実行時間が5秒を超えるのは当然ですね。

ここで処理に一秒かかるtask関数がノンブロッキングで実行できるとします。

import asyncio

async def task(t: float):
    await asyncio.sleep(t)

async def main():
    tasks = []
    for _ in range(5):
        t = asyncio.create_task(task(1))
        tasks.append(t)
    for t in tasks:
        await t

asyncio.run(main())

これを実行した場合、私の手元では実行に1.10秒かかりました。 だいぶ短くなりましたね! これは、タスクが並行に処理されているためです。

もう一つ、実用的な例を考えてみましょう。

import socket

def fetch(host: str, path: str):
    sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    sock.connect((host, 80))
    request = "GET {} HTTP/1.0\r\nHost: {}\r\n\r\n".format(path, host)
    sock.send(request.encode())
    response = b""
    while chunk := sock.recv(1024):
        response += chunk
    return response.decode()

def main():
    for _ in range(100):
        fetch("moudame.com", "/")

main()

これはsocketモジュールを用いてWebページにアクセスするプログラムです。 直列に100回実行しているので、それだけ時間がかかります。 私の手元では実行に1.67秒かかりました。

これをasyncioモジュールを使って書き換えてみましょう。

import asyncio

async def fetch(host: str, path: str):
    reader, writer = await asyncio.open_connection(host, 80)
    request = "GET {} HTTP/1.0\r\nHost: {}\r\n\r\n".format(path, host)
    writer.write(request.encode())
    response = b""
    while chunk := await reader.read(1024):
        response += chunk
    return response.decode()

async def main():
    tasks = []
    for _ in range(100):
        task = asyncio.create_task(fetch("moudame.com", "/"))
        tasks.append(task)
    for t in tasks:
        await t

asyncio.run(main())

これを実行すると、0.61秒で実行できました。

このように、長いノンブロッキングな処理を複数行う際には、非同期処理での高速化が可能です。

実際にこれらの恩恵を得るためには、aiohttpなどのライブラリを利用することが多いでしょう。 また、現在では開発が止まってしまいましたが、discord.pyも非同期処理を利用していました。

もし必要になる機会があれば、その時に思い出してもらえると良いかと思います。

ツール類

VSCode

開発をするには、エディタが欠かせません。 私は普段NeoVimというエディタにcoc.nvimという拡張を入れて開発をしていますが、こだわりがない人にはVSCodeがおすすめです。 ここでは、VSCodeで開発をするために入れておくべき拡張と設定について説明します。

VSCodeの拡張機能マーケットプレイスでPythonで検索すると、一番上にPythonという拡張が出てきます。 まずはこれを導入しましょう。 Python拡張機能をインストールすると、自動でPylanceという拡張もインストールされます。 Pylanceではpyrightという型検査ツールを利用して型検査をファイルの編集中に行うことができます。 デフォルト設定でもVSCodeでPythonを開発することはできますが、Pylanceの型検査はオフになっています。 前の章で説明した通り、型検査はバグの発見に有用なので、型検査を厳しめに設定しましょう。

VSCodeの設定画面の検索窓に @ext:ms-python.vscode-pylance type checking と入力します。 そうすると、Pylanceの型検査モードの設定が変更できるので、これを strict に変更します。

以上で、VSCode上で型検査を利用することができます。

Pipenv

yarnなどのツールを利用したことのある方にはおなじみの、パッケージ管理ツールです。 PipenvはPythonの依存パッケージをプロジェクトごとにわけて簡単に管理できるようにしてくれます。

インストールは

$ pip install pipenv

を実行します。

pyenvがインストールされている環境の場合、Pipenvは自動でpyenvを利用し、プロジェクトごとに使用するPythonのバージョンを変えることができます。 pyenvのインストールはこちらを参照すればできると思います。

例えば、新しいプロジェクトでPython 3.9を使いたい場合は、

$ mkdir project
$ cd project
$ pipenv --python 3.9
Warning: Python 3.9 was not found on your system...
Would you like us to install CPython 3.9.2 with Pyenv? [Y/n]:

といったように、pyenvを利用してPython 3.9をインストールするか尋ねてくれます。

パッケージのインストールは簡単で、opencv-pythonをインストールする場合、

$ pipenv install opencv-python

を実行します。

Pipenvでインストールされたパッケージを利用したい場合は、Pipenvの環境下でプログラムを実行する必要があります。

Pipenvの環境下でscript.pyを実行したい場合は、

$ pipenv run python script.py

のようにするか

$ pipenv shell
$ python script.py

のようにすることで、Pipenvの環境下でPythonを実行することができます。

Pipenvは、依存関係をPipfileとPipfile.lockというファイルで管理しています。

Pipenvで管理されている(既にPipfile、Pipfile.lockが含まれている)プロジェクトにあとから参加する場合には、対象のプロジェクトのディレクトリに入り、

$ pipenv install

を実行するだけで、必要な依存関係をすべてインストールしてくれます。

black

blackは近年注目され始めたPythonでのコードフォーマッタです。 (コード)フォーマッタとは、プログラムのコードを決められた書式に従って整形してくれるツールのことです。

インストールは

$ pip install black

または

$ pipenv install --dev black

を実行します。

autopep8などの昔から使われているフォーマッタもあります。 blackは比較的新しく、設定項目が多くないため、設定で疲弊することが少ないといった利点が挙げられます。 この辺の選択は好みかなと思いますが、少なくとも何らかのフォーマッタは導入するべきでしょう。

blackはVSCodeとも簡単に連携ができます。 VSCodeから利用するには、設定画面の検索窓に @ext:ms-python.python format provider と入力します。 そうすると、フォーマッティングのプロバイダの設定が変更できるので、これを black に変更します。

また、保存時に自動でblackを実行するようにするのがおすすめです。 VSCodeで保存時に自動でフォーマッタを実行するには、設定画面の検索窓に editor format save と入力し、 Format On Save の設定にチェックをつけるだけです。

また、手動でblackを実行して、ファイルをすべて整形するには、

$ black ./**/*.py

または

$ pipenv run black ./**/*.py

のようにすれば実行できます。

pytype

pytypeはGoogleが開発したPythonでの型検査ツールです。

インストールは

$ pip install pytype

または

$ pipenv install --dev pytype

を実行します。

Pythonでの型検査ツールではmypyやpyrightなどが有名ですが、pytypeの強みは強力な型推論器を持ち、一部の型注釈のないコードに対しても型検査を行える点にあります。

どういうことか、具体的に見ていきましょう。 例えば次のようなプログラムがあったとします。

def div(a, b):
    return a / b

print(div("test", 2))

このプログラムは文字列testに対して除算を行おうとしているので実行すると型エラーになりますが、引数a、bに型注釈をつけていないので、mypyやpyrightでは型検査でエラーを出してくれません。

pytypeでは型エラーを出してくれます。

line 2, in div: unsupported operand type(s) for /: 'a: str' and 'b: int' [unsupported-operands]

このように、mypyやpyrightに比べ、強力な型推論を行ってくれるのがpytypeになります。

pytypeの難点は、まだPython 3.7までしか対応していないことです。 導入する際は、プロジェクトがPython 3.7以下かどうかを確認してください。 私はPython 3.10を使いたいのでpytypeを使っていません。 最新のPythonを使いたい方はmypyなどを使いましょう。 まぁmypyもmatch文などにはまだ対応していないですけどね。

さいごに

ここまで読んでいただきありがとうございます。

今回はできるだけ難しい話はせずに、Pythonで快適に開発するための話をしたつもりです。 もしわからないことなどがあれば気軽に聞いてください。 私に答えられることであればお答えします。

今回の記事の大半が型注釈と型検査の説明になってしまったのは私の専門だからという理由もありますが、Pythonでの快適な開発に大きく寄与するというのも大きな理由です。 PythonやTypeScriptのように、動的型付き言語に対し部分的に型をつけていくことを漸進的型付けと言います。 これを機に漸進的型付けにも興味を持って頂けたら幸いです。

では、快適なPython開発を!