effective_python

【 Effective Python, 2nd Edition 】keyword-only arguments (キーワード指定のみ引数) と positional-only arguments (位置指定のみ引数) を使いこなして、コードの読解性と将来的な拡張性を高めよう! 投稿一覧へ戻る

Tags: python , effective

Published 2020年6月22日20:28 by T.Tsuyoshi

Python では、関数を呼び出す際にキーワード引数 (keyword arguments) を利用することで、引数の役割が明確になりコードの読解性、メンテナンス性が高まったり、将来的に、関数側で新たにデフォルト値を設定したオプションパラメータを追加して下位互換性を維持したまま機能の拡張ができるようになったり、非常に多くの利点を享受することができます。


ここで、割り算の答えを返す単純な関数を考えてみましょう。


ただしこの関数では、設定によって、OverflowError 例外が発生した場合でも代わりに 0 を返し、
ZeroDivisionError 例外が発生した場合でも代わりに infinity (無限) を返すことができるようにしましょう。


def safe_division(dividend, divisor, ignore_overflow, ignore_zero_division):
try:
return dividend / divisor
except OverflowError:
if ignore_overflow:
return 0
else:
raise
except ZeroDivisionError:
if ignore_zero_division:
return float('inf')
else:
raise



OverflowError 例外を無視するようにこの関数を呼び出します。

result = safe_division(1.0, 10**500, True, False)
print(result) # 0



次は ZeroDivisionError 例外を無視するように呼び出してみます。

result = safe_division(1.0, 0, False, True)
print(result) # inf



期待通りの動作ですが、ちょっと問題があります。どちらの Boolean 引数がどちらの例外を無視するものなのか、を混同しやすい、ということです。


この例では、3番目と4番目の位置引数として単純に True や False を渡していますが、もし勘違いして値を入れ替えて渡してしまったとしてもプログラムは正常に動作してしまうでしょう。


この種のバグはとにかく見つけ辛いです。ゲッソリやつれます。


そこで、関数側でこの2つのパラメータにデフォルト値を設定し、オプションパラメータとして扱うことにしましょう。


def safer_division(dividend, divisor, ignore_overflow=False, ignore_zero_division=False):
try:
return dividend / divisor
except OverflowError:
if ignore_overflow:
return 0
else:
raise
except ZeroDivisionError:
if ignore_zero_division:
return float('inf')
else:
raise



そして、呼び出す側では、無視したい例外を指定する場合にはキーワード引数で指定するようにします。


result = safer_division(1.0, 10**500, ignore_overflow=True)
print(result) # 0

result = safer_division(1.0, 0, ignore_zero_division=True)
print(result) # inf



一安心ですね、ホッ!


いえいえ、まだまだ安心はできません。


関数側でこのような実装をしたとしても、オプションパラメータに値を指定する場合はキーワード引数で、ということを呼び出し側に強制できるわけではありません。


今までどおり位置引数で渡すこともできちゃいます。


assert safer_division(1.0, 10**500, True, False) == 0



そこで、キーワード引数でなければこれらのオプションパラメータに値を渡せないようにしちゃいましょう、そうしましょう。


def safest_division(dividend, divisor, *, ignore_overflow=False, ignore_zero_division=False):
try:
return dividend / divisor
except OverflowError:
if ignore_overflow:
return 0
else:
raise
except ZeroDivisionError:
if ignore_zero_division:
return float('inf')
else:
raise



パラメータリストに * を追加しました。


パラメータリスト中の * シンボルは、「これ以降のパラメータはキーワード引数のみ受け付けます」という境界線を作り出します。
ですから、3番目と4番目の引数を位置引数として渡すことはできなくなります。


result = safest_division(1.0, 10**500, True, False)

# TypeError: safest_division() takes 2 positional arguments but 4 were given




もちろん、キーワード引数として渡した場合は期待通りに動作します。


result = safest_division(1.0, 0, ignore_zero_division=True)
assert result == float('inf')



該当すればちゃんと例外も投げてきます。


try:
result = safest_division(1.0, 0)
except ZeroDivisionError:
print('Woohoo!')

# Woohoo!




一件落着!?ところが、残念ながら話はまだ続きます。


