티스토리 뷰
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가 발생하는 것이다.
(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 |