Pythonがリストを繰り返すときに個々の要素のコピーのみを作成するのはなぜですか?


31

Pythonで次のように書けば

for i in a:
    i += 1

a変数iはの元の要素のコピーであることが判明したため、元のリストの要素は実際にはまったく影響しませんa

元の要素を変更するには、

for index, i in enumerate(a):
    a[index] += 1

必要になります。

この振る舞いには本当に驚きました。これは非常に直感に反し、他の言語とは一見異なるように思われ、今日は長い間デバッグしなければならなかったコードにエラーが生じました。

以前にPythonチュートリアルを読んだことがあります。念のため、この本をもう一度チェックしましたが、この動作についてはまったく言及していません。

この設計の背後にある理由は何ですか?チュートリアルが読者が自然にそれを手に入れるべきであると信じるように、それは多くの言語の標準的な実践であると予想されますか?繰り返しで同じ動作が存在する他の言語は何ですか?将来的に注意する必要がありますか?


19
それiは、不変であるか、非変更操​​作を実行している場合にのみ当てはまります。ネストされたリストを使用for i in a: a.append(1)すると、動作が異なります。Python ネストされたリストをコピーしません。ただし、整数は不変であり、加算によって新しいオブジェクトが返されますが、古いオブジェクトは変更されません。
-jonrsharpe

10
まったく驚くことではありません。整数のような基本型の配列とまったく同じではない言語を考えることはできません。たとえば、javascriptで試してくださいa=[1,2,3];a.forEach(i => i+=1);alert(a)。C#でも同じ
edc65

7
i = i + 1影響すると思いますaか?
デルタブ

7
この動作は他の言語でも変わらないことに注意してください。C、Javascript、Javaなどはこのように動作します。
スリーブマン

1
リストの@jonrsharpe「+ =」は古いリストを変更し、「+」は新しいリストを作成します
ヴァシリー・アレクセエフ

回答:


68

私はすでに最近同様の質問に答えましたが、それ+=が異なる意味を持つ可能性があることに気付くことが非常に重要です:

  • データ型がインプレース加算を実装している(つまり、正常に__iadd__機能する関数を持っている)場合、i参照するデータは更新されます(リストにあるか他の場所にあるかは関係ありません)。

  • データ型が__iadd__メソッドを実装していない場合、i += xステートメントはただの構文糖でi = i + xあるため、新しい値が作成され、変数名に割り当てられますi

  • データ型は実装し__iadd__ているが、何か変なことをしている場合。それが更新されている可能性があります...またはそうではない-それはそこに実装されているものに依存します。

Pythonの整数、浮動小数点数、文字列は実装__iadd__されないため、これらはインプレースで更新されません。ただし、numpy.arrayまたはlistのような他のデータ型はそれを実装し、期待どおりに動作します。したがって、反復するときはコピーまたはノーコピーの問題ではありません(通常、listsとtuplesのコピーは行いません-しかし、それはコンテナ__iter____getitem__メソッドの実装に依存します!)-それはデータ型の問題ですに保存しましたa


2
これは、質問で説明されている動作の正しい説明です。
-pabouk

19

明確化-用語

Pythonはreferencepointerの概念を区別しません。彼らは通常、単に用語参照を使用しますが、その区別があるC ++のような言語と比較すると、ポインタにはるかに近いです。

質問者は明らかにC ++の背景に由来し、説明に必要な区別はPythonには存在しないため、C ++の用語を使用することにしました。

  • :メモリにある実際のデータ。void foo(int x);値によって整数を受け取る関数のシグネチャです
  • ポインタ:値として扱われるメモリアドレス。それが指すメモリにアクセスするために延期することができます。void foo(int* x);は、ポインタによって整数を受け取る関数のシグネチャです。
  • 参照:ポインターの周りのシュガー。舞台裏にはポインターがありますが、遅延値にのみアクセスでき、それが指すアドレスを変更することはできません。void foo(int& x);は、参照によって整数を受け取る関数のシグネチャです。

