3
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

『Effective Python 第2版』 第3章<関数>

Posted at

はじめに

実行環境

サンプルコードを掲載する際は、以下の実行環境で実行した結果を掲載します。
できる限りミスのない形で提供することを心がけますが、動かない、間違っているなどがありましてもご容赦ください。

  • 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だと以下のような警告を出してくれました。

pycharm_warning

Python3.8から追加された「位置専用引数」と「キーワード専用引数」

突然ですが、以下のコードはどういう意味かわかりますか?

def safe_division(numerator, denominator, /, hoge, *, ignore_overflow, ignore_zero_division):
    ...

「なんで引数に/*が書かれているの?」と思われたかもしれません。
これは、Python3.8から追加された「位置専用引数(/の左側)」と「キーワード専用引数(*の右側)」を使って書かれています。

どういうことかと言いますと、

  • /左側の引数(上の例だとnumeratordenominator)は位置専用引数となる
    • 位置専用引数ということは、関数を呼び出すときに位置引数としてのみ指定できるようになる(言い換えると、キーワード引数としては指定できない)
    • つまり
      • この呼び出し方はOK: safe_division(3, 2, ...)
      • この呼び出し方はNG: safe_division(numerator=3, denominator=2, ...)
  • *右側の引数(上の例だとignore_overflowignore_zero_division)はキーワード専用引数となる
    • キーワード専用引数ということは、関数を呼び出すときにキーワード引数としてのみ指定できるようになる(言い換えると、位置引数としては指定できない)
    • つまり
      • この呼び出し方はOK: safe_division(..., ignore_overflow=True, ignore_zero_division=False)
      • この呼び出し方はNG: safe_division(..., True, False)
  • ちなみに、/の右側かつ*の左側の引数(上の例だとhoge)は位置引数としてでもキーワード引数としてでも指定できます
    • つまりこれまでどおりの呼び出しができる
      • この呼び出し方はOK: safe_division(..., "a", ...)
      • この呼び出し方もOK: safe_division(..., hoge="a", ...)

位置専用引数、キーワード専用引数を使うことのメリットとしては以下があると思います。

  • 位置専用引数を使うメリット
    • 上記例のように「分子 ÷ 分母」の計算を行う関数の場合、分子と分母の指定の順序を強制できます
      • すなわち、(分母=2, 分子=3, ...) のような直感に反する順序での指定を排除することができます
    • また、引数名を使った指定をできなくするので、引数名への依存を減らすことができます
      • すなわち、引数名がdenominatorという名前からdenomという名前に変わったとしても、呼び出し元の修正は必要ありません
  • キーワード専用引数を使うメリット
    • 複数の論理型フラグを使う場合など紛らわしい関数の呼び出し時にキーワード引数で与えることを強制できます
      • すなわち、ignore_overflowTrueなんだよ!ということをちゃんと認識して引数指定させることを強制できます
      • なので、「ignore_overflowTrueにしてるつもりだったのにignore_zero_divisionTrueを指定しまっていた!」というよくありがちなミスを減らすことができます

引数に/*を書くのは最初は違和感があるかもしれませんが、上述のとおりメリットもありますので、必要に応じて使うようにしていくといいでしょう。ただし、Python3.8未満だと動かないので、そこは要注意です!

まとめ

『Effective Python 第2版』 第3章<関数> の内容をベースに、調べて知ったことや感想を記しました。

  • 関数内で想定外のことが起きる場合は、Noneを返すのではなく、例外を送出するようにすると良いでしょう
  • 関数のデフォルト引数に空リスト[]や空辞書{}を指定すると思わぬバグにつながるので避けたほうが良いでしょう
  • Python3.8から追加された「位置専用引数」と「キーワード専用引数」の使い方を理解すればより充実したPython開発ができるようになるでしょう

参考文献

参考サイト

3
5
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?