web-dev-qa-db-ja.com

Pythonがリストを反復するときに個々の要素のコピーしか作成しないのはなぜですか?

Pythonでそれを書いたら

for i in a:
    i += 1

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

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

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

必要になるでしょう。

私はこの行動に本当に驚いていました。これは非常に直感に反し、他の言語とは一見異なるようで、コードにエラーが発生し、今日は長い間デバッグする必要がありました。

私は前にPythonチュートリアルを読みました。念のため、本を今すぐもう一度チェックしましたが、この動作についてはまったく触れられていません。

この設計の背後にある理由は何ですか?チュートリアルが読者が自然に理解する必要があると信じるように、それは多くの言語での標準的な実践であると期待されていますか?他のどの言語で反復の同じ動作が存在するか、今後注意する必要がありますか?

31
xji

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

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

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

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

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

68
MSeifert

明確化-用語

Pythonはreferencepointerの概念を区別しません。彼らは通常referenceという用語を使用しますが、C++のようなその区別がある言語と比較すると、pointer

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

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

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

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

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

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

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

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

19
Idan Arye

ここでの答えはどれも、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は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属性は、オブジェクトの値が異なっていても、実際には両方の反復でsameであることに注意してください(id ofオブジェクトが保持する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の不変の型 の中にあるため、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には、目に見える動作を引き起こす不変タイプがいくつかあります。すべての可変タイプ、あなたの期待は正しいです。

11
enderland

@Idanの答えは、PythonがCのようにループ変数をポインターとして扱わない理由を説明するのに役立ちますが、コードスニペットがどのように展開されるかをより深く説明する価値があります。 in Python多くの単純なコードのコードは、実際には built in methods への呼び出しになります。最初の例をとるには

_for i in a:
    i += 1
_

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

(NB:IteratorIterableであり、.__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) はインプレース加算を実行し、それ自体を返します。

(注:メインメソッドが定義されていない場合、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))
_

最初のスニペットがリストを変更するのに2番目のリストが変更しない理由に関する実際の質問に答えるために、最初のスニペットではinext(_a_iter)から取得しています。つまり、iintになります。 intはその場で変更できないため、_i += 1_はリストに対して何も行いません。 2番目のケースでは、intを変更していませんが、___setitem___を呼び出してリストを変更しています。

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

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

二重下線メソッドは、最初はハードルですが、Pythonの「実行可能な疑似コード」の評判を裏付けるために不可欠です。まともなPythonプログラマーは、これらのメソッドとそれらがどのように呼び出されるかを完全に理解し、そうすることが理にかなっているところはどこでもそれらを定義します。

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

6
walpen

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

iがintの場合、それはcannotに変更できます。これは、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ループでは、iaの各要素を順番に参照します。それらが整数の場合、最初のケースが適用され、i += 1の結果は別の整数オブジェクトを参照している必要があります。もちろん、リストaには、以前と同じ要素がまだあります。

2
RemcoGerlich

ここのループは関係ありません。関数のパラメーターや引数と同様に、そのようなforループを設定することは、基本的には空想に見える割り当てです。

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

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

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に置き換えると、まったく同じ結果が得られます。

1
Kevin