`from __future__ import annotations` がPython 3.10でデフォルトにならなくなりました
PEP 563 は Python 3.10 でデフォルトになる予定で、実際に去年の10月から master ブランチでは有効になっていました。今までの Python 3.10 のアルファ版でも有効になっています。
このPEPはアノテーションの実行時の利用に後方非互換性と大幅な制限を加えてしまいます。
>>> class C: ... a: int ... >>> C.__annotations__ {'a': <class 'int'>}
この例では __annotations__
に int クラスのオブジェクトそのものが入っていますが、PEP 563が有効になると "int"
という文字列が入るので、直接 __annotations__
を見ていたコードは動かなくなります。
PEP 563 が有効でなくてもPEP 484に従ったtype annotationでは、アノテーションが評価されるタイミングでは解決できない名前を a: "int"
のように文字列で書くことでアノテーション利用時に評価していたので、それだけで動かなくなるコードはもともと不完全ともいえるのですが、 PEP 484 にはあまり関心がなく前方参照が無いアノテーションを前提にして書かれていたコードは動かなくなります。
また、名前解決のスコープの問題もあります。
>>> def f(t): ... class C: ... a: t ... return C ... >>> kls = f(str) >>> kls.__annotations__ {'a': <class 'str'>}
この例では、PEP 563では kls.__annotations__
が {'a': 't'}
になりますが、 typing.get_type_hints(kls)
してもすでに t
はモジュールのスコープにないので解決できません。
この例はもう完全にstatic typingが動かないのでPEP 563がAcceptされた時点では妥当な制限だったのですが、最近流行しているFastAPIが利用しているPydanticというライブラリがアノテーションを実行時の型情報を扱うために利用しており、実際にユーザーの中には上の例のようなコードを書いてしまっている人が居るようです。
PEP 563がAcceptされたのが2017年12月で、PydanticやFastAPIが盛り上がったのは完全にその後なのでこの状況は非常に残念なのですが、上のような利用法に対して適切にwarningを出す仕組みが存在しない(仕組み上出すのが非常に難しい)ので仕方ありません。
Pydanticは上のような利用法でも使えるようにする仕組み update_forward_refs()
を提供しては居るのですが、その仕組みを明示的に呼び出していないユーザーのコードは壊れます。
Postponed annotations - pydantic
また、文字列化されたアノテーションをPythonオブジェクトに変換するために eval() を使うのですがそれが遅いという問題もあります。
どの問題も致命的ではないものの、特にFastAPIのコミュニティーが移行の準備が全くできていないということで、PEP 563のデフォルト化は延期されることになりました。現在デフォルト化を取り消すプルリクエストがレビュー中です。
また、PEP 563の欠点のいくつかを改善するPEP 649も提案されています。アノテーションの今後については、Python 3.10が落ち着いてから慎重に議論されるでしょう。
なお、去年このBlogで紹介した PEP 563 向けの最適化はrevertされずにそのまま残ります。なので from __future__ import annotations
を書いているユーザーは起動速度やメモリ使用量のオーバーヘッドが大幅に低減されます。
また単純な revert ではなく、この最適化を生かした形で from __future__ import annotations
が書かれていない場合の実装が改めて書かれています。関数アノテーションは from __future__ import annotations
が書かれていなくても一旦タプルとして保存され、 func.__annotations__
にアクセスされた時にタプルからdictに変換されます。
多くはないですが、 def __init__(self) -> None:
のようなアノテーションならPEP 563がなくても ('return', None)
というタプルになり、これは定数 (int
などは名前解決しないといけないので定数ではない) なので、コンパイル時にタプルが作られた上で、同じモジュールないの同じシグネチャでは同一のタプルオブジェクトを使い回せるという最適化も効いてきます。
gzip, bz2, lzma の binary mode でのイテレーションを高速化しました。
次の2つのプログラムは、どちらも test.gz
というファイルを開いて、片方はバイナリモードで、もう片方はテキストモードでイテレートします。Pythonの使用上ファイルをイテレートしたら行単位で内容を読み出せます。
$ cat dec_gzip.py import gzip with gzip.open("test.gz", 'rb') as f: # バイナリモード for l in f: pass $ cat dec_gzip_t.py import gzip with gzip.open("test.gz", 'rt', encoding="utf-8") as f: # テキストモード for l in f: pass
この2つのプログラム、どちらが速いと思いますか?僕はバイナリモードの方だと思っていました。 TextIOWrapper() の処理がまるまるオーバーヘッドになるはずだからです。
$ python3 -V Python 3.9.1 $ time python3 dec_gzip.py real 0m0.528s user 0m0.508s sys 0m0.021s $ time python3 dec_gzip_t.py real 0m0.285s user 0m0.280s sys 0m0.007s
なんと、バイナリモードの方が2倍くらい遅いのです。
バイナリモードで gzip.open()
すると GzipFile というオブジェクトが返されます。この __iter__
メソッドは親クラスの IOBase.__iter__
で、Cで実装されているのですが、振る舞いとしては毎回 self.readline()
を呼び出してそれを返します。そして GzipFile.readline()
は Python で実装されているのです。
def readline(self, size=-1): self._check_not_closed() return self._buffer.readline(size)
一方テキストモードで開いた場合は、 GzipFile を TextIOWrapper でラップしたオブジェクトが返されます。 TextIOWrapper.__iter__
も IOBase.__iter__
なのですが、 TextIOWrapper.readline()
が C で実装されているのと、TextIOWrapperがある程度バッファリングしていて毎回 GzipFile.read()
を呼び出さなくて良いので速いのです。
bz2 モジュールの場合はさらにこの差が広がります。 gzip と lzma モジュールはスレッドセーフではないのですが、 bz2 モジュールの BZ2File だけは複数スレッドからの並行な read/write が許可されていたので、行ごとに毎回呼び出されるPythonで実装された readline
メソッドの中で with self._lock
しているのです。
$ time python3 dec_bz2.py real 0m1.657s user 0m1.655s sys 0m0.004s $ time python3 dec_bz2_t.py real 0m0.868s user 0m0.851s sys 0m0.016s
bz2の方がgzipよりも圧倒的にデコード負荷が高いために比率で見たら同じ程度の速度低下で済んでいますが、差(オーバーヘッドの大きさ)を考えると、同じ100万行を処理するのにかかるオーバーヘッドが0.22sec程度から0.8sec程度に増えています。
これを Python 3.10 で次のように修正しました。
- bz2 はスレッドセーフをやめる。 (issue43785)
- GzipFile, BZ2File, LZMAFile で
__iter__
をオーバーライドし、内部で使っているバッファ (io.BufferedReader) の__iter__
に移譲することで、毎回Python実装されたreadline()
が呼ばれないようにする。 (Add iter to GzipFile, BZ2File, and LZMAFile #25353)
結果、バイナリモードでのイテレートがPython 3.9より倍以上速くなり、テキストモードよりも少し速くなりました。
$ time ./python dec_gzip.py real 0m0.198s # old: 0.528s, text: 0.285s user 0m0.195s sys 0m0.005s $ time ./python dec_bz2.py real 0m0.771s # old: 1.657s, text: 0.868s user 0m0.758s sys 0m0.015s
ちなみに、Python 3.9 でバイナリモードで行単位の処理をしたい場合は、プライベートメンバーに触ってしまいますが次のようにすれば同じ高速化ができます。
with gzip.open(filename) as f: for l in f._buffer: pass
staticmethod が callable になりました
Pythonで定義した関数とCで定義した関数は型が異なり、振る舞いにも幾らかの違いがあります。これがPython実装とC実装の両方を提供する場合に、挙動の一貫性がないという問題になります。
今回問題になったのは、Cで定義した関数は勝手にメソッドにはならないという点です。
>>> def mylen(x): return len(x) ... >>> class C: ... len = len ... mylen = mylen ... >>> C().len([]) 0 >>> C().mylen([]) Traceback (most recent call last): File "<stdin>", line 1, in <module> TypeError: mylen() takes 1 positional argument but 2 were given
Python で定義した mylen(x)
をクラス変数に代入してインスタンスの属性として呼び出すと、自動的にメソッド扱いされてCのインスタンスが x
に渡されてしまいます。
インスタンスメソッドやクラスメソッドにしないように @staticmethod
を使うと良さそうですが、Python 3.9まで staticmethod
はただのディスクリプタで、クラスやインスタンス経由で利用しないといけませんでした。
>>> @staticmethod ... def mylen(x): return len(x) ... >>> class C: ... mylen = mylen ... >>> C().mylen([]) 0 >>> mylen([]) Traceback (most recent call last): File "<stdin>", line 1, in <module> TypeError: 'staticmethod' object is not callable
これが先ほどのコミットで変更され、 staticmethod がそのまま呼び出せるようになりました。
Python 3.10.0a7+ (heads/master:553ee2781a, Apr 12 2021, 13:46:28) [Clang 12.0.0 (clang-1200.0.32.29)] on darwin Type "help", "copyright", "credits" or "license" for more information. >>> @staticmethod ... def mylen(x): return len(x) ... >>> class C: ... mylen = mylen ... >>> C().mylen([]) 0 >>> mylen([]) 0
Python 3.10a7 はリリース済みなので、この変更が反映されるのは次の Python 3.10b1 からになります。
PYTHONWARNDEFAULTENCODINGを使おう
この記事で紹介した、 open()
などでエンコーディングを指定せずに暗黙でデフォルトのエンコーディングが使われた時に EncodingWarning を発生させる機能のPEPが受理され、実装し、昨晩リリースされた Python 3.10a7 に入りました。
.bashrc
などで extern PYTHONDEFAULTENCODING=1
などとしておけば、デフォルトエンコーディングを使用している箇所に警告が表示されます。自分で書いているプログラムだけでなく、利用しているツールやライブラリ、フレームワークに対する警告についても見かけたら開発元に報告してもらえると助かります。
警告を見つけた場合にどうするべきか、簡単に説明します。
UTF-8を利用している場合
MarkdownやYAML、TOML、JSONを開くのに encoding
引数を指定しないのは、ほとんどの場合バグです。特定の環境で動けば良いプログラムならそのままでも大丈夫ですが、そうでない場合はWindowsでファイルに非ASCII文字が含まれているとエラーになります。 encoding="utf-8"
を指定しましょう。
ロケールエンコーディングを利用している場合
Python 3.10 からは encoding="locale"
を指定して Warning を解消することができますが、これをすると Python 3.9 で動かなくなります。 Python 3.9 にも対応した形でWarningを解消したい場合は encoding=locale.getpreferredencoding(False)
を指定することができますが、そのまま放置しても問題ないでしょう。
将来的にデフォルトのエンコーディングがUTF-8に切り替わった場合に動かなくなりますが、近い将来に切り替わる予定はありません。数年後に Python 3.9 のサポートを切ってから encoding="locale"
を指定しましょう。