「他の言語と異なる」とはどういう意味ですか?私が知っているほとんどの言語は、特に指示がない限り、for-eachループのサポートが要素をコピーしていることを知っています。

特にPythonについて(これらの理由の多くは、同様のアーキテクチャまたは哲学的概念を持つ他の言語に適用される可能性があります):

  1. この振る舞いは、それを知らない人々にバグを引き起こす可能性がありますが、別の振る舞いは、それを知っている人々にもバグを引き起こす可能性あります。variable(i)を割り当てるとき、通常は停止せず、it(a)によって変更される他のすべての変数を考慮しません。作業範囲を制限することは、スパゲッティコードを防ぐための主要な要因であるため、通常、参照による反復をサポートする言語でも、コピーによる反復はデフォルトです。

  2. Python変数は常に単一のポインターであるため、コピーによる反復処理は安価です。参照による反復処理よりも安価で、値にアクセスするたびに余分な遅延が必要になります。

  3. Pythonには、たとえばC ++などの参照変数の概念がありません。つまり、Pythonのすべての変数は実際には参照ですが、ポインターという意味で-C ++ type& name引数のような舞台裏の統計参照ではありません。この概念はPythonには存在しないため、参照による反復を実装します-もちろん、デフォルトにすることはできません!-バイトコードをより複雑にする必要があります。

  4. Pythonのforステートメントは、配列だけでなく、ジェネレータのより一般的な概念でも機能します。舞台裏では、Pythonはiter配列を呼び出してオブジェクトを取得します。オブジェクトを呼び出すとnext、次の要素またはraisesaを返しますStopIteration。Pythonでジェネレーターを実装するにはいくつかの方法がありますが、参照による反復用にジェネレーターを実装するのははるかに困難でした。


答えてくれてありがとう。イテレータについての私の理解はまだ十分ではないようです。C ++参照のイテレータはデフォルトではありませんか?イテレータを間接参照すると、元のコンテナの要素の値をいつでもすぐに変更できますか?
-xji

4
Python 参照によって反復します(まあ、値によってですが、値は参照です)。可変オブジェクトのリストでこれを試すと、コピーが発生しないことがすぐにわかります。
-jonrsharpe

C ++の反復子は、実際には、配列内の値にアクセスするために延期できるオブジェクトです。元の要素を変更するには、*it = ...- を使用しますが、この種類の構文は既に別の場所で何かを変更していることを示しています-これにより、理由#1の問題が少なくなります。理由#2と#3も当てはまりません。C++ではコピーに費用がかかり、参照変数の概念が存在するためです。理由#4に関しては、参照を返す機能により、すべてのケースで簡単な実装が可能になります。
イダンアリー

1
@jonrsharpeはい、参照によって呼び出されますが、ポインターと参照を区別する言語では、この種の反復はポインターごとの反復になります(ポインターは値-値ごとの反復です)。説明を追加します。
イダンアリー

20
最初の段落では、Pythonは他の言語と同様にforループで要素をコピーすることを提案しています。そうではありません。その要素に加える変更の範囲を制限しません。OPは、これらの要素が不変であるため、この動作のみを認識します。その区別を言及することさえせずに、あなたの答えはせいぜい不完全で、最悪の場合誤解を招く。
-jonrsharpe

11

ここでの答えはどれも、Pythonの土地でこれがなぜ起こるのを実際に説明するためのコードを提供しません。そして、これはより深いアプローチで見るのが楽しいので、ここに行きます。

これが期待どおりに機能しない主な理由は、Pythonで次のように書くときです。

i += 1

あなたが思っていることをやっていません。整数は不変です。これは、オブジェクトがPythonで実際に何であるかを調べると確認できます。

a = 0
print('ID of the first integer:', id(a))
a += 1
print('ID of the first integer +=1:', id(a))

id関数は、存続期間中のオブジェクトの一意で一定の値を表します。概念的には、C / C ++のメモリアドレスに大まかにマップします。上記のコードの実行:

ID of the first integer: 140444342529056
ID of the first integer +=1: 140444342529088

