Python 3.8 の概要 (その6) - 拡張モジュール関連

拡張モジュールがリリースビルド/デバッグビルドで共用可能に

これまで、デバッグ用にビルドされたPythonでは、Pythonのメモリ使用状況を調査するための機能 が有効になっていました。このため、リリース用にビルドされたPythonデバッグ用にリリースされたPythonでは、内部のデータ構造が一部異なっており、拡張モジュールのバイナリもリリースビルド用とデバッグビルド用を別々に作成する必要がありました。

Python3.8のデバッグビルドではこの機能がオフになり、リリースビルド用の拡張モジュールをデバッグビルドでも利用できるようになりました。これまで、デバッグビルドのPythonで調査するときには使用する拡張モジュールもすべてデバッグビルド用に再構築していましたが、この作業が不要になりました。

拡張モジュールが共有ライブラリ版とスタティック版で共用可能に

Pythonの構築方法には、Python本体を共有ライブラリとして実行ファイルとは別のファイルに作成する 共有ライブラリ版 と、すべてを実行ファイルで持つ スタティック版 があります。

これまで、共有ライブラリ版のPythonで拡張モジュールをビルドすると、拡張モジュールもPython本体の拡張モジュール(libpython3.x.soなど)にリンクされていました。しかし、スタティック版のPythonでは libpython3.x.so が存在しないため、共有ライブラリ版用にビルドした拡張モジュールは、スタティック版では利用できませんでした。

しかし、拡張モジュールと libpython3.x.so をリンクする必要があるのはかなり特殊なケースに限られるため、利便性を優先して拡張モジュールは libpython3.x とリンクしないように修正されました。これにより、共有ライブラリ版でもスタティック版でもおなじ拡張モジュールを利用できるようになります。

拡張モジュールのバージョン間の互換性を放棄

これはあまり今どきのユーザには関係のない変更だと思いますが、感慨深かったので取り上げます。

従来、Pythonの拡張モジュールは、古いバージョンのPython用にビルドされた拡張モジュールでも、新しいPythonで利用できるようになっていました。例えば、Python 2.0である拡張モジュールをインストールし、そのあと Python 2.0を削除して Python 2.1をインストールしても、そもまま問題なく利用できるようになっていました。

いまではこんな使い方をする人はいないと思いますが、インターネット接続が今ほど普及しておらず、また PyPIpip もない時代には拡張モジュールのビルド・インストールはけっこうな難作業で、できるだけ簡単に使い回せるようになっていました。

しかし、いまでは共有ライブラリを使い回すよりも、うまく共有ライブラリをプロジェクト間で分離するほうが重要な時代となりました。主要なパッケージはバイナリが提供されるようになっていますので、ビルド・インストールの手間も最小限です。

そこで、Python3.8から、旧バージョン用の拡張モジュールは、新しいバージョンでは利用できなくなりました。もともと、実際に複数バージョンで利用できる拡張モジュールはそれほど多くはなかったと思われますが、公式に動作を保証しない、と表明されました。

2009年頃までは、共有モジュールの互換性を確保するためにこんな PEP が出ていたぐらいですが、その後の環境の変化で、ついにこのPEPも不要と成り果てました。今となってはユーザに関係ある話ではないのですが、Pythonの歴史を紹介する意味でちょっと解説してみました。

Python 3.8 の概要 (その5) - デバッグ用 f文字列フォーマット

Python3.8の新機能で、これ一番好きかも。このためだけにPython3.8必須にしてもいい。

通常、 f文字列 に変数名や式を指定すると、その値が文字列に埋め込まれます。

>>> foo, bar = 10, 20
>>> print(f'value is {foo+bar}')
value is 30

便利な機能ですが、デバッグ用にデータを出力するときには、ちょっと面倒です。たとえば foobar の値を確認するときは、確認したい変数名のテキストと、表示したい式を別々に書く必要があります。

>>> print(f'foo={foo} bar={bar} foo+bar={foo+bar}')
foo=10 bar=20 foo+bar=30

そこで、f文字列に出力指定方法が追加され、出力したい式に続けて = を指定すると、その式と式の値の両方が文字列に埋め込まれるようになりました。

