본문 바로가기
프로그래밍/Python

[Python] A Pythonic Object

by 별준 2022. 3. 19.

References

  • Fluent Python

Contents

  • Object Representation
  • An Alternative Constructor
  • @classmethod와 @staticmethod
  • Formatting Specification Mini-Language
  • A Hashable User-Defined Object
  • 'Private' and 'Protected' Attributes
  • __slots__
  • Overriding Class Attributes

파이썬의 Data Model 덕분에 사용자가 정의한 타입도 내장된 타입처럼 자연스럽게 동작할 수 있습니다. 그리고 상속하지 않고도 duck typing 메커니즘을 통해 이 모든 것이 가능합니다. 단지 객체에 필요한 메소드를 구현하면 기대한 대로 동작합니다.

 

이번 포스팅에서는 실제 파이썬 객체처럼 동작하는 사용자 정의 클래스를 구현할 예정입니다. 아마도 여기서 살펴 볼 많은 스페셜 메소드들이 실제 어플리케이션에서 필요없을 수 있습니다. 하지만 라이브러리나 프레임워크를 개발한다면, 클래스를 사용하는 사용자들이 파이썬이 제공하는 클래스처럼 동작하기를 기대할 수 있으며, 이를 위해서는 파이썬다운(pythonic) 객체를 구현해야 합니다.

 

 


Object Representations

모든 객체지향 언어에는 어떠한 객체로부터 문자열 표현을 얻는 표준 방법이 적어도 하나 이상 가지고 있습니다. 파이썬에는 다음의 2가지 방법이 존재합니다.

  • repr()
    객체를 개발자가 보고자하는 형태로 표현한 문자열로 반환합니다. 이것은 파이썬 콘솔이나 디버거로 객체를 볼 때 얻는 것입니다.
  • str()
    객체를 사용자가 보고자 하는 형태로 표현한 문자열로 반환합니다. 이는 개체를 print() 할 때 얻는 것입니다.

repr()와 str() 메소드를 지원하려면 __repr__과 __str__ 스페셜 메소드를 구현해야 합니다.

 

객체를 표현하는 다른 방법으로 __bytes__와 __format__이라는 두 개의 스페셜 메소드가 더 있습니다. __bytes__는 __str__과 비슷하지만 bytes() 메소드에 의해 호출되어 객체를 바이트 시퀀스로 표현합니다. __format__은 f-string, 내장 함수 format(), 그리고 str.format() 메소드에 의해 사용됩니다. 이들은 객체를 표현하는 문자열을 얻기 위해서 스페셜 포맷팅 코드를 사용하여 obj.__format__(format_spec)을 호출합니다.

 


Vector Class

객체 표현을 생성하는 메소드들을 살펴보기 위해서, 2차원 벡터를 표현하는 클래스를 구현해나가보도록 하겠습니다.

우선 2차원 벡터 클래스 Vector2d는 다음과 같은 기본적인 동작은 다음과 같습니다.

위와 같이 동작하는 Vector2d 클래스는 아래 코드처럼 구현됩니다. 여기서는 이 클래스를 사용하는 개발자가 기대하는 연산들을 제공하기 위해서 여러 스페셜 메소드를 구현하고 있습니다.

from array import array
import math

class Vector2d:
    # typecode는 Vector2d와 bytes 간의 변환에 사용하는 클래스 속성
    typecode = 'd'

    def __init__(self, x=0, y=0):
        # x,y를 float로 변환하면 부적절한 인수로 생성하는 것을 조기에 방지할 수 있음
        self.x = float(x)
        self.y = float(y)
    
    def __iter__(self):
        # __iter__ 구현하면 Vector2d를 반복할 수 있게됨
        # 따라서 x, y = my_vector 문장으로 unpack할 수 있음
        # 이 메소드는 제너레이터 표현식을 이용해 요소를 차례대로 하나씩 생성
        return (i for i in (self.x, self.y))
    
    def __repr__(self):
        # __repr__는 {!r}을 각 요소에 repr()을 호출해서 반환된 문자열을 치환해 문자열을 생성
        # Vector2d가 iterable하므로, *self는 format()에 x와 y 속성을 제공함
        class_name = type(self).__name__
        return '{}({!r}, {!r})'.format(class_name, *self)
    
    def __str__(self):
        # iterable Vector2d에서 튜플을 만들어 순서쌍으로 출력하도록 함
        return str(tuple(self))

    def __bytes__(self):
        return (bytes([ord(self.typecode)]) + # bytes를 생성하기 위해 typecode를 bytes로 변환
                bytes(array(self.typecode, self))) # 객체를 반복해서 배열을 생성

    def __eq__(self, other):
        # 모든 속성을 비교하기 위해 피연산자로부터 튜플을 생성.
        # 다만 문제가 있는데, 동일한 숫자값을 가진 어떠한 iterable 객체도 Vector2d와 비교하면
        # True를 반환(ex, Vector2d(3, 4) == [3, 4])
        return tuple(self) == tuple(other)

    def __abs__(self):
        # 벡터의 크기는 sqrt(x*x + y*y). 이를 계산하고 반환
        return math.hypot(self.x, self.y)
    
    def __bool__(self):
        # __bool__은 abs(self)를 사용해서 벡터의 크기를 계산하고 boolean 타입으로 변환
        # 0.0은 False, 그 외의 값은 True
        return bool(abs(self))

 