これは、IDが異なるためa、1つ目は2つ目と同じではないことを意味しaます。事実上、それらはメモリ内の異なる場所にあります。

ただし、オブジェクトを使用する場合、動作は異なります。+=ここで演算子を上書きしました:

class CustomInt:
  def __iadd__(self, other):
    # Override += 1 for this class
    self.value = self.value + other.value
    return self

  def __init__(self, v):
    self.value = v

ints = []
for i in range(5):
  int = CustomInt(i)
  print('ID={}, value={}'.format(id(int), i))
  ints.append(int)


for i in ints:
  i += CustomInt(i.value)

print("######")
for i in ints:
  print('ID={}, value={}'.format(id(i), i.value))

これを実行すると、次の出力が得られます。

ID=140444284275400, value=0
ID=140444284275120, value=1
ID=140444284275064, value=2
ID=140444284310752, value=3
ID=140444284310864, value=4
######
ID=140444284275400, value=0
ID=140444284275120, value=2
ID=140444284275064, value=4
ID=140444284310752, value=6
ID=140444284310864, value=8

この場合のid属性は、オブジェクトの値が異なる場合でも、実際には両方の反復で同じであることに注意してください(idオブジェクトが保持しているint値も見つけることができます。不変です)。

これを、不変オブジェクトで同じ演習を実行するときと比較してください。

ints_primitives = []
for i in range(5):
  int = i
  ints_primitives.append(int)
  print('ID={}, value={}'.format(id(int), i))

print("######")
for i in ints_primitives:
  i += 1
  print('ID={}, value={}'.format(id(int), i))


print("######")
for i in ints_primitives:
  print('ID={}, value={}'.format(id(i), i))

この出力:

ID=140023258889248, value=0
ID=140023258889280, value=1
ID=140023258889312, value=2
ID=140023258889344, value=3
ID=140023258889376, value=4
######
ID=140023258889280, value=1
ID=140023258889312, value=2
ID=140023258889344, value=3
ID=140023258889376, value=4
ID=140023258889408, value=5
######
ID=140023258889248, value=0
ID=140023258889280, value=1
ID=140023258889312, value=2
ID=140023258889344, value=3
ID=140023258889376, value=4

ここで注意すべき点がいくつかあります。まず、のループでは+=、元のオブジェクトに追加しなくなりました。この場合、intはPythonの不変型の1つであるため、pythonは異なるIDを使用します。また、Pythonはid、同じ不変の値を持つ複数の変数に対して同じ基礎を使用していることに注意してください。

a = 1999
b = 1999
c = 1999

print('id a:', id(a))
print('id b:', id(b))
print('id c:', id(c))

id a: 139846953372048
id b: 139846953372048
id c: 139846953372048

tl; dr -Pythonには少数の不変の型があり、これが表示される動作を引き起こします。すべての可変タイプについて、あなたの期待は正しいです。


6

@Idanの答えは、Pythonがループ変数をポインターとしてCのように扱わない理由を説明するのに良い仕事をしますが、Pythonの多くの単純に見えるビットのように、コードスニペットがどのようにアンパックされるかをより深く説明する価値があります実際のコードの組み込みメソッドへの呼び出しになります。最初の例を挙げる

for i in a:
    i += 1

解凍するものは2つあります。for _ in _:構文と_ += _構文です。他の言語と同様に、最初にforループを使用するために、Pythonにはfor-eachループが基本的にイテレーターパターンの構文シュガーです。Pythonでは、イテレーターは.__next__(self)、シーケンス内の現在の要素を返し、次の要素に進みStopIteration、シーケンス内にアイテムがなくなるとを発生させるメソッドを定義するオブジェクトです。AN のIterableを定義するオブジェクトである.__iter__(self)イテレータを返す方法。

(注:an IteratorもanでIterableあり、その.__iter__(self)メソッドから自身を返します。)

