티스토리 뷰

Python

[파이썬]디스크립터(descriptor) - [1]

안톨리니 2022. 7. 21. 13:02

Reference

  • Fluent Python
  • geeksforgeeks
  • Descriptor HowTo Guide(Raymond Hettinger)
  • Stackoverflow

 

 

디스크립터는 독립적인 하나의 클래스가 있다면

해당 클래스의 특정 속성을 다른 클래스(디스크립터)가 관리할 수 있게 한다.

 

사실 말로만 보면 무슨 소리인지 모르겠지만

코드를 보면서 알아가는 게 더 낫다.

디스크립터(descriptor)
class Student:

    def __init__(self, name, age, major):
        self.name = name
        self.age = age
        self.major = major
        

antoliny = Student('Antoliny', 23, 'Computer Science')

굉장히 간단한 Student클래스의 객체를 생성하고 그 객체에 어떤 속성이 있는지는

파이썬을 배운 사람이라면 누구나 알 수 있다.

['name', 'age', 'major']

 

이 중에서 필자는 name이라는 속성을

디스크립터 클래스를 통해서 관리해보겠다.


※ 디스크립터 클래스는 __get__, __set__, __delete__ 프로토콜을 구현한 클래스다.

프로토콜 의미대로 위 매직메서드중 하나만 구현해도

디스크립터처럼 동작한다.


class Descriptor:

    def __get__(self, obj, owner):
        print('나이 =', obj.age)
        print('전공 =', obj.major)
        return '이름 = Antoliny'

class Student:

    def __init__(self, age, major):
        self.name = Descriptor()
        self.age = age
        self.major = major

x = Student(23, 'Computer Science')

print(x.age)
>>> 23
print(x.name)
>>> <__main__.Descriptor object at 0x1011d6fa0>

디스크립터 클래스를 만들어줬고

Student클래스의 'name'속성에 디스크립터 클래스 객체를 생성해줬다.

 

이제 dot operator(.)을 통해 객체의 속성을 출력해보면

'age'는 정상적으로 출력되지만

'name'은 디스크립터 객체의 주소가 출력된다.

 

※ 우리가 dot operator(.)을 통해 속성을 읽을 때(a.x라고 가정)

해당 객체의 __dict__[x]를 먼저 확인하고 해당 값이 없다면

 type(객체).__dict__[x](해당 객체의 클래스.__dict__라고 생각)를 통해 읽고

또 없다면 여러 조회 체인을 거친 다음에

AttributeError를 발생시킨다.

두번째 문단을 봐보자

 

여기서 x객체 클래스 Student.__dict__을 출력해보면

{'__module__': '__main__', '__init__': <function Student.__init__ at 0x10519f8b0>, '__dict__': <attribute '__dict__' of 'Student' objects>, '__weakref__': <attribute '__weakref__' of 'Student' objects>, '__doc__': None}

'name'과 'age'관련된 키값은 없다.

이건 어찌 보면 당연한 결과다.

우리는 'name'과 'age'를 클래스 변수로 선언해 놓은 게 아니기 때문이다.

 

이번에는 디스크립터를 클래스 변수로 선언해보겠다.

class Descriptor:

    def __get__(self, obj, owner):
        print('나이 =', obj.age)
        print('전공 =', obj.major)
        return '이름 = Antoliny'


class Student:
    name = Descriptor()

    def __init__(self, age, major):
        self.age = age
        self.major = major


x = Student(23, 'Computer Science')
print(x.name)
>>> 나이 = 23
>>> 전공 = Computer Science
>>> 이름 = Antoliny

이번에는 Student 클래스의 객체로 'name'속성에 접근해봤더니

디스크립터의 __get__메서드가 실행된 걸 확인할 수 있다.


__get__메서드의 매개변수 self는 디스크립터 객체 자기 자신이고, obj는

Student클래스 객체(x)를 의미합니다.


다시 x객체의 클래스 __dict__을 출력해보면