이렇게 구현된 클래스에서 기본적은 메소드는 모두 구현했지만, 여기서 bytes()로 생성한 바이너리 표현에서 다시 Vector2d 객체를 만드는 메소드가 빠져있습니다.

 

 


An Alternative Contsructor

위에서 구현한 Vector2d 클래스에서 객체를 bytes로 변환하는 메소드가 있으니, 당연히 bytes를 Vector2d로 변환하는 메소드도 있어야 할 것입니다. 비슷하게 구현된 것을 찾아보면, 표준 라이브러리에서 frombytes()라는 클래스 메소드를 가진 array.array가 이 상황에 딱 맞는 것 같습니다. 이 이름과 기능을 이용해서 Vector2d의 클래스 메소드로 추가해보도록 하겠습니다.

@classmethod # classmethod 데코레이터는 이 메소드가 클래스에서 직접 호출될 수 있도록 해줌
def frombytes(cls, octets): # self 매개변수가 없습니다. 대신 클래스 자신이 cls 매개변수로 전달됩니다.
    # 첫 번째 바이트에서 typecode를 읽음
    typecode = chr(octets[0])
    # octets 바이너리 시퀀스로부터 memoryview를 생성하고, typecode를 이용해 타입 변환
    memv = memoryview(octets[1:]).cast(typecode)
    # cast()가 반환한 memoryview를 언패킹해서 생성자에 필요한 인수로 전달
    return cls(*memv)

여기서 사용한 @classmethod 데코레이터는 파이썬의 고유한 기능입니다. 이에 대해서 아래에서 간단히 알아보겠습니다.

 


@classmethod와 @staticmethod

파이썬 튜토리얼에서는 @classmethod와 @staticmethod 데코레이터에 대해 설명하지 않습니다. 자바와 같은 언어로 객체지향 개념을 배웠다면 이 데코레이터가 파이썬에 있는 이유가 궁금할 수 있습니다.

 

먼저 @classmethod에 대해서 살펴보겠습니다. 위에서 구현한 frombytes 메소드를 살펴보면 @classmethod 데코레이터는 객체가 아닌 클래스에 연산을 수행하는 메소드를 정의한다는 것을 알 수 있습니다. @classmethod는 메소드가 호출되는 방식을 변경해서 클래스 자체를 첫 번째 인수로 받게 만들며 frombytes()와 같은 alternative 생성자를 구현하기 위해 주로 사용됩니다. frombytes() 메소드의 마지막 문장에서 cls(*memv)는 객체를 생성하기 위해 cls 인수를 이용해서 실제 클래스의 생성자를 호출합니다. 관습적으로 cls를 클래스 메소드의 첫 번째 매개변수명으로 사용하지만, 어떠한 이름으로 사용해도 무관합니다.

 

반대로 @staticmethod 데코레이터는 메소드가 특별한 첫 번째 인수를 받지 않도록 메소드를 변경합니다. 본질적으로 static 메소드는 모듈 대신 클래스 본체 안에 정의한 평범한 함수일 뿐입니다. 아래 예제 코드는 @classmethod와 @staticmethod 데코레이터의 동작을 비교해서 보여줍니다.

