왜 파이썬은리스트를 반복 할 때 개별 요소의 사본 만 만드는가?


31

파이썬에서 글을 쓰면

for i in a:
    i += 1

원래 목록의 요소 a는 실제로 영향을 미치지 않습니다. 변수 i는의 원래 요소의 사본 인 것으로 밝혀 졌기 때문 입니다 a.

원래 요소를 수정하려면

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

필요할 것입니다.

나는이 행동에 정말 놀랐다. 이것은 매우 반 직관적이며 다른 언어와 다르게 보일 수 있으며 오늘 코드를 오랫동안 디버깅 해야하는 오류가 발생했습니다.

전에 파이썬 튜토리얼을 읽었습니다. 확실히, 나는 지금 바로 책을 다시 확인했고,이 행동에 대해서는 전혀 언급하지 않았습니다.

이 디자인의 이유는 무엇입니까? 튜토리얼에서 독자들이 자연스럽게 가져와야한다고 생각할 수 있도록 많은 언어로 표준 연습이 될 것으로 예상됩니까? 반복에 대해 동일한 행동이 어떤 다른 언어로 존재하며 앞으로 주목해야합니까?


19
i변경 불가능하거나 변경하지 않는 작업을 수행하는 경우에만 해당됩니다 . 중첩 된 목록을 사용하면 for i in a: a.append(1)동작이 달라집니다. 파이썬 중첩리스트를 복사 하지 않습니다 . 그러나 정수는 변경할 수 없으며 더하기는 새 객체를 반환하지만 이전 객체는 변경하지 않습니다.
jonrsharpe

10
전혀 놀라운 일이 아닙니다. 정수와 같은 기본 유형의 배열에 대해 정확히 동일하지 않은 언어는 생각할 수 없습니다. 예를 들어, javascript를 사용해보십시오 a=[1,2,3];a.forEach(i => i+=1);alert(a). C #에서 동일
edc65

7
당신 i = i + 1은 영향을 미칠 것으로 예상 a됩니까?
deltab

7
다른 언어에서는이 동작이 다르지 않습니다. C, Javascript, Java 등이 이런 식으로 동작합니다.
slebetman

1
목록에 대한 @jonrsharpe "+ ="는 이전 목록을 변경하는 반면 "+"는 새로운 목록을 만듭니다
Vasily Alexeev

답변:


68

나는 최근에 비슷한 질문에 이미 대답했으며+= 다른 의미를 가질 수 있음 을 인식하는 것이 매우 중요합니다 .

  • 데이터 유형이 전체 추가를 구현하는 경우 (즉, 올바르게 작동하는 __iadd__기능이있는 경우) i참조 하는 데이터 가 업데이트됩니다 (목록에 있거나 다른 곳에 있는지 여부는 중요하지 않음).

  • 데이터 유형이 __iadd__메소드를 구현하지 않으면 i += x명령문은 구문 설탕 i = i + x일 뿐이 므로 새 값이 작성되어 변수 name에 지정 i됩니다.

  • 데이터 유형이 구현 __iadd__되었지만 이상한 일을하는 경우. 업데이트 될 수도 있고 아닐 수도 있습니다-구현 된 내용에 따라 다릅니다.

파이썬 정수, 부동 소수점, 문자열은 구현 __iadd__되지 않으므로 제자리에서 업데이트되지 않습니다. 그러나 numpy.array또는 같은 다른 데이터 유형은이를 list구현하고 예상 한대로 작동합니다. 따라서 반복 할 때 복사 또는 복사 금지의 문제가 아닙니다 (일반적으로 lists 및 tuples의 복사는 수행하지 않지만 컨테이너 __iter____getitem__메소드 의 구현에 따라 다릅니다 !)-데이터 유형의 문제입니다. 에 저장했습니다 a.


2
질문에 설명 된 동작에 대한 올바른 설명입니다.
pabouk 2012 년

19

설명-용어

파이썬은 참조포인터 의 개념을 구별하지 않습니다 . 그것들은 보통 참조 라는 용어를 사용 하지만, 구별이있는 C ++와 같은 언어와 비교한다면 포인터에 훨씬 가깝습니다 .

