item4 dev story

Python __getitem__과 slice의 이해

작성일:

게시물 주소: https://item4.github.io/2015-10-26/Understanding-Python-__getitem__-and-slice/

Tags:

Python에서, 특정 object에 [] 연산자를 사용하면 내부적으론 __getitem__이라는 메소드가 실행됩니다. 예를 들어서 arr[1] 이라고 했다면 arr.__getitem__(1) 과 같이 변경되는 것이죠. 그렇다면 slice를 사용하면 어떻게 될까요? slice가 무엇인지 모르시는 분들을 위해 간략히 예를 들자면 이런 것입니다.

>>> arr = list(range(10))
>>> arr
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
>>> arr[0:4]
[0, 1, 2, 3]
>>> arr[3:8]
[3, 4, 5, 6, 7]
>>> arr[0:7:3]
[0, 3, 6]
>>> arr[9:0:-1]
[9, 8, 7, 6, 5, 4, 3, 2, 1]

그런데, 가끔은 __getitem__을 직접 구현해야 하는 경우가 있습니다. 이런 경우엔 어떻게 해야할까요?

__getitem__의 동작

먼저, __getitem__이 slice를 만났을 때 어떻게 동작하는지 알아보겠습니다. 테스트를 위해 간단한 class를 작성했습니다.

>>> class A:
...   def __getitem__(self, item):
...     print(repr(item))
...
>>> a = A()
>>> a[1]
1
>>> a[1:2]
slice(1, 2, None)
>>> a[1:2:3]
slice(1, 2, 3)
>>> a[1, 2, 3]
(1, 2, 3)
>>> a[1, 2, 3:4]
(1, 2, slice(3, 4, None))
>>> a[1, 2, 3:4, ...]
(1, 2, slice(3, 4, None), Ellipsis)

우리는 여기서 3가지 사실을 알 수 있습니다.

  1. :를 사용한 경우 slice가 생성된다.
  2. ,로 나눠서 적으면 tuple에 나눠서 저장된다.
  3. ...Ellipsis라는 형태로 처리된다.

아직은 알쏭달쏭합니다.

tuple이 주어졌을 때의 동작

일단 tuple이 주어지면, 즉 ,를 사용하면 어떻게 되는지 알아보도록 하겠습니다.

>>> arr = list(range(10))
>>> arr[1, 4]
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: list indices must be integers or slices, not tuple

일단 일반적으로는 사용할 수 없습니다. 아무래도 확장성을 위한 영역으로 추정됩니다.

지난번에도 글로 썼었지만 기본 자료형을 상속해서 새 자료형을 만들 수 있습니다. 한번 새 class를 list를 상속해서 만든 다음 제 맘대로 동작하도록 바꿔보도록 하죠. 예를 들어 arr[1, 4]라고 하면 [arr[1], arr[4]]가 리턴되도록 해보겠습니다.

>>> class MyList(list):
...   def __getitem__(self, item):
...     if isinstance(item, tuple):
...       return [self[i] for i in item]
...     else:
...       return super().__getitem__(item)
...
>>> arr = MyList(range(100, 1000, 100))
>>> arr
[100, 200, 300, 400, 500, 600, 700, 800, 900]
>>> arr[1]
200
>>> arr[4]
500
>>> arr[1, 4]
[200, 500]

slice는 어떻게 사용할까?

이 글을 쓰게 된 계기이기도 한데, slice의 사용법은 Python 공식 웹 문서에 없습니다. slice는 class인데, Built-in Functions 챕터에 소개되어 있습니다. 하지만 어떻게 사용할지에 대한 정보는 하나도 담겨있지 않습니다.

그렇다면 slice의 사용법은 어떻게 알 수 있을까요?1 Python은 이런 부분들에 대한 문서를 help 함수를 통해 확인할 수 있게 해두었습니다.

>>> help(slice)

class slice(object)
 |  slice(stop)
 |  slice(start, stop[, step])
 |
 |  Create a slice object.  This is used for extended slicing (e.g. a[0:10:2]).

... (중략)

 |  indices(...)
 |      S.indices(len) -> (start, stop, stride)
 |
 |      Assuming a sequence of length len, calculate the start and stop
 |      indices, and the stride length of the extended slice described by
 |      S. Out of bounds indices are clipped in a manner consistent with the
 |      handling of normal slices.
 |
 |  ----------------------------------------------------------------------
 |  Data descriptors defined here:
 |
 |  start
 |
 |  step
 |
 |  stop

... (후략)

slice는 indices라는 메소드와 start, step, stop이라는 프로퍼티를 가지고 있습니다.

일단 간단한 프로퍼티들부터 실제 실행 사례와 함께 살펴보겠습니다.

>>> s = slice(100, 200, 300)
>>> s.start
100
>>> s.stop
200
>>> s.step
300

slice를 생성할때 쓰인 값이 그대로 저장되어 있음을 알 수 있습니다.

