파이썬스러운 객체 만들기 — 시퀀스, 해시, 슬라이스

휴먼스케이프

안녕하세요. 휴먼스케이프에서 개발을 하고 있는 bruno입니다.

이 번 포스트에서는 파이썬에서 사용자 정의 클래스를 정의할 때, 어떻게 하면 시퀀스, 해시 그리고 슬라이스를 파이썬스럽게(pythonic) 정의할 수 있을지 다루겠습니다.

이 포스트는 루시아누 하말류의 Fluent Python을 참고했습니다.

파이썬에서 시퀀스형을 만들기 위해서는 상속이 필요하지 않습니다. 지난 포스트[파이썬스러운 객체]에서 말씀드렸던 것처럼, 덕타이핑에 맞춰 클래스를 정의하면 됩니다.

객체지향 관점에서, 덕타이핑은 인터페이스를 상속하는 것과는 다르게 실제 코드에 나타나진 않고 컴파일러가 강제하지 않습니다. 이처럼 인터페이스는 아니나, 하나의 느슨한 규칙처럼 정의된 사항을 ‘프로토콜'이라고 합니다. 쉽게 ‘비공식 인터페이스'라고도 칭합니다.

우리는 우리의 클래스가 시퀀스처럼 동작하며 슬라이싱을 지원하도록 하기 위해 파이썬의 시퀀스 프로토콜을 따라서 클래스를 정의할 것입니다.

이 포스트의 예제는 지난 포스트[파이썬스러운 객체]에서 정의한 Vector 클래스를 확장합니다.

슬라이싱 가능한 시퀀스

내부에 시퀀스형을 가지고, 그 시퀀스에 시퀀스 프로토콜을 구현하기 위한 __len__()과 __getitem__() 메소드의 기능을 내부 _components에 위임하면 위와 같은 코드가 됩니다.

위 코드의 문제점은 슬라이싱을 하면, 슬라이싱된 Vector 인스턴스가 아닌, 내부 _components와 같은 타입의 배열을 반환합니다.

>>> v7 = Vecdor(range(7))
>>> v7[1:4]
array('d', [1.0, 2.0, 3.0])  # 슬라이싱하면 배열을 반환

[] 연산자로 시퀀스형에 접근하면, 해당 인스턴스는 __getitem__() 메소드를 호출합니다. 그렇다면 []로 접근할 때, 파라미터는 어떤 형식으로 전달될까요?

>>> class MySeq:
...     def __getitem__(self, index):
...             return index  # 어떻게 파라미터가 전달되는지 그대로 반환해보자
...
>>> s = MySeq()
>>> s[1]
1  # 파라미터 1 그대로 반환
>>> s[1:4]
slice(1, 4, None)  # slice 인스턴스가 파라미터로 전달됩니다.
>>> s[1:4:2]
slice(1, 4, 2)
>>> s[1:4:2, 9]
(slice(1, 4, 2), 9)  # 콤마 사용시 튜플로 파라미터들이 전달됩니다.
>>> s[1:4:2, 7:9]  # 복수의 슬라이스 파라미터 전달시
(slice(1, 4, 2), slice(7, 9, None))  # 튜블로 묶여서 전달됩니다.

슬라이싱 파라미터를 전달하면 내부에서 파라미터를 slice 형으로 변환해서 __getitem__() 메소드에 전달합니다. 우리는 이 slice 형을 이용해서 우리가 원하는 방식의 슬라이싱을 적용할 수 있습니다.

위 코드처럼, 우리의 클래스 내부에 __getitem__() 메소드의 파라미터 index의 타입을 isinstance 내부함수를 이용해서 판단하면 슬라이싱을 구현할 수 있습니다.

동적 속성 접근

Vector 클래스에서 v[0], v[1], v[2] 대신 v.x, v.y, v.z와 같이 접근할 수 있도록 클래스를 정의하려면 어떻게 해야 할까요? 동적으로 속성을 만들고 그 속성에 접근하기 위해서는 __getattr__()과 __setattr__() 메소드를 사용합니다.

