본문 바로가기

진리는어디에/Python

[Python] 디스크립터(Descriptor)

이번 포스트에서는 파이썬의 디스크립터에 대해 배워보자. 디스크립터는 이전에 배웠던 classmethod, staticmethod, property 등을 구현 할 때 사용되는 중요한 파이썬 기능중에 하나다.

디스크립터 자체는 어렵지 않지만 각 개념들이 서로 꼬여있어 순서대로 차근 차긴 설명하기가 다소 어렵다. 처음에는 간단한 예제 부터 시작해 점차로 발전 시켜 나가는 형태로 설명이 앞뒤로 약간 왔다 갔다 할 수 있으니 집중해서 따라 오도록 하자. 그리고 포스트 끝에는 요약을 덧붙여 앞의 내용들을 다시 한번 상기 할 수 있도록 할 예정이니 맨 마지막은 꼭 읽어 보도록 하자.

지금 까지 강의들은 이런게 있다는 것만 알고 넘어가는 정도로 봐달라고 했는데, 이번 강의는 앞의 다른 강의들과는 다르게 처음 부터 끝까지 집중해서 볼것을 권한다. 피곤하겠지만 이번만 참아 달라.

디스크립터란?

디스크립터란 파이썬 핵심 기능 중에 하나로 이전 강의에 몇번 언급 되었던 classmethod, staticmethod, property 등을 구현할 때 사용 되는 기능이다.

디스크립터(descriptor)를 간단하게 한 줄로 요약하면,  "__get__, __set__ 또는 __delete__ 스페셜 메소드 중 한개 이상 구현 되어 있는 객체"를 말한다. 이렇게 구현된 디스크립터는 다른 객체의 속성으로 정의 될 수 있으며, 그 속성에 대한 읽기, 쓰기, 삭제 연산을 할 때 동작에 따라 각각 구현된 스페셜 메소드가 호출 된다.

디스크립터란?
__get__, __set__ 또는 __delete__ 중 한개 이상 구현 되어 있는 객체

나머지 추가적인 디스크립터의 특징이 있지만 한번에 설명하고 이해하기는 어려우므로 간단한 예제를 점점 발전 시키켜 가며 점점 디스크립터에 대해 알아가도록 하겠다.

Tip. 스페셜 메소드(special method)

스페셜 메소드는 매직 메소드라고도 불리는 미리 이름이 지정된 특수한 메소드들이다. [여기]에 스페셜 메소드에 대한 자세한 설명을 추가 했다. 

디스크립터 구현해보기

배움의 가장 빠른 방법 중의 하나는 직접 부딫혀 보는 것이다. 아래 예제를 점점 확장해가며 디스크립터에 대해 배워 보도록 하자.

class MyProperty :
    pass

class Point :
    def __init__(self) :
        self.x = 0
        self.y = MyProperty()

point = Point()
x = point.x
y = point.y

print(type(x))  # <class 'int'>
print(type(y))  # <class '__main__.MyProperty'>

Point 라는 클래스의 필드들의 타입을 출력해보는 간단한 예제다. 위 예제를 실행하면 x의 타입은 int, y의 타입은 MyProperty 객체라고 출력 되는 것을 확인 할 수 있다.

반응형

__get__() 메소드 정의하기

object.__get__(self, instance, owner=None)

__get__ 스페셜 메소드는 클래스의 프로퍼티(class property) 또는 클래스 인스턴스의 프로퍼티를 얻기 위해 호출 된다.

선택 인자인 ower는 __get__ 스페셜 메소드를 소유하고 있는 클래스이며, instance는 __get__ 스페셜 메소드가 객체를 통해 호출 되었을 때는 객체를 가리키는 인자가 넘어 오고, 클래스를 통해 호출(스태틱 필드 접근) 되면 None이 넘어 온다.

이 메소드는 값을 리턴하거나 그렇지 않은 경우 AttributeError 예외를 발생 시켜야 한다.

앞에서 디스크립터란 __get__나 __set__, __delete__ 중 한개 이상의 스페셜 메소드를 구현한 객체라고 했다. 그럼 이제 아래와 같이 MyProperty 클래스에 __get__ 스페셜 메소드를 추가하여 MyProperty를 디스크립터로 만들어 보자.

