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 내부 구현을 먼저 살펴봤습니다.