동적 속성으로 사용하고 싶은 xyzt를 string으로 만들고, 해당 attribute에 접근하려고 하면 내부의 _components에 접근하도록 했습니다. 얼핏 보면 문제 없는 코드로 보일 수 있으나, 이 코드에는 큰 문제가 있습니다.

>>> v = Vector(range(5))
>>> v
Vector([0.0, 1.0, 2.0, 3.0, 4.0])
>>> v.x
0.0
>>> v.x = 10  # x 속성이 새로 생성되고, 10으로 초기화됨
>>> v.x
10
>>> v  # v.x가 새로 생성되었기 때문에 v[0]은 변경되지 않음
Vector([0.0, 1.0, 2.0, 3.0, 4.0])

__getattr__() 메소드를 이용해서 x 속성을 읽어오는데에는 성공했으나, v.x = 10 과 같이 속성에 값을 주게 되면, 파이썬은 자동으로 x 속성을 만들고 그 속성에 값을 저장합니다. x 속성을 이용해서 첫 번째 값에 접근하고 싶었던 우리의 의도와는 다른 결과입니다. 때문에 __setattr__() 메소드를 정의해서 Vector 클래스를 불변형으로 만들겠습니다. 불변형으로 만드는 이유는 해싱 가능하도록 하기 위해서입니다. 해싱에 관하여는 뒤에서 더 설명하겠습니다.

위처럼 객체 동작의 불일치를 피하려면 __getattr__()을 구현할 때, __setattr__()도 함께 구현해야 합니다.

해싱 및 더 빠른 ==

__eq__() 메소드와 함께 __hash__() 메소드를 구현하면 객체를 해시할 수 있게 됩니다.

각 요소를 해시한 후, 해시값들에 xor 연산을 적용해서 최종 해시 결과값을 얻어낼 것입니다. 인스턴스의 해시값이 항상 동일한 값을 가리키도록하기 위해선 요소가 불변이 되어야 합니다. 때문에 객체의 _components 안의 값들은 readonly를 유지해야 합니다.

제너레이터를 생성하여 각 요소의 해시값을 순차적으로 얻어내어 메모리를 절약할 수 있습니다.

각 항목에 함수를 적용해서 새로운 시퀀스를 생성하고(맵) 누적 연산을 적용하는(리듀스) 과정을 명확히 보여주기 위해서 map() 함수를 사용할 수도 있습니다.

python3에서는 map 함수도 제너레이터를 생성하여, 느긋한 계산이 가능합니다.

__eq__() 메소드도 제너레이터를 이용해서 접근하면 메모리를 절약하고 더 빠르게 일치성을 판단할 수 있습니다.

zip 함수를 이용하면, 두 개의 시퀀스를 순차적으로 접근하는 제너레이터를 생성할 수 있습니다. 다만, zip 함수는 입력의 길이가 다르면 오류 없이 더 짧은 입력까지만 반복하고 종료하기 때문에, 길이를 먼저 비교해줘야 합니다.

마무리

이 번 포스트에서는 지난 포스트[파이썬스러운 객체]에서 확장해, 다차원 벡터를 예시로 어떻게 시퀀스형 클래스를 파이썬스럽게 정의하는지 다뤘습니다.

여기서 루시아누 하말류가 설명하시는 것처럼, 덕 타이핑과 프로토콜에 맞춰서 파이썬 클래스를 정의한다면, 내장 함수나 타입을 사용하는 것처럼 일관된 사용성을 얻을 수 있습니다.

감사합니다.

Get to know us better! Join our official channels below.

Telegram(EN) : t.me/Humanscape KakaoTalk(KR) : open.kakao.com/o/gqbUQEM Website : humanscape.io Medium : medium.com/humanscape-ico Facebook : www.facebook.com/humanscape Twitter : twitter.com/Humanscape_io Reddit : https://www.reddit.com/r/Humanscape_official Bitcointalk announcement : https://bit.ly/2rVsP4T Email : support@humanscape.io

기업문화 엿볼 때, 더팀스

로그인

/