@classmethod 데코레이터는 유용하게 사용되는 것이 확실하지만, @staticmethod 데코레이터는 사실 사용되는 경우가 잘 없는 듯합니다. 클래스와 함께 동작하지 않는 함수를 정의하려면, 단지 함수를 모듈에 정의하면 됩니다. 아마 함수가 클래스에 영향을 미치지는 않지만, 그 클래스와 밀접히 연관되어 있어서 클래스 코드에 가까운 곳에 두고 싶을 수 있지만, 그런 경우에는 클래스의 바로 앞이나 뒤에서 함수를 정의하면 됩니다.

 

간단하게 @classmethod와 @staticmethod를 살펴봤습니다. 이제 다시 Vector2d 클래스로 돌아와서 출력 포맷을 지원하는 방법에 대해 살펴보겠습니다.

 


Fomatted Displays

f-string, format() built-in 함수, 그리고 str.format() 메소드는 실제 포맷 작업을 __format__(format_spec) 메소드에 위임합니다. format_spec은 format specifier로서, 다음 두 가지 방법 중 하나를 통해 지정합니다.

  • format(my_obj, format_spec)의 두 번째 인수
  • str.format()에 사용된 포맷 문자열 안에 {}로 구분한 대체 필드(replacement field) 안에서 콜론 뒤의 문자열

예를 들면, 다음과 같이 사용합니다.

여기서 두 번째(str.format())과 세 번째(f-string)를 살펴보겠습니다. '{0.mass:5.3e}'와 같은 포맷 문자열은 실제로 두 가지 표기법을 사용합니다. 콜론의 왼쪽에 있는 '0.mass'는 Replace Field(대체 필드)에서 필드명(field_name)에 해당하는 부분이며, 콜론의 오른쪽에 있는 '5.3e'가 formatting specifier 입니다. formatting specifier에 사용된 표기법을 Format Specification Mini-language(link)라고 합니다.

 

몇몇 내장 타입은 자신만의 고유한 표현 코드를 가지고 있습니다. 예를 들어, int형의 경우 이진수를 나타내는 'b', 16진수를 나타내는 'x' 코드를 지원하며, float형의 경우 고정소수점을 나타내는 'f', 백분율을 나타내는 '%' 코드를 지원합니다.

각 클래스가 format_spec 인수를 자신이 원하는 대로 해석해서 format specification mini-language를 확장할 수 있습니다. 예를 들어, datetime 모듈의 클래스들은 자신의 __format__() 메소드에서 strftime() 함수와 동일한 포맷 코드를 사용합니다. 다음 코드에서 format() 내장 함수와 str.format() 메소드를 실행하는 예를 살펴보겠습니다.

만약 클래스에서 __format__() 메소드를 정의하지 않으면, object에서 상속받은 메소드가 str(my_object)를 반환합니다. Vector2d 클래스는 __str__()을 정의하고 있으므로, 다음과 같이 실행됩니다.

그러나 이때 formatting specifier을 사용하면 object.__format__()은 TypeError를 발생시킵니다.

Vector2d 클래스 자체의 format specification mini-language를 구현하면 이 문제를 해결할 수 있습니다. 먼저 사용자가 제공하는 format specifier를 벡터의 각 float 형 요소를 포맷하기 위한 것이라고 가정하겠습니다. 즉, 다음의 결과가 나오기를 원한다고 가정합니다.

위와 같이 출력하려면 다음과 같이 Vector2d 클래스에 __format__() 메소드를 구현합니다.

def __format__(self, fmt_spec=''):
    # 벡터의 각 요소에 fmt_spec 포맷을 적용하기 위해 format() 내장 함수를 호출하고,
    # 포맷된 문자열의 iterator를 생성
    components = (format(c, fmt_spec) for c in self)
    # 포맷된 문자열을 '(x, y)' 형식으로 만든다
    return '({}, {})'.format(*components)

 

이제 Vector2d의 mini-language에 포맷 코드를 추가해보도록 하겠습니다. format specifier가 'p'로 끝나면 벡터를 극좌표 <r, \(\theta\)>로 표현합니다. 여기서 r은 벡터의 크기, 세타는 라디안으로 표현된 각을 나타냅니다. 'p' 앞에 오는 나머지 포맷 명시자는 이전과 동일하게 사용됩니다.

포맷 코드를 추가할 때 다른 자료형에서 사용하는 코드와 중복되지 않는 코드를 선택하는 것이 좋습니다. 공식 문서를 보면, 정수형은 'bcdoxXn'을, 실수형은 'eEfFgGn%'를, 문자열을 's'를 사용합니다. 따라서 여기서 극좌표에 대한 포맷 코드로 'p'를 선택했습니다.

 