asker는 분명히 C ++ 배경에서 왔으며 설명에 필요한 구별 은 Python에 존재하지 않기 때문에 C ++ 용어를 사용하기로 결정했습니다.

  • Value : 메모리에있는 실제 데이터. valuevoid foo(int x); 로 정수를받는 함수의 서명입니다 .
  • 포인터 : 값으로 취급되는 메모리 주소. 가리키는 메모리에 액세스하도록 연기 할 수 있습니다. 포인터void foo(int* x); 로 정수를받는 함수의 서명입니다 .
  • 참조 : 포인터 주변의 설탕. 장면 뒤에 포인터가 있지만 지연된 값에만 액세스 할 수 있으며 가리키는 주소를 변경할 수 없습니다. 참조void foo(int& x); 로 정수를받는 함수의 서명입니다 .

"다른 언어와 다른"은 무엇을 의미합니까? 내가 for-each 루프를 지원한다는 것을 알고있는 대부분의 언어는 특별히 지시하지 않는 한 요소를 복사하고 있습니다.

특히 Python의 경우 (이러한 이유 중 다수는 유사한 구조적 또는 철학적 개념을 가진 다른 언어에도 적용될 수 있습니다) :

  1. 이 동작은 모르는 사람들에게 버그를 유발할 수 있지만, 다른 동작은 버그 를 알고있는 사람들에게도 버그를 유발할 수 있습니다 . 변수 ( i) 를 할당 할 때 일반적으로 중지하지 않으며 변수로 인해 변경 될 다른 모든 변수를 고려합니다 ( a). 작업중인 범위를 제한하는 것이 스파게티 코드를 방지하는 주요 요인이므로 복사를 통한 반복은 일반적으로 참조를 통한 반복을 지원하는 언어에서도 기본값입니다.

  2. 파이썬 변수는 항상 단일 포인터이므로 참조로 반복하는 것보다 저렴합니다. 값을 액세스 할 때마다 추가 지연이 필요합니다.

  3. 파이썬에는 C ++와 같은 참조 변수 개념이 없습니다. 즉, 파이썬의 모든 변수는 실제로 참조이지만, C ++ type& name인수 와 같은 비하인드 (con-the-scens) 상수 참조가 아니라 포인터라는 점에서 의미가 있습니다. 이 개념은 파이썬에는 존재하지 않으므로 참조로 반복을 구현하십시오-기본값으로 만들지 말고! -바이트 코드에 더 많은 복잡성을 추가해야합니다.

  4. 파이썬의 for진술은 배열뿐만 아니라보다 일반적인 생성기 개념에서 작동합니다. 배후에서 파이썬은 iter배열을 호출 하여 객체를 가져올 때 next다음 요소 또는 raisesa를 반환 하는 객체를 얻 습니다 StopIteration. 파이썬에서 생성기를 구현하는 방법에는 여러 가지가 있으며 참조별로 반복하기 위해 생성기를 구현하는 것이 훨씬 어려웠을 것입니다.


답변 해주셔서 감사합니다. 반복자에 대한 나의 이해가 여전히 충분히 견고하지 않은 것 같습니다. C ++의 반복자는 기본적으로 참조되지 않습니까? 반복자를 역 참조하면 언제든지 원래 컨테이너의 요소 값을 즉시 변경할 수 있습니까?
xji

4
파이썬 참조로 반복합니다 (값은 있지만 값은 참조입니다). 변경 가능한 객체 목록으로이 작업을 시도하면 복사가 수행되지 않음을 신속하게 보여줍니다.
jonrsharpe

C ++의 반복자는 실제로 배열의 값에 액세스하기 위해 지연 될 수있는 객체입니다. 원래 요소를 수정하려면 다음을 사용하십시오 *it = .... 그러나 이러한 종류의 구문은 이미 다른 곳에서 무언가를 수정하고 있음을 나타냅니다. 이는 이유 1을 덜 문제로 만듭니다. C ++에서는 복사 비용이 많이 들고 참조 변수의 개념이 존재하기 때문에 이유 # 2와 # 3도 적용되지 않습니다. 이유 # 4-참조를 반환하는 기능은 모든 경우에 간단한 구현을 허용합니다.
Idan Arye

