파이썬 클린코드 | 6장 디스크립터로 멋진 객체 만들기

파이썬 클린코드 | 6장 디스크립터로 멋진 객체 만들기

날짜
Dec 16, 2023
태그
python
설명
파이썬의 객체지향을 한 단계 끌어올리는 혁신적 기능
 
📌
- 디스크립터가 무엇이고 어떻게 동작하는지 확인 - 어떻게 효율적으로 구현하는지 이해 - 데이터, 비데이터 디스크립터의 개념적 차이와 세부 구현의 차이를 분석 - 디스크립터를 활용한 코드의 재사용 방법 - 디스크립터의 좋은 사용
 

디스크립터 개요

 

디스크립터 메커니즘

디스크립터를 구현하려면 최소 두개의 클래스가 필요하다. 클래스에는 최소 한개 이상의 매직 메서드를 포함해야 한다. 아래의 매직 매서드 중 어느 하나가 어트리뷰트에 정의된다면 디스크립터라고 정의한다. 구현하는 이유는 클래스 변수에 저장된 객체가 어트리뷰트 조회 중 발생하는 일을 제어할 수 있도록 하는 것이다.
  • __get__
  • __set__
  • __delete__
  • __set_name__
 
 
이름
의미
ClientClass
디스크립터 구현의 기능을 활용할 도메인 추상화 객체. 디스크립터의 클라이언트이다.
DescriptorClass
디스크립터 로직을 구현한 클래스. 이 클래스는 디스크립터 프로토콜을 따르는 매직 메서드를 구현해야만 한다.
client
ClientClass 의 인스턴스. client = ClientClass()
descriptor
DescritorClass 의 인스턴스. descriptor = DescriptorClass()
 
ClientClass와 Descriptor의 관계(Composition(구성) 관계: 차와 바퀴의 관계)
ClientClass와 Descriptor의 관계(Composition(구성) 관계: 차와 바퀴의 관계)
 
이 프로토콜이 동작하려면 디스크립터 객체가 클래스 속성으로 정의되어 있어야 한다. 이 객체를 인스턴스 속성으로 생성하면 동작하지 않으므로 init 메서드가 아니라 클래스 본문에 있어야 한다.
 
 
>>> class Attribute: ... value = 42 ... >>> class Client: ... attribute = Attribute() ... >>> Client().attribute <__main__.Attribute object at 0x7..> >>> Client().attribute.value 42
이 모든 동작은 런타임 중에 어떻게 동작하는 것일까? 일반적인 클래스의 속성 또는 프로퍼티에 접근하면 예상한 것과 같은 결과를 얻을 수 있다.
 
디스크립터의 경우 약간 다르게 동작한다. 클래스 속성을 객체로 선언하면 디스크립터로 인식되고, 클라이언트에서 해당 속성을 호출하면 객체 자체를 반환하는 것이 아니라 __get__ 매직 메서드의 결과를 반환한다.
 
class DescriptorClass: def __get__(self, instance, owner): if instance is None: return self logger.info( "%s.__get__ 메서드 호출(%r, %r)", self.__class__.__name__, instance, owner ) return instance class ClientClass: descriptor = DescriptorClass()
>>> client = ClientClass() >>> client.descriptor INFO: DescriptorClass.__get__ 메서드 호출 (<ClientClass object at ...>), <class 'ClientClass'>) <ClientClass object at ...> >>> client.descriptor is client INFO: DescriptorClass.__get__ 메서드 호출 (<ClientClass object at ...>), <class 'ClientClass'>) True
 
디스크립터를 사용하면 완전히 새롭게 프로그램의 제어 흐름을 변경할 수 있다. 이 도구를 사용해 __get__ 메서드 뒤쪽으로 모든 종류의 로직을 추상화할 수 있으며 클라이언트에게 세부 내용을 숨긴채로 모든 유형의 변환을 투명하게 실행할 수 있다. 새로운 레벨의 추상화이다.
 

디스크립터 프로토콜의 메서드 탐색

 

get 메서드

__get__(self, instance, owner) # owner = instance.__class__
__get__ 매직메서드의 서명
 
instance는 디스트립터를 호출한 객체(client)를 의미한다. owner는 호출한 객체의 클래스(ClientClass)를 의미한다. 즉, instance는 디스크립터의 객체의 인스턴스이고, owner 는 디스크립터를 소유한 클래스라고 생각하면 이해가 쉽다.
 