그렇다면 indices는 어떻게 사용할까요?

>>> s = slice(1, 1000, 3)
>>> s.indices(10)
(1, 10, 3)
>>> s.indices(100)
(1, 100, 3)
>>> s.indices(1000)
(1, 1000, 3)
>>> s.indices(10000)
(1, 1000, 3)

slice는 길이값을 요구하는데, 길이 값에 따라 실제 slice해야하는 영역을 알려줍니다. 이 결과값을 이용하면 이런 식으로 사용이 가능합니다.

>>> list(range(*s.indices(10)))
[1, 4, 7]
>>> list(range(*s.indices(100)))
[1, 4, 7, 10, 13, 16, 19, 22, 25, 28, 31, 34, 37, 40, 43, 46, 49, 52, 55, 58, 61, 64, 67, 70, 73, 76, 79, 82, 85, 88, 91, 94, 97]

반환되어 나오는 값의 양식이 range의 인자 순서와 일치하기 때문에, 바로 unpack해서 사용할 수 있습니다. 실제로 slice를 구현해야한다면 이런 식으로 사용할 수 있을 것입니다.

>>> class MySpecialStructure:
...   def __getitem__(self, item):
...     if isinstance(item, slice):
...       return ['item-{}'.format(i) for i in range(*item.indices(self.length))]
...     else:
...       return super().__getitem__(item)
...
>>> m = MySpecialStructure()
>>> m.length = 10
>>> m[1:1000:3]
['item-1', 'item-4', 'item-7']
>>> m.length = 100
>>> m[1:1000:3]
['item-1', 'item-4', 'item-7', 'item-10', 'item-13', 'item-16', 'item-19', 'item-22', 'item-25', 'item-28', 'item-31', 'item-34', 'item-37', 'item-40', 'item-43', 'item-46', 'item-49', 'item-52', 'item-55', 'item-58', 'item-61', 'item-64', 'item-67', 'item-70', 'item-73', 'item-76', 'item-79', 'item-82', 'item-85', 'item-88', 'item-91', 'item-94', 'item-97']

Ellipsis는 무엇인가?

Ellipsis, 또는 ...은 Python 3에서 정식적으로 사용 가능한 상수가 되었는데, 공식적으로 딱 어떻게 사용하라는 지침은 없습니다. 즉, 개발자가 필요에 따라 사용할 수 있는 부분입니다.

응용하기

Ellipsis 등등을 개발자 맘대로 사용하는 예를 들기 위해 저는 아까 위에서 만든 MySpecialStructure를 조금 더 확장해보고자 합니다. 예를 들어 length가 7인 arrarr[2:4, ...]과 같이 사용하면 ['item-2', 'item-3', 'item-4', 'item-5', 'item-6']이 나오게 하는 것이죠.

아래 코드와 같은 방식으로 구현이 가능합니다. tuple이 오는 경우의 처리를 위해 재귀호출로 구현되어 있습니다. 본 코드는 위에 예를 든 arr[2:4, ...] 외에도 arr[1, 1, 1], arr[2:4, ..., 40:80] 등에도 모두 대응하여 동작합니다. 다만 문자열등의 예상치 못한 자료형을 넣은 경우 등에는 대응하지 못합니다.

class MSS:
    def __getitem__(self, items):
        def getitem(item, before_stop=None, after_item=None, return_before_stop=False):
            if isinstance(item, tuple):
                after_items = list(item)[1:] + [None]
                result = []
                for x, a in zip(item, after_items):
                    res, before_stop = getitem(x, before_stop, a, True)
                    if isinstance(res, list):
                        result += res
                    else:
                        result.append(res)
                return result
            elif isinstance(item, slice):
                range_ = range(*item.indices(self.length))
                result = [getitem(x) for x in range_]
                if return_before_stop:
                    return result, range_[-1]
                else:
                    return result
            elif item is Ellipsis:
                if before_stop is not None:
                    before_stop += 1
                after_start = after_item
                if isinstance(after, slice):
                    after_start = after_item.start
                result = getitem(slice(before_stop, after_start), return_before_stop=True)
                if return_before_stop:
                    return result[0], result[1]
                else:
                    return result[0]
            else:
                if item >= self.length:
                    raise IndexError('너무 큰 값')
                elif item < 0:
                    raise IndexError('너무 작은 값')
                result = 'item-{}'.format(item)
                if return_before_stop:
                    return result, item
                else:
                    return result
        return getitem(items)

제 코드는 순전히 예제를 위한 것이지만, 실제로 이러한 __getitem__의 확장은 numpy에서 쓰이고 있습니다. 여러분의 코드가 이러한 확장적인 구조를 필요로 한다면 한번쯤 고려해보시는건 어떨까요?


  1. 사실 저는 help의 존재를 까먹고 slice의 Python 내부 구현을 먼저 살펴봤습니다.

GitHub에 이슈 작성 글쓴이에게 초코우유 한 잔 선물하기

주제가 비슷한 글