>>> print(f'{foo=} {bar=} {foo+bar=}')
foo=10 bar=20 foo+bar=30

出力フォーマットを指定する場合は、= に続けて記述します。

>>> print(f'{foo=:0.3f} {bar=:.1e} {foo+bar=:04d}')
foo=10.000 bar=2.0e+01 foo+bar=0030

Python 3.8 の概要 (その4) - multiprocessing.shared_memory モジュール

multiprocessing.shared_memory モジュールで、共有メモリを使ってプロセス間でデータを交換できるようになりました。似たような処理は mmap モジュールで実現できましたが、マルチプラットフォームで簡単に利用できるようになります。

Numpyの ndarray オブジェクトを複数のプロセスで共有する場合、まず最初のプロセスで次のように共有メモリを作成します。この例では、共有メモリの名前は "sharedmemory_test1" とします。

import math
from multiprocessing import  shared_memory
import numpy as np

SHAPE = (3,3)

# 共有メモリ "sharedmemory_test1" を作成
size = math.prod(SHAPE) * numpy.dtype("float").itemsize
shm = shared_memory.SharedMemory(create=True, size=size, name="sharedmemory_test1")

# 共有メモリを利用するndarray オブジェクトを作成する
arr = np.ndarray(shape=SHAPE, dtype=float, buffer=shm.buf)

# ndarray を初期化
arr[:] = 0

他のプロセスでは、"sharedmemory_test1 という名前を指定して共有メモリを参照できます。

from multiprocessing import  shared_memory
import numpy as np

SHAPE = (3,3)

# "sharedmemory_test1" を指定して共有メモリを作成
shm = shared_memory.SharedMemory(name="sharedmemory_test1")

#nbarrayオブジェクトを作成
arr = np.ndarray(shape=SHAPE, dtype=float, buffer=shm.buf)

# 共有メモリを参照
print(arr[0:0])

# 共有メモリを開放
shm.close()

共有メモリが不要になったプロセスでは、close() メソッドでリソースを開放します。

SharedMemoryManager

共有メモリが不要になったら、 unlink() を一度だけ呼び出して、共有メモリを削除します。

SharedMemoryManagerを使うと、共有メモリを管理する専用のプロセスを起動し、共有メモリの寿命を制御できます。

from multiprocessing import Process
from multiprocessing.managers import SharedMemoryManager

def func1(shm, arg):
    shm.buf[0] = arg
    shm.close()

if __name__ == '__main__':

    # 共有メモリ管理プロセスを起動
    with SharedMemoryManager() as smm:

        # 共有メモリを作成し、初期値を設定
        shm1 = smm.SharedMemory(10)  
        shm1.buf[0] = 0

        # func1を異なるプロセスで起動
        p1 = Process(target=func1, args=(shm1, 100))

        # func1を異なるプロセスで起動
        p2 = Process(target=func1, args=(shm1, 200))

        p1.start()
        p2.start()

        p1.join()
        p2.join()
        
        print(shm1.buf[0])

Python 3.8 の概要 (その3) - Pickle protocol 5 with out-of-band data

Pythonでは、複雑なデータの交換や保管する場合、よく Pickleモジュール が使われます。Pickleはデータを外部に出力可能な形式に変換してファイルに変換したり、サーバと通信して送信したりします。

Pythonconcurrent.futuresmultiprocessing を使って並列処理を行う場合も、プロセス間のデータ交換に Pickle が使われています。

PEP-574 Pickle protocol 5 with out-of-band data

Pickleは汎用的なデータフォーマットを定義していて、データを作成したハードウェアと異なるアーキテクチャのハード上で読み込んでも、ただしく元のデータを再現できるようになっています。

しかし、現在ではPickleの使い方は多様化しており、そういった汎用的なデータフォーマットだけでは効率的にデータの転送や保管を行えないことが多くなりました。特に、データサイエンス分野で利用する膨大なデータを高速に扱うには、用途に応じてデータを適切に処理する、専用のメカニズムが必要となります。

そこで、Pickleにあらたに5番目のプロトコルとして、Numpyのarrayオブジェクトなどのメモリ上のデータを直接取得して、効率的に出力する形式が開発されました。