Pythonには通常、カスタムダブルアンダースコアメソッドに委任する組み込み関数があります。したがって、iter(o)どちらに解決されo.__iter__()next(o)に解決されo.__next__()ます。これらの組み込み関数は、委任するメソッドが定義されていない場合、多くの場合、適切なデフォルト定義を試行します。たとえば、len(o)通常はに解決されますo.__len__()が、そのメソッドが定義されていない場合は、が試行されiter(o).__len__()ます。

forループは、基本的にnext()iter()およびより基本的な制御構造で定義されます。一般的にコード

for i in %EXPR%:
    %LOOP%

のようなものに解凍されます

_a_iter = iter(%EXPR%)
while True:
    try:
        i = next(_a_iter)
    except StopIteration:
        break
    %LOOP%

この場合

for i in a:
    i += 1

開梱される

_a_iter = iter(a) # = a.__iter__()
while True:
    try: 
        i = next(_a_iter) # = _a_iter.__next__()
    except StopIteration:
        break
    i += 1

これの残りの半分はですi += 1。一般に%ASSIGN% += %EXPR%に解凍され%ASSIGN% = %ASSIGN%.__iadd__(%EXPR%)ます。ここで__iadd__(self, other)インプレース加算を行い、それ自体を返します。

。(NBこれはmainメソッドが定義されていない場合、Pythonは代替手段を選んだだろうオブジェクトが実装されていない場合は別のケースで__iadd__、それは上のフォールバックします__add__とそれは実際にこのような場合にはこれを行います。int実装していない__iadd__-彼らので、理にかなっています不変であるため、その場で変更することはできません。)

ここのコードは次のようになります

_a_iter = iter(a)
while True:
    try:
        i = next(_a_iter)
    except StopIteration:
        break
    i = iadd(i,1)

定義できる場所

def iadd(o, v):
    try:
        return o.__iadd__(v)
    except AttributeError:
        return o.__add__(v)

2番目のコードでは、もう少し処理が行われます。知っておくべき2つの新しいことは、%ARG%[%KEY%] = %VALUE%にアンパックされること(%ARG%).__setitem__(%KEY%, %VALUE%)と、%ARG%[%KEY%]にアンパックされること(%ARG%).__getitem__(%KEY%)です。一緒にこの知識を置く我々が得るa[ix] += 1にアンパックa.__setitem__(ix, a.__getitem__(ix).__add__(1))(再び:__add__ではなく__iadd__ので、__iadd__int型で実装されていません)。最終的なコードは次のようになります。

_a_iter = iter(enumerate(a))
while True:
    try:
        index, i = next(_a_iter)
    except StopIteration:
        break
    a.__setitem__(index, iadd(a.__getitem__(index), 1))

実際に第二は、我々が取得している私たちの最初のスニペットでは、ありませんしながら、最初のものはリストを変更しない理由として、あなたの質問に答えるためinext(_a_iter)手段は、iとなりますint。のint位置は変更できないi += 1ため、リストには何もしません。2番目のケースでは、を再度変更するのではintなく、を呼び出してリストを変更しています__setitem__

この全体的な精巧な演習の理由は、Pythonについて次のレッスンを教えていると思うからです。

  1. Pythonの可読性の代価は、これらの魔法のダブルスコアメソッドを常に呼び出していることです。
  2. したがって、Pythonコードの一部を真に理解する機会を得るには、これらの翻訳の実行を理解する必要があります。

ダブルアンダースコアメソッドは、開始時にハードルですが、Pythonの「実行可能な擬似コード」の評判を支持するために不可欠です。優れたPythonプログラマーは、これらのメソッドとそれらがどのように呼び出されるかを完全に理解し、実行するのが理にかなっている場合はいつでも定義します。

編集:@deltabは、「コレクション」という用語のずさんな使用を修正しました。


2
「イテレータはコレクションでもある」というのは正しくありません。イテレータも反復可能ですが、コレクションには次のものが__len__あります。__contains__
deltab

2

+=現在の値が可変不変かによって動作が異なります。Python開発者は混乱するのではないかと恐れていたため、これがPythonで実装されるのに長い時間がかかる主な理由でした。