먼저 극좌표를 생성하는 함수를 작성합니다. 크기를 생성하는 __abs__() 메소드는 이미 있으며, 각을 구하기 위해 math.atan2() 함수를 사용하는 angle() 메소드는 다음과 같이 간단하게 구현할 수 있습니다.

def angle(self):
    return math.atan2(self.y, self.x)

 

필요한 코드는 모두 갖추었으니, 이제 __format__() 메소드가 극좌표를 생성하도록 수정해보도록 하겠습니다.

def __format__(self, fmt_spec=''):
    if fmt_spec.endswith('p'): # format specifier가 'p'로 끝나면 극좌표를 사용
        fmt_spec = fmt_spec[:-1] # 마지막에 있는 'p'를 떼어냄
        coords = (abs(self), self.angle())
        outer_fmt = '<{}, {}>'
    else:
        coords = self # 'p'가 없다면 self의 x,y로 직교좌표를 만든다
        outer_fmt = '({}, {})'
    components = (format(c, fmt_spec) for c in coords)
    return outer_fmt.format(*components)

이렇게 구현하면 이제 Vector2d는 다음과 같이 실행됩니다.

 

위에서 설명한 것처럼 사용자 정의 타입을 지원하기 위해 formatting specification mini-language를 확장하는 것은 어렵지 않습니다.

 


A Hashable Vector2d

Vector2d를 해시 가능하도록 만들면 Vector2d의 집합을 만들거나 Vector2d를 딕셔너리의 키로 사용할 수 있습니다. 그러나 해시 가능하게 만들기 전에 Vector2d를 불변형으로 만들어야 합니다.

 

지금까지 정의한 Vector2d는 해시할 수 없습니다. 그러므로 집합 안의 항목으로 사용할 수 없습니다.

Vector2d를 해시 가능하도록 만들려면 __hash__() 메소드를 구현해야 합니다. __eq__() 메소드도 필요하지만, 이 메소드는 이미 구현했습니다. 그리고 해시 가능하려면 Vector2d 객체를 불변형으로 만들어야 합니다.

 

현재 구현된 Vector2d가 불변형이 아니므로 v1.x = 7과 같이 속성을 변경하는 코드를 입력할 수 있습니다.

하지만, 우리가 원하는 동작은 다음과 같습니다.

위와 같은 동작을 위해서는 먼저 아래 코드처럼 x와 y 요소를 읽기 전용 속성으로 만들어야 합니다.

class Vector2d:
    typecode = 'd'

    def __init__(self, x=0, y=0):
        # 2개의 언더바(__)로 시작하도록 하여 속성을 비공개로 만든다
        self.__x = float(x)
        self.__y = float(y)

    # @property 데코레이터는 프로퍼티의 getter 메소드를 나타낸다
    @property
    def x(self): # 노출시키고자 하는 public 프로퍼티 이름을 따라 메소드의 이름 지정
        return self.__x # 단순히 self.__x를 반환
    
    @property
    def y(self): # y 프로퍼티도 x와 동일하게 정의
        return self.__y
    
    def __iter__(self):
        # x와 y를 읽기만 하는 다른 메소드들은 self.x, self.y를 통해 읽으므로
        # __x, __y로 변경하지 않아도 됨
        return (i for i in (self.x, self.y))
    
    # ... 나머지 메소드 생략

이렇게 수정하면 Vector2d를 불변형으로 만든 것입니다. 이제 __hash__() 메소드를 구현해보겠습니다. __hash__() 메소드는 int형을 반환해야 하며, 동일하다고 판단되는 객체는 동일한 해시값을 가져야 하므로 __eq__() 메소드가 사용하는 객체의 속성을 이용해서 해시를 계산하는 것이 이상적입니다. __hash__() 스페셜 메소드 문서(link)에서는 요소의 해시에 비트 단위 XOR 연산자 '^'를 사용하는 것을 권장하므로, 여기서도 이 방법을 따르도록 하겠습니다.

구현해야 하는 Vector2d.__hash__() 메소드는 다음과 같이 아주 간단합니다.

def __hash__(self):
    return hash(self.x) ^ hash(self.y)

