Pythonの関数でmutableなデフォルト引数を設定した際の挙動を確認してみる

2023.10.30

はじめに

データアナリティクス事業本部のkobayashiです。

Pythonで関数で定義する際にミュータブルな引数でデフォルト引数値を指定した場合の挙動には注意が必要です。実装を行っている際にそのような場面に出くわしたので実際にその挙動をコードを用いて確認してみましたのでその内容をまとめます。

関数定義にミュータブルなデフォルト引数値を設定した際の注意点

Pythonの公式ドキュメントの 8. 複合文 (compound statement) の中の 8.7. 関数定義 — Python 3.11.6 ドキュメント を確認すると以下の様に書かれています。

デフォルト引数値は関数定義が実行されるときに左から右へ評価されます。 これは、デフォルト引数の式は関数が定義されるときにただ一度だけ評価され、同じ "計算済みの" 値が呼び出しのたびに使用されることを意味します。この仕様を理解しておくことは特に、デフォルト引数値がリストや辞書のようなミュータブルなオブジェクトであるときに重要です: 関数がこのオブジェクトを変更 (例えばリストに要素を追加) すると、このデフォルト引数値が変更の影響を受けてしまいます。

この中でも最後の1文が今回調査したかった内容で具体的には以下の内容になります。

  1. リストや辞書などのミュータブルな変数をデフォルト変数にした上で関数内でその変数を操作するような関数を定義する。
  2. 一度関数実行して変数を関数内で操作する。
  3. 再度同じ関数を実行して関数内で操作すると1での変更内容が2度目の関数実行で引き継がれてしまう

ということになります。

ではこれを実際のコードで確かめてみたいと思います。

ミュータブルなデフォルト引数値を持った関数を実行してみる

実行する環境は以下の環境になります。

環境

  • Python: 3.11.6

実行するコードは以下になります。内容としては引数としてval_s(文字列型)、val_l(リスト型)、val_d(辞書型)を持ちそれぞれデフォルを引数を設定します。関数内の処理ではリスト型のval_lval_sを追加し、辞書型のval_dにはval_sをキーとして値を追加しています。その後それぞれの値とオブジェクトIDを表示しています。

func_default_arg.py

def test_def_arg(val_s: str = "test", val_l: list = [], val_d: dict = {}):
    val_l.append(val_s)
    val_d[val_s] = 123
    print("val_s", id(val_s), val_s)
    print("val_l", id(val_l), val_l)
    print("val_d", id(val_d), val_d)


print("1. デフォルト引数")
test_def_arg()

print("文字列型")
test_def_arg(val_s="a")

print("リスト型")
test_def_arg(val_l=["l_1"])

print("辞書型")
test_def_arg(val_d={"d_1": 456})

print("デフォルト引数")
test_def_arg()

では実行してみます。

$ python func_default_arg.py 

# 1.デフォルト引数
val_s 4379057328 test
val_l 4377501824 ['l_1', 'test']
val_d 4377500800 {'test': 123}

# 2.文字列型
val_s 4384886064 a
val_l 4377501824 ['l_1', 'test', 'a']
val_d 4377500800 {'test': 123, 'a': 123}

# 3.リスト型
val_s 4379057328 test
val_l 4539194048 ['l_1', 'test']
val_d 4377500800 {'test': 123, 'a': 123}

# 4.辞書型
val_s 4379057328 test
val_l 4377501824 ['l_1', 'test', 'a', 'test']
val_d 4377602560 {'d_1': 456, 'test': 123}

# 5.デフォルト引数
val_s 4379057328 test
val_l 4377501824 ['l_1', 'test', 'a', 'test', 'test']
val_d 4377500800 {'test': 123, 'a': 123}

オブジェクトIDを確認してみると公式ドキュメントの通り最初の呼び出し時に作成されたオブジェクトとしてval_s:4379057328val_l:4377501824val_d:4377500800、を関数実行時に値を指定しないが場合は2度目移行も使用していることがわかります。対して関数実行時に値を指定した場合は新しいオブジェクトとして作成されています(val_s:4384886064val_l:4539194048val_d:4377602560)。

このような挙動のためイミュータブルなオブジェクトでデフォルト引数を使う場合は特に問題ないのですが、ミュータブルなオブジェクトの場合は同じオブジェクトに対して操作を行っているため直前に実行された操作の影響を受けてしまっています。

一般的に期待する結果としてはミュータブルな引数でも値を指定しない場合はデフォルト引数値を使うのが期待される結果かと思います。対策としては公式ドキュメントでも以下のように記載されている通り、

このような動作を避けるには、デフォルト値として None を使い、この値を関数本体の中で明示的にテストします。

デフォルト値ではNoneを使って関数内で改めてデフォルト値を定義することで期待する結果となります。

func_default_arg2.py

def test_def_arg_2(val_s: str = "test", val_l: None = None, val_d: None = None):
    # 関数内でデフォルト値を指定
    val_l = val_l or []
    val_d = val_d or {}
    
    val_l.append(val_s)
    val_d[val_s] = 123
    print("val_s", id(val_s), val_s)
    print("val_l", id(val_l), val_l)
    print("val_d", id(val_d), val_d)


print("# 1.デフォルト引数")
test_def_arg_2()

print("# 2.文字列型")
test_def_arg_2(val_s="aaaa")

print("# 3.リスト型")
test_def_arg_2(val_l=["bbbb"])

print("# 4.辞書型")
test_def_arg_2(val_d={"d_1": 456})

print("# 5.デフォルト引数")
test_def_arg_2()

これを実行してみます。

$ python func_default_arg2.py 

# 1.デフォルト引数
val_s 4303674544 test
val_l 4403927360 ['test']
val_d 4302219776 {'test': 123}

# 2.文字列型
val_s 4303395888 aaaa
val_l 4403927360 ['aaaa']
val_d 4302219776 {'aaaa': 123}

# 3.リスト型
val_s 4303674544 test
val_l 4403927360 ['bbbb', 'test']
val_d 4302219776 {'test': 123}

# 4.辞書型
val_s 4303674544 test
val_l 4403927360 ['test']
val_d 4302219968 {'d_1': 456, 'test': 123}

# 5.デフォルト引数
val_s 4303674544 test
val_l 4403927360 ['test']
val_d 4302219968 {'test': 123}

上記の様に関数内でデフォルト値を設定することで一般的に期待する結果となります。

まとめ

Pythonで関数で定義する際にミュータブルな引数でデフォルト引数値を指定した場合の挙動をオブジェクトIDを持って確認してみました。デフォルト引数にミュータブルなオブジェクトを使う際にはこの挙動に注意して実装しないと思わぬバグとなるので気を付ける必要があります。

最後まで読んで頂いてありがとうございました。