Python

【Python】コンストラクタの引数のデフォルト値にミュータブルな変数を指定してはいけない理由

クラスを作成する場合、通常は__init__()でインスタンス変数などの初期設定を行う。

__init__()には引数を指定できるが、その引数のデフォルト値にミュータブルな型の変数を指定するとバグの温床となる。

多分きっと、初心者は必ず通る道であると思う。(そして、ミュータブル、イミュータブルという考え方を意識し始める(はず))

ミュータブルなデフォルト値を設定した場合

リストをデフォルト値に持つクラス作成

ミュータブルな型の代表例としてリストを使ってみる。

class ClsA:
    # valsのデフォルト値に空のリストを設定
    def __init__(self, a, vals=[]):
        self.a = a
        self.vals = vals

# ClsAのインスタンスを2種類作成する。
# valsにはデフォルト値が設定されるようにする。
cls_a_1 = ClsA(1)
cls_a_2 = ClsA(2)

# cls_a_1のvalsのidと内容確認
print(f"{id(cls_a_1.vals)=}")
print(cls_a_1.vals)

# cls_a_2のvalsのidと内容確認
print(f"{id(cls_a_2.vals)=}")
print(cls_a_2.vals)
>>
id(cls_a_1.vals)=2360930307456
[]
id(cls_a_2.vals)=2360930307456
[]

cls_a_1のvalsとcls_a_2のvalsのidが同じ。

リストはミュータブルなので、cls_a_1またはcls_a_2のvalsにオブジェクトidが変わらない操作を行うと、両方のインスタンスのvalsが更新される。

デフォルト値が設定されたインスタンス変数の更新(破壊的)

リストは大抵の操作がオブジェクトのidを変えずにデータを更新可能。

例として、よく使われるappendをしてみる。

# cls_a_1のvalsのidが変わらない操作を行う。
cls_a_1.vals.append(1)

print(f"{id(cls_a_1.vals)=}")
print(cls_a_1.vals)

print(f"{id(cls_a_2.vals)=}")
print(cls_a_2.vals)
>>
id(cls_a_1.vals)=2360930307456

id(cls_a_2.vals)=2360930307456

cls_a_1のvalsを変更すると、同じオブジェクトを参照しているcls_a_2のvalsの値も更新される(更新というよりも同じオブジェクトを指しているだけ)。

通常、特定のインスタンスに対して行った変更が、別のインスタンスにまで反映されることを想定して開発することはなく、バグの温床となる。

デフォルト値が更新された状態でインスタンス生成

上記のような操作でデフォルト値が更新された状態でデフォルト値を使うようなインスタンス生成を行うと、当然、更新された状態のデフォルト値でインスタンスが生成される。

cls_a_3 = ClsA(3)
print(f"{id(cls_a_3.vals)=}")
print(cls_a_3.vals)
>>
id(cls_a_3.vals)=2360930307456

valsの値を指定しない場合は空のリストとなることを想定しているので、確実にバグの温床となる。

イミュータブルなデフォルト値を設定した場合

Noneをデフォルト値に持つクラス作成

困った時のNone。

class ClsB:
    # valsのデフォルト値にNoneを設定する
    def __init__(self, a, vals=None):
        self.a = a
        # valsの値がNoneの場合、空のリストを設定する
        self.vals = vals if vals is not None else []

# 2個のインスタンス生成
# valsはデフォルト値を使って生成
cls_b_1 = ClsB(1)
cls_b_2 = ClsB(2)
# 各インスタンスのvalsのオブジェクトidと値の確認
print(f"{id(cls_b_1.vals)=}")
print(cls_b_1.vals)

print(f"{id(cls_b_2.vals)=}")
print(cls_b_2.vals)
>>
id(cls_b_1.vals)=2360929857792
[]
id(cls_b_2.vals)=2360929996224
[]

どちらのvalsも値は空のリストだが、オブジェクトidが異なる。

インスタンス変数の更新

オブジェクトidが異なるので、cls_b_1のvalsを更新しても、他のインスタンスには反映されない。

cls_b_1.vals.append(1)
print(f"{id(cls_b_1.vals)=}")
print(cls_b_1.vals)

print(f"{id(cls_b_2.vals)=}")
print(cls_b_2.vals)
>>
id(cls_b_1.vals)=2360929857792

id(cls_b_2.vals)=2360929996224
[]

多分、これが、みんなが想定している動作。

まとめ

破壊的な変更が可能(オブジェクトのidが変わらずに内容を変更可能)なオブジェクトは基本的にはクラスのコンストラクタの引数に設定しない方が良い。

  • リスト
  • DataFrame、Series

DataFrameやSeiresは必ずしも破壊的な変更になるとは限らないが、空のDataFrameやSeriesに何か操作をする場合、破壊的な変更をしてしまう可能性はあると思う。

少々面倒でも、デフォルト値にはNoneなどを設定し、処理の中で空のリストやDataFrameなどを設定するようにした方が良い。