class MyProperty :
    def __get__(self, obj, objType = None) : # __get__ 스페셜 메소드 구현
        print('__get__ 실행 됨')
        return 20

class Point :
    def __init__(self) :
        self.x = 0
        self.y = MyProperty()
        
point = Point()
x = point.x
y = point.y

print(type(x)) # <class 'int'>
print(type(y)) # <class '__main__.MyProperty'>

예제를 실행 시켜 보면 __get__ 스페셜 메소드를 구현하기 전의 결과와 다른 것이 없다. 여기서 우리는 디스크립터의 특징 하나를 더 알아야 한다. 디스크립터는 스태틱 필드(static field)로 만든 경우만 동작한다. 이것은 '왜'라는 이유가 없다. 그렇게 해야만 동작하도록 정의 되어 있는 약속이다.

디스크립터는 스태틱 필드(static field)로 만든 경우만 동작한다

다시 Point 클래스의 y 필드를 스태틱 필드로 변경하고 예제를 다시 실행 시켜 보자.

class MyProperty :
    def __get__(self, instance, owner = None) :
        print('__get__ 실행 됨')
        return 20

class Point :

    y = MyProperty()   # y를 스태틱 필드로 변경
    
    def __init__(self) :
        self.x = 0
        # self.y = MyProperty()

point = Point()
x = point.x
# y = point.y
y = Point.y             # 스태틱 필드로 접근하도록 변경

print(type(x))          # <class 'int'>
print(type(y))          # <class 'int'> 로 변경 된것을 확인

위 예제를 실행 시키면 변수 y가 더 이상 MyProperty이 아니라 int로 변경된 것을 확인 할 수 있다. 이는 디스크립터로 정의 되어 있는 y 스태틱 필드에 접근하게 되면 y의 값을 바로 리턴하는 것이 아니라  __get__ 스페셜 메소드를 실행하고 그 결과를 돌려 주기 때문이다. 위 예제에서 __get__은 20을 리턴하므로 y는 int가 된다.

다시 위 예제로 돌아가 16라인을 살펴보자. 스태틱 필드로 선언 된 y에 접근하기 위해 person 객체로 접근하는 코드를 주석처리 하고 클래스 이름으로 접근하도록 변경했었다. 하지만 사실 디스크립터는 객체를 통한 접근과 클래스 이름을 통한 접근 모두를 허용한다.

디스크립터는 객체를 통한 접근과 클래스 이름을 통한 접근 모두를 허용한다

분명 스태틱 필드로 선언 되어 있지만 디스크립터의 경우 특별히 클래스 이름을 통한 접근 뿐만 아니라 객체를 통한 접근까지 지원한다. 아래 예제를 통해 사실을 확인 해보도록 하자.

class MyProperty :
    def __get__(self, instance, owner = None) :
        print(f'__get__(instance:{instance}, owner:{owner})')
        return 20

class Point :
    y = MyProperty()
    
    def __init__(self) :
        self.x = 0

point = Point()

y1 = point.y  # 객체를 통한 접근
y2 = Point.y  # 클래스 이름을 통한 접근

# OUTPUT :
# __get__(instance:<__main__.Point object at 0x000001D488772FA0>, owner:<class '__main__.Point'>)
# __get__(instance:None, owner:<class '__main__.Point'>)

위 예제를 실행하면 y필드에 접근 할 때, 객체를 통하든 클래스 이름을 통하든, 모두 __get__ 메소드를 호출하는 것을 확인 할 수 있다. 두 방식에서 차이는 객체를 통해 접근하는 경우 __get__메소드의 두 번째 인자인 instance로 호출하는 객체가 넘겨지고, 클래스 이름을 통해 접근하는 경우 None 넘어 온다는 것이다. 

두 번째 인자 instance는,
객체를 통해 호출 되었을 때는 객체를 가리키는 인자가 넘어 오고
클래스를 통해 호출 되면 None이 넘어 온다.

디스크립터 접근 방법은 뒤에 다시 필요해지는 내용이므로 잘 기억해 두도록 하자.

__set__() 메소드 정의하기

object.__set__(self, instance, value)