1
@ jonrsharpe 예, 참조로 호출되지만 포인터와 참조가 구별되는 모든 언어에서 이러한 종류의 반복은 포인터에 의한 반복입니다 (포인터는 값-반복 값). 설명을 추가하겠습니다.
Idan Arye

20
첫 번째 단락에서는 다른 언어와 마찬가지로 Python이 요소를 for 루프에 복사한다고 제안합니다. 그렇지 않습니다. 해당 요소에 대한 변경 범위는 제한되지 않습니다. OP는 해당 요소가 변경 불가능하기 때문에이 동작 만 볼 수 있습니다. 그 구별을 언급하지 않고도 귀하의 답변은 불완전하고 잘못 오도됩니다.
jonrsharpe

11

이 답변 은 파이썬 랜드에서 이런 일이 발생 하는지 실제로 설명하기 위해 사용할 코드를 제공하지 않습니다 . 그리고 이것은 더 깊은 접근법으로 보는 것이 재미있어서 여기로갑니다.

이것이 예상대로 작동하지 않는 주된 이유는 파이썬에서 작성할 때 다음과 같습니다.

i += 1

그것은 당신이 생각하는 것을하지 않습니다. 정수는 불변입니다. 파이썬에서 객체가 실제로 무엇인지 살펴볼 때 볼 수 있습니다.

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가 다르기 때문에 first a는 더 이상 second와 동일하지 않습니다 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의 불변 유형 중 하나이므로 python은 다른 ID를 사용합니다. 또한 파이썬은 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- 파이썬에는 소수의 불변 유형이있어서 동작이 발생합니다. 모든 변경 가능한 유형에 대한 기대치가 정확합니다.


6

@Idan의 대답은 왜 파이썬이 루프 변수를 C에서와 같이 포인터로 취급하지 않는지 설명하는 훌륭한 일을하지만 파이썬에서 간단한 조각 비트와 같이 코드 조각이 풀리는 방법에 대해 더 깊이 설명 할 가치가 있습니다. 실제로 코드는 내장 메서드를 호출 합니다 . 첫 번째 예를 들어

for i in a:
    i += 1

압축 풀기에는 for _ in _:구문과 구문의 두 가지가 _ += _있습니다. 다른 언어와 마찬가지로 for 루프를 먼저 수행하려면 Python에는 for-each반복자 패턴의 구문 설탕 인 루프가 있습니다. 파이썬에서 이터레이터는 .__next__(self)시퀀스의 현재 요소를 반환하고 다음으로 넘어 가서 시퀀스 StopIteration에 더 이상 항목이 없을 때 발생 하는 메서드 를 정의하는 객체입니다 . 의 Iterable은 정의하는 객체입니다 .__iter__(self)방법을 반환하는 반복자를.

(NB : an Iterator도 an Iterable이며 .__iter__(self)메소드 에서 자신을 반환합니다 .)

파이썬에는 일반적으로 커스텀 더블 밑줄 메소드를 위임하는 내장 함수가 있습니다. 그것은이 그래서 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이 메인 메소드가 정의되어 있지 않은 경우 파이썬은 대안을 선택합니다 또 다른 경우는 객체가 구현하지 않습니다. __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)

두 번째 코드에서는 조금 더 진행됩니다. 우리가 알아야 할 두 가지 새로운 일들은이 %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))

실제로 두 번째는 않지만 첫 번째는 우리는 점점 우리의 첫 번째 코드에서, 목록을 수정하지 않는 이유에 대한 귀하의 질문에 대답하려면 i에서 next(_a_iter)수단이되는, i될 것입니다 int. 의 int위치를 수정할 수 없으므로 i += 1목록에 아무 것도 수행하지 않습니다. 두 번째 경우에는 다시 수정하지 않고을 int호출하여 목록을 수정합니다 __setitem__.