{'__module__': '__main__', ★ 'name': <__main__.Descriptor object at 0x1011fefd0> ★, '__init__': <function Student.__init__ at 0x1011f38b0>, '__dict__': <attribute '__dict__' of 'Student' objects>, '__weakref__': <attribute '__weakref__' of 'Student' objects>, '__doc__': None}

'name'이 있는 걸 확인할 수 있다.

'name'의 값은 디스크립터 클래스 객체의 주소이고

파이썬은 이렇게 디스크립터 클래스 객체의 주소를 찾으면

자동적으로 해당 객체의 __get__메서드를 실행한다.

 

그렇기 때문에 위와 같은 결과가 출력된 것이다.

 

그런데 생각해보면 클래스의 객체 속성에 디스크립터를 객체를 할당했을 때도

똑같이 디스크립터 객체의 주소가 출력되었음에도 불구하고

__get__이 실행되지 않았다.

레이몬드 헤팅거의 'Descriptor HowTo Guide'문서의 Overview of descriptor invocation파트를 보면

인스턴스 __dict__외부에 디스크립터를 찾으면

해당 디스크립터의 __get__을 호출한다고 나와있다.

 

이번에는 조금 더 실용적인 예제를 보자

 

디스크립터에 __set__을 이용하면 관리할 클래스의 속성에 값을 할당하는데

제약을 줄 수 있다.

 

 

 

__set__
class Quantity:
    __counter = 0

    def __init__(self):
        cls = self.__class__
        prefix = cls.__name__
        index = cls.__counter
        self.storage_name = f'_{prefix}#{index}'
        cls.__counter += 1

    def __get__(self, instance, owner):
        return getattr(instance, self.storage_name)


    def __set__(self, instance, value):
        if value > 0:
            setattr(instance, self.storage_name, value)
        else:
            raise ValueError('value must be > 0')


class LineItem:
    weight = Quantity()
    price = Quantity()

    def __init__(self, description, weight, price):
        self.description = description
        self.weight = weight
        self.price = price

    def subtotal(self):
        return self.weight * self.price


coconuts = LineItem('Brazilian coconut', 20, 17.95)
print(coconuts.weight)
>>> 20
print(coconuts.price)
>>> 17.95

위 코드에서 LineItem객체를 생성하려고 할 때

매개변수의 인수로 세 개의 값을 전달해야 한다.

[description, weight, price]

 

그리고 LineItem의 클래스 변수를 보면 'weight', 'price'가 Quantity(디스크립터)로 되어있는 걸 확인할 수 있다.

Quantity(디스크립터)의 __set__매직메서드를 보면 'value'값이

무조건 0이상이도록 설정되어있고

만약 0 이하라면 ValueError가 발생하도록 되어있다.

 

'weight'와 'price'가 0 원인건 사실 말이 안 되기 때문에

LineItem객체의 'weight', 'price'속성을 디스크립터로 관리함으로써 이런 예외를 사전에 막는 것이다.

coconuts = LineItem('Brazilian coconut', 20, 17.95)
coconuts.weight = -20
>>> ValueError: value must be > 0

어떤 방식으로든 디스크립터가 적용된 속성의 값을 변경하려고 하면

해당 디스크립터의 __set__메서드가 호출된다.

 

그렇기 때문에 내가 위 코드에서 처럼

코코넛 무게를 -20으로 설정하면

ValueError가 발생한다.

(디스크립터가 getter와 setter 같다는 느낌이 들것이다.)


※ 혹시 디스크립터의 __get__메서드중 'owner'매개변수가 하는 역할에 대해 궁금해할 수 있다.

__get__메서드의 매개변수 순서대로

-----------------------------------------------

self: 디스크립터 자기자신(Quantity)

instance: 호출한 객체(coconuts)

owner: 호출한 객체의 클래스(LineItem)

-----------------------------------------------

를 의미한다.

 

그렇다면 클래스로 속성 값을 호출하면 어떻게 될까?

LineItem.price를 호출하면

AttributeError: 'NoneType' object has no attribute '_Quantity#1'

 

어트리뷰트 에러가 발생하고

NoneType객체는 '_Quantity#1'이라는 속성을 가지고 있지 않다고 예외 메시지를 출력한다.

클래스로 속성 값을 호출하면