소유자 클래스의 인스턴스 instance 의 어트리뷰트를 새 값 value 로 설정할 때 호출 된다.

NOTE : __set__() 이나 __delete__()를 클래스에 추가하면 디스크립터 유형이 데이터 디스크립터(data descriptor)로 변경 된다(데이터 디스크립터에 대한 내용은 뒤에 설명 된다).

__get__에 대해 알아 보았으니 이번에는 __set__을 MyProperty에 추가하고 실행 시켜 보도록 하자.

class MyProperty :
    def __get__(self, instance, owner = None) :
        print(f'__get__(instance:{instance}, owner:{owner})')
        return 20
    
    def __set__(self, instance, value) :
        print(f'__set__(instance:{instance}, value:{value})')

class Point :
    y = MyProperty()
    
    def __init__(self) :
        self.x = 0
        
print(Point.__dict__)  # { ..., 'age': <__main__.MyProperty object at 0x0000015258582FD0>, ... }
Point.y = 10           # 클래스 이름으로 y 에 접근하여 10을 할당
print(Point.__dict__)  # { ..., 'age': 10, ... }

우리의 예상 대로라면 17라인에서 디스크립터 객체인 y에 10을 할당 했고, 방금 MyProperty 클래스에 추가한 __set__ 메소드가 호출 되어 '__set__ 어쩌고 저쩌고..'가 출력 되어야 했지만 아무런 텍스트도 출력 되지 않았다. 심지어 Person 타입의 __dict__를 확인했을 때, y는 MyProperty를 가리키고 있었지만, 18라인에서는 정수 10이 되어 있다.

??

결론을 먼저 말하자면 우리가 추가한 __set__ 메소드는 호출되지 않았다. 우리가 스태틱 필드 y에 10을 할당한 것은 MyProperty 객체를 10으로 덮어 써버릴 뿐이다.

앞에서는 __set__메소드를 구현하면 할당 할 때 대신 호출 된다고 했는데 이제와서 안된다니 이게 무슨 말일까? 이 부분이 필자가 가장 처음에 말했던 디스크립터를 설명하기 어려운 부분이다. 집중하고 잘 따라 오도록 하자.

__set__ 메소드를 제대로 사용하기 위해서는 먼저 디스크립터의 접근 방법과 디스크립터의 종류에 대해 알아야 한다. 디스크립터 접근 방법은 위에서 살펴 보았고 디스크립터의 종류에 대해 잠깐 살펴 보자.

디스크립터는 논-데이터 디스크립터(Non-Data Descriptor)와 데이터 디스크립터(Data Descriptor). 이렇게 두 가지 종류의 디스크립터가 있다.

  • 논-데이터 디스크립터(Non-Data Descriptor) : __get__ 메소드만 제공하는 디스크립터
    객체를 통한 디스크립터 접근과 클래스 이름을 통한 디스크립터 접근을 모두 지원한다.
  • 데이터 디스크립터(Data Descriptor) : __set__ 또는 __delete__ 메소드를 제공하는 디스크립터
    객체를 통한 디스크립터 접근만 허용한다.

다시 생각해보면 지극히 당연한 이야기다. 디스크립터 자체는 스태틱 필드지만 __set__을 통해 저장되는 데이터는 각 객체에 저장되므로 클래스 이름을 통해 접근해서 값을 설정하려고 할 때 어떤 객체의 값을 설정할지가 모호해 진다.

__set__과 __delete__ 메소드는 객체를 통해 접근 할 때만 호출 된다

객체를 통해 y에 새 값을 설정 하도록 코드를 변경하고 다시 실행하면 우리가 기대하던대로 __set__이 호출 되는 것을 확인 할 수 있다.

point = Point()
point.y = 10

# OUTPUT :
# __set__(instance:<__main__.Point object at 0x0000019899F73FA0>, value:10)

__delete__() 메소드 정의하기

object.__delete__(self, instance)

소유 클래스 instance의 어트리뷰트를 삭제하기 위해 호출 된다.

이미 디스크립터의 호출과 종류에 대해 설명 했으므로 __delete__는 별달리 설명 할 것이 없다. 간단히 아래 예제만 확인하고 넘어가자.