呼び出し側で、最初の2つの引数、もしくは、どちらか一方をキーワード引数で渡していたとします。


assert safest_division(dividend=2, divisor=5) == 0.4
assert safest_division(divisor=5, dividend=2) == 0.4
assert safest_division(2, divisor=5) == 0.4



さて、この関数の作成者の気が変わって、パラメータの名前を変更しちゃいました。


def safest_division_changed(numerator, denominator, *, ignore_overflow=False, ignore_zero_division=False):
try:
return numerator / denominator
except OverflowError:
if ignore_overflow:
return 0
else:
raise
except ZeroDivisionError:
if ignore_zero_division:
return float('inf')
else:
raise



このちょっとした表面的な変更は、それまでの division、divisor パラメータにキーワード引数で値を渡していた全ての呼び出し元を機能させなくする、という大問題を引き起こします。


safest_division_changed(dividend=2, divisor=5)

# TypeError: safest_division_changed() got an unexpected keyword argument 'dividend'




そこで、Python 3.8 では、将来的にこのような変更があることも考慮して positional-only arguments (位置指定のみ引数) という考えが採用されました。


これは先ほどまで見てきた keyword-only arguments (キーワード指定のみ引数) の対極に位置するものです。


def safest_division_changed_positional(numerator, denominator, /, *, ignore_overflow=False, ignore_zero_division=False):
try:
return numerator / denominator
except OverflowError:
if ignore_overflow:
return 0
else:
raise
except ZeroDivisionError:
if ignore_zero_division:
return float('inf')
else:
raise



パラメータリスト中の / シンボルは、「ここまでの引数は位置引数としてしか値を渡せませんよ」ということを意味しています。


assert safest_division_changed_positional(2, 5) == 0.4



ですが、キーワード引数として値を渡そうとすると...


safest_division_changed_positional(2, denominator=5)

# TypeError: safest_division_changed_positional() got some positional-only arguments passed as keyword arguments: 'denominator'




ここまでで、前半2つのパラメータに対しては位置引数でしか値を渡せないので、将来的なパラメータ名の変更が可能となり、
後半2つのパラメータに対してはキーワード引数でしか値を渡せないので、コードの読解性を高め、引数の渡し間違いによるバグ発生を軽減できるようになりました。


最後にもう1つだけ。


パラメータリスト内の / シンボルと * シンボルの間に置かれたパラメータに対しては、Python のデフォルトの動作と同様に、位置引数としてもキーワード引数としても値を渡せます。


割り算の結果の小数点以下の桁数を指定するためのパラメータを追加してみましょう。


def safest_division_changed_positional_plus(numerator, denominator, /,
ndigits=10, *,
ignore_overflow=False, ignore_zero_division=False):
try:
fraction = numerator / denominator
return round(fraction, ndigits)
except OverflowError:
if ignore_overflow:
return 0
else:
raise
except ZeroDivisionError:
if ignore_zero_division:
return float('inf')
else:
raise



ndigits パラメータには、引数を省略することも、位置引数として渡すことも、キーワード引数として渡すこともできます。


result = safest_division_changed_positional_plus(22, 7)
print(result) # 3.1428571429

result = safest_division_changed_positional_plus(22, 7, 5)
print(result) # 3.14286

result = safest_division_changed_positional_plus(22, 7, ndigits=2)
print(result) # 3.14



まとめ:

1: Keyword-only arguments (キーワード指定のみ引数) は引数の意味合いを明示し、コードの読解性を高めます。
パラメータリスト中の * シンボルに続くパラメータは全て keyword-only arguments です。

2: Positional-only arguments (位置指定のみ引数) はパラメータ名による束縛を軽減し、将来的な名前変更を担保することで、
コード全体に一貫性を与えるチャンスを残します。
パラメータリスト中の / シンボルより前のパラメータは全て positional-only arguments です。

3: パラメータリスト中の / シンボルと * シンボルの間におかれたパラメータは、Python におけるパラメータのデフォルト操作同様、
位置引数としてもキーワード引数としても値を渡すことが可能です。

この投稿をメールでシェアする

0 comments

コメントはまだありません。

コメントを追加する(不適切と思われるコメントは削除する場合があります)