__hash__() 메소드를 추가해서 Vector2d를 해시 가능한 클래스로 만들었으므로, 다음과 같이 Vector2d를 사용할 수 있습니다.

Hashable 타입을 만들기 위해 반드시 프로퍼티를 구현하거나 객체 속성을 보호할 필요는 없습니다. 단지 __hash__()와 __eq__() 메소드를 제대로 구현하기만 하면 됩니다. 그러나 객체의 해시값이 변하면 안되므로 읽기 전용 프로퍼티를 사용합니다.

 

적절한 스칼라 값을 가진 자료형을 만들 때는 경우에 따라 자료형을 강제 변환하기 위해 사용되는 int()와 float()가 호출하는 __int__()와 __float__() 메소드를 구현하는 것도 좋습니다. 내장된 complex() 생성자를 지원하기 위한 __complex__() 메소드도 있습니다.

 

지금까지 구현한 Vector2d 클래스의 전체 코드는 다음과 같습니다.

from array import array
import math

class Vector2d:
    typecode = 'd'

    def __init__(self, x=0, y=0):
        # 2개의 언더바(__)로 시작하도록 하여 속성을 비공개로 만든다
        self.__x = float(x)
        self.__y = float(y)

    # @property 데코레이터는 프로퍼티의 getter 메소드를 나타낸다
    @property
    def x(self): # 노출시키고자 하는 public 프로퍼티 이름을 따라 메소드의 이름 지정
        return self.__x # 단순히 self.__x를 반환
    
    @property
    def y(self): # y 프로퍼티도 x와 동일하게 정의
        return self.__y
    
    def __iter__(self):
        # x와 y를 읽기만 하는 다른 메소드들은 self.x, self.y를 통해 읽으므로
        # __x, __y로 변경하지 않아도 됨
        return (i for i in (self.x, self.y))
    
    def __repr__(self):
        # __repr__는 {!r}을 각 요소에 repr()을 호출해서 반환된 문자열을 치환해 문자열을 생성
        # Vector2d가 iterable하므로, *self는 format()에 x와 y 속성을 제공함
        class_name = type(self).__name__
        return '{}({!r}, {!r})'.format(class_name, *self)
    
    def __str__(self):
        # iterable Vector2d에서 튜플을 만들어 순서쌍으로 출력하도록 함
        return str(tuple(self))

    def __bytes__(self):
        return (bytes([ord(self.typecode)]) + # bytes를 생성하기 위해 typecode를 bytes로 변환
                bytes(array(self.typecode, self))) # 객체를 반복해서 배열을 생성

    def __eq__(self, other):
        # 모든 속성을 비교하기 위해 피연산자로부터 튜플을 생성.
        # 다만 문제가 있는데, 동일한 숫자값을 가진 어떠한 iterable 객체도 Vector2d와 비교하면
        # True를 반환(ex, Vector2d(3, 4) == [3, 4])
        return tuple(self) == tuple(other)
    
    def __hash__(self):
        return hash(self.x) ^ hash(self.y)

    def __abs__(self):
        # 벡터의 크기는 sqrt(x*x + y*y). 이를 계산하고 반환
        return math.hypot(self.x, self.y)
    
    def __bool__(self):
        # __bool__은 abs(self)를 사용해서 벡터의 크기를 계산하고 boolean 타입으로 변환
        # 0.0은 False, 그 외의 값은 True
        return bool(abs(self))

    def angle(self):
        return math.atan2(self.y, self.x)
    
    def __format__(self, fmt_spec=''):
        if fmt_spec.endswith('p'): # format specifier가 'p'로 끝나면 극좌표를 사용
            fmt_spec = fmt_spec[:-1] # 마지막에 있는 'p'를 떼어냄
            coords = (abs(self), self.angle())
            outer_fmt = '<{}, {}>'
        else:
            coords = self # 'p'가 없다면 self의 x,y로 직교좌표를 만든다
            outer_fmt = '({}, {})'
        components = (format(c, fmt_spec) for c in coords)
        return outer_fmt.format(*components)
    
    @classmethod
    def frombytes(cls, octets):
        typecode = chr(octets[0])
        memv = memoryview(octets[1:]).cast(typecode)
        return cls(*memv)

 


 

'Private' and 'Protected' Attributes in Python