굳이 instance에서 클래스를 직접 구할 수 있음에도 owner를 서명에 정의했는지 궁금할 것이다. 이렇게 하는 이유는 client 인스턴스가 아니라 ClientClass 에서 descriptor를 직접 호출하는 특별한 경우 때문이다. 이런 경우 instance의 값은 None 이기 때문에 클래스를 구할 수 없고, 굳이 따로 파라미터를 추가하여 owner 를 받는 것이다.
 
이해가 잘 되지 않는다. 다음 코드를 사용해 디스크립터가 클래스에서 호출될 때와 인스턴스에서 호출될 때의 차이를 보자.
 
class DescriptorClass: def __get__(self, instance, owner): if instance is None: # 클래스에서 호출된 경우 return f"{self.__class__.__name__}.{owner.__name__}" return f"{instance} 인스턴스" # 인스턴스에서 호출된 경우 class ClientClass: descriptor = DescriptorClass()
descriptor_methods_1.py
>>> ClientClass.descriptor 'DescriptorClass.ClientClass'
ClientClass 에서 직접 호출
>>> ClientClass().descriptor '<descriptors_methods_1.ClientClass object at 0x...> 인스턴스'
객체에서 호출
 
 

set 메서드

__set__(self, instance, value)
__set__ 매직메서드의 서명
 
디스크립터에게 값을 할당하려고 할 때 호출된다.
client.descriptor = "value"
instance 파라미터의 디스크립터에 문자열 “value”를 할당한다.
@property.setter 데코레이터의 동작방식과 비슷하다.
 
이 기능을 잘 활용하면 강력한 추상화를 할 수 있다. 예를 들어, 자주 사용되는 유효성 검사 객체를 디스크립터로 만들면 프로퍼티의 세터 메서드에서 같은 유효성 검사를 반복할 필요가 없다. 유효성 검사 함수는 자유롭게 생성하여 지정할 수 있으며 객체에 값을 할당하기 전에 실행된다.
 
class Validator: 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: isintance(x, (int, float)), "는 숫자가 아님"), validation(lambda x: x >= 0, "는 0보다 작음"), )
__set__() 매직 메서드가 @property.setter 가 하던 일을 대신한다.
>>> client = ClientClass() >>> client.descriptor = 42 >>> client.descriptor 42 >>> client.descriptor = -42 Traceback (most recent call last): ... ValueError: -42는 0보다 작음 >>> client.descriptor = "invalid value" ... ValueError: 'invalid value'는 숫자가 아님
프로퍼티 자리에 놓일 수 있는 것은 디스크립터로 추상화할 수 있으며 여러 번 재사용할 수 있다는 것이다.
 

delete 메서드

__delete__(self, instance)
__delete__ 매직메서드의 서명
 
class ProtectedAttribute: def __init__(self, requires_role=None) -> None: self.permission_required = required_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(required_role="admin") def __init__( self, username: str, email: str: str, permission_list: list = None ) -> None: self.username = username self.email = email self.permissions = permission_list or [] def __str__(self): return self.username
admin권한을 가진 사용자만 email을 삭제할 수 있고, email을 삭제하면 None 으로 설정한다.
>>> 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 권한이 없음
 
 
 

set_name 메서드

__set_name__(self, owner, name)
__set_name__ 매직메서드의 서명
 
일반적으로 클래스에 디스크립터 객체를 만들 때는 디스크립터가 처리하려는 속성의 이름을 알아야 한다. 속성의 이름은 __dict__ 에서 __get____set__ 메서드로 읽고 쓸때 사용된다.
 
파라미터로는 디스크립터를 소유한 클래스와 디스크립터의 이름을 받는다. 디스크립터에 이 메서드를 추가하여 필요한 이름을 지정하면 된다.
 
일반적으로 __init__ 메서드에 기본 값을 지정하고 __set_name__ 을 함께 사용하는 것이 좋다.
 
class DescriptorWithName: def __init__(self, name=None): self.name = name def __set_name__(self, owner, name): self.name = name
 
디스크립터의 이름을 다른 값으로 설정하고 싶은 경우 우선순위가 높은 __init__ 메서드도 사용할 수 있기 때문에 유연성을 유지할 수 있다. 디스크립터의 이름으로 무엇이든 사용할 수 있지만 일반적으로 디스크립터의 이름(속성 이름)을 클라이언트 __dict__ 객체의 키로 사용한다. 즉, 디스크립터의 이름도 속성으로 해석된다는 것을 의미한다. 그런 이유로 가급적 유효한 파이썬 변수명을 사용하려고 노력해야 한다.
 
 

