- Python 3.15.0b1 の機能凍結により、遅延インポートや Tachyon プロファイラ以外にも実用的な改善が確定
asyncio の TaskGroup.cancel() は、独自例外や contextlib.suppress なしでタスクグループをスマートにキャンセル
- ContextDecorator は、非同期関数・ジェネレータ・非同期イテレータのライフサイクル全体を包むように変更
- threading の新ユーティリティは、イテレータ消費をスレッド間で直列化または複製し、Queue なしでも抽象化を維持できるようにする
Counter には xor 演算 が追加され、json.loads は array_hook と frozendict により不変 JSON パースをサポート
Python 3.15 のあまり知られていない変更
- Python 3.15.0b1 の機能凍結により、今年の Python に入る機能が確定し、大きな変更としては 遅延インポート と Tachyon プロファイラ がある
- Python 3.15 には大きな PEP ほど目立たないものの、実用的な 小さな機能変更 も含まれており、
asyncio、コンテキストマネージャ、スレッド安全なイテレータ、Counter、JSON パースまわりの改善が入っている
asyncio TaskGroup のキャンセル
asyncio の中核的な変更として、TaskGroup を スマートにキャンセル できる機能が追加された
TaskGroup は 構造化並行性 の一形態で、複数の並行処理をきれいに生成し、すべて完了するまで待てるようにする
async with asyncio.TaskGroup() as tg:
tg.create_task(run())
tg.create_task(run())
# Waits for all the tasks to complete
- Python 3.15 より前は、バックグラウンドシグナルを待って
TaskGroup の実行を中断するには、独自例外 を発生させて contextlib.suppress で除外しなければならなかった
class Interrupt(Exception):
...
with suppress(Interrupt):
async with asyncio.TaskGroup() as tg:
tg.create_task(run())
tg.create_task(run())
if await wait_for_signal():
raise Interrupt()
- この方式は、タスクグループ内で例外が発生すると他のタスクがキャンセルされ、独自の
Interrupt 例外が ExceptionGroup の一部として発生したあと、contextlib.suppress によってフィルタリングされるため機能する
ExceptionGroup と連携する suppress の仕組みは Python 3.12 で追加されたが、あまり注目されなかった
- Python 3.15 の TaskGroup.cancel は、同じ作業をはるかに単純にする
async with asyncio.TaskGroup() as tg:
tg.create_task(run())
tg.create_task(run())
if await wait_for_signal():
tg.cancel()
TaskGroup.cancel() は例外を発生させずにグループをキャンセルするため、別途例外と suppress の組み合わせは不要になる
コンテキストマネージャの改善
- コンテキストマネージャは Python 3.3 から デコレータ としても直接使えた
@contextmanager
def duration(message: str) -> Iterator[None]:
start = time.perf_counter()
try:
yield
finally:
print(f"{message} elapsed {time.perf_counter() - start:.2f} seconds")
@duration('workload')
def workload():
...
# Or simple as a wrapper
duration('stuff')(other_workload)(...)
duration() のようにブロック実行時間を出力するコンテキストマネージャは、関数デコレータのように使えて便利だが、非同期関数、ジェネレータ、非同期イテレータでは正しく動作しないことがあった
@duration('async workload')
async def async_workload():
...
@duration('generator workload')
def workload():
while True:
yield ...
- イテレータ、非同期関数、非同期イテレータは通常の関数と意味論が異なり、呼び出し時点でそれぞれジェネレータオブジェクト、コルーチンオブジェクト、非同期ジェネレータオブジェクトを返す
- 従来のデコレータはラップ対象のライフサイクル全体をカバーできず、すぐに完了してしまうため、実際の実行時間全体を包めなかった
- Python 3.15 では
ContextDecorator がラップする関数の型を確認し、デコレータがその対象の ライフサイクル全体 を覆うように変更された
- コンテキストマネージャをデコレータとして使う際に生じがちな落とし穴を避け、よりすっきりした構文を使えるようになる
スレッド安全なイテレータ
- イテレータは Python の中核的な抽象化のひとつで、データソースとデータ消費側を分離して、よりきれいな構造を作れる
lazy from typing import Iterator
def stream_events(...) -> Iterator[str]:
while True:
yield blocking_get_event(...)
events = stream_events(...)
for event in events:
consume(event)
- この抽象化はスレッディングや free-threading 環境では壊れることがあり、標準のイテレータは スレッド安全 ではないため、値が飛ばされたり内部のイテレータ状態が壊れたりする可能性がある
- Python 3.15 の threading.serialize_iterator は既存のイテレータをラップし、スレッド間での消費を直列化する
import threading
events = threading.serialize_iterator(stream_events(...))
with ThreadPoolExecutor() as executor:
fut1 = executor.submit(consume, events)
fut2 = executor.submit(consume, events)
source1, source2 = threading.concurrent_tee(squares(10), n=2)
with ThreadPoolExecutor() as executor:
fut1 = executor.submit(consume, source1)
fut2 = executor.submit(consume, source2)
- 従来はスレッド間での消費を同期するため主に Queue に依存していたが、新ユーティリティを使えばマルチスレッドコードでも既存のイテレータ抽象化を変えずに維持できる
追加機能
c = Counter(a=3, b=1)
d = Counter(a=1, b=2)
print(f"{c + d = }") # add two counters together: c[x] + d[x]
print(f"{c - d = }") # subtract (keeping only positive counts)
Counter(a=4, b=3)
Counter(a=1, b=0)
Counter には積集合と和集合に相当する &、| 演算もある
print(f"{c & d = }") # intersection: min(c[x], d[x])
print(f"{c | d = }") # union: max(c[x], d[x])
Counter(a=1, b=1)
Counter(a=3, b=2)
Counter は離散オブジェクトの集合のように見なすことができ、例は次のように解釈できる
{a_0, a_1, a_2, b_0} & {a_0, b_0, b_1} == {a_0, b_0}
{a_0, a_1, a_2, b_0} | {a_0, b_0, b_1} == {a_0, a_1, a_2, b_0, b_1}
- Python 3.15 ではここに xor 演算 が追加された
c = Counter(a=3, b=1)
d = Counter(a=1, b=2)
c ^ d == c | d - c & d == Counter(a=3, b=2) - Counter(a=1, b=1) == Counter(a=2, b=1)
{a_0, a_1, a_2, b_0} ^ {a_0, b_0, b_1} == {a_1, a_2, b_1}
Counter の集合演算をあまり使ってこなかったなら、xor の具体的な用途は思い浮かびにくいかもしれないが、演算体系の完成度という観点で追加された機能である
-
不変 JSON オブジェクト
- Python 3.15 に frozendict が追加されたことで、JSON の型である配列、ブール値、実数、null、文字列、オブジェクトをすべて 不変かつハッシュ可能な形 で表現できるようになった
- json.load と json.loads に
array_hook パラメータが追加され、既存の object_hook を補完する
array_hook=tuple, object_hook=frozendict を組み合わせれば、JSON オブジェクトをそのまま不変構造としてパースできる
json.loads('{"a": [1, 2, 3, 4]}', array_hook=tuple, object_hook=frozendict) == frozendict({'a': (1, 2, 3, 4)})
1件のコメント
Hacker News のコメント
例を見ると
lazy from typing import Iteratorのように書いていて、Python にとうとう遅延 importが入ったのかと思った。この変更を見落としていたようで、これも Python 3.15 からなのか、それとも以前のバージョンにもあったのか気になる
そうなるとアノテーションの遅延評価が必要だが、デフォルトで有効だったとは思わない
def __getattr__(name: str) -> object:を実装すれば回避できる個人的にもかなり期待している。実際、今週だけでも、アプリケーションで実際には使わないモジュールの import が追加されたせいで Python プロセスがメモリ上限を超え、メモリ不足になったのを見た
import文を書く形で遅延 importができた。その関数が呼ばれるまではライブラリは import されない3.15 に
frozendictが追加されたことで、JSON のすべての型、つまり配列、真偽値、浮動小数点、null、文字列、オブジェクトを不変かつハッシュ可能な形で表現できるようになった。この最後の機能は本当に気に入っている
Python 3.15 にIterator 同期プリミティブが追加されたのはうれしい: https://docs.python.org/3.15/library/threading.html#iterator...
自分の
threaded-generatorパッケージも、スレッド/プロセス + ジェネレーター + キューでまさにこれをやっているので、うまく補完してくれそう: https://pypi.org/project/threaded-generator/Counterの集合演算、とくに xor の使い道が思い浮かばないと言っていたが、対称差を考えればよい。https://en.wikipedia.org/wiki/Symmetric_difference
Counterに適用すると多重集合の対称差になり、これには自然な定義がない。提案を正しく理解しているなら、各要素の個数差の絶対値で定義するようだが、これは結合則も成り立たない。パリティだけを見るなら
F_2上の加法として解釈できるのでより自然だが、それでも実際の用途はあまり見えないCounterの例の 1 つは間違っている。3.13 と 3.15.0a の両方で確認した。Counter(a=3, b=1) - Counter(a=1, b=2)の結果はCounter({'a': 2})だCounterオブジェクトを組み合わせて多重集合を作るためのさまざまな数学演算が提供されており、加算と減算は対応する要素数を足し引きし、積集合と和集合はそれぞれ最小/最大の個数を返す。各演算は負の個数を持つ入力も受け付けるが、出力では個数が 0 以下の結果を除外する。いずれにせよ、見事な Counter-example だ ;-)
10 年間 Python にどっぷり浸かっていて、作業するのも楽しかったが、AI コードボット以後の世界では今年だけでもすでに 10 万行以上を削除し、より高速な言語へ移した。最近は主に Go へ移行している
1 つの方法として、Python でプロトタイプを作ってから変換するやり方はありそうだ
フィルタリング、ウィンドウ処理、オーバーラップなどが入る信号処理コードを書いてみると、今あるライブラリだけでは簡単な方法がほとんどない
Python の内部構造と運用、とくに free-threading に関してよいインタビューがある: https://alexalejandre.com/programming/interview-with-ngoldba...
ああ、愛しの Python。15 年近く使ってきた。懐かしいが、もう使っていない。君のせいではなく、人生が変わっただけだ
イテレータ、非同期関数、非同期イテレータは通常の関数とは意味論が異なるため、デコレータとうまく噛み合っていなかった。呼び出すとそれぞれジェネレーターオブジェクト、コルーチン関数、非同期ジェネレーターオブジェクトを即座に返してしまい、デコレータが包む対象が全ライフサイクルではなく即時に終わってしまう。
3.15 では
ContextDecoratorが包む関数の型を確認し、デコレータがライフサイクル全体を覆うように変わるが、この発想自体はとても気に入っている一方で、選択的に適用する仕組みなしに既存の利用箇所の挙動を微妙に変えてしまう点はかなり危うく見える。誰かが昔の壊れたやり方で意図的にデコレータを使っていなければ問題にならない、いわば「スペースバー暖房」的な状況ではあるが、実際にそうしていたなら予期せず壊れる可能性があるこういう小さな機能こそ、結局いちばん役に立つことが多い。とくに今のプロジェクトで、新しい標準ライブラリ追加機能を試してみたい