TL;DR
Pythonでデフォルト値を取る引数に関してはその前にアスタリスク(*
)の引数設定があるとキーワード引数を前提とする縛りを設けられて引数順や引数の追加などをした際に影響が少なくて堅牢で良さそう、という話です。
きっかけ
先日タイムラインに以下のようなツイートが流れてきて拝見しました。
PythonのOSSのレビューで、新しいキーワード引数を入れる時に、前に`*`を入れるように指摘されるのをよく見るんだけど、なるほど、*の後はキーワード引数じゃないといけなくなるのか。確かに`*`無しだと、間違って引数を入力しちゃうこともあるし、キーワード引数の順番も変えられなくなるから、これはいい pic.twitter.com/PL6aHorINW
— Atsushi Sakai (@Atsushi_twi) December 4, 2021
Effective Pythonとかの本(私が読んだのは初版なので第二版以降はどう書かれているか把握できていませんが)にも確か「デフォルト値を取る引数に関してはキーワード引数で指定すべき」といったTIPSが書かれていた気がしますが(昔のことすぎて若干記憶が曖昧ですが・・・)、前述のものをコードを書く際に守るように気を付けるよりもそもそも守らないとコードが動かない・・・形の方が確実で良さそうに思えました。
実際にapyscという趣味で作っている以下のPythonライブラリで反映してみたのですが、うっかり前述のデフォルト値を取る引数に対してキーワード引数が使われていない・・・といったケースが少し見つかったりと、Lintなどと同様に人に頼らずに確実に制限される形が好ましそうと感じました。
どういった話なのか
例えば以下のような関数があったとします。a, c, eとそれぞれ整数の型の引数を受け付けます。cとeの引数はデフォルト値をそれぞれ持っています。
def any_func(a: int, c: int = 30, e: int = 50) -> str:
...
この関数のユーザーはaの引数のみ必須で指定する必要がありますが、cとeの引数の設定は任意です(aだけ指定すれば呼び出すことができます)。
any_func(10)
また、cやeなどの引数は以下のように位置引数で指定したりキーワード引数としてもどちらでも指定できます。
any_func(10, 20, 30)
any_func(10, c=20, e=30)
ただしデフォルト値を取る引数に関しては順番が変わったりその前に別に引数がアップデートなどで追加などがされがちなのでキーワード引数の指定をするようになっていると関数のアップデートで影響を受けづらくなります。
例えばbというこれまた整数で且つデフォルト値を取る引数が関数にアップデートで追加されたとします。エンジニアとしてはb引数はaとcの引数の間に追加するのが自然だ・・・と感じてその位置に引数を追加したとします(aのようなデフォルト値を持たない引数に関しては順番が変わるような破壊的変更が入ることは稀ですが、デフォルト引数を取る引数に関してはこのような変更が入ることがあったり追加を行った方が自然なケースがあります)。
def any_func(a: int, b: int = 20, c: int = 30, e: int = 50) -> str:
...
すると、位置引数でcやeの引数に値を指定していた呼び出し箇所ではそれぞれの引数の指定がcとeからbとcに変わってしまい、想定外の挙動となってバグの原因になったりする可能性があります(特にライブラリアップデートなどしてインターフェイスが変わった際などは気づきづらいケースがあります)。
テストで引っかかるケースが大半だとは思いますし各引数の型が異なっていればmypyなどの型チェックでCIのタイミングなどで検知できるケースも多くありますが、運が悪いとこれらのチェックもすり抜けてしまうケースも無いとは言えません。
any_func(10, 20, 30)
一方でcやeの引数指定をキーワード引数で行っていた場合はb引数が追加された後でもcとeへの指定のままの挙動となり影響が出ずに済みます。
any_func(10, c=20, e=30)
そのためデフォルト値を取る引数に関しては基本的にキーワード引数で指定する・・・としておくと関数の更新時やライブラリアップデートなどで影響が少なくなります。
しかしながらついうっかり位置引数で指定してしまったり、もしくは途中からデフォルト値を取るようになった関数などの呼び出し元では位置引数で指定しまっているといったケースが発生し得ます。他にもライブラリなどを提供している場合には、全てのユーザーがこのようにキーワード引数で使ってくれるわけではないのでアップデート時に破壊的更新となってしまいユーザーに迷惑がかかってしまうケースなどもあるかもしれません。
そういったケースを軽減し破壊的な影響を出しにくくするために引数の途中でアスタリスクの*
を入れることで、以降の必ずキーワード引数で指定しないといけなくなるようにすることができます。
例えば関数の引数の記述を以下のようにaとbの間にアスタリスクを追加する形で対応ができます。
def any_func(a: int, *, b: int = 20, c: int = 30, e: int = 50) -> str:
...
アスタリスクの後の引数はキーワード引数で指定しないといけなくなるというPythonの言語仕様がありますので、これでユーザーはb以降のデフォルト値を取る引数はキーワード引数で指定しないといけなくなります。
試しに位置引数で指定してみるとVS Code上のPylanceでリアルタイムにエラーが表示されます(mypyなどでも引っかかることは確認済みですので、CIなどでmypyを通す形になっていれば事前にミスに気づけます)。
any_func(10, 20, 30)
プログラム自体も動かしてみるとエラーとなって位置引数では動かせられないことが確認できます。
TypeError: any_func() takes 1 positional argument but 3 were given
これによって「必ずユーザーはデフォルト引数付きの引数はキーワード引数で指定している」という状態を担保できるため、安全に破壊的変更になりにくい形で関数などの引数内容の追加や位置調整などのアップデートをかけることができるようになります。
お手軽ですし堅牢性が高まったりとメリットが多いため、今後は積極的に使っていきたいところです。
一部ビルトインやライブラリとの兼ね合いで使えないケースも発生する
自作ライブラリで反映作業をしていて気づいたのですが、デフォルト値を取る引数で必ずしもその引数の前にアスタリスクが配置できるわけではない・・・というケースを見つけています。例えばビルトインなどで位置引数が必要になるケースです。
並列処理のPoolで考えてみます。Poolのiterable引数に指定する値は位置引数として指定されるため、キーワード引数での指定を強制してあるとエラーになります(Pylanceなどでも引っかかります)。
from multiprocessing import Pool
def any_func(*, a: int = 10) -> None:
print(a)
with Pool(processes=4) as p:
p.map(func=any_func, iterable=[10, 20, 30])
こういったケースではそもそもPoolの代わりにconcurrent.futuresパッケージなどのキーワード引数が指定できるビルトインのものを使う・・・といったケースも出てくるかもしれません。
from concurrent.futures import ProcessPoolExecutor, Future, as_completed
from typing import List
def any_func(*, a: int = 10) -> None:
print(a)
futures: List[Future] = []
with ProcessPoolExecutor(max_workers=4) as executor:
for a in (10, 20, 30):
future = executor.submit(fn=any_func, a=a)
futures.append(future)
_ = as_completed(fs=futures)
参考 :
参考文献・参考サイトまとめ
https://twitter.com/Atsushi_twi/status/1467110845243326465?s=20