파이썬에는 private modifier가 있는 자바와 달리 private 변수를 생성할 수 있는 방법은 없지만, 서브클래스에서 'private' 성격의 속성을 실수로 변경하지 못하도록 하는 간단한 메커니즘은 있습니다.

 

다음과 같은 시나리오를 고려해보겠습니다. 클래스 외부에 노출시키지 않고 내부적으로 개의 상태를 나타내는 mood 객체 속성을 사용하는 Dog라는 클래스가 있습니다. 우리는 Dog를 상속해서 Beagle이라는 클래스를 정의해야 합니다. 이때 Dog의 mood라는 속성이 있는지 모르고 Beagle에서 mood라는 속성을 정의하는 경우 name clash가 발생됩니다. 그러면 Dog에서 상속된 메소드가 사용하는 mood 속성값을 엉뚱하게 변경하게 됩니다. 이러한 상황은 디버깅으로 발견하기도 힘듭니다.

 

이런 상황을 방지하기 위해 속성명을 __mood처럼 두 개의 언더바로 시작하거나 언더바없이 하나의 언더바로 끝나도록 정의하면, 파이썬은 언더바와 클래스명을 변수명 앞에 붙여 객체의 __dict__에 저장합니다. 따라서 Dog 클래스의 경우 __mood는 _Dog__mood가 되고 Beagle 클래스의 경우 _Beagle__mood가 됩니다. 이러한 파이썬 언어의 기능을 name mangling이라고 합니다.

 

다음 예제 코드는 위에서 정의한 Vector2d 클래스의 __dict__ 속성을 보여줍니다.

private name이 어떻게 만들어지는지 아는 사람은 위 예제 코드의 마지막 문장에서 보여주는 것처럼 비공개 속성을 직접 읽을 수 있습니다. 사실 이 기법은 디버깅과 serialization에 유용하게 사용됩니다. 그리고 v1._Vector2d__x = 7과 같이 작성하면 Vector2d의 비공개 요소에 직접 값을 할당할 수도 있습니다.

 

모든 파이썬 개발자가 self.__x와 같은 변수명을 좋아하는 것은 아니고, 이런 구문을 피하고 self._x처럼 언더바 하나를 붙여 속성을 보호하는 것을 좋아하는 개발자도 있습니다. 이중 언더바 맹글링을 비판하는 사람들은 속성이 충돌되는 문제를 명명 관례(naming convention)으로 해결해야 한다고 제안합니다. (절대 언더바 두 개를 붙여서 사용하지 말라는 사람도 있습니다.)

 

속성명 앞에 언더바 하나를 붙이더라도 파이썬 인터프리터가 별도로 특별하게 처리하는 것은 아니지만, 클래스 외부에서 그런 속성에 접근하지 않는 것은 파이썬 개발자 사이에 일종의 금기처럼 자리잡혀 있다고 합니다. 언더바 하나를 앞에 붙여서 표시한 객체의 프라이버시를 졵중하는 것은 모든 글자를 대문자로 사용하는 상수를 표현하는 것과 유사합니다.

 

파이썬 문서 일부에서는 언더바 하나로 시작하는 속성을 'protected' 속성이라고 부르기도 합니다. self._x 형태의 속성을 '보호'하는 관례는 대부분 개발자가 보편적으로 따르고 있지만, 이런 속성을 protected 속성이라고 부르는 것이 보편적이지는 않습니다. 이런 속성을 'private' 속성이라고 부르는 개발자도 있습니다.

 

정리하자면 Vector2d 요소는 'private' 요소이며, Vector2d 객체는 'immutable'입니다(파이썬에는 private 속성과 불변 속성을 정의하는 진정한 방법은 없습니다).

 


Saving Memory with __slots__

기본적으로 파이썬은 객체 속성을 각 객체 안의 __dict__라는 딕셔너리형 속성에 저장합니다. 딕셔너리는 빠른 접근 속도를 제공하기 위해 내부에 해시 테이블을 유지하므로 메모리 사용량 부담이 상당히 큽니다.

하지만, 속성 이름의 시퀀스를 담고 있는 __slots__ 클래스 속성을 정의하면, 파이썬은 인스턴스 속성을 위한 다른 저장 모델을 사용합니다. __slots__에 명명된 속성은 숨겨진 배열이나 참조에 저장되며 이는 dict보다 메모리를 덜 사용합니다.

 

간단한 예제를 통해서 살펴보겠습니다.

