このBlogは移転しました。今後は aish.dev を御覧ください。

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

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

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