iがintの場合、intは不変であるため変更できません。したがって、値がi変更された場合、必ず別のオブジェクトを指す必要があります。

>>> i=3
>>> id(i)
14336296
>>> i+=1
>>> id(i)
14336272   # Other object

ただし、左側がmutableの場合、+ =は実際に変更できます。リストのように:

>>> i=[]
>>> id(i)
140257231883944
>>> i+=[1]
>>> id(i)
140257231883944  # Still the same object!

forループで、のi各要素をa順番に参照します。これらが整数の場合、最初のケースが適用され、その結果はi += 1別の整数オブジェクトを参照する必要があります。aもちろん、リストには以前と同じ要素がまだあります。


可変オブジェクトと不変オブジェクトのこの区別を理解していません。不変整数オブジェクトにi = 1設定iする場合は、不変リストオブジェクトにi = []設定iする必要があります。言い換えれば、なぜ整数オブジェクトは不変であり、リストオブジェクトは可変なのでしょうか?この背後にあるロジックは見当たりません。
ジョルジオ

@Giorgio:オブジェクトは異なるクラスからのものでありlist、コンテンツを変更するメソッドを実装intしていますが、そうではありません。[] 可変リストオブジェクトであり、そのオブジェクトを参照i = []できiます。
-RemcoGerlich

@GiorgioにはPythonには不変リストのようなものはありません。リストは変更可能です。整数はそうではありません。リストのようなもので不変なものが必要な場合は、タプルを検討してください。理由については、どのレベルで回答したいのか明確ではありません。
-jonrsharpe

@RemcoGerlich:クラスによって動作が異なることは理解していますが、このように実装された理由はわかりません。つまり、この選択の背後にあるロジックはわかりません。+=元のオブジェクトを変更するか、整数とリストの両方の変更されたコピーを返すというどちらのタイプでも、同様の動作をする演算子/メソッドを実装していました(最も驚くべきことではありません)。
ジョルジオ

1
@Giorgio:+=Pythonでは驚くべきことは絶対に真実ですが、あなたが言及する他のオプションも驚くべきであるか、少なくとも実用的ではないと感じられました(元のオブジェクトを変更することは最も一般的なタイプの値ではできません+ = with、intsを使用します。また、リスト全体をコピーすることは、それを変更するよりもはるかに高価です。Pythonは、明示的に指示されない限り、リストや辞書などをコピーしません。当時は大きな議論でした。
-RemcoGerlich

1

ここのループは無関係です。関数のパラメーターや引数によく似ており、そのようなforループを設定することは、基本的には見栄えの良い割り当てです。

整数は不変です。それらを変更する唯一の方法は、新しい整数を作成し、元の整数と同じ名前に割り当てることです。

Pythonの割り当てのセマンティクスは、Cに直接マップされます(CPythonのPyObject *ポインターが与えられると当然のことですが)、唯一の注意事項は、すべてがポインターであり、ダブルポインターを使用できないことです。次のコードを検討してください。

a = 1
b = a
b += 1
print(a)

何が起こるのですか?印刷し1ます。どうして?実際には、次のCコードとほぼ同等です。

i64* a = malloc(sizeof(i64));
*a = 1;
i64* b = a;
i64* tmp = malloc(sizeof(i64));
tmp = *b + 1;
b = tmp;
printf("%d\n", *a);

Cコードでaは、の値がまったく影響を受けないことは明らかです。

リストが機能しているように見える理由については、答えは基本的に同じ名前に割り当てているということだけです。リストは変更可能です。指定a[0]されたオブジェクトのID は変更されますがa[0]、有効な名前のままです。これは次のコードで確認できます。

x = 1
a = [x]
print(a[0] is x)
a[0] += 1
print(a[0] is x)

しかし、これはリストにとって特別なことではありません。a[0]そのコードをに置き換えるyと、まったく同じ結果が得られます。

弊社のサイトを使用することにより、あなたは弊社のクッキーポリシーおよびプライバシーポリシーを読み、理解したものとみなされます。
Licensed under cc by-sa 3.0 with attribution required.