똑같이 해당 속성의 디스크립터의 __get__ 메서드가 호출되는데

문제는 instance매개변수의 값에는 호출한 객체가 들어와야 하는데

클래스를 통해 호출해버려서 None값이 돼버린다.

이런 문제 때문에 AttributeError가 발생하는 것이다.

Python 'Descriptor HowTo Guide'

 

(Fluent Python에서는 이러한 문제 때문에 __get__메서드에 None예외처리를 하고(if instance is None:)

리턴 값으로 디스크립터 객체(self)를 반환하게 하는 것이 좋다고 한다.


위 예제 코드에서 봤던  __set__은 조건문이 하나밖에 없어서

동작에 대한 이해를 하긴 쉽지만

다소 실용적이다라고는 말하지 못하겠다.

 

개인적으로 Python 'Descriptor HowTo Guide'문서에 있는 코드 중에서

디스크립터에 대한 활용적인 부분에서 훨씬 더 실용적이기도 하고

추상 클래스와, 추상 메서드를 통해 더 디테일하게 구현되어 있기 때문에

디스크립터, __set__의 조금 더 실용적인 부분이 궁금하다면

이 코드를 추천한다.

from abc import ABC, abstractmethod

class Validator(ABC):

    def __set_name__(self, owner, name):
        self.private_name = '_' + name

    def __get__(self, obj, objtype=None):
        return getattr(obj, self.private_name)

    def __set__(self, obj, value):
        self.validate(value)
        setattr(obj, self.private_name, value)

    @abstractmethod
    def validate(self, value):
        pass


class OneOf(Validator):

    def __init__(self, *options):
        self.options = set(options)

    def validate(self, value):
        if value not in self.options:
            raise ValueError(f'Expected {value!r} to be one of {self.options!r}')


class Number(Validator):

    def __init__(self, minvalue=None, maxvalue=None):
        self.minvalue = minvalue
        self.maxvalue = maxvalue

    def validate(self, value):
        if not isinstance(value, (int, float)):
            raise TypeError(f'Expected {value!r} to be an int or float')
        if self.minvalue is not None and value < self.minvalue:
            raise ValueError(
                f'Expected {value!r} to be at least {self.minvalue!r}'
            )
        if self.maxvalue is not None and value > self.maxvalue:
            raise ValueError(
                f'Expected {value!r} to be no more than {self.maxvalue!r}'
            )


class String(Validator):

    def __init__(self, minsize=None, maxsize=None, predicate=None):
        self.minsize = minsize
        self.maxsize = maxsize
        self.predicate = predicate

    def validate(self, value):
        if not isinstance(value, str):
            raise TypeError(f'Expected {value!r} to be an str')
        if self.minsize is not None and len(value) < self.minsize:
            raise ValueError(
                f'Expected {value!r} to be no smaller than {self.minsize!r}'
            )
        if self.maxsize is not None and len(value) > self.maxsize:
            raise ValueError(
                f'Expected {value!r} to be no bigger than {self.maxsize!r}'
            )
        if self.predicate is not None and not self.predicate(value):
            raise ValueError(
                f'Expected {self.predicate} to be true for {value!r}'
            )


class Component:

    name = String(minsize=3, maxsize=10, predicate=str.isupper)
    kind = OneOf('wood', 'metal', 'plastic')
    quantity = Number(minvalue=0)

    def __init__(self, name, kind, quantity):
        self.name = name
        self.kind = kind
        self.quantity = quantity
    

>>> Component('Widget', 'metal', 5)      # Blocked: 'Widget' is not all uppercase
Traceback (most recent call last):
    ...
ValueError: Expected <method 'isupper' of 'str' objects> to be true for 'Widget'

>>> Component('WIDGET', 'metle', 5)      # Blocked: 'metle' is misspelled
Traceback (most recent call last):
    ...
ValueError: Expected 'metle' to be one of {'metal', 'plastic', 'wood'}

>>> Component('WIDGET', 'metal', -5)     # Blocked: -5 is negative
Traceback (most recent call last):
    ...
ValueError: Expected -5 to be at least 0
>>> Component('WIDGET', 'metal', 'V')    # Blocked: 'V' isn't a number
Traceback (most recent call last):
    ...
TypeError: Expected 'V' to be an int or float

>>> c = Component('WIDGET', 'metal', 5)  # Allowed:  The inputs are valid

코드에 대한 간단한 설명

 

Validate를 상속받은 클래스들의 if문들이 Component클래스 객체 속성들의 제약조건이다.

Validate의 __set__메서드를 보면

self.validate(value)를 통해 다형성을 구현해서

각각 디스크립터 클래스마다의 제약조건이 실행되게 구현되어있다.

 

더 디테일한 내용이 궁금하다면

https://docs.python.org/3/howto/descriptor.html#dynamic-lookups

 

Descriptor HowTo Guide — Python 3.10.5 documentation

Author Raymond Hettinger Contact Descriptors let objects customize attribute lookup, storage, and deletion. This guide has four major sections: The “primer” gives a basic overview, moving gently from simple examples, adding one feature at a time. Start

docs.python.org

이 글에서 <Complete Practical Example> 부분을 확인하기 바란다 :)

 

 

 