디스크립터의 유형

 
디스크립터의 작동방식에 따라 디스크립터를 구분할 수 있다. 이 구분을 이해하는 것은 디스크립터의 런타임 시 경고 또는 일반적인 오류를 피하는데 도움이 된다.
 
유형
메서드
데이터 디스크립터
__set__, __delete__
비데이터 디스크립터
__get__
미분류
__set_name__
 
비데이터 디스크립터는 객체의 사전에 디스크립터와 동일한 이름의 키가 있으면 객체의 사전 값이 적용되고 디스크립터는 절대 호출되지 않을 것이다.
 
반대로, 데이터 디스크립터에서는 디스크립터와 동일한 이름을 갖는 키가 사전에 존재하더라도 디스크립터 자체가 항상 먼저 호출되기 때문에 객체의 키 값은 결코 사용되지 않을 것이다.
 
디스크립터는 객체의 __dict__ 사전과 별개의 객체임을 기억하자. 둘은 별개의 객체이지만 같은 이름으로 접근할 수 있으므로 우선순위 지정이 필요한데, 데이터 디스크립터는 객체의 사전보다 우선순위가 높고, 비데이터 디스크립터는 객체의 사전보다 우선순위가 낮다.
 

비데이터 디스크립터

 
class NonDataDescriptor: def __init__(self, instance, owner): if instance in None: return self return 42 class ClientClass: descriptor = NonDataDescriptor()
__get__ 매직 메서드만을 구현한 디스크립터
 
>>> client = ClientClass() >>> client.descriptor 42
descriptor 를 호출하면 __get__ 매직 메서드의 결과를 얻을 수 있다.
>>> client.descriptor = 43 >>> client.descriptor 43
descriptor 속성을 다른 값으로 바꾸면 이전의 값을 잃고 대신 새로 설정한 값을 갖는다.
>>> del client.descriptor >>> client.descriptor 42
descriptor 를 지우고 다시 물으면 __get__ 매직 메서드의 값을 반환한다.
>>> vars(client) {}
처음 client 객체를 만들었을 때 descriptor 속성은 인스턴스가 아니라 클래스 안에 있다. 따라서 client 객체의 사전을 조회하면 값은 비어 있다.
 
descriptor 의 속성을 조회하면 첫번째로 client.__dict__ 에서 “descriptor” 라는 이름의 키를 찾지 못하고, 다음으로 클래스에서 디스크립터를 찾아보게 된다. 이것이 __get__ 매직 메서드의 결과가 반환되는 이유이다.
 
>>> client.descriptor = 99 >>> vars(client) {'descriptor': 99}
그러나 descriptor 속성에 다른 값을 설정하면 인스턴스의 사전이 변경되므로 client.__dict__ 은 비어있지 않다.
 
descriptor 속성을 조회하면 객체의 __dict__ 사전에서 descriptor 키를 찾을 수 있으므로 클래스까지 검색하지 않고 바로 __dict__ 사전에서 값을 반환한다. 디스크립터 프로토콜이 사용되지 않고 다음에 이 속성을 조회할 때는 덮어써진 99 값을 반환한다.
 
>>> del client.descriptor >>> vars(client) {} >>> client.descriptor 42
del 을 호출해 이 속성을 지우면 객체의 __dict__ 사전에서 descriptor 키를 지운 것과 같으므로 다시 디스크립터 프로토콜이 활성화된다.
 
descriptor 에 속성값을 설정하면 우연히 디스크립터가 깨진 것처럼 동작하게 된다. 왜냐하면 디스크립터가 __delete__ 매직 메서드를 구현하지 않았기 때문이다.
 
이런 유형의 디스크립터는 다음 섹션에서 볼 예정인 __set__ 매직 메서드를 구현하지 않았기 때문이다.
 