class MyProperty :
    def __get__(self, instance, owner = None) :
        print(f'__get__(instance:{instance}, owner:{owner})')
        return 20
    
    def __set__(self, instance, value) :
        print(f'__set__(instance:{instance}, value:{value})')

    def __delete__(self, instance) :
        print(f'__delete__(instance:{instance})')     

class Point :
    y = MyProperty()
    
    def __init__(self) :
        self.x = 0
        
point = Point()
del point.y   # y 어트리뷰트 삭제

# OUTPUT :
# __delete__(instance:<__main__.Point object at 0x0000016A0FD92FA0>)

MyProperty

이제 앞에서 배운것들을 응용하여 파이썬의 프로퍼티(property) 처럼 getter/setter를 MyProperty에 구현해 보도록 하자.

class MyProperty :
    def __get__(self, instance, owner = None) :
        print(f'__get__(instance:{instance}, owner:{owner})')
        if None == instance._y :
            raise AttributeError()
            
        return instance._y
    
    def __set__(self, instance, value) :
        print(f'__set__(instance:{instance}, value:{value})')
        if True != isinstance(value, int) :
            raise AttributeError('y should be int')
        if 0 > value :
            raise AttributeError('y should be bigger or equal than 0')
        
        instance._y = value
               
    def __delete__(self, instance) :
        print(f'__delete__(instance:{instance})')
        del instance._y

class Point :
    y = MyProperty()
    
    def __init__(self) :
        self.x = 0
        
point = Point()
point.y = 25
print(point.y)
print(point.__dict__)
point.y = -1   # AttributeError: y should be bigger than 0

파이썬 property와 비교하면 너무 어설픈 구현이지만 그래도 y에 0 보다 작은 값이 설정 되는 경우 예외를 발생 시키는 getter와 setter가 완성 되었다.

__set_name__() 이용해 필드 구분

__set_name__ 메소드는 동일 디스크립터를 이용해 두 개 이상의 스태틱 필드를 만들 경우 각 디스크립터들이 건드리는 프로퍼티를 구분하기 위해 사용 되는 메소드다. 이에 대한 설명을 위해 한 가지 상황을 만들어 보도록 하자.

class MyProperty :
    def __get__(self, instance, owner = None) :
        return instance._y
    
    def __set__(self, instance, value) :
        instance._y = value
               
    def __delete__(self, instance) :
        del instance._y
        
class Point :
    y = MyProperty()
    
    def __init__(self) :
        self.x = 0
        self.y = 0   # self.y.__set__(self.y, self, 0)
        
point = Point()
print(point.__dict__)

# OUTPUT
# __set__(instance:<__main__.Point object at 0x0000029E848A9FA0>, value:0)
# {'x': 0, '_y': 0}

Point 클래스의 기존 스태틱 필드 y외에 생성자에서 인스턴스 필드 y를 추가 생성하고 있다. 하지만 위 코드를 실행 후 point객체의 __dict__ 어트리뷰트를 확인해보면, point 객체에는 y 인스턴스 필드 대신 _y가 생성 되었다.

앞에서 우리는 디스크립터는 스태틱 필드로 생성 되더라도 객체를 통해 접근 할 수 있다고 배웠다. Point 클래스의 y는 스태틱 필드의 디스크립터라고 이미 정의 되어 있다. 생성자의 self라고 하더라도 결국엔 같은 객체이므로, self객체를 통해 인스턴스 필드 y에 접근하려고 시도하면 디스크립터의 __set__을 호출하게 된다. MyProperty의 __set__에서는 _y에 값을 저장하므로 point 객체의 __dict__에는 y대신 _y가 추가된 것이다.

여기 까지는 지금까지 배웠던 내용이다. 그럼 y에만 적용 되어있던 디스크립터를 x에도 같이 적용하고 싶다면 어떻게 해야 할까?

간단하게는 아래 처럼 스태틱 필드 x를 만들고 MyProperty 디스크립터를 적용하는 방법을 생각해 볼 수 있을 것이다.

class Point :
    x = MyProperty()
    y = MyProperty()
    
    def __init__(self) :
        self.x = 1   # self.x.__set__(self.x, self, 1)
        self.y = 2   # self.y.__set__(self.y, self, 2)

point = Point()
print(point.__dict__)