dot operator의 동작원리

 

이 부분은 디스크립터와 크게 관련이 없는내용이긴 하나

파이썬의 동작 원리에 대해 이해하는데 어느 정도 도움이 된다고 생각해서

이 부분에 대한 이해가 확실하진 않지만

글을 적기로 나 자신과 약속했다.

(혹시나 읽으실 분들에게 그냥 가볍게 읽기 바란다고 말씀드리고 싶다.)

 

필자는 궁금했다.

dot.operator를 통해 값을 읽을 때

어떤 내부적인 동작을 통해 해당 값을 찾아내는 걸까?

파이썬에서 객체의 속성에 접근할 때 호출되는 매직 메서드가 있다.

그 메서드는 바로 __getattribute__메서드와 __getattr__메서드인데

__getattr__메서드는 객체의 속성에 값이 없을 때 호출되는 메서드이기도 하고

보통 사용자가 구현해야 하는 부분이기 때문에

이번 문제와 관련이 없고

 

객체의 해당 속성이 있든 없든 호출되는 __getattribute__메서드가 핵심인 거 같다.

(__getattribute__를 먼저 호출하고 AttributeError발생 시 except처리해서 __getattr__을 호출하는 형태)

(선순위: __getattribute__, 후순위: __getattr__)

def getattr_hook(obj, name):
    "Objects/typeobject.c 에 있는 slot_tp_getattr_hook()을 흉내 냅니다"
    try:
        return obj.__getattribute__(name)
    except AttributeError:
        if not hasattr(type(obj), '__getattr__'):
            raise
    return type(obj).__getattr__(obj, name)

참고로 디스크립터 또한 __getattribute__메서드로 호출된다.

첫번째를 확인하자

사실 필자가 궁금한 부분이

dot operation을 통했을 때 어떤 동작으로 해당 객체의 속성 값을 찾냐였기 때문에

이 문제를 해결하려면

__getattribute__가 어떻게 구현되어있는지 알아야 했다.

다행히도 참 친절한 'Descriptor HowTo Guide'문서에

CPython에서 C로 구현한 __getattribute__메서드를 파이썬 버전으로 해석해놓은 코드가 있었다.

 

코드를 한 번 자세히 봐보자

def object_getattribute(obj, name):
    "Emulate PyObject_GenericGetAttr() in Objects/object.c"
    null = object()
    objtype = type(obj)
    cls_var = getattr(objtype, name, null)
    descr_get = getattr(type(cls_var), '__get__', null)
    if descr_get is not null:
        if (hasattr(type(cls_var), '__set__')
            or hasattr(type(cls_var), '__delete__')):
            return descr_get(cls_var, obj, objtype)     # data descriptor
    if hasattr(obj, '__dict__') and name in vars(obj):
        return vars(obj)[name]                          # instance variable
    if descr_get is not null:
        return descr_get(cls_var, obj, objtype)         # non-data descriptor
    if cls_var is not null:
        return cls_var                                  # class variable
    raise AttributeError(name)