Numpy配列のPickle

例として、1億個の乱数からなるNumpyの配列をPickleで保存してみましょう。

data = np.random.rand(100_000_000)

pickle.dump(data, open("data.pickle", "wb"))

この場合、手元のマシン(Macbook air 2018-1.6 GHz Intel Core i5) では、pickle.dumps() に 1.5秒かかります。

新しいPickleフォーマットを利用する場合は、次のようになります。

pickle.dump(data, open("data5.pickle", "wb"),
            protocol=5)

新しい形式を利用する場合は、protocol=5 と指定します。この場合、pickle.dump() の処理時間は0.7秒ほどで、通常のPickleの2倍以上高速になっています。

そのかわり、保存したPickeを読み込んで、オブジェクトを復元できるとは保証されていません。たとえば、このファイルを作成したPCと異なるアーキテクチャのハードウェアでロードしても、正しい値として復元できません。プロトコル5を利用する場合は、保存したPickleをロードできるかどうかは、アプリケーションの開発者が責任を持つ必要があります。

プロトコル5をサポートするオブジェクトをPickleする場合、バッファプロトコル を使ってデータを取得し、メモリ上のデータを値の変換せずに直接ファイルに出力できます。このとき、データのコピーが発生しないように考慮されており、大きなデータでも効率よく出力できます。

この例では Numpyの ndarray オブジェクトのメモリ上のデータを memoryviewで取得して出力していますが、オブジェクトに __reduce_ex__() メソッドを実装して、この動作をカスタマイズできます。

Out-of-band data

プロトコル5では、実際のデータをPickle内に含めず、独自の形式で管理することもできます。データ本体はそれぞれのアプリケーションが用意する、最適な方式で処理します。

buffers = []

pickle.dump(data, open("data5.pickle", "wb"),
            protocol=5,
            buffer_callback=buffers.append)

with open("data-body.raw", "wb") as f:
    f.write(buffers[0])

この形式では、各オブジェクトのバイナリデータは buffer_callback に指定した関数に渡されます。この例では、buffers.append() を呼び出し、buffers リストに保管します。このデータはPickle本体には含まれていませんので、独自に別のファイルに保存しておきます。

保存したデータは、次のように復元します。

with open("data-body.raw", "rb") as f:
    buffers = [f.read()]

pickle5.load(open("data5.pickle", "rb"), buffers=buffers)

データの本体は事前にロードし、リストに格納しています。pickle.load() はこのリストから元データを取得し、オブジェクトを復元します。

サポート状況

Numpyでは、このプロトコルがサポートされる予定です。

Python3.6/3.7でもこの機能を利用するための pickle5 パッケージがリリースされており、Python3.8以外でも利用できるようになっています。

Python 3.8 の概要 (その2) - Positional-only parameters

Python 3.0 以降では、関数を定義するときに、キーワード専用引数 を指定できるようになりました。

def func(a, b, *, c=1, d=2):
    return a+b+c+d

こんなのですね。引数のリストに * がある関数を呼び出すとき、* の後ろにある引数の値は、かならずキーワード引数として指定しなければいけません。

↑の関数だと、引数 c はキーワード引数で指定すればちゃんと動きます。

>>> func(1, 2, c=10)
15

しかし、キーワードなしで呼び出すとエラーになります。

>>> func(1, 2, 10)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: func() takes 2 positional arguments but 3 were given

「この関数呼び出すとき、この引数はかならず argname=value の形で指定してね。そうじゃないと読みにくくなっちゃうから」という、関数の開発者の気配りです。

で、キーワード専用引数はずいぶん前に実現してたんですが、その逆、キーワードなし専用引数 は存在しませんでした。不公平ですね。

しかし、キーワード専用引数に遅れること11年、Python 3.8でついに PEP 570 -- Python Positional-Only Parameters として実現することになりました。

PEP 570 -- Python Positional-Only Parameters

関数の引数のリストに / があると、/ より前にある引数はすべて 位置専用パラメータ (Positional-Only Parameters) となり、呼び出すときに値をキーワードでは指定できなくなります。

def func(a, b, /, c):
    return a+b+c