데이터 디스크립터

 
class DataDescriptor: def __get__(self, instance, owner): if instance is None: return self return 42 def __set__(self, instace, value): logger.debug("%s.descriptor를 %s 값으로 설정", instance, value) instance.__dict__["descriptor"] = value class ClientClass: descriptor = DataDescriptor()
>>> client = ClientClass() >>> client.descriptor 42
descriptor 의 값을 확인
>>> client.descriptor = 99 >>> client.descriptor 42
descriptor 의 값을 변경
descriptor 의 값이 변경되지 않았다.
>>> vars(client) {'descriptor': 99} >>> client.__dict__["descriptor"] 99
다른 값으로 할당하면 객체의 __dict__ 사전에는 업데이트 돼야 한다. 이렇게 되는 이유는 사실 __set__() 매직 메서드가 호출되면 객체의 사전에 값을 설정하기 때문이다. 데이터 디스크립터에서 속성을 조회하면 객체의 __dict__ 에서 조회하는 대신 클래스의 descriptor를 먼저 조회하게 된다. 데이터 디스크립터는 인스턴스의 __dict__ 를 오버라이드 하여 인스턴스 사전보다 높은 우선순위를 가지지만, 비데이터 디스크립터는 인스턴스 사전보다 낮은 우선순위를 가진다.
 
>>> del client.descriptor Traceback (most recent call last): ... AttributeError: __delete__
속성 삭제는 더 이상 동작하지 않는다.
삭제가 되지 않는 이유는 del 을 호출하면 인스턴스의 __dict__ 사전에서 속성을 지우려고 시도하는 것이 아니라 descriptor 에서 __delete__() 매직 메서드를 호출하게 되는데 이 예제에서는 __delete__() 를 구현하지 않았기 때문이다.
 
이것이 데이터, 비데이터 디스크립터의 차이이다. 만약 디스크립터가 __set__() 메서드를 구현했다면 객체의 사전보다 높은 우선순위를 갖는다. __set__() 매직 메서드를 구현하지 않았다면 객체의 사전이 우선순위를 갖고 그 다음에 디스크립터가 실행된다.
 
instance.__dict__["descriptor"] = value
어쩌면 발견했을지 모르는 흥미로운 코드다. 살펴볼 내용이 많아 보인다.
 
첫째, 왜 하필 “descriptor”라는 이름의 속성 값을 바꾸는 걸까?
예제는 단순화를 위해 디스크립터의 이름을 따로 설정하지 않았기 때문이다. 이것은 더 적은 코드를 사용하기 위해 단순화 한 것 이지만, 이전 섹션에서 본 __set_name__ 을 사용하면 쉽게 해결할 수 있다. 실제로는 __init__ 메서드에서 디스크립터의 이름을 받아서 내부에 저장하거나 또는 __set_name__ 메서드를 사용해서 이름을 설정할 수 있다.
 
다음으로 인스턴스의 __dict__ 속성에 직접 접근하는 이유는 무엇일까?
적어도 두가지의 설명이 있다. 첫째로 단순하게 다음처럼 하지 않았을까?
setattr(instance, "descriptor", value)
이와 같은 패턴은 디스크립터와 함께 무한루프를 만들게 된다.
디스크립터의 속성에 무언가 할당하려고 하면 __set__ 매직 메서드가 호출된다는 것을 기억하자.
따라서 setattr() 를 사용하면 디스크립터의 __set__ 매직 메서드가 호출되고 __set__ 매직 메서드는 setattr를 호출하고 … 의 무한 루프가 발생하게 된다.
 
📌
디스크립터의 __set__ 매직 메서드에서 setattr() 이나 할당 표현식을 직접 사용하면 무한 루프가 발생한다.
 
디스크립터가 모든 인스턴스의 프로퍼티 값을 보관할 수 없는 이유는 뭘까?
클라이언트 클래스는 이미 디스크립터의 참조를 가지고 있다. 디스크립터가 다시 클라이언트 객체를 참조하면 순환 종속성이 생기게 되어 가비지 컬렉션이 되지 않는 문제가 생긴다. 서로를 가리키고 있기 때문에 참조 카운트가 제거 임계치 이하로 떨어지지 않는다.
 
📌
디스크립터 작업을 할때 잠재적인 메모리 누수에 주의해야 한다. 순환 의존성을 만들지 않았는지 확인한다.
 
이에 대안은 weakref 모듈에 있는 약한 참조를 활요한 방법이다. 이러한 구현 방법을 이용하는 것은 상당히 일반적인 경우이다.
 
 

디스크립터 실전

 

디스크립터를 사용한 애플리케이션

디스크립터를 사용하면 중복 코드를 추상화하여 클라이언트의 코드가 줄어든다.
 

디스크립터를 사용하지 않은 예

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
여행자와 방문 도시를 표현하고 있다.
 
>>> googie = Traveller("googie", "seoul") >>> googie.current_city = "paris" >>> googie.current_city = "tokyo" >>> googie.cities_visited ["seoul", "paris", "tokyo"]
방문했던 도시를 프로퍼티로 구현했다
 