# OUTPUT :
# __set__(instance:<__main__.Point object at 0x0000021A3CEB9EE0>, value:1)
# __set__(instance:<__main__.Point object at 0x0000021A3CEB9EE0>, value:2)
# {'_y': 2}

지금까지 강의를 따라 오신 분들은 이이 예상 했을 수도 있다. 역시 우리가 원하는대로 되지 않았다.

MyProperty 디스크립터는 어떤 객체가 인자로 넘어오던 상관 없이, 그 객체의 _y 어트리뷰트에 대해서만 연산하도록 만들어졌다. 생성자에서 self.x에 대해 디스크립터의 __set__을 적용하게 되면 self 객체의 _y에 1을 저장한다. 그리고 바로 아래의 self.y에 대해 디스크립터를 다시 적용하면 self객체의 _y에 2를 덮어 쓰게 되므로 결국 point 객체의 __dict__를 확인하면 _y만 있는 것이다.

앞의 경우를 비추어 보았을 때, 동일한 디스크립터를 이용해 두 개 이상의 스태틱 필드를 만들었을 경우,  __set__메소드의 인자로 넘어 오는 instance의 어떤 어트리뷰트에 대해 작업을 할지 구분 할 수 있는 방법이 있어야 한다. 그것이 바로 __set_name__이다

__set_name__을 이용해 수정할 어트리뷰트를 결정 할 수 있다.

그럼 지금 부터 __set_name__ 메소드를 적용하는 방법을 살펴 보자.

object.__set_name__(self, owner, name)

owner 클래스가 생성 되었을 때 호출 된다. name에는 디스크립터 객체의 이름이 할당 된다.
class MyProperty :
    def __set_name__(self, owner, name) :
        print(f'__set_name__(owner:{owner}, name:{name})')
    
    def __get__(self, instance, owner = None) :
        return instance._y
    
    def __set__(self, instance, value) :
        instance._y = value
               
    def __delete__(self, instance) :
        del instance._y

class Point :
    x = MyProperty() # x.__set_name__(x, Point, 'x')
    y = MyProperty() # y.__set_name__(y, Point, 'y')
    
    def __init__(self) :
        self.x = 1   # self.x.__set__(self.x, self, 1)
        self.y = 2   # self.y.__set__(self.y, self, 2)
        
# OUTPUT :
# __set_name__(owner:<class '__main__.Point'>, name:x)
# __set_name__(owner:<class '__main__.Point'>, name:y)

__set_name__ 스페셜 메소드를 적용 후, 위 예제를 실행하면 아직 Point 객체를 생성하지 않았지만 __set_name__관련된 텍스트가 출력 되는 것을 볼 수 있다. 이는 스태틱 필드로 지정되어 있는 x와 y가 디스크립터 객체로 초기화 될 때, 디스크립터의 __set_name__() 을 호출해 주기 때문이다.

__set_name__의 두번째 인자는 어트리뷰트를 소유하고 있는 클래스, 세번째 인자는 디스크립터로 지정하는 필드의 이름이 문자열 형태로 넘어 온다. 우리는 이 인자들을 이용해 각 디스크립터가 관리하는 필드를 구분 할 수 있도록 할 것이다.

class MyProperty :
    def __set_name__(self, owner, name) :
        print(f'__set_name__(owner:{owner}, name:{name})')
        self.name = '_' + name # x.name = '_x', y.name = '_y'
    
    def __get__(self, instance, owner = None) :
        #return instance._y
        return getattr(instance, self.name)
    
    def __set__(self, instance, value) :
        #instance._y = value
        setattr(instance, self.name, value)
               
    def __delete__(self, instance) :
        #del instance._y
        delattr(instance, self.name)

가장 먼저 __set_name__()을 살펴 보자. 인자로 넘어온 필드의 이름을 self.name에 저장하고 있다. 그리고 각 __get__, __set__, __delete__에서는 필드의 이름을 통해 동적으로 각 필드에 접근 할 수 있는 setattr, getattr, delattr 함수들을 이용해 필드에 접근하여 값을 읽어 오거나 셋팅하고 있다.

setattr(object, attribute_name, value)