この例では、引数 c/ の後ろにありますから、キーワードで指定できます。

>>> func(1, 2, c=3)
6

しかし、ab/ の前にありますので、キーワードは使えません。

>>> func(1, b=2, c=3)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: func() got some positional-only arguments passed as keyword arguments: 'b'

古くて新しい機能

じつは、位置専用パラメータは完全に新しい機能というわけではなく、Python 3.8以前にも存在していました。しかし、そういう関数はPython言語では作成できず、組み込みモジュールとしてC/C++言語などでしか作成できませんでした。C言語Pythonの関数を定義するときは、キーワードを無効化したほうが簡単でパフォーマンスも良かったりします。

昔からあるシンプルな組み込み関数などはだいたいそういうパターンで作られていて、例えば range()sum() などは、キーワードとして引数を指定できません。

>>> sum(itearble=[1, 2, 3])
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: sum() takes no keyword arguments

この sum() と同じインターフェースの関数は、これまでPythonの構文としてはサポートされていなかったのですが、位置専用パラメータを利用すれば

def sum(iterable, start=0, /):
    ...

と定義できるようになります。

ところで、まだ位置専用パラメータがサポートされていない、Python3.7で sum() 関数を調べてみましょう。

Python 3.7.2 (v3.7.2:9a3ffc0492, Dec 24 2018, 02:44:43)
>>> help(sum)

Help on built-in function sum in module builtins:

sum(iterable, start=0, /)
    Return the sum of a 'start' value (default: 0) plus an iterable of numbers
    ...

ここで表示される関数の定義をよく見てみると、helpドキュメントにはすでに

sum(iterable, start=0, /)

と、位置専用パラメータの構文で表示されてますね。実はこの構文、あたらしく作られたのではなく、Pythonの組み込み関数の開発に使われる ツール ですでに採用されていた書き方なのです。

これ、なにが嬉しいの?

例えば、こんな関数を考えてみましょう。

def set_attrs(obj, **kwargs):
    for k, v in kwargs.items():
        setattr(obj, k, v)

set_attrs() は、指定したオブジェクトに、キーワード引数に指定した属性を設定します。

# target.foo = 1, target.bar=2 とする
>>> set_attrs(target, foo=1, bar=2)
>>> target.foo
1
>>> target.bar
2

いい感じですが、この実装には欠点があります。obj という名前の属性を設定できないのです。

>>> set_attrs(target, obj=1)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: set_attrs() got multiple values for argument 'obj'

obj という名前の仮引数は、更新対象のオブジェクトを指定するために使われていますので、もう一度同じ objという名前を使うことはできません。

この問題、これまでは、仮引数 obj の名前を _target___obj などのあまり使われなさそうな名前にして、問題が発生しないことを祈る、などが主な対策でした。

しかし、位置専用パラメータを利用して

def set_attrs(obj, /, **kwargs):
    for k, v in kwargs.items():
        setattr(obj, k, v)

と定義すれば、位置パラメータしてしか指定できない obj という仮引数名は、なかった ものとして扱われ、キーワード引数として obj を指定できるようになります。

>>> set_attrs(target, obj=1)
>>> target.obj
1

他には?

PEP 570 -- Python Positional-Only Parameters には他にも想定される使い道が示されているのですが、あまり私のハートには刺さりませんでした… 😁

たとえば、

def div_obj(left, right):
    return left/right

という、left/right を計算する関数があったとき、ふつうは

>>> div_obj(10, 2)
5

という感じに使うと思います。しかし、キーワード引数を使って

>>> div_obj(right=2, left=10)
5

と書かれてしまうと、これはとても気持ち悪いコードになってしまいます。

そこで、PEP 570ではこの関数の定義を

def div_obj(left, right, /):
    return left/right

としてキーワード引数を使えなくしてしまえば、こういった気持ちの悪い使い方を禁止できますよ、と紹介しています。

しかし、そのためにわざわざキーワード引数を使えなくしてしまうのは、ちょっと余計なお世話じゃないかなっていう感じがして、たぶん自分ではこういう使い方はしないかなーと思います。

Python 3.8 の概要 (その1) - Assignment expressions

古来、Pythonでは「代入は文であるべき!」と一貫して主張してきました。