만약 다른 클래스나 앱의 여러곳에서 동일한 요구사항이 있다면 같은 코드가 반복되어야 한다. 디스크립터를 사용하면 클라이언트의 코드를 줄일 수 있다.
 

이상적인 구현방법

class HistoryTracedAttribute: def __init__(self, trace_attribute_name) -> 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
디스크립터를 사용하여 구현했다. 비즈니스 로직이 들어가 있지 않기 때문에 호환성이 좋다.
 
>>> googie = Traveller("googie", "seoul")
 
디스크립터를 사용하여 얻은 장점은 ‘클라이언트 코드가 간결’ 하게 변경되었고, 어떠한 ‘비즈니스 로직이 포함되어 있지 않아서’ 다른 클래스에서도 사용이 가능하다는 점이다. 그래서 디스크립터는 비즈니스 로직의 구현보다는 라이브러리 혹은 프레임워크의 내부 API 를 구현하는 것에 적합하다고 평가된다.
 

다른 형태의 디스크립터

 
클라이언트 클래스가 이미 디스크립터의 참조를 가지고 있기 때문에 디스크립터가 다시 클라이언트 객체를 참조할 경우 순환종속성이 생기는 것은 불가피하다. 앞서 이야기한 setattr() 의 무한 루프 같은 문제가 발생하는 이유이다. 이는 약한 참조를 사용하여 해결할 수 있다.
 

전역 상태 공유 이슈

클래스 속성으로 디스크립터를 추가하고 있다. 이에 따른 문제점은 디스크립터가 클래스의 모든 인스턴스에서 공유된다는 것이다. 즉, 디스크립터 객체에 데이터를 보관하면 모든 객체가 동일한 값에 접근할 수 있다.
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("name is")
>>> googie = ClientClass() >>> googie.descriptor 'name is' >>> lena = ClientClass() >>> lena.descriptor 'name is' >>> lena.descriptor = "lena is" >>> lena.descriptor 'lena is' >>> googie.descriptor 'lena is' # ClientClass.descriptor 가 고유하기 때문
다른 객체가 동일한 디스크립터 클래스를 공유하면서 생기는 문제
 
이러한 문제를 해결하려면 __dict__, setattr(), getattr() 을 이용해서 구현할 수 있다. 앞서 말한 setattr(), getattr() 은 순환종속성 이슈가 있으니 불가피하게 __dict__을 사용해야 한다.
 
데이터를 바로 저장하는 방식이 아닌 인스턴스의 값을 보관했다가 반환하는 형식으로 구현한다.
 

객체의 사전에 접근하기

만약 __dict__를 사용하지 않고도 다른 대안이 있을까? 디스크립터 객체가 직접 내부 매핑을 통해 각 인스턴스의 값을 보관하고 반환하는 방법이다. 하지만 이 방법은 순환종속성 이슈가 생길 수 있다. 따라서 약한 참조(weakref) 를 사용해야 한다.
 

약한 참조 사용

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
인스턴스 객체는 더이상 속성을 보유하지 않고, 디스크립터가 속성을 보유한다.
 
디스크립터 객체가 직접 내부에서 맵핑을 하여 각 인스턴스의 값을 보관하고 반환한다. 한가지 주의할 점은 객체가 __hash__ 메서드 구현이 가능해야 한다는 것이다(WeakKeyDictionary 패키지에서 __hash__ 를 사용해 값을 검색, 매핑하기 때문이다)
 
WeakKeyDictionary 는 다른 곳에서 더 이상 참조되지 않으면 인스턴스(key)가 가비지 수집되는 것을 방지하지 않는 방식으로 클래스의 인스턴스를 값과 연결하는 데에 사용되고 있다. 더 이상 필요없는 인스턴스가 python의 가비지콜렉터에 의해 수집되는 것을 방지하고 클래스 인스턴스에 추가 데이터를 첨부하려는 경우 유용하다.
 

디스크립터에 대한 추가 고려사항

디스크립터의 장점과 사용 사례를 보자.
 

코드 재사용

디스크립터는 코드 중복을 피하기 위한 강력한 추상화 도구이다. 특히 프로퍼티가 필요한 구조가 반복되는 곳에 사용하기 좋다. 만들 때에는 비지니스 코드가 아닌 구현 코드가 더 많이 포함되어야 하며, 3의 규칙을 적용해야 한다.
 

클래스 데코레이터의 대안

