Pythonにおける可変オブジェクトの扱い:デフォルト引数とリストの複製に潜む罠

関数におけるデフォルト引数の評価タイミング

Pythonでよく遭遇する落とし穴の一つに、関数のデフォルト引数としてリストなどのミュータブル(可変)なオブジェクトを指定してしまうことがあります。以下の関数 collect_data を例に見てみましょう。

def collect_data(value, container=[]):
    container.append(value)
    return container

この関数を、特定のリストを指定して呼び出す場合は期待通りに動作します。

print(collect_data(10, [1, 2])) # [1, 2, 10]
print(collect_data(20, [3, 4])) # [3, 4, 20]

しかし、デフォルト引数に依存して複数回呼び出すと、結果は予想に反したものになります。

print(collect_data('a')) # ['a']
print(collect_data('b')) # ['a', 'b']
print(collect_data('c')) # ['a', 'b', 'c']

このように、過去の呼び出し時のデータが保持されてしまいます。これは、Pythonのデフォルト引数が関数定義時に一度だけ評価されるためです。変数 container は定義時に作成された単一のリストオブジェクトを指し続け、そのオブジェクトが呼び出しのたびに変更(mutate)されます。

この問題を回避するための標準的な手法は、デフォルト値として None を使用し、関数内部で新しいリストを生成することです。

def collect_data_safe(value, container=None):
    if container is None:
        container = []
    container.append(value)
    return container

# 実行結果
print(collect_data_safe('a')) # ['a']
print(collect_data_safe('b')) # ['b']

リストの乗算による参照の複製

多次元配列(リストのリスト)を初期化する際、* 演算子を使用すると簡潔に記述できるように見えますが、ここにも参照に関する罠が潜んでいます。

例えば、3x3のグリッドを作成しようとして以下のコードを書いたとします。

# 誤った初期化方法
grid = [[0] * 3] * 3
print(grid)
# [[0, 0, 0], [0, 0, 0], [0, 0, 0]]

一見正しく見えますが、インデックスを指定して値を書き換えると問題が明らかになります。

grid[0][0] = 9
print(grid)
# [[9, 0, 0], [9, 0, 0], [9, 0, 0]]

一つの要素を変更しただけなのに、すべての行の最初の要素が 9 に変わってしまいました。これは、[[0] * 3] * 3 という式が「同じリストオブジェクトへの参照を3つ持つリスト」を作成しているためです。外側のリストに含まれる3つの要素はすべて同一のメモリ空間を指しています。

意図した通りに、各行が独立したオブジェクトとして生成されるようにするには、リスト内包表記を使用するのが適切です。

# 正しい初期化方法
correct_grid = [[0] * 3 for _ in range(3)]

correct_grid[0][0] = 9
print(correct_grid)
# [[9, 0, 0], [0, 0, 0], [0, 0, 0]]

この書き方では、ループが回るたびに [0] * 3 が新しく評価され、独立したリストオブジェクトが生成されます。これは以下の手続き的な記述と等価です。

independent_grid = []
for _ in range(3):
    row = [0] * 3
    independent_grid.append(row)

ミュータブルなオブジェクトを扱う際は、常に「値のコピー」なのか「参照の共有」なのかを意識することが重要です。

タグ: Python List mutable scope CodingBestPractices

9月5日 03:41 投稿