C言語などでは、代入は足し算や掛け算と同じ、値を計算する「式」で、たとえば

a = (b=100) / 2;

と書くと、b には 100 を代入し、a100/2=50 を代入します。1+12 という値になる ですが、b=100 も同様に値が 100 となる なのです。

Pythonでは、代入は式ではないので、こういう書き方はできません。

Pythonの代入は、足し算などの演算子の仲間ではなく、iffor のような制御文の仲間で、あまり自由な書き方は出来ないのです。

Python FAQ では、その理由として

Python の式中での代入を許さない理由は、この構造によって起こる、他の言語ではありがちで見つけづらいバグです:
if (x = 0) {
    // error handling
}
else {
    // code that only works for nonzero x
}

と説明しています。

このC言語のコードでは、if 文の中で x = 0 と書いて変数 x に値を代入しています。しかし、このコードは

if (x == 0) {
    // error handling
}
else {
    // code that only works for nonzero x
}

の書き間違いである場合が多いのです。本来、 if (x == 0) { と書くべきところを、if (x = 0) { と書いてしまうという間違いですね。

この間違いは比較的発生しやすく、一見正しい処理に見えてしまうため発見しにくいBugの原因として知られています。多くのプロジェクトで、このエラーを回避するために

== で比較するときには、x == 0 ではなく 0 == x のように書く

というコーディング規約を定めています。

このように、定数を左側に記述するようにすると、間違えて === と書いてしまっても、0 = x と定数に変数を代入する式となるので、コンパイルエラーが発生してミスを発見できるためです。このような書き方は、ヨーダ記法 とも呼ばれます。

PEP 572 -- Assignment Expressions

Pythonは1990年代初頭にリリースされて以来30年近く、代入が演算子であるプログラミング言語をこんな風にDisり続けてきたわけですが、ここへ来て突如方針を転換します。君子は豹変するのです。

あるとき、Pythonの開発者メーリングリストで「PythonC言語のような代入演算子を導入しよう」という提案がありました。過去にも似たような提案がされており、やれやれまたか、まあ採用はされないだろうけどできるだけいろいろ議論してドキュメントにまとめ、今後似たような提案があったときに参照できるようにしよう、ということになりました。

多くの開発者はこの提案が受け入れられるとは思ってもいなかったし、Pythonの仕様決定権をもつPythonの父 Guido van Rossum 氏もそのつもりはなかったようです。しかし、議論が進むにつれ、なんとGuidoが代入演算子を受け入れる意向を示し始めました。

多くのPython開発者はこの提案に反対で、大変な議論が巻き起こりましたが、Guidoを始めとする推進派は丁寧に議論を進め、最終的に PEP 572 -- Assignment Expressions としてPythonに導入されることとなりました。

この議論で疲れ果てた Guido は、Pythonの最高権力者であるBDFL(Benevolent Dictator For Life:慈悲深き終身独裁官) からの退任を表明することとなります。

セイウチ演算子

このような紆余曲折を経て、Python 3.8では 代入式 が導入され、次のように書けるようになりました。

>>> a = (b:=100) / 2
>>> a, b
(50.0, 100)

= は従来どおり代入文で使われます。新しく追加された代入式は、:= 演算子を使用します。

いつのまにか、:= 演算子は「セイウチ演算子」(Walrus operator) と呼ばれるようになりました。そう言われてしまうと、もうセイウチにしか見えなくなります。

f:id:atsuoishimoto:20190903002638p:plain

:= 演算子はふつうの式として、ほとんどの処理で利用できます。

たとえば、これまで

from random import random

a = random()
b = random()

print(a, '+', b, '=', a+b)

と書いていた処理は、

from random import random

print(a:=random() '+', b:=random(), '=', a+b)

のように書けます。

また、if 文や for などの制御文で

v = dist_data.get(key, -1)
if v != -1:
    do_something(v)

のように書いていた処理は、

if (v:= dist_data.get(key, -1)) != -1:
    do_something(v)

と一行にまとめて書けるようになります。

:= 演算子が特に便利なのは、リスト内包などの式でしょう。

これまで、データの編集と判定を同時に行うような処理は、リスト内包ではすっきりと書けませんでした。

for item in data:
    text = item.strip()
    if text:
        result.append(text)

この処理をリスト内包で書こうとすると、

result = [text.strip() for text in data if text.strip()]

のように、text.strip() を2回呼び出すか、

result = [s for s in (text.strip() for text in data) if s]

のように、一つ余計にイテレータを作成する必要がありました。

しかし、:=演算子のおかげで

result = [t for text in data if (t:=text.strip())]

とすっきり記述できるようになりました。

注意点

:=演算子は、あいまいな書き方ができないようにするために、 () でくくらないと SyntaxError とされる場合があります。

>>> a = b:=2
  File "<stdin>", line 1
    a = b:=2
         ^
SyntaxError: invalid syntax

>>> x=(1+y:=2)
  File "<stdin>", line 1
SyntaxError: cannot use named assignment with operator

>>> dict(a=a:=10, b=a*2)
  File "<stdin>", line 1
    dict(a=a:=10, b=a*2)
            ^
SyntaxError: invalid syntax

上記のようなエラーは、:=演算子() でくくって範囲を明確にすると解消します。

>>> a = (b:=2)
>>> x = 1+(y:=2)
>>> dict(a=(a:=10), b=a*2)
{'a': 10, 'b': 20}

また、:= 演算子は非常に優先順位が低いので、複雑な式では () を使うことが多くなるでしょう。たとえば

[a := 1 +  2]

という式は、

[(a := (1 +  2))]

と評価され、 a の値は 3 となります。

カッコをつかって

[(a := 1) +  2]

と書けば、a1 となります。

RecursionError は RecursionError とは限らない、という話

先日、 Pythonjp Discordサーバ の初心者部屋で、こんな質問 があった。

次のようなコードが RecursionError 例外を出して困っているという。

if a:
    ...
elif b:
    ...
elif c:
    ...
elif d:
    ...

のように、数千個の elif が並んでるコードだ。

多くの場合、RecursionError は、関数が自分自身を繰り返し呼び出す 再帰呼び出し で発生する。こんな感じだ。

>>> def f():
...     f()
... 
>>> f()
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "<stdin>", line 2, in f
  File "<stdin>", line 2, in f
  File "<stdin>", line 2, in f
  [Previous line repeated 996 more times]
RecursionError: maximum recursion depth exceeded
>>> 

しかし、問題のコードには、再帰呼出しはもちろん関数呼び出しも存在しない。なぜ RecursionError 例外が発生するのだろうか?

関数呼び出しのないRecursionError

問題のコードを実行すると、次のようなエラーが出る。

$ python3 foo.py
RecursionError: maximum recursion depth exceeded during compilation

実は、このエラーはPythonスクリプトを実行して発生しているのではない。ソースコードコンパイルするときに、Python言語のコンパイラで発生している。

Pythonは、ソースコードコンパイルするときに 抽象構文木(AST) というツリー状のデータ構造を作る。この AST を処理するときに再帰的に処理を行うため、ツリーのネストが深すぎると RecursionError 例外が発生する。

このケースでは if 〜 elif 〜 elif 〜 が大量に並んでおり、if 文の子ノードとして elif ノードが作られ、その elif ノードの子ノードとしてまた elif ノードが作られ、その elif ノードの子ノードとして…… という風に、elif の数だけツリーが深くなっている。

このため、ソースコードコンパイルする過程で行われる再帰呼び出しの回数が制限を超えてしまい、RecursionError 例外 が発生してしまうのだ。

対処方法

関数呼び出しがまったく存在しないソースコードであっても、Pythonコンパイラの制約により RecursionError 例外が発生してしまうことがある。この例では極端に長い if 〜 elif 〜 elif 〜 ブロックだったが、他にも ((((......(a)+1)+1)+1)......) のようなコードでも発生する場合がある。

このようなエラーが出てしまったら、ASTが深くなりすぎないようなコードに修正する必要がある。

if 〜 elif 〜 elif 〜 ブロックなどが長すぎる場合は、短く分割する。長すぎる式は複数行に分割する、などの変更ですむので、対処はそれほど難しくない場合が多いと思う。