디스크립터 개요
디스크립터는 클래스 변수의 조작에 제약을 주고 싶을 때 사용한다.
디스크립터 메커니즘
디스크립터를 구현하려면 최소 두 개의 클래스가 필요하다.
클라이언트 클래스는 디스크립터 구현의 기능을 활용할 도메일 모델로서 솔루션을 위해 생성한 일반적인 추상화 객체이다. 디스크립터 클래스는 디스크립터 로직의 구현체이다.
디스크립터는 디스크립터 프로토콜을 구현한 클래스의 인스턴스이다. 이 클래스는 다음 매직 메서드 중 최소 한 개 이상을 포함해야 한다.
__get____set____delete____set_name__
이 프로토콜이 동작하려면 디스크립터 객체가 클래스 속성으로 정의되어야 한다. 이 객체를 인스턴스 속성으로 생성하면 동작하지 않으므로 init 메서드가 아닌 클래스 본문에 있어야 한다.
디스크립터의 경우 클래스에서 디스크립터를 호출하면 객체 자체를 반환하는 것이 아닌 __get__ 매직 메서드의 결과를 반환한다.
class DescriptorClass:
def __get__(self, instance, owner):
if instance is None:
return self
logger.info(
"Call: %s.__get__(%r, %r)",
self.__class__.__name__,
instance,
owner,
)
return instance
class ClientClass:
descriptor = DescriptorClass()>>> client = ClientClass()
>>> client.descriptor
INFO:Call: DescriptorClass.__get__(<ClientClass object at 0x...>, <class 'ClientClass'>)
<ClientClass object at 0x...>ClientClass 인스턴스의 descriptor 속성에 접근해보면 DescriptorClass인스턴스를 반환하지 않고 대신 __get__() 메서드의 반환 값을 사용한다.
디스크립터 프로토콜의 메서드 탐색
__get__(self, instance, owner)
두 번째 파라미터 instance는 디스크립터를 호출한 객체를 의미한다. 위 예제에서는 client객체를 의미한다.
세 번째 owner 파라미터는 해당 객체의 클래스를 의미한다. 예제에서는 ClientClass 클래스이다.
client인스턴스가 아닌 ClientClass에서 descriptor을 호출하는 경우 instance의 값은 None이 되기 때문에 owner 파라미터를 갖는다.
__set__(self, instance, value)
이 메서드는 디스크립터에 값을 할당하려고 할 때 호출된다. 만약 __set__()메서드를 구현하지 않은 경우 값 할당시 디스크립터를 덮어쓴다.
디스크립터 속성에 값을 할당할 때는
__set__메서드를 구현했는지 반드시 확인해야 한다.
class Validation:
def __init__(self, validation_function: Callable[[Any], bool], error_msg: str) -> None:
self.validation_function = validation_function
self.error_msg = error_msg
def __call__(self, value):
if not self.validation_function(value):
raise ValueError(f"{value!r} {self.error_msg}")
class Field:
def __init__(self, *validations):
self._name = None
self.validations = validations
def __set_name__(self, owner, name):
self._name = name
def __get__(self, instance, owner):
if instance is None:
return self
return instance.__dict__[self._name]
def validate(self, value):
for validation in self.validations:
validation(value)
def __set__(self, instance, value):
self.validate(value)
instance.__dict__[self._name] = value
class ClientClass:
descriptor = Field(
Validation(lambda x: isinstance(x, (int, float)), "는 숫자가 아님"),
Validation(lambda x: x >= 0, "is not >= 0"),
)프로퍼티 자리에 놓일수 있는 것은 디스크립터로 추상화할 수 있으며 여러번 재사용할 수 있다. 위의 예에서는 @property.setter가 하던 일을 __set__()메서드가 대신한다.
__delete__(self, instance)
class ProtectedAttribute:
def __init__(self, requires_role=None) -> None:
self.permission_required = requires_role
self._name = None
def __set_name__(self, owner, name):
self._name = name
def __set__(self, user, value):
if value is None:
raise ValueError(f"{self._name}을 None으로 설정할 수 없음")
user.__dict__[self._name] = value
def __delete__(self, user):
if self.permission_required in user.permissions:
user.__dict__[self._name] = None
else:
raise ValueError(f"{user!s} 사용자는 {self.permission_required} 권한이 없음")
class User:
"""admin 권한을 가진 사용자만 이메일 주소를 삭제할 수 있음"""
email = ProtectedAttribute(requires_role="admin")
def __init__(self, username: str, email: str, permission_list: list = None) -> None:
self.username = username
self.email = email
self.permissions = permission_list or []
def __str__(self):
return self.username>>> admin = User("root", "root@d.com", ["admin"])
>>> user = User("user", "user1@d.com", ["email", "helpdesk"])
>>> admin.email
'root@d.com'
>>> del admin.email
>>> admin.email is None
True
>>> user.email
'user1@d.com'
>>> user.email = None
...
ValueError: email을 None으로 설정할 수 없음
>>> del user.email
...
ValueError: User 사용자는 admin 권한이 없음admin권한이 있는 사용자만 email을 삭제할 수 있다. admin권한이 없는 사용자가 해당 속성에 del을 요청하면 ValueError이 발생한다.
set_name(self, owner, name)
한 클래스에 같은 디스크립터를 이용해 두 개 이상의 스태틱 필드를 만드는 경우에 각 디스크립터가 관리하는 프로퍼티를 구분하기 위해 사용한다.
파이썬 3.6 이전에는 디스크립터가 이름을 자동으로 설정하지 못했기 때문에 객체 초기화시 명시적으로 이름을 전달했다. 이 방법은 잘 동작하지만 새로운 속성에 대한 디스크립터를 추가할 때마다 이름을 복사해야 하는 불편함이 있었다.
class DescriptorWithName:
def __init__(self, name):
self.name = name
def __get__(self, instance, value):
if instance is None:
return self
logger.debug("%r에서 %r 속성 가져오기", instance, self.name)
return instance.__dict__[self.name]
def __set__(self, instance, value):
instance.__dict__[self.name] = value위 코드는 __set_name()__ 이 없을 때의 디스크립터 코드이다.
class DescriptorWithName(DescriptorWithName):
def __init__(self, name: str = None) -> None:
self.name = name
def __set_name__(self, owner, name):
self.name = self.name or name
class ClientClass:
descriptor = DescriptorWithName("descriptor")__set_name__ 메서드를 사용하면 DescriptorWithName에 변수 이름을 복사하여 파라미터에 전달하지 않아도 된다. 하위 호환을 위해 __init__ 메서드에 기본 값을 지정하고 __set_name__을 함께 사용하는 것이 좋다.
디스크립터의 유형
디스크립터의 작동방식에 따라 디스크립터를 구분할 수 있다.
- 데이터 디스크립터:
__set__이나__delete__메서드 구현 - 비데이터 디스크립터:
__get__만을 구현
객체의 속성을 결정할 때 데이터 디스크립터는 객체의 딕셔너리보다 우선적으로 적용되지만 비데이터 디스크립터는 그렇지 않다. 만약 객체의 사전에 비데이터 디스크립터와 동일한 이름의 키가 있으면 디스크립터는 절대 호출되지 않는다.
비데이터 디스크립터
class NonDataDescriptor:
def __get__(self, instance, owner):
if instance is None:
return self
return 42
class ClientClass:
descriptor = NonDataDescriptor()>>> client = ClientClass()
>>> client.descriptor
42
>>> client.descriptor = 43
>>> client.descriptor
43
>>> vars(client)
{'descriptor': 43}
>>> del client.descriptor
>>> client.descriptor
42.descriptor속성에 다른 값을 설정하면 인스턴스의 딕셔너리가 변경된다.
이후 .descriptor속성을 조회하면 객체의 __dict__ 딕셔너리에서 descriptor키를 찾을 수 있으므로 클래스까지 검색하지 않고 __dict__ 딕셔너리에서 값을 반환한다.
때문에 디스크립터 프로토콜이 사용되지 않고 다음에 이 속성을 조회할 때는 덮어써진 43을 반환한다.
그 뒤 del을 호출해 속성을 제거하게 되면 객체의 __dict__ 딕셔너리에서 descriptor키를 지운 것과 같으므로 디스크립터 프로토콜이 다시 활성화 된다.
디스크립터가 __delete__ 메서드를 구현하지 않았기 때문에 디스크립터의 속성을 설정하면 속성이 깨지게 된다.
데이터 디스크립터
class DataDescriptor:
def __get__(self, instance, owner):
if instance is None:
return self
return 42
def __set__(self, instance, value):
logger.debug("%s.descriptor를 %s 값으로 설정", instance, value)
instance.__dict__["descriptor"] = value
class ClientClass:
descriptor = DataDescriptor()>>> client = ClientClass()
>>> client.descriptor
42
>>> client.descriptor = 99
>>> client.descriptor
42
>>> vars(client)
{'descriptor': 99}
>>> client.__dict__["descriptor"]
99
>>> del client.descriptor
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
AttributeError: __delete__descriptor의 값을 변경해도 반환 값은 변경되지 않는다. 객체의 __dict__에는 값 설정이 되었지만 데이터 디스크립터는 객체의 __dict__에서 조회하는 대신 클래스의 descriptor을 먼저 조회한다.
속성 삭제 또한 동작하지 않는다.
del을 호출하면 descriptor에서 __delete__()메서드를 호출하게 되는데 위의 예제에서는 __delete__메서드를 구현하지 않았기 때문이다.
instance.__dict__["descriptor"] = value위 코드에서는 인스턴스의 __dict__에 직접 접근해 값을 할당한다. 만약 setattr(instance, "descriptor", value) 을 통해 값을 설정하려고 시도하면 __set__ 메서드가 다시 호출되어 무한루프가 발생하게 된다.
디스크립터의 set 메서드에서 setattr()이나 할당 표현식을 직접 사용하면 안 된다.
디스크립터 실전
디스크립터를 사용한 애플리케이션
디스크립터를 사용하지 않은 예
예제에서 사용하는 클래스는 current_city를 속성으로 가진다. 프로그램 실행중 current_city의 변경 사항을 추적한다.
class Traveller:
def __init__(self, name, current_city):
self.name = name
self._current_city = current_city
self._cities_visited = [current_city]
@property
def current_city(self):
return self._current_city
@current_city.setter
def current_city(self, new_city):
if new_city != self._current_city:
self._cities_visited.append(new_city)
self._current_city = new_city
@property
def cities_visited(self):
return self._cities_visited>>> alice = Traveller("Alice", "Barcelona")
>>> alice.current_city = "Paris"
>>> alice.current_city = "Brussels"
>>> alice.current_city = "Amsterdam"
>>> alice.cities_visited
['Barcelona', 'Paris', 'Brussels', 'Amsterdam']
>>> alice.current_city
'Amsterdam'코드는 요구사항을 만족한다. 이 것이 필요한 전부라면 프로퍼티를 사용하는것 만으로도 충분하며 추가로 구현해야 할 것은 없다.
애플리케이션이 여러 곳에서 같은 로직을 사용하고자 할 경우 코드를 반복하거나 데코레이터, 디스크립터 등을사용해야 한다.
이상적인 구현방법
실질적인 코드 반복의 증거가 없거나 복잡성의 대가가 명확하지 않다면 디스크립터를 사용할 필요가 없다.
디스크립터가 다른 프로젝트에서도 동일한 결과를 내도록 설계되었기 때문에 메서드나 속성의 이름이 현재 도메인과 관련이 없다.
class HistoryTracedAttribute:
def __init__(self, trace_attribute_name: str) -> None:
self.trace_attribute_name = trace_attribute_name
self._name = None
def __set_name__(self, owner, name):
self._name = name
def __get__(self, instance, owner):
if instance is None:
return self
return instance.__dict__[self._name]
def __set__(self, instance, value):
self._track_change_in_value_for_instance(instance, value)
instance.__dict__[self._name] = value
def _track_change_in_value_for_instance(self, instance, value):
self._set_default(instance)
if self._needs_to_track_change(instance, value):
instance.__dict__[self.trace_attribute_name].append(value)
def _needs_to_track_change(self, instance, value) -> bool:
try:
current_value = instance.__dict__[self._name]
except KeyError:
return True
return value != current_value
def _set_default(self, instance):
instance.__dict__.setdefault(self.trace_attribute_name, [])
class Traveller:
current_city = HistoryTracedAttribute("cities_visited")
def __init__(self, name, current_city):
self.name = name
self.current_city = current_city예제에서 디스크립터의 코드는 복잡해졌지만 클라이언트 클래스의 코드는 간결해졌다.
디스크립터 안에서는 어떠한 비즈니스 로직도 포함되어 있지 않다. 따라서 다른 어떤 클래스에 적용하여도 동일하게 동작할 것이다.
다른 형태의 디스크립터
전역 상태 공유 이슈
디스크립터는 클래스 속성으로 설정해야 한다.
클래스 속성의 문제점은 해당 클래스의 모든 인스턴스에서 공유된다는 것이다. 디스크립터도 예외가 아니기 때문에 디스크립터 객체에 데이터를 보관하면 모든 객체가 동일한 값에 접근할 수 있다.
class SharedDataDescriptor:
def __init__(self, initial_value):
self.value = initial_value
def __get__(self, instance, owner):
if instance is None:
return self
return self.value
def __set__(self, instance, value):
self.value = value
class ClientClass:
descriptor = SharedDataDescriptor("첫번째 값")디스크립터가 객체를 바로 저장하게 되는 경우에 인스턴스의 값을 수정하면 같은 클래스의 다른 모든 인스턴스에서도 값이 수정된다.
>>> client1 = ClientClass()
>>> client1.descriptor
'첫 번째 값'
>>> client2 = ClientClass()
>>> client2.descriptor
'첫 번째 값'
>>> client2.descriptor = "client 2를 위한 값"
>>> client2.descriptor
'client 2를 위한 값'
>>> client1.descriptor
'client 2를 위한 값'약한 참조 사용
__dict__를 사용하지 않으려는 경우 다른 대안은 디스크립터 객체가 내부 매핑을 통해 각 인스턴스의 값을 보관하고 반환하는 것이다.
내부 매핑을 할 때는 딕셔너리를 사용해서는 안 된다. 클라이언트 클래스는 디스크립터에 대한 참조를 가지며 디스크립터는 디스크립터를 사용하는 객체에 대한 참조를가지므로 순환 종속성이 생겨 가비지 컬렉션이 되지 않는다.
이러한 문제를 해결하기 위해서 딕셔너리는 약한 키가 되어야 한다.
from weakref import WeakKeyDictionary
class DescriptorClass:
def __init__(self, initial_value):
self.value = initial_value
self.mapping = WeakKeyDictionary()
def __get__(self, instance, owner):
if instance is None:
return self
return self.mapping.get(instance, self.value)
def __set__(self, instance, value):
self.mapping[instance] = value이렇게 하면 문제가 해결되지만 몇 가지 고려사항이 존재한다.
- 인스턴스 객체는 속성을 보유하지 않는다. 객체의 딕셔너리에 있는 내용을 찾으려고 할 경우 완전한 데이터를 반환하지 않는다. (예:
vars(client)호출) - 객체는
__hash__메서드를 구현하여 해시가 가능해야 한다. 만약 해시가 가능하지 않다면WeakKeyDictionary에 매핑할 수 없다.
디스크립터에 대한 추가 고려사항
코드 재사용
디스크립터는 코드 중복을 피하기 위한 일반적인 도구이자 강력한 추상화 도구이다. 디스크립터가 필요한 곳을 찾는 좋은 방법은 프로퍼티가 필요한 구조가 반복되는 경우를 찾는 것이다.
프로퍼티는 디스크립터의 특수한경우이며 디스크립터는 프로퍼티보다 훨씬 복잡한 작업에 사용될 수 있다.
디스크립터는 데코레이터가 클래스 메서드에서도 동작할 수 있도록 도와 더 나은 데코레이터를 만들 수 있게 한다. 데코레이터는 항상 __get__() 메서드를 구현하고 디스크립터를 사용하는 것이 안전하다.
데코레이터, 디스크립터를 만들 가치가 있는지 결정할 때는 같은 코드가 3회 이상 필요한지 확인해야 한다. 하지만 클라이언트가 사용하게 되는 내부API에 대해서는 디스크립터를 사용하는 것이 좋다. 이는 일회성 솔루션이 아닌 라이브러리나 프레임워크의 디자인에대해 기능을 확장하기 좋기 때문이다.
매우 특별한 이유가 있거나 코드를 훨씬 좋게하는 무언가가 없다면 디스크립터에 비즈니스 로직을 넣어서는 안 된다.
클래스 데코레이터 피하기
from functools import partial
from typing import Any, Callable
class BaseFieldTransformation:
def __init__(self, transformation: Callable[[Any, str], str]) -> None:
self._name = None
self.transformation = transformation
def __get__(self, instance, owner):
if instance is None:
return self
raw_value = instance.__dict__[self._name]
return self.transformation(raw_value)
def __set_name__(self, owner, name):
self._name = name
def __set__(self, instance, value):
instance.__dict__[self._name] = value
ShowOriginal = partial(BaseFieldTransformation, transformation=lambda x: x)
HideField = partial(BaseFieldTransformation, transformation=lambda x: "**redacted**")
FormatTime = partial(BaseFieldTransformation,transformation=lambda ft: ft.strftime("%Y-%m-%d %H:%M"),)위의 예제는 각 속성에 대해 주어진 함수를 적용시킨 후 수정된 버전을 반환하는 디스크립터이다.
class LoginEvent:
username = ShowOriginal()
password = HideField()
ip = ShowOriginal()
timestamp = FormatTime()
def __init__(self, username, password, ip, timestamp):
self.username = username
self.password = password
self.ip = ip
self.timestamp = timestamp
def serialize(self):
return {
"username": self.username,
"password": self.password,
"ip": self.ip,
"timestamp": self.timestamp,
}객체의 __dict__ 딕셔너리에 접근하여 원본 값을 가져올 수도 있다. 그러나 기본적으로는 값을 요청하면 변환된 값을 반환한다.
디스크립터는 객체이므로 모델을 만들어서 객체 지향 프로그래밍의 모든 규칙을 적용할 수 있다. 디자인 패턴은 디스크립터에도 적용된다. 계층 구조를 정의하고 사용자 정의 동작을 설정하는 등의 작업을 할 수 있다.
__init__()와 serialize() 메서드를 구현한 기본 클래스를 만들고 그 클래스를 상속받아 LoginEvent 클래스를 정의할 수 있다.
class BaseEvent:
def __init__(self, **kwargs):
self.__dict__.update(kwargs)
def serialize(self):
return {
attr: getattr(self, attr) for attr in self._fields_to_serialize()
}
def _fields_to_serialize(self):
for attr_name, value in vars(self.__class__).items():
if isinstance(value, BaseFieldTransformation):
yield attr_name
class NewLoginEvent(BaseEvent):
username = ShowOriginal()
password = HideField()
ip = ShowOriginal()
timestamp = FormatTime()이렇게 코드를 작성하면 필요한 속성만 정의하면 되고 각 속성의 클래스를 보면 어떤 로직이 적용되었는지 바로 이해할 수 있다. 기본 클래스는 공통 메서드만 추상화할 것이고, 각 이벤트 클래스는 더 작고 간단하게 된다.
각 이벤트 클래스가 단순해질 뿐만 아니라, 디스크립터 자체도 작아서 클래스 데코레이터보다 훨씬 간단하다.
디스크립터 분석
파이썬 내부에서의 디스크립터 활용
함수와 메서드
함수는 __get__ 메서드를 구현했기 때문에 클래스 안에서 메서드처럼 동작할 수 있다.
메서드는 추가 파라미터를 가진 함수일 뿐이다. 관습적으로 메서드의 첫 번째 파라미터는 self라는 이름을 사용하여 해당 메서드를 소유하고 있는 클래스의 인스턴스를 나타낸다. 따라서 메서드에서 self를 사용하는 것은 객체를 받아서 수정을 하는 함수를 사용하는 것과 동일하다.
다음과 같은 형태로 메서드를 호출하면
instance = MyClass()
instance.method(...)파이썬은 내부적으로 디스크립터를 사용하여 다음과 같이 변환하여 처리한다.
instance = MyClass()
MyClass.method(instance)함수는 디스크립터 프로토콜을 구현했으므로 메서드를 호출하기 전 __get__() 메서드가 먼저 호출된 후 필요한 작업을 수행한다.
class Method:
def __init__(self, name):
self.name = name
def __call__(self, instance, arg1, arg2):
print(f"{self.name}: {instance} 호출됨. 인자는 {arg1} 과 {arg2} 입니다.")
class MyClass:
method = Method("Internal call") 이 함수는 전달 받은 3개의 인자를 그대로 출력한다. __call__() 메서드에서 self는 Method의 인스턴스를 나타낸다.
instance = MyClass()
Method('External call')(instance, "first", "second")
instance.method("first", "second")위의 두 가지 호출은 동일한 역할을 할 것으로 보인다. 하지만 첫 번째 호출은 동작하지만 두 번째 호출은 에러를 발생시킨다.
오류가 발생하는 이유는 파라미터가 한 칸씩 밀려 arg2자리에 아무 것도 전달되지 않았기 때문이다.
메서드를 디스크립터로 변경함으로써 이 문제를 해결할 수 있다.
디스크립터로 변경하면 instance.method 호출 시 Method.__get__ 메서드를 먼저 호출할 것이다. 여기에서 첫 번째 파라미터로 Method의 인스턴스를 전달함으로써 객체에 바인딩 해 준다.
from types import MethodType
class Method:
def __init__(self, name):
self.name = name
def __call__(self, instance, arg1, arg2):
print(f"{self.name}: {instance} 호출됨. 인자는 {arg1} 과 {arg2} 입니다.")
def __get__(self, instance, owner):
if instance is None:
return self
return MethodType(self, instance)이렇게 수정하면 두 호출이 모두 예상대로 동작한다.
types 모듈의 MethodType을 사용하여 함수를 메서드로 변환하였다.
파이썬의 함수 객체도 이것과 비슷하게 동작한다. 따라서 클래스 내부에 함수를 정의할 경우 메서드처럼 동작하는 것이다.
메서드를 위한 빌트인 데코레이터
메서드를 인스턴스가 아닌 클래스에서 직접 호출할 때는 디스크립터 자체를 반환한다. @classmethod를 사용하면 데코레이팅 함수의 첫 번째 파라미터로 메서드를 소유한 클래스를 넘겨준다.
@staticmethod를 사용하면 정의한 파라미터 이외의 파라미터를 넘기지 않는다. 즉 함수의 첫 번째 파라미터에 self를 바인딩하는 작업을 취소한다.
슬롯
클래스에 __slot__ 속성을 정의하면 클래스가 기대하는 특정 속성만 정의하고 다른 것은 제한할 수 있다.
__slot__에 정의되지 않은 속성을 동적으로 추가하려고 할 경우 AttributeError가 발생한다. 이 속성을 정의하면 클래스는 정적으로 되고 __dict__ 속성을 갖지 않는다. 따라서 객체에 동적으로 속성을 추가할 수 없다.
이것은 파이썬의 동적인 특성을 없애기 때문에 조심해서 사용해야 한다.