Pythonの基礎的な話、というか仕様にまつわる話です。個別の要素については既にたくさんの記事がありますが、「どうすればいいか」「なぜそうなるのか」という点をまとめて解説した記事はなさそうなので(あったらすいません)、そのあたりを知識の整理も兼ねて解説していきたいと思います。
for文でリストを改変できない
想定している罠は以下のようなものです。
nums = list(range(1,5)) #[1, 2, 3, 4]
for num in nums:
num *= 2
print(nums)
[1, 2, 3, 4]
をそれぞれ2倍するという命令です。当然[2, 4, 6, 8]
が出力されるものと思いきや、
[1, 2, 3, 4]
元のリストと変わらないものが出てきます。
どうすればいいのか
なぜそうなるのかは置いといて、これはとりあえず以下のようにすれば望む結果が出てきます。
nums = list(range(1,5)) #[1, 2, 3, 4]
for i in range(4):
nums[i] *= 2
print(nums) #[2, 4, 6, 8]
なぜそうなるのか
両者の違いはfor
文で直接アクセスするか(以降、便宜上foreach
と呼びます。JavaScript や C# ではこのような名前がつきます)、i
で個別にアクセスするかの違い(以降、便宜上「個別アクセス」と呼びます)でした。ここで重要なのは、「foreach
では改変できずに、個別アクセスでは改変できる」という話ではないということです。
上のプロセスをより丁寧に追っていくために、オブジェクトIDを追いながら同じ命令を走らせてみます。
nums = list(range(1,5)) #[1, 2, 3, 4]
for num in nums:
print('before',id(num))
num *= 2
print('after',id(num))
for num in nums:
print(id(num),num)
before 140722392559872
after 140722392559904
before 140722392559904
after 140722392559968
before 140722392559936
after 140722392560032
before 140722392559968
after 140722392560096
140722392559872 1
140722392559904 2
140722392559936 3
140722392559968 4
nums
のそれぞれの要素のオブジェクトIDが、before
と変わっていません。
nums = list(range(1,5)) #[1, 2, 3, 4]
for i in range(4):
print('before',id(nums[i]))
nums[i] *= 2
print('after',id(nums[i]))
for i in range(4):
print(id(nums[i]),nums[i])
before 140722388562176
after 140722388562208
before 140722388562208
after 140722388562272
before 140722388562240
after 140722388562336
before 140722388562272
after 140722388562400
140722388562208 2
140722388562272 4
140722388562336 6
140722388562400 8
nums
のそれぞれの要素のオブジェクトIDが、after
のものに変わっています。これで直接的な原因がわかりました。
なぜこの違いが出るのか
参照渡しと値渡しという概念があります。前者は、あるオブジェクトから別のオブジェクトに値を持ってくる時に、元の値と連動させるもの(つまり、別名を与えているだけ)で、後者は、全く新しいオブジェクトとして切り離すもの(つまり、コピーする)です。
なるほど、じゃあforeach
の時は値渡しをしているのか!……とはなりません。だったらforeach
と個別アクセスの時で、before
が同じオブジェクトIDを指さないですからね。
実は、Pythonは基本的にすべて参照渡しです。(追記:この点、@shiracamus さんから指摘いただきまして、正確には「参照の値渡し」になります)
しかし、一部のオブジェクトについては~~「最初は参照渡しだけど、別の値が代入された時には新しいオブジェクトIDを取得する」~~「別の値が代入されると、その値のオブジェクトIDが変数に代入される」**(追記:同上)**という処理がなされます。具体的には、int
やtuple
等のイミュータブル(変更不可能、詳細はぐぐってください)なオブジェクトではそうなります。
# immutable
a = 1 # int
b = a # この時点ではbとaは同じオブジェクトID
b = 2 # bに違うオブジェクトIDが割り当てられる
print(a) # 1
# aも一緒に2に変わってしまうようなことはない
# mutable
a = [1] # list
b = a # この時点ではbとaは同じオブジェクトID
b.append(2) # この状態でも同一ID
print(a) # [1, 2]
# aも一緒に変わってしまう
つまり、foreach
の場合には、int
型の要素に直接代入しているから、単に別オブジェクトのint
のIDを示しているだけで、個別アクセスの場合には、リストのどの要素がどのint
型の要素を参照しているかを変更しているので、結果的にリストが改変できるという訳です。
図で表すとこのようになります。(追記:@shiracamus さんのコメントで使用された図をそのまま使用させていただいております。元の図は削除し、以下の説明も変更しました)
foreach
の時は、num
の参照先はint
型のオブジェクトであり、それに別の値を代入してもnum
が参照するint
が変更されるだけで、nums
の参照先である元のリストは変わりません。対して、個別アクセスの時は、nums
の参照先であるlist
型のオブジェクト内の参照を変更しているので、結果として元のリストが改変できます。
逆にミュータブルな時は
ここら辺を理解していないと、list
等のミュータブルなオブジェクトを使った時に、逆に改変したくないのに改変してしまうという悲劇が起きることになります。
nums = [[1],[2],[3],[4]]
for num in nums:
num.append(5)
print(nums) # [[1, 5], [2, 5], [3, 5], [4, 5]]
nums = [[1],[2],[3],[4]]
for i in range(4):
nums[i].append(5)
print(nums) # [[1, 5], [2, 5], [3, 5], [4, 5]] 結果は全く同じ
これはforeach
の時でも、num
の参照先がlist
型であることから同一IDになるためです。
ミュータブルでも値渡ししたい
前にも述べた通り、Pythonは基本的に参照渡し参照の値渡し**(追記:同上)**なので、明示的な値渡しはできません。しかし、値渡しのような動きをさせたい時は出てくると思います。そのような時には、copy
を使うか、インデックス指定して事実上のコピーをすることで対処できます。
nums = [[1],[2],[3],[4]]
for num in nums:
new_num = num.copy()
new_num.append(5)
print(nums) # [[1], [2], [3], [4]] 元のリストは変わらない
nums = [[1],[2],[3],[4]]
for num in nums:
new_num = num[:]
new_num.append(5)
print(nums) # [[1], [2], [3], [4]] 元のリストは変わらない
復習みたいになりますが、これではダメです。
nums = [[1],[2],[3],[4]]
for num in nums:
new_num = num # 同一ID
new_num.append(5)
print(nums) # [[1, 5], [2, 5], [3, 5], [4, 5]]
浅いコピーと深いコピー
ちなみに、リストのメソッドであるcopy()
やインデックス指定は浅いコピーと呼ばれるものです。多重リストの場合は、最初の一層目だけは別オブジェクトIDを取得してくれますが、二層目以降の参照は変わりません。なので、多重リスト全体をコピーしようとするとまた思わぬ罠にはまります。
nums = [[1],[2],[3],[4]]
new_nums = nums.copy()
for num in new_nums:
num.append(5) # 二層目以降は同一ID
print(nums) # [[1, 5], [2, 5], [3, 5], [4, 5]] 改変されてしまう
多重リストで、全ての要素で新しいコピーを作るためにはcopy
モジュールを用いて深いコピーをする必要があります。
import copy
nums = [[1],[2],[3],[4]]
new_nums = copy.deepcopy(nums)
for num in new_nums:
num.append(5)
print(nums) #[[1], [2], [3], [4]] 改変されていない