9.1 객체 표현
repr() : 객체를 개발자가 보고자 하는 형태로 표현한 문자열로 반환한다.
str() : 객체를 사용자가 보고자 하는 형태로 표현한 문자열로 반환한다.
repr(), str() 메서드를 지원하려면 각각 __repr__(), __str__() 매직 메서드를 구현해야 한다.
객체를 표현하는 다른 방법을 지원하는 __bytes__(), __format__() 매직 메서드도 있다.
__bytes__() 는 __str__() 과 비슷하지만 bytes() 메서드에 의해 호출되어 객체를 바이트 시퀀스로 표현한다.
__format__() 은 내장 함수 format() 과 str.format() 메서드 둘 다 사용하며, 특별 포맷 코드를 이용해서 객체를 표현하는 문자열을 반환한다.
9.2 벡터 클래스
객체 표현을 생성하기 위해 사용하는 여러 메서드의 예를 살펴보기 위해 Vector2d 클래스를 이용한다.
이 클래스를 계속 확장해나간다. 아래는 Vector2d 객체가 수행할 기본적인 동작을 보여준다.
v1 = Vector2d(3, 4)
print(v1.x, v1.y) # 1
> 3.0 4.0
x, y = v1 # 2
print((x, y))
> (3.0 4.0)
print(repr(v1)) # 3
> Vector2d(3.0, 4.0)
v1_clone = eval(repr(v1)) # 4
print(v1 == v1_clone) # 5
> True
print(v1) # 6
> (3.0, 4.0)
octets = bytes(v1) # 7
print(octets)
> b'd\x00\x00\x00\x00\x00\x00\x08@\x00\x00\x00\x00\x00\x00\x10@'
print(abs(v1)) # 8
> 5.0
print(bool(v1), bool(Vector2d(0, 0))) # 9
> True False
1. Vector2d 요소들은 getter 메서드를 호출할 필요 없이 직접 속성에 접근할 수 있다.
2. Vector2d 를 변수들의 튜플에 언패킹할 수 있다.
3. Vector2d 의 repr() 은 객체를 생성하는 소스 코드와 같은 형태를 출력한다.
4. eval() 을 이용해서 Vector2d의 repr() 생성자 호출을 제대로 표현했는지 확인한다
--> repr() 을 강조하기 위해 eval() 함수를 사용하여 객체를 복사했으나, copy.copy() 를 통해 보다 빠르고 안전하게 객체를 복사할 수 있다.
5. Vector2d는 == 연산자를 이용한 비교를 지원한다. 이는 객체를 비교하는 데 유용하다.
6. print() 는 str() 을 호출하며, str() 은 Vector2d 객체의 경우 순서쌍을 생성한다.
7. bytes() 는 __bytes__() 메서드를 이용해서 이진 표현을 생성한다.
8. abs() 는 __abs__() 메서드를 이용해서 Vector2d 객체의 크기를 반환한다.
9. bool() 은 __bool__() 메서드를 사용하며, Vector2d 객체의 크기가 0이면 False, 아니면 True 를 반환한다.
import math
from array import array
class Vector2d:
typecode = 'd' # <1>
def __init__(self, x, y):
self.x = float(x) # <2>
self.y = float(y)
def __iter__(self):
return (i for i in (self.x, self.y)) # <3>
def __repr__(self):
class_name = type(self).__name__
return '{}({!r}, {!r})'.format(class_name, *self) # <4>
def __str__(self):
return str(tuple(self)) # <5>
def __bytes__(self):
return (bytes([ord(self.typecode)]) + # <6>
bytes(array(self.typecode, self))) # <7>
def __eq__(self, other):
return tuple(self) == tuple(other) # <8>
def __abs__(self):
return math.hypot(self.x, self.y) # <9>
def __bool__(self):
return bool(abs(self)) # <10>
1. typecode 는 Vector2d 와 bytes 간의 변환에 사용하는 클래스 속성이다.
2. __init__() 안에서 x 와 y 를 float 으로 변환하면 부적절한 인수로 Vector2d 객체를 생성하는 경우 조기에 에러를 잡는 데 도움이 된다.
3. __iter__() 를 구현하면 Vector2d를 반복할 수 있게 된다. 그렇기 때문에 x, y = my_vector 문장으로 언패킹할 수 있었다. 이 메서드는 제너레이터 표현식을 이용해서 요소를 차례대로 하나씩 생성한다.
4. __repr__() 은 {!r} 을 각 요소에 repr() 을 호출해서 반환된 문자열로 치환해 문자열을 만든다. Vector2d 를 반복할 수 있으므로, *self 는 format() 에 x와 y 속성을 공급한다.
5. 반복형 Vector2d 에서 튜플을 만들어 순서쌍으로 출력한다.
6. bytes 를 생성하기 위해 typecode를 bytes로 변환한다.
7. 그리고 이것을 객체를 반복 생성한 배열에서 변환된 bytes 와 연결한다.
8. 모든 속성을 비교하기 위해 피연산자로부터 튜플을 생성한다. Vector2d 객체를 피연산자로 사용하면 작동하지만 문제가 있다. 동일한 숫자값을 가진 어떤 반복형 객체도 Vector2d 객체와 비교하면 True 를 반환한다. (예 : Vector2d(3, 4) == [3, 4])
- 이와 관련한 연산자 오버로딩은 13장에서 확인한다.
9. magnitude() 는 x와 y로 만들어진 직삼각형 사변의 길이다.
10. __bool__() 은 abs(self) 를 사용해서 사변 길이를 계산하고 불리언으로 변환한다. 따라서 0.0 은 False 고, 그 외 값은 True 다.
기본적인 메서드를 거의 구현했지만, bytes() 로 생성한 이진 표현에서 Vector2d 객체를 다시 만드는 메서드가 없다. 이를 구현해본다.
9.3 대안 생성자
Vector2d 를 bytes 로 변환하는 메서드가 있으니, 이 반대의 메서드도 필요하다. array.array 의 frombytes() 메서드가 이와 유사한데, 동일한 이름으로 구현해본다.
@classmethod # <1>
def frombytes(cls, octets): # <2>
typecode = chr(octets[0]) # <3>
memv = memoryview(octets[1:]).cast(typecode) # <4>
return cls(*memv) # <5>
1. 클래스 메서드로 구현하였다.
2. self 대신 cls 매개변수로 전달된다.
3. 첫 번째 바이트에서 typecode 를 읽는다.
4. octets 이진 시퀀스로부터 memoryview 를 생성하고 typecode 를 이용해서 형 변환한다.
--> 메모리뷰는 공유 메모리 시퀀스형으로서 bytes 를 복사하지 않고 배열의 슬라이스를 다룰 수 있게 해준다. memoryview.cast() 메서드는 바이트를 이동시키지 않고 여러 바이트로 된 데이터의 읽기, 쓰기 형식을 바꿀 수 있게 하며, 동일 메모리를 공유하는 또 다른 memoryview 객체를 반환한다. (2.9.2절 참고)
5. cast() 가 반환한 memoryview 를 언패킹해서 생성자에 필요한 인수로 전달한다.
9.4 @classmethod와 @staticmethod
@classmethod 데커레이터는 객체가 아닌 클래스에 연산을 수행하는 메서드를 정의한다. @staticmethod 는 본질적으로 모듈 대신 클래스 본체 안에 정의된 평범한 함수이다.
class Demo:
@classmethod
def klassmethod(*args):
return args
@staticmethod
def statmeth(*args):
return args
print(Demo.klassmethod())
> (<class '__main__.Demo'>,)
print(Demo.klassmethod('spam'))
> (<class '__main__.Demo'>, 'spam')
print(Demo.statmeth())
> ()
print(Demo.statmeth('spam'))
> ('spam',)
- Demo.klassmethod() 는 호출 방법과는 무관하게 Demo 클래스를 첫 번째 인수로 받는다.
- Demo.statmeth() 는 평범한 함수처럼 동작한다.
9.5 포맷된 출력
format() 내장 함수와 str.format() 메서드는 실제 포맷 작업을 __format__(format_spec) 메서드에 위임한다. format_spec 은 포멧 명시자(format specifier) 로서, 다음 두 가지 방법 중 하나를 통해 지정한다.
- format(my_obj, format_spec) 의 두 번째 인수
- str.format() 에 사용된 포맷 문자열 안에 {} 로 구분한 대체 필드 안에서 콜론 뒤의 문자열
brl = 1/2.43
print(brl)
> 0.4115226337448559
print(format(brl, '0.4f'))
> 0.4115
print('1 BRL = {rate:0.2f} USD'.format(rate=brl))
> 1 BRL = 0.41 USD
- '0.4f' 가 포맷 명시자다
- {rate:0.2f} 에서 0.2f 는 포맷 명시자이며, rate 가 필드명이다. 필드명은 포맷 명시자와 상관 없지만 format() 인수 중 어느 인수가 대체 필드에 들어갈 것인지 결정한다.
포맷 명시자에 사용된 표기법을 포맷 명시 간이 언어(Format Specification Mini-Language) 라고 한다.
몇몇 내장 자료형은 포맷 명시 간이 언어에 고유한 표현 코드를 가지고 있다.
예를 들어 int 형의 경우 이진수를 나타내는 'b', 16진수를 나타내는 'x', 코드를 지원하며, float 형의 경우 고정 소수점을 나타내는 '.f', 백분율을 나타내는 '%' 코드를 지원한다.
print(format(42, 'b'))
> 101010
print(format(2/3, '%'))
> 66.666667%
각 클래스가 format_spec 인수를 자신이 원하는 대로 해석해서 포맷 명시 간이 언어를 확장할 수 있다. 예를 들어 datetime 모듈의 클래스들은 자신의 __format__() 메서드에서 strftime() 함수와 동일한 포맷 코드를 사용한다.
from datetime import datetime
now = datetime.now()
print(format(now, "%H:%M:%S"))
> 16:21:18
print("It's now {:%I:%M %p}".format(now))
> It's now 04:21 PM
클래스에서 __format__() 메서드를 정의하지 않으면, object 에서 상속받은 메서드가 str(my_object) 를 반환한다. Vector2d는 __str__() 을 정의하고 있으므로, 다음과 같이 실행된다.
v1 = Vector2d(3, 4)
print(format(v1))
> (3.0, 4.0)
그러나 이 때 포맷 명시자를 사용하면 object.__format__() 은 TypeError를 발생시킨다.
print(format(v1, '.3f'))
Traceback (most recent call last):
...
TypeError: unsupported format string passed to Vector2d.__format__
Vector2d 클래스 자체의 포맷 간이 언어를 구현하면 이 문제를 해결할 수 있다. 다음과 같은 결과가 나온다고 가정한다.
v1 = Vector2d(3, 4)
print(format(v1))
> (3.0, 4.0)
print(format(v1, '.2f'))
> (3.00, 4.00)
print(format(v1, '.3e'))
> (3.000e+00, 4.000e+00)
위와 같이 출력하기 위해 __format__() 메서드를 구현한다.
class Vector2d:
...
def __format__(self, format_spec=''):
components = (format(c, format_spec) for c in self)
return '({}, {})'.format(*components)
- 벡터의 각 요소에 format_spec 포맷을 적용하기 위해 format() 내장 함수를 호출하고, 포맷된 문자열의 반복형을 생성한다.
- 포맷된 문자열을 (x, y) 형식으로 만든다.
이제 Vector2d 의 간이 언어에 포맷 코드를 추가해본다. 포맷 명시자가 'p' 로 끝나면 벡터를 극좌표 <r, 𝜽> 로 표현한다. 극좌표를 생성하기 위한 angle 메서드를 다음과 같이 구현할 수 있다.
def angle(self):
return math.atan2(self.y, self.x)
이제 __format__() 메서드가 극좌표를 생성하도록 수정한다.
def __format__(self, fmt_spec=''):
if fmt_spec.endswith('p'):
fmt_spec = fmt_spec[:-1]
coords = (abs(self), self.angle())
outer_fmt = '<{}, {}>'
else:
coords = self
outer_fmt = '({}, {})'
components = (format(c, fmt_spec) for c in coords)
return outer_fmt.format(*components)
- 포맷 명시자가 'p' 로 끝나면 극좌표를 사용한다.
- p 앞에 나오는 포맷 명시자는 이전과 동일하게 사용되므로 p 만 떼서 적용한다.
print(format(Vector2d(1,1), 'p'))
> <1.4142135623730951, 0.7853981633974483>
print(format(Vector2d(1,1), '0.5fp'))
> <1.41421, 0.78540>
9.6 해시 가능한 Vector2d
지금까지 정의한 Vector2d 는 해시할 수 없다. 그러므로 집합 안의 항목으로 사용할 수 없다.
print(hash(Vector2d(1, 1)))
> Traceback (most recent call last):
...
TypeError: unhashable type: 'Vector2d'
print(set([Vector2d(1, 1)]))
> Traceback (most recent call last):
...
TypeError: unhashable type: 'Vector2d'
Vector2d 를 해시 가능하게 만들려면 __hash__() 메서드를 구현해야 한다. (__eq__() 메서드도 필요하지만 이미 구현되어 있다.)
현재 구현된 Vector2d로는 v1.x = 7 과 같이 속성을 변경하는 것이 가능하다. 하지만 불변형으로 만들면 다음과 같이 실행된다.
print(v1.x, v1.y)
> (3.0, 4.0)
v1.x = 7
> > Traceback (most recent call last):
...
AttributeError: can't set attribute
불변형으로 구현하기 위해 우선 x 와 y 요소를 읽기 전용 속성으로 만든다. 해시 가능형을 구현하기 위해 반드시 프로퍼티를 구현하거나 속성을 보호할 필요는 없다. 다만, 객체의 해시값이 변하면 안 되므로 읽기 전용 프로퍼티를 사용하는 것이 좋다.
class Vector2d:
typecode = 'd'
def __init__(self, x, y):
self.__x = float(x)
self.__y = float(y)
@property
def x(self):
return self.__x
@property
def y(self):
return self.__y
def __iter__(self):
return (i for i in (self.x, self.y))
...
- 언더바 두 개로 시작해서 속성을 비공개로 만든다. (완벽하지 않고, 일반적으로 언더바 하나로 암시적인 비공개 속성임을 나타낸다.)
- @property 데커레이터는 getter 메서드를 나타낸다.
Vector2d 를 불변형으로 만들었으므로, __hash__() 메서드를 구현하면 된다. __hash__() 메서드는 int 를 반환해야 한다. 그리고 동일하다고 판단되는 객체는 동일한 해시값을 가져야 하므로 __eq__() 메서드가 사용하는 객체의 속성을 이용해서 해시를 계산하는 것이 가장 이상적이다. __hash__() 매직 메서드 문서에서는 요소의 해시에 비트단위 XOR(배타적 논리합) 연산자(^) 를 사용하는 것을 권장하므로, 거기에 따른다.
def __hash__(self):
return hash(self.x) ^ hash(self.y)
이제 해시 가능하다.
v1 = Vector2d(3, 4)
v2 = Vector2d(3.1, 4.2)
print(hash(v1), hash(v2))
> 7 384307168202284039
print(set([v1, v2]))
> {Vector2d(3.1, 4.2), Vector2d(3.0, 4.0)}
적절한 스칼라 값을 가진 자료형을 만들 때는 경우에 따라 자료형을 강제 변환하기 위해 사용되는 int() 와 float() 이 호출하는 __int__() 와 __float__() 메서드를 구현하는 것도 좋다. 내장된 complex() 생성자를 지원하기 위한 __complex__() 메서드도 있다.
9.7 파이썬에서의 비공개 속성과 보호된 속성
파이썬에는 private 수정자가 있는 자바와 달리 비공개 변수를 생성할 수 있는 방법은 없지만, 서브클래스에서 '비공개' 성격의 속성을 실수로 변경하지 못하게 하는 간단한 메커니즘은 있다.
속성명을 두 개의 언더바로 시작하고 언더바 없이 또는 하나의 언더바로 끝나도록 정의하면, 파이썬은 언더바와 클래스명을 변수명 앞에 붙여 객체의 __dict__ 에 저장한다.
Dog 클래스의 __mood 속성이 있다고 했을 때, __mood 는 _Dog__mood 가 된다. 이러한 파이썬 언어 기능을 이름 장식(name mangling) 이라고 한다.
이름 장식은 안전을 제공하지만, 보안 기능은 아니다. 실수로 접근하는 것을 막도록 설계되어 있지만 고의적인 악용을 막지는 못한다.
self._x 처럼 언더바 하나를 붙여 속성을 보호할 수도 있다. 다만 실질적으로 어떠한 기능을 하는 것은 아니다.
모듈에서 최상위 이름 앞에 _ 를 하나 붙이는 경우에는 from mymod import * 로 작성했을 때, _ 로 시작하는 이름들은 임포트 하지 않는다. 그러나 직접 지정하면 임포트 가능하다.
self._x 형태의 속성을 보호하는 관례는 대부분의 개발자가 보편적으로 따르고 있다.
9.8 __slots__ 클래스 속성으로 공간 절약하기
기본적으로 파이썬은 객체 속성을 각 객체 안의 __dict__ 라는 딕셔너리 속성에 저장한다. 딕셔너리는 빠른 접근 속도를 제공하는 대신 메모리 사용량 부담이 크다. 만약 속성이 몇 개 없는 수백만 개의 객체를 다룬다면, __slots__ 클래스 속성을 이용해서 메모리 사용량을 엄청나게 줄일 수 있다. __slots__ 속성은 파이썬 인터프리터가 객체 속성을 딕셔너리 대신 튜플에 저장하게 만든다.
참고) 슈퍼클래스에서 상속받은 __slots__ 속성은 서브클래스에 영향을 주지 않는다. 파이썬은 각 클래스에서 개별적으로 정의된 __slots__ 속성만 고려한다.
__slots__ 를 정의하려면, __slots__ 라는 이름의 클래스 속성을 생성하고 여기에 객체 속성 식별자들을 담은 문자열의 반복형을 할당한다. 불변형인 튜플을 사용하면 __slots__ 정의를 변경할 수 없음을 알려주므로, 튜플을 주로 사용한다.
class Vector2d:
__slots__ = ('__x', '__y')
typecode = 'd'
...
__slots__ 를 클래스에 정의함으로써 "이 속성들이 이 클래스 객체가 가지는 속성" 임을 인터프리터에 알려준다. 그러면 파이썬 인터프리터는 이 속성들을 각 객체의 튜플형 구조체에 저장함으로써 __dict__ 속성을 각 객체마다 유지하는 부담을 덜어낸다.
참고) 실제 수백만 개의 숫자 데이터를 처리하는 경우에는 NumPy 를 사용하는 것이 훨씬 좋다. NumPy 는 메모리를 효율적으로 사용할 뿐만 아니라, 숫자 처리에 상당히 최적화된 함수들을 가지고 있기 때문이다.
클래스 안에 __slots__ 를 명시하는 경우, 명시되지 않은 다른 속성은 가질 수 없다. 이는 __slots__ 의 존재 이유가 아니다. 최적화를 위함이지 새로운 속성을 추가하지 못하게 하기 위함이 아니다.
__slots__ 는 고정된 스키마의 아주 큰 데이터베이스와 같은 테이블 형태의 데이터를 사용할 때 유용하다. 그러나 이런 형태의 데이터를 자주 처리해야 한다면 NumPy 나 Pandas 를 사용하는 것이 더 좋다.
__slots__ 는 적절히 사용하면 메모리 사용량을 크게 줄일 수 있지만, 다음과 같이 주의할 점이 있다.
- 인터프리터는 상속된 __slots__ 속성을 무시하므로 각 클래스마다 다시 정의해야 한다.
- __dict__ 를 __slots__ 에 추가하지 않는 한 객체는 __slots__ 에 나열된 속성만 가질 수 있다.
- 그러나 __dict__ 를 __slots__ 에 추가하면 메모리 절감 효과가 반감될 수 있다.
- __weakref__ 를 __slots__ 에 추가하지 않으면 객체가 약한 참조의 대상이 될 수 없다.
9.9 클래스 속성 오버라이드
클래스 속성을 객체 속성의 기본값으로 사용하는 것은 파이썬의 독특한 특징이다. Vector2d 클래스는 typecode 라는 클래스 속성이 있다. Vector2d 객체가 생성될 때, 그들 자신의 typecode 속성을 가지고 생성된 것은 아니므로, self.typecode 는 기본적으로 Vector2d.typecode 클래스 속성을 가져온다.
존재하지 않는 객체 속성에 값을 저장하면, 새로운 객체 속성을 생성하고 동일한 이름의 클래스 속성은 변경하지 않는다. 그 후부터는 객체가 self.typecode 를 읽을 때 객체 자체의 typecode 를 가져오므로, 동일한 이름의 클래스 속성을 가리게 된다. 즉, 각 객체가 서로 다른 typecode 를 커스터마이징할 수 있게 된다.
v1 = Vector2d(1.1, 2.2)
dumpd = bytes(v1)
print(dumpd)
> b'd\x9a\x99\x99\x99\x99\x99\xf1?\x9a\x99\x99\x99\x99\x99\x01@'
print(len(dumpd))
> 17
v1.typecode = 'f'
dumpf = bytes(v1)
print(dumpf)
> b'f\xcd\xcc\x8c?\xcd\xcc\x0c@'
print(len(dumpf))
> 9
print(Vector2d.typecode)
> d
- Vector2d.typecode 는 변경되지 않았으며, v1 객체의 typecode 만 'f' 가 된다.
클래스 속성을 커스터마이즈할 때는 클래스를 상속하는 것이 일반적인 방식이다.
class ShortVector2d(Vector2d):
typecode = 'f'
sv = ShortVector2d(1/11, 1/27)
print(bytes(sv))
> ShortVector2d(0.09090909090909091, 0.037037037037037035)
print(len(bytes(sv)))
> 9
이 예제를 통해서 Vector2d.__repr__() 에서 class_name 을 하드코딩하지 않고, type(self).__name__ 에서 읽어오는 이유를 알 수 있다. 하드코딩 했을 경우, 서브클래스의 __repr_() 메서드도 변경해야 했을 것이다.
9.10 요약
Simple is better than complex
파이썬스러운 객체는 요구사항을 만족하는 한 가장 단순해야 하며, 언어 기능을 모두 갖출 필요는 없다.
Vector2d 클래스를 개선하면서 여러 매직 메서드를 구현하였다.
- 문자열/바이트로 표현하는 모든 메서드 : __repr__(), __str__(), __format__(), __bytes__()
- 객체를 숫자로 변환하는 여러 메서드 : __abs__(), __bool__(), __hash__()
- bytes 로 변환하고 해시할 수 있게 해주는 메서드 : __eq__(), __hash__()
포맷 명시 간이 언어는 __format__() 메서드를 구현해서 확장할 수 있다. __format__() 메서드는 format(obj, format_spec) 내장 함수의 format_spec 이나 str.format() 메서드에 사용되는 문자열 안에 있는 '{:<format_spec>}' 치환 필드를 파싱한다.
Vector2d를 해시 가능하게 만들기 위해 준비하면서, x와 y 속성을 비공개로 구현하고 읽기 전용 프로퍼티로 공개함으로써 실수로 값을 변경하지 못하도록 불변형으로 만들었다. 그러고 나서 객체 속성들의 해시를 XOR 하는 권장 기법을 이용해서 __hash__() 메서드를 구현했다.
또한 메모리 절약과 Vector2d 에서 __slots__ 속성을 선언할 때 주의해야 할 점을 확인했다. __slots__ 는 사용하기 까다로우므로 아주 많은 (수백만 개 이상) 객체를 다룰 때만 사용할 가치가 있다.
마지막으로 self.typecode 객체 속성을 이용하여 클래스 속성을 오버라이드 하였는데, 객체 속성을 통해 오버라이드하는 방법과 클래스 상속을 통해 서브클래스 수준에서 덮어쓰는 방법을 구현했다.
'python' 카테고리의 다른 글
Architecture Patterns with Python(10장) (0) | 2021.12.17 |
---|---|
Architecture Patterns with Python(9장) (0) | 2021.12.11 |
Architecture Patterns with Python(8장) (0) | 2021.12.04 |
Fluent Python (챕터 8) - 객체 참조, 가변성, 재활용 (0) | 2021.11.19 |
Architecture Patterns with Python(6장) (0) | 2021.11.12 |