__slots__은 반드시 클래스가 생성될 때 있어야 합니다. 클래스가 생성된 후에 추가하거나 변경되는 것은 아무런 영향도 끼치지 못합니다. 속성의 이름은 tuple이나 list에 저장되는데, 변경할 수 없도록 만들기 위해서 tuple을 권장하는 편입니다.

 

위 코드에서 Pixel의 인스턴스를 생성하는데, 인스턴스에서 __slots__의 영향을 확인할 수 있습니다. 첫 번째는 Pixel의 인스턴스는 __dict__를 가지고 있지 않습니다. 두 번째는 p.x와 p.y 속성은 값을 설정할 수 있지만, __slots__에 없는 속성에 값을 설정하려고 시도하면 AttributeError가 발생합니다.

 

이번에는 Pixel의 서브클래스를 정의해서 __slots__이 각 클래스에만 개별적으로 영향을 미친다는 것을 보도록 하겠습니다.

위에서 정의된 OpenPixel은 소유하는 속성이 없습니다. 이 클래스로 생성된 인스턴스는 __dict__를 가지고 있는 것을 확인할 수 있습니다. 그리고 베이스 클래스 Pixel의 __slots__에 명명된 x 속성에 값을 설정한다면, 이것은 인스턴스의 __dict__에 저장되지 않는 것을 볼 수 있습니다. 즉, 인스턴스의 숨겨진 배열의 참조에 저장되었다는 것을 의미합니다. 만약 __slots__에 명명되지 않은 속성을 설정하게 되면, 이 속성은 __dict__에 저장됩니다.

 

만약 OpenPixel에 __slots__ = ()을 선언한다면, 서브클래스의 인스턴스는 __dict__를 가지지 않을 것이고 오직 베이스 클래스의 __slots__에 명명된 속성만 받을 수 있습니다.

 

만약 서브클래스에 추가 속성을 가지고 싶다면, 그 이름을 __slots__에 지정해주면 됩니다.

 

하지만, 이렇게 절약한 메모리를 낭비할 수도 있습니다. '__dict__' 문자열을 __slots__ 리스트에 추가하면 __slots__에서 지정한 속성들을 각 객체의 튜플에 저장하지만, 동적으로 속성을 생성할 수도 있게 해줍니다. 이때 동적으로 생성한 속성은 __dict__에 저장됩니다. 물론 __dict__를 __slots__ 안에 넣으면, 각 객체의 정적 및 동적 속성의 수와 사용법에 따라 달라지기는 하지만 __slots__을 사용하는 의미를 상실하게 됩니다.

 

그리고 각 객체마다 유지하고 싶은 또 다른 특별한 속성인 __weakref__가 있습니다. 만약 객체가 약한 참조를 지원하려면 __weakref__ 속성이 필요합니다. 이 속성은 자용자 정의 클래스에 기본적으로 존재합니다. 그러나 클래스가 __slots__를 정의하고 이 클래스의 객체를 약한 참조의 대상이 되게 하려면 '__weakref__'를 __slots__ 리스트에 추가해주어야 합니다.

 

이제 __slots__을 위에서 정의한 Vector2d 클래스에 추가하면 어떤 효과가 있는지 살펴보겠습니다.

 

Simple Measure of __slots__ Savings

다음 코드는 Vector2d에 __slots__을 구현한 것입니다. 다른 메소드들은 생략하였습니다.

class Vector2d:
    __slots__ = ('__x', '__y')
    
    typecode = 'd'
    
    # ... 나머지 메소드 생략

 

천만 개를 가진 리스트를 만드는 스크립트(link)를 실행하여, 비교한 결과는 다음과 같습니다. 첫 번째 실행은 __slots__이 없는 Vector2d, 두 번째 실행은 __slots__을 추가한 Vector2d입니다. 스크립트에서 사용하는 resource가 윈도우에서는 사용이 안되는 것 같아, 교재의 결과를 첨부합니다.

인스턴스 __dict__를 사용할 때 스크립트의 램 사용량이 1.55GB로 증가했지만, __slots__ 속성을 사용할 때는 551MB로 줄어들었습니다. __slots__을 사용한 버전이 더 빠릅니다.

수백만 개의 숫자 데이터를 처리하는 경우에는 Numpy를 사용하느 것이 좋습니다. Numpy는 메모리를 효율적으로 사용할 뿐만 아니라 숫자 처리에 상당히 최적화된 함수들을 가지고 있으며, 그중 배열 전체를 한꺼번에 처리하는 함수도 많이 있습니다.

 

