증분 대입문

그동안 프로젝트 하나를 진행하느라 바빠서 포스팅할 시간이 없었습니다. 제가 Functional Data Engineering Platform 이라고 부르는 것인데, 기계 학습을 위한 실용적인 실행 환경을 제공하는 제품입니다. 얼마전에 상용 환경에 투입되었고, 그럭 저럭 기대한 성능을 보여주고 있기 때문에, 이제 다른 곳에도 신경쓸 여유가 좀 생겼습니다. 이 제품에 대해서는 따로 소개할 기회가 있을겁니다. 오늘은 좀 더 가벼운 이야기나 나눠봅시다.

아무 생각 없이 쓰던 코드가 어느날 갑자기 이상하게 보일 때가 있습니다. 얼마전에 그런 경험을 했는데, 다음과 같은 코드입니다.

>>> l = []
>>> l += 'hello'
>>> l
['h', 'e', 'l', 'l', 'o']

+= 는 증분 대입 연산자인데, 문법적인 정의는 파이썬 언어 레퍼런스증분 대입문(Augmented assignment statements) 섹션에 나옵니다.

요약하면, x += 1 은 정확히 같지는 않지만 x = x + 1 과 비슷한 결과를 준다는 것입니다.

l += 'hello' 가 이상함을 넘어서, 약간의 불쾌감을 준 이유는, l = l + 'hello' 와 비슷하지도 않기 때문입니다.

>>> l = []
>>> l = l + 'hello'
Traceback (most recent call last):
  ...
TypeError: can only concatenate list (not "str") to list

+ 연산자로 리스트와 문자열을 합할 수 없을뿐만 아니라, 리스트와 튜플을 합하는 것도 허락하지 않습니다.

이런 상황이 벌어지는 이유는 증분 대입문이, 풀어 쓴 대입문과 의미적으로 비슷한 결과를 줄 뿐이고, 그 것 마저 강제되지 않기 때문입니다.

리스트의 경우 += 연산자는 list.extend() 메서드와 같은 결과를 주도록 설계되어 있습니다. 이 메서드는 인자로 이터러블 을 받도록 되어있기 때문에 우변이 문자열이건 튜플이건 모두 받아들입니다.

그래서 이제 이런식으로 쓰는 것을 좀 더 편안하게 느낍니다.

>>> l = []
>>> l.extend('hello')

또는

>>> l = list('hello')

연산자 재정의라는 기능은 언제나 애증이 교차하는 곳입니다. 문법적으로 달콤하고, 때로 쿨하게 보이지만, 마음 한편으로는 불편한 감정이 커져갑니다. 자바에서 이 기능을 넣지 않기로 한 결정에 찬사를 보내지만, 파이썬 같은 스크립트 언어에서도 그럴 수 있을지는 의문입니다.

부록

좀 더 깊이 들어가보고 싶은 분들을 위해, 파이썬 인터프리터가 증분 대입문을 어떻게 처리하는지 살펴봅시다.

먼저 연산자 정의 부터 살펴봐야 하는데, 클래스 인스턴스에 연산자를 정의하는 것은 파이썬 언어 레퍼런스숫자 형 흉내 내기 섹션에 나옵니다. 덧셈이 정의된 클래스를 하나 만들어봅시다.

>>> class Integer:
...     def __init__(self, value=0):
...         self.value = value
...     def __repr__(self):
...         return 'Integer({})'.format(self.value)
...     def __add__(self, other):
...         print('add')
...         return Integer(self.value + other)
...
>>> Integer() + 1
Integer(1)

이 상태에서 i += 1 을 실행하면 i = i + 1 로 바꿔서 실행합니다.

>>> i = Integer()
>>> i = i + 1
add
>>> i
Integer(1)
>>> i += 1
add
>>> i
Integer(2)

하지만 __iadd__ 를 정의하면 달라집니다.

>>> class Integer:
...     def __init__(self, value=0):
...         self.value = value
...     def __repr__(self):
...         return 'Integer({})'.format(self.value)
...     def __add__(self, other):
...         return Integer(self.value + other)
...     def __iadd__(self, other):
...         print('iadd')
...         self.value += other
...         return self
...
>>> i = Integer()
>>> i = i + 1
add
>>> i
Integer(1)
>>> i += 1
iadd
>>> i
Integer(2)

__iadd__self.value 의 값을 바꾼 후에, self 를 돌려주는 것에 주목해야합니다. 이 것이 제자리에서 연산을 수행한다, 즉 새 인스턴스를 만들지 않고 자신의 값을 바꾼다는 것입니다. 하지만 이 역시 꼭 그래야만 하는 것은 아닙니다. 그냥 새 인스턴스를 만들어서 돌려주어도 아무도 불평하지 않습니다. 증분 대입문도 대입문입니다. self 를 돌려주건, 새 인스턴스를 돌려주건 변수 i 에는 대입이 발생합니다. self 를 돌려주면 i = i 와 같은 의미없는 대입이 실행되기 때문에 대입이 일어나고 있지 않은 것처럼 보일뿐입니다. 이 때문에 증분 연산자는 불변 형에서도 정의할 수 있습니다. (당연히 새 인스턴스를 만들어서 돌려주어야 합니다. 그런데, 그럴거면 __iadd__ 를 정의할 필요가 없겠지요?)

때로 묘한 상황이 별어질 수도 있습니다.

>>> a_tuple = (['foo'], 'bar')
>>> a_tuple[0] += ['item']
Traceback (most recent call last):
  ...
TypeError: 'tuple' object does not support item assignment

두 번째 문장은 a_tuple[0] = a_tuple[0].__iadd__(['item']) 과 동등한 표현입니다. 때문에 튜플에 대입하려고 할 때 예외가 발생하는 것은 당연합니다. 묘한 상황이라는 것은, 예외가 발생했지만 이미 a_tuple[0] 은 변경되었다는 것입니다.

>>> a_tuple
(['foo', 'item'], 'bar')

이는 대입에서 예외가 발생하기 전에 __iadd__ 는 이미 성공했기 때문입니다.

기억하세요, 증분 대입문은 증분과 대입의 두 단계로 진행되는 문장입니다.