@Serializaion( username=show_original, password=hide_field, ip=show_original, timestamp=format_time, ) @dataclass class LoginEvent: username: str password: str ip: str timestamp: datetime
저번 챕터의 언급한 데코레이터를 통한 코드 개선 방법
 
디스크립터를 사용해 위 코드를 더 깔끔하고 이해가 쉽게 작성할 수 있다.
 
from functools import partial from typing import Callable class BaseFieldTransformation: def __init__(self, trasformation: Callable[[], str]) -> None: self._name = None self.trasformation = 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: "**민감한 정보 삭제**" ) FormatTime = partial( BaseFieldTransformation, transformation=lambda ft: ft.strftime("%Y-%m-%d %H:%M") ) class LoginEvent: username = ShowOriginal() poassword = 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, }
디스크립터를 사용한 구현
 
class LoginEvent(BaseEvent): username = ShowOriginal() password = HideField() ip = ShowOriginal() timestamp = FormatTime()
이전과 비교해 깔끔해진 클래스
 
__init__()serialize() 메서드를 구현한 BaseEvent를 만들고 그걸 상속받는다면, 위 코드와 같이 더 깔끔한 클래스를 작성할 수 있다. 결과적으로 각 이벤트 클래스는 작고 간단해지며, 클래스 데코레이터보다 간단하고 이해가 쉽다.
 

디스크립터 분석

파이썬 내부에서의 디스크립터 활용

파이썬은 디스크립터를 어떻게 사용하고 있을까?
 

함수와 메서드

class MyClass: def method(self, ...): self.x = 1
파이썬 함수 역시 디스크립터에 해당한다.
함수는 기본적으로 __get__() 메서드를 구현했기 때문에 클래스 안에서 메서드처럼 동작할 수 있다.
 
class MyClass: pass def method(myclass_instance, ...): myclass_instance.x = 1 method(MyClass())
위 코드와 같은 기능을 한다.
 
메서드는 객체를 수정하는 다른 함수일 뿐, 객체 안에 정의되어 객체에 바인딩 되어있다고 말한다.
 
 

메서드를 위한 빌트인 데코레이터

공식문서에 설명된 바와 같이 @property@classmethod@staticmethod 데코레이터는 디스크립터이다.
@property를 클래스에서 직접 호출하면 계산할 속성이 없으므로 프로퍼티 객체 자체를 반환하며,
@classmethod를 사용하면 데코레이팅 함수에 첫 번째 파라미터로 메서드를 소유한 클래스를 넘겨주고, @staticmehtod를 사용하면 정의한 파라미터 이외의 파라미터를 넘기지 않도록 한다.
 

슬롯(slot)

__slots__ 매직 메서드를 사용하면 클래스가 기대하는 특정 속성만을 정의하며, 정의되지 않은 동적 속성에 대해 AttributeError를 발생시킨다.
 
슬롯을 이용한 객체는 고정된 필드 값만 저장하면 되므로 메모리를 덜 사용한다는 장점이 있지만, 파이썬의 동적인 특성을 없애므로 사용에 유의해야한다.
 

데코레이터를 디스크립터로 구현하기

데코레이터를 구현하면서 발생하는 문제를 디스크립터로 해결할 수 있다. 일반적인 방법은 __get__() 메서드를 구현하는 것이다. 그리고 types.MethodType을 사용해 데코레이터 자체를 객체에 바인딩된 메서드로 만드는 것이다. 유의할 점은 데코레이터를 객체로 구현하거나 클래스로 정의해야한다는 점이다. 함수는 이미 __get__메서드가 존재하므로 함수로 구현 시 데코레이터가 정상적으로 동작하지 않을 수 있다.
class LogCallsDecorator: def __init__(self, func): self.func = func def __get__(self, instance, owner): # 클래스를 가진 인스턴스의 메서드에 액세스할 때 호출됩니다. if instance is None: return self.func # 클래스 자체에서 액세스하여 원래 기능을 반환합니다. # 원래 방법을 감싼 새 방법을 정의합니다. def wrapped_method(*args, **kwargs): print(f"Calling method '{self.func.__name__}'") result = self.func(instance, *args, **kwargs) print(f"Method '{self.func.__name__}' finished") return result return wrapped_method class MyClass: def __init__(self, value): self.value = value @LogCallsDecorator def add(self, x, y): return x + y obj = MyClass(10) result = obj.add(5, 7) # descriptor의 __get__ 메소드를 트리거 print("Result:", result) # Result: 12
 
 

댓글

guest