はじめに
- こんにちは、takadowaです。
- 前回の『Effective Python 第2版』 第2章<リストと辞書>に引き続き、今回は第3章<関数>の内容をベースに、さらに調べて知ったことや自分の感想をまとめていきたいと思います。
実行環境
サンプルコードを掲載する際は、以下の実行環境で実行した結果を掲載します。
できる限りミスのない形で提供することを心がけますが、動かない、間違っているなどがありましてもご容赦ください。
- macOS 10.14.6
- Python 3.8.5
第3章<関数>
Noneではなく例外を送出する
関数の中で処理した際、何か想定外のことが起きたら何を返しますか?
自分はこれを知るまではよくNone
を返していました。例えば以下のようなコードです。
# 良くない例(想定外のときにNoneを返している)
from datetime import datetime
def parse_datetimes(datetime_string_list):
result = []
for datetime_string in datetime_string_list:
try:
result.append(datetime.strptime(datetime_string, "%Y-%m-%d"))
except (ValueError, TypeError):
return None
return result
>>> print(parse_datetimes(["2020-09-22"]))
[datetime.datetime(2020, 9, 22, 0, 0)]
>>> print(parse_datetimes([]))
[]
>>> print(parse_datetimes(["hoge"]))
None
この関数は年月日を表した文字列のリストを入力として受け付け、パースが成功したらdatetime型に変換したリストを、失敗したらNone
を返します。
一見良さそうに見えますが、これには2つの問題があります。
- (問題1): 呼び出し元で返り値が
None
かどうかのチェックを忘れたら(あるいは意図的に無視したら)そのままプログラムが次の処理をできてしまう - (問題2): 呼び出し元で返り値を
if result
で評価してしまうと、None
の場合だけでなく空リスト[]
のときも同じ扱いになってしまいバグを生みやすい
これらの問題を避けるため、 関数内で想定外のことが起きた場合は例外を送出する ようにしましょう。
# 良い例(想定外のときに例外を送出している)
from datetime import datetime
class MyError(Exception):
def __init__(self, message):
self.message = message
def parse_datetimes(datetime_string_list):
result = []
for datetime_string in datetime_string_list:
try:
result.append(datetime.strptime(datetime_string, "%Y-%m-%d"))
except (ValueError, TypeError):
raise MyError(f"入力された値が不正です: {datetime_string}") # 例外を送出する
return result
>>> print(parse_datetimes(["2020-09-22"]))
[datetime.datetime(2020, 9, 22, 0, 0)]
>>> print(parse_datetimes([]))
[]
>>> print(parse_datetimes(["hoge"]))
...
Traceback (most recent call last):
...
__main__.MyError: 入力された値が不正です: hoge
上記のように例外を送出するように実装されていれば、MyError
という例外をキャッチしていない限り、そのまま処理が継続することはありませんし、None
と空リスト[]
を同一扱いしてしまうという恐れもないので安心ですね。
デフォルト引数に空リスト[]
や空辞書{}
を指定してはならない
引数に値が指定されればそれを使い、指定されなければデフォルト値を使いたい、そんなときに便利なのが「デフォルト引数を設定すること」ですよね。例えば、以下のようなコードです。
# これは良くない例
def square(value, result_list=[]):
result_list.append(value ** 2)
return result_list
この関数はvalue
を2乗したものをresult_list
に追加して返しています。
この実装をした人の気持ちとしては、
-
result_list
にリストが指定されれば、そのリストに結果を入れて返してほしい -
result_list
に値を指定しなければ、空リストに結果を入れて返してほしい
と考えていると思います。
# result_listを指定すれば、そのリストに結果が入って返ってくる
>>> result_list = [1, 4]
>>> result_list = square(3, result_list)
>>> print(result_list)
[1, 4, 9]
# result_listを指定しなければ、空リストに結果が入って返ってくる
>>> result_list = square(3)
>>> print(result_list)
[9]
全然問題ないように見えますよね?でもこのあと不思議なことが起きます。
>>> result_list = square(1)
>>> print(result_list)
[9, 1] # あれ?[1]が返ってくるはずでは??
>>> result_list = square(2)
print(result_list)
[9, 1, 4] # あれ?なんで値が3つも入っているの??
そう。知らないとこのような不思議な挙動に頭を抱えることになってしまいます。
実は、**デフォルト引数は関数が呼び出されるたびに評価されるのではなく、モジュールがロードされたときに1度だけ評価される**のです。したがって、デフォルト引数に空リスト[]
を指定してしまうと、その空リストにどんどんappend
されていくことになるため、上記のように呼び出すたびにリストの中身が増えていくのです。
これを知っていて使うならまだ良いですが、誰でも知っている挙動ではないので、デフォルト引数に空リストや空辞書などを指定するのは避けた方がいいでしょう。代わりに、以下のように書くといいでしょう。
# 良い例
def square(value, result_list=None):
if result_list is None:
result_list = [] # ここで初期化することで、毎回空リストにappendされるようにする
result_list.append(value ** 2)
return result_list
上記のように実装すれば、result_list
が指定されなかったときは空リストに結果が入ったものが返るようになります。
>>> result_list = square(3)
>>> print(result_list)
[9]
>>> result_list = square(1)
>>> print(result_list)
[1]
>>> result_list = square(2)
>>> print(result_list)
[4]
ちなみに、IDEによっては警告を出してくれることもありますので、警告が出ていたら対応するようにしましょう。
PyCharmだと以下のような警告を出してくれました。
Python3.8から追加された「位置専用引数」と「キーワード専用引数」
突然ですが、以下のコードはどういう意味かわかりますか?
def safe_division(numerator, denominator, /, hoge, *, ignore_overflow, ignore_zero_division):
...
「なんで引数に/
や*
が書かれているの?」と思われたかもしれません。
これは、Python3.8から追加された「位置専用引数(/
の左側)」と「キーワード専用引数(*
の右側)」を使って書かれています。
どういうことかと言いますと、
-
/
の左側の引数(上の例だとnumerator
とdenominator
)は位置専用引数となる- 位置専用引数ということは、関数を呼び出すときに位置引数としてのみ指定できるようになる(言い換えると、キーワード引数としては指定できない)
- つまり
- この呼び出し方はOK:
safe_division(3, 2, ...)
- この呼び出し方はNG:
safe_division(numerator=3, denominator=2, ...)
- この呼び出し方はOK:
-
*
の右側の引数(上の例だとignore_overflow
とignore_zero_division
)はキーワード専用引数となる- キーワード専用引数ということは、関数を呼び出すときにキーワード引数としてのみ指定できるようになる(言い換えると、位置引数としては指定できない)
- つまり
- この呼び出し方はOK:
safe_division(..., ignore_overflow=True, ignore_zero_division=False)
- この呼び出し方はNG:
safe_division(..., True, False)
- この呼び出し方はOK:
- ちなみに、
/
の右側かつ*
の左側の引数(上の例だとhoge
)は位置引数としてでもキーワード引数としてでも指定できます- つまりこれまでどおりの呼び出しができる
- この呼び出し方はOK:
safe_division(..., "a", ...)
- この呼び出し方もOK:
safe_division(..., hoge="a", ...)
- この呼び出し方はOK:
- つまりこれまでどおりの呼び出しができる
位置専用引数、キーワード専用引数を使うことのメリットとしては以下があると思います。
- 位置専用引数を使うメリット
- 上記例のように「分子 ÷ 分母」の計算を行う関数の場合、分子と分母の指定の順序を強制できます
- すなわち、
(分母=2, 分子=3, ...)
のような直感に反する順序での指定を排除することができます
- すなわち、
- また、引数名を使った指定をできなくするので、引数名への依存を減らすことができます
- すなわち、引数名が
denominator
という名前からdenom
という名前に変わったとしても、呼び出し元の修正は必要ありません
- すなわち、引数名が
- 上記例のように「分子 ÷ 分母」の計算を行う関数の場合、分子と分母の指定の順序を強制できます
- キーワード専用引数を使うメリット
- 複数の論理型フラグを使う場合など紛らわしい関数の呼び出し時にキーワード引数で与えることを強制できます
- すなわち、
ignore_overflow
がTrue
なんだよ!ということをちゃんと認識して引数指定させることを強制できます - なので、「
ignore_overflow
をTrue
にしてるつもりだったのにignore_zero_division
にTrue
を指定しまっていた!」というよくありがちなミスを減らすことができます
- すなわち、
- 複数の論理型フラグを使う場合など紛らわしい関数の呼び出し時にキーワード引数で与えることを強制できます
引数に/
や*
を書くのは最初は違和感があるかもしれませんが、上述のとおりメリットもありますので、必要に応じて使うようにしていくといいでしょう。ただし、Python3.8未満だと動かないので、そこは要注意です!
まとめ
『Effective Python 第2版』 第3章<関数> の内容をベースに、調べて知ったことや感想を記しました。
- 関数内で想定外のことが起きる場合は、
None
を返すのではなく、例外を送出するようにすると良いでしょう - 関数のデフォルト引数に空リスト
[]
や空辞書{}
を指定すると思わぬバグにつながるので避けたほうが良いでしょう - Python3.8から追加された「位置専用引数」と「キーワード専用引数」の使い方を理解すればより充実したPython開発ができるようになるでしょう
参考文献
- Effective Python 第2版
参考サイト
- なぜオブジェクト間でデフォルト値が共有されるのですか? -- Python公式ドキュメント
- 位置専用 と キーワード専用 -- Python公式ドキュメント