객체에 존재하는 어트리뷰트를 변경하거나 새로운 어트리뷰트를 생성하여 값을 부여한다.
"setattr(obj, 'new_attr', value)", "obj.new_attr = value", "obj.__dict__['new_attr'] = value" 는 모두 같은 의미다.

이제 __set_name__을 사용하기 위한 준비는 다 끝났다. 아래와 같이 Point 객체를 생성하고 값들을 변경하면, 같은 디스크립터를 사용하더라도 각각의 필드들에 대해 따로 접근하는 것을 볼 수 있다.

class Point :
    x = MyProperty() # x.__set_name__(x, Point, 'x')
    y = MyProperty() # y.__set_name__(y, Point, 'y')
    
    def __init__(self) :
        self.x = 1   # self.x.__set__(self.x, self, 1)
        self.y = 2   # self.y.__set__(self.y, self, 2)
        
point = Point()
print(point.x)
print(point.y)
print(point.__dict__)

# OUTPUT :
# __set_name__(owner:<class '__main__.Point'>, name:x)
# __set_name__(owner:<class '__main__.Point'>, name:y)
# 1
# 2
# {'_x': 1, '_y': 2}

위 예제를 실행하면 이제 Point 객체의 x, y 값을 덮어 쓰지 않고 _x, _y 필드를 각각 생성하여 값을 저장하고 있는 것을 확인 할 수 있다.

다시 MyProperty

이전 버전의 MyProperty 디스크립터는 실제 사용하기에는 부족한면이 없지 않아 많이 있었다. 위 코드를 수정하여 클래스(class) #4 property에서 사용되었던것과 비슷한 MyProperty를 만들어 보며 이번 포스트를 마치도록 하자.

class MyProperty():
    def __init__(self, getter=None, setter=None, deleter=None):
        self.getter = getter
        self.setter = setter
        self.deleter = deleter

    def __get__(self, instance, owner=None):
        if None is self.getter :
            raise AttributeError
        
        return self.getter(instance)

    def __set__(self, instance, value):
        if None is self.setter :
            raise AttributeError
        
        return self.setter(instance, value)

    def __delete__(self, instance):
        if None is self.deleter :
            raise AttributeError

        return self.deleter(instance)

class Person :
    
    def __init__(self, name : str, age : int) :
        self.__name = name
        self.__age = age

    def get_name(self) :
        return self.__name
    
    def set_name(self, name) :
        if True != isinstance(name, str) :
            raise ValueError('name should be str type')
        self.__name = name
        
    name = MyProperty(get_name, set_name) # name에 대한 property
    
    def get_age(self) :
        return self.__age
    
    def set_age(self, age) :
        if True != isinstance(age, int) or 0 > age :
            raise ValueError('age should be int type and not be negative')
        self.__age = age
        
    age = MyProperty(get_age, set_age)    # age에 대한 property
        

person = Person('Jason', '24')

print(person.name, person.age)   # Jason 24

person.name = 10                 # ValueError: name should be str type
person.age = -100                # ValueError: age should be int type and not be negative

마치며

마무리 하기 전에 디스크립터에 대해 요약 하자면

  • 디스크립터는 __get__, __set__, __delete__ 스페셜 메소드 중 한개 이상이 구현되어 있는 객체다.
  • 디스크립터는 스태틱 필드로 만든 경우만 동작한다.
  • 디스크립터는 객체를 통한 접근과 클래스 이름을 통한 접근 모두를 허용한다.
  • __set__과 __delete__메소드는 객체를 통해 접근 할 때만 호출된다.
  • 디스크립터가 두개 이상의 스태틱 필드에 적용 되는 경우 __set_name__을 이용해 각 필드를 구분 할 수 있다.
  • 디스크립터는 파이썬의 property, classmethod, staticmethod 등을 구현하는데 사용 된다.

정도로 요약 할 수 있다.

디스크립터에 대한 강의는 이 정도로 마치도록 하겠다. 다음 포스트에서는 파이썬 모듈(module)모듈 객체(module object)에 대해서 알아 보도록하겠다.

오늘은 강의가 좀 길었는데 끝까지 따라와 주어 고맙다.

부록 1. 같이 보면 좋은 글

유익한 글이었다면 공감(❤) 버튼 꾹!! 추가 문의 사항은 댓글로!!