Summarizing The Issues with __slots__

__slots__ 클래스 속성을 적절하게 사용하면 사용하면 메모리 사용량을 엄청나게 줄일 수 있지만, 다음과 같이 주의할 점이 몇 가지 있습니다.

  • 인터프리터는 상속된 __slots__ 속성을 무시하므로 각 클래스마다 __slots__ 속성을 다시 정의해야 한다
  • '__dict__'를 __slots__에 추가하지 않은 객체는 __slots__에 나열한 속성만 가질 수 있다(단, 추가하면 메모리 절감 효과가 반감될 수 있다)
  • __slots__을 사용하는 클래스는 명시적으로 '__dict__'를 __slots__에 추가하지 않으면 @cached_property 데코레이터를 사용할 수 없다
  • '__weakref__'를 __slots__에 추가하지 않으면 객체가 약한 참조의 대상이 될 수 없다

 


Overriding Class Attributes

클래스 속성을 인스턴스 속성의 기본값으로 사용하는 것은 파이썬의 독특한 특징입니다. Vector2d 클래스에는 typecode라는 클래스 속성이 있습니다. 이 속성은 __bytes__() 메소드에서 두 번 사용되는데, 단지 self.typecode로 그 값을 읽었습니다. Vector2d 인스턴스가 그들 자신의 typecode 속성을 가지고 생성된 것이 아니므로, self.typecode는 기본적으로 Vector2d.typecode 클래스 속성을 가져옵니다.

 

그러나 존재하지 않은 인스턴스 속성에 값을 저장하면, 새로운 인스턴스 속성을 생성하고(ex, typecode 인스턴스 속성) 동일한 이름의 클래스 속성은 변경하지 않습니다. 그 후부터는 인스턴스가 self.typecode를 읽을 때 인스턴스 자체의 typecode를 가져오므로, 동일한 이름의 클래스 속성을 가리게 됩니다. 그러면 각 객체가 서로 다른 typecode를 갖도록 커스터마이즈할 수 있게 됩니다.

 

Vector2d.typecode의 기본값이 'd'이므로 객체를 bytes로 export할 때 Vector2d의 각 요소가 8바이트 double-precision 실수로 표현됩니다. 만약 Vector2d 인스턴스의 typecode를 'f'로 설정하면, 각 요소는 4바이트 single-precision 실수로 export됩니다. 다음 예제 코드를 살펴보겠습니다. (__slots__ 속성은 제거한 상태입니다.)

 

만약 클래스 속성을 변경하려면 클래스 정의에서 직접 바꿔야 하며, 인스턴스를 통해 변경하면 안됩니다. 다음과 같이 클래스 속성을 변경하면 모든 객체의 기본 typecode도 변경됩니다.

그러나 변경 의도를 명백히 보여주고 영구적으로 효과가 지속되는 파이썬에서 즐겨 사용하는 방법이 있습니다. 클래스 속성은 공개되어 있고 모든 서브클래스가 상속하므로, 클래스 데이터 속성을 커스터마이즈할 때는 클래스를 상속하는 것이 일반적입니다. Django 클래스 기반 뷰가 이 기법을 많이 사용합니다.

사용 방법은 다음 예제 코드를 살펴보겠습니다.

위 예제를 보면 Vector2d.__repr__()에서 class_name을 하드코딩하지 않고, 다음과 같이 type(self).__name__에서 읽어오는 이유를 알 수 있습니다.

def __repr__(self):
    class_name = type(self).__name__
    return '{}({!r}, {!r})'.format(class_name, *self)

class_name을 하드코딩했다면 단지 class_name을 변경하기 위해 ShortVector2d와 같은 Vector2d 클래스의 __repr__() 메소드도 변경해야 했을 것입니다. 인스턴스의 type에서 이름을 읽어오도록 만듦으로써 이 클래스를 상속하더라도 __repr__()을 안전하게 사용할 수 있습니다.

 


 

지금까지 이번 포스팅을 통해 스페셜 메소드 및 파이썬스러운(pythonic) 클래스를 생성하는 관례에 대해 알아봤습니다 !

부족한 내용이나 잘못된 내용이 있다면 언제든지 지적 부탁드립니닷.. !

댓글