python clean code chapter6
Python Clean Code
6. 디스크립터로 더 멋진 객체 만들기
데이터 디스크립터, 비데이터 디스크립터 2가지 유형이 있다.
6-1. 디스크립터
복잡하진 않으나 세부구현시 주의사항이 많다.
- 최소 2 개의 클래스가 필요하다.
-
클라이언트 클래스: 디스크립터 구현 기능을 활용할 도메인 모델
-
디스크립터 클래스: 디스크립터 로직의 구현체
-
디스크립터 클래스는 다음 매직 매서드 중에 최소 한 개 이상을 포함해야 한다.(python ver3.6기준)
__get__
__set__
__delete__
__set_name__
다음과 같은 네이밍 컨벤션을 사용한다.
- ClientClass 클래스 속성으로 descriptor를 갖는다.
- DescriptorClass
- client
- descriptor
여기서의 클래스 속성은 클래스 본문에 정의한 속성이다.
__init__내부에서 정의하지 않음을 주의.(내부에서 정의하면 인스턴스 속성이라 한다.)
여러 객체가 값을 공유하는 특성이 있다.
__get__메서드
__get__(self,instance,owner)
instance 파라미터는 디스크립터를 호출한 객체를 의미한다. 디스크립터가 행동을 취하려는 객체
owner 파라미터는 인스턴스의 클래스를 의미한다.
class Descriptorclass:
def __get__(self,instance,owner):
if instance is None:
return f"{self.__class__.__name__}.{owner.__name__}"
return f"value for {instance}"
class ClientClass:
descriptor = DescriptorClass()
ClientClass 에서 직접호출시 네임스페이스와 클래스이름 출력
>>>ClientClass.descriptor
'DescriptorClass.ClientClass
#클래스 속성으로 descriptor를 호출
#이때, ClientClass 인스턴스에서 호출 한게 아니므로 instance는 None 이다.
>>>ClientClass().descriptor
'value for <instance ...>'
#인스턴스를 생성하고 descriptor를 호출
__set__메서드
디스크립터 속성에 값을 할당하려고 할 때 호출된다. 반드시 __set__메서드를 구현해야만 활성화 된다.
client.descriptor = 'value'
__set__메서드를 구현하지 않으면, ‘value’는 descriptor 자체를 덮어 쓴다.
def __set__(self,instance,value):
instance.__dict__[self._name] = value
>>> client.descriptor = 42
>>>client.descriptor
42
#__set__메서드를 구현하지 않았다면 descriptor가 42가 된다.
구현했다면 _name 의 값이 42고, _name값을 보는 것이다.
프로퍼티 자리에 놓일 수 있는 것은 디스크립터로 추상화 가능하며, __set__메서드가 @property.setter가 하던 일을 대신 한다.
__delete__메서드
__delete__(self, instance)
#다음과 같은 형태로 호출
>>> del client.descriptor
__set_name__메서드
def __set_name__(self, owner, name):
self.name = name
기존 디스크립터는 속성을 처리하려면 디스크립터에 명시적으로 속성을 전달해야 함.
__set_name__메서드를 사용하면 디스크립터에 속성명을 명시적으로 전달하지 않아도 처리가 가능.
6-2. 디스크립터 유형
__set__이나 __delete__메서드를 구현 했다면 데이터 디스크립터.
그렇지 않고 __get__만을 구현한 디스크립터를 비데이터 디스크립터.
비데이터 디스크립터
__get__만을 구현한 디스크립터
class NonDataDescriptor:
def __get__(self,instance,owner):
if instance is None:
return self
return 42
class ClientClass:
descriptor = NonDataDescriptor()
client = ClientClass()
#descriptor 속성은 인스턴스가 아니라 클래스 안에 있음
#client 객체의 사전을 조회하면 그값은 비어있음
vars(client)
{}
client.descriptor
#여기서 .descriptor 속성을 조회하면
#client.__dict__에서 descriptor 라는 키를 찾지 못함
#찾지 못하고 마침내 클래스에서 디스크립터를 찾게 됨.
#그래서 __get__메서드 결과를 반환
#속성의 __dict__에서 키를 찾아보고, 없으면 디스크립터를 찾게됨
client.descriptor = 99
vars(client)
{'descriptor':99}
#속성에 다른 값을 설정하면 인스턴스의 사전이 변경됨
#이제 __dict__는 비어있지 않음.
데이터 디스크립터
__set__나 __delete__메서드를 구현한 디스크립터
class DataDescriptor:
def __get__(self,instance,owner):
if instance is None:
return self
return 42
def __set__(self,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}
#__set__()메서드가 호출되면서 객체의 사전에 값을 설정
#속성을 조회하면 __dict__에서 조회하는 대신 클래스의 descriptor를 먼저 조회
비데이터 디스크립터와 데이터 스크립터의 중요한 차이
-
비데이터 디스크립터: 인스턴스의 __dict__우선순위가 (descriptor클래스 보다) 더 높다.
-
데이터 디스크립터: descriptor클래스의 우선순위가 (인스턴스의 __dict__보다) 더 높다.
그렇기에 우선순위가 높은 메서드가 먼저 호출
디스크립터의 __set__메서드에서 setattr()이나 할당 표현식을 직접 사용하면 안된다. 무한루프가 발생한다.
__set__메서드와 setattr()이 서로 호출하기때문
6-3. 디스크립터 실전
만약, 실질적인 코드 반복의 증거가 없거나 복잡성의 대가가 명확하지 않다면 굳이 디스크립터를 사용할 필요가 없다.
디스크립터의 코드가 다소 복잡한 것은 사실이다. 반면 클라이언트 클래스코드는 상당히 간단해진다. 따라서 이 디스크립터를 여러번 사용한다면, 충분히 가치가 있다.
디스크립터는 비즈니스로직을 포함하지 않아 독립적이다. 비즈니스 로직의 구현보다는 라이브러리, 프레임워크, 또는 내부 API를 정의하는데 적합하다.
예제.
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
전역 상태 공유
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 = "client2를 위한 값"
>>> client2.descriptor
'client2를 위한 값'
>>> client1.descriptor
'client2를 위한 값'
디스크립터 객체에 데이터를 보관하면 모든 객체가 동일한 값에 접근할 수 있다.
인스턴스의 값을 수정하면 같은 클래스의 다른 모든 인스턴스에서도 값이 수정된다.
이는 ClientClass.descriptor가 고유하기 때문이다.
만약 원하는 상황이 아니라면 각 인스턴스의 __dict__사전에 값을 설정하고 검색해야 한다.
약한 참조
인스턴스 __dict__를 사용하지 않으려는 경우 또 다른 대안은 디스크립터 객체가 직접 내부 매핑을 통해 각 인스턴스의 값을 보관하고 반환하는 방법이 있다.
(이때, 내부 매핑시 사전을 사용해서는 안된다. 가비지 컬렉션 불가)
이를 해결하기 위해서는 사전은 weaker.WeakKeyDictionary를 사용
이러면 단점은 인스턴스 객체는 더 이상 속성을 보유하지 않고, 디스크립터가 속성을 보유하게 된다.
또한 __hash__메서드로 해시 가능해야 사용가능 하다.
6-4.디스크립터 분석
파이썬이 어떻게 디스크립터를 사용하는지 알아보자.
함수와 메서드
class myClass:
def method(self, ...):
self.x = 1
#이것은 다음의 정의와 같다.
class myClass: pass
def method(myClass_instance, ...):
myClass_instance.x = 1
method(myClass())
메서드는 객체를 수정하는 또 다른 함수일 뿐이며, 객체 안에서 정의되었기 때문에 객체에 바인딩되어 있다고 말한다.
instance = myClass()
instance.method(...)
#이것은 다음과 같다.
instance = myClass()
myClass.method(instance,...)
파이썬 추가 예제.
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')
instance = Myclass()
#1
Method('External Call')(intance,'first','second')
#2
instance.method('first','second')
첫번째 호출은 동작하지만, 두번째 호출은 에러가 발생한다.
Method.__call__기준으로 파라미터 위치가 1칸씩 밀려서 arg2에 값이 전달되지 않았다.
이를 해결하려면 디스크립터를 변경해야한다.
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을 사용하여 함수를 메서드로 변환하는 것이다.
method는 클래스 속성으로 정의된 객체이고 __get__메서드가 있기 때문에 __get__메서드가 호출된다. 그리고 __get__메서드가 하는 일은 함수를 메서드로 변환하는 것이다.
즉, 함수를 작업하려는 객체의 인스턴스에 바인딩한다.
메서드를 위한 빌트인 데코레이터
@property, @classmethod, @staticmethod 데코레이터는 디스크립터다.
메서드를 인스턴스가 아닌 클래스에서 직접 호출하면 디스크립터 자체를 반환한다.
@classmethod를 사용하면 디스크립터의 __get__함수가 메서드를 인스턴스에서 호출하든 클래스에서 직접 호출하든 상관없이, 데코레이팅 함수에 첫 번째 파라미터로 메서드를 소유한 클래스를 넘겨준다.
@staticmethod를 사용하면 정의한 파라미터 이외의 파라미터를 넘기지 않도록한다.
슬롯
클래스에 __slot__속성을 정의하면 클래스가 기대하는 특정 속성만 정의하고 다른 것은 제한할 수 있다.
__slot__에 정의되지 않은 속성을 동적으로 추가하려고 할 경우 AttributeError가 발생한다.
이 속성을 정의하면 클래스는 정적으로 되고, __dict__속성을 갖지 않는다.
class Coordinate2D:
__slots__ = ('lat','long')
def __init__(self,lat,long):
self.lat =lat
self.long = long
...
파이썬의 동적인 특성을 없애기에 사용에 주의.
메모리를 덜 사용하게 된다는 장점이 있다.