코드에 대한 설명

-------------------------------------------------------------------------------------------------------------------------------------

 

1. objtype = type(obj) --> 매개변수로 보낸 객체의 클래스를 가져온다.

 

2. cls_var = getattr(objtype, name, null) --> ※ (이 부분이 필자의 머리를 굉장히 어지럽혔다.)

해당클래스__dict__에 찾으려고 하는 객체 속성 값이 있나 확인하는 메서드인데 만약 해당 클래스에 찾으려고 하는 속성 값에 디스크립터가 있고 __get__메서드가 구현되어있으면 사실상 객체의 클래스.__get__이 호출되면서 위에서 설명했듯이 클래스로 호출했기 때문에 instance매개변수가 None값이 되어버린다.

이런 문제를 해결하기 위해 각 디스크립터 클래스의 __get__메서드에 if instance == None일때 self를 반환하도록 해야 한다.

(간단하게 제어문을 통해 self를 반환하면 cls_var는 디스크립터 객체가 된다.

 

3. descr_get = getattr(type(cls_var), '__get__', null) cls_var의 타입 --> 디스크립터에 __get__메서드가 있는지 확인하는 동작이다.

__get__메서드가 있다면 해당 메서드를 가져온다.

 

4. 첫 번째 if --> 데이터 디스크립터인지 확인(디스크립터 객체[cls_var]에게 __set__ 이나 __delete__ 메서드가 있는지 확인

만약 있다면 descr_get(cls_var=디스크립터 객체, obj=매개변수로 넘겨준 객체, objtype=obj의 클래스)를 통해서

디스크립터의 __get__ 반환 값을 리턴한다.

 

5. 두 번째 if --> 매개변수로 넘겨준 객체(obj) __dict__에 찾으려고 하는 속성 값이 있는지 확인

 

6. 세 번째 if --> 4번과 동일한데 디스크립터가 non-data descriptor일 때 실행(__get__메서드만 가진 디스크립터)

 

7. 네 번째 if --> 매개변수로 넘겨준 객체의 클래스 반환

 

8. raise AttributeError(name) 에러 발생이지만 __getattr__이 정의되어있으면 __getattr__실행(위 코드에서는 이 부분을 생략했음)

 

-------------------------------------------------------------------------------------------------------------------------------------

 

위에 있었던 object_getattribute함수를 LineItem클래스와 Student클래스를 통해 테스트를 해보겠다.

 

테스트하기 전에 어떤 부분에서 리턴되는지 확인하기 위해서

리턴문마다 해당 숫자를 출력하도록 수정했다.

Ex.) 첫 번째 if문에서 리턴 --> 1 출력

class Quantity:
    __counter = 0

    def __init__(self):
        cls = self.__class__
        prefix = cls.__name__
        index = cls.__counter
        self.storage_name = f'_{prefix}#{index}'
        cls.__counter += 1

    def __get__(self, instance, owner):
        if instance == None:
            return self
        return getattr(instance, self.storage_name)


    def __set__(self, instance, value):
        if value > 0:
            setattr(instance, self.storage_name, value)
        else:
            raise ValueError('value must be > 0')

class LineItem:
    weight = Quantity()
    price = Quantity()

    def __init__(self, description, weight, price):
        self.description = description
        self.weight = weight
        self.price = price

    def subtotal(self):
        return self.weight * self.price


coconuts = LineItem('Brazilian coconut', 20, 17.95)
print(object_getattribute(coconuts, 'description'))
>>> 2
>>> Brazilian coconut
print(object_getattribute(coconuts, 'weight'))
>>> 1
>>> 20

LineItem클래스의 속성 description은 디스크립터로 관리하지 않은 속성이기 때문에

2번이 출력되었고

weight속성은 디스크립터로 관리하는 속성이고 해당 디스크립터가 데이터 디스크립터이기 때문에

1번이 출력되었다.

 

이번에는 Student클래스를 통해 테스트해보겠다.


class Descriptor:

    def __get__(self, obj, owner):
        if obj == None:
            return self
        return '이름 = Antoliny'


class Student:
    name = Descriptor()

    def __init__(self, age, major):
        self.age = age
        self.major = major


x = Student(23, 'Computer Science')
print(object_getattribute(x, 'name'))
>>> 3
>>> 이름 = Antoliny
print(object_getattribute(x, 'age'))
>>> 2
>>> 23

Student클래스는 LineItem클래스와 다른 부분은

디스크립터가 비 데이터 디스크립터라는 점이다.(__set__, __delete__가 정의되어 있지 x)

그렇기 때문에 디스크립터가 관리하는 속성인 'name'은

3이 출력되었고

관리하지 않는 속성인 'age'는 2가 출력되었다.

 

object_getattribute함수를 통해

__getattribute__가 해당 객체의 속성을 가져오는 로직에 대해 어느 정도 알 수 있었다.

항상 가장 먼저 디스크립터를 우선순위로 둔다는 점이 핵심인 거 같다.

 

하지만 명심해야 할 점은 위 object_getattribute함수가 파이썬 버전으로 해석한거뿐이지

완벽한 건 아닌 거 같다.

왜냐하면 모든 디스크립터 객체 __get__클래스에 두 번째 매개변수가 None인 경우 디스크립터 자기 자신을 반환하도록

제어문으로 예외처리를 하지 않으면 위 object_getattribute함수가 실행이 안된다.

 

그런데 막상 예외 처리하지 않고 그냥

print(객체.속성)을 접근해보면 잘 출력된다.

 

그런 의미로 완벽하지 않다고 표현한 것이다.

 

사실 완벽함을 따지기 보단 위에서도 언급했듯이

dot operation을 통해 __getattribute__메서드가 어느 순서로 속성 값을 찾아내는지에 대해

알아가는 게 핵심이었다고 생각하기에 딱히 중요한 문제는 아닌 거 같다.

(사실 약간 찜찜하긴 하다.)

 

 

 


아직 디스크립터에 대해 더 설명할 부분들이 남아있지만

쓰다 보니 너무 길어져서

각각 내용별로 분리해서 포스팅해야겠다고 생각했다.

 

사실 분리할 정도로 큰 주제인가 싶지만

하면 할수록 중요한 파트라고 느껴진다.

 

어쩌면 필자가 많이 궁금해했던

동작원리 부분에 대한 이해도를 쌓을 수 있는 기회인 거 같다.

 

 

 

후기

 

디스크립터에 대해 이해하는데 조금 시간이 걸린 거 같다.

사실 이 글도 작성하는데 이틀이나 걸렸다.

(배우는 시간까지 하면 3일이 걸렸지만 막상 dot.operator부분에서 거의 대부분의 시간을 썼다.)

 

충격적이게도 아직 완벽히 이해하지 못했기 때문에

더 많은 시간을 써야겠다.

 

괜찮다.

한 번 보고 이해한 적이 없는 안타까운 필자에겐

익숙한 일이다.

 

마지막으로 필자가 개인적으로 디스크립터를 조금이라도 이해하는데 큰 도움을 줬던 글을 소개해주고 싶다.

 

이 글에서도 자주 등장한 레이몬드 헤팅거의 'Descriptor HowTo Guide'라는 문서인데

한 번 읽어볼 만하다.

정말 괜찮은 글이라고 생각하기 때문에 혹시라도 관심 있는 분들에게 추천드리고 싶다.

https://docs.python.org/3/howto/descriptor.html#id1

 

Descriptor HowTo Guide — Python 3.10.5 documentation

Author Raymond Hettinger Contact Descriptors let objects customize attribute lookup, storage, and deletion. This guide has four major sections: The “primer” gives a basic overview, moving gently from simple examples, adding one feature at a time. Start

docs.python.org

'Python' 카테고리의 다른 글

[파이썬] 클래스 메타프로그래밍  (0) 2022.07.30
[파이썬] 디스크립터(descriptor) - [2]  (0) 2022.07.25
[파이썬] yield from  (0) 2022.07.19
[파이썬] 코루틴  (0) 2022.07.11
[파이썬] 데코레이터  (0) 2022.07.04
댓글
공지사항