이 전체 연습의 이유는 파이썬에 대해 다음과 같은 교훈을 가르치기 때문입니다.

  1. 파이썬의 가독성은 항상이 매직 더블 스코어 방법을 호출한다는 것입니다.
  2. 따라서 파이썬 코드를 실제로 이해하려면 이러한 번역이 수행하는 작업을 이해해야합니다.

이중 밑줄 메서드는 시작할 때 장애물이지만, 파이썬의 "실행 가능한 의사 코드"평판을 뒷받침하는 데 필수적입니다. 괜찮은 파이썬 프로그래머는이 메소드들과 그것들이 어떻게 호출되는지를 철저히 이해하고 그것이 이해되는 곳에 정의 할 것입니다.

편집 : @deltab는 "컬렉션"이라는 용어의 부주의 한 사용을 수정했습니다.


2
그들은 또한 반복 가능한, 그러나 "반복자는 컬렉션 또한 것은"매우 옳지 않다 컬렉션은이 __len____contains__
deltab

2

+=현재 값이 변경 가능한지 또는 변경 불가능한 지에 따라 다르게 작동합니다 . 이것이 파이썬 개발자들이 혼란 스러울 까봐 두려워서 파이썬으로 구현하는데 오랜 시간이 걸리는 주된 이유였습니다.

경우 i를 int이며, 그것은 할 수 의 int는 불변이기 때문에 변경, 따라서의 값 경우 i변경은 반드시 다른 객체를 가리켜 야합니다 :

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

그러나 왼쪽이 변경 가능한 경우 + =는 실제로 변경할 수 있습니다. 그것이 목록 인 경우처럼 :

>>> 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

@Giorgio : 객체는 다른 클래스에서 왔으며 list내용을 변경하는 메소드를 구현하지만 int그렇지 않습니다. [] 이다 변경 가능한 목록 개체 및 i = []i해당 객체를 참조하십시오.
RemcoGerlich

@Giorgio는 파이썬에서 불변 목록과 같은 것은 없습니다. 리스트는 변경 가능합니다. 정수는 아닙니다. 리스트와 같은 것이지만 불변 인 것을 원한다면 튜플을 고려하십시오. 그 이유에 관해서는, 당신이 어느 수준으로 대답하고 싶은지 명확하지 않습니다.
jonrsharpe

@ RemcoGerlich : 다른 클래스가 다르게 행동한다는 것을 이해합니다. 왜 이런 식으로 구현되었는지 이해하지 못합니다. 즉,이 선택의 배후에있는 논리를 이해하지 못합니다. +=두 유형에 대해 비슷하게 작동하도록 연산자 / 메소드를 구현했을 것입니다 . 원래 객체를 변경하거나 정수와 목록 모두에 대해 수정 된 사본을 반환하십시오.
Giorgio

1
@Giorgio : +=파이썬에서 놀라운 것은 사실 이지만, 언급 한 다른 옵션도 놀랍거나 적어도 실용적이지 않다는 느낌이 들었습니다 (원래 객체 변경은 가장 일반적인 유형의 값으로 수행 할 수 없음) int와 함께 + =를 사용하고 전체 목록을 복사하는 것이 변경하는 것보다 훨씬 비쌉니다 .Python은 명시 적으로 지시하지 않는 한 목록 및 사전과 같은 것을 복사하지 않습니다). 당시에는 큰 논쟁이었습니다.
RemcoGerlich

1

여기의 루프는 관련이 없습니다. 함수 매개 변수 또는 인수와 마찬가지로 for 루프를 설정하는 것은 본질적으로 환상적 인 할당입니다.

정수는 불변입니다. 그것들을 수정하는 유일한 방법은 새로운 정수를 생성하고 그것을 원래 이름과 같은 이름으로 할당하는 것입니다.

할당에 대한 파이썬의 의미는 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이 완전히 영향을받지 않는 것이 분명합니다 .

왜 목록이 작동하는 것처럼 대답은 기본적으로 동일한 이름으로 할당하는 것입니다. 리스트는 변경 가능합니다. 명명 된 개체의 ID a[0]는 변경되지만 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.