[Python] キーワード専用引数をおすすめしたい

この記事は[Jij Inc. Advent Calendar 2023](https://qiita.com/advent-calendar/2023/jij_inc_2023)の15日目の記事です。

株式会社JijのNY57です。

本記事ではPythonの引数に関する持論を展開したいと考えています。よろしくお願いします。


# TL:DR

* キーワード専用引数は変更・削除される可能性のある引数に使うと良い

# 前提: 関数への値の渡し方

以下のように定義された関数を考えます。
```Python
def func(arg1, arg2, arg3):
    ...
```
この関数を利用する時、引数への値の渡し方は2パターン存在しています。
```Python
func(1, 2, 3) # 位置引数で指定する
func(arg1=1, arg2=2, arg3=3) # キーワード引数で指定する
```
ひとつは`func([値], ...)`で指定する方法。もうひとつは`func([引数名]=[値], ...)`で指定する方法です。本記事では前者を**位置引数指定**と呼び、後者を**キーワード引数指定**と呼ぶことにします。


# 前提: 引数の定義の仕方

Pythonでは関数の引数の定義の仕方が3パターン存在しています。もっとも一般的な定義の仕方は以下の通りです。
```Python
def func(arg1, arg2, arg3):
    ...
```
この場合、引数に値を渡す方法は位置引数指定・キーワード引数指定のどちらも許されています。
```Python
func(1, 2, 3) # OK!
func(arg1=1, arg2=2, arg3=3) # OK!
```
一方で位置引数指定のみ・キーワード引数のみを許すような定義方法も存在しています。
位置引数指定のみを許すようにしたい場合、次のように定義します。
```Python
def func_pos(pos1, pos2, pos3, /): # /より前の引数は位置引数指定のみを許す
    ...
```
上記のように定義することで引数`pos1`, `pos2`, `pos3`は位置引数指定のみが許されるようになります。本記事ではこのような位置引数指定のみが許されている引数を**位置専用引数**と呼ぶことにします。
位置専用引数にキーワード引数指定で値を渡すと以下のようなエラーが出ます。
```Python
func_pos(1, 2, pos3=3)

""" 実行結果
TypeError: func() got some positional-only arguments passed as keyword arguments: 'pos3'
"""
```
同様にキーワード引数指定のみを許すようにしたい場合、次のように定義します。
```Python
def func_kw(*, kw1, kw2, kw3): # *より後の引数はキーワード引数指定のみを許す
    ...
```
上記のように定義することで引数`kw1`, `kw2`, `kw3`はキーワード引数指定のみが許されるようになります。本記事ではこのようなキーワード引数指定のみが許されている引数を**キーワード専用引数**と呼ぶことにします。
キーワード専用引数に位置引数指定で値を渡すと以下のようなエラーが出ます。
```Python
func_kw(1, kw2=2, kw3=3)

""" 実行結果
TypeError: func() takes 0 positional arguments but 1 positional argument (and 2 keyword-only argument) were given
"""
```


# キーワード専用引数のメリット

さて、ここからが本題です。自分がキーワード専用引数をおすすめしたい理由は、以下のような変更が生じた時に対処しやすいからです。

- 引数の削除
- 引数名の変更

これらの変更は破壊的な変更でありユーザーに影響を与える変更です。ですので、ここでいう「対処しやすい」とは「ユーザー与える影響を抑えつつ変更する方法がある」ことを指すものとします。

### メリット: 引数の削除に対処しやすい
まず、通常の引数定義の場合を考えてみましょう。以下の定義から引数`arg2`が不要になったので削除したいとします。
```Python
def func(arg1, arg2, arg3):
    ...

### ↓ `arg2`を削除 ↓ ###

def func(arg1, arg3):
    ...
```
このとき、安易に`arg2`を削除してしまうと関数`func`のユーザーは
```Python
func(1, 2, 3)

""" 実行結果
TypeError: func() takes 2 positional arguments but 3 were given
"""
```
```Python
func(arg1=1, arg2=2, arg3=3)

""" 実行結果
TypeError: func() got an unexpected keyword argument 'arg2'
"""
```
のようなTypeErrorが発生し始めてしまい困ってしまいます。全てのユーザーがリリースノートを読んだりErrorを読むに抵抗がないとは限りませんから、こういった変更には警告（Warning）を出す期間を設けたいものです。では、そのようなWarningを出す処理を書き加えることができるでしょうか？
```Python
def func(arg1, arg2=None, arg3):
    if arg2 is not None:
        warnings.warn("`arg2` is deprecated")
    ...
```
例えば、こんな定義をしてみてはどうでしょうか？　一見すると`arg2`に値が渡されればWarningが出て、そうでなければデフォルト値のNoneが設定されてWarningが出ないように見えます。が、この定義ではSyntaxErrorが発生してしまいます。
```
SyntaxError: non-default argument follows default argument
```
`arg3`にデフォルト値を設定できればSyntaxErrorを解決できますが、必ずしも設定できるとは限らない為、どうやらこの実装方法ではダメそうです。では、デフォルト値を設定しない場合はどうでしょう？
```Python
def func(arg1, arg2, arg3):
    if arg2 is not None:
        warnings.warn("`arg2` is deprecated")
    ...
```
この実装では、Warningを見たユーザーが引数`arg2`を削除してしまうとTypeErrorが発生してしまいます。これではユーザーのコードを`arg2`削除後の形に移行することができません。
このように通常の引数の定義の仕方ではWarningを出しつつ移行できる状態を作ることができないのです。

一方でキーワード専用引数の場合を考えてみます。この場合、以下のように変更することでWarningを出しつつ移行できる状態を作ることができます。
```Python
def func(*, arg1, arg2=None, arg3):
    if arg2 is not None:
        warnings.warn("`arg2` is deprecated")
    ...
```
一見すると、デフォルト値が設定されている`arg2`が設定されていない`arg3`より先に来ている為、SyntaxErrorが起こりそうですが、キーワード専用引数で指定している場合は問題にはなりません。そして、`arg2`を使っているユーザーはWarningを受け取り、`arg2`を削除できる状態になっています。
```Python
func(arg1=1, arg2=2, arg3=3) # Warningが発生する
func(arg1=1, arg3=3) # OK！
```
この意味で**通常の引数定義よりキーワード専用引数のほうが引数の削除に対処しやすい**と言えるでしょう。


### メリット: 引数名の変更に対処しやすい
次は引数名`arg2`を`new_arg2`に変更することを考えてみましょう。通常の引数定義の場合に安直に引数名を変更すると
```Python
def func(arg1, arg2, arg3):
    ...

### ↓ `arg2`を`new_arg2`へ変更 ↓ ###

def func(arg1, new_arg2, arg3):
    ...
```
ユーザーはその利用パターンに応じてTypeErrorが発生したりしなかったりします。
```Python
func(1, 2, 3) # OK!
```
```Python
func(arg1=1, arg2=2, arg3=3)

""" 実行結果
TypeError: func() got an unexpected keyword argument 'arg2'
"""
```
このTypeErrorを発生させずに、`arg2`を使用しているユーザーにWarningを出しつつ、`arg2`を`new_arg2`に移行可能な状態にする方法はありません。

一方でキーワード引数限定の場合は以下のように変更すればWarningを出しつつ移行できる状態を作ることができます。
```Python
def func(*, arg1, new_arg2=None, arg3, arg2=None):
    if arg2 is not None:
        warnings.warn("Please use `new_arg2` instead of `arg2`")
        new_arg2 = arg2
    elif new_arg2 is None:
        raise TypeError("Please set a value for `new_arg2`")
    ...
```
この実装の場合、引数`arg2`に与えられた値は`new_arg2`へと代入されるので、関数内部の変数名は`new_arg2`に移行してしまって問題ありません。その上で`arg2`を使っている場合はWarningが表示され、ユーザーがキーワード引数名を変更中にうっかり`arg2`と`new_arg2`の両方を設定し忘れた場合にはTypeErrorが出るようにできます。勿論、キーワード専用引数としている為、デフォルト値の設定の有無と前後によるSyntaxErrorは出ません。
```Python
func(arg1=1, arg2=2, arg3=3) # Warningが発生する
func(arg1=1, arg3=3) # TypeErrorが発生する
func(arg1=1, new_arg2=2, arg3=3) # OK!
```
この意味で**通常の引数定義よりキーワード専用引数のほうが引数名の変更に対処しやすい**と言えるでしょう。

:::note info
位置専用引数なら引数名の変更はいつでも可能です。削除されない引数であれば位置専用引数を選択するのも良い案でしょう。
:::


# まとめ

キーワード専用引数を使うメリットは上記以外にもあると考えていますが、少なくとも上記で提示したメリットから言えるのは「変更・削除の可能性がある引数にはキーワード専用引数を使うと良い」ということです。

キーワード専用引数のメリットを活用してユーザーに優しい開発をしていきましょう。


# 最後に
＼Rustエンジニア・数理最適化エンジニア募集中！／
株式会社Jijでは、数学や物理学のバックグラウンドを活かし、量子計算と数理最適化のフロンティアで活躍するRustエンジニア、数理最適化エンジニアを募集しています！
詳細は下記のリンクからご覧ください。皆さんのご応募をお待ちしております！
Rustエンジニア: https://open.talentio.com/r/1/c/j-ij.com/pages/51062
数理最適化エンジニア: https://open.talentio.com/r/1/c/j-ij.com/pages/75132